diff --git a/corpus/conflict-page/README.md b/corpus/conflict-page/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ffe296770ee5a58ef7a9d29c5301215a1dbf9bfa --- /dev/null +++ b/corpus/conflict-page/README.md @@ -0,0 +1,77 @@ +# Conflict pages + +What OneNote 2010 does when a sync finds that two clients changed the same object, and +that it reads and deletes the conflict pages Rust writes. `tools/native_conflict.py` +made every capture; `tools/test_conflict_page.py` checks them without a VM. + +`native` is two OneNote clients on one Samba notebook (`native_conflict.py sync`, fixture +`native-ink/cold-ui-ink`): client A, offline, rewrote the first outline's paragraph and the +second outline's; client B rewrote the same first paragraph and the left table cell and +published (`b-published`); A reconnected and synced (`conflict`). A kept B's version of the +paragraph both changed and merged its own disjoint edit into the page; its own version of the +page became a conflict page: + +- its own object space, listed only in the page manifest's `ChildGraphSpaceElementNodes` + (`0x2c001d63`), never in the page series, with a copy of its metadata in the manifest's + `0x24003442` whose identity is the space's XOR the metadata salt (MS-ONE 2.2.81); +- metadata root `jcidConflictPageMetaData` (`0x20038`): `CachedTitleString`, a fresh + `NotebookManagementEntityGuid`, `PageLevel`, both schema revisions (`0x28`), + `TopologyCreationTimeStamp` of the merge, `ConflictingUserName` and + `ConflictingUserInitials` of the client whose version it is; +- a page node with `IsReadOnly`, `Deletable` and `IsConflictPage`, holding A's whole page; + only the rich text of the paragraph both changed has `IsConflictObjectForSelection` and + `IsConflictObjectForRender`; +- the page's metadata and the section's copy of it gain `HasConflictPages`. + +Both clients show the same UI (`a/`, `b/`): the page's tab carries a conflict mark and a +yellow bar reads "This page has changes that could not be merged during synchronization. +Click here to show versions of the page with unmerged changes." Clicking it lists the +conflict pages under the page, each as its date and user ("9/26/2026 snow"), and opens the +first; that page's bar reads "Conflicting changes are highlighted in red. This page cannot be +edited, but you can copy changes to the primary page. Click here for more options." over a +light red highlight (RGB 255, 214, 214), and its menu offers Delete Conflict Page, Copy Page +To..., Select Previous/Next Conflicting Change and Collapse Conflict Pages. + +`native-three` is `evidence/m8/conflict-abrupt-cold-01`: three clients' conflict pages under +one page. OneNote appends each to `ChildGraphSpaceElementNodes` and lists them last first +(`conflicts.png`); the metadata copies are in their own order. + +`native-delete` is OneNote's Delete Conflict Page on `native/conflict` in a fresh clone +(`native_conflict.py cold --delete`): the manifest loses `ChildGraphSpaceElementNodes` (its +last entry) but keeps the metadata copy, the page's metadata and the section's copy lose +`HasConflictPages`, and the conflict page's space is left as it was. + +`native-pages` and `native-restore` are page-list merges (`native_conflict.py pages`, +`restore`): client A, offline, moved pages through COM and edited `Target`'s body while +client B moved pages and deleted `Target`; A reconnected (`merged`, both clients' `listed.json` +and screenshots). OneNote merges page series, not page positions. A page a client moves +gets a series of its own, and a COM move also re-series every page it jumps over (a drag +re-series only the dragged pages, `page-lifecycle/movement`). The merge keeps each page in +the series of the client that re-seriesed it, the server's where both did, leaving the +other's series empty (`native-pages` keeps two, which MS-ONE's one-page minimum does not +allow); series only A made go after the server's. `Target` comes back as a new page space +holding A's edit, in a new series where A had it; B's version stays in the recycle bin. + +- `native-pages`: from One, Two, Three, Four, Target, A moved Four before One and Two last + (re-seriesing One, Two, Three), B moved Four last and Three before One (re-seriesing One, + Two, Four): One, Two, Four, Three, Target. +- `native-restore`: from One, Two, Target, Three, Four, A moved Four before One + (re-seriesing One, Two, Target, Three) and B deleted Target: Four, One, Two, Target, Three. + +`page_moves_and_a_removed_page_merge_as_onenote_merges_them` in +`crates/notebook/tests/sync_pages.rs` replays both with Snowbound's queue, A's moves as +moves of the pages A re-seriesed, and reaches OneNote's order. + +`candidate` is `a_conflict_page_is_stored_as_onenote_stores_one` in +`crates/onestore/tests/conflict.rs` (`ONESTORE_CONFLICT_EXPORT`): the remote renamed the +first word of the fixture's first paragraph, this machine did too, and `SectionOp::Conflict` +kept this machine's page for "Clover Snow", whose author objects carry her initials beside +her name as OneNote's do. `cold` is OneNote's fresh read and the same UI +walk, ending in Delete Conflict Page on the Rust-written page: its bar and list entry show, +the conflict page opens (window title) with the paragraph highlighted, and the deletion +stores what `native-delete` did. + +`candidate-delete` is `deleting_a_conflict_page_stores_what_onenote_stores` +(`ONESTORE_CONFLICT_DELETE_EXPORT`): `SectionOp::Delete` of `native/conflict`'s conflict page, +which stores the objects OneNote's deletion did; `cold-delete` is its fresh read, without a +bar. diff --git a/corpus/conflict-page/candidate-delete/Open Notebook.onetoc2 b/corpus/conflict-page/candidate-delete/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..c7beb44bf509f935090fe55e7549e4b50e0ae0e9 Binary files /dev/null and b/corpus/conflict-page/candidate-delete/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/candidate-delete/synthetic.one b/corpus/conflict-page/candidate-delete/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..ea2ecf61b9d305de11c694334fd94369e99b7eea Binary files /dev/null and b/corpus/conflict-page/candidate-delete/synthetic.one differ diff --git a/corpus/conflict-page/candidate/Open Notebook.onetoc2 b/corpus/conflict-page/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..c7beb44bf509f935090fe55e7549e4b50e0ae0e9 Binary files /dev/null and b/corpus/conflict-page/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/candidate/synthetic.one b/corpus/conflict-page/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..647cd9bc181046ab29ee4528aebd31fbb9dba4fd Binary files /dev/null and b/corpus/conflict-page/candidate/synthetic.one differ diff --git a/corpus/conflict-page/cold-delete/commands.jsonl b/corpus/conflict-page/cold-delete/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..059e40e61d665797d4683acc66d6459966fff285 --- /dev/null +++ b/corpus/conflict-page/cold-delete/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-4D3235A0 -ExpectedPages 1 -KeepOpen", "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\\read -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/conflict-page/cold-delete/machine.json b/corpus/conflict-page/cold-delete/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..a1f05d8179bfaf87806c989ea93c89a231bd45af --- /dev/null +++ b/corpus/conflict-page/cold-delete/machine.json @@ -0,0 +1 @@ +{"name": "m6-4d3235a0", "hostname": "ONE-M6-4D3235A0"} diff --git a/corpus/conflict-page/cold-delete/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/conflict-page/cold-delete/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment new file mode 100644 index 0000000000000000000000000000000000000000..66b6151e91f90df6a61a19ef2f4447da278e07b2 --- /dev/null +++ b/corpus/conflict-page/cold-delete/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment @@ -0,0 +1 @@ +Fictitious attachment for native corpus. \ No newline at end of file diff --git a/corpus/conflict-page/cold-delete/read/environment.json b/corpus/conflict-page/cold-delete/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..d03335c154a59437cf873f38ab49810353d580b1 --- /dev/null +++ b/corpus/conflict-page/cold-delete/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-4D3235A0", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/conflict-page/cold-delete/read/hierarchy.xml b/corpus/conflict-page/cold-delete/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..8e71ea46462fed2239de01f765d60863222f3739 --- /dev/null +++ b/corpus/conflict-page/cold-delete/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/cold-delete/read/page-000.navigation.json b/corpus/conflict-page/cold-delete/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..668f002c22e396d7530675ec44f659f85b6f6a86 --- /dev/null +++ b/corpus/conflict-page/cold-delete/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Client B conflicting edit. - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/cold-delete/read/page-000.png b/corpus/conflict-page/cold-delete/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..c6d238e1b156fda4d252289eb13f6a8ed3ff0906 Binary files /dev/null and b/corpus/conflict-page/cold-delete/read/page-000.png differ diff --git a/corpus/conflict-page/cold-delete/read/page-000.xml b/corpus/conflict-page/cold-delete/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..37567a5be70a94b48ee71b7e9676a697078dd76e --- /dev/null +++ b/corpus/conflict-page/cold-delete/read/page-000.xml @@ -0,0 +1,21 @@ + +AK4FHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3 +URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBADBkgQRSNG +IwUIC2VPZmdoZGkZFDIIAIAeAiHi4kEzCADwEAKLxeJBZAMVRgBACpUE/QGH81d3mq7mkzgM0mcB +mk1gEBgEBgEBgE1mUAgMzmUAmsygE1mUzmkBmc0gMzmk1mUAmsyTOaTWZQCazKZgALSmiazKATWZ +QCAwCAwBAZpM4DAJlNYBMprAJlNZpM4DNJmmU1mkzgM0mcBmkzmU1gEyAAFpTSAzOaQGZzRAYBAY +BAYBMprAIDNJnAZpM0yms0mcBmkzgM0mcymsAmU1gEyms0maZAACZTSawCZTWATKawCAoBAYBAZn +NIDAJrMoBNZlAJrMpnMoBNZlAJrMpnNIDM5pAZnNJrMkzmk1AACH8C7XgWnmkzgM0msAmU1gEBgE +BgEBTOaQGATWZQCazKATWZTOaTWZTNNJrMpmmUzTSaplM00mqZTOaTVMpmmk1TKATKazSawCAzSZ +wGAQFAIDAJrMoBAZnNIDM5pAZnMpnNJrMpmmk1mUzTSaplM00mqZTNNJrMpmmk1TKZplhuAzKATW +ZQCazKAIDAIDAIDNJnAYBMprAJlM4DNJnMprNJnMk1mkzmSazRM5kms0TOZJnMprNEzmSazQtKaQ +GZzRNZlAIDAIDAIDAJlNYAmU1gEymsAmU1mkzmU1miZzKazRM5kmcyTWaJnMk1mkzmSazRM5kAUU +RgBAAAAJBAUURgAAAAASABEgXhT+65lz/EiPi+8SFvjqCAk+gEcDAa/89Qd0AxVGAEAKABEg2R87 +QQrSFUA=AOECHQJSegFgKgAaH4tCcEWEdYC+0EXZBPqHlTLy7BdBuMAWv/Mr9HreuxOL5V6zSJyboSVat1ES +v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIEEUjRiMF +CAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQArKAUuH88s3nkzmkzgM0mcBmkzg +KAQGAQGATWZQCAzOaQGZzKATWZTOaQGZzSAzOaTWZQCazJM5pNZlAJrMpmAAAACH8D9XgeBJlM0z +mRNZpNZpM4DAJlNYBAUAgMAgMzmkBgE1mUAmsyTOaTWZTOaTVMpnNJqmUzTSaplM00mqZTOZTNNJ +qmUzTSagBRRGAIAAAAkEBRRGAAAAABIAESDPTQ4xwIZyTqQ8FVDbHxeKCj9AI5rARvPPUDugAxVG +AEAKABEgLMlfQdkfI0A=iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/conflict-page/cold-delete/read/payloads.json b/corpus/conflict-page/cold-delete/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..de140b63b49c11437c4b611e7bf1ba60bd5f31b3 --- /dev/null +++ b/corpus/conflict-page/cold-delete/read/payloads.json @@ -0,0 +1,10 @@ +[ + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{577D18B7-BF93-4AA8-9305-80E5A4F26E9B}{11}{B0}", + "kind": "InsertedFile", + "page": "{DE321CF1-6751-492B-959C-42BC27DEE80E}{1}{B0}", + "bytes": 43 + } +] \ No newline at end of file diff --git a/corpus/conflict-page/cold-delete/run.json b/corpus/conflict-page/cold-delete/run.json new file mode 100644 index 0000000000000000000000000000000000000000..7c3bfe43bd28d9130a244a2123cb5a1665748154 --- /dev/null +++ b/corpus/conflict-page/cold-delete/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/gate/candidate-delete", + "expected_pages": 1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": false, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/conflict-page/cold-delete/scripts/cold.ps1 b/corpus/conflict-page/cold-delete/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/conflict-page/cold-delete/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/conflict-page/cold-delete/scripts/read.ps1 b/corpus/conflict-page/cold-delete/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/conflict-page/cold-delete/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/conflict-page/cold-delete/source.json b/corpus/conflict-page/cold-delete/source.json new file mode 100644 index 0000000000000000000000000000000000000000..c1ef9fe53decc033de306870451ed968b741f78c --- /dev/null +++ b/corpus/conflict-page/cold-delete/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 4944, + "sha256": "36274ad5cc077c6b1ad51ee4c4b90d3b6798a45ce446cbfd7c9d7ac504776b28", + "mtime_ns": 1790473161246592184 + }, + { + "path": "synthetic.one", + "bytes": 41728, + "sha256": "78e247a18c1fe2c8d4de66dd8bf79bf918636bfb42933a5533a91c8f21939de0", + "mtime_ns": 1790473248313861507 + } +] diff --git a/corpus/conflict-page/cold-delete/teardown.json b/corpus/conflict-page/cold-delete/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/conflict-page/cold-delete/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/conflict-page/cold/before-read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/conflict-page/cold/before-read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment new file mode 100644 index 0000000000000000000000000000000000000000..66b6151e91f90df6a61a19ef2f4447da278e07b2 --- /dev/null +++ b/corpus/conflict-page/cold/before-read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment @@ -0,0 +1 @@ +Fictitious attachment for native corpus. \ No newline at end of file diff --git a/corpus/conflict-page/cold/before-read/environment.json b/corpus/conflict-page/cold/before-read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..6f6316cb1e6b9b42bb515e8c9bb2673249eca696 --- /dev/null +++ b/corpus/conflict-page/cold/before-read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-FA8F1E62", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/conflict-page/cold/before-read/hierarchy.xml b/corpus/conflict-page/cold/before-read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..712236a875d72e75cb8e64fa22404a7e7ad56182 --- /dev/null +++ b/corpus/conflict-page/cold/before-read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/cold/before-read/page-000.xml b/corpus/conflict-page/cold/before-read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..eb35bbfb1c1b8d416a12b3d07500528f3d87ad66 --- /dev/null +++ b/corpus/conflict-page/cold/before-read/page-000.xml @@ -0,0 +1,26 @@ + +Remote: café, 東京, مرحبا]]>AK4FHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3 +URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBADBkgQRSNG +IwUIC2VPZmdoZGkZFDIIAIAeAiHi4kEzCADwEAKLxeJBZAMVRgBACpUE/QGH81d3mq7mkzgM0mcB +mk1gEBgEBgEBgE1mUAgMzmUAmsygE1mUzmkBmc0gMzmk1mUAmsyTOaTWZQCazKZgALSmiazKATWZ +QCAwCAwBAZpM4DAJlNYBMprAJlNZpM4DNJmmU1mkzgM0mcBmkzmU1gEyAAFpTSAzOaQGZzRAYBAY +BAYBMprAIDNJnAZpM0yms0mcBmkzgM0mcymsAmU1gEyms0maZAACZTSawCZTWATKawCAoBAYBAZn +NIDAJrMoBNZlAJrMpnMoBNZlAJrMpnNIDM5pAZnNJrMkzmk1AACH8C7XgWnmkzgM0msAmU1gEBgE +BgEBTOaQGATWZQCazKATWZTOaTWZTNNJrMpmmUzTSaplM00mqZTOaTVMpmmk1TKATKazSawCAzSZ +wGAQFAIDAJrMoBAZnNIDM5pAZnMpnNJrMpmmk1mUzTSaplM00mqZTNNJrMpmmk1TKZplhuAzKATW +ZQCazKAIDAIDAIDNJnAYBMprAJlM4DNJnMprNJnMk1mkzmSazRM5kms0TOZJnMprNEzmSazQtKaQ +GZzRNZlAIDAIDAIDAJlNYAmU1gEymsAmU1mkzmU1miZzKazRM5kmcyTWaJnMk1mkzmSazRM5kAUU +RgBAAAAJBAUURgAAAAASABEgXhT+65lz/EiPi+8SFvjqCAk+gEcDAa/89Qd0AxVGAEAKABEg2R87 +QQrSFUA=AOECHQJSegFgKgAaH4tCcEWEdYC+0EXZBPqHlTLy7BdBuMAWv/Mr9HreuxOL5V6zSJyboSVat1ES +v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIEEUjRiMF +CAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQArKAUuH88s3nkzmkzgM0mcBmkzg +KAQGAQGATWZQCAzOaQGZzKATWZTOaQGZzSAzOaTWZQCazJM5pNZlAJrMpmAAAACH8D9XgeBJlM0z +mRNZpNZpM4DAJlNYBAUAgMAgMzmkBgE1mUAmsyTOaTWZTOaTVMpnNJqmUzTSaplM00mqZTOZTNNJ +qmUzTSagBRRGAIAAAAkEBRRGAAAAABIAESDPTQ4xwIZyTqQ8FVDbHxeKCj9AI5rARvPPUDugAxVG +AEAKABEgLMlfQdkfI0A=iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/conflict-page/cold/before-read/payloads.json b/corpus/conflict-page/cold/before-read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..88ae247d1cd3dee9c2ac3f05912170135543e7cb --- /dev/null +++ b/corpus/conflict-page/cold/before-read/payloads.json @@ -0,0 +1,10 @@ +[ + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{EEA4864A-EC4F-48DD-94E4-A54023F53C64}{11}{B0}", + "kind": "InsertedFile", + "page": "{67EB820C-348D-4B5E-927D-6719A0D9BAF1}{1}{B0}", + "bytes": 43 + } +] \ No newline at end of file diff --git a/corpus/conflict-page/cold/cold-conflict.json b/corpus/conflict-page/cold/cold-conflict.json new file mode 100644 index 0000000000000000000000000000000000000000..2797bae39ee1a51a132ddf62bd0f0a7b5fb6f9e9 --- /dev/null +++ b/corpus/conflict-page/cold/cold-conflict.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Snowbound: caf\u00e9, \u6771\u4eac, \u0645\u0631\u062d\u0628\u0627 - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/cold/cold-conflict.png b/corpus/conflict-page/cold/cold-conflict.png new file mode 100644 index 0000000000000000000000000000000000000000..3e581162febb7372e249109b1cc0fbc31e841ff2 Binary files /dev/null and b/corpus/conflict-page/cold/cold-conflict.png differ diff --git a/corpus/conflict-page/cold/cold-delete.json b/corpus/conflict-page/cold/cold-delete.json new file mode 100644 index 0000000000000000000000000000000000000000..61e9e7317ecda208a0500463bc8537e2a0328ff0 --- /dev/null +++ b/corpus/conflict-page/cold/cold-delete.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Remote: caf\u00e9, \u6771\u4eac, \u0645\u0631\u062d\u0628\u0627 - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/cold/cold-delete.png b/corpus/conflict-page/cold/cold-delete.png new file mode 100644 index 0000000000000000000000000000000000000000..8d35fc6da74741e9c963466bd316d601d603ca2c Binary files /dev/null and b/corpus/conflict-page/cold/cold-delete.png differ diff --git a/corpus/conflict-page/cold/cold-main.json b/corpus/conflict-page/cold/cold-main.json new file mode 100644 index 0000000000000000000000000000000000000000..61e9e7317ecda208a0500463bc8537e2a0328ff0 --- /dev/null +++ b/corpus/conflict-page/cold/cold-main.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Remote: caf\u00e9, \u6771\u4eac, \u0645\u0631\u062d\u0628\u0627 - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/cold/cold-main.png b/corpus/conflict-page/cold/cold-main.png new file mode 100644 index 0000000000000000000000000000000000000000..07962cb3e707d017176cdf0394b5e6866c78699b Binary files /dev/null and b/corpus/conflict-page/cold/cold-main.png differ diff --git a/corpus/conflict-page/cold/cold-menu.json b/corpus/conflict-page/cold/cold-menu.json new file mode 100644 index 0000000000000000000000000000000000000000..f43854a7a42d5bb99c0b443513bc0c0993c2716c --- /dev/null +++ b/corpus/conflict-page/cold/cold-menu.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "", + "class": "Net UI Tool Window", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/cold/cold-menu.png b/corpus/conflict-page/cold/cold-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..ae05c88ae8f99bca435602c2848815a8db493a97 Binary files /dev/null and b/corpus/conflict-page/cold/cold-menu.png differ diff --git a/corpus/conflict-page/cold/cold-shown.json b/corpus/conflict-page/cold/cold-shown.json new file mode 100644 index 0000000000000000000000000000000000000000..2797bae39ee1a51a132ddf62bd0f0a7b5fb6f9e9 --- /dev/null +++ b/corpus/conflict-page/cold/cold-shown.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Snowbound: caf\u00e9, \u6771\u4eac, \u0645\u0631\u062d\u0628\u0627 - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/cold/cold-shown.png b/corpus/conflict-page/cold/cold-shown.png new file mode 100644 index 0000000000000000000000000000000000000000..28b4db4798984a0b42158552a903406f50099aab Binary files /dev/null and b/corpus/conflict-page/cold/cold-shown.png differ diff --git a/corpus/conflict-page/cold/commands.jsonl b/corpus/conflict-page/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..88a0fe416fa51778997f253463110777c3747341 --- /dev/null +++ b/corpus/conflict-page/cold/commands.jsonl @@ -0,0 +1,6 @@ +{"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-FA8F1E62 -ExpectedPages -1 -KeepOpen", "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} +{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "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-FA8F1E62 -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 2 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/conflict-page/cold/machine.json b/corpus/conflict-page/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..d0177b47e02f051df52127aafaa35425381a2411 --- /dev/null +++ b/corpus/conflict-page/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-fa8f1e62", "hostname": "ONE-M6-FA8F1E62"} diff --git a/corpus/conflict-page/cold/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one b/corpus/conflict-page/cold/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one new file mode 100644 index 0000000000000000000000000000000000000000..ffe604aa40452c5694188fa0042a52d1d9cf7da8 Binary files /dev/null and b/corpus/conflict-page/cold/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one differ diff --git a/corpus/conflict-page/cold/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 b/corpus/conflict-page/cold/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..e795d08b1ebc6d904ffba085f27ed57f2dfe90f6 Binary files /dev/null and b/corpus/conflict-page/cold/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/cold/notebook/Open Notebook.onetoc2 b/corpus/conflict-page/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..6e5dfc82f4398ec9c92ff1009ceb7b20c199f700 Binary files /dev/null and b/corpus/conflict-page/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/cold/notebook/synthetic.one b/corpus/conflict-page/cold/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..deff07779000e935c7af2c07cc425e9a57e8c0bc Binary files /dev/null and b/corpus/conflict-page/cold/notebook/synthetic.one differ diff --git a/corpus/conflict-page/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/conflict-page/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment new file mode 100644 index 0000000000000000000000000000000000000000..66b6151e91f90df6a61a19ef2f4447da278e07b2 --- /dev/null +++ b/corpus/conflict-page/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment @@ -0,0 +1 @@ +Fictitious attachment for native corpus. \ No newline at end of file diff --git a/corpus/conflict-page/cold/read/environment.json b/corpus/conflict-page/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..7c72a2249c5bedec250b192ce0de0762aaa1dbde --- /dev/null +++ b/corpus/conflict-page/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-FA8F1E62", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/conflict-page/cold/read/hierarchy.xml b/corpus/conflict-page/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..129b3d3fff6dbcbe8697a747320aeb5ae880165b --- /dev/null +++ b/corpus/conflict-page/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/cold/read/page-000.xml b/corpus/conflict-page/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..631b9d5bd28c3cfa00181deedba99f442f148656 --- /dev/null +++ b/corpus/conflict-page/cold/read/page-000.xml @@ -0,0 +1,26 @@ + +Remote: café, 東京, مرحبا]]>AK4FHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3 +URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBADBkgQRSNG +IwUIC2VPZmdoZGkZFDIIAIAeAiHi4kEzCADwEAKLxeJBZAMVRgBACpUE/QGH81d3mq7mkzgM0mcB +mk1gEBgEBgEBgE1mUAgMzmUAmsygE1mUzmkBmc0gMzmk1mUAmsyTOaTWZQCazKZgALSmiazKATWZ +QCAwCAwBAZpM4DAJlNYBMprAJlNZpM4DNJmmU1mkzgM0mcBmkzmU1gEyAAFpTSAzOaQGZzRAYBAY +BAYBMprAIDNJnAZpM0yms0mcBmkzgM0mcymsAmU1gEyms0maZAACZTSawCZTWATKawCAoBAYBAZn +NIDAJrMoBNZlAJrMpnMoBNZlAJrMpnNIDM5pAZnNJrMkzmk1AACH8C7XgWnmkzgM0msAmU1gEBgE +BgEBTOaQGATWZQCazKATWZTOaTWZTNNJrMpmmUzTSaplM00mqZTOaTVMpmmk1TKATKazSawCAzSZ +wGAQFAIDAJrMoBAZnNIDM5pAZnMpnNJrMpmmk1mUzTSaplM00mqZTNNJrMpmmk1TKZplhuAzKATW +ZQCazKAIDAIDAIDNJnAYBMprAJlM4DNJnMprNJnMk1mkzmSazRM5kms0TOZJnMprNEzmSazQtKaQ +GZzRNZlAIDAIDAIDAJlNYAmU1gEymsAmU1mkzmU1miZzKazRM5kmcyTWaJnMk1mkzmSazRM5kAUU +RgBAAAAJBAUURgAAAAASABEgXhT+65lz/EiPi+8SFvjqCAk+gEcDAa/89Qd0AxVGAEAKABEg2R87 +QQrSFUA=AOECHQJSegFgKgAaH4tCcEWEdYC+0EXZBPqHlTLy7BdBuMAWv/Mr9HreuxOL5V6zSJyboSVat1ES +v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIEEUjRiMF +CAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQArKAUuH88s3nkzmkzgM0mcBmkzg +KAQGAQGATWZQCAzOaQGZzKATWZTOaQGZzSAzOaTWZQCazJM5pNZlAJrMpmAAAACH8D9XgeBJlM0z +mRNZpNZpM4DAJlNYBAUAgMAgMzmkBgE1mUAmsyTOaTWZTOaTVMpnNJqmUzTSaplM00mqZTOZTNNJ +qmUzTSagBRRGAIAAAAkEBRRGAAAAABIAESDPTQ4xwIZyTqQ8FVDbHxeKCj9AI5rARvPPUDugAxVG +AEAKABEgLMlfQdkfI0A=iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/conflict-page/cold/read/payloads.json b/corpus/conflict-page/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..88ae247d1cd3dee9c2ac3f05912170135543e7cb --- /dev/null +++ b/corpus/conflict-page/cold/read/payloads.json @@ -0,0 +1,10 @@ +[ + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{EEA4864A-EC4F-48DD-94E4-A54023F53C64}{11}{B0}", + "kind": "InsertedFile", + "page": "{67EB820C-348D-4B5E-927D-6719A0D9BAF1}{1}{B0}", + "bytes": 43 + } +] \ No newline at end of file diff --git a/corpus/conflict-page/cold/run.json b/corpus/conflict-page/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..376f7384b0892afab7194adc1def657f070b7909 --- /dev/null +++ b/corpus/conflict-page/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/gate3/candidate", + "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/conflict-page/cold/scripts/cold.ps1 b/corpus/conflict-page/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/conflict-page/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/conflict-page/cold/scripts/read.ps1 b/corpus/conflict-page/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/conflict-page/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/conflict-page/cold/source.json b/corpus/conflict-page/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..94cafa9cd7aff191a1fc72f639a1d752940f8906 --- /dev/null +++ b/corpus/conflict-page/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 4944, + "sha256": "36274ad5cc077c6b1ad51ee4c4b90d3b6798a45ce446cbfd7c9d7ac504776b28", + "mtime_ns": 1788585164198018949 + }, + { + "path": "synthetic.one", + "bytes": 43112, + "sha256": "ab763188e885724957e2440a78eb29c457f47d538f78c3cc40e9c1a396be5c3b", + "mtime_ns": 1790489509247358867 + } +] diff --git a/corpus/conflict-page/cold/teardown.json b/corpus/conflict-page/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/conflict-page/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/conflict-page/native-delete/before-read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/conflict-page/native-delete/before-read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment new file mode 100644 index 0000000000000000000000000000000000000000..66b6151e91f90df6a61a19ef2f4447da278e07b2 --- /dev/null +++ b/corpus/conflict-page/native-delete/before-read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment @@ -0,0 +1 @@ +Fictitious attachment for native corpus. \ No newline at end of file diff --git a/corpus/conflict-page/native-delete/before-read/environment.json b/corpus/conflict-page/native-delete/before-read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..9048ca4bd4e5ef3cdfc99810e0455a385a8556d2 --- /dev/null +++ b/corpus/conflict-page/native-delete/before-read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-7691E04E", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/conflict-page/native-delete/before-read/hierarchy.xml b/corpus/conflict-page/native-delete/before-read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..22ff82ae69fa8483dea86f371739b36373c76aa2 --- /dev/null +++ b/corpus/conflict-page/native-delete/before-read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-delete/before-read/page-000.xml b/corpus/conflict-page/native-delete/before-read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..0fe04aa3c60ab810ec3f3dda7268469e20ca8065 --- /dev/null +++ b/corpus/conflict-page/native-delete/before-read/page-000.xml @@ -0,0 +1,21 @@ + +AK4FHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3 +URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBADBkgQRSNG +IwUIC2VPZmdoZGkZFDIIAIAeAiHi4kEzCADwEAKLxeJBZAMVRgBACpUE/QGH81d3mq7mkzgM0mcB +mk1gEBgEBgEBgE1mUAgMzmUAmsygE1mUzmkBmc0gMzmk1mUAmsyTOaTWZQCazKZgALSmiazKATWZ +QCAwCAwBAZpM4DAJlNYBMprAJlNZpM4DNJmmU1mkzgM0mcBmkzmU1gEyAAFpTSAzOaQGZzRAYBAY +BAYBMprAIDNJnAZpM0yms0mcBmkzgM0mcymsAmU1gEyms0maZAACZTSawCZTWATKawCAoBAYBAZn +NIDAJrMoBNZlAJrMpnMoBNZlAJrMpnNIDM5pAZnNJrMkzmk1AACH8C7XgWnmkzgM0msAmU1gEBgE +BgEBTOaQGATWZQCazKATWZTOaTWZTNNJrMpmmUzTSaplM00mqZTOaTVMpmmk1TKATKazSawCAzSZ +wGAQFAIDAJrMoBAZnNIDM5pAZnMpnNJrMpmmk1mUzTSaplM00mqZTNNJrMpmmk1TKZplhuAzKATW +ZQCazKAIDAIDAIDNJnAYBMprAJlM4DNJnMprNJnMk1mkzmSazRM5kms0TOZJnMprNEzmSazQtKaQ +GZzRNZlAIDAIDAIDAJlNYAmU1gEymsAmU1mkzmU1miZzKazRM5kmcyTWaJnMk1mkzmSazRM5kAUU +RgBAAAAJBAUURgAAAAASABEgXhT+65lz/EiPi+8SFvjqCAk+gEcDAa/89Qd0AxVGAEAKABEg2R87 +QQrSFUA=AOECHQJSegFgKgAaH4tCcEWEdYC+0EXZBPqHlTLy7BdBuMAWv/Mr9HreuxOL5V6zSJyboSVat1ES +v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIEEUjRiMF +CAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQArKAUuH88s3nkzmkzgM0mcBmkzg +KAQGAQGATWZQCAzOaQGZzKATWZTOaQGZzSAzOaTWZQCazJM5pNZlAJrMpmAAAACH8D9XgeBJlM0z +mRNZpNZpM4DAJlNYBAUAgMAgMzmkBgE1mUAmsyTOaTWZTOaTVMpnNJqmUzTSaplM00mqZTOZTNNJ +qmUzTSagBRRGAIAAAAkEBRRGAAAAABIAESDPTQ4xwIZyTqQ8FVDbHxeKCj9AI5rARvPPUDugAxVG +AEAKABEgLMlfQdkfI0A=iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/conflict-page/native-delete/before-read/payloads.json b/corpus/conflict-page/native-delete/before-read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..f4ee299a7df5c6b52e3098b2bc5840a1996ec7e2 --- /dev/null +++ b/corpus/conflict-page/native-delete/before-read/payloads.json @@ -0,0 +1,10 @@ +[ + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{19686511-7C8F-4B47-BC86-55344C46A4DA}{11}{B0}", + "kind": "InsertedFile", + "page": "{90276157-A44D-48C4-BA1F-976DCF6A224F}{1}{B0}", + "bytes": 43 + } +] \ No newline at end of file diff --git a/corpus/conflict-page/native-delete/cold-conflict.json b/corpus/conflict-page/native-delete/cold-conflict.json new file mode 100644 index 0000000000000000000000000000000000000000..2fd05a4a7938577ce0347213e7f3ed53dab2c0a4 --- /dev/null +++ b/corpus/conflict-page/native-delete/cold-conflict.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Client A conflicting edit. - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native-delete/cold-conflict.png b/corpus/conflict-page/native-delete/cold-conflict.png new file mode 100644 index 0000000000000000000000000000000000000000..8d522c209a274b0ad1076843186dd469c683d028 Binary files /dev/null and b/corpus/conflict-page/native-delete/cold-conflict.png differ diff --git a/corpus/conflict-page/native-delete/cold-delete.json b/corpus/conflict-page/native-delete/cold-delete.json new file mode 100644 index 0000000000000000000000000000000000000000..668f002c22e396d7530675ec44f659f85b6f6a86 --- /dev/null +++ b/corpus/conflict-page/native-delete/cold-delete.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Client B conflicting edit. - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native-delete/cold-delete.png b/corpus/conflict-page/native-delete/cold-delete.png new file mode 100644 index 0000000000000000000000000000000000000000..aa8b2818d72f6d10d67a3bfe8c11c73c2bb34d9c Binary files /dev/null and b/corpus/conflict-page/native-delete/cold-delete.png differ diff --git a/corpus/conflict-page/native-delete/cold-main.json b/corpus/conflict-page/native-delete/cold-main.json new file mode 100644 index 0000000000000000000000000000000000000000..668f002c22e396d7530675ec44f659f85b6f6a86 --- /dev/null +++ b/corpus/conflict-page/native-delete/cold-main.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Client B conflicting edit. - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native-delete/cold-main.png b/corpus/conflict-page/native-delete/cold-main.png new file mode 100644 index 0000000000000000000000000000000000000000..d82c666ffe306025e3f33d57ad6e47433bad0a59 Binary files /dev/null and b/corpus/conflict-page/native-delete/cold-main.png differ diff --git a/corpus/conflict-page/native-delete/cold-menu.json b/corpus/conflict-page/native-delete/cold-menu.json new file mode 100644 index 0000000000000000000000000000000000000000..f43854a7a42d5bb99c0b443513bc0c0993c2716c --- /dev/null +++ b/corpus/conflict-page/native-delete/cold-menu.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "", + "class": "Net UI Tool Window", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native-delete/cold-menu.png b/corpus/conflict-page/native-delete/cold-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..c740dbf75d89b9f347e3cead98f6b4f188fe0b1f Binary files /dev/null and b/corpus/conflict-page/native-delete/cold-menu.png differ diff --git a/corpus/conflict-page/native-delete/cold-shown.json b/corpus/conflict-page/native-delete/cold-shown.json new file mode 100644 index 0000000000000000000000000000000000000000..2fd05a4a7938577ce0347213e7f3ed53dab2c0a4 --- /dev/null +++ b/corpus/conflict-page/native-delete/cold-shown.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Client A conflicting edit. - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native-delete/cold-shown.png b/corpus/conflict-page/native-delete/cold-shown.png new file mode 100644 index 0000000000000000000000000000000000000000..3257684d36d68a5b5ff42b439cef7a97c793ad62 Binary files /dev/null and b/corpus/conflict-page/native-delete/cold-shown.png differ diff --git a/corpus/conflict-page/native-delete/commands.jsonl b/corpus/conflict-page/native-delete/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..bf51e5ed90a7bdb52ff561231782da39c4669557 --- /dev/null +++ b/corpus/conflict-page/native-delete/commands.jsonl @@ -0,0 +1,6 @@ +{"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-7691E04E -ExpectedPages -1 -KeepOpen", "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} +{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "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-7691E04E -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 2 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/conflict-page/native-delete/machine.json b/corpus/conflict-page/native-delete/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..a5cd39156d6dc01d0e333458bf636f41fdff0721 --- /dev/null +++ b/corpus/conflict-page/native-delete/machine.json @@ -0,0 +1 @@ +{"name": "m6-7691e04e", "hostname": "ONE-M6-7691E04E"} diff --git a/corpus/conflict-page/native-delete/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one b/corpus/conflict-page/native-delete/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one new file mode 100644 index 0000000000000000000000000000000000000000..1cd05d71d4fe210c8010abf2a4a08bfd8c4abba9 Binary files /dev/null and b/corpus/conflict-page/native-delete/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one differ diff --git a/corpus/conflict-page/native-delete/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 b/corpus/conflict-page/native-delete/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..94d5ec410cd4d8d0de874baaa40fe256f0a686bb Binary files /dev/null and b/corpus/conflict-page/native-delete/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native-delete/notebook/Open Notebook.onetoc2 b/corpus/conflict-page/native-delete/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..5d8fb2d3a1d1c39967d3a3260efd997006aaf554 Binary files /dev/null and b/corpus/conflict-page/native-delete/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native-delete/notebook/synthetic.one b/corpus/conflict-page/native-delete/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..2d9ecaa8a40f17d391c48d15af3eb5ca56563ca4 Binary files /dev/null and b/corpus/conflict-page/native-delete/notebook/synthetic.one differ diff --git a/corpus/conflict-page/native-delete/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/conflict-page/native-delete/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment new file mode 100644 index 0000000000000000000000000000000000000000..66b6151e91f90df6a61a19ef2f4447da278e07b2 --- /dev/null +++ b/corpus/conflict-page/native-delete/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment @@ -0,0 +1 @@ +Fictitious attachment for native corpus. \ No newline at end of file diff --git a/corpus/conflict-page/native-delete/read/environment.json b/corpus/conflict-page/native-delete/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..5f02dc66163bb072e2494050996e4b456ee3b69a --- /dev/null +++ b/corpus/conflict-page/native-delete/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-7691E04E", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/conflict-page/native-delete/read/hierarchy.xml b/corpus/conflict-page/native-delete/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..d9c3291f114855dddea2bd070799775265dd86e0 --- /dev/null +++ b/corpus/conflict-page/native-delete/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-delete/read/page-000.xml b/corpus/conflict-page/native-delete/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..6c687224b62d5cb559633435c7772dc6de9e40a4 --- /dev/null +++ b/corpus/conflict-page/native-delete/read/page-000.xml @@ -0,0 +1,21 @@ + +AK4FHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3 +URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBADBkgQRSNG +IwUIC2VPZmdoZGkZFDIIAIAeAiHi4kEzCADwEAKLxeJBZAMVRgBACpUE/QGH81d3mq7mkzgM0mcB +mk1gEBgEBgEBgE1mUAgMzmUAmsygE1mUzmkBmc0gMzmk1mUAmsyTOaTWZQCazKZgALSmiazKATWZ +QCAwCAwBAZpM4DAJlNYBMprAJlNZpM4DNJmmU1mkzgM0mcBmkzmU1gEyAAFpTSAzOaQGZzRAYBAY +BAYBMprAIDNJnAZpM0yms0mcBmkzgM0mcymsAmU1gEyms0maZAACZTSawCZTWATKawCAoBAYBAZn +NIDAJrMoBNZlAJrMpnMoBNZlAJrMpnNIDM5pAZnNJrMkzmk1AACH8C7XgWnmkzgM0msAmU1gEBgE +BgEBTOaQGATWZQCazKATWZTOaTWZTNNJrMpmmUzTSaplM00mqZTOaTVMpmmk1TKATKazSawCAzSZ +wGAQFAIDAJrMoBAZnNIDM5pAZnMpnNJrMpmmk1mUzTSaplM00mqZTNNJrMpmmk1TKZplhuAzKATW +ZQCazKAIDAIDAIDNJnAYBMprAJlM4DNJnMprNJnMk1mkzmSazRM5kms0TOZJnMprNEzmSazQtKaQ +GZzRNZlAIDAIDAIDAJlNYAmU1gEymsAmU1mkzmU1miZzKazRM5kmcyTWaJnMk1mkzmSazRM5kAUU +RgBAAAAJBAUURgAAAAASABEgXhT+65lz/EiPi+8SFvjqCAk+gEcDAa/89Qd0AxVGAEAKABEg2R87 +QQrSFUA=AOECHQJSegFgKgAaH4tCcEWEdYC+0EXZBPqHlTLy7BdBuMAWv/Mr9HreuxOL5V6zSJyboSVat1ES +v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIEEUjRiMF +CAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQArKAUuH88s3nkzmkzgM0mcBmkzg +KAQGAQGATWZQCAzOaQGZzKATWZTOaQGZzSAzOaTWZQCazJM5pNZlAJrMpmAAAACH8D9XgeBJlM0z +mRNZpNZpM4DAJlNYBAUAgMAgMzmkBgE1mUAmsyTOaTWZTOaTVMpnNJqmUzTSaplM00mqZTOZTNNJ +qmUzTSagBRRGAIAAAAkEBRRGAAAAABIAESDPTQ4xwIZyTqQ8FVDbHxeKCj9AI5rARvPPUDugAxVG +AEAKABEgLMlfQdkfI0A=iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/conflict-page/native-delete/read/payloads.json b/corpus/conflict-page/native-delete/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..f4ee299a7df5c6b52e3098b2bc5840a1996ec7e2 --- /dev/null +++ b/corpus/conflict-page/native-delete/read/payloads.json @@ -0,0 +1,10 @@ +[ + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{19686511-7C8F-4B47-BC86-55344C46A4DA}{11}{B0}", + "kind": "InsertedFile", + "page": "{90276157-A44D-48C4-BA1F-976DCF6A224F}{1}{B0}", + "bytes": 43 + } +] \ No newline at end of file diff --git a/corpus/conflict-page/native-delete/run.json b/corpus/conflict-page/native-delete/run.json new file mode 100644 index 0000000000000000000000000000000000000000..8bbfce1033d107da6ca6bc60347fa5c2614f6a2e --- /dev/null +++ b/corpus/conflict-page/native-delete/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/gate/native-conflict", + "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/conflict-page/native-delete/scripts/cold.ps1 b/corpus/conflict-page/native-delete/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/conflict-page/native-delete/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/conflict-page/native-delete/scripts/read.ps1 b/corpus/conflict-page/native-delete/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/conflict-page/native-delete/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/conflict-page/native-delete/source.json b/corpus/conflict-page/native-delete/source.json new file mode 100644 index 0000000000000000000000000000000000000000..c7bc810c8304aa1ca14df7b60ab0c9dcfaffb63c --- /dev/null +++ b/corpus/conflict-page/native-delete/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 4944, + "sha256": "36274ad5cc077c6b1ad51ee4c4b90d3b6798a45ce446cbfd7c9d7ac504776b28", + "mtime_ns": 1790472520423354873 + }, + { + "path": "synthetic.one", + "bytes": 40600, + "sha256": "e8d43dd57e02fe76e88c8ef45a7e18487bbf28928d0baf077288a4bb1415692c", + "mtime_ns": 1790472520423873581 + } +] diff --git a/corpus/conflict-page/native-delete/teardown.json b/corpus/conflict-page/native-delete/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/conflict-page/native-delete/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/conflict-page/native-pages/a/a-merged.json b/corpus/conflict-page/native-pages/a/a-merged.json new file mode 100644 index 0000000000000000000000000000000000000000..2f394e7eaf1defcfe3e06e321b5af76be4f84054 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/a-merged.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Fictitious: caf\u00e9, \u6771\u4eac, \u0645\u0631\u062d\u0628\u0627 - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/a-merged.png b/corpus/conflict-page/native-pages/a/a-merged.png new file mode 100644 index 0000000000000000000000000000000000000000..2bf9f7a8336aac9401d21ad0614e03e51551d83e Binary files /dev/null and b/corpus/conflict-page/native-pages/a/a-merged.png differ diff --git a/corpus/conflict-page/native-pages/a/command-0001.json b/corpus/conflict-page/native-pages/a/command-0001.json new file mode 100644 index 0000000000000000000000000000000000000000..d79cc0093fa7c789c925d193b3110c4da6523951 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0001.json @@ -0,0 +1 @@ +{"action": "pages", "titles": ["One", "Two", "Three", "Four", "Target"]} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/command-0002.json b/corpus/conflict-page/native-pages/a/command-0002.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0002.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/command-0003.json b/corpus/conflict-page/native-pages/a/command-0003.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0003.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/command-0004.json b/corpus/conflict-page/native-pages/a/command-0004.json new file mode 100644 index 0000000000000000000000000000000000000000..8bd5839cfa379b0117fb5154acd9d20bc06b5cda --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0004.json @@ -0,0 +1 @@ +{"action": "move", "page": "Four", "before": "One"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/command-0005.json b/corpus/conflict-page/native-pages/a/command-0005.json new file mode 100644 index 0000000000000000000000000000000000000000..39953577e616d0ad2af01c7227449b427efdf5e5 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0005.json @@ -0,0 +1 @@ +{"action": "move", "page": "Two", "before": null} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/command-0006.json b/corpus/conflict-page/native-pages/a/command-0006.json new file mode 100644 index 0000000000000000000000000000000000000000..3f6f2524a6523bec79f8152f3340d2ae649729a0 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0006.json @@ -0,0 +1 @@ +{"action": "edit", "expected": "Body Target.", "text": "Body Target edited offline."} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/command-0007.json b/corpus/conflict-page/native-pages/a/command-0007.json new file mode 100644 index 0000000000000000000000000000000000000000..554ff0ef8a35478d74f3e5388f75789140b158b8 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0007.json @@ -0,0 +1 @@ +{"action": "snapshot"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/command-0008.json b/corpus/conflict-page/native-pages/a/command-0008.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0008.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/command-0009.json b/corpus/conflict-page/native-pages/a/command-0009.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0009.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/command-0010.json b/corpus/conflict-page/native-pages/a/command-0010.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0010.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/command-0011.json b/corpus/conflict-page/native-pages/a/command-0011.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0011.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/command-0012.json b/corpus/conflict-page/native-pages/a/command-0012.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0012.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/command-0013.json b/corpus/conflict-page/native-pages/a/command-0013.json new file mode 100644 index 0000000000000000000000000000000000000000..554ff0ef8a35478d74f3e5388f75789140b158b8 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0013.json @@ -0,0 +1 @@ +{"action": "snapshot"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/command-0014.json b/corpus/conflict-page/native-pages/a/command-0014.json new file mode 100644 index 0000000000000000000000000000000000000000..7adf980027ef43cc77767d4fd1b1d67ad1541ab4 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/command-0014.json @@ -0,0 +1 @@ +{"action": "close"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/commands.jsonl b/corpus/conflict-page/native-pages/a/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..a0ec79a71dfef56ca2cfd608ba91f4a17561c1c6 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/commands.jsonl @@ -0,0 +1,16 @@ +{"command": "mkdir C:\\one-tests\\runs\\capture", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac 52:54:02:a8:e9:55", "exit": 0, "stdout": "{\"renewalResult\":0,\"releaseResult\":0,\"mac\":\"52:54:02:a8:e9:55\",\"address\":\"192.168.77.42\"}\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0001.tmp C:\\one-tests\\runs\\capture\\inbox\\0001.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0002.tmp C:\\one-tests\\runs\\capture\\inbox\\0002.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0003.tmp C:\\one-tests\\runs\\capture\\inbox\\0003.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0004.tmp C:\\one-tests\\runs\\capture\\inbox\\0004.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0005.tmp C:\\one-tests\\runs\\capture\\inbox\\0005.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0006.tmp C:\\one-tests\\runs\\capture\\inbox\\0006.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0007.tmp C:\\one-tests\\runs\\capture\\inbox\\0007.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0008.tmp C:\\one-tests\\runs\\capture\\inbox\\0008.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0009.tmp C:\\one-tests\\runs\\capture\\inbox\\0009.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0010.tmp C:\\one-tests\\runs\\capture\\inbox\\0010.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0011.tmp C:\\one-tests\\runs\\capture\\inbox\\0011.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0012.tmp C:\\one-tests\\runs\\capture\\inbox\\0012.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0013.tmp C:\\one-tests\\runs\\capture\\inbox\\0013.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0014.tmp C:\\one-tests\\runs\\capture\\inbox\\0014.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} diff --git a/corpus/conflict-page/native-pages/a/hierarchy-0007.xml b/corpus/conflict-page/native-pages/a/hierarchy-0007.xml new file mode 100644 index 0000000000000000000000000000000000000000..ef20a1ea257cde476594b8c089fec60f01448292 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/hierarchy-0007.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-pages/a/hierarchy-0013.xml b/corpus/conflict-page/native-pages/a/hierarchy-0013.xml new file mode 100644 index 0000000000000000000000000000000000000000..1b7acda08daec45d584097dfb24cc17574ae24c1 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/hierarchy-0013.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-pages/a/listed.json b/corpus/conflict-page/native-pages/a/listed.json new file mode 100644 index 0000000000000000000000000000000000000000..67a675a4f64d48e4266aa24db7ca7731d86ad8c6 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/listed.json @@ -0,0 +1 @@ +["Fictitious: café, 東京, مرحبا", "One", "Two", "Four", "Three", "Target"] \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/a/machine.json b/corpus/conflict-page/native-pages/a/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..8078699df47e7da61dfbd6d7fcafb6d2f7114e2d --- /dev/null +++ b/corpus/conflict-page/native-pages/a/machine.json @@ -0,0 +1 @@ +{"name": "m6-327953db", "hostname": "ONE-M6-327953DB"} diff --git a/corpus/conflict-page/native-pages/a/teardown.json b/corpus/conflict-page/native-pages/a/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/conflict-page/native-pages/a/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/conflict-page/native-pages/b-published/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one b/corpus/conflict-page/native-pages/b-published/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one new file mode 100644 index 0000000000000000000000000000000000000000..4af78d7a2dbe5360b92dcd980204e841a09ba37d Binary files /dev/null and b/corpus/conflict-page/native-pages/b-published/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one differ diff --git a/corpus/conflict-page/native-pages/b-published/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 b/corpus/conflict-page/native-pages/b-published/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..6e4b6c9be779af4e6515e1c9facf29cf6c5b792e Binary files /dev/null and b/corpus/conflict-page/native-pages/b-published/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native-pages/b-published/notebook/Open Notebook.onetoc2 b/corpus/conflict-page/native-pages/b-published/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..c5257a0970c051ac5c96357e05cdd9b0f242ee38 Binary files /dev/null and b/corpus/conflict-page/native-pages/b-published/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native-pages/b-published/notebook/synthetic.one b/corpus/conflict-page/native-pages/b-published/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..1d3f7dc3f9b4db4d434241133067f9942301fb88 Binary files /dev/null and b/corpus/conflict-page/native-pages/b-published/notebook/synthetic.one differ diff --git a/corpus/conflict-page/native-pages/b/b-merged.json b/corpus/conflict-page/native-pages/b/b-merged.json new file mode 100644 index 0000000000000000000000000000000000000000..2f394e7eaf1defcfe3e06e321b5af76be4f84054 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/b-merged.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Fictitious: caf\u00e9, \u6771\u4eac, \u0645\u0631\u062d\u0628\u0627 - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/b/b-merged.png b/corpus/conflict-page/native-pages/b/b-merged.png new file mode 100644 index 0000000000000000000000000000000000000000..33bdd9945f10bcec0adcd5e541085aa7e14e86e6 Binary files /dev/null and b/corpus/conflict-page/native-pages/b/b-merged.png differ diff --git a/corpus/conflict-page/native-pages/b/command-0001.json b/corpus/conflict-page/native-pages/b/command-0001.json new file mode 100644 index 0000000000000000000000000000000000000000..554ff0ef8a35478d74f3e5388f75789140b158b8 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/command-0001.json @@ -0,0 +1 @@ +{"action": "snapshot"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/b/command-0002.json b/corpus/conflict-page/native-pages/b/command-0002.json new file mode 100644 index 0000000000000000000000000000000000000000..dc7bcbe3a9d3ab7ff629841610b3af0459d3ffce --- /dev/null +++ b/corpus/conflict-page/native-pages/b/command-0002.json @@ -0,0 +1 @@ +{"action": "move", "page": "Four", "before": null} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/b/command-0003.json b/corpus/conflict-page/native-pages/b/command-0003.json new file mode 100644 index 0000000000000000000000000000000000000000..4b88e6612d0b555ffdaf5d84fc00c75de4937491 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/command-0003.json @@ -0,0 +1 @@ +{"action": "move", "page": "Three", "before": "One"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/b/command-0004.json b/corpus/conflict-page/native-pages/b/command-0004.json new file mode 100644 index 0000000000000000000000000000000000000000..eff482c021828f4924fb0845aa6043a8312745ea --- /dev/null +++ b/corpus/conflict-page/native-pages/b/command-0004.json @@ -0,0 +1 @@ +{"action": "delete-page", "page": "Target"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/b/command-0005.json b/corpus/conflict-page/native-pages/b/command-0005.json new file mode 100644 index 0000000000000000000000000000000000000000..554ff0ef8a35478d74f3e5388f75789140b158b8 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/command-0005.json @@ -0,0 +1 @@ +{"action": "snapshot"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/b/command-0006.json b/corpus/conflict-page/native-pages/b/command-0006.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/command-0006.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/b/command-0007.json b/corpus/conflict-page/native-pages/b/command-0007.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/command-0007.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/b/command-0008.json b/corpus/conflict-page/native-pages/b/command-0008.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/command-0008.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/b/command-0009.json b/corpus/conflict-page/native-pages/b/command-0009.json new file mode 100644 index 0000000000000000000000000000000000000000..554ff0ef8a35478d74f3e5388f75789140b158b8 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/command-0009.json @@ -0,0 +1 @@ +{"action": "snapshot"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/b/command-0010.json b/corpus/conflict-page/native-pages/b/command-0010.json new file mode 100644 index 0000000000000000000000000000000000000000..7adf980027ef43cc77767d4fd1b1d67ad1541ab4 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/command-0010.json @@ -0,0 +1 @@ +{"action": "close"} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/b/commands.jsonl b/corpus/conflict-page/native-pages/b/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..b01eb35abba6cfb0631130c1778f33d75ea7ae3e --- /dev/null +++ b/corpus/conflict-page/native-pages/b/commands.jsonl @@ -0,0 +1,12 @@ +{"command": "mkdir C:\\one-tests\\runs\\capture", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac 52:54:02:19:c7:6d", "exit": 0, "stdout": "{\"renewalResult\":0,\"releaseResult\":0,\"mac\":\"52:54:02:19:c7:6d\",\"address\":\"192.168.77.210\"}\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0001.tmp C:\\one-tests\\runs\\capture\\inbox\\0001.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0002.tmp C:\\one-tests\\runs\\capture\\inbox\\0002.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0003.tmp C:\\one-tests\\runs\\capture\\inbox\\0003.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0004.tmp C:\\one-tests\\runs\\capture\\inbox\\0004.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0005.tmp C:\\one-tests\\runs\\capture\\inbox\\0005.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0006.tmp C:\\one-tests\\runs\\capture\\inbox\\0006.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0007.tmp C:\\one-tests\\runs\\capture\\inbox\\0007.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0008.tmp C:\\one-tests\\runs\\capture\\inbox\\0008.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0009.tmp C:\\one-tests\\runs\\capture\\inbox\\0009.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0010.tmp C:\\one-tests\\runs\\capture\\inbox\\0010.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} diff --git a/corpus/conflict-page/native-pages/b/hierarchy-0001.xml b/corpus/conflict-page/native-pages/b/hierarchy-0001.xml new file mode 100644 index 0000000000000000000000000000000000000000..109b3296412c627c6a42b9c2d44d5ce4dcdc8709 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/hierarchy-0001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-pages/b/hierarchy-0005.xml b/corpus/conflict-page/native-pages/b/hierarchy-0005.xml new file mode 100644 index 0000000000000000000000000000000000000000..633860ac88594b8ebd31efd814b5032cda6b6ff7 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/hierarchy-0005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-pages/b/hierarchy-0009.xml b/corpus/conflict-page/native-pages/b/hierarchy-0009.xml new file mode 100644 index 0000000000000000000000000000000000000000..95e5b0fbf2cd7769d2f9edc187df53611350f2f2 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/hierarchy-0009.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-pages/b/listed.json b/corpus/conflict-page/native-pages/b/listed.json new file mode 100644 index 0000000000000000000000000000000000000000..67a675a4f64d48e4266aa24db7ca7731d86ad8c6 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/listed.json @@ -0,0 +1 @@ +["Fictitious: café, 東京, مرحبا", "One", "Two", "Four", "Three", "Target"] \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/b/machine.json b/corpus/conflict-page/native-pages/b/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..4c203c09d03f32d2e43453d8cd7f643f38ad7c96 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/machine.json @@ -0,0 +1 @@ +{"name": "m6-39da335d", "hostname": "ONE-M6-39DA335D"} diff --git a/corpus/conflict-page/native-pages/b/teardown.json b/corpus/conflict-page/native-pages/b/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/conflict-page/native-pages/b/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/conflict-page/native-pages/clients.json b/corpus/conflict-page/native-pages/clients.json new file mode 100644 index 0000000000000000000000000000000000000000..d931d1ff7f174fc34ff42841be4fca8f0991ad66 --- /dev/null +++ b/corpus/conflict-page/native-pages/clients.json @@ -0,0 +1,4 @@ +{ + "a": "m6-327953db", + "b": "m6-39da335d" +} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/initial/notebook/Open Notebook.onetoc2 b/corpus/conflict-page/native-pages/initial/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..c7beb44bf509f935090fe55e7549e4b50e0ae0e9 Binary files /dev/null and b/corpus/conflict-page/native-pages/initial/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native-pages/initial/notebook/synthetic.one b/corpus/conflict-page/native-pages/initial/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..9d00682a40e27f509b734b04bdbe10e25e7d3d56 Binary files /dev/null and b/corpus/conflict-page/native-pages/initial/notebook/synthetic.one differ diff --git a/corpus/conflict-page/native-pages/linux-teardown.json b/corpus/conflict-page/native-pages/linux-teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/conflict-page/native-pages/linux-teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/conflict-page/native-pages/linux.json b/corpus/conflict-page/native-pages/linux.json new file mode 100644 index 0000000000000000000000000000000000000000..f81354b6eed97724307d4606252c29ab86b6bbfa --- /dev/null +++ b/corpus/conflict-page/native-pages/linux.json @@ -0,0 +1,11 @@ +{ + "cpus": 2, + "disk_gb": 16, + "hostname": "one-conflict2", + "lab_address": "192.168.77.1", + "lab_mac": "52:54:04:00:03:8c", + "memory_mb": 2048, + "samba_port": 14450, + "ssh_port": 22000, + "wan_mac": "52:54:03:c3:23:a2" +} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/merged.json b/corpus/conflict-page/native-pages/merged.json new file mode 100644 index 0000000000000000000000000000000000000000..72ed0278337d84924b221fde7086fa5f092b024f --- /dev/null +++ b/corpus/conflict-page/native-pages/merged.json @@ -0,0 +1,49 @@ +{ + "order": [ + "", + "One", + "Two", + "Four", + "Three", + "Target" + ], + "pages": [ + [ + "", + "Fictitious positioned outline.", + "Fictitious: café, 東京, مرحبا", + "Left cell", + "Right cell" + ], + [ + "2:20 AM", + "Body One.", + "One", + "Sunday, September 27, 2026" + ], + [ + "2:20 AM", + "Body Two.", + "Sunday, September 27, 2026", + "Two" + ], + [ + "2:20 AM", + "Body Four.", + "Four", + "Sunday, September 27, 2026" + ], + [ + "2:20 AM", + "Body Three.", + "Sunday, September 27, 2026", + "Three" + ], + [ + "2:20 AM", + "Body Target edited offline.", + "Sunday, September 27, 2026", + "Target" + ] + ] +} \ No newline at end of file diff --git a/corpus/conflict-page/native-pages/merged/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one b/corpus/conflict-page/native-pages/merged/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one new file mode 100644 index 0000000000000000000000000000000000000000..2b5107aef4cdfdc82bea6f5069c97e40d26506eb Binary files /dev/null and b/corpus/conflict-page/native-pages/merged/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one differ diff --git a/corpus/conflict-page/native-pages/merged/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 b/corpus/conflict-page/native-pages/merged/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..6e4b6c9be779af4e6515e1c9facf29cf6c5b792e Binary files /dev/null and b/corpus/conflict-page/native-pages/merged/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native-pages/merged/notebook/Open Notebook.onetoc2 b/corpus/conflict-page/native-pages/merged/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..c5257a0970c051ac5c96357e05cdd9b0f242ee38 Binary files /dev/null and b/corpus/conflict-page/native-pages/merged/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native-pages/merged/notebook/synthetic.one b/corpus/conflict-page/native-pages/merged/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..8343d60ee07dc07bdcb875b18eb5c9d07c7c293c Binary files /dev/null and b/corpus/conflict-page/native-pages/merged/notebook/synthetic.one differ diff --git a/corpus/conflict-page/native-pages/native_conflict.py b/corpus/conflict-page/native-pages/native_conflict.py new file mode 100644 index 0000000000000000000000000000000000000000..ccb4026acf69f3df84fdfbb01e9b6e39461762d3 --- /dev/null +++ b/corpus/conflict-page/native-pages/native_conflict.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""OneNote 2010 conflict pages: how they arise, what they store and how OneNote shows them. + +`sync LINUX OUTPUT` runs two disposable OneNote clients on one Samba notebook: client A edits +two paragraphs offline while client B edits one of them and a table cell online; A then +reconnects, which makes OneNote write a conflict page. The server file is kept before and +after, A and B are screenshotted with the information bar, the page list shown, the conflict +page open and its menu open, and A then deletes the conflict page through that menu. + +`pages LINUX OUTPUT` runs the same two clients on page-list changes: client A, offline, moves +a page that client B also moves, moves another page, and edits a page B deletes; A then +reconnects. The server's page order and pages, with the recycle bin, are kept at each step. + +`restore LINUX OUTPUT` runs them on a page in the middle of the list: client A, offline, edits +it and moves a page B leaves alone while B deletes it; A then reconnects. + +`cold NOTEBOOK OUTPUT [--delete]` cold-opens a notebook holding a conflict page in a fresh +clone and screenshots the same views; `--delete` deletes the conflict page through the menu +and captures the notebook OneNote leaves. +""" +import argparse +from contextlib import ExitStack +from concurrent.futures import ThreadPoolExecutor +import base64 +import json +from pathlib import Path +import shutil +import subprocess +import sys +import time +import xml.etree.ElementTree as ET + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent / 'w7')) +import native_runner as runner +from native_runner import ROOT, clone, command, windows, vm +import linux_vm + +FIXTURE = ROOT / 'corpus/native-ink/cold-ui-ink/notebook' +SHARE = 'm6-conflict' +ORIGINAL = 'Fictitious: café, 東京, مرحبا' +OTHER_OUTLINE = 'Fictitious positioned outline.' +CELL = 'Left cell' + +# Coordinates on OneNote 2010 maximized at 800x600, from the stage-5, m8 and sync-01 captures: +# the information bar's text, and the first conflict page listed under its page. +BAR = (332, 100) +FIRST_CONFLICT = (725, 142) +# Delete Conflict Page, first in the menu the bar opens on a conflict page. +DELETE = (420, 113) + + +def shot(name, output, label, script): + """Runs an AutoHotkey step on the clone, keeping its screenshot as `label.png`.""" + (output / f'{label}.ahk').write_text(script) + result = windows.do_exec(script, target=name, timeout_ms=120000, shot_delay_ms=1500) + screenshot = result.pop('png_b64', None) + if screenshot: + (output / f'{label}.png').write_bytes(base64.b64decode(screenshot)) + (output / f'{label}.json').write_text(json.dumps(result, indent=2)) + if result.get('error') or result.get('exit') != 0: + raise RuntimeError(f'{label}: {result}') + return result + + +def click(x, y): + return ('WinActivate("ahk_exe ONENOTE.EXE")\nCoordMode("Mouse", "Screen")\n' + f'Click({x}, {y})\nSleep(1200)\n') + + +# Reports any dialog OneNote raised, so a confirmation shows in the step's record. +DIALOG = ('if WinExist("ahk_class #32770 ahk_exe ONENOTE.EXE") {\n' + ' FileAppend("dialog: " WinGetTitle() " | " WinGetText() "`n", "*", "UTF-8")\n' + '}\n') + + +def conflict_views(name, output, page, prefix, delete=False): + """Screenshots a page with a conflict page: the bar, the list shown, the conflict page and + its menu; `delete` then chooses Delete Conflict Page.""" + shot(name, output, f'{prefix}-main', runner.navigation_script(page) + 'Sleep(2000)\n') + shot(name, output, f'{prefix}-shown', click(*BAR)) + shot(name, output, f'{prefix}-conflict', click(*FIRST_CONFLICT)) + shot(name, output, f'{prefix}-menu', click(*BAR)) + if delete: + shot(name, output, f'{prefix}-delete', click(*DELETE) + DIALOG) + else: + shot(name, output, f'{prefix}-closed', 'WinActivate("ahk_exe ONENOTE.EXE")\nSend("{Esc}")\nSleep(800)\n') + + +def cold(notebook, output, delete): + def interaction(name, output): + page = ET.parse(sorted((output / 'read').glob('page-*.xml'))[0]).getroot().attrib['ID'] + conflict_views(name, output, page, 'cold', delete) + if delete: + # OneNote writes the deletion in the background; give it time before the re-read. + time.sleep(20) + runner.capture(notebook, output, collect_notebook=True, interaction=interaction) + + +class Lab: + """Two OneNote clients on one Samba notebook: `action` runs a controller command, + `state` copies the share and exports its section, `settle` syncs until a check holds.""" + + +def page_list(model): + """The section's pages in order, each as its sorted texts.""" + from document_model import ordered_pages, walk + return [sorted(node['kind']['text'] for _, node in walk(page, oid) if node['kind']['type'] == 'RichText') + for _, _, page, oid in ordered_pages(model)] + + +def edits(lab): + from native_collaboration import reachable_page_text + a, b, output, action, settle = lab.a, lab.b, lab.output, lab.action, lab.settle + state = lambda label: reachable_page_text(lab.state(label)) + texts = lambda state: {text for values in state.values() for text in values} + state('initial') + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': False}) + try: + action(a, 'edit', expected=ORIGINAL, text='Client A conflicting edit.') + action(a, 'edit', expected=OTHER_OUTLINE, text='Client A disjoint edit.') + action(a, 'snapshot') + action(b, 'edit', expected=ORIGINAL, text='Client B conflicting edit.') + action(b, 'edit', expected=CELL, text='Client B disjoint cell.') + settle(b, lambda label: {'Client B conflicting edit.', 'Client B disjoint cell.'} <= texts(state(label)), 'b-published') + finally: + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': True}) + settle(a, lambda label: len(found := state(label)) > 1 and 'Client A disjoint edit.' in texts(found), 'conflict') + (output / 'conflict.json').write_text(json.dumps(state('conflict'), indent=2, ensure_ascii=False)) + page = action(a, 'snapshot')[0].attrib['ID'] + conflict_views(a['name'], output / 'a', page, 'a') + action(b, 'sync') + time.sleep(5) + page_b = action(b, 'snapshot')[0].attrib['ID'] + conflict_views(b['name'], output / 'b', page_b, 'b') + conflict_views(a['name'], output / 'a', page, 'a-again', delete=True) + settle(a, lambda label: len(state(label)) == 1, 'deleted') + action(b, 'sync') + time.sleep(5) + shot(b['name'], output / 'b', 'b-after-delete', runner.navigation_script(page_b) + 'Sleep(2000)\n') + + +PAGES = ['One', 'Two', 'Three', 'Four', 'Target'] + + +def titled(model, titles): + """The section's pages in order, each named by the title among `titles` it holds.""" + return [next((text for text in texts if text in titles), '') for texts in page_list(model)] + + +def offline(lab, titles, a_actions, b_actions): + """Creates the titled pages; A runs its actions offline while B runs and publishes its + own; A then reconnects. Keeps the merged order and pages, and what each client lists.""" + a, b, output, action = lab.a, lab.b, lab.output, lab.action + action(a, 'pages', titles=titles) + lab.settle(a, lambda label: 'Body Target.' in str(page_list(lab.state(label))), 'initial') + deadline = time.monotonic() + 300 + while not any(node.get('name') == 'Target' for node in action(b, 'snapshot')): + action(b, 'sync') + if time.monotonic() > deadline: raise TimeoutError('B never listed the new pages') + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': False}) + try: + for kind, parameters in a_actions: + action(a, kind, **parameters) + action(a, 'snapshot') + for kind, parameters in b_actions: + action(b, kind, **parameters) + action(b, 'snapshot') + lab.settle(b, lambda label: 'Body Target.' not in str(page_list(lab.state(label))), 'b-published') + finally: + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': True}) + # A's changes reach the server when its sync writes; wait until the file stops changing. + previous, steady = None, 0 + deadline = time.monotonic() + 300 + while steady < 3 and time.monotonic() < deadline: + action(a, 'sync') + time.sleep(10) + current = page_list(lab.state('merged')) + steady = steady + 1 if current == previous else 0 + previous = current + (output / 'merged.json').write_text(json.dumps({'order': titled(lab.state('merged'), titles), 'pages': previous}, indent=2, ensure_ascii=False)) + for client, label in ((a, 'a'), (b, 'b')): + action(client, 'sync') + time.sleep(5) + listed = action(client, 'snapshot') + (output / label / 'listed.json').write_text(json.dumps([node.get('name') for node in listed], ensure_ascii=False)) + if listed: + shot(client['name'], output / label, f'{label}-merged', runner.navigation_script(listed[0].get('ID')) + 'Sleep(2000)\n') + + +def pages(lab): + """Competing page moves, and an offline edit of a page the other client deletes.""" + offline(lab, PAGES, [ + ('move', {'page': 'Four', 'before': 'One'}), + ('move', {'page': 'Two', 'before': None}), + ('edit', {'expected': 'Body Target.', 'text': 'Body Target edited offline.'}), + ], [ + ('move', {'page': 'Four', 'before': None}), + ('move', {'page': 'Three', 'before': 'One'}), + ('delete-page', {'page': 'Target'}), + ]) + + +def restore(lab): + """An offline edit of a page in the middle of the list the other client deletes, with an + offline move of a page the other client leaves alone.""" + offline(lab, ['One', 'Two', 'Target', 'Three', 'Four'], [ + ('edit', {'expected': 'Body Target.', 'text': 'Body Target edited offline.'}), + ('move', {'page': 'Four', 'before': 'One'}), + ], [ + ('delete-page', {'page': 'Target'}), + ]) + + +def sync(server, output, scenario): + from document_model import EXPORTER + + if linux_vm.instance_path(server).exists(): + raise ValueError('Choose a new Linux VM name; existing machines are not owned by this run.') + output = output.resolve() + output.mkdir(parents=True, exist_ok=False) + scripts = output / 'scripts' + scripts.mkdir() + for name in ('cold.ps1', 'collaborate.ps1', 'network.ps1', 'text.ps1', 'stress.ps1'): + shutil.copyfile(ROOT / 'tools/native' / name, scripts / name) + shutil.copyfile(Path(__file__), output / 'native_conflict.py') + + def ssh(text, timeout=90): + result = linux_vm.run_ssh(server, text, timeout=timeout) + with (output / 'server.jsonl').open('a') as log: + log.write(json.dumps({'command': text, 'exit': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr}) + '\n') + result.check_returncode() + return result.stdout + + def server_state(label): + """Copies the server's notebook, recycle bin included, until its section parses, + keeping it as `label/notebook`; returns the section's exported model.""" + deadline = time.monotonic() + 120 + while True: + target = output / label + shutil.rmtree(target, ignore_errors=True) + (target / 'notebook').mkdir(parents=True) + archive = subprocess.run(linux_vm.ssh_argv(server, f"tar cf - -C '/srv/agent/{SHARE}' ."), capture_output=True, check=True, timeout=60).stdout + subprocess.run(['tar', 'xf', '-', '-C', target / 'notebook'], input=archive, check=True) + result = subprocess.run([EXPORTER, target / 'notebook/synthetic.one', target / 'model'], capture_output=True, text=True) + if result.returncode == 0: + return json.loads((target / 'model/document.json').read_text()) + if time.monotonic() > deadline: + raise RuntimeError(f'{label}: {result.stderr}') + time.sleep(1) + + linux_vm.create_instance(server) + try: + linux_vm.launch(server) + linux_vm.wait_instance(server, 600) + (output / 'linux.json').write_text(json.dumps(linux_vm.load_instance(server), indent=2)) + ssh(f'mkdir /srv/agent/{SHARE}') + for name in ('synthetic.one', 'Open Notebook.onetoc2'): + with (FIXTURE / name).open('rb') as stream: + subprocess.run(linux_vm.ssh_argv(server, f"cat > '/srv/agent/{SHARE}/{name}'"), stdin=stream, check=True) + ssh(f'chmod u+w /srv/agent/{SHARE}/*') + with ExitStack() as stack: + labels = ['a', 'b'] + for label in labels: + (output / label).mkdir() + + def start(label): + manager = clone(output / label) + name = manager.__enter__() + return label, name, manager + with ThreadPoolExecutor(max_workers=2) as pool: + started = list(pool.map(start, labels)) + for _, _, manager in started: + stack.push(manager) + clients = {} + for label, name, _ in started: + folder = output / label + for local in scripts.iterdir(): + remote = 'cold-current.ps1' if local.name == 'cold.ps1' else local.name + result = windows.do_put(local, 'C:\\one-tests\\' + remote, name) + if result.get('error'): raise RuntimeError(result['error']) + command(name, 'mkdir C:\\one-tests\\runs\\capture', folder) + command(name, 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac ' + vm.lab_mac(name), folder) + result = windows.do_spawn('cmd /c powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\collaborate.ps1 -Root C:\\one-tests\\runs\\capture -SharedPath \\\\192.168.77.1\\agent\\' + SHARE + ' -CloneHost ONE-' + name.upper() + ' > C:\\one-tests\\runs\\capture\\controller.log 2>&1', name) + if result.get('error'): raise RuntimeError(result['error']) + deadline = time.monotonic() + 180 + while windows.do_cmd('if exist C:\\one-tests\\runs\\capture\\ready (echo ready)', target=name).get('stdout', '').find('ready') < 0: + if time.monotonic() > deadline: raise RuntimeError('The collaboration controller did not open the notebook') + time.sleep(1) + clients[label] = {'name': name, 'folder': folder, 'sequence': 0} + (output / 'clients.json').write_text(json.dumps({label: client['name'] for label, client in clients.items()}, indent=2)) + + def action(client, kind, **parameters): + client['sequence'] += 1 + sequence = client['sequence'] + local = client['folder'] / f'command-{sequence:04}.json' + local.write_text(json.dumps({'action': kind, **parameters}, ensure_ascii=False)) + inbox = f'C:\\one-tests\\runs\\capture\\inbox\\{sequence:04}' + result = windows.do_put(local, inbox + '.tmp', client['name']) + if result.get('error'): raise RuntimeError(result['error']) + command(client['name'], f'move {inbox}.tmp {inbox}.json', client['folder']) + remote = f'C:\\one-tests\\runs\\capture\\outbox\\{sequence}' + deadline = time.monotonic() + 600 + while True: + result = windows.do_cmd(f'if exist {remote}\\error (type {remote}\\error & exit /b 1) else (if exist {remote}\\done (echo complete))', target=client['name']) + if result.get('exit') != 0: raise RuntimeError(str(result)) + if 'complete' in result.get('stdout', ''): break + if time.monotonic() > deadline: raise TimeoutError(f'{kind} did not complete') + time.sleep(.5) + if kind != 'snapshot': + return None + hierarchy = client['folder'] / f'hierarchy-{sequence:04}.xml' + windows.do_get(remote + '\\hierarchy.xml', hierarchy, client['name']) + pages = [node for node in ET.fromstring(hierarchy.read_text(encoding='utf-8-sig').strip()).iter() if node.tag.endswith('}Page')] + for i in range(len(pages)): + windows.do_get(remote + f'\\page-{i}.xml', client['folder'] / f'snapshot-{sequence:04}-{i}.xml', client['name']) + return pages + + def settle(client, check, label): + deadline = time.monotonic() + 300 + while True: + action(client, 'sync') + if check(label): return + if time.monotonic() > deadline: raise TimeoutError(label) + time.sleep(2) + + lab = Lab() + lab.a, lab.b, lab.output = clients['a'], clients['b'], output + lab.action, lab.state, lab.settle = action, server_state, settle + scenario(lab) + + for client in clients.values(): + try: + action(client, 'close') + except Exception as error: + (client['folder'] / 'close-failure.txt').write_text(str(error)) + server_state('final') + finally: + if linux_vm.running(server): + linux_vm.shutdown(server, 120) + linux_vm.delete_instance(server) + (output / 'linux-teardown.json').write_text(json.dumps({'absent': not linux_vm.instance_path(server).exists()}) + '\n') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + commands = parser.add_subparsers(dest='mode', required=True) + scenarios = {'sync': edits, 'pages': pages, 'restore': restore} + for mode in scenarios: + lab = commands.add_parser(mode) + lab.add_argument('linux') + lab.add_argument('output', type=Path) + reopen = commands.add_parser('cold') + reopen.add_argument('notebook', type=Path) + reopen.add_argument('output', type=Path) + reopen.add_argument('--delete', action='store_true') + args = parser.parse_args() + if args.mode in scenarios: + sync(args.linux, args.output, scenarios[args.mode]) + else: + cold(args.notebook, args.output, args.delete) + print('Captured', args.output) diff --git a/corpus/conflict-page/native-pages/scripts/cold.ps1 b/corpus/conflict-page/native-pages/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/conflict-page/native-pages/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/conflict-page/native-pages/scripts/collaborate.ps1 b/corpus/conflict-page/native-pages/scripts/collaborate.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..6a42a3d526db486f022b032583704b0f4aee26d3 --- /dev/null +++ b/corpus/conflict-page/native-pages/scripts/collaborate.ps1 @@ -0,0 +1,145 @@ +param([Parameter(Mandatory=$true)][string]$Root, + [Parameter(Mandatory=$true)][string]$SharedPath, + [Parameter(Mandatory=$true)][string]$CloneHost, + [switch]$ResumeCache, + [int]$StartSequence = 1) +function Keep-Container($page, $target) { + $container = $target + while ($container.ParentNode -ne $page.DocumentElement) { $container = $container.ParentNode } + foreach ($sibling in @($page.DocumentElement.ChildNodes)) { + if ($sibling -ne $container -and $sibling.LocalName -in @('Outline', 'Title', 'Image', 'InkDrawing')) { + [void]$page.DocumentElement.RemoveChild($sibling) + } + } +} +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot\text.ps1" +if ($SharedPath -notmatch '^\\\\192\.168\.77\.1\\agent\\m6-[a-z0-9-]+$') { throw 'Choose a disposable Linux lab notebook.' } +if ($env:COMPUTERNAME -ne $CloneHost) { throw 'The collaboration controller requires its owned clone.' } +if (-not $ResumeCache) { & "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost } +$shared = $SharedPath +if (-not (Test-Path "$shared\synthetic.one")) { throw 'Create the shared fixture first.' } +New-Item -ItemType Directory "$Root\inbox", "$Root\outbox" -Force | Out-Null +$user = 'HKCU:\Software\Microsoft\Office\Common\UserInfo' +New-Item $user -Force | Out-Null +New-ItemProperty $user -Name UserName -Value $CloneHost -PropertyType String -Force | Out-Null +New-ItemProperty $user -Name UserInitials -Value $CloneHost.Substring($CloneHost.Length - 3) -PropertyType String -Force | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebook = '' +try { + $app.OpenHierarchy($shared, '', [ref]$notebook, 0) + $section = '' + $app.OpenHierarchy('synthetic.one', $notebook, [ref]$section, 0) + [IO.File]::WriteAllText("$Root\ready", 'ready') + $deadline = [DateTime]::UtcNow.AddMinutes(30) + $sequence = $StartSequence + while ([DateTime]::UtcNow -lt $deadline) { + $name = '{0:d4}.json' -f $sequence + $input = "$Root\inbox\$name" + if (-not (Test-Path $input)) { Start-Sleep -Milliseconds 100; continue } + $command = Get-Content -Raw -Encoding UTF8 $input | ConvertFrom-Json + $output = "$Root\outbox\$sequence" + New-Item -ItemType Directory $output | Out-Null + try { + if ($command.action -in @('prepare-stress', 'stress')) { + . "$PSScriptRoot\stress.ps1" + Invoke-Stress $app $section $command $output $shared + } + elseif ($command.action -eq 'sync') { $app.SyncHierarchy($notebook) } + elseif ($command.action -in @('pages', 'move', 'delete-page')) { + $namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$tree = $hierarchy + $listed = @($tree.SelectNodes('//*[local-name()="Page"]')) + $named = { param($title) @($listed | Where-Object { $_.GetAttribute('name') -eq $title }) } + if ($command.action -eq 'pages') { + # New titled pages with one body paragraph each, appended in order. + foreach ($title in $command.titles) { + $page = '' + $app.CreateNewPage($section, [ref]$page, 2) + $body = [Security.SecurityElement]::Escape('Body ' + $title + '.') + $name = [Security.SecurityElement]::Escape($title) + $xml = '' + $name + '' + $body + '' + $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false) + } + } elseif ($command.action -eq 'move') { + # Moves the page titled `page` before the one titled `before`, or last. + $moved = @(& $named $command.page) + if ($moved.Count -ne 1) { throw 'Expected one page to move.' } + $order = [Collections.ArrayList]@($listed | Where-Object { $_ -ne $moved[0] }) + $at = $order.Count + if ($command.before) { + $anchor = @(& $named $command.before) + if ($anchor.Count -ne 1) { throw 'Expected one anchor page.' } + $at = $order.IndexOf($anchor[0]) + } + $order.Insert($at, $moved[0]) + $xml = '' + foreach ($node in $order) { + $xml += '' + } + $app.UpdateHierarchy($xml + '', 1) + } else { + $doomed = @(& $named $command.page) + if ($doomed.Count -ne 1) { throw 'Expected one page to delete.' } + $app.DeleteHierarchy($doomed[0].GetAttribute('ID'), [DateTime]::MinValue, $false) + } + } + elseif ($command.action -eq 'kill-process') { + Get-Process ONENOTE | Stop-Process -Force + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + $app = New-Object -ComObject OneNote.Application + $app.OpenHierarchy($shared, '', [ref]$notebook, 0) + $app.OpenHierarchy('synthetic.one', $notebook, [ref]$section, 0) + } + elseif ($command.action -eq 'edit' -or $command.action -eq 'snapshot') { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText("$output\hierarchy.xml", $hierarchy, [Text.Encoding]::UTF8) + [xml]$tree = $hierarchy + $matches = 0 + $editedPage = $null + $i = 0 + foreach ($node in $tree.SelectNodes('//*[local-name()="Page"]')) { + $content = '' + $app.GetPageContent($node.GetAttribute('ID'), [ref]$content, 1, 1) + [IO.File]::WriteAllText("$output\page-$i.xml", $content, [Text.Encoding]::UTF8) + [xml]$page = $content + if ($command.action -eq 'edit') { + $targets = @($page.SelectNodes('//*[local-name()="T"]') | Where-Object { + (Get-ParagraphText $_.InnerText) -eq $command.expected + }) + if ($targets.Count -gt 1) { throw 'Expected one matching text object.' } + if ($targets.Count -eq 1) { + $matches++ + if ($matches -gt 1) { throw 'The text occurs on multiple pages.' } + $targets[0].InnerText = [Security.SecurityElement]::Escape($command.text) + Keep-Container $page $targets[0] + $editedPage = $page + } + } + $i++ + } + if ($command.action -eq 'edit' -and $matches -ne 1) { throw 'Expected one matching text object.' } + if ($editedPage) { $app.UpdatePageContent($editedPage.OuterXml, [DateTime]::MinValue, 1, $false) } + } elseif ($command.action -ne 'close') { throw 'Unknown collaboration command.' } + [IO.File]::WriteAllText("$output\done", 'ok') + } catch { + [IO.File]::WriteAllText("$output\error", $_.Exception.ToString()) + } + if ($command.action -eq 'close') { break } + $sequence++ + } +} finally { + try { if ($notebook) { $app.CloseNotebook($notebook, $false) } } + finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + [IO.File]::WriteAllText("$Root\closed", 'closed') + } +} diff --git a/corpus/conflict-page/native-pages/scripts/network.ps1 b/corpus/conflict-page/native-pages/scripts/network.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..95fa83d5243ee6a2a653cc075128b063e2f09cf2 --- /dev/null +++ b/corpus/conflict-page/native-pages/scripts/network.ps1 @@ -0,0 +1,20 @@ +param([Parameter(Mandatory=$true)][string]$LabMac) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$adapter = @(Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.MACAddress -eq $LabMac }) +if ($adapter.Count -ne 1) { throw 'The clone lab adapter is ambiguous.' } +$enabled = $adapter[0].EnableDHCP() +if ($enabled.ReturnValue -notin @(0, 1)) { + throw ('Unable to enable DHCP on the clone lab adapter; result ' + $enabled.ReturnValue) +} +$release = $adapter[0].ReleaseDHCPLease() +$result = $adapter[0].RenewDHCPLease() +$deadline = [DateTime]::UtcNow.AddSeconds(60) +do { + $adapter = @(Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.MACAddress -eq $LabMac }) + $addresses = @($adapter[0].IPAddress | Where-Object { $_ -match '^192\.168\.77\.\d+$' -and $_ -ne '192.168.77.1' }) + if ($addresses.Count -eq 1) { break } + Start-Sleep -Milliseconds 250 +} while ([DateTime]::UtcNow -lt $deadline) +if ($addresses.Count -ne 1) { throw ('The clone did not receive a lab IPv4 address; renewal result ' + $result.ReturnValue + '; adapter ' + ($adapter[0] | Select-Object MACAddress,DHCPEnabled,IPEnabled,IPAddress | ConvertTo-Json -Compress)) } +@{mac=$LabMac;address=$addresses[0];releaseResult=$release.ReturnValue;renewalResult=$result.ReturnValue} | ConvertTo-Json -Compress diff --git a/corpus/conflict-page/native-pages/scripts/stress.ps1 b/corpus/conflict-page/native-pages/scripts/stress.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0ab5bc69b4dabe970373e481873dfd951a1a69a --- /dev/null +++ b/corpus/conflict-page/native-pages/scripts/stress.ps1 @@ -0,0 +1,83 @@ +function Invoke-Stress($app, $section, $command, $output, $shared) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText("$output\hierarchy.xml", $hierarchy, [Text.Encoding]::UTF8) + [xml]$tree = $hierarchy + $pages = @($tree.SelectNodes('//*[local-name()="Page"]')) + if ($pages.Count -ne 1) { throw 'Expected one stress-test page.' } + $pageId = $pages[0].GetAttribute('ID') + if ($command.action -eq 'prepare-stress') { + for ($i = 0; $i -lt $command.clients; $i++) { + $xml = '' + $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false) + } + return + } + $random = New-Object Random([int]$command.seed) + $events = New-Object IO.StreamWriter("$output\events.jsonl", $false, [Text.Encoding]::UTF8) + $events.AutoFlush = $true + try { + [IO.File]::WriteAllText("$output\ready", 'ready') + $deadline = [DateTime]::UtcNow.AddMinutes(10) + while (-not (Test-Path "$shared\stress-start")) { + if ([DateTime]::UtcNow -gt $deadline) { throw 'Stress start timed out.' } + Start-Sleep -Milliseconds 20 + } + for ($i = 0; $i -lt $command.operations; $i++) { + if (($command.PSObject.Properties.Name -contains 'maintenance') -and $command.maintenance -and $i -eq [Math]::Floor($command.operations / 2)) { + [IO.File]::WriteAllText("$shared\maintenance-paused-n$($command.actor)", 'paused') + while (-not (Test-Path "$shared\maintenance-resume")) { + if ([DateTime]::UtcNow -gt $deadline) { throw 'Maintenance pause timed out.' } + Start-Sleep -Milliseconds 100 + } + } + Start-Sleep -Milliseconds $random.Next(10, 100) + $started = [DateTime]::UtcNow.Ticks + $content = '' + $app.GetPageContent($pageId, [ref]$content, 1, 1) + [xml]$page = $content + $matches = @($page.SelectNodes('//*[local-name()="T"]') | Where-Object { + (Get-ParagraphText $_.InnerText).StartsWith($command.prefix) + }) + if ($matches.Count -ne 1) { throw 'Expected one stress-test paragraph.' } + $before = Get-ParagraphText $matches[0].InnerText + $token = ' [n' + $command.actor + ':' + $i + ']' + $start = $end = $before.Length + $replacement = $token + $bold = $italic = $false + if ($command.edit) { + $boundaries = New-Object 'Collections.Generic.List[int]' + for ($at = $command.prefix.Length; $at -le $before.Length; $at++) { + $boundaries.Add($at) + if ($at -lt $before.Length -and [char]::IsHighSurrogate($before[$at])) { $at++ } + } + $first = $boundaries[$random.Next($boundaries.Count)] + $second = $boundaries[$random.Next($boundaries.Count)] + $start = [Math]::Min($first, $second) + $end = [Math]::Max($first, $second) + $replacement = ' caf' + [char]0xe9 + ' ' + [char]::ConvertFromUtf32(0x1f980) + $token + $bold = $random.Next(2) -eq 1 + $italic = $random.Next(2) -eq 1 + } + $after = $before.Substring(0, $start) + $replacement + $before.Substring($end) + $html = [Security.SecurityElement]::Escape($after) + if ($command.edit) { + $weight = if ($bold) { 'bold' } else { 'normal' } + $style = if ($italic) { 'italic' } else { 'normal' } + $html = '' + $html + '' + } + $matches[0].InnerText = $html + Keep-Container $page $matches[0] + $updateStarted = [DateTime]::UtcNow.Ticks + $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false) + $updated = [DateTime]::UtcNow.Ticks + $events.WriteLine((@{ operation=$i; before=$before; token=$token; started_ticks=$started; + range=@($start, $end); replacement=$replacement; bold=$bold; italic=$italic; + update_started_ticks=$updateStarted; updated_ticks=$updated } | ConvertTo-Json -Compress)) + if ($i -eq 0) { [IO.File]::WriteAllText("$output\editing", 'editing') } + if ($command.sync_every -gt 0 -and (($i + 1) % $command.sync_every) -eq 0) { + $app.SyncHierarchy($section) + } + } + } finally { $events.Dispose() } +} diff --git a/corpus/conflict-page/native-pages/scripts/text.ps1 b/corpus/conflict-page/native-pages/scripts/text.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..75e9e71207b599d16eaaf68824ca30831860610a --- /dev/null +++ b/corpus/conflict-page/native-pages/scripts/text.ps1 @@ -0,0 +1,13 @@ +function Get-ParagraphText([string]$html) { + $plain = [regex]::Replace($html, '<[^>]*>', '') + $plain = [regex]::Replace($plain, '&#(?:x([0-9a-fA-F]+)|([0-9]+));', { + param($match) + $value = if ($match.Groups[1].Success) { + [Convert]::ToInt32($match.Groups[1].Value, 16) + } else { [int]$match.Groups[2].Value } + # The Win7 framework leaves supplementary numeric entities undecoded. + if ($value -gt 0xffff -and $value -le 0x10ffff) { [char]::ConvertFromUtf32($value) } + else { $match.Value } + }) + [Net.WebUtility]::HtmlDecode($plain) +} diff --git a/corpus/conflict-page/native-pages/server.jsonl b/corpus/conflict-page/native-pages/server.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..d5df993564a30ddaea1b427699648ff9b1a1417d --- /dev/null +++ b/corpus/conflict-page/native-pages/server.jsonl @@ -0,0 +1,2 @@ +{"command": "mkdir /srv/agent/m6-conflict", "exit": 0, "stdout": "", "stderr": ""} +{"command": "chmod u+w /srv/agent/m6-conflict/*", "exit": 0, "stdout": "", "stderr": ""} diff --git a/corpus/conflict-page/native-restore/a/a-merged.json b/corpus/conflict-page/native-restore/a/a-merged.json new file mode 100644 index 0000000000000000000000000000000000000000..2f394e7eaf1defcfe3e06e321b5af76be4f84054 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/a-merged.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Fictitious: caf\u00e9, \u6771\u4eac, \u0645\u0631\u062d\u0628\u0627 - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/a-merged.png b/corpus/conflict-page/native-restore/a/a-merged.png new file mode 100644 index 0000000000000000000000000000000000000000..e1de0c6ef0b01ae79888f9cbead1ac135d60d788 Binary files /dev/null and b/corpus/conflict-page/native-restore/a/a-merged.png differ diff --git a/corpus/conflict-page/native-restore/a/command-0001.json b/corpus/conflict-page/native-restore/a/command-0001.json new file mode 100644 index 0000000000000000000000000000000000000000..e473ce8f2c61fdf632e44de1f4a834d605faaece --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0001.json @@ -0,0 +1 @@ +{"action": "pages", "titles": ["One", "Two", "Target", "Three", "Four"]} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0002.json b/corpus/conflict-page/native-restore/a/command-0002.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0002.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0003.json b/corpus/conflict-page/native-restore/a/command-0003.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0003.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0004.json b/corpus/conflict-page/native-restore/a/command-0004.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0004.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0005.json b/corpus/conflict-page/native-restore/a/command-0005.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0005.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0006.json b/corpus/conflict-page/native-restore/a/command-0006.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0006.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0007.json b/corpus/conflict-page/native-restore/a/command-0007.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0007.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0008.json b/corpus/conflict-page/native-restore/a/command-0008.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0008.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0009.json b/corpus/conflict-page/native-restore/a/command-0009.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0009.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0010.json b/corpus/conflict-page/native-restore/a/command-0010.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0010.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0011.json b/corpus/conflict-page/native-restore/a/command-0011.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0011.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0012.json b/corpus/conflict-page/native-restore/a/command-0012.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0012.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0013.json b/corpus/conflict-page/native-restore/a/command-0013.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0013.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0014.json b/corpus/conflict-page/native-restore/a/command-0014.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0014.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0015.json b/corpus/conflict-page/native-restore/a/command-0015.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0015.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0016.json b/corpus/conflict-page/native-restore/a/command-0016.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0016.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0017.json b/corpus/conflict-page/native-restore/a/command-0017.json new file mode 100644 index 0000000000000000000000000000000000000000..3f6f2524a6523bec79f8152f3340d2ae649729a0 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0017.json @@ -0,0 +1 @@ +{"action": "edit", "expected": "Body Target.", "text": "Body Target edited offline."} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0018.json b/corpus/conflict-page/native-restore/a/command-0018.json new file mode 100644 index 0000000000000000000000000000000000000000..8bd5839cfa379b0117fb5154acd9d20bc06b5cda --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0018.json @@ -0,0 +1 @@ +{"action": "move", "page": "Four", "before": "One"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0019.json b/corpus/conflict-page/native-restore/a/command-0019.json new file mode 100644 index 0000000000000000000000000000000000000000..554ff0ef8a35478d74f3e5388f75789140b158b8 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0019.json @@ -0,0 +1 @@ +{"action": "snapshot"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0020.json b/corpus/conflict-page/native-restore/a/command-0020.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0020.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0021.json b/corpus/conflict-page/native-restore/a/command-0021.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0021.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0022.json b/corpus/conflict-page/native-restore/a/command-0022.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0022.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0023.json b/corpus/conflict-page/native-restore/a/command-0023.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0023.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0024.json b/corpus/conflict-page/native-restore/a/command-0024.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0024.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0025.json b/corpus/conflict-page/native-restore/a/command-0025.json new file mode 100644 index 0000000000000000000000000000000000000000..554ff0ef8a35478d74f3e5388f75789140b158b8 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0025.json @@ -0,0 +1 @@ +{"action": "snapshot"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/command-0026.json b/corpus/conflict-page/native-restore/a/command-0026.json new file mode 100644 index 0000000000000000000000000000000000000000..7adf980027ef43cc77767d4fd1b1d67ad1541ab4 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/command-0026.json @@ -0,0 +1 @@ +{"action": "close"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/commands.jsonl b/corpus/conflict-page/native-restore/a/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..636ed5f31cb52c0cd0e9552fa99ebf892dc44dd1 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/commands.jsonl @@ -0,0 +1,28 @@ +{"command": "mkdir C:\\one-tests\\runs\\capture", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac 52:54:02:b6:9a:97", "exit": 0, "stdout": "{\"renewalResult\":0,\"releaseResult\":0,\"mac\":\"52:54:02:b6:9a:97\",\"address\":\"192.168.77.119\"}\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0001.tmp C:\\one-tests\\runs\\capture\\inbox\\0001.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0002.tmp C:\\one-tests\\runs\\capture\\inbox\\0002.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0003.tmp C:\\one-tests\\runs\\capture\\inbox\\0003.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0004.tmp C:\\one-tests\\runs\\capture\\inbox\\0004.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0005.tmp C:\\one-tests\\runs\\capture\\inbox\\0005.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0006.tmp C:\\one-tests\\runs\\capture\\inbox\\0006.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0007.tmp C:\\one-tests\\runs\\capture\\inbox\\0007.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0008.tmp C:\\one-tests\\runs\\capture\\inbox\\0008.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0009.tmp C:\\one-tests\\runs\\capture\\inbox\\0009.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0010.tmp C:\\one-tests\\runs\\capture\\inbox\\0010.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0011.tmp C:\\one-tests\\runs\\capture\\inbox\\0011.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0012.tmp C:\\one-tests\\runs\\capture\\inbox\\0012.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0013.tmp C:\\one-tests\\runs\\capture\\inbox\\0013.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0014.tmp C:\\one-tests\\runs\\capture\\inbox\\0014.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0015.tmp C:\\one-tests\\runs\\capture\\inbox\\0015.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0016.tmp C:\\one-tests\\runs\\capture\\inbox\\0016.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0017.tmp C:\\one-tests\\runs\\capture\\inbox\\0017.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0018.tmp C:\\one-tests\\runs\\capture\\inbox\\0018.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0019.tmp C:\\one-tests\\runs\\capture\\inbox\\0019.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0020.tmp C:\\one-tests\\runs\\capture\\inbox\\0020.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0021.tmp C:\\one-tests\\runs\\capture\\inbox\\0021.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0022.tmp C:\\one-tests\\runs\\capture\\inbox\\0022.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0023.tmp C:\\one-tests\\runs\\capture\\inbox\\0023.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0024.tmp C:\\one-tests\\runs\\capture\\inbox\\0024.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0025.tmp C:\\one-tests\\runs\\capture\\inbox\\0025.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0026.tmp C:\\one-tests\\runs\\capture\\inbox\\0026.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} diff --git a/corpus/conflict-page/native-restore/a/hierarchy-0019.xml b/corpus/conflict-page/native-restore/a/hierarchy-0019.xml new file mode 100644 index 0000000000000000000000000000000000000000..9d33204b027aaa0d60fe105c9a70d960b28be1bb --- /dev/null +++ b/corpus/conflict-page/native-restore/a/hierarchy-0019.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-restore/a/hierarchy-0025.xml b/corpus/conflict-page/native-restore/a/hierarchy-0025.xml new file mode 100644 index 0000000000000000000000000000000000000000..11e06fe95607fb8908a394804a20cbb4d93601b0 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/hierarchy-0025.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-restore/a/listed.json b/corpus/conflict-page/native-restore/a/listed.json new file mode 100644 index 0000000000000000000000000000000000000000..559836710a7579ea1e2c4dd98dff086a1d6bf2b3 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/listed.json @@ -0,0 +1 @@ +["Fictitious: café, 東京, مرحبا", "Four", "One", "Two", "Target", "Three"] \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/a/machine.json b/corpus/conflict-page/native-restore/a/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..084bcf445566e4a984e4a5465bf8a0ceabf53384 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/machine.json @@ -0,0 +1 @@ +{"name": "m6-d281b38e", "hostname": "ONE-M6-D281B38E"} diff --git a/corpus/conflict-page/native-restore/a/teardown.json b/corpus/conflict-page/native-restore/a/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/conflict-page/native-restore/a/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/conflict-page/native-restore/b-published/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one b/corpus/conflict-page/native-restore/b-published/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one new file mode 100644 index 0000000000000000000000000000000000000000..753afa4aa521f599083936966e3e96226937d4e6 Binary files /dev/null and b/corpus/conflict-page/native-restore/b-published/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one differ diff --git a/corpus/conflict-page/native-restore/b-published/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 b/corpus/conflict-page/native-restore/b-published/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..810bfcc34d55bf600d42219614d17db63df60ef9 Binary files /dev/null and b/corpus/conflict-page/native-restore/b-published/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native-restore/b-published/notebook/Open Notebook.onetoc2 b/corpus/conflict-page/native-restore/b-published/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..b49e1ce4cfca70df24a2af81ac03d97caca48955 Binary files /dev/null and b/corpus/conflict-page/native-restore/b-published/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native-restore/b-published/notebook/synthetic.one b/corpus/conflict-page/native-restore/b-published/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..7f97f031472ec657a2563be65149f4de16f5466e Binary files /dev/null and b/corpus/conflict-page/native-restore/b-published/notebook/synthetic.one differ diff --git a/corpus/conflict-page/native-restore/b/b-merged.json b/corpus/conflict-page/native-restore/b/b-merged.json new file mode 100644 index 0000000000000000000000000000000000000000..2f394e7eaf1defcfe3e06e321b5af76be4f84054 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/b-merged.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Fictitious: caf\u00e9, \u6771\u4eac, \u0645\u0631\u062d\u0628\u0627 - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/b-merged.png b/corpus/conflict-page/native-restore/b/b-merged.png new file mode 100644 index 0000000000000000000000000000000000000000..f2c8f6396508393f4488c326a9d2d6959a14abae Binary files /dev/null and b/corpus/conflict-page/native-restore/b/b-merged.png differ diff --git a/corpus/conflict-page/native-restore/b/command-0001.json b/corpus/conflict-page/native-restore/b/command-0001.json new file mode 100644 index 0000000000000000000000000000000000000000..554ff0ef8a35478d74f3e5388f75789140b158b8 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0001.json @@ -0,0 +1 @@ +{"action": "snapshot"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/command-0002.json b/corpus/conflict-page/native-restore/b/command-0002.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0002.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/command-0003.json b/corpus/conflict-page/native-restore/b/command-0003.json new file mode 100644 index 0000000000000000000000000000000000000000..554ff0ef8a35478d74f3e5388f75789140b158b8 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0003.json @@ -0,0 +1 @@ +{"action": "snapshot"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/command-0004.json b/corpus/conflict-page/native-restore/b/command-0004.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0004.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/command-0005.json b/corpus/conflict-page/native-restore/b/command-0005.json new file mode 100644 index 0000000000000000000000000000000000000000..554ff0ef8a35478d74f3e5388f75789140b158b8 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0005.json @@ -0,0 +1 @@ +{"action": "snapshot"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/command-0006.json b/corpus/conflict-page/native-restore/b/command-0006.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0006.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/command-0007.json b/corpus/conflict-page/native-restore/b/command-0007.json new file mode 100644 index 0000000000000000000000000000000000000000..554ff0ef8a35478d74f3e5388f75789140b158b8 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0007.json @@ -0,0 +1 @@ +{"action": "snapshot"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/command-0008.json b/corpus/conflict-page/native-restore/b/command-0008.json new file mode 100644 index 0000000000000000000000000000000000000000..eff482c021828f4924fb0845aa6043a8312745ea --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0008.json @@ -0,0 +1 @@ +{"action": "delete-page", "page": "Target"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/command-0009.json b/corpus/conflict-page/native-restore/b/command-0009.json new file mode 100644 index 0000000000000000000000000000000000000000..554ff0ef8a35478d74f3e5388f75789140b158b8 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0009.json @@ -0,0 +1 @@ +{"action": "snapshot"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/command-0010.json b/corpus/conflict-page/native-restore/b/command-0010.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0010.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/command-0011.json b/corpus/conflict-page/native-restore/b/command-0011.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0011.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/command-0012.json b/corpus/conflict-page/native-restore/b/command-0012.json new file mode 100644 index 0000000000000000000000000000000000000000..2272087176d433cd1d42b77fb18efd145b9bbbf5 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0012.json @@ -0,0 +1 @@ +{"action": "sync"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/command-0013.json b/corpus/conflict-page/native-restore/b/command-0013.json new file mode 100644 index 0000000000000000000000000000000000000000..554ff0ef8a35478d74f3e5388f75789140b158b8 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0013.json @@ -0,0 +1 @@ +{"action": "snapshot"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/command-0014.json b/corpus/conflict-page/native-restore/b/command-0014.json new file mode 100644 index 0000000000000000000000000000000000000000..7adf980027ef43cc77767d4fd1b1d67ad1541ab4 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/command-0014.json @@ -0,0 +1 @@ +{"action": "close"} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/commands.jsonl b/corpus/conflict-page/native-restore/b/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..2335dad6840245c83e266c7e5d6601001755f2d0 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/commands.jsonl @@ -0,0 +1,16 @@ +{"command": "mkdir C:\\one-tests\\runs\\capture", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac 52:54:02:7b:1a:4d", "exit": 0, "stdout": "{\"renewalResult\":0,\"releaseResult\":0,\"mac\":\"52:54:02:7b:1a:4d\",\"address\":\"192.168.77.40\"}\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0001.tmp C:\\one-tests\\runs\\capture\\inbox\\0001.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0002.tmp C:\\one-tests\\runs\\capture\\inbox\\0002.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0003.tmp C:\\one-tests\\runs\\capture\\inbox\\0003.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0004.tmp C:\\one-tests\\runs\\capture\\inbox\\0004.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0005.tmp C:\\one-tests\\runs\\capture\\inbox\\0005.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0006.tmp C:\\one-tests\\runs\\capture\\inbox\\0006.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0007.tmp C:\\one-tests\\runs\\capture\\inbox\\0007.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0008.tmp C:\\one-tests\\runs\\capture\\inbox\\0008.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0009.tmp C:\\one-tests\\runs\\capture\\inbox\\0009.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0010.tmp C:\\one-tests\\runs\\capture\\inbox\\0010.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0011.tmp C:\\one-tests\\runs\\capture\\inbox\\0011.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0012.tmp C:\\one-tests\\runs\\capture\\inbox\\0012.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0013.tmp C:\\one-tests\\runs\\capture\\inbox\\0013.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0014.tmp C:\\one-tests\\runs\\capture\\inbox\\0014.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} diff --git a/corpus/conflict-page/native-restore/b/hierarchy-0001.xml b/corpus/conflict-page/native-restore/b/hierarchy-0001.xml new file mode 100644 index 0000000000000000000000000000000000000000..831d1d427c0ef26e4d79ed1c8f185ea22c942068 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/hierarchy-0001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-restore/b/hierarchy-0003.xml b/corpus/conflict-page/native-restore/b/hierarchy-0003.xml new file mode 100644 index 0000000000000000000000000000000000000000..18798800a4f9f74d5296ac80adbf8348f81d6f43 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/hierarchy-0003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-restore/b/hierarchy-0005.xml b/corpus/conflict-page/native-restore/b/hierarchy-0005.xml new file mode 100644 index 0000000000000000000000000000000000000000..5c16c8efeebe230903ed784409d50d7c039c87fd --- /dev/null +++ b/corpus/conflict-page/native-restore/b/hierarchy-0005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-restore/b/hierarchy-0007.xml b/corpus/conflict-page/native-restore/b/hierarchy-0007.xml new file mode 100644 index 0000000000000000000000000000000000000000..2a44e1e49093e9f699bfa6eaa4821e4d3c1de3f8 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/hierarchy-0007.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-restore/b/hierarchy-0009.xml b/corpus/conflict-page/native-restore/b/hierarchy-0009.xml new file mode 100644 index 0000000000000000000000000000000000000000..e5aeaf4af2852eaea5d8f51cd321eb61baf23cfb --- /dev/null +++ b/corpus/conflict-page/native-restore/b/hierarchy-0009.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-restore/b/hierarchy-0013.xml b/corpus/conflict-page/native-restore/b/hierarchy-0013.xml new file mode 100644 index 0000000000000000000000000000000000000000..dd73cb16e07cd7840fbd0e3ed63ad9d52459e1e1 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/hierarchy-0013.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/conflict-page/native-restore/b/listed.json b/corpus/conflict-page/native-restore/b/listed.json new file mode 100644 index 0000000000000000000000000000000000000000..559836710a7579ea1e2c4dd98dff086a1d6bf2b3 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/listed.json @@ -0,0 +1 @@ +["Fictitious: café, 東京, مرحبا", "Four", "One", "Two", "Target", "Three"] \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/b/machine.json b/corpus/conflict-page/native-restore/b/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..54a3c6b9ac2277847928cb46fb3d062726a4070a --- /dev/null +++ b/corpus/conflict-page/native-restore/b/machine.json @@ -0,0 +1 @@ +{"name": "m6-a205cc61", "hostname": "ONE-M6-A205CC61"} diff --git a/corpus/conflict-page/native-restore/b/teardown.json b/corpus/conflict-page/native-restore/b/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/conflict-page/native-restore/b/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/conflict-page/native-restore/clients.json b/corpus/conflict-page/native-restore/clients.json new file mode 100644 index 0000000000000000000000000000000000000000..0c922a99bf3f3f13b0eb03af843301a714be0c4c --- /dev/null +++ b/corpus/conflict-page/native-restore/clients.json @@ -0,0 +1,4 @@ +{ + "a": "m6-d281b38e", + "b": "m6-a205cc61" +} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/initial/notebook/Open Notebook.onetoc2 b/corpus/conflict-page/native-restore/initial/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..c7beb44bf509f935090fe55e7549e4b50e0ae0e9 Binary files /dev/null and b/corpus/conflict-page/native-restore/initial/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native-restore/initial/notebook/synthetic.one b/corpus/conflict-page/native-restore/initial/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..058b8cd4a335818630c0305bf44d2ba394abd270 Binary files /dev/null and b/corpus/conflict-page/native-restore/initial/notebook/synthetic.one differ diff --git a/corpus/conflict-page/native-restore/linux-teardown.json b/corpus/conflict-page/native-restore/linux-teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/conflict-page/native-restore/linux-teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/conflict-page/native-restore/linux.json b/corpus/conflict-page/native-restore/linux.json new file mode 100644 index 0000000000000000000000000000000000000000..51be344e66ab7a2c56327e2ce949f3785fedee54 --- /dev/null +++ b/corpus/conflict-page/native-restore/linux.json @@ -0,0 +1,11 @@ +{ + "cpus": 2, + "disk_gb": 16, + "hostname": "one-conflict3", + "lab_address": "192.168.77.1", + "lab_mac": "52:54:04:08:6d:03", + "memory_mb": 2048, + "samba_port": 14450, + "ssh_port": 22000, + "wan_mac": "52:54:03:bb:9c:87" +} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/merged.json b/corpus/conflict-page/native-restore/merged.json new file mode 100644 index 0000000000000000000000000000000000000000..889578b037833da0bb993bcb332e6377ca5fbb09 --- /dev/null +++ b/corpus/conflict-page/native-restore/merged.json @@ -0,0 +1,49 @@ +{ + "order": [ + "", + "Four", + "One", + "Two", + "Target", + "Three" + ], + "pages": [ + [ + "", + "Fictitious positioned outline.", + "Fictitious: café, 東京, مرحبا", + "Left cell", + "Right cell" + ], + [ + "2:55 AM", + "Body Four.", + "Four", + "Sunday, September 27, 2026" + ], + [ + "2:55 AM", + "Body One.", + "One", + "Sunday, September 27, 2026" + ], + [ + "2:55 AM", + "Body Two.", + "Sunday, September 27, 2026", + "Two" + ], + [ + "2:55 AM", + "Body Target edited offline.", + "Sunday, September 27, 2026", + "Target" + ], + [ + "2:55 AM", + "Body Three.", + "Sunday, September 27, 2026", + "Three" + ] + ] +} \ No newline at end of file diff --git a/corpus/conflict-page/native-restore/merged/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one b/corpus/conflict-page/native-restore/merged/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one new file mode 100644 index 0000000000000000000000000000000000000000..d58aedc6e4ba0cee7324ec43ffa4997e89ac864a Binary files /dev/null and b/corpus/conflict-page/native-restore/merged/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one differ diff --git a/corpus/conflict-page/native-restore/merged/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 b/corpus/conflict-page/native-restore/merged/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..810bfcc34d55bf600d42219614d17db63df60ef9 Binary files /dev/null and b/corpus/conflict-page/native-restore/merged/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native-restore/merged/notebook/Open Notebook.onetoc2 b/corpus/conflict-page/native-restore/merged/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..b49e1ce4cfca70df24a2af81ac03d97caca48955 Binary files /dev/null and b/corpus/conflict-page/native-restore/merged/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native-restore/merged/notebook/synthetic.one b/corpus/conflict-page/native-restore/merged/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..82effac1ffff4519beccb2392a513f3985fa7916 Binary files /dev/null and b/corpus/conflict-page/native-restore/merged/notebook/synthetic.one differ diff --git a/corpus/conflict-page/native-restore/native_conflict.py b/corpus/conflict-page/native-restore/native_conflict.py new file mode 100644 index 0000000000000000000000000000000000000000..ccb4026acf69f3df84fdfbb01e9b6e39461762d3 --- /dev/null +++ b/corpus/conflict-page/native-restore/native_conflict.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""OneNote 2010 conflict pages: how they arise, what they store and how OneNote shows them. + +`sync LINUX OUTPUT` runs two disposable OneNote clients on one Samba notebook: client A edits +two paragraphs offline while client B edits one of them and a table cell online; A then +reconnects, which makes OneNote write a conflict page. The server file is kept before and +after, A and B are screenshotted with the information bar, the page list shown, the conflict +page open and its menu open, and A then deletes the conflict page through that menu. + +`pages LINUX OUTPUT` runs the same two clients on page-list changes: client A, offline, moves +a page that client B also moves, moves another page, and edits a page B deletes; A then +reconnects. The server's page order and pages, with the recycle bin, are kept at each step. + +`restore LINUX OUTPUT` runs them on a page in the middle of the list: client A, offline, edits +it and moves a page B leaves alone while B deletes it; A then reconnects. + +`cold NOTEBOOK OUTPUT [--delete]` cold-opens a notebook holding a conflict page in a fresh +clone and screenshots the same views; `--delete` deletes the conflict page through the menu +and captures the notebook OneNote leaves. +""" +import argparse +from contextlib import ExitStack +from concurrent.futures import ThreadPoolExecutor +import base64 +import json +from pathlib import Path +import shutil +import subprocess +import sys +import time +import xml.etree.ElementTree as ET + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent / 'w7')) +import native_runner as runner +from native_runner import ROOT, clone, command, windows, vm +import linux_vm + +FIXTURE = ROOT / 'corpus/native-ink/cold-ui-ink/notebook' +SHARE = 'm6-conflict' +ORIGINAL = 'Fictitious: café, 東京, مرحبا' +OTHER_OUTLINE = 'Fictitious positioned outline.' +CELL = 'Left cell' + +# Coordinates on OneNote 2010 maximized at 800x600, from the stage-5, m8 and sync-01 captures: +# the information bar's text, and the first conflict page listed under its page. +BAR = (332, 100) +FIRST_CONFLICT = (725, 142) +# Delete Conflict Page, first in the menu the bar opens on a conflict page. +DELETE = (420, 113) + + +def shot(name, output, label, script): + """Runs an AutoHotkey step on the clone, keeping its screenshot as `label.png`.""" + (output / f'{label}.ahk').write_text(script) + result = windows.do_exec(script, target=name, timeout_ms=120000, shot_delay_ms=1500) + screenshot = result.pop('png_b64', None) + if screenshot: + (output / f'{label}.png').write_bytes(base64.b64decode(screenshot)) + (output / f'{label}.json').write_text(json.dumps(result, indent=2)) + if result.get('error') or result.get('exit') != 0: + raise RuntimeError(f'{label}: {result}') + return result + + +def click(x, y): + return ('WinActivate("ahk_exe ONENOTE.EXE")\nCoordMode("Mouse", "Screen")\n' + f'Click({x}, {y})\nSleep(1200)\n') + + +# Reports any dialog OneNote raised, so a confirmation shows in the step's record. +DIALOG = ('if WinExist("ahk_class #32770 ahk_exe ONENOTE.EXE") {\n' + ' FileAppend("dialog: " WinGetTitle() " | " WinGetText() "`n", "*", "UTF-8")\n' + '}\n') + + +def conflict_views(name, output, page, prefix, delete=False): + """Screenshots a page with a conflict page: the bar, the list shown, the conflict page and + its menu; `delete` then chooses Delete Conflict Page.""" + shot(name, output, f'{prefix}-main', runner.navigation_script(page) + 'Sleep(2000)\n') + shot(name, output, f'{prefix}-shown', click(*BAR)) + shot(name, output, f'{prefix}-conflict', click(*FIRST_CONFLICT)) + shot(name, output, f'{prefix}-menu', click(*BAR)) + if delete: + shot(name, output, f'{prefix}-delete', click(*DELETE) + DIALOG) + else: + shot(name, output, f'{prefix}-closed', 'WinActivate("ahk_exe ONENOTE.EXE")\nSend("{Esc}")\nSleep(800)\n') + + +def cold(notebook, output, delete): + def interaction(name, output): + page = ET.parse(sorted((output / 'read').glob('page-*.xml'))[0]).getroot().attrib['ID'] + conflict_views(name, output, page, 'cold', delete) + if delete: + # OneNote writes the deletion in the background; give it time before the re-read. + time.sleep(20) + runner.capture(notebook, output, collect_notebook=True, interaction=interaction) + + +class Lab: + """Two OneNote clients on one Samba notebook: `action` runs a controller command, + `state` copies the share and exports its section, `settle` syncs until a check holds.""" + + +def page_list(model): + """The section's pages in order, each as its sorted texts.""" + from document_model import ordered_pages, walk + return [sorted(node['kind']['text'] for _, node in walk(page, oid) if node['kind']['type'] == 'RichText') + for _, _, page, oid in ordered_pages(model)] + + +def edits(lab): + from native_collaboration import reachable_page_text + a, b, output, action, settle = lab.a, lab.b, lab.output, lab.action, lab.settle + state = lambda label: reachable_page_text(lab.state(label)) + texts = lambda state: {text for values in state.values() for text in values} + state('initial') + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': False}) + try: + action(a, 'edit', expected=ORIGINAL, text='Client A conflicting edit.') + action(a, 'edit', expected=OTHER_OUTLINE, text='Client A disjoint edit.') + action(a, 'snapshot') + action(b, 'edit', expected=ORIGINAL, text='Client B conflicting edit.') + action(b, 'edit', expected=CELL, text='Client B disjoint cell.') + settle(b, lambda label: {'Client B conflicting edit.', 'Client B disjoint cell.'} <= texts(state(label)), 'b-published') + finally: + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': True}) + settle(a, lambda label: len(found := state(label)) > 1 and 'Client A disjoint edit.' in texts(found), 'conflict') + (output / 'conflict.json').write_text(json.dumps(state('conflict'), indent=2, ensure_ascii=False)) + page = action(a, 'snapshot')[0].attrib['ID'] + conflict_views(a['name'], output / 'a', page, 'a') + action(b, 'sync') + time.sleep(5) + page_b = action(b, 'snapshot')[0].attrib['ID'] + conflict_views(b['name'], output / 'b', page_b, 'b') + conflict_views(a['name'], output / 'a', page, 'a-again', delete=True) + settle(a, lambda label: len(state(label)) == 1, 'deleted') + action(b, 'sync') + time.sleep(5) + shot(b['name'], output / 'b', 'b-after-delete', runner.navigation_script(page_b) + 'Sleep(2000)\n') + + +PAGES = ['One', 'Two', 'Three', 'Four', 'Target'] + + +def titled(model, titles): + """The section's pages in order, each named by the title among `titles` it holds.""" + return [next((text for text in texts if text in titles), '') for texts in page_list(model)] + + +def offline(lab, titles, a_actions, b_actions): + """Creates the titled pages; A runs its actions offline while B runs and publishes its + own; A then reconnects. Keeps the merged order and pages, and what each client lists.""" + a, b, output, action = lab.a, lab.b, lab.output, lab.action + action(a, 'pages', titles=titles) + lab.settle(a, lambda label: 'Body Target.' in str(page_list(lab.state(label))), 'initial') + deadline = time.monotonic() + 300 + while not any(node.get('name') == 'Target' for node in action(b, 'snapshot')): + action(b, 'sync') + if time.monotonic() > deadline: raise TimeoutError('B never listed the new pages') + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': False}) + try: + for kind, parameters in a_actions: + action(a, kind, **parameters) + action(a, 'snapshot') + for kind, parameters in b_actions: + action(b, kind, **parameters) + action(b, 'snapshot') + lab.settle(b, lambda label: 'Body Target.' not in str(page_list(lab.state(label))), 'b-published') + finally: + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': True}) + # A's changes reach the server when its sync writes; wait until the file stops changing. + previous, steady = None, 0 + deadline = time.monotonic() + 300 + while steady < 3 and time.monotonic() < deadline: + action(a, 'sync') + time.sleep(10) + current = page_list(lab.state('merged')) + steady = steady + 1 if current == previous else 0 + previous = current + (output / 'merged.json').write_text(json.dumps({'order': titled(lab.state('merged'), titles), 'pages': previous}, indent=2, ensure_ascii=False)) + for client, label in ((a, 'a'), (b, 'b')): + action(client, 'sync') + time.sleep(5) + listed = action(client, 'snapshot') + (output / label / 'listed.json').write_text(json.dumps([node.get('name') for node in listed], ensure_ascii=False)) + if listed: + shot(client['name'], output / label, f'{label}-merged', runner.navigation_script(listed[0].get('ID')) + 'Sleep(2000)\n') + + +def pages(lab): + """Competing page moves, and an offline edit of a page the other client deletes.""" + offline(lab, PAGES, [ + ('move', {'page': 'Four', 'before': 'One'}), + ('move', {'page': 'Two', 'before': None}), + ('edit', {'expected': 'Body Target.', 'text': 'Body Target edited offline.'}), + ], [ + ('move', {'page': 'Four', 'before': None}), + ('move', {'page': 'Three', 'before': 'One'}), + ('delete-page', {'page': 'Target'}), + ]) + + +def restore(lab): + """An offline edit of a page in the middle of the list the other client deletes, with an + offline move of a page the other client leaves alone.""" + offline(lab, ['One', 'Two', 'Target', 'Three', 'Four'], [ + ('edit', {'expected': 'Body Target.', 'text': 'Body Target edited offline.'}), + ('move', {'page': 'Four', 'before': 'One'}), + ], [ + ('delete-page', {'page': 'Target'}), + ]) + + +def sync(server, output, scenario): + from document_model import EXPORTER + + if linux_vm.instance_path(server).exists(): + raise ValueError('Choose a new Linux VM name; existing machines are not owned by this run.') + output = output.resolve() + output.mkdir(parents=True, exist_ok=False) + scripts = output / 'scripts' + scripts.mkdir() + for name in ('cold.ps1', 'collaborate.ps1', 'network.ps1', 'text.ps1', 'stress.ps1'): + shutil.copyfile(ROOT / 'tools/native' / name, scripts / name) + shutil.copyfile(Path(__file__), output / 'native_conflict.py') + + def ssh(text, timeout=90): + result = linux_vm.run_ssh(server, text, timeout=timeout) + with (output / 'server.jsonl').open('a') as log: + log.write(json.dumps({'command': text, 'exit': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr}) + '\n') + result.check_returncode() + return result.stdout + + def server_state(label): + """Copies the server's notebook, recycle bin included, until its section parses, + keeping it as `label/notebook`; returns the section's exported model.""" + deadline = time.monotonic() + 120 + while True: + target = output / label + shutil.rmtree(target, ignore_errors=True) + (target / 'notebook').mkdir(parents=True) + archive = subprocess.run(linux_vm.ssh_argv(server, f"tar cf - -C '/srv/agent/{SHARE}' ."), capture_output=True, check=True, timeout=60).stdout + subprocess.run(['tar', 'xf', '-', '-C', target / 'notebook'], input=archive, check=True) + result = subprocess.run([EXPORTER, target / 'notebook/synthetic.one', target / 'model'], capture_output=True, text=True) + if result.returncode == 0: + return json.loads((target / 'model/document.json').read_text()) + if time.monotonic() > deadline: + raise RuntimeError(f'{label}: {result.stderr}') + time.sleep(1) + + linux_vm.create_instance(server) + try: + linux_vm.launch(server) + linux_vm.wait_instance(server, 600) + (output / 'linux.json').write_text(json.dumps(linux_vm.load_instance(server), indent=2)) + ssh(f'mkdir /srv/agent/{SHARE}') + for name in ('synthetic.one', 'Open Notebook.onetoc2'): + with (FIXTURE / name).open('rb') as stream: + subprocess.run(linux_vm.ssh_argv(server, f"cat > '/srv/agent/{SHARE}/{name}'"), stdin=stream, check=True) + ssh(f'chmod u+w /srv/agent/{SHARE}/*') + with ExitStack() as stack: + labels = ['a', 'b'] + for label in labels: + (output / label).mkdir() + + def start(label): + manager = clone(output / label) + name = manager.__enter__() + return label, name, manager + with ThreadPoolExecutor(max_workers=2) as pool: + started = list(pool.map(start, labels)) + for _, _, manager in started: + stack.push(manager) + clients = {} + for label, name, _ in started: + folder = output / label + for local in scripts.iterdir(): + remote = 'cold-current.ps1' if local.name == 'cold.ps1' else local.name + result = windows.do_put(local, 'C:\\one-tests\\' + remote, name) + if result.get('error'): raise RuntimeError(result['error']) + command(name, 'mkdir C:\\one-tests\\runs\\capture', folder) + command(name, 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac ' + vm.lab_mac(name), folder) + result = windows.do_spawn('cmd /c powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\collaborate.ps1 -Root C:\\one-tests\\runs\\capture -SharedPath \\\\192.168.77.1\\agent\\' + SHARE + ' -CloneHost ONE-' + name.upper() + ' > C:\\one-tests\\runs\\capture\\controller.log 2>&1', name) + if result.get('error'): raise RuntimeError(result['error']) + deadline = time.monotonic() + 180 + while windows.do_cmd('if exist C:\\one-tests\\runs\\capture\\ready (echo ready)', target=name).get('stdout', '').find('ready') < 0: + if time.monotonic() > deadline: raise RuntimeError('The collaboration controller did not open the notebook') + time.sleep(1) + clients[label] = {'name': name, 'folder': folder, 'sequence': 0} + (output / 'clients.json').write_text(json.dumps({label: client['name'] for label, client in clients.items()}, indent=2)) + + def action(client, kind, **parameters): + client['sequence'] += 1 + sequence = client['sequence'] + local = client['folder'] / f'command-{sequence:04}.json' + local.write_text(json.dumps({'action': kind, **parameters}, ensure_ascii=False)) + inbox = f'C:\\one-tests\\runs\\capture\\inbox\\{sequence:04}' + result = windows.do_put(local, inbox + '.tmp', client['name']) + if result.get('error'): raise RuntimeError(result['error']) + command(client['name'], f'move {inbox}.tmp {inbox}.json', client['folder']) + remote = f'C:\\one-tests\\runs\\capture\\outbox\\{sequence}' + deadline = time.monotonic() + 600 + while True: + result = windows.do_cmd(f'if exist {remote}\\error (type {remote}\\error & exit /b 1) else (if exist {remote}\\done (echo complete))', target=client['name']) + if result.get('exit') != 0: raise RuntimeError(str(result)) + if 'complete' in result.get('stdout', ''): break + if time.monotonic() > deadline: raise TimeoutError(f'{kind} did not complete') + time.sleep(.5) + if kind != 'snapshot': + return None + hierarchy = client['folder'] / f'hierarchy-{sequence:04}.xml' + windows.do_get(remote + '\\hierarchy.xml', hierarchy, client['name']) + pages = [node for node in ET.fromstring(hierarchy.read_text(encoding='utf-8-sig').strip()).iter() if node.tag.endswith('}Page')] + for i in range(len(pages)): + windows.do_get(remote + f'\\page-{i}.xml', client['folder'] / f'snapshot-{sequence:04}-{i}.xml', client['name']) + return pages + + def settle(client, check, label): + deadline = time.monotonic() + 300 + while True: + action(client, 'sync') + if check(label): return + if time.monotonic() > deadline: raise TimeoutError(label) + time.sleep(2) + + lab = Lab() + lab.a, lab.b, lab.output = clients['a'], clients['b'], output + lab.action, lab.state, lab.settle = action, server_state, settle + scenario(lab) + + for client in clients.values(): + try: + action(client, 'close') + except Exception as error: + (client['folder'] / 'close-failure.txt').write_text(str(error)) + server_state('final') + finally: + if linux_vm.running(server): + linux_vm.shutdown(server, 120) + linux_vm.delete_instance(server) + (output / 'linux-teardown.json').write_text(json.dumps({'absent': not linux_vm.instance_path(server).exists()}) + '\n') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + commands = parser.add_subparsers(dest='mode', required=True) + scenarios = {'sync': edits, 'pages': pages, 'restore': restore} + for mode in scenarios: + lab = commands.add_parser(mode) + lab.add_argument('linux') + lab.add_argument('output', type=Path) + reopen = commands.add_parser('cold') + reopen.add_argument('notebook', type=Path) + reopen.add_argument('output', type=Path) + reopen.add_argument('--delete', action='store_true') + args = parser.parse_args() + if args.mode in scenarios: + sync(args.linux, args.output, scenarios[args.mode]) + else: + cold(args.notebook, args.output, args.delete) + print('Captured', args.output) diff --git a/corpus/conflict-page/native-restore/scripts/cold.ps1 b/corpus/conflict-page/native-restore/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/conflict-page/native-restore/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/conflict-page/native-restore/scripts/collaborate.ps1 b/corpus/conflict-page/native-restore/scripts/collaborate.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..6a42a3d526db486f022b032583704b0f4aee26d3 --- /dev/null +++ b/corpus/conflict-page/native-restore/scripts/collaborate.ps1 @@ -0,0 +1,145 @@ +param([Parameter(Mandatory=$true)][string]$Root, + [Parameter(Mandatory=$true)][string]$SharedPath, + [Parameter(Mandatory=$true)][string]$CloneHost, + [switch]$ResumeCache, + [int]$StartSequence = 1) +function Keep-Container($page, $target) { + $container = $target + while ($container.ParentNode -ne $page.DocumentElement) { $container = $container.ParentNode } + foreach ($sibling in @($page.DocumentElement.ChildNodes)) { + if ($sibling -ne $container -and $sibling.LocalName -in @('Outline', 'Title', 'Image', 'InkDrawing')) { + [void]$page.DocumentElement.RemoveChild($sibling) + } + } +} +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot\text.ps1" +if ($SharedPath -notmatch '^\\\\192\.168\.77\.1\\agent\\m6-[a-z0-9-]+$') { throw 'Choose a disposable Linux lab notebook.' } +if ($env:COMPUTERNAME -ne $CloneHost) { throw 'The collaboration controller requires its owned clone.' } +if (-not $ResumeCache) { & "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost } +$shared = $SharedPath +if (-not (Test-Path "$shared\synthetic.one")) { throw 'Create the shared fixture first.' } +New-Item -ItemType Directory "$Root\inbox", "$Root\outbox" -Force | Out-Null +$user = 'HKCU:\Software\Microsoft\Office\Common\UserInfo' +New-Item $user -Force | Out-Null +New-ItemProperty $user -Name UserName -Value $CloneHost -PropertyType String -Force | Out-Null +New-ItemProperty $user -Name UserInitials -Value $CloneHost.Substring($CloneHost.Length - 3) -PropertyType String -Force | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebook = '' +try { + $app.OpenHierarchy($shared, '', [ref]$notebook, 0) + $section = '' + $app.OpenHierarchy('synthetic.one', $notebook, [ref]$section, 0) + [IO.File]::WriteAllText("$Root\ready", 'ready') + $deadline = [DateTime]::UtcNow.AddMinutes(30) + $sequence = $StartSequence + while ([DateTime]::UtcNow -lt $deadline) { + $name = '{0:d4}.json' -f $sequence + $input = "$Root\inbox\$name" + if (-not (Test-Path $input)) { Start-Sleep -Milliseconds 100; continue } + $command = Get-Content -Raw -Encoding UTF8 $input | ConvertFrom-Json + $output = "$Root\outbox\$sequence" + New-Item -ItemType Directory $output | Out-Null + try { + if ($command.action -in @('prepare-stress', 'stress')) { + . "$PSScriptRoot\stress.ps1" + Invoke-Stress $app $section $command $output $shared + } + elseif ($command.action -eq 'sync') { $app.SyncHierarchy($notebook) } + elseif ($command.action -in @('pages', 'move', 'delete-page')) { + $namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$tree = $hierarchy + $listed = @($tree.SelectNodes('//*[local-name()="Page"]')) + $named = { param($title) @($listed | Where-Object { $_.GetAttribute('name') -eq $title }) } + if ($command.action -eq 'pages') { + # New titled pages with one body paragraph each, appended in order. + foreach ($title in $command.titles) { + $page = '' + $app.CreateNewPage($section, [ref]$page, 2) + $body = [Security.SecurityElement]::Escape('Body ' + $title + '.') + $name = [Security.SecurityElement]::Escape($title) + $xml = '' + $name + '' + $body + '' + $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false) + } + } elseif ($command.action -eq 'move') { + # Moves the page titled `page` before the one titled `before`, or last. + $moved = @(& $named $command.page) + if ($moved.Count -ne 1) { throw 'Expected one page to move.' } + $order = [Collections.ArrayList]@($listed | Where-Object { $_ -ne $moved[0] }) + $at = $order.Count + if ($command.before) { + $anchor = @(& $named $command.before) + if ($anchor.Count -ne 1) { throw 'Expected one anchor page.' } + $at = $order.IndexOf($anchor[0]) + } + $order.Insert($at, $moved[0]) + $xml = '' + foreach ($node in $order) { + $xml += '' + } + $app.UpdateHierarchy($xml + '', 1) + } else { + $doomed = @(& $named $command.page) + if ($doomed.Count -ne 1) { throw 'Expected one page to delete.' } + $app.DeleteHierarchy($doomed[0].GetAttribute('ID'), [DateTime]::MinValue, $false) + } + } + elseif ($command.action -eq 'kill-process') { + Get-Process ONENOTE | Stop-Process -Force + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + $app = New-Object -ComObject OneNote.Application + $app.OpenHierarchy($shared, '', [ref]$notebook, 0) + $app.OpenHierarchy('synthetic.one', $notebook, [ref]$section, 0) + } + elseif ($command.action -eq 'edit' -or $command.action -eq 'snapshot') { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText("$output\hierarchy.xml", $hierarchy, [Text.Encoding]::UTF8) + [xml]$tree = $hierarchy + $matches = 0 + $editedPage = $null + $i = 0 + foreach ($node in $tree.SelectNodes('//*[local-name()="Page"]')) { + $content = '' + $app.GetPageContent($node.GetAttribute('ID'), [ref]$content, 1, 1) + [IO.File]::WriteAllText("$output\page-$i.xml", $content, [Text.Encoding]::UTF8) + [xml]$page = $content + if ($command.action -eq 'edit') { + $targets = @($page.SelectNodes('//*[local-name()="T"]') | Where-Object { + (Get-ParagraphText $_.InnerText) -eq $command.expected + }) + if ($targets.Count -gt 1) { throw 'Expected one matching text object.' } + if ($targets.Count -eq 1) { + $matches++ + if ($matches -gt 1) { throw 'The text occurs on multiple pages.' } + $targets[0].InnerText = [Security.SecurityElement]::Escape($command.text) + Keep-Container $page $targets[0] + $editedPage = $page + } + } + $i++ + } + if ($command.action -eq 'edit' -and $matches -ne 1) { throw 'Expected one matching text object.' } + if ($editedPage) { $app.UpdatePageContent($editedPage.OuterXml, [DateTime]::MinValue, 1, $false) } + } elseif ($command.action -ne 'close') { throw 'Unknown collaboration command.' } + [IO.File]::WriteAllText("$output\done", 'ok') + } catch { + [IO.File]::WriteAllText("$output\error", $_.Exception.ToString()) + } + if ($command.action -eq 'close') { break } + $sequence++ + } +} finally { + try { if ($notebook) { $app.CloseNotebook($notebook, $false) } } + finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + [IO.File]::WriteAllText("$Root\closed", 'closed') + } +} diff --git a/corpus/conflict-page/native-restore/scripts/network.ps1 b/corpus/conflict-page/native-restore/scripts/network.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..95fa83d5243ee6a2a653cc075128b063e2f09cf2 --- /dev/null +++ b/corpus/conflict-page/native-restore/scripts/network.ps1 @@ -0,0 +1,20 @@ +param([Parameter(Mandatory=$true)][string]$LabMac) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$adapter = @(Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.MACAddress -eq $LabMac }) +if ($adapter.Count -ne 1) { throw 'The clone lab adapter is ambiguous.' } +$enabled = $adapter[0].EnableDHCP() +if ($enabled.ReturnValue -notin @(0, 1)) { + throw ('Unable to enable DHCP on the clone lab adapter; result ' + $enabled.ReturnValue) +} +$release = $adapter[0].ReleaseDHCPLease() +$result = $adapter[0].RenewDHCPLease() +$deadline = [DateTime]::UtcNow.AddSeconds(60) +do { + $adapter = @(Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.MACAddress -eq $LabMac }) + $addresses = @($adapter[0].IPAddress | Where-Object { $_ -match '^192\.168\.77\.\d+$' -and $_ -ne '192.168.77.1' }) + if ($addresses.Count -eq 1) { break } + Start-Sleep -Milliseconds 250 +} while ([DateTime]::UtcNow -lt $deadline) +if ($addresses.Count -ne 1) { throw ('The clone did not receive a lab IPv4 address; renewal result ' + $result.ReturnValue + '; adapter ' + ($adapter[0] | Select-Object MACAddress,DHCPEnabled,IPEnabled,IPAddress | ConvertTo-Json -Compress)) } +@{mac=$LabMac;address=$addresses[0];releaseResult=$release.ReturnValue;renewalResult=$result.ReturnValue} | ConvertTo-Json -Compress diff --git a/corpus/conflict-page/native-restore/scripts/stress.ps1 b/corpus/conflict-page/native-restore/scripts/stress.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0ab5bc69b4dabe970373e481873dfd951a1a69a --- /dev/null +++ b/corpus/conflict-page/native-restore/scripts/stress.ps1 @@ -0,0 +1,83 @@ +function Invoke-Stress($app, $section, $command, $output, $shared) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText("$output\hierarchy.xml", $hierarchy, [Text.Encoding]::UTF8) + [xml]$tree = $hierarchy + $pages = @($tree.SelectNodes('//*[local-name()="Page"]')) + if ($pages.Count -ne 1) { throw 'Expected one stress-test page.' } + $pageId = $pages[0].GetAttribute('ID') + if ($command.action -eq 'prepare-stress') { + for ($i = 0; $i -lt $command.clients; $i++) { + $xml = '' + $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false) + } + return + } + $random = New-Object Random([int]$command.seed) + $events = New-Object IO.StreamWriter("$output\events.jsonl", $false, [Text.Encoding]::UTF8) + $events.AutoFlush = $true + try { + [IO.File]::WriteAllText("$output\ready", 'ready') + $deadline = [DateTime]::UtcNow.AddMinutes(10) + while (-not (Test-Path "$shared\stress-start")) { + if ([DateTime]::UtcNow -gt $deadline) { throw 'Stress start timed out.' } + Start-Sleep -Milliseconds 20 + } + for ($i = 0; $i -lt $command.operations; $i++) { + if (($command.PSObject.Properties.Name -contains 'maintenance') -and $command.maintenance -and $i -eq [Math]::Floor($command.operations / 2)) { + [IO.File]::WriteAllText("$shared\maintenance-paused-n$($command.actor)", 'paused') + while (-not (Test-Path "$shared\maintenance-resume")) { + if ([DateTime]::UtcNow -gt $deadline) { throw 'Maintenance pause timed out.' } + Start-Sleep -Milliseconds 100 + } + } + Start-Sleep -Milliseconds $random.Next(10, 100) + $started = [DateTime]::UtcNow.Ticks + $content = '' + $app.GetPageContent($pageId, [ref]$content, 1, 1) + [xml]$page = $content + $matches = @($page.SelectNodes('//*[local-name()="T"]') | Where-Object { + (Get-ParagraphText $_.InnerText).StartsWith($command.prefix) + }) + if ($matches.Count -ne 1) { throw 'Expected one stress-test paragraph.' } + $before = Get-ParagraphText $matches[0].InnerText + $token = ' [n' + $command.actor + ':' + $i + ']' + $start = $end = $before.Length + $replacement = $token + $bold = $italic = $false + if ($command.edit) { + $boundaries = New-Object 'Collections.Generic.List[int]' + for ($at = $command.prefix.Length; $at -le $before.Length; $at++) { + $boundaries.Add($at) + if ($at -lt $before.Length -and [char]::IsHighSurrogate($before[$at])) { $at++ } + } + $first = $boundaries[$random.Next($boundaries.Count)] + $second = $boundaries[$random.Next($boundaries.Count)] + $start = [Math]::Min($first, $second) + $end = [Math]::Max($first, $second) + $replacement = ' caf' + [char]0xe9 + ' ' + [char]::ConvertFromUtf32(0x1f980) + $token + $bold = $random.Next(2) -eq 1 + $italic = $random.Next(2) -eq 1 + } + $after = $before.Substring(0, $start) + $replacement + $before.Substring($end) + $html = [Security.SecurityElement]::Escape($after) + if ($command.edit) { + $weight = if ($bold) { 'bold' } else { 'normal' } + $style = if ($italic) { 'italic' } else { 'normal' } + $html = '' + $html + '' + } + $matches[0].InnerText = $html + Keep-Container $page $matches[0] + $updateStarted = [DateTime]::UtcNow.Ticks + $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false) + $updated = [DateTime]::UtcNow.Ticks + $events.WriteLine((@{ operation=$i; before=$before; token=$token; started_ticks=$started; + range=@($start, $end); replacement=$replacement; bold=$bold; italic=$italic; + update_started_ticks=$updateStarted; updated_ticks=$updated } | ConvertTo-Json -Compress)) + if ($i -eq 0) { [IO.File]::WriteAllText("$output\editing", 'editing') } + if ($command.sync_every -gt 0 -and (($i + 1) % $command.sync_every) -eq 0) { + $app.SyncHierarchy($section) + } + } + } finally { $events.Dispose() } +} diff --git a/corpus/conflict-page/native-restore/scripts/text.ps1 b/corpus/conflict-page/native-restore/scripts/text.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..75e9e71207b599d16eaaf68824ca30831860610a --- /dev/null +++ b/corpus/conflict-page/native-restore/scripts/text.ps1 @@ -0,0 +1,13 @@ +function Get-ParagraphText([string]$html) { + $plain = [regex]::Replace($html, '<[^>]*>', '') + $plain = [regex]::Replace($plain, '&#(?:x([0-9a-fA-F]+)|([0-9]+));', { + param($match) + $value = if ($match.Groups[1].Success) { + [Convert]::ToInt32($match.Groups[1].Value, 16) + } else { [int]$match.Groups[2].Value } + # The Win7 framework leaves supplementary numeric entities undecoded. + if ($value -gt 0xffff -and $value -le 0x10ffff) { [char]::ConvertFromUtf32($value) } + else { $match.Value } + }) + [Net.WebUtility]::HtmlDecode($plain) +} diff --git a/corpus/conflict-page/native-restore/server.jsonl b/corpus/conflict-page/native-restore/server.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..d5df993564a30ddaea1b427699648ff9b1a1417d --- /dev/null +++ b/corpus/conflict-page/native-restore/server.jsonl @@ -0,0 +1,2 @@ +{"command": "mkdir /srv/agent/m6-conflict", "exit": 0, "stdout": "", "stderr": ""} +{"command": "chmod u+w /srv/agent/m6-conflict/*", "exit": 0, "stdout": "", "stderr": ""} diff --git a/corpus/conflict-page/native-three/commands.jsonl b/corpus/conflict-page/native-three/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..bd094dd5fa92bff9cd29a895346604fd12f198af --- /dev/null +++ b/corpus/conflict-page/native-three/commands.jsonl @@ -0,0 +1,6 @@ +{"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-C6F6C14F -ExpectedPages -1 -Pdf -KeepOpen", "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\\read -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "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-C6F6C14F -UseCurrentCache -ExpectedPages -1 -Pdf", "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/conflict-page/native-three/conflicts.png b/corpus/conflict-page/native-three/conflicts.png new file mode 100644 index 0000000000000000000000000000000000000000..05b2a356d3b39f7a6660730662dc6aabbbb107f0 Binary files /dev/null and b/corpus/conflict-page/native-three/conflicts.png differ diff --git a/corpus/conflict-page/native-three/expand.png b/corpus/conflict-page/native-three/expand.png new file mode 100644 index 0000000000000000000000000000000000000000..2996fb0cf1da7b204973c3ba4e08a9c4c6f4df53 Binary files /dev/null and b/corpus/conflict-page/native-three/expand.png differ diff --git a/corpus/conflict-page/native-three/machine.json b/corpus/conflict-page/native-three/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..45055b0c57355c25e302d24c46f2f498763ea030 --- /dev/null +++ b/corpus/conflict-page/native-three/machine.json @@ -0,0 +1 @@ +{"name": "m6-c6f6c14f", "hostname": "ONE-M6-C6F6C14F"} diff --git a/corpus/conflict-page/native-three/notebook/Open Notebook.onetoc2 b/corpus/conflict-page/native-three/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..c4a2b9c6013a262a0c6d4d71d2a50d67c98131e8 Binary files /dev/null and b/corpus/conflict-page/native-three/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native-three/notebook/synthetic.one b/corpus/conflict-page/native-three/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..1db8bd91ac5b4eb9c3623ca701e9773b66d6010e Binary files /dev/null and b/corpus/conflict-page/native-three/notebook/synthetic.one differ diff --git a/corpus/conflict-page/native-three/run.json b/corpus/conflict-page/native-three/run.json new file mode 100644 index 0000000000000000000000000000000000000000..ee5c95acf2fb14729ddaece26555e5eb5face4bb --- /dev/null +++ b/corpus/conflict-page/native-three/run.json @@ -0,0 +1,17 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m8/conflict-abrupt-01/conflict-closed/notebook", + "expected_pages": -1, + "author": null, + "author_timeout_seconds": 600, + "inspect": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "086789e467779add0fc071ad8c524c2b02d3d0d609aea4a41f3128cadba371e8" + } +} diff --git a/corpus/conflict-page/native-three/source.json b/corpus/conflict-page/native-three/source.json new file mode 100644 index 0000000000000000000000000000000000000000..e6bfda307ffe01e66ae1cd42a2465e9173d9ca69 --- /dev/null +++ b/corpus/conflict-page/native-three/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 3780, + "sha256": "f21c6d4eefc25ce78aee0420fdee0b409da52fdf3119b66302ac6ca4690c55ae", + "mtime_ns": 1788733155312083279 + }, + { + "path": "synthetic.one", + "bytes": 46416, + "sha256": "7ac7a42932848380b90aa478800ab141fce4fb591aae2bf1e167f1e516e5edc0", + "mtime_ns": 1788733155311971612 + } +] diff --git a/corpus/conflict-page/native-three/teardown.json b/corpus/conflict-page/native-three/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/conflict-page/native-three/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/conflict-page/native/a/a-conflict.json b/corpus/conflict-page/native/a/a-conflict.json new file mode 100644 index 0000000000000000000000000000000000000000..2fd05a4a7938577ce0347213e7f3ed53dab2c0a4 --- /dev/null +++ b/corpus/conflict-page/native/a/a-conflict.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Client A conflicting edit. - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native/a/a-conflict.png b/corpus/conflict-page/native/a/a-conflict.png new file mode 100644 index 0000000000000000000000000000000000000000..779f4bd296c5f9cf5aa32b4c8b81d4d7704a8095 Binary files /dev/null and b/corpus/conflict-page/native/a/a-conflict.png differ diff --git a/corpus/conflict-page/native/a/a-main.json b/corpus/conflict-page/native/a/a-main.json new file mode 100644 index 0000000000000000000000000000000000000000..668f002c22e396d7530675ec44f659f85b6f6a86 --- /dev/null +++ b/corpus/conflict-page/native/a/a-main.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Client B conflicting edit. - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native/a/a-main.png b/corpus/conflict-page/native/a/a-main.png new file mode 100644 index 0000000000000000000000000000000000000000..160e31b2ff70f2b56ede40769422db0f0e81083d Binary files /dev/null and b/corpus/conflict-page/native/a/a-main.png differ diff --git a/corpus/conflict-page/native/a/a-menu.json b/corpus/conflict-page/native/a/a-menu.json new file mode 100644 index 0000000000000000000000000000000000000000..f43854a7a42d5bb99c0b443513bc0c0993c2716c --- /dev/null +++ b/corpus/conflict-page/native/a/a-menu.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "", + "class": "Net UI Tool Window", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native/a/a-menu.png b/corpus/conflict-page/native/a/a-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..3dff58a0556eab58682e8cb6c2a28e3ab6aff07d Binary files /dev/null and b/corpus/conflict-page/native/a/a-menu.png differ diff --git a/corpus/conflict-page/native/a/a-shown.json b/corpus/conflict-page/native/a/a-shown.json new file mode 100644 index 0000000000000000000000000000000000000000..2fd05a4a7938577ce0347213e7f3ed53dab2c0a4 --- /dev/null +++ b/corpus/conflict-page/native/a/a-shown.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Client A conflicting edit. - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native/a/a-shown.png b/corpus/conflict-page/native/a/a-shown.png new file mode 100644 index 0000000000000000000000000000000000000000..324161780c3b6bc1d4a76ead6a5e5c567c003dda Binary files /dev/null and b/corpus/conflict-page/native/a/a-shown.png differ diff --git a/corpus/conflict-page/native/a/commands.jsonl b/corpus/conflict-page/native/a/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..71a20b5a21782c6d65840a920e949089a97e2520 --- /dev/null +++ b/corpus/conflict-page/native/a/commands.jsonl @@ -0,0 +1,124 @@ +{"command": "mkdir C:\\one-tests\\runs\\capture", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac 52:54:02:a3:5c:e3", "exit": 0, "stdout": "{\"renewalResult\":0,\"releaseResult\":0,\"mac\":\"52:54:02:a3:5c:e3\",\"address\":\"192.168.77.180\"}\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0001.tmp C:\\one-tests\\runs\\capture\\inbox\\0001.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0002.tmp C:\\one-tests\\runs\\capture\\inbox\\0002.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0003.tmp C:\\one-tests\\runs\\capture\\inbox\\0003.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0004.tmp C:\\one-tests\\runs\\capture\\inbox\\0004.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0005.tmp C:\\one-tests\\runs\\capture\\inbox\\0005.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0006.tmp C:\\one-tests\\runs\\capture\\inbox\\0006.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0007.tmp C:\\one-tests\\runs\\capture\\inbox\\0007.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0008.tmp C:\\one-tests\\runs\\capture\\inbox\\0008.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0009.tmp C:\\one-tests\\runs\\capture\\inbox\\0009.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0010.tmp C:\\one-tests\\runs\\capture\\inbox\\0010.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0011.tmp C:\\one-tests\\runs\\capture\\inbox\\0011.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0012.tmp C:\\one-tests\\runs\\capture\\inbox\\0012.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0013.tmp C:\\one-tests\\runs\\capture\\inbox\\0013.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0014.tmp C:\\one-tests\\runs\\capture\\inbox\\0014.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0015.tmp C:\\one-tests\\runs\\capture\\inbox\\0015.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0016.tmp C:\\one-tests\\runs\\capture\\inbox\\0016.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0017.tmp C:\\one-tests\\runs\\capture\\inbox\\0017.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0018.tmp C:\\one-tests\\runs\\capture\\inbox\\0018.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0019.tmp C:\\one-tests\\runs\\capture\\inbox\\0019.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0020.tmp C:\\one-tests\\runs\\capture\\inbox\\0020.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0021.tmp C:\\one-tests\\runs\\capture\\inbox\\0021.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0022.tmp C:\\one-tests\\runs\\capture\\inbox\\0022.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0023.tmp C:\\one-tests\\runs\\capture\\inbox\\0023.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0024.tmp C:\\one-tests\\runs\\capture\\inbox\\0024.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0025.tmp C:\\one-tests\\runs\\capture\\inbox\\0025.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0026.tmp C:\\one-tests\\runs\\capture\\inbox\\0026.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0027.tmp C:\\one-tests\\runs\\capture\\inbox\\0027.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0028.tmp C:\\one-tests\\runs\\capture\\inbox\\0028.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0029.tmp C:\\one-tests\\runs\\capture\\inbox\\0029.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0030.tmp C:\\one-tests\\runs\\capture\\inbox\\0030.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0031.tmp C:\\one-tests\\runs\\capture\\inbox\\0031.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0032.tmp C:\\one-tests\\runs\\capture\\inbox\\0032.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0033.tmp C:\\one-tests\\runs\\capture\\inbox\\0033.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0034.tmp C:\\one-tests\\runs\\capture\\inbox\\0034.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0035.tmp C:\\one-tests\\runs\\capture\\inbox\\0035.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0036.tmp C:\\one-tests\\runs\\capture\\inbox\\0036.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0037.tmp C:\\one-tests\\runs\\capture\\inbox\\0037.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0038.tmp C:\\one-tests\\runs\\capture\\inbox\\0038.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0039.tmp C:\\one-tests\\runs\\capture\\inbox\\0039.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0040.tmp C:\\one-tests\\runs\\capture\\inbox\\0040.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0041.tmp C:\\one-tests\\runs\\capture\\inbox\\0041.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0042.tmp C:\\one-tests\\runs\\capture\\inbox\\0042.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0043.tmp C:\\one-tests\\runs\\capture\\inbox\\0043.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0044.tmp C:\\one-tests\\runs\\capture\\inbox\\0044.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0045.tmp C:\\one-tests\\runs\\capture\\inbox\\0045.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0046.tmp C:\\one-tests\\runs\\capture\\inbox\\0046.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0047.tmp C:\\one-tests\\runs\\capture\\inbox\\0047.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0048.tmp C:\\one-tests\\runs\\capture\\inbox\\0048.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0049.tmp C:\\one-tests\\runs\\capture\\inbox\\0049.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0050.tmp C:\\one-tests\\runs\\capture\\inbox\\0050.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0051.tmp C:\\one-tests\\runs\\capture\\inbox\\0051.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0052.tmp C:\\one-tests\\runs\\capture\\inbox\\0052.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0053.tmp C:\\one-tests\\runs\\capture\\inbox\\0053.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0054.tmp C:\\one-tests\\runs\\capture\\inbox\\0054.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0055.tmp C:\\one-tests\\runs\\capture\\inbox\\0055.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0056.tmp C:\\one-tests\\runs\\capture\\inbox\\0056.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0057.tmp C:\\one-tests\\runs\\capture\\inbox\\0057.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0058.tmp C:\\one-tests\\runs\\capture\\inbox\\0058.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0059.tmp C:\\one-tests\\runs\\capture\\inbox\\0059.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0060.tmp C:\\one-tests\\runs\\capture\\inbox\\0060.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0061.tmp C:\\one-tests\\runs\\capture\\inbox\\0061.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0062.tmp C:\\one-tests\\runs\\capture\\inbox\\0062.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0063.tmp C:\\one-tests\\runs\\capture\\inbox\\0063.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0064.tmp C:\\one-tests\\runs\\capture\\inbox\\0064.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0065.tmp C:\\one-tests\\runs\\capture\\inbox\\0065.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0066.tmp C:\\one-tests\\runs\\capture\\inbox\\0066.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0067.tmp C:\\one-tests\\runs\\capture\\inbox\\0067.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0068.tmp C:\\one-tests\\runs\\capture\\inbox\\0068.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0069.tmp C:\\one-tests\\runs\\capture\\inbox\\0069.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0070.tmp C:\\one-tests\\runs\\capture\\inbox\\0070.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0071.tmp C:\\one-tests\\runs\\capture\\inbox\\0071.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0072.tmp C:\\one-tests\\runs\\capture\\inbox\\0072.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0073.tmp C:\\one-tests\\runs\\capture\\inbox\\0073.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0074.tmp C:\\one-tests\\runs\\capture\\inbox\\0074.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0075.tmp C:\\one-tests\\runs\\capture\\inbox\\0075.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0076.tmp C:\\one-tests\\runs\\capture\\inbox\\0076.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0077.tmp C:\\one-tests\\runs\\capture\\inbox\\0077.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0078.tmp C:\\one-tests\\runs\\capture\\inbox\\0078.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0079.tmp C:\\one-tests\\runs\\capture\\inbox\\0079.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0080.tmp C:\\one-tests\\runs\\capture\\inbox\\0080.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0081.tmp C:\\one-tests\\runs\\capture\\inbox\\0081.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0082.tmp C:\\one-tests\\runs\\capture\\inbox\\0082.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0083.tmp C:\\one-tests\\runs\\capture\\inbox\\0083.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0084.tmp C:\\one-tests\\runs\\capture\\inbox\\0084.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0085.tmp C:\\one-tests\\runs\\capture\\inbox\\0085.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0086.tmp C:\\one-tests\\runs\\capture\\inbox\\0086.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0087.tmp C:\\one-tests\\runs\\capture\\inbox\\0087.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0088.tmp C:\\one-tests\\runs\\capture\\inbox\\0088.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0089.tmp C:\\one-tests\\runs\\capture\\inbox\\0089.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0090.tmp C:\\one-tests\\runs\\capture\\inbox\\0090.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0091.tmp C:\\one-tests\\runs\\capture\\inbox\\0091.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0092.tmp C:\\one-tests\\runs\\capture\\inbox\\0092.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0093.tmp C:\\one-tests\\runs\\capture\\inbox\\0093.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0094.tmp C:\\one-tests\\runs\\capture\\inbox\\0094.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0095.tmp C:\\one-tests\\runs\\capture\\inbox\\0095.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0096.tmp C:\\one-tests\\runs\\capture\\inbox\\0096.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0097.tmp C:\\one-tests\\runs\\capture\\inbox\\0097.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0098.tmp C:\\one-tests\\runs\\capture\\inbox\\0098.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0099.tmp C:\\one-tests\\runs\\capture\\inbox\\0099.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0100.tmp C:\\one-tests\\runs\\capture\\inbox\\0100.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0101.tmp C:\\one-tests\\runs\\capture\\inbox\\0101.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0102.tmp C:\\one-tests\\runs\\capture\\inbox\\0102.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0103.tmp C:\\one-tests\\runs\\capture\\inbox\\0103.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0104.tmp C:\\one-tests\\runs\\capture\\inbox\\0104.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0105.tmp C:\\one-tests\\runs\\capture\\inbox\\0105.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0106.tmp C:\\one-tests\\runs\\capture\\inbox\\0106.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0107.tmp C:\\one-tests\\runs\\capture\\inbox\\0107.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0108.tmp C:\\one-tests\\runs\\capture\\inbox\\0108.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0109.tmp C:\\one-tests\\runs\\capture\\inbox\\0109.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0110.tmp C:\\one-tests\\runs\\capture\\inbox\\0110.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0111.tmp C:\\one-tests\\runs\\capture\\inbox\\0111.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0112.tmp C:\\one-tests\\runs\\capture\\inbox\\0112.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0113.tmp C:\\one-tests\\runs\\capture\\inbox\\0113.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0114.tmp C:\\one-tests\\runs\\capture\\inbox\\0114.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0115.tmp C:\\one-tests\\runs\\capture\\inbox\\0115.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0116.tmp C:\\one-tests\\runs\\capture\\inbox\\0116.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0117.tmp C:\\one-tests\\runs\\capture\\inbox\\0117.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0118.tmp C:\\one-tests\\runs\\capture\\inbox\\0118.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0119.tmp C:\\one-tests\\runs\\capture\\inbox\\0119.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0120.tmp C:\\one-tests\\runs\\capture\\inbox\\0120.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0121.tmp C:\\one-tests\\runs\\capture\\inbox\\0121.json", "exit": 0, "stdout": " 1 file(s) moved.\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": "ZipArchiveHelper : The process cannot access the file \r\n'C:\\one-tests\\runs\\capture\\controller.log' because it is being used by another \r\nprocess.\r\nAt C:\\Windows\\system32\\WindowsPowerShell\\v1.0\\Modules\\Microsoft.PowerShell.Arch\r\nive\\Microsoft.PowerShell.Archive.psm1:631 char:30\r\n+ ... sArchived = ZipArchiveHelper $sourceFilePaths $destinationPath $compr ...\r\n+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r\n + CategoryInfo : PermissionDenied: (C:\\one-tests\\runs\\capture\\con \r\n troller.log:String) [Write-Error], IOException\r\n + FullyQualifiedErrorId : CompressArchiveUnauthorizedAccessError,ZipArchiv \r\n eHelper\r\n \r\n", "error": null} diff --git a/corpus/conflict-page/native/a/machine.json b/corpus/conflict-page/native/a/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..0f864b281e1b3fddd8d8f15005c6783a803b8317 --- /dev/null +++ b/corpus/conflict-page/native/a/machine.json @@ -0,0 +1 @@ +{"name": "m6-8a93780a", "hostname": "ONE-M6-8A93780A"} diff --git a/corpus/conflict-page/native/a/teardown.json b/corpus/conflict-page/native/a/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/conflict-page/native/a/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/conflict-page/native/b-published/notebook/Open Notebook.onetoc2 b/corpus/conflict-page/native/b-published/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..c7beb44bf509f935090fe55e7549e4b50e0ae0e9 Binary files /dev/null and b/corpus/conflict-page/native/b-published/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native/b-published/notebook/synthetic.one b/corpus/conflict-page/native/b-published/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..7a6d1f09e1619db43158817a0fd6f9fab64b486f Binary files /dev/null and b/corpus/conflict-page/native/b-published/notebook/synthetic.one differ diff --git a/corpus/conflict-page/native/b/b-main.json b/corpus/conflict-page/native/b/b-main.json new file mode 100644 index 0000000000000000000000000000000000000000..668f002c22e396d7530675ec44f659f85b6f6a86 --- /dev/null +++ b/corpus/conflict-page/native/b/b-main.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Client B conflicting edit. - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native/b/b-main.png b/corpus/conflict-page/native/b/b-main.png new file mode 100644 index 0000000000000000000000000000000000000000..6fc48cfee0af3e8e1a5eb28b9554a742fd124c56 Binary files /dev/null and b/corpus/conflict-page/native/b/b-main.png differ diff --git a/corpus/conflict-page/native/b/b-shown.json b/corpus/conflict-page/native/b/b-shown.json new file mode 100644 index 0000000000000000000000000000000000000000..2fd05a4a7938577ce0347213e7f3ed53dab2c0a4 --- /dev/null +++ b/corpus/conflict-page/native/b/b-shown.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Client A conflicting edit. - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/conflict-page/native/b/b-shown.png b/corpus/conflict-page/native/b/b-shown.png new file mode 100644 index 0000000000000000000000000000000000000000..df913522858ab019e71ed65ae817e86cb56570ad Binary files /dev/null and b/corpus/conflict-page/native/b/b-shown.png differ diff --git a/corpus/conflict-page/native/b/commands.jsonl b/corpus/conflict-page/native/b/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..4281870e093fd1993182f6602b819b2e68c183ff --- /dev/null +++ b/corpus/conflict-page/native/b/commands.jsonl @@ -0,0 +1,21 @@ +{"command": "mkdir C:\\one-tests\\runs\\capture", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac 52:54:02:e7:d0:a7", "exit": 0, "stdout": "{\"renewalResult\":0,\"releaseResult\":0,\"mac\":\"52:54:02:e7:d0:a7\",\"address\":\"192.168.77.42\"}\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0001.tmp C:\\one-tests\\runs\\capture\\inbox\\0001.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0002.tmp C:\\one-tests\\runs\\capture\\inbox\\0002.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0003.tmp C:\\one-tests\\runs\\capture\\inbox\\0003.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0004.tmp C:\\one-tests\\runs\\capture\\inbox\\0004.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0005.tmp C:\\one-tests\\runs\\capture\\inbox\\0005.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0006.tmp C:\\one-tests\\runs\\capture\\inbox\\0006.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0007.tmp C:\\one-tests\\runs\\capture\\inbox\\0007.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0008.tmp C:\\one-tests\\runs\\capture\\inbox\\0008.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0009.tmp C:\\one-tests\\runs\\capture\\inbox\\0009.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0010.tmp C:\\one-tests\\runs\\capture\\inbox\\0010.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0011.tmp C:\\one-tests\\runs\\capture\\inbox\\0011.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0012.tmp C:\\one-tests\\runs\\capture\\inbox\\0012.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0013.tmp C:\\one-tests\\runs\\capture\\inbox\\0013.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0014.tmp C:\\one-tests\\runs\\capture\\inbox\\0014.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0015.tmp C:\\one-tests\\runs\\capture\\inbox\\0015.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0016.tmp C:\\one-tests\\runs\\capture\\inbox\\0016.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0017.tmp C:\\one-tests\\runs\\capture\\inbox\\0017.json", "exit": 0, "stdout": " 1 file(s) moved.\r\n", "stderr": "", "error": null} +{"command": "move C:\\one-tests\\runs\\capture\\inbox\\0018.tmp C:\\one-tests\\runs\\capture\\inbox\\0018.json", "exit": 0, "stdout": " 1 file(s) moved.\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": "ZipArchiveHelper : The process cannot access the file \r\n'C:\\one-tests\\runs\\capture\\controller.log' because it is being used by another \r\nprocess.\r\nAt C:\\Windows\\system32\\WindowsPowerShell\\v1.0\\Modules\\Microsoft.PowerShell.Arch\r\nive\\Microsoft.PowerShell.Archive.psm1:631 char:30\r\n+ ... sArchived = ZipArchiveHelper $sourceFilePaths $destinationPath $compr ...\r\n+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r\n + CategoryInfo : PermissionDenied: (C:\\one-tests\\runs\\capture\\con \r\n troller.log:String) [Write-Error], IOException\r\n + FullyQualifiedErrorId : CompressArchiveUnauthorizedAccessError,ZipArchiv \r\n eHelper\r\n \r\n", "error": null} diff --git a/corpus/conflict-page/native/b/machine.json b/corpus/conflict-page/native/b/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..844bc51f2eafc6db25c8e50620b8e329eb173289 --- /dev/null +++ b/corpus/conflict-page/native/b/machine.json @@ -0,0 +1 @@ +{"name": "m6-d63654a2", "hostname": "ONE-M6-D63654A2"} diff --git a/corpus/conflict-page/native/b/teardown.json b/corpus/conflict-page/native/b/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/conflict-page/native/b/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/conflict-page/native/clients.json b/corpus/conflict-page/native/clients.json new file mode 100644 index 0000000000000000000000000000000000000000..3d33f0e3408a7aaa0ca6279a8db3983cbb55d9f5 --- /dev/null +++ b/corpus/conflict-page/native/clients.json @@ -0,0 +1,4 @@ +{ + "a": "m6-8a93780a", + "b": "m6-d63654a2" +} \ No newline at end of file diff --git a/corpus/conflict-page/native/conflict.json b/corpus/conflict-page/native/conflict.json new file mode 100644 index 0000000000000000000000000000000000000000..8a1442378c5f28dc3b321c2f15c3f17c5c7e68cb --- /dev/null +++ b/corpus/conflict-page/native/conflict.json @@ -0,0 +1,15 @@ +{ + "{64183EF4-4E19-0CBE-3D3B-88FF17D58B1C},1": [ + "Client A disjoint edit.", + "Client B conflicting edit.", + "Client B disjoint cell.", + "Right cell" + ], + "{9C2E0DE1-2E23-094F-3F9F-A919195FC40D},1": [ + "", + "Client A conflicting edit.", + "Client A disjoint edit.", + "Left cell", + "Right cell" + ] +} \ No newline at end of file diff --git a/corpus/conflict-page/native/conflict/notebook/Open Notebook.onetoc2 b/corpus/conflict-page/native/conflict/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..c7beb44bf509f935090fe55e7549e4b50e0ae0e9 Binary files /dev/null and b/corpus/conflict-page/native/conflict/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native/conflict/notebook/synthetic.one b/corpus/conflict-page/native/conflict/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..8d01b4f2cf195178b1f688fe06874ed37eeab86c Binary files /dev/null and b/corpus/conflict-page/native/conflict/notebook/synthetic.one differ diff --git a/corpus/conflict-page/native/initial/notebook/Open Notebook.onetoc2 b/corpus/conflict-page/native/initial/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..c7beb44bf509f935090fe55e7549e4b50e0ae0e9 Binary files /dev/null and b/corpus/conflict-page/native/initial/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/conflict-page/native/initial/notebook/synthetic.one b/corpus/conflict-page/native/initial/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..e46d7a24cead34b8521bc6e1d117d68a9ff70f09 Binary files /dev/null and b/corpus/conflict-page/native/initial/notebook/synthetic.one differ diff --git a/corpus/conflict-page/native/linux-teardown.json b/corpus/conflict-page/native/linux-teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/conflict-page/native/linux-teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/conflict-page/native/linux.json b/corpus/conflict-page/native/linux.json new file mode 100644 index 0000000000000000000000000000000000000000..51c769af931eb724e1a4f34d25afa486513f27ae --- /dev/null +++ b/corpus/conflict-page/native/linux.json @@ -0,0 +1,11 @@ +{ + "cpus": 2, + "disk_gb": 16, + "hostname": "one-conflict", + "lab_address": "192.168.77.1", + "lab_mac": "52:54:04:69:39:00", + "memory_mb": 2048, + "samba_port": 14450, + "ssh_port": 22000, + "wan_mac": "52:54:03:76:a2:ae" +} \ No newline at end of file diff --git a/corpus/conflict-page/native/native_conflict.py b/corpus/conflict-page/native/native_conflict.py new file mode 100644 index 0000000000000000000000000000000000000000..ba5057280e4efb096969c86293a53973fdb8a19d --- /dev/null +++ b/corpus/conflict-page/native/native_conflict.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""OneNote 2010 conflict pages: how they arise, what they store and how OneNote shows them. + +`sync LINUX OUTPUT` runs two disposable OneNote clients on one Samba notebook: client A edits +two paragraphs offline while client B edits one of them and a table cell online; A then +reconnects, which makes OneNote write a conflict page. The server file is kept before and +after, A and B are screenshotted with the information bar, the page list shown, the conflict +page open and its menu open, and A then deletes the conflict page through that menu. + +`cold NOTEBOOK OUTPUT [--delete]` cold-opens a notebook holding a conflict page in a fresh +clone and screenshots the same views; `--delete` deletes the conflict page through the menu +and captures the notebook OneNote leaves. +""" +import argparse +from contextlib import ExitStack +from concurrent.futures import ThreadPoolExecutor +import base64 +import json +from pathlib import Path +import shutil +import subprocess +import sys +import time +import xml.etree.ElementTree as ET + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent / 'w7')) +import native_runner as runner +from native_runner import ROOT, clone, command, windows, vm +import linux_vm + +FIXTURE = ROOT / 'corpus/native-ink/cold-ui-ink/notebook' +SHARE = 'm6-conflict' +ORIGINAL = 'Fictitious: café, 東京, مرحبا' +OTHER_OUTLINE = 'Fictitious positioned outline.' +CELL = 'Left cell' + +# Coordinates on OneNote 2010 maximized at 800x600, from the stage-5 and m8 captures: the +# information bar's text, and the first conflict page listed under its page. +BAR = (332, 100) +FIRST_CONFLICT = (725, 142) + + +def shot(name, output, label, script): + """Runs an AutoHotkey step on the clone, keeping its screenshot as `label.png`.""" + (output / f'{label}.ahk').write_text(script) + result = windows.do_exec(script, target=name, timeout_ms=120000, shot_delay_ms=1500) + screenshot = result.pop('png_b64', None) + if screenshot: + (output / f'{label}.png').write_bytes(base64.b64decode(screenshot)) + (output / f'{label}.json').write_text(json.dumps(result, indent=2)) + if result.get('error') or result.get('exit') != 0: + raise RuntimeError(f'{label}: {result}') + return result + + +def click(x, y): + return ('WinActivate("ahk_exe ONENOTE.EXE")\nCoordMode("Mouse", "Screen")\n' + f'Click({x}, {y})\nSleep(1200)\n') + + +# Reports any dialog OneNote raised, so a confirmation shows in the step's record. +DIALOG = ('if WinExist("ahk_class #32770 ahk_exe ONENOTE.EXE") {\n' + ' FileAppend("dialog: " WinGetTitle() " | " WinGetText() "`n", "*", "UTF-8")\n' + '}\n') + + +def conflict_views(name, output, page, prefix, delete=False): + """Screenshots a page with a conflict page: the bar, the list shown, the conflict page and + its menu; `delete` then chooses Delete Conflict Page.""" + shot(name, output, f'{prefix}-main', runner.navigation_script(page) + 'Sleep(2000)\n') + shot(name, output, f'{prefix}-shown', click(*BAR)) + shot(name, output, f'{prefix}-conflict', click(*FIRST_CONFLICT)) + shot(name, output, f'{prefix}-menu', click(*BAR)) + if delete: + shot(name, output, f'{prefix}-delete', 'WinActivate("ahk_exe ONENOTE.EXE")\nSend("d")\nSleep(1500)\n' + DIALOG) + else: + shot(name, output, f'{prefix}-closed', 'WinActivate("ahk_exe ONENOTE.EXE")\nSend("{Esc}")\nSleep(800)\n') + + +def cold(notebook, output, delete): + def interaction(name, output): + page = ET.parse(sorted((output / 'read').glob('page-*.xml'))[0]).getroot().attrib['ID'] + conflict_views(name, output, page, 'cold', delete) + if delete: + # OneNote writes the deletion in the background; give it time before the re-read. + time.sleep(20) + runner.capture(notebook, output, collect_notebook=True, interaction=interaction) + + +def sync(server, output): + from native_collaboration import reachable_page_text + from document_model import EXPORTER + + if linux_vm.instance_path(server).exists(): + raise ValueError('Choose a new Linux VM name; existing machines are not owned by this run.') + output = output.resolve() + output.mkdir(parents=True, exist_ok=False) + scripts = output / 'scripts' + scripts.mkdir() + for name in ('cold.ps1', 'collaborate.ps1', 'network.ps1', 'text.ps1', 'stress.ps1'): + shutil.copyfile(ROOT / 'tools/native' / name, scripts / name) + shutil.copyfile(Path(__file__), output / 'native_conflict.py') + + def ssh(text, timeout=90): + result = linux_vm.run_ssh(server, text, timeout=timeout) + with (output / 'server.jsonl').open('a') as log: + log.write(json.dumps({'command': text, 'exit': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr}) + '\n') + result.check_returncode() + return result.stdout + + def server_state(label): + """Copies the server's notebook until it parses, keeping it as `label/notebook`.""" + deadline = time.monotonic() + 120 + while True: + target = output / label + shutil.rmtree(target, ignore_errors=True) + (target / 'notebook').mkdir(parents=True) + for name in ('synthetic.one', 'Open Notebook.onetoc2'): + with (target / 'notebook' / name).open('wb') as stream: + subprocess.run(linux_vm.ssh_argv(server, f"cat '/srv/agent/{SHARE}/{name}'"), stdout=stream, check=True, timeout=60) + result = subprocess.run([EXPORTER, target / 'notebook/synthetic.one', target / 'model'], capture_output=True, text=True) + if result.returncode == 0: + model = json.loads((target / 'model/document.json').read_text()) + return reachable_page_text(model) + if time.monotonic() > deadline: + raise RuntimeError(f'{label}: {result.stderr}') + time.sleep(1) + + linux_vm.create_instance(server) + try: + linux_vm.launch(server) + linux_vm.wait_instance(server, 600) + (output / 'linux.json').write_text(json.dumps(linux_vm.load_instance(server), indent=2)) + ssh(f'mkdir /srv/agent/{SHARE}') + for name in ('synthetic.one', 'Open Notebook.onetoc2'): + with (FIXTURE / name).open('rb') as stream: + subprocess.run(linux_vm.ssh_argv(server, f"cat > '/srv/agent/{SHARE}/{name}'"), stdin=stream, check=True) + ssh(f'chmod u+w /srv/agent/{SHARE}/*') + with ExitStack() as stack: + labels = ['a', 'b'] + for label in labels: + (output / label).mkdir() + + def start(label): + manager = clone(output / label) + name = manager.__enter__() + return label, name, manager + with ThreadPoolExecutor(max_workers=2) as pool: + started = list(pool.map(start, labels)) + for _, _, manager in started: + stack.push(manager) + clients = {} + for label, name, _ in started: + folder = output / label + for local in scripts.iterdir(): + remote = 'cold-current.ps1' if local.name == 'cold.ps1' else local.name + result = windows.do_put(local, 'C:\\one-tests\\' + remote, name) + if result.get('error'): raise RuntimeError(result['error']) + command(name, 'mkdir C:\\one-tests\\runs\\capture', folder) + command(name, 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac ' + vm.lab_mac(name), folder) + result = windows.do_spawn('cmd /c powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\collaborate.ps1 -Root C:\\one-tests\\runs\\capture -SharedPath \\\\192.168.77.1\\agent\\' + SHARE + ' -CloneHost ONE-' + name.upper() + ' > C:\\one-tests\\runs\\capture\\controller.log 2>&1', name) + if result.get('error'): raise RuntimeError(result['error']) + deadline = time.monotonic() + 180 + while windows.do_cmd('if exist C:\\one-tests\\runs\\capture\\ready (echo ready)', target=name).get('stdout', '').find('ready') < 0: + if time.monotonic() > deadline: raise RuntimeError('The collaboration controller did not open the notebook') + time.sleep(1) + clients[label] = {'name': name, 'folder': folder, 'sequence': 0} + (output / 'clients.json').write_text(json.dumps({label: client['name'] for label, client in clients.items()}, indent=2)) + + def action(client, kind, **parameters): + client['sequence'] += 1 + sequence = client['sequence'] + local = client['folder'] / f'command-{sequence:04}.json' + local.write_text(json.dumps({'action': kind, **parameters}, ensure_ascii=False)) + inbox = f'C:\\one-tests\\runs\\capture\\inbox\\{sequence:04}' + result = windows.do_put(local, inbox + '.tmp', client['name']) + if result.get('error'): raise RuntimeError(result['error']) + command(client['name'], f'move {inbox}.tmp {inbox}.json', client['folder']) + remote = f'C:\\one-tests\\runs\\capture\\outbox\\{sequence}' + deadline = time.monotonic() + 600 + while True: + result = windows.do_cmd(f'if exist {remote}\\error (type {remote}\\error & exit /b 1) else (if exist {remote}\\done (echo complete))', target=client['name']) + if result.get('exit') != 0: raise RuntimeError(str(result)) + if 'complete' in result.get('stdout', ''): break + if time.monotonic() > deadline: raise TimeoutError(f'{kind} did not complete') + time.sleep(.5) + if kind != 'snapshot': + return None + hierarchy = client['folder'] / f'hierarchy-{sequence:04}.xml' + windows.do_get(remote + '\\hierarchy.xml', hierarchy, client['name']) + pages = [node for node in ET.fromstring(hierarchy.read_text(encoding='utf-8-sig').strip()).iter() if node.tag.endswith('}Page')] + for i in range(len(pages)): + windows.do_get(remote + f'\\page-{i}.xml', client['folder'] / f'snapshot-{sequence:04}-{i}.xml', client['name']) + return pages + + def settle(client, check, label): + deadline = time.monotonic() + 300 + while True: + action(client, 'sync') + state = server_state(label) + if check(state): return state + if time.monotonic() > deadline: raise TimeoutError(f'{label}: {state}') + time.sleep(2) + + a, b = clients['a'], clients['b'] + texts = lambda state: {text for values in state.values() for text in values} + server_state('initial') + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': False}) + try: + action(a, 'edit', expected=ORIGINAL, text='Client A conflicting edit.') + action(a, 'edit', expected=OTHER_OUTLINE, text='Client A disjoint edit.') + action(a, 'snapshot') + action(b, 'edit', expected=ORIGINAL, text='Client B conflicting edit.') + action(b, 'edit', expected=CELL, text='Client B disjoint cell.') + settle(b, lambda state: {'Client B conflicting edit.', 'Client B disjoint cell.'} <= texts(state), 'b-published') + finally: + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': True}) + state = settle(a, lambda state: len(state) > 1 and 'Client A disjoint edit.' in texts(state), 'conflict') + (output / 'conflict.json').write_text(json.dumps(state, indent=2, ensure_ascii=False)) + page = action(a, 'snapshot')[0].attrib['ID'] + conflict_views(a['name'], output / 'a', page, 'a') + action(b, 'sync') + time.sleep(5) + page_b = action(b, 'snapshot')[0].attrib['ID'] + conflict_views(b['name'], output / 'b', page_b, 'b') + conflict_views(a['name'], output / 'a', page, 'a-again', delete=True) + state = settle(a, lambda state: len(state) == 1, 'deleted') + (output / 'deleted.json').write_text(json.dumps(state, indent=2, ensure_ascii=False)) + action(b, 'sync') + time.sleep(5) + shot(b['name'], output / 'b', 'b-after-delete', runner.navigation_script(page_b) + 'Sleep(2000)\n') + for client in clients.values(): + try: + action(client, 'close') + except Exception as error: + (client['folder'] / 'close-failure.txt').write_text(str(error)) + server_state('final') + finally: + if linux_vm.running(server): + linux_vm.shutdown(server, 120) + linux_vm.delete_instance(server) + (output / 'linux-teardown.json').write_text(json.dumps({'absent': not linux_vm.instance_path(server).exists()}) + '\n') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + commands = parser.add_subparsers(dest='mode', required=True) + lab = commands.add_parser('sync') + lab.add_argument('linux') + lab.add_argument('output', type=Path) + reopen = commands.add_parser('cold') + reopen.add_argument('notebook', type=Path) + reopen.add_argument('output', type=Path) + reopen.add_argument('--delete', action='store_true') + args = parser.parse_args() + if args.mode == 'sync': + sync(args.linux, args.output) + else: + cold(args.notebook, args.output, args.delete) + print('Captured', args.output) diff --git a/corpus/conflict-page/native/scripts/cold.ps1 b/corpus/conflict-page/native/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/conflict-page/native/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/conflict-page/native/scripts/collaborate.ps1 b/corpus/conflict-page/native/scripts/collaborate.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..4a13a4320675250d49eac958bd3a020ca3b882f6 --- /dev/null +++ b/corpus/conflict-page/native/scripts/collaborate.ps1 @@ -0,0 +1,105 @@ +param([Parameter(Mandatory=$true)][string]$Root, + [Parameter(Mandatory=$true)][string]$SharedPath, + [Parameter(Mandatory=$true)][string]$CloneHost, + [switch]$ResumeCache, + [int]$StartSequence = 1) +function Keep-Container($page, $target) { + $container = $target + while ($container.ParentNode -ne $page.DocumentElement) { $container = $container.ParentNode } + foreach ($sibling in @($page.DocumentElement.ChildNodes)) { + if ($sibling -ne $container -and $sibling.LocalName -in @('Outline', 'Title', 'Image', 'InkDrawing')) { + [void]$page.DocumentElement.RemoveChild($sibling) + } + } +} +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot\text.ps1" +if ($SharedPath -notmatch '^\\\\192\.168\.77\.1\\agent\\m6-[a-z0-9-]+$') { throw 'Choose a disposable Linux lab notebook.' } +if ($env:COMPUTERNAME -ne $CloneHost) { throw 'The collaboration controller requires its owned clone.' } +if (-not $ResumeCache) { & "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost } +$shared = $SharedPath +if (-not (Test-Path "$shared\synthetic.one")) { throw 'Create the shared fixture first.' } +New-Item -ItemType Directory "$Root\inbox", "$Root\outbox" -Force | Out-Null +$user = 'HKCU:\Software\Microsoft\Office\Common\UserInfo' +New-Item $user -Force | Out-Null +New-ItemProperty $user -Name UserName -Value $CloneHost -PropertyType String -Force | Out-Null +New-ItemProperty $user -Name UserInitials -Value $CloneHost.Substring($CloneHost.Length - 3) -PropertyType String -Force | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebook = '' +try { + $app.OpenHierarchy($shared, '', [ref]$notebook, 0) + $section = '' + $app.OpenHierarchy('synthetic.one', $notebook, [ref]$section, 0) + [IO.File]::WriteAllText("$Root\ready", 'ready') + $deadline = [DateTime]::UtcNow.AddMinutes(30) + $sequence = $StartSequence + while ([DateTime]::UtcNow -lt $deadline) { + $name = '{0:d4}.json' -f $sequence + $input = "$Root\inbox\$name" + if (-not (Test-Path $input)) { Start-Sleep -Milliseconds 100; continue } + $command = Get-Content -Raw -Encoding UTF8 $input | ConvertFrom-Json + $output = "$Root\outbox\$sequence" + New-Item -ItemType Directory $output | Out-Null + try { + if ($command.action -in @('prepare-stress', 'stress')) { + . "$PSScriptRoot\stress.ps1" + Invoke-Stress $app $section $command $output $shared + } + elseif ($command.action -eq 'sync') { $app.SyncHierarchy($notebook) } + elseif ($command.action -eq 'kill-process') { + Get-Process ONENOTE | Stop-Process -Force + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + $app = New-Object -ComObject OneNote.Application + $app.OpenHierarchy($shared, '', [ref]$notebook, 0) + $app.OpenHierarchy('synthetic.one', $notebook, [ref]$section, 0) + } + elseif ($command.action -eq 'edit' -or $command.action -eq 'snapshot') { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText("$output\hierarchy.xml", $hierarchy, [Text.Encoding]::UTF8) + [xml]$tree = $hierarchy + $matches = 0 + $editedPage = $null + $i = 0 + foreach ($node in $tree.SelectNodes('//*[local-name()="Page"]')) { + $content = '' + $app.GetPageContent($node.GetAttribute('ID'), [ref]$content, 1, 1) + [IO.File]::WriteAllText("$output\page-$i.xml", $content, [Text.Encoding]::UTF8) + [xml]$page = $content + if ($command.action -eq 'edit') { + $targets = @($page.SelectNodes('//*[local-name()="T"]') | Where-Object { + (Get-ParagraphText $_.InnerText) -eq $command.expected + }) + if ($targets.Count -gt 1) { throw 'Expected one matching text object.' } + if ($targets.Count -eq 1) { + $matches++ + if ($matches -gt 1) { throw 'The text occurs on multiple pages.' } + $targets[0].InnerText = [Security.SecurityElement]::Escape($command.text) + Keep-Container $page $targets[0] + $editedPage = $page + } + } + $i++ + } + if ($command.action -eq 'edit' -and $matches -ne 1) { throw 'Expected one matching text object.' } + if ($editedPage) { $app.UpdatePageContent($editedPage.OuterXml, [DateTime]::MinValue, 1, $false) } + } elseif ($command.action -ne 'close') { throw 'Unknown collaboration command.' } + [IO.File]::WriteAllText("$output\done", 'ok') + } catch { + [IO.File]::WriteAllText("$output\error", $_.Exception.ToString()) + } + if ($command.action -eq 'close') { break } + $sequence++ + } +} finally { + try { if ($notebook) { $app.CloseNotebook($notebook, $false) } } + finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + [IO.File]::WriteAllText("$Root\closed", 'closed') + } +} diff --git a/corpus/conflict-page/native/scripts/network.ps1 b/corpus/conflict-page/native/scripts/network.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..95fa83d5243ee6a2a653cc075128b063e2f09cf2 --- /dev/null +++ b/corpus/conflict-page/native/scripts/network.ps1 @@ -0,0 +1,20 @@ +param([Parameter(Mandatory=$true)][string]$LabMac) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$adapter = @(Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.MACAddress -eq $LabMac }) +if ($adapter.Count -ne 1) { throw 'The clone lab adapter is ambiguous.' } +$enabled = $adapter[0].EnableDHCP() +if ($enabled.ReturnValue -notin @(0, 1)) { + throw ('Unable to enable DHCP on the clone lab adapter; result ' + $enabled.ReturnValue) +} +$release = $adapter[0].ReleaseDHCPLease() +$result = $adapter[0].RenewDHCPLease() +$deadline = [DateTime]::UtcNow.AddSeconds(60) +do { + $adapter = @(Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.MACAddress -eq $LabMac }) + $addresses = @($adapter[0].IPAddress | Where-Object { $_ -match '^192\.168\.77\.\d+$' -and $_ -ne '192.168.77.1' }) + if ($addresses.Count -eq 1) { break } + Start-Sleep -Milliseconds 250 +} while ([DateTime]::UtcNow -lt $deadline) +if ($addresses.Count -ne 1) { throw ('The clone did not receive a lab IPv4 address; renewal result ' + $result.ReturnValue + '; adapter ' + ($adapter[0] | Select-Object MACAddress,DHCPEnabled,IPEnabled,IPAddress | ConvertTo-Json -Compress)) } +@{mac=$LabMac;address=$addresses[0];releaseResult=$release.ReturnValue;renewalResult=$result.ReturnValue} | ConvertTo-Json -Compress diff --git a/corpus/conflict-page/native/scripts/stress.ps1 b/corpus/conflict-page/native/scripts/stress.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0ab5bc69b4dabe970373e481873dfd951a1a69a --- /dev/null +++ b/corpus/conflict-page/native/scripts/stress.ps1 @@ -0,0 +1,83 @@ +function Invoke-Stress($app, $section, $command, $output, $shared) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText("$output\hierarchy.xml", $hierarchy, [Text.Encoding]::UTF8) + [xml]$tree = $hierarchy + $pages = @($tree.SelectNodes('//*[local-name()="Page"]')) + if ($pages.Count -ne 1) { throw 'Expected one stress-test page.' } + $pageId = $pages[0].GetAttribute('ID') + if ($command.action -eq 'prepare-stress') { + for ($i = 0; $i -lt $command.clients; $i++) { + $xml = '' + $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false) + } + return + } + $random = New-Object Random([int]$command.seed) + $events = New-Object IO.StreamWriter("$output\events.jsonl", $false, [Text.Encoding]::UTF8) + $events.AutoFlush = $true + try { + [IO.File]::WriteAllText("$output\ready", 'ready') + $deadline = [DateTime]::UtcNow.AddMinutes(10) + while (-not (Test-Path "$shared\stress-start")) { + if ([DateTime]::UtcNow -gt $deadline) { throw 'Stress start timed out.' } + Start-Sleep -Milliseconds 20 + } + for ($i = 0; $i -lt $command.operations; $i++) { + if (($command.PSObject.Properties.Name -contains 'maintenance') -and $command.maintenance -and $i -eq [Math]::Floor($command.operations / 2)) { + [IO.File]::WriteAllText("$shared\maintenance-paused-n$($command.actor)", 'paused') + while (-not (Test-Path "$shared\maintenance-resume")) { + if ([DateTime]::UtcNow -gt $deadline) { throw 'Maintenance pause timed out.' } + Start-Sleep -Milliseconds 100 + } + } + Start-Sleep -Milliseconds $random.Next(10, 100) + $started = [DateTime]::UtcNow.Ticks + $content = '' + $app.GetPageContent($pageId, [ref]$content, 1, 1) + [xml]$page = $content + $matches = @($page.SelectNodes('//*[local-name()="T"]') | Where-Object { + (Get-ParagraphText $_.InnerText).StartsWith($command.prefix) + }) + if ($matches.Count -ne 1) { throw 'Expected one stress-test paragraph.' } + $before = Get-ParagraphText $matches[0].InnerText + $token = ' [n' + $command.actor + ':' + $i + ']' + $start = $end = $before.Length + $replacement = $token + $bold = $italic = $false + if ($command.edit) { + $boundaries = New-Object 'Collections.Generic.List[int]' + for ($at = $command.prefix.Length; $at -le $before.Length; $at++) { + $boundaries.Add($at) + if ($at -lt $before.Length -and [char]::IsHighSurrogate($before[$at])) { $at++ } + } + $first = $boundaries[$random.Next($boundaries.Count)] + $second = $boundaries[$random.Next($boundaries.Count)] + $start = [Math]::Min($first, $second) + $end = [Math]::Max($first, $second) + $replacement = ' caf' + [char]0xe9 + ' ' + [char]::ConvertFromUtf32(0x1f980) + $token + $bold = $random.Next(2) -eq 1 + $italic = $random.Next(2) -eq 1 + } + $after = $before.Substring(0, $start) + $replacement + $before.Substring($end) + $html = [Security.SecurityElement]::Escape($after) + if ($command.edit) { + $weight = if ($bold) { 'bold' } else { 'normal' } + $style = if ($italic) { 'italic' } else { 'normal' } + $html = '' + $html + '' + } + $matches[0].InnerText = $html + Keep-Container $page $matches[0] + $updateStarted = [DateTime]::UtcNow.Ticks + $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false) + $updated = [DateTime]::UtcNow.Ticks + $events.WriteLine((@{ operation=$i; before=$before; token=$token; started_ticks=$started; + range=@($start, $end); replacement=$replacement; bold=$bold; italic=$italic; + update_started_ticks=$updateStarted; updated_ticks=$updated } | ConvertTo-Json -Compress)) + if ($i -eq 0) { [IO.File]::WriteAllText("$output\editing", 'editing') } + if ($command.sync_every -gt 0 -and (($i + 1) % $command.sync_every) -eq 0) { + $app.SyncHierarchy($section) + } + } + } finally { $events.Dispose() } +} diff --git a/corpus/conflict-page/native/scripts/text.ps1 b/corpus/conflict-page/native/scripts/text.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..75e9e71207b599d16eaaf68824ca30831860610a --- /dev/null +++ b/corpus/conflict-page/native/scripts/text.ps1 @@ -0,0 +1,13 @@ +function Get-ParagraphText([string]$html) { + $plain = [regex]::Replace($html, '<[^>]*>', '') + $plain = [regex]::Replace($plain, '&#(?:x([0-9a-fA-F]+)|([0-9]+));', { + param($match) + $value = if ($match.Groups[1].Success) { + [Convert]::ToInt32($match.Groups[1].Value, 16) + } else { [int]$match.Groups[2].Value } + # The Win7 framework leaves supplementary numeric entities undecoded. + if ($value -gt 0xffff -and $value -le 0x10ffff) { [char]::ConvertFromUtf32($value) } + else { $match.Value } + }) + [Net.WebUtility]::HtmlDecode($plain) +} diff --git a/corpus/conflict-page/native/server.jsonl b/corpus/conflict-page/native/server.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..d5df993564a30ddaea1b427699648ff9b1a1417d --- /dev/null +++ b/corpus/conflict-page/native/server.jsonl @@ -0,0 +1,2 @@ +{"command": "mkdir /srv/agent/m6-conflict", "exit": 0, "stdout": "", "stderr": ""} +{"command": "chmod u+w /srv/agent/m6-conflict/*", "exit": 0, "stdout": "", "stderr": ""} diff --git a/corpus/extended-ascii/README.md b/corpus/extended-ascii/README.md new file mode 100644 index 0000000000000000000000000000000000000000..873405ca5918c84f20bd12c78c2bef1eb7adb4cb --- /dev/null +++ b/corpus/extended-ascii/README.md @@ -0,0 +1,9 @@ +# Extended ASCII text + +`TextExtendedAscii` (`0x1c003498`, MS-ONE 2.2.89) names no code page. Each `candidate` +is `m6/ansi/notebook` with the paragraph's `RichEditTextLangID` (`0x10001cfe`) set to +0x0409 (`en`), 0x0419 (`ru`, ANSI code page 1251) or 0x0411 (`ja`, code page 932); its +text holds the bytes 0x80–0xFF. `cold` is each one's fresh OneNote 2010 read: all three +export U+0080–U+00FF, so OneNote reads the bytes as Latin-1 whatever the language, and +`read/page-000.png` shows 0x80–0x9F as nothing and 0xC0 as À rather than Cyrillic А. +OneNote left the section files unchanged. diff --git a/corpus/extended-ascii/en/candidate/Open Notebook.onetoc2 b/corpus/extended-ascii/en/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..a33d3bfd210f844e3db618e648ac898adeee09c6 Binary files /dev/null and b/corpus/extended-ascii/en/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/extended-ascii/en/candidate/synthetic.one b/corpus/extended-ascii/en/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..d2ed44f1e9f620a51ba6e44d52867ebcb5d9ee16 Binary files /dev/null and b/corpus/extended-ascii/en/candidate/synthetic.one differ diff --git a/corpus/extended-ascii/en/cold/commands.jsonl b/corpus/extended-ascii/en/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..1b14b83ce031e1dc4b11a4d3f159a39c13615d7a --- /dev/null +++ b/corpus/extended-ascii/en/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-A375E105 -ExpectedPages 1 -KeepOpen", "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/extended-ascii/en/cold/machine.json b/corpus/extended-ascii/en/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..57863a786fdc66a1bf4d1f75151759a2f20903fc --- /dev/null +++ b/corpus/extended-ascii/en/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-a375e105", "hostname": "ONE-M6-A375E105"} diff --git a/corpus/extended-ascii/en/cold/notebook/Open Notebook.onetoc2 b/corpus/extended-ascii/en/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..a33d3bfd210f844e3db618e648ac898adeee09c6 Binary files /dev/null and b/corpus/extended-ascii/en/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/extended-ascii/en/cold/notebook/synthetic.one b/corpus/extended-ascii/en/cold/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..d2ed44f1e9f620a51ba6e44d52867ebcb5d9ee16 Binary files /dev/null and b/corpus/extended-ascii/en/cold/notebook/synthetic.one differ diff --git a/corpus/extended-ascii/en/cold/read/environment.json b/corpus/extended-ascii/en/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..fcb5a8538909915c2c9f4a7c9e1a70717a4d7647 --- /dev/null +++ b/corpus/extended-ascii/en/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-A375E105", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/extended-ascii/en/cold/read/hierarchy.xml b/corpus/extended-ascii/en/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..fa4d29784012bad3829a8ecd7052cd044813a56f --- /dev/null +++ b/corpus/extended-ascii/en/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/extended-ascii/en/cold/read/page-000.navigation.json b/corpus/extended-ascii/en/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..f55849761016bf753db6dc26da07ab49553ce0a6 --- /dev/null +++ b/corpus/extended-ascii/en/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Extended bytes: \u0080 \u0081 \u0082 \u0083 \u0084 \u0085 \u0086 \u0087 \u0088 \u0089 \u008a \u008b \u008c \u008d \u008e \u008f \u0090 - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/extended-ascii/en/cold/read/page-000.png b/corpus/extended-ascii/en/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..aa6b6e3797f63f2489c730ddf74e2ee8fc18f279 Binary files /dev/null and b/corpus/extended-ascii/en/cold/read/page-000.png differ diff --git a/corpus/extended-ascii/en/cold/read/page-000.xml b/corpus/extended-ascii/en/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..4a5baca4877801b2a1b4c88c5df1fda85d1d80f1 --- /dev/null +++ b/corpus/extended-ascii/en/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/extended-ascii/en/cold/read/payloads.json b/corpus/extended-ascii/en/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/extended-ascii/en/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/extended-ascii/en/cold/run.json b/corpus/extended-ascii/en/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..7d96357217a412779632df1272e8d93361ea1931 --- /dev/null +++ b/corpus/extended-ascii/en/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/ansi-lang/en/notebook", + "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/extended-ascii/en/cold/scripts/cold.ps1 b/corpus/extended-ascii/en/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/extended-ascii/en/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/extended-ascii/en/cold/scripts/read.ps1 b/corpus/extended-ascii/en/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/extended-ascii/en/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/extended-ascii/en/cold/source.json b/corpus/extended-ascii/en/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..d3e5ffe17dfc9c590b42235a151cbd45cbf9aab2 --- /dev/null +++ b/corpus/extended-ascii/en/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 4816, + "sha256": "b2f857961b76258a1582b402c048b340e89962257681426b3bfc58df8e0adbfa", + "mtime_ns": 1790487072902156985 + }, + { + "path": "synthetic.one", + "bytes": 12456, + "sha256": "aa6f1188773f1cc41cc5c619f213ce04b30592320c6b2e32b2b4c91639423ab4", + "mtime_ns": 1790487072902335569 + } +] diff --git a/corpus/extended-ascii/en/cold/teardown.json b/corpus/extended-ascii/en/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/extended-ascii/en/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/extended-ascii/ja/candidate/Open Notebook.onetoc2 b/corpus/extended-ascii/ja/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..a33d3bfd210f844e3db618e648ac898adeee09c6 Binary files /dev/null and b/corpus/extended-ascii/ja/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/extended-ascii/ja/candidate/synthetic.one b/corpus/extended-ascii/ja/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..c15b622081fc5df7733206820860626a18c0521f Binary files /dev/null and b/corpus/extended-ascii/ja/candidate/synthetic.one differ diff --git a/corpus/extended-ascii/ja/cold/commands.jsonl b/corpus/extended-ascii/ja/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..09a67abb1857d332e99ed1eadf88fdf023df14c0 --- /dev/null +++ b/corpus/extended-ascii/ja/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-8DC7DCC7 -ExpectedPages 1 -KeepOpen", "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/extended-ascii/ja/cold/machine.json b/corpus/extended-ascii/ja/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..7a96f7cfbb857707e13a4e759dd5cb9664973b3f --- /dev/null +++ b/corpus/extended-ascii/ja/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-8dc7dcc7", "hostname": "ONE-M6-8DC7DCC7"} diff --git a/corpus/extended-ascii/ja/cold/notebook/Open Notebook.onetoc2 b/corpus/extended-ascii/ja/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..a33d3bfd210f844e3db618e648ac898adeee09c6 Binary files /dev/null and b/corpus/extended-ascii/ja/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/extended-ascii/ja/cold/notebook/synthetic.one b/corpus/extended-ascii/ja/cold/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..c15b622081fc5df7733206820860626a18c0521f Binary files /dev/null and b/corpus/extended-ascii/ja/cold/notebook/synthetic.one differ diff --git a/corpus/extended-ascii/ja/cold/read/environment.json b/corpus/extended-ascii/ja/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..5d25d66b05678d093c31c8611399998b97e017a9 --- /dev/null +++ b/corpus/extended-ascii/ja/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-8DC7DCC7", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/extended-ascii/ja/cold/read/hierarchy.xml b/corpus/extended-ascii/ja/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..f007d2a89ff7d4305b16385dd20d179cbe89c671 --- /dev/null +++ b/corpus/extended-ascii/ja/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/extended-ascii/ja/cold/read/page-000.navigation.json b/corpus/extended-ascii/ja/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..f55849761016bf753db6dc26da07ab49553ce0a6 --- /dev/null +++ b/corpus/extended-ascii/ja/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Extended bytes: \u0080 \u0081 \u0082 \u0083 \u0084 \u0085 \u0086 \u0087 \u0088 \u0089 \u008a \u008b \u008c \u008d \u008e \u008f \u0090 - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/extended-ascii/ja/cold/read/page-000.png b/corpus/extended-ascii/ja/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..32aeda3c9e3dea9b2d0a652414d228d486b0f4f2 Binary files /dev/null and b/corpus/extended-ascii/ja/cold/read/page-000.png differ diff --git a/corpus/extended-ascii/ja/cold/read/page-000.xml b/corpus/extended-ascii/ja/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..10ac23b6e39ed1116912c93e9a83d1994aa09335 --- /dev/null +++ b/corpus/extended-ascii/ja/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/extended-ascii/ja/cold/read/payloads.json b/corpus/extended-ascii/ja/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/extended-ascii/ja/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/extended-ascii/ja/cold/run.json b/corpus/extended-ascii/ja/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..5e2d4b5264e879413dc43a7e9f6a66cbfdf9dd19 --- /dev/null +++ b/corpus/extended-ascii/ja/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/ansi-lang/ja/notebook", + "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/extended-ascii/ja/cold/scripts/cold.ps1 b/corpus/extended-ascii/ja/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/extended-ascii/ja/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/extended-ascii/ja/cold/scripts/read.ps1 b/corpus/extended-ascii/ja/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/extended-ascii/ja/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/extended-ascii/ja/cold/source.json b/corpus/extended-ascii/ja/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..741eec391e4355839f257cefd01dd18b8a7df53b --- /dev/null +++ b/corpus/extended-ascii/ja/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 4816, + "sha256": "b2f857961b76258a1582b402c048b340e89962257681426b3bfc58df8e0adbfa", + "mtime_ns": 1790487072903313695 + }, + { + "path": "synthetic.one", + "bytes": 12456, + "sha256": "b792b045a793ef97ded2a9d6ab456070fb5d9d3f48763067ed5e24bde482358a", + "mtime_ns": 1790487072903438737 + } +] diff --git a/corpus/extended-ascii/ja/cold/teardown.json b/corpus/extended-ascii/ja/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/extended-ascii/ja/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/extended-ascii/ru/candidate/Open Notebook.onetoc2 b/corpus/extended-ascii/ru/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..a33d3bfd210f844e3db618e648ac898adeee09c6 Binary files /dev/null and b/corpus/extended-ascii/ru/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/extended-ascii/ru/candidate/synthetic.one b/corpus/extended-ascii/ru/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..45c40a62a8fa360a8ffe1cf02ed36a71ddbf91c0 Binary files /dev/null and b/corpus/extended-ascii/ru/candidate/synthetic.one differ diff --git a/corpus/extended-ascii/ru/cold/commands.jsonl b/corpus/extended-ascii/ru/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..fd5f5a24943070a800781a23db1508f1cceaf637 --- /dev/null +++ b/corpus/extended-ascii/ru/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-2B8202A3 -ExpectedPages 1 -KeepOpen", "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/extended-ascii/ru/cold/machine.json b/corpus/extended-ascii/ru/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..c0c1f5fced309e8a81ce576ebbe21e6841635728 --- /dev/null +++ b/corpus/extended-ascii/ru/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-2b8202a3", "hostname": "ONE-M6-2B8202A3"} diff --git a/corpus/extended-ascii/ru/cold/notebook/Open Notebook.onetoc2 b/corpus/extended-ascii/ru/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..a33d3bfd210f844e3db618e648ac898adeee09c6 Binary files /dev/null and b/corpus/extended-ascii/ru/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/extended-ascii/ru/cold/notebook/synthetic.one b/corpus/extended-ascii/ru/cold/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..45c40a62a8fa360a8ffe1cf02ed36a71ddbf91c0 Binary files /dev/null and b/corpus/extended-ascii/ru/cold/notebook/synthetic.one differ diff --git a/corpus/extended-ascii/ru/cold/read/environment.json b/corpus/extended-ascii/ru/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..7bebbff19e046fca4248fbe1866abe0aded53b6e --- /dev/null +++ b/corpus/extended-ascii/ru/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-2B8202A3", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/extended-ascii/ru/cold/read/hierarchy.xml b/corpus/extended-ascii/ru/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..4bacc530ba83fcb77bd446d98f5f7235a277ca21 --- /dev/null +++ b/corpus/extended-ascii/ru/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/extended-ascii/ru/cold/read/page-000.navigation.json b/corpus/extended-ascii/ru/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..f55849761016bf753db6dc26da07ab49553ce0a6 --- /dev/null +++ b/corpus/extended-ascii/ru/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Extended bytes: \u0080 \u0081 \u0082 \u0083 \u0084 \u0085 \u0086 \u0087 \u0088 \u0089 \u008a \u008b \u008c \u008d \u008e \u008f \u0090 - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/extended-ascii/ru/cold/read/page-000.png b/corpus/extended-ascii/ru/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..aa6b6e3797f63f2489c730ddf74e2ee8fc18f279 Binary files /dev/null and b/corpus/extended-ascii/ru/cold/read/page-000.png differ diff --git a/corpus/extended-ascii/ru/cold/read/page-000.xml b/corpus/extended-ascii/ru/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..c4553c063a192e9509c702e94a3b772ef09903b4 --- /dev/null +++ b/corpus/extended-ascii/ru/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/extended-ascii/ru/cold/read/payloads.json b/corpus/extended-ascii/ru/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/extended-ascii/ru/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/extended-ascii/ru/cold/run.json b/corpus/extended-ascii/ru/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..ad6463bfc4bfb47f16fdb81a0794cba65ee794e6 --- /dev/null +++ b/corpus/extended-ascii/ru/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/ansi-lang/ru/notebook", + "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/extended-ascii/ru/cold/scripts/cold.ps1 b/corpus/extended-ascii/ru/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/extended-ascii/ru/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/extended-ascii/ru/cold/scripts/read.ps1 b/corpus/extended-ascii/ru/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/extended-ascii/ru/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/extended-ascii/ru/cold/source.json b/corpus/extended-ascii/ru/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..c9a2d1f495428e88c662543b6ae55467d85b3f88 --- /dev/null +++ b/corpus/extended-ascii/ru/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 4816, + "sha256": "b2f857961b76258a1582b402c048b340e89962257681426b3bfc58df8e0adbfa", + "mtime_ns": 1790487072902780320 + }, + { + "path": "synthetic.one", + "bytes": 12456, + "sha256": "3d9562625271091ff5c85c24325ab2bf12a670e81aab2caffb6b00c724d7e19f", + "mtime_ns": 1790487072903016820 + } +] diff --git a/corpus/extended-ascii/ru/cold/teardown.json b/corpus/extended-ascii/ru/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/extended-ascii/ru/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/m6/native-template-controls-01/ORACLE.md b/corpus/m6/native-template-controls-01/ORACLE.md index 1398b8768f069f10b066deef297000096e66ef90..0b45ecc81398e040e4768509d5e6da38c37ab811 100644 --- a/corpus/m6/native-template-controls-01/ORACLE.md +++ b/corpus/m6/native-template-controls-01/ORACLE.md @@ -1,7 +1,9 @@ # Native default page template and unaligned image container -The captured UI operations selected Hearts from the native Templates pane, selected Hearts - Decorative as the section default, and created a new page. The default background appeared automatically; `default-template.png` and `ui-operations.json` record that result. `read/` contains the three native page exports and PDFs. The clone was deleted. +The captured UI operations selected Hearts from the native Templates pane, selected Hearts - Decorative as the section default, and created a new page. The default background appeared automatically; `default-template.png` and `ui-operations.json` record that result. The clone was deleted. The section property 0x2c001d62 refers to a separate page object space whose role-2 metadata has JCID 0x2003e. Property 0x1c001ca5 contains the UTF-16 template name Hearts. These native template fields are not in the published MS-ONE object list; the typed model identifies them as observed native metadata. The original personal notebook uses the same relationship with the name Ivy. -The embedded image object starts at byte 11396, four bytes past an eight-byte file boundary. Its payload is 10056 bytes; the footer begins at 21492 after four padding bytes. Native XML exports the same image bytes (SHA-256 2035407a0540e1c4f7934db08ba4add750fcb9a62863ddd9553e7871c81a99e3). MS-ONESTORE 2.6.13 pads the object length, independent of its file position. This fixture exposed the reader's incorrect absolute-offset alignment. The regression relocates the object through all eight offsets and separately corrupts its footer. +The embedded image object starts at byte 11396, four bytes past an eight-byte file boundary. Its payload is 10056 bytes; the footer begins at 21492 after four padding bytes. MS-ONESTORE 2.6.13 pads the object length, independent of its file position. This fixture exposed the reader's incorrect absolute-offset alignment. The regression relocates the object through all eight offsets and separately corrupts its footer. + +The stored picture is Snowbound's rendering of Hearts (SHA-256 6a0593db927c1a34a94cf11093e5bd8700843323ed4fe8c905df0c595cb09c26), written over Microsoft's at the same length with a PNG comment as padding, so the file is otherwise native. `read/` is a cold OneNote read of that notebook, whose XML exports the same image bytes; `default-template.png` still shows Microsoft's art as OneNote drew it during the capture. diff --git a/corpus/m6/native-template-controls-01/notebook/synthetic.one b/corpus/m6/native-template-controls-01/notebook/synthetic.one index cebce593d432621558945ae7b5a7c38df5f0edd9..f4383ebafc3d7ba7debaeb1bd0b749220679923b 100644 Binary files a/corpus/m6/native-template-controls-01/notebook/synthetic.one and b/corpus/m6/native-template-controls-01/notebook/synthetic.one differ diff --git a/corpus/m6/native-template-controls-01/read/environment.json b/corpus/m6/native-template-controls-01/read/environment.json index 00b1c91c33877bb34f0dc2cf539d0a5a33ea3403..5f136cd4ef1353e4b1f1e9c219aef40a69ad2a86 100644 --- a/corpus/m6/native-template-controls-01/read/environment.json +++ b/corpus/m6/native-template-controls-01/read/environment.json @@ -1,7 +1,7 @@ { "powershell": "5.1.14409.1005", "schema": "xs2010", - "hostname": "ONE-M6-A33C1CD2", - "cold": false, + "hostname": "ONE-M6-F782B029", + "cold": true, "onenote": "14.0.4763.1000" } diff --git a/corpus/m6/native-template-controls-01/read/hierarchy.xml b/corpus/m6/native-template-controls-01/read/hierarchy.xml index ec464dcc8c5f9366feae4ca0516afde08819b5fc..a55855c144608eded7f667f6382bf012aa5ead4c 100644 --- a/corpus/m6/native-template-controls-01/read/hierarchy.xml +++ b/corpus/m6/native-template-controls-01/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/m6/native-template-controls-01/read/page-000.pdf b/corpus/m6/native-template-controls-01/read/page-000.pdf index 6de9701a0cc8f7ec5a0296425c0521a3b8b0db5b..683072610667c97b348d7454745f6ddc75d03733 100644 Binary files a/corpus/m6/native-template-controls-01/read/page-000.pdf and b/corpus/m6/native-template-controls-01/read/page-000.pdf differ diff --git a/corpus/m6/native-template-controls-01/read/page-000.xml b/corpus/m6/native-template-controls-01/read/page-000.xml index f790c5cdcb93e85fafeaa6348f8119a9e748dfbf..36138ba823e2dbcc3096ab3ca02ed671b81ac8a7 100644 --- a/corpus/m6/native-template-controls-01/read/page-000.xml +++ b/corpus/m6/native-template-controls-01/read/page-000.xml @@ -1,179 +1,2 @@  -iVBORw0KGgoAAAANSUhEUgAAAKsAAAIoCAMAAAAhjet0AAADAFBMVEX////d0dncvM/S6+W+4qyw -3t7akbqZ06ir2qrewNLB5OHcxtTr9/fp1eH9+/z9+fvcmL7tyN274avlzdu44d/cvc/jydjP6uTE -5ub25O779/n58/bbscncw9Lbuc2f1q3Y7++03quy3t6i1qncztez3r7gxNXm9Oq14ODu3ufbpsTJ -5+Pcz9jak7v6/f327vPcwdHcydXam7/F5uLjrcvy5u35/PrcvtDcy9bL6OPO6uq/5OT1+/vczdf7 -8ve24N/cv9D46/L06vDcxdPw4uqf1aiq0rS84uDbtsyb06jfn8Os27jw+fm64uLhpsew3KrM6dTa -lrzaocHD5eGm2Knamb20397outTZ79/x1uXbq8fbs8vs9+/n0d7z+vTi8/Pqwdjr2eTcytXcwtK/ -5MnN6eTT7Ozd8fHansCy3aq64uC54Kvcx9S336um2bPao8Ok16n03erbrsjbqcWp2anG5s7H5uK7 -0sDJ6Oid1Kjf8eTp9fLn9fW+4+Gt26q54cPvz+HmtNDM0cyd0qvZvs/U0dLJobXQ0c+y293Y0dW/ -0cPOxtOu0rey0rr0+vnTpbbA0djYlrnd8OzT7NnRnLfLyNS12dy419u71drH0cni8u7SwtHRxNKh -0q7Gzda20r3m9PDY7ujD0cb8/v22yK7h1t3KvrLg8vDWwNDDz9el0rG9rbLNnbb7/frexNTIw7HP -r7TMubPV7Of7+fq909m2yLnq4uepzbHPpMPWm7i/3Ky6srHFza/d2eSxuq6dzqnn9OC+17LGyLDb -79Du4Oju5+vKsMi8yrzIytXL6Mru+PW1tq/Gq7Ot267A47r9/P273cTewtPk0dzf1Nvh4uDfq8Xy -7vHp2OLy6e7M6eiq1Kr6/fzFytXQ6sXz+vDr9vTVqsbZ3dm02KvS7OnY1+HD0q7B16339PbBqbPa -7+rCxMDXm77T7Oeu3LjYydLL6ejQpLvKuc3G5snXp7za1dfx+ffgzdne0dq94rCy2dvE2t+lxqvA -ysDRqrXNtLPU7OkRzzSdAAAAAWJLR0QAiAUdSAAAAAxjbVBQSkNtcDA3MTIAAAADSABzvAAAI95J -REFUeF7NnXdgFMe9x/eW050k1AtCgBoCgYSECiAJ05Gw6YhmejXVgOk2YGxckzixndhOc3p7qS8v -eekvyeu9l7zee++9vzd9Z+vM7MzsZf4B3e3dfva33/m1mbtzHJujZrC3urHCJWPGkcnZNs+m8d7r -T/RQSu/fnt5ZGm9p56Wzq5k5g8AnauycMuW7DjaGLeo9UrE+5dtaeNnsRFLIfMLCWVO9ZW+STclz -1ane2fSLamZIoLrutwPs7NgpFbiEXtNGUn4/aVTXLbWrVUB1ZygbwugLaiKcf7x6lxk9t+qbCX2V -D7xH9e1NHj8p5QG8g0po2BpZF8ByGZOGUnuvakWzum7J0phZyqjupJoxzB2tbla30dzZld6pJs6s -Q0PT4ZgYGgsfoXQGcwdHOYG1Eyfn5bzRf7ItgDto7vwq7xRKWcYu8pyEuH96M49bGsEGZ9bQOGdQ -/r/zhjjY0uSxy3x3dyyOFFJP9w4tzeQ6wrNOxNiUPDzMji1N/sJlLc0nk1FzuQkGqzIjTB3LybV5 -lQg1l2OaNXV+7n1mD072wjE5GJMhDzJLyaDm5lFvYJZ19mS13x3NOBFRMnv14LDYqtz8Msg6GNU6 -cd2K6qATP0HtKphW9EL6iWFNsdb0JqT5M/zJJ82yx/qlzJrLkQhmhrWmV5COzuBtS1mT/KrvKlbh -G2GEdb1E4lztNaaIpIckrQoOw5mMAdYauQSvh5mWyFXarFQE+qySvRMASJMPzDomb9YcDl7arPKo -rNeDWS8qsM6DL9COsSqoFBazRiSB8fTwBdq5C/OVLBYl/gd10ZQlgOPsEU0NeOFSjhW1/NChknGA -mBqy6jbglPo8iBHAon/lwitVBUy2NOuC9ZLW5A9b5iBvLJFgcfKFKbdmveVLmgPcQxPD43j+zBu/ -2MbVpbjrruAFSPqit8oRWzqPTYQc/byLFLcHxq21yqwVelMrRgIPxeT6J7lKTyHAkrJL02VFLkwM -JTjOYSaFh5TtqlnGRrRQBfVTP62epiuzaq5zhVnXCqPRSZw4q7Nq9glDHmutRPa8CsEqsg65uo3t -IKsMai7XvzYNq+4aV5BV0r9DWEW7rnV1e/CBCCt9flBFSx+L56B+W9s/t5olxEpm/6pmNdZ52nJ1 -/KwqmdOwysG53Lj+Yrc/FkiqFZtW2Mby+d/prqZ3dRxfjG1W8+5KR7dpJgMglfD1fBUjvBLrWl2P -BWB5R6A2W5RQc/oScBy+M2CRdVxfAn7BWmSdbkACDi5H8LDIOqRZvuAsnROBRVbdvAWzzvbsqubd -VebWSd1qm1Q/Xhvbns9q00xdaaHmLVXZiwW/plcVeq/2XKywJFC579yxq4zMLEjsGVatkyIP/kum -zMrFrjb50ysd+evmWJlhLQn2W+ZQHYe5ArU8T9a2v2qSlfU1FfsTcrDf+nmTrA7rwJnyBC+8/hgY -r78Ar+YrRlEdttfKTOh6/qUiGS8+k8t90Swrqw8UqsN4AXzkWYoK/r3/DwyjOkwFBvIXZlRCfNM0 -LFVBs7ZiP8gZFf93y1HDtNQX6MaDj4RQi8WOw4ZhafmtsHAZIdnnea162PsNw5K+hmJvPcB7f4RZ -4UOGYek2YZ3p9UwMqnFYuv1aqf3it+v7Y1mLhjVLujDpVfB6PGqxw7A3IBlX6ugVp1bsugzPLwKb -Mt96PsGs4CnTQQGX4FK7rsIe67Fk1uICw5bFsHLrBkFafmZtKAfjyiYfvWkVkOZGmvnFSaD7Qh6N -hh23eVrDXtZx8OaHFLH2zQzrSUyKRjkH+4hhEdDCVh32RUq1g0PN5y9tsBdrU8NSpHIfKhBCN4M1 -rljQ5ELdQ0XLvpEQbQiggj/3MFjD0QtqahYqbdVgqVwvhVk9WNM+FsLirXBKsCTJ3hOBms9fIZY1 -P7tQ6wjqYEh+dS5HAizxVkHN0glmOCsgjmU22ikin3ThLLs70qz5/AUSFoy7WEILSwXx1nESvV7A -d/l8DGt+B37ehmBx0xuaVjL3xmn2pjjUfB57rvnGwwF7w0mgWvFWDWhazBo9s9AFXEAH2JlcmBc6 -hGaZHZmPIRR/yPIbGXtZ3q5HX7v3CDL2zTujRuwNd45JTDHM2hCvAWJYj2n/FixhNDpOmfEQg0C2 -EyLvhVhvJ6ASxdLIdQdZlB+nzNh2WY9QCChzSZAruArkCnDCPXovSApte8eIEJz1je5Y4noCCgV8 -Mhg2cQNjXdARgQoeOmUG1hmsTqRFrF2JGsjD4AXtuj+aFDy6xYwOQEbT2zM2HKdbxBodYNkF3ACH -ANRTsagGYcG6+JHmiehWImJNNmv+ScSahGq2UVezrHEoyriQNSJ19dF3QdZkVBAtTMkAaX/W5Iy2 -UAsBssYlLhQYhK75ryEB3L7RBV3xpR00WeRkYUyzZKKGcWVY8+AWQ6hubg42lPsLc/C0+ZwhgAtZ -g5VWSL7IepsCuVhDyLY2krHZ1d5Mg8sEUqxXwnH4fNC0hoKC31uDz1I9hNPxxyRZI6/nkq/lAZRi -dH4x5FlH3DbocyVZYxKxBq6JAHViQwW4MhsDpoVlrEgDl+JzxiCsmawrHLJBO2EY5doi1q6EfKHB -LwNTmUGIFnxW6aQMa1J2m7/kn2C2DAsaNc2rJOyaHILP+9KEKUMpV/htBt21oOa+IcgHBE/D1IYN -i4VZr/tVcYwVsPola82uYHPHl7VZaQ8B29Yi6+BfisotCYHwvsAiq/PPwBYSOImHkOaMbbs6s8AZ -BHWB8FJQRUaGTbs684X1lpCVNJKs29W5KQ5cQlhuJcSqXe+I+gNC0nyeiwdWWUfFBZeQFlZkWejV -Af0pIYzggMxYgWAFzQzhpXisHVY14CzQn1yeXk0nhbWt9ceX4lF/tdZxOoRVt8iwnh8wuih+9fjm -gm883QqafyIYwfNsidHgCmNtfQAUUf/9f8WvbUhdhBe3zJm1vs5vUvbX9+mlsCwfMNbOWLc9hhQ8 -/DNS9os7iOZZxjbyHIgnBc98XAOWmdXUil0yaqHwzdSwDbQ6NOWvRKiF70nNSvtapnzAukQBoCc/ -lxIWdpLhMNXTrI1yVQH8lIalYjWF6hwXm7VQ+KE0hqWo80213VbLoBY+n4KVopqaVo6zT4r1FXVW -kgd0mHJWjlMrhVoo/IkiLO1sbzG4decpP+veywfnbAQ7DNrnHNw1wD+lGA9oW9toC2spBzRysJ3/ -RoD2gxzt/6rY9QJxq/ONdgY5CYwc4kHR/9vPsSt5qzwrXYZ5xFxihQqKqxRm4FqIFD6wiz4vzcpI -zc0pUvrUE5a9vrvPYTNYObte2IMTgPkpl13WHahfuhRmp6AsObDaX58Rucaiuu07ydVIlIgNO/By -RsfNdDq9ui+QQ29/ClRRbOC0dSDOqsDAcwiraOHgUjkBPZXOpKuPRyb7+zzjYpA5kVolD+7Fx+yJ -F8Gl8+WkqNpyM+V8qo0tS+rqqWERxq4kVPcgZmWLyF1Pwr3QZOzp7iYZ6iP3phakjvtJZUlhOzYt -Tgeh848f7TxrF/Kc833j3tTUnZTmJCZbF1fr4VPXXYXHtYrN6rpIBG+HdgWB895+g0FTDhWc/QBl -DccAv5EvQ9Y3AL1e2DCV+i4ntIdqk62KTAtgoV13JioAPIkE+4byfMPfpHNFoi6WVKZ3ALFeFrEi -r/WZroZ/FJ003fNyCXRhHWSNDq7cBRyCrN/I/2Q6FOGros26d1cfGudoLKqDfkAkV9eFrB/7HeFJ -Ux4QVuvOy7zD33gd48IQK5IAZv3Bz6ZEEb0sVEWPhG/0QWJc4dRCrK/8gOicaZ9nmR72pQPXI23X -h54ckbLrr6RFEb6OZnoYdW+cIg/BvF/MuhEcxYKy8NyqB/hYd8YnUe0gJIlZoc9qVUWQPt7HmjTP -AawcK59HSmNIHcizJrv69p1i1uvAX0idNtVBfOMvOYlyD50Tzi0wB+3JFadPeAhRxKFgpFBYl8pk -ci/yWPuEZhMeMFDYLHfWdEd53f/E8kSICQ8ALut4Ogq5V3l9Sn3Wa3YlwAlWn/WyXQk4Dkte9Fl3 -2vQCUCZMBMLsVKRZINdA/0NOhvJHsWRb2w9ctxkI8AU9LetfRXY9V0AVr81Bw8GAiEXwfLvtmQWN -QJvAmoK9jmpzy4PWBrv0DLu3zl6K5VmA1IdJPUDxZRyy7bAwL21nHBQTxR+xKxOzOg5ZYpEo/mJZ -2wcsZoO+SUCml4Zh+zIyq+OsxpE2oeASOayBpyx7AO/tSe2dOnYdtJq4BsxA0gJx7h9p4PYBe+Vr -xP3Ckt2bzhX0Pe2srFqZmQrIRoZUKmj/sf8sg2PFmYx4SSs+RRr7lnciUjTuy4YWww4ISu+QSJ57 -m0cK/9eZiRRwxhW/Mhip5efe5UcFSsjEtLizIe6vcNBh1LKyhVuzMG0rkoFKwhUQALbxwmNZwGLN -ysP+RFAA5O9sYNEeLFnYT74phrUsExnUok7MuYT1bE6t745DzUizuAqX8gbvi0UFml2chWadq1C0 -AxKpARcDwtQrMmF1VqPkQBhu35Jg1syCguMcgKYdEYSweLXii6jKxrJOLSwYBxJN+95ks5aVbcuI -FexugELYmZDKRIYBH382qQGySCv0tSNxtM9xvvWBR6eBsfyJB/2mzsgX4Nt3IIH2HYyr6TQkRWPJ -XB/tfZmpANkWKmEkqsD9EqV6gJGC/7Q87IPNxskyi6yG+7Z2Xg75hEhUQLubh81QsaQvcwD2Pfde -9+FS5+qzKtYBB5udK/DUtvopmCbsve6lCZ/CRGtaeAUQ0XKwdzNVLD0Zxj1H3QIpBxaFUX2WzXZ2 -cYZBO/nw7tfnsO3CCkDsnmYXlsSunheD0QzLdW6EAhDsA0wGmSSycQYBS+MgFf8AgqmMUgB8rGUN -hT1bQsOC4FsHdmrhvMULAkHo5ZS1ZIIlPmx74RoKsDFqRdyVBHZmSe0KW6B/hUginQCxcAvNDUrM -6rR+DaI+GKdWX0goNavzx5D1iUTWacSwJXUE0FCdkHV5MitxsllVB7H37xZ0rsmo01rw7Co560wA -8bCAdRpOD/2sK6uOdXZWZcq/AkDsFrHihIvjOnZrIXW7M89mltpKyHXatNM+1pWdDBQDZ9RWduC5 -RGadhj0B7sYuDpLCp7LJxMGJmsSsSLAI9e42evN9/67IwKGtBGcUeFcaZ2FSuPhMJCl4MINObZXM -1MI5N8gHVsKZGDes52GQNSkZIPJYBGeQszUwpwLUthMxyBqfDzIhQ9azx5JRrc8wyCqeWtOQXRPu -P3nKbsNegRXzzH24chEYj1ayeoG/BKvFLmCVcFnIrnA0LfFuwulKfxsJPm91kUmJtSkwC1u8wpEa -d4XFgLtYzq6PwrsfkTYsCpn2jMWMPLnWoncc1FxrIt2FV+ZSy1rMvBLKbc4/PFD2QEwDoaUp4B62 -2VOBHGtTbP9g2rSgQ7CXx8yMb2NwduWmf8gbB2Ww0NrquBxrYrhgRTlRg7VYe0bKrsmhbXnAG9hS -bGdiz0Ui/MJD+KYyzBws+a27UnFLxOxfW7C1KFplhLXFrwJbs8sIq38dxJoItok6GaL7j5/3Ld7d -siTYmTL5q5jXN71sCbZTpi4Qs07zKdaSXY/J1FsSrE/weYEl1q3iFpEE6TSWjiNkS6zOwoS8RIqS -HJSBXZ2ZMkWMBDSfHNqya2dyB16CEh/CFTTW1u2qyuLW4aQx0YF0vQZowd6SjSFHwLFaTLfNTC6O -1V7N1SnswUuJwWO1uNdgq5mMwPMD9iTgOAsFa0ZSZuWCrK2UEHrC+3bL0SQexdaYy2y2M5xjJgTL -8gG7e7kWm0gLWQJrtVfoOLfQbjetwfJXaxU3idtnJZY3kq+EFVy20myWYazUTgloMmCzp0lwV2h6 -LaqADFCdTr0wSxsvWaA6W9foTCyKestWc8ifDm+TWDiKuxyKajO08rQaoetRXMFus5dcBaqM9KGL -pFdnsrn/EJvmWsslFjx5LZzG2dXMzIwKYfH+nJakTWUResVLXNvsrheGCs0VS1B9pxRsl6AcIGtS -x5kJPeyjKvELk97K9O5jE98HuwRN0ons6UpIuu2szbQ6tsnQCViXS1bfLUtgH3tFZwb7RiJ5zwDW -J2Ry7uW7wdRfceZudj4qxDsTzK0HoQRe/dDjcLzn1b8IzftFSyqbts08c7YEEuV5P/rXcBfW8i88 -/lH+0Zdffvn3EPnjn/jEJ85WVVWV0JiM66e+AIy4pOxDPlJbDbSk962q6uz8u9/4o/9bsmgRTlM/ -/Oqr73n88X95+bfwq17+0IfRw5UWexDi615c1XlrW1lT5aLY7tqHX/VUWWmvZSZC3doJdlPNVYhE -TSVivXsf3KCkAAo1UArWlWfgpr+5lYp91RKwVsGtwmUPolxEaSzJem5VwY3C6jaFF7Xc2vJJ5NTC -pGXcZ/FULJvNZ7cx+GK8jS5qc5IU8sPZ5cx38d7ENan7Ertt96KoFohRIz/fJmVVkBBkNLm2km20 -igWT/yoezCR1riJ7U/XWAJ+wtVOFdwTHyGJo1AccJQWAvFYGkYuizk09rcgFzbWeoMINt2hIl3Zx -tn7AttdaSfdRazX7EP6jtnaqUMGyjfSK/Z0I47ZYW6HGsOgTWChdUZhFcYc2WV1FgZ+8MKRWcAF2 -QxfOVuDQaPcyMy+3Gbo8s5pZAZ5rsZnifUJBL2RRyz5sMXR5n/uQafCIZ5/FQoaFAbl912LWRfaK -A+awTLG22Pu4pucFzOwNBQ24TlHjIe3z3Ie/zMytafY6Gsy5gv+IxShzRKW1MMuz6iaE+EqWWKtm -eVbtTQCIdVGZrbyQ/1ididwFLHFZ21vD+QGpDzlKKNbabkDfhwDNeII1tibXWV6wZjYxNpVZKrq2 -+lj1ixigkSZrkcv/8Wrt4hCwVlpzBP4PWGsX3YjVVpT1cm2kBs1mBnQTlWXWilmf15L7pGOy46q0 -5rQcLoNFlk3cvdQiUZNVWvyKv4Bhy5bEm61FxlGADSxpsz7h6wKKLYuHbVlTalavmUF9bYxlwT4q -mZoM2NViLRv6toXIjAtuTpLZigeOs7joHv6+hfCawSL0kdvSszq0/8oF3Cd8i4anyZ5PmW1CYNuF -RbuCnTW+tAD/wRZjT+9mH2OW6STaZnWCjgvjrmkCg35gDapahhVcmFW7OouTvs0Eca+4K8kKDrPL -KoRdsRimuhJ6hV9AZZlVAHvfYgdmZBJ+AH41hm3WRFjY/IOSlmCFW6+tszrIclFjG4pDMCuXqMjg -5y8yYHXI6nGAtxPXT/BRibIcbmjNgjXqe5jYF3GhKxBvK4FHCfMlMwesPMO3N7Zxv5uBWBMSRpxK -oo8LmUGReZeqzpnQ28685dtICb7JBQzhMjj6fj+Zs9g8BlfnwgUQuFs0gxVkwZXiGScSAYzI3y6s -Aq8Fv8zHWs0tLxviyZLTF5Q7/kMWv2uWCE46NMmGRUnZ156Wt4CdI2namJS/4M82/X5hnx0E6Xel -EfjBhHiAPoXxJndvqWFZ+zPex55GZn23u3GgsNTyr1wmm9hrf8b6LVyVfcB1D+0s1GX1s3GR1Cz4 -xnUTycdx3wK+dbv9XKGwub50/sArIGMkSz6R/Rz63Zg54DePC/us/zBnjBa4yjyy9Um+6vdd9Cdu -Nl4Gvz9fVyJcLgOLgKUfxfsU93M813aB33Sv25fpb8hhS/NdhNBXzbGvF3uv/9e5EO7m+qzdgq+j -ONcfbBnqm0I/JNZ+EPwGfeHpjI3r73jwbaTlrDXztqgfPZsD3EJhcwY/1epNtUAPfO5uEsJauO+6 -gB4rYmzclTUt2iLPj4crweC/VSwsAQqOnNjm7JTAth4GmdnfvBcImvca8GEZBl9uf0w07ieTfm+y -/TL82anMwll0Q5FxR84sDn8ONG1WQhCo4H2in/Fsh3OscDybTAG2NmPHl0So4PnrEHb7Oum8WedA -/zK+HztRrcwhgEBWqMvG2cZ/STLIXGXGIQhbOK5jMOnXxsG+E2eD4oFhsynKYmADWUsCM4bdnskM -i4R9h9ig7IgsYSMmmAoqqMigYTOybFXwu/3VUF33YIaw/q/3f6cwCIQEguJtIaMWzUr0WU84/uO3 -ZT0ATwwz8Ky8AfBxdztvgZby96b7/XnQ8YDjKWlfaeLAzSl/fh5F20Ihu5TWcepT/u6468LsG4Tb -7OrG2rqUZnXdjdiwS03cXKn3OJ5OrWiWYV9QyKr1tbqQ2qyg7YWnVyGbDNHZN6AQWkOH9mHW7VJ3 -UPeg1eJf7U26FqLYbBzXvoLqz8770WGTA/qCDFKu1YVzOhJw3WuYNYvMu15nZqGrJKwF6062tq4g -91vj8cYnIrBfJRzQlYDrEk9gX7HbtSXgzqEisJzDrCtoegEgDca6Wdd9Jr/+eCF12sIE3E7tajl4 -wd8O1R6M1WrD4GqhcE0bFReJ1gMt+PV2vaAFL5TptVCwGbvqCuK8Reh+qc8ClrVYIAAvMCKUwGUR -LK4Q0bDotfBvCAtGn+AQmmhBVospN/jla/jjzMmjT3AMLrnwsMdaC95dXBIAOV5PuBpSymJWe32N -VvDu9PfE42ng1ImXAW4U2bcrkKsEK1oeGImZYJwPsKqBz/6TFCvW40CUDtDiHD+steV/+BUF1kJh -58GAbQ8GSe1Vs7/7h9AiYr2Cg45j2w3sOoh+gB5U2nP6aIbNm9VWojX67x+TYoXus5XAIq6REdIS -CNx/8Kcll3X05/I/LcUKS79W5+kwWPgRS6Xs4Z/Nd30Dni3JdaLbDd1Aq1MLwoZwWDHr6NSefP7J -P4cnF6avcPkVpCQSsDYacEenOm7k8/nyNyD1CSIsSvhg+lQrkoH++tHoAt/YP3VqS7G7C6DmryBW -UcWNpjtO9WDoiB9LNXLXo3em7s3vKIZG9w1EyliT6wKcR5O0tHVzPGpqrS6YIpS3u8vLu7oIHEZk -o/w70ZmTO0TY3dOOSm19XTTtvnQ9l9H997Atu8u7GgJ4flZy2qQihiRRXi0cRbv5eCpSCrppz/lE -TgjdhfWaZFjSVvOHo6v7OCnULa1P1yU+egrrs3tHkj3Zc4Q1vpKdQwJUyBnVtrbWg3G1Ne18WjAf -3/s9F6RI8/m3EhEMkCAf9F4si049b2L6KJS0XHjv2ZVQ1sJAVAKz0cuizG4rPUpsekXWphD4u705 -3RdMpdEmNzrS3ukos45O4bt/O9o5xUni4xzOQB/nDub0cWW02SJqwSMY9Yb87Uf43/T7yp0jl/v6 -+i6P+DjhIQaTfWLUTeclp5R3WFLI9J6rM9apHN1C7v8lZdT8/0jBGvMCh0nE36B4/9GFfU6G1VgC -rYWa/zosDkXDlFop6qY0VgWG/bwI1NwKNkUtptAqEsGfCg272ZBvZajl6tOKvIILB5EmrkuXmIQ8 -B/UAxQ2pUb2cIFoMplCdUzTlV4tW/gv7+tsTJKtTlvhMu4CidmuYNZ//TVwdRAyDHzMigbVYlEtW -4y7o/Pd/Jho1ZVkSFeT2s6JPy6z5hmLxx5eGaLc/ZWj+I3RmVj0J5PPdxQ5n3XGurbJ934FU9VNs -2nCHmTW9w8I3pLxYPAxP0wrqkqutrWYxET+pVQHxk3oayHcVi1PGMqnIN/KaFDoeC11msbjFLqvX -TtFm3VAsjlqF9Vh1NZDfUyzeyYhVd27lnywWb2bEquuz4OSanxFrUdMPwGhQrLEJy7Uq1WvCwNWB -9/rljFi1RdBdLP63TcPyLWBdrwVYv1xt0bA8q65hbxSLX3UH7cGy1AVCayoWZATF5h57KrjJG3aT -Ssct7DYg69+6R6wZ9jDPqlVxoUyr+Eyba0+ypIlJkPckONkGkUQg6+s5i7BeAotw42EbNoj8xHnw -+sdyAPaILc36DRubxjbsKUqy5oabeyx5A79i4yzbADI+ESvUwIs5MOYBHcyyMsV8rgDKIKKpdQmg -FkVeArLeD1kt0pLOK/MIG0JtrR2b4JOixhzHmsv1D689st68bY+GVlv9XfgLIHjCIaMBYlds3Ivf -VW0cd4HPycI/Nt1gtj1/BT7QAaagKqsdXK+h4VHfvlJeXn6jG939YsdhCVZoft6uRLoXf9GsdaNg -OWN3HHaAqEVVTjQrIB5vqzgx25x2E2EBqgPIbwgqB3gLwnZF1u2/ODZj0liESICFqJBV1J+FNyKG -FeAOj7nVpoy7IGLvBZLBFtT6ETstUBsmsSLaRkN+ga7FBpzCKdyggI8mdz1BzZ3Mimh7lpkR7lTY -tI/Q9gQEuZIoWNDLELHmctObTdEeZf14bNyOKdb1QX8nRi4YhhP0ij1Yrr/NdQ2lNqOveRH33n7u -diHWJK91AR0BckLRGB9z3UZDs2x0wRQYry3w6wqljklrdTtkWXP9E2DnxgljHiysf5zmJhgWheLi -R0RWRc+PN7tuhSGXEDFVyYp97Moi7BCB8YwUa65/CJi20U6O6zgkVsTGAyyB4vNyrMAhANiKSTP+ -K/guRzFLbKBFXqBYlEUFvhbowFZlRrse0QEBBS0Jl8VdyyoI22PIIfhNm7wUStLxl+TtmsshWNeG -DlhxvilifhG1Ft+swprrXwthbfQ+vNZXSAYNOCMvFt+oxEpgZ5h3tcRrRblZMrGKz6qhgoiLLDvD -uPMa5TKbbj4zAG0OMlB3QGlgzVYYn2GcYYubvCWmC9SqRUW5oouyBMsblpW6l5hRVSIBZ/txtK3T -uGVfo/ea/nvbMyl86NNKt58ePGwHNtihCbCreSx2YSCjtWBZtuMoaGD89wup7EqcgVth2BskluZK -QYu/rHnIGbim/WywqcgbWDEQcLRYsu4Mw2lXoCLjWONbA2JpPIRhTYfb2PklnbpGoPdjFZhOZEa9 -bSe+GSZRFSbY9yJmNb6UFymDT6d0ApQfJQYgJpjOY/hgS2z7bPqJhWlx+AJFmOH55YR6X9qouRyZ -XqYl6zijv+DTqgHU3DxiWNd4zvUdz9zv0b6oqVWsAmpY0172z8B7P//BT0Pc97+kK1UyvahiTavg -K2L3rn4EcQWGE4MvqoNIvIJEWsPh60clzqx+SD+dXSYjQs2/qYPIvILOLpNO9kdkTpziGCYCg4b9 -1xQcMi9hLtacYZetkjlxmmNItmUwh2lMgyH1GtiVxcPQDp9ZE1LnTXMQbMqSYab4OnEyDYbUazjW -E0byrQqp06Y66KRn1x4TrOuHUmFIvYilBIDZRLpVPV3qtKkO4lkNiKDGHU+FIfUiWnVBKRgQwTK3 -X+q0qQ7i5pbr6nuCI2tTUci9iCUE0LD6a3XuQ3KnTXWUF7cAa6+uJ1jvqk2ti/MUoFd5LstERVut -OLWmr1WQN25u0qFdd/W4aonLdLdN2rBemoVxNTUwy3WlT40OBF5IGtY3s/RZl6myQu8uCetl2kQF -mnatdhUjLIpEUrD+iWVAAz2qrDgSPSSeYHD7Q2Do2RXIVdGuJBKtFc1IProS5Ao91vXARGpzi0XN -RLc8zysIPNtqdgx7XcVQgLZf4DE2HHeVYMt01NBMtBqVWaEf6mG0UbIdDroqyq2ZD1Qos8KbWzFJ -T9/cNuyLueMX40DBKzQ73HBKq+kVUTpHuHvcPNQ2HY6JIdpri1SA7prMIHhXNT+Aw6ZTMyOaJ+lR -zY8ogKilyHoSszqzgHrUhm6DALgBd0xJA3A3HkpCZivCai/OnoAnVmIF2xwxqyqs9i5Z4LJcpZyQ -xHgUgJQsq+lbwekQa6xLjzA4lgBJRGfJTzAD67LoZCrdLJKPkMBew7uupJlmYnsZen+FyUUzUpaE -TMrMMDO7jrEt5Ks9PLP4YmQWklHi6NWMV8Qw+BzShSzNW3xF3mAyrbHPfWFWWRGQnSGh5eB4WoMf -7XDIvZP0BCzch2b1rMmwS6g4MqnfFOKyc8IqZ1gvKY1qoNSs7z1C1dDY2LvMRAfTV0fQOSGjWC5/ -1kxE05Uy7M6J25p8qm/03sqSsyncLCj1+vkUWrPGk4ULHOe5m2RY+MkMb+hmd+lgYU5IRnO8DOYF -6hLtlCkVLMy12WiL7k+Mh4rSksjVgTUMZ9qJULQdn/DdfXSsZpmfyqjwRcFAPjYxTLSwavziRFRD -wnVLIwGSwCanHsFnS+MFgF25ySVLrN31TyuC2bKE7Djje+7k0Wm7R5q5ZGZVF4GBxT95QwaOrJE2 -KD7QePqkQl6tBFtCBYCLqpGp7uj1GCidVewYOpb1J8UG1l5M0wKFLxZXouQqGs0UpDrAsiootQDk -G1MVpUoDArdBootm7aOvyoqoEWi2pyTVYNxlJDWmGr9Nbj9jr+mNdrQ9J0pTBiRro2bZkQDujOpl -NkH/HxtpiBH77U/NAAAAAElFTkSuQmCC - + diff --git a/corpus/m6/native-template-controls-01/read/page-001.pdf b/corpus/m6/native-template-controls-01/read/page-001.pdf index 0ac8ed808bfd97720ab024f2ebba2dee3cfb7589..11040ee567fdefb86570164433198d26730369f1 100644 Binary files a/corpus/m6/native-template-controls-01/read/page-001.pdf and b/corpus/m6/native-template-controls-01/read/page-001.pdf differ diff --git a/corpus/m6/native-template-controls-01/read/page-001.xml b/corpus/m6/native-template-controls-01/read/page-001.xml index ac17667115fd7991586c55deaa29fafb18625258..389fbd0fe6d35248578195aec4831642d6ccba71 100644 --- a/corpus/m6/native-template-controls-01/read/page-001.xml +++ b/corpus/m6/native-template-controls-01/read/page-001.xml @@ -1,179 +1,179 @@  -iVBORw0KGgoAAAANSUhEUgAAAKsAAAIoCAMAAAAhjet0AAADAFBMVEX////d0dncvM/S6+W+4qyw -3t7akbqZ06ir2qrewNLB5OHcxtTr9/fp1eH9+/z9+fvcmL7tyN274avlzdu44d/cvc/jydjP6uTE -5ub25O779/n58/bbscncw9Lbuc2f1q3Y7++03quy3t6i1qncztez3r7gxNXm9Oq14ODu3ufbpsTJ -5+Pcz9jak7v6/f327vPcwdHcydXam7/F5uLjrcvy5u35/PrcvtDcy9bL6OPO6uq/5OT1+/vczdf7 -8ve24N/cv9D46/L06vDcxdPw4uqf1aiq0rS84uDbtsyb06jfn8Os27jw+fm64uLhpsew3KrM6dTa -lrzaocHD5eGm2Knamb20397outTZ79/x1uXbq8fbs8vs9+/n0d7z+vTi8/Pqwdjr2eTcytXcwtK/ -5MnN6eTT7Ozd8fHansCy3aq64uC54Kvcx9S336um2bPao8Ok16n03erbrsjbqcWp2anG5s7H5uK7 -0sDJ6Oid1Kjf8eTp9fLn9fW+4+Gt26q54cPvz+HmtNDM0cyd0qvZvs/U0dLJobXQ0c+y293Y0dW/ -0cPOxtOu0rey0rr0+vnTpbbA0djYlrnd8OzT7NnRnLfLyNS12dy419u71drH0cni8u7SwtHRxNKh -0q7Gzda20r3m9PDY7ujD0cb8/v22yK7h1t3KvrLg8vDWwNDDz9el0rG9rbLNnbb7/frexNTIw7HP -r7TMubPV7Of7+fq909m2yLnq4uepzbHPpMPWm7i/3Ky6srHFza/d2eSxuq6dzqnn9OC+17LGyLDb -79Du4Oju5+vKsMi8yrzIytXL6Mru+PW1tq/Gq7Ot267A47r9/P273cTewtPk0dzf1Nvh4uDfq8Xy -7vHp2OLy6e7M6eiq1Kr6/fzFytXQ6sXz+vDr9vTVqsbZ3dm02KvS7OnY1+HD0q7B16339PbBqbPa -7+rCxMDXm77T7Oeu3LjYydLL6ejQpLvKuc3G5snXp7za1dfx+ffgzdne0dq94rCy2dvE2t+lxqvA -ysDRqrXNtLPU7OkRzzSdAAAAAWJLR0QAiAUdSAAAAAxjbVBQSkNtcDA3MTIAAAADSABzvAAAI95J -REFUeF7NnXdgFMe9x/eW050k1AtCgBoCgYSECiAJ05Gw6YhmejXVgOk2YGxckzixndhOc3p7qS8v -eekvyeu9l7zee++9vzd9Z+vM7MzsZf4B3e3dfva33/m1mbtzHJujZrC3urHCJWPGkcnZNs+m8d7r -T/RQSu/fnt5ZGm9p56Wzq5k5g8AnauycMuW7DjaGLeo9UrE+5dtaeNnsRFLIfMLCWVO9ZW+STclz -1ane2fSLamZIoLrutwPs7NgpFbiEXtNGUn4/aVTXLbWrVUB1ZygbwugLaiKcf7x6lxk9t+qbCX2V -D7xH9e1NHj8p5QG8g0po2BpZF8ByGZOGUnuvakWzum7J0phZyqjupJoxzB2tbla30dzZld6pJs6s -Q0PT4ZgYGgsfoXQGcwdHOYG1Eyfn5bzRf7ItgDto7vwq7xRKWcYu8pyEuH96M49bGsEGZ9bQOGdQ -/r/zhjjY0uSxy3x3dyyOFFJP9w4tzeQ6wrNOxNiUPDzMji1N/sJlLc0nk1FzuQkGqzIjTB3LybV5 -lQg1l2OaNXV+7n1mD072wjE5GJMhDzJLyaDm5lFvYJZ19mS13x3NOBFRMnv14LDYqtz8Msg6GNU6 -cd2K6qATP0HtKphW9EL6iWFNsdb0JqT5M/zJJ82yx/qlzJrLkQhmhrWmV5COzuBtS1mT/KrvKlbh -G2GEdb1E4lztNaaIpIckrQoOw5mMAdYauQSvh5mWyFXarFQE+qySvRMASJMPzDomb9YcDl7arPKo -rNeDWS8qsM6DL9COsSqoFBazRiSB8fTwBdq5C/OVLBYl/gd10ZQlgOPsEU0NeOFSjhW1/NChknGA -mBqy6jbglPo8iBHAon/lwitVBUy2NOuC9ZLW5A9b5iBvLJFgcfKFKbdmveVLmgPcQxPD43j+zBu/ -2MbVpbjrruAFSPqit8oRWzqPTYQc/byLFLcHxq21yqwVelMrRgIPxeT6J7lKTyHAkrJL02VFLkwM -JTjOYSaFh5TtqlnGRrRQBfVTP62epiuzaq5zhVnXCqPRSZw4q7Nq9glDHmutRPa8CsEqsg65uo3t -IKsMai7XvzYNq+4aV5BV0r9DWEW7rnV1e/CBCCt9flBFSx+L56B+W9s/t5olxEpm/6pmNdZ52nJ1 -/KwqmdOwysG53Lj+Yrc/FkiqFZtW2Mby+d/prqZ3dRxfjG1W8+5KR7dpJgMglfD1fBUjvBLrWl2P -BWB5R6A2W5RQc/oScBy+M2CRdVxfAn7BWmSdbkACDi5H8LDIOqRZvuAsnROBRVbdvAWzzvbsqubd -VebWSd1qm1Q/Xhvbns9q00xdaaHmLVXZiwW/plcVeq/2XKywJFC579yxq4zMLEjsGVatkyIP/kum -zMrFrjb50ysd+evmWJlhLQn2W+ZQHYe5ArU8T9a2v2qSlfU1FfsTcrDf+nmTrA7rwJnyBC+8/hgY -r78Ar+YrRlEdttfKTOh6/qUiGS8+k8t90Swrqw8UqsN4AXzkWYoK/r3/DwyjOkwFBvIXZlRCfNM0 -LFVBs7ZiP8gZFf93y1HDtNQX6MaDj4RQi8WOw4ZhafmtsHAZIdnnea162PsNw5K+hmJvPcB7f4RZ -4UOGYek2YZ3p9UwMqnFYuv1aqf3it+v7Y1mLhjVLujDpVfB6PGqxw7A3IBlX6ugVp1bsugzPLwKb -Mt96PsGs4CnTQQGX4FK7rsIe67Fk1uICw5bFsHLrBkFafmZtKAfjyiYfvWkVkOZGmvnFSaD7Qh6N -hh23eVrDXtZx8OaHFLH2zQzrSUyKRjkH+4hhEdDCVh32RUq1g0PN5y9tsBdrU8NSpHIfKhBCN4M1 -rljQ5ELdQ0XLvpEQbQiggj/3MFjD0QtqahYqbdVgqVwvhVk9WNM+FsLirXBKsCTJ3hOBms9fIZY1 -P7tQ6wjqYEh+dS5HAizxVkHN0glmOCsgjmU22ikin3ThLLs70qz5/AUSFoy7WEILSwXx1nESvV7A -d/l8DGt+B37ehmBx0xuaVjL3xmn2pjjUfB57rvnGwwF7w0mgWvFWDWhazBo9s9AFXEAH2JlcmBc6 -hGaZHZmPIRR/yPIbGXtZ3q5HX7v3CDL2zTujRuwNd45JTDHM2hCvAWJYj2n/FixhNDpOmfEQg0C2 -EyLvhVhvJ6ASxdLIdQdZlB+nzNh2WY9QCChzSZAruArkCnDCPXovSApte8eIEJz1je5Y4noCCgV8 -Mhg2cQNjXdARgQoeOmUG1hmsTqRFrF2JGsjD4AXtuj+aFDy6xYwOQEbT2zM2HKdbxBodYNkF3ACH -ANRTsagGYcG6+JHmiehWImJNNmv+ScSahGq2UVezrHEoyriQNSJ19dF3QdZkVBAtTMkAaX/W5Iy2 -UAsBssYlLhQYhK75ryEB3L7RBV3xpR00WeRkYUyzZKKGcWVY8+AWQ6hubg42lPsLc/C0+ZwhgAtZ -g5VWSL7IepsCuVhDyLY2krHZ1d5Mg8sEUqxXwnH4fNC0hoKC31uDz1I9hNPxxyRZI6/nkq/lAZRi -dH4x5FlH3DbocyVZYxKxBq6JAHViQwW4MhsDpoVlrEgDl+JzxiCsmawrHLJBO2EY5doi1q6EfKHB -LwNTmUGIFnxW6aQMa1J2m7/kn2C2DAsaNc2rJOyaHILP+9KEKUMpV/htBt21oOa+IcgHBE/D1IYN -i4VZr/tVcYwVsPola82uYHPHl7VZaQ8B29Yi6+BfisotCYHwvsAiq/PPwBYSOImHkOaMbbs6s8AZ -BHWB8FJQRUaGTbs684X1lpCVNJKs29W5KQ5cQlhuJcSqXe+I+gNC0nyeiwdWWUfFBZeQFlZkWejV -Af0pIYzggMxYgWAFzQzhpXisHVY14CzQn1yeXk0nhbWt9ceX4lF/tdZxOoRVt8iwnh8wuih+9fjm -gm883QqafyIYwfNsidHgCmNtfQAUUf/9f8WvbUhdhBe3zJm1vs5vUvbX9+mlsCwfMNbOWLc9hhQ8 -/DNS9os7iOZZxjbyHIgnBc98XAOWmdXUil0yaqHwzdSwDbQ6NOWvRKiF70nNSvtapnzAukQBoCc/ -lxIWdpLhMNXTrI1yVQH8lIalYjWF6hwXm7VQ+KE0hqWo80213VbLoBY+n4KVopqaVo6zT4r1FXVW -kgd0mHJWjlMrhVoo/IkiLO1sbzG4decpP+veywfnbAQ7DNrnHNw1wD+lGA9oW9toC2spBzRysJ3/ -RoD2gxzt/6rY9QJxq/ONdgY5CYwc4kHR/9vPsSt5qzwrXYZ5xFxihQqKqxRm4FqIFD6wiz4vzcpI -zc0pUvrUE5a9vrvPYTNYObte2IMTgPkpl13WHahfuhRmp6AsObDaX58Rucaiuu07ydVIlIgNO/By -RsfNdDq9ui+QQ29/ClRRbOC0dSDOqsDAcwiraOHgUjkBPZXOpKuPRyb7+zzjYpA5kVolD+7Fx+yJ -F8Gl8+WkqNpyM+V8qo0tS+rqqWERxq4kVPcgZmWLyF1Pwr3QZOzp7iYZ6iP3phakjvtJZUlhOzYt -Tgeh848f7TxrF/Kc833j3tTUnZTmJCZbF1fr4VPXXYXHtYrN6rpIBG+HdgWB895+g0FTDhWc/QBl -DccAv5EvQ9Y3AL1e2DCV+i4ntIdqk62KTAtgoV13JioAPIkE+4byfMPfpHNFoi6WVKZ3ALFeFrEi -r/WZroZ/FJ003fNyCXRhHWSNDq7cBRyCrN/I/2Q6FOGros26d1cfGudoLKqDfkAkV9eFrB/7HeFJ -Ux4QVuvOy7zD33gd48IQK5IAZv3Bz6ZEEb0sVEWPhG/0QWJc4dRCrK/8gOicaZ9nmR72pQPXI23X -h54ckbLrr6RFEb6OZnoYdW+cIg/BvF/MuhEcxYKy8NyqB/hYd8YnUe0gJIlZoc9qVUWQPt7HmjTP -AawcK59HSmNIHcizJrv69p1i1uvAX0idNtVBfOMvOYlyD50Tzi0wB+3JFadPeAhRxKFgpFBYl8pk -ci/yWPuEZhMeMFDYLHfWdEd53f/E8kSICQ8ALut4Ogq5V3l9Sn3Wa3YlwAlWn/WyXQk4Dkte9Fl3 -2vQCUCZMBMLsVKRZINdA/0NOhvJHsWRb2w9ctxkI8AU9LetfRXY9V0AVr81Bw8GAiEXwfLvtmQWN -QJvAmoK9jmpzy4PWBrv0DLu3zl6K5VmA1IdJPUDxZRyy7bAwL21nHBQTxR+xKxOzOg5ZYpEo/mJZ -2wcsZoO+SUCml4Zh+zIyq+OsxpE2oeASOayBpyx7AO/tSe2dOnYdtJq4BsxA0gJx7h9p4PYBe+Vr -xP3Ckt2bzhX0Pe2srFqZmQrIRoZUKmj/sf8sg2PFmYx4SSs+RRr7lnciUjTuy4YWww4ISu+QSJ57 -m0cK/9eZiRRwxhW/Mhip5efe5UcFSsjEtLizIe6vcNBh1LKyhVuzMG0rkoFKwhUQALbxwmNZwGLN -ysP+RFAA5O9sYNEeLFnYT74phrUsExnUok7MuYT1bE6t745DzUizuAqX8gbvi0UFml2chWadq1C0 -AxKpARcDwtQrMmF1VqPkQBhu35Jg1syCguMcgKYdEYSweLXii6jKxrJOLSwYBxJN+95ks5aVbcuI -FexugELYmZDKRIYBH382qQGySCv0tSNxtM9xvvWBR6eBsfyJB/2mzsgX4Nt3IIH2HYyr6TQkRWPJ -XB/tfZmpANkWKmEkqsD9EqV6gJGC/7Q87IPNxskyi6yG+7Z2Xg75hEhUQLubh81QsaQvcwD2Pfde -9+FS5+qzKtYBB5udK/DUtvopmCbsve6lCZ/CRGtaeAUQ0XKwdzNVLD0Zxj1H3QIpBxaFUX2WzXZ2 -cYZBO/nw7tfnsO3CCkDsnmYXlsSunheD0QzLdW6EAhDsA0wGmSSycQYBS+MgFf8AgqmMUgB8rGUN -hT1bQsOC4FsHdmrhvMULAkHo5ZS1ZIIlPmx74RoKsDFqRdyVBHZmSe0KW6B/hUginQCxcAvNDUrM -6rR+DaI+GKdWX0goNavzx5D1iUTWacSwJXUE0FCdkHV5MitxsllVB7H37xZ0rsmo01rw7Co560wA -8bCAdRpOD/2sK6uOdXZWZcq/AkDsFrHihIvjOnZrIXW7M89mltpKyHXatNM+1pWdDBQDZ9RWduC5 -RGadhj0B7sYuDpLCp7LJxMGJmsSsSLAI9e42evN9/67IwKGtBGcUeFcaZ2FSuPhMJCl4MINObZXM -1MI5N8gHVsKZGDes52GQNSkZIPJYBGeQszUwpwLUthMxyBqfDzIhQ9azx5JRrc8wyCqeWtOQXRPu -P3nKbsNegRXzzH24chEYj1ayeoG/BKvFLmCVcFnIrnA0LfFuwulKfxsJPm91kUmJtSkwC1u8wpEa -d4XFgLtYzq6PwrsfkTYsCpn2jMWMPLnWoncc1FxrIt2FV+ZSy1rMvBLKbc4/PFD2QEwDoaUp4B62 -2VOBHGtTbP9g2rSgQ7CXx8yMb2NwduWmf8gbB2Ww0NrquBxrYrhgRTlRg7VYe0bKrsmhbXnAG9hS -bGdiz0Ui/MJD+KYyzBws+a27UnFLxOxfW7C1KFplhLXFrwJbs8sIq38dxJoItok6GaL7j5/3Ld7d -siTYmTL5q5jXN71sCbZTpi4Qs07zKdaSXY/J1FsSrE/weYEl1q3iFpEE6TSWjiNkS6zOwoS8RIqS -HJSBXZ2ZMkWMBDSfHNqya2dyB16CEh/CFTTW1u2qyuLW4aQx0YF0vQZowd6SjSFHwLFaTLfNTC6O -1V7N1SnswUuJwWO1uNdgq5mMwPMD9iTgOAsFa0ZSZuWCrK2UEHrC+3bL0SQexdaYy2y2M5xjJgTL -8gG7e7kWm0gLWQJrtVfoOLfQbjetwfJXaxU3idtnJZY3kq+EFVy20myWYazUTgloMmCzp0lwV2h6 -LaqADFCdTr0wSxsvWaA6W9foTCyKestWc8ifDm+TWDiKuxyKajO08rQaoetRXMFus5dcBaqM9KGL -pFdnsrn/EJvmWsslFjx5LZzG2dXMzIwKYfH+nJakTWUResVLXNvsrheGCs0VS1B9pxRsl6AcIGtS -x5kJPeyjKvELk97K9O5jE98HuwRN0ons6UpIuu2szbQ6tsnQCViXS1bfLUtgH3tFZwb7RiJ5zwDW -J2Ry7uW7wdRfceZudj4qxDsTzK0HoQRe/dDjcLzn1b8IzftFSyqbts08c7YEEuV5P/rXcBfW8i88 -/lH+0Zdffvn3EPnjn/jEJ85WVVWV0JiM66e+AIy4pOxDPlJbDbSk962q6uz8u9/4o/9bsmgRTlM/ -/Oqr73n88X95+bfwq17+0IfRw5UWexDi615c1XlrW1lT5aLY7tqHX/VUWWmvZSZC3doJdlPNVYhE -TSVivXsf3KCkAAo1UArWlWfgpr+5lYp91RKwVsGtwmUPolxEaSzJem5VwY3C6jaFF7Xc2vJJ5NTC -pGXcZ/FULJvNZ7cx+GK8jS5qc5IU8sPZ5cx38d7ENan7Ertt96KoFohRIz/fJmVVkBBkNLm2km20 -igWT/yoezCR1riJ7U/XWAJ+wtVOFdwTHyGJo1AccJQWAvFYGkYuizk09rcgFzbWeoMINt2hIl3Zx -tn7AttdaSfdRazX7EP6jtnaqUMGyjfSK/Z0I47ZYW6HGsOgTWChdUZhFcYc2WV1FgZ+8MKRWcAF2 -QxfOVuDQaPcyMy+3Gbo8s5pZAZ5rsZnifUJBL2RRyz5sMXR5n/uQafCIZ5/FQoaFAbl912LWRfaK -A+awTLG22Pu4pucFzOwNBQ24TlHjIe3z3Ie/zMytafY6Gsy5gv+IxShzRKW1MMuz6iaE+EqWWKtm -eVbtTQCIdVGZrbyQ/1ididwFLHFZ21vD+QGpDzlKKNbabkDfhwDNeII1tibXWV6wZjYxNpVZKrq2 -+lj1ixigkSZrkcv/8Wrt4hCwVlpzBP4PWGsX3YjVVpT1cm2kBs1mBnQTlWXWilmf15L7pGOy46q0 -5rQcLoNFlk3cvdQiUZNVWvyKv4Bhy5bEm61FxlGADSxpsz7h6wKKLYuHbVlTalavmUF9bYxlwT4q -mZoM2NViLRv6toXIjAtuTpLZigeOs7joHv6+hfCawSL0kdvSszq0/8oF3Cd8i4anyZ5PmW1CYNuF -RbuCnTW+tAD/wRZjT+9mH2OW6STaZnWCjgvjrmkCg35gDapahhVcmFW7OouTvs0Eca+4K8kKDrPL -KoRdsRimuhJ6hV9AZZlVAHvfYgdmZBJ+AH41hm3WRFjY/IOSlmCFW6+tszrIclFjG4pDMCuXqMjg -5y8yYHXI6nGAtxPXT/BRibIcbmjNgjXqe5jYF3GhKxBvK4FHCfMlMwesPMO3N7Zxv5uBWBMSRpxK -oo8LmUGReZeqzpnQ28685dtICb7JBQzhMjj6fj+Zs9g8BlfnwgUQuFs0gxVkwZXiGScSAYzI3y6s -Aq8Fv8zHWs0tLxviyZLTF5Q7/kMWv2uWCE46NMmGRUnZ156Wt4CdI2namJS/4M82/X5hnx0E6Xel -EfjBhHiAPoXxJndvqWFZ+zPex55GZn23u3GgsNTyr1wmm9hrf8b6LVyVfcB1D+0s1GX1s3GR1Cz4 -xnUTycdx3wK+dbv9XKGwub50/sArIGMkSz6R/Rz63Zg54DePC/us/zBnjBa4yjyy9Um+6vdd9Cdu -Nl4Gvz9fVyJcLgOLgKUfxfsU93M813aB33Sv25fpb8hhS/NdhNBXzbGvF3uv/9e5EO7m+qzdgq+j -ONcfbBnqm0I/JNZ+EPwGfeHpjI3r73jwbaTlrDXztqgfPZsD3EJhcwY/1epNtUAPfO5uEsJauO+6 -gB4rYmzclTUt2iLPj4crweC/VSwsAQqOnNjm7JTAth4GmdnfvBcImvca8GEZBl9uf0w07ieTfm+y -/TL82anMwll0Q5FxR84sDn8ONG1WQhCo4H2in/Fsh3OscDybTAG2NmPHl0So4PnrEHb7Oum8WedA -/zK+HztRrcwhgEBWqMvG2cZ/STLIXGXGIQhbOK5jMOnXxsG+E2eD4oFhsynKYmADWUsCM4bdnskM -i4R9h9ig7IgsYSMmmAoqqMigYTOybFXwu/3VUF33YIaw/q/3f6cwCIQEguJtIaMWzUr0WU84/uO3 -ZT0ATwwz8Ky8AfBxdztvgZby96b7/XnQ8YDjKWlfaeLAzSl/fh5F20Ihu5TWcepT/u6468LsG4Tb -7OrG2rqUZnXdjdiwS03cXKn3OJ5OrWiWYV9QyKr1tbqQ2qyg7YWnVyGbDNHZN6AQWkOH9mHW7VJ3 -UPeg1eJf7U26FqLYbBzXvoLqz8770WGTA/qCDFKu1YVzOhJw3WuYNYvMu15nZqGrJKwF6062tq4g -91vj8cYnIrBfJRzQlYDrEk9gX7HbtSXgzqEisJzDrCtoegEgDca6Wdd9Jr/+eCF12sIE3E7tajl4 -wd8O1R6M1WrD4GqhcE0bFReJ1gMt+PV2vaAFL5TptVCwGbvqCuK8Reh+qc8ClrVYIAAvMCKUwGUR -LK4Q0bDotfBvCAtGn+AQmmhBVospN/jla/jjzMmjT3AMLrnwsMdaC95dXBIAOV5PuBpSymJWe32N -VvDu9PfE42ng1ImXAW4U2bcrkKsEK1oeGImZYJwPsKqBz/6TFCvW40CUDtDiHD+steV/+BUF1kJh -58GAbQ8GSe1Vs7/7h9AiYr2Cg45j2w3sOoh+gB5U2nP6aIbNm9VWojX67x+TYoXus5XAIq6REdIS -CNx/8Kcll3X05/I/LcUKS79W5+kwWPgRS6Xs4Z/Nd30Dni3JdaLbDd1Aq1MLwoZwWDHr6NSefP7J -P4cnF6avcPkVpCQSsDYacEenOm7k8/nyNyD1CSIsSvhg+lQrkoH++tHoAt/YP3VqS7G7C6DmryBW -UcWNpjtO9WDoiB9LNXLXo3em7s3vKIZG9w1EyliT6wKcR5O0tHVzPGpqrS6YIpS3u8vLu7oIHEZk -o/w70ZmTO0TY3dOOSm19XTTtvnQ9l9H997Atu8u7GgJ4flZy2qQihiRRXi0cRbv5eCpSCrppz/lE -TgjdhfWaZFjSVvOHo6v7OCnULa1P1yU+egrrs3tHkj3Zc4Q1vpKdQwJUyBnVtrbWg3G1Ne18WjAf -3/s9F6RI8/m3EhEMkCAf9F4si049b2L6KJS0XHjv2ZVQ1sJAVAKz0cuizG4rPUpsekXWphD4u705 -3RdMpdEmNzrS3ukos45O4bt/O9o5xUni4xzOQB/nDub0cWW02SJqwSMY9Yb87Uf43/T7yp0jl/v6 -+i6P+DjhIQaTfWLUTeclp5R3WFLI9J6rM9apHN1C7v8lZdT8/0jBGvMCh0nE36B4/9GFfU6G1VgC -rYWa/zosDkXDlFop6qY0VgWG/bwI1NwKNkUtptAqEsGfCg272ZBvZajl6tOKvIILB5EmrkuXmIQ8 -B/UAxQ2pUb2cIFoMplCdUzTlV4tW/gv7+tsTJKtTlvhMu4CidmuYNZ//TVwdRAyDHzMigbVYlEtW -4y7o/Pd/Jho1ZVkSFeT2s6JPy6z5hmLxx5eGaLc/ZWj+I3RmVj0J5PPdxQ5n3XGurbJ934FU9VNs -2nCHmTW9w8I3pLxYPAxP0wrqkqutrWYxET+pVQHxk3oayHcVi1PGMqnIN/KaFDoeC11msbjFLqvX -TtFm3VAsjlqF9Vh1NZDfUyzeyYhVd27lnywWb2bEquuz4OSanxFrUdMPwGhQrLEJy7Uq1WvCwNWB -9/rljFi1RdBdLP63TcPyLWBdrwVYv1xt0bA8q65hbxSLX3UH7cGy1AVCayoWZATF5h57KrjJG3aT -Ssct7DYg69+6R6wZ9jDPqlVxoUyr+Eyba0+ypIlJkPckONkGkUQg6+s5i7BeAotw42EbNoj8xHnw -+sdyAPaILc36DRubxjbsKUqy5oabeyx5A79i4yzbADI+ESvUwIs5MOYBHcyyMsV8rgDKIKKpdQmg -FkVeArLeD1kt0pLOK/MIG0JtrR2b4JOixhzHmsv1D689st68bY+GVlv9XfgLIHjCIaMBYlds3Ivf -VW0cd4HPycI/Nt1gtj1/BT7QAaagKqsdXK+h4VHfvlJeXn6jG939YsdhCVZoft6uRLoXf9GsdaNg -OWN3HHaAqEVVTjQrIB5vqzgx25x2E2EBqgPIbwgqB3gLwnZF1u2/ODZj0liESICFqJBV1J+FNyKG -FeAOj7nVpoy7IGLvBZLBFtT6ETstUBsmsSLaRkN+ga7FBpzCKdyggI8mdz1BzZ3Mimh7lpkR7lTY -tI/Q9gQEuZIoWNDLELHmctObTdEeZf14bNyOKdb1QX8nRi4YhhP0ij1Yrr/NdQ2lNqOveRH33n7u -diHWJK91AR0BckLRGB9z3UZDs2x0wRQYry3w6wqljklrdTtkWXP9E2DnxgljHiysf5zmJhgWheLi -R0RWRc+PN7tuhSGXEDFVyYp97Moi7BCB8YwUa65/CJi20U6O6zgkVsTGAyyB4vNyrMAhANiKSTP+ -K/guRzFLbKBFXqBYlEUFvhbowFZlRrse0QEBBS0Jl8VdyyoI22PIIfhNm7wUStLxl+TtmsshWNeG -DlhxvilifhG1Ft+swprrXwthbfQ+vNZXSAYNOCMvFt+oxEpgZ5h3tcRrRblZMrGKz6qhgoiLLDvD -uPMa5TKbbj4zAG0OMlB3QGlgzVYYn2GcYYubvCWmC9SqRUW5oouyBMsblpW6l5hRVSIBZ/txtK3T -uGVfo/ea/nvbMyl86NNKt58ePGwHNtihCbCreSx2YSCjtWBZtuMoaGD89wup7EqcgVth2BskluZK -QYu/rHnIGbim/WywqcgbWDEQcLRYsu4Mw2lXoCLjWONbA2JpPIRhTYfb2PklnbpGoPdjFZhOZEa9 -bSe+GSZRFSbY9yJmNb6UFymDT6d0ApQfJQYgJpjOY/hgS2z7bPqJhWlx+AJFmOH55YR6X9qouRyZ -XqYl6zijv+DTqgHU3DxiWNd4zvUdz9zv0b6oqVWsAmpY0172z8B7P//BT0Pc97+kK1UyvahiTavg -K2L3rn4EcQWGE4MvqoNIvIJEWsPh60clzqx+SD+dXSYjQs2/qYPIvILOLpNO9kdkTpziGCYCg4b9 -1xQcMi9hLtacYZetkjlxmmNItmUwh2lMgyH1GtiVxcPQDp9ZE1LnTXMQbMqSYab4OnEyDYbUazjW -E0byrQqp06Y66KRn1x4TrOuHUmFIvYilBIDZRLpVPV3qtKkO4lkNiKDGHU+FIfUiWnVBKRgQwTK3 -X+q0qQ7i5pbr6nuCI2tTUci9iCUE0LD6a3XuQ3KnTXWUF7cAa6+uJ1jvqk2ti/MUoFd5LstERVut -OLWmr1WQN25u0qFdd/W4aonLdLdN2rBemoVxNTUwy3WlT40OBF5IGtY3s/RZl6myQu8uCetl2kQF -mnatdhUjLIpEUrD+iWVAAz2qrDgSPSSeYHD7Q2Do2RXIVdGuJBKtFc1IProS5Ao91vXARGpzi0XN -RLc8zysIPNtqdgx7XcVQgLZf4DE2HHeVYMt01NBMtBqVWaEf6mG0UbIdDroqyq2ZD1Qos8KbWzFJ -T9/cNuyLueMX40DBKzQ73HBKq+kVUTpHuHvcPNQ2HY6JIdpri1SA7prMIHhXNT+Aw6ZTMyOaJ+lR -zY8ogKilyHoSszqzgHrUhm6DALgBd0xJA3A3HkpCZivCai/OnoAnVmIF2xwxqyqs9i5Z4LJcpZyQ -xHgUgJQsq+lbwekQa6xLjzA4lgBJRGfJTzAD67LoZCrdLJKPkMBew7uupJlmYnsZen+FyUUzUpaE -TMrMMDO7jrEt5Ks9PLP4YmQWklHi6NWMV8Qw+BzShSzNW3xF3mAyrbHPfWFWWRGQnSGh5eB4WoMf -7XDIvZP0BCzch2b1rMmwS6g4MqnfFOKyc8IqZ1gvKY1qoNSs7z1C1dDY2LvMRAfTV0fQOSGjWC5/ -1kxE05Uy7M6J25p8qm/03sqSsyncLCj1+vkUWrPGk4ULHOe5m2RY+MkMb+hmd+lgYU5IRnO8DOYF -6hLtlCkVLMy12WiL7k+Mh4rSksjVgTUMZ9qJULQdn/DdfXSsZpmfyqjwRcFAPjYxTLSwavziRFRD -wnVLIwGSwCanHsFnS+MFgF25ySVLrN31TyuC2bKE7Djje+7k0Wm7R5q5ZGZVF4GBxT95QwaOrJE2 -KD7QePqkQl6tBFtCBYCLqpGp7uj1GCidVewYOpb1J8UG1l5M0wKFLxZXouQqGs0UpDrAsiootQDk -G1MVpUoDArdBootm7aOvyoqoEWi2pyTVYNxlJDWmGr9Nbj9jr+mNdrQ9J0pTBiRro2bZkQDujOpl -NkH/HxtpiBH77U/NAAAAAElFTkSuQmCC +iVBORw0KGgoAAAANSUhEUgAAAKsAAAIoCAMAAAAhjet0AAADAFBMVEUAAAABAQECAgIDAwMXHhna +kbrakbrakbrakbrakbrakbrakbrakbrakbrakrvakrvblr3bmr/TnLjbnMDcn8LdosPPo7bdpsXd +q8jdr8ncssqEtpHdt83duc7du8/evNDdvNDcvM/cvM/cvM/cvM/cvM/cvM/cvM/cvM/cvM/cvM/c +vM/cvc/cvc/avc/TvtDTv9DTv9DUv9DYwNHbwNHcwNHcwdLewdLcwtLdw9PdxNPdxtS6yK7dyNXd +ydXeydbgydfizNrnzd3lztzd0Nnd0Nnd0Njd0Nnd0Nnd0Nnd0Nno0d/d0dnd0dnd0dnd0dnd0dnd +0dnd0dnd0dnd0dnd0dnd0dmY0qeh0q2Z06iZ06iZ06iZ06iZ06iZ06iZ06iZ06iZ06ia06ie06mZ +06ig06uk1LTq1OHh1Nyc1Kib1Kie1ana1duq1sHt1+Tl1+Dr2+Ss287u3Oex3Kux3Ku03auz3auw +3t6w3t6w3t7v3uiw3t6w3t6w3t6x3t6x3t6w3t6w3t6w3t6w3t7V3uC938i737+937m336uy396z +39+939W9387U3+C24N/y4Ory4Oq64Ku54Ku/4La/4LS94Ni44d+84ay94ay64uC+4qy+4qy74uC8 +4uC94q694q++4qy+4qy+4qy+4qy+4qy+4qzy4uu+4ty+4qy+4q2+4qy+4qy+4+C+4+K+49+94+G/ +5OHQ5OPN6eXN6eLN6ebP6uXR6+TS6+LS6+XS6+XS6+XS6+XS6+XS6+XS6+XS6+XS6+XT7OHT7N/T +7N/T7ODT7OLT7OLT7OTT7OLX7ujc8Ovl8/Hm9PHn9PPp9fTs9vXy9vfw9/fv9/f09/j39/j5+vr7 +/fz9/f39/f39/f78/v79/v79/v79/v79/v79/v79/v78/v79/v79/v79/v7///////////////// +//////////////////////////////////////////////////////////////////////////// +//////////////+3cRl2AAAAGXRFWHRTbm93Ym91bmQgdGVtcGxhdGUAaGVhcnRzVrgCNgAAGoNJ +REFUeNrt3btPI1uaAHBSR+AxWhsEInLoXd0AjMwFDNeZI0czEzkCjahtQFhNYMlkyyKkJeAO9BJc +jeSgpI26hCEiWQHSCkPftmzKnmz+h/oLth4uux6nqs7j+6rbmv6kkWbm8vjdw3e+8yzXlMYYg2I2 +OuoaRkxhULPZYu87sJazlCF/c+uRBcmt6LEcji0Pvq21pxtWNzYrwyhtrq3kgvNg8E2tv+Q2ShV3 +lDZWgrD53je0djYqpCgV4sGyWDuVoCitxoGdgqDqsUnuafnBN7GGUgMTAbKDTQFR9dggloTyN7Ae +RForJWIeHMVulSsVXmwnZuvf/1LhxhYGmNaeXP9Fj0P5fUCdrSHYQzSreujs0WVrDnJYocXm8LLA +a1UPfCWyPtD+XqGOTbws8FjreVJBlzv01soaWi1wWXtBE+ktBmtlBWv4clrlvD07Lazp4ZjurbFY +SSl7CGyVh8m1Oc69Qo7DWlkn/GVUUKtFXXHPT0trpnaDyVpBatgpFzW3SZ7usbUrqRZAZKxt7Rg/ +cLkU0LEZrZVVlFIwtKpGt1otBXWWAqO1RKixYFajWK0E/+rVinjDdoCsR4EJMMSyWgkZewBjNTIg +x+xhLAV5GKsxB9gEpZJqbAfCatSAVVgqqXcdQlgPwDNAD/9EtghgVdmLPUUQplsDceshRrOSkkAW +t+pFoFCBjxz40DVlTgRKCFb/cLAlbDV6FgKVkLB5YWsBJwVIQ5dg55rqsU9P6WIDfDSYMtK1ghLg +hWBKr1jLOFbwQjC1hZSupPWsqBVl0Aqwbv1zWTdwrIUJsq79sMZkzf5zWdfj6lsFcesk1azCBFlX +JsianRTrFtIShjTPEjzvNOaESEULfF/TnGujdK4SvNVYw6CsDTfgrcb2APTGmxnr8GsYc5MQIwnW +4NeG5l4Gwh4RaWtbdM1tbr1hVIJl6KmLsZ9VxOldWYQ9IutkC7x3baBYB1mMOcE6/BGXsa9dxsjY +Ao7VOt7E71odAKtmtgHw6iCLsgdvNyxojd1AOOW0zg0L4N2LMGqVYazWdWHIddcKfNcaWgdWrwXM +gizSuaGdsYApu4HQtUZ3HbbMH7cMhSWkK9DZsWbdGgfEriB0rfF9l0PrJwKdIWfhTw0dVvuBjFWI +liVd1OvBWbVeHi4NCjTp2nt7ejTi6a3ParXvkkFgc1ETwv7z1W51HLtXX9msmn1FMyc65yIst10j +wduVZBIlSarp/7G4z0zW8TNEgvOY9dCR4O3UdNbOmsP4dLxjeHfbDFYHVqyHkZ46GWWpKd25bLqj +ZmhP+/RW60qZcB6QUsBO1yeJJDW1+j/40Ke3joqBMYXjbtqNwOraNxpV+tgkxvGengd9eqsTm+Nd +L64GVdevRt/faQbFmY79wGB1Yr0XTAVSwJxnfzX+/rVmcBzr//yRwaqpzovQPIlAGrSMC3ptnboX +Rm02d3Rsj8Hqfvwxt8asLZDTta079s5Cqc1L/WuuWKzawPUAJHNFyBHnrpEJYIb+RRKTdfQI5Ehb +EkyBstbfpaGaheuNzap13M8ZsGQCOQXMYhVJbX4M611Bz+6onkcNctS9jJQC6pWRrJ/orFesVm8e +GBWMqt6SBoLC1ypVBljWU3Yr4SkOmlQgDQSHlBnAbyU0rfEkL/tAkH00mvUjjbXGbdV6W/5fHJ65 +xBTYpW1W0/rIZx0/I0PLJaXAf9Bmq1lfq8+8Vm1wRPqrZpfXS9QpYDTrXpMuXfdqx39rNv/n16cO +h5X0+Flw65JWBD/RN+uO48t+7XBY9ZFhKxvE9XS15YBmrR43meO///Pxjd0aotW5m6XQFDCbda/J +EztV6bHPbA3T6oXMTl7S83s/V0Mn2JFTmWd2q17ADoK12WUzeXNBKVDjs36SnGMu0+cPqAf5EG6u +sBaUAtVLPqu5UhhhGT8vYyAXsmxhpoDU5A3JgWX9HBJ9dCgzWT+IWWvmn+WZ02o8mZynt1YF0tWI +PfPftc9rZWlcK12Pm2JJYGUBp9XIXKpP0DHnAnRzrLAkMFe33FajiB0WKK38VHNGO2xYEasxQhyE +p27hg6j10rJK4lYjdcO4R1WxMqDHcDu5DWEN5bbBrFdAVpNLyt0inHUXzmp2NV9lkB9Fy+vIWgW1 +GqOE7J6NDa6ErWe29Q3YatbdcfKWze0WMesxotWsZMNskAGsNdv6jGM1s6FsPMdfFbZKtvURzWp2 +Nm1oFRhiL6vxWDUA6ygFsPJ13LLCVik265uoddys+kzrO7eOmxV8LIC2Opr1NCYr77Lgcq/qKAPx +WGviGWCceOBan4WsNedtCA3b+ihidVIh1jCIVhdVeG2IanVTzTMEYatc3p4zYmm7/g5o3XFRrbNE +Iau6v5BwxuyS58N/n6zfxLyj+Umq+ptVxPq+lPDH7Lbqr1ms663anpsq9cSsKklqRNKh5bKeeRp1 +dIrEa60nE4GRLAtYz3a80tG9Ek7rdiI05t75rJ9qvjYdZQCvdSkREUnZaa1SQnf2qoQYXTHjspYT +0VFmsn76SGpRxz4xr1W2PdMpPYISd4nOevnxuCYFOd23CTis6qz5V07P29sA85lUANa21qz4aIf1 +PyVJ2qtGhPPiA4fV6FezC+7NoIX0tB9bHFm540kTs84mUgv+bbZFgna/LyaV3Efe7NZ6IkPev1z0 +Z4IsRN31XOBltxbnA3eGM77S9UcB6pX3HiSzdbAYsuU+782Df+GWfvBf02K2HoaeD/iwP3P++UmX +M1itnYhzl4UZt/UPYFKEdy14W5Y9C06DLukxWtXo07c5oYY9fQ6+WcxolSmOCtO8Dbt79cxyXzsq +jiisizMc1tOr58iHUBitWzRnsPPuhv1TeG06vXp8o3tWht4qy7TWrHsAm7869YXxHMzz2xvb8zxU +VnnbysHkHN3FAXfDJjWgoLDKjp6dznI0rByX1b1eTdFZ3ROD7Zis8rACJVMps8jTWRddA0I6Hqts +rFCmh0uABX3+n6HDzroaVo3DqiYNqWNmtZDhSQI5DqueAMn5LEcsYiRsqFVfWSUXs1zhWt1m8K36 +2nqak+qeFCTxrXpdneekehIW3SpTl/7IoUvGti7xZ4AeLmsd2aqKNGs2OwNfCIKt+4mEQLO6pwRF +ZOsC7eBPUQgyyNYk7Xj67a3vAgXLZ03jWmXaORVV0cK1bieSE2RN/bD+sE6QdWZirPUJqlmy4FgQ +p1VLiI2xsVrTYp0rVmtRbE4Yq7UuNteO1arNCFWteK1Fod6VjtX6nhBZHMZrNW5fpGGsaXTru3Fz +BcSaQbcaGTu9wGlNxWxVZ/g334Ktave13W6/drt9SKtRY3mxSe/dFzO6baUxDuXldzirkQWcWMIG +rPpy0/DGRVuFsqpGF5meh7B27yycotw+6HHbUn47N/+flz6M1UzZxDR7NVjwXNZSTem55RxFy2zo +iy8wVk02N9FSrOXAs6fZvtBN160HX9xeG9o7GOsQm0gv8lv/8JvRpgSpEcoJLZbm3NDCTqdZ2tY5 +FKQNjfIQFLfGP36BsWrv9i9OzfFY53XKSeshOFpGGnRhrI5zzulUhi4XxodxPxnU24ewMGquAmTV +BwXHNnVyNhNdxFJO6sX9Q3gYxasNZfVdeU6m0unM/HzksEVFNRv2BsyqZ20mQY5kyhnuCyTz0Qlg +xL2RsSqcNeDie2hQUh8eLmiSgO2+y3t5loWaaoQWK9bexXxHTy5Sc2eMwnnz8O2sBrfsvYlHjn8z +MuCe2nqBYTWzYX87EwFOU2eAZW1gWYctXN/ezmQCksLIgPOH78bqUJuxv21FXf/v/2X8+hal1Zht +nes/pvePGKy+6F8wNOuD/je4uGub/7X9ez9u6wtDtj7cuue2L+1uN07rBX0R0NPVkyv3+ncrr3FZ +X41m/fzAHZ/NdWM8VoUlBYJG3YaixmDtmivUewHrvbnIvejiW82edf0gEq2GF4tkFU6BYcq6sDhW +tcEyEAQWMgvbx7W2hdN13LDj9TiO1dpkEaQOM7bR+IJqvRDvWsORt+FYiaFYrXRVhK32tuIrovUL +kNXeq71BtL5AlAFHwg43ZVCsCpD11rbeff/Wh9HuN561AVJeHVYrCTCsVhloPABaX7CsXXjr3QRZ +FSzrC5S1NT5b+u6tyg8rivUC36oAWW8bsVlvH+BSANvaAkyB8+/c2nKe3n/nVucB/h2yVXCu7exZ +1rnH92u9dlq7yPVVzNpyXd/QkK2fRaj3Jw1vumJahdbc7psxr9jWC6BhQI8+trUBRh3uEn2X1tsT +t7WLZ31tiA0GXqp9+Im4huG1tjzU0fUSTCtfgVW8V+NGZ8oY1r6A9d5/ja+LaR3uZfAU2Na5jzq+ +DYViHf5C5s3iW3+jNm76uFaFq2jdKyd+qvPeFqr1Vljqug+HuP/KUrRanxvEcN0zRLVSFoJb5Zws +bSh9dGu7QT8rvFUuGg0qKo61S1m0bpXPJ43gOO9q8VlDCsF9S7kJc/pbFet8qxFSCHSlcn3eiAwf +Fcl6QygE9y0Ded2gjDv/rRccq11gFR2oCz9fX580mOL8lfBTcawvDbFQiHc2v0frecCNTRxrV4R6 +F+/9LAGr0g38qUh3SLjbtBvyQ5GsN1x5ehd+DRrzbg7jH/816hkezDtPLFCaR6OQrG1wqLC1q4fx +QJ4Zd4ojqJ13X6gf5eOxdn83YQ3RUF6+MD1yyGTtd3VjAyDOlZfXLnMbUVt15g2AUjGUfY0r6J4z +er27EDe+fOFFUlvVtsIP1IXtbjeWzyfU+q+8UKWvQUf4c8cCf/oLLU6r9Zgod8RoFZTGaW2LdvzY +rF2Ait+Nx9puNCbE2r9rTIq1DzLix2LtUo36J9fXv+mjUssM906QvbPS1rCt3Yj+f36tC0P3q23r +i4Zs7YdQz28Umo1qJSZrcK5eK7Sb/3FZ74KgDPduY7ISV58nCtulsHisXaKU9SqzfVFF0RCt/RsA +aUxWfwZc81wJjMPa9TfqA0/cxmD1lqtz3nuW+FZvs15wPx6Ab1WgqPhWT7NeCzx0gW69g2rV0RVb +PKtrznIidH35Gtn6xdWsYrdssa2uFIC5uIpmdQ6vos9fIltVuAwYWe+QrM6KJfyo6DXunPAFsFlj +tIo/gYtsVcCKQKzWeyhrF9168zA5VmWCrC0wq4ZkHdeBh8mxngNYz5GG2KH1C1x1tefaaFYV3vqC +ZR3NswCs91jbr975K4C1hVWyvOuCCzBrH81qX10He1j4RsOz2lXrFsh6h2jtn8NOs9qIVpAH7xxD +QRfTOixbJ0DlVUO1dmEmLy20dHXuFb+CVFira70iW2E+7sZKJRXbamHPxVYxJ0gTF69V+3IuWgpu +0SqW74zTvDkiUmMVvBTwn3OrL4oI9gKtCpDvZajdtqex7tlSoBufVTPukP2f/evvlfPPbClwo8Vq +1bR/9L4Yl1r/95qpjJ2gFVct+j6hwjTuojZrpPWCqYidYzZrpJVpnqjgjQO01hZTs3YnwqogrbUR +rLeuDKhv78tQL5JnqAOU+Wp+8HPX/sbhiyHSc0vbZVmOxUo/P/jsqgGq98UBunlfxrW+0m4aeJK1 +Tv4s89m5Ir84ytqnXIgrnjlLMfST4pfKMoKVMgm8VC0d+Sn8c9sytFWlaVgfVaV7w8Fc+R3Sai5s +Iu4T+AtrnfrdEbNFGc5qlq2wKcG9+SH57llAkeVVF7OUrUthNS9EBl8qMT/j4Kbr/h73O0/m5tJp +7xtO3LEgw1gtbMBbflrm9pXvFVru927YL41ZnDfQZPPsPoh1eIP7phUg9T8mKrsZvpdIzaVnk+xa +ymeizMW45xLs8OMYbgjz1W0XIuDtk/MZL3i2DmHV+i/W/t+5dUtbUYZPkyvEmbU7XUPehbU4l3Z5 +F1QAK/GRo5uXgB+dJKZrkDfjeL9gsg5hNbkv9sdwGE/hBbZBRLqGc5dUEOswiC/DcMY+Tbp6X4aX +nrZnC++A1shwjwS0bxxctF+Ok5RjtLomLtMsb5mbDsOiWN0vi2J5fd9iOgSLYXV3LcZX0M6biZBU +Y7KWudJ11LRmSUjHZHW//oz59ZjW+waX4rG6ulaS3Zo1h716LFbWkYCMnVVjsMocIwHpVY7FGKx1 +oa7leD/iO751W7BrWdVg2t+9EKwLol1rlLLv6NYM96jlipQvYxGsIqOWcwDT/yjxWrnfm242bB3Z +KkOUgWHGLsVqzQrEdGIW2brNO3klDQhyjNaUiHXOk7Dw1iUw66L9cnc0awamZBkxk1iYGGsqkZkY +axrbmgQaCuKwQg0FP6xo1lnsOgBoTWHXV0DrdGJ/UqwL6POBtPgqdhiZxIyGa4UbC1Lo81cw6wL+ +umABap4165lqf8fz1wXPjBDDug+0hkklZlRsK9DaUE/7soZtVRMQRWuesF3MYu29vb09Pw7j6lSP +q8dRvI2+bFZ4S1OnTnvGAUpr/+1Jl32ohsfj6OuX2Bey3mnunE71nySHW78+X51W6eJ09E1l9o2X +dNKZ1+ZhTFFjsH59pGV6rO/s+9p6dqbsfynrSI5wXBBkbV9JVbaQNHLChp1yO2upUYzTelhLoLJG +af3KDDVi/P1F9p1Czz0C2nPD5w9VrvgaUGGnFxitM9sBJ91e6/NulTPGRctz1YFinM2/l4fTiJnM +PuWZ/Bu3tFp9Gv8Yz+2s6LFryxxEZDn8QqfT2j+tCsSjFtS7phejrIdUY5HD2pZEqNWrwPlL9AFH +j9H6WBWLU+ePzSRYimxRY7KK/f19VjnBgu0wWfsMhWpPcgSxwGreA7lw7IHGYo2iStJOrfbx48cm +KWokq+8CbGpRMANs62mIsnZ81gyNj77BwKxA3muD0wGla2HAZL0KgtYimC7rm/sny74LgynCCLaY +Lmss1jYRunPcpIzhN3gf3Nv3YROzHu1iZnpGZbH2JFKLXjabjNZHLRqbSKZHqbA4NzsdMKMKtPqT +VTpusoQUYNX2yVddk6nU6OpuRmOx+jOg1mzCWDV5NhEeaZXJ6p2uSJeMVLtonRJ+vJoJpzJd2596 +FmzUcGtgHlgJwPZs6tSuMDXcqqlLAdKZssYWU+7h8yMH1S6wu0G/471IaNuZIvMDvy7r3llTwFoN +/i3q/oKbm9nX2MNlPW7yRaTVrAnlYiaTSWcyC9ucTxpNCeYqvVU8HFaJl9rci916xm2V4rZyZ0D8 +1r1PwtZ+XFaBZrUHg7e4rJeTY5Wak2OtAVi1mKxnE2QVodoTgpis0g8rirUGYJUmyHo6EdZarFYJ +wnooT4z137PZwmFPQ7fuAVg3zJ2fwqGKZt0Vn7pY1j+ON1RlpM+kagN0LnP++rNzA7AsDxCsw603 +SdT653/17qzL8NbhlualoPVn/z4wdE+bsreKdwSsxvf/RD4LgMwFY1/7WbBhz/Tv/lPwKUsH0mph ++TO2Rk6BcS7UVTir9iaJlALd+ueIQ0yQxrXP4k4F1gZSeLMOG1c8c0fnsb0r3m3CZmDP8jauCmQ1 +MuHxry4DZW87pmlWK36Roax6fP11LK1RlrEaZbMKp4Lvvkvn6ddf//brX2sS9Xas5JgKUES+PoCy +WvFMv7YNSoFlcG2A9ZG+4O6QUyBXKq0FcvNc3UzYehnQrIWKHqW1XBCXo21DrDWRZs2WKlaUCjmo +TAjJ1xplsxLHrJXKODYDuPk6iPWNduYVNGZtVpxR2lgJqGAA1j5lvh4HjFm5ijcCUvcXVdhqrhai +d+Uv96rk4rpWIcRmgfSlhwNR6xNNwn6SqhE9i6Zx87KgtS9RJIFeA6of8kEFixykxi0PhKw0lcCk +ytE9K7px6Zo2+A70h4izZDMBPvTLVD3LExs+7S8DEauRBSHYS4N62h+QmnW9Ehmb3iKW7whYta9S +yIWC2p51NVNm6FkR2rqA1cSSc9ZsVMm4kFVm61nuxC2w5UHo8wXmKkzyac92zC1M40hTZe5Zbq27 +bfM9fuvw+vZezZEJZzVzn+bUOniTeXpWcCbkZQGr1n/cte9AGiFZVwVO7SPCIu2YFaJ11QRZwKr5 +n4s4fRodaKvcPcsZrnp7IGQ1etnz46kZV0+uQ1dSCqxWmKO0SoUVe36LlAIbFY5wJsIBilUV7Vnk ++nWAYZUFimtY0x4gWMsQPYtUbI/grXApMCwI4aVLxAqaAlbT5sJGMBHrIWgKWFh7+6MwgLUWgFPA +hS2CWnvgKWBGIbB/CVhl+BRwYXuA1i2EFHBiIT/zDycFHNgjMGtHaJZN08HyYM/Jk9K1AhTDOnsA +ZS2DTAeDsNYPVIGseaDpIDnWCQ3LbVWRKpYdK/6G5bYS0nUZkDpM2QMQ6yFaxXJlwQDCWkSrWHbk +vJNDbityuuqx4R28eK09rAHW27164lYZO1312PT0Ll7rEddOJmMYQ21B3HqAnq52KVCFrVt4kwFH +jXVXAl5rHnckGIYxOSyLWgeYExd378qLWjvCW5l0YfzgDrx1A8O66kxYTutRTNZ151IGzlrCsBqV +YAveWkEJ52gAZl3BsRpDl6D1IC7rqrh1Ky7rmmO+PQnWDrR1Fce6gWFd+2GdNOtggqzgNauAY10X +tx7FWLPyk2ItiM9d5FiWMBVzj+BI0NqJcZ4lOtdWY5y/qoJWLaZ1wQbA2pCwTYi13joQtpZjKbAl +kL0MGX+b0JsCkHuaCJ1rGWSfkLBJtIHRrM6NbW5rHAm74trSBD2HKeE2K+j5FnASGKdGWxqElVBh +gacEq1nPJ22Cnh2zJkHo129mPc0qYB2In24UIjIgPwCyAhwZrBRCqd5bWgLWjnDDrgR+g0n1XtUD +vvPE1rD6AmWF+B3mnf7iANBK6F1sKxmjp+f8p7jWnU0fVew+YUHw/Ni6I7BaIjQqgQp+pzRXYrbq +2vG/YWnduupSBL6jRzyRY9mDcyZPYW1jfa1g39A7ID1qIGYlXdOjP5Y9kEnPKGWzQbfhBd+3Qbqq +ST0tkDV1iyTdovzsetbBKy+QssZg3/FptwIf4hF9jwnxwQ067F+GeXToKCf5sA8qEH7nCikLlku0 +zWqFKh8dbG1tHcpCz8NQZEGRs2UPmH+V+LtseqTOnIvuYO/fwEp+3CyydHF8gAbEO4LI2JVSeL1i +j/8H9g/ABkdaki4AAAlPdEVYdENvbW1lbnQAICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICBmyrzgAAAAAElFTkSuQmCC diff --git a/corpus/m6/native-template-controls-01/read/page-002.pdf b/corpus/m6/native-template-controls-01/read/page-002.pdf index 89fb7d27fbbdb4a03794effd961c4b799aec3ba8..954a2453d2cd802238876a912ee02bc70a42ff6f 100644 Binary files a/corpus/m6/native-template-controls-01/read/page-002.pdf and b/corpus/m6/native-template-controls-01/read/page-002.pdf differ diff --git a/corpus/m6/native-template-controls-01/read/page-002.xml b/corpus/m6/native-template-controls-01/read/page-002.xml index e44203ad99d6e09579fd00769eadf953830dd469..21905ddc3b9598c677be8fa3e97c2a8e680b0db9 100644 --- a/corpus/m6/native-template-controls-01/read/page-002.xml +++ b/corpus/m6/native-template-controls-01/read/page-002.xml @@ -1,2 +1,179 @@  - +iVBORw0KGgoAAAANSUhEUgAAAKsAAAIoCAMAAAAhjet0AAADAFBMVEUAAAABAQECAgIDAwMXHhna +kbrakbrakbrakbrakbrakbrakbrakbrakbrakrvakrvblr3bmr/TnLjbnMDcn8LdosPPo7bdpsXd +q8jdr8ncssqEtpHdt83duc7du8/evNDdvNDcvM/cvM/cvM/cvM/cvM/cvM/cvM/cvM/cvM/cvM/c +vM/cvc/cvc/avc/TvtDTv9DTv9DUv9DYwNHbwNHcwNHcwdLewdLcwtLdw9PdxNPdxtS6yK7dyNXd +ydXeydbgydfizNrnzd3lztzd0Nnd0Nnd0Njd0Nnd0Nnd0Nnd0Nno0d/d0dnd0dnd0dnd0dnd0dnd +0dnd0dnd0dnd0dnd0dnd0dmY0qeh0q2Z06iZ06iZ06iZ06iZ06iZ06iZ06iZ06iZ06ia06ie06mZ +06ig06uk1LTq1OHh1Nyc1Kib1Kie1ana1duq1sHt1+Tl1+Dr2+Ss287u3Oex3Kux3Ku03auz3auw +3t6w3t6w3t7v3uiw3t6w3t6w3t6x3t6x3t6w3t6w3t6w3t6w3t7V3uC938i737+937m336uy396z +39+939W9387U3+C24N/y4Ory4Oq64Ku54Ku/4La/4LS94Ni44d+84ay94ay64uC+4qy+4qy74uC8 +4uC94q694q++4qy+4qy+4qy+4qy+4qy+4qzy4uu+4ty+4qy+4q2+4qy+4qy+4+C+4+K+49+94+G/ +5OHQ5OPN6eXN6eLN6ebP6uXR6+TS6+LS6+XS6+XS6+XS6+XS6+XS6+XS6+XS6+XS6+XT7OHT7N/T +7N/T7ODT7OLT7OLT7OTT7OLX7ujc8Ovl8/Hm9PHn9PPp9fTs9vXy9vfw9/fv9/f09/j39/j5+vr7 +/fz9/f39/f39/f78/v79/v79/v79/v79/v79/v79/v78/v79/v79/v79/v7///////////////// +//////////////////////////////////////////////////////////////////////////// +//////////////+3cRl2AAAAGXRFWHRTbm93Ym91bmQgdGVtcGxhdGUAaGVhcnRzVrgCNgAAGoNJ +REFUeNrt3btPI1uaAHBSR+AxWhsEInLoXd0AjMwFDNeZI0czEzkCjahtQFhNYMlkyyKkJeAO9BJc +jeSgpI26hCEiWQHSCkPftmzKnmz+h/oLth4uux6nqs7j+6rbmv6kkWbm8vjdw3e+8yzXlMYYg2I2 +OuoaRkxhULPZYu87sJazlCF/c+uRBcmt6LEcji0Pvq21pxtWNzYrwyhtrq3kgvNg8E2tv+Q2ShV3 +lDZWgrD53je0djYqpCgV4sGyWDuVoCitxoGdgqDqsUnuafnBN7GGUgMTAbKDTQFR9dggloTyN7Ae +RForJWIeHMVulSsVXmwnZuvf/1LhxhYGmNaeXP9Fj0P5fUCdrSHYQzSreujs0WVrDnJYocXm8LLA +a1UPfCWyPtD+XqGOTbws8FjreVJBlzv01soaWi1wWXtBE+ktBmtlBWv4clrlvD07Lazp4ZjurbFY +SSl7CGyVh8m1Oc69Qo7DWlkn/GVUUKtFXXHPT0trpnaDyVpBatgpFzW3SZ7usbUrqRZAZKxt7Rg/ +cLkU0LEZrZVVlFIwtKpGt1otBXWWAqO1RKixYFajWK0E/+rVinjDdoCsR4EJMMSyWgkZewBjNTIg +x+xhLAV5GKsxB9gEpZJqbAfCatSAVVgqqXcdQlgPwDNAD/9EtghgVdmLPUUQplsDceshRrOSkkAW +t+pFoFCBjxz40DVlTgRKCFb/cLAlbDV6FgKVkLB5YWsBJwVIQ5dg55rqsU9P6WIDfDSYMtK1ghLg +hWBKr1jLOFbwQjC1hZSupPWsqBVl0Aqwbv1zWTdwrIUJsq79sMZkzf5zWdfj6lsFcesk1azCBFlX +JsianRTrFtIShjTPEjzvNOaESEULfF/TnGujdK4SvNVYw6CsDTfgrcb2APTGmxnr8GsYc5MQIwnW +4NeG5l4Gwh4RaWtbdM1tbr1hVIJl6KmLsZ9VxOldWYQ9IutkC7x3baBYB1mMOcE6/BGXsa9dxsjY +Ao7VOt7E71odAKtmtgHw6iCLsgdvNyxojd1AOOW0zg0L4N2LMGqVYazWdWHIddcKfNcaWgdWrwXM +gizSuaGdsYApu4HQtUZ3HbbMH7cMhSWkK9DZsWbdGgfEriB0rfF9l0PrJwKdIWfhTw0dVvuBjFWI +liVd1OvBWbVeHi4NCjTp2nt7ejTi6a3ParXvkkFgc1ETwv7z1W51HLtXX9msmn1FMyc65yIst10j +wduVZBIlSarp/7G4z0zW8TNEgvOY9dCR4O3UdNbOmsP4dLxjeHfbDFYHVqyHkZ46GWWpKd25bLqj +ZmhP+/RW60qZcB6QUsBO1yeJJDW1+j/40Ke3joqBMYXjbtqNwOraNxpV+tgkxvGengd9eqsTm+Nd +L64GVdevRt/faQbFmY79wGB1Yr0XTAVSwJxnfzX+/rVmcBzr//yRwaqpzovQPIlAGrSMC3ptnboX +Rm02d3Rsj8Hqfvwxt8asLZDTta079s5Cqc1L/WuuWKzawPUAJHNFyBHnrpEJYIb+RRKTdfQI5Ehb +EkyBstbfpaGaheuNzap13M8ZsGQCOQXMYhVJbX4M611Bz+6onkcNctS9jJQC6pWRrJ/orFesVm8e +GBWMqt6SBoLC1ypVBljWU3Yr4SkOmlQgDQSHlBnAbyU0rfEkL/tAkH00mvUjjbXGbdV6W/5fHJ65 +xBTYpW1W0/rIZx0/I0PLJaXAf9Bmq1lfq8+8Vm1wRPqrZpfXS9QpYDTrXpMuXfdqx39rNv/n16cO +h5X0+Flw65JWBD/RN+uO48t+7XBY9ZFhKxvE9XS15YBmrR43meO///Pxjd0aotW5m6XQFDCbda/J +EztV6bHPbA3T6oXMTl7S83s/V0Mn2JFTmWd2q17ADoK12WUzeXNBKVDjs36SnGMu0+cPqAf5EG6u +sBaUAtVLPqu5UhhhGT8vYyAXsmxhpoDU5A3JgWX9HBJ9dCgzWT+IWWvmn+WZ02o8mZynt1YF0tWI +PfPftc9rZWlcK12Pm2JJYGUBp9XIXKpP0DHnAnRzrLAkMFe33FajiB0WKK38VHNGO2xYEasxQhyE +p27hg6j10rJK4lYjdcO4R1WxMqDHcDu5DWEN5bbBrFdAVpNLyt0inHUXzmp2NV9lkB9Fy+vIWgW1 +GqOE7J6NDa6ErWe29Q3YatbdcfKWze0WMesxotWsZMNskAGsNdv6jGM1s6FsPMdfFbZKtvURzWp2 +Nm1oFRhiL6vxWDUA6ygFsPJ13LLCVik265uoddys+kzrO7eOmxV8LIC2Opr1NCYr77Lgcq/qKAPx +WGviGWCceOBan4WsNedtCA3b+ihidVIh1jCIVhdVeG2IanVTzTMEYatc3p4zYmm7/g5o3XFRrbNE +Iau6v5BwxuyS58N/n6zfxLyj+Umq+ptVxPq+lPDH7Lbqr1ms663anpsq9cSsKklqRNKh5bKeeRp1 +dIrEa60nE4GRLAtYz3a80tG9Ek7rdiI05t75rJ9qvjYdZQCvdSkREUnZaa1SQnf2qoQYXTHjspYT +0VFmsn76SGpRxz4xr1W2PdMpPYISd4nOevnxuCYFOd23CTis6qz5V07P29sA85lUANa21qz4aIf1 +PyVJ2qtGhPPiA4fV6FezC+7NoIX0tB9bHFm540kTs84mUgv+bbZFgna/LyaV3Efe7NZ6IkPev1z0 +Z4IsRN31XOBltxbnA3eGM77S9UcB6pX3HiSzdbAYsuU+782Df+GWfvBf02K2HoaeD/iwP3P++UmX +M1itnYhzl4UZt/UPYFKEdy14W5Y9C06DLukxWtXo07c5oYY9fQ6+WcxolSmOCtO8Dbt79cxyXzsq +jiisizMc1tOr58iHUBitWzRnsPPuhv1TeG06vXp8o3tWht4qy7TWrHsAm7869YXxHMzz2xvb8zxU +VnnbysHkHN3FAXfDJjWgoLDKjp6dznI0rByX1b1eTdFZ3ROD7Zis8rACJVMps8jTWRddA0I6Hqts +rFCmh0uABX3+n6HDzroaVo3DqiYNqWNmtZDhSQI5DqueAMn5LEcsYiRsqFVfWSUXs1zhWt1m8K36 +2nqak+qeFCTxrXpdneekehIW3SpTl/7IoUvGti7xZ4AeLmsd2aqKNGs2OwNfCIKt+4mEQLO6pwRF +ZOsC7eBPUQgyyNYk7Xj67a3vAgXLZ03jWmXaORVV0cK1bieSE2RN/bD+sE6QdWZirPUJqlmy4FgQ +p1VLiI2xsVrTYp0rVmtRbE4Yq7UuNteO1arNCFWteK1Fod6VjtX6nhBZHMZrNW5fpGGsaXTru3Fz +BcSaQbcaGTu9wGlNxWxVZ/g334Ktave13W6/drt9SKtRY3mxSe/dFzO6baUxDuXldzirkQWcWMIG +rPpy0/DGRVuFsqpGF5meh7B27yycotw+6HHbUn47N/+flz6M1UzZxDR7NVjwXNZSTem55RxFy2zo +iy8wVk02N9FSrOXAs6fZvtBN160HX9xeG9o7GOsQm0gv8lv/8JvRpgSpEcoJLZbm3NDCTqdZ2tY5 +FKQNjfIQFLfGP36BsWrv9i9OzfFY53XKSeshOFpGGnRhrI5zzulUhi4XxodxPxnU24ewMGquAmTV +BwXHNnVyNhNdxFJO6sX9Q3gYxasNZfVdeU6m0unM/HzksEVFNRv2BsyqZ20mQY5kyhnuCyTz0Qlg +xL2RsSqcNeDie2hQUh8eLmiSgO2+y3t5loWaaoQWK9bexXxHTy5Sc2eMwnnz8O2sBrfsvYlHjn8z +MuCe2nqBYTWzYX87EwFOU2eAZW1gWYctXN/ezmQCksLIgPOH78bqUJuxv21FXf/v/2X8+hal1Zht +nes/pvePGKy+6F8wNOuD/je4uGub/7X9ez9u6wtDtj7cuue2L+1uN07rBX0R0NPVkyv3+ncrr3FZ +X41m/fzAHZ/NdWM8VoUlBYJG3YaixmDtmivUewHrvbnIvejiW82edf0gEq2GF4tkFU6BYcq6sDhW +tcEyEAQWMgvbx7W2hdN13LDj9TiO1dpkEaQOM7bR+IJqvRDvWsORt+FYiaFYrXRVhK32tuIrovUL +kNXeq71BtL5AlAFHwg43ZVCsCpD11rbeff/Wh9HuN561AVJeHVYrCTCsVhloPABaX7CsXXjr3QRZ +FSzrC5S1NT5b+u6tyg8rivUC36oAWW8bsVlvH+BSANvaAkyB8+/c2nKe3n/nVucB/h2yVXCu7exZ +1rnH92u9dlq7yPVVzNpyXd/QkK2fRaj3Jw1vumJahdbc7psxr9jWC6BhQI8+trUBRh3uEn2X1tsT +t7WLZ31tiA0GXqp9+Im4huG1tjzU0fUSTCtfgVW8V+NGZ8oY1r6A9d5/ja+LaR3uZfAU2Na5jzq+ +DYViHf5C5s3iW3+jNm76uFaFq2jdKyd+qvPeFqr1Vljqug+HuP/KUrRanxvEcN0zRLVSFoJb5Zws +bSh9dGu7QT8rvFUuGg0qKo61S1m0bpXPJ43gOO9q8VlDCsF9S7kJc/pbFet8qxFSCHSlcn3eiAwf +Fcl6QygE9y0Ded2gjDv/rRccq11gFR2oCz9fX580mOL8lfBTcawvDbFQiHc2v0frecCNTRxrV4R6 +F+/9LAGr0g38qUh3SLjbtBvyQ5GsN1x5ehd+DRrzbg7jH/816hkezDtPLFCaR6OQrG1wqLC1q4fx +QJ4Zd4ojqJ13X6gf5eOxdn83YQ3RUF6+MD1yyGTtd3VjAyDOlZfXLnMbUVt15g2AUjGUfY0r6J4z +er27EDe+fOFFUlvVtsIP1IXtbjeWzyfU+q+8UKWvQUf4c8cCf/oLLU6r9Zgod8RoFZTGaW2LdvzY +rF2Ait+Nx9puNCbE2r9rTIq1DzLix2LtUo36J9fXv+mjUssM906QvbPS1rCt3Yj+f36tC0P3q23r +i4Zs7YdQz28Umo1qJSZrcK5eK7Sb/3FZ74KgDPduY7ISV58nCtulsHisXaKU9SqzfVFF0RCt/RsA +aUxWfwZc81wJjMPa9TfqA0/cxmD1lqtz3nuW+FZvs15wPx6Ab1WgqPhWT7NeCzx0gW69g2rV0RVb +PKtrznIidH35Gtn6xdWsYrdssa2uFIC5uIpmdQ6vos9fIltVuAwYWe+QrM6KJfyo6DXunPAFsFlj +tIo/gYtsVcCKQKzWeyhrF9168zA5VmWCrC0wq4ZkHdeBh8mxngNYz5GG2KH1C1x1tefaaFYV3vqC +ZR3NswCs91jbr975K4C1hVWyvOuCCzBrH81qX10He1j4RsOz2lXrFsh6h2jtn8NOs9qIVpAH7xxD +QRfTOixbJ0DlVUO1dmEmLy20dHXuFb+CVFira70iW2E+7sZKJRXbamHPxVYxJ0gTF69V+3IuWgpu +0SqW74zTvDkiUmMVvBTwn3OrL4oI9gKtCpDvZajdtqex7tlSoBufVTPukP2f/evvlfPPbClwo8Vq +1bR/9L4Yl1r/95qpjJ2gFVct+j6hwjTuojZrpPWCqYidYzZrpJVpnqjgjQO01hZTs3YnwqogrbUR +rLeuDKhv78tQL5JnqAOU+Wp+8HPX/sbhiyHSc0vbZVmOxUo/P/jsqgGq98UBunlfxrW+0m4aeJK1 +Tv4s89m5Ir84ytqnXIgrnjlLMfST4pfKMoKVMgm8VC0d+Sn8c9sytFWlaVgfVaV7w8Fc+R3Sai5s +Iu4T+AtrnfrdEbNFGc5qlq2wKcG9+SH57llAkeVVF7OUrUthNS9EBl8qMT/j4Kbr/h73O0/m5tJp +7xtO3LEgw1gtbMBbflrm9pXvFVru927YL41ZnDfQZPPsPoh1eIP7phUg9T8mKrsZvpdIzaVnk+xa +ymeizMW45xLs8OMYbgjz1W0XIuDtk/MZL3i2DmHV+i/W/t+5dUtbUYZPkyvEmbU7XUPehbU4l3Z5 +F1QAK/GRo5uXgB+dJKZrkDfjeL9gsg5hNbkv9sdwGE/hBbZBRLqGc5dUEOswiC/DcMY+Tbp6X4aX +nrZnC++A1shwjwS0bxxctF+Ok5RjtLomLtMsb5mbDsOiWN0vi2J5fd9iOgSLYXV3LcZX0M6biZBU +Y7KWudJ11LRmSUjHZHW//oz59ZjW+waX4rG6ulaS3Zo1h716LFbWkYCMnVVjsMocIwHpVY7FGKx1 +oa7leD/iO751W7BrWdVg2t+9EKwLol1rlLLv6NYM96jlipQvYxGsIqOWcwDT/yjxWrnfm242bB3Z +KkOUgWHGLsVqzQrEdGIW2brNO3klDQhyjNaUiHXOk7Dw1iUw66L9cnc0awamZBkxk1iYGGsqkZkY +axrbmgQaCuKwQg0FP6xo1lnsOgBoTWHXV0DrdGJ/UqwL6POBtPgqdhiZxIyGa4UbC1Lo81cw6wL+ +umABap4165lqf8fz1wXPjBDDug+0hkklZlRsK9DaUE/7soZtVRMQRWuesF3MYu29vb09Pw7j6lSP +q8dRvI2+bFZ4S1OnTnvGAUpr/+1Jl32ohsfj6OuX2Bey3mnunE71nySHW78+X51W6eJ09E1l9o2X +dNKZ1+ZhTFFjsH59pGV6rO/s+9p6dqbsfynrSI5wXBBkbV9JVbaQNHLChp1yO2upUYzTelhLoLJG +af3KDDVi/P1F9p1Czz0C2nPD5w9VrvgaUGGnFxitM9sBJ91e6/NulTPGRctz1YFinM2/l4fTiJnM +PuWZ/Bu3tFp9Gv8Yz+2s6LFryxxEZDn8QqfT2j+tCsSjFtS7phejrIdUY5HD2pZEqNWrwPlL9AFH +j9H6WBWLU+ePzSRYimxRY7KK/f19VjnBgu0wWfsMhWpPcgSxwGreA7lw7IHGYo2iStJOrfbx48cm +KWokq+8CbGpRMANs62mIsnZ81gyNj77BwKxA3muD0wGla2HAZL0KgtYimC7rm/sny74LgynCCLaY +Lmss1jYRunPcpIzhN3gf3Nv3YROzHu1iZnpGZbH2JFKLXjabjNZHLRqbSKZHqbA4NzsdMKMKtPqT +VTpusoQUYNX2yVddk6nU6OpuRmOx+jOg1mzCWDV5NhEeaZXJ6p2uSJeMVLtonRJ+vJoJpzJd2596 +FmzUcGtgHlgJwPZs6tSuMDXcqqlLAdKZssYWU+7h8yMH1S6wu0G/471IaNuZIvMDvy7r3llTwFoN +/i3q/oKbm9nX2MNlPW7yRaTVrAnlYiaTSWcyC9ucTxpNCeYqvVU8HFaJl9rci916xm2V4rZyZ0D8 +1r1PwtZ+XFaBZrUHg7e4rJeTY5Wak2OtAVi1mKxnE2QVodoTgpis0g8rirUGYJUmyHo6EdZarFYJ +wnooT4z137PZwmFPQ7fuAVg3zJ2fwqGKZt0Vn7pY1j+ON1RlpM+kagN0LnP++rNzA7AsDxCsw603 +SdT653/17qzL8NbhlualoPVn/z4wdE+bsreKdwSsxvf/RD4LgMwFY1/7WbBhz/Tv/lPwKUsH0mph ++TO2Rk6BcS7UVTir9iaJlALd+ueIQ0yQxrXP4k4F1gZSeLMOG1c8c0fnsb0r3m3CZmDP8jauCmQ1 +MuHxry4DZW87pmlWK36Roax6fP11LK1RlrEaZbMKp4Lvvkvn6ddf//brX2sS9Xas5JgKUES+PoCy +WvFMv7YNSoFlcG2A9ZG+4O6QUyBXKq0FcvNc3UzYehnQrIWKHqW1XBCXo21DrDWRZs2WKlaUCjmo +TAjJ1xplsxLHrJXKODYDuPk6iPWNduYVNGZtVpxR2lgJqGAA1j5lvh4HjFm5ijcCUvcXVdhqrhai +d+Uv96rk4rpWIcRmgfSlhwNR6xNNwn6SqhE9i6Zx87KgtS9RJIFeA6of8kEFixykxi0PhKw0lcCk +ytE9K7px6Zo2+A70h4izZDMBPvTLVD3LExs+7S8DEauRBSHYS4N62h+QmnW9Ehmb3iKW7whYta9S +yIWC2p51NVNm6FkR2rqA1cSSc9ZsVMm4kFVm61nuxC2w5UHo8wXmKkzyac92zC1M40hTZe5Zbq27 +bfM9fuvw+vZezZEJZzVzn+bUOniTeXpWcCbkZQGr1n/cte9AGiFZVwVO7SPCIu2YFaJ11QRZwKr5 +n4s4fRodaKvcPcsZrnp7IGQ1etnz46kZV0+uQ1dSCqxWmKO0SoUVe36LlAIbFY5wJsIBilUV7Vnk ++nWAYZUFimtY0x4gWMsQPYtUbI/grXApMCwI4aVLxAqaAlbT5sJGMBHrIWgKWFh7+6MwgLUWgFPA +hS2CWnvgKWBGIbB/CVhl+BRwYXuA1i2EFHBiIT/zDycFHNgjMGtHaJZN08HyYM/Jk9K1AhTDOnsA +ZS2DTAeDsNYPVIGseaDpIDnWCQ3LbVWRKpYdK/6G5bYS0nUZkDpM2QMQ6yFaxXJlwQDCWkSrWHbk +vJNDbityuuqx4R28eK09rAHW27164lYZO1312PT0Ll7rEddOJmMYQ21B3HqAnq52KVCFrVt4kwFH +jXVXAl5rHnckGIYxOSyLWgeYExd378qLWjvCW5l0YfzgDrx1A8O66kxYTutRTNZ151IGzlrCsBqV +YAveWkEJ52gAZl3BsRpDl6D1IC7rqrh1Ky7rmmO+PQnWDrR1Fce6gWFd+2GdNOtggqzgNauAY10X +tx7FWLPyk2ItiM9d5FiWMBVzj+BI0NqJcZ4lOtdWY5y/qoJWLaZ1wQbA2pCwTYi13joQtpZjKbAl +kL0MGX+b0JsCkHuaCJ1rGWSfkLBJtIHRrM6NbW5rHAm74trSBD2HKeE2K+j5FnASGKdGWxqElVBh +gacEq1nPJ22Cnh2zJkHo129mPc0qYB2In24UIjIgPwCyAhwZrBRCqd5bWgLWjnDDrgR+g0n1XtUD +vvPE1rD6AmWF+B3mnf7iANBK6F1sKxmjp+f8p7jWnU0fVew+YUHw/Ni6I7BaIjQqgQp+pzRXYrbq +2vG/YWnduupSBL6jRzyRY9mDcyZPYW1jfa1g39A7ID1qIGYlXdOjP5Y9kEnPKGWzQbfhBd+3Qbqq +ST0tkDV1iyTdovzsetbBKy+QssZg3/FptwIf4hF9jwnxwQ067F+GeXToKCf5sA8qEH7nCikLlku0 +zWqFKh8dbG1tHcpCz8NQZEGRs2UPmH+V+LtseqTOnIvuYO/fwEp+3CyydHF8gAbEO4LI2JVSeL1i +j/8H9g/ABkdaki4AAAlPdEVYdENvbW1lbnQAICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICBmyrzgAAAAAElFTkSuQmCC + diff --git a/corpus/outline-edit/README.md b/corpus/outline-edit/README.md index 92f9b0d0a9aba3b94b0dedcaddeeab5a1b48d6df..42b3c1c4fe77b26dd0d0d650724244199eef7875 100644 --- a/corpus/outline-edit/README.md +++ b/corpus/outline-edit/README.md @@ -46,7 +46,7 @@ The controller selects the target again after activating the native window and transfers XML as UTF-8 data. Captured authoring scripts retain their exact source; identical immutable artifacts link to one canonical corpus copy. -`layout/candidate` applies `PreparedEdit::outline` to the five geometry and saved +`layout/candidate` applies Rust outline edits to the five geometry and saved collapse cases in `before`. `layout/cold` captures that Rust output through a fresh OneNote cache. Every active identity, child/content/structure reference, child level, and collapse default survives; text, fields, tags and explicit formatting compare diff --git a/corpus/page-model/README.md b/corpus/page-model/README.md index c1307cf93d91157ea6b88a82ede89a3b253f182d..6445e48b1851d4f3aa2066d2641789c74d1609ea 100644 --- a/corpus/page-model/README.md +++ b/corpus/page-model/README.md @@ -1,6 +1,6 @@ # Page-model edits through a cold native reopen -`candidate` is `corpus/outline-edit/before` after eight compound `PreparedEdit::page` +`candidate` is `corpus/outline-edit/before` after eight compound page-model publications, one per edited page, each combining several kinds of change in a single transaction: paragraph reorder and Unicode replacement with a bold range; a subtree move with a new nested child and an italic/colored/resized range; a subtree deletion diff --git a/corpus/paragraph-edit/README.md b/corpus/paragraph-edit/README.md index 13ebe199e69cb9b6431fcd8a29d23a249168f47b..652844d8e69cde9043718ec511614cebed57fcc4 100644 --- a/corpus/paragraph-edit/README.md +++ b/corpus/paragraph-edit/README.md @@ -26,7 +26,7 @@ notebook before completing inspection. Cold-open the split notebook for the join pass. Captured COM IDs belong to their originating sessions; discover fresh IDs when regenerating the corpus. -`rust-split` retains twelve `ParagraphSplit` outputs, their cold native captures, +`rust-split` retains twelve Rust paragraph-split outputs, their cold native captures, and a second native session typing into four empty boundary paragraphs. The hyperlink case has no output: the operation rejected fields when this row was captured, and the writer now splits before a link as OneNote does. The Rust diff --git a/corpus/protected-edit/README.md b/corpus/protected-edit/README.md index 21d294a63600c317ebb157826b4e0d042aeecc67..bc7dc43bc32e6c0817ecf781b64e2f6c3445e44a 100644 --- a/corpus/protected-edit/README.md +++ b/corpus/protected-edit/README.md @@ -1,7 +1,7 @@ # Protected page edit `candidate/notebook` is `native-encrypted/encrypted-01` after -`Notebook::save_unlocked` appended text to the first paragraph and added a +a Rust edit of the unlocked page appended text to the first paragraph and added a paragraph, exported by `an_unlocked_page_is_saved_under_the_section_key` (`ONESTORE_PROTECTED_EXPORT`). `candidate/read` is OneNote 2010's COM read from a fresh clone with an empty cache after unlocking the section in its UI with the diff --git a/corpus/table-edit/README.md b/corpus/table-edit/README.md index 24f1e88e56ac8043ec15f6b143fd8f39a5785ec6..5c413768c060e666a9906f88746c6aa4d26685f0 100644 --- a/corpus/table-edit/README.md +++ b/corpus/table-edit/README.md @@ -27,7 +27,7 @@ modification time, the indent array copied from a sibling cell (OneNote's default without one), and the flags every native cell carries, rebuilds each table's row and cell order to the model's, and writes the column count, widths, locks and border flag. New tables start as an empty paragraph whose content the structure pass replaces. -An emptied cell keeps a replacement paragraph, as the tree writer provides. +An edit that ends with an empty cell is refused; its ops refill the cell themselves. `tools/test_table_edit.py` checks all three captures without a VM. Regenerate with `ONESTORE_TABLE_EXPORT`, `ONESTORE_TABLE_EDIT_EXPORT` and `ONESTORE_NESTED_TABLE_EXPORT` set to new absolute directories while running diff --git a/crates/notebook/Cargo.toml b/crates/notebook/Cargo.toml index 798548ff3a48fc39ae90b621a5de01e91b23c191..be20d9945a195e6b3cecd1af67c01d0e0a5df09a 100644 --- a/crates/notebook/Cargo.toml +++ b/crates/notebook/Cargo.toml @@ -36,3 +36,7 @@ required-features = ["smb"] [[example]] name = "smb_concurrent_client" required-features = ["smb"] + +[[example]] +name = "smb_publish_probe" +required-features = ["smb"] diff --git a/crates/notebook/README.md b/crates/notebook/README.md index aca6350886a28468c7c89088df71beb923831914..dd79930b674765da96546172bb80dfbba09d5144 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -11,7 +11,7 @@ each by hash. Local success does not acknowledge publication to a shared noteboo editor ── Edit ──► section thread ─ Section::apply, INSERT edit ─┐ (the parsed section: base + sealed + open) │ one fsync per burst sync thread: remote.stamp() == base ? publish(sealed batch) ──────┤ base chunks += transaction - otherwise read once, replay the queue on it ─────────┘ conflict, or base := remote + otherwise read once, replay the queue on it ─────────┘ base := remote, conflict pages queued ``` ## Edits @@ -56,15 +56,11 @@ same queue after a relaunch. A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains remote changes (`Changed(spaces)`), publication outcomes, unreachable files and failures; `notify` runs on a background thread whenever an event waits. -`conflict()`, `remote_page(space)` and `resolve(id, Resolution)` present and end a -conflict; `release(id, archive, Resolution)` ends an uncertain attempt. +`conflicts()` lists each page's conflict pages, which `page` reads and `delete_pages` +removes; `release(id, archive, Resolution)` ends an uncertain attempt. The author an +edit names is the host's: the app passes the account's full name. `import_page` creates a page holding a copy of another; `delete_pages` removes pages. -Until the application emits ops, `save`, `queue_save`, `saved`, `saving`, -`conflicts`, `queue`, `review` and `Event::Refreshed` keep the whole-page API: a -save lowers the page it is given against the stored page (`onestore::op::lower_page`) -and applies the ops. - `Section::resume(file, replica, notify)` starts from an owned `Replica` without consulting the remote file; with the `smb` feature, `Section::resume_smb(path, replica, limit, connect, notify)` binds a share-relative path, `connect` running on @@ -81,10 +77,15 @@ queue replays on it (`merge.rs`): an op whose objects the remote left alone appl as it is; a text op shifts past the remote's changes to the same text when its range stays clear of them; a move, deletion or property the remote already made is done; a page the remote already holds as the local edits leave it drops their ops. -Anything else is a `Conflict` on the batch: the queue stays on its base and the -remote image is kept for review. `resolve(id, Mine)` rewrites the remote page to the -local one (`lower_page`, once), `Theirs` drops the local ops on that page; either -applies from the conflicted batch on. +Anything else conflicts, as in OneNote 2010: the op is dropped with every later op naming +what it named, the remote's version stays the page, and the local version becomes a +conflict page under it (`SectionOp::Conflict`, queued as one more edit and published with +the rest), its conflicting objects marked, named for the author of the first edit that did +not replay. Page lists merge as OneNote 2010 merges page series: a page the remote moved +keeps the remote's place, a page moved here goes before the next page in the local order +the remote left in place, or last, and a page the remote removed comes back as a new copy +of the local one, placed the same way (`corpus/conflict-page/native-pages`, +`native-restore`). Nothing blocks the queue. Publication attempts are recorded before network I/O. An attempt confirms only when the remote holds its revisions, or every page it changed as it changed them, @@ -96,7 +97,9 @@ A durable `Published` receipt survives reopening. A schema-14 cache is converted when opened: it is exported to `.v14-recovery`, each queued page is lowered against the page the conversion has so far, and every converted page must equal the page in the old working image, -or the conversion rolls back and the open fails naming the archive. +or the conversion rolls back and the open fails naming the archive; a page it held in +conflict becomes a conflict page. A schema-15 cache's batches lose the review conflict +they recorded when opened. With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an `notebook::smb::Client` to one share-relative file and snapshot limit. Remote identity uses the logical root @@ -161,7 +164,7 @@ conflict policy lives in the client. ## Recovery archives `export_recovery(new_path)` captures the base and remote images, the edit queue, -uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one +uncertain attempts, receipts, downloaded media and the edit-ID sequence in one SQLite snapshot. It refuses existing destinations and leaves the live queue unchanged. Export to a local directory from a background thread: copying holds the cache mutex while capturing the database. Failure after the final rename can @@ -261,16 +264,16 @@ targets are `None`. With the optional `protected` feature, `Notebook::unlock(path, password)` reads a `Locked` section as `Unlocked { pages, .. }`, and -`Notebook::save_unlocked(path, password, &mut unlocked, space, page, author)` -saves an edited page under the section's key, straight to the file: nothing of -a protected section is cached or queued, a section written since `unlocked` -was read fails the save, and a wrong password is +`Notebook::apply_unlocked(path, password, &mut unlocked, author, edit)` +applies page ops and stores them under the section's key, straight to the file: +nothing of a protected section is cached or queued, a section written since +`unlocked` was read refuses the edit, and a wrong password is `Error::Protected(PasswordMismatch)`. `Section::import_page(page, author)` copies a page, usually read from another -section, to the end of this one as a page creation and a save queued like the +section, to the end of this one as one `SectionOp::Import` queued like the user's own edits, under fresh identities (`Page::copy`); payloads travel with -the model, so the copy publishes offline later like any save. Content outside +the model, so the copy publishes offline later like any edit. Content outside the model refuses to copy. A move is an import here followed by `Section::delete_pages` there. @@ -309,15 +312,20 @@ let document = onestore::document::Document::parse(&revisions)?; ``` Paths are relative to the share. The read limit bounds the complete physical -snapshot. Call from a background thread outside a Tokio runtime. Use identities -from the document and the same snapshot with `Client::commit_text` or -`Client::commit_property_bytes`; their errors retain `onestore::CommitState`. -`PreparedEdit::page` separates preparation from I/O: inspect the immutable image -and persist the intended revision identity before `Client::commit_transaction` publishes -its `transaction()`. `Client::stamp` reads a file's header and length without coordination, -for polling. `Client::confirm_snapshot` checks and flushes an observed image's stamp, then refreshes -its header version metadata without adding a revision. The caller must first -establish which intents that image contains and reread before another commit. +snapshot. Call from a background thread outside a Tokio runtime. +`Client::commit_transaction` publishes a `Transaction` from `Section::seal`; its errors +retain `onestore::CommitState`. `Client::stamp` reads a file's header and length in one +round trip without coordination, for polling. `Client::confirm` checks and flushes a +stamp, then refreshes its header version metadata without adding a revision. The caller +must first establish which intents that image contains and reread before another commit. + +A commit is eight round trips while its appended bytes and patches fit fifteen 64 KiB +writes: open; the coordination locks, identity queries and the stamp check's reads as one +compound request; the path's identity; the appended bytes and patches, the header, the +counter (twice at a carry) and the version, each as its writes and a flush in one +compound; close. The server +performs a compound in order and answers its flush once the writes are durable, so the +writes carry no write-through flag. Readers use shared native guards while writers publish under native write-open and byte-lock exclusion. Maintenance is excluded during each operation; pathname diff --git a/crates/notebook/examples/cache_probe.rs b/crates/notebook/examples/cache_probe.rs index 0e1df5bad63a10f9728981594fcb4ff3c34eeeb9..2176f33a28e75d22cf3bf794055d64bede8f1517 100644 --- a/crates/notebook/examples/cache_probe.rs +++ b/crates/notebook/examples/cache_probe.rs @@ -32,6 +32,17 @@ fn content(bytes: &[u8]) -> (ExGuid, ExGuid, String) { .unwrap() } +/// The images the queue leaves and started from, as a recovery archive records them. +fn images(cache: &Replica, path: &Path) -> Result<(Vec, Vec), Box> { + let archive = path.with_extension(format!("probe-{}", std::process::id())); + cache.export_recovery(&archive)?; + let recovery = notebook::Recovery::open(&archive)?; + let images = (recovery.snapshot()?, recovery.remote_snapshot()?); + drop(recovery); + std::fs::remove_file(&archive)?; + Ok(images) +} + fn payload(operation: u64, size: usize) -> String { format!("{operation}:🦀{}", "x".repeat(size)) } @@ -73,8 +84,7 @@ fn main() -> Result<(), Box> { } let cache = Replica::open(path)?; if mode == "read" { - let snapshot = cache.snapshot()?; - let base = cache.remote_snapshot()?; + let (snapshot, base) = images(&cache, path)?; let (sid, target, mut expected) = content(&base); let store = Store::parse(&snapshot)?; assert!(store.checksum_mismatches.is_empty()); @@ -164,8 +174,7 @@ fn main() -> Result<(), Box> { let instruction = lines.next().unwrap()?; let (operation, acknowledgement) = instruction.split_once(' ').unwrap(); let operation: u64 = operation.parse()?; - let source = cache.snapshot()?; - let (sid, oid, text) = content(&source); + let (sid, oid, text) = content(&images(&cache, path)?.0); let replacement = payload(operation, size); println!("editing {operation}"); io::stdout().flush()?; diff --git a/crates/notebook/examples/keystroke_probe.rs b/crates/notebook/examples/keystroke_probe.rs index b7829a358d5851bbc136d47c7f89db3861f717b1..d0579cd7c47d4792a6888875e22b1a5cecafc4d5 100644 --- a/crates/notebook/examples/keystroke_probe.rs +++ b/crates/notebook/examples/keystroke_probe.rs @@ -66,15 +66,15 @@ impl Remote for Memory { self.read += self.bytes.len() as u64; Ok(self.bytes.clone()) } - fn stamp(&mut self) -> io::Result> { + fn stamp(&mut self) -> io::Result { self.read += 1024; - Ok(Stamp::of(&self.bytes).ok()) + Stamp::of(&self.bytes).map_err(io::Error::other) } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { transaction.commit(self) } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - onestore::confirm_snapshot(self, snapshot) + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + onestore::confirm(self, base) } } @@ -135,6 +135,7 @@ fn main() -> Result<(), Box> { identity: None, created: None, margin_origin: [0.0; 2], + color: None, objects: vec![PageObject::Outline(Outline { id: new_id()?, title: false, diff --git a/crates/notebook/examples/migrate_probe.rs b/crates/notebook/examples/migrate_probe.rs index c26e8032a9628e230bad606ae71ce34f6ca22908..fa449976cf8984da3df03528e0f9076106b1bc73 100644 --- a/crates/notebook/examples/migrate_probe.rs +++ b/crates/notebook/examples/migrate_probe.rs @@ -28,14 +28,14 @@ impl Remote for Memory { fn read(&mut self) -> io::Result> { Ok(self.0.clone()) } - fn stamp(&mut self) -> io::Result> { - Ok(Stamp::of(&self.0).ok()) + fn stamp(&mut self) -> io::Result { + Stamp::of(&self.0).map_err(io::Error::other) } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { transaction.commit(self) } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - onestore::confirm_snapshot(self, snapshot) + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + onestore::confirm(self, base) } } @@ -72,11 +72,11 @@ fn main() -> Result<(), Box> { let pending = replica.pending()?; let summary = replica.recovery_summary()?; println!( - "{path}: converted in {:.1} ms; {} edits; {:?}; conflict {:?}", + "{path}: converted in {:.1} ms; {} edits; {:?}; conflict pages {:?}", elapsed.as_secs_f64() * 1e3, pending.len(), summary, - replica.conflict()? + replica.conflicts()? ); for edit in &pending { println!( @@ -89,11 +89,15 @@ fn main() -> Result<(), Box> { for (space, title, level) in replica.pages()? { println!(" page {space} {level} {title:?}"); } - let blocked = replica.conflict()?.is_some() - || replica.recovery_summary()?.uncertain_edits > 0; + let blocked = replica.recovery_summary()?.uncertain_edits > 0; if !pending.is_empty() && !blocked { let local = pages(&replica)?; - let mut remote = Memory(replica.remote_snapshot()?); + // The remote the queue was made on, as a recovery archive records it. + let archive = copy.with_extension("probe-recovery"); + replica.export_recovery(&archive)?; + let mut remote = + Memory(notebook::Recovery::open(&archive)?.remote_snapshot()?); + std::fs::remove_file(&archive)?; let synced = replica.sync_once(&mut remote)?; let published = { let arena = onestore::Arena::default(); diff --git a/crates/notebook/examples/open_probe.rs b/crates/notebook/examples/open_probe.rs index d093914bf64d997dbad657b4246d415fcf8f7639..d3370a13769574a67c332621068a8b3520e5cd34 100644 --- a/crates/notebook/examples/open_probe.rs +++ b/crates/notebook/examples/open_probe.rs @@ -44,6 +44,7 @@ fn generated(count: usize) -> Result, Box> { identity: None, created: None, margin_origin: [0.0; 2], + color: None, objects: vec![PageObject::Outline(Outline { id: new_id()?, title: false, @@ -106,14 +107,14 @@ impl Remote for Memory { fn read(&mut self) -> io::Result> { Ok(self.0.clone()) } - fn stamp(&mut self) -> io::Result> { - Ok(Stamp::of(&self.0).ok()) + fn stamp(&mut self) -> io::Result { + Stamp::of(&self.0).map_err(io::Error::other) } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { transaction.commit(self) } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - onestore::confirm_snapshot(self, snapshot) + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + onestore::confirm(self, base) } } diff --git a/crates/notebook/examples/queue_scale.rs b/crates/notebook/examples/queue_scale.rs index 4b5e4393cc08bab0482a7c7d57993a199014e4eb..28833b139bc2dbd89b4429e5a73f9aa2ce4f2d10 100644 --- a/crates/notebook/examples/queue_scale.rs +++ b/crates/notebook/examples/queue_scale.rs @@ -1,8 +1,8 @@ use notebook::{EditStatus, Remote, Replica}; use onestore::{ - CommitError, PageCreation, PreparedEdit, RevisionIndex, Store, Transaction, - document::Document, - page::{Page, PageObject, Paragraph, text::Edit}, + Arena, CommitError, ExGuid, PageCreation, Section, Stamp, Transaction, + op::{Edit, Op, PageOp, SectionOp}, + page::PageObject, }; use std::{io, path::PathBuf, time::Instant}; @@ -12,11 +12,14 @@ impl Remote for FileRemote { fn read(&mut self) -> io::Result> { onestore::read_file(&self.0) } + fn stamp(&mut self) -> io::Result { + Stamp::of(&self.read()?).map_err(io::Error::other) + } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { transaction.commit_file(&self.0) } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - onestore::confirm_file_snapshot(&self.0, snapshot) + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + onestore::confirm_file(&self.0, base) } } @@ -29,13 +32,14 @@ fn main() -> Result<(), Box> { std::fs::create_dir(&directory)?; let source = onestore::create_section("queue.one", "First", "Fixture")?; let creation = PageCreation::new(None, Some("Second"), "Fixture")?; - let source = PreparedEdit::create_page(&source, &creation)? - .as_bytes() - .to_vec(); - let pages = { - let store = Store::parse(&source)?; - let index = RevisionIndex::parse(&store)?; - Document::parse(&index)?.pages()? + let (source, pages) = { + let arena = Arena::default(); + let mut section = Section::open(&arena, source)?; + let ops = vec![Op::Section(SectionOp::Create(creation))]; + section.apply("Fixture", &Edit { at: 133_000_000_000_000_000, ops })?; + section.seal()?; + let pages: Vec = section.pages()?.into_iter().map(|(space, ..)| space).collect(); + (section.image(), pages) }; assert_eq!(pages.len(), 2); let path = directory.join("cache.sqlite"); @@ -48,40 +52,32 @@ fn main() -> Result<(), Box> { for n in 0..count { let edit_start = Instant::now(); let slot = n % 2; - let space = pages[slot].0; - let mut page = cache.page(space)?; + let space = pages[slot]; + let page = cache.page(space)?; let text = page .objects - .iter_mut() + .iter() .find_map(|object| match object { - PageObject::Outline(outline) => { - outline.paragraphs.iter_mut().find_map(|p| p.text_mut()) - } PageObject::Title(title) => title .outlines - .iter_mut() - .flat_map(|outline| &mut outline.paragraphs) - .find_map(|p| p.text_mut().filter(|text| text.date_field.is_none())), + .iter() + .flat_map(|outline| &outline.paragraphs) + .find_map(|p| p.text().filter(|text| text.date_field.is_none())), _ => None, }) .unwrap(); expected[slot] = format!("Edit {n} 🦀 e\u{301}"); let end = u32::try_from(text.text.text().encode_utf16().count())?; - let format = text.text.format_at(0)?.clone(); - text.text.apply(Edit { + let op = PageOp::Text { + text: text.id, range: 0..end, - replacement: Paragraph::new(expected[slot].clone(), format), - })?; - let before = cache.page(space)?; - let ops = onestore::op::lower_page(&before, &page)?; + with: expected[slot].clone(), + }; let id = cache.apply( "Fixture", - onestore::op::Edit { + Edit { at: 133_000_000_000_000_000, - ops: ops - .into_iter() - .map(|op| onestore::op::Op::Page { space, op }) - .collect(), + ops: vec![Op::Page { space, op }], }, )?; assert!(ids.last().is_none_or(|previous| *previous < id)); @@ -108,8 +104,7 @@ fn main() -> Result<(), Box> { .collect::>(), ids ); - let source = cache.snapshot()?; - verify(&source, &pages, &expected)?; + verify(&cache, &pages, &expected)?; println!( "{}", serde_json::json!({"phase":"reopen","seconds":start.elapsed().as_secs_f64()}) @@ -132,9 +127,13 @@ fn main() -> Result<(), Box> { serde_json::json!({"phase":"publish","count":ids.len(),"ms":step.elapsed().as_secs_f64()*1000.0}) ); assert!(cache.pending()?.is_empty()); - let published = cache.snapshot()?; - assert_eq!(onestore::read_file(&remote.0)?, published); - verify(&published, &pages, &expected)?; + let published = onestore::read_file(&remote.0)?; + verify(&cache, &pages, &expected)?; + let arena = Arena::default(); + let section = Section::open(&arena, published.clone())?; + for space in &pages { + assert_eq!(section.page(*space)?, cache.page(*space)?); + } drop(cache); let cache = Replica::open(&path)?; for id in &ids { @@ -152,7 +151,11 @@ fn main() -> Result<(), Box> { .collect::>(), ids ); - verify(&archive.snapshot()?, &pages, &expected)?; + let arena = Arena::default(); + let section = Section::open(&arena, archive.snapshot()?)?; + for (slot, space) in pages.iter().enumerate() { + assert_eq!(section.page(*space)?.title, expected[slot]); + } println!( "{}", serde_json::json!({"phase":"complete","count":count, @@ -163,17 +166,12 @@ fn main() -> Result<(), Box> { } fn verify( - bytes: &[u8], - pages: &[(onestore::ExGuid, onestore::ExGuid)], + cache: &Replica, + pages: &[ExGuid], expected: &[String; 2], ) -> Result<(), Box> { - let store = Store::parse(bytes)?; - assert!(store.checksum_mismatches.is_empty()); - let index = RevisionIndex::parse(&store)?; - index.validate_current()?; - let document = Document::parse(&index)?; - for (slot, (space, _)) in pages.iter().enumerate() { - assert_eq!(Page::from_space(&document, *space)?.title, expected[slot]); + for (slot, space) in pages.iter().enumerate() { + assert_eq!(cache.page(*space)?.title, expected[slot]); } Ok(()) } diff --git a/crates/notebook/examples/recovery_probe.rs b/crates/notebook/examples/recovery_probe.rs index 6a14689bbdde8f6ab1b9263621432439cbc10a44..fe95596907bcbfc4cbc8c7415f8f88ae21fb822f 100644 --- a/crates/notebook/examples/recovery_probe.rs +++ b/crates/notebook/examples/recovery_probe.rs @@ -1,10 +1,10 @@ mod support { pub mod view; } -use support::view::view; +use support::view::{cached, view}; use notebook::{EditStatus, Remote, Replica}; -use onestore::{CommitError, CommitIo, Transaction}; +use onestore::{CommitError, CommitIo, Stamp, Transaction}; use serde_json::json; use std::{ env, @@ -73,16 +73,19 @@ impl Remote for Disk { phase("publish-after"); result } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + fn stamp(&mut self) -> io::Result { + Stamp::of(&self.read()?).map_err(io::Error::other) + } + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { phase("confirm-before"); - let result = onestore::confirm_snapshot(self, snapshot); + let result = onestore::confirm(self, base); phase("confirm-after"); result } } fn report(cache: &Replica, root: &Path) -> Result<(), Box> { - let local = view(&cache.snapshot()?)?; + let (_, _, local) = cached(cache)?; let remote = view(&fs::read(root.join("remote.one"))?)?; let (status, revision) = match cache.status(1)? { Some(EditStatus::Pending) => ("pending", None), @@ -90,14 +93,13 @@ fn report(cache: &Replica, root: &Path) -> Result<(), Box ("uncertain", Some(revision.to_string())) } Some(EditStatus::Published { revision }) => ("published", Some(revision.to_string())), - Some(EditStatus::Conflict(_)) => ("conflict", None), Some(EditStatus::Archived { archive }) => ("archived", Some(archive)), None => ("missing", None), }; println!( "{}", json!({"event":"state", "status":status, "revision":revision, - "local_text":local.text, "remote_text":remote.text, "remote_revision":remote.revision.to_string(), + "local_text":local, "remote_text":remote.text, "remote_revision":remote.revision.to_string(), "pending":cache.pending()?.iter().map(|pending| match &pending.edit.ops[..] { [onestore::op::Op::Page { op: onestore::op::PageOp::Text { range, with, .. }, .. }] => json!({"id":pending.id,"replacement":with,"range":[range.start,range.end]}), diff --git a/crates/notebook/examples/save_probe.rs b/crates/notebook/examples/save_probe.rs index 1e2af9eb89cb96d0fe96b23bf5c22000557ec5e2..6020ced7fe383cb16bb0df7a32a740613cf93674 100644 --- a/crates/notebook/examples/save_probe.rs +++ b/crates/notebook/examples/save_probe.rs @@ -1,12 +1,13 @@ -//! Times importing a generated page into a copy of a section, then saving a one-character -//! edit of it: `save_probe SECTION PARAGRAPHS`. +//! Times importing a generated page into a copy of a section, then typing one character +//! into it until durable and queued: `save_probe SECTION PARAGRAPHS`. use notebook::session::Section; use onestore::{ document::{Format, Layout}, + op::{Edit, Op, PageOp}, page::{ Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, - text::{Edit, new_id}, + text::new_id, }, }; use std::time::{Duration, Instant}; @@ -49,6 +50,7 @@ fn main() -> Result<(), Box> { identity: None, created: None, margin_origin: [0.0; 2], + color: None, objects: vec![PageObject::Outline(Outline { id: new_id()?, title: false, @@ -67,38 +69,38 @@ fn main() -> Result<(), Box> { let start = Instant::now(); let space = section.import_page(&imported, "Probe")?; println!("import {count}: {:?}", start.elapsed()); - let before = section.page(space)?; - let edit = |page: &Page, word: &str| -> Result> { - let mut after = page.clone(); - let text = after - .objects - .iter_mut() - .find_map(|object| match object { - PageObject::Outline(outline) if outline.paragraphs.len() == count => { - outline.paragraphs[count / 2].text_mut() - } - _ => None, - }) - .ok_or("the imported outline is missing")?; - let format = text.text.format_at(0)?.clone(); - text.text.apply(Edit { - range: 0..0, - replacement: Paragraph::new(word.into(), format), - })?; - Ok(after) + let text = section + .page(space)? + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) if outline.paragraphs.len() == count => { + outline.paragraphs[count / 2].text().map(|text| text.id) + } + _ => None, + }) + .ok_or("the imported outline is missing")?; + let typed = |word: &str| Edit { + at: 133_000_000_000_000_000, + ops: vec![Op::Page { + space, + op: PageOp::Text { + text, + range: 0..0, + with: word.into(), + }, + }], }; - let after = edit(&before, "x")?; let start = Instant::now(); - section.save(space, &before, &after, "Probe")?; - println!("save: {:?}", start.elapsed()); - let next = edit(&after, "y")?; + section.replica().apply("Probe", typed("x"))?; + println!("typed, durable: {:?}", start.elapsed()); let start = Instant::now(); - section.queue_save(space, after, next, "Probe")?; - println!("queue_save: {:?}", start.elapsed()); + section.apply("Probe", typed("y"))?; + println!("typed, queued: {:?}", start.elapsed()); let deadline = start + Duration::from_secs(600); while Instant::now() < deadline { - if let Some((_, save)) = section.saved().pop() { - println!("queued save done: {:?} {save:?}", start.elapsed()); + if section.replica().pending()?.len() == 2 { + println!("queued edit durable: {:?}", start.elapsed()); break; } std::thread::sleep(Duration::from_millis(1)); diff --git a/crates/notebook/examples/session_client.rs b/crates/notebook/examples/session_client.rs index 2cae639fd49e77790f6457afab66f52ee6067608..06f0f0109f87f17dd635bc9b32977647d1e28a95 100644 --- a/crates/notebook/examples/session_client.rs +++ b/crates/notebook/examples/session_client.rs @@ -1,15 +1,16 @@ -//! Drives a section the way the application does: open it through a session, save -//! page-model edits, wait for their publication and report the page texts. +//! Drives a section the way the application does: open it through a session, apply +//! edits, wait for their publication and report the page texts. //! `session_client SECTION CACHE_DIR LABEL` edits the first body paragraph of every //! page whose title starts with "Move" and adds one outline per page; each launch //! reports the pending queue it found and the receipts it obtained. use notebook::{ EditStatus, - session::{Event, Save, Section}, + session::{Event, Section}, }; use onestore::{ ExGuid, + op::PageOp, page::{Outline, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id}, }; use std::{ @@ -46,28 +47,9 @@ fn texts(page: &Page) -> Vec { out } -fn edit(page: &mut Page, label: &str) { +/// Types `label` before the first body paragraph and adds an outline below the body. +fn edit(page: &Page, label: &str) -> Vec { let text = body_text(page).expect("a body paragraph"); - for object in &mut page.objects { - let PageObject::Outline(outline) = object else { - continue; - }; - if let Some(paragraph) = outline - .paragraphs - .iter_mut() - .find(|p| p.text().is_some_and(|t| t.id == text)) - { - let target = paragraph.text_mut().unwrap(); - let format = target.text.format_at(0).unwrap().clone(); - target - .text - .apply(onestore::page::text::Edit { - range: 0..0, - replacement: onestore::page::Paragraph::new(format!("{label} "), format), - }) - .unwrap(); - } - } let template = page .objects .iter() @@ -110,12 +92,21 @@ fn edit(page: &mut Page, label: &str) { paragraphs: vec![paragraph], unsupported: Vec::new(), }; - let at = page - .objects - .iter() - .position(|o| matches!(o, PageObject::Title(_))) - .unwrap_or(page.objects.len()); - page.objects.insert(at, PageObject::Outline(outline)); + let title = page.objects.iter().find_map(|object| match object { + PageObject::Title(title) => Some(title.id), + _ => None, + }); + vec![ + PageOp::Text { + text, + range: 0..0, + with: format!("{label} "), + }, + PageOp::Add { + object: PageObject::Outline(outline), + before: title, + }, + ] } fn main() -> Result<(), Box> { @@ -138,13 +129,21 @@ fn main() -> Result<(), Box> { .iter() .filter(|(_, title, _)| title.starts_with("Move")) { - let before = section.page(*space)?; - let mut after = before.clone(); - edit(&mut after, label); - match section.save(*space, &before, &after, "session client")? { - Save::Queued(id) => queued.push((id, *space, title.clone(), texts(&after))), - other => return Err(format!("{title}: {other:?}").into()), + let mut page = section.page(*space)?; + let ops = edit(&page, label); + for op in &ops { + onestore::op::predict(&mut page, op)?; } + let unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?; + let edit = onestore::op::Edit { + at: (unix.as_secs() + 11_644_473_600) * 10_000_000, + ops: ops + .into_iter() + .map(|op| onestore::op::Op::Page { space: *space, op }) + .collect(), + }; + let id = section.replica().apply("session client", edit)?; + queued.push((id, *space, title.clone(), texts(&page))); } let deadline = Instant::now() + Duration::from_secs(120); let mut receipts = Vec::new(); @@ -176,7 +175,7 @@ fn main() -> Result<(), Box> { } Event::Failed(error) => return Err(error.into()), Event::Rejected { error, .. } => return Err(error.into()), - Event::Refreshed | Event::Changed(_) => {} + Event::Changed(_) => {} } } // A batch reports its newest edit; each edit's receipt is its own. diff --git a/crates/notebook/examples/smb_concurrent_client.rs b/crates/notebook/examples/smb_concurrent_client.rs index 9c55d513e961b1c553f0b0957032c8fb94cd6d2e..797684d46afed217caf77c0488bf72ccd9fde4f2 100644 --- a/crates/notebook/examples/smb_concurrent_client.rs +++ b/crates/notebook/examples/smb_concurrent_client.rs @@ -1,5 +1,7 @@ #[path = "../../onestore/examples/support/concurrent.rs"] mod concurrent; +#[path = "support/edit.rs"] +mod edit; use notebook::smb::{Client, Credentials}; use std::{env, time::Duration}; @@ -16,7 +18,10 @@ fn main() -> Result<(), Box> { &args, |path| client.read(path, 256 * 1024 * 1024), |path, source, space, object, range, replacement| { - client.commit_text(path, source, space, object, range, replacement) + client.commit_transaction( + path, + &edit::replaced(source, space, object, range, replacement)?, + ) }, ) } diff --git a/crates/notebook/examples/smb_offline_client.rs b/crates/notebook/examples/smb_offline_client.rs index 73e90b9eb2bfb40e097ca0813d8695c521943bb3..dbf7ceeae4fe935f5bb7c558ffbca12d6b1de756 100644 --- a/crates/notebook/examples/smb_offline_client.rs +++ b/crates/notebook/examples/smb_offline_client.rs @@ -3,7 +3,7 @@ mod concurrent; use notebook::smb::{Client, Credentials}; use notebook::{EditStatus, Error, Remote, Replica, SmbRemote}; -use onestore::{CommitError, CommitState, ExGuid, RevisionIndex, Store, Transaction}; +use onestore::{CommitError, CommitState, ExGuid, RevisionIndex, Stamp, Store, Transaction}; use serde_json::json; use std::{ env, @@ -18,7 +18,7 @@ mod support { pub mod view; } use concurrent::{DocumentView, document_view}; -use support::view::view; +use support::view::{cached, view}; impl DocumentView { fn changes(&self, before: &Self) -> Self { @@ -79,6 +79,13 @@ impl Remote for Traced { Ok(bytes) } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { + // Publication reads nothing while the stamp holds; observe an unseen base once. + if Stamp::of(&self.read).ok().as_ref() != Some(transaction.base()) { + self.read().map_err(|error| CommitError { + state: CommitState::NotCommitted, + error, + })?; + } let mut image = self.read.clone(); transaction.apply(&mut image).map_err(|error| CommitError { state: CommitState::NotCommitted, @@ -193,10 +200,22 @@ impl Remote for Traced { thread::sleep(Duration::from_millis(10)); } } + if result.is_ok() { + self.read = image; + self.before = Some((after.text, documents)); + } result } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + fn stamp(&mut self) -> io::Result { + self.remote.stamp() + } + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { let captured = (|| -> Result<_, Box> { + // A confirmation follows the read of its image, except for edits that changed nothing. + if Stamp::of(&self.read).ok().as_ref() != Some(base) { + self.read = self.remote.read()?; + } + let snapshot = &self.read[..]; let store = Store::parse(snapshot)?; let index = RevisionIndex::parse(&store)?; let revisions = index @@ -240,7 +259,7 @@ impl Remote for Traced { error: io::Error::other(error.to_string()), })?; let started = now(); - let result = self.remote.confirm(snapshot); + let result = self.remote.confirm(base); println!( "{}", json!({"event":"remote_confirm", "started_us":started, "finished_us":now(), "revisions":revisions, "current_revisions":current, "capture":capture.as_ref().and_then(|path| path.file_name()).map(|name| name.to_string_lossy()), "text":self.before.as_ref().map(|(text,_)|text), @@ -343,6 +362,7 @@ fn main() -> Result<(), Box> { } })?; let mut ids = Vec::new(); + let mut tokens = Vec::new(); let result = (|| -> Result<(), Box> { let mut generated = 0; let mut received = 0; @@ -380,62 +400,46 @@ fn main() -> Result<(), Box> { 8 }; if generated < operations && pending.len() < capacity { - let source = cache.snapshot()?; - let target = view(&source)?; - let at = u32::try_from(target.text.encode_utf16().count())?; + let (space, object, text) = cached(&cache)?; + let at = u32::try_from(text.encode_utf16().count())?; let token = format!(" [{}:{}]", args[2], generated); let started = now(); match cache.apply( "Offline document writer", - append(target.space, target.object, at, &token), + append(space, object, at, &token), ) { Ok(id) => { println!( "{}", - json!({"event":"local_commit", "id":id, "operation":generated, "space":target.space.to_string(), "object":target.object.to_string(), "before":target.text, "token":token, "started_us":started, "finished_us":now()}) + json!({"event":"local_commit", "id":id, "operation":generated, "space":space.to_string(), "object":object.to_string(), "before":text, "token":token, "started_us":started, "finished_us":now()}) ); ids.push(id); + tokens.push(token); generated += 1; } other => return Err(format!("Unexpected local result: {other:?}").into()), } } - if let Some(conflict) = cache.conflict()? { - // Concurrent appends meet at the end of the text: take the remote text and - // append the queued tokens it lacks after it again. - let remote = view(&cache.remote_snapshot()?)?; - let tokens: Vec = cache - .pending()? + if received == ids.len() && cache.pending()?.is_empty() { + // Concurrent appends meet at the end of the text: a merge keeps the remote's + // text and this writer's on a conflict page. Append the published tokens + // the text lacks after it again. + let (space, object, text) = cached(&cache)?; + let missing: Vec = tokens .iter() - .flat_map(|queued| queued.edit.ops.iter()) - .filter_map(|op| match op { - onestore::op::Op::Page { - op: onestore::op::PageOp::Text { with, .. }, - .. - } => Some(with.clone()), - _ => None, - }) - .filter(|token| !remote.text.contains(token.as_str())) + .filter(|token| !text.contains(token.as_str())) + .cloned() .collect(); - match cache.resolve(conflict.id, notebook::Resolution::Theirs) { - Ok(()) => { - let mut at = u32::try_from(remote.text.encode_utf16().count())?; - for token in &tokens { - cache.apply( - "Offline document writer", - append(remote.space, remote.object, at, token), - )?; - at += u32::try_from(token.encode_utf16().count())?; - } - println!( - "{}", - json!({"event":"reviewed_append", "id":conflict.id, "remote":remote.text, "tokens":tokens, "at_us":now()}) - ); - } - Err(Error::Io(error)) - if [io::ErrorKind::WouldBlock, io::ErrorKind::InvalidInput] - .contains(&error.kind()) => {} - Err(error) => return Err(error.into()), + let mut at = u32::try_from(text.encode_utf16().count())?; + for token in &missing { + ids.push(cache.apply("Offline document writer", append(space, object, at, token))?); + at += u32::try_from(token.encode_utf16().count())?; + } + if !missing.is_empty() { + println!( + "{}", + json!({"event":"reviewed_append", "remote":text, "tokens":missing, "at_us":now()}) + ); } } if generated == operations && received == ids.len() { diff --git a/crates/notebook/examples/smb_publish_probe.rs b/crates/notebook/examples/smb_publish_probe.rs new file mode 100644 index 0000000000000000000000000000000000000000..a6f5cb71cc1562cc4f18d06d39f695c82ab16be6 --- /dev/null +++ b/crates/notebook/examples/smb_publish_probe.rs @@ -0,0 +1,110 @@ +//! Publishes typed keystrokes to a Samba lab one at a time and reports each publication's +//! wall time: `smb_publish_probe ADDRESS KEYSTROKES [CONTROL]`. With `CONTROL`, the +//! `tools/smb-proxy.py` control file, each publication is marked in the proxy's trace. + +use notebook::{ + EditStatus, Replica, SmbRemote, + smb::{Client, Credentials}, +}; +use onestore::op::{Edit, Op, PageOp}; +use std::{ + env, fs, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +fn main() -> Result<(), Box> { + let args: Vec<_> = env::args().skip(1).collect(); + let [address, count, control @ ..] = &args[..] else { + return Err("smb_publish_probe ADDRESS KEYSTROKES [CONTROL]".into()); + }; + let count: u32 = count.parse()?; + let mark = |phase: String| -> std::io::Result<()> { + if let [control] = control { + fs::write( + format!("{control}.tmp"), + format!("{{\"phase\":\"{phase}\"}}"), + )?; + fs::rename(format!("{control}.tmp"), control)?; + // The proxy polls its control file every 50 ms. + std::thread::sleep(Duration::from_millis(120)); + } + Ok(()) + }; + let connect = || { + Client::connect( + address, + "agent", + Credentials::default(), + Duration::from_secs(5), + ) + }; + let path = format!( + "probe-{}.one", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() + ); + let source = onestore::create_section(&path, "Body", "Probe")?; + connect()?.create(&path, &source)?; + let directory = tempfile::tempdir()?; + let replica = Replica::create(directory.path().join("cache.sqlite"), &source)?; + let space = replica.pages()?[0].0; + let text = replica + .page(space)? + .objects + .iter() + .find_map(|object| match object { + onestore::page::PageObject::Outline(outline) => outline + .paragraphs + .iter() + .find_map(|p| p.text().map(|t| t.id)), + _ => None, + }) + .ok_or("no text")?; + let mut remote = SmbRemote::new(connect()?, path.clone(), 1 << 24); + mark("idle".into())?; + let mut idle = Vec::new(); + for _ in 0..count { + let started = Instant::now(); + assert_eq!(replica.sync_once(&mut remote)?.edit, None); + idle.push(started.elapsed()); + } + let mut published = Vec::new(); + for n in 0..count { + replica.apply( + "Probe", + Edit { + at: 133_000_000_000_000_000, + ops: vec![Op::Page { + space, + op: PageOp::Text { + text, + range: 4 + n..4 + n, + with: "x".into(), + }, + }], + }, + )?; + mark(format!("publish-{n}"))?; + let started = Instant::now(); + let synced = replica.sync_once(&mut remote)?; + published.push(started.elapsed()); + assert!(matches!( + synced.edit, + Some((_, EditStatus::Published { .. })) + )); + } + mark("done".into())?; + let quantiles = |mut times: Vec| { + times.sort(); + let at = |q: f64| times[((times.len() - 1) as f64 * q) as usize].as_secs_f64() * 1000.0; + format!( + "p50 {:.2} ms, p90 {:.2} ms, max {:.2} ms", + at(0.5), + at(0.9), + at(1.0) + ) + }; + println!("idle poll: {}", quantiles(idle)); + println!("publish: {}", quantiles(published)); + connect()?.delete(&path)?; + Ok(()) +} diff --git a/crates/notebook/examples/smb_reconnect_client.rs b/crates/notebook/examples/smb_reconnect_client.rs index d72a42c9c0a88db608811c8e5b08601222c2f8ee..814635fe985b4894d77a70be1ce638c7078c4012 100644 --- a/crates/notebook/examples/smb_reconnect_client.rs +++ b/crates/notebook/examples/smb_reconnect_client.rs @@ -1,9 +1,11 @@ #[path = "../../onestore/examples/support/concurrent.rs"] mod concurrent; +#[path = "support/edit.rs"] +mod edit; use notebook::smb::{Client, Credentials}; use onestore::{ - CommitError, CommitState, ExGuid, RevisionIndex, Store, + CommitError, CommitState, ExGuid, RevisionIndex, Stamp, Store, document::{Document, Kind}, }; use serde_json::json; @@ -155,14 +157,14 @@ fn main() -> Result<(), Box> { &args, read, |path, source, space, object, range, replacement| { - let outcome = client.borrow().as_ref().unwrap().commit_text( - path, - source, - space, - object, - range, - replacement, - ); + let outcome = + edit::replaced(source, space, object, range, replacement).and_then(|transaction| { + client + .borrow() + .as_ref() + .unwrap() + .commit_transaction(path, &transaction) + }); let Err(error) = outcome else { return Ok(()); }; @@ -209,14 +211,12 @@ fn main() -> Result<(), Box> { error: io::Error::other(failure.to_string()), })?; if published { - let confirmation = client.borrow().as_ref().unwrap().commit_text( - path, - ¤t, - space, - object, - 0..0, - "", - ); + let confirmation = Stamp::of(¤t) + .map_err(|failure| CommitError { + state: CommitState::NotCommitted, + error: io::Error::other(failure.to_string()), + }) + .and_then(|base| client.borrow().as_ref().unwrap().confirm(path, &base)); if let Err(failure) = confirmation && failure.state != CommitState::Committed { diff --git a/crates/notebook/examples/support/edit.rs b/crates/notebook/examples/support/edit.rs new file mode 100644 index 0000000000000000000000000000000000000000..888dc0d03cfc8e27b2af03972a7da368d7b7d05c --- /dev/null +++ b/crates/notebook/examples/support/edit.rs @@ -0,0 +1,49 @@ +use onestore::{ + Arena, CommitError, CommitState, ExGuid, Section, Transaction, + op::{Edit, Op, PageOp}, +}; +use std::{ + io, + ops::Range, + time::{SystemTime, UNIX_EPOCH}, +}; + +/// The transaction replacing `range` of `text` in `image`, typed now. +pub fn replaced( + image: &[u8], + space: ExGuid, + text: ExGuid, + range: Range, + with: &str, +) -> Result { + let failed = |error: String| CommitError { + state: CommitState::NotCommitted, + error: io::Error::new(io::ErrorKind::InvalidData, error), + }; + let arena = Arena::default(); + let mut section = Section::open(&arena, image.to_vec()).map_err(|e| failed(e.to_string()))?; + let at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| failed(e.to_string()))? + .as_nanos() as u64 + / 100 + + 116_444_736_000_000_000; + let edit = Edit { + at, + ops: vec![Op::Page { + space, + op: PageOp::Text { + text, + range, + with: with.to_owned(), + }, + }], + }; + section + .apply("Concurrent client", &edit) + .map_err(|e| failed(format!("{e:?}")))?; + section + .seal() + .map_err(|e| failed(e.to_string()))? + .ok_or_else(|| failed("The edit changed nothing".into())) +} diff --git a/crates/notebook/examples/support/view.rs b/crates/notebook/examples/support/view.rs index 280be0443d90acca9f8db8dd3d7f0dc63d1b5ecc..96977f14d8ccb470793b81099e4be8aeff8c4785 100644 --- a/crates/notebook/examples/support/view.rs +++ b/crates/notebook/examples/support/view.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use onestore::{ ExGuid, RevisionIndex, Store, document::{Document, Kind}, @@ -55,3 +57,42 @@ pub fn view(bytes: &[u8]) -> Result> { } Ok(found.pop().unwrap()) } + +/// The concurrent-edit paragraph of `page`: its text object and text. +fn in_page(page: &onestore::page::Page) -> Option<(ExGuid, String)> { + let mut pending: Vec<&onestore::page::PageParagraph> = page + .objects + .iter() + .flat_map(|object| match object { + onestore::page::PageObject::Outline(outline) => outline.paragraphs.iter().collect(), + _ => Vec::new(), + }) + .collect(); + pending.reverse(); + while let Some(paragraph) = pending.pop() { + if let Some(text) = paragraph.text() + && text.text.text().starts_with("Concurrent edits:") + { + return Some((text.id, text.text.text().to_owned())); + } + if let onestore::page::ParagraphContent::Table(table) = ¶graph.content { + pending.extend(table.rows.iter().flat_map(|row| &row.cells).flat_map(|cell| &cell.paragraphs).rev()); + } + } + None +} + +/// The concurrent-edit paragraph as the queued edits leave it: its page, text object and +/// text. +pub fn cached(cache: ¬ebook::Replica) -> Result<(ExGuid, ExGuid, String), Box> { + let mut found = Vec::new(); + for (space, ..) in cache.pages()? { + if let Some((object, text)) = in_page(&cache.page(space)?) { + found.push((space, object, text)); + } + } + match <[_; 1]>::try_from(found) { + Ok([found]) => Ok(found), + Err(_) => Err("Expected one concurrent-edit paragraph".into()), + } +} diff --git a/crates/notebook/src/bin/onestore-diagnostic.rs b/crates/notebook/src/bin/onestore-diagnostic.rs index cb5fbe8ecd9e720d60238cfec56dd06c59a8ad46..f5bc0fa7ff1552d0572fb5b56ede701b2c139ffd 100644 --- a/crates/notebook/src/bin/onestore-diagnostic.rs +++ b/crates/notebook/src/bin/onestore-diagnostic.rs @@ -1,4 +1,11 @@ -use onestore::{ExGuid, Insertion, PreparedEdit, TextAttribute}; +use onestore::{ + Arena, ExGuid, Section, TextAttribute, + document::{Format, Layout}, + op::{Op, PageOp}, + page::{ + Outline, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, text::new_id, + }, +}; use serde::Deserialize; use serde_json::{Value, json}; use std::{ @@ -40,6 +47,33 @@ enum Action { }, } +/// A new paragraph holding `text` in OneNote's default text style. +fn paragraph(text: String) -> Result> { + let format = Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + language: Some(0x409), + ..Default::default() + }; + Ok(PageParagraph { + id: new_id()?, + parent: None, + level: 1, + style: None, + format: Format::default(), + content: ParagraphContent::Text(TextObject { + id: new_id()?, + date_field: None, + text: Paragraph::new(text, format), + tags: Vec::new(), + }), + lists: Vec::new(), + tags: Vec::new(), + media: Default::default(), + collapsed: false, + }) +} + fn run(args: &[std::ffi::OsString]) -> Result> { if let [mode, root] = args && mode == "catalog" @@ -79,34 +113,93 @@ fn run(args: &[std::ffi::OsString]) -> Result> let edit: Edit = serde_json::from_reader(io::stdin().lock())?; let sid = edit.space; let oid = edit.object; - let prepared = match edit.action { + let (op, author) = match edit.action { Action::Text { start, end, replacement, - } => PreparedEdit::text(&bytes, sid, oid, start..end, &replacement)?, + } => ( + PageOp::Text { + text: oid, + range: start..end, + with: replacement, + }, + String::new(), + ), Action::Format { start, end, attributes, - } => PreparedEdit::format(&bytes, sid, oid, start..end, &attributes)?, + } => ( + PageOp::Format { + text: oid, + range: start..end, + set: attributes, + clear: Vec::new(), + }, + String::new(), + ), Action::Paragraph { before, text, author, - } => PreparedEdit::insert( - &bytes, - sid, - &Insertion::paragraph(oid, before, &text, &author)?, - )?, + } => ( + PageOp::Insert { + container: oid, + before, + paragraphs: vec![paragraph(text)?], + }, + author, + ), Action::Outline { x, y, text, author } => { - PreparedEdit::insert(&bytes, sid, &Insertion::outline(oid, x, y, &text, &author)?)? + let store = onestore::Store::parse(&bytes)?; + let index = onestore::RevisionIndex::parse(&store)?; + if !onestore::document::Document::parse(&index)? + .pages()? + .contains(&(sid, oid)) + { + return Err("Choose the page to hold the outline".into()); + } + ( + PageOp::Add { + object: PageObject::Outline(Outline { + id: new_id()?, + title: false, + min_width: None, + layout: Layout { + x: Some(x), + y: Some(y), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: vec![paragraph(text)?], + unsupported: Vec::new(), + }), + before: None, + }, + author, + ) } }; + let arena = Arena::default(); + let mut section = Section::open(&arena, bytes)?; + let unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?; + let at = (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100); + section.apply( + &author, + &onestore::op::Edit { + at, + ops: vec![Op::Page { space: sid, op }], + }, + )?; + let transaction = section.seal()?; if check { return Ok(json!({"ok": true})); } - Ok(match prepared.commit_file(&args[1]) { + let Some(transaction) = transaction else { + return Ok(json!({"ok": true, "state": "Unchanged"})); + }; + Ok(match transaction.commit_file(&args[1]) { Ok(()) => json!({"ok": true, "state": "Committed"}), Err(error) => json!({"ok": false, "state": format!("{:?}", error.state), "kind": format!("{:?}", error.error.kind()), "error": error.error.to_string()}), diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index 75c9457dbe646828d4687291cc1c9576df7e6a4c..4456baadbf222a8b4ae28e3c6e6907bf5ec88a94 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -29,7 +29,7 @@ mod schema; pub mod session; pub use recovery::{Recovery, RecoverySummary}; mod sync; -pub use sync::{ConflictKind, EditStatus, Remote, Synced}; +pub use sync::{EditStatus, Remote, Synced}; mod worker; mod working; #[cfg(feature = "smb")] @@ -72,19 +72,8 @@ pub struct PendingEdit { pub edit: Edit, } -/// Unpublished work the remote section no longer accepts: the batch holding edit `id` -/// changed page `space` where the remote changed it too. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Conflict { - pub id: u64, - pub space: ExGuid, - pub kind: ConflictKind, -} - -/// How a conflict or an uncertain attempt ends. `Mine` keeps the local pages: a conflicted -/// page is rewritten from the remote page to the local one; a released attempt publishes -/// again. `Theirs` drops the local edits to the conflicted page, or the whole unpublished -/// branch of a released attempt. +/// How an uncertain attempt ends after review: `Mine` publishes it again, `Theirs` drops +/// the whole unpublished branch. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Resolution { Mine, @@ -161,6 +150,7 @@ impl Replica { // the file as it was. match version { migrate::VERSION => migrate::migrate(&mut connection, path)?, + schema::PREVIOUS => schema::upgrade(&mut connection)?, schema::VERSION => {} _ => { return Err(io::Error::new( @@ -248,9 +238,14 @@ impl Replica { self.ask(|reply| working::Request::Pages { reply }) } + /// The conflict pages of each page that has them (`onestore::Section::conflicts`). + pub fn conflicts(&self) -> Result)>> { + self.ask(|reply| working::Request::Conflicts { reply }) + } + /// The section image the queued edits leave, the unsealed ones sealed as one more - /// revision whose identities differ per call: O(section), for tests and diagnostics. - pub fn snapshot(&self) -> Result> { + /// revision whose identities differ per call: O(section). + pub(crate) fn snapshot(&self) -> Result> { self.ask(|reply| working::Request::Flush { reply })?; working::image(&*self.lock()?) } diff --git a/crates/notebook/src/merge.rs b/crates/notebook/src/merge.rs index c29a016bd5a9f1af16ffb12c22e80e5860801cd5..f2f21fbb893ad8d4e23e8610011bb64d7c09838a 100644 --- a/crates/notebook/src/merge.rs +++ b/crates/notebook/src/merge.rs @@ -2,11 +2,16 @@ //! section when the objects it reads are as the base image had them; text ops on a text //! the remote also changed shift past the remote's changes when they stay clear of them; //! a move, deletion or outline setting the remote already made is dropped as done. -//! Anything else is a conflict: the queue stays on its base until it is resolved. +//! An op on a page the remote changed where it did is dropped, with every later op naming +//! the objects it named, and the page conflicts: as OneNote 2010 does, the remote's version +//! stays the page and the local one becomes a conflict page under it (`conflict_page`). +//! Page-list edits merge as OneNote 2010 merges page series: a page the remote moved keeps +//! the remote's place, and a page the queue placed goes before the next page in the queue's +//! order the remote left in place (`corpus/conflict-page/native-pages`, `native-restore`). //! Objects the queue itself created are not in the base, so nothing remote can have //! changed them. -use crate::{ConflictKind, PendingEdit, Result}; +use crate::{PendingEdit, Result}; use onestore::{ ExGuid, OutlineEdit, PageCreation, PageEdit, PagePosition, Section, document::{Format, Layout, Tag}, @@ -21,143 +26,113 @@ use std::{ ops::Range, }; -/// A conflict being resolved: from edit `first` on, ops on `space` are dropped. Keeping -/// mine replaces the first of them by the ops rewriting the remote page to `target` (or -/// putting `target` back as a page where the remote removed it), or, for the section's -/// page list, keeps what of each section op still applies. -pub(crate) struct Resolving<'p> { - pub first: u64, - pub space: ExGuid, - pub mine: bool, - pub target: Option<&'p Page>, -} +/// The remote changed what an op reads: the op does not replay. +struct Conflicting; -pub(crate) enum Outcome { - /// Every edit applied; these edits' ops changed. An edit whose ops were all dropped - /// stays, empty, to be published and acknowledged with its batch. - Applied(Vec<(u64, Edit)>), - Conflict { - id: u64, - space: ExGuid, - kind: ConflictKind, - }, +/// The queue replayed on the remote section. +pub(crate) struct Merged { + /// Edits whose ops changed. An edit whose ops were all dropped stays, empty, to be + /// published and acknowledged with its batch. + pub rewritten: Vec<(u64, Edit)>, + /// Pages whose local version the remote could not take, with who made the first edit + /// that did not replay and the objects the dropped ops named. + pub conflicts: BTreeMap)>, } /// Replays `edits` on `new`, the remote section; `old` is the base they were made on. +/// Ops on the pages in `converged`, which the remote holds as the edits leave them, are done. pub(crate) fn rebase( old: &mut Section<'_>, new: &mut Section<'_>, edits: &[PendingEdit], converged: &BTreeSet, - resolving: Option>, -) -> Result { - let root = new.root(); +) -> Result { let before: BTreeMap = old.revisions().collect(); let after: BTreeMap = new.revisions().collect(); let orders = (order(old)?, order(new)?); let mut state = Replay { before, after, + local: orders.0.clone(), + unmoved: unmoved(old, new)?, orders, created: BTreeSet::new(), diffs: BTreeMap::new(), }; - let mut rewritten = Vec::new(); - let mut resolved = false; + let mut merged = Merged { + rewritten: Vec::new(), + conflicts: BTreeMap::new(), + }; for queued in edits { - let branch = resolving - .as_ref() - .filter(|resolving| queued.id >= resolving.first); let mut ops = Vec::new(); let mut changed = false; for op in &queued.edit.ops { - let conflict = |kind, space| Outcome::Conflict { - id: queued.id, - space, - kind, + let apply = |state: &mut Replay, new: &mut Section<'_>, op: &Op| { + state.apply(new, &queued.author, queued.edit.at, op) }; - if let Some(resolving) = branch { - let dropped = match op { - Op::Page { space, .. } => *space == resolving.space, - Op::Section(_) => resolving.space == root, - }; - if dropped { - changed = true; - let rewrite = match op { - Op::Page { space, .. } if !resolved && resolving.mine => { - resolving.target.map(|target| (*space, target)) - } - _ => None, - }; - resolved |= matches!(op, Op::Page { .. }); - if let Some((space, target)) = rewrite { - if !state.listed(space) { - // Kept while the remote removed it: the page comes back. - match state.restore( - new, - space, - target, - &queued.author, - queued.edit.at, - )? { - Ok(restored) => ops.extend(restored), - Err(kind) => return Ok(conflict(kind, space)), - } + match op { + Op::Page { space, op: page_op } => { + if converged.contains(space) { + changed = true; + continue; + } + // An op naming what a dropped op named would build on it. + let named = named(page_op); + if let Some((_, dropped)) = merged.conflicts.get_mut(space) + && named.iter().any(|id| dropped.contains(id)) + { + dropped.extend(named); + changed = true; + continue; + } + let replayed = match state.check(old, new, *space, page_op)? { + Ok(None) => { + changed = true; continue; } - let current = new.page(space)?; - for op in onestore::op::lower_page(¤t, target) - .map_err(|error| OpError::Unsupported(error.message))? - { - let op = Op::Page { space, op }; - if let Some(kind) = - state.apply(new, &queued.author, queued.edit.at, &op)? - { - return Ok(conflict(kind, space)); - } - ops.push(op); + Ok(Some(mapped)) => { + let mapped = Op::Page { + space: *space, + op: mapped, + }; + apply(&mut state, new, &mapped)?.then_some(mapped) } - } - if let (Op::Section(op), true) = (op, resolving.mine) { - // Keeping mine for the section list: the first form that applies. - for op in state.salvage(new, op) { - let op = Op::Section(op); - if state - .apply(new, &queued.author, queued.edit.at, &op)? - .is_none() - { - ops.push(op); - break; - } + Err(Conflicting) => None, + }; + match replayed { + Some(mapped) => { + changed |= mapped != *op; + ops.push(mapped); + } + None => { + changed = true; + merged + .conflicts + .entry(*space) + .or_insert_with(|| (queued.author.clone(), BTreeSet::new())) + .1 + .extend(named); } } - continue; } - } - let space = match op { - Op::Page { space, .. } => *space, - Op::Section(_) => root, - }; - if matches!(op, Op::Page { .. }) && converged.contains(&space) { - changed = true; - continue; - } - let mapped = match state.check(old, new, op)? { - Ok(Some(mapped)) => mapped, - Ok(None) => { - changed = true; - continue; + Op::Section(section_op) => { + advance(&mut state.local, section_op); + // The first form of the edit the remote's page list takes, or none. + let mut kept = None; + for form in state.forms(new, section_op)? { + let form = Op::Section(form); + if apply(&mut state, new, &form)? { + kept = Some(form); + break; + } + } + changed |= kept.as_ref() != Some(op); + ops.extend(kept); } - Err(kind) => return Ok(conflict(kind, space)), - }; - changed |= mapped != *op; - if let Some(kind) = state.apply(new, &queued.author, queued.edit.at, &mapped)? { - return Ok(conflict(kind, space)); } - ops.push(mapped); } if changed { - rewritten.push(( + merged.rewritten.push(( queued.id, Edit { at: queued.edit.at, @@ -166,7 +141,176 @@ pub(crate) fn rebase( )); } } - Ok(Outcome::Applied(rewritten)) + Ok(merged) +} + +/// The stored objects a page op names; a conflict page marks those it holds. +fn named(op: &PageOp) -> Vec { + match op { + PageOp::Text { text, .. } + | PageOp::Format { text, .. } + | PageOp::Link { text, .. } + | PageOp::Equation { text, .. } + | PageOp::Split { text, .. } => vec![*text], + PageOp::Date { fields, .. } => fields.iter().map(|(text, _)| *text).collect(), + PageOp::Color(_) => Vec::new(), + PageOp::Join { left, right } => vec![*left, *right], + PageOp::Insert { container, .. } => vec![*container], + PageOp::Move { object, .. } | PageOp::Delete { object } | PageOp::Outline { object, .. } => { + vec![*object] + } + PageOp::Level { paragraph, .. } + | PageOp::Paragraph { paragraph, .. } + | PageOp::Style { paragraph, .. } + | PageOp::List { paragraph, .. } => vec![*paragraph], + PageOp::Tags { target, .. } => vec![*target], + PageOp::Add { object, .. } => vec![object.id()], + PageOp::Picture { picture: object, .. } + | PageOp::Attachment { + attachment: object, .. + } + | PageOp::Strokes { ink: object, .. } => vec![*object], + PageOp::Table { table, edit } => match edit { + TableEdit::Cell { cell, .. } => vec![*cell], + TableEdit::DeleteRow(row) => vec![*row], + _ => vec![*table], + }, + } +} + +/// The edit keeping `page`, the version of page `space` the remote could not take, and +/// applies it to `new`: a conflict page under the remote's page marking `objects` where +/// `page` holds them, or, where the remote removed the page, the page put back as a copy +/// where `local`, the section as the queue leaves it, has it. Content outside the page +/// model stays on the remote's page only; `at` is now. +#[allow(clippy::too_many_arguments)] +pub(crate) fn conflict_page( + new: &mut Section<'_>, + old: &mut Section<'_>, + local: &mut Section<'_>, + space: ExGuid, + page: &Page, + author: &str, + objects: &BTreeSet, + at: u64, +) -> Result { + let mut page = page.clone(); + page.objects.retain(|object| !matches!(object, PageObject::Unsupported(_))); + let listed = order(new)?; + let ops = if listed.iter().any(|(listed, _)| *listed == space) { + let index = Index::of(&page); + let mut marked: Vec = objects + .iter() + .copied() + .filter(|id| index.nodes.contains_key(id)) + .collect(); + let copy = page.copy_with(&mut marked)?; + let titled = page + .objects + .iter() + .any(|object| matches!(object, PageObject::Title(_))); + vec![Op::Section(SectionOp::Conflict { + of: space, + creation: PageCreation::new(None, titled.then_some(page.title.as_str()), author)?, + page: copy, + objects: marked, + })] + } else { + // A copy under fresh identities, as OneNote 2010 puts a removed page back. + let (base, unmoved) = (order(old)?, unmoved(old, new)?); + let queued = order(local)?; + let at = queued.iter().position(|(queued, _)| *queued == space); + let before = at + .map_or(&[][..], |at| &queued[at + 1..]) + .iter() + .map(|(next, _)| *next) + .find(|next| { + listed.iter().any(|(listed, _)| listed == next) + && (unmoved.contains(next) || base.iter().all(|(page, _)| page != next)) + }); + let creation = PageCreation::new(before, Some(&page.title), author)?; + let mut ops = vec![Op::Section(SectionOp::Import { + creation: creation.clone(), + page: page.copy()?, + })]; + if let Some(level) = at.map(|at| queued[at].1).filter(|level| *level > 1) { + ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::set_level( + creation.space(), + level, + )?]))); + } + ops + }; + let edit = Edit { at, ops }; + new.apply(author, &edit)?; + Ok(edit) +} + +/// Pages the base and the remote both list that the remote left in place: in the same +/// series at the same level, in base order among themselves. OneNote 2010 gives a page it +/// moves a series of its own; a page moved within its series is out of base order. +fn unmoved(old: &mut Section<'_>, new: &mut Section<'_>) -> Result> { + let series = (old.series()?, new.series()?); + let base: BTreeMap = order(old)? + .into_iter() + .enumerate() + .map(|(at, (space, level))| (space, (at, level))) + .collect(); + let kept: Vec<(ExGuid, usize)> = order(new)? + .into_iter() + .filter_map(|(space, level)| { + let (at, before) = base.get(&space)?; + (*before == level && series.0.get(&space) == series.1.get(&space)).then_some((space, *at)) + }) + .collect(); + // A longest subsequence of `kept` in base order (patience sorting). + let mut tails: Vec = Vec::new(); + let mut previous = vec![None; kept.len()]; + for (i, (_, at)) in kept.iter().enumerate() { + let k = tails.partition_point(|tail| kept[*tail].1 < *at); + previous[i] = k.checked_sub(1).map(|k| tails[k]); + if k == tails.len() { + tails.push(i); + } else { + tails[k] = i; + } + } + let mut unmoved = BTreeSet::new(); + let mut next = tails.last().copied(); + while let Some(i) = next { + unmoved.insert(kept[i].0); + next = previous[i]; + } + Ok(unmoved) +} + +/// Applies a queued page-list edit to `local`, the page order as the queue leaves it. +fn advance(local: &mut Order, op: &SectionOp) { + let insert = |local: &mut Order, page: (ExGuid, u32), before: Option| { + let at = before + .and_then(|before| local.iter().position(|(space, _)| *space == before)) + .unwrap_or(local.len()); + local.insert(at, page); + }; + match op { + SectionOp::Create(creation) | SectionOp::Import { creation, .. } => { + insert(local, (creation.space(), 1), creation.before()); + } + SectionOp::Pages(edits) => { + for edit in edits { + let Some(at) = local.iter().position(|(space, _)| *space == edit.space()) else { + continue; + }; + local[at].1 = edit.level(); + if let PagePosition::Before(before) = edit.position() { + let page = local.remove(at); + insert(local, page, before); + } + } + } + SectionOp::Delete(spaces) => local.retain(|(space, _)| !spaces.contains(space)), + SectionOp::Conflict { .. } | SectionOp::Color(_) => {} + } } fn order(section: &mut Section<'_>) -> Result { @@ -186,6 +330,10 @@ struct Replay { after: BTreeMap, /// Page order of the base and the remote. orders: (Order, Order), + /// Page order as the queue leaves it, up to the op replaying. + local: Order, + /// Pages the remote left in place (`unmoved`). + unmoved: BTreeSet, /// Page spaces the replayed edits created. created: BTreeSet, /// Per page the remote changed, what it changed; `None` when it left the page alone. @@ -193,14 +341,8 @@ struct Replay { } impl Replay { - /// Applies one op to the remote section; `Some` names why it did not apply. - fn apply( - &mut self, - new: &mut Section<'_>, - author: &str, - at: u64, - op: &Op, - ) -> Result> { + /// Applies one op to the remote section; false when the section refused it. + fn apply(&mut self, new: &mut Section<'_>, author: &str, at: u64, op: &Op) -> Result { match new.apply( author, &Edit { @@ -210,64 +352,49 @@ impl Replay { ) { Ok(()) => { if let Op::Section( - SectionOp::Create(creation) | SectionOp::Import { creation, .. }, + SectionOp::Create(creation) + | SectionOp::Import { creation, .. } + | SectionOp::Conflict { creation, .. }, ) = op { self.created.insert(creation.space()); } - Ok(None) + Ok(true) } Err(OpError::Failed(error)) => Err(error.into()), - Err(error) => Ok(Some(match op { - // A page list the remote reordered refuses the op, however it says so. - Op::Section(_) => ConflictKind::StructureChanged, - Op::Page { .. } if matches!(error, OpError::Unsupported(_)) => { - ConflictKind::UnsupportedEdit - } - Op::Page { space, .. } if !self.listed(*space) => ConflictKind::TargetUnavailable, - Op::Page { .. } => ConflictKind::ContentChanged, - })), + Err(_) => Ok(false), } } - /// The op as it replays on the remote section, or why it cannot. + /// The page op as it replays on the remote section; `None` when the remote made it. fn check( &mut self, old: &mut Section<'_>, new: &mut Section<'_>, - op: &Op, - ) -> Result, ConflictKind>> { - match op { - Op::Page { space, op } => { - if !self.listed(*space) { - return Ok(Err(ConflictKind::TargetUnavailable)); - } - let diff = match self.diffs.get_mut(space) { - Some(diff) => diff, - None => { - let diff = match (self.before.get(space), self.after.get(space)) { - (Some(before), Some(after)) if before != after => Some(Diff { - old: Index::of(&old.page(*space)?), - new: Index::of(&new.page(*space)?), - regions: BTreeMap::new(), - }), - _ => None, - }; - self.diffs.entry(*space).or_insert(diff) - } + space: ExGuid, + op: &PageOp, + ) -> Result, Conflicting>> { + if !self.listed(space) { + return Ok(Err(Conflicting)); + } + let diff = match self.diffs.get_mut(&space) { + Some(diff) => diff, + None => { + let diff = match (self.before.get(&space), self.after.get(&space)) { + (Some(before), Some(after)) if before != after => Some(Diff { + old: Index::of(&old.page(space)?), + new: Index::of(&new.page(space)?), + regions: BTreeMap::new(), + }), + _ => None, }; - Ok(match diff { - None => Ok(Some(Op::Page { - space: *space, - op: op.clone(), - })), - Some(diff) => diff - .check(op) - .map(|op| op.map(|op| Op::Page { space: *space, op })), - }) + self.diffs.entry(space).or_insert(diff) } - Op::Section(section) => Ok(self.section(section).map(|()| Some(op.clone()))), - } + }; + Ok(match diff { + None => Ok(Some(op.clone())), + Some(diff) => diff.check(op), + }) } /// Whether the remote section lists the page, or the replayed edits created it; a page @@ -276,151 +403,82 @@ impl Replay { self.created.contains(&space) || self.orders.1.iter().any(|(page, _)| *page == space) } - /// Puts `page`, which the remote removed, back into the section and returns the ops - /// that did: a copy under fresh identities at its level, before the first page after it - /// that the remote still has and that can take a page before it, or last. - fn restore( - &mut self, - new: &mut Section<'_>, - space: ExGuid, - page: &Page, - author: &str, - at: u64, - ) -> Result, ConflictKind>> { - let old = self.orders.0.clone(); - let position = old.iter().position(|(listed, _)| *listed == space); - let anchors = position - .map_or(&[][..], |position| &old[position + 1..]) - .iter() - .map(|(next, _)| Some(*next)) - .filter(|next| next.is_some_and(|next| self.listed(next))) - .chain([None]) - .collect::>(); - let copy = page.copy()?; - let mut refusal = ConflictKind::StructureChanged; - for before in anchors { - let creation = PageCreation::new(before, Some(&page.title), author)?; - let import = Op::Section(SectionOp::Import { - creation: creation.clone(), - page: copy.clone(), - }); - match self.apply(new, author, at, &import)? { - Some(kind) => refusal = kind, - None => { - let mut ops = vec![import]; - if let Some(level) = position.map(|at| old[at].1).filter(|level| *level > 1) { - let indent = Op::Section(SectionOp::Pages(vec![PageEdit::set_level( - creation.space(), - level, - )?])); - if let Some(kind) = self.apply(new, author, at, &indent)? { - return Ok(Err(kind)); - } - ops.push(indent); - } - return Ok(Ok(ops)); - } - } - } - Ok(Err(refusal)) + /// Whether the remote moved or re-leveled a page the base listed. + fn placed(&self, page: ExGuid) -> bool { + !self.unmoved.contains(&page) && self.orders.0.iter().any(|(listed, _)| *listed == page) + } + + /// Where a page the queue put before `before` goes on the remote: before the first page + /// from `before` on, in the queue's order, that the remote lists where the base had it, + /// or last, as OneNote 2010 places a page series only one side changed. + fn anchor(&self, listed: &Order, page: ExGuid, before: Option) -> Option { + let from = self.local.iter().position(|(space, _)| Some(*space) == before)?; + self.local[from..].iter().map(|(space, _)| *space).find(|space| { + *space != page && !self.placed(*space) && listed.iter().any(|(listed, _)| listed == space) + }) } - fn section(&self, op: &SectionOp) -> std::result::Result<(), ConflictKind> { - let (old, new) = &self.orders; - let find = - |list: &[(ExGuid, u32)], space: ExGuid| list.iter().position(|(s, _)| *s == space); - match op { - SectionOp::Create(_) | SectionOp::Import { .. } => Ok(()), - SectionOp::Delete(pages) => { - for page in pages { - if find(new, *page).is_none() && !self.created.contains(page) { - return Err(ConflictKind::TargetUnavailable); - } - if self.before.get(page) != self.after.get(page) { - return Err(ConflictKind::ContentChanged); - } + /// Forms of a section op to try on the remote section, in order: a page placed by + /// `anchor`, the page edits of pages the remote still has and did not move, removals of + /// pages the remote still has (a page it changed is not removed), a conflict page's + /// content as a page of its own where the remote removed its page. + fn forms(&self, new: &mut Section<'_>, op: &SectionOp) -> Result> { + let conflicts: BTreeSet = new + .conflicts()? + .into_iter() + .flat_map(|(_, pages)| pages.into_iter().map(|page| page.space)) + .collect(); + let listed = order(new)?; + let placed = |creation: &PageCreation| { + creation.reposition(self.anchor(&listed, creation.space(), creation.before())) + }; + Ok(match op { + SectionOp::Create(creation) => vec![SectionOp::Create(placed(creation)?)], + SectionOp::Import { creation, page } => vec![SectionOp::Import { + creation: placed(creation)?, + page: page.clone(), + }], + SectionOp::Color(_) => vec![op.clone()], + SectionOp::Conflict { + of, creation, page, .. + } => vec![if self.listed(*of) { + op.clone() + } else { + SectionOp::Import { + creation: creation.clone(), + page: page.clone(), } - Ok(()) - } + }], + // A page the remote moved or re-leveled keeps the remote's placement. SectionOp::Pages(edits) => { - // Where the batch puts each page, from the base order. - let mut wanted: Vec = old.iter().map(|(space, _)| *space).collect(); + let mut kept = Vec::new(); for edit in edits { - if let PagePosition::Before(before) = edit.position() - && let Some(at) = wanted.iter().position(|space| *space == edit.space()) + if self.placed(edit.space()) + || listed.iter().all(|(listed, _)| *listed != edit.space()) { - let space = wanted.remove(at); - let at = before - .and_then(|before| wanted.iter().position(|space| *space == before)) - .unwrap_or(wanted.len()); - wanted.insert(at, space); - } - } - let wanted: Vec<(ExGuid, u32)> = - wanted.into_iter().map(|space| (space, 0)).collect(); - for edit in edits { - let space = edit.space(); - let Some(at) = find(old, space) else { continue; - }; - let Some(now) = find(new, space) else { - return Err(ConflictKind::TargetUnavailable); - }; - if old[at].1 != new[now].1 && new[now].1 != edit.level() { - return Err(ConflictKind::StructureChanged); - } - if edit.position() != PagePosition::Keep { - // Each page the remote also has must keep its side of the moved page, - // or already be on the side the batch wants. - let side = |list: &[(ExGuid, u32)], peer: ExGuid| { - Some(find(list, peer)? < find(list, space)?) - }; - let conflicting = old.iter().any(|(peer, _)| { - *peer != space - && find(new, *peer).is_some() - && side(new, *peer) != side(old, *peer) - && side(new, *peer) != side(&wanted, *peer) - }); - if conflicting { - return Err(ConflictKind::StructureChanged); - } } + kept.push(match edit.position() { + PagePosition::Keep => edit.clone(), + PagePosition::Before(before) => edit.reposition( + PagePosition::Before(self.anchor(&listed, edit.space(), before)), + edit.level(), + )?, + }); } - Ok(()) - } - } - } - - /// Forms of a section op to try on the remote section list, in order: as it is, a - /// creation appended instead, moves and removals of the pages still there. - fn salvage(&self, new: &mut Section<'_>, op: &SectionOp) -> Vec { - let pages: BTreeSet = new - .pages() - .map(|pages| pages.into_iter().map(|(space, ..)| space).collect()) - .unwrap_or_default(); - let present = |space: &ExGuid| pages.contains(space) || self.created.contains(space); - match op { - SectionOp::Create(creation) => std::iter::once(creation.clone()) - .chain(creation.reposition(None).ok()) - .map(SectionOp::Create) - .collect(), - SectionOp::Import { .. } => vec![op.clone()], - SectionOp::Pages(edits) => { - let edits: Vec = edits - .iter() - .filter(|edit| present(&edit.space())) - .cloned() - .collect(); - if edits.is_empty() { + if kept.is_empty() { Vec::new() } else { - vec![SectionOp::Pages(edits)] + vec![SectionOp::Pages(kept)] } } SectionOp::Delete(deleted) => { let deleted: Vec = deleted .iter() - .filter(|space| present(space)) + .filter(|space| { + self.listed(**space) && self.before.get(space) == self.after.get(space) + || conflicts.contains(space) + }) .copied() .collect(); if deleted.is_empty() { @@ -429,7 +487,7 @@ impl Replay { vec![SectionOp::Delete(deleted)] } } - } + }) } } @@ -491,7 +549,7 @@ impl Diff { /// Where the remote changed a text's characters and formats; its tags and date field /// are not positions an op names. - fn region(&mut self, text: ExGuid) -> std::result::Result, ConflictKind> { + fn region(&mut self, text: ExGuid) -> std::result::Result, Conflicting> { if !self.regions.contains_key(&text) { if self.ours(text) { return Ok(None); @@ -500,7 +558,7 @@ impl Diff { self.old.nodes.get(&text).map(|node| &node.own), self.new.nodes.get(&text).map(|node| &node.own), ) else { - return Err(ConflictKind::ContentChanged); + return Err(Conflicting); }; if a.text == b.text { return Ok(None); @@ -512,21 +570,20 @@ impl Diff { } /// The op as it replays on the remote page; `None` when the remote already made it. - fn check(&mut self, op: &PageOp) -> std::result::Result, ConflictKind> { - use ConflictKind::ContentChanged as Changed; - let require = |ok: bool| if ok { Ok(()) } else { Err(Changed) }; + fn check(&mut self, op: &PageOp) -> std::result::Result, Conflicting> { + let require = |ok: bool| if ok { Ok(()) } else { Err(Conflicting) }; let mut op = op.clone(); match &mut op { PageOp::Text { text, range, with } => { if let Some(region) = self.region(*text)? { - let mapped = region.map(range).ok_or(Changed)?; + let mapped = region.map(range).ok_or(Conflicting)?; region.replaced(range, with.encode_utf16().count() as u32); *range = mapped; } } PageOp::Format { text, range, .. } => { if let Some(region) = self.region(*text)? { - *range = region.map(range).ok_or(Changed)?; + *range = region.map(range).ok_or(Conflicting)?; } } PageOp::Link { text, .. } | PageOp::Equation { text, .. } => { @@ -547,7 +604,7 @@ impl Diff { if let Some(region) = self.region(*text)? { // The cut moves with the character after it: text the remote typed at // the cut stays left of it. - let cut = region.map(&(*at..*at + 1)).ok_or(Changed)?; + let cut = region.map(&(*at..*at + 1)).ok_or(Conflicting)?; let right_changes = region.split(*at); if !right_changes.0.is_empty() { self.regions.insert(*right, right_changes); @@ -565,7 +622,8 @@ impl Diff { self.region(text)?; } } - PageOp::Insert { .. } | PageOp::Add { .. } => {} + // A page's colour is the latest one set, whichever side set it. + PageOp::Insert { .. } | PageOp::Add { .. } | PageOp::Color(_) => {} PageOp::Move { object, parent, @@ -633,7 +691,7 @@ impl Diff { } _ => false, }; - return if done { Ok(None) } else { Err(Changed) }; + return if done { Ok(None) } else { Err(Conflicting) }; } require(before.is_some())? } diff --git a/crates/notebook/src/migrate.rs b/crates/notebook/src/migrate.rs index 86fd430ad49763b4647863ad309e110a0d511387..6b139418ac872ac7f2b224a24230a8a13ac09fc3 100644 --- a/crates/notebook/src/migrate.rs +++ b/crates/notebook/src/migrate.rs @@ -1,7 +1,8 @@ //! Converts a schema-14 cache, whose queue holds whole-page edits over a working image, //! to this schema's op queue. The cache is exported first; each queued page is lowered to //! ops against the page the conversion has so far, and every converted page must equal -//! the page in the old working image, or nothing changes. +//! the page in the old working image, or nothing changes. A page edit schema 14 held in +//! conflict becomes the conflict page a merge makes now, the remote's page staying. use crate::{Result, base, queue, schema}; use onestore::{ @@ -87,7 +88,6 @@ struct Converted { edit: Edit, /// Schema 14's publication evidence, when this edit was attempted. attempted: Option, - conflict: Option<(i64, ExGuid)>, } pub(crate) fn migrate(connection: &mut Connection, path: &Path) -> Result<()> { @@ -107,6 +107,53 @@ pub(crate) fn migrate(connection: &mut Connection, path: &Path) -> Result<()> { Ok(()) } +/// The conflict page keeping `local`, the version of page `space` its merge with `remote` +/// did not take, marking the texts `remote` does not hold as they are. +fn conflict(space: ExGuid, remote: &Page, local: &Page, author: &str) -> Result { + let texts = |page: &Page| -> BTreeMap { + let mut texts = BTreeMap::new(); + let mut pending: Vec<&onestore::page::PageParagraph> = Vec::new(); + for object in &page.objects { + match object { + onestore::page::PageObject::Outline(outline) => pending.extend(&outline.paragraphs), + onestore::page::PageObject::Title(title) => { + pending.extend(title.outlines.iter().flat_map(|outline| &outline.paragraphs)) + } + _ => {} + } + } + while let Some(paragraph) = pending.pop() { + match ¶graph.content { + onestore::page::ParagraphContent::Text(text) => { + texts.insert(text.id, format!("{:?}", text.text)); + } + onestore::page::ParagraphContent::Table(table) => pending.extend( + table.rows.iter().flat_map(|row| &row.cells).flat_map(|cell| &cell.paragraphs), + ), + _ => {} + } + } + texts + }; + let kept = texts(remote); + let mut objects: Vec = texts(local) + .into_iter() + .filter(|(id, text)| kept.get(id) != Some(text)) + .map(|(id, _)| id) + .collect(); + let page = local.copy_with(&mut objects)?; + let titled = local + .objects + .iter() + .any(|object| matches!(object, onestore::page::PageObject::Title(_))); + Ok(Op::Section(SectionOp::Conflict { + of: space, + creation: PageCreation::new(None, titled.then_some(local.title.as_str()), author)?, + page, + objects, + })) +} + fn convert(transaction: &rusqlite::Transaction<'_>) -> Result<()> { let (base, patch): (Vec, Vec) = transaction.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| { @@ -118,9 +165,9 @@ fn convert(transaction: &rusqlite::Transaction<'_>) -> Result<()> { Ok((row.get(0)?, row.get(1)?)) }) .optional()?; - let conflicts: BTreeMap = transaction - .prepare("SELECT edit_id, kind FROM conflicts")? - .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + let conflicts: std::collections::BTreeSet = transaction + .prepare("SELECT edit_id FROM conflicts")? + .query_map([], |row| row.get(0))? .collect::>()?; let queued: Vec<(i64, String, String)> = transaction .prepare("SELECT id, space, operation FROM edits ORDER BY id")? @@ -146,6 +193,11 @@ fn convert(transaction: &rusqlite::Transaction<'_>) -> Result<()> { let operation: Operation = serde_json::from_str(&operation) .map_err(|error| invalid(format!("Queued edit {id} is unreadable: {error}")))?; let (author, ops) = match operation { + Operation::Page(intent) if conflicts.contains(&id) => { + let current = section.page(space)?; + let op = conflict(space, ¤t, &intent.after, &intent.author)?; + (intent.author, vec![op]) + } Operation::Page(intent) => { let current = section.page(space)?; let ops = onestore::op::lower_page(¤t, &intent.after)?; @@ -185,17 +237,12 @@ fn convert(transaction: &rusqlite::Transaction<'_>) -> Result<()> { } sealed = Some(section.seal()?); } - // A conflicting page-list edit is the section's conflict, whichever page it names. - let conflicted = match edit.ops.first() { - Some(Op::Section(_)) => section.root(), - _ => space, - }; + // A schema-14 conflict is queued work: the next rebase keeps both versions. converted.push(Converted { id, author, edit, attempted, - conflict: conflicts.get(&id).map(|kind| (*kind, conflicted)), }); } let listed = |image: &[u8]| -> Result> { @@ -258,12 +305,6 @@ fn convert(transaction: &rusqlite::Transaction<'_>) -> Result<()> { } else { batch = Some(current); } - if let Some((kind, space)) = edit.conflict { - transaction.execute( - "UPDATE batches SET conflict=?1, space=?2 WHERE id=?3", - params![kind, space.to_string(), current], - )?; - } queue::insert( transaction, Some(crate::unsigned(edit.id)?), diff --git a/crates/notebook/src/queue.rs b/crates/notebook/src/queue.rs index 66bf8399eca6aa71097a5da93af31e98521d8d66..c4c9c89b5528b285f766a886c53a50780d3b996b 100644 --- a/crates/notebook/src/queue.rs +++ b/crates/notebook/src/queue.rs @@ -74,7 +74,7 @@ fn slots(edit: &mut Edit, mut visit: impl FnMut(&mut Payload)) { } => cells(list, &mut visit), _ => {} }, - Op::Section(SectionOp::Import { page, .. }) => { + Op::Section(SectionOp::Import { page, .. } | SectionOp::Conflict { page, .. }) => { for added in &mut page.objects { object(added, &mut visit); } @@ -235,8 +235,12 @@ pub(crate) fn spaces(edit: &Edit, root: onestore::ExGuid) -> Vec { spaces.push(creation.space()); } + SectionOp::Conflict { of, creation, .. } => { + spaces.extend([*of, creation.space()]); + } SectionOp::Pages(edits) => spaces.extend(edits.iter().map(|edit| edit.space())), SectionOp::Delete(pages) => spaces.extend(pages), + SectionOp::Color(_) => {} } } } diff --git a/crates/notebook/src/recovery.rs b/crates/notebook/src/recovery.rs index 1999ad76d2bfd6f289551327e43762fcf59c86cd..dbb9a08142bc220b206d48227810a44d2b36bf01 100644 --- a/crates/notebook/src/recovery.rs +++ b/crates/notebook/src/recovery.rs @@ -8,7 +8,6 @@ const RECOVERY_ID: u32 = 0x4f4e4552; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub struct RecoverySummary { pub queued_edits: u64, - pub conflicts: u64, pub uncertain_edits: u64, pub published_receipts: u64, /// Bytes of the image the queued edits apply to. @@ -38,7 +37,8 @@ impl Recovery { io::Error::new(io::ErrorKind::InvalidData, "Not a recovery archive").into(), ); } - if version != schema::VERSION { + // A schema-15 archive differs only in columns nothing reads. + if ![schema::PREVIOUS, schema::VERSION].contains(&version) { return Err(io::Error::new( io::ErrorKind::InvalidData, format!( @@ -166,18 +166,16 @@ fn summary(connection: &Connection) -> Result { let length = |image| -> Result { Ok(base::stamp(connection, image)?.map_or(0, |stamp| stamp.length)) }; - let (queued_edits, conflicts, uncertain_edits, published_receipts): (i64, i64, i64, i64) = + let (queued_edits, uncertain_edits, published_receipts): (i64, i64, i64) = connection.query_row( "SELECT (SELECT count(*) FROM edits), - (SELECT count(*) FROM edits JOIN batches ON batches.id=edits.batch WHERE conflict IS NOT NULL), (SELECT count(*) FROM edits JOIN batches ON batches.id=edits.batch WHERE attempted=1), (SELECT count(*) FROM receipts)", [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), )?; Ok(RecoverySummary { queued_edits: unsigned(queued_edits)?, - conflicts: unsigned(conflicts)?, uncertain_edits: unsigned(uncertain_edits)?, published_receipts: unsigned(published_receipts)?, base_bytes: length(base::Image::Base)?, diff --git a/crates/notebook/src/schema.rs b/crates/notebook/src/schema.rs index cb9a5628939f7337a71909d976fac544e94dae94..7dd045367f88e1540d262fca1571008c21eebce3 100644 --- a/crates/notebook/src/schema.rs +++ b/crates/notebook/src/schema.rs @@ -1,7 +1,9 @@ use super::*; -use rusqlite::Transaction; +use rusqlite::{OptionalExtension, Transaction}; -pub(crate) const VERSION: u32 = 15; +pub(crate) const VERSION: u32 = 16; +/// The schema whose batches still recorded review conflicts. +pub(crate) const PREVIOUS: u32 = 15; /// Tables a schema-14 cache shares with this one. pub(crate) const KEPT: &str = " @@ -28,11 +30,8 @@ pub(crate) const QUEUE: &str = " sealed TEXT, revisions TEXT, attempted INTEGER NOT NULL DEFAULT 0 CHECK(attempted IN (0,1)), - conflict INTEGER CHECK(conflict BETWEEN 0 AND 3), - space TEXT, CHECK((sealed IS NULL) = (revisions IS NULL)), - CHECK(attempted=0 OR sealed IS NOT NULL), - CHECK((conflict IS NULL) = (space IS NULL)) + CHECK(attempted=0 OR sealed IS NOT NULL) ) STRICT; CREATE TABLE edits ( id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), @@ -51,3 +50,57 @@ pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> { transaction.execute_batch(KEPT)?; Ok(()) } + +/// Converts a schema-15 cache: its batches lose the review conflict and its page, which +/// a merge no longer records. A conflict it held is queued work the next rebase keeps as +/// conflict pages. Foreign keys stay off while `edits` briefly references the old table. +pub(crate) fn upgrade(connection: &mut Connection) -> Result<()> { + connection.execute_batch("PRAGMA foreign_keys=OFF")?; + let upgraded = (|| -> Result<()> { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?; + let sequence: Option = transaction + .query_row( + "SELECT seq FROM sqlite_sequence WHERE name='batches'", + [], + |row| row.get(0), + ) + .optional()?; + transaction.execute_batch( + "CREATE TABLE upgraded ( + id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), + sealed TEXT, + revisions TEXT, + attempted INTEGER NOT NULL DEFAULT 0 CHECK(attempted IN (0,1)), + CHECK((sealed IS NULL) = (revisions IS NULL)), + CHECK(attempted=0 OR sealed IS NOT NULL) + ) STRICT; + INSERT INTO upgraded(id, sealed, revisions, attempted) + SELECT id, sealed, revisions, attempted FROM batches; + DROP TABLE batches; + ALTER TABLE upgraded RENAME TO batches;", + )?; + if let Some(sequence) = sequence { + transaction.execute( + "UPDATE sqlite_sequence SET seq=max(seq, ?1) WHERE name='batches'", + [sequence], + )?; + } + let dangling: bool = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM pragma_foreign_key_check)", + [], + |row| row.get(0), + )?; + if dangling { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "The schema-15 cache holds edits without their batch", + ) + .into()); + } + transaction.pragma_update(None, "user_version", VERSION)?; + transaction.commit()?; + Ok(()) + })(); + connection.execute_batch("PRAGMA foreign_keys=ON")?; + upgraded +} diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 3ae33747104978bd0a241b0c381789626d3ebb3f..2e78dc28de2e40d27939f19b774898dd0e1041c9 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -2,11 +2,11 @@ //! publishes their edits to the section file in the background. use crate::{ - Conflict, ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, + EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, }; use onestore::{ - CommitError, ExGuid, PageCreation, PreparedEdit, RevisionIndex, Stamp, Store, Transaction, + CommitError, ExGuid, PageCreation, RevisionIndex, Stamp, Store, Transaction, document::Document, op::{Edit, Op, SectionOp}, page::Page, @@ -18,7 +18,6 @@ use std::{ path::{Path, PathBuf}, sync::{ Arc, Mutex, - atomic::{AtomicUsize, Ordering}, mpsc::{self, Receiver, Sender}, }, time::Duration, @@ -81,25 +80,19 @@ pub trait Storage: Send + Sync { fn create_directory(&self, path: &str) -> Result<()>; /// Renames or moves a file or directory; an existing target is an error. fn rename(&self, from: &str, to: &str) -> Result<()>; + /// Deletes a file or an empty directory. + fn delete(&self, path: &str) -> Result<()>; /// Names a section or TOC file for its notebook, as `onestore::place`. fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()>; - fn commit(&self, path: &str, edit: &PreparedEdit<'_>) -> Result<()>; - fn set_property( - &self, - path: &str, - source: &[u8], - space: ExGuid, - object: ExGuid, - property: u32, - value: &[u8], - ) -> Result<()>; + /// Publishes a transaction made on the file's current image. + fn commit(&self, path: &str, transaction: &Transaction) -> Result<()>; } /// A password-protected section as `Notebook::unlock` read it. #[cfg(feature = "protected")] pub struct Unlocked { pub pages: Vec<(ExGuid, Page)>, - /// The stored section the pages came from, which a save must still find in place. + /// The stored section the pages came from, which an edit must still find in place. snapshot: Vec, } @@ -145,31 +138,21 @@ impl Storage for Directory { Ok(std::fs::rename(self.path(from), self.path(to))?) } + fn delete(&self, path: &str) -> Result<()> { + let path = self.path(path); + Ok(if path.is_dir() { + std::fs::remove_dir(path)? + } else { + std::fs::remove_file(path)? + }) + } + fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> { Ok(onestore::place_file(self.path(path), ancestor, name)?) } - fn commit(&self, path: &str, edit: &PreparedEdit<'_>) -> Result<()> { - Ok(edit.commit_file(self.path(path))?) - } - - fn set_property( - &self, - path: &str, - source: &[u8], - space: ExGuid, - object: ExGuid, - property: u32, - value: &[u8], - ) -> Result<()> { - Ok(onestore::commit_file_property( - self.path(path), - source, - space, - object, - property, - value, - )?) + fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> { + Ok(transaction.commit_file(self.path(path))?) } } @@ -225,33 +208,18 @@ impl Storage for Share { Ok(self.client.rename(&self.path(from), &self.path(to))?) } + fn delete(&self, path: &str) -> Result<()> { + Ok(self.client.delete(&self.path(path))?) + } + fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> { Ok(self.client.place(&self.path(path), ancestor, name)?) } - fn commit(&self, path: &str, edit: &PreparedEdit<'_>) -> Result<()> { + fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> { Ok(self .client - .commit_transaction(&self.path(path), &edit.transaction())?) - } - - fn set_property( - &self, - path: &str, - source: &[u8], - space: ExGuid, - object: ExGuid, - property: u32, - value: &[u8], - ) -> Result<()> { - Ok(self.client.commit_property_bytes( - &self.path(path), - source, - space, - object, - property, - value, - )?) + .commit_transaction(&self.path(path), transaction)?) } } @@ -279,6 +247,22 @@ impl Notebook { Self::with(Box::new(Directory(root.clone())), Some(root), cache) } + /// Creates a notebook in the new folder `root`, as OneNote 2010 creates one: a table of + /// contents in the notebook colour `color` (COLORREF) and "New Section 1" holding the + /// page `page` creates. + pub fn create( + root: impl AsRef, + cache: impl AsRef, + color: u32, + page: &PageCreation, + ) -> Result { + std::fs::create_dir(root.as_ref())?; + let mut notebook = Self::open(root, cache)?; + notebook.edit_toc("", &[onestore::TocEdit::Color(color)])?; + notebook.create_section("", "New Section 1", page)?; + Ok(notebook) + } + /// Opens the notebook at `root` on the share `client` is connected to. Sections open /// through `Section::resume_smb` with the catalog's paths. #[cfg(feature = "smb")] @@ -393,18 +377,38 @@ impl Notebook { fn edit_toc(&self, folder: &str, edits: &[onestore::TocEdit]) -> Result<()> { let (toc, _) = self.toc(folder)?; - let source = self.storage.read(&toc)?; - self.storage - .commit(&toc, &PreparedEdit::table_of_contents(&source, edits)?) + self.commit_toc(&toc, edits) } - /// Creates `name.one` in `folder` with one empty page and lists it last in the folder's - /// TOC, as OneNote creates a section. Returns the new catalog path. - pub fn create_section(&mut self, folder: &str, name: &str, author: &str) -> Result { + fn commit_toc(&self, toc: &str, edits: &[onestore::TocEdit]) -> Result<()> { + let source = self.storage.read(toc)?; + match onestore::edit_table_of_contents(&source, edits)? { + Some(transaction) => self.storage.commit(toc, &transaction), + None => Ok(()), + } + } + + /// Creates `name.one` in `folder` holding the page `page` creates, in the next of + /// OneNote's section colours, and lists it last in the folder's TOC, as OneNote creates + /// a section. Returns the new catalog path. + pub fn create_section(&mut self, folder: &str, name: &str, page: &PageCreation) -> Result { let filename = format!("{name}.one"); - self.folder(folder)?; + let color = next_color(self.folder(folder)?); let (_, ancestor) = self.toc(folder)?; - let bytes = onestore::create_section(&filename, "", author)?; + let mut bytes = onestore::create_empty_section(&filename, Some(color))?; + let transaction = { + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, bytes.clone())?; + let edit = Edit { + at: crate::now(), + ops: vec![Op::Section(SectionOp::Create(page.clone()))], + }; + section.apply(page.author(), &edit)?; + section.seal()? + }; + if let Some(transaction) = transaction { + transaction.apply(&mut bytes)?; + } let path = catalog_path(folder, &filename); self.storage.create(&path, &bytes)?; self.storage.place(&path, ancestor, &filename)?; @@ -483,23 +487,16 @@ impl Notebook { /// Sets a section's colour (COLORREF) in its own metadata, where OneNote keeps it. pub fn set_section_color(&mut self, path: &str, color: Option) -> Result<()> { let path = self.section_path(path)?.path.clone(); - let source = self.storage.read(&path)?; - let store = onestore::Store::parse(&source)?; - let index = onestore::RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let revision = document.active(document.root)?; - let metadata = *revision - .roots - .get(&2) - .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?; - self.storage.set_property( - &path, - &source, - document.root, - metadata, - 0x14001cbe, - &color.unwrap_or(0xffff_ffff).to_le_bytes(), - )?; + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, self.storage.read(&path)?)?; + let edit = Edit { + at: crate::now(), + ops: vec![Op::Section(SectionOp::Color(color))], + }; + section.apply("", &edit)?; + if let Some(transaction) = section.seal()? { + self.storage.commit(&path, &transaction)?; + } self.refresh()?; Ok(()) } @@ -518,15 +515,51 @@ impl Notebook { self.refresh().map(drop) } - /// Deletes a section the way OneNote does: the file moves into the notebook's - /// `OneNote_RecycleBin` folder, a section group with its own TOC that lists it (and that - /// the root TOC lists), and its own folder's TOC entry goes. + /// Deletes a section or section group the way OneNote 2010 does. A section's file moves + /// into the notebook's `OneNote_RecycleBin` folder, a section group with its own TOC that + /// lists it (and that the root TOC lists). A group's sections, in its groups too, move + /// there the same way, then its folders go. Either way its folder's TOC entry goes. pub fn delete(&mut self, path: &str) -> Result<()> { let (folder, entry) = split(path); let (filename, identity) = self.entry(folder, entry)?; - if !filename.to_ascii_lowercase().ends_with(".one") { + if path == RECYCLE_BIN { return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); } + if filename.to_ascii_lowercase().ends_with(".one") { + self.bin_section(path, &filename, identity)?; + } else { + // Sections first, then each folder once it is empty, deepest first. + let mut sections = Vec::new(); + let mut folders = Vec::new(); + let mut pending = vec![self.folder(path)?]; + while let Some(group) = pending.pop() { + sections.extend( + group + .sections + .iter() + .map(|section| (section.path.clone(), section.file_id)), + ); + folders.push((group.path.clone(), group.toc.as_ref().map(|toc| toc.filename.clone()))); + pending.extend(&group.groups); + } + for (section, identity) in sections { + let (_, name) = split(§ion); + self.bin_section(§ion, name, identity)?; + } + for (group, toc) in folders.into_iter().rev() { + if let Some(toc) = toc { + self.storage.delete(&catalog_path(&group, &toc))?; + } + self.storage.delete(&group)?; + } + } + self.edit_toc(folder, &[onestore::TocEdit::Remove { identity }])?; + self.refresh().map(drop) + } + + /// The recycle bin's TOC path and file identity, creating the bin as OneNote does: a + /// section group the root TOC lists. + fn bin(&self) -> Result<(String, [u8; 16])> { let bin_toc = catalog_path(RECYCLE_BIN, TOC); if !self.storage.exists(&bin_toc) { if !self.storage.exists(RECYCLE_BIN) { @@ -545,37 +578,116 @@ impl Notebook { }], )?; } - let mut target = filename.clone(); + let source = self.storage.read(&bin_toc)?; + Ok((bin_toc, onestore::Store::parse(&source)?.header.file_id)) + } + + /// Moves the section file at `path` into the recycle bin, under a name no binned + /// section has, and lists it there; its own folder's TOC is the caller's. + fn bin_section(&self, path: &str, filename: &str, identity: [u8; 16]) -> Result<()> { + let (bin_toc, bin_identity) = self.bin()?; + let mut target = filename.to_owned(); let mut attempt = 1; while self.storage.exists(&catalog_path(RECYCLE_BIN, &target)) { attempt += 1; - let (stem, extension) = filename.rsplit_once('.').unwrap_or((&filename, "")); + let (stem, extension) = filename.rsplit_once('.').unwrap_or((filename, "")); target = format!("{stem} ({attempt}).{extension}"); } let binned = catalog_path(RECYCLE_BIN, &target); - self.storage - .rename(&catalog_path(folder, &filename), &binned)?; - let source = self.storage.read(&bin_toc)?; - self.storage.place( - &binned, - onestore::Store::parse(&source)?.header.file_id, - &target, - )?; - self.storage.commit( + self.storage.rename(path, &binned)?; + self.storage.place(&binned, bin_identity, &target)?; + self.commit_toc( &bin_toc, - &PreparedEdit::table_of_contents( - &source, + &[onestore::TocEdit::Add { + filename: target, + identity, + group: false, + }], + ) + } + + /// Keeps copies of `pages` in the notebook's recycle bin, as OneNote 2010 does with a + /// page it deletes: in `OneNote_RecycleBin/OneNote_DeletedPages.one`, each at the top + /// level with its identity, title, date and creation time. The caller then deletes the + /// pages from their section. + pub fn recycle_pages(&mut self, pages: &[Page], author: &str) -> Result<()> { + const DELETED: &str = "OneNote_DeletedPages.one"; + let (bin_toc, bin_identity) = self.bin()?; + let path = catalog_path(RECYCLE_BIN, DELETED); + if !self.storage.exists(&path) { + // OneNote's "Deleted Pages" section is grey. + let bytes = onestore::create_empty_section(DELETED, Some(0x00e1e1e1))?; + self.storage.create(&path, &bytes)?; + self.storage.place(&path, bin_identity, DELETED)?; + self.commit_toc( + &bin_toc, &[onestore::TocEdit::Add { - filename: target, - identity, + filename: DELETED.into(), + identity: onestore::Store::parse(&bytes)?.header.file_id, group: false, }], - )?, - )?; - self.edit_toc(folder, &[onestore::TocEdit::Remove { identity }])?; + )?; + } + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, self.storage.read(&path)?)?; + let mut ops = Vec::new(); + for page in pages { + let mut creation = PageCreation::new(None, Some(&page.title), author)?; + if let (Some(identity), Some(created)) = (page.identity, page.created) { + creation = creation.keeping(identity, created)?; + } + if let Some([date, time]) = page.date_text() { + creation = creation.dated(&date, &time)?; + } + ops.push(Op::Section(SectionOp::Import { + creation, + page: page.copy()?, + })); + } + section.apply(author, &Edit { at: crate::now(), ops })?; + if let Some(transaction) = section.seal()? { + self.storage.commit(&path, &transaction)?; + } self.refresh().map(drop) } + /// Moves a section or section group into the folder at catalog path `folder`, last, as + /// OneNote moves one dragged onto a group: the file or folder moves and each TOC's + /// entry follows it. Returns its new catalog path. + pub fn move_entry(&mut self, path: &str, folder: &str) -> Result { + let (from, entry) = split(path); + let (filename, identity) = self.entry(from, entry)?; + self.folder(folder)?; + let group = !filename.to_ascii_lowercase().ends_with(".one"); + let target = catalog_path(folder, &filename); + if from == folder + || path == RECYCLE_BIN + || group && (folder == path || folder.starts_with(&format!("{path}/"))) + || self.storage.exists(&target) + { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + let (_, ancestor) = self.toc(folder)?; + self.storage.rename(path, &target)?; + let placed = if group { + catalog_path(&target, TOC) + } else { + target.clone() + }; + self.storage.place(&placed, ancestor, &filename)?; + self.edit_toc( + folder, + &[onestore::TocEdit::Add { + filename, + identity, + group, + }], + )?; + self.edit_toc(from, &[onestore::TocEdit::Remove { identity }])?; + self.refresh()?; + Ok(target) + } + /// The stored filename and TOC identity of a folder's section or group. fn entry(&self, folder: &str, name: &str) -> Result<(String, [u8; 16])> { let parent = self.folder(folder)?; @@ -660,30 +772,31 @@ impl Notebook { Ok(Unlocked { pages, snapshot }) } - /// Saves an edited page of `unlocked` under the section's key, straight to the file: - /// nothing of a protected section is cached or queued. A section written since - /// `unlocked` was read fails the save; unlock again for its pages. + /// Applies `edit` to pages of `unlocked` and stores it under the section's key, straight + /// to the file: nothing of a protected section is cached or queued. A section written + /// since `unlocked` was read refuses the edit; unlock again for its pages. #[cfg(feature = "protected")] - pub fn save_unlocked( + pub fn apply_unlocked( &self, path: &str, password: &str, unlocked: &mut Unlocked, - space: ExGuid, - page: &Page, author: &str, + edit: &Edit, ) -> Result<()> { let path = self.section_path(path)?.path.clone(); - let edit = onestore::PreparedEdit::page_protected( - &unlocked.snapshot, - password, - space, - page, - author, - )?; - self.storage.commit(&path, &edit)?; - let written = edit.as_bytes().to_vec(); - unlocked.snapshot = written; + let transaction = { + let store = Store::parse(&unlocked.snapshot)?; + let index = RevisionIndex::parse(&store)?; + onestore::protected::UnlockedSection::open( + &index, + password, + onestore::protected::Limits::default(), + )? + .apply(author, edit)? + }; + self.storage.commit(&path, &transaction)?; + transaction.apply(&mut unlocked.snapshot)?; Ok(()) } @@ -706,6 +819,27 @@ impl Notebook { } } +/// The section colours OneNote 2010 gives new sections, COLORREF, in the order it gives +/// them (`evidence/notebook-management/new-notebook`). +const SECTION_COLORS: [u32; 3] = [0x00e4a88a, 0x0078b0f6, 0x00bba4d5]; + +/// The colour OneNote gives a new section in `folder`: the first of its colours none of +/// the folder's sections has, or the next in turn when all are taken. +fn next_color(folder: &discover::Folder) -> u32 { + let taken: Vec = folder + .sections + .iter() + .filter_map(|section| match section.state { + discover::SectionState::Readable { color, .. } => color, + _ => None, + }) + .collect(); + SECTION_COLORS + .into_iter() + .find(|color| !taken.contains(color)) + .unwrap_or(SECTION_COLORS[folder.sections.len() % SECTION_COLORS.len()]) +} + fn component(name: &str) -> bool { !name.is_empty() && !name.contains(['/', '\\', '\0']) && name != "." && name != ".." } @@ -733,8 +867,6 @@ pub enum Event { /// The section refused an edit `apply` handed it; the pages it names are as they were /// before it, so an editor showing them should reload them. Rejected { spaces: Vec, error: String }, - /// Old session API, until the application reloads on `Changed`: pages changed remotely. - Refreshed, /// A publication attempt finished with this durable state. Attempt { id: u64, status: EditStatus }, /// The section file could not be reached; the replica keeps its state. @@ -743,25 +875,6 @@ pub enum Event { Failed(String), } -/// The outcome of saving an edited page (old session API). -#[derive(Debug, PartialEq, Eq)] -pub enum Save { - /// The model equals the stored page. - Unchanged, - /// The edit is durable locally under this id. - Queued(u64), - /// The stored page no longer matches `before`; reload it before saving again. - Stale, -} - -/// A queued edit and its durable state. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct QueuedEdit { - pub id: u64, - pub space: ExGuid, - pub status: EditStatus, -} - type Notify = Arc; /// A section file with its replica and background publication. `Send + Sync`: edits @@ -773,10 +886,6 @@ pub struct Section { events: Mutex>, sender: Sender, notify: Notify, - saved: Mutex)>>, - saves: Sender<(ExGuid, std::result::Result)>, - /// Saves queued and not yet reported. - unsaved: Arc, } impl Section { @@ -850,7 +959,6 @@ impl Section { ) -> Result { let replica = Arc::new(replica); let (sender, events) = mpsc::channel(); - let (saves, saved) = mpsc::channel(); let notify = Mutex::new(notify); let notify: Notify = Arc::new(move || { if let Ok(notify) = notify.lock() { @@ -865,7 +973,6 @@ impl Section { Ok(synced) => { if !synced.changed.is_empty() { events.push(Event::Changed(synced.changed.clone())); - events.push(Event::Refreshed); } if let Some((id, status)) = &synced.edit { events.push(Event::Attempt { @@ -899,9 +1006,6 @@ impl Section { events: Mutex::new(events), sender, notify, - saved: Mutex::new(saved), - saves, - unsaved: Arc::new(AtomicUsize::new(0)), }) } @@ -971,8 +1075,8 @@ impl Section { Ok(space) } - /// Removes pages permanently, queued like the user's own edits (a move across - /// sections is `import_page` there, then this here). + /// Removes pages or conflict pages permanently, queued like the user's own edits (a + /// move across sections is `import_page` there, then this here). pub fn delete_pages(&self, pages: &[ExGuid]) -> Result { self.replica.apply( "", @@ -991,23 +1095,10 @@ impl Section { self.replica.pending() } - /// The unpublished batch the remote no longer accepts, if any. Show `remote_page` - /// against `page` for its space, then `resolve`. - pub fn conflict(&self) -> Result> { - self.replica.conflict() - } - - /// The page as last observed in the section file, for reviewing a conflict. - pub fn remote_page(&self, space: ExGuid) -> Result { - let snapshot = self.replica.remote_snapshot()?; - let arena = onestore::Arena::default(); - Ok(onestore::Section::open(&arena, snapshot)?.page(space)?) - } - - /// Ends a conflict: `Mine` publishes the local page over the remote change, `Theirs` - /// drops the local edits to the conflicted page (and its later ones). - pub fn resolve(&self, id: u64, resolution: Resolution) -> Result<()> { - self.replica.resolve(id, resolution) + /// The conflict pages of each page that has them, as the local edits leave them + /// (`onestore::Section::conflicts`); `page` reads one, `delete_pages` removes it. + pub fn conflicts(&self) -> Result)>> { + self.replica.conflicts() } /// Retires an uncertain attempt after review, exporting the queue to `archive` first: @@ -1056,114 +1147,6 @@ impl Section { None => Ok(()), } } - - // Old whole-page session API, kept until the application emits ops. - - /// Saves an edited page. `before` is the model the edit started from; a stored page - /// that differs from it means the section changed underneath the editor. - pub fn save(&self, space: ExGuid, before: &Page, after: &Page, author: &str) -> Result { - let outcome = self.replica.ask(|reply| crate::working::Request::Save { - space, - before: before.clone(), - after: after.clone(), - author: author.to_owned(), - reply, - })?; - Ok(outcome.unwrap_or(Save::Unchanged)) - } - - /// `save` on the section thread, returning at once; `saved` reports the outcome. - /// Saves queue in order, and one whose `before` is the previous save's `after` - /// continues it: pass each save's `after` as the next one's `before`. - pub fn queue_save(&self, space: ExGuid, before: Page, after: Page, author: &str) -> Result<()> { - let (saves, notify, unsaved) = ( - self.saves.clone(), - Arc::clone(&self.notify), - Arc::clone(&self.unsaved), - ); - self.unsaved.fetch_add(1, Ordering::Relaxed); - self.replica.send(crate::working::Request::Save { - space, - before, - after, - author: author.to_owned(), - reply: Box::new(move |outcome| { - unsaved.fetch_sub(1, Ordering::Release); - let outcome = match outcome { - Ok(None) => return, - Ok(Some(save)) => Ok(save), - Err(error) => Err(error.to_string()), - }; - if saves.send((space, outcome)).is_ok() { - notify(); - } - }), - }) - } - - /// Whether a queued save has yet to report, so the stored page may trail the editor's. - pub fn saving(&self) -> bool { - self.unsaved.load(Ordering::Acquire) > 0 - } - - /// Outcomes of queued saves since the last poll, oldest first; `notify` runs for each. - /// Consecutive saves continuing one another finish as one, reported once. - pub fn saved(&self) -> Vec<(ExGuid, std::result::Result)> { - self.saved - .lock() - .map(|saved| saved.try_iter().collect()) - .unwrap_or_default() - } - - /// Every queued edit with its state: pending, awaiting confirmation of a retained - /// attempt, or a conflict awaiting review. `space` is the first page it edits. - pub fn queue(&self) -> Result> { - self.replica - .pending()? - .into_iter() - .map(|edit| { - Ok(QueuedEdit { - id: edit.id, - space: edit - .edit - .ops - .iter() - .find_map(|op| match op { - Op::Page { space, .. } => Some(*space), - Op::Section(_) => None, - }) - .unwrap_or(self.replica.root), - status: self.replica.status(edit.id)?.unwrap_or(EditStatus::Pending), - }) - }) - .collect() - } - - /// The conflict as a queue entry, for the review dialog. - pub fn conflicts(&self) -> Result> { - Ok(self - .replica - .conflict()? - .map(|conflict| { - ( - QueuedEdit { - id: conflict.id, - space: conflict.space, - status: EditStatus::Conflict(conflict.kind), - }, - conflict.kind, - ) - }) - .into_iter() - .collect()) - } - - /// Resolves the conflict holding `id` with a page reviewed against `remote_page`: the - /// remote page is rewritten to `after`. - pub fn review(&self, id: u64, after: &Page) -> Result<()> { - self.replica - .resolve_with(id, Resolution::Mine, Some(after.clone())) - } } /// The section file itself as the publication target, under OneNote-compatible exclusion. @@ -1176,21 +1159,21 @@ impl Remote for FileRemote { /// Read without the whole-file lock, which would block OneNote's readers: a change /// detector, never a snapshot to edit. - fn stamp(&mut self) -> io::Result> { + fn stamp(&mut self) -> io::Result { use std::io::Read; let mut file = std::fs::File::open(&self.0)?; let mut header = [0; 1024]; file.read_exact(&mut header)?; let length = file.metadata()?.len(); - Ok(Some(Stamp { header, length })) + Ok(Stamp { header, length }) } fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> { transaction.commit_file(&self.0) } - fn confirm(&mut self, snapshot: &[u8]) -> std::result::Result<(), CommitError> { - onestore::confirm_file_snapshot(&self.0, snapshot) + fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> { + onestore::confirm_file(&self.0, base) } } diff --git a/crates/notebook/src/smb/directory.rs b/crates/notebook/src/smb/directory.rs index d947b6d92e1baebad2130624677e690ef8a76847..1649cc9e07b18c39b8a8c2d0899c51d5e90b4a5b 100644 --- a/crates/notebook/src/smb/directory.rs +++ b/crates/notebook/src/smb/directory.rs @@ -35,11 +35,7 @@ impl Client { create_contexts: Vec::new(), }, )?; - let file = File { - client: self, - id: Some(response.file_id), - length: response.end_of_file, - }; + let file = File::new(self, &response); let mut entries = BTreeMap::new(); loop { let response: io::Result = diff --git a/crates/notebook/src/smb/mod.rs b/crates/notebook/src/smb/mod.rs index 6b10a7fcc89c548290c3cfa4826a38bccdf57e38..051352e80331ccf33c6ac38f4967b4937fc6f773 100644 --- a/crates/notebook/src/smb/mod.rs +++ b/crates/notebook/src/smb/mod.rs @@ -1,9 +1,9 @@ //! Blocking SMB access to OneNote sections, table-of-contents files and payloads. -use onestore::{CommitError, CommitIo, CommitState, ExGuid}; +use onestore::{CommitError, CommitIo, CommitState}; use smb2::{ Session, Tree, - client::connection::{Connection, NegotiatedParams}, + client::connection::{CompoundOp, Connection, Frame, NegotiatedParams}, msg::{ close::{CloseRequest, CloseResponse}, create::{ @@ -22,7 +22,7 @@ use smb2::{ flags::{Capabilities, FileAccessMask}, }, }; -use std::{io, ops::Range, sync::Mutex, time::Duration}; +use std::{io, sync::Mutex, time::Duration}; use tokio::runtime::{Handle, Runtime}; mod directory; @@ -149,6 +149,54 @@ impl Client { .map_err(|_| io::Error::from(io::ErrorKind::TimedOut)) .and_then(|result| result.map_err(io::Error::other)) .inspect_err(|_| self.retire())?; + self.unpack(&self.body(command, frame)?) + } + + /// Sends `requests` as one related compound request, which the server performs in order, + /// and returns each response body or status error. A request after a CREATE names the + /// file it opened as `FileId::SENTINEL`; each request fits one credit. + fn compound(&self, requests: &[(Command, &dyn Pack)]) -> io::Result>>> { + if Handle::try_current().is_ok() { + return Err(io::ErrorKind::InvalidInput.into()); + } + let connection = self + .connection + .lock() + .map_err(|_| io::ErrorKind::Other)? + .clone() + .ok_or(io::ErrorKind::NotConnected)?; + let operations: Vec<_> = requests + .iter() + .map(|(command, body)| CompoundOp::new(*command, *body, Some(self.tree.tree_id))) + .collect(); + let frames = { + let runtime = self.runtime.lock().map_err(|_| io::ErrorKind::Other)?; + runtime + .as_ref() + .ok_or(io::ErrorKind::NotConnected)? + .block_on(async { + tokio::time::timeout(self.timeout, connection.execute_compound(&operations)) + .await + }) + }; + let frames = frames + .map_err(|_| io::Error::from(io::ErrorKind::TimedOut)) + .and_then(|result| result.map_err(io::Error::other)) + .inspect_err(|_| self.retire())?; + Ok(frames + .into_iter() + .zip(requests) + .map(|(frame, (command, _))| { + let frame = frame + .map_err(io::Error::other) + .inspect_err(|_| self.retire())?; + self.body(*command, frame) + }) + .collect()) + } + + /// The body of a response to `command`, or its status as an error. + fn body(&self, command: Command, frame: Frame) -> io::Result> { if frame.header.command != command { self.retire(); return Err(io::ErrorKind::InvalidData.into()); @@ -170,12 +218,17 @@ impl Client { }, )); } - T::unpack(&mut ReadCursor::new(&frame.body)).map_err(|error| { + Ok(frame.body) + } + + fn unpack(&self, body: &[u8]) -> io::Result { + T::unpack(&mut ReadCursor::new(body)).map_err(|error| { self.retire(); io::Error::new(io::ErrorKind::InvalidData, error) }) } + /// Opens as OneNote 2010 does: readers share everything, a writer denies other writers. fn open(&self, path: &str, write: bool) -> io::Result> { self.open_shared(path, write, if write { 5 } else { 7 }) } @@ -186,7 +239,7 @@ impl Client { if write { 0xc0000000 } else { 0x80000000 }, sharing, CreateDisposition::FileOpen, - 0x42, + 0x40, ) } @@ -198,28 +251,11 @@ impl Client { disposition: CreateDisposition, options: u32, ) -> io::Result> { - if path.is_empty() || path.contains('\0') || path.encode_utf16().count() > 32767 { - return Err(io::ErrorKind::InvalidInput.into()); - } let response: CreateResponse = self.request( Command::Create, - CreateRequest { - requested_oplock_level: OplockLevel::None, - impersonation_level: ImpersonationLevel::Impersonation, - desired_access: FileAccessMask::new(access), - file_attributes: 0, - share_access: ShareAccess(sharing), - create_disposition: disposition, - create_options: options, - name: smb2::encode_path(&path.replace('\\', "/")), - create_contexts: Vec::new(), - }, + create_request(path, access, sharing, disposition, options)?, )?; - Ok(File { - client: self, - id: Some(response.file_id), - length: response.end_of_file, - }) + Ok(File::new(self, &response)) } /// Creates a file holding `bytes`; an existing file is an error. @@ -286,7 +322,7 @@ impl Client { /// Names a section or TOC file for its notebook (`onestore::place`) under native /// writer coordination. pub fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<(), CommitError> { - self.commit(path, |file| { + self.commit(path, &[(0, 1024)], |file| { onestore::place(file, ancestor, name).map_err(|error| CommitError { state: CommitState::NotCommitted, error, @@ -338,7 +374,9 @@ impl Client { path: &str, snapshot: impl FnOnce(&mut File<'_>) -> io::Result>>, ) -> io::Result> { - let mut file = self.open(path, false)?.coordinate(path, false)?; + let mut file = self + .open(path, false)? + .coordinate(path, false, &[(0, 1024)])?; let result = snapshot(&mut file) .and_then(|snapshot| snapshot.ok_or_else(|| io::ErrorKind::WouldBlock.into())); let closed = file.close(); @@ -347,79 +385,67 @@ impl Client { Ok(snapshot) } - pub fn commit_text( - &self, - path: &str, - source: &[u8], - space: ExGuid, - object: ExGuid, - range: Range, - replacement: &str, - ) -> Result<(), CommitError> { - self.commit(path, |file| { - onestore::commit_text(file, source, space, object, range, replacement) - }) - } - - pub fn commit_property_bytes( - &self, - path: &str, - source: &[u8], - space: ExGuid, - object: ExGuid, - property: u32, - value: &[u8], - ) -> Result<(), CommitError> { - self.commit(path, |file| { - onestore::commit_property_bytes(file, source, space, object, property, value) - }) - } - /// Publishes a transaction using the same native writer coordination as text commits. pub fn commit_transaction( &self, path: &str, transaction: &onestore::Transaction, ) -> Result<(), CommitError> { - self.commit(path, |file| transaction.commit(file)) + self.commit(path, &checked(transaction.base()), |file| { + transaction.commit(file) + }) } - /// The header and length of a revision store, read without writer coordination or path - /// identity checks: a change detector for polling, never a snapshot to edit. + /// The header and length of a revision store, in one round trip, without writer + /// coordination or path identity checks: a change detector for polling, never a snapshot + /// to edit. pub fn stamp(&self, path: &str) -> io::Result { - let mut file = self.open(path, false)?; - let mut header = [0; 1024]; - let mut read = 0; - let result = loop { - match file.read_at(read as u64, &mut header[read..]) { - Ok(0) => break Err(io::ErrorKind::UnexpectedEof.into()), - Ok(count) => read += count, - Err(error) => break Err(error), - } - if read == header.len() { - break Ok(()); - } + let create = create_request(path, 0x80000000, 7, CreateDisposition::FileOpen, 0x40)?; + let read = read_request(FileId::SENTINEL, 0, 1024); + let close = CloseRequest { + file_id: FileId::SENTINEL, + flags: 0, }; - let length = file.length; - let closed = file.close(); - result?; + let [created, read, closed]: [_; 3] = self + .compound(&[ + (Command::Create, &create), + (Command::Read, &read), + (Command::Close, &close), + ])? + .try_into() + .map_err(|_| io::ErrorKind::InvalidData)?; + let created: CreateResponse = self.unpack(&created?)?; + if closed.is_err() { + self.retire(); + } + let read: ReadResponse = self.unpack(&read?)?; closed?; - Ok(onestore::Stamp { header, length }) + let header = read + .data + .try_into() + .map_err(|_| io::ErrorKind::UnexpectedEof)?; + Ok(onestore::Stamp { + header, + length: created.end_of_file, + }) } - /// Confirms an observed snapshot's durability under native writer coordination. - pub fn confirm_snapshot(&self, path: &str, source: &[u8]) -> Result<(), CommitError> { - self.commit(path, |file| onestore::confirm_snapshot(file, source)) + /// Confirms that the file still has `base`'s stamp and is durable, then refreshes its + /// version, under native writer coordination. + pub fn confirm(&self, path: &str, base: &onestore::Stamp) -> Result<(), CommitError> { + self.commit(path, &checked(base), |file| onestore::confirm(file, base)) } + /// Opens `path` for writing under OneNote's coordination, making `reads` with the locks. fn commit( &self, path: &str, + reads: &[(u64, usize)], operation: impl FnOnce(&mut File<'_>) -> Result<(), CommitError>, ) -> Result<(), CommitError> { let mut file = self .open(path, true) - .and_then(|file| file.coordinate(path, true)) + .and_then(|file| file.coordinate(path, true, reads)) .map_err(|error| CommitError { state: CommitState::NotCommitted, error, @@ -443,24 +469,103 @@ impl Drop for Client { struct File<'a> { client: &'a Client, id: Option, - /// The end of file when opened. - length: u64, + /// Writes `flush` sends ahead of its flush, in order. + writes: Vec<(u64, Vec)>, + /// Reads made under the coordination locks, each returned once to the same `read_at`. + prefetched: Vec<(u64, usize, Vec)>, } -impl File<'_> { - fn coordinate(self, path: &str, write: bool) -> io::Result { - self.lock(0xfffffffb, 0x11)?; +impl<'a> File<'a> { + fn new(client: &'a Client, created: &CreateResponse) -> Self { + Self { + client, + id: Some(created.file_id), + writes: Vec::new(), + prefetched: Vec::new(), + } + } + + /// Takes OneNote 2010's coordination locks in one request (the shared reader byte, and + /// to write the exclusive writer byte), makes `reads` under them, then checks that the + /// handle is still the file at `path`: maintenance replaces a file while holding its + /// locks. + fn coordinate(mut self, path: &str, write: bool, reads: &[(u64, usize)]) -> io::Result { + let id = self.id.unwrap(); + let mut locks = vec![LockElement { + offset: 0xfffffffb, + length: 1, + flags: 0x11, + }]; if write { - self.lock(0xfffffffd, 0x12)?; + locks.push(LockElement { + offset: 0xfffffffd, + length: 1, + flags: 0x12, + }); } - let current = self.client.open(path, false)?; - let same = self.identity()? == current.identity()?; - current.close()?; - if !same { + let lock = LockRequest { + file_id: id, + lock_sequence: 0, + locks, + }; + let [index, volume] = identity_requests(id); + let reading: Vec<_> = reads + .iter() + .map(|(offset, length)| read_request(id, *offset, *length)) + .collect(); + let mut requests: Vec<(Command, &dyn Pack)> = vec![ + (Command::Lock, &lock), + (Command::QueryInfo, &index), + (Command::QueryInfo, &volume), + ]; + requests.extend( + reading + .iter() + .map(|read| (Command::Read, read as &dyn Pack)), + ); + let mut responses = self.client.compound(&requests)?.into_iter(); + let mut next = || responses.next().ok_or(io::ErrorKind::InvalidData); + let _: LockResponse = self.client.unpack(&next()??)?; + let own = self.client.identity(next()?, next()?)?; + for (offset, length) in reads { + let data = match next()? { + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => Vec::new(), + body => self.client.unpack::(&body?)?.data, + }; + if data.len() > *length { + self.client.retire(); + return Err(io::ErrorKind::InvalidData.into()); + } + self.prefetched.push((*offset, *length, data)); + } + let create = create_request(path, 0x80, 7, CreateDisposition::FileOpen, 0)?; + let [index, volume] = identity_requests(FileId::SENTINEL); + let close = CloseRequest { + file_id: FileId::SENTINEL, + flags: 0, + }; + let [created, index, volume, closed]: [_; 4] = self + .client + .compound(&[ + (Command::Create, &create), + (Command::QueryInfo, &index), + (Command::QueryInfo, &volume), + (Command::Close, &close), + ])? + .try_into() + .map_err(|_| io::ErrorKind::InvalidData)?; + created?; + if closed.is_err() { + self.client.retire(); + } + let current = self.client.identity(index, volume)?; + closed?; + if own != current { return Err(io::ErrorKind::ResourceBusy.into()); } Ok(self) } + #[cfg(test)] fn lock(&self, offset: u64, flags: u32) -> io::Result<()> { let _: LockResponse = self.client.request( Command::Lock, @@ -477,19 +582,37 @@ impl File<'_> { Ok(()) } - fn identity(&self) -> io::Result<(u64, u32)> { - let index: QueryInfoResponse = self.client.request( - Command::QueryInfo, - QueryInfoRequest { - info_type: InfoType::File, - file_info_class: 6, - output_buffer_length: 8, - additional_information: 0, - flags: 0, - file_id: self.id.unwrap(), - input_buffer: Vec::new(), - }, - )?; + fn close(mut self) -> io::Result<()> { + self.release() + } + + fn release(&mut self) -> io::Result<()> { + if let Some(file_id) = self.id.take() { + let result: io::Result = self + .client + .request(Command::Close, CloseRequest { file_id, flags: 0 }); + if result.is_err() { + self.client.retire(); + } + result?; + } + Ok(()) + } +} +impl Drop for File<'_> { + fn drop(&mut self) { + let _ = self.release(); + } +} + +impl Client { + /// A file's volume serial number and index, from the responses to `identity_requests`. + fn identity( + &self, + index: io::Result>, + volume: io::Result>, + ) -> io::Result<(u64, u32)> { + let index: QueryInfoResponse = self.unpack(&index?)?; let index = u64::from_le_bytes( index .output_buffer @@ -499,47 +622,69 @@ impl File<'_> { if index == 0 { return Err(io::ErrorKind::Unsupported.into()); } - let volume: QueryInfoResponse = self.client.request( - Command::QueryInfo, - QueryInfoRequest { - info_type: InfoType::Filesystem, - file_info_class: 1, - output_buffer_length: 1024, - additional_information: 0, - flags: 0, - file_id: self.id.unwrap(), - input_buffer: Vec::new(), - }, - )?; + let volume: QueryInfoResponse = self.unpack(&volume?)?; let serial = volume .output_buffer .get(8..12) .ok_or(io::ErrorKind::InvalidData)?; Ok((index, u32::from_le_bytes(serial.try_into().unwrap()))) } +} - fn close(mut self) -> io::Result<()> { - self.release() - } +/// FileInternalInformation and FileFsVolumeInformation queries. +fn identity_requests(file_id: FileId) -> [QueryInfoRequest; 2] { + let query = |info_type, file_info_class, output_buffer_length| QueryInfoRequest { + info_type, + file_info_class, + output_buffer_length, + additional_information: 0, + flags: 0, + file_id, + input_buffer: Vec::new(), + }; + [ + query(InfoType::File, 6, 8), + query(InfoType::Filesystem, 1, 1024), + ] +} - fn release(&mut self) -> io::Result<()> { - if let Some(file_id) = self.id.take() { - let result: io::Result = self - .client - .request(Command::Close, CloseRequest { file_id, flags: 0 }); - if result.is_err() { - self.client.retire(); - } - result?; - } - Ok(()) +fn create_request( + path: &str, + access: u32, + sharing: u32, + disposition: CreateDisposition, + options: u32, +) -> io::Result { + if path.is_empty() || path.contains('\0') || path.encode_utf16().count() > 32767 { + return Err(io::ErrorKind::InvalidInput.into()); } + Ok(CreateRequest { + requested_oplock_level: OplockLevel::None, + impersonation_level: ImpersonationLevel::Impersonation, + desired_access: FileAccessMask::new(access), + file_attributes: 0, + share_access: ShareAccess(sharing), + create_disposition: disposition, + create_options: options, + name: smb2::encode_path(&path.replace('\\', "/")), + create_contexts: Vec::new(), + }) } -impl Drop for File<'_> { - fn drop(&mut self) { - let _ = self.release(); + +fn read_request(file_id: FileId, offset: u64, length: usize) -> ReadRequest { + ReadRequest { + file_id, + offset, + length: length as u32, + minimum_count: 1, + flags: 0, + padding: 0, + channel: 0, + remaining_bytes: 0, + read_channel_info: Vec::new(), } } + fn read_size(params: &NegotiatedParams, credits: u16, requested: usize) -> usize { let budget = if params.dialect != Dialect::Smb2_0_2 && params.capabilities.contains(Capabilities::LARGE_MTU) @@ -559,6 +704,15 @@ impl CommitIo for File<'_> { if output.is_empty() { return Ok(0); } + if let Some(at) = self + .prefetched + .iter() + .position(|(start, length, _)| *start == offset && *length == output.len()) + { + let (_, _, data) = self.prefetched.swap_remove(at); + output[..data.len()].copy_from_slice(&data); + return Ok(data.len()); + } let mut size = 0; let response: io::Result = self.client.request_with(Command::Read, |connection| { @@ -595,40 +749,71 @@ impl CommitIo for File<'_> { output[..response.data.len()].copy_from_slice(&response.data); Ok(response.data.len()) } + /// Queues at most one request's worth of `bytes`; `flush` sends it. fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result { - if bytes.is_empty() { - return Ok(0); - } let size = bytes.len().min(65536); - let response: WriteResponse = self.client.request( - Command::Write, - WriteRequest { - file_id: self.id.unwrap(), + if size > 0 { + self.writes.push((offset, bytes[..size].to_vec())); + } + Ok(size) + } + + /// Sends the queued writes and then the flush in related compound requests of at most + /// sixteen, awaiting each: the server performs a compound in order, and answers the flush + /// once every write before it is durable. + fn flush(&mut self) -> io::Result<()> { + let file_id = self.id.unwrap(); + let writes: Vec<_> = self + .writes + .drain(..) + .map(|(offset, data)| WriteRequest { + file_id, offset, - data: bytes[..size].to_vec(), + data, data_offset: 112, - flags: 1, + flags: 0, channel: 0, remaining_bytes: 0, write_channel_info_offset: 0, write_channel_info_length: 0, - }, - )?; - if response.count as usize > size { - return Err(io::ErrorKind::InvalidData.into()); + }) + .collect(); + let flush = FlushRequest { file_id }; + let mut requests: Vec<(Command, &dyn Pack)> = writes + .iter() + .map(|write| (Command::Write, write as &dyn Pack)) + .collect(); + requests.push((Command::Flush, &flush)); + let mut sizes = writes.iter().map(|write| write.data.len()); + for compound in requests.chunks(16) { + for (response, (command, _)) in + self.client.compound(compound)?.into_iter().zip(compound) + { + let body = response?; + if *command == Command::Flush { + let _: FlushResponse = self.client.unpack(&body)?; + continue; + } + let written: WriteResponse = self.client.unpack(&body)?; + let size = sizes.next().ok_or(io::ErrorKind::InvalidData)?; + if written.count as usize != size { + return Err(if (written.count as usize) < size { + io::ErrorKind::WriteZero + } else { + io::ErrorKind::InvalidData + } + .into()); + } + } } - Ok(response.count as usize) - } - fn flush(&mut self) -> io::Result<()> { - let _: FlushResponse = self.client.request( - Command::Flush, - FlushRequest { - file_id: self.id.unwrap(), - }, - )?; Ok(()) } } +/// The reads `Stamp::check` makes: the header and a probe of the last byte. +fn checked(stamp: &onestore::Stamp) -> [(u64, usize); 2] { + [(0, 1024), (stamp.length.saturating_sub(1), 2)] +} + #[cfg(test)] mod tests; diff --git a/crates/notebook/src/smb/remote.rs b/crates/notebook/src/smb/remote.rs index 16fd2ba06ab25ceb5a2e0c08fb4329eced4fceb4..b1178eb2115de1f5478754686127724f74713299 100644 --- a/crates/notebook/src/smb/remote.rs +++ b/crates/notebook/src/smb/remote.rs @@ -26,15 +26,15 @@ impl Remote for SmbRemote { self.client.read(&self.path, self.limit) } - fn stamp(&mut self) -> io::Result> { - self.client.stamp(&self.path).map(Some) + fn stamp(&mut self) -> io::Result { + self.client.stamp(&self.path) } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.client.commit_transaction(&self.path, transaction) } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - self.client.confirm_snapshot(&self.path, snapshot) + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + self.client.confirm(&self.path, base) } } diff --git a/crates/notebook/src/smb/tests.rs b/crates/notebook/src/smb/tests.rs index 54078552e8a67c7b15b3a34a1d308dda2d0a2e57..af3c34d20f8ccbfbe66741337843cd11284932df 100644 --- a/crates/notebook/src/smb/tests.rs +++ b/crates/notebook/src/smb/tests.rs @@ -1,7 +1,8 @@ use super::*; use onestore::{ - RevisionIndex, Store, + ExGuid, RevisionIndex, Store, Transaction, document::{Document, Kind}, + op::{Edit, Op, PageOp}, }; use std::{ fs, @@ -117,11 +118,7 @@ fn create(client: &Client, path: &str, bytes: &[u8]) { }, ) .unwrap(); - let mut file = File { - client, - id: Some(response.file_id), - length: response.end_of_file, - }; + let mut file = File::new(client, &response); let mut offset = 0; while offset < bytes.len() { offset += file.write_at(offset as u64, &bytes[offset..]).unwrap(); @@ -129,6 +126,43 @@ fn create(client: &Client, path: &str, bytes: &[u8]) { file.flush().unwrap(); file.close().unwrap(); } +/// The transaction replacing `range` of the first text in `image` with `with`, as typed now. +fn replaced(image: &[u8], range: std::ops::Range, with: &str) -> Transaction { + let (space, text, _) = text(image); + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, image.to_vec()).unwrap(); + let at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + / 100 + + 116_444_736_000_000_000; + section + .apply( + "Fixture", + &Edit { + at, + ops: vec![Op::Page { + space, + op: PageOp::Text { + text, + range, + with: with.to_owned(), + }, + }], + }, + ) + .unwrap(); + section.seal().unwrap().unwrap() +} + +/// `image` after `transaction`. +fn applied(image: &[u8], transaction: &Transaction) -> Vec { + let mut image = image.to_vec(); + transaction.apply(&mut image).unwrap(); + image +} + fn text(bytes: &[u8]) -> (ExGuid, ExGuid, String) { let store = Store::parse(bytes).unwrap(); assert!(store.checksum_mismatches.is_empty()); @@ -171,21 +205,15 @@ fn live_coordination() { client .open(&path, false) .unwrap() - .coordinate(&path, false) + .coordinate(&path, false, &[]) .unwrap() }) .collect(); - let (sid, oid, before) = text(&source); + let (_, _, before) = text(&source); let replacement = "After café 🦀"; + let everything = 0..before.encode_utf16().count() as u32; writer - .commit_text( - &path, - &source, - sid, - oid, - 0..before.encode_utf16().count() as u32, - replacement, - ) + .commit_transaction(&path, &replaced(&source, everything.clone(), replacement)) .unwrap(); let after = writer.read(&path, 1 << 20).unwrap(); assert_eq!(text(&after).2, replacement); @@ -196,7 +224,7 @@ fn live_coordination() { assert_eq!(snapshot, after); } let error = writer - .commit_text(&path, &source, sid, oid, 0..1, "X") + .commit_transaction(&path, &replaced(&source, 0..1, "X")) .unwrap_err(); assert_eq!(error.state, CommitState::NotCommitted); assert_eq!(error.error.kind(), io::ErrorKind::ResourceBusy); @@ -262,7 +290,7 @@ fn live_coordination() { .unwrap(); drop(connection); guard.close().unwrap(); - let error = stale.coordinate(&path, true).err().unwrap(); + let error = stale.coordinate(&path, true, &[]).err().unwrap(); assert_eq!(error.kind(), io::ErrorKind::ResourceBusy); assert_eq!(writer.read(&path, 1 << 20).unwrap(), source); @@ -270,7 +298,7 @@ fn live_coordination() { let file = retiring .open(&path, true) .unwrap() - .coordinate(&path, true) + .coordinate(&path, true, &[]) .unwrap(); retiring.retire(); assert_eq!( @@ -282,7 +310,7 @@ fn live_coordination() { loop { match writer .open(&path, true) - .and_then(|file| file.coordinate(&path, true)) + .and_then(|file| file.coordinate(&path, true, &[])) { Ok(file) => { file.close().unwrap(); @@ -300,7 +328,7 @@ fn live_coordination() { let mut unfinished = writer .open(&path, true) .unwrap() - .coordinate(&path, true) + .coordinate(&path, true, &[]) .unwrap(); assert_eq!( unfinished @@ -314,14 +342,7 @@ fn live_coordination() { assert_eq!(snapshot.len(), source.len() + 11); assert_eq!(text(&snapshot).2, before); writer - .commit_text( - &path, - &snapshot, - sid, - oid, - 0..before.encode_utf16().count() as u32, - "Recovered café 🦀", - ) + .commit_transaction(&path, &replaced(&snapshot, everything, "Recovered café 🦀")) .unwrap(); let recovered = writer.read(&path, 1 << 20).unwrap(); assert_eq!(text(&recovered).2, "Recovered café 🦀"); @@ -376,19 +397,27 @@ fn live_stamp_and_transaction() { onestore::Stamp::of(&source).unwrap() ); guard.close().unwrap(); - let (sid, oid, _) = text(&source); - let edit = onestore::PreparedEdit::text(&source, sid, oid, 0..0, "Changed ").unwrap(); - let transaction = edit.transaction(); + let transaction = replaced(&source, 0..0, "Changed "); + let edited = applied(&source, &transaction); poller.commit_transaction(&path, &transaction).unwrap(); assert_eq!( poller.stamp(&path).unwrap(), - onestore::Stamp::of(edit.as_bytes()).unwrap() + onestore::Stamp::of(&edited).unwrap() ); - assert_eq!(poller.read(&path, 1 << 20).unwrap(), edit.as_bytes()); + assert_eq!(poller.read(&path, 1 << 20).unwrap(), edited); let stale = poller.commit_transaction(&path, &transaction).unwrap_err(); assert_eq!(stale.state, CommitState::NotCommitted); assert_eq!(stale.error.kind(), io::ErrorKind::ResourceBusy); - assert_eq!(poller.read(&path, 1 << 20).unwrap(), edit.as_bytes()); + assert_eq!(poller.read(&path, 1 << 20).unwrap(), edited); + let base = onestore::Stamp::of(&edited).unwrap(); + poller.confirm(&path, &base).unwrap(); + let confirmed = poller.read(&path, 1 << 20).unwrap(); + assert_eq!(confirmed[..212], edited[..212]); + assert_ne!(confirmed[212..252], edited[212..252]); + assert_eq!(confirmed[252..], edited[252..]); + let stale = poller.confirm(&path, &base).unwrap_err(); + assert_eq!(stale.state, CommitState::NotCommitted); + assert_eq!(stale.error.kind(), io::ErrorKind::ResourceBusy); poller.delete(&path).unwrap(); } @@ -488,7 +517,7 @@ fn live_reader_hold() { let mut file = client .open(&path, false) .unwrap() - .coordinate(&path, false) + .coordinate(&path, false, &[]) .unwrap(); fs::write(output.join("ready"), b"held").unwrap(); let deadline = Instant::now() + Duration::from_secs(300); @@ -580,13 +609,13 @@ fn live_structure() { let mut notebook = Notebook::open_smb(std::sync::Arc::clone(&client), &root, cache.path()).unwrap(); assert_eq!( - notebook.create_section("", "Second", "Author").unwrap(), + notebook.create_section("", "Second", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()).unwrap(), "Second.one" ); assert_eq!(notebook.create_group("", "Archive").unwrap(), "Archive"); assert_eq!( notebook - .create_section("Archive", "Inner", "Author") + .create_section("Archive", "Inner", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()) .unwrap(), "Archive/Inner.one" ); diff --git a/crates/notebook/src/smb/tests/faults.rs b/crates/notebook/src/smb/tests/faults.rs index 99641ee062cb06e9a4dad56f284ca954293b65fc..a9b74ec3144932898298846fec2c3026203a47a8 100644 --- a/crates/notebook/src/smb/tests/faults.rs +++ b/crates/notebook/src/smb/tests/faults.rs @@ -297,7 +297,7 @@ fn live_message_loss() { &source, ) .unwrap(); - let (sid, oid, before) = text(&source); + let (_, _, before) = text(&source); let after = if fixture == "chunked" { "After café 🦀 ".repeat(6000) } else { @@ -318,15 +318,11 @@ fn live_message_loss() { ); std::thread::sleep(Duration::from_millis(5)); } - let expected = onestore::replace_text( + let everything = 0..before.encode_utf16().count() as u32; + let new = snapshot(&applied( &source, - sid, - oid, - 0..before.encode_utf16().count() as u32, - &after, - ) - .unwrap(); - let new = snapshot(&expected); + &replaced(&source, everything.clone(), &after), + )); let baseline_path = format!("fault-{prefix}-{fixture}-baseline.one"); create(&observer, &baseline_path, &source); configure(&output, json!({"phase":format!("{fixture}-setup")})); @@ -340,13 +336,9 @@ fn live_message_loss() { let start = configure(&output, json!({"phase":format!("{fixture}-baseline")})); let began = stamp(); baseline - .commit_text( + .commit_transaction( &baseline_path, - &source, - sid, - oid, - 0..before.encode_utf16().count() as u32, - &after, + &replaced(&source, everything.clone(), &after), ) .unwrap(); let events = records(&output); @@ -397,15 +389,9 @@ fn live_message_loss() { json!({"phase":name, "direction":direction, "cut":command, "status":status, "occurrence":occurrence}), ); let began = stamp(); + let transaction = replaced(&source, everything.clone(), &after); let error = interrupted - .commit_text( - &path, - &source, - sid, - oid, - 0..before.encode_utf16().count() as u32, - &after, - ) + .commit_transaction(&path, &transaction) .unwrap_err(); assert_eq!( interrupted.read(&path, 1 << 20).unwrap_err().kind(), @@ -429,7 +415,7 @@ fn live_message_loss() { loop { match fresh .open(&path, true) - .and_then(|file| file.coordinate(&path, true)) + .and_then(|file| file.coordinate(&path, true, &[])) { Ok(file) => { file.close().unwrap(); @@ -458,14 +444,7 @@ fn live_message_loss() { } if !visible { fresh - .commit_text( - &path, - &saved, - sid, - oid, - 0..before.encode_utf16().count() as u32, - &after, - ) + .commit_transaction(&path, &replaced(&saved, everything.clone(), &after)) .unwrap(); } let saved = fresh.read(&path, 1 << 20).unwrap(); @@ -475,7 +454,7 @@ fn live_message_loss() { ); let end = after.encode_utf16().count() as u32; fresh - .commit_text(&path, &saved, sid, oid, end..end, suffix) + .commit_transaction(&path, &replaced(&saved, end..end, suffix)) .unwrap(); let recovered = fresh.read(&path, 1 << 20).unwrap(); assert_eq!(text(&recovered).2, format!("{after}{suffix}")); diff --git a/crates/notebook/src/sync.rs b/crates/notebook/src/sync.rs index f972d937376b28dcce55dedbbcac584e1c792d5c..e23cca5ce9edd84e1b1f37fabd4cb8ab1a192088 100644 --- a/crates/notebook/src/sync.rs +++ b/crates/notebook/src/sync.rs @@ -1,5 +1,5 @@ use super::*; -use crate::working::{Rebased, Request, Resolve, Sealed}; +use crate::working::{Request, Sealed}; use onestore::{CommitError, CommitState, RevisionIndex, Stamp, Store, Transaction}; use rusqlite::OptionalExtension; use std::{ @@ -12,44 +12,12 @@ use std::{ /// cached readers. pub trait Remote { fn read(&mut self) -> io::Result>; - /// The file's stamp without reading its body, when the remote can tell. While it is the - /// last observed image's, synchronization neither reads nor revalidates the file. - fn stamp(&mut self) -> io::Result> { - Ok(None) - } + /// The file's stamp without reading its body or coordinating with writers. While it is + /// the last observed image's, synchronization neither reads nor revalidates the file. + fn stamp(&mut self) -> io::Result; fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError>; - fn confirm(&mut self, snapshot: &[u8]) -> std::result::Result<(), CommitError>; -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(i64)] -pub enum ConflictKind { - /// The edited page or a selected page no longer exists remotely. - TargetUnavailable = 0, - /// The remote section no longer accepts the edit. - UnsupportedEdit = 1, - /// Page order or indentation changed remotely in a way the batch cannot absorb. - StructureChanged = 2, - /// The remote page changed where the local edits changed it too; review is required. - ContentChanged = 3, -} - -impl ConflictKind { - fn from_stored(kind: i64) -> Result { - Ok(match kind { - 0 => Self::TargetUnavailable, - 1 => Self::UnsupportedEdit, - 2 => Self::StructureChanged, - 3 => Self::ContentChanged, - _ => { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Unknown cached conflict kind", - ) - .into()); - } - }) - } + /// Confirms that the file still has `base`'s stamp and is durable (`onestore::confirm`). + fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError>; } #[derive(Debug, Clone, PartialEq, Eq)] @@ -59,7 +27,6 @@ pub enum EditStatus { AwaitingConfirmation { revision: ExGuid, }, - Conflict(ConflictKind), /// Historical confirmation; later remote edits or restores may remove the effect. Published { revision: ExGuid, @@ -84,15 +51,13 @@ struct State { base: Stamp, /// The observed remote image's stamp while it is not the base. remote: Option, - /// The first batch waiting on a remote answer: sealed, attempted, or in conflict. + /// The first batch waiting on a remote answer to an attempt. blocked: Option, queued: bool, } struct Blocked { batch: i64, - attempted: bool, - conflict: Option, revisions: Option>, } @@ -101,24 +66,14 @@ fn state(connection: &Connection) -> Result { .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?; let blocked = connection .query_row( - "SELECT id, attempted, conflict, revisions FROM batches - WHERE attempted=1 OR conflict IS NOT NULL ORDER BY id LIMIT 1", + "SELECT id, revisions FROM batches WHERE attempted=1 ORDER BY id LIMIT 1", [], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, bool>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - )) - }, + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option>(1)?)), ) .optional()? - .map(|(batch, attempted, conflict, revisions)| { + .map(|(batch, revisions)| { Ok::<_, Error>(Blocked { batch, - attempted, - conflict: conflict.map(ConflictKind::from_stored).transpose()?, revisions: revisions .map(|revisions| decode_revisions(&revisions)) .transpose()?, @@ -186,7 +141,7 @@ impl Replica { /// The last observed remote image. Observation alone does not acknowledge any pending /// edit's remote durability. - pub fn remote_snapshot(&self) -> Result> { + pub(crate) fn remote_snapshot(&self) -> Result> { let connection = self.lock()?; let image = match base::read(&connection, base::Image::Remote)? { Some(image) => Some(image), @@ -197,26 +152,6 @@ impl Replica { })?) } - /// The unpublished batch the remote no longer accepts, if any. - pub fn conflict(&self) -> Result> { - let connection = self.lock()?; - let row: Option<(i64, i64, String)> = connection - .query_row( - "SELECT id, conflict, space FROM batches WHERE conflict IS NOT NULL ORDER BY id LIMIT 1", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .optional()?; - row.map(|(batch, kind, space)| { - Ok(Conflict { - id: newest(&connection, batch)?, - space: space.parse()?, - kind: ConflictKind::from_stored(kind)?, - }) - }) - .transpose() - } - /// Publishes the oldest unpublished batch, rebasing the queue first when the remote /// changed. Reads the remote image only when its stamp moved; network I/O holds /// synchronization ownership without holding the cache mutex. Uncertain attempts are @@ -225,8 +160,8 @@ impl Replica { let _owner = self.sync_owner()?; let state = state(&*self.lock()?)?; let observed = remote.stamp().map_err(Error::RemoteIo)?; - if let (Some(observed), Some(blocked)) = (&observed, &state.blocked) - && Some(observed) == state.remote.as_ref().or(Some(&state.base)) + if let Some(blocked) = &state.blocked + && observed == *state.remote.as_ref().unwrap_or(&state.base) { // Nothing new to decide: the remote is as it was when the batch blocked. let id = newest(&*self.lock()?, blocked.batch)?; @@ -239,7 +174,7 @@ impl Replica { }); } let image = match observed { - Some(observed) if observed == state.base => None, + observed if observed == state.base => None, _ => { let image = remote.read().map_err(Error::RemoteIo)?; // A read image is compared whole: a stamp stands for it only when read alone. @@ -249,13 +184,7 @@ impl Replica { }; let mut changed = Vec::new(); if let Some(image) = image { - if let Some(Blocked { - batch, - attempted: true, - revisions, - .. - }) = &state.blocked - { + if let Some(Blocked { batch, revisions }) = &state.blocked { let id = newest(&*self.lock()?, *batch)?; let revisions = revisions.clone().unwrap_or_default(); let store = Store::parse(&image)?; @@ -308,7 +237,7 @@ impl Replica { .collect(), ) }; - if let Err(error) = remote.confirm(&image) { + if let Err(error) = remote.confirm(&Stamp::of(&image)?) { if error.state == CommitState::Committed { self.acknowledge(*batch, sealed.as_ref(), receipts.as_ref())?; } @@ -316,49 +245,26 @@ impl Replica { } self.acknowledge(*batch, sealed.as_ref(), receipts.as_ref())?; let revision = self.receipt(id)?; - changed = self.rebase(Some(image), None)?.unwrap_or_default(); + changed = self.rebase(Some(image))?; return Ok(Synced { edit: Some((id, EditStatus::Published { revision })), changed, }); } - match self.rebase(Some(image), None)? { - Ok(spaces) => changed = spaces, - Err(conflict) => { - return Ok(Synced { - edit: Some((conflict.id, EditStatus::Conflict(conflict.kind))), - changed, - }); - } - } + changed = self.rebase(Some(image))?; } else if let Some(blocked) = &state.blocked { if state.remote.is_some() { // The remote is back at the base: what blocked the queue is gone. - let connection = self.lock()?; - base::clear(&connection, base::Image::Remote)?; - if blocked.conflict.is_some() { - connection.execute("UPDATE batches SET conflict=NULL, space=NULL", [])?; - } - } - if blocked.attempted { - let id = newest(&*self.lock()?, blocked.batch)?; - return Ok(Synced { - edit: Some(( - id, - status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending), - )), - changed, - }); - } - if let Some(kind) = blocked.conflict - && state.remote.is_none() - { - let id = newest(&*self.lock()?, blocked.batch)?; - return Ok(Synced { - edit: Some((id, EditStatus::Conflict(kind))), - changed, - }); + base::clear(&*self.lock()?, base::Image::Remote)?; } + let id = newest(&*self.lock()?, blocked.batch)?; + return Ok(Synced { + edit: Some(( + id, + status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending), + )), + changed, + }); } else if !state.queued { return Ok(Synced::default()); } @@ -383,7 +289,9 @@ impl Replica { let id = newest(&*self.lock()?, batch)?; let Some(transaction) = transaction else { // Edits that changed nothing are published once the remote's image is durable. - let base = base::read(&*self.lock()?, base::Image::Base)?.unwrap_or_default(); + let base = base::stamp(&*self.lock()?, base::Image::Base)?.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "The cache holds no base image") + })?; if let Err(error) = remote.confirm(&base) { if error.state == CommitState::Committed { self.acknowledge(batch, None, None)?; @@ -439,22 +347,10 @@ impl Replica { )) } - /// Asks the section thread to replay the queue on `image`; `Err` is the conflict found. - fn rebase( - &self, - image: Option>, - resolve: Option, - ) -> Result, Conflict>> { - Ok( - match self.ask(|reply| Request::Rebase { - image, - resolve, - reply, - })? { - Rebased::Applied { changed } => Ok(changed), - Rebased::Conflict { id, space, kind } => Err(Conflict { id, space, kind }), - }, - ) + /// Asks the section thread to replay the queue on `image`, returning the pages the + /// remote changed. + fn rebase(&self, image: Option>) -> Result> { + self.ask(|reply| Request::Rebase { image, reply }) } fn receipt(&self, id: u64) -> Result { @@ -501,49 +397,6 @@ impl Replica { Ok(()) } - /// Resolves the conflict holding edit `id`: the batch and everything queued after it - /// drop their ops on the conflicted page; `Mine` rewrites the remote page to the local - /// one first. A conflict on another page may follow. - pub fn resolve(&self, id: u64, resolution: Resolution) -> Result<()> { - self.resolve_with(id, resolution, None) - } - - pub(crate) fn resolve_with(&self, id: u64, keep: Resolution, page: Option) -> Result<()> { - let owner = self.sync_owner()?; - let (first, space) = { - let connection = self.lock()?; - let row: Option<(i64, String)> = connection - .query_row( - "SELECT (SELECT min(id) FROM edits WHERE batch=batches.id), space FROM batches - WHERE conflict IS NOT NULL AND id=(SELECT batch FROM edits WHERE id=?1)", - [signed(id)?], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - let Some((first, space)) = row else { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "The edit is not in conflict", - ) - .into()); - }; - (unsigned(first)?, space.parse()?) - }; - // A conflict on another page is recorded like any other. - let _ = self.rebase( - None, - Some(Resolve { - first, - space, - keep, - page, - }), - )?; - drop(owner); - self.wake_sync(); - Ok(()) - } - /// Retires the uncertain attempt of the batch holding edit `id` after review. The /// queue is first exported to `archive` (a new file), which is the record: no receipt /// is written. `Mine` publishes the batch again against the current remote; `Theirs` @@ -620,10 +473,7 @@ impl Replica { None if !state.queued => state.base, None => return Ok(false), }; - let Some(stamp) = remote.stamp().map_err(Error::RemoteIo)? else { - return Ok(false); - }; - Ok(stamp == expected) + Ok(remote.stamp().map_err(Error::RemoteIo)? == expected) } } @@ -651,26 +501,23 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result, Option, String)> = connection + let record: Option<(bool, Option, String)> = connection .query_row( - "SELECT batches.attempted, batches.conflict, batches.revisions, edits.edit + "SELECT batches.attempted, batches.revisions, edits.edit FROM edits JOIN batches ON batches.id=edits.batch WHERE edits.id=?1", [id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional()?; Ok(match record { None => None, - Some((true, _, revisions, edit)) => { + Some((true, revisions, edit)) => { let revisions = decode_revisions(revisions.as_deref().unwrap_or("{}"))?; let edit: onestore::op::Edit = serde_json::from_str(&edit) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; let revision = revision_of(&edit, &revisions)?; Some(EditStatus::AwaitingConfirmation { revision }) } - Some((false, Some(kind), ..)) => { - Some(EditStatus::Conflict(ConflictKind::from_stored(kind)?)) - } - Some((false, None, ..)) => Some(EditStatus::Pending), + Some((false, ..)) => Some(EditStatus::Pending), }) } diff --git a/crates/notebook/src/working.rs b/crates/notebook/src/working.rs index ec937105e69c6ba4a4d9fba33a3483b46e8ed16d..53c2f3fa7b83c31f06bc26a1baff499848736d96 100644 --- a/crates/notebook/src/working.rs +++ b/crates/notebook/src/working.rs @@ -4,12 +4,11 @@ //! and, when the remote changed, to rebase the queue. use crate::{ - ConflictKind, Resolution, Result, base, lock, merge, queue, session::Save, signed, - worker::Signal, + Result, base, lock, merge, queue, worker::Signal, }; use onestore::{ Arena, ExGuid, Section, Transaction, - op::{Edit, Op, OpError}, + op::{Edit, OpError}, page::Page, }; use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; @@ -28,15 +27,6 @@ pub(crate) enum Request { edit: Edit, reply: Reply, }, - /// A whole-page save of the old session API: `None` answers a save a later one - /// continuing it absorbed. - Save { - space: ExGuid, - before: Page, - after: Page, - author: String, - reply: Reply>, - }, Page { space: ExGuid, reply: Reply, @@ -44,6 +34,9 @@ pub(crate) enum Request { Pages { reply: Reply>, }, + Conflicts { + reply: Reply)>>, + }, /// Answers once the edits before it are written. Flush { reply: Reply<()>, @@ -53,11 +46,11 @@ pub(crate) enum Request { Seal { reply: Reply>, }, - /// Replays the queue on `image` (the stored remote image, or the base, when `None`). + /// Replays the queue on `image` (the stored remote image, or the base, when `None`); + /// answers with the pages the remote changed. Rebase { image: Option>, - resolve: Option, - reply: Reply, + reply: Reply>, }, /// Rereads the queue after the sync thread replaced it. Reopen { @@ -78,34 +71,14 @@ pub(crate) struct Sealed { pub transaction: Option, } -/// A conflict's resolution: the batch it holds and every later edit drop their ops on -/// `space`; `Mine` first rewrites the rebased page to `page`, the local one unless given. -pub(crate) struct Resolve { - pub first: u64, - pub space: ExGuid, - pub keep: Resolution, - pub page: Option, -} - -pub(crate) enum Rebased { - /// The queue now applies to the image, which is the base; these pages changed. - Applied { changed: Vec }, - /// The batch holding edit `id` no longer applies; nothing changed but the record. - Conflict { - id: u64, - space: ExGuid, - kind: ConflictKind, - }, -} - impl Request { fn fail(self, message: &str) { let error = || io::Error::other(message.to_owned()).into(); match self { Self::Apply { reply, .. } => reply(Err(error())), - Self::Save { reply, .. } => reply(Err(error())), Self::Page { reply, .. } => reply(Err(error())), Self::Pages { reply } => reply(Err(error())), + Self::Conflicts { reply } => reply(Err(error())), Self::Flush { reply } => reply(Err(error())), Self::Seal { reply } => reply(Err(error())), Self::Rebase { reply, .. } => reply(Err(error())), @@ -244,8 +217,7 @@ fn fail(error: crate::Error, backlog: VecDeque, requests: mpsc::Receive enum Job { Rebase { image: Option>, - resolve: Option, - reply: Reply, + reply: Reply>, }, Reopen { reply: Reply<()>, @@ -253,18 +225,14 @@ enum Job { } /// Runs `job`, rereads the section and takes the requests over from the thread that -/// started it; a rebase that found a conflict changed nothing that thread serves. +/// started it; a failed rebase changed nothing that thread serves. fn build(shared: Arc, job: Job, signal: mpsc::Sender) { let answer: Box = match job { Job::Reopen { reply } => Box::new(move || reply(Ok(()))), - Job::Rebase { - image, - resolve, - reply, - } => match rebase(&shared.connection, image, resolve) { - Ok(applied @ Rebased::Applied { .. }) => Box::new(move || reply(Ok(applied))), - other => { - reply(other); + Job::Rebase { image, reply } => match rebase(&shared.connection, image) { + Ok(changed) => Box::new(move || reply(Ok(changed))), + Err(error) => { + reply(Err(error)); let _ = signal.send(Request::Resume); return; } @@ -298,21 +266,7 @@ fn build(shared: Arc, job: Job, signal: mpsc::Sender) { struct Accepted { author: String, edit: Edit, - done: Done, -} - -enum Done { - Apply(Reply), - Save(Reply>), -} - -impl Done { - fn reply(self, written: Result) { - match self { - Self::Apply(reply) => reply(written), - Self::Save(reply) => reply(written.map(|id| Some(Save::Queued(id)))), - } - } + reply: Reply, } struct Working<'a> { @@ -321,8 +275,6 @@ struct Working<'a> { open: Option, /// Spaces the open batch's edits change. touched: BTreeSet, - /// The last whole-page save: its space, the page it was given, and the page as stored. - saved: Option<(ExGuid, Page, Page)>, } impl<'a> Working<'a> { @@ -332,7 +284,6 @@ impl<'a> Working<'a> { section, open, touched, - saved: None, }) } @@ -367,6 +318,9 @@ impl<'a> Working<'a> { reply(self.section.page(space).map_err(Into::into)) } Request::Pages { reply } => reply(self.section.pages().map_err(Into::into)), + Request::Conflicts { reply } => { + reply(self.section.conflicts().map_err(Into::into)) + } Request::Handover(to) => { let mut waiting = held.take().unwrap_or_default(); waiting.extend(burst.drain(..).chain(later.drain(..))); @@ -386,86 +340,9 @@ impl<'a> Working<'a> { edit, reply, } => self - .accept( - author, - edit, - Done::Apply(reply), - &mut accepted, - connection, - worker, - ) + .accept(author, edit, reply, &mut accepted, connection, worker) .err() .unwrap_or(false), - Request::Save { - space, - before, - mut after, - author, - reply, - } => { - // Saves that continue one another are written once. - let mut replies = vec![reply]; - while let Some(Request::Save { - space: next, - before: continued, - author: by, - .. - }) = burst.front() - && (*next, continued, by) == (space, &after, &author) - { - let Some(Request::Save { - after: later, - reply, - .. - }) = burst.pop_front() - else { - unreachable!() - }; - after = later; - replies.push(reply); - } - let last = replies.pop().unwrap(); - for folded in replies { - folded(Ok(None)); - } - match self.save(space, &before, &after) { - Ok(Some(edit)) => match self.accept( - author, - edit, - Done::Save(last), - &mut accepted, - connection, - worker, - ) { - Ok(()) => { - self.saved = self - .section - .page(space) - .ok() - .map(|stored| (space, after, stored)); - false - } - Err(broken) => broken, - }, - Ok(None) => { - self.saved = self - .section - .page(space) - .ok() - .map(|stored| (space, after, stored)); - last(Ok(Some(Save::Unchanged))); - false - } - Err(Stale::Stale) => { - last(Ok(Some(Save::Stale))); - false - } - Err(Stale::Error(error)) => { - last(Err(error)); - false - } - } - } Request::Page { space, reply } => { reply(self.section.page(space).map_err(Into::into)); false @@ -474,6 +351,10 @@ impl<'a> Working<'a> { reply(self.section.pages().map_err(Into::into)); false } + Request::Conflicts { reply } => { + reply(self.section.conflicts().map_err(Into::into)); + false + } request @ (Request::Flush { .. } | Request::Seal { .. }) if !burst.is_empty() => { @@ -502,50 +383,15 @@ impl<'a> Working<'a> { failed } } - Request::Rebase { - image, - mut resolve, - reply, - } => { - let written = self.flush(connection, worker, &mut accepted); - // The page a `Mine` resolution keeps is read here, before the - // rebuild; the root space lists pages and holds none. - let root = self.section.root(); - let kept = match &mut resolve { - Some(Resolve { - keep: Resolution::Mine, - page: page @ None, - space, - .. - }) if *space != root => { - self.section.page(*space).map(|local| *page = Some(local)) - } - _ => Ok(()), - }; - match (written, kept) { - (true, Ok(())) => { - held = self - .rebuild( - shared, - Job::Rebase { - image, - resolve, - reply, - }, - ) - .then(VecDeque::new); - false - } - (_, Err(error)) => { - reply(Err(error.into())); - !written - } - (false, _) => { - reply(Err( - io::Error::other("The queue could not be written").into() - )); - true - } + Request::Rebase { image, reply } => { + if self.flush(connection, worker, &mut accepted) { + held = self + .rebuild(shared, Job::Rebase { image, reply }) + .then(VecDeque::new); + false + } else { + reply(Err(io::Error::other("The queue could not be written").into())); + true } } Request::Reopen { reply } => { @@ -598,7 +444,7 @@ impl<'a> Working<'a> { &mut self, author: String, edit: Edit, - done: Done, + reply: Reply, accepted: &mut Vec, connection: &Mutex, worker: &Worker, @@ -607,7 +453,11 @@ impl<'a> Working<'a> { Ok(()) => { self.touched .extend(queue::spaces(&edit, self.section.root())); - accepted.push(Accepted { author, edit, done }); + accepted.push(Accepted { + author, + edit, + reply, + }); Ok(()) } Err(error) => { @@ -615,40 +465,12 @@ impl<'a> Working<'a> { if broken { self.flush(connection, worker, accepted); } - done.reply(Err(error.into())); + reply(Err(error.into())); Err(broken) } } } - /// The edit that takes the stored page from `before` to `after`. A save continuing - /// the previous one finds the page as that one stored it, which may read back - /// normalized. - fn save( - &mut self, - space: ExGuid, - before: &Page, - after: &Page, - ) -> std::result::Result, Stale> { - let current = self - .section - .page(space) - .map_err(|error| Stale::Error(error.into()))?; - let expected = match &self.saved { - Some((saved, given, stored)) if *saved == space && given == before => stored, - _ => before, - }; - if current != *expected { - return Err(Stale::Stale); - } - let ops = onestore::op::lower_page(¤t, after) - .map_err(|error| Stale::Error(OpError::Unsupported(error.message).into()))?; - Ok((!ops.is_empty()).then(|| Edit { - at: crate::now(), - ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), - })) - } - /// Writes the accepted edits in one transaction, then answers their senders; false /// when the write failed and the section holds edits the queue lacks. fn flush( @@ -689,15 +511,14 @@ impl<'a> Working<'a> { match written { Ok(ids) => { for (edit, id) in accepted.drain(..).zip(ids) { - edit.done.reply(Ok(id)); + (edit.reply)(Ok(id)); } crate::wake(worker); } Err(error) => { let message = error.to_string(); for edit in accepted.drain(..) { - edit.done - .reply(Err(io::Error::other(message.clone()).into())); + (edit.reply)(Err(io::Error::other(message.clone()).into())); } } } @@ -712,7 +533,7 @@ impl<'a> Working<'a> { { let connection = lock(connection)?; let waiting: bool = connection.query_row( - "SELECT EXISTS(SELECT 1 FROM batches WHERE sealed IS NOT NULL OR conflict IS NOT NULL)", + "SELECT EXISTS(SELECT 1 FROM batches WHERE sealed IS NOT NULL)", [], |row| row.get(0), )?; @@ -746,12 +567,10 @@ impl<'a> Working<'a> { } } -/// Replays every queued edit on `image`, keeping it as the base when all apply. -fn rebase( - connection: &Mutex, - image: Option>, - resolve: Option, -) -> Result { +/// Replays every queued edit on `image`, which becomes the base; each page whose local +/// version the remote could not take gains a conflict page holding it, queued as a new edit. +/// Returns the pages the remote changed. +fn rebase(connection: &Mutex, image: Option>) -> Result> { let (base_image, image, edits) = { let connection = lock(connection)?; if connection.query_row( @@ -777,117 +596,91 @@ fn rebase( (base_image, image, queue::load(&connection, None)?) }; let old_arena = Arena::default(); - let mut old = Section::open(&old_arena, base_image.clone())?; + let mut old = Section::open(&old_arena, base_image)?; let before: BTreeMap = old.revisions().collect(); - let mut after: BTreeMap; + let remote_arena = Arena::default(); + let remote = Section::open(&remote_arena, image.clone())?; + if old.root() != remote.root() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Remote snapshot belongs to another document", + ) + .into()); + } + let after: BTreeMap = remote.revisions().collect(); + // The pages as the queue leaves them, read once a page conflicts: O(section). + let local_arena = Arena::default(); + let mut local = None; // Pages the remote already holds as the local edits leave them: their ops are done. let mut converged = BTreeSet::new(); - let outcome = loop { + let (rewritten, added) = loop { let arena = Arena::default(); let mut new = Section::open(&arena, image.clone())?; - if old.root() != new.root() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Remote snapshot belongs to another document", - ) - .into()); + let merged = merge::rebase(&mut old, &mut new, &edits, &converged)?; + if !merged.conflicts.is_empty() && local.is_none() { + local = Some(replay(&local_arena, &*lock(connection)?)?.0); } - after = new.revisions().collect(); - let outcome = merge::rebase( - &mut old, - &mut new, - &edits, - &converged, - resolve.as_ref().map(|resolve| merge::Resolving { - first: resolve.first, - space: resolve.space, - mine: resolve.keep == Resolution::Mine, - target: resolve.page.as_ref(), - }), - )?; - if let merge::Outcome::Conflict { space, .. } = &outcome - && !converged.contains(space) - && let Some(local) = local_page(connection, *space)? - && Section::open(&Arena::default(), image.clone())? - .page(*space) - .is_ok_and(|remote| remote == local) - { - converged.insert(*space); + let pages: BTreeMap = merged + .conflicts + .keys() + .filter_map(|space| Some((*space, local.as_ref()?.page(*space).ok()?))) + .collect(); + let settled: Vec = pages + .iter() + .filter(|(space, page)| remote.page(**space).is_ok_and(|remote| remote == **page)) + .map(|(space, _)| *space) + .collect(); + if !settled.is_empty() { + converged.extend(settled); continue; } - break outcome; + let mut added = Vec::new(); + for (space, (author, objects)) in &merged.conflicts { + // A page the queue went on to delete keeps no version of its own. + let (Some(page), Some(local)) = (pages.get(space), local.as_mut()) else { + continue; + }; + let edit = merge::conflict_page( + &mut new, + &mut old, + local, + *space, + page, + author, + objects, + crate::now(), + )?; + added.push((author.clone(), edit)); + } + break (merged.rewritten, added); }; let mut connection = lock(connection)?; let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; - let rebased = match outcome { - merge::Outcome::Conflict { id, space, kind } => { - transaction.execute("UPDATE batches SET conflict=NULL, space=NULL", [])?; - transaction.execute( - "UPDATE batches SET conflict=?1, space=?2 WHERE id=(SELECT batch FROM edits WHERE id=?3)", - params![kind as i64, space.to_string(), signed(id)?], - )?; - if image == base_image { - base::clear(&transaction, base::Image::Remote)?; - } else { - base::write(&transaction, base::Image::Remote, &image)?; - } - // A conflict is reported under its batch's newest edit. - let id = crate::unsigned(transaction.query_row( - "SELECT max(id) FROM edits WHERE batch=(SELECT batch FROM edits WHERE id=?1)", - [signed(id)?], - |row| row.get(0), - )?)?; - Rebased::Conflict { id, space, kind } - } - merge::Outcome::Applied(rewritten) => { - base::write(&transaction, base::Image::Base, &image)?; - base::clear(&transaction, base::Image::Remote)?; - transaction.execute("INSERT INTO batches DEFAULT VALUES", [])?; - let batch = transaction.last_insert_rowid(); - transaction.execute("UPDATE edits SET batch=?1", [batch])?; - transaction.execute("DELETE FROM batches WHERE id<>?1", [batch])?; - for (id, edit) in rewritten { - queue::rewrite(&transaction, id, &edit)?; - } - if transaction - .query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))? - == 0 - { - transaction.execute("DELETE FROM batches", [])?; - } - queue::collect(&transaction)?; - let mut changed: BTreeSet = before - .iter() - .filter(|(space, rid)| after.get(space) != Some(rid)) - .map(|(space, _)| *space) - .chain( - after - .keys() - .filter(|space| !before.contains_key(space)) - .copied(), - ) - .collect(); - changed.extend(resolve.as_ref().map(|resolve| resolve.space)); - changed.extend(converged); - Rebased::Applied { - changed: changed.into_iter().collect(), - } - } - }; + base::write(&transaction, base::Image::Base, &image)?; + base::clear(&transaction, base::Image::Remote)?; + transaction.execute("INSERT INTO batches DEFAULT VALUES", [])?; + let batch = transaction.last_insert_rowid(); + transaction.execute("UPDATE edits SET batch=?1", [batch])?; + transaction.execute("DELETE FROM batches WHERE id<>?1", [batch])?; + for (id, edit) in rewritten { + queue::rewrite(&transaction, id, &edit)?; + } + for (author, edit) in &added { + queue::insert(&transaction, None, batch, author, edit)?; + } + if transaction.query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))? == 0 { + transaction.execute("DELETE FROM batches", [])?; + } + queue::collect(&transaction)?; transaction.commit()?; - Ok(rebased) -} - -/// A page as the queued edits leave it, read from the cache: O(section). -fn local_page(connection: &Mutex, space: ExGuid) -> Result> { - let arena = Arena::default(); - let (section, ..) = replay(&arena, &*lock(connection)?)?; - Ok(section.page(space).ok()) -} - -enum Stale { - Stale, - Error(crate::Error), + let mut changed: BTreeSet = before + .iter() + .filter(|(space, rid)| after.get(space) != Some(rid)) + .map(|(space, _)| *space) + .chain(after.keys().filter(|space| !before.contains_key(space)).copied()) + .collect(); + changed.extend(converged); + Ok(changed.into_iter().collect()) } /// Batches in order with their sealed transactions: `None` while open, `Some(None)` when diff --git a/crates/notebook/tests/assets.rs b/crates/notebook/tests/assets.rs index 16d1f0826eae82e50aa8ab2a995941efe14ed2ee..17a308be3320d6f122ccb1c81dcda68b29b4254f 100644 --- a/crates/notebook/tests/assets.rs +++ b/crates/notebook/tests/assets.rs @@ -15,6 +15,7 @@ use std::{ mod model_ops; #[path = "support/server.rs"] mod server; +use server::{remote_snapshot, snapshot}; const FIXTURE: &str = "../../corpus/native-external-assets/notebook"; @@ -86,8 +87,8 @@ impl notebook::discover::Source for Payload { fn downloaded_media_survives_reopen_and_recovery_with_the_queue_intact() { let directory = tempfile::tempdir().unwrap(); let cache = queued_cache(directory.path()); - let working = cache.snapshot().unwrap(); - let remote = cache.remote_snapshot().unwrap(); + let working = snapshot(&cache); + let remote = remote_snapshot(&cache); let pending = cache.pending().unwrap(); assert_eq!( pending.iter().map(|edit| edit.id).collect::>(), @@ -118,10 +119,10 @@ fn downloaded_media_survives_reopen_and_recovery_with_the_queue_intact() { (2, 1024) ); assert_eq!( - server::pages(&cache.snapshot().unwrap()), + server::pages(&snapshot(&cache)), server::pages(&working) ); - assert_eq!(cache.remote_snapshot().unwrap(), remote); + assert_eq!(remote_snapshot(&cache), remote); assert_eq!(cache.pending().unwrap(), pending); assert_eq!(cache.status(1).unwrap(), uncertain); cache @@ -227,7 +228,7 @@ fn download_network_wait_does_not_block_local_edits() { .unwrap() }); waiting.recv_timeout(Duration::from_secs(5)).unwrap(); - let text = first_text(&cache.snapshot().unwrap()); + let text = first_text(&snapshot(&cache)); let id = model_ops::save(&cache, text, |page| { model_ops::replace_text(page, text, 0..0, "during download ") }) @@ -272,12 +273,18 @@ fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() { struct Native; impl notebook::Remote for Native { fn read(&mut self) -> io::Result> { - fs::read("../../corpus/native-external-assets/native/synthetic.one") + let mut image = fs::read("../../corpus/native-external-assets/native/synthetic.one")?; + // The fixture keeps the old header; a native commit writes a new file version. + image[212] ^= 1; + Ok(image) } fn publish(&mut self, _: &onestore::Transaction) -> Result<(), onestore::CommitError> { panic!("Unexpected publication") } - fn confirm(&mut self, _: &[u8]) -> Result<(), onestore::CommitError> { + fn stamp(&mut self) -> io::Result { + onestore::Stamp::of(&self.read()?).map_err(io::Error::other) + } + fn confirm(&mut self, _: &onestore::Stamp) -> Result<(), onestore::CommitError> { panic!("Unexpected confirmation") } } @@ -301,8 +308,8 @@ fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() { ); assert!(cache.cached_asset(&name, 1024).unwrap().is_none()); assert_eq!( - cache.snapshot().unwrap(), - fs::read("../../corpus/native-external-assets/native/synthetic.one").unwrap() + snapshot(&cache), + notebook::Remote::read(&mut Native).unwrap() ); } diff --git a/crates/notebook/tests/cache.rs b/crates/notebook/tests/cache.rs index 54bdc5d9b4e90da0e5462e9d7694f9ed729b0012..d06e3fc9eaad9cff72a0d930c38326f8f12e04c6 100644 --- a/crates/notebook/tests/cache.rs +++ b/crates/notebook/tests/cache.rs @@ -1,3 +1,5 @@ +#[path = "../../onestore/tests/support/ops.rs"] +mod ops; use notebook::{Error, Replica}; use onestore::{ ExGuid, RevisionIndex, Store, @@ -17,6 +19,7 @@ use std::{ mod model_ops; #[path = "support/server.rs"] mod server; +use server::snapshot; fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { let store = Store::parse(source).unwrap(); @@ -60,7 +63,7 @@ fn cache_reopen_preserves_the_base_and_queued_edits() { let path = dir.path().join("section.sqlite"); let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap(); let replica = Replica::create(&path, &source).unwrap(); - assert_eq!(replica.snapshot().unwrap(), source); + assert_eq!(snapshot(&replica), source); assert!(replica.pending().unwrap().is_empty()); let (sid, oid, _) = target(&source); let unchanged = |page: &mut onestore::page::Page| { @@ -73,7 +76,7 @@ fn cache_reopen_preserves_the_base_and_queued_edits() { }) .unwrap() .unwrap(); - assert_eq!(target(&replica.snapshot().unwrap()).2, "café 🐈 日本語"); + assert_eq!(target(&snapshot(&replica)).2, "café 🐈 日本語"); let edits = replica.pending().unwrap(); assert_eq!(edits.len(), 1); assert_eq!( @@ -99,14 +102,14 @@ fn cache_reopen_preserves_the_base_and_queued_edits() { "closing checkpoints and removes the log" ); let replica = Replica::open(&path).unwrap(); - assert_eq!(target(&replica.snapshot().unwrap()).2, "café 🐈 日本語"); + assert_eq!(target(&snapshot(&replica)).2, "café 🐈 日本語"); assert_eq!(replica.pending().unwrap(), edits); let second = replica .apply(model_ops::AUTHOR, typed(sid, oid, "Recovered ")) .unwrap(); assert!(second > first); assert_eq!( - target(&replica.snapshot().unwrap()).2, + target(&snapshot(&replica)).2, "Recovered café 🐈 日本語" ); assert_eq!(replica.pending().unwrap().len(), 2); @@ -160,7 +163,7 @@ fn refused_edits_and_failed_writes_leave_the_queue_as_it_was() { replica.apply("Author", typed(ExGuid::default(), oid, "Elsewhere ")), Err(Error::Rejected(_)) )); - assert_eq!(replica.snapshot().unwrap(), source); + assert_eq!(snapshot(&replica), source); assert!(replica.pending().unwrap().is_empty()); drop(replica); let connection = rusqlite::Connection::open(&path).unwrap(); @@ -168,10 +171,10 @@ fn refused_edits_and_failed_writes_leave_the_queue_as_it_was() { drop(connection); let replica = Replica::open(&path).unwrap(); assert!(replica.apply("Author", typed(sid, oid, "lost? ")).is_err()); - assert_eq!(replica.snapshot().unwrap(), source); + assert_eq!(snapshot(&replica), source); drop(replica); let replica = Replica::open(&path).unwrap(); - assert_eq!(replica.snapshot().unwrap(), source); + assert_eq!(snapshot(&replica), source); assert!(replica.pending().unwrap().is_empty()); } @@ -230,7 +233,7 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { } }); assert_eq!(replica.pending().unwrap().len(), 60); - let content = target(&replica.snapshot().unwrap()).2; + let content = target(&snapshot(&replica)).2; for writer in 0..3 { for edit in 0..20 { assert_eq!(content.matches(&format!("[{writer}:{edit}] ")).count(), 1); @@ -255,7 +258,7 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() { assert!( matches!(Replica::create(&path, &source), Err(Error::Io(error)) if error.kind() == ErrorKind::AlreadyExists) ); - assert_eq!(replica.snapshot().unwrap(), source); + assert_eq!(snapshot(&replica), source); } drop(replica); let current: u32 = rusqlite::Connection::open(&path) @@ -264,10 +267,10 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() { .unwrap(); for sql in [ "PRAGMA application_id=0".to_owned(), - // Schema 14 converts; older caches do not. + // Schemas 14 and 15 convert; older caches do not. format!( "PRAGMA application_id=1330529615; PRAGMA user_version={}", - current - 2 + current - 3 ), format!("PRAGMA user_version={}", current + 1), ] { @@ -348,7 +351,7 @@ fn twelve_local_editors_queue_every_edit_once_in_order() { } let ids: BTreeSet<_> = outcomes.into_iter().flatten().collect(); assert_eq!(ids.len(), 240); - let content = target(&replica.snapshot().unwrap()).2; + let content = target(&snapshot(&replica)).2; assert!(content.ends_with("Shared café 🦀")); for writer in 0..12 { for edit in 0..20 { @@ -362,7 +365,7 @@ fn twelve_local_editors_queue_every_edit_once_in_order() { ); drop(replica); let reopened = Replica::open(&path).unwrap(); - assert_eq!(target(&reopened.snapshot().unwrap()).2, content); + assert_eq!(target(&snapshot(&reopened)).2, content); assert_eq!(reopened.pending().unwrap(), pending); } @@ -410,7 +413,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { } text = expected; assert_eq!( - target(&replica.snapshot().unwrap()).2, + target(&snapshot(&replica)).2, text, "seed 911, step {step}" ); @@ -418,7 +421,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { let pending = replica.pending().unwrap(); drop(replica); replica = Replica::open(&path).unwrap(); - assert_eq!(target(&replica.snapshot().unwrap()).2, text); + assert_eq!(target(&snapshot(&replica)).2, text); assert_eq!(replica.pending().unwrap(), pending); } } @@ -508,13 +511,10 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { .flat_map(|(ids, _)| ids.iter().copied()) .collect(); assert_eq!(ids.len(), 84); - let snapshot = cache.snapshot().unwrap(); + let image = snapshot(&cache); drop(cache); let cache = Replica::open(&path).unwrap(); - assert_eq!( - server::pages(&cache.snapshot().unwrap()), - server::pages(&snapshot) - ); + assert_eq!(server::pages(&snapshot(&cache)), server::pages(&image)); assert_eq!( cache .pending() @@ -524,7 +524,7 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { .collect::>(), ids ); - let store = Store::parse(&snapshot).unwrap(); + let store = Store::parse(&image).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let doc = Document::parse(&index).unwrap(); let s = &doc.spaces[&sid]; @@ -559,7 +559,7 @@ fn unrecognized_persisted_ops_are_rejected_without_dropping_fields() { let path = directory.path().join("unknown.sqlite"); let first = onestore::create_section("unknown.one", "Original", "Author").unwrap(); let second = onestore::PageCreation::new(None, Some("Second"), "Author").unwrap(); - let source = onestore::PreparedEdit::create_page(&first, &second) + let source = ops::section_op(&first, SectionOp::Create(second.clone())) .unwrap() .as_bytes() .to_vec(); diff --git a/crates/notebook/tests/changes.rs b/crates/notebook/tests/changes.rs index 1131d39958c722393ffcedb30c3b651d8d264c9b..4d86c90b6653093506a1e59c47208b0bc498f8c3 100644 --- a/crates/notebook/tests/changes.rs +++ b/crates/notebook/tests/changes.rs @@ -24,9 +24,9 @@ fn external_structure_changes_are_reported_by_identity() { let mut other = Notebook::open(&root, temporary.path().join("other")).unwrap(); assert_eq!(watcher.refresh().unwrap(), []); - other.create_section("", "Second", "Author").unwrap(); + other.create_section("", "Second", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()).unwrap(); other.create_group("", "Group").unwrap(); - other.create_section("Group", "Inner", "Author").unwrap(); + other.create_section("Group", "Inner", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()).unwrap(); assert_eq!( watcher.refresh().unwrap(), [ diff --git a/crates/notebook/tests/copy.rs b/crates/notebook/tests/copy.rs index 09d26a61193b5a1c9cf9e8a2e587798103c8dccf..8961a6f3a073da4a3838b5404fcd6f07d6dde706 100644 --- a/crates/notebook/tests/copy.rs +++ b/crates/notebook/tests/copy.rs @@ -1,7 +1,4 @@ -use notebook::{ - EditStatus, - session::{Event, Section}, -}; +use notebook::session::{Event, Section}; use onestore::{ RevisionIndex, Store, document::Document, @@ -197,13 +194,7 @@ fn pages_copy_into_another_section_with_their_content_and_fresh_identities() { ] ); let deadline = Instant::now() + Duration::from_secs(120); - while section - .queue() - .unwrap() - .iter() - .any(|edit| !matches!(edit.status, EditStatus::Published { .. })) - || !section.queue().unwrap().is_empty() - { + while !section.pending().unwrap().is_empty() { for event in section.events() { if let Event::Failed(message) = event { panic!("{message}"); @@ -212,7 +203,7 @@ fn pages_copy_into_another_section_with_their_content_and_fresh_identities() { assert!( Instant::now() < deadline, "publication did not finish: {:?}", - section.queue().unwrap() + section.pending().unwrap() ); std::thread::sleep(Duration::from_millis(50)); } @@ -233,7 +224,7 @@ fn pages_copy_into_another_section_with_their_content_and_fresh_identities() { } section.delete_pages(&[expected.last().unwrap().0]).unwrap(); let deadline = Instant::now() + Duration::from_secs(60); - while !section.queue().unwrap().is_empty() { + while !section.pending().unwrap().is_empty() { assert!(Instant::now() < deadline); std::thread::sleep(Duration::from_millis(50)); } diff --git a/crates/notebook/tests/migrate.rs b/crates/notebook/tests/migrate.rs index 4f929c388c58e8b9c04aa9c3246fbb1b1a762697..83af0972f9e8d3fa1b5cd6ca70b2dbc7dec78064 100644 --- a/crates/notebook/tests/migrate.rs +++ b/crates/notebook/tests/migrate.rs @@ -1,8 +1,12 @@ //! Converting schema-14 caches: whole-page edits become ops, verified page by page against -//! the old working image, with a recovery archive first and nothing changed on a mismatch. +//! the old working image, with a recovery archive first and nothing changed on a mismatch; +//! and schema-15 caches, whose batches lose the review conflict. -use notebook::{ConflictKind, EditStatus, Replica, Resolution}; -use onestore::{ExGuid, PageCreation, PageEdit, PreparedEdit, page::Page}; +#[path = "../../onestore/tests/support/ops.rs"] +mod ops; + +use notebook::{EditStatus, Replica}; +use onestore::{ExGuid, PageCreation, PageEdit, op::SectionOp, page::Page}; use rusqlite::{Connection, params}; use serde_json::json; use std::path::Path; @@ -48,17 +52,17 @@ fn v14(path: &Path, base: &[u8], queue: &[Queued], working: Option<&[u8]>) -> Ve let (space, operation, next) = match entry { Queued::Page(space, after) => { let before = model_ops::page_of(&image, *space); - let next = PreparedEdit::page(&image, *space, after, "Author").unwrap(); + let next = ops::saved(&image, *space, after).unwrap(); ( *space, json!({"Page": {"before": before, "after": after, "author": "Author"}}), - next.as_bytes().to_vec(), + next.as_slice().to_vec(), ) } Queued::Create(creation) => ( creation.space(), json!({"CreatePage": creation}), - PreparedEdit::create_page(&image, creation) + ops::section_op(&image, SectionOp::Create(creation.clone())) .unwrap() .as_bytes() .to_vec(), @@ -71,7 +75,7 @@ fn v14(path: &Path, base: &[u8], queue: &[Queued], working: Option<&[u8]>) -> Ve ( root(&image), json!({"Pages": {"edits": batch, "observed": observed}}), - PreparedEdit::pages(&image, batch) + ops::section_op(&image, SectionOp::Pages(batch.to_vec())) .unwrap() .as_bytes() .to_vec(), @@ -80,7 +84,7 @@ fn v14(path: &Path, base: &[u8], queue: &[Queued], working: Option<&[u8]>) -> Ve Queued::Delete(spaces) => ( root(&image), json!({"DeletePages": spaces}), - PreparedEdit::delete_pages_permanently(&image, spaces) + ops::section_op(&image, SectionOp::Delete(spaces.to_vec())) .unwrap() .as_bytes() .to_vec(), @@ -187,9 +191,9 @@ fn a_schema_14_queue_converts_to_ops_that_reach_its_working_pages_and_publish() Queued::Page( a, appended( - PreparedEdit::page(SOURCE, a, &first, "Author") + ops::saved(SOURCE, a, &first) .unwrap() - .as_bytes(), + .as_slice(), a, a_text, " two", @@ -216,12 +220,12 @@ fn a_schema_14_queue_converts_to_ops_that_reach_its_working_pages_and_publish() } }) ); - let local = pages(&cache.snapshot().unwrap()); + let local = pages(&snapshot(&cache)); assert_eq!(local, pages(&working)); drop(cache); // A converted cache opens as it is. let cache = Replica::open(&path).unwrap(); - assert_eq!(pages(&cache.snapshot().unwrap()), local); + assert_eq!(pages(&snapshot(&cache)), local); let next = cache .apply( "Author", @@ -245,7 +249,7 @@ fn a_schema_14_queue_converts_to_ops_that_reach_its_working_pages_and_publish() Some((id, EditStatus::Published { .. })) if id == next )); let published = pages(&server.durable); - assert_eq!(published, pages(&cache.snapshot().unwrap())); + assert_eq!(published, pages(&snapshot(&cache))); assert!( published .iter() @@ -255,7 +259,7 @@ fn a_schema_14_queue_converts_to_ops_that_reach_its_working_pages_and_publish() } #[test] -fn an_uncertain_attempt_and_a_conflict_keep_their_states() { +fn an_uncertain_attempt_keeps_its_state_and_a_conflict_becomes_a_conflict_page() { let body = body_pages(SOURCE); let (a, a_text) = body[0]; let directory = tempfile::tempdir().unwrap(); @@ -263,9 +267,9 @@ fn an_uncertain_attempt_and_a_conflict_keep_their_states() { // The oldest edit was attempted: its schema-14 evidence names the revision it published. let path = directory.path().join("attempted.sqlite"); let after = appended(SOURCE, a, a_text, " attempted"); - let published = PreparedEdit::page(SOURCE, a, &after, "Author").unwrap(); + let published = ops::saved(SOURCE, a, &after).unwrap(); let revision = - onestore::RevisionIndex::parse(&onestore::Store::parse(published.as_bytes()).unwrap()) + onestore::RevisionIndex::parse(&onestore::Store::parse(published.as_slice()).unwrap()) .unwrap() .active(a) .unwrap(); @@ -286,7 +290,7 @@ fn an_uncertain_attempt_and_a_conflict_keep_their_states() { Some((2, awaiting)) ); assert_eq!(unchanged.publications, 0); - let mut landed = Server::new(published.as_bytes()); + let mut landed = Server::new(published.as_slice()); assert_eq!( cache.sync_once(&mut landed).unwrap().edit, Some((2, EditStatus::Published { revision })) @@ -294,17 +298,17 @@ fn an_uncertain_attempt_and_a_conflict_keep_their_states() { assert_eq!(landed.publications, 0); drop(cache); - // The oldest edit was in conflict: the conversion lowered it onto the remote page the - // conflict was recorded against, and keeping mine publishes it. + // The oldest edit was in conflict: the conversion keeps the remote page the conflict was + // recorded against and queues the local version as its conflict page. let path = directory.path().join("conflicted.sqlite"); - let remote = PreparedEdit::page(SOURCE, a, &appended(SOURCE, a, a_text, " remote"), "Native") + let remote = ops::saved(SOURCE, a, &appended(SOURCE, a, a_text, " remote")) .unwrap() - .as_bytes() + .as_slice() .to_vec(); let local = appended(SOURCE, a, a_text, " local"); - let working = PreparedEdit::page(SOURCE, a, &local, "Author") + let working = ops::saved(SOURCE, a, &local) .unwrap() - .as_bytes() + .as_slice() .to_vec(); { // Schema 14 stored the remote as the base and kept the local working image. @@ -324,27 +328,26 @@ fn an_uncertain_attempt_and_a_conflict_keep_their_states() { .unwrap(); } let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.status(2).unwrap(), Some(EditStatus::Pending)); assert_eq!( - cache.status(2).unwrap(), - Some(EditStatus::Conflict(ConflictKind::ContentChanged)) - ); - assert_eq!( - model_ops::page_of(&cache.snapshot().unwrap(), a), - model_ops::page_of(&working, a) + model_ops::page_of(&snapshot(&cache), a), + model_ops::page_of(&remote, a) ); let mut server = Server::new(&remote); - assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((2, EditStatus::Conflict(ConflictKind::ContentChanged))) - ); - cache.resolve(2, Resolution::Mine).unwrap(); assert!(matches!( cache.sync_once(&mut server).unwrap().edit, Some((2, EditStatus::Published { .. })) )); assert_eq!( model_ops::page_of(&server.durable, a), - model_ops::page_of(&working, a) + model_ops::page_of(&remote, a) + ); + assert_eq!( + conflicts(&server.durable), + [( + a, + vec![("Author".to_owned(), page_texts(&model_ops::page_of(&working, a)))] + )] ); } @@ -355,9 +358,9 @@ fn a_page_the_conversion_cannot_reproduce_leaves_the_cache_untouched() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("cache.sqlite"); // The working image holds a page the queued edit does not reach. - let stray = PreparedEdit::page(SOURCE, a, &appended(SOURCE, a, a_text, " stray"), "Author") + let stray = ops::saved(SOURCE, a, &appended(SOURCE, a, a_text, " stray")) .unwrap() - .as_bytes() + .as_slice() .to_vec(); v14( &path, @@ -382,3 +385,75 @@ fn a_page_the_conversion_cannot_reproduce_leaves_the_cache_untouched() { assert!(Replica::open(&path).is_err()); assert_eq!(std::fs::read(&path).unwrap(), before); } + +/// A schema-15 cache upgrades in place: its queue, receipts and recovery archives stay, and +/// a conflict it recorded is queued work the next sync keeps as a conflict page. +#[test] +fn a_schema_15_cache_upgrades_in_place_and_its_conflict_becomes_a_conflict_page() { + let (space, text) = body_pages(SOURCE)[0]; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, SOURCE).unwrap(); + let id = model_ops::save(&cache, text, |page| { + model_ops::replace_text(page, text, 0..0, "Local ") + }) + .unwrap() + .unwrap(); + let pending = cache.pending().unwrap(); + let archive = directory.path().join("before.sqlite"); + drop(cache); + { + // Schema 15 recorded the review conflict and its page on the batch. + let connection = Connection::open(&path).unwrap(); + connection + .execute_batch(&format!( + "PRAGMA foreign_keys=OFF; + BEGIN; + CREATE TABLE old ( + id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), + sealed TEXT, + revisions TEXT, + attempted INTEGER NOT NULL DEFAULT 0 CHECK(attempted IN (0,1)), + conflict INTEGER CHECK(conflict BETWEEN 0 AND 3), + space TEXT, + CHECK((sealed IS NULL) = (revisions IS NULL)), + CHECK(attempted=0 OR sealed IS NOT NULL), + CHECK((conflict IS NULL) = (space IS NULL)) + ) STRICT; + INSERT INTO old SELECT id, sealed, revisions, attempted, 3, '{space}' FROM batches; + DROP TABLE batches; + ALTER TABLE old RENAME TO batches; + PRAGMA user_version=15; + COMMIT;" + )) + .unwrap(); + } + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.pending().unwrap(), pending); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + cache.export_recovery(&archive).unwrap(); + drop(cache); + let connection = Connection::open(&path).unwrap(); + let version: u32 = connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + let columns: Vec = connection + .prepare("SELECT name FROM pragma_table_info('batches')") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(version, 16); + assert_eq!(columns, ["id", "sealed", "revisions", "attempted"]); + drop(connection); + assert_eq!(notebook::Recovery::open(&archive).unwrap().pending().unwrap(), pending); + let cache = Replica::open(&path).unwrap(); + let remote = typed(SOURCE, space, text, 0..0, "Remote "); + let mut server = Server::new(&remote); + assert!(matches!( + cache.sync_once(&mut server).unwrap().edit, + Some((_, EditStatus::Published { .. })) + )); + assert!(conflicted(&server.durable, space)); +} diff --git a/crates/notebook/tests/protected.rs b/crates/notebook/tests/protected.rs index fc929e2d4425a6f7d974fe03537a03b2f3ba4757..926222b59e1fe7bece32bb17de561b5a64cbd82a 100644 --- a/crates/notebook/tests/protected.rs +++ b/crates/notebook/tests/protected.rs @@ -28,8 +28,8 @@ fn a_locked_section_unlocks_for_reading() { )); } -/// An unlocked page is edited and saved under the section's key, then reads back with the -/// same password. `ONESTORE_PROTECTED_EXPORT` names a directory receiving the notebook for +/// An unlocked page is edited as ops stored under the section's key, then reads back with +/// the same password. `ONESTORE_PROTECTED_EXPORT` names a directory receiving the notebook for /// a cold reopen in OneNote. #[test] fn an_unlocked_page_is_saved_under_the_section_key() { @@ -59,6 +59,7 @@ fn an_unlocked_page_is_saved_under_the_section_key() { .clone(); let mut unlocked = notebook.unlock(§ion, password).unwrap(); let (space, mut page) = unlocked.pages[0].clone(); + let before = page.clone(); let paragraphs = page .objects .iter_mut() @@ -95,20 +96,28 @@ fn an_unlocked_page_is_saved_under_the_section_key() { format, )) .unwrap(); + let edit = onestore::op::Edit { + at: 134_000_000_000_000_000, + ops: onestore::op::lower_page(&before, &page) + .unwrap() + .into_iter() + .map(|op| onestore::op::Op::Page { space, op }) + .collect(), + }; assert!(matches!( - notebook.save_unlocked(§ion, "wrong", &mut unlocked, space, &page, "Rust"), + notebook.apply_unlocked(§ion, "wrong", &mut unlocked, "Rust", &edit), Err(notebook::Error::Protected( onestore::protected::Error::PasswordMismatch )) )); let mut stale = notebook.unlock(§ion, password).unwrap(); notebook - .save_unlocked(§ion, password, &mut unlocked, space, &page, "Rust") + .apply_unlocked(§ion, password, &mut unlocked, "Rust", &edit) .unwrap(); - // A view read before that save no longer matches the file. + // A view read before that edit no longer matches the file. assert!( notebook - .save_unlocked(§ion, password, &mut stale, space, &page, "Rust") + .apply_unlocked(§ion, password, &mut stale, "Rust", &edit) .is_err() ); let (_, stored) = notebook.unlock(§ion, password).unwrap().pages.remove(0); diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index 1f3800abc52273f37826eb2687a9ee06b76c0fdb..a58a6215634697375500e5554820c527fe3b2c61 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -1,8 +1,14 @@ +#[path = "../../onestore/tests/support/ops.rs"] +mod ops; use notebook::{ - ConflictKind, EditStatus, Recovery, Resolution, - session::{Event, Notebook, QueuedEdit, Save, Section}, + EditStatus, Recovery, Resolution, + session::{Event, Notebook, Section}, +}; +use onestore::{ + ExGuid, + op::{Edit, Op, PageOp}, + page::Page, }; -use onestore::{ExGuid, PreparedEdit, page::Page}; use std::{ path::Path, sync::{ @@ -35,6 +41,21 @@ fn edited(page: &Page, text: &str) -> Page { after } +/// Replaces `range` of the page's first body text as the editor does: the edit's id once +/// it is durable. +fn typed(section: &Section, space: ExGuid, page: &Page, range: std::ops::Range, text: &str) -> u64 { + let op = PageOp::Text { + text: first_text(page), + range, + with: text.into(), + }; + let edit = Edit { + at: model_ops::now(), + ops: vec![Op::Page { space, op }], + }; + section.replica().apply("Editor", edit).unwrap() +} + fn open(file: &Path, cache: &Path) -> (Section, Arc) { let notified = Arc::new(AtomicUsize::new(0)); let counter = Arc::clone(¬ified); @@ -105,14 +126,8 @@ fn a_section_opens_through_its_replica_and_a_save_reaches_the_file_and_survives_ assert_eq!(pages.len(), 1); let space = pages[0].0; let before = section.page(space).unwrap(); - assert_eq!( - section.save(space, &before, &before, "Editor").unwrap(), - Save::Unchanged - ); let after = edited(&before, "Saved "); - let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else { - panic!() - }; + let id = typed(§ion, space, &before, 0..0, "Saved "); assert_same(section.page(space).unwrap(), &after); published(§ion, id); // The worker reports the step after writing its receipt. @@ -167,9 +182,7 @@ fn saves_wait_for_an_unreachable_file_and_publish_after_relaunch() { let permissions = std::fs::metadata(&file).unwrap().permissions(); std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o444)).unwrap(); let after = edited(&before, "Offline "); - let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else { - panic!() - }; + let id = typed(§ion, space, &before, 0..0, "Offline "); wait(§ion, |event| matches!(event, Event::Unreachable(_))); section.close().unwrap(); assert_eq!( @@ -198,7 +211,7 @@ fn saves_wait_for_an_unreachable_file_and_publish_after_relaunch() { } #[test] -fn an_external_change_refreshes_the_page_and_stales_a_save_from_the_old_model() { +fn an_external_change_reloads_the_page_and_later_edits_apply_to_it() { let directory = tempfile::tempdir().unwrap(); let file = directory.path().join("notes.one"); let cache = directory.path().join("cache"); @@ -214,7 +227,7 @@ fn an_external_change_refreshes_the_page_and_stales_a_save_from_the_old_model() let deadline = Instant::now() + Duration::from_secs(20); loop { let bytes = onestore::read_file(&file).unwrap(); - match PreparedEdit::page(&bytes, space, &native, "Native") + match ops::save(&bytes, space, &native) .unwrap() .commit_file(&file) { @@ -227,29 +240,18 @@ fn an_external_change_refreshes_the_page_and_stales_a_save_from_the_old_model() } } section.wake(); - let deadline = Instant::now() + Duration::from_secs(20); - while section.page(space).unwrap().title == before.title { - assert!(Instant::now() < deadline, "the refresh did not arrive"); - wait(§ion, |event| matches!(event, Event::Refreshed)); - } + wait(§ion, |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space))); assert_same(section.page(space).unwrap(), &native); - let native = section.page(space).unwrap(); - let stale = edited(&before, "Local "); - assert_eq!( - section.save(space, &before, &stale, "Editor").unwrap(), - Save::Stale - ); + // An edit made on the page shown before the change applies to the page as it is now. + let id = typed(§ion, space, &before, 0..0, "Local "); let after = edited(&native, "Local "); - let Save::Queued(id) = section.save(space, &native, &after, "Editor").unwrap() else { - panic!() - }; published(§ion, id); assert_same(stored_page(&file, space), &after); section.close().unwrap(); } #[test] -fn queued_saves_continue_each_other_detect_stale_pages_and_finish_before_close() { +fn edits_apply_in_order_without_waiting_and_finish_before_close() { let directory = tempfile::tempdir().unwrap(); let file = directory.path().join("notes.one"); let cache = directory.path().join("cache"); @@ -260,36 +262,44 @@ fn queued_saves_continue_each_other_detect_stale_pages_and_finish_before_close() .unwrap(); let (section, _) = open(&file, &cache); let space = section.pages().unwrap()[0].0; - let mut before = section.page(space).unwrap(); + let original = section.page(space).unwrap(); + let text = first_text(&original); + let apply = |word: &str| { + let op = PageOp::Text { + text, + range: 0..0, + with: word.into(), + }; + let edit = Edit { + at: model_ops::now(), + ops: vec![Op::Page { space, op }], + }; + section.apply("Editor", edit).unwrap(); + }; + let mut expected = original.clone(); for word in ["one ", "two ", "three ", "four ", "five "] { - let after = edited(&before, word); - section - .queue_save(space, before, after.clone(), "Editor") - .unwrap(); - before = after; + apply(word); + expected = edited(&expected, word); } let deadline = Instant::now() + Duration::from_secs(20); loop { let mut stored = section.page(space).unwrap(); - stored.title = before.title.clone(); - if stored == before { + stored.title = expected.title.clone(); + if stored == expected { break; } - assert!(Instant::now() < deadline, "the saves did not arrive"); - for (_, save) in section.saved() { - assert!(matches!(save, Ok(Save::Queued(_))), "{save:?}"); - } + assert!(Instant::now() < deadline, "the edits did not arrive"); std::thread::sleep(Duration::from_millis(20)); } while !section.pending().unwrap().is_empty() { - assert!(Instant::now() < deadline, "the saves were not published"); + assert!(Instant::now() < deadline, "the edits were not published"); std::thread::sleep(Duration::from_millis(20)); } let native = edited(§ion.page(space).unwrap(), "Native "); let deadline = Instant::now() + Duration::from_secs(20); loop { let bytes = onestore::read_file(&file).unwrap(); - match PreparedEdit::page(&bytes, space, &native, "Native") + match ops::save(&bytes, space, &native) .unwrap() .commit_file(&file) { @@ -302,29 +312,11 @@ fn queued_saves_continue_each_other_detect_stale_pages_and_finish_before_close() } } section.wake(); - while !section.page(space).unwrap().objects.eq(&native.objects) { - assert!(Instant::now() < deadline, "the refresh did not arrive"); - wait(§ion, |event| matches!(event, Event::Refreshed)); - } - let stale = edited(&before, "Local "); - section.queue_save(space, before, stale, "Editor").unwrap(); - // Outcomes of the earlier saves may still be arriving. - while !section - .saved() - .into_iter() - .any(|saved| saved == (space, Ok(Save::Stale))) - { - assert!(Instant::now() < deadline, "the stale save was not reported"); - std::thread::sleep(Duration::from_millis(20)); - } - let native = section.page(space).unwrap(); - let after = edited(&native, "Local "); - section - .queue_save(space, native, after.clone(), "Editor") - .unwrap(); + wait(§ion, |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space))); + apply("Local "); section.close().unwrap(); let (section, _) = open(&file, &cache); - assert_same(section.page(space).unwrap(), &after); + assert_same(section.page(space).unwrap(), &edited(&native, "Local ")); section.close().unwrap(); } @@ -426,9 +418,7 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() { let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); let after = edited(&before, "First "); - let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else { - panic!() - }; + let id = typed(§ion, space, &before, 0..0, "First "); wait( §ion, |event| matches!(event, Event::Unreachable(error) if error.kind() == std::io::ErrorKind::NotFound), @@ -442,9 +432,7 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() { assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending)); let before = section.page(space).unwrap(); let after = edited(&before, "Second "); - let Save::Queued(next) = section.save(space, &before, &after, "Editor").unwrap() else { - panic!() - }; + let next = typed(§ion, space, &before, 0..0, "Second "); assert!(!file.exists()); let restored = directory.path().join("restored.one"); std::fs::write(&restored, &source).unwrap(); @@ -470,9 +458,7 @@ fn resuming_against_another_document_preserves_both_remote_and_pending_edit() { let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); let after = edited(&before, "Local "); - let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else { - panic!() - }; + let id = typed(§ion, space, &before, 0..0, "Local "); wait(§ion, |event| matches!(event, Event::Unreachable(_))); section.close().unwrap(); @@ -499,10 +485,7 @@ fn offline_save_process() { let section = Section::resume(root.join("absent.one"), replica, || {}).unwrap(); let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); - let after = edited(&before, "Durable "); - let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else { - panic!() - }; + let id = typed(§ion, space, &before, 0..0, "Durable "); std::fs::write(root.join("acknowledged"), id.to_string()).unwrap(); // Terminate without running Session or SQLite destructors after acknowledgement. std::process::exit(0); @@ -562,9 +545,7 @@ fn smb_connection_failure_keeps_the_session_locally_editable_and_retries() { let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); let after = edited(&before, "Offline SMB "); - let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else { - panic!() - }; + let id = typed(§ion, space, &before, 0..0, "Offline SMB "); section.wake(); wait( §ion, @@ -611,9 +592,7 @@ fn live_smb_session_save_publishes_and_reopens() { let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); let after = edited(&before, "Session SMB "); - let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else { - panic!() - }; + let id = typed(§ion, space, &before, 0..0, "Session SMB "); published(§ion, id); let remote = client.read("session.one", 1024 * 1024).unwrap(); assert_same(model_ops::page_of(&remote, space), &after); @@ -661,9 +640,7 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() { let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); let after = edited(&before, "Saved during connection "); - let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else { - panic!() - }; + let id = typed(§ion, space, &before, 0..0, "Saved during connection "); let (dropped, finished) = mpsc::channel(); let owner = std::thread::spawn(move || { drop(section); @@ -692,14 +669,14 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() { }; assert_eq!(replica.status(id).unwrap(), Some(EditStatus::Pending)); assert_same( - model_ops::page_of(&replica.snapshot().unwrap(), space), + model_ops::page_of(&server::snapshot(&replica), space), &after, ); assert!(connecting.try_recv().is_err()); } #[test] -fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recovery() { +fn a_conflicting_save_keeps_the_native_page_and_a_conflict_page_the_session_deletes() { let directory = tempfile::tempdir().unwrap(); let file = directory.path().join("notes.one"); let cache = directory.path().join("cache"); @@ -717,9 +694,7 @@ fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recov std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o444)).unwrap(); let mut local = before.clone(); model_ops::replace_text(&mut local, text, 0..8, "Local"); - let Save::Queued(id) = section.save(space, &before, &local, "Editor").unwrap() else { - panic!() - }; + let id = typed(§ion, space, &before, 0..8, "Local"); wait(§ion, |event| matches!(event, Event::Unreachable(_))); std::fs::set_permissions(&file, permissions).unwrap(); let mut native = before.clone(); @@ -727,7 +702,7 @@ fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recov let deadline = Instant::now() + Duration::from_secs(20); loop { let bytes = onestore::read_file(&file).unwrap(); - match PreparedEdit::page(&bytes, space, &native, "Native") + match ops::save(&bytes, space, &native) .unwrap() .commit_file(&file) { @@ -740,40 +715,38 @@ fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recov } } section.wake(); - wait( - §ion, - |event| matches!(event, Event::Attempt { id: n, status: EditStatus::Conflict(ConflictKind::ContentChanged) } if *n == id), - ); - let conflicts = section.conflicts().unwrap(); + wait(§ion, |event| { + matches!(event, Event::Attempt { id: n, status: EditStatus::Published { .. } } if *n > id) + }); + assert!(matches!( + section.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + // As OneNote does, the native version stays the page and the local one is kept beside it. + assert_same(section.page(space).unwrap(), &native); + assert_same(stored_page(&file, space), &native); + let listed = section.conflicts().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].0, space); + let conflict = &listed[0].1[0]; + assert_eq!(conflict.user, "Editor"); + let kept = section.page(conflict.space).unwrap(); assert_eq!( - conflicts, - [( - QueuedEdit { - id, - space, - status: EditStatus::Conflict(ConflictKind::ContentChanged) - }, - ConflictKind::ContentChanged - )] + model_ops::paragraph_with(&kept, first_text(&kept)) + .and_then(|paragraph| paragraph.text()) + .map(|text| text.text.text().to_owned()), + Some("Local".to_owned()) ); - assert_same(section.page(space).unwrap(), &local); - let remote = section.remote_page(space).unwrap(); - assert_same(remote.clone(), &native); - let archive = directory.path().join("review.sqlite"); - section.export_recovery(&archive).unwrap(); - let recovery = Recovery::open(&archive).unwrap(); - assert_eq!(recovery.pending().unwrap().len(), 1); assert_eq!( - recovery.status(id).unwrap(), - Some(EditStatus::Conflict(ConflictKind::ContentChanged)) + server::conflicts(&onestore::read_file(&file).unwrap())[0].1, + [("Editor".to_owned(), vec!["Local".to_owned()])] ); - let mut reviewed = remote.clone(); - model_ops::replace_text(&mut reviewed, text, 0..6, "Native and local"); - section.review(id, &reviewed).unwrap(); - assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending)); - published(§ion, id); - assert_same(stored_page(&file, space), &reviewed); - assert!(section.queue().unwrap().is_empty()); + // Merged by hand, the conflict page is deleted. + let deleted = section.delete_pages(&[conflict.space]).unwrap(); + published(§ion, deleted); + assert!(section.conflicts().unwrap().is_empty()); + assert!(server::conflicts(&onestore::read_file(&file).unwrap()).is_empty()); + assert!(section.pending().unwrap().is_empty()); section.close().unwrap(); } @@ -819,7 +792,7 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() { ); assert_eq!( section - .queue() + .pending() .unwrap() .iter() .map(|edit| edit.id) @@ -850,7 +823,7 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() { recovery.status(id).unwrap(), Some(EditStatus::AwaitingConfirmation { .. }) )); - assert!(section.queue().unwrap().is_empty()); + assert!(section.pending().unwrap().is_empty()); assert!( section .release( diff --git a/crates/notebook/tests/smb_queue.rs b/crates/notebook/tests/smb_queue.rs index c817c1a575b6689295ee1876334c876ad1d97995..4282840cdde547b398df649e169b3f53cb5ea192 100644 --- a/crates/notebook/tests/smb_queue.rs +++ b/crates/notebook/tests/smb_queue.rs @@ -17,6 +17,10 @@ use std::{ time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; +#[path = "support/server.rs"] +mod server; +use server::snapshot; + fn client() -> Client { Client::connect( &std::env::var("ONESTORE_SMB_LAB").unwrap(), @@ -49,15 +53,15 @@ impl Remote for Counted { self.reads += 1; self.remote.read() } - fn stamp(&mut self) -> io::Result> { + fn stamp(&mut self) -> io::Result { self.stamps += 1; self.remote.stamp() } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.remote.publish(transaction) } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - self.remote.confirm(snapshot) + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + self.remote.confirm(base) } } @@ -140,7 +144,7 @@ fn live_keystrokes_publish_reading_only_the_header_and_a_native_change_merges() assert_eq!(remote.stamps, 20); assert_eq!( lab.read(&path, 1 << 24).unwrap(), - replica.snapshot().unwrap() + snapshot(&replica) ); for _ in 0..5 { assert_eq!(replica.sync_once(&mut remote).unwrap().edit, None); @@ -173,7 +177,7 @@ fn live_keystrokes_publish_reading_only_the_header_and_a_native_change_merges() assert!(merged.ends_with(" local"), "{merged}"); assert_eq!( lab.read(&path, 1 << 24).unwrap(), - replica.snapshot().unwrap() + snapshot(&replica) ); lab.delete(&path).unwrap(); } @@ -244,6 +248,6 @@ fn live_session_applies_edits_through_the_embedded_client_and_reopens() { section.close().unwrap(); let replica = Replica::open(&cache).unwrap(); assert_eq!(text_of(&replica, space, text), "0123456789Session"); - assert_eq!(replica.snapshot().unwrap(), published); + assert_eq!(snapshot(&replica), published); lab.delete(&path).unwrap(); } diff --git a/crates/notebook/tests/structure.rs b/crates/notebook/tests/structure.rs index 6274848d901a6ee49db5374bd193c0b9b0f6ae3d..79d684d23c9c67749c548746b0fc927000947265 100644 --- a/crates/notebook/tests/structure.rs +++ b/crates/notebook/tests/structure.rs @@ -53,13 +53,13 @@ fn sections_and_groups_are_created_renamed_coloured_ordered_and_deleted() { onestore::place_file(root.join("First.one"), toc_id, "First.one").unwrap(); let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap(); assert_eq!( - notebook.create_section("", "Second", "Author").unwrap(), + notebook.create_section("", "Second", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()).unwrap(), "Second.one" ); assert_eq!(notebook.create_group("", "Archive").unwrap(), "Archive"); assert_eq!( notebook - .create_section("Archive", "Inner", "Author") + .create_section("Archive", "Inner", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()) .unwrap(), "Archive/Inner.one" ); diff --git a/crates/notebook/tests/support/model_ops.rs b/crates/notebook/tests/support/model_ops.rs index 8e49e7c98c7fee9df4bc671a8be943a38214af54..5110dbd392412cac020f6986fdbcac9d542de9f5 100644 --- a/crates/notebook/tests/support/model_ops.rs +++ b/crates/notebook/tests/support/model_ops.rs @@ -287,17 +287,16 @@ pub fn save( text: ExGuid, edit: impl FnOnce(&mut Page), ) -> Result, notebook::Error> { - let (space, _) = locate(&cache.snapshot()?, text); - save_page(cache, space, edit) -} - -/// Resolves a conflict by taking the remote page, then queues `edit` of it. -pub fn review( - cache: &Replica, - id: u64, - text: ExGuid, - edit: impl FnOnce(&mut Page), -) -> Result, notebook::Error> { - cache.resolve(id, notebook::Resolution::Theirs)?; - save(cache, text, edit) + let mut space = None; + for (listed, ..) in cache.pages()? { + if paragraph_with(&cache.page(listed)?, text).is_some() { + space = Some(listed); + break; + } + } + save_page( + cache, + space.expect("text object belongs to an active page"), + edit, + ) } diff --git a/crates/notebook/tests/support/model_schedule.rs b/crates/notebook/tests/support/model_schedule.rs index b877ea01705cee00e27cdf0528ed010bb8ad0fa7..e019ef53a86ed6ceff186058d1d520819453e56c 100644 --- a/crates/notebook/tests/support/model_schedule.rs +++ b/crates/notebook/tests/support/model_schedule.rs @@ -1,16 +1,20 @@ //! A deterministic multi-actor schedule over page-model saves: several replicas edit one -//! page offline, publish through a fault-injecting remote, reopen, and review conflicts. +//! page offline, publish through a fault-injecting remote, reopen, and delete the conflict +//! pages the merges leave. use crate::model_ops::{self, AUTHOR}; -use crate::server::{Fault, Server}; -use notebook::{EditStatus, Remote, Replica, Resolution}; +use crate::server::{Fault, Server, remote_snapshot, snapshot}; +use notebook::{EditStatus, Remote, Replica}; use onestore::{ - CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, Transaction, + CommitError, ExGuid, RevisionIndex, Stamp, Store, Transaction, document::Document, page::{Outline, Page, PageObject}, }; use std::{io, sync::LazyLock}; +#[path = "../../../onestore/tests/support/ops.rs"] +mod ops; + static SOURCE: LazyLock<(Vec, ExGuid)> = LazyLock::new(|| { let source = onestore::create_section("model-schedule.one", "Original 🦀 é 0", "Author").unwrap(); @@ -23,9 +27,9 @@ static SOURCE: LazyLock<(Vec, ExGuid)> = LazyLock::new(|| { for at in 1..8 { anchor = model_ops::insert_after(&mut page, anchor, &format!("Original 🦀 é {at}")).1; } - let source = PreparedEdit::page(&source, space, &page, "Author") + let source = ops::saved(&source, space, &page) .unwrap() - .as_bytes() + .as_slice() .to_vec(); (source, space) }); @@ -139,6 +143,10 @@ impl Remote for Session<'_> { self.server.read() } + fn stamp(&mut self) -> io::Result { + self.server.stamp() + } + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { assert!(!self.retired, "a retired attempt was replayed"); self.publications += 1; @@ -146,8 +154,8 @@ impl Remote for Session<'_> { self.server.publish(transaction) } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - self.server.confirm(snapshot) + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + self.server.confirm(base) } } @@ -165,11 +173,17 @@ fn statuses(cache: &Replica) -> Vec<(u64, Option)> { .collect() } -/// Fails unless the durable remote holds `revision` in the edited page's space. +/// Fails unless the durable remote holds `revision`: in the edited page's space, or, for +/// the edit adding a conflict page, in the space it changed first. fn durable(server: &Server, revision: ExGuid) { let store = Store::parse(&server.durable).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); - assert!(index.spaces[&SOURCE.1].revisions.contains_key(&revision)); + assert!( + index + .spaces + .values() + .any(|space| space.revisions.contains_key(&revision)) + ); } pub fn run(input: &[u8]) { @@ -181,10 +195,10 @@ pub fn run(input: &[u8]) { let actor = usize::from(step[0]) % replicas.len(); let path = directory.path().join(format!("{actor}.sqlite")); let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap()); - let snapshot = cache.snapshot().unwrap(); + let image = snapshot(cache); let before = local(cache); let pending = cache.pending().unwrap(); - let rows_now = rows(&snapshot); + let rows_now = rows(&image); match step[1] % 8 { 0..=2 => { let row = rows_now[usize::from(step[2]) % rows_now.len()].clone(); @@ -266,12 +280,17 @@ pub fn run(input: &[u8]) { }; server.fault = Fault::None; let next = cache.pending().unwrap(); - // Edits leave the queue oldest first, each with a durable receipt. - let left = pending.len() - next.len(); - assert!( - next.iter() - .all(|edit| pending[left..].iter().any(|kept| kept.id == edit.id)) - ); + // Edits leave the queue oldest first, each with a durable receipt; a rebase + // queues the conflict pages it makes after them. + let queued = |edit: ¬ebook::PendingEdit| pending.iter().any(|kept| kept.id == edit.id); + let left = pending.len() - next.iter().filter(|edit| queued(edit)).count(); + assert!(next.iter().all(|edit| { + pending[left..].iter().any(|kept| kept.id == edit.id) + || matches!( + &edit.edit.ops[..], + [onestore::op::Op::Section(onestore::op::SectionOp::Conflict { .. })] + ) + })); for edit in &pending[..left] { let Some(EditStatus::Published { revision }) = cache.status(edit.id).unwrap() else { @@ -283,7 +302,7 @@ pub fn run(input: &[u8]) { assert_eq!( local(cache), shape(&model_ops::page_of( - &cache.remote_snapshot().unwrap(), + &remote_snapshot(cache), *space )) ); @@ -301,31 +320,41 @@ pub fn run(input: &[u8]) { &char::from(b'a' + step[3] % 26).to_string(), ); let visible = server.visible.clone(); - PreparedEdit::page(&visible, *space, &page, "Remote") + ops::save(&visible, *space, &page) .unwrap() .commit(&mut server) .unwrap(); } } 6 => { - if let Some(conflict) = cache.conflict().unwrap() { - let keep = if step[2] & 1 == 0 { - Resolution::Mine - } else { - Resolution::Theirs - }; - let remote = model_ops::page_of(&cache.remote_snapshot().unwrap(), *space); - cache.resolve(conflict.id, keep).unwrap(); - match cache.conflict().unwrap() { - Some(next) => assert_ne!(next.space, conflict.space), - None => assert_eq!( - local(cache), - match keep { - Resolution::Mine => before.clone(), - Resolution::Theirs => shape(&remote), - } - ), - } + // The writer merged a conflict page's version by hand and deletes it. + let listed = cache.conflicts().unwrap(); + if let Some(conflict) = listed + .iter() + .find(|(page, _)| page == space) + .map(|(_, pages)| pages[usize::from(step[2]) % pages.len()].space) + { + let delete = onestore::op::Op::Section(onestore::op::SectionOp::Delete(vec![ + conflict, + ])); + cache + .apply( + AUTHOR, + onestore::op::Edit { + at: model_ops::now(), + ops: vec![delete], + }, + ) + .unwrap(); + assert!( + cache + .conflicts() + .unwrap() + .iter() + .flat_map(|(_, pages)| pages) + .all(|page| page.space != conflict) + ); + assert_eq!(local(cache), before); let (result, publications) = { let mut session = Session { retired: false, @@ -356,6 +385,6 @@ pub fn run(input: &[u8]) { } } rows(&server.durable); - rows(&replicas[actor].as_ref().unwrap().snapshot().unwrap()); + rows(&snapshot(replicas[actor].as_ref().unwrap())); } } diff --git a/crates/notebook/tests/support/page_schedule.rs b/crates/notebook/tests/support/page_schedule.rs index 76f6ac49a40e1b22c5e5a63696e05718da051c53..b01d6af2d69bb66f20d70eaa47627df58cb3851b 100644 --- a/crates/notebook/tests/support/page_schedule.rs +++ b/crates/notebook/tests/support/page_schedule.rs @@ -1,8 +1,12 @@ -use crate::{disk, model_ops}; -use notebook::{EditStatus, Remote, Replica, Resolution}; +use crate::{ + disk, model_ops, + server::{remote_snapshot, snapshot}, +}; +use notebook::{Remote, Replica}; use onestore::{ - CommitError, ExGuid, PageEdit, PreparedEdit, RevisionIndex, Store, Transaction, + CommitError, ExGuid, PageEdit, RevisionIndex, Stamp, Store, Transaction, document::{Document, Format, Kind, Layout}, + op::SectionOp, page::{ Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, text::new_id, @@ -10,6 +14,9 @@ use onestore::{ }; use std::{io, sync::LazyLock}; +#[path = "../../../onestore/tests/support/ops.rs"] +mod ops; + const BODY: &str = "Body 🦋 é"; #[path = "../../../onestore/tests/support/current.rs"] @@ -19,7 +26,7 @@ static SOURCE: LazyLock> = LazyLock::new(|| { let mut source = onestore::create_section("page-schedule.one", "Original", "Author").unwrap(); for _ in 0..3 { let page = onestore::PageCreation::new(None, Some("Same title"), "Author").unwrap(); - source = PreparedEdit::create_page(&source, &page) + source = ops::section_op(&source, SectionOp::Create(page.clone())) .unwrap() .as_bytes() .to_vec(); @@ -107,6 +114,10 @@ impl Remote for Session<'_> { Ok(self.disk.visible.clone()) } + fn stamp(&mut self) -> io::Result { + Stamp::of(&self.disk.visible).map_err(io::Error::other) + } + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { let mut image = self.disk.visible.clone(); transaction.apply(&mut image).unwrap(); @@ -121,8 +132,8 @@ impl Remote for Session<'_> { result } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - onestore::confirm_snapshot(self.disk, snapshot) + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + onestore::confirm(self.disk, base) } } @@ -152,8 +163,8 @@ pub fn run(input: &[u8]) { let actor = usize::from(step[0]) % replicas.len(); let path = directory.path().join(format!("{actor}.sqlite")); let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, &SOURCE).unwrap()); - let snapshot = cache.snapshot().unwrap(); - let listed = pages(&snapshot); + let image = snapshot(cache); + let listed = pages(&image); let pending = cache.pending().unwrap(); match step[1] % 8 { 0 | 1 => { @@ -183,7 +194,7 @@ pub fn run(input: &[u8]) { .collect(); replicas[actor] = None; let cache = Replica::open(&path).unwrap(); - assert_eq!(pages(&cache.snapshot().unwrap()), listed); + assert_eq!(pages(&snapshot(&cache)), listed); assert_eq!(cache.pending().unwrap(), pending); assert_eq!( pending @@ -200,24 +211,9 @@ pub fn run(input: &[u8]) { disk.fail_at = (step[2] != 0).then_some(usize::from(u16::from_le_bytes([step[2], step[3]]))); disk.random = u64::from(step[4]) + 1; - let prior = pending - .first() - .and_then(|edit| cache.status(edit.id).unwrap()); let result = cache.sync_once(&mut Session { disk: &mut disk }); - if matches!(prior, Some(EditStatus::AwaitingConfirmation { .. })) { - assert!( - result.is_err() - || !matches!( - result.as_ref().unwrap().edit, - Some((_, EditStatus::Conflict(_))) - ) - ); - } if result.is_ok() && cache.pending().unwrap().is_empty() { - assert_eq!( - pages(&cache.snapshot().unwrap()), - pages(&cache.remote_snapshot().unwrap()) - ); + assert_eq!(pages(&snapshot(cache)), pages(&remote_snapshot(cache))); } } 5 => { @@ -244,36 +240,34 @@ pub fn run(input: &[u8]) { } }) .collect(); - let expected = PreparedEdit::pages(&snapshot, &edits); + let expected = ops::section_op(&image, SectionOp::Pages(edits.to_vec())); match section_op(cache, onestore::op::SectionOp::Pages(edits)) { Ok(_) => assert_eq!( - pages(&cache.snapshot().unwrap()), + pages(&snapshot(cache)), pages(expected.unwrap().as_bytes()) ), Err(_) => { assert!(expected.is_err()); - assert_eq!(pages(&cache.snapshot().unwrap()), listed); + assert_eq!(pages(&snapshot(cache)), listed); assert_eq!(cache.pending().unwrap(), pending); } } } 7 => { - if let Some(conflict) = cache.conflict().unwrap() { - // Taking theirs for the page list would drop the pages this actor made. - let root = listed.iter().all(|(sid, ..)| *sid != conflict.space); - let keep = if root || step[2] & 1 == 0 { - Resolution::Mine - } else { - Resolution::Theirs - }; - if cache.resolve(conflict.id, keep).is_err() { - assert_eq!(cache.pending().unwrap(), pending); - } + // The actor merged a conflict page's version by hand and deletes it. + if let Some(conflict) = cache + .conflicts() + .unwrap() + .first() + .map(|(_, pages)| pages[usize::from(step[2]) % pages.len()].space) + { + section_op(cache, onestore::op::SectionOp::Delete(vec![conflict])).unwrap(); + assert_eq!(pages(&snapshot(cache)), listed); } } _ => unreachable!(), } - let local = pages(&replicas[actor].as_ref().unwrap().snapshot().unwrap()); + let local = pages(&snapshot(replicas[actor].as_ref().unwrap())); for page in &owned[actor] { assert!(local.iter().any(|(sid, oid, _)| (*sid, *oid) == *page)); } diff --git a/crates/notebook/tests/support/server.rs b/crates/notebook/tests/support/server.rs index 199421ab5489b423a522c6ab13d7ebf61608df59..a9b329189d4df13e19d7bd2c055e04fef8bce051 100644 --- a/crates/notebook/tests/support/server.rs +++ b/crates/notebook/tests/support/server.rs @@ -75,8 +75,8 @@ impl Remote for Server { fn read(&mut self) -> io::Result> { Ok(self.visible.clone()) } - fn stamp(&mut self) -> io::Result> { - Ok(Stamp::of(&self.visible).ok()) + fn stamp(&mut self) -> io::Result { + Stamp::of(&self.visible).map_err(io::Error::other) } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.publications += 1; @@ -99,13 +99,13 @@ impl Remote for Server { _ => Ok(()), } } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { self.confirmations += 1; if matches!(self.fault, Fault::Confirm) { self.fault = Fault::None; return Err(failure(CommitState::Unknown)); } - onestore::confirm_snapshot(self, snapshot)?; + onestore::confirm(self, base)?; if matches!(self.fault, Fault::ConfirmCommitted) { self.fault = Fault::None; return Err(failure(CommitState::Committed)); @@ -165,3 +165,100 @@ pub fn section_op(cache: ¬ebook::Replica, op: onestore::op::SectionOp) -> u64 ) .unwrap() } + +/// The image the queue leaves, as a recovery archive of the cache records it. +pub fn snapshot(cache: ¬ebook::Replica) -> Vec { + archived(cache, |recovery| recovery.snapshot()) +} + +/// The remote image the cache last observed, as a recovery archive records it. +pub fn remote_snapshot(cache: ¬ebook::Replica) -> Vec { + archived(cache, |recovery| recovery.remote_snapshot()) +} + +fn archived( + cache: ¬ebook::Replica, + image: impl FnOnce(¬ebook::Recovery) -> Result, notebook::Error>, +) -> Vec { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("recovery.sqlite"); + cache.export_recovery(&path).unwrap(); + image(¬ebook::Recovery::open(&path).unwrap()).unwrap() +} + +/// `image` after another writer replaces `range` of the text object `text` with `with`. +pub fn typed( + image: &[u8], + space: ExGuid, + text: ExGuid, + range: std::ops::Range, + with: &str, +) -> Vec { + let op = onestore::op::PageOp::Text { + text, + range, + with: with.into(), + }; + edited(image, vec![onestore::op::Op::Page { space, op }]).unwrap() +} + +/// `image` after another writer's `ops`, sealed as one revision per space they change. +pub fn edited(image: &[u8], ops: Vec) -> Result, onestore::op::OpError> { + let arena = onestore::Arena::default(); + let mut section = + onestore::Section::open(&arena, image.to_vec()).map_err(onestore::op::OpError::Failed)?; + let edit = onestore::op::Edit { + at: 134_000_000_000_000_000, + ops, + }; + section.apply("Other writer", &edit)?; + section.seal().map_err(onestore::op::OpError::Failed)?; + Ok(section.image()) +} + +/// A conflict page as the tests compare it: whose version it is and its texts. +pub type Version = (String, Vec); + +/// Each page's conflict pages in list order. +pub fn conflicts(image: &[u8]) -> Vec<(ExGuid, Vec)> { + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, image.to_vec()).unwrap(); + section + .conflicts() + .unwrap() + .into_iter() + .map(|(page, conflicts)| { + let conflicts = conflicts + .into_iter() + .map(|conflict| { + let page = section.page(conflict.space).unwrap(); + (conflict.user, page_texts(&page)) + }) + .collect(); + (page, conflicts) + }) + .collect() +} + +/// The texts of a page's outlines and title, in page order. +pub fn page_texts(page: &onestore::page::Page) -> Vec { + use onestore::page::PageObject; + page.objects + .iter() + .flat_map(|object| match object { + PageObject::Outline(outline) => outline.paragraphs.clone(), + PageObject::Title(title) => title + .outlines + .iter() + .flat_map(|outline| outline.paragraphs.clone()) + .collect(), + _ => Vec::new(), + }) + .filter_map(|paragraph| Some(paragraph.text()?.text.text().to_owned())) + .collect() +} + +/// Whether `image` lists a conflict page under page `space`. +pub fn conflicted(image: &[u8], space: ExGuid) -> bool { + conflicts(image).iter().any(|(page, _)| *page == space) +} diff --git a/crates/notebook/tests/sync.rs b/crates/notebook/tests/sync.rs index 581864cfcea245531af577d10627ad55a54e1988..bfc7d5dfa34d8b4f6a5b2d3ce866f079f6f922bf 100644 --- a/crates/notebook/tests/sync.rs +++ b/crates/notebook/tests/sync.rs @@ -1,6 +1,6 @@ -//! Replica protocol: durable receipts, retired attempts, conflict resolution and contention. +//! Replica protocol: durable receipts, retired attempts, conflict pages and contention. -use notebook::{ConflictKind, EditStatus, Error, Remote, Replica, Resolution}; +use notebook::{EditStatus, Error, Remote, Replica}; use onestore::{CommitError, CommitState, ExGuid, Transaction}; use std::{io, ops::Range}; @@ -29,14 +29,14 @@ fn an_unchanged_stamp_publishes_and_settles_without_reading_the_remote() { self.reads += 1; self.server.read() } - fn stamp(&mut self) -> io::Result> { + fn stamp(&mut self) -> io::Result { self.server.stamp() } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.server.publish(transaction) } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - self.server.confirm(snapshot) + fn confirm(&mut self, base: &onestore::Stamp) -> Result<(), CommitError> { + self.server.confirm(base) } } let directory = tempfile::tempdir().unwrap(); @@ -56,11 +56,11 @@ fn an_unchanged_stamp_publishes_and_settles_without_reading_the_remote() { assert_eq!(remote.reads, 0); // Another writer's commit moves the header, so the next step reads the file. let native = - onestore::replace_text(&remote.server.visible, sid, object, 0..0, "Native ").unwrap(); + typed(&remote.server.visible, sid, object, 0..0, "Native "); remote.server.visible.clone_from(&native); assert_eq!(cache.sync_once(&mut remote).unwrap().edit, None); assert_eq!(remote.reads, 1); - assert_eq!(cache.snapshot().unwrap(), native); + assert_eq!(snapshot(&cache), native); } #[test] @@ -89,14 +89,13 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin let created = onestore::PageCreation::new(None, Some("Recovery 🦀"), "Fixture").unwrap(); section_op(&cache, onestore::op::SectionOp::Create(created)); save(&cache, object, 0..0, "Last ").unwrap(); - let working = cache.snapshot().unwrap(); - let remote = cache.remote_snapshot().unwrap(); + let working = snapshot(&cache); + let remote = remote_snapshot(&cache); let pending = cache.pending().unwrap(); let uncertain = cache.status(attempted).unwrap(); let source_file = std::fs::read(&path).unwrap(); let summary = RecoverySummary { queued_edits: 4, - conflicts: 0, uncertain_edits: 1, published_receipts: 1, base_bytes: remote.len() as u64, @@ -173,28 +172,21 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin } #[test] -fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archives() { +fn recovery_archive_retains_the_queue_and_rejects_foreign_or_future_archives() { use notebook::Recovery; let directory = tempfile::tempdir().unwrap(); let source = onestore::create_section("recovery.one", "Original", "Fixture").unwrap(); - let (space, object, _) = text(&source); + let (_, object, _) = text(&source); let cache = Replica::create(directory.path().join("live.sqlite"), &source).unwrap(); let id = save(&cache, object, 0..8, "Local").unwrap(); - let changed = onestore::replace_text(&source, space, object, 0..8, "Remote").unwrap(); - let mut server = Server::new(&changed); - let outcome = cache.sync_once(&mut server).unwrap().edit.unwrap(); - assert_eq!( - outcome, - (id, EditStatus::Conflict(ConflictKind::ContentChanged)) - ); - let path = directory.path().join("conflict.sqlite"); + let path = directory.path().join("queued.sqlite"); cache.export_recovery(&path).unwrap(); let archive = Recovery::open(&path).unwrap(); assert_eq!(text(&archive.snapshot().unwrap()).2, "Local"); - assert_eq!(archive.remote_snapshot().unwrap(), changed); - assert_eq!(archive.status(id).unwrap(), Some(outcome.1.clone())); - assert_eq!(archive.summary().unwrap().conflicts, 1); + assert_eq!(archive.remote_snapshot().unwrap(), source); + assert_eq!(archive.status(id).unwrap(), Some(EditStatus::Pending)); + assert_eq!(archive.summary().unwrap().queued_edits, 1); assert_eq!(archive.summary().unwrap().uncertain_edits, 0); drop(archive); for sql in [ @@ -208,8 +200,8 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv assert!(Recovery::open(&path).is_err()); assert_eq!(std::fs::read(&path).unwrap(), before); } - assert_eq!(cache.status(id).unwrap(), Some(outcome.1.clone())); - assert_eq!(text(&cache.snapshot().unwrap()).2, "Local"); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + assert_eq!(text(&snapshot(&cache)).2, "Local"); } #[test] @@ -220,13 +212,13 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() { let (sid, oid, _) = text(&source); let cache = Replica::create(&path, &source).unwrap(); let id = save(&cache, oid, 2..4, "🐈").unwrap(); - let remote = onestore::replace_text(&source, sid, oid, 0..6, "Xab🦀cYd").unwrap(); + let remote = typed(&source, sid, oid, 0..6, "Xab🦀cYd"); let mut server = Server::new(&remote); let outcome = cache.sync_once(&mut server).unwrap().edit.unwrap(); assert_eq!(outcome.0, id); assert!(matches!(outcome.1, EditStatus::Published { .. })); assert_eq!(text(&server.durable).2, "Xab🐈cYd"); - assert_eq!(cache.snapshot().unwrap(), server.durable); + assert_eq!(snapshot(&cache), server.durable); assert!(cache.pending().unwrap().is_empty()); drop(cache); let cache = Replica::open(&path).unwrap(); @@ -238,31 +230,38 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() { assert!(next > id); } +/// As OneNote 2010 does, the remote's version stays the page and the local one becomes a +/// conflict page under it, published with the rest of the queue. #[test] -fn overlapping_changes_preserve_both_images_and_survive_restart() { +fn overlapping_changes_keep_both_versions_and_survive_restart() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cache.sqlite"); let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); let (sid, oid, _) = text(&source); let cache = Replica::create(&path, &source).unwrap(); let id = save(&cache, oid, 1..2, "L").unwrap(); - let local = cache.snapshot().unwrap(); - let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); + let remote = typed(&source, sid, oid, 1..2, "R"); let mut server = Server::new(&remote); - assert_eq!( + assert!(matches!( cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) + Some((newest, EditStatus::Published { .. })) if newest > id + )); + assert_eq!(server.publications, 1); + assert_eq!(content(&server.durable, oid), "aRc"); + assert_eq!( + conflicts(&server.durable), + [(sid, vec![(model_ops::AUTHOR.to_owned(), vec!["aLc".to_owned()])])] ); - assert_eq!(server.publications, 0); + assert_eq!(snapshot(&cache), server.durable); + assert!(cache.pending().unwrap().is_empty()); drop(cache); let cache = Replica::open(&path).unwrap(); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); - assert_eq!(cache.remote_snapshot().unwrap(), remote); - assert_eq!(cache.pending().unwrap().len(), 1); - assert_eq!( + assert!(matches!( cache.status(id).unwrap(), - Some(EditStatus::Conflict(ConflictKind::ContentChanged)) - ); + Some(EditStatus::Published { .. }) + )); + assert_eq!(cache.conflicts().unwrap()[0].0, sid); + assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); } #[test] @@ -452,16 +451,19 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio } impl Remote for Paused { fn read(&mut self) -> io::Result> { + self.server.read() + } + fn stamp(&mut self) -> io::Result { self.wait("read"); - self.server.read() + self.server.stamp() } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.wait("publish"); self.server.publish(transaction) } - fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { + fn confirm(&mut self, base: &onestore::Stamp) -> Result<(), CommitError> { self.wait("confirm"); - self.server.confirm(source) + self.server.confirm(base) } } for phase in ["read", "publish", "confirm"] { @@ -505,9 +507,6 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio assert!( matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock) ); - assert!( - matches!(cache.resolve(first, Resolution::Theirs), Err(Error::Io(error)) if error.kind() == io::ErrorKind::WouldBlock) - ); let started = std::time::Instant::now(); let handles: Vec<_> = (0..12) .map(|writer| { @@ -550,7 +549,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio cache.pending().unwrap().len(), if phase == "read" { 0 } else { 12 } ); - let expected = text(&cache.snapshot().unwrap()).2; + let expected = text(&snapshot(&cache)).2; for writer in 0..12 { assert_eq!(expected.matches(&format!("[{writer}] ")).count(), 1); } @@ -562,7 +561,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio } assert_eq!(server.publications, if phase == "read" { 1 } else { 2 }); assert_eq!(text(&server.durable).2, expected); - assert_eq!(cache.snapshot().unwrap(), server.durable); + assert_eq!(snapshot(&cache), server.durable); } } @@ -578,12 +577,12 @@ fn unrelated_remote_files_never_replace_a_local_cache() { if pending { save(&cache, oid, 0..0, "Local ").unwrap(); } - let before = pages(&cache.snapshot().unwrap()); + let before = pages(&snapshot(&cache)); assert!( matches!(cache.sync_once(&mut server),Err(Error::Io(error)) if error.kind()==io::ErrorKind::InvalidInput) ); - assert_eq!(pages(&cache.snapshot().unwrap()), before); - assert_eq!(cache.remote_snapshot().unwrap(), source); + assert_eq!(pages(&snapshot(&cache)), before); + assert_eq!(remote_snapshot(&cache), source); assert_eq!(server.publications, 0); } } @@ -600,165 +599,76 @@ fn content(bytes: &[u8], text: ExGuid) -> String { .to_owned() } +/// A conflict never holds the queue: later edits of the page apply to the remote's version. #[test] -fn a_conflict_holds_later_edits_until_resolved_either_way_and_survives_reopen() { - for resolution in [Resolution::Mine, Resolution::Theirs] { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("cache.sqlite"); - let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); - let cache = Replica::create(&path, &source).unwrap(); - let first = save(&cache, oid, 1..2, "L").unwrap(); - let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap(); - let mut server = Server::new(&remote); - let conflict = EditStatus::Conflict(ConflictKind::ContentChanged); - assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((first, conflict.clone())) - ); - let mut dependent = None; - for n in 0..12 { - dependent = save(&cache, oid, 0..0, &format!("[{n}] ")); - } - let dependent = dependent.unwrap(); - assert_ne!(dependent, first); - assert_eq!(cache.status(dependent).unwrap(), Some(conflict.clone())); - let local = content(&cache.snapshot().unwrap(), oid); - assert!(local.ends_with("[0] aLc"), "{local}"); - let pending = cache.pending().unwrap(); - assert_eq!(pending.len(), 13); - assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((dependent, conflict.clone())), - "an unchanged remote leaves the conflict as it was" - ); - assert_eq!(server.publications, 0); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.pending().unwrap(), pending); - assert_eq!(content(&cache.snapshot().unwrap(), oid), local); - assert_eq!( - cache.conflict().unwrap(), - Some(notebook::Conflict { - id: dependent, - space: sid, - kind: ConflictKind::ContentChanged - }) - ); - cache.resolve(first, resolution).unwrap(); - assert_eq!(cache.conflict().unwrap(), None); - assert_eq!(cache.remote_snapshot().unwrap(), remote); - let expected = match resolution { - Resolution::Mine => local.clone(), - Resolution::Theirs => "aRcZ".to_owned(), - }; - assert_eq!(content(&cache.snapshot().unwrap(), oid), expected); - drop(cache); - let cache = Replica::open(&path).unwrap(); - match resolution { - Resolution::Mine => { - assert!(matches!( - cache.sync_once(&mut server).unwrap().edit, - Some((_, EditStatus::Published { .. })) - )); - assert_eq!(server.publications, 1); - for edit in &pending { - assert!(matches!( - cache.status(edit.id).unwrap(), - None | Some(EditStatus::Published { .. }) - )); - } - assert!(matches!( - cache.status(first).unwrap(), - Some(EditStatus::Published { .. }) - )); - } - Resolution::Theirs => { - // The emptied edits are acknowledged once the remote image is flushed. - assert!(matches!( - cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Published { .. })) if id == dependent - )); - assert_eq!((server.publications, server.confirmations), (0, 1)); - assert!(matches!( - cache.status(first).unwrap(), - Some(EditStatus::Published { .. }) - )); - } - } - assert_eq!(content(&server.durable, oid), expected); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&server.durable)); - assert!(cache.pending().unwrap().is_empty()); +fn edits_after_a_conflict_apply_to_the_remote_version_and_survive_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cache.sqlite"); + let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); + let (sid, oid, _) = text(&source); + let cache = Replica::create(&path, &source).unwrap(); + save(&cache, oid, 1..2, "L").unwrap(); + let remote = typed(&source, sid, oid, 0..3, "aRcZ"); + let mut server = Server::new(&remote); + assert!(matches!( + cache.sync_once(&mut server).unwrap().edit, + Some((_, EditStatus::Published { .. })) + )); + assert_eq!(content(&snapshot(&cache), oid), "aRcZ"); + let mut expected = "aRcZ".to_owned(); + for n in 0..12 { + save(&cache, oid, 0..0, &format!("[{n}] ")).unwrap(); + expected.insert_str(0, &format!("[{n}] ")); } -} - -#[test] -fn resolution_refuses_edits_that_are_not_in_conflict() { - let dir = tempfile::tempdir().unwrap(); - let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); - let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); - let id = save(&cache, oid, 1..2, "L").unwrap(); - let refused = |cache: &Replica, id| matches!(cache.resolve(id, Resolution::Mine), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput); - assert!(refused(&cache, id)); - let remote = onestore::replace_text(&source, sid, oid, 0..3, "🦀Rc").unwrap(); - let mut server = Server::new(&remote); - assert!(matches!( - cache.sync_once(&mut server).unwrap().edit, - Some((_, EditStatus::Conflict(_))) - )); let pending = cache.pending().unwrap(); - assert!(refused(&cache, id + 1)); + drop(cache); + let cache = Replica::open(&path).unwrap(); assert_eq!(cache.pending().unwrap(), pending); - cache.resolve(id, Resolution::Mine).unwrap(); - server.fault = Fault::UnknownBefore; - assert!( - matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Unknown) + assert!(matches!( + cache.sync_once(&mut server).unwrap().edit, + Some((id, EditStatus::Published { .. })) if id == pending.last().unwrap().id + )); + assert_eq!(server.publications, 2); + assert_eq!(content(&server.durable, oid), expected); + assert_eq!( + conflicts(&server.durable), + [(sid, vec![(model_ops::AUTHOR.to_owned(), vec!["aLc".to_owned()])])] ); - let attempted = cache.status(id).unwrap(); - assert!(matches!( - attempted, - Some(EditStatus::AwaitingConfirmation { .. }) - )); - assert!(refused(&cache, id)); - assert_eq!(cache.status(id).unwrap(), attempted); - assert_eq!(server.publications, 1); + assert!(cache.pending().unwrap().is_empty()); } +/// A rebase that cannot be stored leaves the queue, and every page, as it was. #[test] -fn a_failed_resolution_leaves_the_conflict_as_it_was() { +fn a_failed_rebase_leaves_the_queue_as_it_was() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cache.sqlite"); let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); let (sid, oid, _) = text(&source); let cache = Replica::create(&path, &source).unwrap(); let id = save(&cache, oid, 1..2, "L").unwrap(); - let remote = onestore::replace_text(&source, sid, oid, 0..3, "XaRc").unwrap(); + let remote = typed(&source, sid, oid, 0..3, "XaRc"); let mut server = Server::new(&remote); - cache.sync_once(&mut server).unwrap(); - let local = pages(&cache.snapshot().unwrap()); + let local = pages(&snapshot(&cache)); let pending = cache.pending().unwrap(); - let status = cache.status(id).unwrap(); drop(cache); let connection = rusqlite::Connection::open(&path).unwrap(); - connection.execute_batch("CREATE TRIGGER fail_clear BEFORE DELETE ON batches BEGIN SELECT RAISE(ABORT, 'test resolution failure'); END;").unwrap(); + connection.execute_batch("CREATE TRIGGER fail_clear BEFORE DELETE ON batches BEGIN SELECT RAISE(ABORT, 'test rebase failure'); END;").unwrap(); drop(connection); let cache = Replica::open(&path).unwrap(); assert!(matches!( - cache.resolve(id, Resolution::Mine), + cache.sync_once(&mut server), Err(Error::Database(_)) )); drop(cache); let cache = Replica::open(&path).unwrap(); assert_eq!(cache.pending().unwrap(), pending); - assert_eq!(cache.status(id).unwrap(), status); - assert_eq!(pages(&cache.snapshot().unwrap()), local); - assert_eq!(cache.remote_snapshot().unwrap(), remote); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + assert_eq!(pages(&snapshot(&cache)), local); assert_eq!(server.publications, 0); } #[test] -fn seeded_conflicts_resolve_to_the_remote_text_and_later_edits_publish_once() { +fn seeded_conflicts_keep_the_remote_text_and_later_edits_publish_once() { let dir = tempfile::tempdir().unwrap(); let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect(); let mut seed = 911_u64; @@ -779,70 +689,71 @@ fn seeded_conflicts_resolve_to_the_remote_text_and_later_edits_publish_once() { .unwrap(); let (sid, oid, _) = text(&source); let cache = Replica::create(dir.path().join(format!("{case}.sqlite")), &source).unwrap(); - let id = save(&cache, oid, start..end, "λ🦊μ").unwrap(); + save(&cache, oid, start..end, "λ🦊μ").unwrap(); save(&cache, oid, start + 1..start + 3, "🐕").unwrap(); + let local = content(&snapshot(&cache), oid); let mut remote_text = original.clone(); remote_text.splice(at..at + 1, "Ω🐈π".chars()); let remote_text = prefix.clone() + &remote_text.iter().collect::() + &suffix; - let remote = onestore::replace_text( + let remote = typed( &source, sid, oid, 0..original.iter().map(|ch| ch.len_utf16() as u32).sum(), &remote_text, - ) - .unwrap(); + ); let mut server = Server::new(&remote); assert!( matches!( cache.sync_once(&mut server).unwrap().edit, - Some((_, EditStatus::Conflict(ConflictKind::ContentChanged))) + Some((_, EditStatus::Published { .. })) ), "case {case}, seed {seed}" ); + assert_eq!(content(&server.durable, oid), remote_text, "case {case}, seed {seed}"); + assert_eq!( + conflicts(&server.durable), + [(sid, vec![(model_ops::AUTHOR.to_owned(), vec![local])])], + "case {case}, seed {seed}" + ); let at_remote = start + prefix.len() as u32; - let reviewed = model_ops::review(&cache, id, oid, |page| { - model_ops::replace_text(page, oid, at_remote..at_remote + 4, "λ🐕μ") - }) - .unwrap() - .unwrap(); + let later = save(&cache, oid, at_remote..at_remote + 4, "λ🐕μ").unwrap(); assert!( - matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == reviewed), + matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == later), "case {case}, seed {seed}" ); let mut expected = original.clone(); expected.splice(at..at + 1, "λ🐕μ".chars()); let expected = prefix + &expected.iter().collect::() + &suffix; - assert_eq!( - text(&server.durable).2, - expected, - "case {case}, seed {seed}" - ); - assert_eq!(server.publications, 1); + assert_eq!(content(&server.durable, oid), expected, "case {case}, seed {seed}"); + assert_eq!(server.publications, 2); assert!(cache.pending().unwrap().is_empty()); } } #[test] -fn remote_changes_after_resolution_are_never_overwritten() { - struct ChangedAfterRead { +fn remote_changes_after_a_conflict_are_never_overwritten() { + struct ChangedAfterStamp { server: Server, change: Option>, } - impl Remote for ChangedAfterRead { + impl Remote for ChangedAfterStamp { fn read(&mut self) -> io::Result> { - let snapshot = self.server.read()?; + self.server.read() + } + fn stamp(&mut self) -> io::Result { + let stamp = self.server.stamp()?; if let Some(changed) = self.change.take() { self.server.visible = changed.clone(); self.server.durable = changed; } - Ok(snapshot) + Ok(stamp) } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.server.publish(transaction) } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - self.server.confirm(snapshot) + fn confirm(&mut self, base: &onestore::Stamp) -> Result<(), CommitError> { + self.server.confirm(base) } } for after_read in [false, true] { @@ -850,50 +761,41 @@ fn remote_changes_after_resolution_are_never_overwritten() { let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); let (sid, oid, _) = text(&source); let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); - let id = save(&cache, oid, 1..2, "L").unwrap(); - let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); - let mut remote = ChangedAfterRead { + save(&cache, oid, 1..2, "L").unwrap(); + let remote = typed(&source, sid, oid, 1..2, "R"); + let mut remote = ChangedAfterStamp { server: Server::new(&remote), change: None, }; assert!(matches!( cache.sync_once(&mut remote).unwrap().edit, - Some((_, EditStatus::Conflict(_))) + Some((_, EditStatus::Published { .. })) )); - let id = model_ops::review(&cache, id, oid, |page| { - model_ops::replace_text(page, oid, 1..2, "L") - }) - .unwrap() - .unwrap(); - let local = content(&cache.snapshot().unwrap(), oid); - assert_eq!(local, "aLc"); - let changed = onestore::replace_text(&remote.server.visible, sid, oid, 1..2, "Q").unwrap(); + // The local version again, typed over the remote's. + let id = save(&cache, oid, 1..2, "L").unwrap(); + assert_eq!(content(&snapshot(&cache), oid), "aLc"); + let changed = typed(&remote.server.visible, sid, oid, 1..2, "Q"); if after_read { remote.change = Some(changed.clone()); + assert!( + matches!(cache.sync_once(&mut remote), Err(Error::Remote(error)) if error.state == CommitState::NotCommitted) + ); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); } else { remote.server.visible = changed.clone(); remote.server.durable = changed.clone(); } - if after_read { - assert!( - matches!(cache.sync_once(&mut remote), Err(Error::Remote(error)) if error.state == CommitState::NotCommitted) - ); - assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); - } - assert_eq!( - cache.sync_once(&mut remote).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) - ); - assert_eq!(remote.server.durable, changed); - assert_eq!(remote.server.visible, changed); - assert_eq!(remote.server.publications, usize::from(after_read)); - assert_eq!(content(&cache.snapshot().unwrap(), oid), local); - assert!(cache.pending().unwrap()[0].edit.ops.is_empty()); assert!(matches!( - &cache.pending().unwrap()[1].edit.ops[..], - [onestore::op::Op::Page { op: onestore::op::PageOp::Text { range, with, .. }, .. }] - if *range == (1..2) && with == "L" + cache.sync_once(&mut remote).unwrap().edit, + Some((_, EditStatus::Published { .. })) )); + assert_eq!(content(&remote.server.durable, oid), "aQc"); + let versions = [(model_ops::AUTHOR.to_owned(), vec!["aLc".to_owned()])]; + assert_eq!( + conflicts(&remote.server.durable), + [(sid, [versions.clone(), versions].concat())] + ); + assert!(cache.pending().unwrap().is_empty()); } } @@ -908,7 +810,7 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() { let published = save(&cache, object, 0..0, "Published ").unwrap(); let (_, receipt) = cache.sync_once(&mut server).unwrap().edit.unwrap(); assert!(matches!(receipt, EditStatus::Published { .. })); - let published_image = cache.snapshot().unwrap(); + let published_image = snapshot(&cache); cache .export_recovery(directory.path().join("published.sqlite")) .unwrap(); @@ -918,8 +820,8 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() { server.durable.clone_from(&source); let cache = Replica::open(&path).unwrap(); assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); - assert_eq!(cache.snapshot().unwrap(), source); - assert_eq!(cache.remote_snapshot().unwrap(), source); + assert_eq!(snapshot(&cache), source); + assert_eq!(remote_snapshot(&cache), source); assert_eq!(cache.status(published).unwrap(), Some(receipt.clone())); assert_eq!(server.publications, 1); assert!(cache.pending().unwrap().is_empty()); @@ -929,7 +831,7 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() { assert_eq!(text(&archive.snapshot().unwrap()).2, "Published Original"); drop(cache); let cache = Replica::open(path).unwrap(); - assert_eq!(text(&cache.snapshot().unwrap()).2, "Original"); + assert_eq!(text(&snapshot(&cache)).2, "Original"); assert_eq!(cache.status(published).unwrap(), Some(receipt.clone())); } @@ -945,14 +847,14 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() { let mut server = Server::new(&source); let published = save(&cache, object, 0..0, "Published ").unwrap(); let (_, receipt) = cache.sync_once(&mut server).unwrap().edit.unwrap(); - let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap(); + let end = u32::try_from(text(&snapshot(&cache)).2.encode_utf16().count()).unwrap(); let queued = save(&cache, object, end..end, " Local").unwrap(); if uncertain { server.fault = fault; assert!(cache.sync_once(&mut server).is_err()); } let prior_status = cache.status(queued).unwrap(); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); let pending = cache.pending().unwrap(); cache .export_recovery(directory.path().join("before-restore.sqlite")) @@ -969,7 +871,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() { assert_eq!(Some(status.clone()), prior_status); assert_eq!(server.publications, attempts); assert_eq!(server.visible, source); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + assert_eq!(pages(&snapshot(&cache)), pages(&local)); assert_eq!(cache.pending().unwrap(), pending); } else { assert!(matches!(status, EditStatus::Published { .. })); @@ -989,8 +891,10 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() { } } +/// A remote restored to an older image conflicts with local work on what it undid: the +/// restored page stays and the local version becomes its conflict page. #[test] -fn restore_conflicts_keep_dependent_work_until_mine_publishes_it() { +fn a_remote_restore_keeps_local_work_on_a_conflict_page() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("cache.sqlite"); let source = onestore::create_section("restore.one", "Original", "Fixture").unwrap(); @@ -1002,52 +906,26 @@ fn restore_conflicts_keep_dependent_work_until_mine_publishes_it() { let head = save(&cache, object, 0..9, "Revised").unwrap(); server.visible.clone_from(&source); server.durable.clone_from(&source); - let conflict = EditStatus::Conflict(ConflictKind::ContentChanged); - assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((head, conflict.clone())) - ); - let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap(); - let dependent = save(&cache, object, end..end, " Later").unwrap(); - assert_ne!(head, dependent); - let local = text(&cache.snapshot().unwrap()).2; - assert_eq!(local, "Revised Original Later"); - let pending = cache.pending().unwrap(); - assert_eq!(pending.len(), 2); - cache - .export_recovery(directory.path().join("conflicted.sqlite")) - .unwrap(); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(cache.status(head).unwrap(), Some(conflict.clone())); - assert_eq!(cache.status(dependent).unwrap(), Some(conflict.clone())); - assert_eq!(cache.pending().unwrap(), pending); - let changed = onestore::replace_text(&source, space, object, 8..8, " Remote").unwrap(); - server.visible.clone_from(&changed); - server.durable.clone_from(&changed); - assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((dependent, conflict.clone())) - ); - assert_eq!(cache.remote_snapshot().unwrap(), changed); - assert_eq!(server.publications, 1); - cache.resolve(head, Resolution::Mine).unwrap(); - drop(cache); - let cache = Replica::open(&path).unwrap(); assert!(matches!( cache.sync_once(&mut server).unwrap().edit, Some((_, EditStatus::Published { .. })) )); - assert_eq!(text(&server.durable).2, local); + assert_eq!(content(&server.durable, object), "Original"); + assert_eq!( + conflicts(&server.durable), + [( + space, + vec![(model_ops::AUTHOR.to_owned(), vec!["Revised Original".to_owned()])] + )] + ); + drop(cache); + let cache = Replica::open(&path).unwrap(); assert!(matches!( cache.status(head).unwrap(), Some(EditStatus::Published { .. }) )); - assert_eq!(cache.status(published).unwrap(), Some(receipt.clone())); - let archive = notebook::Recovery::open(directory.path().join("conflicted.sqlite")).unwrap(); - assert_eq!(text(&archive.snapshot().unwrap()).2, local); - assert_eq!(archive.pending().unwrap(), pending); - assert_eq!(archive.status(head).unwrap(), Some(conflict.clone())); + assert_eq!(cache.status(published).unwrap(), Some(receipt)); + assert!(cache.pending().unwrap().is_empty()); } #[test] diff --git a/crates/notebook/tests/sync/tree.rs b/crates/notebook/tests/sync/tree.rs index 41aac56061497585288e7811cef9426685ea691c..ba1f76c27c65ac8704f72946e5dc63327df0fb6b 100644 --- a/crates/notebook/tests/sync/tree.rs +++ b/crates/notebook/tests/sync/tree.rs @@ -34,9 +34,9 @@ fn fixture() -> (Vec, ExGuid, ExGuid, [ExGuid; 4], [ExGuid; 4]) { for at in 1..4 { anchor = model_ops::insert_after(&mut page, anchor, &format!("Sibling {at}")).1; } - let source = PreparedEdit::page(&source, space, &page, "Author") + let source = ops::saved(&source, space, &page) .unwrap() - .as_bytes() + .as_slice() .to_vec(); let stored = page_of(&source, space); let siblings = &outline_of(&stored, outline).paragraphs; @@ -87,12 +87,12 @@ fn move_preserves_remote_content_and_dependent_edits_through_reopen() { }) .unwrap() .unwrap(); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); let queue = cache.pending().unwrap(); drop(cache); let cache = Replica::open(&path).unwrap(); assert_eq!(cache.pending().unwrap(), queue); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + assert_eq!(pages(&snapshot(&cache)), pages(&local)); let mut server = Server::new(&remote_with(&source, space, |page| { insert_child(page, paragraphs[0], "New remote child"); restyle(page, texts[1], 0..7, |format| format.bold = Some(true)); @@ -185,14 +185,16 @@ fn native_empty_child_list_normalization_merges_with_a_local_deletion() { } } +/// Deleting what the remote changed keeps the remote's content, and the local version on a +/// conflict page; the edits that do not depend on the deletion merge. #[test] -fn deletion_requires_review_of_remote_content_and_preserves_later_work() { +fn deletion_of_remote_content_keeps_a_conflict_page_and_merges_later_work() { let (source, space, outline, paragraphs, texts) = fixture(); let mut page = page_of(&source, space); insert_child(&mut page, paragraphs[0], "Descendant"); - let source = PreparedEdit::page(&source, space, &page, "Author") + let source = ops::saved(&source, space, &page) .unwrap() - .as_bytes() + .as_slice() .to_vec(); let child = outline_of(&page_of(&source, space), outline).paragraphs[1] .text() @@ -215,24 +217,20 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() { replace_text(page, texts[1], 0..0, "Local "); }; let id = save(&cache, texts[1], change).unwrap().unwrap(); - let queue = cache.pending().unwrap(); - let local = cache.snapshot().unwrap(); let mut server = Server::new(&remote_with(&source, space, remote)); + assert!(conflicted(&cache, &mut server, space)); assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) + text_of(&page_of(&server.durable, space), texts[1]), + "Local Sibling 1" ); - assert_eq!(server.publications, 0); - assert_eq!(cache.pending().unwrap(), queue); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); drop(cache); let cache = Replica::open(&path).unwrap(); - assert_eq!( - cache.status(id).unwrap(), - Some(EditStatus::Conflict(ConflictKind::ContentChanged)) - ); - review(&cache, id, texts[1], change).unwrap(); published(&cache, &mut server, id); + // The writer deletes the paragraph again, remote changes and all. + let again = save(&cache, texts[1], |page| delete_paragraph(page, texts[0])) + .unwrap() + .unwrap(); + published(&cache, &mut server, again); assert_eq!( order(&server.durable, space, outline), paragraphs[1..].to_vec() @@ -247,7 +245,7 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() { #[test] fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() { let (source, space, outline, paragraphs, texts) = fixture(); - // The published order of the fixture's paragraphs, or a conflict. + // The published order of the fixture's paragraphs, or a conflict page. let cases: [(Change, Option<&[usize]>); 5] = [ ( Box::new(move |page| { @@ -279,14 +277,16 @@ fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() { .unwrap() .unwrap(); let mut server = Server::new(&remote_with(&source, space, remote)); - let result = cache.sync_once(&mut server).unwrap().edit.unwrap(); - assert_eq!(result.0, id); + let conflict = conflicted(&cache, &mut server, space); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); let Some(expected) = expected else { - assert_eq!(result.1, EditStatus::Conflict(ConflictKind::ContentChanged)); - assert_eq!(server.publications, 0); + assert!(conflict); continue; }; - assert!(matches!(result.1, EditStatus::Published { .. })); + assert!(!conflict); let published: Vec = order(&server.durable, space, outline) .into_iter() .filter(|id| paragraphs.contains(id)) @@ -352,7 +352,7 @@ fn cell_mut(page: &mut Page, id: ExGuid) -> &mut TableCell { } /// Moves a cell's sole paragraph to the end of another cell, or deletes it, leaving the -/// cell an empty paragraph as the writers do. +/// cell a new empty paragraph, as an edit that empties a cell must. fn empty_cell(page: &mut Page, cell: ExGuid, destination: Option) { let emptied = cell_mut(page, cell); let mut replacement = model_ops::fresh_paragraph(&emptied.paragraphs[0], ""); @@ -419,7 +419,7 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay( empty_cell(page, cell, move_out.then_some(neighbour)); }) .unwrap(); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); let queue = cache.pending().unwrap(); { let page = page_of(&local, space); @@ -431,7 +431,7 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay( drop(cache); let cache = Replica::open(&path).unwrap(); assert_eq!(cache.pending().unwrap(), queue); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + assert_eq!(pages(&snapshot(&cache)), pages(&local)); let mut server = Server::new(&if competing { remote_with(source, space, |page| { let target = cell_mut(page, cell); @@ -574,28 +574,20 @@ fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_conten let path = directory.path().join("native.sqlite"); let cache = Replica::create(&path, BEFORE).unwrap(); let id = save(&cache, dependent, save_both).unwrap().unwrap(); - let local = cache.snapshot().unwrap(); - let queue = cache.pending().unwrap(); - let merged = match cache.sync_once(&mut server).unwrap().edit.unwrap() { - (actual, EditStatus::Published { .. }) => { - assert_eq!(actual, id, "{name}"); - true + let merged = !conflicted(&cache, &mut server, space); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + if !merged { + // The dependent text merged; the writer makes the change again. + drop(cache); + let cache = Replica::open(&path).unwrap(); + // Unless the remote made the same change. + if let Some(again) = save(&cache, dependent, &change).unwrap() { + published(&cache, &mut server, again); } - result => { - assert_eq!( - result, - (id, EditStatus::Conflict(ConflictKind::ContentChanged)), - "{name}" - ); - assert_eq!(cache.pending().unwrap(), queue); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); - drop(cache); - let cache = Replica::open(&path).unwrap(); - review(&cache, id, dependent, save_both).unwrap(); - published(&cache, &mut server, id); - false - } - }; + } let durable = page_of(&server.durable, space); assert!( text_of(&durable, dependent).starts_with("Offline "), diff --git a/crates/notebook/tests/sync_formatting.rs b/crates/notebook/tests/sync_formatting.rs index dca00aa6e1511bafceb8e6c0f5b63620c83d7fbf..2810b47732944500353c35c1ac95d4d2275082e7 100644 --- a/crates/notebook/tests/sync_formatting.rs +++ b/crates/notebook/tests/sync_formatting.rs @@ -1,21 +1,23 @@ //! Formatting and insertion as page-model differences: what reconciles with an independent -//! remote change, what conflicts, and what a reviewed conflict publishes. +//! remote change, what keeps a conflict page, and what the edit made again publishes. -use notebook::{ConflictKind, EditStatus, Replica}; +#[path = "../../onestore/tests/support/ops.rs"] +mod ops; + +use notebook::{EditStatus, Replica}; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, document::{Document, Format}, page::{Page, PageObject}, }; #[path = "support/server.rs"] mod server; -use server::{Fault, Server, pages}; +use server::{Fault, Server, conflicted, pages, snapshot}; #[path = "support/model_ops.rs"] mod model_ops; use model_ops::{ - AUTHOR, insert_after, insert_outline, page_of, paragraph_with, replace_text, restyle, review, - save, + AUTHOR, insert_after, insert_outline, page_of, paragraph_with, replace_text, restyle, save, }; const OUTLINES: &[u8] = @@ -80,9 +82,9 @@ fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server { let mut page = page_of(OUTLINES, space); change(&mut page); Server::new( - PreparedEdit::page(OUTLINES, space, &page, "Native author") + ops::saved(OUTLINES, space, &page) .unwrap() - .as_bytes(), + .as_slice(), ) } @@ -112,7 +114,7 @@ fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() { .all(|format| format.bold == Some(true)) ); assert_eq!(text_of(&published, ids[3]), "Remote Trailing sibling"); - assert_eq!(cache.snapshot().unwrap(), server.durable); + assert_eq!(snapshot(&cache), server.durable); } #[test] @@ -127,7 +129,7 @@ fn offline_formatting_conflicts_only_with_remote_changes_it_overlaps() { }) .unwrap() .unwrap(); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); let mut server = remote_with(space, |page| { if typed { replace_text(page, ids[0], 0..0, "Remote "); @@ -150,18 +152,18 @@ fn offline_formatting_conflicts_only_with_remote_changes_it_overlaps() { ); } } else { - assert_eq!( - outcome, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) - ); - assert_eq!(server.publications, 0); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + assert!(matches!(outcome, Some((_, EditStatus::Published { .. })))); + assert!(conflicted(&server.durable, space)); + let remote = page_of(&server.durable, space); + assert!(formats(&remote, ids[0]).iter().all(|format| format.bold != Some(true))); + assert_eq!(pages(&snapshot(&cache)), pages(&server.durable)); + assert_ne!(pages(&local), pages(&server.durable)); } } } #[test] -fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() { +fn competing_formatting_of_one_paragraph_keeps_a_conflict_page_until_made_again() { let (space, page) = page_titled(OUTLINES, PAGE); let ids = plain(&page); let directory = tempfile::tempdir().unwrap(); @@ -172,24 +174,22 @@ fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() { }) .unwrap() .unwrap(); - let pending = cache.pending().unwrap(); let mut server = remote_with(space, |page| { restyle(page, ids[0], 0..3, |format| format.italic = Some(true)); }); - for _ in 0..2 { - assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) - ); - } - assert_eq!(cache.pending().unwrap(), pending); + assert!(matches!( + cache.sync_once(&mut server).unwrap().edit, + Some((_, EditStatus::Published { .. })) + )); + assert!(conflicted(&server.durable, space)); + assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); drop(cache); let cache = Replica::open(&path).unwrap(); - assert_eq!( + assert!(matches!( cache.status(id).unwrap(), - Some(EditStatus::Conflict(ConflictKind::ContentChanged)) - ); - review(&cache, id, ids[0], |page| { + Some(EditStatus::Published { .. }) + )); + save(&cache, ids[0], |page| { restyle(page, ids[0], 0..6, |format| format.font_size = Some(14.0)); }) .unwrap(); @@ -287,7 +287,7 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone }) .unwrap() .unwrap(); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); let mut server = remote_with(space, |page| { for (step, &at) in remote.iter().enumerate() { if step % 2 == 0 { @@ -313,17 +313,10 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone } if underlined[target] && start == 0 { conflicts += 1; - assert_eq!( - outcome.1, - EditStatus::Conflict(ConflictKind::ContentChanged), - "seed {seed}" - ); - assert_eq!(server.publications, 0, "seed {seed}"); - assert_eq!( - pages(&cache.snapshot().unwrap()), - pages(&local), - "seed {seed}" - ); + assert!(matches!(outcome.1, EditStatus::Published { .. }), "seed {seed}"); + assert!(conflicted(&server.durable, space), "seed {seed}"); + assert_eq!(page_of(&server.durable, space), remote_page, "seed {seed}"); + assert_ne!(pages(&snapshot(&cache)), pages(&local), "seed {seed}"); continue; } published += 1; @@ -345,7 +338,7 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone "seed {seed}" ); } - assert_eq!(cache.snapshot().unwrap(), server.durable, "seed {seed}"); + assert_eq!(snapshot(&cache), server.durable, "seed {seed}"); } assert!( published >= 16, @@ -366,11 +359,11 @@ fn offline_insertions_survive_reopen_and_carry_their_formatting() { }) .unwrap() .unwrap(); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); let pending = cache.pending().unwrap(); drop(cache); cache = Replica::open(&path).unwrap(); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + assert_eq!(pages(&snapshot(&cache)), pages(&local)); assert_eq!(cache.pending().unwrap(), pending); let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote ")); assert!( @@ -399,7 +392,7 @@ fn offline_insertions_survive_reopen_and_carry_their_formatting() { for (offset, format) in styles.iter().enumerate() { assert_eq!(format.bold == Some(true), (8..17).contains(&offset)); } - assert_eq!(cache.snapshot().unwrap(), server.durable); + assert_eq!(snapshot(&cache), server.durable); } #[test] @@ -415,7 +408,7 @@ fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() { }) .unwrap() .unwrap(); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); let mut server = Server::new(OUTLINES); server.fault = fault; assert!(cache.sync_once(&mut server).is_err()); @@ -433,7 +426,7 @@ fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() { } assert_eq!(server.publications, 1); assert_eq!(server.durable, OUTLINES); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + assert_eq!(pages(&snapshot(&cache)), pages(&local)); continue; } assert!(matches!( @@ -474,7 +467,7 @@ fn a_format_the_remote_already_carries_is_confirmed_without_republishing() { ); assert_eq!((server.publications, server.confirmations), (0, 2)); assert!(cache.pending().unwrap().is_empty()); - let published = page_of(&cache.snapshot().unwrap(), space); + let published = page_of(&snapshot(&cache), space); assert_eq!(published, page_of(&server.durable, space)); assert!( formats(&published, ids[0]) @@ -552,7 +545,7 @@ fn paragraph_formatting_survives_reopen_rebase_and_a_lost_publication_reply() { let id = save(&cache, ids[0], |page| restyle(page, ids[0], 0..6, apply)) .unwrap() .unwrap(); - let expected = formats(&page_of(&cache.snapshot().unwrap(), space), ids[0]); + let expected = formats(&page_of(&snapshot(&cache), space), ids[0]); drop(cache); let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap(); let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote ")); diff --git a/crates/notebook/tests/sync_model.rs b/crates/notebook/tests/sync_model.rs index 5132e5f0cb78f260ebda6bb0a5091b5e7e9331ad..1de8ceb7ce71feadc18569948778bcc3f8e0b6c2 100644 --- a/crates/notebook/tests/sync_model.rs +++ b/crates/notebook/tests/sync_model.rs @@ -1,8 +1,11 @@ //! Page-model saves: one intent per page until it is attempted, reconciliation against the //! remote page, reviewed conflicts, and independence between pages. -use notebook::{ConflictKind, EditStatus, Replica}; -use onestore::{ExGuid, PreparedEdit, RevisionIndex, Store, document::Document}; +#[path = "../../onestore/tests/support/ops.rs"] +mod ops; + +use notebook::{EditStatus, Replica}; +use onestore::{ExGuid, RevisionIndex, Store, document::Document}; #[path = "support/server.rs"] mod server; @@ -80,7 +83,7 @@ fn a_saved_page_model_publishes_and_reads_back() { .unwrap() .unwrap(); assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); - assert_eq!(page_of(&cache.snapshot().unwrap(), space), after); + assert_eq!(page_of(&snapshot(&cache), space), after); let pending = cache.pending().unwrap(); assert_eq!(pending.len(), 1); assert_eq!(pending[0].author, "Model author"); @@ -113,7 +116,7 @@ fn saves_before_an_attempt_publish_together() { let id = model_ops::save_as(&cache, space, &after, "Model author") .unwrap() .unwrap(); - let mut later = page_of(&cache.snapshot().unwrap(), space); + let mut later = page_of(&snapshot(&cache), space); append(&mut later, " two"); let second = model_ops::save_as(&cache, space, &later, "Model author") .unwrap() @@ -133,7 +136,7 @@ fn saves_before_an_attempt_publish_together() { first_text(&page_of(&server.durable, space)), first_text(&later) ); - let mut third = page_of(&cache.snapshot().unwrap(), space); + let mut third = page_of(&snapshot(&cache), space); append(&mut third, " three"); let next = model_ops::save_as(&cache, space, &third, "Model author") .unwrap() @@ -157,7 +160,7 @@ fn an_attempted_save_is_never_rewritten() { cache.status(id).unwrap(), Some(EditStatus::AwaitingConfirmation { .. }) )); - let mut more = page_of(&cache.snapshot().unwrap(), space); + let mut more = page_of(&snapshot(&cache), space); append(&mut more, " more"); let next = model_ops::save_as(&cache, space, &more, "Model author") .unwrap() @@ -165,7 +168,7 @@ fn an_attempted_save_is_never_rewritten() { assert!(next > id); let pending = cache.pending().unwrap(); assert_eq!(pending.iter().map(|p| p.id).collect::>(), [id, next]); - assert_eq!(page_of(&cache.snapshot().unwrap(), space), more); + assert_eq!(page_of(&snapshot(&cache), space), more); assert!(matches!( cache.status(id).unwrap(), Some(EditStatus::AwaitingConfirmation { .. }) @@ -173,8 +176,10 @@ fn an_attempted_save_is_never_rewritten() { assert_eq!(cache.status(next).unwrap(), Some(EditStatus::Pending)); } +/// A remote change to the saved text keeps the remote's page and the local one as its +/// conflict page; the writer's change made again publishes over the remote's. #[test] -fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() { +fn a_remote_change_to_the_saved_page_keeps_a_conflict_page() { let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); let directory = tempfile::tempdir().unwrap(); let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); @@ -184,49 +189,37 @@ fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() { .unwrap(); let mut remote_page = page_of(OUTLINES, space); append(&mut remote_page, " remote"); - let remote = PreparedEdit::page(OUTLINES, space, &remote_page, "Native author").unwrap(); - let mut server = Server::new(remote.as_bytes()); - assert_eq!( + let remote = ops::saved(OUTLINES, space, &remote_page).unwrap(); + let mut server = Server::new(remote.as_slice()); + assert!(matches!( cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) + Some((_, EditStatus::Published { .. })) + )); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + assert_eq!(server.publications, 1); + let published = page_of(&server.durable, space); + assert_eq!(first_text(&published), first_text(&remote_page)); + assert_eq!( + conflicts(&server.durable)[0].1[0].0, + "Model author", + "the conflict page names whose version it is" ); - assert_eq!(server.publications, 0); - assert!(cache.pending().unwrap().len() == 1); - let observed = cache.remote_snapshot().unwrap(); - assert_eq!(observed, remote.as_bytes()); - let mut reviewed = page_of(&observed, space); - append(&mut reviewed, " local"); - assert!(cache.resolve(id + 1, notebook::Resolution::Theirs).is_err()); - let text = model_ops::paragraph_with(&reviewed, { - let outline = reviewed - .objects - .iter() - .find_map(|object| match object { - PageObject::Outline(outline) => Some(outline), - _ => None, - }) - .unwrap(); - outline.paragraphs[0].text().unwrap().id - }) - .unwrap() - .text() - .unwrap() - .id; - let reviewed_id = model_ops::review(&cache, id, text, |page| append(page, " local")) + let text = published + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => outline.paragraphs[0].text().map(|text| text.id), + _ => None, + }) + .unwrap(); + let again = model_ops::save(&cache, text, |page| append(page, " local")) .unwrap() .unwrap(); - // The conflicted edit stays, emptied, and publishes with the reviewed one. - assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); - assert_eq!( - cache.status(reviewed_id).unwrap(), - Some(EditStatus::Pending) - ); assert!( - matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == reviewed_id) - ); - assert_eq!( - first_text(&page_of(&server.durable, space)), - first_text(&reviewed) + matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == again) ); assert!(first_text(&page_of(&server.durable, space)).ends_with(" remote local")); } @@ -242,14 +235,14 @@ fn remote_changes_to_other_pages_do_not_conflict() { .unwrap() .unwrap(); append(&mut other_page, " elsewhere"); - let remote = PreparedEdit::page(OUTLINES, other, &other_page, "Native author").unwrap(); - let mut server = Server::new(remote.as_bytes()); + let remote = ops::saved(OUTLINES, other, &other_page).unwrap(); + let mut server = Server::new(remote.as_slice()); assert!( matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) ); assert_eq!(page_of(&server.durable, space), after); assert_eq!(page_of(&server.durable, other), other_page); - assert_eq!(page_of(&cache.snapshot().unwrap(), other), other_page); + assert_eq!(page_of(&snapshot(&cache), other), other_page); } #[test] @@ -267,7 +260,7 @@ fn pending_saves_survive_reopening_the_cache() { let reopened = Replica::open(&path).unwrap(); assert_eq!(reopened.pending().unwrap(), pending); assert_eq!(reopened.status(id).unwrap(), Some(EditStatus::Pending)); - assert_eq!(page_of(&reopened.snapshot().unwrap(), space), after); + assert_eq!(page_of(&snapshot(&reopened), space), after); } /// Edits paragraph `index` of the first body outline: replaces its text range with `replacement`. @@ -309,11 +302,7 @@ fn texts(page: &Page) -> Vec { fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server { let mut page = page_of(OUTLINES, space); change(&mut page); - Server::new( - PreparedEdit::page(OUTLINES, space, &page, "Native author") - .unwrap() - .as_bytes(), - ) + Server::new(ops::saved(OUTLINES, space, &page).unwrap().as_slice()) } #[test] @@ -366,11 +355,16 @@ fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() { .unwrap() .unwrap(); let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..6, "Remote")); - assert_eq!( + assert!(matches!( cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) - ); - assert_eq!(server.publications, 0); + Some((_, EditStatus::Published { .. })) + )); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + assert!(conflicted(&server.durable, space)); + assert_eq!(server.publications, 1); } #[test] @@ -428,7 +422,7 @@ fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() { } #[test] -fn a_remote_deletion_of_the_edited_paragraph_conflicts() { +fn a_remote_deletion_of_the_edited_paragraph_keeps_a_conflict_page() { let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); let directory = tempfile::tempdir().unwrap(); let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); @@ -447,10 +441,15 @@ fn a_remote_deletion_of_the_edited_paragraph_conflicts() { .unwrap(); outline.paragraphs.pop(); }); - assert_eq!( + assert!(matches!( cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) - ); + Some((_, EditStatus::Published { .. })) + )); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + assert!(conflicted(&server.durable, space)); } #[test] @@ -515,10 +514,15 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() { }; outline.layout.x = Some(50.0); }); - assert_eq!( + assert!(matches!( cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) - ); + Some((_, EditStatus::Published { .. })) + )); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + assert!(conflicted(&server.durable, space)); } #[test] diff --git a/crates/notebook/tests/sync_outline.rs b/crates/notebook/tests/sync_outline.rs index f136c756bf1812076e9fc045271e37caacc0d554..79b2085fb9e9750bb53c2cb233be5c9187d2e528 100644 --- a/crates/notebook/tests/sync_outline.rs +++ b/crates/notebook/tests/sync_outline.rs @@ -1,9 +1,12 @@ //! Outline layout through page-model saves: reconciliation against the remote page, //! review of competing layout, twelve offline writers, and the native outline fixtures. -use notebook::{ConflictKind, EditStatus, Replica}; +#[path = "../../onestore/tests/support/ops.rs"] +mod ops; + +use notebook::{EditStatus, Replica}; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, document::Document, page::{Outline, Page, PageObject, PageParagraph}, }; @@ -11,12 +14,12 @@ use serde_json::json; #[path = "support/server.rs"] mod server; -use server::{Fault, Server, pages}; +use server::{Fault, Server, conflicts, pages, snapshot}; #[path = "support/model_ops.rs"] mod model_ops; use model_ops::{ delete_paragraph, insert_outline, outlines_mut, page_of, paragraph_with, replace_text, restyle, - review, save, + save, }; #[path = "support/model_schedule.rs"] mod model_schedule; @@ -99,12 +102,30 @@ pub(crate) fn text_of(page: &Page, text: ExGuid) -> String { pub(crate) fn remote_with(source: &[u8], space: ExGuid, change: impl FnOnce(&mut Page)) -> Vec { let mut page = page_of(source, space); change(&mut page); - PreparedEdit::page(source, space, &page, "Native author") + ops::saved(source, space, &page) .unwrap() - .as_bytes() + .as_slice() .to_vec() } +/// Runs one synchronization step, which publishes; true when the remote's version of +/// `space` stayed and the local one became a conflict page under it. +pub(crate) fn conflicted(cache: &Replica, server: &mut Server, space: ExGuid) -> bool { + let count = |image: &[u8]| { + conflicts(image) + .into_iter() + .filter(|(page, _)| *page == space) + .map(|(_, pages)| pages.len()) + .sum::() + }; + let before = count(&server.durable); + assert!(matches!( + cache.sync_once(server).unwrap().edit, + Some((_, EditStatus::Published { .. })) + )); + count(&server.durable) > before +} + /// Publishes the batch holding `id`, unless an earlier step already did. pub(crate) fn published(cache: &Replica, server: &mut Server, id: u64) { if !matches!( @@ -149,13 +170,13 @@ fn layout_and_dependent_text_survive_reopen_and_a_remote_format_of_the_same_page let path = directory.path().join("cache.sqlite"); let cache = Replica::create(&path, &source).unwrap(); let id = save(&cache, text_id, &change).unwrap().unwrap(); - let expected = page_of(&cache.snapshot().unwrap(), space); + let expected = page_of(&snapshot(&cache), space); let pending = cache.pending().unwrap(); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); drop(cache); let cache = Replica::open(&path).unwrap(); assert_eq!(cache.pending().unwrap(), pending); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + assert_eq!(pages(&snapshot(&cache)), pages(&local)); let mut server = Server::new(&remote_with(&source, space, |page| { restyle(page, text_id, 0..8, |format| format.bold = Some(true)); })); @@ -208,9 +229,9 @@ fn an_uncertain_move_confirms_from_the_remote_revision_and_adopts_the_remote_tit let (source, space, outline, _) = fixture(); let mut page = page_of(&source, space); insert_outline(&mut page, 144.0, 36.0, "Second"); - let source = PreparedEdit::page(&source, space, &page, "Author") + let source = ops::saved(&source, space, &page) .unwrap() - .as_bytes() + .as_slice() .to_vec(); let second = text_with(&page_of(&source, space), "Second"); let directory = tempfile::tempdir().unwrap(); @@ -242,8 +263,9 @@ fn an_uncertain_move_confirms_from_the_remote_revision_and_adopts_the_remote_tit assert_eq!((server.publications, server.confirmations), (1, 1)); } +/// Competing layout keeps the remote's on the page and the local one on its conflict page. #[test] -fn competing_layout_conflicts_until_the_local_page_is_kept() { +fn competing_layout_keeps_the_local_one_on_a_conflict_page() { let (source, space, outline, text_id) = fixture(); let cases: [(Change, Change); 2] = [ (Box::new(moved(180.0, 216.0)), Box::new(moved(288.0, 216.0))), @@ -256,46 +278,40 @@ fn competing_layout_conflicts_until_the_local_page_is_kept() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("cache.sqlite"); let cache = Replica::create(&path, &source).unwrap(); - let id = save(&cache, text_id, |page| { + save(&cache, text_id, |page| { local_change(outline_mut(page, outline)) }) .unwrap() .unwrap(); - let local = cache.snapshot().unwrap(); - let mut server = Server::new(&remote_with(&source, space, |page| { + let local = page_of(&snapshot(&cache), space); + let remote = remote_with(&source, space, |page| { remote_change(outline_mut(page, outline)); - })); + }); + let mut server = Server::new(&remote); + assert!(conflicted(&cache, &mut server, space)); + assert_eq!(server.publications, 1); + let durable = page_of(&server.durable, space); assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) + outline_of(&durable, outline).layout, + outline_of(&page_of(&remote, space), outline).layout ); - assert_eq!(server.publications, 0); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, server.durable.clone()).unwrap(); + let conflict = section.conflicts().unwrap()[0].1[0].space; + let kept = section.page(conflict).unwrap(); + assert_eq!(body_outlines(&kept)[0].layout, outline_of(&local, outline).layout); let dependent = save(&cache, text_id, |page| { replace_text(page, text_id, 0..0, "Local ") }) .unwrap() .unwrap(); - let local = cache.snapshot().unwrap(); drop(cache); let cache = Replica::open(&path).unwrap(); + published(&cache, &mut server, dependent); assert_eq!( - cache.status(id).unwrap(), - Some(EditStatus::Conflict(ConflictKind::ContentChanged)) + text_of(&page_of(&server.durable, space), text_id), + "Local Original 🦀 é" ); - cache.resolve(id, notebook::Resolution::Mine).unwrap(); - assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); - drop(cache); - let cache = Replica::open(&path).unwrap(); - for id in [id, dependent] { - published(&cache, &mut server, id); - } - let durable = page_of(&server.durable, space); - assert_eq!( - outline_of(&durable, outline).layout, - outline_of(&page_of(&local, space), outline).layout - ); - assert_eq!(text_of(&durable, text_id), "Local Original 🦀 é"); } } @@ -346,15 +362,14 @@ fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restart paragraph_mut(page, text_id).collapsed = (actor + round) % 2 != 0; }; let id = save(&cache, text_id, change).unwrap().unwrap(); - let expected = page_of(&cache.snapshot().unwrap(), space); + let expected = page_of(&snapshot(&cache), space); drop(cache); let cache = Replica::open(&path).unwrap(); - if cache.sync_once(&mut server).unwrap().edit - == Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) - { - review(&cache, id, text_id, change).unwrap(); + if conflicted(&cache, &mut server, space) { + // The writer makes the change again over the remote's version. + let again = save(&cache, text_id, change).unwrap().unwrap(); reviewed += 1; - published(&cache, &mut server, id); + published(&cache, &mut server, again); } assert!(matches!( cache.status(id).unwrap(), @@ -388,7 +403,7 @@ fn an_uncertain_layout_attempt_confirms_by_revision_or_by_an_equal_remote_page() }) .unwrap() .unwrap(); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); let mut server = Server::new(&source); server.fault = fault; assert!(cache.sync_once(&mut server).is_err()); @@ -416,7 +431,7 @@ fn an_uncertain_layout_attempt_confirms_by_revision_or_by_an_equal_remote_page() cache.sync_once(&mut server).unwrap().edit, Some((id, status.clone())) ); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + assert_eq!(pages(&snapshot(&cache)), pages(&local)); server.visible = remote_with(&source, space, |page| { resized(144.0, true)(outline_mut(page, outline)); }); @@ -483,7 +498,7 @@ pub(crate) fn native_pages(source: &[u8]) -> Vec<(String, ExGuid, ExGuid, ExGuid } #[test] -fn native_moves_deletions_and_layout_changes_merge_or_require_review() { +fn native_moves_deletions_and_layout_changes_merge_or_keep_a_conflict_page() { let mut server = Server::new(NATIVE); let mut records = Vec::new(); for (name, space, outline, target, dependent) in native_pages(BEFORE) { @@ -496,28 +511,20 @@ fn native_moves_deletions_and_layout_changes_merge_or_require_review() { let path = directory.path().join("cache.sqlite"); let cache = Replica::create(&path, BEFORE).unwrap(); let id = save(&cache, dependent, save_both).unwrap().unwrap(); - let local = cache.snapshot().unwrap(); - let queue = cache.pending().unwrap(); - let merged = match cache.sync_once(&mut server).unwrap().edit.unwrap() { - (actual, EditStatus::Published { .. }) => { - assert_eq!(actual, id, "{name}"); - true + let merged = !conflicted(&cache, &mut server, space); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + if !merged { + // The dependent text merged; the writer makes the change again. + drop(cache); + let cache = Replica::open(&path).unwrap(); + // Unless the remote made the same change. + if let Some(again) = save(&cache, dependent, &change).unwrap() { + published(&cache, &mut server, again); } - result => { - assert_eq!( - result, - (id, EditStatus::Conflict(ConflictKind::ContentChanged)), - "{name}" - ); - assert_eq!(cache.pending().unwrap(), queue); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); - drop(cache); - let cache = Replica::open(&path).unwrap(); - review(&cache, id, dependent, save_both).unwrap(); - published(&cache, &mut server, id); - false - } - }; + } let durable = page_of(&server.durable, space); assert!( text_of(&durable, dependent).starts_with("Local "), @@ -564,7 +571,7 @@ fn native_moves_deletions_and_layout_changes_merge_or_require_review() { } #[test] -fn a_new_native_wrap_reservation_requires_review_before_width_replacement() { +fn a_new_native_wrap_reservation_keeps_the_local_width_on_a_conflict_page() { let (_, space, outline, _, text_id) = native_pages(BEFORE) .into_iter() .find(|(name, ..)| name == "Move outline") @@ -584,15 +591,17 @@ fn a_new_native_wrap_reservation_requires_review_before_width_replacement() { .unwrap() .unwrap(); let mut server = Server::new(NATIVE); - assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) - ); - review(&cache, id, text_id, |page| { + assert!(conflicted(&cache, &mut server, space)); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + let again = save(&cache, text_id, |page| { resized(144.0, true)(outline_mut(page, outline)) }) + .unwrap() .unwrap(); - published(&cache, &mut server, id); + published(&cache, &mut server, again); let layout = outline_of(&page_of(&server.durable, space), outline) .layout .clone(); diff --git a/crates/notebook/tests/sync_page.rs b/crates/notebook/tests/sync_page.rs index 2c434a6a1b628b0499c9ac12b530f23506aa542b..1fa47119d629b644e5e0c5ef2c259a0ad5e18e6f 100644 --- a/crates/notebook/tests/sync_page.rs +++ b/crates/notebook/tests/sync_page.rs @@ -1,7 +1,10 @@ -//! Page creation reconciliation: dependent body saves, uncertain publication, anchor review, +//! Page creation reconciliation: dependent body saves, uncertain publication, lost anchors, //! and deterministic multi-actor schedules. -use notebook::{ConflictKind, EditStatus, Recovery, Replica, Resolution}; +#[path = "../../onestore/tests/support/ops.rs"] +mod ops; + +use notebook::{EditStatus, Recovery, Replica}; use onestore::op::SectionOp; use onestore::{ ExGuid, PageCreation, RevisionIndex, Store, @@ -67,11 +70,11 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() { assert!( matches!(&original[0].edit.ops[..], [onestore::op::Op::Section(SectionOp::Create(retained))] if retained == &page) ); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); drop(cache); let cache = Replica::open(&path).unwrap(); assert_eq!(cache.pending().unwrap(), original); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + assert_eq!(pages(&snapshot(&cache)), pages(&local)); assert!(matches!( cache.sync_once(&mut server).unwrap().edit, Some((published, EditStatus::Published { .. })) if published == dependent @@ -120,7 +123,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() { let cache = Replica::create(&path, &source).unwrap(); let page = PageCreation::new(None, Some("Created"), "Author").unwrap(); let id = section_op(&cache, SectionOp::Create(page.clone())); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); let mut server = Server::new(&source); server.fault = fault; let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -155,12 +158,12 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() { assert_eq!(server.confirmations, usize::from(visible)); if visible { assert!(matches!(result.1, EditStatus::Published { .. })); - assert!(cache.snapshot().unwrap() == observed); + assert!(snapshot(&cache) == observed); assert!(observed[..212] == server.durable[..212]); assert!(observed[252..] == server.durable[252..]); } else { assert_eq!(result, (id, attempted.clone())); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + assert_eq!(pages(&snapshot(&cache)), pages(&local)); } } } @@ -235,8 +238,10 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() { assert_eq!(server.publications, 1); } +/// A page created before a page the remote moved goes before the next page the remote left +/// in place, its content under the identities it was made with. #[test] -fn changed_page_anchor_conflicts_until_kept_without_regenerating_dependent_identities() { +fn a_moved_page_anchor_places_the_page_without_regenerating_dependent_identities() { let source = include_bytes!("../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one"); let remote = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); let store = Store::parse(source).unwrap(); @@ -252,30 +257,29 @@ fn changed_page_anchor_conflicts_until_kept_without_regenerating_dependent_ident let save = model_ops::save_as(&cache, page.space(), &created, "Author") .unwrap() .unwrap(); - let local = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); + let local = snapshot(&cache); let mut server = Server::new(remote); - assert_eq!( + assert!(matches!( cache.sync_once(&mut server).unwrap().edit, - Some((save, EditStatus::Conflict(ConflictKind::StructureChanged))) - ); - cache.resolve(id, Resolution::Mine).unwrap(); - assert_eq!(cache.pending().unwrap()[1], pending[1]); + Some((published, EditStatus::Published { .. })) if published == save + )); assert!(matches!( - &cache.pending().unwrap()[0].edit.ops[..], - [onestore::op::Op::Section(SectionOp::Create(reviewed))] if *reviewed == page.reposition(None).unwrap() + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) )); + assert_eq!(server.publications, 1); assert_eq!( - model_ops::page_of(&cache.snapshot().unwrap(), page.space()), + model_ops::page_of(&server.durable, page.space()), model_ops::page_of(&local, page.space()) ); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert!(matches!( - cache.sync_once(&mut server).unwrap().edit, - Some((_, EditStatus::Published { .. })) - )); - assert_eq!(server.publications, 1); + let arena = onestore::Arena::default(); + let listed = onestore::Section::open(&arena, server.durable.clone()) + .unwrap() + .pages() + .unwrap(); + // The remote made `child` and `grandchild` subpages; `Renamed` stayed. + let at = listed.iter().position(|listed| listed.0 == pages[6].0).unwrap(); + assert_eq!(listed[at - 1].0, page.space()); let store = Store::parse(&server.durable).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); @@ -285,8 +289,9 @@ fn changed_page_anchor_conflicts_until_kept_without_regenerating_dependent_ident Kind::RichText { text, .. } if text == "Retained body")); } +/// Edits of a page the remote removed put the local version back where it stood. #[test] -fn keeping_mine_puts_back_a_page_the_remote_removed_where_it_stood() { +fn a_page_the_remote_removed_comes_back_where_it_stood() { let source = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); let order = |image: &[u8]| -> Vec<(ExGuid, String, u32)> { let arena = onestore::Arena::default(); @@ -319,73 +324,61 @@ fn keeping_mine_puts_back_a_page_the_remote_removed_where_it_stood() { _ => None, }) .unwrap(); - for keep in [Resolution::Mine, Resolution::Theirs] { - let directory = tempfile::tempdir().unwrap(); - let cache = Replica::create(directory.path().join("pages.sqlite"), source).unwrap(); - let id = model_ops::save(&cache, text, |page| { - model_ops::replace_text(page, text, 0..0, "Kept ") - }) + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("pages.sqlite"), source).unwrap(); + model_ops::save(&cache, text, |page| { + model_ops::replace_text(page, text, 0..0, "Kept ") + }) + .unwrap() + .unwrap(); + let local = cache.page(space).unwrap(); + let removed = ops::section_op(source, SectionOp::Delete([space].to_vec())) .unwrap() - .unwrap(); - let local = cache.page(space).unwrap(); - let removed = onestore::PreparedEdit::delete_pages_permanently(source, &[space]) - .unwrap() - .as_bytes() - .to_vec(); - let mut server = Server::new(&removed); - assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::TargetUnavailable))) - ); - assert_eq!( - server.publications, 0, - "a removed page is not edited in place" - ); - cache.resolve(id, keep).unwrap(); - assert!(matches!( - cache.sync_once(&mut server).unwrap().edit, - Some((_, EditStatus::Published { .. })) - )); - let published = order(&server.durable); - assert!(published.iter().all(|(page, ..)| *page != space)); - match keep { - Resolution::Mine => { - assert_eq!(published.len(), pages.len()); - let (restored, title, restored_level) = published[at].clone(); - assert_eq!( - (title.as_str(), restored_level), - (pages[at].1.as_str(), level) - ); - let page = model_ops::page_of(&server.durable, restored); - let texts = |page: &onestore::page::Page| -> Vec { - page.objects - .iter() - .filter_map(|object| match object { - onestore::page::PageObject::Outline(outline) => Some(outline), - _ => None, - }) - .flat_map(|outline| &outline.paragraphs) - .filter_map(|p| p.text().map(|t| t.text.text().to_owned())) - .collect() - }; - assert_eq!(texts(&page), texts(&local)); - assert!(texts(&page)[0].starts_with("Kept ")); - let others: Vec<_> = published - .iter() - .map(|(page, ..)| *page) - .filter(|page| *page != restored) - .collect(); - let before: Vec<_> = pages - .iter() - .map(|(page, ..)| *page) - .filter(|page| *page != space) - .collect(); - assert_eq!(others, before); - } - Resolution::Theirs => assert_eq!(published, order(&removed)), - } - assert_eq!(pages_of(&cache), published); - } + .as_bytes() + .to_vec(); + let mut server = Server::new(&removed); + assert!(matches!( + cache.sync_once(&mut server).unwrap().edit, + Some((_, EditStatus::Published { .. })) + )); + assert_eq!( + server.publications, 1, + "a removed page is not edited in place" + ); + let published = order(&server.durable); + assert!(published.iter().all(|(page, ..)| *page != space)); + assert_eq!(published.len(), pages.len()); + let (restored, title, restored_level) = published[at].clone(); + assert_eq!( + (title.as_str(), restored_level), + (pages[at].1.as_str(), level) + ); + let page = model_ops::page_of(&server.durable, restored); + let texts = |page: &onestore::page::Page| -> Vec { + page.objects + .iter() + .filter_map(|object| match object { + onestore::page::PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .flat_map(|outline| &outline.paragraphs) + .filter_map(|p| p.text().map(|t| t.text.text().to_owned())) + .collect() + }; + assert_eq!(texts(&page), texts(&local)); + assert!(texts(&page)[0].starts_with("Kept ")); + let others: Vec<_> = published + .iter() + .map(|(page, ..)| *page) + .filter(|page| *page != restored) + .collect(); + let before: Vec<_> = pages + .iter() + .map(|(page, ..)| *page) + .filter(|page| *page != space) + .collect(); + assert_eq!(others, before); + assert_eq!(pages_of(&cache), published); } fn pages_of(cache: &Replica) -> Vec<(ExGuid, String, u32)> { diff --git a/crates/notebook/tests/sync_pages.rs b/crates/notebook/tests/sync_pages.rs index 35d493870769e00b9ba018ab948e7150ad985f51..009f731609957044662bd133a4ef454a2d213550 100644 --- a/crates/notebook/tests/sync_pages.rs +++ b/crates/notebook/tests/sync_pages.rs @@ -1,10 +1,14 @@ //! Page batch reconciliation: atomic batches with dependent body saves, competing moves and -//! indentation, uncertain batches, and native page movement fixtures. +//! indentation (a page the remote moved keeps the remote's placement), uncertain batches, and +//! native page movement fixtures. -use notebook::{ConflictKind, EditStatus, Recovery, Replica, Resolution}; +#[path = "../../onestore/tests/support/ops.rs"] +mod ops; + +use notebook::{EditStatus, Recovery, Replica}; use onestore::op::{Op, SectionOp}; use onestore::{ - ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, + ExGuid, PageEdit, PagePosition, RevisionIndex, Store, document::{Document, Format, Kind, Layout}, page::{ Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, @@ -135,12 +139,12 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() { assert!( matches!(&queue[0].edit.ops[..], [Op::Section(SectionOp::Pages(batch))] if *batch == edits) ); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); drop(cache); let cache = Replica::open(&path).unwrap(); assert_eq!(cache.pending().unwrap(), queue); assert_eq!( - server::pages(&cache.snapshot().unwrap()), + server::pages(&snapshot(&cache)), server::pages(&local) ); let mut server = Server::new(SOURCE); @@ -164,7 +168,7 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() { } #[test] -fn indentation_only_edits_preserve_remote_page_movement() { +fn indenting_a_page_the_remote_moved_keeps_the_remote_placement() { let directory = tempfile::tempdir().unwrap(); let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); let before = order(SOURCE); @@ -174,86 +178,56 @@ fn indentation_only_edits_preserve_remote_page_movement() { SectionOp::Pages([PageEdit::set_level(sid, 3).unwrap()].to_vec()), ); let mut server = Server::new(SOURCE); - PreparedEdit::pages( - SOURCE, - &[PageEdit::move_to(sid, Some(before[7].0), 2).unwrap()], - ) + ops::section_op(SOURCE, SectionOp::Pages([PageEdit::move_to(sid, Some(before[7].0), 2).unwrap()].to_vec())) .unwrap() .commit(&mut server) .unwrap(); - let mut expected = order(&server.durable); - expected.iter_mut().find(|p| p.0 == sid).unwrap().1 = 3; + let expected = order(&server.durable); assert!(matches!(cache.sync_once(&mut server).unwrap().edit, Some((published, EditStatus::Published { .. })) if published == id)); assert_eq!(order(&server.durable), expected); } +/// A page both sides moved stays where the remote put it, as OneNote 2010 keeps it +/// (corpus/conflict-page/native-pages). #[test] -fn competing_page_moves_conflict_until_mine_is_kept_with_its_identities() { +fn competing_page_moves_keep_the_remote_placement() { let directory = tempfile::tempdir().unwrap(); let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); let pages = order(SOURCE); let sid = pages[6].0; - let original = PageEdit::move_to(sid, None, 1).unwrap(); - let id = section_op(&cache, SectionOp::Pages(vec![original.clone()])); + let id = section_op(&cache, SectionOp::Pages(vec![PageEdit::move_to(sid, None, 1).unwrap()])); let mut server = Server::new(SOURCE); - PreparedEdit::pages( - SOURCE, - &[PageEdit::move_to(sid, Some(pages[0].0), 1).unwrap()], - ) + ops::section_op(SOURCE, SectionOp::Pages([PageEdit::move_to(sid, Some(pages[0].0), 1).unwrap()].to_vec())) .unwrap() .commit(&mut server) .unwrap(); - let remote = server.visible.clone(); - let local = cache.snapshot().unwrap(); - assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) - ); - assert_eq!(server.publications, 0); - assert_eq!( - server::pages(&cache.snapshot().unwrap()), - server::pages(&local) - ); - // Keeping mine moves the page last again, from where the remote put it. - cache.resolve(id, Resolution::Mine).unwrap(); - assert!(matches!(&cache.pending().unwrap()[0].edit.ops[..], - [Op::Section(SectionOp::Pages(batch))] if *batch == [original.clone()])); - let expected = PreparedEdit::pages(&remote, &[original]).unwrap(); + let expected = order(&server.visible); assert!(matches!(cache.sync_once(&mut server).unwrap().edit, Some((published, EditStatus::Published { .. })) if published == id)); - assert_eq!(order(&server.durable), order(expected.as_bytes())); + assert_eq!(order(&server.durable), expected); } #[test] -fn one_competing_level_prevents_publication_of_the_whole_batch() { +fn a_competing_level_keeps_the_remote_level_and_the_rest_of_the_batch() { let directory = tempfile::tempdir().unwrap(); let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); let pages = order(SOURCE); - let id = section_op( - &cache, - SectionOp::Pages(vec![ - PageEdit::set_level(pages[4].0, 3).unwrap(), - PageEdit::set_level(pages[5].0, 2).unwrap(), - ]), - ); - let local = cache.snapshot().unwrap(); + let edits = vec![ + PageEdit::set_level(pages[4].0, 3).unwrap(), + PageEdit::set_level(pages[5].0, 2).unwrap(), + ]; + let id = section_op(&cache, SectionOp::Pages(edits.clone())); let mut server = Server::new(SOURCE); - PreparedEdit::pages(SOURCE, &[PageEdit::set_level(pages[5].0, 1).unwrap()]) + ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(pages[5].0, 1).unwrap()].to_vec())) .unwrap() .commit(&mut server) .unwrap(); - let remote = server.durable.clone(); - assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) - ); - assert_eq!(server.publications, 0); - assert_eq!(server.durable, remote); - assert_eq!( - server::pages(&cache.snapshot().unwrap()), - server::pages(&local) - ); + let expected = + ops::section_op(&server.durable.clone(), SectionOp::Pages(edits[..1].to_vec())).unwrap(); + assert!(matches!(cache.sync_once(&mut server).unwrap().edit, + Some((published, EditStatus::Published { .. })) if published == id)); + assert_eq!(order(&server.durable), order(expected.as_bytes())); } #[test] @@ -273,7 +247,7 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() { PageEdit::set_level(pages[5].0, 2).unwrap(), ]; let id = section_op(&cache, SectionOp::Pages(edits.to_vec())); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); let queue = cache.pending().unwrap(); let mut server = Server::new(SOURCE); server.fault = fault; @@ -293,7 +267,6 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() { server::pages(&recovery.snapshot().unwrap()), server::pages(&local) ); - assert!(cache.resolve(id, Resolution::Mine).is_err()); let result = cache.sync_once(&mut server).unwrap().edit.unwrap(); assert_eq!(server.publications, 1); if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) { @@ -319,9 +292,23 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() { let copy = *view.nodes.iter().find(|(_, node)| matches!(&node.kind, Kind::Metadata { title: Some(title), .. } if title == "child")) .unwrap().0; - let source = - onestore::replace_property_bytes(SOURCE, root, copy, 0x14001dff, &3_u32.to_le_bytes()) - .unwrap(); + // The section's copy of that page's metadata says level 3, stored where it lies. + let revision = index.resolve_active(root).unwrap(); + let onestore::ObjectData::Properties(data) = revision.objects[©].data else { + panic!() + }; + let properties = onestore::PropertySets::parse(data).unwrap(); + let onestore::Value::Bytes(level) = properties.sets[0] + .iter() + .find(|property| property.id == 0x14001dff) + .unwrap() + .value + else { + panic!() + }; + let at = level.as_ptr().addr() - SOURCE.as_ptr().addr(); + let mut source = SOURCE.to_vec(); + source[at..at + 4].copy_from_slice(&3_u32.to_le_bytes()); let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("pages.sqlite"); let cache = Replica::create(&path, &source).unwrap(); @@ -368,7 +355,7 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() { } #[test] -fn an_explicit_anchor_is_retained_even_when_originally_in_place() { +fn an_explicit_anchor_yields_to_a_remote_move() { let directory = tempfile::tempdir().unwrap(); let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); let pages = order(SOURCE); @@ -380,18 +367,18 @@ fn an_explicit_anchor_is_retained_even_when_originally_in_place() { ]), ); let mut server = Server::new(SOURCE); - PreparedEdit::pages( - SOURCE, - &[PageEdit::move_to(pages[6].0, Some(pages[0].0), 1).unwrap()], - ) + ops::section_op(SOURCE, SectionOp::Pages([PageEdit::move_to(pages[6].0, Some(pages[0].0), 1).unwrap()].to_vec())) .unwrap() .commit(&mut server) .unwrap(); - assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) - ); - assert_eq!(server.publications, 0); + let expected = ops::section_op( + &server.durable.clone(), + SectionOp::Pages(vec![PageEdit::set_level(pages[4].0, 3).unwrap()]), + ) + .unwrap(); + assert!(matches!(cache.sync_once(&mut server).unwrap().edit, + Some((published, EditStatus::Published { .. })) if published == id)); + assert_eq!(order(&server.durable), order(expected.as_bytes())); } #[test] @@ -404,7 +391,7 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() { SectionOp::Pages([PageEdit::set_level(sid, 1).unwrap()].to_vec()), ); let mut server = Server::new(SOURCE); - PreparedEdit::pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()]) + ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(sid, 1).unwrap()].to_vec())) .unwrap() .commit(&mut server) .unwrap(); @@ -418,7 +405,7 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() { assert_eq!(server.confirmations, 2); } #[test] -fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { +fn native_page_changes_reconcile_with_atomic_offline_batches() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../corpus/page-lifecycle/movement"); let provenance: serde_json::Value = @@ -455,48 +442,36 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { .unwrap(); let native = std::fs::read(root.join(phase).join("notebook/Lifecycle.one")).unwrap(); let mut server = Server::new(&native); - let local = cache.snapshot().unwrap(); - let expected_conflict = if mode == "indent" { - matches!( - phase.as_str(), - "04-subpage-move" | "07-promoted-root" | "08-collapsed-group-move" - ) - } else { - !matches!( - phase.as_str(), - "02-promoted-parent" | "03-selected-group-move" | "06-promoted-level-two" - ) + // The page list takes the edits of the pages OneNote left in their series, at + // their level and after the same page. + let both: Vec = order(&native) + .into_iter() + .map(|(sid, _)| sid) + .filter(|sid| original.iter().any(|(s, _)| s == sid)) + .collect(); + let placement = |image: &[u8]| -> BTreeMap, u32)> { + let arena = onestore::Arena::default(); + let series = onestore::Section::open(&arena, image.to_vec()).unwrap().series().unwrap(); + let shared: Vec<_> = order(image).into_iter().filter(|(sid, _)| both.contains(sid)).collect(); + (0..shared.len()) + .map(|at| { + let (sid, level) = shared[at]; + (sid, (series[&sid], at.checked_sub(1).map(|b| shared[b].0), level)) + }) + .collect() }; - let first = if expected_conflict { - cache.sync_once(&mut server).unwrap().edit.unwrap() - } else { - (text_id, EditStatus::Pending) - }; - let mut reviewed = edits.clone(); - if expected_conflict { - assert_eq!( - first.1, - EditStatus::Conflict(ConflictKind::StructureChanged), - "{mode}:{phase}" - ); - assert_eq!(server.publications, 0); - assert_eq!( - server::pages(&cache.snapshot().unwrap()), - server::pages(&local) - ); - // Keeping mine drops the moves that no longer apply. - cache.resolve(id, Resolution::Mine).unwrap(); - reviewed = match &cache.pending().unwrap()[0].edit.ops[..] { - [Op::Section(SectionOp::Pages(kept))] => kept.clone(), - [] => Vec::new(), - other => panic!("{other:?}"), - }; - } + let (before, after) = (placement(SOURCE), placement(&native)); + let reviewed: Vec = edits + .iter() + .filter(|edit| after.get(&edit.space()) == before.get(&edit.space())) + .cloned() + .collect(); + let trimmed = reviewed != edits; assert!( matches!(cache.sync_once(&mut server).unwrap().edit, Some((published, EditStatus::Published { .. })) if published == text_id), "{mode}:{phase}" ); - counts[usize::from(expected_conflict)] += 1; + counts[usize::from(trimmed)] += 1; drop(cache); let cache = Replica::open(&path).unwrap(); assert!(cache.pending().unwrap().is_empty()); @@ -525,7 +500,7 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { path.join("manifest.json"), serde_json::to_vec_pretty( &serde_json::json!({"mode": mode, "phase": phase, "original": edits, - "reviewed": reviewed, "conflict": expected_conflict, + "reviewed": reviewed, "conflict": trimmed, "publications": server.publications, "confirmations": server.confirmations, "page_receipt": format!("{:?}", cache.status(id).unwrap()), "text_receipt": format!("{:?}", cache.status(text_id).unwrap())}), @@ -536,7 +511,7 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { } } } - assert_eq!(counts, [9, 9]); + assert_eq!(counts.iter().sum::(), 18); } #[test] @@ -545,7 +520,7 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { let mut created = Vec::new(); for _ in 0..36 { let page = onestore::PageCreation::new(None, Some("Same title"), "Author").unwrap(); - source = PreparedEdit::create_page(&source, &page) + source = ops::section_op(&source, SectionOp::Create(page.clone())) .unwrap() .as_bytes() .to_vec(); @@ -564,7 +539,7 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { ]; section_op(&cache, SectionOp::Pages(edits.to_vec())); let body = format!("Actor {actor} 🦀 e\u{301}"); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); let mut model = model_ops::page_of(&local, group[1].space()); let text = body_outline(&mut model, &body); model_ops::save_as(&cache, group[1].space(), &model, "Author") @@ -594,7 +569,7 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { for (path, queue) in &queues { let cache = Replica::open(path).unwrap(); assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); - assert_eq!(order(&cache.snapshot().unwrap()), expected); + assert_eq!(order(&snapshot(&cache)), expected); for edit in queue { assert!(matches!( cache.status(edit.id).unwrap(), @@ -627,3 +602,77 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { .unwrap(); } } + +/// Two OneNote 2010 clients' page merges (`corpus/conflict-page/native-*`), replayed: client +/// A, offline, `moves` pages (each last, or before another) and edits `Target`'s body while +/// client B publishes `b-published`. The pages the merge lists after the first, and +/// `Target`'s space and texts. +fn native_page_merge(capture: &str, moves: &[(&str, Option<&str>)]) -> (Vec, ExGuid, Vec) { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../corpus/conflict-page") + .join(capture); + let read = |phase: &str| std::fs::read(root.join(phase).join("notebook/synthetic.one")).unwrap(); + let pages = |image: &[u8]| { + let arena = onestore::Arena::default(); + onestore::Section::open(&arena, image.to_vec()).unwrap().pages().unwrap() + }; + let initial = read("initial"); + let listed = pages(&initial); + let space = |title: &str| listed.iter().find(|page| page.1 == title).unwrap().0; + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("pages.sqlite"), &initial).unwrap(); + for (page, before) in moves { + section_op( + &cache, + SectionOp::Pages(vec![PageEdit::move_to(space(page), before.map(space), 1).unwrap()]), + ); + } + let body = texts(&initial) + .into_iter() + .find(|(_, text)| text == "Body Target.") + .unwrap() + .0 + .1; + model_ops::save(&cache, body, |page| { + model_ops::replace_text(page, body, 0..12, "Body Target edited offline.") + }) + .unwrap() + .unwrap(); + let mut server = Server::new(&read("b-published")); + for _ in 0..moves.len() + 2 { + cache.sync_once(&mut server).unwrap(); + } + assert!(cache.pending().unwrap().is_empty(), "{capture}"); + let merged = pages(&server.durable); + let target = merged.iter().find(|page| page.1 == "Target").unwrap().0; + let texts = texts(&server.durable) + .into_iter() + .filter(|((page, _), _)| *page == target) + .map(|(_, text)| text) + .collect(); + (merged[1..].iter().map(|page| page.1.clone()).collect(), target, texts) +} + +/// Each side keeps the pages it gave a series of their own, the remote's where both did; +/// a page the remote deleted comes back as a new page where the queue had it. OneNote's +/// COM moves re-series the pages a page jumps over, so its moves replay as moves of those. +#[test] +fn page_moves_and_a_removed_page_merge_as_onenote_merges_them() { + for (capture, moves) in [ + ("native-pages", &[("One", Some("Target")), ("Three", Some("Target")), ("Two", None)][..]), + ("native-restore", &[("One", None), ("Two", None), ("Target", None), ("Three", None)][..]), + ("native-restore", &[("Four", Some("One"))][..]), + ] { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/conflict-page"); + let native: serde_json::Value = + serde_json::from_slice(&std::fs::read(root.join(capture).join("merged.json")).unwrap()).unwrap(); + let (order, target, texts) = native_page_merge(capture, moves); + let native: Vec = serde_json::from_value(native["order"].clone()).unwrap(); + assert_eq!(order, native[1..], "{capture}"); + assert!(texts.contains(&"Body Target edited offline.".to_owned()), "{capture}"); + let arena = onestore::Arena::default(); + let initial = std::fs::read(root.join(capture).join("initial/notebook/synthetic.one")).unwrap(); + let listed = onestore::Section::open(&arena, initial).unwrap().pages().unwrap(); + assert!(listed.iter().all(|page| page.0 != target), "{capture}: a new page"); + } +} diff --git a/crates/notebook/tests/sync_paragraph.rs b/crates/notebook/tests/sync_paragraph.rs index 787b98694edef420111e836f4c98496766537612..aa54b56ff2c2db8b1f699f2636c475467a7034c4 100644 --- a/crates/notebook/tests/sync_paragraph.rs +++ b/crates/notebook/tests/sync_paragraph.rs @@ -1,9 +1,12 @@ //! Paragraph splits and joins expressed as page-model differences: reconciliation against //! native OneNote captures, dependent edits, uncertain attempts and competing clients. -use notebook::{ConflictKind, EditStatus, Replica}; +#[path = "../../onestore/tests/support/ops.rs"] +mod ops; + +use notebook::{EditStatus, Replica}; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, document::Document, page::{Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id}, }; @@ -11,7 +14,7 @@ use std::path::Path; #[path = "support/server.rs"] mod server; -use server::{Fault, Server, pages}; +use server::{Fault, Server, conflicts, pages, snapshot, typed}; #[path = "support/model_ops.rs"] mod model_ops; use model_ops::{AUTHOR, outlines_mut, page_of, replace_text}; @@ -175,18 +178,27 @@ fn save_page( text: ExGuid, edit: impl FnOnce(&mut Page), ) -> Result, notebook::Error> { - let source = cache.snapshot()?; + let source = snapshot(cache); let (space, mut page) = holder(&source, text); edit(&mut page); model_ops::save_as(cache, space, &page, AUTHOR) } -/// Resolves a conflict by taking the remote page, then saves `edit` of it. -fn review_page(cache: &Replica, id: u64, text: ExGuid, edit: impl FnOnce(&mut Page)) { - cache.resolve(id, notebook::Resolution::Theirs).unwrap(); +/// Makes `edit` again on the page as the remote's version left it, as a writer does after +/// a conflict page kept the first try. +fn redo_page(cache: &Replica, text: ExGuid, edit: impl FnOnce(&mut Page)) { save_page(cache, text, edit).unwrap().unwrap(); } +/// The conflict pages `image` lists under page `space`. +fn conflict_pages(image: &[u8], space: ExGuid) -> usize { + conflicts(image) + .into_iter() + .filter(|(page, _)| *page == space) + .map(|(_, pages)| pages.len()) + .sum() +} + /// The styled text of an object anywhere on a page. fn content(page: &Page, text: ExGuid) -> onestore::page::Paragraph { locate_in(&mut page.clone(), text, |list, index| { @@ -250,12 +262,15 @@ fn reconcile_controls() -> (Vec, Vec) { drop(cache); cache = Replica::open(&path).unwrap(); assert_eq!(cache.pending().unwrap(), pending); - let (actual, status) = cache.sync_once(&mut server).unwrap().edit.unwrap(); - assert_eq!(actual, id); - let reviewed = matches!(status, EditStatus::Conflict(_)); + let kept = conflict_pages(&server.visible, space); + let (_, status) = cache.sync_once(&mut server).unwrap().edit.unwrap(); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + let reviewed = conflict_pages(&server.visible, space) > kept; let receipt = if reviewed { - assert_eq!(cache.pending().unwrap(), pending); - review_page(&cache, id, left, apply); + redo_page(&cache, left, apply); cache.sync_once(&mut server).unwrap().edit.unwrap().1 } else { automatic += 1; @@ -301,7 +316,7 @@ fn reconcile_controls() -> (Vec, Vec) { panic!("{name} dependent: {status:?}") }; assert!(cache.pending().unwrap().is_empty()); - assert_eq!(cache.snapshot().unwrap(), server.durable); + assert_eq!(snapshot(&cache), server.durable); recorded.push( serde_json::json!({"case": name, "space": space, "intent": intent, "outcome": if reviewed { "reviewed" } else { "automatic" }, @@ -309,12 +324,13 @@ fn reconcile_controls() -> (Vec, Vec) { ); } assert_eq!((recorded.len(), automatic), (16, 8)); - assert_eq!(server.publications, 32); + // Each edit and its dependent, and each conflict page before its edit made again. + assert_eq!(server.publications, 32 + 16 - automatic); (server.durable, recorded) } #[test] -fn native_keyboard_edits_merge_with_offline_splits_and_joins_or_survive_review() { +fn native_keyboard_edits_merge_with_offline_splits_and_joins_or_keep_a_conflict_page() { let (durable, cases) = reconcile_controls(); let expected = [ ("Split remote prefix", "automatic", vec!["Xab", "🦀Cd"]), @@ -349,8 +365,9 @@ fn native_keyboard_edits_merge_with_offline_splits_and_joins_or_survive_review() } #[test] -fn a_native_text_object_replacement_conflicts_with_every_offline_split_and_join() { +fn a_native_text_object_replacement_keeps_a_conflict_page_for_every_offline_split_and_join() { let mut server = Server::new(REPLACED); + let mut count = 0; for (space, name, split) in controls() { let original = page_of(BEFORE, space); let left = body(&original)[0].text().unwrap().id; @@ -366,24 +383,23 @@ fn a_native_text_object_replacement_conflicts_with_every_offline_split_and_join( let id = model_ops::save_as(&cache, space, &after, AUTHOR) .unwrap() .unwrap(); - let local = cache.snapshot().unwrap(); - let pending = cache.pending().unwrap(); - assert_eq!( - cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))), + let remote = page_of(&server.visible, space); + assert!( + matches!( + cache.sync_once(&mut server).unwrap().edit, + Some((_, EditStatus::Published { .. })) + ), "{name}" ); - drop(cache); - let cache = Replica::open(&path).unwrap(); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); - assert_eq!(cache.pending().unwrap(), pending); - assert_eq!( - cache.status(id).unwrap(), - Some(EditStatus::Conflict(ConflictKind::ContentChanged)) + assert!( + matches!(cache.status(id).unwrap(), Some(EditStatus::Published { .. })), + "{name}" ); + assert_eq!(page_of(&server.visible, space), remote, "{name}"); + assert_eq!(conflict_pages(&server.visible, space), 1, "{name}"); + count += 1; } - assert_eq!(server.publications, 0); - assert_eq!(server.visible, REPLACED); + assert_eq!(server.publications, count); } /// Replays a recorded native split or join intent as a page-model difference. @@ -459,7 +475,7 @@ fn offline_paragraphs(output: Option<&Path>) { { let intent = &case["intent"]; let left = origin(intent, split); - let (space, page) = holder(&cache.snapshot().unwrap(), left); + let (space, page) = holder(&snapshot(&cache), left); let offset = if split { intent["offset"].as_u64().unwrap() } else { @@ -468,7 +484,7 @@ fn offline_paragraphs(output: Option<&Path>) { let prefixed = offset > 0 && !content(&page, left).text().is_empty(); if prefixed { server.visible = - onestore::replace_text(&server.visible, space, left, 0..0, "\u{2602}").unwrap(); + typed(&server.visible, space, left, 0..0, "\u{2602}"); server.durable.clone_from(&server.visible); } let Ok(queued) = save_page(&cache, left, |page| replay(page, intent, split)) else { @@ -484,6 +500,8 @@ fn offline_paragraphs(output: Option<&Path>) { let case = entry["case"].as_str().unwrap().to_owned(); let intent = entry["intent"].clone(); let mut uncertain = id % 2 == 0; + let mut kept = conflict_pages(&server.visible, space); + let mut id = id; let revision = loop { if uncertain { server.fault = Fault::UnknownAfter; @@ -501,7 +519,7 @@ fn offline_paragraphs(output: Option<&Path>) { Ok(notebook::Synced { edit: Some((_, EditStatus::Published { revision })), .. - }) => { + }) if conflict_pages(&server.visible, space) == kept => { assert!(matches!( cache.status(id).unwrap(), Some(EditStatus::Published { .. }) @@ -509,20 +527,21 @@ fn offline_paragraphs(output: Option<&Path>) { break revision; } Ok(notebook::Synced { - edit: Some((actual, EditStatus::Conflict(_))), + edit: Some((_, EditStatus::Published { .. })), .. }) => { - assert_eq!(actual, id); + kept += 1; server.fault = Fault::None; reviewed.push(case.clone()); - // The reviewed split sits after the remote's one-unit prefix. + // The split made again sits after the remote's one-unit prefix. let mut placed = intent.clone(); if split && entry["remote_prefix"] == true { placed["offset"] = (intent["offset"].as_u64().unwrap() + 1).into(); } - review_page(&cache, id, origin(&intent, split), |page| { + redo_page(&cache, origin(&intent, split), |page| { replay(page, &placed, split) }); + id = cache.pending().unwrap().last().unwrap().id; } other => panic!("{name} {case}: {other:?}"), } @@ -543,7 +562,7 @@ fn offline_paragraphs(output: Option<&Path>) { ); assert_eq!(server.publications, entries.len()); assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); - assert_eq!(cache.snapshot().unwrap(), server.durable); + assert_eq!(snapshot(&cache), server.durable); if output.is_some() { cache .export_recovery(folder.join("recovery.sqlite")) @@ -573,9 +592,9 @@ fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server { let mut page = page_of(BEFORE, space); change(&mut page); Server::new( - PreparedEdit::page(BEFORE, space, &page, "Native author") + ops::saved(BEFORE, space, &page) .unwrap() - .as_bytes(), + .as_slice(), ) } @@ -611,7 +630,7 @@ fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() { ["ab\u{1f980}cd", "Right", "Preserved sibling"] ); assert_eq!(server.publications, 2); - assert_eq!(cache.snapshot().unwrap(), server.durable); + assert_eq!(snapshot(&cache), server.durable); } #[test] @@ -637,7 +656,7 @@ fn a_split_merges_with_a_remote_edit_to_another_paragraph() { } #[test] -fn a_join_conflicts_when_the_remote_changed_the_paragraph_it_removes() { +fn a_join_keeps_a_conflict_page_when_the_remote_changed_the_paragraph_it_removes() { let (space, page) = control("Join remote prefix"); let (left, right) = ( body(&page)[0].text().unwrap().id, @@ -649,12 +668,16 @@ fn a_join_conflicts_when_the_remote_changed_the_paragraph_it_removes() { .unwrap() .unwrap(); let mut server = remote_with(space, |page| replace_text(page, right, 0..0, "Remote ")); - assert_eq!( + assert!(matches!( cache.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) - ); - assert_eq!(server.publications, 0); - review_page(&cache, id, left, |page| join_paragraphs(page, left, right)); + Some((_, EditStatus::Published { .. })) + )); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + )); + assert_eq!(conflict_pages(&server.durable, space), 1); + redo_page(&cache, left, |page| join_paragraphs(page, left, right)); assert!(matches!( cache.sync_once(&mut server).unwrap().edit, Some((_, EditStatus::Published { .. })) @@ -680,7 +703,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() { .unwrap() .unwrap(); cache.sync_once(&mut server).unwrap().edit.unwrap(); - let tail = body(&page_of(&cache.snapshot().unwrap(), space))[1] + let tail = body(&page_of(&snapshot(&cache), space))[1] .text() .unwrap() .id; @@ -689,17 +712,17 @@ fn edits_dependent_on_a_published_split_survive_reopen() { }) .unwrap() .unwrap(); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); let pending = cache.pending().unwrap(); drop(cache); cache = Replica::open(&path).unwrap(); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + assert_eq!(pages(&snapshot(&cache)), pages(&local)); assert_eq!(cache.pending().unwrap(), pending); let mut remote = page_of(&server.durable, space); replace_text(&mut remote, other, 0..0, "Remote "); - server.visible = PreparedEdit::page(&server.durable, space, &remote, "Native author") + server.visible = ops::saved(&server.durable, space, &remote) .unwrap() - .as_bytes() + .as_slice() .to_vec(); server.durable.clone_from(&server.visible); assert!( @@ -742,7 +765,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { }) .unwrap() .unwrap(); - let local = cache.snapshot().unwrap(); + let local = snapshot(&cache); let pending = cache.pending().unwrap(); let mut server = Server::new(BEFORE); server.fault = fault; @@ -764,7 +787,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { Fault::UnknownBefore => { assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); assert_eq!(cache.pending().unwrap(), pending); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + assert_eq!(pages(&snapshot(&cache)), pages(&local)); for _ in 0..3 { assert_eq!( cache.sync_once(&mut server).unwrap().edit, @@ -777,7 +800,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { } Fault::UnknownAfter => { assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); - assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); + assert_eq!(pages(&snapshot(&cache)), pages(&local)); assert!(matches!( cache.sync_once(&mut server).unwrap().edit.unwrap().1, EditStatus::Published { .. } @@ -797,13 +820,13 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { }; assert_eq!(texts(&page_of(&server.durable, space)), expected); assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); - assert_eq!(cache.snapshot().unwrap(), server.durable); + assert_eq!(snapshot(&cache), server.durable); } } } #[test] -fn a_competing_join_leaves_the_other_client_a_reviewable_split() { +fn a_competing_join_leaves_the_other_client_its_split_on_a_conflict_page() { let (space, page) = control("Join remote prefix"); let (left, right) = ( body(&page)[0].text().unwrap().id, @@ -819,27 +842,24 @@ fn a_competing_join_leaves_the_other_client_a_reviewable_split() { let id = save_page(&second, right, |page| split_paragraph(page, right, 2)) .unwrap() .unwrap(); - let local = second.snapshot().unwrap(); - let pending = second.pending().unwrap(); let mut server = Server::new(BEFORE); assert!(matches!( first.sync_once(&mut server).unwrap().edit.unwrap().1, EditStatus::Published { .. } )); - assert_eq!( + assert!(matches!( second.sync_once(&mut server).unwrap().edit, - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) - ); - assert_eq!(server.publications, 1); + Some((_, EditStatus::Published { .. })) + )); + assert_eq!(server.publications, 2); + assert_eq!(conflict_pages(&server.durable, space), 1); drop(second); let second = Replica::open(&path).unwrap(); - assert_eq!(pages(&second.snapshot().unwrap()), pages(&local)); - assert_eq!(second.pending().unwrap(), pending); - assert_eq!( + assert!(matches!( second.status(id).unwrap(), - Some(EditStatus::Conflict(ConflictKind::ContentChanged)) - ); - review_page(&second, id, left, |page| { + Some(EditStatus::Published { .. }) + )); + redo_page(&second, left, |page| { split_paragraph(page, left, 2); }); assert!(matches!( diff --git a/crates/notebook/tests/sync_worker.rs b/crates/notebook/tests/sync_worker.rs index ecf4189e963aaed6629d73fc19acad8f23059d34..14a63b9e1b065f3b5d31046a883c3cc2e1452b5a 100644 --- a/crates/notebook/tests/sync_worker.rs +++ b/crates/notebook/tests/sync_worker.rs @@ -1,6 +1,7 @@ //! The background worker: waking, backoff, ownership and error propagation. -use notebook::{ConflictKind, EditStatus, Error, Remote, Replica}; +use notebook::{EditStatus, Error, Remote, Replica}; +use onestore::Stamp; use onestore::{ CommitError, CommitState, ExGuid, RevisionIndex, Store, Transaction, document::Document, }; @@ -25,14 +26,14 @@ impl Remote for Shared { fn read(&mut self) -> io::Result> { self.0.lock().unwrap().read() } - fn stamp(&mut self) -> io::Result> { + fn stamp(&mut self) -> io::Result { self.0.lock().unwrap().stamp() } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.0.lock().unwrap().publish(transaction) } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - self.0.lock().unwrap().confirm(snapshot) + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + self.0.lock().unwrap().confirm(base) } } @@ -75,11 +76,17 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() { } self.shared.read() } + fn stamp(&mut self) -> io::Result { + if self.fail_read { + return Err(io::ErrorKind::ConnectionReset.into()); + } + self.shared.stamp() + } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.shared.publish(transaction) } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - self.shared.confirm(snapshot) + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + self.shared.confirm(base) } } let dir = tempfile::tempdir().unwrap(); @@ -142,7 +149,7 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() { drop(cache); let cache = Replica::open(&path).unwrap(); assert_eq!(cache.status(id).unwrap(), Some(published)); - let cached = cache.snapshot().unwrap(); + let cached = snapshot(&cache); // Confirmation changes reader-notification fields without changing the committed graph. assert_eq!(cached[..212], server.durable[..212]); assert_eq!(cached[252..], server.durable[252..]); @@ -159,7 +166,7 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() { fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { let dir = tempfile::tempdir().unwrap(); let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); - let (_, oid, _) = text(&source); + let (sid, oid, _) = text(&source); let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); let server = Arc::new(Mutex::new(Server::new(&source))); let shared = Shared(Arc::clone(&server)); @@ -190,13 +197,19 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { (0..12) .map(|writer| { let cache = &cache; + // Typed as an op: a page read before another writer's edit would lower stale. scope.spawn(move || { - let marker = format!("[{writer}] "); - model_ops::save(cache, oid, |page| { - model_ops::replace_text(page, oid, 0..0, &marker) - }) - .unwrap() - .unwrap() + let op = onestore::op::PageOp::Text { + text: oid, + range: 0..0, + with: format!("[{writer}] "), + }; + let ops = vec![onestore::op::Op::Page { space: sid, op }]; + let edit = onestore::op::Edit { + at: model_ops::now(), + ops, + }; + cache.apply(model_ops::AUTHOR, edit).unwrap() }) }) .collect::>() @@ -221,7 +234,7 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { Some(EditStatus::Published { .. }) )); } - let content = text(&cache.snapshot().unwrap()).2; + let content = text(&snapshot(&cache)).2; for writer in 0..12 { assert_eq!(content.matches(&format!("[{writer}] ")).count(), 1); } @@ -230,7 +243,7 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { assert_eq!(text(&server.durable).2, content); // Edits queued while the worker was busy publish together. assert_eq!(server.publications, 1); - assert_eq!(cache.snapshot().unwrap(), server.durable); + assert_eq!(snapshot(&cache), server.durable); } #[test] @@ -244,13 +257,16 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove fn read(&mut self) -> io::Result> { self.shared.read() } + fn stamp(&mut self) -> io::Result { + self.shared.stamp() + } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.entered.send(()).unwrap(); self.resume.recv_timeout(Duration::from_secs(5)).unwrap(); self.shared.publish(transaction) } - fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { - self.shared.confirm(snapshot) + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + self.shared.confirm(base) } } let dir = tempfile::tempdir().unwrap(); @@ -358,81 +374,65 @@ fn cache_failures_stop_retries_and_return_the_error_without_remote_publication() assert!(rx.try_iter().next().is_none()); assert_eq!(server.lock().unwrap().publications, 0); assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); - assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc"); + assert_eq!(text(&snapshot(&cache)).2, "L abc"); } #[test] -fn polling_reports_a_blocked_queue_once_per_remote_change_without_replay() { - for uncertain in [false, true] { - let dir = tempfile::tempdir().unwrap(); - let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); - let (sid, oid, _) = text(&source); - let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); - let id = save(&cache, oid, 1..2, "L").unwrap(); - let local = text(&cache.snapshot().unwrap()).2; - let mut server = if uncertain { - Server::new(&source) - } else { - Server::new(&onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap()) - }; - if uncertain { - server.fault = Fault::UnknownBefore; - } - let server = Arc::new(Mutex::new(server)); - let shared = Shared(Arc::clone(&server)); - let (tx, rx) = mpsc::channel(); - let worker = cache - .start_sync( - Duration::from_millis(10), - move || Ok(shared.clone()), - move |result| { - tx.send( - result - .as_ref() - .map(|synced| synced.edit.clone()) - .map_err(|_| ()), - ) - .unwrap(); - }, - ) +fn polling_reports_an_uncertain_attempt_once_per_remote_change_without_replay() { + let dir = tempfile::tempdir().unwrap(); + let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); + let (sid, oid, _) = text(&source); + let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); + let id = save(&cache, oid, 1..2, "L").unwrap(); + let local = text(&snapshot(&cache)).2; + let mut server = Server::new(&source); + server.fault = Fault::UnknownBefore; + let server = Arc::new(Mutex::new(server)); + let shared = Shared(Arc::clone(&server)); + let (tx, rx) = mpsc::channel(); + let worker = cache + .start_sync( + Duration::from_millis(10), + move || Ok(shared.clone()), + move |result| { + tx.send( + result + .as_ref() + .map(|synced| synced.edit.clone()) + .map_err(|_| ()), + ) + .unwrap(); + }, + ) + .unwrap(); + assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); + for round in 0..3 { + let (actual, status) = rx + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap() .unwrap(); - if uncertain { - assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); - } - let expected = |status: &EditStatus| { - if uncertain { - matches!(status, EditStatus::AwaitingConfirmation { .. }) - } else { - *status == EditStatus::Conflict(ConflictKind::ContentChanged) - } - }; - for round in 0..3 { - let (actual, status) = rx - .recv_timeout(Duration::from_secs(5)) - .unwrap() - .unwrap() - .unwrap(); - assert_eq!(actual, id); - assert!(expected(&status), "{status:?}"); - // An unchanged remote is not read or reported again. - assert!(rx.recv_timeout(Duration::from_millis(100)).is_err()); - // Another writer's change elsewhere in the text is read and decided again. - let mut server = server.lock().unwrap(); - let changed = - onestore::replace_text(&server.visible, sid, oid, 0..0, &round.to_string()) - .unwrap(); - server.visible = changed.clone(); - server.durable = changed; - drop(server); - worker.wake(); - } - worker.stop().unwrap(); - assert_eq!(text(&cache.snapshot().unwrap()).2, local); - assert_eq!(cache.pending().unwrap().len(), 1); - let server = server.lock().unwrap(); - assert_eq!(server.publications, usize::from(uncertain)); - assert_eq!(server.confirmations, 0); + assert_eq!(actual, id); + assert!( + matches!(status, EditStatus::AwaitingConfirmation { .. }), + "{status:?}" + ); + // An unchanged remote is not read or reported again. + assert!(rx.recv_timeout(Duration::from_millis(100)).is_err()); + // Another writer's change elsewhere in the text is read and decided again. + let mut server = server.lock().unwrap(); + let changed = typed(&server.visible, sid, oid, 0..0, &round.to_string()); + server.visible = changed.clone(); + server.durable = changed; + drop(server); + worker.wake(); } + worker.stop().unwrap(); + assert_eq!(text(&snapshot(&cache)).2, local); + assert_eq!(cache.pending().unwrap().len(), 1); + let server = server.lock().unwrap(); + assert_eq!(server.publications, 1); + assert_eq!(server.confirmations, 0); } #[test] @@ -533,51 +533,46 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() { .unwrap(); assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), None); worker.stop().unwrap(); - assert_eq!(cache.snapshot().unwrap(), source); + assert_eq!(snapshot(&cache), source); } +/// A conflict never blocks the worker: one step publishes the remote's version with the +/// local one as its conflict page. #[test] -fn a_resolved_conflict_wakes_the_worker_and_publishes_the_original_edit_once() { +fn a_conflict_publishes_its_conflict_page_in_one_worker_step() { let dir = tempfile::tempdir().unwrap(); let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); let (sid, oid, _) = text(&source); let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); let id = save(&cache, oid, 1..2, "L").unwrap(); - let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); + let remote = typed(&source, sid, oid, 1..2, "R"); let server = Arc::new(Mutex::new(Server::new(&remote))); let shared = Shared(Arc::clone(&server)); let (tx, rx) = mpsc::channel(); - let (resume_tx, resume_rx) = mpsc::channel(); - let mut first = true; let worker = cache .start_sync( Duration::from_secs(3600), move || Ok(shared.clone()), move |result| { tx.send(result.as_ref().unwrap().edit.clone()).unwrap(); - if first { - first = false; - resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); - } }, ) .unwrap(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(5)).unwrap(), - Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) - ); - cache.resolve(id, notebook::Resolution::Mine).unwrap(); - assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); - resume_tx.send(()).unwrap(); assert!( - matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual > id) ); worker.stop().unwrap(); assert!(cache.pending().unwrap().is_empty()); let server = server.lock().unwrap(); assert_eq!(server.publications, 1); - assert_eq!(text(&server.durable).2, "aLc"); - assert_eq!(cache.snapshot().unwrap(), server.durable); + assert_eq!( + conflicts(&server.durable), + [( + sid, + vec![(model_ops::AUTHOR.to_owned(), vec!["aLc".to_owned()])] + )] + ); + assert_eq!(snapshot(&cache), server.durable); } #[test] @@ -589,11 +584,14 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() { } impl Remote for Busy { fn read(&mut self) -> io::Result> { + self.server.read() + } + fn stamp(&mut self) -> io::Result { self.reads += 1; match self.reads { 1 => Err(io::ErrorKind::WouldBlock.into()), 2 => Err(io::ErrorKind::ResourceBusy.into()), - _ => self.server.read(), + _ => self.server.stamp(), } } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { @@ -611,8 +609,8 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() { _ => self.server.publish(transaction), } } - fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { - self.server.confirm(source) + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + self.server.confirm(base) } } let dir = tempfile::tempdir().unwrap(); @@ -648,7 +646,7 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() { matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) ); worker.stop().unwrap(); - assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc"); + assert_eq!(text(&snapshot(&cache)).2, "L abc"); assert!(cache.pending().unwrap().is_empty()); } @@ -659,6 +657,9 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() { fn read(&mut self) -> io::Result> { self.0.read() } + fn stamp(&mut self) -> io::Result { + self.0.stamp() + } fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { let server = &mut self.0; if server.publications == 0 { @@ -670,8 +671,8 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() { } server.publish(transaction) } - fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { - self.0.confirm(source) + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + self.0.confirm(base) } } let dir = tempfile::tempdir().unwrap(); @@ -714,6 +715,6 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() { } worker.stop().unwrap(); assert!(cache.pending().unwrap().is_empty()); - assert_eq!(content(&cache.snapshot().unwrap(), oid), "L abc"); - assert_eq!(pages(&cache.snapshot().unwrap()), 2); + assert_eq!(content(&snapshot(&cache), oid), "L abc"); + assert_eq!(pages(&snapshot(&cache)), 2); } diff --git a/crates/onestore/README.md b/crates/onestore/README.md index c969393e74c3777ff8a682e6383fcaa4f92c3cb4..09df6594d155a27c8b088d5e36ab03d4fdbfcec5 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -49,32 +49,27 @@ harness also accepts `--client-profile release`. | `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments, ink drawings and handwriting decoded to stroke polylines in page points) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | -| `PageCreation`, `PreparedEdit::create_page` | Add an empty top-level page and its section entry atomically, retaining page identities across retries | -| `PageEdit`, `PreparedEdit::pages` | Publish explicitly selected page moves and indentation changes together, preserving page content and historical revisions | -| `PreparedEdit::delete_pages_permanently` | Remove explicit pages and their section references atomically while retaining stored revisions | +| `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries | +| `PageEdit`, `SectionOp::Pages` | Publish explicitly selected page moves and indentation changes together, preserving page content and historical revisions | +| `SectionOp::Delete` | Remove explicit pages and their section references atomically while retaining stored revisions | | `create_table_of_contents` | Create ordered section entries from filenames and file identities | -| `TocEdit`, `PreparedEdit::table_of_contents` | Add, rename, colour, order and remove a table of contents' section and group entries as one revision | +| `TocEdit`, `edit_table_of_contents` | Add, rename, order and remove a table of contents' section and group entries, and colour the notebook, as one revision's `Transaction`; a section's colour is `SectionOp::Color` in its own metadata | | `place`, `place_file` | Name a file for its notebook as OneNote does on adoption (parent TOC identity and name CRC in the header), so OneNote keeps its identity, on any `CommitIo` or under the filesystem adapter | -| `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references | -| `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range across ordinary text runs; publish text, run boundaries and modification time together | -| `Insertion`, `PreparedEdit::insert` | Insert paragraphs into editable containers or positioned outlines into a page, retaining intent identities across rebases | -| `ParagraphSplit`, `PreparedEdit::split` | Split ordinary text at a UTF-16 scalar boundary, retaining the original left identities and moving children to the right | -| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | -| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | -| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | -| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks (external and, via `page::link`, to pages, paragraphs and sections), bullets and numbering, note tags, table rows, columns, cell shading and indents, nested tables, inserted pictures (in paragraphs or on the page) and attachments, picture position, size and description, a stored attachment's shown name, source path and icon size, paragraph styles on new paragraphs, ink drawings and handwriting (strokes added and erased), equations (built from `page::Math` trees), 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 | -| `Transaction`, `Stamp`, `PreparedEdit::transaction` | The bytes a commit writes (appended data, in-place list-tail and log patches, header) and the header and length it requires unchanged; `commit` under caller-held exclusion, `commit_file` under the conservative filesystem adapter, `apply` to the base image in memory; serializable for queues | +| `TextAttribute`, `PageOp::Format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | +| `OutlineEdit`, `PageOp::Outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | +| `Transaction`, `Stamp` | The bytes a commit writes (appended data, in-place list-tail and log patches, header) and the header and length it requires unchanged; `commit` under caller-held exclusion, `commit_file` under the conservative filesystem adapter, `apply` to the base image in memory; serializable for queues | | `Arena`, `Section` | Keep a section parsed across edits: `open` validates an image once; `seal` appends one revision per changed space as a `Transaction` on `stamp`, checking only what it appends; `replay` applies a queued transaction; `image` and `page` read the result | -| `op::{Edit, Op, PageOp, TableEdit, SectionOp}`, `Section::apply` | Object-level edits (text, formatting, links, equations, paragraph insertion/split/join/move/deletion/levels, outlines, lists, tags, styles, paragraph formatting, tables, pictures, attachments, ink, page creation/import/moves/removal) applied whole or not to a kept-open section with emitter-chosen identities, UTF-16 ranges and the edit's time; refusals name the target, identity or structure at fault | -| `op::lower`, `op::lower_page`, `Section::apply_page` | The ops turning a range of paragraphs, or a whole page model, into another, computed from the models alone | +| `op::{Edit, Op, PageOp, TableEdit, SectionOp}`, `Section::apply` | Object-level edits (text, formatting, links, equations, paragraph insertion/split/join/move/deletion/levels, outlines, lists, tags, styles, paragraph formatting, tables, pictures, attachments, ink, page creation/import/moves/removal, conflict pages) applied whole or not to a kept-open section with emitter-chosen identities, UTF-16 ranges and the edit's time; refusals name the target, identity or structure at fault | +| `SectionOp::Conflict`, `Section::conflicts`, `ConflictPage` | Keep the version of a page a merge could not take as OneNote 2010 does: a read-only conflict page (`jcidConflictPageMetaData`, `IsConflictPage`, conflict objects marked) under the page's manifest, which says it has conflict pages; list each page's, newest first, and delete one (`SectionOp::Delete`) as OneNote's Delete Conflict Page does | +| `op::lower`, `op::lower_page` | The ops turning a range of paragraphs, or a whole page model, into another, computed from the models alone, for editors and imports | +| `op::predict` | The page an op leaves, as the section stores it and reading it back shows it | | `read_file` | Read a snapshot under whole-file exclusion | | `read_snapshot` | Read a validated snapshot through fresh positioned I/O while the caller excludes maintenance | -| `commit_file_property` | Lock, check the source snapshot's stamp, append and flush, then publish the revision | -| `CommitIo`, `commit_property_bytes` | Supply another storage backend with equivalent exclusion and ordered durability | +| `CommitIo`, `confirm`, `confirm_file` | Supply another storage backend with equivalent exclusion and ordered durability; check that a stamp still holds | -Scalar edits accept encoded values and require the caller to maintain MS-ONE -semantics. Text edits maintain run boundaries, inherit the insertion run's formatting, +Edits enter a kept-open `Section` as ops and leave as one appended revision per +changed space; only section and page creation, imports and opening files handle +whole images. Text edits maintain run boundaries, inherit the insertion run's formatting, and promote legacy text to Unicode when needed. Explicit and body-derived navigation titles update in the same transaction; unsupported fields, protected objects and split surrogate pairs are @@ -85,19 +80,19 @@ sections retain their encrypted structure and payloads. With the optional `protected` feature, `protected::UnlockedSection` opens native OneNote 2010 AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect passwords, unsupported protection profiles and work-limit failures remain distinct. -The source stays encrypted. `PreparedEdit::page_protected` publishes a page-model -edit stored under the section's key (fresh IV per object, payloads under the file -IV); the other writers reject protected sections. +The source stays encrypted. `UnlockedSection::apply` applies page ops and seals them +under the section's key (fresh IV per object, payloads under the file IV); `Section` +rejects protected sections. `PageCreation::new` appends, or inserts before the first page space of an existing series. `Some("")` creates an empty title field; `None` omits the title node. The page has no body outlines, generated date/time text or applied template. Retain the intent to preserve its page, title and space identities; existing identities require reconciliation before retry. Body insertion and title edits -use those identities through the existing APIs. [Native page-creation fixtures](../../corpus/page-lifecycle/creation/README.md) +use those identities through page ops. [Native page-creation fixtures](../../corpus/page-lifecycle/creation/README.md) cover duplicate Unicode titles, native edits and Rust follow-up edits. `PageEdit::set_level` changes one page's indentation in place. `PageEdit::move_to` moves before an existing page space, or appends for `None`, and sets its level. -`PreparedEdit::pages` applies moves in slice order and publishes the final order, +`SectionOp::Pages` applies moves in slice order and publishes the final order, series membership and metadata levels in one transaction. Each page occurs once; levels are 1–3 and the final first page must have level 1. A following deeper-level page remains in place unless explicitly selected. To move a group, supply all its @@ -105,40 +100,39 @@ pages in order. Retain the intents across retries so newly formed series keep their identities; `reposition(PagePosition, level)` revises their placement while preserving those identities. [Native page-edit fixtures](../../corpus/page-lifecycle/page-edits/README.md) cover individual tabs, selected and collapsed groups, nesting and promotion. -`PreparedEdit::delete_pages_permanently` removes exactly the supplied page spaces, +`SectionOp::Delete` removes exactly the supplied page spaces, including subpages only when selected explicitly. The first remaining page becomes top-level; other page levels and surviving content are retained. The operation creates no recycle-bin copies and preserves prior revisions, so it is not secure erasure. Duplicate, missing or non-page identities reject the entire batch; -an empty selection leaves the file unchanged. Commit uses the exact source -snapshot and rejects a stale prepared removal before writing. -Insertions update child references, reference counts, modification times and automatic -titles atomically. Paragraphs can be nested or inserted into table cells; outline -coordinates use points. `Insertion::with_formatting` includes nonoverlapping UTF-16 -spans in that same publication; gaps keep the default insertion style. Empty text -accepts one `0..0` span for subsequent typing. Retain the `Insertion` value for rebasing: creating another -value creates different object identities. Duplicate insertion identities require -reconciliation. Formatting accepts explicit attributes, preserves inherited values, +an empty selection leaves the file unchanged. +`PageOp::Insert` and `PageOp::Add` update child references, reference counts, +modification times and automatic titles atomically. Paragraphs can be nested or +inserted into table cells; outline coordinates use points. Inserted text keeps its +spans' formats; `PageOp::Format` over an empty text's `0..0` sets its insertion style. +New objects carry the emitter's identities, so a queued edit replays onto another +image unchanged; an identity already on the page refuses the edit. Formatting accepts explicit attributes, preserves inherited values, and gives retired immutable styles zero current references while retaining history. Outline layout edits use points. Width is at least 36 points; an explicit user width and an automatic maximum-width hint remain distinct. Native layout generates the rendered height. Saved paragraph collapse defaults can be overridden by the native client's cached view. [Native layout captures](../../corpus/outline-edit/README.md) verify these edits through a fresh OneNote cache, including fields and nested content. -`TreeEdit::move_to` takes an existing parent and an optional direct sibling to insert +`PageOp::Move` takes an existing parent and an optional direct sibling to insert before; `None` appends. Paragraphs retain their descendants and explicit list styles. Outlines remain page children, so reordering changes their stacking order while -retaining coordinates. `TreeEdit::delete` removes the selected subtree from the active +retaining coordinates. `PageOp::Delete` removes the selected subtree from the active graph and preserves historical objects. Empty outlines/groups are removed; surviving -group indentation is normalized without shifting other paragraphs. Empty table cells -receive new empty paragraph/text objects with identities retained in the intent. +group indentation is normalized without shifting other paragraphs. An edit that +leaves a table cell without a paragraph is refused; insert its replacement in the +same edit. Title/protected content, ambiguous ancestry, cycles, and incompatible destinations reject before publication. Modification times, move attribution and automatic titles publish with the tree change. These explicit destinations differ from keyboard list indentation, which can also substitute list markers. Paragraph splits preserve character formatting, retain tags on the left, and clone -mutable list objects without restarting numbering. The new right paragraph/text -identities belong to the retained `ParagraphSplit` intent. Its publication includes +mutable list objects without restarting numbering. `PageOp::Split` names +the new right paragraph/text identities. Its publication includes the complete child graph and title metadata; repeating an existing identity requires reconciliation. Title containers, generated fields, recording-linked text and associated run metadata are rejected before I/O. Native split controls and subsequent @@ -206,9 +200,8 @@ fn main() -> Result<(), Box> { ``` `Store::parse` exposes checksum mismatches for diagnostic readers; the writer -rejects them. The resolved graph borrows the snapshot. Select the object-space ID, -object ID, and property from that graph, then pass those IDs, the same snapshot, -and the replacement's encoded bytes to `commit_file_property`. The +rejects them. The resolved graph borrows the snapshot. Open it as a `Section`, +apply ops naming objects from that graph, seal, and commit the `Transaction`. The `edit_property` example demonstrates selection by JCID/property/expected bytes, with optional explicit IDs when conflict copies contain identical text. @@ -228,7 +221,7 @@ the header (MS-ONESTORE 2.3.1), so the body is never compared. Live readers must equivalent exclusion. Native conflict creation can still expose cross-space references before their targets are saved; such snapshots must be rejected and reread while synchronization -proceeds. `read_file` and `commit_file_property` serialize within the process because +proceeds. `read_file` and `Transaction::commit_file` serialize within the process because macOS SMB locks can be reentrant. The lock is nonblocking across processes; contention requires a fresh read and a later retry. @@ -245,13 +238,16 @@ native acceptance captures. A no-op still flushes; a failed flush has an unknown durability outcome. The filesystem adapter uses whole-file locking. On macOS it acquires the lock -as part of opening the file (`O_EXLOCK | O_NONBLOCK`): separate open and `flock` -calls allowed overlapping exclusive holders and stranded server locks under -multi-process SMB contention. `tools/smb_lock_race.py` reproduces that failure -without notebook parsing or writing. On the tested macOS SMB mount, -POSIX byte-range locks returned `ENOTSUP`; whole-file locks excluded native -OneNote's lock ranges. This adapter serializes readers and writers during each -operation; it does not reproduce native reader/writer concurrency. The +as part of opening the file (`O_EXLOCK` to commit, `O_SHLOCK` to read, with +`O_NONBLOCK`): separate open and `flock` calls allowed overlapping exclusive holders +and stranded server locks under multi-process SMB contention. +`tools/smb_lock_race.py` reproduces that failure without notebook parsing or writing, +and with `--shared-reads` checks that shared readers never overlap a writer. On the +tested macOS SMB mount, POSIX byte-range locks returned `ENOTSUP`, and `flock` of +either kind became an exclusive lock over the whole file, which fails OneNote's reads. +smbfs sends `O_SHLOCK` and `O_EXLOCK` as share modes: a read excludes writers, +OneNote's included, but not OneNote's readers; a commit excludes everyone. It does not +reproduce native reader/writer concurrency. The [locking audit](../../evidence/LOCKING.md) records native coordination bytes, write-open share modes, and a working macOS SMB-specific byte-range lock probe. `sync_all` falls back to `fsync` on macOS only when `F_FULLFSYNC` is unsupported. Successful SMB FLUSH replies were observed on the diff --git a/crates/onestore/examples/concurrent_client.rs b/crates/onestore/examples/concurrent_client.rs index c1c4f7797c92304505d3e1fb9a3a73fa1cc3be6d..7a6e1484327a1633212ce80f3631f851fcdd51fa 100644 --- a/crates/onestore/examples/concurrent_client.rs +++ b/crates/onestore/examples/concurrent_client.rs @@ -2,6 +2,8 @@ mod concurrent; #[path = "../src/flush.rs"] mod flush; +#[path = "support/typing.rs"] +mod typing; use std::{env, fs, io::Write}; @@ -22,7 +24,15 @@ fn main() -> Result<(), Box> { &args, |path| onestore::read_file(path), |path, source, space, object, range, replacement| { - onestore::commit_file_text(path, source, space, object, range, replacement) + let edit = typing::text(space, object, range, replacement); + match typing::sealed(source, "Concurrent client", &edit) { + Ok(Some(transaction)) => transaction.commit_file(path), + Ok(None) => Ok(()), + Err(error) => Err(onestore::CommitError { + state: onestore::CommitState::NotCommitted, + error: std::io::Error::new(std::io::ErrorKind::InvalidData, error), + }), + } }, ) } diff --git a/crates/onestore/examples/edit_property.rs b/crates/onestore/examples/edit_property.rs index a2a8402cef734f39429c1e9a8fd214aed9fcb429..2a720da5cb0f5f34f7065899856eebbbfbeb8ded 100644 --- a/crates/onestore/examples/edit_property.rs +++ b/crates/onestore/examples/edit_property.rs @@ -1,9 +1,15 @@ +//! Changes one stored property value: a text object's text (`6000e 1c001c22`, UTF-16LE, +//! a trailing NUL dropped) as a text op, the notebook's colour on the table of contents root +//! (`20001 14001cbe`) as a TOC edit, each appending a revision; any other value of the same +//! length only in a copy, patched where it is stored, as a fixture that is no edit. + #[path = "../src/flush.rs"] mod flush; +#[path = "support/typing.rs"] +mod typing; use onestore::{ - ExGuid, ObjectData, PropertySets, RevisionIndex, Store, Value, commit_file_property, - replace_property_bytes, + ExGuid, ObjectData, PropertySets, RevisionIndex, Store, TocEdit, Transaction, Value, }; use std::{env, fs, io::Write}; @@ -59,16 +65,67 @@ fn main() -> Result<(), Box> { } } let (space, object) = selected.ok_or("No matching object")?; + let transaction: Option = match (jcid, id) { + (0x6000e, 0x1c001c22) => { + let units = |bytes: &[u8]| -> Vec { + let mut units: Vec = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(); + if units.last() == Some(&0) { + units.pop(); + } + units + }; + let length = u32::try_from(units(&expected).len())?; + let text = String::from_utf16(&units(&value))?; + let edit = typing::text(space, object, 0..length, &text); + typing::sealed(&bytes, "Property editor", &edit)? + } + (0x20001, 0x14001cbe) => { + if index.resolve_active(space)?.roots.get(&1) != Some(&object) { + return Err("Only the notebook's colour, on the TOC root, changes".into()); + } + let color = u32::from_le_bytes(value.as_slice().try_into()?); + onestore::edit_table_of_contents(&bytes, &[TocEdit::Color(color)])? + } + _ if args[1] != "--in-place" && value.len() == expected.len() => { + let revision = index.resolve_active(space)?; + let ObjectData::Properties(data) = revision.objects[&object].data else { + return Err("The object stores no properties".into()); + }; + let properties = PropertySets::parse(data)?; + let Some(Value::Bytes(stored)) = properties.sets[0] + .iter() + .find(|property| property.id == id) + .map(|property| &property.value) + else { + return Err("The property stores no bytes".into()); + }; + let at = stored.as_ptr().addr() - bytes.as_ptr().addr(); + let mut patched = bytes.clone(); + patched[at..at + value.len()].copy_from_slice(&value); + write(&args[1], &patched)?; + return Ok(()); + } + _ => return Err("Only text and TOC colours change as edits; other values are patched into a copy of equal length".into()), + }; + let transaction = transaction.ok_or("The value is already stored")?; if args[1] == "--in-place" { - commit_file_property(&args[0], &bytes, space, object, id, &value)?; + transaction.commit_file(&args[0])?; return Ok(()); } - let written = replace_property_bytes(&bytes, space, object, id, &value)?; + let mut written = bytes.clone(); + transaction.apply(&mut written)?; + write(&args[1], &written) +} + +fn write(path: &str, written: &[u8]) -> Result<(), Box> { let mut output = fs::OpenOptions::new() .write(true) .create_new(true) - .open(&args[1])?; - output.write_all(&written)?; + .open(path)?; + output.write_all(written)?; flush::flush(&output)?; Ok(()) } diff --git a/crates/onestore/examples/header_faults.rs b/crates/onestore/examples/header_faults.rs index 5ef222ae5af5b1ffcebe31d48e2ca94baa17892e..00886c3137eed02af387c4a67202b25f0f426200 100644 --- a/crates/onestore/examples/header_faults.rs +++ b/crates/onestore/examples/header_faults.rs @@ -1,24 +1,28 @@ +#[path = "support/typing.rs"] +mod typing; + use onestore::*; fn main() -> Result<(), Box> { let source = std::fs::read("corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one")?; let store = Store::parse(&source)?; let index = RevisionIndex::parse(&store)?; - let mut target = None; - for (osid, space) in &index.spaces { - let revision = index.resolve(*osid, space.labels[&(ExGuid::default(), 1)])?; - for (oid, object) in revision.objects { - if let ObjectData::Properties(data) = object.data { - for p in &PropertySets::parse(data)?.sets[0] { - if p.value == Value::Bytes(b"Fictitious plain text.") { - target = Some((*osid, oid, p.id)); - } - } - } - } - } - let (osid, oid, property) = target.unwrap(); - let output = replace_property_bytes(&source, osid, oid, property, b"A durable edit.")?; + let document = document::Document::parse(&index)?; + let (osid, oid) = document + .spaces + .iter() + .find_map(|(sid, space)| { + space.active()?.nodes.iter().find_map(|(oid, node)| { + matches!(&node.kind, document::Kind::RichText { text, .. } if text == "Fictitious plain text.") + .then_some((*sid, *oid)) + }) + }) + .ok_or("Missing native fixture text")?; + let end = "Fictitious plain text.".encode_utf16().count() as u32; + let edit = typing::text(osid, oid, 0..end, "A durable edit."); + let transaction = typing::sealed(&source, "Header faults", &edit)?.ok_or("No change")?; + let mut output = source.clone(); + transaction.apply(&mut output)?; let mut invalid = Vec::new(); for prefix in 0..=1024 { let mut torn = output.clone(); diff --git a/crates/onestore/examples/insert.rs b/crates/onestore/examples/insert.rs index e443909cfddb89775826197ba05b3a74b3f9b78d..41a08d32b447ea4e672e334da186c0db5ba45386 100644 --- a/crates/onestore/examples/insert.rs +++ b/crates/onestore/examples/insert.rs @@ -1,37 +1,96 @@ #[path = "../src/flush.rs"] mod flush; +#[path = "support/typing.rs"] +mod typing; -use onestore::{Insertion, PreparedEdit}; +use onestore::{ + document::{Format, Layout}, + op::{Edit, Op, PageOp}, + page::{Outline, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, text::new_id}, +}; use std::{fs, io::Write}; +/// A paragraph holding `text` in OneNote's default text style. +fn paragraph(text: &str) -> Result> { + let format = Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + language: Some(0x409), + ..Format::default() + }; + Ok(PageParagraph { + id: new_id()?, + parent: None, + level: 1, + style: None, + format: Format::default(), + content: ParagraphContent::Text(TextObject { + id: new_id()?, + date_field: None, + text: Paragraph::new(text.into(), format), + tags: Vec::new(), + }), + lists: Vec::new(), + tags: Vec::new(), + media: Default::default(), + collapsed: false, + }) +} + fn main() -> Result<(), Box> { let args: Vec<_> = std::env::args().skip(1).collect(); - let usage = "Usage: insert paragraph INPUT OUTPUT|--in-place SPACE PARENT BEFORE|- TEXT AUTHOR\n insert outline INPUT OUTPUT|--in-place SPACE PAGE X Y TEXT AUTHOR"; - let insertion = match args.first().map(String::as_str) { - Some("paragraph") if args.len() == 8 => Insertion::paragraph( - args[4].parse()?, - if args[5] == "-" { - None - } else { - Some(args[5].parse()?) - }, - &args[6], - &args[7], - )?, - Some("outline") if args.len() == 9 => Insertion::outline( - args[4].parse()?, - args[5].parse()?, - args[6].parse()?, - &args[7], - &args[8], - )?, + let usage = "Usage: insert paragraph INPUT OUTPUT|--in-place SPACE CONTAINER BEFORE|- TEXT AUTHOR\n insert outline INPUT OUTPUT|--in-place SPACE X Y TEXT AUTHOR"; + let (op, object, text, author) = match args.first().map(String::as_str) { + Some("paragraph") if args.len() == 8 => { + let paragraph = paragraph(&args[6])?; + let (object, text) = (paragraph.id, paragraph.text().unwrap().id); + let op = PageOp::Insert { + container: args[4].parse()?, + before: if args[5] == "-" { + None + } else { + Some(args[5].parse()?) + }, + paragraphs: vec![paragraph], + }; + (op, object, text, &args[7]) + } + Some("outline") if args.len() == 8 => { + let paragraph = paragraph(&args[6])?; + let text = paragraph.text().unwrap().id; + let object = new_id()?; + let op = PageOp::Add { + object: PageObject::Outline(Outline { + id: object, + title: false, + min_width: None, + layout: Layout { + x: Some(args[4].parse()?), + y: Some(args[5].parse()?), + ..Layout::default() + }, + indents: Vec::new(), + paragraphs: vec![paragraph], + unsupported: Vec::new(), + }), + before: None, + }; + (op, object, text, &args[7]) + } _ => return Err(usage.into()), }; let source = onestore::read_file(&args[1])?; - let prepared = PreparedEdit::insert(&source, args[3].parse()?, &insertion)?; - let mut record = serde_json::json!({"intent": insertion, "object": insertion.object(), "text_object": insertion.text_object()}); + let edit = Edit { + at: typing::now(), + ops: vec![Op::Page { + space: args[3].parse()?, + op, + }], + }; + let transaction = typing::sealed(&source, author, &edit)?.ok_or("The insertion stores nothing")?; + let mut record = serde_json::json!({"edit": edit, "object": object, "text_object": text}); if args[2] == "--in-place" { - if let Err(error) = prepared.commit_file(&args[1]) { + if let Err(error) = transaction.commit_file(&args[1]) { record["state"] = format!("{:?}", error.state).into(); record["error"] = error.error.to_string().into(); println!("{record}"); @@ -39,11 +98,13 @@ fn main() -> Result<(), Box> { } record["state"] = "Committed".into(); } else { + let mut written = source; + transaction.apply(&mut written)?; let mut file = fs::OpenOptions::new() .write(true) .create_new(true) .open(&args[2])?; - file.write_all(prepared.as_bytes())?; + file.write_all(&written)?; flush::flush(&file)?; record["state"] = "Created".into(); } diff --git a/crates/onestore/examples/maintenance_fixture.rs b/crates/onestore/examples/maintenance_fixture.rs index ccfab15788a4bc6b940847fa6a127fe89e465851..6b97f160df8721518e4052daeadf36f4115fe1f0 100644 --- a/crates/onestore/examples/maintenance_fixture.rs +++ b/crates/onestore/examples/maintenance_fixture.rs @@ -1,3 +1,6 @@ +#[path = "support/typing.rs"] +mod typing; + use onestore::{ ExGuid, RevisionIndex, Store, document::{Document, Kind}, @@ -46,7 +49,10 @@ fn main() -> Result<(), Box> { } else { format!("Revision {revision}: {}", "x".repeat(65536)) }; - bytes = onestore::replace_text(&bytes, sid, oid, 0..end, &text)?; + let edit = typing::text(sid, oid, 0..end, &text); + if let Some(transaction) = typing::sealed(&bytes, "Fixture", &edit)? { + transaction.apply(&mut bytes)?; + } } let store = Store::parse(&bytes)?; let toc = onestore::create_table_of_contents( diff --git a/crates/onestore/examples/power_loss.rs b/crates/onestore/examples/power_loss.rs index 274b7fcf3d7a72df55b8c401483c1f111cea2357..66d93a25d28b626db09844f3925ba95bedb029b4 100644 --- a/crates/onestore/examples/power_loss.rs +++ b/crates/onestore/examples/power_loss.rs @@ -8,6 +8,8 @@ use trace::{Event, Trace}; #[path = "../tests/support/checkpoint.rs"] mod checkpoint; +#[path = "support/typing.rs"] +mod typing; use onestore::{ ExGuid, RevisionIndex, Store, @@ -15,6 +17,16 @@ use onestore::{ }; use std::{collections::BTreeMap, fs, path::PathBuf}; +/// `source`, a table of contents, with the notebook recoloured to `color`. +fn recolored(source: &[u8], color: u32) -> Result, onestore::Error> { + let mut image = source.to_vec(); + let edit = onestore::TocEdit::Color(color); + if let Some(transaction) = onestore::edit_table_of_contents(source, &[edit])? { + transaction.apply(&mut image)?; + } + Ok(image) +} + fn main() -> Result<(), Box> { let mut args = std::env::args_os().skip(1); let destination = PathBuf::from(args.next().ok_or("Provide a new evidence directory")?); @@ -69,12 +81,11 @@ fn main() -> Result<(), Box> { let index = RevisionIndex::parse(&store)?; let document = Document::parse(&index)?; let toc = store.header.file_type == onestore::FileType::TableOfContents; + // A table of contents changes the notebook's colour. let (sid, oid) = if toc { let root = &document.spaces[&document.root]; - ( - document.root, - root.revisions[&root.contexts[&ExGuid::default()]].roots[&1], - ) + let root = &root.revisions[&root.contexts[&ExGuid::default()]]; + (document.root, root.roots[&1]) } else { document .spaces @@ -95,37 +106,43 @@ fn main() -> Result<(), Box> { }) .ok_or("Missing native fixture text")? }; - if name.ends_with("checkpoint") { - source = - checkpoint::pending(&source, sid, oid, if toc { 0x14001cbe } else { 0x14001d7a }); - } - if name == "toc-rollover" { - loop { + match name { + "checkpoint" => source = checkpoint::pending(&source, sid, oid), + "toc-checkpoint" => { + for value in 0.. { + let store = Store::parse(&source)?; + let index = RevisionIndex::parse(&store)?; + let rid = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; + let depth = std::iter::successors(Some(rid), |id| { + index.spaces[&sid].revisions[id].dependency + }) + .count(); + if depth == 512 { + break; + } + source = recolored(&source, value)?; + } + } + "toc-rollover" => loop { let count = Store::parse(&source)?.header.transaction_count; if count == 255 { break; } assert!(count < 255); - source = onestore::replace_property_bytes( - &source, - sid, - oid, - 0x14001cbe, - &count.to_le_bytes(), - )?; - } + source = recolored(&source, count)?; + }, + _ => {} } let apply = |io: &mut Trace, bytes: &[u8], recovering: bool| -> Result<(), Box> { if toc { - let value = if recovering { - [0x44, 0x55, 0x66, 0] - } else { - [0x33, 0x66, 0x99, 0] - }; - onestore::commit_property_bytes(io, bytes, sid, oid, 0x14001cbe, &value)?; + let color = if recovering { 0x665544 } else { 0x996633 }; + let edit = onestore::TocEdit::Color(color); + let transaction = + onestore::edit_table_of_contents(bytes, &[edit])?.ok_or("No change")?; + transaction.commit(io)?; } else { let store = Store::parse(bytes)?; let index = RevisionIndex::parse(&store)?; @@ -136,18 +153,14 @@ fn main() -> Result<(), Box> { unreachable!() }; let end = u32::try_from(text.encode_utf16().count())?; - onestore::commit_text( - io, - bytes, - sid, - oid, - end..end, - if recovering { - " [recovered]" - } else { - " [durable café 🦀]" - }, - )?; + let with = if recovering { + " [recovered]" + } else { + " [durable café 🦀]" + }; + let edit = typing::text(sid, oid, end..end, with); + let transaction = typing::sealed(bytes, "Power loss", &edit)?.ok_or("No change")?; + transaction.commit(io)?; } Ok(()) }; diff --git a/crates/onestore/examples/random_edit.rs b/crates/onestore/examples/random_edit.rs index 4a93ec13169510713cf9731ce052b94614727c3f..965efd77cb361a65e40d8dbeeffe6af49751d6be 100644 --- a/crates/onestore/examples/random_edit.rs +++ b/crates/onestore/examples/random_edit.rs @@ -1,5 +1,7 @@ #[path = "../src/flush.rs"] mod flush; +#[path = "support/typing.rs"] +mod typing; use onestore::{ ExGuid, RevisionIndex, Store, @@ -111,13 +113,20 @@ fn main() -> Result<(), Box> { end }; let range = offset + boundaries[start]..offset + boundaries[end]; - let written = match onestore::replace_text(&source, sid, oid, range.clone(), replacement) { - Ok(written) => written, + let edit = typing::text(sid, oid, range.clone(), replacement); + let transaction = match typing::sealed(&source, "Random editor", &edit) { + Ok(Some(transaction)) => transaction, + Ok(None) => { + rejected.push("The edit stores nothing".into()); + continue; + } Err(error) => { rejected.push(error.to_string()); continue; } }; + let mut written = source.clone(); + transaction.apply(&mut written)?; let space = &document.spaces[&sid]; let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; let Kind::RichText { runs, .. } = &revision.nodes[&oid].kind else { @@ -133,7 +142,7 @@ fn main() -> Result<(), Box> { "run_before": actual[selected].text, "run_start": runs[selected].start, "source_md5": format!("{:x}", md5::compute(&source)), "started_ms": started, "rejected_candidates": rejected}); let failed = if args[1] == "--in-place" { - match onestore::commit_file_text(&args[0], &source, sid, oid, range, replacement) { + match transaction.commit_file(&args[0]) { Ok(()) => { record["state"] = "Committed".into(); false diff --git a/crates/onestore/examples/scratch_dump.rs b/crates/onestore/examples/scratch_dump.rs new file mode 100644 index 0000000000000000000000000000000000000000..552781b7f6128203e993a10d2617f370c6d21650 --- /dev/null +++ b/crates/onestore/examples/scratch_dump.rs @@ -0,0 +1,29 @@ +use onestore::{ObjectData, PropertySets, RevisionIndex, Store, Value, document::Document}; +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + let bytes = std::fs::read(&args[0])?; + let store = Store::parse(&bytes)?; + let index = RevisionIndex::parse(&store)?; + let pages = Document::parse(&index).and_then(|document| document.pages()).unwrap_or_default(); + let space = if args[1] == "root" { index.root } else { pages[args[1].parse::()?].0 }; + println!("space {space}; pages {:?}", pages.iter().map(|p| p.0).collect::>()); + let raw = index.resolve_active(space)?; + for (id, object) in &raw.objects { + println!("{id} jcid {:#x}", object.jcid); + if let ObjectData::Properties(data) = object.data { + let sets = PropertySets::parse(data)?; + for (n, set) in sets.sets.iter().enumerate() { + for p in set { + let v = match &p.value { + Value::NoData => "-".to_string(), + Value::Bytes(b) => b.iter().map(|x| format!("{x:02x}")).collect(), + Value::References { stream, compact_ids } => format!("{stream:?} {}", compact_ids.iter().map(|x| format!("{x:02x}")).collect::()), + Value::Sets(r) => format!("sets {r:?}"), + }; + println!(" [{n}] {:#010x} {v}", p.id); + } + } + } + } + Ok(()) +} diff --git a/crates/onestore/examples/support/concurrent.rs b/crates/onestore/examples/support/concurrent.rs index d39a781b522c604809473846aff03471fb3a73e2..8b8984b9b660e473761c5ec94ba38e1c68e46953 100644 --- a/crates/onestore/examples/support/concurrent.rs +++ b/crates/onestore/examples/support/concurrent.rs @@ -318,7 +318,46 @@ pub fn run( #[cfg(test)] mod tests { use super::*; - use onestore::{Insertion, ParagraphJoin, ParagraphSplit, PreparedEdit, TextAttribute}; + use onestore::{ + TextAttribute, + document::{Format, Layout}, + op::{Edit, Op, PageOp}, + page::{Outline, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, text::new_id}, + }; + + fn paragraph(text: &str, level: u32) -> PageParagraph { + PageParagraph { + id: new_id().unwrap(), + parent: None, + level, + style: None, + format: Format::default(), + content: ParagraphContent::Text(TextObject { + id: new_id().unwrap(), + date_field: None, + text: Paragraph::new(text.into(), Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + language: Some(0x409), + ..Format::default() + }), + tags: Vec::new(), + }), + lists: Vec::new(), + tags: Vec::new(), + media: Default::default(), + collapsed: false, + } + } + + fn edited(image: &[u8], space: ExGuid, ops: Vec) -> Vec { + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, image.to_vec()).unwrap(); + let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); + section.apply("Author", &Edit { at: 134_000_000_000_000_000, ops }).unwrap(); + section.seal().unwrap(); + section.image() + } #[test] fn observation_follows_split_suffixes_and_moved_children_through_active_ancestry() { @@ -328,57 +367,96 @@ mod tests { let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); let (space, page) = document.pages().unwrap()[0]; - let insertion = Insertion::outline(page, 144.0, 216.0, "Document w0:0 🦀", "Author") - .unwrap() - .with_formatting(0..16, &[TextAttribute::Bold(true)]) - .unwrap(); - let inserted = PreparedEdit::insert(&source, space, &insertion).unwrap(); - let before = document_view(inserted.as_bytes()).unwrap(); + let first = paragraph("Document w0:0 🦀", 1); + let (paragraph_id, text) = (first.id, first.text().unwrap().id); + let outline_id = new_id().unwrap(); + let inserted = edited( + &source, + space, + vec![ + PageOp::Add { + object: PageObject::Outline(Outline { + id: outline_id, + title: false, + min_width: None, + layout: Layout { + x: Some(144.0), + y: Some(216.0), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: vec![first], + unsupported: Vec::new(), + }), + before: None, + }, + PageOp::Format { + text, + range: 0..16, + set: vec![TextAttribute::Bold(true)], + clear: Vec::new(), + }, + ], + ); + let before = document_view(&inserted).unwrap(); assert_eq!(before.texts.len(), 1); assert_eq!(before.graph.len(), 2); - let outline = insertion.object().to_string(); - let paragraph = before.graph[&outline]["children"][0] - .as_str() - .unwrap() - .to_owned(); - let child = - Insertion::paragraph(paragraph.parse().unwrap(), None, "Unmarked child", "Author") - .unwrap(); - let with_child = PreparedEdit::insert(inserted.as_bytes(), space, &child).unwrap(); - let before = document_view(with_child.as_bytes()).unwrap(); + let outline = outline_id.to_string(); + let paragraph_name = paragraph_id.to_string(); + assert_eq!(before.graph[&outline]["children"], json!([paragraph_name])); + let child = paragraph("Unmarked child", 2); + let child_id = child.id; + let with_child = edited( + &inserted, + space, + vec![PageOp::Insert { + container: paragraph_id, + before: None, + paragraphs: vec![child], + }], + ); + let before = document_view(&with_child).unwrap(); assert_eq!(before.texts.len(), 2); assert_eq!(before.graph.len(), 3); assert_eq!( before.graph[&outline], - json!({"parent":page.to_string(), "children":[paragraph], "content":[], "child_level":1, "position":{"x":144.0,"y":216.0}}) + json!({"parent":page.to_string(), "children":[paragraph_name], "content":[], "child_level":1, "position":{"x":144.0,"y":216.0}}) ); for offset in [14, 16] { - let split = ParagraphSplit::new(insertion.text_object(), offset, "Author").unwrap(); - let split_edit = PreparedEdit::split(with_child.as_bytes(), space, &split).unwrap(); - let observed = document_view(split_edit.as_bytes()).unwrap(); + let (split, right) = (new_id().unwrap(), new_id().unwrap()); + let split_edit = edited( + &with_child, + space, + vec![PageOp::Split { + text, + at: offset, + paragraph: split, + right, + lists: Vec::new(), + }], + ); + let observed = document_view(&split_edit).unwrap(); assert_eq!(observed.texts.len(), 3); assert_eq!(observed.graph.len(), 4); assert_eq!( - observed.texts[&split.text_object().to_string()]["text"], + observed.texts[&right.to_string()]["text"], if offset == 14 { "🦀" } else { "" } ); assert_eq!( observed.graph[&outline]["children"], - json!([paragraph, split.object().to_string()]) + json!([paragraph_name, split.to_string()]) ); - assert_eq!(observed.graph[¶graph]["children"], json!([])); + assert_eq!(observed.graph[¶graph_name]["children"], json!([])); assert_eq!( - observed.graph[&split.object().to_string()]["children"], - json!([child.object().to_string()]) + observed.graph[&split.to_string()]["children"], + json!([child_id.to_string()]) ); assert_eq!( - observed.graph[&child.object().to_string()]["parent"], - split.object().to_string() + observed.graph[&child_id.to_string()]["parent"], + split.to_string() ); - let join = - ParagraphJoin::new(insertion.text_object(), split.text_object(), "Author").unwrap(); - let joined = PreparedEdit::join(split_edit.as_bytes(), space, &join).unwrap(); - let joined = document_view(joined.as_bytes()).unwrap(); + let joined = edited(&split_edit, space, vec![PageOp::Join { left: text, right }]); + let joined = document_view(&joined).unwrap(); assert_eq!(joined.graph, before.graph); let characters = |view: &DocumentView| { view.texts diff --git a/crates/onestore/examples/support/typing.rs b/crates/onestore/examples/support/typing.rs new file mode 100644 index 0000000000000000000000000000000000000000..97e5a6a8936a27f75e72538b3da89ed79ff5f883 --- /dev/null +++ b/crates/onestore/examples/support/typing.rs @@ -0,0 +1,41 @@ +//! Text typed as ops through a `Section`, for the examples that edit a section file. +#![allow(dead_code)] + +use onestore::{ + Arena, ExGuid, Section, Transaction, + op::{Edit, Op, OpError, PageOp}, +}; +use std::{ + ops::Range, + time::{SystemTime, UNIX_EPOCH}, +}; + +/// FILETIME now, as an edit's `at`. +pub fn now() -> u64 { + let unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100) +} + +/// The op replacing `range` of the text object `text` in `space` with `with`. +pub fn text(space: ExGuid, text: ExGuid, range: Range, with: &str) -> Edit { + let op = PageOp::Text { + text, + range, + with: with.to_owned(), + }; + Edit { + at: now(), + ops: vec![Op::Page { space, op }], + } +} + +/// The transaction `edit` seals into on `image`, as `author` made it; none when it stores +/// nothing. +pub fn sealed(image: &[u8], author: &str, edit: &Edit) -> Result, OpError> { + let arena = Arena::default(); + let mut section = Section::open(&arena, image.to_vec()).map_err(OpError::Failed)?; + section.apply(author, edit)?; + section.seal().map_err(OpError::Failed) +} diff --git a/crates/onestore/src/active.rs b/crates/onestore/src/active.rs index 6ed47dcb39e3259ff8b0b688b87b5103e14f33bb..7abdae2e79e56a3a4beb7e111dd2320b3cc8cc87 100644 --- a/crates/onestore/src/active.rs +++ b/crates/onestore/src/active.rs @@ -1,7 +1,13 @@ use crate::{ - Error, ExGuid, FileType, Object, RevisionIndex, Store, - document::{Document, Element, Kind, Revision, Space}, - write::{Commit, LiveRevision, PropertyObject, chain_depth, declared, differing}, + Error, ExGuid, FileType, Object, + document::{Element, Kind, Revision}, + write::{Commit, LiveRevision, PropertyObject, declared, differing}, +}; +#[cfg(test)] +use crate::{ + RevisionIndex, Store, + document::{Document, Space}, + write::chain_depth, }; use bumpalo::Bump; use std::{ @@ -35,6 +41,8 @@ pub(crate) struct ActivePage<'a> { } impl<'a> ActivePage<'a> { + /// The active page of `space`, for tests comparing writers. + #[cfg(test)] pub(crate) fn parse(index: &'a RevisionIndex<'a>, space: ExGuid) -> Result { index.validate_current()?; let mut document = Document::parse(index)?; @@ -131,6 +139,7 @@ impl<'a> ActivePage<'a> { /// Stores a writer's objects and payloads as the next revision; false when it stores /// nothing, as appending it would leave the image unchanged. + #[cfg(test)] pub(crate) fn write( &mut self, arena: &'a Bump, @@ -328,26 +337,19 @@ fn is_title(node: &Element<'_>) -> bool { .is_some_and(|fields| fields.iter().any(|field| field.id == 0x88001cb4)) } -/// Writes one revision of a page space: `changes` computes its objects from the active page. -pub(crate) fn write( - source: &[u8], - space: ExGuid, - changes: impl FnOnce(&ActivePage<'_>) -> Result, -) -> Result> { - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - let changes = changes(&ActivePage::parse(&index, space)?)?; - if changes.is_empty() { - return Ok(source.to_vec()); - } - crate::write::write_revision_on(&index, space, |_| Ok(changes)) -} - #[cfg(test)] pub(crate) mod tests { use super::*; use crate::{Insertion, ParagraphSplit, TextAttribute, TreeEdit, document::Kind}; + /// An identity from `fresh_guid`, which seeded tests draw deterministically per thread. + fn new_id() -> Result { + Ok(ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }) + } + /// A seeded source of the typed writers' changes: insertions, moves, deletions, splits /// and formatting of what a page holds. pub(crate) struct Writes(pub u64); @@ -392,15 +394,31 @@ pub(crate) mod tests { let text = *texts.get(self.pick(texts.len())).ok_or(unavailable)?; let words = ["", "a", "Two words", "東京 🦀", "longer text here"]; let word = words[self.pick(words.len())]; - match self.pick(8) { - 0..=3 => Insertion::paragraph(container, anchor, word, "Author") - .and_then(|insertion| insertion.changes(active)), + // `Section::apply` refuses an edit ending with a table cell emptied. + let fills_a_cell = active.parents[¶graph].iter().any(|parent| { + matches!(nodes[parent].kind, Kind::Cell { .. }) + && nodes[parent].children == [paragraph] + }); + let choice = match self.pick(8) { + 4 | 5 if fills_a_cell => 0, + choice => choice, + }; + match choice { + 0..=3 => Insertion::paragraph(container, anchor, word, "Author").and_then( + |insertion| { + let paragraph = new_id()?; + insertion.changes_as(active, paragraph, paragraph, new_id()?) + }, + ), 4 => TreeEdit::move_to(paragraph, container, anchor, "Author") .and_then(|edit| edit.changes(active)), 5 => TreeEdit::delete(paragraph, "Author").and_then(|edit| edit.changes(active)), 6 => { let at = self.pick(3) as u32; - ParagraphSplit::new(text, at, "Author").and_then(|split| split.changes(active)) + let lists = (0..8).map(|_| new_id()).collect::>>()?; + ParagraphSplit::new(text, at, "Author").and_then(|split| { + split.changes_as(active, new_id()?, new_id()?, &lists) + }) } _ => { let end = self.pick(2) as u32; diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index 87ecff00fd31b2f8ef33abfdce63dc3a1bac15b0..d372d746452d028b4474f254f402695c3f1e6d55 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -1,4 +1,3 @@ -use crate::{ExGuid, replace_property_bytes}; use std::io::{self, ErrorKind}; #[cfg(any(unix, windows))] @@ -33,8 +32,14 @@ impl FileIo { #[cfg(target_os = "macos")] { use std::os::unix::fs::OpenOptionsExt; - // SMB can lose exclusion when separate opens race with flock. - options.custom_flags(nix::libc::O_EXLOCK | nix::libc::O_NONBLOCK); + // SMB can lose exclusion when separate opens race with flock. On smbfs these are + // share modes: a shared reader denies only writers, so OneNote's readers proceed. + let lock = if write { + nix::libc::O_EXLOCK + } else { + nix::libc::O_SHLOCK + }; + options.custom_flags(lock | nix::libc::O_NONBLOCK); } let file = options.open(path)?; #[cfg(not(target_os = "macos"))] @@ -100,14 +105,14 @@ pub fn place_file(path: impl AsRef, ancestor: [u8; 16], name: &str) -> io: released } -/// Reads a snapshot under the same whole-file exclusion used for commits. -/// Native writers can expose incomplete graphs to unlocked filesystem reads. +/// Reads a snapshot excluding writers, as commits exclude everyone (macOS shares it with +/// other readers). Native writers can expose incomplete graphs to unlocked filesystem reads. #[cfg(any(unix, windows))] pub fn read_file(path: impl AsRef) -> io::Result> { read_file_limited(path, usize::MAX) } -/// Reads under whole-file exclusion, rejecting a snapshot larger than the byte limit. +/// `read_file`, rejecting a snapshot larger than the byte limit. /// A size failure returns `FileTooLarge` without a partial snapshot. #[cfg(any(unix, windows))] pub fn read_file_limited(path: impl AsRef, limit: usize) -> io::Result> { @@ -147,278 +152,25 @@ impl CommitIo for FileIo { } } -/// Commits under an exclusive whole-file lock; contention returns WouldBlock. -/// Calls are serialized within this process because macOS SMB locks are reentrant. -/// Durability relies on the filesystem and server honoring their flush contract. -/// A changed snapshot returns ResourceBusy without publishing the edit. +/// `confirm` under the same whole-file exclusion `commit_file` uses. #[cfg(any(unix, windows))] -pub fn commit_file_property( - path: impl AsRef, - source: &[u8], - space: ExGuid, - object: ExGuid, - property: u32, - value: &[u8], -) -> Result<(), CommitError> { +pub fn confirm_file(path: impl AsRef, base: &Stamp) -> Result<(), CommitError> { let mut io = FileIo::open(path, true).map_err(|error| CommitError { state: CommitState::NotCommitted, error, })?; - let result = commit_property_bytes(&mut io, source, space, object, property, value); + let result = confirm(&mut io, base); io.finish(result) } -/// Commits a text edit with the same exclusion and snapshot check as scalar edits. -#[cfg(any(unix, windows))] -pub fn commit_file_text( - path: impl AsRef, - source: &[u8], - space: ExGuid, - object: ExGuid, - range: std::ops::Range, - replacement: &str, -) -> Result<(), CommitError> { - let mut io = FileIo::open(path, true).map_err(|error| CommitError { - state: CommitState::NotCommitted, - error, - })?; - let result = commit_text(&mut io, source, space, object, range, replacement); - io.finish(result) -} - -/// Atomically publishes text and its dependent run boundaries under caller-held exclusion. -pub fn commit_text( - io: &mut impl CommitIo, - source: &[u8], - space: ExGuid, - object: ExGuid, - range: std::ops::Range, - replacement: &str, -) -> Result<(), CommitError> { - PreparedEdit::text(source, space, object, range, replacement) - .map_err(|error| CommitError { - state: CommitState::NotCommitted, - error: io::Error::new(ErrorKind::InvalidData, error), - })? - .commit(io) -} - -/// An immutable writer-generated transition tied to its original snapshot. -/// Persist intended revision identities from `as_bytes` before publishing an offline edit. -/// Missing identities after native maintenance do not prove an edit was never published. -pub struct PreparedEdit<'a> { - source: &'a [u8], - written: Vec, -} - -impl<'a> PreparedEdit<'a> { - /// Applies page moves in slice order and publishes final order and indentation atomically. - /// Each page occurs once; all pages to move must be explicit, including selected subpages. - pub fn pages(source: &'a [u8], edits: &[crate::PageEdit]) -> Result { - Ok(Self { - source, - written: crate::pages::edit_pages(source, edits, &[])?, - }) - } - - /// Applies table-of-contents edits (sections and section groups: add, rename, colour, - /// order, remove) as one revision of a `.onetoc2` file. - pub fn table_of_contents( - source: &'a [u8], - edits: &[crate::TocEdit], - ) -> Result { - Ok(Self { - source, - written: crate::toc::edit_table_of_contents(source, edits)?, - }) - } - - /// Permanently removes explicitly selected pages from the section in one transaction. - /// Subpages must be selected explicitly; a surviving first subpage becomes top-level. - /// Creates no recycle-bin copies. Prior revisions remain stored; this is not secure erasure. - pub fn delete_pages_permanently( - source: &'a [u8], - pages: &[crate::ExGuid], - ) -> Result { - Ok(Self { - source, - written: crate::pages::edit_pages(source, &[], pages)?, - }) - } - - /// Publishes an edited page model as one revision per changed space. - /// The model must come from this snapshot; new paragraphs and outlines carry the identities - /// the model assigned, and content outside the model is untouched. - pub fn page( - source: &'a [u8], - space: ExGuid, - page: &crate::page::Page, - author: &str, - ) -> Result { - Ok(Self { - source, - written: crate::page::write::write_page(source, space, page, author)?, - }) - } - - /// `page` for a password-protected section: the revision is stored under the section's - /// key. The model must come from this snapshot unlocked with `password`. - #[cfg(feature = "protected")] - pub fn page_protected( - source: &'a [u8], - password: &str, - space: ExGuid, - page: &crate::page::Page, - author: &str, - ) -> Result { - Ok(Self { - source, - written: crate::protected::write_page(source, password, space, page, author)?, - }) - } - - /// Creates a page and its section entry in one transaction, retaining the intent's identities. - pub fn create_page(source: &'a [u8], page: &crate::PageCreation) -> Result { - Ok(Self { - source, - written: page.apply(source)?, - }) - } - - /// Moves or removes a subtree and normalizes its containers in one revision. - pub fn tree( - source: &'a [u8], - space: ExGuid, - edit: &crate::TreeEdit, - ) -> Result { - Ok(Self { - source, - written: edit.apply(source, space)?, - }) - } - - /// Changes outline geometry or a paragraph's saved expansion state, preserving content. - pub fn outline( - source: &'a [u8], - space: ExGuid, - object: ExGuid, - edit: crate::OutlineEdit, - ) -> Result { - Ok(Self { - source, - written: edit.apply(source, space, object)?, - }) - } - - /// Joins adjacent ordinary paragraphs with native left-tag and text-identity semantics. - pub fn join( - source: &'a [u8], - space: ExGuid, - join: &crate::ParagraphJoin, - ) -> Result { - Ok(Self { - source, - written: join.apply(source, space)?, - }) - } - - /// Splits a paragraph and updates its children, lists, tags and title metadata atomically. - /// Fields and associated run metadata are rejected before I/O. - pub fn split( - source: &'a [u8], - space: ExGuid, - split: &crate::ParagraphSplit, - ) -> Result { - Ok(Self { - source, - written: split.apply(source, space)?, - }) - } - - /// Prepares an insertion and its dependent metadata in one revision, without I/O. - pub fn insert( - source: &'a [u8], - space: ExGuid, - insertion: &crate::Insertion, - ) -> Result { - Ok(Self { - source, - written: insertion.apply(source, space)?, - }) - } - - /// Validates and prepares a text edit without I/O, with `replace_text` semantics. - pub fn text( - source: &'a [u8], - space: ExGuid, - object: ExGuid, - range: std::ops::Range, - replacement: &str, - ) -> Result { - Ok(Self { - source, - written: crate::replace_text(source, space, object, range, replacement)?, - }) - } - - /// Changes character formatting over a UTF-16 range, preserving unselected runs and styles. - /// A zero-length range sets the insertion style only when the paragraph is empty. - /// Fields, associated run objects and boundaries splitting preserved run data are rejected. - pub fn format( - source: &'a [u8], - space: ExGuid, - object: ExGuid, - range: std::ops::Range, - attributes: &[crate::TextAttribute], - ) -> Result { - Ok(Self { - source, - written: crate::formatting::format_text(source, space, object, range, attributes)?, - }) - } - - /// The exact complete image this edit will publish; identities do not regenerate on commit. - /// Do not overwrite a live notebook with this image; use `commit` under exclusion. - pub fn as_bytes(&self) -> &[u8] { - &self.written - } - - /// The bytes publishing this edit writes, against its snapshot's stamp. - pub fn transaction(&self) -> Transaction { - Transaction::between(self.source, &self.written) - } - - /// `Transaction::commit` of this edit. - pub fn commit(&self, io: &mut impl CommitIo) -> Result<(), CommitError> { - self.transaction().commit(io) - } - - /// `Transaction::commit_file` of this edit. - #[cfg(any(unix, windows))] - pub fn commit_file(&self, path: impl AsRef) -> Result<(), CommitError> { - self.transaction().commit_file(path) - } -} - -/// `confirm_snapshot` under the same whole-file exclusion `commit_file` uses. -#[cfg(any(unix, windows))] -pub fn confirm_file_snapshot(path: impl AsRef, source: &[u8]) -> Result<(), CommitError> { - let mut io = FileIo::open(path, true).map_err(|error| CommitError { - state: CommitState::NotCommitted, - error, - })?; - let result = confirm_snapshot(&mut io, source); - io.finish(result) -} - -/// Checks and flushes a snapshot's stamp, then refreshes its header version metadata. -/// No revision is added; reread before using the snapshot for another physical commit. +/// Checks that the file still has `base`'s stamp and flushes it, then refreshes its header +/// version metadata. No revision is added; reread before committing on `base` again. /// The caller must hold OneNote-compatible exclusion and independently establish which -/// intents the snapshot contains. A successful read alone is not a durable acknowledgement. -pub fn confirm_snapshot(io: &mut impl CommitIo, source: &[u8]) -> Result<(), CommitError> { +/// intents the image contains. A successful read alone is not a durable acknowledgement. +pub fn confirm(io: &mut impl CommitIo, base: &Stamp) -> Result<(), CommitError> { let mut state = CommitState::NotCommitted; let result = (|| -> io::Result<()> { - let header = crate::Header::parse(source).map_err(io::Error::other)?; + let header = crate::Header::parse(&base.header).map_err(io::Error::other)?; let generation = header .generation .checked_add(1) @@ -427,7 +179,7 @@ pub fn confirm_snapshot(io: &mut impl CommitIo, source: &[u8]) -> Result<(), Com version[..16].copy_from_slice(&crate::write::fresh_guid().map_err(io::Error::other)?); version[16..24].copy_from_slice(&generation.to_le_bytes()); version[24..].copy_from_slice(&crate::write::fresh_guid().map_err(io::Error::other)?); - Stamp::of(source).map_err(io::Error::other)?.check(io)?; + base.check(io)?; state = CommitState::Unknown; io.flush()?; write_all(io, 212, &version)?; @@ -493,26 +245,6 @@ fn write_all(io: &mut impl CommitIo, mut offset: u64, mut bytes: &[u8]) -> io::R Ok(()) } -/// Publishes a scalar revision after checking the locked file against its snapshot. -/// Unknown outcomes require rereading; Committed errors affect lock release. -pub fn commit_property_bytes( - io: &mut impl CommitIo, - source: &[u8], - space: ExGuid, - object: ExGuid, - property: u32, - value: &[u8], -) -> Result<(), CommitError> { - let written = - replace_property_bytes(source, space, object, property, value).map_err(|error| { - CommitError { - state: CommitState::NotCommitted, - error: io::Error::new(ErrorKind::InvalidData, error), - } - })?; - Transaction::between(source, &written).commit(io) -} - /// What a commit requires unchanged since its snapshot: the header, which every committed /// transaction and every placement rewrites (MS-ONESTORE 2.3.1 `guidFileVersion`), and the /// length appends start from. Equal stamps name the same committed image. @@ -619,31 +351,6 @@ impl TryFrom for Transaction { } impl Transaction { - /// The transaction turning `source`, a parsed image, into `written`, which extends it. - pub(crate) fn between(source: &[u8], written: &[u8]) -> Self { - let differs = |at: &usize| source[*at] != written[*at]; - let mut patches = Vec::new(); - let mut offset = 1024; - while let Some(start) = (offset..source.len()).find(differs) { - let mut end = start + 1; - // Equal gaps shorter than a write request's overhead join the patch. - while let Some(next) = (end..source.len().min(end + 64)).find(differs) { - end = next + 1; - } - patches.push((start as u64, written[start..end].to_vec())); - offset = end; - } - Self { - base: Stamp { - header: *source.first_chunk().unwrap(), - length: source.len() as u64, - }, - append: written[source.len()..].to_vec(), - patches, - header: *written.first_chunk().unwrap(), - } - } - /// The image this transaction applies to. pub fn base(&self) -> &Stamp { &self.base diff --git a/crates/onestore/src/create.rs b/crates/onestore/src/create.rs index 4c3824efd1c512308787f79d4b83d3fc60c52383..c39c23aa9ee1c14540c8df06d473eeb5c268e8e9 100644 --- a/crates/onestore/src/create.rs +++ b/crates/onestore/src/create.rs @@ -17,6 +17,21 @@ pub(crate) fn string(value: &str) -> Vec { .collect() } +/// An author's initials as OneNote 2010 stores them beside the name (`0x1c001df8`, with +/// no MS-ONE entry): Office's default, the first letter of each word of the name. +pub(crate) fn initials(author: &str) -> String { + author + .split_whitespace() + .filter_map(|word| word.chars().next()) + .flat_map(char::to_uppercase) + .collect() +} + +/// An author object's properties as OneNote 2010 writes them: initials, then the name. +pub(crate) fn author_properties(name: &str) -> Vec<(u32, Vec)> { + vec![(0x1c001df8, string(&initials(name))), (0x1c001d75, string(name))] +} + pub(crate) fn default_text_style() -> Vec<(u32, Vec)> { vec![ (0x14001c3b, 0x409_u32.to_le_bytes().to_vec()), @@ -249,6 +264,7 @@ pub fn create_section(file_name: &str, text: &str, author: &str) -> Result Result Result) -> Result> { + if file_name.contains(['/', '\\', '\0']) || !file_name.to_ascii_lowercase().ends_with(".one") { + return Err(Error { + offset: 0, + message: "Invalid section name", + }); + } + let timestamp = current_timestamps()?.1.to_le_bytes().to_vec(); + let id = |value: u32| value.to_le_bytes().to_vec(); + let spaces = vec![NewSpace { + id: 1, + roots: vec![(1, 10), (2, 11)], + objects: vec![ + NewObject { + id: 10, + jcid: 0x60007, + properties: vec![ + (0x1c001c30, fresh_guid()?.to_vec()), + (0x18001c65, timestamp), + ], + }, + NewObject { + id: 11, + jcid: 0x20031, + properties: vec![ + (0x14001d82, id(40)), + (0x1400348b, id(40)), + (0x14001cbe, id(color.unwrap_or(0xffff_ffff))), + ], + }, + ], + }]; + create(file_name, FileType::Section, spaces) +} + /// Creates a notebook table of contents from section filenames and file identities. pub fn create_table_of_contents(file_name: &str, sections: &[(&str, [u8; 16])]) -> Result> { if file_name.contains(['/', '\\', '\0']) diff --git a/crates/onestore/src/edit.rs b/crates/onestore/src/edit.rs index 4e97286956a4152a579493ae92dca906b56380a4..7caa327195eb331e17f8910c85a4dbd62671596f 100644 --- a/crates/onestore/src/edit.rs +++ b/crates/onestore/src/edit.rs @@ -28,26 +28,6 @@ pub(crate) fn automatic_title(text: &str) -> &str { /// Insertion at a run boundary uses the following run, except at the end of text. /// The final run retains its insertion style even when emptied. /// Replacing a range with identical text leaves its existing formatting unchanged. -/// Returns a complete file image without I/O; use `commit_file_text` to update an existing file. -pub fn replace_text( - source: &[u8], - space: ExGuid, - object: ExGuid, - range: Range, - replacement: &str, -) -> Result, Error> { - if range.start > range.end || replacement.contains(['\0', '\n', '\r', '\u{fffc}']) { - return Err(Error { - offset: 0, - message: "Use a valid text range and ordinary paragraph text", - }); - } - crate::active::write(source, space, |active| { - text_changes(active, object, range, replacement) - }) -} - -/// `replace_text` on an active page. pub(crate) fn text_changes( active: &ActivePage<'_>, object: ExGuid, @@ -312,7 +292,9 @@ fn rewrite( .roots .get(&2) .ok_or_else(|| invalid("Page title metadata is unavailable"))?; - let Kind::Metadata { title, .. } = &revision.nodes[metadata].kind else { + let (Kind::Metadata { title, .. } | Kind::ConflictMetadata { title, .. }) = + &revision.nodes[metadata].kind + else { return Err(invalid("Page title metadata is unavailable")); }; let cached: Vec<_> = title_text @@ -544,7 +526,7 @@ pub(crate) fn update_title( .roots .get(&2) .ok_or_else(|| invalid("Page title metadata is unavailable"))?; - if raw.objects[metadata].jcid != 0x20030 { + if ![0x20030, 0x20038].contains(&raw.objects[metadata].jcid) { return Err(invalid("Page title metadata is unavailable")); } let title = string(&title); diff --git a/crates/onestore/src/formatting.rs b/crates/onestore/src/formatting.rs index 44b35d32379deb52ba3e76da402d9355b1f7a8e0..61d0da02fc7f81fc05727c6e1fc6911bdd9ef3f9 100644 --- a/crates/onestore/src/formatting.rs +++ b/crates/onestore/src/formatting.rs @@ -100,20 +100,8 @@ impl TextAttribute { } } -pub(crate) fn format_text( - source: &[u8], - space: ExGuid, - object: ExGuid, - range: Range, - attributes: &[TextAttribute], -) -> Result, Error> { - crate::active::write(source, space, |active| { - format_changes(active, object, range, attributes, &[]) - }) -} - -/// `format_text` on an active page, also removing the `cleared` properties from the range's -/// run styles so the text inherits them. +/// Character formatting set over a UTF-16 range, also removing the `cleared` properties +/// from the range's run styles so the text inherits them. pub(crate) fn format_changes( active: &ActivePage<'_>, object: ExGuid, diff --git a/crates/onestore/src/insertion.rs b/crates/onestore/src/insertion.rs index 0ce998267f8effcd436520af297ca20ae50e6347..dda6301876eff8cc7d772db236a5b0c6d15517a3 100644 --- a/crates/onestore/src/insertion.rs +++ b/crates/onestore/src/insertion.rs @@ -6,10 +6,8 @@ use crate::{ op::content::{NATIVE_INDENTS, measurement_bytes}, write::{PropertyObject, fresh_guid}, }; -use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, BTreeSet}, - ops::Range, sync::Arc, }; @@ -17,59 +15,29 @@ fn invalid(message: &'static str) -> Error { Error { offset: 0, message } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone, PartialEq)] enum Placement { Paragraph { before: Option }, Outline { x: f32, y: f32 }, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct FormatSpan { - guid: [u8; 16], - range: Range, - attributes: Vec, -} - -/// A paragraph or outline insertion with stable object identities and creation time. -/// Retain this intent across rebases; constructing another intent allocates different identities. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Insertion { +/// A paragraph or outline insertion with its creation time; `changes_as` names the new +/// objects. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Insertion { guid: [u8; 16], parent: ExGuid, placement: Placement, text: String, author: String, created: u32, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - formats: Vec, } impl Insertion { - /// Adds a nonoverlapping UTF-16 formatting span, retaining all existing identities. - /// Gaps use the ordinary insertion style; only empty text accepts a zero-length span. - pub fn with_formatting( - &self, - range: Range, - attributes: &[crate::TextAttribute], - ) -> Result { - let mut intent = self.clone(); - intent.formats.push(FormatSpan { - guid: fresh_guid()?, - range, - attributes: attributes.to_vec(), - }); - intent.formats.sort_by_key(|span| span.range.start); - intent.validate()?; - Ok(intent) - } - /// Inserts before a direct child, or appends when `before` is None. /// The parent must be an editable outline, paragraph, outline group or table cell. /// Carriage returns represent soft line breaks; line feeds and embedded-field markers are rejected. - pub fn paragraph( + pub(crate) fn paragraph( parent: ExGuid, before: Option, text: &str, @@ -79,38 +47,10 @@ impl Insertion { } /// Adds an outline to an editable page at coordinates measured in points. - pub fn outline(page: ExGuid, x: f32, y: f32, text: &str, author: &str) -> Result { + pub(crate) fn outline(page: ExGuid, x: f32, y: f32, text: &str, author: &str) -> Result { Self::new(page, Placement::Outline { x, y }, text, author) } - /// Changes a paragraph intent's placement while retaining its identities, text and author. - pub fn reposition_paragraph( - &self, - parent: ExGuid, - before: Option, - ) -> Result { - if !matches!(self.placement, Placement::Paragraph { .. }) { - return Err(invalid("An outline intent cannot become a paragraph")); - } - let mut intent = self.clone(); - intent.parent = parent; - intent.placement = Placement::Paragraph { before }; - intent.validate()?; - Ok(intent) - } - - /// Changes an outline intent's placement while retaining its identities, text and author. - pub fn reposition_outline(&self, page: ExGuid, x: f32, y: f32) -> Result { - if !matches!(self.placement, Placement::Outline { .. }) { - return Err(invalid("A paragraph intent cannot become an outline")); - } - let mut intent = self.clone(); - intent.parent = page; - intent.placement = Placement::Outline { x, y }; - intent.validate()?; - Ok(intent) - } - fn new(parent: ExGuid, placement: Placement, text: &str, author: &str) -> Result { let intent = Self { guid: fresh_guid()?, @@ -119,28 +59,11 @@ impl Insertion { text: text.to_owned(), author: author.to_owned(), created: current_timestamps()?.0, - formats: Vec::new(), }; intent.validate()?; Ok(intent) } - /// Identity of the new paragraph, or the new outline for an outline insertion. - pub fn object(&self) -> ExGuid { - ExGuid { - guid: self.guid, - n: 1, - } - } - - /// Identity of the insertion's ordinary rich-text object. - pub fn text_object(&self) -> ExGuid { - ExGuid { - guid: self.guid, - n: 2, - } - } - fn validate(&self) -> Result<(), Error> { if self.guid == [0; 16] || self.parent.guid == [0; 16] @@ -156,69 +79,11 @@ impl Insertion { { return Err(invalid("Outline coordinates must be finite")); } - if !self.formats.is_empty() { - let units: Vec<_> = self.text.encode_utf16().collect(); - let length = u32::try_from(units.len()) - .map_err(|_| invalid("Text exceeds UTF-16 offset range"))?; - let mut identities = BTreeSet::from([self.guid]); - let mut end = 0; - for span in &self.formats { - if span.guid == [0; 16] || !identities.insert(span.guid) { - return Err(invalid("Formatting identities must be distinct")); - } - if span.range.start > span.range.end - || span.range.end > length - || span.range.start < end - || (span.range.is_empty() && (!units.is_empty() || self.formats.len() != 1)) - { - return Err(invalid( - "Formatting spans must be nonoverlapping ranges within the new text", - )); - } - for boundary in [span.range.start, span.range.end] { - if boundary > 0 - && boundary < length - && (0xd800..=0xdbff).contains(&units[boundary as usize - 1]) - { - return Err(invalid("Formatting boundary splits a surrogate pair")); - } - } - crate::formatting::attribute_values(&span.attributes)?; - end = span.range.end; - } - } Ok(()) } - pub(crate) fn apply(&self, source: &[u8], space: ExGuid) -> Result, Error> { - self.validate()?; - crate::active::write(source, space, |active| self.changes(active)) - } - - pub(crate) fn changes(&self, active: &ActivePage<'_>) -> Result { - let paragraph = ExGuid { - guid: self.guid, - n: if matches!(self.placement, Placement::Outline { .. }) { - 3 - } else { - 1 - }, - }; - let changes = self.changes_as(active, self.object(), paragraph, self.text_object())?; - let raw = &active.live.revision; - if changes - .keys() - .any(|id| id.guid == self.guid && raw.objects.contains_key(id)) - { - return Err(invalid( - "An insertion identity is already present; reconcile the existing edit", - )); - } - Ok(changes) - } - - /// `changes` creating the outline or paragraph `object`, the paragraph `paragraph` and its - /// text `text` under those identities. + /// The objects creating the outline or paragraph `object`, the paragraph `paragraph` and + /// its text `text` under those identities. pub(crate) fn changes_as( &self, active: &ActivePage<'_>, @@ -321,7 +186,7 @@ impl Insertion { (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()), ], ), - (author, 0x120001, vec![(0x1c001d75, string(&self.author))]), + (author, 0x120001, crate::create::author_properties(&self.author)), (default, 0x12004d, default_text_style()), ] { new.insert( @@ -333,51 +198,6 @@ impl Insertion { }, ); } - if !self.formats.is_empty() { - let mut segments = Vec::new(); - let mut end = 0; - for span in &self.formats { - if end < span.range.start { - segments.push((span.range.start, default)); - } - let id = ExGuid { - guid: span.guid, - n: 1, - }; - let mut style = PropertyObject { - jcid: 0x12004d, - bytes: properties(&default_text_style())?, - global_ids: Arc::new(BTreeMap::from([(0, span.guid)])), - }; - let values = crate::formatting::attribute_values(&span.attributes)?; - style.set( - &values - .iter() - .map(|(id, value)| (*id, value.as_slice())) - .collect::>(), - )?; - new.insert(id, style); - segments.push((span.range.end, id)); - end = span.range.end; - } - let length = u32::try_from(self.text.encode_utf16().count()) - .map_err(|_| invalid("Text exceeds UTF-16 offset range"))?; - if end < length { - segments.push((length, default)); - } - if !segments.iter().any(|(_, id)| *id == default) { - new.remove(&default); - } - let target = new.get_mut(&text).unwrap(); - let mut references = Vec::new(); - let mut ends = Vec::new(); - for (end, id) in segments { - references.extend_from_slice(&target.reference(id)?); - ends.extend_from_slice(&end.to_le_bytes()); - } - ends.truncate(ends.len() - 4); - target.set(&[(0x24001e13, &references), (0x1c001e12, &ends)])?; - } if let Placement::Outline { x, y } = self.placement { let children = reference(paragraph)?; new.insert( @@ -449,7 +269,7 @@ impl Insertion { .roots .get(&2) .ok_or_else(|| invalid("Page title metadata is unavailable"))?; - if raw.objects[metadata].jcid != 0x20030 { + if ![0x20030, 0x20038].contains(&raw.objects[metadata].jcid) { return Err(invalid("Page title metadata is unavailable")); } let title = string(&title); diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index d60291cd2898af3dfb123177aa011dc332c413a1..065d879e8c8c54755bb14f599a51308e60dec2a1 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -28,29 +28,21 @@ mod toc; mod tree; mod write; -pub use commit::{ - CommitError, CommitIo, CommitState, PreparedEdit, Stamp, Transaction, commit_property_bytes, - commit_text, confirm_snapshot, place, -}; +pub use commit::{CommitError, CommitIo, CommitState, Stamp, Transaction, confirm, place}; #[cfg(any(unix, windows))] -pub use commit::{ - commit_file_property, commit_file_text, confirm_file_snapshot, place_file, read_file, - read_file_limited, -}; -pub use create::{create_section, create_table_of_contents}; -pub use edit::replace_text; +pub use commit::{confirm_file, place_file, read_file, read_file_limited}; +pub use create::{create_empty_section, create_section, create_table_of_contents}; pub use files::FileDataReference; pub use formatting::{FONT_SIZES, TextAttribute}; -pub use insertion::Insertion; +pub(crate) use insertion::Insertion; pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision}; pub use outline::OutlineEdit; -pub use pages::{PageCreation, PageEdit, PagePosition}; -pub use paragraph::{ParagraphJoin, ParagraphSplit}; +pub use pages::{ConflictPage, PageCreation, PageEdit, PagePosition}; +pub(crate) use paragraph::{ParagraphJoin, ParagraphSplit}; pub use properties::{IdStream, Property, PropertySets, Value}; pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex}; pub use section::{Arena, Section}; pub use snapshot::{read_snapshot, read_storage_snapshot}; pub use store::{Chunk, Error, FileType, Header, Node, NodeList, Reference, Store}; -pub use toc::TocEdit; -pub use tree::TreeEdit; -pub use write::replace_property_bytes; +pub use toc::{TocEdit, edit_table_of_contents}; +pub(crate) use tree::TreeEdit; diff --git a/crates/onestore/src/op/apply.rs b/crates/onestore/src/op/apply.rs index d82c371bd17b36a2e86298e7d4b2981d446390cc..b9b9c292ac294d898bf012fb16235541b124fdbd 100644 --- a/crates/onestore/src/op/apply.rs +++ b/crates/onestore/src/op/apply.rs @@ -49,6 +49,28 @@ impl<'a> Section<'a> { Op::Section(op) => section.apply_section(author, op)?, } } + let spaces: BTreeSet<_> = edit + .ops + .iter() + .filter_map(|op| match op { + Op::Page { space, .. } => Some(*space), + Op::Section(_) => None, + }) + .collect(); + for space in spaces { + // A page the edit went on to delete has no cells left to check. + let Ok(page) = section.active(space) else { + continue; + }; + if page.view.nodes.values().any(|node| { + matches!(node.kind, Kind::Cell { .. }) && node.children.is_empty() + }) { + return Err(OpError::Unsupported( + "A table cell keeps a paragraph; insert its replacement first", + ) + .into()); + } + } Ok(()) }) }) @@ -99,28 +121,26 @@ impl<'a> Section<'a> { } SectionOp::Import { creation, page } => { self.apply_section(author, &SectionOp::Create(creation.clone()))?; + self.fill(author, creation.space(), page)?; + } + SectionOp::Conflict { + of, + creation, + page, + objects, + } => { + let level = self + .pages()? + .into_iter() + .find(|(space, ..)| space == of) + .map(|(.., level)| level) + .ok_or(OpError::TargetUnavailable(*of))?; let space = creation.space(); - let before = self.page(space)?; - let mut after = page.clone(); - // The created page's title stays; the imported page brings the rest. - after.objects.retain(|object| !matches!(object, PageObject::Title(_))); - for object in before.objects.iter().rev() { - if let PageObject::Title(_) = object { - after.objects.insert(0, object.clone()); - } - } - after.title = before.title.clone(); - after.identity = before.identity; - after.created = before.created; - after.margin_origin = before.margin_origin; - for op in super::lower_page(&before, &after)? { - Writer { - section: self, - space, - author, - } - .apply(&op)?; - } + let (roots, created, metadata) = creation.conflict(self, &page.title, level)?; + self.create_space(space, roots, created)?; + self.fill(author, space, page)?; + self.mark_conflict(space, creation, page, objects)?; + self.link_conflict(*of, space, metadata)?; } SectionOp::Pages(edits) => { for (space, changes) in crate::pages::section_changes(self, edits, &[])? { @@ -128,15 +148,237 @@ impl<'a> Section<'a> { } } SectionOp::Delete(pages) => { - for (space, changes) in crate::pages::section_changes(self, &[], pages)? { - self.apply_changes(space, &[], changes)?; + let mut listed = Vec::new(); + let conflicts = self.conflicts()?; + for space in pages { + let of = conflicts.iter().find_map(|(of, conflicts)| { + conflicts.iter().any(|page| page.space == *space).then_some(*of) + }); + match of { + Some(of) => self.unlink_conflict(of, *space)?, + None => listed.push(*space), + } } + if !listed.is_empty() { + for (space, changes) in crate::pages::section_changes(self, &[], &listed)? { + self.apply_changes(space, &[], changes)?; + } + } + } + SectionOp::Color(color) => { + let root = self.root(); + let page = self.active(root)?; + let metadata = *page + .view + .roots + .get(&2) + .filter(|id| page.live.revision.objects.contains_key(id)) + .ok_or_else(|| invalid("The section has no metadata to colour"))?; + let mut object = crate::write::PropertyObject::from_object( + &page.live.revision.objects[&metadata], + )?; + object.set(&[(0x14001cbe, &color.unwrap_or(0xffff_ffff).to_le_bytes())])?; + self.apply_changes(root, &[], BTreeMap::from([(metadata, object)]))?; + } + } + Ok(()) + } + + /// Writes `page`'s content onto the page just created in `space`, whose title stays. + fn fill(&mut self, author: &str, space: ExGuid, page: &crate::page::Page) -> Result<(), Failure> { + let before = self.page(space)?; + let mut after = page.clone(); + after.objects.retain(|object| !matches!(object, PageObject::Title(_))); + for object in &mut after.objects { + // Stroke points are page coordinates; new ink takes its position from them. + if let PageObject::Ink(ink) = object { + ink.layout = Default::default(); + } + } + for object in before.objects.iter().rev() { + if let PageObject::Title(_) = object { + after.objects.insert(0, object.clone()); + } + } + after.title = before.title.clone(); + after.identity = before.identity; + after.created = before.created; + after.margin_origin = before.margin_origin; + for op in super::lower_page(&before, &after)? { + Writer { + section: self, + space, + author, + } + .apply(&op)?; + } + Ok(()) + } + + /// Marks a filled conflict page as OneNote 2010 does: the page read-only, deletable and + /// a conflict page (MS-ONE 2.1.2), and each of `objects` a conflict object (2.1.1). The + /// created title stands for `page`'s, so objects of that title mark its counterparts. + fn mark_conflict( + &mut self, + space: ExGuid, + creation: &crate::PageCreation, + page: &crate::page::Page, + objects: &[ExGuid], + ) -> Result<(), Failure> { + let created = |n| ExGuid { + guid: creation.space().guid, + n, + }; + let mut titles = BTreeMap::new(); + for object in &page.objects { + let PageObject::Title(title) = object else { + continue; + }; + titles.insert(title.id, created(13)); + if let Some(outline) = title.outlines.first() { + titles.insert(outline.id, created(14)); + if let Some(paragraph) = outline.paragraphs.first() { + titles.insert(paragraph.id, created(15)); + if let Some(text) = paragraph.text() { + titles.insert(text.id, created(16)); + } + } + } + } + let active = self.active(space)?; + let mut changes = BTreeMap::new(); + let mut page_node = crate::write::PropertyObject::from_object( + &active.live.revision.objects[&creation.object()], + )?; + page_node.set(&[(0x88001cde, &[]), (0x88001d0c, &[]), (0x88001d7c, &[])])?; + changes.insert(creation.object(), page_node); + for id in objects { + let id = titles.get(id).copied().unwrap_or(*id); + if !active.live.is_reachable(id) { + return Err(OpError::TargetUnavailable(id).into()); + } + let mut object = + crate::write::PropertyObject::from_object(&active.live.revision.objects[&id])?; + // A table is rendered as conflicting but is not selectable as such. + if object.jcid == 0x60022 { + object.set(&[(0x88001d96, &[])])?; + } else { + object.set(&[(0x88001ddb, &[]), (0x88001d96, &[])])?; + } + changes.insert(id, object); + } + self.apply_changes(space, &[], changes)?; + Ok(()) + } + + /// Lists conflict page `space` under page `of` as OneNote 2010 does: the page's manifest + /// references it and keeps a copy of its metadata, and the page's metadata and the + /// section's copy of it say the page has conflict pages (MS-ONE 2.2.34, 2.3.71). + fn link_conflict( + &mut self, + of: ExGuid, + space: ExGuid, + mut metadata: crate::write::PropertyObject, + ) -> Result<(), Failure> { + use crate::{pages::set_references, write::PropertyObject}; + let page = self.active(of)?; + let manifest_id = page.view.roots[&1]; + let manifest_node = &page.view.nodes[&manifest_id]; + let mut spaces = manifest_node.spaces.clone(); + spaces.push(space); + let mut copies = objects(manifest_node, 0x24003442); + let copy = crate::pages::metadata_id(space); + copies.push(copy); + metadata.reference(copy)?; + let mut manifest = PropertyObject::from_object(&page.live.revision.objects[&manifest_id])?; + set_references(&mut manifest, 0x2c001d63, &spaces)?; + set_references(&mut manifest, 0x24003442, &copies)?; + self.apply_changes( + of, + &[], + BTreeMap::from([(manifest_id, manifest), (copy, metadata)]), + )?; + self.has_conflicts(of, true) + } + + /// Deletes conflict page `space` of page `of` as OneNote 2010 does: the manifest stops + /// referencing it (its metadata copy stays) and the last one's removal clears + /// `HasConflictPages`; the conflict page's own space is left as it is. + fn unlink_conflict(&mut self, of: ExGuid, space: ExGuid) -> Result<(), Failure> { + use crate::{pages::set_references, write::PropertyObject}; + let page = self.active(of)?; + let manifest_id = page.view.roots[&1]; + let spaces: Vec = page.view.nodes[&manifest_id] + .spaces + .iter() + .copied() + .filter(|listed| *listed != space) + .collect(); + let mut manifest = PropertyObject::from_object(&page.live.revision.objects[&manifest_id])?; + if spaces.is_empty() { + manifest.remove(&[0x2c001d63])?; + } else { + set_references(&mut manifest, 0x2c001d63, &spaces)?; + } + self.apply_changes(of, &[], BTreeMap::from([(manifest_id, manifest)]))?; + if spaces.is_empty() { + self.has_conflicts(of, false)?; + } + Ok(()) + } + + /// Sets or clears `HasConflictPages` on page `of`'s metadata and on the section's copy + /// of it, where its series keeps one. + fn has_conflicts(&mut self, of: ExGuid, value: bool) -> Result<(), Failure> { + use crate::write::PropertyObject; + let flag = |object: &crate::Object<'_>| -> Result { + let mut object = PropertyObject::from_object(object)?; + if value { + object.set(&[(0x88001d97, &[])])?; + } else { + object.remove(&[0x88001d97])?; } + Ok(object) + }; + let page = self.active(of)?; + let stored_id = page.view.roots[&2]; + let identity = crate::pages::metadata_guid(&page.view.nodes[&stored_id])?; + let stored = flag(&page.live.revision.objects[&stored_id])?; + self.apply_changes(of, &[], BTreeMap::from([(stored_id, stored)]))?; + let root = self.root(); + let section = self.active(root)?; + let copy = section + .view + .nodes + .values() + .find(|node| node.spaces.contains(&of)) + .map(|series| objects(series, 0x24003442)) + .unwrap_or_default() + .into_iter() + .find(|id| { + section.view.nodes.get(id).is_some_and(|node| { + crate::pages::metadata_guid(node).is_ok_and(|guid| guid == identity) + }) + }); + if let Some(id) = copy { + let object = flag(§ion.live.revision.objects[&id])?; + self.apply_changes(root, &[], BTreeMap::from([(id, object)]))?; } Ok(()) } } +/// The objects an element's reference property names. +fn objects(node: &crate::document::Element<'_>, property: u32) -> Vec { + node.extra[0] + .iter() + .find_map(|field| match &field.value { + crate::document::FieldValue::Objects(ids) if field.id == property => Some(ids.clone()), + _ => None, + }) + .unwrap_or_default() +} + /// Whether an op may store more than one revision of a page, so its failure part way needs /// the page as it was. fn stores_more_than_once(op: &Op) -> bool { @@ -348,6 +590,19 @@ impl<'a> Writer<'_, 'a> { Ok(BTreeMap::from([(*metadata, object)])) }) } + PageOp::Color(color) => self.write(|page| { + let [node] = crate::active::manifest_pages(&page.view)[..] else { + return Err(invalid("The page has no page node to colour")); + }; + let mut object = + crate::write::PropertyObject::from_object(&page.live.revision.objects[&node])?; + // OneNote's "No color" removes the property. + match color { + Some(color) => object.set(&[(crate::page::PAGE_COLOR, &color.to_le_bytes())])?, + None => object.remove(&[crate::page::PAGE_COLOR])?, + } + Ok(BTreeMap::from([(node, object)])) + }), PageOp::Insert { container, before, @@ -386,18 +641,6 @@ impl<'a> Writer<'_, 'a> { } PageOp::Delete { object } => { self.target(*object)?; - let page = self.page()?; - if matches!(page.view.nodes[object].kind, Kind::Paragraph { .. }) { - let container = self.container(*object)?; - if matches!(self.page()?.view.nodes[&container].kind, Kind::Cell { .. }) - && levels::flat_children(&self.page()?.view, container) == [*object] - { - return Err(OpError::Unsupported( - "A table cell keeps a paragraph; insert its replacement first", - ) - .into()); - } - } let edit = TreeEdit::delete(*object, self.author)?; self.write(|page| edit.changes(page)) } diff --git a/crates/onestore/src/op/levels.rs b/crates/onestore/src/op/levels.rs index 4143bed29890b99348f68feec990d63a6e3f56c3..b015fe417e7d64573bc74674e63c2ed85ab51336 100644 --- a/crates/onestore/src/op/levels.rs +++ b/crates/onestore/src/op/levels.rs @@ -14,21 +14,6 @@ fn invalid(message: &'static str) -> Error { Error { offset: 0, message } } -/// The children of `container` with its outline groups lifted into it, in order. -pub(crate) fn flat_children(view: &Revision<'_>, container: ExGuid) -> Vec { - fn flat(view: &Revision<'_>, id: ExGuid, out: &mut Vec) { - for child in &view.nodes[&id].children { - match view.nodes[child].kind { - Kind::OutlineGroup => flat(view, *child, out), - _ => out.push(*child), - } - } - } - let mut children = Vec::new(); - flat(view, container, &mut children); - children -} - /// How much deeper than `container` each of its children lies, groups lifted. pub(crate) fn stored_depths(view: &Revision<'_>, container: ExGuid) -> Vec<(ExGuid, u32)> { fn walk(view: &Revision<'_>, id: ExGuid, base: u32, out: &mut Vec<(ExGuid, u32)>) { @@ -46,23 +31,6 @@ pub(crate) fn stored_depths(view: &Revision<'_>, container: ExGuid) -> Vec<(ExGu out } -/// Lifts the children of `container`'s outline groups into it. -pub(crate) fn ungroup_changes(active: &ActivePage<'_>, container: ExGuid) -> Result { - let children = flat_children(&active.view, container); - let parents = active.editable_parents(container)?; - let modified = crate::create::current_timestamps()?.0.to_le_bytes(); - let raw = &active.live.revision; - let mut holder = PropertyObject::from_object(&raw.objects[&container])?; - let mut references = Vec::new(); - for child in children { - references.extend(holder.reference(child)?); - } - holder.set(&[(0x24001c20, &references), (0x14001d7a, &modified)])?; - let mut changed = BTreeMap::from([(container, holder)]); - crate::formatting::touch_ancestors(raw, parents, container, &modified, &mut changed)?; - Ok(changed) -} - /// Children in runs: one child at the container's child level, or a run sharing a deeper /// depth. pub(crate) type Runs = Vec<(u32, Vec)>; diff --git a/crates/onestore/src/op/lower.rs b/crates/onestore/src/op/lower.rs index 249ac6809d2244831cbf5feb195cf5cc73494ed8..995f4fafdb1bbf293c56264f3c604bf2a0b575f7 100644 --- a/crates/onestore/src/op/lower.rs +++ b/crates/onestore/src/op/lower.rs @@ -44,6 +44,7 @@ pub fn lower( identity: None, created: None, margin_origin: [0.0; 2], + color: None, objects: vec![PageObject::Outline(Outline { id: container, title: false, @@ -163,16 +164,6 @@ impl<'a> View<'a> { self.paragraphs.get(¶graph).and_then(|p| p.text()) } - /// How much deeper than `container` each of its children lies: their outline levels - /// less the paragraph's, or less nothing in an outline or cell. - pub(crate) fn depths(&self, container: ExGuid, children: &[ExGuid]) -> Option> { - let base = self.paragraphs.get(&container).map_or(0, |p| p.level); - children - .iter() - .map(|id| self.paragraphs.get(id)?.level.checked_sub(base)) - .collect() - } - /// Each outline's paragraphs in document order, a table's cells following its paragraph. pub(crate) fn documents(&self) -> impl Iterator> + '_ { fn walk<'a>(list: &'a [PageParagraph], out: &mut Vec<&'a PageParagraph>) { @@ -677,11 +668,6 @@ pub(crate) fn paragraph_fields( }) } -/// `paragraph_fields` encoded, as the page writer stores them. -pub(crate) fn paragraph_change(stored: &Paragraph, target: &Paragraph) -> Result { - paragraph_fields(stored, target)?.values() -} - /// Whether two tag lists store the same tags. pub(crate) fn same_tags(a: &[Tag], b: &[Tag]) -> bool { let key = |t: &Tag| { @@ -1555,6 +1541,9 @@ impl Lowering { let shown = fields.iter().all(|(id, shown)| { model::paragraph_text(&self.current, *id).is_some_and(|text| text.text.text() == shown) }); + if before.color != after.color { + self.emit(PageOp::Color(after.color))?; + } if before.created == after.created && shown { return Ok(()); } diff --git a/crates/onestore/src/op/mod.rs b/crates/onestore/src/op/mod.rs index 32eefd577750ba90de58345808041dca1fb91851..cdda21dc027bd590d687e5bd132a187f58f71acf 100644 --- a/crates/onestore/src/op/mod.rs +++ b/crates/onestore/src/op/mod.rs @@ -19,7 +19,7 @@ pub(crate) mod model; pub(crate) mod properties; pub(crate) mod table; #[cfg(test)] -mod tests; +pub(crate) mod tests; pub use lower::{lower, lower_page}; /// `op` applied to `page`, the model of what the section holds: the page `Section::apply` @@ -29,7 +29,7 @@ pub use model::apply as predict; /// Property identifiers with their encoded values. pub(crate) type Values = Vec<(u32, Vec)>; -/// One user action, applied whole or not at all. +/// One user action, applied whole or not at all; it leaves every table cell a paragraph. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Edit { @@ -80,6 +80,8 @@ pub enum PageOp { created: u64, fields: Vec<(ExGuid, String)>, }, + /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color". + Color(Option), /// Inserts paragraphs before a direct child of `container`, or last. Children follow /// their parents in `paragraphs`; levels are absolute. Text keeps its spans' formats; /// lists, tags, styles and collapse state are set by their own ops. @@ -108,7 +110,7 @@ pub enum PageOp { parent: Option, before: Option, }, - /// Removes a subtree; an emptied table cell receives an empty paragraph. + /// Removes a subtree. Delete { object: ExGuid }, /// Sets a paragraph's absolute outline level, regrouping its container. Level { paragraph: ExGuid, level: u32 }, @@ -204,10 +206,23 @@ pub enum SectionOp { /// A page created with `creation` holding `page`'s content under the identities it /// carries (`Page::copy` gives fresh ones); the created title stays. Import { creation: PageCreation, page: Page }, + /// A read-only conflict page of the listed page `of`, as OneNote 2010 keeps the version of + /// a page a merge could not take: created with `creation` (not placed, its author the user + /// whose version it is, a title only where `page` has one) and holding `page` as `Import` + /// does, with `objects`, identities on `page`, marked as the conflicting ones. + Conflict { + of: ExGuid, + creation: PageCreation, + page: Page, + objects: Vec, + }, /// Page moves and indentation, in order. Pages(Vec), - /// Removes pages permanently; their revisions stay stored. + /// Removes pages permanently, listed or conflict pages; their revisions stay stored. Delete(Vec), + /// The section's colour as COLORREF, kept in its own metadata; `None` is OneNote's + /// "no colour". + Color(Option), } /// A character property that `PageOp::Format` can clear so the text inherits it. diff --git a/crates/onestore/src/op/model.rs b/crates/onestore/src/op/model.rs index 474982b04a5f52b4a6e518f2b65fa24b3e8be954..3e293dec4c83ba064814ce4667ec105fb681915e 100644 --- a/crates/onestore/src/op/model.rs +++ b/crates/onestore/src/op/model.rs @@ -603,6 +603,7 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { } page.created = Some(*created); } + PageOp::Color(color) => page.color = *color, PageOp::Text { text, range, with } => { let object = text_mut(page, *text)?; object.text = replace_text(&object.text, range.clone(), with)?; @@ -861,13 +862,7 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { let (path, index) = find(page, *object).ok_or_else(|| invalid("The object is not on the page"))?; let list = list_at(page, &path); - let range = subtree(list, index); - if !path.cells.is_empty() && range.len() == list.len() { - return Err(invalid( - "A table cell keeps a paragraph; insert its replacement first", - )); - } - list.drain(range); + list.drain(subtree(list, index)); } drop_emptied(page); retitle(page, None); diff --git a/crates/onestore/src/op/table.rs b/crates/onestore/src/op/table.rs index 37678fee9457d3bfe8bdecc47130b6926f0c3da8..1edccce0d28f1824f5352fbb048c6b5b1fe9533c 100644 --- a/crates/onestore/src/op/table.rs +++ b/crates/onestore/src/op/table.rs @@ -35,7 +35,6 @@ pub(crate) fn validate_table(table: &Table) -> Result<(), Error> { return Err(invalid("Every table row has one cell per column")); } for cell in &row.cells { - // An emptied cell keeps a replacement paragraph, as the tree writer provides. for paragraph in &cell.paragraphs { if let crate::page::ParagraphContent::Table(nested) = ¶graph.content { validate_table(nested)?; diff --git a/crates/onestore/src/op/tests.rs b/crates/onestore/src/op/tests.rs index 4501ae30eb9dadd83ce5bbb2c3e23f7027025eab..6fafcec7d42b38ad91f38f1af19b9365e581a98c 100644 --- a/crates/onestore/src/op/tests.rs +++ b/crates/onestore/src/op/tests.rs @@ -1,5 +1,6 @@ -//! Ops against the page-model writer: for random edits of corpus pages, lowering the edit -//! and applying the ops through a `Section` stores what `PreparedEdit::page` stores. +//! Ops against the model: for random edits of corpus pages, lowering the edit and applying +//! the ops through a `Section` stores the edited page, as the model oracle predicts and as +//! the sealed image reads back. use super::*; use crate::{ @@ -11,7 +12,7 @@ use crate::{ }, write::GUIDS, }; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; const SOURCES: &[(&str, &[u8])] = &[ ("outline-edit", include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one")), @@ -85,20 +86,25 @@ fn read(image: &[u8], space: ExGuid) -> Page { } /// `page` with writer-chosen identities replaced in document order: list nodes, which a -/// copy for a second owner gets, and tag field indices. +/// copy for a second owner gets (so equal definitions become one), tag field indices, and ink +/// coordinates at the resolution they are stored in. pub(super) fn normalize(page: &Page) -> Page { let mut page = page.clone(); let mut lists = BTreeMap::new(); + let mut canonical = BTreeMap::new(); let mut paths: Vec<_> = model::lists(&page).into_iter().map(|(path, _, _)| path).collect(); paths.sort_by_key(|path| format!("{path:?}")); + let definitions = page.definitions.clone(); for path in &paths { for paragraph in model::list_at(&mut page, path).iter_mut() { for list in &mut paragraph.lists { let next = ExGuid { guid: [0xee; 16], - n: 1 + lists.len() as u32, + n: 1 + canonical.len() as u32, }; - *list = *lists.entry(*list).or_insert(next); + let content = definitions.get(list).map_or(format!("{list:?}"), |d| format!("{d:?}")); + let id = *canonical.entry(content).or_insert(next); + *list = *lists.entry(*list).or_insert(id); } for tag in paragraph.tags.iter_mut() { tag.extra_set = 0; @@ -136,9 +142,113 @@ pub(super) fn normalize(page: &Page) -> Page { _ => Some((id, definition)), }) .collect(); + fn quantize(ink: &mut Ink) { + for stroke in &mut ink.strokes { + for point in &mut stroke.points { + *point = point.map(|value| (value * 10.0).round() / 10.0); + } + } + ink.groups.iter_mut().for_each(quantize); + } + for object in &mut page.objects { + if let PageObject::Ink(ink) = object { + quantize(ink); + } + } + for path in &paths { + for paragraph in model::list_at(&mut page, path).iter_mut() { + if let ParagraphContent::Ink(ink) = &mut paragraph.content { + quantize(ink); + } + } + } page } +/// `after` with what the writers derive rather than store as given: the title from the +/// title text, the values they give new objects where the model leaves them unset, and only +/// the definitions something uses. +fn as_written(after: &Page, before: &Page, stored: &Page) -> Page { + use serde_json::Value; + fn ids(value: &Value, out: &mut BTreeSet) { + match value { + Value::Object(map) => { + if let Some(Value::String(id)) = map.get("id") { + out.insert(id.clone()); + } + map.values().for_each(|value| ids(value, out)); + } + Value::Array(values) => values.iter().for_each(|value| ids(value, out)), + _ => {} + } + } + fn adopt(after: &mut Value, stored: &Value, old: &BTreeSet, new: bool) { + match (after, stored) { + (Value::Object(after), Value::Object(stored)) => { + let new = new + || matches!(after.get("id"), Some(Value::String(id)) if !old.contains(id)); + for (key, value) in after.iter_mut() { + let Some(written) = stored.get(key) else { + continue; + }; + // Ink coordinates are stored at a coarser resolution. + let near = |a: &Value, b: &Value| { + a.as_f64().zip(b.as_f64()).is_some_and(|(a, b)| (a - b).abs() < 0.05) + }; + let close_points = key == "points" + && value.as_array().zip(written.as_array()).is_some_and(|(a, b)| { + a.len() == b.len() + && a.iter().zip(b).all(|(a, b)| { + a.as_array().zip(b.as_array()).is_some_and(|(a, b)| { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| near(a, b)) + }) + }) + }); + let unset = new && (value.is_null() || value.as_array().is_some_and(Vec::is_empty)); + if close_points || unset { + *value = written.clone(); + } else { + adopt(value, written, old, new); + } + } + } + (Value::Array(after), Value::Array(stored)) if after.len() == stored.len() => { + for (value, written) in after.iter_mut().zip(stored) { + adopt(value, written, old, new); + } + } + _ => {} + } + } + let mut old = BTreeSet::new(); + ids(&serde_json::to_value(before).unwrap(), &mut old); + let mut value = serde_json::to_value(after).unwrap(); + adopt(&mut value, &serde_json::to_value(stored).unwrap(), &old, false); + let mut page: Page = serde_json::from_value(value).unwrap(); + page.title = stored.title.clone(); + unused_dropped(&mut page); + page +} + +/// Drops the definitions nothing on `page` uses, as reading a page leaves them out. +fn unused_dropped(page: &mut Page) { + let mut used = BTreeSet::new(); + for (_, _, list) in model::lists(page) { + for paragraph in list { + used.extend(paragraph.style); + used.extend(paragraph.lists.iter().copied()); + used.extend(paragraph.tags.iter().filter_map(|tag| tag.definition)); + if let ParagraphContent::Table(table) = ¶graph.content { + used.extend(table.tags.iter().filter_map(|tag| tag.definition)); + } + if let Some(text) = paragraph.text() { + used.extend(text.tags.iter().filter_map(|tag| tag.definition)); + } + } + } + page.definitions.retain(|id, _| used.contains(id)); +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(super) enum Family { Text, @@ -224,7 +334,17 @@ fn id() -> ExGuid { new_id().unwrap() } -fn text_paragraph(text: &str, format: Format) -> PageParagraph { +/// `ops` on page `space` of `source` as one edit, sealed: the image they leave. +pub(crate) fn edited(source: &[u8], space: ExGuid, ops: Vec) -> Result, OpError> { + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).map_err(OpError::Failed)?; + let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); + section.apply("Author", &Edit { at: AT, ops })?; + section.seal().map_err(OpError::Failed)?; + Ok(section.image()) +} + +pub(crate) fn text_paragraph(text: &str, format: Format) -> PageParagraph { PageParagraph { id: id(), parent: None, @@ -473,8 +593,10 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { } inserted.push(paragraph); } - // Children follow their parent; keep them after the first inserted paragraph. - inserted.sort_by_key(|p| p.parent.is_some()); + // Children follow their parent, the first inserted paragraph, directly. + let (children, mut inserted): (Vec<_>, Vec<_>) = + inserted.into_iter().partition(|p| p.parent.is_some()); + inserted.splice(1..1, children); let cell_level = list.first().map_or(1, |p| p.level); for paragraph in &mut inserted { if paragraph.parent.is_none() { @@ -993,125 +1115,70 @@ fn stroke(rng: &mut Rng) -> InkStroke { } } -/// How one differential case came out. +/// How one lowered edit came out. #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] pub(super) enum Outcome { /// The edit made no change the writers store. Unchanged, - /// Both refused the edit. + /// Lowering or the section refused the edit; the section is as it was. Refused, - /// The page writer refused; the ops were written. - OnlyOps, - /// Identical bytes. - Bytes, - /// Equal pages, both images valid, the op revision no larger. - Model, - /// The op revision declares more than the page writer's. - Larger, + /// The sealed image reads back as the edited page. + Stored, Mismatch(String), } -/// Objects the space's active revision declares in `image`. -pub(super) fn declared(image: &[u8], space: ExGuid) -> usize { - let store = Store::parse(image).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let rid = index.active(space).unwrap(); - index.spaces[&space].revisions[&rid] - .nodes - .iter() - .filter_map(|node| match node.reference { - Some(crate::Reference::NodeList(chunk)) if node.id == 0xb0 => Some(chunk), - _ => None, - }) - .map(|chunk| { - store - .lists - .values() - .find(|list| list.fragments.first().is_some_and(|f| f.offset == chunk.offset)) - .map_or(0, |list| { - list.nodes - .iter() - .filter(|n| { - matches!(n.id, 0x2d | 0x2e | 0x41 | 0x42 | 0xa4 | 0xa5 | 0xc4 | 0xc5 | 0x72 | 0x73) - }) - .count() - }) - }) - .sum() -} - -/// Writes `after` over page `space` of `source` both ways and compares the images. -pub(super) fn differential(source: &[u8], space: ExGuid, after: &Page, seed: u64) -> Outcome { +/// Lowers `after` over page `space` of `source`, applies and seals the ops, and compares +/// the page the section holds with the edited page, with the model oracle's prediction and +/// with the page the sealed image reads back as. +pub(super) fn stored(source: &[u8], space: ExGuid, after: &Page, seed: u64) -> Outcome { let before = read(source, space); - let oracle = seeded(seed, || { - crate::create::at(AT, || crate::PreparedEdit::page(source, space, after, "Author")) - }); let arena = Arena::default(); let mut section = Section::open(&arena, source.to_vec()).unwrap(); - let ops = lower_page(&before, after); - let written = ops.as_ref().ok().map(|ops| { - seeded(seed, || { - let edit = Edit { - at: AT, - ops: ops - .iter() - .map(|op| Op::Page { - space, - op: op.clone(), - }) - .collect(), - }; - section.apply("Author", &edit).map(|()| { - let predicted = { - let mut page = before.clone(); - for op in ops { - model::apply(&mut page, op).unwrap(); - } - page - }; - (section.seal().unwrap(), predicted) - }) - }) - }); - let image = section.image(); - match (oracle, written) { - (Err(_), None | Some(Err(_))) => Outcome::Refused, - (Err(_), Some(Ok(_))) => Outcome::OnlyOps, - (Ok(_), None) => Outcome::Mismatch(format!("lowering refused: {:?}", ops.unwrap_err())), - (Ok(_), Some(Err(error))) => Outcome::Mismatch(format!("apply refused: {error:?} for {:?}", ops.unwrap())), - (Ok(edit), Some(Ok((transaction, predicted)))) => { - let stored = normalize(&read(&image, space)); - if normalize(&predicted) != stored { - return Outcome::Mismatch(format!( - "the model predicts otherwise: {}", - first_difference(&stored, &normalize(&predicted)) - )); - } - let expected = edit.as_bytes(); - if expected == source && transaction.is_none() { - return Outcome::Unchanged; - } - if expected == image.as_slice() { - return Outcome::Bytes; - } - let store = Store::parse(&image).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - if let Err(error) = index.validate_current() { - return Outcome::Mismatch(format!("invalid image: {error:?}")); - } - let (ours, theirs) = (normalize(&read(&image, space)), normalize(&read(expected, space))); - if ours != theirs { - return Outcome::Mismatch(first_difference(&theirs, &ours)); - } - if expected == source { - return Outcome::Model; - } - if declared(&image, space) > declared(expected, space) { - return Outcome::Larger; - } - Outcome::Model + let Ok(ops) = lower_page(&before, after) else { + return Outcome::Refused; + }; + let edit = Edit { + at: AT, + ops: ops.iter().map(|op| Op::Page { space, op: op.clone() }).collect(), + }; + if seeded(seed, || section.apply("Author", &edit)).is_err() { + if section.page(space).unwrap() != before { + return Outcome::Mismatch("a refused edit changed the page".into()); } + return Outcome::Refused; } + let mut predicted = before.clone(); + for op in &ops { + model::apply(&mut predicted, op).unwrap(); + } + let live = section.page(space).unwrap(); + if normalize(&predicted) != normalize(&live) { + return Outcome::Mismatch(format!( + "the model predicts otherwise: {}", + first_difference(&normalize(&live), &normalize(&predicted)) + )); + } + let Some(_) = seeded(seed, || section.seal().unwrap()) else { + return if live == before { Outcome::Unchanged } else { + Outcome::Mismatch("the page changed and the seal stored nothing".into()) + }; + }; + let image = section.image(); + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + if let Err(error) = index.validate_current() { + return Outcome::Mismatch(format!("invalid image: {error:?}")); + } + let reread = read(&image, space); + if reread != live { + return Outcome::Mismatch(format!("reads back otherwise: {}", first_difference(&live, &reread))); + } + let written = as_written(after, &before, &reread); + let (stored, wanted) = (normalize(&reread), normalize(&written)); + if stored != wanted { + return Outcome::Mismatch(format!("stores another page: {}", first_difference(&wanted, &stored))); + } + Outcome::Stored } fn spans(text: &Paragraph) -> String { @@ -1155,7 +1222,7 @@ pub(super) fn first_difference(expected: &Page, actual: &Page) -> String { /// Every family on every corpus page it applies to, several seeds each. #[test] -fn ops_store_what_the_page_writer_stores() { +fn lowered_edits_store_the_edited_page() { let mut tally: BTreeMap<(Family, String), usize> = BTreeMap::new(); let mut mismatches = Vec::new(); let seeds = std::env::var("OP_SEEDS").map_or(3, |s| s.parse().unwrap()); @@ -1170,7 +1237,7 @@ fn ops_store_what_the_page_writer_stores() { continue; } GUIDS.set(None); - let outcome = differential(source, space, &after, 1 << 40 | seed); + let outcome = stored(source, space, &after, 1 << 40 | seed); let key = match &outcome { Outcome::Mismatch(_) => "Mismatch".to_owned(), other => format!("{other:?}"), @@ -1192,7 +1259,6 @@ fn ops_store_what_the_page_writer_stores() { println!("{mismatch}\n"); } assert!(mismatches.is_empty(), "{} mismatches", mismatches.len()); - assert!(!tally.keys().any(|(_, outcome)| outcome == "Larger")); } #[test] @@ -1674,42 +1740,34 @@ fn random_ops_read_back_as_the_model_predicts() { assert!(applied > refused); } -/// Page creation, moves, indentation and removal through a section store what the image -/// writers store, and the section lists the pages the document does. +/// Page creation, moves, indentation and removal through a section leave the page list +/// they describe, and the sealed image lists the pages the section does. #[test] -fn section_ops_store_what_the_image_writers_store() { +fn section_ops_leave_the_page_list_they_describe() { let sources: Vec> = vec![ crate::create_section("pages.one", "First", "Author").unwrap(), include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one").to_vec(), SOURCES[3].1.to_vec(), ]; - let (mut bytes, mut total) = (0, 0); + let mut applied = 0; for (n, source) in sources.iter().enumerate() { let arena = Arena::default(); let mut section = Section::open(&arena, source.to_vec()).unwrap(); - let mut image = source.clone(); let mut rng = Rng(n as u64 + 17); for step in 0..12u64 { - let spaces = pages(&image); + let listed = section.pages().unwrap(); + let spaces: Vec = listed.iter().map(|(space, ..)| *space).collect(); + let anchor = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied().filter(|_| rng.coin()); let op = match step % 4 { - 0 => SectionOp::Create( - seeded(1 << 50 | step << 20, || { - crate::PageCreation::new( - spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied().filter(|_| rng.coin()), - Some("Created"), - "Author", - ) - }) - .unwrap(), - ), + 0 => SectionOp::Create(crate::PageCreation::new(anchor, Some("Created"), "Author").unwrap()), 1 => { let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied() else { continue }; let level = if spaces[0] == space { 1 } else { 1 + (rng.next() % 2) as u32 }; - SectionOp::Pages(vec![seeded(1 << 51 | step << 20, || crate::PageEdit::set_level(space, level)).unwrap()]) + SectionOp::Pages(vec![crate::PageEdit::set_level(space, level).unwrap()]) } 2 => { let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied() else { continue }; - SectionOp::Pages(vec![seeded(1 << 52 | step << 20, || crate::PageEdit::move_to(space, None, 1)).unwrap()]) + SectionOp::Pages(vec![crate::PageEdit::move_to(space, None, 1).unwrap()]) } _ => { if spaces.len() < 2 { @@ -1718,63 +1776,71 @@ fn section_ops_store_what_the_image_writers_store() { SectionOp::Delete(vec![spaces[rng.pick(spaces.len()).unwrap()]]) } }; - let expected = seeded(1 << 40 | step << 16, || { - crate::create::at(AT, || match &op { - SectionOp::Create(creation) => crate::PreparedEdit::create_page(&image, creation), - SectionOp::Pages(edits) => crate::PreparedEdit::pages(&image, edits), - SectionOp::Delete(spaces) => crate::PreparedEdit::delete_pages_permanently(&image, spaces), - SectionOp::Import { .. } => unreachable!(), - }) - .map(|edit| edit.as_bytes().to_vec()) - }); - let applied = seeded(1 << 40 | step << 16, || { - section - .apply("Author", &Edit { at: AT, ops: vec![Op::Section(op.clone())] }) - .map(|()| section.seal().unwrap()) - }); - match (expected, applied) { - (Err(_), Err(_)) => continue, - (Ok(expected), Ok(_)) => { - total += 1; - let written = section.image(); - if written == expected { - bytes += 1; + let edit = Edit { at: AT, ops: vec![Op::Section(op.clone())] }; + if section.apply("Author", &edit).is_err() { + assert_eq!(section.pages().unwrap(), listed, "{n} step {step} {op:?}"); + continue; + } + section.seal().unwrap(); + applied += 1; + let after = section.pages().unwrap(); + let order = |list: &[(ExGuid, String, u32)], without: ExGuid| -> Vec { + list.iter().map(|(space, ..)| *space).filter(|space| *space != without).collect() + }; + match &op { + SectionOp::Create(creation) => { + let at = after.iter().position(|(space, ..)| *space == creation.space()).unwrap(); + assert_eq!(after[at].1, "Created"); + assert_eq!(order(&after, creation.space()), spaces, "{n} step {step}"); + match anchor { + Some(before) => assert_eq!(after[at + 1].0, before), + None => assert_eq!(at + 1, after.len()), + } + } + SectionOp::Pages(edits) => { + let space = edits[0].space(); + let at = after.iter().position(|(listed, ..)| *listed == space).unwrap(); + assert_eq!(after[at].2, edits[0].level(), "{n} step {step} {op:?}"); + if edits[0].position() == crate::PagePosition::Keep { + assert_eq!(order(&after, space), order(&listed, space)); + assert_eq!(after.iter().map(|(s, ..)| *s).collect::>(), spaces); } else { - let store = Store::parse(&written).unwrap(); - RevisionIndex::parse(&store).unwrap().validate_current().unwrap(); - let spaces = pages(&expected); - assert_eq!(pages(&written), spaces, "{n} step {step} {op:?}"); - for space in spaces { - assert_eq!(read(&written, space), read(&expected, space), "{n} step {step}"); - } + assert_eq!(after.last().unwrap().0, space, "{n} step {step}"); } - image = expected; - let store = Store::parse(&image).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let listed: Vec<(ExGuid, String, u32)> = document - .pages() - .unwrap() - .into_iter() - .map(|(space, page)| { - let (title, level) = Page::heading(document.active(space).unwrap(), page); - (space, title, level) - }) - .collect(); - assert_eq!(section.pages().unwrap(), listed, "{n} step {step}"); } - (expected, applied) => panic!("{n} step {step} {op:?}: {:?} vs {:?}", expected.err(), applied.err()), + SectionOp::Delete(deleted) => { + assert_eq!(after.iter().map(|(s, ..)| *s).collect::>(), order(&listed, deleted[0])); + } + SectionOp::Import { .. } | SectionOp::Conflict { .. } | SectionOp::Color(_) => { + unreachable!() + } + } + let image = section.image(); + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + let document = Document::parse(&index).unwrap(); + let reread: Vec<(ExGuid, String, u32)> = document + .pages() + .unwrap() + .into_iter() + .map(|(space, page)| { + let (title, level) = Page::heading(document.active(space).unwrap(), page); + (space, title, level) + }) + .collect(); + assert_eq!(reread, after, "{n} step {step}"); + for (space, ..) in &after { + assert_eq!(read(&image, *space), section.page(*space).unwrap(), "{n} step {step}"); } } } - println!("{bytes} of {total} section edits byte-identical"); - assert!(total > 12); + assert!(applied > 12, "{applied} section edits applied"); } -/// Importing a page stores what creating it and writing its copy through the page writer -/// does. +/// An imported page reads back as its copy under the created page's title. #[test] -fn an_imported_page_reads_as_the_page_writer_leaves_it() { +fn an_imported_page_reads_back_as_its_copy() { let target = crate::create_section("import.one", "First", "Author").unwrap(); let mut imported = 0; for (name, source) in SOURCES { @@ -1783,48 +1849,45 @@ fn an_imported_page_reads_as_the_page_writer_leaves_it() { continue; }; let creation = crate::PageCreation::new(None, Some(©.title), "Author").unwrap(); - let created = crate::PreparedEdit::create_page(&target, &creation).unwrap(); - let created = created.as_bytes(); - let mut after = read(created, creation.space()); - after.objects.retain(|object| matches!(object, PageObject::Title(_))); - after.objects.extend( + let arena = Arena::default(); + let mut section = Section::open(&arena, target.clone()).unwrap(); + let import = |section: &mut Section<'_>, op: SectionOp| { + section.apply("Author", &Edit { at: AT, ops: vec![Op::Section(op)] }) + }; + import(&mut section, SectionOp::Create(creation.clone())).unwrap(); + let created = section.page(creation.space()).unwrap(); + let mut expected = created.clone(); + let arena = Arena::default(); + let mut section = Section::open(&arena, target.clone()).unwrap(); + if import(&mut section, SectionOp::Import { creation: creation.clone(), page: copy.clone() }).is_err() { + continue; + } + section.seal().unwrap(); + let image = section.image(); + let store = Store::parse(&image).unwrap(); + RevisionIndex::parse(&store).unwrap().validate_current().unwrap(); + expected.objects.retain(|object| matches!(object, PageObject::Title(_))); + expected.objects.extend( copy.objects .iter() .filter(|object| !matches!(object, PageObject::Title(_))) .cloned(), ); - after.definitions = copy.definitions.clone(); - let expected = crate::create::at(AT, || { - crate::PreparedEdit::page(created, creation.space(), &after, "Author") - }); - let arena = Arena::default(); - let mut section = Section::open(&arena, target.clone()).unwrap(); - let applied = section.apply( - "Author", - &Edit { - at: AT, - ops: vec![Op::Section(SectionOp::Import { - creation: creation.clone(), - page: copy.clone(), - })], - }, - ); - match (expected, applied) { - (Err(_), Err(_)) => continue, - (Ok(expected), Ok(())) => { - section.seal().unwrap(); - let image = section.image(); - let store = Store::parse(&image).unwrap(); - RevisionIndex::parse(&store).unwrap().validate_current().unwrap(); - let (ours, theirs) = ( - normalize(&read(&image, creation.space())), - normalize(&read(expected.as_bytes(), creation.space())), - ); - assert!(ours == theirs, "{name}: {}", first_difference(&theirs, &ours)); - imported += 1; + expected.definitions.extend(copy.definitions.clone()); + let stored = read(&image, creation.space()); + // New page children stack as the writers add them. + let order: Vec = stored.objects.iter().map(PageObject::id).collect(); + expected.objects.sort_by_key(|object| order.iter().position(|id| *id == object.id())); + let mut expected = as_written(&expected, &created, &stored); + // An outline's height follows its content; no op carries it. + for (object, stored) in expected.objects.iter_mut().zip(&stored.objects) { + if let (PageObject::Outline(outline), PageObject::Outline(stored)) = (object, stored) { + outline.layout.max_height = stored.layout.max_height; } - (expected, applied) => panic!("{name}: {:?} vs {:?}", expected.err(), applied.err()), } + let (ours, theirs) = (normalize(&stored), normalize(&expected)); + assert!(ours == theirs, "{name}: {}", first_difference(&theirs, &ours)); + imported += 1; } } assert!(imported > 10, "{imported} pages imported"); diff --git a/crates/onestore/src/outline.rs b/crates/onestore/src/outline.rs index 4f28658702161656c4bca2d135b7c5a88b66eccd..8486e6badb744a9c26e321c0a9e7ba6e8a4f8d1e 100644 --- a/crates/onestore/src/outline.rs +++ b/crates/onestore/src/outline.rs @@ -25,15 +25,6 @@ pub enum OutlineEdit { } impl OutlineEdit { - pub(crate) fn apply( - self, - source: &[u8], - space: ExGuid, - object: ExGuid, - ) -> Result, Error> { - crate::active::write(source, space, |active| self.changes(active, object)) - } - pub(crate) fn changes(self, active: &ActivePage<'_>, object: ExGuid) -> Result { let invalid = |message| Error { offset: 0, message }; let values = match self { @@ -146,8 +137,12 @@ impl OutlineEdit { #[cfg(test)] mod tests { use super::*; - use crate::write::write_revision; - use crate::{RevisionIndex, Store, document::Document}; + use crate::{ + RevisionIndex, Store, + document::Document, + op::{PageOp, tests::edited}, + write::write_revision, + }; #[test] fn protection_on_the_target_or_ancestor_prevents_layout_edits() { @@ -184,7 +179,7 @@ mod tests { (paragraph, OutlineEdit::Collapsed(true)), ] { assert_eq!( - edit.apply(&bytes, sid, object).is_err(), + edited(&bytes, sid, vec![PageOp::Outline { object, edit }]).is_err(), enabled, "{target} {property:x} {edit:?}" ); @@ -199,10 +194,10 @@ mod tests { Ok(BTreeMap::from([(outline, object)])) }) .unwrap(); - assert!( - OutlineEdit::Collapsed(true) - .apply(&duplicated, sid, paragraph) - .is_err() - ); + let collapse = PageOp::Outline { + object: paragraph, + edit: OutlineEdit::Collapsed(true), + }; + assert!(edited(&duplicated, sid, vec![collapse]).is_err()); } } diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index a7e3d827eb5965b6d4afc77774ac1189629cef8e..2d7df2e8224223a168f389bc5994c7e846dd5c48 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -17,7 +17,6 @@ pub mod text; pub use ink::{Ink, InkStroke}; pub use math::Math; -pub(crate) mod write; /// Picture and attachment payloads travel with the model (queued intents replay them), /// as base64 text. @@ -52,6 +51,10 @@ mod payload { } pub use text::Paragraph; +/// The page node's colour (View, Page Color) as COLORREF, absent for "No color"; not in +/// MS-ONE, observed in OneNote 2010's pages (`evidence/notebook-management`). +pub(crate) const PAGE_COLOR: u32 = 0x14001d2a; + /// The role of a title-outline paragraph that displays the page's creation date or time. #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub enum DateField { @@ -67,6 +70,9 @@ pub struct Page { /// FILETIME ticks from the page's TopologyCreationTimeStamp. pub created: Option, pub margin_origin: [f32; 2], + /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color". + #[serde(default)] + pub color: Option, pub objects: Vec, pub definitions: BTreeMap, } @@ -394,9 +400,14 @@ impl Page { /// links keep naming the original. Content outside the model has no copy, so an /// `Unsupported` object is an error. pub fn copy(&self) -> Result { + self.copy_with(&mut []) + } + + /// `copy`, renaming `ids`, identities on this page, to theirs on the copy. + pub fn copy_with(&self, ids: &mut [ExGuid]) -> Result { let invalid = |message| Error { offset: 0, message }; - let mut value = - serde_json::to_value(self).map_err(|_| invalid("Page is not serializable"))?; + let mut value = serde_json::to_value((self, &*ids)) + .map_err(|_| invalid("Page is not serializable"))?; let mut fresh: BTreeMap = BTreeMap::new(); fn walk( value: &mut serde_json::Value, @@ -444,8 +455,9 @@ impl Page { Ok(()) } walk(&mut value, &mut fresh)?; - let mut copy: Self = + let (mut copy, renamed): (Self, Vec) = serde_json::from_value(value).map_err(|_| invalid("Page copy does not deserialize"))?; + ids.copy_from_slice(&renamed); copy.identity = Some(crate::write::fresh_guid()?); // A paragraph's indent level follows its parent chain; a level beyond that comes // from an outline group, which the model does not hold. @@ -477,6 +489,28 @@ impl Page { Ok(copy) } + /// The date and time the title's date fields show, when it has both. + pub fn date_text(&self) -> Option<[String; 2]> { + let title = self.objects.iter().find_map(|object| match object { + PageObject::Title(title) => Some(title), + _ => None, + })?; + let outline = title.outlines.iter().find(|outline| Some(outline.id) == title.date)?; + let [first, second] = &outline.paragraphs[..] else { + return None; + }; + let [first, second] = [first.text()?, second.text()?]; + let text = |object: &TextObject| object.text.text().to_owned(); + // OneNote writes the date first; a field marked as the other role says otherwise. + Some( + if first.date_field == Some(DateField::Time) || second.date_field == Some(DateField::Date) { + [text(second), text(first)] + } else { + [text(first), text(second)] + }, + ) + } + /// The notebook-management identity of the page in an active page revision, which /// internal links name. pub fn identity_of(revision: &Revision<'_>) -> Option<[u8; 16]> { @@ -562,6 +596,17 @@ impl Page { margin_origin_x.unwrap_or(1.0_f32 * 36.0), margin_origin_y.unwrap_or(0.4_f32 * 36.0), ], + color: root + .extra + .first() + .and_then(|fields| fields.iter().find(|field| field.id == PAGE_COLOR)) + .map(|field| match field.value { + FieldValue::Bytes(&[red, green, blue, alpha]) => { + Ok(u32::from_le_bytes([red, green, blue, alpha])) + } + _ => Err(invalid("The page colour is damaged")), + }) + .transpose()?, objects: Vec::new(), definitions: BTreeMap::new(), }; diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs deleted file mode 100644 index edc1011d82cc4fe8892b6ae0a304ad74c389f8fc..0000000000000000000000000000000000000000 --- a/crates/onestore/src/page/write.rs +++ /dev/null @@ -1,1646 +0,0 @@ -//! Publishes an edited page model by lowering the difference from the stored page onto -//! the typed writers, whose revisions accumulate in memory, then squashing them into one -//! revision per space. `PreparedEdit::page` keeps it until the op path replaces it. - -use super::{Image, Ink, Outline, Page, PageObject, PageParagraph, ParagraphContent}; -use crate::{ - Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets, - RevisionIndex, Store, TreeEdit, Value, - active::{ActivePage, Changes}, - document::Kind, - op::{ - content::{self, AttachmentIds}, - levels, - lower::{View, collect_containers, kept_set, text_edit, validate}, - properties, - table::{self, Structure}, - }, - write::{PropertyObject, RevisionEdit}, -}; -use bumpalo::Bump; -use std::collections::{BTreeMap, BTreeSet}; - -fn invalid(message: &'static str) -> Error { - Error { offset: 0, message } -} - -fn page_images(page: &Page) -> impl Iterator { - page.objects.iter().filter_map(|object| match object { - PageObject::Image(image) => Some((image.id, image)), - _ => None, - }) -} - -fn page_ink(page: &Page) -> impl Iterator { - page.objects.iter().filter_map(|object| match object { - PageObject::Ink(ink) => Some((ink.id, ink)), - _ => None, - }) -} - -pub(crate) fn write_page( - source: &[u8], - space: ExGuid, - after: &Page, - author: &str, -) -> Result, Error> { - if author.contains('\0') { - return Err(invalid("Choose an author name without NUL")); - } - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - let arena = Bump::new(); - let active = ActivePage::parse(&index, space)?; - let [page] = active.pages[..] else { - return Err(invalid("Choose an object space containing one active page")); - }; - let before = Page::from_revision(&active.view, page)?; - let mut lowering = Lowering { - active, - arena: &arena, - current: Some(before.clone()), - edited: false, - page, - author, - alias: BTreeMap::new(), - built: BTreeSet::new(), - }; - lowering.run(&before, after)?; - if !lowering.edited { - return Ok(source.to_vec()); - } - let active = &lowering.active; - squash( - &index, - &[(space, &active.live.revision)], - &active.payloads, - &lowering.alias, - None, - ) -} - -struct Lowering<'a> { - active: ActivePage<'a>, - /// Holds the objects the typed writers store for as long as `active` reads them. - arena: &'a Bump, - /// The page as `active` stores it, until a writer changes it. - current: Option, - /// Whether a writer stored anything. - edited: bool, - page: ExGuid, - author: &'a str, - /// Model identities of new objects mapped to the identities the typed writers allocated. - alias: BTreeMap, - /// Tables whose structure this run has written. - built: BTreeSet, -} - -impl<'a> Lowering<'a> { - fn id(&self, model: ExGuid) -> ExGuid { - self.alias.get(&model).copied().unwrap_or(model) - } - - /// A writer identity for a new object; squash renames it to the model's. - fn allocate(&mut self, model: ExGuid) -> Result { - let written = ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, - }; - self.alias.insert(model, written); - Ok(written) - } - - /// Stores the next revision a typed writer computes from the page as stored so far. - fn write( - &mut self, - changes: impl FnOnce(&ActivePage<'a>) -> Result, - ) -> Result<(), Error> { - self.write_with(&[], changes) - } - - fn write_with( - &mut self, - payloads: &[([u8; 16], &[u8])], - changes: impl FnOnce(&ActivePage<'a>) -> Result, - ) -> Result<(), Error> { - let changes = changes(&self.active)?; - if self.active.write(self.arena, payloads, changes)? { - self.current = None; - self.edited = true; - } - Ok(()) - } - - fn current(&mut self) -> Result { - if self.current.is_none() { - self.current = Some(Page::from_revision(&self.active.view, self.page)?); - } - Ok(self.current.clone().unwrap()) - } - - fn run(&mut self, before: &Page, after: &Page) -> Result<(), Error> { - let old = View::new(before, false)?; - let new = View::new(after, false)?; - validate(before, after, &old, &new)?; - if after.created != before.created { - return Err(invalid("Page creation time cannot be edited")); - } - for id in new.outlines.keys().chain(new.paragraphs.keys()) { - if !old.outlines.contains_key(id) - && !old.paragraphs.contains_key(id) - && self.active.live.revision.objects.contains_key(id) - { - return Err(invalid( - "A new model identity already exists in the section", - )); - } - } - let mut placed: BTreeMap> = BTreeMap::new(); - for (container, children) in &old.children { - placed.insert(*container, children.clone()); - } - let mut page_order: Vec = old - .page_children - .iter() - .copied() - .filter(|id| !old.outlines.contains_key(id) || new.outlines.contains_key(id)) - .collect(); - self.insert_outlines(&old, &new, &mut placed, &mut page_order)?; - let mut consumed = BTreeSet::new(); - self.ungroup(&old, &new)?; - self.split_and_join(&old, &new, &mut placed, &mut consumed)?; - self.place(&old, &new, &placed, &page_order)?; - self.delete(&old, &new, &consumed)?; - self.edit_levels(&new)?; - self.edit_text_identities(&new)?; - self.edit_equations(&new)?; - self.edit_text(&new)?; - self.edit_paragraph_styles(&old, &new, after)?; - self.edit_lists(after, &new)?; - self.edit_tags(after, &new)?; - self.edit_paragraph_formatting(&new)?; - self.edit_formatting(&new)?; - self.edit_layout(&old, &new)?; - Ok(()) - } - - fn insert_outlines( - &mut self, - old: &View<'_>, - new: &View<'_>, - placed: &mut BTreeMap>, - page_order: &mut Vec, - ) -> Result<(), Error> { - for id in &new.page_children { - let Some(outline) = new.outlines.get(id) else { - continue; - }; - if old.outlines.contains_key(id) { - continue; - } - if new.title_outlines.contains(id) { - return Err(invalid("Title outlines cannot be added")); - } - let (Some(x), Some(y)) = (outline.layout.x, outline.layout.y) else { - return Err(invalid("A new outline needs a position")); - }; - let Some(first) = new.children[id].first().copied() else { - return Err(invalid("A new outline needs a paragraph")); - }; - // A new outline starts as one text paragraph; other content replaces it in - // its own pass, as for a paragraph inserted into an existing outline. - let (text, text_id) = match &new.paragraphs[&first].content { - ParagraphContent::Text(text) if text.date_field.is_none() => { - (text.text.text(), Some(text.id)) - } - ParagraphContent::Table(_) - | ParagraphContent::Image(_) - | ParagraphContent::Attachment(_) - | ParagraphContent::Ink(_) => ("", None), - _ => { - return Err(invalid( - "A new outline starts with a paragraph the writer builds", - )); - } - }; - let insertion = Insertion::outline(self.page, x, y, text, self.author)?; - self.write(|active| insertion.changes(active))?; - let outline_id = insertion.object(); - self.alias.insert(*id, outline_id); - self.alias.insert( - first, - ExGuid { - guid: outline_id.guid, - n: 3, - }, - ); - if let Some(text_id) = text_id { - self.alias.insert(text_id, insertion.text_object()); - } - placed.insert(*id, vec![first]); - placed.insert(first, Vec::new()); - page_order.push(*id); - } - Ok(()) - } - - /// Splits and joins stored paragraphs as OneNote's Enter, Backspace and Delete do. A new - /// styled paragraph that is no exact split starts as Enter at the end of the nearest stored - /// paragraph that allows one, taking its properties as OneNote's new paragraphs do; later - /// passes place it and give it its text. - fn split_and_join( - &mut self, - old: &View<'_>, - new: &View<'_>, - placed: &mut BTreeMap>, - consumed: &mut BTreeSet, - ) -> Result<(), Error> { - // A new paragraph may be split from the stored paragraph nearest before it, whatever - // placement or pasted paragraphs the same save adds between them. - let mut splits = Vec::new(); - for document in new.documents() { - let mut left = None; - for paragraph in document { - if old.paragraphs.contains_key(¶graph.id) { - left = paragraph.text().is_some().then_some(paragraph.id); - } else if let Some(left) = left { - let container = new.container[¶graph.id]; - let at = new.children[&container] - .iter() - .position(|id| *id == paragraph.id); - splits.push(((container, at), left, paragraph.id)); - } - } - } - splits.sort(); - let mut split = BTreeSet::new(); - for (_, left, right) in splits { - let (Some(previous), Some(after), Some(next)) = - (old.text(left), new.text(left), new.text(right)) - else { - continue; - }; - if split.contains(&left) - || after.id != previous.id - || new.paragraphs[&right].style != old.paragraphs[&left].style - { - continue; - } - let Ok(offset) = after.text.utf16_offset(after.text.text().len()) else { - continue; - }; - // Splitting at the end only differs from appending a paragraph by the copied - // paragraph style; without one, an appended empty paragraph is an insertion. - if offset == previous.text.utf16_offset(previous.text.text().len())? - && new.paragraphs[&right].style.is_none() - { - continue; - } - let Ok(head) = previous.text.slice(0..offset) else { - continue; - }; - let Ok(tail) = previous.text.slice( - offset - ..previous - .text - .utf16_offset(previous.text.text().len()) - .unwrap_or(0), - ) else { - continue; - }; - // An emptied side takes the writer's insertion style, so only its text must agree. - let same = |expected: &super::Paragraph, actual: &super::Paragraph| { - expected.text() == actual.text() - && (expected.text().is_empty() || expected == actual) - }; - if !same(&head, &after.text) || !same(&tail, &next.text) { - continue; - } - let edit = ParagraphSplit::new(previous.id, offset, self.author)?; - if self.split(&edit, left, right, old, new, placed)? { - split.insert(left); - } - } - for document in new.documents() { - for (at, paragraph) in document.iter().enumerate() { - if old.paragraphs.contains_key(¶graph.id) - || self.alias.contains_key(¶graph.id) - || paragraph.style.is_none() - || paragraph.text().is_none() - { - continue; - } - let stored = |candidate: &&&PageParagraph| { - old.paragraphs.contains_key(&candidate.id) && candidate.text().is_some() - }; - let before = document[..at].iter().rev().filter(stored); - for template in before.chain(document[at + 1..].iter().filter(stored)) { - let text = self.active.view.nodes[&template.id].content[0]; - let Kind::RichText { text: content, .. } = &self.active.view.nodes[&text].kind - else { - continue; - }; - let end = u32::try_from(content.encode_utf16().count()) - .map_err(|_| invalid("Paragraph exceeds the UTF-16 offset range"))?; - let edit = ParagraphSplit::new(text, end, self.author)?; - if self.split(&edit, template.id, paragraph.id, old, new, placed)? { - break; - } - } - } - } - for (container, children) in &old.children { - if !new.children.contains_key(container) { - continue; - } - for pair in children.windows(2) { - let [left, right] = [pair[0], pair[1]]; - let (Some(previous), Some(after), Some(removed)) = - (old.text(left), new.text(left), old.text(right)) - else { - continue; - }; - if new.paragraphs.contains_key(&right) - || !old.children[&left].is_empty() - || !old.children[&right].is_empty() - || removed.text.text().is_empty() - { - continue; - } - let mut joined = previous.text.clone(); - if joined.append(removed.text.clone()).is_err() || joined != after.text { - continue; - } - let adopts_right = previous.text.text().is_empty(); - if after.id - != if adopts_right { - removed.id - } else { - previous.id - } - { - continue; - } - let join = ParagraphJoin::new(previous.id, removed.id, self.author)?; - let Ok(changes) = join.changes(&self.active) else { - continue; - }; - self.write(|_| Ok(changes))?; - consumed.insert(right); - placed.get_mut(container).unwrap().retain(|id| *id != right); - placed.remove(&right); - } - } - Ok(()) - } - - /// Stores `edit` of model paragraph `left` as model paragraph `right`, unless the split - /// cannot be made there. - fn split( - &mut self, - edit: &ParagraphSplit, - left: ExGuid, - right: ExGuid, - old: &View<'_>, - new: &View<'_>, - placed: &mut BTreeMap>, - ) -> Result { - let Ok(changes) = edit.changes(&self.active) else { - return Ok(false); - }; - self.write(|_| Ok(changes))?; - self.alias.insert(right, edit.object()); - // A text object a join moved into the paragraph is taken over after placement. - if let Some(text) = new.text(right) - && !self.active.live.revision.objects.contains_key(&text.id) - { - self.alias.insert(text.id, edit.text_object()); - } - // The split copies the left paragraph's list nodes in order; a list the model shares - // with a stored paragraph stays that paragraph's. - if new.paragraphs[&right].lists.len() == old.paragraphs[&left].lists.len() { - for (n, list) in (5..).zip(&new.paragraphs[&right].lists) { - if !self.active.live.revision.objects.contains_key(list) { - let guid = edit.object().guid; - self.alias.insert(*list, ExGuid { guid, n }); - } - } - } - // An earlier split may have carried the left paragraph to its tail. - let list = placed - .values_mut() - .find(|list| list.contains(&left)) - .unwrap(); - let at = list.iter().position(|id| *id == left).unwrap(); - list.insert(at + 1, right); - placed.insert(right, placed[&left].clone()); - placed.insert(left, Vec::new()); - Ok(true) - } - - fn place( - &mut self, - old: &View<'_>, - new: &View<'_>, - placed: &BTreeMap>, - page_order: &[ExGuid], - ) -> Result<(), Error> { - let title = |page: &Page, id: ExGuid| { - page.objects - .iter() - .any(|object| matches!(object, PageObject::Title(title) if title.id == id)) - }; - self.edit_page_images(old, new)?; - self.edit_page_ink(old, new)?; - let survivors: Vec = page_order - .iter() - .copied() - .filter(|id| { - (new.outlines.contains_key(id) || !old.outlines.contains_key(id)) - && !title(old.page, *id) - }) - .collect(); - let after: Vec = new - .page_children - .iter() - .copied() - .filter(|id| !title(new.page, *id)) - .collect(); - let kept = kept_set(&survivors, &after, |id| { - new.outlines.contains_key(&id) && !new.title_outlines.contains(&id) - })?; - let mut next = None; - for id in after.iter().rev() { - if !kept.contains(id) { - let edit = TreeEdit::move_to( - self.id(*id), - self.page, - next.map(|n| self.id(n)), - self.author, - )?; - self.write(|active| edit.changes(active))?; - } - next = Some(*id); - } - let mut containers: Vec = Vec::new(); - for object in &new.page.objects { - let outlines: Vec<&Outline> = match object { - PageObject::Outline(outline) => vec![outline], - PageObject::Title(title) => title.outlines.iter().collect(), - PageObject::Image(_) | PageObject::Ink(_) | PageObject::Unsupported(_) => { - Vec::new() - } - }; - for outline in outlines { - containers.push(outline.id); - collect_containers(&outline.paragraphs, &mut containers); - } - } - // Cells that do not exist yet, and containers inside them, receive their - // paragraphs once the table structure does. - let new_cell = |id: &ExGuid| { - !(old.children.contains_key(id) - || new.paragraphs.contains_key(id) - || new.outlines.contains_key(id)) - }; - let (existing, deferred): (Vec, Vec) = - containers.into_iter().partition(|container| { - let mut at = *container; - loop { - if new_cell(&at) { - return false; - } - match new.container.get(&at) { - Some(parent) => at = *parent, - None => return true, - } - } - }); - self.place_containers(old, new, placed, &existing)?; - // Each round builds the tables whose holders exist and fills their cells, which - // may hold further new tables. - let mut pending = deferred; - loop { - self.edit_table_structure(old, new)?; - if pending.is_empty() { - break; - } - let (ready, waiting): (Vec, Vec) = - pending.into_iter().partition(|container| { - self.alias.contains_key(container) || old.children.contains_key(container) - }); - if ready.is_empty() { - return Err(invalid("Table content has no table to hold it")); - } - self.place_containers(old, new, placed, &ready)?; - pending = waiting; - } - self.edit_cells(old, new)?; - self.edit_images(old, new)?; - self.edit_ink_paragraphs(old, new)?; - self.edit_attachments(old, new)?; - Ok(()) - } - - /// Gives each new attachment paragraph what OneNote stores for an inserted file: the - /// payload embedded in the file-data store, an embedded-file container declaring it, - /// and an attachment object naming the file that the paragraph holds as content. - fn edit_attachments(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { - for (paragraph_id, paragraph) in &new.paragraphs { - let ParagraphContent::Attachment(attachment) = ¶graph.content else { - continue; - }; - if let Some(previous) = old.paragraphs.get(paragraph_id) { - let ParagraphContent::Attachment(stored) = &previous.content else { - return Err(invalid("Paragraph content type cannot change")); - }; - if stored != attachment { - self.write(|active| content::attachment_edit_changes(active, stored, attachment))?; - } - continue; - } - if attachment.recording.is_some() { - return Err(invalid("Recordings are captured by OneNote, not inserted")); - } - let Some(bytes) = &attachment.bytes else { - return Err(invalid("A new attachment needs its payload")); - }; - let ids = AttachmentIds { - object: self.allocate(attachment.id)?, - file: ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, - }, - payload: crate::write::fresh_guid()?, - preview: match &attachment.preview { - Some(_) => { - let payload = crate::write::fresh_guid()?; - let icon = ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, - }; - Some((payload, icon)) - } - None => None, - }, - }; - let mut payloads: Vec<([u8; 16], &[u8])> = vec![(ids.payload, bytes)]; - if let (Some((payload, _)), Some(icon)) = (ids.preview, &attachment.preview) { - payloads.push((payload, icon)); - } - let holder = self.id(*paragraph_id); - self.write_with(&payloads, |active| { - content::attachment_changes(active, attachment, &ids, holder) - })?; - } - Ok(()) - } - - /// Gives each new picture paragraph what OneNote stores for an inserted picture: the - /// payload embedded in the section's file-data store, a file-data object declaring it - /// by identity and extension, and a picture object the paragraph holds as content. - fn edit_images(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { - for (paragraph_id, paragraph) in &new.paragraphs { - let ParagraphContent::Image(image) = ¶graph.content else { - continue; - }; - if let Some(previous) = old.paragraphs.get(paragraph_id) { - let ParagraphContent::Image(stored) = &previous.content else { - return Err(invalid("Paragraph content type cannot change")); - }; - if stored != image { - self.edit_image(stored, image)?; - } - continue; - } - if image.layout.x.is_some() || image.layout.y.is_some() { - return Err(invalid("A paragraph picture has no position of its own")); - } - let holder = self.id(*paragraph_id); - self.insert_image(image, Some(holder))?; - } - Ok(()) - } - - /// Page-level pictures are direct page children, as OneNote stores a picture placed - /// outside any outline: new ones are appended for the placement pass to order, changed - /// ones are moved, resized or described, and removed ones are deleted in the delete pass. - fn edit_page_images(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { - let stored: BTreeMap = page_images(old.page).collect(); - for (id, image) in page_images(new.page) { - match stored.get(&id) { - Some(previous) => { - if *previous != image { - self.edit_image(previous, image)?; - } - } - None => { - if image.layout.x.is_none() || image.layout.y.is_none() { - return Err(invalid("A new page-level picture needs a position")); - } - self.insert_image(image, None)?; - } - } - } - Ok(()) - } - - /// Ink placed on the page: new drawings are appended for the placement pass to order, - /// changed ones have their stroke list rewritten, removed ones go in the delete pass. - fn edit_page_ink(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { - let stored: BTreeMap = page_ink(old.page).collect(); - for (id, ink) in page_ink(new.page) { - match stored.get(&id) { - Some(previous) => { - if *previous != ink { - self.edit_ink(previous, ink)?; - } - } - None => self.insert_ink(ink, None)?, - } - } - Ok(()) - } - - fn edit_ink_paragraphs(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { - for (paragraph_id, paragraph) in &new.paragraphs { - let ParagraphContent::Ink(ink) = ¶graph.content else { - continue; - }; - match old.paragraphs.get(paragraph_id) { - Some(previous) => { - let ParagraphContent::Ink(stored) = &previous.content else { - return Err(invalid("Paragraph content type cannot change")); - }; - if stored != ink { - self.edit_ink(stored, ink)?; - } - } - None => { - let holder = self.id(*paragraph_id); - self.insert_ink(ink, Some(holder))?; - } - } - } - Ok(()) - } - - /// Writes new ink the way OneNote 2010 stores a drawing: a container the page lists as a - /// child (or a paragraph holds as content), its data node listing stroke objects, each - /// stroke's packet and half-inch origin, and one drawing-attribute object per distinct pen. - fn insert_ink(&mut self, ink: &Ink, holder: Option) -> Result<(), Error> { - let container = self.allocate(ink.id)?; - let data = ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, - }; - let mut strokes = Vec::new(); - for stroke in &ink.strokes { - strokes.push((self.allocate(stroke.id)?, stroke)); - } - self.write(|active| content::ink_changes(active, ink, container, data, &strokes, holder)) - } - - /// Rewrites a drawing's stroke list: stored strokes stay as they are (OneNote erases whole - /// strokes rather than editing them), removed ones leave the list, new ones are created. - fn edit_ink(&mut self, stored: &Ink, ink: &Ink) -> Result<(), Error> { - if stored.id != ink.id { - return Err(invalid("Ink identity cannot change")); - } - if stored.layout != ink.layout || stored.groups != ink.groups { - return Err(invalid("Ink position and groups stay as stored")); - } - let mut kept = Vec::new(); - let mut added = Vec::new(); - for stroke in &ink.strokes { - match stored.strokes.iter().find(|s| s.id == stroke.id) { - Some(previous) if previous == stroke => kept.push(self.id(stroke.id)), - Some(_) => return Err(invalid("A stored stroke keeps its path and pen")), - None => added.push((self.allocate(stroke.id)?, stroke)), - } - } - let container = self.id(ink.id); - self.write(|active| content::strokes_changes(active, container, &kept, &added)) - } - - /// Gives a new picture what OneNote stores for an inserted one: the payload embedded - /// in the section's file-data store, a file-data object declaring it by identity and - /// extension, and a picture object that a paragraph holds as content or the page - /// lists as a child. - fn insert_image(&mut self, image: &Image, holder: Option) -> Result<(), Error> { - let Some(bytes) = &image.bytes else { - return Err(invalid("A new picture needs its payload")); - }; - let image_id = self.allocate(image.id)?; - let file_id = ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, - }; - let payload_guid = crate::write::fresh_guid()?; - let payload: &[u8] = bytes; - self.write_with(&[(payload_guid, payload)], |active| { - content::picture_changes(active, image, image_id, file_id, payload_guid, holder) - }) - } - - fn edit_image(&mut self, stored: &Image, image: &Image) -> Result<(), Error> { - content::picture_fixed_fields(stored, image)?; - let object = self.id(image.id); - self.write(|active| { - content::picture_edit_changes( - active, - object, - (&stored.layout, &stored.alt), - &image.layout, - &image.alt, - ) - }) - } - - fn place_containers( - &mut self, - old: &View<'_>, - new: &View<'_>, - placed: &BTreeMap>, - containers: &[ExGuid], - ) -> Result<(), Error> { - for container in containers { - let after = &new.children[container]; - let current: Vec = placed - .get(container) - .map(|list| { - list.iter() - .copied() - .filter(|id| new.paragraphs.contains_key(id)) - .collect() - }) - .unwrap_or_default(); - let kept = kept_set(¤t, after, |_| true)?; - let mut next = None; - for id in after.iter().rev() { - let anchor = next.map(|n| self.id(n)); - if !old.paragraphs.contains_key(id) && !self.alias.contains_key(id) { - let paragraph = new.paragraphs[id]; - // A styled paragraph appended to a stored outline is a split whose - // precondition failed under a merge; it stays for review. A new outline - // takes styled paragraphs as built (a copied page). - let mut root = *container; - while let Some(parent) = new.container.get(&root) { - root = *parent; - } - if paragraph.style.is_some() && old.outlines.contains_key(&root) { - return Err(invalid( - "New paragraphs in a stored outline contain plain text without styles", - )); - } - if paragraph.media != Default::default() { - return Err(invalid( - "Recording annotations are made by OneNote while it records", - )); - } - // A new table starts as an empty text paragraph whose content the - // structure pass replaces with the table. - let (text, text_id) = match ¶graph.content { - ParagraphContent::Text(text) if text.date_field.is_none() => { - (text.text.text(), Some(text.id)) - } - ParagraphContent::Table(_) - | ParagraphContent::Image(_) - | ParagraphContent::Attachment(_) - | ParagraphContent::Ink(_) => ("", None), - _ => { - return Err(invalid( - "New paragraphs contain plain text without fields or styles", - )); - } - }; - let insertion = - Insertion::paragraph(self.id(*container), anchor, text, self.author)?; - self.write(|active| insertion.changes(active))?; - self.alias.insert(*id, insertion.object()); - if let Some(text_id) = - text_id.filter(|id| !self.active.live.revision.objects.contains_key(id)) - { - self.alias.insert(text_id, insertion.text_object()); - } - } else if !kept.contains(id) { - let edit = - TreeEdit::move_to(self.id(*id), self.id(*container), anchor, self.author)?; - self.write(|active| edit.changes(active))?; - } - next = Some(*id); - } - } - Ok(()) - } - - /// Changes the shading and indents of stored cells in place. - fn edit_cells(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { - let stored: BTreeMap = old - .paragraphs - .values() - .filter_map(|paragraph| match ¶graph.content { - ParagraphContent::Table(table) => Some(table), - _ => None, - }) - .flat_map(|table| table.rows.iter().flat_map(|row| &row.cells)) - .map(|cell| (cell.id, cell)) - .collect(); - for paragraph in new.paragraphs.values() { - let ParagraphContent::Table(table) = ¶graph.content else { - continue; - }; - for cell in table.rows.iter().flat_map(|row| &row.cells) { - let Some(previous) = stored.get(&cell.id) else { - continue; - }; - if (cell.shading, &cell.indents) == (previous.shading, &previous.indents) { - continue; - } - let object = self.id(cell.id); - self.write(|active| { - table::cell_changes( - active, - object, - (previous.shading, &previous.indents), - cell.shading, - &cell.indents, - ) - })?; - } - } - Ok(()) - } - - /// Creates tables, rows and cells the model added, rebuilds every table's row and cell - /// order to the model's, and writes the column widths, locks and border flag. - fn edit_table_structure(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { - let old_tables: BTreeMap = old - .paragraphs - .values() - .filter_map(|p| match &p.content { - ParagraphContent::Table(table) => Some((table.id, table)), - _ => None, - }) - .collect(); - for (paragraph_id, paragraph) in &new.paragraphs { - let ParagraphContent::Table(table) = ¶graph.content else { - continue; - }; - let previous = old_tables.get(&table.id).copied(); - // A table is built once its holder is placed, and only once. - if !(old.paragraphs.contains_key(paragraph_id) || self.alias.contains_key(paragraph_id)) - || self.built.contains(&table.id) - { - continue; - } - self.built.insert(table.id); - let unchanged = previous.is_some_and(|previous| { - previous.columns == table.columns - && previous.borders == table.borders - && previous.rows.len() == table.rows.len() - && previous.rows.iter().zip(&table.rows).all(|(a, b)| { - a.id == b.id - && a.cells.len() == b.cells.len() - && a.cells.iter().zip(&b.cells).all(|(x, y)| x.id == y.id) - }) - }); - if unchanged { - continue; - } - let table_id = if previous.is_some() { - self.id(table.id) - } else { - self.allocate(table.id)? - }; - let existing_rows: BTreeSet = previous - .map(|p| p.rows.iter().map(|r| r.id).collect()) - .unwrap_or_default(); - let existing_cells: BTreeSet = previous - .map(|p| { - p.rows - .iter() - .flat_map(|r| r.cells.iter().map(|c| c.id)) - .collect() - }) - .unwrap_or_default(); - let template = previous - .and_then(|p| p.rows.first()) - .and_then(|r| r.cells.first()) - .map(|cell| cell.indents.as_slice()); - let mut structure = Structure { - rows: Vec::new(), - new_rows: BTreeSet::new(), - new_cells: BTreeMap::new(), - columns: &table.columns, - borders: table.borders, - }; - for row in &table.rows { - let row_id = if existing_rows.contains(&row.id) { - self.id(row.id) - } else { - let written = self.allocate(row.id)?; - structure.new_rows.insert(written); - written - }; - let mut cells = Vec::new(); - for cell in &row.cells { - let cell_id = if existing_cells.contains(&cell.id) { - self.id(cell.id) - } else { - let written = self.allocate(cell.id)?; - structure.new_cells.insert( - written, - (table::cell_indents(&cell.indents, template), cell.shading), - ); - written - }; - cells.push(cell_id); - } - structure.rows.push((row_id, cells)); - } - let holder = previous.is_none().then(|| self.id(*paragraph_id)); - self.write(|active| table::table_changes(active, table_id, holder, &structure))?; - } - Ok(()) - } - - fn delete( - &mut self, - old: &View<'_>, - new: &View<'_>, - consumed: &BTreeSet, - ) -> Result<(), Error> { - let removed_outline = - |id: ExGuid| old.outlines.contains_key(&id) && !new.outlines.contains_key(&id); - for id in old.paragraphs.keys() { - if new.paragraphs.contains_key(id) || consumed.contains(id) { - continue; - } - let mut ancestor = old.container[id]; - let mut covered = false; - // A surviving ancestor is placed where the model says, carrying this paragraph along. - while !new.paragraphs.contains_key(&ancestor) { - if removed_outline(ancestor) - || (old.paragraphs.contains_key(&ancestor) && !consumed.contains(&ancestor)) - || (old.children.contains_key(&ancestor) - && !old.paragraphs.contains_key(&ancestor) - && !old.outlines.contains_key(&ancestor) - && !new.children.contains_key(&ancestor)) - { - covered = true; - break; - } - match old.container.get(&ancestor) { - Some(parent) => ancestor = *parent, - None => break, - } - } - if covered { - continue; - } - let edit = TreeEdit::delete(*id, self.author)?; - self.write(|active| edit.changes(active))?; - } - let kept: BTreeSet = page_images(new.page) - .map(|(id, _)| id) - .chain(page_ink(new.page).map(|(id, _)| id)) - .collect(); - for id in page_images(old.page) - .map(|(id, _)| id) - .chain(page_ink(old.page).map(|(id, _)| id)) - { - if !kept.contains(&id) { - let edit = TreeEdit::delete(id, self.author)?; - self.write(|active| edit.changes(active))?; - } - } - for id in old.outlines.keys() { - if removed_outline(*id) { - if old.title_outlines.contains(id) { - return Err(invalid("Title outlines cannot be removed")); - } - let edit = TreeEdit::delete(*id, self.author)?; - self.write(|active| edit.changes(active))?; - } - } - Ok(()) - } - - /// Rewrites every equation paragraph whose stored text object differs from the model. - fn edit_equations(&mut self, new: &View<'_>) -> Result<(), Error> { - let current = self.current()?; - let current = View::new(¤t, false)?; - for (id, paragraph) in &new.paragraphs { - let Some(text) = paragraph.text() else { - continue; - }; - let stored = current.text(self.id(*id)); - // Deleting an equation leaves no math in the model, but its stored runs are still math. - if !super::Math::is_equation(&text.text) - && !stored.is_some_and(|stored| super::Math::is_equation(&stored.text)) - { - continue; - } - let stored = stored - .ok_or_else(|| invalid("An equation paragraph is missing after placement"))?; - if stored.text == text.text { - continue; - } - let object = stored.id; - self.write(|active| content::equation_changes(active, object, &text.text))?; - } - Ok(()) - } - - /// Lifts the paragraphs of outline groups into their container wherever this save changes - /// the container's children or their depths, for placement to see them; `edit_levels` - /// groups them again. - fn ungroup(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { - for (container, children) in &old.children { - let Some(after) = new.children.get(container) else { - continue; - }; - let grouped = self.active.view.nodes[container] - .children - .iter() - .any(|id| matches!(self.active.view.nodes[id].kind, Kind::OutlineGroup)); - if !grouped - || (after == children - && old.depths(*container, children) == new.depths(*container, after)) - { - continue; - } - let object = *container; - self.write(|active| levels::ungroup_changes(active, object))?; - } - Ok(()) - } - - /// Indents each container's children to the depths the model gives them. - fn edit_levels(&mut self, new: &View<'_>) -> Result<(), Error> { - let current = self.current()?; - let stored = View::new(¤t, false)?; - for (container, children) in &new.children { - let depths = new - .depths(*container, children) - .ok_or_else(|| invalid("A paragraph lies no deeper than its parent"))?; - let object = self.id(*container); - let written: Vec = children.iter().map(|id| self.id(*id)).collect(); - if children.is_empty() || stored.depths(object, &written) == Some(depths.clone()) { - continue; - } - let is_cell = - !new.paragraphs.contains_key(container) && !new.outlines.contains_key(container); - let (level, runs) = levels::runs(&written, &depths, is_cell)?; - self.write(|active| levels::regroup_changes(active, object, level, &runs))?; - } - Ok(()) - } - - /// Gives a paragraph the text object its model names when a split and a join in one save - /// moved text between paragraphs: a stored object is taken over, as OneNote moves a lower - /// paragraph's text into an emptied upper one; a new one starts as a copy of the text - /// the paragraph holds. - fn edit_text_identities(&mut self, new: &View<'_>) -> Result<(), Error> { - for (id, paragraph) in &new.paragraphs { - let Some(text) = paragraph.text() else { - continue; - }; - let object = self.id(*id); - let stored = self.active.view.nodes[&object].content[0]; - if stored == self.id(text.id) { - continue; - } - let target = if self.active.live.revision.objects.contains_key(&text.id) { - text.id - } else { - self.allocate(text.id)? - }; - let unstyled = paragraph.style.is_none(); - self.write(|active| { - let parents = active.editable_parents(object)?; - let modified = crate::create::current_timestamps()?.0.to_le_bytes(); - let raw = &active.live.revision; - let mut changed = BTreeMap::new(); - if !raw.objects.contains_key(&target) { - let mut copy = PropertyObject::from_object(&raw.objects[&stored])?; - copy.remove(&[0x1c001c98, 0x14001c99])?; - if unstyled { - copy.remove(&[0x2000342c])?; - } - copy.reference(target)?; - changed.insert(target, copy); - } - let mut holder = PropertyObject::from_object(&raw.objects[&object])?; - let reference = holder.reference(target)?; - holder.set(&[(0x24001c1f, &reference), (0x14001d7a, &modified)])?; - changed.insert(object, holder); - crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?; - Ok(changed) - })?; - } - Ok(()) - } - - fn edit_text(&mut self, new: &View<'_>) -> Result<(), Error> { - let current = self.current()?; - let current = View::new(¤t, false)?; - for (id, paragraph) in &new.paragraphs { - let Some(text) = paragraph.text() else { - continue; - }; - let image_id = self.id(*id); - let Some(stored) = current.text(image_id) else { - return Err(invalid("A paragraph is missing after structural edits")); - }; - if stored.id != self.id(text.id) { - return Err(invalid("Text object identities cannot change")); - } - if stored.text.text() == text.text.text() { - continue; - } - let (range, replacement) = text_edit(stored.text.text(), text.text.text())?; - let object = stored.id; - self.write(|active| crate::edit::text_changes(active, object, range, &replacement))?; - } - 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, false)?; - 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)| { - self.id(*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(&self.id(*list)) { - Some(owner) if *owner == image_id => self.id(*list), - Some(_) => ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, - }, - // A node its paragraph let go of in this save serves the model again. - None if self.active.live.revision.objects.contains_key(list) => *list, - 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"))?; - nodes.push((node_id, properties::list_values(definition)?)); - } - let object = self.id(*id); - self.write(|active| properties::list_changes(active, object, &nodes))?; - } - Ok(()) - } - - /// Rewrites the note tags of paragraphs and text objects whose model tags differ from - /// the stored ones. A tag definition new to the section becomes a definition object - /// carrying the model's identity after squash. - fn edit_tags(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> { - let current = self.current()?; - let stored = View::new(¤t, false)?; - 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 mut targets = vec![(image_id, ¶graph.tags, &previous.tags)]; - if let (Some(text), Some(before)) = (paragraph.text(), previous.text()) { - targets.push((self.id(text.id), &text.tags, &before.tags)); - } - for (object, tags, stored_tags) in targets { - if crate::op::lower::same_tags(tags, stored_tags) { - continue; - } - let mut entries = Vec::new(); - for tag in tags { - let definition = tag - .definition - .ok_or_else(|| invalid("A note tag names its definition"))?; - let known = self.active.live.revision.objects.contains_key(&definition) - || self.alias.contains_key(&definition); - let written = if known { - self.id(definition) - } else { - let allocated = ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, - }; - self.alias.insert(definition, allocated); - allocated - }; - entries.push((written, tag, after.definitions.get(&definition))); - } - self.write(|active| properties::tag_changes(active, object, &entries))?; - } - } - Ok(()) - } - - /// A new paragraph, or one whose style changed, references its paragraph style from - /// its text object; a style the page has not stored yet is created from its - /// definition, as OneNote keeps quick styles. - fn edit_paragraph_styles( - &mut self, - old: &View<'_>, - new: &View<'_>, - after: &Page, - ) -> Result<(), Error> { - for (id, paragraph) in &new.paragraphs { - let Some(definition) = paragraph.style else { - continue; - }; - if old - .paragraphs - .get(id) - .is_some_and(|previous| previous.style == paragraph.style) - { - continue; - } - let Some(text) = paragraph.text() else { - return Err(invalid("Only text paragraphs take a paragraph style")); - }; - let known = self.active.live.revision.objects.contains_key(&definition) - || self.alias.contains_key(&definition); - let style_id = if known { - self.id(definition) - } else { - let allocated = ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, - }; - self.alias.insert(definition, allocated); - allocated - }; - let object = self.id(text.id); - let definition = after.definitions.get(&definition); - self.write(|active| properties::style_changes(active, object, style_id, definition))?; - } - Ok(()) - } - - fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { - let current = self.current()?; - let current = View::new(¤t, false)?; - for (id, paragraph) in &new.paragraphs { - let Some(text) = paragraph.text() else { - continue; - }; - let stored = current - .text(self.id(*id)) - .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; - let values = crate::op::lower::paragraph_change(&stored.text, &text.text)?; - if values.is_empty() { - continue; - } - if text.date_field.is_some() { - return Err(invalid( - "Generated title fields cannot be formatted as ordinary text", - )); - } - let object = stored.id; - self.write(|active| properties::paragraph_format_changes(active, object, &values))?; - } - Ok(()) - } - - fn edit_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { - let current = self.current()?; - let current = View::new(¤t, false)?; - for (id, paragraph) in &new.paragraphs { - let Some(text) = paragraph.text() else { - continue; - }; - let stored = current - .text(self.id(*id)) - .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; - if stored.text.text() != text.text.text() { - return Err(invalid("Text edits did not converge on the model")); - } - let fresh = self.alias.contains_key(&text.id); - for (range, set, clear) in crate::op::lower::format_edits(&stored.text, &text.text, fresh)? { - let object = stored.id; - self.write(|active| { - crate::formatting::format_changes(active, object, range, &set, &clear) - })?; - } - } - Ok(()) - } - - fn edit_layout(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { - let current = self.current()?; - let current = View::new(¤t, false)?; - for (id, paragraph) in &new.paragraphs { - let stored = current - .paragraphs - .get(&self.id(*id)) - .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; - if stored.collapsed != paragraph.collapsed { - let object = self.id(*id); - let edit = OutlineEdit::Collapsed(paragraph.collapsed); - self.write(|active| edit.changes(active, object))?; - } - } - for (id, outline) in &new.outlines { - if new.title_outlines.contains(id) { - continue; - } - let stored = current - .outlines - .get(&self.id(*id)) - .ok_or_else(|| invalid("An outline is missing after text edits"))?; - let object = self.id(*id); - // A new outline takes the model's indentation table (a copied outline keeps - // its levels' offsets); a stored table stays as it is. - if !old.outlines.contains_key(id) - && !outline.indents.is_empty() - && outline.indents != stored.indents - { - let indents = content::measurement_bytes(&outline.indents, 4)?; - self.write(|active| { - let raw = &active.live.revision; - let mut node = PropertyObject::from_object(&raw.objects[&object])?; - node.set(&[(0x1c001c12, &indents)])?; - Ok(BTreeMap::from([(object, node)])) - })?; - } - if (outline.layout.x, outline.layout.y) != (stored.layout.x, stored.layout.y) { - let (Some(x), Some(y)) = (outline.layout.x, outline.layout.y) else { - return Err(invalid("An outline position needs both coordinates")); - }; - let edit = OutlineEdit::Position { x, y }; - self.write(|active| edit.changes(active, object))?; - } - if (outline.layout.max_width, outline.layout.width_set_by_user) - != (stored.layout.max_width, stored.layout.width_set_by_user) - && (old.outlines.contains_key(id) || outline.layout.max_width.is_some()) - { - let Some(points) = outline.layout.max_width else { - return Err(invalid("An outline width cannot be removed")); - }; - let edit = OutlineEdit::Width { - points, - user_set: outline.layout.width_set_by_user == Some(true), - }; - self.write(|active| edit.changes(active, object))?; - } - } - Ok(()) - } -} - -/// Payload identities the file-data store of `store` declares, in order. -pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> { - store - .lists - .values() - .flat_map(|list| &list.nodes) - .filter(|node| node.id == 0x94) - .filter_map(|node| node.payload.get(..16).and_then(|g| g.try_into().ok())) - .collect() -} - -/// Writes the spaces the typed writers `edited` as one transaction on the validated -/// `source`, renaming writer-allocated identities to the model's and embedding the -/// `payloads` it lacks. A protected `source` takes the revisions its plaintext twin gained. -pub(crate) fn squash( - source: &RevisionIndex<'_>, - edited: &[(ExGuid, &crate::ResolvedRevision<'_>)], - payloads: &[([u8; 16], &[u8])], - alias: &BTreeMap, - protection: Option<&dyn crate::write::Protection>, -) -> Result, Error> { - let rename: BTreeMap = alias - .iter() - .map(|(model, image)| (*image, *model)) - .collect(); - let existing = declared_payloads(source.store); - let payloads: Vec<_> = payloads - .iter() - .filter(|(guid, _)| !existing.contains(guid)) - .copied() - .collect(); - let edit = |index: &RevisionIndex<'_>| { - let mut changes = BTreeMap::new(); - for (sid, after) in edited { - let before = match protection { - Some(protection) => protection.resolve(*sid, index.active(*sid)?)?, - None => index.resolve_active(*sid)?, - }; - if before.roots != after.roots { - return Err(invalid("Page edits cannot change revision roots")); - } - // Retired styles and deleted content stay in history; only live objects are written. - let live = after.reachable()?; - let mut changed = BTreeMap::new(); - for (id, object) in &after.objects { - if !live.contains(id) { - continue; - } - // Stored tables keep the entries an object names, so those decide. - if let Some(previous) = before.objects.get(id) - && previous.jcid == object.jcid - && previous.data == object.data - { - let entries = match object.data { - ObjectData::Properties(bytes) => crate::write::table_entries(bytes)?, - _ => Vec::new(), - }; - if entries - .iter() - .all(|entry| previous.global_ids.get(entry) == object.global_ids.get(entry)) - { - continue; - } - } - let id = rename.get(id).copied().unwrap_or(*id); - let mut replacement = match object.data { - ObjectData::Properties(_) => { - let mut replacement = PropertyObject::from_object(object)?; - remap(&mut replacement, &rename)?; - replacement - } - ObjectData::File { - reference, - extension, - } => { - let text = |bytes: &[u8]| { - String::from_utf16( - &bytes - .chunks_exact(2) - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) - .collect::>(), - ) - .map_err(|_| invalid("Invalid UTF-16 file-data declaration")) - }; - let mut replacement = - PropertyObject::file(id, &text(reference)?, &text(extension)?)?; - replacement.jcid = object.jcid; - replacement - } - ObjectData::Encrypted(_) => { - return Err(invalid("Page edits only produce property objects")); - } - }; - replacement.reference(id)?; - if before.objects.contains_key(&id) && rename.values().any(|model| *model == id) { - return Err(invalid( - "A new model identity already exists in the section", - )); - } - changed.insert(id, replacement); - } - changes.insert(*sid, RevisionEdit::Update(changed)); - } - Ok(changes) - }; - let output = crate::write::build_on(source, &payloads, protection, edit)?; - crate::write::check(&output, protection.is_none())?; - Ok(output) -} - -fn remap(object: &mut PropertyObject, rename: &BTreeMap) -> Result<(), Error> { - let mut remapped = Vec::new(); - for property in PropertySets::parse(&object.bytes)?.sets.iter().flatten() { - if let Value::References { - stream: crate::IdStream::Objects, - compact_ids, - } = property.value - { - for bytes in compact_ids.chunks_exact(4) { - let offset = bytes.as_ptr().addr() - object.bytes.as_ptr().addr(); - let id = crate::bytes::Cursor { bytes, offset }.compact(&object.global_ids)?; - if let Some(model) = rename.get(&id) { - remapped.push((offset, *model)); - } - } - } - } - for (offset, id) in remapped { - let reference = object.reference(id)?; - object.bytes[offset..offset + 4].copy_from_slice(&reference); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - document::Format, - page::{PageParagraph, Paragraph, TextObject, text::new_id}, - }; - - /// A new page in a new section and the page with `count` paragraphs added in an outline. - fn generated(count: usize) -> (Vec, ExGuid, Page) { - let section = crate::create_section("big.one", "First", "Author").unwrap(); - let creation = crate::PageCreation::new(None, Some("Big"), "Author").unwrap(); - let source = crate::PreparedEdit::create_page(§ion, &creation) - .unwrap() - .as_bytes() - .to_vec(); - let space = creation.space(); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let mut page = - Page::from_space(&crate::document::Document::parse(&index).unwrap(), space).unwrap(); - let paragraph = |i: usize| PageParagraph { - id: new_id().unwrap(), - parent: None, - level: 1, - style: None, - format: Format::default(), - content: ParagraphContent::Text(TextObject { - id: new_id().unwrap(), - date_field: None, - text: Paragraph::new(format!("Paragraph {i}"), Format::default()), - tags: Vec::new(), - }), - lists: Vec::new(), - tags: Vec::new(), - media: Default::default(), - collapsed: false, - }; - page.objects.push(PageObject::Outline(Outline { - id: new_id().unwrap(), - title: false, - min_width: None, - layout: crate::document::Layout { - x: Some(36.0), - y: Some(86.4), - ..Default::default() - }, - indents: Vec::new(), - paragraphs: (0..count).map(paragraph).collect(), - unsupported: Vec::new(), - })); - (source, space, page) - } - - #[test] - fn a_page_write_builds_one_revision_however_many_paragraphs_it_adds() { - for count in [10, 40] { - let (source, space, after) = generated(count); - let builds = crate::write::BUILDS.with(std::cell::Cell::get); - let written = write_page(&source, space, &after, "Author").unwrap(); - assert_eq!(crate::write::BUILDS.with(std::cell::Cell::get) - builds, 1); - let store = Store::parse(&written).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let read = Page::from_space(&crate::document::Document::parse(&index).unwrap(), space) - .unwrap(); - let texts = |page: &Page| -> Vec { - page.objects - .iter() - .filter_map(|object| match object { - PageObject::Outline(outline) => Some(outline), - _ => None, - }) - .flat_map(|outline| &outline.paragraphs) - .filter_map(|paragraph| Some(paragraph.text()?.text.text().to_owned())) - .collect() - }; - assert_eq!(texts(&read), texts(&after)); - } - } -} diff --git a/crates/onestore/src/pages.rs b/crates/onestore/src/pages.rs index dcb87cb8868234d9523e7fd08f093fa329a490b0..d0a6782091ab9b112f18f5d3750c6f8861dde5ef 100644 --- a/crates/onestore/src/pages.rs +++ b/crates/onestore/src/pages.rs @@ -1,9 +1,9 @@ use crate::{ Error, ExGuid, create::{current_timestamps, properties, string}, - document::{Document, FieldValue, Kind}, + document::{FieldValue, Kind}, op::content::{NATIVE_INDENTS, measurement_bytes}, - write::{PropertyObject, RevisionEdit, fresh_guid, write_revisions}, + write::{PropertyObject, fresh_guid}, }; use serde::{Deserialize, Serialize}; use std::{ @@ -15,7 +15,7 @@ fn invalid(message: &'static str) -> Error { Error { offset: 0, message } } -fn metadata_id(space: ExGuid) -> ExGuid { +pub(crate) fn metadata_id(space: ExGuid) -> ExGuid { let mut id = ExGuid { guid: space.guid, n: 1, @@ -28,7 +28,7 @@ fn metadata_id(space: ExGuid) -> ExGuid { id } -fn metadata_guid(metadata: &crate::document::Element<'_>) -> Result<[u8; 16], Error> { +pub(crate) fn metadata_guid(metadata: &crate::document::Element<'_>) -> Result<[u8; 16], Error> { metadata.extra[0] .iter() .find_map(|field| match field.value { @@ -38,7 +38,7 @@ fn metadata_guid(metadata: &crate::document::Element<'_>) -> Result<[u8; 16], Er .ok_or_else(|| invalid("Page metadata needs its page identifier")) } -fn set_references(object: &mut PropertyObject, property: u32, ids: &[ExGuid]) -> Result<(), Error> { +pub(crate) fn set_references(object: &mut PropertyObject, property: u32, ids: &[ExGuid]) -> Result<(), Error> { let mut references = Vec::new(); for id in ids { references.extend_from_slice(&object.reference(*id)?); @@ -51,6 +51,20 @@ fn set_references(object: &mut PropertyObject, property: u32, ids: &[ExGuid]) -> object.copy_property(&source, property) } +/// A conflict page (MS-ONE 2.1.2): the version of a page a merge could not take, kept +/// read-only under the page it conflicts with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConflictPage { + pub space: ExGuid, + pub title: String, + /// Whose version it is (`ConflictingUserName`). + pub user: String, + /// FILETIME of its `TopologyCreationTimeStamp`, when the merge made it. + pub created: Option, + /// The conflict objects on it (`IsConflictObjectForRender`), which OneNote highlights. + pub objects: Vec, +} + /// Whether an edit preserves position or moves before a page (None appends). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -138,42 +152,7 @@ pub(crate) struct PageParts<'a> { pub stored_metadata: (ExGuid, PropertyObject), } -pub(crate) fn edit_pages( - source: &[u8], - edits: &[PageEdit], - removals: &[ExGuid], -) -> Result, Error> { - write_revisions(source, |index| { - let document = Document::parse(index)?; - let pages: Vec = document.pages()?.into_iter().map(|(sid, _)| sid).collect(); - let root = document.active(document.root)?; - let raw = index.resolve_active(document.root)?; - let parts = |sid: ExGuid| -> Result, Error> { - let page = document.active(sid)?; - let revision = index.resolve_active(sid)?; - let metadata = page - .roots - .get(&2) - .and_then(|id| page.nodes.get(id)) - .ok_or_else(|| invalid("Page metadata is unavailable"))?; - let (manifest, stored) = (revision.roots[&1], revision.roots[&2]); - Ok(PageParts { - metadata: metadata.clone(), - manifest: (manifest, PropertyObject::from_object(&revision.objects[&manifest])?), - stored_metadata: (stored, PropertyObject::from_object(&revision.objects[&stored])?), - }) - }; - let Some(changes) = page_changes(document.root, root, &raw, &pages, parts, edits, removals)? else { - return Ok(BTreeMap::new()); - }; - Ok(changes - .into_iter() - .map(|(space, changes)| (space, RevisionEdit::Update(changes))) - .collect()) - }) -} - -/// `edit_pages` on a section kept open: the objects each changed space stores. +/// Page moves and removals on a section kept open: the objects each changed space stores. pub(crate) fn section_changes( section: &mut crate::Section<'_>, edits: &[PageEdit], @@ -197,12 +176,18 @@ pub(crate) fn section_changes( let parts = |sid: ExGuid| -> Result, Error> { let revision = section.revision(sid)?; let (manifest, stored) = (revision.roots[&1], revision.roots[&2]); - Ok(PageParts { - metadata: crate::document::Element::parse_with( - &revision.objects[&stored], + let element = |id| { + crate::document::Element::parse_with( + &revision.objects[id], crate::FileType::Section, &mut |_| Err(invalid("Page metadata holds no payload")), - )?, + ) + }; + if element(&manifest)?.content.len() != 1 { + return Err(invalid("Each page space must contain one page with a valid level")); + } + Ok(PageParts { + metadata: element(&stored)?, manifest: (manifest, PropertyObject::from_object(&revision.objects[&manifest])?), stored_metadata: (stored, PropertyObject::from_object(&revision.objects[&stored])?), }) @@ -491,6 +476,12 @@ pub struct PageCreation { title: Option, author: String, created: u32, + /// The title's date and time fields' text, for a titled page. + #[serde(default, skip_serializing_if = "Option::is_none")] + date: Option<[String; 2]>, + /// The page identity and creation time (FILETIME) a page keeps from elsewhere. + #[serde(default, skip_serializing_if = "Option::is_none")] + kept: Option<([u8; 16], u64)>, } impl PageCreation { @@ -505,11 +496,57 @@ impl PageCreation { title: title.map(str::to_owned), author: author.to_owned(), created: current_timestamps()?.0, + date: None, + kept: None, }; page.validate()?; Ok(page) } + /// Gives a titled page's title OneNote 2010's date and time fields, showing `date` and + /// `time`: the creation time (`created`) as the user's long date and short time. + pub fn dated(mut self, date: &str, time: &str) -> Result { + if self.title.is_none() { + return Err(invalid("Only a titled page shows its date")); + } + self.date = Some([date.to_owned(), time.to_owned()]); + self.validate()?; + Ok(self) + } + + /// Keeps `identity`, the page identity internal links name, and `created`, as OneNote + /// 2010 keeps both for a page it moves to the notebook's recycle bin. + pub fn keeping(mut self, identity: [u8; 16], created: u64) -> Result { + if identity == [0; 16] { + return Err(invalid("Keep an existing page identity")); + } + let seconds = (created / 10_000_000) + .checked_sub(11_644_473_600 + 315_532_800) + .and_then(|seconds| u32::try_from(seconds).ok()) + .ok_or_else(|| invalid("The kept creation time is outside OneNote's range"))?; + self.created = seconds; + self.kept = Some((identity, created)); + Ok(self) + } + + /// Who creates the page. + pub fn author(&self) -> &str { + &self.author + } + + /// When the page was created, FILETIME, which its date and time fields show. + pub fn created(&self) -> u64 { + match self.kept { + Some((_, created)) => created, + None => (u64::from(self.created) + 315_532_800 + 11_644_473_600) * 10_000_000, + } + } + + /// The page identity internal links name as `page-id`. + fn identity(&self) -> [u8; 16] { + self.kept.map_or(self.guid, |(identity, _)| identity) + } + pub fn space(&self) -> ExGuid { ExGuid { guid: self.guid, @@ -531,6 +568,11 @@ impl PageCreation { }) } + /// The page the new page goes before; `None` appends it. + pub fn before(&self) -> Option { + self.before + } + /// Changes the insertion anchor while retaining all identities and creation metadata. pub fn reposition(&self, before: Option) -> Result { let mut page = self.clone(); @@ -547,8 +589,9 @@ impl PageCreation { || self.before.is_some_and(|id| id.guid == [0; 16]) || self .title - .as_ref() - .is_some_and(|title| title.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}'])) + .iter() + .chain(self.date.iter().flatten()) + .any(|text| text.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}'])) { return Err(invalid( "Use a new page identity, an existing page anchor, and ordinary single-line title text", @@ -557,28 +600,7 @@ impl PageCreation { Ok(()) } - pub(crate) fn apply(&self, source: &[u8]) -> Result, Error> { - self.validate()?; - write_revisions(source, |index| { - if index.spaces.contains_key(&self.space()) { - return Err(invalid( - "This page identity already exists; reconcile the original creation", - )); - } - let document = Document::parse(index)?; - document.pages()?; - let view = document.active(document.root)?; - let raw = index.resolve_active(document.root)?; - let (root, roots, objects) = self.creation(view, &raw)?; - Ok(BTreeMap::from([ - (document.root, RevisionEdit::Update(root)), - (self.space(), RevisionEdit::Create { roots, objects }), - ])) - }) - } - - /// `apply` on a section kept open: the root space's changes, and the new space's roots - /// and objects. + /// The root space's changes creating this page, and the new space's roots and objects. pub(crate) fn changes( &self, section: &mut crate::Section<'_>, @@ -594,6 +616,133 @@ impl PageCreation { self.creation(&page.view, &page.live.revision) } + /// The object space of a conflict page this creation makes, as OneNote 2010 stores one: + /// the page's space with `jcidConflictPageMetaData` (MS-ONE 2.2.35) as its metadata, + /// naming this creation's author as the conflicting user and `title` as the page's. + /// Returns the roots, the objects and the metadata, which the conflicting page's + /// manifest also keeps a copy of. + pub(crate) fn conflict( + &self, + section: &mut crate::Section<'_>, + title: &str, + level: u32, + ) -> Result<(BTreeMap, crate::active::Changes, PropertyObject), Error> { + if self.before.is_some() { + return Err(invalid("A conflict page is not placed in the page list")); + } + let (_, roots, mut objects) = self.changes(section)?; + let timestamp = self.created(); + let initials = crate::create::initials(&self.author); + let metadata = PropertyObject { + jcid: 0x20038, + bytes: properties(&[ + (0x1c001cf3, string(title)), + (0x1c001c30, self.identity().to_vec()), + (0x14001dff, level.to_le_bytes().to_vec()), + (0x14001d82, 40_u32.to_le_bytes().to_vec()), + (0x1400348b, 40_u32.to_le_bytes().to_vec()), + (0x18001c65, timestamp.to_le_bytes().to_vec()), + (0x1c001d9e, string(&self.author)), + (0x1c001d9f, string(&initials)), + ])?, + global_ids: Arc::new(BTreeMap::from([(0, self.guid)])), + }; + objects.insert(roots[&2], metadata.clone()); + Ok((roots, objects, metadata)) + } + + /// The title's second outline as OneNote 2010 writes it for a new page (object numbers, + /// jcids and properties): a read-only outline of two paragraphs, the date then the + /// time, in the `PageDateTime` style. + fn date_fields(&self, date: &str, time: &str) -> Vec<(u32, u32, Vec<(u32, Vec)>)> { + let reference = |n: u32| n.to_le_bytes().to_vec(); + let modified = || (0x14001d7a, self.created.to_le_bytes().to_vec()); + let yes = |id: u32| (id | 1 << 31, Vec::new()); + let paragraph = |text: u32| { + vec![ + modified(), + (0x20001d79, reference(17)), + (0x20001d78, reference(17)), + (0x14001d09, self.created.to_le_bytes().to_vec()), + (0x0c001c03, vec![1]), + (0x24001c1f, reference(text)), + yes(0x08001cb2), + yes(0x08001d0c), + (0x08001c34, Vec::new()), + ] + }; + let text = |value: &str, role: u32| { + // OneNote keeps Latin-1 text as TextExtendedAscii, other text as Unicode. + let content = match value.chars().map(|c| u8::try_from(c).ok()).collect::>>() { + Some(latin) => (0x1c003498, latin), + None => (0x1c001c22, string(value)), + }; + vec![ + modified(), + (0x2000342c, reference(23)), + yes(0x08001cde), + yes(0x08001c00), + yes(0x08001c88), + yes(0x08001d0c), + yes(role), + (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()), + (0x1c001cc8, vec![0, 0, 0, 0, 3]), + content, + (0x24001e13, reference(19)), + yes(0x080034dd), + ] + }; + let no = |id: u32| (id, Vec::new()); + vec![ + ( + 20, + 0x6000c, + vec![ + modified(), + (0x24001c20, [reference(21), reference(24)].concat()), + ( + 0x1c001c12, + measurement_bytes(&NATIVE_INDENTS, 4).expect("Native indents encode"), + ), + (0x0c001c13, vec![0]), + (0x0c001c03, vec![1]), + (0x14001c1c, 0.6_f32.to_le_bytes().to_vec()), + yes(0x08001cb5), + yes(0x08001c00), + yes(0x08001cb2), + yes(0x08001cde), + yes(0x08001d0c), + (0x14001c3e, reference(0)), + (0x14001c84, reference(0xc)), + ], + ), + (21, 0x6000d, paragraph(22)), + (22, 0x6000e, text(date, 0x08001cb5)), + ( + 23, + 0x12004d, + vec![ + (0x1c00345a, string("PageDateTime")), + no(0x08001c04), + no(0x08001c05), + no(0x08001c06), + (0x14001c0c, 0x0080_8080_u32.to_le_bytes().to_vec()), + (0x1c001c0a, string("Calibri")), + (0x10001c0b, 20_u16.to_le_bytes().to_vec()), + (0x1400342e, 0_f32.to_le_bytes().to_vec()), + (0x1400342f, 0_f32.to_le_bytes().to_vec()), + (0x14003430, 0_f32.to_le_bytes().to_vec()), + no(0x08001c07), + (0x14001c0d, 0xff00_0000_u32.to_le_bytes().to_vec()), + no(0x08001c09), + no(0x08001c08), + ], + ), + (24, 0x6000d, paragraph(25)), + (25, 0x6000e, text(time, 0x08001c87)), + ] + } + fn creation( &self, view: &crate::document::Revision<'_>, @@ -621,9 +770,7 @@ impl PageCreation { }; let id = |n| ExGuid { guid: self.guid, n }; let reference = |n: u32| n.to_le_bytes().to_vec(); - let timestamp = ((u64::from(self.created) + 315532800 + 11644473600) * 10000000) - .to_le_bytes() - .to_vec(); + let timestamp = self.created().to_le_bytes().to_vec(); let modified = || (0x14001d7a, self.created.to_le_bytes().to_vec()); let mut table = BTreeMap::from([(0, self.guid)]); let metadata_id = metadata_id(self.space()); @@ -638,7 +785,7 @@ impl PageCreation { }; let title = self.title.as_deref().unwrap_or_default().trim_start(); let metadata = vec![ - (0x1c001c30, self.guid.to_vec()), + (0x1c001c30, self.identity().to_vec()), (0x1c001cf3, string(title)), (0x14001d82, reference(40)), (0x1400348b, reference(40)), @@ -649,6 +796,7 @@ impl PageCreation { vec![ modified(), (0x1c001d75, string(&self.author)), + (0x1c001df8, string(&crate::create::initials(&self.author))), (0x1c001d3c, string("")), ], crate::create::page_margins(), @@ -669,7 +817,11 @@ impl PageCreation { 0x6002c, vec![ modified(), - (0x24001c20, reference(14)), + ( + 0x24001c20, + [reference(14), self.date.iter().flat_map(|_| reference(20)).collect()] + .concat(), + ), (0x14001c14, 0_f32.to_le_bytes().to_vec()), (0x14001c15, 0_f32.to_le_bytes().to_vec()), (0x14001c3e, reference(0x0009_000c)), @@ -723,7 +875,7 @@ impl PageCreation { (0x88001cb4, vec![]), ], ), - (17, 0x120001, vec![(0x1c001d75, string(&self.author))]), + (17, 0x120001, crate::create::author_properties(&self.author)), ( 18, 0x12004d, @@ -737,6 +889,11 @@ impl PageCreation { ] { objects.insert(id(n), object(jcid, values)?); } + if let Some([date, time]) = &self.date { + for (n, jcid, values) in self.date_fields(date, time) { + objects.insert(id(n), object(jcid, values)?); + } + } } let series = object( 0x60008, @@ -774,7 +931,25 @@ impl PageCreation { #[cfg(test)] mod tests { use super::*; - use crate::{RevisionIndex, Store}; + use crate::{ + RevisionIndex, Store, + document::Document, + op::{Edit, Op, OpError, SectionOp}, + write::{RevisionEdit, write_revisions}, + }; + + /// Page moves and removals through a section, sealed: the image they leave. + fn edit_pages(source: &[u8], edits: &[PageEdit], removals: &[ExGuid]) -> Result, OpError> { + let arena = crate::Arena::default(); + let mut section = crate::Section::open(&arena, source.to_vec()).map_err(OpError::Failed)?; + let mut ops = vec![Op::Section(SectionOp::Pages(edits.to_vec()))]; + if !removals.is_empty() { + ops.push(Op::Section(SectionOp::Delete(removals.to_vec()))); + } + section.apply("Author", &Edit { at: 133_700_000_000_000_000, ops })?; + section.seal().map_err(OpError::Failed)?; + Ok(section.image()) + } #[test] fn page_movement_restores_an_optional_metadata_cache_from_history() { diff --git a/crates/onestore/src/paragraph.rs b/crates/onestore/src/paragraph.rs index 10430ec625669a99979faa780694bbd4c1e936c9..b202513488732130869e9136c509e7277f999b02 100644 --- a/crates/onestore/src/paragraph.rs +++ b/crates/onestore/src/paragraph.rs @@ -6,7 +6,6 @@ use crate::{ edit::update_title, write::{PropertyObject, fresh_guid}, }; -use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, BTreeSet}, ops::Range, @@ -17,11 +16,10 @@ fn invalid(message: &'static str) -> Error { Error { offset: 0, message } } -/// Splits ordinary paragraph text while retaining the new objects' identities across retries. -/// The original paragraph/text remain on the left; nested children move to the right. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ParagraphSplit { +/// Splits ordinary paragraph text. The original paragraph/text remain on the left; nested +/// children move to the right. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ParagraphSplit { guid: [u8; 16], text: ExGuid, offset: u32, @@ -31,7 +29,7 @@ pub struct ParagraphSplit { impl ParagraphSplit { /// The offset is measured in UTF-16 code units and must lie between Unicode scalars. - pub fn new(text: ExGuid, offset: u32, author: &str) -> Result { + pub(crate) fn new(text: ExGuid, offset: u32, author: &str) -> Result { if text.guid == [0; 16] || author.contains('\0') { return Err(invalid( "Choose paragraph text and an author name without NUL", @@ -46,64 +44,6 @@ impl ParagraphSplit { }) } - /// Identity of the new right paragraph. - pub fn object(&self) -> ExGuid { - ExGuid { - guid: self.guid, - n: 1, - } - } - - /// Identity of the new right paragraph's text. - pub fn text_object(&self) -> ExGuid { - ExGuid { - guid: self.guid, - n: 2, - } - } - - /// Original text identity and UTF-16 split boundary. - pub fn position(&self) -> (ExGuid, u32) { - (self.text, self.offset) - } - - /// Changes the split boundary while retaining allocated identities and creation time. - /// Preparing the edit validates the offset against the supplied text. - pub fn reposition(&self, offset: u32) -> Self { - let mut split = self.clone(); - split.offset = offset; - split - } - - pub(crate) fn apply(&self, source: &[u8], space: ExGuid) -> Result, Error> { - if self.guid == [0; 16] || self.text.guid == [0; 16] || self.author.contains('\0') { - return Err(invalid( - "Choose paragraph text and an author name without NUL", - )); - } - crate::active::write(source, space, |active| self.changes(active)) - } - - pub(crate) fn changes(&self, active: &ActivePage<'_>) -> Result { - let lists = (5..256) - .map(|n| ExGuid { - guid: self.guid, - n, - }) - .collect::>(); - let changes = self.changes_as(active, self.object(), self.text_object(), &lists)?; - let raw = &active.live.revision; - if changes - .keys() - .any(|id| id.guid == self.guid && raw.objects.contains_key(id)) - { - return Err(invalid( - "A split identity already exists; reconcile the existing edit", - )); - } - Ok(changes) - } - /// `changes` creating the right paragraph `paragraph` with text `right`, and a copy of /// the left paragraph's list nodes under `lists`, in order. pub(crate) fn changes_as( @@ -244,7 +184,7 @@ impl ParagraphSplit { author_id, PropertyObject { jcid: 0x120001, - bytes: properties(&[(0x1c001d75, string(&self.author))])?, + bytes: properties(&crate::create::author_properties(&self.author))?, global_ids: Arc::new(BTreeMap::from([(0, self.guid)])), }, ); @@ -411,22 +351,16 @@ fn ordinary_text<'a>( /// The left paragraph survives. Empty left text adopts the right text identity; /// otherwise the left text survives. The left paragraph's tags are retained. /// Right-side tags are removed from active text, including when the left text is empty. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ParagraphJoin { +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ParagraphJoin { left: ExGuid, right: ExGuid, author: String, } impl ParagraphJoin { - /// Ordered left and right text identities. - pub fn texts(&self) -> [ExGuid; 2] { - [self.left, self.right] - } - /// Select the preceding leaf paragraph's text and the following paragraph's text. - pub fn new(left: ExGuid, right: ExGuid, author: &str) -> Result { + pub(crate) fn new(left: ExGuid, right: ExGuid, author: &str) -> Result { if left == right || left.guid == [0; 16] || right.guid == [0; 16] || author.contains('\0') { return Err(invalid( "Choose two distinct text objects and an author name without NUL", @@ -439,19 +373,6 @@ impl ParagraphJoin { }) } - pub(crate) fn apply(&self, source: &[u8], space: ExGuid) -> Result, Error> { - if self.left == self.right - || self.left.guid == [0; 16] - || self.right.guid == [0; 16] - || self.author.contains('\0') - { - return Err(invalid( - "Choose two distinct text objects and an author name without NUL", - )); - } - crate::active::write(source, space, |active| self.changes(active)) - } - pub(crate) fn changes(&self, active: &ActivePage<'_>) -> Result { let pages = &active.pages; if pages.len() != 1 { @@ -672,7 +593,7 @@ impl ParagraphJoin { author, PropertyObject { jcid: 0x120001, - bytes: properties(&[(0x1c001d75, string(&self.author))])?, + bytes: properties(&crate::create::author_properties(&self.author))?, global_ids: Arc::new(BTreeMap::from([(0, author.guid)])), }, ); diff --git a/crates/onestore/src/protected/crypto.rs b/crates/onestore/src/protected/crypto.rs index fef881d6e82f13387a652a6e1af9fecaf23959e5..fa5863eab5211ac43249755892eac6b2997890b1 100644 --- a/crates/onestore/src/protected/crypto.rs +++ b/crates/onestore/src/protected/crypto.rs @@ -8,6 +8,7 @@ use zeroize::{Zeroize, Zeroizing}; const NS: &str = "http://schemas.microsoft.com/office/2006/encryption"; const PASSWORD_NS: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password"; +#[derive(Clone)] pub(super) struct Key { value: Zeroizing<[u8; 16]>, file_iv: [u8; 16], diff --git a/crates/onestore/src/protected/mod.rs b/crates/onestore/src/protected/mod.rs index 24dee8209d7be545a26fb7990c69d923d4c33503..a2907077ff91a737b696a52eeb8fad2bb49397ac 100644 --- a/crates/onestore/src/protected/mod.rs +++ b/crates/onestore/src/protected/mod.rs @@ -7,7 +7,11 @@ mod crypto; -use crate::{ExGuid, FileDataReference, ObjectData, Reference, RevisionIndex, document::Document}; +use crate::{ + ExGuid, FileDataReference, ObjectData, Reference, RevisionIndex, Transaction, + document::Document, + op::{Edit, Op, OpError}, +}; use std::{ collections::{BTreeMap, BTreeSet}, fmt, @@ -107,7 +111,18 @@ pub struct UnlockedSection<'a> { impl<'a> UnlockedSection<'a> { /// Verifies the password and every labeled revision before exposing a view. /// Metadata and password input are each bounded to 64 KiB. - pub fn open(index: &'a RevisionIndex<'a>, password: &str, mut limits: Limits) -> Result { + pub fn open(index: &'a RevisionIndex<'a>, password: &str, limits: Limits) -> Result { + Self::unlock(index, limits, |metadata, rounds| { + crypto::Key::open(metadata, password, rounds) + }) + } + + /// `open` with each distinct encryption metadata's key from `key`. + fn unlock( + index: &'a RevisionIndex<'a>, + mut limits: Limits, + mut key: impl FnMut(&[u8], &mut u64) -> Result, + ) -> Result { if index.store.header.file_type != crate::FileType::Section { return Err(Error::Unsupported); } @@ -163,10 +178,7 @@ impl<'a> UnlockedSection<'a> { let metadata = metadata.ok_or_else(|| invalid("Protected object space has no revision"))?; if !keys.contains_key(metadata) { - keys.insert( - metadata, - crypto::Key::open(metadata, password, &mut limits.kdf_rounds)?, - ); + keys.insert(metadata, key(metadata, &mut limits.kdf_rounds)?); } let key = &keys[metadata]; for rid in space.labels.values().copied().collect::>() { @@ -294,8 +306,10 @@ impl UnlockedSection<'_> { use crate::write::{PropertyObject, RevisionEdit, append_revisions}; let copy = |space: ExGuid, revision: ExGuid| { let revision = self.resolve(space, revision)?; + // A revision still declares objects its edits detached; a new one holds live ones. + let live = revision.reachable()?; let mut objects = BTreeMap::new(); - for (id, object) in &revision.objects { + for (id, object) in revision.objects.iter().filter(|(id, _)| live.contains(id)) { let copy = match object.data { ObjectData::File { reference, @@ -390,60 +404,66 @@ impl UnlockedSection<'_> { } } -/// An edited page of a protected section as one stored revision per changed space, the -/// protected counterpart of [`crate::PreparedEdit::page`]. -pub(crate) fn write_page( - source: &[u8], - password: &str, - space: ExGuid, - page: &crate::page::Page, - author: &str, -) -> Result> { - let store = crate::Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - let unlocked = UnlockedSection::open(&index, password, Limits::default())?; - if unlocked.keys.len() != 1 { - return Err(Error::Unsupported); - } - let twin = unlocked.twin()?; - let applied = Zeroizing::new(crate::page::write::write_page(&twin, space, page, author)?); - let applied_store = crate::Store::parse(&applied)?; - let applied_index = RevisionIndex::parse(&applied_store)?; - let twin_store = crate::Store::parse(&twin)?; - if RevisionIndex::parse(&twin_store)? - .spaces - .keys() - .ne(applied_index.spaces.keys()) - { - return Err(invalid("Page edits cannot create object spaces")); - } - // The twin's scaffold spaces are not the section's. - let mut edited = Vec::new(); - for (sid, space) in &index.spaces { - let rid = applied_index.active(*sid)?; - if space.labels.get(&(ExGuid::default(), 1)) != Some(&rid) { - edited.push((*sid, applied_index.resolve(*sid, rid)?)); +impl UnlockedSection<'_> { + /// Applies `edit` to this section as `Section::apply` applies it to an ordinary one and + /// seals the result as one revision per changed space, stored under the section's key. + /// Page ops only: a protected section's page list is not edited. + pub fn apply(&self, author: &str, edit: &Edit) -> std::result::Result { + let failed = |error: crate::Error| OpError::Failed(error); + if self.keys.len() != 1 { + return Err(OpError::Unsupported( + "Only a section under one key takes edits", + )); + } + if edit.ops.iter().any(|op| matches!(op, Op::Section(_))) { + return Err(OpError::Unsupported("A protected section's page list is not edited")); } + let twin = self.twin().map_err(failed)?; + let arena = crate::Arena::default(); + let mut section = crate::Section::open(&arena, twin.to_vec()).map_err(failed)?; + section.apply(author, edit)?; + section.seal().map_err(failed)?; + let applied = Zeroizing::new(section.image()); + drop(section); + let applied_store = crate::Store::parse(&applied).map_err(failed)?; + let applied_index = RevisionIndex::parse(&applied_store).map_err(failed)?; + // The twin's scaffold spaces are not the section's. + let edited = self + .index + .spaces + .keys() + .map(|space| Ok((*space, applied_index.resolve_active(*space)?))) + .collect::, crate::Error>>() + .map_err(failed)?; + let edited: Vec<_> = edited.iter().map(|(space, after)| (*space, after)).collect(); + let payloads = crate::write::declared_payloads(&applied_store) + .into_iter() + .map(|guid| Ok((guid, applied_store.file_data(guid)?))) + .collect::, crate::Error>>() + .map_err(failed)?; + let transaction = crate::write::squash(self.index, &edited, &payloads, Some(self)) + .map_err(failed)? + .ok_or(OpError::Unsupported("The edit changes nothing"))?; + let written = crate::write::applied(self.index.store.data, Some(&transaction)) + .map_err(failed)?; + let store = crate::Store::parse(&written).map_err(failed)?; + let index = RevisionIndex::parse(&store).map_err(failed)?; + UnlockedSection::unlock(&index, Limits::default(), |metadata, _| { + self.keys.get(metadata).cloned().ok_or(Error::PasswordMismatch) + }) + .map_err(|error| match error { + Error::Invalid(error) => failed(error), + _ => failed(crate::Error { + offset: 0, + message: "The written section does not unlock under its key", + }), + })?; + Ok(transaction) } - let edited: Vec<_> = edited.iter().map(|(sid, after)| (*sid, after)).collect(); - let payloads = crate::page::write::declared_payloads(&applied_store) - .into_iter() - .map(|guid| Ok((guid, applied_store.file_data(guid)?))) - .collect::, crate::Error>>()?; - let written = crate::page::write::squash( - &index, - &edited, - &payloads, - &BTreeMap::new(), - Some(&unlocked), - )?; - let store = crate::Store::parse(&written)?; - UnlockedSection::open(&RevisionIndex::parse(&store)?, password, Limits::default())?; - Ok(written) } impl UnlockedSection<'_> { - /// The section key; `write_page` admits sections with exactly one. + /// The section key; `apply` admits sections with exactly one. fn key(&self) -> &crypto::Key { self.keys.values().next().expect("one section key") } diff --git a/crates/onestore/src/section.rs b/crates/onestore/src/section.rs index f0892fb238f7fc462d88948b5d5114e1d11da097..7588ac5631b6cd8d064121e160ff21d50d3af7e7 100644 --- a/crates/onestore/src/section.rs +++ b/crates/onestore/src/section.rs @@ -226,15 +226,108 @@ impl<'a> Section<'a> { } } + /// The conflict pages of each page that has them, in section order, each page's in the + /// order OneNote 2010 lists them under it: the last stored first. Read from the pages' + /// manifests and the conflict pages' metadata alone. + pub fn conflicts(&mut self) -> Result)>> { + let listed: Vec = self.pages()?.into_iter().map(|(space, ..)| space).collect(); + let element = |object: &crate::Object<'a>| { + crate::document::Element::parse_with(object, FileType::Section, &mut |_| { + Err(Error { + offset: 0, + message: "Page metadata holds no payload", + }) + }) + }; + let mut conflicts = Vec::new(); + for page in listed { + let revision = self.revision(page)?; + let Some(manifest) = revision + .roots + .get(&1) + .and_then(|id| revision.objects.get(id)) + else { + continue; + }; + let mut pages = Vec::new(); + for space in element(manifest)?.spaces.into_iter().rev() { + let Some(metadata) = self.revision(space).ok().and_then(|revision| { + revision + .roots + .get(&2) + .and_then(|id| revision.objects.get(id)) + }) else { + continue; + }; + let metadata = element(metadata)?; + let crate::document::Kind::ConflictMetadata { title, author, .. } = metadata.kind + else { + continue; + }; + let created = metadata.extra[0] + .iter() + .find_map(|field| match field.value { + crate::document::FieldValue::Bytes(bytes) if field.id == 0x18001c65 => { + bytes.try_into().ok().map(u64::from_le_bytes) + } + _ => None, + }); + let mut objects = Vec::new(); + for (id, object) in &self.revision(space)?.objects { + let ObjectData::Properties(bytes) = object.data else { + continue; + }; + if crate::PropertySets::parse(bytes)?.sets[0] + .iter() + .any(|property| property.id == 0x88001d96) + { + objects.push(*id); + } + } + pages.push(crate::ConflictPage { + space, + title: title.unwrap_or_default(), + user: author.unwrap_or_default(), + created, + objects, + }); + } + if !pages.is_empty() { + conflicts.push((page, pages)); + } + } + Ok(conflicts) + } + + /// The page series holding each listed page. Moving a page gives it a series of its + /// own, so a page in another series than before was moved. + pub fn series(&mut self) -> Result> { + let view = &self.active(self.root)?.view; + let section = view.roots.get(&1).and_then(|id| view.nodes.get(id)).ok_or(Error { + offset: 0, + message: "Section root is unavailable", + })?; + Ok(section + .children + .iter() + .filter_map(|series| Some((*series, view.nodes.get(series)?))) + .flat_map(|(series, node)| node.spaces.iter().map(move |page| (*page, series))) + .collect()) + } + /// Page spaces in section order with the title and outline level (1 at the top) the /// page list shows, read from each page's metadata alone. pub fn pages(&mut self) -> Result> { let root = self.root; let view = &self.active(root)?.view; - let section = view.roots.get(&1).and_then(|id| view.nodes.get(id)).ok_or(Error { - offset: 0, - message: "Section root is unavailable", - })?; + let section = view + .roots + .get(&1) + .and_then(|id| view.nodes.get(id)) + .ok_or(Error { + offset: 0, + message: "Section root is unavailable", + })?; let spaces: Vec = section .children .iter() @@ -253,7 +346,10 @@ impl<'a> Section<'a> { for space in spaces { let revision = self.revision(space)?; let (mut title, mut level) = (None, 1); - if let Some(metadata) = revision.roots.get(&2).and_then(|id| revision.objects.get(id)) + if let Some(metadata) = revision + .roots + .get(&2) + .and_then(|id| revision.objects.get(id)) && let crate::document::Kind::Metadata { title: stored, level: stored_level, @@ -263,7 +359,10 @@ impl<'a> Section<'a> { level = stored_level.unwrap_or(1); } if title.is_none() { - let manifest = revision.roots.get(&1).and_then(|id| revision.objects.get(id)); + let manifest = revision + .roots + .get(&1) + .and_then(|id| revision.objects.get(id)); if let Some(manifest) = manifest && let Some(page) = element(manifest)? .content @@ -695,6 +794,17 @@ mod tests { result } + /// The image the image writer leaves: the `edited` revisions squashed onto the source + /// `index` parsed. + fn squashed( + index: &RevisionIndex<'_>, + edited: &[(ExGuid, &ResolvedRevision<'_>)], + payloads: &[([u8; 16], &[u8])], + ) -> Vec { + let transaction = crate::write::squash(index, edited, payloads, None).unwrap(); + crate::write::applied(index.store.data, transaction.as_ref()).unwrap() + } + /// The page spaces of an image, fullest first. fn page_spaces(image: &[u8]) -> Vec { let store = Store::parse(image).unwrap(); @@ -729,65 +839,62 @@ mod tests { GUIDS.set(Some(1 << 62 | seed << 40)); let (mut sealed, mut checkpoints) = (0, 0); for batch in 0..batches { - let space = spaces[batch % spaces.len().min(pages)]; - let store = Store::parse(&image).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let bump = Bump::new(); - let mut legacy = ActivePage::parse(&index, space).unwrap(); - for _ in 0..1 + (writes.0 as usize >> 40) % largest { - let Ok(changes) = writes.next(section.active(space).unwrap()) else { - continue; - }; - let stored = legacy.write(&bump, &[], changes.clone()).unwrap(); - assert_eq!( - section.apply_changes(space, &[], changes).unwrap(), - stored, - "batch {batch}" - ); - } - let guids = (batch as u64 + 1) << 32; - let expected = seeded(guids, || { - crate::page::write::squash( - &index, - &[(space, &legacy.live.revision)], - &legacy.payloads, - &BTreeMap::new(), - None, - ) - .unwrap() - }); - let before = section.stamp().clone(); - let chains = depths(§ion); - let transaction = seeded(guids, || section.seal().unwrap()); - checkpoints += depths(§ion) - .iter() - .filter(|(id, depth)| chains.get(id).is_some_and(|before| before > depth)) - .count(); - let mut written = image.clone(); - if let Some(transaction) = &transaction { - assert_eq!(transaction.base, before); - transaction.apply(&mut written).unwrap(); - sealed += 1; - } - assert!(written == expected, "batch {batch}"); - assert_eq!(Stamp::of(&written).unwrap(), *section.stamp()); - drop(legacy); - drop(index); - drop(store); - image = expected; - if batch % 25 == 0 || batch + 1 == batches { - assert!(section.image() == image); + // The writers stamp modification times; a fixed clock keeps the batches alike. + let at = 134_000_000_000_000_000 + batch as u64 * 10_000_000; + crate::create::at(at, || { + let space = spaces[batch % spaces.len().min(pages)]; let store = Store::parse(&image).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); - index.validate_current().unwrap(); - let document = Document::parse(&index).unwrap(); - for space in &spaces { + let bump = Bump::new(); + let mut legacy = ActivePage::parse(&index, space).unwrap(); + for _ in 0..1 + (writes.0 as usize >> 40) % largest { + let Ok(changes) = writes.next(section.active(space).unwrap()) else { + continue; + }; + let stored = legacy.write(&bump, &[], changes.clone()).unwrap(); assert_eq!( - section.page(*space).unwrap(), - Page::from_space(&document, *space).unwrap() + section.apply_changes(space, &[], changes).unwrap(), + stored, + "batch {batch}" ); } - } + let guids = (batch as u64 + 1) << 32; + let expected = seeded(guids, || { + squashed(&index, &[(space, &legacy.live.revision)], &legacy.payloads) + }); + let before = section.stamp().clone(); + let chains = depths(§ion); + let transaction = seeded(guids, || section.seal().unwrap()); + checkpoints += depths(§ion) + .iter() + .filter(|(id, depth)| chains.get(id).is_some_and(|before| before > depth)) + .count(); + let mut written = image.clone(); + if let Some(transaction) = &transaction { + assert_eq!(transaction.base, before); + transaction.apply(&mut written).unwrap(); + sealed += 1; + } + assert!(written == expected, "batch {batch}"); + assert_eq!(Stamp::of(&written).unwrap(), *section.stamp()); + drop(legacy); + drop(index); + drop(store); + image = expected; + if batch % 25 == 0 || batch + 1 == batches { + assert!(section.image() == image); + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + let document = Document::parse(&index).unwrap(); + for space in &spaces { + assert_eq!( + section.page(*space).unwrap(), + Page::from_space(&document, *space).unwrap() + ); + } + } + }); } GUIDS.set(None); assert!(sealed * 3 > batches, "{sealed} of {batches} batches sealed"); @@ -932,14 +1039,7 @@ mod tests { legacy.write(&bump, &[], changes).unwrap(); let expected = seeded(guids, || { let revision = &legacy.live.revision; - crate::page::write::squash( - &index, - &[(space, revision)], - &[], - &BTreeMap::new(), - None, - ) - .unwrap() + squashed(&index, &[(space, revision)], &[]) }); drop(legacy); drop(index); @@ -971,14 +1071,7 @@ mod tests { let legacy = ActivePage::parse(&index, space).unwrap(); let expected = seeded(seed, || { let revision = &legacy.live.revision; - crate::page::write::squash( - &index, - &[(space, revision)], - &[payload], - &BTreeMap::new(), - None, - ) - .unwrap() + squashed(&index, &[(space, revision)], &[payload]) }); let transaction = seeded(seed, || section.seal().unwrap()); assert_eq!(transaction.is_some(), expected != image); @@ -1036,8 +1129,12 @@ mod tests { && page.parents.contains_key(id) }) .find_map(|(outline, _)| { + let paragraph = crate::page::text::new_id().ok()?; + let text = crate::page::text::new_id().ok()?; Insertion::paragraph(*outline, None, "Tampered text", "Author") - .and_then(|insertion| insertion.changes(page)) + .and_then(|insertion| { + insertion.changes_as(page, paragraph, paragraph, text) + }) .ok() }) .unwrap(); @@ -1127,8 +1224,7 @@ mod probe { (space, texts[texts.len() / 2], texts.len()) } - /// One-character edits of a large page through the image writer and through a - /// `Section`: `SECTION_PROBE=path cargo test --release -p onestore --lib probe -- --ignored --nocapture`. + /// One-character edits of a large page through a `Section`: `SECTION_PROBE=path cargo test --release -p onestore --lib probe -- --ignored --nocapture`. #[test] #[ignore] fn keystrokes() { @@ -1140,57 +1236,6 @@ mod probe { image.len() ); - let mut samples = Vec::new(); - let mut written = 0; - for _ in 0..9 { - let start = Instant::now(); - let edit = crate::PreparedEdit::text(&image, space, text, 0..0, "x").unwrap(); - samples.push(start.elapsed()); - let transaction = edit.transaction(); - written = transaction.append.len() - + transaction - .patches - .iter() - .map(|(_, bytes)| bytes.len()) - .sum::(); - } - println!( - "image writer, one keystroke: {:?}, {written} bytes written", - median(samples) - ); - - let before = { - let store = Store::parse(&image).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - Page::from_space(&crate::document::Document::parse(&index).unwrap(), space).unwrap() - }; - let mut after = before.clone(); - let edited = after - .objects - .iter_mut() - .filter_map(|object| match object { - crate::page::PageObject::Outline(outline) => Some(outline), - _ => None, - }) - .flat_map(|outline| &mut outline.paragraphs) - .find_map(|paragraph| paragraph.text_mut().filter(|object| object.id == text)) - .unwrap(); - let format = edited.text.format_at(0).unwrap().clone(); - edited - .text - .apply(crate::page::text::Edit { - range: 0..0, - replacement: crate::page::Paragraph::new("x".into(), format), - }) - .unwrap(); - let mut samples = Vec::new(); - for _ in 0..9 { - let start = Instant::now(); - crate::PreparedEdit::page(&image, space, &after, "Probe").unwrap(); - samples.push(start.elapsed()); - } - println!("page-model writer, one keystroke: {:?}", median(samples)); - let arena = Arena::default(); let start = Instant::now(); let mut section = Section::open(&arena, image.clone()).unwrap(); diff --git a/crates/onestore/src/toc.rs b/crates/onestore/src/toc.rs index 68db7ee3ff4f7dfdcb6d6e08fc7346a4dbe1bdb7..72c120ec39e40ddfeb012d6d0edb60d9cff1eb15 100644 --- a/crates/onestore/src/toc.rs +++ b/crates/onestore/src/toc.rs @@ -1,14 +1,15 @@ //! Edits to a notebook's table of contents: the documented `jcidPersistablePropertyContainerForTOC` -//! root lists section and section-group entries, each carrying a file identity, an ordering -//! number, a filename and (for sections) a colour. +//! root carries the notebook's colour and lists section and section-group entries, each +//! carrying a file identity, an ordering number and a filename. A section's colour lives in +//! its own metadata (`op::SectionOp::Color`); its entry keeps 0xffffffff, as OneNote writes. use crate::{ - Error, ExGuid, PropertySets, Store, Value, + Error, ExGuid, PropertySets, Store, Transaction, Value, document::{Document, Kind}, revisions::RevisionIndex, - write::{PropertyObject, fresh_guid, write_revision}, + write::{PropertyObject, RevisionEdit, applied, build_on, check, fresh_guid}, }; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; fn invalid(message: &'static str) -> Error { Error { offset: 0, message } @@ -27,11 +28,8 @@ pub enum TocEdit { identity: [u8; 16], filename: String, }, - /// Section colour as COLORREF; `None` restores OneNote's "undefined" 0xffffffff. - Color { - identity: [u8; 16], - color: Option, - }, + /// The notebook's colour as COLORREF. + Color(u32), /// Every entry, in the wanted order; entries left out keep their relative order after these. Order(Vec<[u8; 16]>), Remove { @@ -43,15 +41,19 @@ fn component(name: &str) -> bool { !name.is_empty() && !name.contains(['/', '\\', '\0']) && name != "." && name != ".." } -/// The table of contents with `edits` applied in order. -type PropertyChange = (u32, Option>); - -pub(crate) fn edit_table_of_contents(source: &[u8], edits: &[TocEdit]) -> Result, Error> { +/// Applies table-of-contents edits (sections and section groups: add, rename, order, +/// remove; the notebook's colour) in order as one revision of a `.onetoc2` file; none when they change +/// nothing. +pub fn edit_table_of_contents( + source: &[u8], + edits: &[TocEdit], +) -> Result, Error> { let store = Store::parse(source)?; if store.header.file_type != crate::FileType::TableOfContents { return Err(invalid("Choose a table-of-contents file")); } let index = RevisionIndex::parse(&store)?; + index.validate_current()?; let document = Document::parse(&index)?; let space = document.root; let revision = document.active(space)?; @@ -76,8 +78,8 @@ pub(crate) fn edit_table_of_contents(source: &[u8], edits: &[TocEdit]) -> Result listed.push((*id, *identity, filename.clone().unwrap_or_default())); } let mut created: BTreeMap = BTreeMap::new(); - // Property values to set (or remove, `None`) on each existing entry. - let mut changes: BTreeMap> = BTreeMap::new(); + let mut renamed = BTreeSet::new(); + let mut color = None; for edit in edits { let position = |identity: &[u8; 16]| { listed @@ -143,18 +145,9 @@ pub(crate) fn edit_table_of_contents(source: &[u8], edits: &[TocEdit]) -> Result } let id = listed[at].0; listed[at].2 = filename.clone(); - changes - .entry(id) - .or_default() - .push((0x1c001d6b, Some(crate::create::string(filename)))); - } - TocEdit::Color { identity, color } => { - let id = listed[position(identity)?].0; - changes.entry(id).or_default().push(( - 0x14001cbe, - Some(color.unwrap_or(0xffff_ffff).to_le_bytes().to_vec()), - )); + renamed.insert(id); } + TocEdit::Color(value) => color = Some(*value), TocEdit::Order(wanted) => { let mut ordered: Vec<(ExGuid, [u8; 16], String)> = Vec::new(); for identity in wanted { @@ -175,17 +168,17 @@ pub(crate) fn edit_table_of_contents(source: &[u8], edits: &[TocEdit]) -> Result TocEdit::Remove { identity } => { let at = position(identity)?; let (id, _, _) = listed.remove(at); - changes.remove(&id); created.remove(&id); } } } let listed = listed; - write_revision(source, space, |raw| { + let transaction = build_on(&index, &[], None, |index| { + let raw = index.resolve_active(space)?; let mut changed = BTreeMap::new(); let mut root_object = PropertyObject::from_object(&raw.objects[&root])?; let mut references = Vec::new(); - for (order, (id, _, _)) in listed.iter().enumerate() { + for (order, (id, _, filename)) in listed.iter().enumerate() { let mut object = match created.remove(id) { Some(object) => object, None => PropertyObject::from_object(&raw.objects[id])?, @@ -201,11 +194,8 @@ pub(crate) fn edit_table_of_contents(source: &[u8], edits: &[TocEdit]) -> Result if stored_order != Some(order as u32 + 1) { updates.push((0x14001cb9, (order as u32 + 1).to_le_bytes().to_vec())); } - for (property, value) in changes.get(id).into_iter().flatten() { - match value { - Some(bytes) => updates.push((*property, bytes.clone())), - None => object.remove(&[*property])?, - } + if renamed.contains(id) { + updates.push((0x1c001d6b, crate::create::string(filename))); } let updates: Vec<(u32, &[u8])> = updates .iter() @@ -220,7 +210,12 @@ pub(crate) fn edit_table_of_contents(source: &[u8], edits: &[TocEdit]) -> Result } } root_object.set(&[(0x24001cf6, &references)])?; + if let Some(color) = color { + root_object.set(&[(0x14001cbe, &color.to_le_bytes())])?; + } changed.insert(root, root_object); - Ok(changed) - }) + Ok(BTreeMap::from([(space, RevisionEdit::Update(changed))])) + })?; + check(&applied(source, transaction.as_ref())?, true)?; + Ok(transaction) } diff --git a/crates/onestore/src/tree.rs b/crates/onestore/src/tree.rs index 577dc9d74f9959421e2ff32b6ed61e1c320b87ad..8b1d66d7c86f9008fecbb7e207c64894fda92e64 100644 --- a/crates/onestore/src/tree.rs +++ b/crates/onestore/src/tree.rs @@ -1,12 +1,11 @@ use crate::{ Error, ExGuid, active::{ActivePage, Changes}, - create::{current_timestamps, properties, string}, + create::{current_timestamps, properties}, document::{Kind, Revision}, edit::{editable_parents, update_title}, write::{PropertyObject, fresh_guid}, }; -use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, BTreeSet}, sync::Arc, @@ -16,8 +15,7 @@ fn invalid(message: &'static str) -> Error { Error { offset: 0, message } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone, PartialEq)] enum Placement { Delete, Move { @@ -27,40 +25,25 @@ enum Placement { } /// An atomic subtree move or deletion on one active page. -/// Retain the intent across retries so an emptied cell's replacement keeps its identity. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct TreeEdit { +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct TreeEdit { guid: [u8; 16], object: ExGuid, placement: Placement, author: String, - created: u32, } impl TreeEdit { - pub fn object(&self) -> ExGuid { - self.object - } - - /// Move destination and direct sibling anchor; deletion has no destination. - pub fn destination(&self) -> Option<(ExGuid, Option)> { - match self.placement { - Placement::Delete => None, - Placement::Move { parent, before } => Some((parent, before)), - } - } - /// Removes a paragraph or ordinary outline and its descendants from the active tree. - /// Historical objects remain available; an emptied table cell receives an empty paragraph. - pub fn delete(object: ExGuid, author: &str) -> Result { + /// Historical objects remain available. + pub(crate) fn delete(object: ExGuid, author: &str) -> Result { Self::new(object, Placement::Delete, author) } /// Moves before a direct child, or appends when `before` is None. /// Paragraph destinations are outlines, groups, paragraphs or cells on the same page. /// Outlines remain direct page children. Content and explicit list formatting are preserved. - pub fn move_to( + pub(crate) fn move_to( object: ExGuid, parent: ExGuid, before: Option, @@ -75,7 +58,6 @@ impl TreeEdit { object, placement, author: author.to_owned(), - created: current_timestamps()?.0, }; intent.validate()?; Ok(intent) @@ -95,11 +77,6 @@ impl TreeEdit { Ok(()) } - pub(crate) fn apply(&self, source: &[u8], space: ExGuid) -> Result, Error> { - self.validate()?; - crate::active::write(source, space, |active| self.changes(active)) - } - pub(crate) fn changes(&self, active: &ActivePage<'_>) -> Result { let pages = &active.pages; let [page] = pages.as_slice() else { @@ -164,7 +141,6 @@ impl TreeEdit { .retain(|id| *id != self.object); } - let mut empty_cell = None; let mut at = source_parent; loop { let node = &view.nodes[&at]; @@ -174,8 +150,8 @@ impl TreeEdit { break; } if node.children.is_empty() { + // `Section::apply` refuses an edit that ends with the cell still empty. if matches!(node.kind, Kind::Cell { .. }) { - empty_cell = Some(at); break; } if !matches!(node.kind, Kind::Outline { .. } | Kind::OutlineGroup) { @@ -278,53 +254,14 @@ impl TreeEdit { guid: self.guid, n: 3, }; - if let Some(cell) = empty_cell { - let paragraph = ExGuid { - guid: self.guid, - n: 1, - }; - let text = ExGuid { - guid: self.guid, - n: 2, - }; - view.nodes.get_mut(&cell).unwrap().children.push(paragraph); - for (id, jcid, values) in [ - ( - paragraph, - 0x6000d, - vec![ - (0x24001c1f, 2_u32.to_le_bytes().to_vec()), - (0x0c001c03, vec![1]), - (0x14001d09, self.created.to_le_bytes().to_vec()), - (0x14001d7a, modified.to_vec()), - (0x20001d78, 3_u32.to_le_bytes().to_vec()), - (0x20001d79, 3_u32.to_le_bytes().to_vec()), - ], - ), - ( - text, - 0x6000e, - vec![(0x1c001c22, Vec::new()), (0x14001d7a, modified.to_vec())], - ), - ] { - changed.insert( - id, - PropertyObject { - jcid, - bytes: properties(&values)?, - global_ids: Arc::new(BTreeMap::from([(0, self.guid)])), - }, - ); - } - } let moved_paragraph = matches!(self.placement, Placement::Move { .. }) && matches!(view.nodes[&self.object].kind, Kind::Paragraph { .. }); - if empty_cell.is_some() || moved_paragraph { + if moved_paragraph { changed.insert( author_id, PropertyObject { jcid: 0x120001, - bytes: properties(&[(0x1c001d75, string(&self.author))])?, + bytes: properties(&crate::create::author_properties(&self.author))?, global_ids: Arc::new(BTreeMap::from([(0, self.guid)])), }, ); @@ -413,47 +350,15 @@ fn checked_path( #[cfg(test)] mod tests { use super::*; - use crate::write::write_revision; - use crate::{Insertion, PreparedEdit, RevisionIndex, Store, document::Document}; - - #[test] - fn a_retained_move_intent_can_reuse_its_immutable_author_after_another_move() { - let source = crate::create_section("move.one", "First", "Author").unwrap(); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let (sid, page) = document.pages().unwrap()[0]; - let view = document.active(sid).unwrap(); - let outline = *view.nodes[&page] - .children - .iter() - .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) - .unwrap(); - let first = view.nodes[&outline].children[0]; - let second = Insertion::paragraph(outline, None, "Second", "Author").unwrap(); - let source = PreparedEdit::insert(&source, sid, &second) - .unwrap() - .as_bytes() - .to_vec(); - let retained = TreeEdit::move_to(first, outline, None, "Tree author").unwrap(); - let moved = PreparedEdit::tree(&source, sid, &retained).unwrap(); - let reversed = - TreeEdit::move_to(first, outline, Some(second.object()), "Tree author").unwrap(); - let reversed = PreparedEdit::tree(moved.as_bytes(), sid, &reversed).unwrap(); - let repeated = PreparedEdit::tree(reversed.as_bytes(), sid, &retained).unwrap(); - let store = Store::parse(repeated.as_bytes()).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let view = document.active(sid).unwrap(); - assert_eq!(view.nodes[&outline].children, [second.object(), first]); - assert_eq!( - view.nodes[&first].latest_author, - Some(ExGuid { - guid: retained.guid, - n: 3 - }) - ); - } + use crate::{ + RevisionIndex, Store, + document::{Document, Format}, + op::{ + PageOp, + tests::{edited, text_paragraph}, + }, + write::write_revision, + }; #[test] fn group_normalization_preserves_unequal_indentation_and_overlapping_moves() { @@ -469,22 +374,23 @@ mod tests { .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) .unwrap(); let first = view.nodes[&outline].children[0]; - let second = Insertion::paragraph(outline, None, "Second", "Author").unwrap(); - let third = Insertion::paragraph(outline, None, "Third", "Author").unwrap(); - let source = PreparedEdit::insert(&source, sid, &second) - .unwrap() - .as_bytes() - .to_vec(); - let source = PreparedEdit::insert(&source, sid, &third) - .unwrap() - .as_bytes() - .to_vec(); + let paragraphs = vec![ + text_paragraph("Second", Format::default()), + text_paragraph("Third", Format::default()), + ]; + let (second, third) = (paragraphs[0].id, paragraphs[1].id); + let insert = PageOp::Insert { + container: outline, + before: None, + paragraphs, + }; + let source = edited(&source, sid, vec![insert]).unwrap(); let guid = fresh_guid().unwrap(); let a = ExGuid { guid, n: 1 }; let b = ExGuid { guid, n: 2 }; let source = write_revision(&source, sid, |raw| { let mut changed = BTreeMap::new(); - for (id, child, level) in [(a, first, 2), (b, second.object(), 1)] { + for (id, child, level) in [(a, first, 2), (b, second, 1)] { let mut group = PropertyObject { jcid: 0x60019, bytes: properties(&[(0x0c001c03, vec![level])])?, @@ -496,7 +402,7 @@ mod tests { } let mut object = PropertyObject::from_object(&raw.objects[&outline])?; let mut references = Vec::new(); - for id in [a, b, third.object()] { + for id in [a, b, third] { references.extend_from_slice(&object.reference(id)?); } object.set(&[(0x24001c20, &references)])?; @@ -504,38 +410,37 @@ mod tests { Ok(changed) }) .unwrap(); - for moving in [false, true] { - let intent = if moving { - TreeEdit::move_to(third.object(), a, Some(first), "Author") - } else { - TreeEdit::delete(third.object(), "Author") - } - .unwrap(); - let edit = PreparedEdit::tree(&source, sid, &intent).unwrap(); - let store = Store::parse(edit.as_bytes()).unwrap(); + let page = |image: &[u8]| { + let store = Store::parse(image).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); index.validate_current().unwrap(); - let document = Document::parse(&index).unwrap(); - let view = document.active(sid).unwrap(); - assert_eq!(view.nodes[&outline].children, [a, second.object()]); - assert_eq!(view.nodes[&outline].child_level, Some(2)); - assert_eq!(view.nodes[&a].child_level, Some(1)); - assert_eq!( - view.nodes[&a].children, - if moving { - vec![third.object(), first] - } else { - vec![first] - } - ); - if !moving && let Some(output) = std::env::var_os("ONESTORE_TREE_OUTPUT") { - let output = std::path::PathBuf::from(output).join("unequal-groups"); - assert!(output.is_absolute()); - std::fs::create_dir_all(output.parent().unwrap()).unwrap(); - std::fs::create_dir(&output).unwrap(); - std::fs::write(output.join("groups.one"), edit.as_bytes()).unwrap(); - } + crate::page::Page::from_space(&Document::parse(&index).unwrap(), sid).unwrap() + }; + let written = edited(&source, sid, vec![PageOp::Delete { object: third }]).unwrap(); + let store = Store::parse(&written).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let view = document.active(sid).unwrap(); + assert_eq!(view.nodes[&outline].children, [a, second]); + assert_eq!(view.nodes[&outline].child_level, Some(2)); + assert_eq!(view.nodes[&a].child_level, Some(1)); + assert_eq!(view.nodes[&a].children, [first]); + if let Some(output) = std::env::var_os("ONESTORE_TREE_OUTPUT") { + let output = std::path::PathBuf::from(output).join("unequal-groups"); + assert!(output.is_absolute()); + std::fs::create_dir_all(output.parent().unwrap()).unwrap(); + std::fs::create_dir(&output).unwrap(); + std::fs::write(output.join("groups.one"), &written).unwrap(); } + // A move keeps the paragraph's level; the writers regroup the outline around it. + let moved = PageOp::Move { + object: third, + parent: Some(outline), + before: Some(first), + }; + let mut predicted = page(&source); + crate::op::predict(&mut predicted, &moved).unwrap(); + assert_eq!(page(&edited(&source, sid, vec![moved]).unwrap()), predicted); } #[test] @@ -553,7 +458,9 @@ mod tests { .unwrap(); let paragraph = view.nodes[&outline].children[0]; let text = view.nodes[¶graph].content[0]; - let deletion = TreeEdit::delete(paragraph, "Author").unwrap(); + let deleted = |bytes: &[u8]| { + edited(bytes, sid, vec![PageOp::Delete { object: paragraph }]).is_ok() + }; for target in [page, outline, paragraph, text] { for property in [0x08001cde, 0x08001cb4, 0x08001cf9, 0x08001cb2] { for enabled in [false, true] { @@ -563,11 +470,7 @@ mod tests { Ok(BTreeMap::from([(target, object)])) }) .unwrap(); - assert_eq!( - PreparedEdit::tree(&bytes, sid, &deletion).is_err(), - enabled, - "{target}: {property:#x}" - ); + assert_eq!(deleted(&bytes), !enabled, "{target}: {property:#x}"); } } } @@ -578,7 +481,7 @@ mod tests { Ok(BTreeMap::from([(protected, object)])) }) .unwrap(); - assert!(PreparedEdit::tree(&bytes, sid, &deletion).is_err()); + assert!(!deleted(&bytes)); } let bytes = write_revision(&source, sid, |raw| { let mut object = PropertyObject::from_object(&raw.objects[&outline])?; @@ -587,10 +490,6 @@ mod tests { Ok(BTreeMap::from([(outline, object)])) }) .unwrap(); - assert!(PreparedEdit::tree(&bytes, sid, &deletion).is_err()); - let mut value = serde_json::to_value(&deletion).unwrap(); - value["guid"] = serde_json::to_value([0_u8; 16]).unwrap(); - let restored = serde_json::from_value(value).unwrap(); - assert!(PreparedEdit::tree(&source, sid, &restored).is_err()); + assert!(!deleted(&bytes)); } } diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs index 69f67f124e1242bb8fb39f3616abee8fecbca39b..fd5d62e231a8aa97f353d618bf16c049e483de34 100644 --- a/crates/onestore/src/write.rs +++ b/crates/onestore/src/write.rs @@ -323,45 +323,6 @@ pub(crate) struct ObjectEdit<'a> { pub inserts: &'a [(u32, &'a [u8])], } -/// Replaces a root-level scalar byte property in the default active revision. -/// The caller supplies its encoded value and is responsible for MS-ONE semantics. -/// Returns a complete file image without I/O; use `commit_file_property` to update an existing file. -pub fn replace_property_bytes( - source: &[u8], - space: ExGuid, - object_id: ExGuid, - property: u32, - value: &[u8], -) -> Result> { - if !(3..=7).contains(&((property >> 26) & 31)) { - return Err(Error { - offset: 0, - message: "Property does not contain scalar bytes", - }); - } - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - index.validate_current()?; - replace_objects( - &index, - space, - &[ObjectEdit { - object: object_id, - updates: &[(property, value)], - inserts: &[], - }], - ) -} - -/// Patches objects of a source the caller has parsed and validated. -pub(crate) fn replace_objects( - index: &RevisionIndex<'_>, - space: ExGuid, - edits: &[ObjectEdit<'_>], -) -> Result> { - write_revision_on(index, space, |revision| patched(revision, edits)) -} - /// Objects of `revision` with `edits` applied. pub(crate) fn patched( revision: &crate::ResolvedRevision<'_>, @@ -511,6 +472,7 @@ pub(crate) struct PropertyObject { pub(crate) enum RevisionEdit { Update(BTreeMap), + #[cfg(any(test, feature = "protected"))] Create { roots: BTreeMap, objects: BTreeMap, @@ -1269,24 +1231,18 @@ pub(crate) fn chain_depth(index: &RevisionIndex<'_>, space: ExGuid, rid: ExGuid) .count() } +/// A test fixture: `source` with one revision of `space` storing `edit`'s objects. +#[cfg(test)] pub(crate) fn write_revision( source: &[u8], space: ExGuid, edit: impl FnOnce(&crate::ResolvedRevision<'_>) -> Result>, ) -> Result> { - write_revision_with_payloads(source, space, &[], edit) -} - -pub(crate) fn write_revision_with_payloads( - source: &[u8], - space: ExGuid, - payloads: &[([u8; 16], &[u8])], - edit: impl FnOnce(&crate::ResolvedRevision<'_>) -> Result>, -) -> Result> { - write_revisions_with_payloads(source, payloads, update(space, edit)) + write_revisions(source, update(space, edit)) } /// One space's active revision edited into its update. +#[cfg(test)] fn update( space: ExGuid, edit: impl FnOnce(&crate::ResolvedRevision<'_>) -> Result>, @@ -1300,26 +1256,30 @@ fn update( } } +/// A test fixture: the transaction appending `edit`'s revisions to `source`. +#[cfg(test)] +pub(crate) fn revisions( + source: &[u8], + edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, +) -> Result> { + let store = Store::parse(source)?; + let index = RevisionIndex::parse(&store)?; + index.validate_current()?; + build_on(&index, &[], None, edit) +} + +/// A test fixture: `source` with `edit`'s revisions appended. +#[cfg(test)] pub(crate) fn write_revisions( source: &[u8], edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, ) -> Result> { - write_revisions_with_payloads(source, &[], edit) + publish(source, &[], None, true, edit) } -/// `write_revisions` that also stores embedded payloads: each becomes a file-data store -/// object referenced from the root file node list under its identity, as OneNote embeds -/// pictures and attachments. -pub(crate) fn write_revisions_with_payloads( - source: &[u8], - payloads: &[([u8; 16], &[u8])], - edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, -) -> Result> { - publish(source, payloads, None, true, edit) -} - -/// `write_revisions_with_payloads` without validating that current revisions are -/// complete: a protected section is validated by unlocking it, through `protection`. +/// `source` with `edit`'s revisions and the `payloads` appended, without validating that +/// current revisions are complete: a protected section is validated by unlocking it, +/// through `protection`. #[cfg(feature = "protected")] pub(crate) fn append_revisions( source: &[u8], @@ -1330,6 +1290,7 @@ pub(crate) fn append_revisions( publish(source, payloads, protection, false, edit) } +#[cfg(any(test, feature = "protected"))] fn publish( source: &[u8], payloads: &[([u8; 16], &[u8])], @@ -1355,7 +1316,8 @@ pub(crate) fn check(output: &[u8], validate: bool) -> Result<()> { /// The written image, unchecked: `check` follows once the caller has released whatever /// `edit` borrowed. -pub(crate) fn build( +#[cfg(any(test, feature = "protected"))] +fn build( source: &[u8], payloads: &[([u8; 16], &[u8])], protection: Option<&dyn Protection>, @@ -1367,16 +1329,20 @@ pub(crate) fn build( if validate { index.validate_current()?; } - build_on(&index, payloads, protection, edit) + applied(source, build_on(&index, payloads, protection, edit)?.as_ref()) } /// `write_revision` on a source the caller has parsed and validated. +#[cfg(test)] pub(crate) fn write_revision_on( index: &RevisionIndex<'_>, space: ExGuid, edit: impl FnOnce(&crate::ResolvedRevision<'_>) -> Result>, ) -> Result> { - let output = build_on(index, &[], None, update(space, edit))?; + let output = applied( + index.store.data, + build_on(index, &[], None, update(space, edit))?.as_ref(), + )?; check(&output, true)?; Ok(output) } @@ -1387,12 +1353,14 @@ thread_local! { pub(crate) static BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// The transaction appending `edit`'s revisions and the `payloads` to the parsed store; +/// none when nothing changes. pub(crate) fn build_on( index: &RevisionIndex<'_>, payloads: &[([u8; 16], &[u8])], protection: Option<&dyn Protection>, edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, -) -> Result> { +) -> Result> { #[cfg(test)] BUILDS.with(|builds| builds.set(builds.get() + 1)); let store = index.store; @@ -1411,13 +1379,13 @@ pub(crate) fn build_on( let changes = edit(index)?; let mut appending = Appending::new(store.state()?); for (space, change) in changes { - let new_space = matches!(change, RevisionEdit::Create { .. }); let current = (ExGuid::default(), 1_u32); - let (rid, label, revision, replacements) = match change { + let (rid, label, revision, replacements, new_space) = match change { RevisionEdit::Update(objects) => { let rid = index.active(space)?; - (Some(rid), current, resolve(space, rid)?, objects) + (Some(rid), current, resolve(space, rid)?, objects, false) } + #[cfg(any(test, feature = "protected"))] RevisionEdit::Create { roots, objects } => { if !is_section || space.guid == [0; 16] || index.spaces.contains_key(&space) { return Err(Error { @@ -1439,6 +1407,7 @@ pub(crate) fn build_on( objects: BTreeMap::new(), }, objects, + true, ) } #[cfg(feature = "protected")] @@ -1462,6 +1431,7 @@ pub(crate) fn build_on( objects: BTreeMap::new(), }, objects, + false, ) } }; @@ -1525,13 +1495,78 @@ pub(crate) fn build_on( )?; } appending.payloads(payloads, protection)?; - Ok(match appending.finish()? { - Some((transaction, _)) => { - let mut output = source.to_vec(); - transaction.apply(&mut output)?; - output + Ok(appending.finish()?.map(|(transaction, _)| transaction)) +} + +/// `source` with `transaction` applied. +pub(crate) fn applied(source: &[u8], transaction: Option<&crate::Transaction>) -> Result> { + let mut output = source.to_vec(); + if let Some(transaction) = transaction { + transaction.apply(&mut output)?; + } + Ok(output) +} + +/// Payload identities the file-data store of `store` declares, in order. +#[cfg(any(test, feature = "protected"))] +pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> { + store + .lists + .values() + .flat_map(|list| &list.nodes) + .filter(|node| node.id == 0x94) + .filter_map(|node| node.payload.get(..16).and_then(|g| g.try_into().ok())) + .collect() +} + +/// Writes the live objects of each `edited` revision that differ from its space's active +/// revision in the validated `source` as one revision per space, embedding the `payloads` +/// it lacks. A protected `source` takes the revisions its plaintext twin gained. +#[cfg(any(test, feature = "protected"))] +pub(crate) fn squash( + source: &RevisionIndex<'_>, + edited: &[(ExGuid, &crate::ResolvedRevision<'_>)], + payloads: &[([u8; 16], &[u8])], + protection: Option<&dyn Protection>, +) -> Result> { + let existing = declared_payloads(source.store); + let payloads: Vec<_> = payloads + .iter() + .filter(|(guid, _)| !existing.contains(guid)) + .copied() + .collect(); + build_on(source, &payloads, protection, |index| { + let mut changes = BTreeMap::new(); + for (space, after) in edited { + let before = match protection { + Some(protection) => protection.resolve(*space, index.active(*space)?)?, + None => index.resolve_active(*space)?, + }; + if before.roots != after.roots { + return Err(Error { + offset: 0, + message: "Page edits cannot change revision roots", + }); + } + // Retired styles and deleted content stay in history; only live objects are written. + let live = after.reachable()?; + let mut changed = BTreeMap::new(); + for (id, object) in &after.objects { + if !live.contains(id) + || before + .objects + .get(id) + .map(|previous| unchanged(previous, object)) + .transpose()? + == Some(true) + { + continue; + } + changed.insert(*id, replacement(*id, object)?); + } + changes.insert(*space, RevisionEdit::Update(changed)); } - None => source.to_vec(), + Ok(changes) }) } diff --git a/crates/onestore/src/write/tests.rs b/crates/onestore/src/write/tests.rs index a187c505bfb35df2aefbc374f5d52c877210520f..7f9f138491c89743808db459b9c598f3615d9dfa 100644 --- a/crates/onestore/src/write/tests.rs +++ b/crates/onestore/src/write/tests.rs @@ -72,7 +72,15 @@ fn copying_reference_arrays_remaps_every_entry_in_each_stream() { #[test] fn document_insertions_and_formatting_respect_readonly_ancestors() { use super::{PropertyObject, write_revision}; - use crate::{ExGuid, Insertion, PreparedEdit, RevisionIndex, Store}; + use crate::{ + ExGuid, RevisionIndex, Store, TextAttribute, + document::{Format, Layout}, + op::{ + PageOp, + tests::{edited, text_paragraph}, + }, + page::{Outline, PageObject, text::new_id}, + }; use std::collections::BTreeMap; let source = crate::create_section("readonly.one", "Original", "Author").unwrap(); let store = Store::parse(&source).unwrap(); @@ -97,9 +105,23 @@ fn document_insertions_and_formatting_respect_readonly_ancestors() { )) }) .unwrap(); - let right = Insertion::paragraph(outline, None, "Right", "Author").unwrap(); - let expanded = PreparedEdit::insert(&source, sid, &right).unwrap(); - let source = expanded.as_bytes(); + let right = text_paragraph("Right", Format::default()); + let right_text = right.text().unwrap().id; + let insert = |container, paragraph| PageOp::Insert { + container, + before: None, + paragraphs: vec![paragraph], + }; + let expanded = edited(&source, sid, vec![insert(outline, right.clone())]).unwrap(); + let source = expanded.as_slice(); + let raw = index + .resolve(sid, index.spaces[&sid].labels[&(ExGuid::default(), 1)]) + .unwrap(); + let text = raw + .objects + .iter() + .find_map(|(id, object)| (object.jcid == 0x6000e).then_some(*id)) + .unwrap(); for blocked in [page, outline, paragraph] { let protected = write_revision(source, sid, |raw| { let mut object = PropertyObject::from_object(&raw.objects[&blocked])?; @@ -107,47 +129,55 @@ fn document_insertions_and_formatting_respect_readonly_ancestors() { Ok(BTreeMap::from([(blocked, object)])) }) .unwrap(); - let child = Insertion::paragraph(paragraph, None, "Nested", "Author").unwrap(); - assert!(PreparedEdit::insert(&protected, sid, &child).is_err()); - let raw = index - .resolve(sid, index.spaces[&sid].labels[&(ExGuid::default(), 1)]) - .unwrap(); - let text = raw - .objects - .iter() - .find_map(|(id, object)| (object.jcid == 0x6000e).then_some(*id)) - .unwrap(); - let split = crate::ParagraphSplit::new(text, 1, "Author").unwrap(); - assert!(PreparedEdit::split(&protected, sid, &split).is_err()); - let join = crate::ParagraphJoin::new(text, right.text_object(), "Author").unwrap(); - assert!(PreparedEdit::join(&protected, sid, &join).is_err()); - assert!( - PreparedEdit::format( - &protected, - sid, - text, - 1..3, - &[crate::TextAttribute::Bold(true)] - ) - .is_err() - ); + let refused = |op| edited(&protected, sid, vec![op]).is_err(); + assert!(refused(insert(paragraph, text_paragraph("Nested", Format::default())))); + assert!(refused(PageOp::Split { + text, + at: 1, + paragraph: new_id().unwrap(), + right: new_id().unwrap(), + lists: Vec::new(), + })); + assert!(refused(PageOp::Join { + left: text, + right: right_text, + })); + assert!(refused(PageOp::Format { + text, + range: 1..3, + set: vec![TextAttribute::Bold(true)], + clear: Vec::new(), + })); if blocked == page { - let outline = Insertion::outline(page, 36.0, 36.0, "Outline", "Author").unwrap(); - assert!(PreparedEdit::insert(&protected, sid, &outline).is_err()); + assert!(refused(PageOp::Add { + object: PageObject::Outline(Outline { + id: new_id().unwrap(), + title: false, + min_width: None, + layout: Layout { + x: Some(36.0), + y: Some(36.0), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: vec![text_paragraph("Outline", Format::default())], + unsupported: Vec::new(), + }), + before: None, + })); } } let protected = write_revision(source, sid, |raw| { - let mut object = PropertyObject::from_object(&raw.objects[&right.object()])?; + let mut object = PropertyObject::from_object(&raw.objects[&right.id])?; object.set(&[(0x88001cde, &[])])?; - Ok(BTreeMap::from([(right.object(), object)])) + Ok(BTreeMap::from([(right.id, object)])) }) .unwrap(); - let document = crate::document::Document::parse(&index).unwrap(); - let space = &document.spaces[&sid]; - let view = space.active().unwrap(); - let text = view.nodes[¶graph].content[0]; - let join = crate::ParagraphJoin::new(text, right.text_object(), "Author").unwrap(); - assert!(PreparedEdit::join(&protected, sid, &join).is_err()); + let join = PageOp::Join { + left: text, + right: right_text, + }; + assert!(edited(&protected, sid, vec![join]).is_err()); } fn add_paragraph(source: &[u8], number: u32) -> Vec { @@ -774,8 +804,9 @@ fn invalid_property_splices_are_rejected() { fn character_formatting_preserves_inheritance_and_associated_data() { use super::{PropertyObject, write_revision}; use crate::{ - ExGuid, PreparedEdit, RevisionIndex, Store, TextAttribute, + ExGuid, RevisionIndex, Store, TextAttribute, document::{Document, Kind}, + op::{PageOp, tests::edited}, }; use std::collections::BTreeMap; let source = crate::create_section("inherited.one", "abcdef", "Author").unwrap(); @@ -858,13 +889,29 @@ fn character_formatting_preserves_inheritance_and_associated_data() { Ok(BTreeMap::from([(text, target)])) }) .unwrap(); + let text_edit = |range, with: &str| { + let with = with.to_owned(); + edited(&fixture, sid, vec![PageOp::Text { text, range, with }]) + }; + let bold = |range| { + edited( + &fixture, + sid, + vec![PageOp::Format { + text, + range, + set: vec![TextAttribute::Bold(true)], + clear: Vec::new(), + }], + ) + }; if variant == 9 { - assert!(PreparedEdit::text(&fixture, sid, text, 0..0, "x").is_err()); + assert!(text_edit(0..0, "x").is_err()); continue; } if variant >= 7 { - let edit = PreparedEdit::text(&fixture, sid, text, 1..3, "longer").unwrap(); - let [before, after] = [&fixture, edit.as_bytes()].map(|bytes| { + let edit = text_edit(1..3, "longer").unwrap(); + let [before, after] = [&fixture, &edit].map(|bytes| { let store = Store::parse(bytes).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); @@ -877,24 +924,20 @@ fn character_formatting_preserves_inheritance_and_associated_data() { format!("{:?}", view.nodes[&text].extra) }); assert_eq!(before, after); - assert!(PreparedEdit::text(&fixture, sid, text, 0..4, "x").is_err()); - assert_eq!( - PreparedEdit::text(&fixture, sid, text, 3..5, "x").is_ok(), - variant == 7 - ); + assert!(text_edit(0..4, "x").is_err()); + assert_eq!(text_edit(3..5, "x").is_ok(), variant == 7); continue; } // Hidden and hyperlink runs are ordinary text with flags; associated data, // equations and embedded objects are not. - let text_edit = PreparedEdit::text(&fixture, sid, text, 1..5, "x"); - assert_eq!(text_edit.is_ok(), matches!(variant, 0 | 1 | 4)); - let edit = PreparedEdit::format(&fixture, sid, text, 0..6, &[TextAttribute::Bold(true)]); + assert_eq!(text_edit(1..5, "x").is_ok(), matches!(variant, 0 | 1 | 4)); + let edit = bold(0..6); if matches!(variant, 2 | 5 | 6) { assert!(edit.is_err()); continue; } let edit = edit.unwrap(); - let store = Store::parse(edit.as_bytes()).unwrap(); + let store = Store::parse(&edit).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let doc = Document::parse(&index).unwrap(); let s = &doc.spaces[&sid]; @@ -911,10 +954,7 @@ fn character_formatting_preserves_inheritance_and_associated_data() { let data = &view.nodes[&text].extra[runs[0].extra_set.unwrap()]; assert_eq!(data.len(), 1); assert_eq!(data[0].id, 0x14001234); - assert!( - PreparedEdit::format(&fixture, sid, text, 1..3, &[TextAttribute::Bold(true)]) - .is_err() - ); + assert!(bold(1..3).is_err()); let previous = Store::parse(&fixture).unwrap(); let previous = RevisionIndex::parse(&previous).unwrap(); let previous_doc = Document::parse(&previous).unwrap(); diff --git a/crates/onestore/src/write/tests/multi.rs b/crates/onestore/src/write/tests/multi.rs index 17ff70a0194c7b1cdcc6655af43640929d298f39..1680b1e3f9c4140ea33d2c5eae62ffc445d196ac 100644 --- a/crates/onestore/src/write/tests/multi.rs +++ b/crates/onestore/src/write/tests/multi.rs @@ -1,7 +1,8 @@ use crate::{ ExGuid, RevisionIndex, Store, document::{Document, Kind}, - write::{PropertyObject, RevisionEdit, write_revisions}, + Transaction, + write::{PropertyObject, RevisionEdit, applied, revisions, write_revisions}, }; use std::collections::BTreeMap; @@ -18,8 +19,8 @@ mod current { const SOURCE: &[u8] = include_bytes!("../../../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one"); -fn nest(source: &[u8]) -> Vec { - write_revisions(source, |index| { +fn nest(source: &[u8]) -> Option { + revisions(source, |index| { let document = Document::parse(index)?; let pages = document.pages()?; assert_eq!(pages.len(), 9); @@ -100,7 +101,7 @@ fn nest(source: &[u8]) -> Vec { #[test] fn nesting_publishes_section_order_and_page_levels_in_one_transaction() { - let written = nest(SOURCE); + let written = applied(SOURCE, nest(SOURCE).as_ref()).unwrap(); let before = Store::parse(SOURCE).unwrap(); let after = Store::parse(&written).unwrap(); assert_eq!( @@ -160,7 +161,7 @@ fn nesting_publishes_section_order_and_page_levels_in_one_transaction() { .collect::>(), [1, 1, 1, 3, 1, 1, 1] ); - assert_eq!(nest(&written), written); + assert_eq!(nest(&written), None); if let Some(output) = std::env::var_os("ONESTORE_PAGE_BATCH_OUTPUT") { std::fs::create_dir(&output).unwrap(); std::fs::write( @@ -173,7 +174,8 @@ fn nesting_publishes_section_order_and_page_levels_in_one_transaction() { #[test] fn interrupted_multi_space_publication_never_exposes_a_partial_nesting() { - let written = nest(SOURCE); + let transaction = nest(SOURCE).unwrap(); + let written = applied(SOURCE, Some(&transaction)).unwrap(); let old = current::current(SOURCE); let new = current::current(&written); assert_ne!(old, new); @@ -186,9 +188,7 @@ fn interrupted_multi_space_publication_never_exposes_a_partial_nesting() { write_limit, random: 1951, }; - crate::commit::Transaction::between(SOURCE, &written) - .commit(&mut complete) - .unwrap(); + transaction.commit(&mut complete).unwrap(); assert_eq!(complete.durable, written); for fail_at in 1..=complete.operation { let mut interrupted = disk::Disk { @@ -199,9 +199,7 @@ fn interrupted_multi_space_publication_never_exposes_a_partial_nesting() { write_limit, random: 1951 + fail_at as u64, }; - crate::commit::Transaction::between(SOURCE, &written) - .commit(&mut interrupted) - .unwrap_err(); + transaction.commit(&mut interrupted).unwrap_err(); let observed = current::current(&interrupted.durable); assert!( observed == old || observed == new, @@ -234,7 +232,7 @@ fn repeated_multi_space_edits_cross_counter_carries_and_checkpoint_each_space() let initial_index = RevisionIndex::parse(&initial_store).unwrap(); let mut checkpoints = BTreeMap::new(); for step in 1_u32..=514 { - let written = write_revisions(&source, |index| { + let transaction = revisions(&source, |index| { let mut changes = BTreeMap::new(); for (sid, space) in &index.spaces { let raw = index.resolve(*sid, space.labels[&(ExGuid::default(), 1)])?; @@ -259,7 +257,9 @@ fn repeated_multi_space_edits_cross_counter_carries_and_checkpoint_each_space() .map(|(sid, objects)| (sid, RevisionEdit::Update(objects))) .collect()) }) + .unwrap() .unwrap(); + let written = applied(&source, Some(&transaction)).unwrap(); let store = Store::parse(&written).unwrap(); assert_eq!( store.header.transaction_count, @@ -290,9 +290,7 @@ fn repeated_multi_space_edits_cross_counter_carries_and_checkpoint_each_space() write_limit: 4096, random: 1952, }; - crate::commit::Transaction::between(&source, &written) - .commit(&mut complete) - .unwrap(); + transaction.commit(&mut complete).unwrap(); for fail_at in 1..=complete.operation { let mut interrupted = disk::Disk { visible: source.clone(), @@ -302,9 +300,7 @@ fn repeated_multi_space_edits_cross_counter_carries_and_checkpoint_each_space() write_limit: 4096, random: 1952 + fail_at as u64, }; - crate::commit::Transaction::between(&source, &written) - .commit(&mut interrupted) - .unwrap_err(); + transaction.commit(&mut interrupted).unwrap_err(); let observed = current::current(&interrupted.durable); assert!(observed == old || observed == new, "{step}:{fail_at}"); } diff --git a/crates/onestore/tests/commit.rs b/crates/onestore/tests/commit.rs index 0c21e0adae4f18bd9b4a672a1c5c974d51cfa5ce..0b7ea4099d653c3bc903383fa0202943b600d64e 100644 --- a/crates/onestore/tests/commit.rs +++ b/crates/onestore/tests/commit.rs @@ -2,104 +2,94 @@ mod disk; use disk::Disk; use onestore::{ - ExGuid, ObjectData, PropertySets, RevisionIndex, Store, Value, replace_property_bytes, + Arena, ExGuid, RevisionIndex, Section, Store, Transaction, + document::{Document, Kind}, + op::{Edit, Op, PageOp}, }; use std::fs; #[test] -fn incomplete_native_save_is_rejected_before_storage_io() { +fn incomplete_native_save_is_rejected_before_an_edit() { let source = fs::read("../../corpus/malformed/native-inflight.one").unwrap(); let store = Store::parse(&source).unwrap(); + let graph_error = RevisionIndex::parse(&store) + .unwrap() + .validate_current() + .unwrap_err(); + let arena = Arena::default(); + let error = Section::open(&arena, source.clone()).err().unwrap(); + assert_eq!(error.to_string(), graph_error.to_string()); +} + +/// The text object holding `text` and its page space. +fn target(source: &[u8], text: &str) -> (ExGuid, ExGuid) { + let store = Store::parse(source).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); - let graph_error = index.validate_current().unwrap_err(); - let mut checked = 0; - for (osid, space) in &index.spaces { - let revision = index - .resolve(*osid, space.labels[&(ExGuid::default(), 1)]) - .unwrap(); - for oid in revision.reachable().unwrap() { - let object = &revision.objects[&oid]; - if object.jcid != 0x6000e { - continue; - } - let ObjectData::Properties(data) = object.data else { - continue; - }; - for property in &PropertySets::parse(data).unwrap().sets[0] { - if property.value != Value::Bytes(b"Rust same paragraph.") { - continue; - } - let mut disk = Disk { - visible: source.clone(), - durable: source.clone(), - operation: 0, - fail_at: None, - write_limit: 1024, - random: 1, - }; - let error = onestore::commit_property_bytes( - &mut disk, - &source, - *osid, - oid, - property.id, - b"Rust same paragraph.", - ) - .unwrap_err(); - assert_eq!(error.state, onestore::CommitState::NotCommitted); - assert_eq!(error.error.to_string(), graph_error.to_string()); - assert_eq!(disk.operation, 0); - checked += 1; - } - } - } - assert_eq!(checked, 1); + let document = Document::parse(&index).unwrap(); + document + .spaces + .iter() + .find_map(|(space, view)| { + view.active()?.nodes.iter().find_map(|(id, node)| { + matches!(&node.kind, Kind::RichText { text: stored, .. } if stored == text) + .then_some((*space, *id)) + }) + }) + .unwrap() +} + +/// The text `object` holds in `image`, which must be valid. +fn text_of(image: &[u8], space: ExGuid, object: ExGuid) -> String { + let store = Store::parse(image).unwrap(); + assert!(store.checksum_mismatches.is_empty()); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + let document = Document::parse(&index).unwrap(); + let Kind::RichText { text, .. } = &document.spaces[&space].active().unwrap().nodes[&object].kind + else { + panic!() + }; + text.clone() } #[test] fn repeated_appends_cross_log_fragments_and_expose_counter_tears() { - let mut source = + let initial = fs::read("../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one") .unwrap(); - let store = Store::parse(&source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let mut target = None; - for (osid, space) in &index.spaces { - let revision = index - .resolve(*osid, space.labels[&(ExGuid::default(), 1)]) - .unwrap(); - for (oid, object) in revision.objects { - if let ObjectData::Properties(data) = object.data { - for property in &PropertySets::parse(data).unwrap().sets[0] { - if property.value == Value::Bytes(b"Fictitious plain text.") { - target = Some((*osid, oid, property.id)); - } - } - } - } - } - let (osid, oid, property) = target.unwrap(); + let store = Store::parse(&initial).unwrap(); + let (space, object) = target(&initial, "Fictitious plain text."); let root = store.header.root; let log = store.header.transaction_log; let initial_count = store.header.transaction_count; + let arena = Arena::default(); + let mut section = Section::open(&arena, initial.clone()).unwrap(); + let mut source = initial.clone(); + let mut length = "Fictitious plain text.".encode_utf16().count() as u32; let mut saw_counter_tear = false; - for transaction in initial_count + 1..=258 { - let value = format!("Transaction {transaction}"); - let written = - replace_property_bytes(&source, osid, oid, property, value.as_bytes()).unwrap(); + for count in initial_count + 1..=258 { + let value = format!("Transaction {count}"); + let op = PageOp::Text { + text: object, + range: 0..length, + with: value.clone(), + }; + length = value.encode_utf16().count() as u32; + section + .apply("Author", &Edit { at: 133_700_000_000_000_000 + u64::from(count), ops: vec![Op::Page { space, op }] }) + .unwrap(); + let transaction = section.seal().unwrap().unwrap(); + let mut written = source.clone(); + transaction.apply(&mut written).unwrap(); let store = Store::parse(&written).unwrap(); assert_eq!(store.header.root, root); assert_eq!(store.header.transaction_log, log); - assert_eq!(store.header.transaction_count, transaction); - assert!(store.checksum_mismatches.is_empty()); - RevisionIndex::parse(&store) - .unwrap() - .validate_current() - .unwrap(); + assert_eq!(store.header.transaction_count, count); + assert_eq!(text_of(&written, space, object), value); let mut previous = written.clone(); previous[..1024].copy_from_slice(&source[..1024]); let old = Store::parse(&previous).unwrap(); - assert_eq!(old.header.transaction_count, transaction - 1); + assert_eq!(old.header.transaction_count, count - 1); assert!(old.checksum_mismatches.is_empty()); RevisionIndex::parse(&old) .unwrap() @@ -110,17 +100,17 @@ fn repeated_appends_cross_log_fragments_and_expose_counter_tears() { torn[prefix..1024].copy_from_slice(&source[prefix..1024]); let result = Store::parse(&torn) .and_then(|store| RevisionIndex::parse(&store)?.validate_current()); - if transaction == 256 && prefix == 97 { + if count == 256 && prefix == 97 { assert!(result.is_err()); saw_counter_tear = true; } else { result.unwrap(); } } - if transaction == initial_count + 1 || transaction == 256 { - check_crashes(&source, osid, oid, property, value.as_bytes()); + if count == initial_count + 1 || count == 256 { + check_crashes(&source, &transaction, space, object, &value); } - if transaction == initial_count + 1 { + if count == initial_count + 1 { let path = std::env::temp_dir().join(format!( "onestore-commit-{}-{}.one", std::process::id(), @@ -130,61 +120,29 @@ fn repeated_appends_cross_log_fragments_and_expose_counter_tears() { .as_nanos() )); fs::write(&path, &source).unwrap(); - onestore::commit_file_property(&path, &source, osid, oid, property, value.as_bytes()) - .unwrap(); + transaction.commit_file(&path).unwrap(); let committed = fs::read(&path).unwrap(); - let parsed = Store::parse(&committed).unwrap(); - assert_eq!(parsed.header.transaction_count, transaction); - RevisionIndex::parse(&parsed) - .unwrap() - .validate_current() - .unwrap(); - let stale = - onestore::commit_file_property(&path, &source, osid, oid, property, b"stale") - .unwrap_err(); + assert_eq!(committed, written); + // The file moved past the transaction's base: nothing is written again. + let stale = transaction.commit_file(&path).unwrap_err(); assert_eq!(stale.state, onestore::CommitState::NotCommitted); assert_eq!(fs::read(&path).unwrap(), committed); - onestore::commit_file_property( - &path, - &committed, - osid, - oid, - property, - value.as_bytes(), - ) - .unwrap(); - assert_eq!(fs::read(&path).unwrap(), committed); fs::remove_file(path).unwrap(); } source = written; } assert!(saw_counter_tear); + assert!(section.image() == source); } -fn check_crashes(source: &[u8], osid: ExGuid, oid: ExGuid, property: u32, value: &[u8]) { - let current_value = |data: &[u8]| { - let store = Store::parse(data).unwrap(); - assert!(store.checksum_mismatches.is_empty()); - let index = RevisionIndex::parse(&store).unwrap(); - index.validate_current().unwrap(); - let revision = index - .resolve(osid, index.spaces[&osid].labels[&(ExGuid::default(), 1)]) - .unwrap(); - let ObjectData::Properties(blob) = revision.objects[&oid].data else { - panic!() - }; - let properties = PropertySets::parse(blob).unwrap(); - let Value::Bytes(value) = properties.sets[0] - .iter() - .find(|p| p.id == property) - .unwrap() - .value - else { - panic!() - }; - value.to_vec() - }; - let before = current_value(source); +fn check_crashes( + source: &[u8], + transaction: &Transaction, + space: ExGuid, + object: ExGuid, + value: &str, +) { + let before = text_of(source, space, object); for limit in [17, 1024] { let disk = |fail_at, random| Disk { visible: source.to_vec(), @@ -195,22 +153,13 @@ fn check_crashes(source: &[u8], osid: ExGuid, oid: ExGuid, property: u32, value: random, }; let mut successful = disk(None, 1); - onestore::commit_property_bytes(&mut successful, source, osid, oid, property, value) - .unwrap(); - assert_eq!(current_value(&successful.durable), value); + transaction.commit(&mut successful).unwrap(); + assert_eq!(text_of(&successful.durable, space, object), value); for at in 1..=successful.operation { for seed in [0, 1, 42, u64::MAX] { let mut interrupted = disk(Some(at), seed); - let failure = onestore::commit_property_bytes( - &mut interrupted, - source, - osid, - oid, - property, - value, - ) - .unwrap_err(); - let persisted = current_value(&interrupted.durable); + let failure = transaction.commit(&mut interrupted).unwrap_err(); + let persisted = text_of(&interrupted.durable, space, object); match failure.state { onestore::CommitState::NotCommitted => assert_eq!(persisted, before), onestore::CommitState::Committed => assert_eq!(persisted, value), @@ -222,6 +171,7 @@ fn check_crashes(source: &[u8], osid: ExGuid, oid: ExGuid, property: u32, value: } } } + #[test] #[cfg(any(unix, windows))] fn bounded_file_reads_reject_partial_images_and_release_the_owner() { diff --git a/crates/onestore/tests/conflict.rs b/crates/onestore/tests/conflict.rs new file mode 100644 index 0000000000000000000000000000000000000000..9d42f3773a391a3f67b4ba647d5f516e8858718d --- /dev/null +++ b/crates/onestore/tests/conflict.rs @@ -0,0 +1,357 @@ +use onestore::{ + Arena, ConflictPage, ExGuid, ObjectData, PageCreation, PropertySets, RevisionIndex, Section, + Store, Value, + op::{Edit, Op, PageOp, SectionOp}, + page::{Page, PageObject}, +}; +use std::collections::BTreeMap; + +const AT: u64 = 134_030_000_000_000_000; + +fn read(path: &str) -> Vec { + std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap() +} + +/// Each object's jcid and root-set property identities, by space and object. +fn objects(image: &[u8]) -> BTreeMap)>> { + let store = Store::parse(image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index + .spaces + .keys() + .map(|space| { + let revision = index.resolve_active(*space).unwrap(); + let objects = revision + .objects + .iter() + .map(|(id, object)| { + let ids = match object.data { + ObjectData::Properties(bytes) => PropertySets::parse(bytes).unwrap().sets + [0] + .iter() + .map(|property| property.id) + .collect(), + _ => Vec::new(), + }; + (*id, (object.jcid, ids)) + }) + .collect(); + (*space, objects) + }) + .collect() +} + +/// The object-space references a property holds. +fn spaces(image: &[u8], space: ExGuid, object: ExGuid, property: u32) -> usize { + let store = Store::parse(image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let revision = index.resolve_active(space).unwrap(); + let ObjectData::Properties(bytes) = revision.objects[&object].data else { + panic!() + }; + PropertySets::parse(bytes).unwrap().sets[0] + .iter() + .find(|field| field.id == property) + .map_or(0, |field| match &field.value { + Value::References { compact_ids, .. } => compact_ids.len() / 4, + _ => panic!(), + }) +} + +fn texts(page: &Page) -> Vec<(ExGuid, String)> { + page.objects + .iter() + .flat_map(|object| match object { + PageObject::Outline(outline) => outline.paragraphs.clone(), + PageObject::Title(title) => title + .outlines + .iter() + .flat_map(|outline| outline.paragraphs.clone()) + .collect(), + _ => Vec::new(), + }) + .filter_map(|paragraph| { + paragraph + .text() + .map(|text| (text.id, text.text.text().to_owned())) + }) + .collect() +} + +#[test] +fn native_conflict_pages_are_listed_under_their_page_last_first() { + let arena = Arena::default(); + let mut section = Section::open( + &arena, + read("corpus/collaboration/round-01/offline/notebook/synthetic.one"), + ) + .unwrap(); + let pages = section.pages().unwrap(); + let conflicts = section.conflicts().unwrap(); + assert_eq!(conflicts.len(), 1); + let (page, listed) = &conflicts[0]; + assert_eq!(*page, pages[0].0); + let [ConflictPage { + space, + title, + user, + created, + objects, + }] = listed.as_slice() + else { + panic!("{listed:?}") + }; + assert_eq!( + (title.as_str(), user.as_str(), *created), + ( + "Native edited while disconnected.", + "snow", + Some(0x01dd3d10d7afd9e0) + ) + ); + let conflict = section.page(*space).unwrap(); + let marked: Vec<_> = texts(&conflict) + .into_iter() + .filter(|(id, _)| objects.contains(id)) + .map(|(_, text)| text) + .collect(); + assert_eq!(marked, ["Native edited while disconnected."]); + + // Three clients' conflict pages: OneNote lists the one stored last first. + let arena = Arena::default(); + let mut section = Section::open( + &arena, + read("corpus/conflict-page/native-three/notebook/synthetic.one"), + ) + .unwrap(); + let users: Vec<_> = section.conflicts().unwrap()[0] + .1 + .iter() + .map(|page| page.user.clone()) + .collect(); + assert_eq!( + users, + ["ONE-M6-053BD357", "ONE-M6-9451B6C6", "ONE-M6-DDE10E0D"] + ); +} + +/// The conflict page a merge makes of `local`, the version of `space`'s page it could not +/// take, marking the text object `conflicting`. +fn conflict(space: ExGuid, local: &Page, conflicting: ExGuid, user: &str) -> Op { + let titled = local + .objects + .iter() + .any(|object| matches!(object, PageObject::Title(_))); + let mut objects = [conflicting]; + let page = local.copy_with(&mut objects).unwrap(); + Op::Section(SectionOp::Conflict { + of: space, + creation: PageCreation::new(None, titled.then_some(local.title.as_str()), user).unwrap(), + page, + objects: objects.to_vec(), + }) +} + +/// A conflict page written through ops stores what OneNote 2010 writes for one +/// (`corpus/collaboration/round-01/offline`): its own space under the page's manifest, with +/// conflict metadata, a read-only deletable conflict page and the conflicting text marked. +/// `ONESTORE_CONFLICT_EXPORT` names a directory to write the notebook to, for a cold read. +#[test] +fn a_conflict_page_is_stored_as_onenote_stores_one() { + let notebook = "corpus/native-ink/cold-ui-ink/notebook"; + let source = read(&format!("{notebook}/synthetic.one")); + let arena = Arena::default(); + let mut section = Section::open(&arena, source.clone()).unwrap(); + let space = section.pages().unwrap()[0].0; + let base = section.page(space).unwrap(); + let (text, _) = texts(&base) + .into_iter() + .find(|(_, text)| text.starts_with("Fictitious: café")) + .unwrap(); + // Both machines renamed the paragraph's first word, within its Latin run. + let replace = |with: &str| Edit { + at: AT, + ops: vec![Op::Page { + space, + op: PageOp::Text { + text, + range: 0..10, + with: with.into(), + }, + }], + }; + section.apply("Remote", &replace("Remote")).unwrap(); + let scratch = Arena::default(); + let mut local = Section::open(&scratch, source).unwrap(); + local.apply("Clover Snow", &replace("Snowbound")).unwrap(); + let local = local.page(space).unwrap(); + let op = conflict(space, &local, text, "Clover Snow"); + let Op::Section(SectionOp::Conflict { creation, .. }) = &op else { + unreachable!() + }; + let created = creation.space(); + section + .apply( + "Clover Snow", + &Edit { + at: AT + 10_000_000, + ops: vec![op.clone()], + }, + ) + .unwrap(); + section.seal().unwrap(); + let image = section.image(); + + let arena = Arena::default(); + let mut reopened = Section::open(&arena, image.clone()).unwrap(); + assert_eq!(reopened.pages().unwrap().len(), 1); + let listed = reopened.conflicts().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].0, space); + assert_eq!( + listed[0].1, + [ConflictPage { + space: created, + title: "Snowbound: café, 東京, مرحبا".into(), + user: "Clover Snow".into(), + created: listed[0].1[0].created, + objects: listed[0].1[0].objects.clone(), + }] + ); + let kept = reopened.page(created).unwrap(); + let marked: Vec<_> = texts(&kept) + .into_iter() + .filter(|(id, _)| listed[0].1[0].objects.contains(id)) + .map(|(_, text)| text) + .collect(); + assert_eq!(marked, ["Snowbound: café, 東京, مرحبا"]); + assert!( + texts(&reopened.page(space).unwrap()) + .iter() + .any(|(_, text)| text == "Remote: café, 東京, مرحبا") + ); + + // The stored form, property by property, as OneNote writes it. + let stored = objects(&image); + let conflict_space = &stored[&created]; + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let roots = index.resolve_active(created).unwrap().roots; + let (jcid, metadata) = &conflict_space[&roots[&2]]; + assert_eq!(*jcid, 0x20038); + assert_eq!( + metadata, + &[ + 0x1c001cf3, 0x1c001c30, 0x14001dff, 0x14001d82, 0x1400348b, 0x18001c65, 0x1c001d9e, + 0x1c001d9f + ] + ); + let page_node = conflict_space + .values() + .find(|(jcid, _)| *jcid == 0x6000b) + .unwrap(); + for flag in [0x88001cde, 0x88001d0c, 0x88001d7c] { + assert!(page_node.1.contains(&flag), "{flag:#x}"); + } + let marked: Vec<_> = conflict_space + .iter() + .filter(|(_, (_, ids))| ids.contains(&0x88001d96)) + .map(|(_, (jcid, ids))| (*jcid, ids.contains(&0x88001ddb))) + .collect(); + assert_eq!(marked, [(0x6000e, true)]); + let main = &stored[&space]; + let main_roots = index.resolve_active(space).unwrap().roots; + assert_eq!(spaces(&image, space, main_roots[&1], 0x2c001d63), 1); + assert!(main[&main_roots[&2]].1.contains(&0x88001d97)); + assert_eq!( + main.values() + .filter(|(jcid, ids)| *jcid == 0x20038 && ids.contains(&0x1c001d9e)) + .count(), + 1 + ); + assert_eq!( + stored[&index.root] + .values() + .filter(|(jcid, ids)| *jcid == 0x20030 && ids.contains(&0x88001d97)) + .count(), + 1 + ); + + if let Some(output) = std::env::var_os("ONESTORE_CONFLICT_EXPORT") { + let output = std::path::Path::new(&output); + std::fs::create_dir_all(output).unwrap(); + std::fs::write(output.join("synthetic.one"), &image).unwrap(); + std::fs::copy( + format!("{}/../../{notebook}/Open Notebook.onetoc2", env!("CARGO_MANIFEST_DIR")), + output.join("Open Notebook.onetoc2"), + ) + .unwrap(); + } +} + +/// Deleting a conflict page stores what OneNote 2010's Delete Conflict Page does +/// (`corpus/conflict-page/native-delete`): the page's manifest stops listing the conflict +/// page, keeping its metadata copy, and the page and the section's copy of its metadata no +/// longer say it has conflict pages; the conflict page's space is left alone. +/// `ONESTORE_CONFLICT_DELETE_EXPORT` names a directory to write the notebook to. +#[test] +fn deleting_a_conflict_page_stores_what_onenote_stores() { + let notebook = "corpus/conflict-page/native/conflict/notebook"; + let arena = Arena::default(); + let mut section = Section::open(&arena, read(&format!("{notebook}/synthetic.one"))).unwrap(); + let (page, listed) = section.conflicts().unwrap().remove(0); + let conflict = listed[0].space; + section + .apply( + "Clover Snow", + &Edit { + at: AT, + ops: vec![Op::Section(SectionOp::Delete(vec![conflict]))], + }, + ) + .unwrap(); + section.seal().unwrap(); + let image = section.image(); + let arena = Arena::default(); + let mut reopened = Section::open(&arena, image.clone()).unwrap(); + assert!(reopened.conflicts().unwrap().is_empty()); + assert_eq!(reopened.pages().unwrap().len(), 1); + + // Each space's objects and their property identities, as OneNote left them. + let native = read("corpus/conflict-page/native-delete/notebook/synthetic.one"); + let shapes = |image: &[u8]| { + let store = Store::parse(image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let root = index.root; + let roots = index.resolve_active(page).unwrap().roots; + let objects = objects(image); + let manifest = objects[&page][&roots[&1]].clone(); + let metadata = objects[&page][&roots[&2]].clone(); + let flagged = objects[&root] + .values() + .filter(|(_, ids)| ids.contains(&0x88001d97)) + .count(); + (manifest, metadata, flagged, objects[&conflict].clone()) + }; + let (manifest, metadata, flagged, space) = shapes(&image); + let (native_manifest, native_metadata, native_flagged, native_space) = shapes(&native); + assert_eq!(manifest.1.iter().filter(|id| **id != 0x3400347b).collect::>(), [&0x24001c1f, &0x24003442]); + assert_eq!( + native_manifest.1.iter().filter(|id| **id != 0x3400347b).collect::>(), + [&0x24001c1f, &0x24003442] + ); + assert!(!metadata.1.contains(&0x88001d97) && !native_metadata.1.contains(&0x88001d97)); + assert_eq!((flagged, native_flagged), (0, 0)); + assert_eq!(space, native_space); + + if let Some(output) = std::env::var_os("ONESTORE_CONFLICT_DELETE_EXPORT") { + let output = std::path::Path::new(&output); + std::fs::create_dir_all(output).unwrap(); + std::fs::write(output.join("synthetic.one"), &image).unwrap(); + std::fs::copy( + format!("{}/../../{notebook}/Open Notebook.onetoc2", env!("CARGO_MANIFEST_DIR")), + output.join("Open Notebook.onetoc2"), + ) + .unwrap(); + } +} diff --git a/crates/onestore/tests/document.rs b/crates/onestore/tests/document.rs index 20874875d3826865e851767aff8651715db7d7ca..ccc831b773d2d740ebe68d73d528750dbaf828cc 100644 --- a/crates/onestore/tests/document.rs +++ b/crates/onestore/tests/document.rs @@ -482,10 +482,6 @@ fn rust_unicode_and_native_latin1_use_utf16_run_offsets() { }; assert_eq!(actual, text); assert_eq!(runs[0].end, 7); - let bytes = fs::read("../../corpus/m6/ansi/notebook/synthetic.one").unwrap(); - let store = Store::parse(&bytes).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); let expected = format!( "Extended bytes: {}", (128u8..=255) @@ -493,14 +489,23 @@ fn rust_unicode_and_native_latin1_use_utf16_run_offsets() { .collect::>() .join(" ") ); - assert!( - document - .spaces - .values() - .flat_map(|s| s.revisions.values()) - .flat_map(|r| r.nodes.values()) - .any(|n| matches!(&n.kind, Kind::RichText { text, .. } if text == &expected)) - ); + // OneNote 2010 reads TextExtendedAscii as Latin-1 whatever the run's language + // (corpus/extended-ascii/{en,ru,ja}/cold). + for language in ["en", "ru", "ja"] { + let path = format!("../../corpus/extended-ascii/{language}/candidate/synthetic.one"); + let bytes = fs::read(path).unwrap(); + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + assert!( + document + .spaces + .values() + .flat_map(|s| s.revisions.values()) + .flat_map(|r| r.nodes.values()) + .any(|n| matches!(&n.kind, Kind::RichText { text, .. } if text == &expected)) + ); + } } #[test] @@ -717,11 +722,11 @@ fn malformed_run_boundaries_fail_after_valid_storage_decoding() { else { unreachable!() }; + // Damage the stored boundaries where they lie, as no writer would store them. + let at = boundaries.as_ptr().addr() - bytes.as_ptr().addr(); for invalid in [5u32, u32::MAX] { - let mut changed = boundaries.to_vec(); - changed[..4].copy_from_slice(&invalid.to_le_bytes()); - let mutated = - onestore::replace_property_bytes(&bytes, sid, oid, 0x1c001e12, &changed).unwrap(); + let mut mutated = bytes.to_vec(); + mutated[at..at + 4].copy_from_slice(&invalid.to_le_bytes()); let store = Store::parse(&mutated).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); index.validate_current().unwrap(); diff --git a/crates/onestore/tests/edit.rs b/crates/onestore/tests/edit.rs index bf22275a152972be7cada34f6e97b62f3fea4835..6d9f87595221dfafcc8f86ab14d1ca980fcc9625 100644 --- a/crates/onestore/tests/edit.rs +++ b/crates/onestore/tests/edit.rs @@ -2,14 +2,18 @@ mod checkpoint; #[path = "support/disk.rs"] mod disk; +#[path = "support/ops.rs"] +mod ops; #[path = "support/trace.rs"] mod trace; use disk::Disk; use onestore::{ - CommitState, ExGuid, RevisionIndex, Store, + CommitError, CommitIo, CommitState, ExGuid, RevisionIndex, Store, Transaction, document::{Document, Kind}, + op::{Op, OpError, PageOp}, }; +use std::ops::Range; const SOURCE: &[u8] = include_bytes!( "../../../corpus/native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one" @@ -31,6 +35,55 @@ fn target(source: &[u8]) -> (ExGuid, ExGuid) { panic!("Missing native text fixture") } +fn typed(space: ExGuid, text: ExGuid, range: Range, with: &str) -> Vec { + let op = PageOp::Text { + text, + range, + with: with.into(), + }; + vec![Op::Page { space, op }] +} + +/// `source` with `range` of the text object replaced. +fn replace_text( + source: &[u8], + space: ExGuid, + text: ExGuid, + range: Range, + with: &str, +) -> Result, OpError> { + ops::edited(source, typed(space, text, range, with)) +} + +/// The transaction replacing `range` of the text object in `source`. +fn text_transaction( + source: &[u8], + space: ExGuid, + text: ExGuid, + range: Range, + with: &str, +) -> Result { + let refused = |error: String| CommitError { + state: CommitState::NotCommitted, + error: std::io::Error::new(std::io::ErrorKind::InvalidData, error), + }; + ops::transaction(source, "Author", typed(space, text, range, with)) + .map_err(|error| refused(error.to_string()))? + .ok_or_else(|| refused("The edit changes nothing".into())) +} + +/// `text_transaction` committed under caller-held exclusion. +fn commit_text( + io: &mut impl CommitIo, + source: &[u8], + space: ExGuid, + text: ExGuid, + range: Range, + with: &str, +) -> Result<(), CommitError> { + text_transaction(source, space, text, range, with)?.commit(io) +} + fn text_runs(source: &[u8], sid: ExGuid, oid: ExGuid) -> serde_json::Value { let store = Store::parse(source).unwrap(); assert!(store.checksum_mismatches.is_empty()); @@ -61,9 +114,11 @@ fn prepared_publication_preserves_its_identity_through_every_io_failure() { let source = onestore::create_section("prepared.one", "Fictitious: café 🦀", "Fixture").unwrap(); let (sid, oid) = target(&source); - let source = checkpoint::pending(&source, sid, oid, 0x14001d7a); - let edit = onestore::PreparedEdit::text(&source, sid, oid, 0..0, "Prepared 🐈 ").unwrap(); - let store = Store::parse(edit.as_bytes()).unwrap(); + let source = checkpoint::pending(&source, sid, oid); + let edit = text_transaction(&source, sid, oid, 0..0, "Prepared 🐈 ").unwrap(); + let mut written = source.clone(); + edit.apply(&mut written).unwrap(); + let store = Store::parse(&written).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let planned = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; let persisted: ExGuid = @@ -72,7 +127,7 @@ fn prepared_publication_preserves_its_identity_through_every_io_failure() { let before_index = RevisionIndex::parse(&before_store).unwrap(); assert!(!before_index.spaces[&sid].revisions.contains_key(&persisted)); let before = text_runs(&source, sid, oid); - let after = text_runs(edit.as_bytes(), sid, oid); + let after = text_runs(&written, sid, oid); for write_limit in [17, 4096] { let disk = |fail_at| Disk { visible: source.clone(), @@ -84,12 +139,12 @@ fn prepared_publication_preserves_its_identity_through_every_io_failure() { }; let mut success = disk(None); edit.commit(&mut success).unwrap(); - assert_eq!(success.durable, edit.as_bytes()); + assert_eq!(success.durable, written); let operations = success.operation; let error = edit.commit(&mut success).unwrap_err(); assert_eq!(error.state, CommitState::NotCommitted); assert_eq!(error.error.kind(), std::io::ErrorKind::ResourceBusy); - assert_eq!(success.durable, edit.as_bytes()); + assert_eq!(success.durable, written); for at in 1..=operations { let mut interrupted = disk(Some(at)); let error = edit.commit(&mut interrupted).unwrap_err(); @@ -108,7 +163,8 @@ fn prepared_publication_preserves_its_identity_through_every_io_failure() { interrupted.visible.clone_from(&snapshot); interrupted.fail_at = None; interrupted.write_limit = 17; - onestore::confirm_snapshot(&mut interrupted, &snapshot).unwrap(); + onestore::confirm(&mut interrupted, &onestore::Stamp::of(&snapshot).unwrap()) + .unwrap(); assert_refreshed(&snapshot, &interrupted.durable); } } @@ -126,9 +182,9 @@ fn snapshot_confirmation_needs_no_surviving_edit_target() { random: 911, }; assert!( - onestore::replace_text(SOURCE, ExGuid::default(), ExGuid::default(), 0..0, "").is_err() + replace_text(SOURCE, ExGuid::default(), ExGuid::default(), 0..0, "").is_err() ); - onestore::confirm_snapshot(&mut disk, SOURCE).unwrap(); + onestore::confirm(&mut disk, &onestore::Stamp::of(SOURCE).unwrap()).unwrap(); assert_refreshed(SOURCE, &disk.durable); let flush = disk.operation; for (failure, state) in [ @@ -140,7 +196,7 @@ fn snapshot_confirmation_needs_no_surviving_edit_target() { disk.operation = 0; disk.fail_at = Some(failure); assert_eq!( - onestore::confirm_snapshot(&mut disk, SOURCE) + onestore::confirm(&mut disk, &onestore::Stamp::of(SOURCE).unwrap()) .unwrap_err() .state, state @@ -153,7 +209,7 @@ fn confirmation_notifies_cached_readers_after_interrupted_version_publication() let source = onestore::create_section("confirmation.one", "Fictitious: before", "Fixture").unwrap(); let (sid, oid) = target(&source); - let mut snapshot = onestore::replace_text(&source, sid, oid, 0..0, "Recovered ").unwrap(); + let mut snapshot = replace_text(&source, sid, oid, 0..0, "Recovered ").unwrap(); snapshot[212..252].copy_from_slice(&source[212..252]); let expected = text_runs(&snapshot, sid, oid); assert_ne!(expected, text_runs(&source, sid, oid)); @@ -167,11 +223,13 @@ fn confirmation_notifies_cached_readers_after_interrupted_version_publication() random: 911, }; let mut success = disk(None); - onestore::confirm_snapshot(&mut success, &snapshot).unwrap(); + onestore::confirm(&mut success, &onestore::Stamp::of(&snapshot).unwrap()).unwrap(); assert_refreshed(&snapshot, &success.durable); for failure in 1..=success.operation { let mut interrupted = disk(Some(failure)); - let error = onestore::confirm_snapshot(&mut interrupted, &snapshot).unwrap_err(); + let error = + onestore::confirm(&mut interrupted, &onestore::Stamp::of(&snapshot).unwrap()) + .unwrap_err(); assert_ne!(error.state, CommitState::Committed); assert_eq!(text_runs(&interrupted.durable, sid, oid), expected); assert_eq!(&interrupted.durable[..212], &snapshot[..212]); @@ -183,51 +241,53 @@ fn confirmation_notifies_cached_readers_after_interrupted_version_publication() #[test] fn confirming_visible_text_requires_flush_without_another_revision() { let (sid, oid) = target(SOURCE); - let visible = onestore::replace_text(SOURCE, sid, oid, 0..0, "Recovered ").unwrap(); + let visible = replace_text(SOURCE, sid, oid, 0..0, "Recovered ").unwrap(); + let base = onestore::Stamp::of(&visible).unwrap(); let mut disk = Disk { visible: visible.clone(), durable: SOURCE.to_vec(), operation: 0, fail_at: None, - write_limit: 0, + write_limit: 64, random: 1, }; - onestore::commit_text(&mut disk, &visible, sid, oid, 0..0, "").unwrap(); - assert_eq!(disk.visible, visible); - assert_eq!(disk.durable, visible); + onestore::confirm(&mut disk, &base).unwrap(); + assert_refreshed(&visible, &disk.durable); let flush = disk.operation; for (failure, state) in [ (1, CommitState::NotCommitted), (flush, CommitState::Unknown), ] { + disk.visible.clone_from(&visible); disk.durable = SOURCE.to_vec(); disk.operation = 0; disk.fail_at = Some(failure); - let error = onestore::commit_text(&mut disk, &visible, sid, oid, 0..0, "").unwrap_err(); + let error = onestore::confirm(&mut disk, &base).unwrap_err(); assert_eq!(error.state, state); - assert_eq!(disk.visible, visible); + assert_eq!(&disk.visible[..212], &visible[..212]); + assert_eq!(&disk.visible[252..], &visible[252..]); } } #[test] fn confirmation_rejects_a_changed_header_or_length_before_any_write() { - let (sid, oid) = target(SOURCE); let mut source = SOURCE.to_vec(); source.resize(3 * 1024 * 1024 + 131, 0); + let base = onestore::Stamp::of(&source).unwrap(); let mut disk = trace::Trace { bytes: source.clone(), events: Vec::new(), }; - onestore::commit_text(&mut disk, &source, sid, oid, 0..0, "").unwrap(); - assert_eq!(disk.events.len(), 1); - if let trace::Event::Write(offset, bytes) = &disk.events[0] { - panic!("Confirmation wrote {} bytes at {offset}", bytes.len()); - } + let confirmed = |events: &[trace::Event]| { + matches!(events, [trace::Event::Flush, trace::Event::Write(212, version), trace::Event::Flush] if version.len() == 40) + }; + onestore::confirm(&mut disk, &base).unwrap(); + assert!(confirmed(&disk.events)); for changed in [96, 128, 212, 1023] { disk.bytes.clone_from(&source); disk.bytes[changed] ^= 1; disk.events.clear(); - let error = onestore::commit_text(&mut disk, &source, sid, oid, 0..0, "").unwrap_err(); + let error = onestore::confirm(&mut disk, &base).unwrap_err(); assert_eq!(error.state, CommitState::NotCommitted); assert_eq!(error.error.kind(), std::io::ErrorKind::ResourceBusy); assert!(disk.events.is_empty()); @@ -237,14 +297,14 @@ fn confirmation_rejects_a_changed_header_or_length_before_any_write() { disk.bytes.clone_from(&source); disk.bytes[changed] ^= 1; disk.events.clear(); - onestore::commit_text(&mut disk, &source, sid, oid, 0..0, "").unwrap(); - assert!(matches!(disk.events[..], [trace::Event::Flush])); + onestore::confirm(&mut disk, &base).unwrap(); + assert!(confirmed(&disk.events)); } for length in [source.len() - 1, source.len() + 1] { disk.bytes.clone_from(&source); disk.bytes.resize(length, 0); disk.events.clear(); - let error = onestore::commit_text(&mut disk, &source, sid, oid, 0..0, "").unwrap_err(); + let error = onestore::confirm(&mut disk, &base).unwrap_err(); assert_eq!(error.state, CommitState::NotCommitted); assert!(disk.events.is_empty()); } @@ -332,7 +392,7 @@ fn text_splices_preserve_formats_objects_and_history_in_one_transaction() { let old_index = RevisionIndex::parse(&old).unwrap(); let before = text_runs(SOURCE, sid, oid); for replacement in ["", "🦀", "A longer replacement with 日本語 and e\u{301}"] { - let edited = onestore::replace_text(SOURCE, sid, oid, 0..10, replacement).unwrap(); + let edited = replace_text(SOURCE, sid, oid, 0..10, replacement).unwrap(); let store = Store::parse(&edited).unwrap(); assert_eq!( store.header.transaction_count, @@ -379,7 +439,7 @@ fn text_splices_preserve_formats_objects_and_history_in_one_transaction() { #[test] fn invalid_text_edits_never_touch_storage() { let (sid, oid) = target(SOURCE); - let source = onestore::replace_text(SOURCE, sid, oid, 0..0, "🦀").unwrap(); + let source = replace_text(SOURCE, sid, oid, 0..0, "🦀").unwrap(); for (range, replacement) in [ (1..1, "x"), (0..999, "x"), @@ -396,7 +456,7 @@ fn invalid_text_edits_never_touch_storage() { random: 1, }; let error = - onestore::commit_text(&mut disk, &source, sid, oid, range, replacement).unwrap_err(); + commit_text(&mut disk, &source, sid, oid, range, replacement).unwrap_err(); assert_eq!(error.state, CommitState::NotCommitted); assert_eq!(disk.operation, 0); assert_eq!(disk.durable, source); @@ -437,7 +497,7 @@ fn cross_run_splices_match_a_character_model_and_preserve_history() { let mut expected = before.clone(); expected.splice(a..b, replacement.chars().map(|c| (c, before[a].1.clone()))); let edited = - onestore::replace_text(SOURCE, sid, oid, offsets[a]..offsets[b], replacement) + replace_text(SOURCE, sid, oid, offsets[a]..offsets[b], replacement) .unwrap(); assert_eq!(characters(&edited), expected, "characters {a}..{b}"); let current_store = Store::parse(&edited).unwrap(); @@ -460,8 +520,8 @@ fn cross_run_splices_match_a_character_model_and_preserve_history() { } } let cleared = - onestore::replace_text(SOURCE, sid, oid, 0..*offsets.last().unwrap(), "").unwrap(); - let typed = onestore::replace_text(&cleared, sid, oid, 0..0, "new").unwrap(); + replace_text(SOURCE, sid, oid, 0..*offsets.last().unwrap(), "").unwrap(); + let typed = replace_text(&cleared, sid, oid, 0..0, "new").unwrap(); let format = &before.last().unwrap().1; assert_eq!( characters(&typed), @@ -472,7 +532,7 @@ fn cross_run_splices_match_a_character_model_and_preserve_history() { ); let text: String = before[..before.len() - 1].iter().map(|(c, _)| *c).collect(); assert_eq!( - onestore::replace_text(SOURCE, sid, oid, 0..*offsets.last().unwrap(), &text).unwrap(), + replace_text(SOURCE, sid, oid, 0..*offsets.last().unwrap(), &text).unwrap(), SOURCE ); } @@ -520,9 +580,9 @@ fn export_native_cross_run_edits() { .unwrap(); let range = range.start..range.end.min(length); let mut edited = - onestore::replace_text(&source, sid, oid, range.clone(), replacement).unwrap(); + replace_text(&source, sid, oid, range.clone(), replacement).unwrap(); if retype { - edited = onestore::replace_text(&edited, sid, oid, 0..0, "Retyped 🦀").unwrap(); + edited = replace_text(&edited, sid, oid, 0..0, "Retyped 🦀").unwrap(); } let candidate = output.join(name).join("candidate"); fs::create_dir_all(&candidate).unwrap(); @@ -540,7 +600,7 @@ fn export_native_cross_run_edits() { fn insertion_at_a_style_boundary_uses_the_following_style() { let (sid, oid) = target(SOURCE); let before = text_runs(SOURCE, sid, oid); - let edited = onestore::replace_text(SOURCE, sid, oid, 18..18, "🦀").unwrap(); + let edited = replace_text(SOURCE, sid, oid, 18..18, "🦀").unwrap(); let mut expected = before.clone(); expected[1]["text"] = format!("🦀{}", before[1]["text"].as_str().unwrap()).into(); assert_eq!(text_runs(&edited, sid, oid), expected); @@ -588,10 +648,10 @@ fn automatic_titles_follow_native_line_and_utf16_limits() { }; let (sid, oid) = check(&source, &expected); let cleared = - onestore::replace_text(&source, sid, oid, 0..text.encode_utf16().count() as u32, "") + replace_text(&source, sid, oid, 0..text.encode_utf16().count() as u32, "") .unwrap(); check(&cleared, ""); - let written = onestore::replace_text(&cleared, sid, oid, 0..0, " new 🦀 name ").unwrap(); + let written = replace_text(&cleared, sid, oid, 0..0, " new 🦀 name ").unwrap(); check(&written, "new 🦀 name"); } } @@ -641,7 +701,7 @@ fn automatic_titles_follow_native_rtl_and_attachment_order() { if node.extra[0].iter().any(|field| field.id == 0x88001cb4) { continue; } - let changed = onestore::replace_text(&source, sid, *oid, 0..0, "Edited ").unwrap(); + let changed = replace_text(&source, sid, *oid, 0..0, "Edited ").unwrap(); let store = Store::parse(&changed).unwrap(); let current = RevisionIndex::parse(&store).unwrap(); let after = Document::parse(¤t).unwrap(); @@ -675,8 +735,8 @@ fn automatic_titles_follow_native_rtl_and_attachment_order() { fn an_emptied_final_run_retains_its_insertion_style() { let (sid, oid) = target(SOURCE); let before = text_runs(SOURCE, sid, oid); - let erased = onestore::replace_text(SOURCE, sid, oid, 22..27, "").unwrap(); - let edited = onestore::replace_text(&erased, sid, oid, 22..22, "a").unwrap(); + let erased = replace_text(SOURCE, sid, oid, 22..27, "").unwrap(); + let edited = replace_text(&erased, sid, oid, 22..22, "a").unwrap(); let mut expected = before.clone(); expected[3]["text"] = "a".into(); assert_eq!(text_runs(&edited, sid, oid), expected); @@ -739,12 +799,12 @@ fn title_text_and_navigation_caches_publish_together() { .collect::>(), ]) }; - let checkpoint = checkpoint::pending(&source, sid, *oid, 0x14001d7a); + let checkpoint = checkpoint::pending(&source, sid, *oid); for (source, write_limit) in [(source.as_slice(), 17), (checkpoint.as_slice(), 257)] { let before = state(source); for replacement in ["Renamed 🦀 日本語", ""] { let edit = - onestore::PreparedEdit::text(source, sid, *oid, 0..end, replacement).unwrap(); + text_transaction(source, sid, *oid, 0..end, replacement).unwrap(); let disk = |fail_at| Disk { visible: source.to_vec(), durable: source.to_vec(), @@ -790,7 +850,7 @@ fn title_text_and_navigation_caches_publish_together() { ); } assert_other_objects_preserved(&old, ¤t, *oid); - for at in source.len().div_ceil(193)..=success.operation { + for at in 1..=success.operation { let mut interrupted = disk(Some(at)); let error = edit.commit(&mut interrupted).unwrap_err(); let observed = state(&interrupted.durable); @@ -827,7 +887,7 @@ fn native_conflict_pages_are_readable_but_not_random_edit_targets() { if !matches!(node.kind, Kind::RichText { .. }) { continue; } - assert!(onestore::replace_text(&source, *sid, *oid, 0..0, "edit").is_err()); + assert!(replace_text(&source, *sid, *oid, 0..0, "edit").is_err()); conflicts += 1; } } @@ -837,7 +897,7 @@ fn native_conflict_pages_are_readable_but_not_random_edit_targets() { #[test] fn interrupted_text_commits_never_publish_mismatched_run_boundaries() { let (sid, oid) = target(SOURCE); - let checkpoint = checkpoint::pending(SOURCE, sid, oid, 0x14001d7a); + let checkpoint = checkpoint::pending(SOURCE, sid, oid); for source in [SOURCE, checkpoint.as_slice()] { let make_disk = |fail_at, random| Disk { visible: source.to_vec(), @@ -849,14 +909,14 @@ fn interrupted_text_commits_never_publish_mismatched_run_boundaries() { }; let before = text_runs(source, sid, oid); let mut success = make_disk(None, 1); - onestore::commit_text(&mut success, source, sid, oid, 0..23, "🐈 mixed edit").unwrap(); + commit_text(&mut success, source, sid, oid, 0..23, "🐈 mixed edit").unwrap(); let after = text_runs(&success.durable, sid, oid); assert_ne!(before, after); - for at in source.len().div_ceil(193)..=success.operation { + for at in 1..=success.operation { for seed in [1, 42] { let mut disk = make_disk(Some(at), seed); let error = - onestore::commit_text(&mut disk, source, sid, oid, 0..23, "🐈 mixed edit") + commit_text(&mut disk, source, sid, oid, 0..23, "🐈 mixed edit") .unwrap_err(); let observed = text_runs(&disk.durable, sid, oid); match error.state { @@ -906,7 +966,7 @@ fn empty_and_legacy_native_text_gain_unicode_without_losing_existing_properties( let properties = PropertySets::parse(blob).unwrap(); assert!(!properties.sets[0].iter().any(|p| p.id == 0x1c001c22)); let expected = format!("🦀 café {original}"); - let written = onestore::replace_text(&source, sid, oid, 0..0, "🦀 café ").unwrap(); + let written = replace_text(&source, sid, oid, 0..0, "🦀 café ").unwrap(); let after_store = Store::parse(&written).unwrap(); assert!(after_store.checksum_mismatches.is_empty()); let after = RevisionIndex::parse(&after_store).unwrap(); @@ -953,12 +1013,12 @@ fn empty_and_legacy_native_text_gain_unicode_without_losing_existing_properties( random, }; let mut success = disk(None, 1); - onestore::commit_text(&mut success, &source, sid, oid, 0..0, "🦀 café ").unwrap(); - for at in source.len().div_ceil(193)..=success.operation { + commit_text(&mut success, &source, sid, oid, 0..0, "🦀 café ").unwrap(); + for at in 1..=success.operation { for seed in [1, 42] { let mut interrupted = disk(Some(at), seed); let error = - onestore::commit_text(&mut interrupted, &source, sid, oid, 0..0, "🦀 café ") + commit_text(&mut interrupted, &source, sid, oid, 0..0, "🦀 café ") .unwrap_err(); let actual = text_runs(&interrupted.durable, sid, oid); match error.state { diff --git a/crates/onestore/tests/formatting.rs b/crates/onestore/tests/formatting.rs index 2fbdff3dc7eae65977f8049a722c0d9bef34766a..168ea29a9c670856cd3ef4558738e4a3756724f5 100644 --- a/crates/onestore/tests/formatting.rs +++ b/crates/onestore/tests/formatting.rs @@ -1,10 +1,13 @@ +#[path = "support/ops.rs"] +mod ops; #[path = "support/current.rs"] mod current; #[path = "support/disk.rs"] mod disk; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, TextAttribute as A, + ExGuid, RevisionIndex, Store, TextAttribute as A, document::{Document, Kind}, + op::PageOp, }; use serde_json::{Value, json}; fn target(source: &[u8]) -> (ExGuid, ExGuid) { @@ -85,13 +88,7 @@ fn overlapping_unicode_format_edits_match_an_independent_character_model() { json!(0x00ff00_u32), ), }; - let edit = PreparedEdit::format( - &source, - sid, - id, - offsets[start]..offsets[end], - std::slice::from_ref(&attribute), - ) + let edit = ops::page_op(&source, sid, PageOp::Format { text: id, range: offsets[start]..offsets[end], set: std::slice::from_ref(&attribute).to_vec(), clear: Vec::new() }) .unwrap(); for (_, style) in &mut expected[start..end] { style[key] = value.clone(); @@ -102,13 +99,7 @@ fn overlapping_unicode_format_edits_match_an_independent_character_model() { "seed {seed}, step {step}" ); assert_eq!( - PreparedEdit::format( - edit.as_bytes(), - sid, - id, - offsets[start]..offsets[end], - &[attribute] - ) + ops::page_op(edit.as_bytes(), sid, PageOp::Format { text: id, range: offsets[start]..offsets[end], set: vec![attribute], clear: Vec::new() }) .unwrap() .as_bytes(), edit.as_bytes() @@ -130,21 +121,9 @@ fn overlapping_unicode_format_edits_match_an_independent_character_model() { fn script_positions_are_exclusive_and_explicit_false_overrides_true() { let source = onestore::create_section("script.one", "abc", "Author").unwrap(); let (sid, id) = target(&source); - let superscript = PreparedEdit::format( - &source, - sid, - id, - 0..3, - &[A::Superscript(true), A::Bold(true)], - ) + let superscript = ops::page_op(&source, sid, PageOp::Format { text: id, range: 0..3, set: vec![A::Superscript(true), A::Bold(true)], clear: Vec::new() }) .unwrap(); - let subscript = PreparedEdit::format( - superscript.as_bytes(), - sid, - id, - 1..2, - &[A::Subscript(true), A::Bold(false)], - ) + let subscript = ops::page_op(superscript.as_bytes(), sid, PageOp::Format { text: id, range: 1..2, set: vec![A::Subscript(true), A::Bold(false)], clear: Vec::new() }) .unwrap(); let chars = characters(subscript.as_bytes(), sid, id); assert_eq!(chars[0].1["superscript"], true); @@ -159,15 +138,9 @@ fn script_positions_are_exclusive_and_explicit_false_overrides_true() { fn empty_paragraph_style_is_used_by_later_text_edits() { let source = onestore::create_section("empty.one", "", "Author").unwrap(); let (sid, id) = target(&source); - let formatted = PreparedEdit::format( - &source, - sid, - id, - 0..0, - &[A::Italic(true), A::FontSize(18.0)], - ) + let formatted = ops::page_op(&source, sid, PageOp::Format { text: id, range: 0..0, set: vec![A::Italic(true), A::FontSize(18.0)], clear: Vec::new() }) .unwrap(); - let filled = PreparedEdit::text(formatted.as_bytes(), sid, id, 0..0, "Added 🦀").unwrap(); + let filled = ops::page_op(formatted.as_bytes(), sid, PageOp::Text { text: id, range: 0..0, with: "Added 🦀".into() }).unwrap(); for (_, format) in characters(filled.as_bytes(), sid, id) { assert_eq!(format["italic"], true); assert_eq!(format["font_size"], 18.0); @@ -179,7 +152,7 @@ fn invalid_ranges_attributes_and_fields_are_rejected() { let (sid, id) = target(&source); for (start, end) in [(2, 3), (1, 2), (0, 8), (2, 2), (3, 1), (1, 1)] { let range = start..end; - assert!(PreparedEdit::format(&source, sid, id, range, &[A::Bold(true)]).is_err()); + assert!(ops::page_op(&source, sid, PageOp::Format { text: id, range, set: vec![A::Bold(true)], clear: Vec::new() }).is_err()); } for attributes in [ vec![], @@ -188,24 +161,18 @@ fn invalid_ranges_attributes_and_fields_are_rejected() { vec![A::Font("".into())], vec![A::Font("a\0b".into())], ] { - assert!(PreparedEdit::format(&source, sid, id, 0..4, &attributes).is_err()); + assert!(ops::page_op(&source, sid, PageOp::Format { text: id, range: 0..4, set: attributes.to_vec(), clear: Vec::new() }).is_err()); } for size in [f32::NAN, f32::INFINITY, 0.0, 5.5, 130.5, 144.0, 144.5, 12.1] { - assert!(PreparedEdit::format(&source, sid, id, 0..4, &[A::FontSize(size)]).is_err()); + assert!(ops::page_op(&source, sid, PageOp::Format { text: id, range: 0..4, set: vec![A::FontSize(size)], clear: Vec::new() }).is_err()); } - assert!(PreparedEdit::format(&source, sid, ExGuid::default(), 0..4, &[A::Bold(true)]).is_err()); + assert!(ops::page_op(&source, sid, PageOp::Format { text: ExGuid::default(), range: 0..4, set: vec![A::Bold(true)], clear: Vec::new() }).is_err()); } #[test] fn formatting_publication_faults_preserve_complete_old_or_new_styles() { let source = onestore::create_section("atomic.one", "Before 🦀 after", "Author").unwrap(); let (sid, id) = target(&source); - let edit = PreparedEdit::format( - &source, - sid, - id, - 2..10, - &[A::Bold(true), A::Color(Some([8, 64, 128]))], - ) + let edit = ops::page_op(&source, sid, PageOp::Format { text: id, range: 2..10, set: vec![A::Bold(true), A::Color(Some([8, 64, 128]))], clear: Vec::new() }) .unwrap(); let before = current::current(&source); let after = current::current(edit.as_bytes()); @@ -251,7 +218,7 @@ fn export_native_formatting_candidates() { let mut save = |name: &str, source: &[u8], sid, id, range: std::ops::Range, attributes: &[A]| { let prepared = - PreparedEdit::format(source, sid, id, range.clone(), attributes).unwrap(); + ops::page_op(source, sid, PageOp::Format { text: id, range: range.clone(), set: attributes.to_vec(), clear: Vec::new() }).unwrap(); fs::write(output.join(format!("{name}.one")), prepared.as_bytes()).unwrap(); manifest.push( json!({"name":name,"space":sid,"object":id,"range":range,"attributes":attributes}), @@ -329,21 +296,9 @@ fn export_native_formatting_candidates() { ); let empty = onestore::create_section("empty.one", "", "Author").unwrap(); let (empty_sid, empty_id) = target(&empty); - let formatted = PreparedEdit::format( - &empty, - empty_sid, - empty_id, - 0..0, - &[A::FontSize(18.0), A::Italic(true)], - ) + let formatted = ops::page_op(&empty, empty_sid, PageOp::Format { text: empty_id, range: 0..0, set: vec![A::FontSize(18.0), A::Italic(true)], clear: Vec::new() }) .unwrap(); - let typed = PreparedEdit::text( - formatted.as_bytes(), - empty_sid, - empty_id, - 0..0, - "Typed café 🦀", - ) + let typed = ops::page_op(formatted.as_bytes(), empty_sid, PageOp::Text { text: empty_id, range: 0..0, with: "Typed café 🦀".into() }) .unwrap(); fs::write(output.join("empty-then-type.one"), typed.as_bytes()).unwrap(); let native = include_bytes!("../../../corpus/native-ink/20260905-ui/notebook/synthetic.one"); diff --git a/crates/onestore/tests/insertion.rs b/crates/onestore/tests/insertion.rs index cd04819fdb60c9c6e6adabf559eb69d00f3a7cd0..f24b03b3d26019258ee549e4ebb61c1a75660465 100644 --- a/crates/onestore/tests/insertion.rs +++ b/crates/onestore/tests/insertion.rs @@ -4,11 +4,65 @@ mod checkpoint; mod current; #[path = "support/disk.rs"] mod disk; +#[path = "support/ops.rs"] +mod ops; use onestore::{ - ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TextAttribute as A, + ExGuid, RevisionIndex, Store, TextAttribute as A, document::{Document, Kind}, + op::{Edit, Op, PageOp}, }; +use std::ops::Range; + +/// Where an insertion goes: a paragraph in a container before a child of it, or last; or a +/// new outline at a point on the page. +#[derive(Clone, Copy)] +enum Place { + In(ExGuid, Option), + Outline(f32, f32), +} + +/// The ops inserting `text` at `place` with `formats` set over UTF-16 ranges of it, and the +/// identities of the new paragraph (or outline) and text. +fn insertion( + space: ExGuid, + place: Place, + text: &str, + formats: &[(Range, Vec)], +) -> (Vec, ExGuid, ExGuid) { + let paragraph = ops::paragraph(text); + let text = paragraph.text().unwrap().id; + let (op, object) = match place { + Place::In(container, before) => { + let id = paragraph.id; + let paragraphs = vec![paragraph]; + ( + PageOp::Insert { + container, + before, + paragraphs, + }, + id, + ) + } + Place::Outline(x, y) => { + let object = ops::outline(x, y, vec![paragraph]); + let id = object.id(); + (PageOp::Add { object, before: None }, id) + } + }; + let mut result = vec![Op::Page { space, op }]; + for (range, set) in formats { + let op = PageOp::Format { + text, + range: range.clone(), + set: set.clone(), + clear: Vec::new(), + }; + result.push(Op::Page { space, op }); + } + (result, object, text) +} fn targets(source: &[u8]) -> (ExGuid, ExGuid, ExGuid, ExGuid, ExGuid) { let store = Store::parse(source).unwrap(); @@ -34,23 +88,15 @@ fn atomic_formatted_insertions_match_a_character_model_and_preserve_history() { let source = onestore::create_section("rich.one", "Original", "Author").unwrap(); let (sid, page, outline, _, _) = targets(&source); let text = "ab🦀 e\u{301} 東京\rEnd\t!"; - let plain = Insertion::paragraph(outline, None, text, "Author").unwrap(); - assert!( - serde_json::to_value(&plain) - .unwrap() - .get("formats") - .is_none() - ); - let restored: Insertion = serde_json::from_slice(&serde_json::to_vec(&plain).unwrap()).unwrap(); - assert_eq!(plain, restored); - let base = PreparedEdit::insert(&source, sid, &plain).unwrap(); - let base_store = Store::parse(base.as_bytes()).unwrap(); + let (plain, _, plain_text) = insertion(sid, Place::In(outline, None), text, &[]); + let base = ops::edited(&source, plain).unwrap(); + let base_store = Store::parse(&base).unwrap(); let base_index = RevisionIndex::parse(&base_store).unwrap(); let base_doc = Document::parse(&base_index).unwrap(); let base_space = &base_doc.spaces[&sid]; let base_view = &base_space.revisions[&base_space.contexts[&ExGuid::default()]]; let default = - serde_json::to_value(&base_view.text_runs(plain.text_object()).unwrap()[0].format).unwrap(); + serde_json::to_value(&base_view.text_runs(plain_text).unwrap()[0].format).unwrap(); let offsets: Vec = std::iter::once(0) .chain(text.chars().scan(0, |n, c| { *n += c.len_utf16() as u32; @@ -62,12 +108,8 @@ fn atomic_formatted_insertions_match_a_character_model_and_preserve_history() { for seed in 1..=48_u64 { let mut random = seed; let mut expected: Vec<_> = text.chars().map(|c| (c, default.clone())).collect(); - let mut intent = if seed % 2 == 0 { - Insertion::outline(page, 72.0, 144.0, text, "Author").unwrap() - } else { - plain.clone() - }; - // Descending construction also exercises persisted ordering independently of call order. + let mut formats = Vec::new(); + // Descending construction also exercises the order formats apply in. for i in (0..expected.len()).rev() { random ^= random << 13; random ^= random >> 7; @@ -106,18 +148,22 @@ fn atomic_formatted_insertions_match_a_character_model_and_preserve_history() { json!({"bold":false,"color":0xff000000_u32,"highlight":0xff000000_u32}), ), }; - intent = intent - .with_formatting(offsets[i]..offsets[i + 1], &attributes) - .unwrap(); + formats.push((offsets[i]..offsets[i + 1], attributes)); for (key, value) in values.as_object().unwrap() { expected[i].1[key] = value.clone(); } } - let restored: Insertion = - serde_json::from_slice(&serde_json::to_vec(&intent).unwrap()).unwrap(); - assert_eq!(restored, intent); - let edited = PreparedEdit::insert(&source, sid, &restored).unwrap(); - let store = Store::parse(edited.as_bytes()).unwrap(); + let place = if seed % 2 == 0 { + Place::Outline(72.0, 144.0) + } else { + Place::In(outline, None) + }; + let (ops, _, inserted) = insertion(sid, place, text, &formats); + let edit = Edit { at: ops::now(), ops }; + let restored: Edit = serde_json::from_slice(&serde_json::to_vec(&edit).unwrap()).unwrap(); + assert_eq!(restored, edit); + let edited = ops::edited(&source, restored.ops).unwrap(); + let store = Store::parse(&edited).unwrap(); assert_eq!( store.header.transaction_count, old_store.header.transaction_count + 1 @@ -139,8 +185,11 @@ fn atomic_formatted_insertions_match_a_character_model_and_preserve_history() { let document = Document::parse(&index).unwrap(); let space = &document.spaces[&sid]; let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + if let Place::Outline(..) = place { + assert!(view.nodes[&page].children.len() > 1); + } let actual: Vec<_> = view - .text_runs(intent.text_object()) + .text_runs(inserted) .unwrap() .into_iter() .flat_map(|run| { @@ -152,88 +201,34 @@ fn atomic_formatted_insertions_match_a_character_model_and_preserve_history() { } } -#[test] -fn invalid_or_forged_insertion_formats_fail_before_source_parsing() { - use serde_json::json; - let source = onestore::create_section("invalid-rich.one", "Original", "Author").unwrap(); - let (sid, _, outline, _, _) = targets(&source); - let plain = Insertion::paragraph(outline, None, "a🦀e\u{301}b", "Author").unwrap(); - for (start, end) in [(0, 0), (2, 3), (1, 2), (4, 3), (0, 7)] { - let range = start..end; - assert!(plain.with_formatting(range, &[A::Bold(true)]).is_err()); - } - for attributes in [ - vec![], - vec![A::Bold(true), A::Bold(false)], - vec![A::FontSize(f32::NAN)], - vec![A::FontSize(5.5)], - vec![A::Font("a\0b".into())], - vec![A::Superscript(true), A::Subscript(true)], - ] { - assert!(plain.with_formatting(1..3, &attributes).is_err()); - } - let formatted = plain.with_formatting(1..3, &[A::Bold(true)]).unwrap(); - for range in [0..3, 1..3, 1..4] { - assert!( - formatted - .with_formatting(range, &[A::Italic(true)]) - .is_err() - ); - } - let encoded = serde_json::to_value(&formatted).unwrap(); - for mutate in 0..6 { - let mut value = encoded.clone(); - match mutate { - 0 => value["formats"][0]["guid"] = serde_json::to_value([0; 16]).unwrap(), - 1 => value["formats"][0]["guid"] = value["guid"].clone(), - 2 => { - let duplicate = value["formats"][0].clone(); - value["formats"].as_array_mut().unwrap().push(duplicate); - } - 3 => value["formats"][0]["range"] = json!({"start":2,"end":3}), - 4 => value["formats"][0]["attributes"] = json!([]), - _ => value["formats"][0]["range"] = json!({"start":0,"end":u32::MAX}), - } - let forged: Insertion = serde_json::from_value(value).unwrap(); - let error = PreparedEdit::insert(&[], sid, &forged).err().unwrap(); - assert_eq!( - error, - PreparedEdit::insert(&source, sid, &forged).err().unwrap() - ); - } -} - #[test] fn formatted_empty_insertions_preserve_the_insertion_style_for_later_typing() { let source = onestore::create_section("empty-rich.one", "Original", "Author").unwrap(); - let (sid, page, outline, _, _) = targets(&source); - for plain in [ - Insertion::outline(page, 72.0, 144.0, "", "Author").unwrap(), - Insertion::paragraph(outline, None, "", "Author").unwrap(), - ] { - let intent = plain - .with_formatting(0..0, &[A::Italic(true), A::FontSize(18.0)]) - .unwrap(); - assert!(intent.with_formatting(0..0, &[A::Bold(true)]).is_err()); - let inserted = PreparedEdit::insert(&source, sid, &intent).unwrap(); - let typed = PreparedEdit::text( - inserted.as_bytes(), + let (sid, _, outline, _, _) = targets(&source); + for place in [Place::Outline(72.0, 144.0), Place::In(outline, None)] { + let style = [(0..0, vec![A::Italic(true), A::FontSize(18.0)])]; + let (ops, _, text) = insertion(sid, place, "", &style); + let inserted = ops::edited(&source, ops).unwrap(); + let typed = ops::page_edited( + &inserted, sid, - intent.text_object(), - 0..0, - "Typed 🦀", + vec![PageOp::Text { + text, + range: 0..0, + with: "Typed 🦀".into(), + }], ) .unwrap(); - for (bytes, text) in [(inserted.as_bytes(), ""), (typed.as_bytes(), "Typed 🦀")] { + for (bytes, expected) in [(&inserted, ""), (&typed, "Typed 🦀")] { let store = Store::parse(bytes).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); index.validate_current().unwrap(); let document = Document::parse(&index).unwrap(); let space = &document.spaces[&sid]; let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - let runs = view.text_runs(intent.text_object()).unwrap(); + let runs = view.text_runs(text).unwrap(); assert_eq!(runs.len(), 1); - assert_eq!(runs[0].text, text); + assert_eq!(runs[0].text, expected); assert_eq!(runs[0].format.italic, Some(true)); assert_eq!(runs[0].format.font_size, Some(18.0)); } @@ -263,11 +258,9 @@ fn export_native_formatted_insertions() { ("empty", generated.as_slice(), true), ("empty-typed", generated.as_slice(), true), ] { - let (sid, page, outline, paragraph, _) = targets(source); - let insertion = match name { - "native-paragraph" => { - Insertion::paragraph(outline, Some(paragraph), text, "Rich author") - } + let (sid, _, outline, paragraph, _) = targets(source); + let place = match name { + "native-paragraph" => Place::In(outline, Some(paragraph)), "native-cell" => { let store = Store::parse(source).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); @@ -280,24 +273,14 @@ fn export_native_formatted_insertions() { .find(|(_, node)| matches!(node.kind, Kind::Cell { .. })) .unwrap() .0; - Insertion::paragraph(cell, None, text, "Rich author") + Place::In(cell, None) } - _ => Insertion::outline( - page, - 72.0, - 144.0, - if empty { "" } else { text }, - "Rich author", - ), - } - .unwrap(); - let mut intent = insertion; - if empty { - intent = intent - .with_formatting(0..0, &[A::Italic(true), A::FontSize(18.0)]) - .unwrap(); + _ => Place::Outline(72.0, 144.0), + }; + let formats = if empty { + vec![(0..0, vec![A::Italic(true), A::FontSize(18.0)])] } else { - for (label, attributes) in [ + [ ("Bold", vec![A::Bold(true), A::Underline(true)]), ("🦀", vec![A::Font("Arial".into()), A::FontSize(13.5)]), ("italic", vec![A::Italic(true), A::Strike(true)]), @@ -319,36 +302,33 @@ fn export_native_formatted_insertions() { A::Highlight(None), ], ), - ] { + ] + .into_iter() + .map(|(label, attributes)| { let start = text[..text.find(label).unwrap()].encode_utf16().count() as u32; - intent = intent - .with_formatting( - start..start + label.encode_utf16().count() as u32, - &attributes, - ) - .unwrap(); - } - } - let inserted = PreparedEdit::insert(source, sid, &intent).unwrap(); - let bytes = if name == "empty-typed" { - PreparedEdit::text( - inserted.as_bytes(), - sid, - intent.text_object(), - 0..0, - "Typed café 🦀", - ) - .unwrap() - .as_bytes() - .to_vec() - } else { - inserted.as_bytes().to_vec() + (start..start + label.encode_utf16().count() as u32, attributes) + }) + .collect() }; + let (mut ops, object, inserted) = + insertion(sid, place, if empty { "" } else { text }, &formats); + if name == "empty-typed" { + let op = PageOp::Text { + text: inserted, + range: 0..0, + with: "Typed café 🦀".into(), + }; + ops.push(Op::Page { space: sid, op }); + } + let edit = Edit { at: ops::now(), ops }; + let bytes = ops::edited(source, edit.ops.clone()).unwrap(); current::current(&bytes); let notebook = output.join(name); fs::create_dir(¬ebook).unwrap(); fs::write(notebook.join("synthetic.one"), &bytes).unwrap(); - manifest.push(serde_json::json!({"name":name,"space":sid,"intent":intent})); + manifest.push(serde_json::json!({ + "name": name, "space": sid, "object": object, "text": inserted, "edit": edit + })); } fs::write( output.join("manifest.json"), @@ -361,23 +341,15 @@ fn export_native_formatted_insertions() { fn paragraph_and_outline_insertions_publish_metadata_and_references_together() { let source = onestore::create_section("insertion.one", "Original", "Original author").unwrap(); let (sid, page, outline, paragraph, original_text) = targets(&source); - for (intent, expected_parent, expected_x, expected_y) in [ - ( - Insertion::paragraph(outline, Some(paragraph), "First 🦀\rSecond", "New author") - .unwrap(), - outline, - None, - None, - ), - ( - Insertion::outline(page, 144.0, 18.0, "First 🦀\rSecond", "New author").unwrap(), - page, - Some(144.0), - Some(18.0), - ), + for (place, expected_parent, expected_x, expected_y) in [ + (Place::In(outline, Some(paragraph)), outline, None, None), + (Place::Outline(144.0, 18.0), page, Some(144.0), Some(18.0)), ] { - let prepared = PreparedEdit::insert(&source, sid, &intent).unwrap(); - let store = Store::parse(prepared.as_bytes()).unwrap(); + let (ops, object, text) = insertion(sid, place, "First 🦀\rSecond", &[]); + let transaction = ops::transaction(&source, "New author", ops).unwrap().unwrap(); + let mut written = source.clone(); + transaction.apply(&mut written).unwrap(); + let store = Store::parse(&written).unwrap(); assert_eq!( store.header.transaction_count, Store::parse(&source).unwrap().header.transaction_count + 1 @@ -387,18 +359,14 @@ fn paragraph_and_outline_insertions_publish_metadata_and_references_together() { let document = Document::parse(&index).unwrap(); let space = &document.spaces[&sid]; let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - assert!( - view.nodes[&expected_parent] - .children - .contains(&intent.object()) - ); - assert_eq!(view.nodes[&intent.object()].layout.x, expected_x); - assert_eq!(view.nodes[&intent.object()].layout.y, expected_y); + assert!(view.nodes[&expected_parent].children.contains(&object)); + assert_eq!(view.nodes[&object].layout.x, expected_x); + assert_eq!(view.nodes[&object].layout.y, expected_y); assert!( matches!(&view.nodes[&original_text].kind, Kind::RichText { text, .. } if text == "Original") ); assert!( - matches!(&view.nodes[&intent.text_object()].kind, Kind::RichText { text, .. } if text == "First 🦀\rSecond") + matches!(&view.nodes[&text].kind, Kind::RichText { text, .. } if text == "First 🦀\rSecond") ); assert!( matches!(&view.nodes[&page].kind, Kind::Page { alternate_title, .. } if alternate_title.as_deref() == Some("First 🦀")) @@ -411,7 +379,7 @@ fn paragraph_and_outline_insertions_publish_metadata_and_references_together() { .values() .any(|node| matches!(&node.kind, Kind::Author { name } if name.as_deref() == Some("New author"))) ); - let runs = view.text_runs(intent.text_object()).unwrap(); + let runs = view.text_runs(text).unwrap(); assert_eq!(runs.len(), 1); assert_eq!(runs[0].format.font.as_deref(), Some("Calibri")); assert_eq!(runs[0].format.font_size, Some(11.0)); @@ -434,26 +402,20 @@ fn repeated_insertions_and_formatting_share_immutable_objects() { let (sid, _, outline, _, _) = targets(&source); let mut texts = Vec::new(); for _ in 0..12 { - let insertion = - Insertion::paragraph(outline, None, "Repeated paragraph", "Same author").unwrap(); - source = PreparedEdit::insert(&source, sid, &insertion) + let (ops, _, text) = insertion(sid, Place::In(outline, None), "Repeated paragraph", &[]); + let transaction = ops::transaction(&source, "Same author", ops).unwrap().unwrap(); + transaction.apply(&mut source).unwrap(); + let bold = PageOp::Format { + text, + range: 1..8, + set: vec![A::Bold(true), A::FontSize(20.0)], + clear: Vec::new(), + }; + let transaction = ops::transaction(&source, "Same author", vec![Op::Page { space: sid, op: bold }]) .unwrap() - .as_bytes() - .to_vec(); - source = PreparedEdit::format( - &source, - sid, - insertion.text_object(), - 1..8, - &[ - onestore::TextAttribute::Bold(true), - onestore::TextAttribute::FontSize(20.0), - ], - ) - .unwrap() - .as_bytes() - .to_vec(); - texts.push(insertion.text_object()); + .unwrap(); + transaction.apply(&mut source).unwrap(); + texts.push(text); } let store = Store::parse(&source).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); @@ -494,103 +456,54 @@ fn repeated_insertions_and_formatting_share_immutable_objects() { fn serialized_insertions_rebase_with_the_same_objects_and_preserve_remote_edits() { let source = onestore::create_section("rebase.one", "Original", "Author").unwrap(); let (sid, _, outline, paragraph, text) = targets(&source); - let intent = Insertion::paragraph(outline, Some(paragraph), "Inserted", "Author") - .unwrap() - .with_formatting(0..3, &[A::Bold(true), A::Color(Some([12, 34, 56]))]) - .unwrap(); - let encoded = serde_json::to_vec(&intent).unwrap(); - let restored: Insertion = serde_json::from_slice(&encoded).unwrap(); - assert_eq!(intent.object(), restored.object()); - assert_eq!(intent.text_object(), restored.text_object()); - assert_eq!(intent, restored); - let original_preparation = PreparedEdit::insert(&source, sid, &intent).unwrap(); - let remote = PreparedEdit::text(&source, sid, text, 0..0, "Remote ").unwrap(); - let updated = PreparedEdit::insert(remote.as_bytes(), sid, &restored).unwrap(); - let store = Store::parse(updated.as_bytes()).unwrap(); + let formats = [(0..3, vec![A::Bold(true), A::Color(Some([12, 34, 56]))])]; + let (ops, object, inserted) = + insertion(sid, Place::In(outline, Some(paragraph)), "Inserted", &formats); + let encoded = serde_json::to_vec(&ops).unwrap(); + let restored: Vec = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(ops, restored); + let original = ops::edited(&source, ops).unwrap(); + let remote = ops::page_edited( + &source, + sid, + vec![PageOp::Text { + text, + range: 0..0, + with: "Remote ".into(), + }], + ) + .unwrap(); + let updated = ops::edited(&remote, restored.clone()).unwrap(); + let store = Store::parse(&updated).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); let space = &document.spaces[&sid]; let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - assert_eq!( - view.nodes[&outline].children, - [restored.object(), paragraph] - ); + assert_eq!(view.nodes[&outline].children, [object, paragraph]); assert!( matches!(&view.nodes[&text].kind, Kind::RichText { text, .. } if text == "Remote Original") ); assert!( - matches!(&view.nodes[&restored.text_object()].kind, Kind::RichText { text, .. } if text == "Inserted") + matches!(&view.nodes[&inserted].kind, Kind::RichText { text, .. } if text == "Inserted") ); - let runs = view.text_runs(restored.text_object()).unwrap(); + let runs = view.text_runs(inserted).unwrap(); assert_eq!(runs[0].text, "Ins"); assert_eq!(runs[0].format.bold, Some(true)); assert_eq!(runs[0].format.color, Some(0x38220c)); assert_eq!(runs[1].text, "erted"); assert_ne!(runs[1].format.bold, Some(true)); - assert!(PreparedEdit::insert(original_preparation.as_bytes(), sid, &restored).is_err()); - assert!(PreparedEdit::insert(updated.as_bytes(), sid, &restored).is_err()); -} - -#[test] -fn repositioning_preserves_intent_identity_and_kind() { - let source = onestore::create_section("placement.one", "Original", "Author").unwrap(); - let (sid, page, outline, paragraph, _) = targets(&source); - let p = Insertion::paragraph(outline, Some(paragraph), "Inserted", "Author") - .unwrap() - .with_formatting(1..4, &[A::Italic(true)]) - .unwrap(); - let o = Insertion::outline(page, 144.0, 144.0, "Inserted", "Author") - .unwrap() - .with_formatting(0..8, &[A::FontSize(18.0)]) - .unwrap(); - assert!(p.reposition_outline(page, 72.0, 72.0).is_err()); - assert!(o.reposition_paragraph(outline, None).is_err()); - assert!(p.reposition_paragraph(ExGuid::default(), None).is_err()); - assert!(o.reposition_outline(page, f32::NAN, 72.0).is_err()); - for (original, moved) in [ - (&p, p.reposition_paragraph(outline, None).unwrap()), - (&o, o.reposition_outline(page, 288.0, 360.0).unwrap()), - ] { - let mut before = serde_json::to_value(original).unwrap(); - let mut after = serde_json::to_value(&moved).unwrap(); - for name in ["parent", "placement"] { - before.as_object_mut().unwrap().remove(name); - after.as_object_mut().unwrap().remove(name); - } - assert_eq!(before, after); - assert_eq!(original.object(), moved.object()); - assert_eq!(original.text_object(), moved.text_object()); - let restored: Insertion = - serde_json::from_value(serde_json::to_value(&moved).unwrap()).unwrap(); - let prepared = PreparedEdit::insert(&source, sid, &restored).unwrap(); - let store = Store::parse(prepared.as_bytes()).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - index.validate_current().unwrap(); - let doc = Document::parse(&index).unwrap(); - let space = &doc.spaces[&sid]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - if original == &p { - assert_eq!(view.nodes[&outline].children, [paragraph, moved.object()]); - } else { - assert_eq!( - ( - view.nodes[&moved.object()].layout.x, - view.nodes[&moved.object()].layout.y - ), - (Some(288.0), Some(360.0)) - ); - } - } + assert!(ops::edited(&original, restored.clone()).is_err()); + assert!(ops::edited(&updated, restored).is_err()); } #[test] #[cfg(any(unix, windows))] -fn filesystem_insertion_uses_the_prepared_identity_and_rejects_stale_replay() { +fn filesystem_insertion_commits_once_and_rejects_stale_replay() { use std::{fs, io::Write}; let source = onestore::create_section("file.one", "Original", "Author").unwrap(); let (sid, _, outline, _, _) = targets(&source); - let intent = Insertion::paragraph(outline, None, "File insertion", "Author").unwrap(); - let path = std::env::temp_dir().join(format!("onestore-insertion-{}.one", intent.object())); + let (ops, object, _) = insertion(sid, Place::In(outline, None), "File insertion", &[]); + let path = std::env::temp_dir().join(format!("onestore-insertion-{object}.one")); let mut file = fs::OpenOptions::new() .write(true) .create_new(true) @@ -599,60 +512,71 @@ fn filesystem_insertion_uses_the_prepared_identity_and_rejects_stale_replay() { file.write_all(&source).unwrap(); file.sync_all().unwrap(); drop(file); - let edit = PreparedEdit::insert(&source, sid, &intent).unwrap(); - let result = edit.commit_file(&path); + let transaction = ops::transaction(&source, "Author", ops).unwrap().unwrap(); + let result = transaction.commit_file(&path); let written = onestore::read_file(&path).unwrap(); - let repeated = edit.commit_file(&path).unwrap_err(); + let repeated = transaction.commit_file(&path).unwrap_err(); fs::remove_file(path).unwrap(); result.unwrap(); - assert_eq!(written, edit.as_bytes()); + let mut expected = source.clone(); + transaction.apply(&mut expected).unwrap(); + assert_eq!(written, expected); assert_eq!(repeated.state, onestore::CommitState::NotCommitted); assert_eq!(repeated.error.kind(), std::io::ErrorKind::ResourceBusy); } #[test] -fn anchors_targets_serialized_identities_and_text_are_validated_before_publication() { +fn anchors_targets_identities_and_text_are_validated_before_publication() { let source = onestore::create_section("invalid.one", "Original", "Author").unwrap(); - let (sid, page, outline, paragraph, text) = targets(&source); - assert!(Insertion::outline(page, f32::NAN, 0.0, "Text", "Author").is_err()); - assert!(Insertion::outline(page, 0.0, f32::INFINITY, "Text", "Author").is_err()); + let (sid, page, outline, _, text) = targets(&source); + let refused = |source: &[u8], place, content: &str| { + ops::edited(source, insertion(sid, place, content, &[]).0).is_err() + }; + assert!(refused(&source, Place::Outline(f32::NAN, 0.0), "Text")); + assert!(refused(&source, Place::Outline(0.0, f32::INFINITY), "Text")); for content in ["a\0b", "a\nb", "a\u{fffc}b"] { - assert!(Insertion::paragraph(outline, None, content, "Author").is_err()); + assert!(refused(&source, Place::In(outline, None), content)); } - assert!(Insertion::paragraph(outline, None, "Text", "a\0b").is_err()); - for intent in [ - Insertion::paragraph(outline, Some(text), "Text", "Author").unwrap(), - Insertion::paragraph(text, None, "Text", "Author").unwrap(), - Insertion::paragraph(page, None, "Text", "Author").unwrap(), - Insertion::outline(paragraph, 0.0, 0.0, "Text", "Author").unwrap(), + assert!( + ops::transaction(&source, "a\0b", insertion(sid, Place::In(outline, None), "Text", &[]).0) + .is_err() + ); + for place in [ + Place::In(outline, Some(text)), + Place::In(text, None), + Place::In(page, None), ] { - assert!(PreparedEdit::insert(&source, sid, &intent).is_err()); + assert!(refused(&source, place, "Text")); } - let intent = Insertion::paragraph(outline, None, "Text", "Author").unwrap(); - let created = PreparedEdit::insert(&source, sid, &intent).unwrap(); - let before_missing = - Insertion::paragraph(outline, Some(intent.object()), "Anchored", "Author").unwrap(); - assert!(PreparedEdit::insert(&source, sid, &before_missing).is_err()); - assert!(PreparedEdit::insert(created.as_bytes(), sid, &before_missing).is_ok()); - let mut encoded = serde_json::to_value(&intent).unwrap(); - encoded["parent"] = serde_json::to_value(intent.object()).unwrap(); - let collision: Insertion = serde_json::from_value(encoded).unwrap(); - assert!(PreparedEdit::insert(created.as_bytes(), sid, &collision).is_err()); + let (ops, object, _) = insertion(sid, Place::In(outline, None), "Text", &[]); + let created = ops::edited(&source, ops.clone()).unwrap(); + let before_created = Place::In(outline, Some(object)); + assert!(refused(&source, before_created, "Anchored")); + assert!(!refused(&created, before_created, "Anchored")); + // A new object's identity may not already be on the page. + assert!(ops::edited(&created, ops).is_err()); } #[test] fn insertions_into_nested_paragraphs_and_native_table_cells_preserve_structure() { let source = onestore::create_section("nested.one", "Original", "Author").unwrap(); let (sid, _, outline, paragraph, _) = targets(&source); - let child = Insertion::paragraph(paragraph, None, "Nested", "Author").unwrap(); - let changed = PreparedEdit::insert(&source, sid, &child).unwrap(); - let store = Store::parse(changed.as_bytes()).unwrap(); + let mut nested = ops::paragraph("Nested"); + nested.level = 2; + let child = nested.id; + let insert = PageOp::Insert { + container: paragraph, + before: None, + paragraphs: vec![nested], + }; + let changed = ops::page_edited(&source, sid, vec![insert]).unwrap(); + let store = Store::parse(&changed).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); let space = &document.spaces[&sid]; let view = &space.revisions[&space.contexts[&ExGuid::default()]]; assert_eq!(view.nodes[&outline].children, [paragraph]); - assert_eq!(view.nodes[¶graph].children, [child.object()]); + assert_eq!(view.nodes[¶graph].children, [child]); let source = include_bytes!( "../../../corpus/native/20260905-05/snapshots/07-table/notebook/synthetic.one" ); @@ -669,34 +593,32 @@ fn insertions_into_nested_paragraphs_and_native_table_cells_preserve_structure() }) }) .unwrap(); - let insertion = Insertion::paragraph(cell, None, "Added to cell", "Author").unwrap(); - let changed = PreparedEdit::insert(source, sid, &insertion).unwrap(); - current::current(changed.as_bytes()); + let (ops, _, _) = insertion(sid, Place::In(cell, None), "Added to cell", &[]); + current::current(&ops::edited(source, ops).unwrap()); } #[test] fn insertion_publication_faults_expose_only_complete_graphs_and_title_caches() { let original = onestore::create_section("atomic.one", "Original", "Author").unwrap(); let (sid, _, _, _, text) = targets(&original); - let checkpoint = checkpoint::pending(&original, sid, text, 0x14001d7a); + let checkpoint = checkpoint::pending(&original, sid, text); for source in [ original.as_slice(), include_bytes!("../../../corpus/append/round-01/tx-255/notebook/synthetic.one"), &checkpoint, ] { - let (sid, page, outline, paragraph, _) = targets(source); - for intent in [ - Insertion::paragraph(outline, Some(paragraph), "First 🦀", "New author").unwrap(), - Insertion::outline(page, 0.0, 0.0, "First 🦀", "New author").unwrap(), - ] { - let intent = intent - .with_formatting(0..3, &[A::Bold(true), A::FontSize(18.0)]) - .unwrap() - .with_formatting(6..8, &[A::Italic(true), A::Highlight(Some([255, 255, 0]))]) - .unwrap(); - let edit = PreparedEdit::insert(source, sid, &intent).unwrap(); + let (sid, _, outline, paragraph, _) = targets(source); + for place in [Place::In(outline, Some(paragraph)), Place::Outline(0.0, 0.0)] { + let formats = [ + (0..3, vec![A::Bold(true), A::FontSize(18.0)]), + (6..8, vec![A::Italic(true), A::Highlight(Some([255, 255, 0]))]), + ]; + let (ops, _, _) = insertion(sid, place, "First 🦀", &formats); + let edit = ops::transaction(source, "New author", ops).unwrap().unwrap(); + let mut written = source.to_vec(); + edit.apply(&mut written).unwrap(); let before = current::current(source); - let after = current::current(edit.as_bytes()); + let after = current::current(&written); for write_limit in [17, 4096] { let disk = |fail_at| disk::Disk { visible: source.to_vec(), @@ -708,7 +630,7 @@ fn insertion_publication_faults_expose_only_complete_graphs_and_title_caches() { }; let mut successful = disk(None); edit.commit(&mut successful).unwrap(); - assert_eq!(successful.durable, edit.as_bytes()); + assert_eq!(successful.durable, written); for at in 1..=successful.operation { let mut interrupted = disk(Some(at)); let failure = edit.commit(&mut interrupted).unwrap_err(); diff --git a/crates/onestore/tests/outline.rs b/crates/onestore/tests/outline.rs index e5cea6fb9d28a956ebc49c029a42ef1e1beaeca4..ba143b2a9943619e5edc4f3858607d96726db6a5 100644 --- a/crates/onestore/tests/outline.rs +++ b/crates/onestore/tests/outline.rs @@ -1,6 +1,7 @@ use onestore::{ - ExGuid, OutlineEdit as Edit, PreparedEdit, RevisionIndex, Store, + ExGuid, OutlineEdit as Edit, RevisionIndex, Store, document::{Document, Kind}, + op::PageOp, }; use std::collections::BTreeSet; @@ -8,6 +9,8 @@ use std::collections::BTreeSet; mod current; #[path = "support/disk.rs"] mod disk; +#[path = "support/ops.rs"] +mod ops; const SOURCE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); @@ -62,7 +65,7 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() { for (name, sid, object, edit) in cases(&document) { let restored = serde_json::from_value(serde_json::to_value(edit).unwrap()).unwrap(); assert_eq!(edit, restored); - let prepared = PreparedEdit::outline(SOURCE, sid, object, restored).unwrap(); + let prepared = ops::page_op(SOURCE, sid, PageOp::Outline { object, edit: restored }).unwrap(); let after_store = Store::parse(prepared.as_bytes()).unwrap(); assert!(after_store.checksum_mismatches.is_empty()); let after_index = RevisionIndex::parse(&after_store).unwrap(); @@ -139,7 +142,7 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() { assert_eq!(old_space.labels, after_index.spaces[space_id].labels); } } - let again = PreparedEdit::outline(prepared.as_bytes(), sid, object, edit).unwrap(); + let again = ops::page_op(prepared.as_bytes(), sid, PageOp::Outline { object, edit }).unwrap(); assert_eq!(again.as_bytes(), prepared.as_bytes()); } } @@ -159,20 +162,15 @@ fn invalid_geometry_and_non_outline_targets_are_rejected() { user_set: true, }, ] { - assert!(PreparedEdit::outline(SOURCE, sid, object, edit).is_err()); + assert!(ops::page_op(SOURCE, sid, PageOp::Outline { object, edit }).is_err()); } } for points in [-1.0, 0.0, 35.999] { assert!( - PreparedEdit::outline( - SOURCE, - sid, - object, - Edit::Width { + ops::page_op(SOURCE, sid, PageOp::Outline { object, edit: Edit::Width { points, user_set: false - } - ) + } }) .is_err() ); } @@ -181,7 +179,7 @@ fn invalid_geometry_and_non_outline_targets_are_rejected() { for (id, node) in &view.nodes { if !matches!(node.kind, Kind::Outline { .. }) { assert!( - PreparedEdit::outline(SOURCE, sid, *id, Edit::Position { x: 72.0, y: 72.0 }) + ops::page_op(SOURCE, sid, PageOp::Outline { object: *id, edit: Edit::Position { x: 72.0, y: 72.0 } }) .is_err() ); } @@ -197,20 +195,20 @@ fn invalid_geometry_and_non_outline_targets_are_rejected() { }, Edit::Collapsed(true), ] { - assert!(PreparedEdit::outline(SOURCE, sid, child, edit).is_err()); + assert!(ops::page_op(SOURCE, sid, PageOp::Outline { object: child, edit }).is_err()); } } } if !matches!(node.kind, Kind::Paragraph { .. }) { - assert!(PreparedEdit::outline(SOURCE, sid, *id, Edit::Collapsed(true)).is_err()); + assert!(ops::page_op(SOURCE, sid, PageOp::Outline { object: *id, edit: Edit::Collapsed(true) }).is_err()); } } assert!( - PreparedEdit::outline(SOURCE, ExGuid::default(), object, Edit::Collapsed(true)) + ops::page_op(SOURCE, ExGuid::default(), PageOp::Outline { object, edit: Edit::Collapsed(true) }) .is_err() ); assert!( - PreparedEdit::outline(SOURCE, sid, ExGuid::default(), Edit::Collapsed(true)).is_err() + ops::page_op(SOURCE, sid, PageOp::Outline { object: ExGuid::default(), edit: Edit::Collapsed(true) }).is_err() ); } } @@ -228,15 +226,10 @@ fn resizing_a_native_reserved_width_preserves_content() { let space = &document.spaces[&sid]; let before = &space.revisions[&space.contexts[&ExGuid::default()]]; assert!(before.nodes[&object].layout.reserved_width.is_some()); - let edit = PreparedEdit::outline( - source, - sid, - object, - Edit::Width { + let edit = ops::page_op(source, sid, PageOp::Outline { object, edit: Edit::Width { points: 144.0, user_set: true, - }, - ) + } }) .unwrap(); let after_store = Store::parse(edit.as_bytes()).unwrap(); let after_index = RevisionIndex::parse(&after_store).unwrap(); @@ -270,12 +263,8 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); let (sid, page) = document.pages().unwrap()[0]; - let insertion = - onestore::Insertion::outline(page, 144.0, 144.0, "Second 🦋 é", "Author").unwrap(); - let source = PreparedEdit::insert(&source, sid, &insertion) - .unwrap() - .as_bytes() - .to_vec(); + let (add, insertion, _) = ops::new_outline(144.0, 144.0, "Second 🦋 é"); + let source = ops::page_op(&source, sid, add).unwrap().image; let store = Store::parse(&source).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); @@ -288,7 +277,7 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content (144.0, 36.0, "First"), (0.0, 144.0, "First"), ] { - let edit = PreparedEdit::outline(&source, sid, insertion.object(), Edit::Position { x, y }) + let edit = ops::page_op(&source, sid, PageOp::Outline { object: insertion, edit: Edit::Position { x, y } }) .unwrap(); let saved = Store::parse(edit.as_bytes()).unwrap(); let saved_index = RevisionIndex::parse(&saved).unwrap(); @@ -308,7 +297,7 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content after.nodes.keys().collect::>() ); for (oid, node) in &before.nodes { - if ![page, metadata, insertion.object()].contains(oid) { + if ![page, metadata, insertion].contains(oid) { assert_eq!( serde_json::to_value(node).unwrap(), serde_json::to_value(&after.nodes[oid]).unwrap() @@ -320,12 +309,7 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content format!("{:?}", saved_index.resolve(sid, rid).unwrap()) ); assert_eq!( - PreparedEdit::outline( - edit.as_bytes(), - sid, - insertion.object(), - Edit::Position { x, y } - ) + ops::page_op(edit.as_bytes(), sid, PageOp::Outline { object: insertion, edit: Edit::Position { x, y } }) .unwrap() .as_bytes(), edit.as_bytes() @@ -366,7 +350,7 @@ fn native_automatic_and_explicit_titles_follow_outline_movement() { 72.0 }, }; - candidate = PreparedEdit::outline(&candidate, sid, outline, change) + candidate = ops::page_op(&candidate, sid, PageOp::Outline { object: outline, edit: change }) .unwrap() .as_bytes() .to_vec(); @@ -418,11 +402,8 @@ fn outline_publication_interruptions_reopen_as_complete_old_or_new_layout() { .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) .unwrap(); let paragraph = view.nodes[&outline].children[0]; - let second = onestore::Insertion::outline(page, 144.0, 36.0, "Second", "Author").unwrap(); - let source = PreparedEdit::insert(&source, sid, &second) - .unwrap() - .as_bytes() - .to_vec(); + let (add, _, _) = ops::new_outline(144.0, 36.0, "Second"); + let source = ops::page_op(&source, sid, add).unwrap().image; for (object, operation) in [ (outline, Edit::Position { x: 216.0, y: 72.0 }), ( @@ -434,7 +415,7 @@ fn outline_publication_interruptions_reopen_as_complete_old_or_new_layout() { ), (paragraph, Edit::Collapsed(true)), ] { - let edit = PreparedEdit::outline(&source, sid, object, operation).unwrap(); + let edit = ops::page_op(&source, sid, PageOp::Outline { object, edit: operation }).unwrap(); let before = current::current(&source); let after = current::current(edit.as_bytes()); for write_limit in [17, 4096] { @@ -481,11 +462,8 @@ fn repeated_geometry_changes_and_expansion_match_an_independent_model() { .unwrap(); let paragraph = view.nodes[&outline].children[0]; let text = view.nodes[¶graph].content[0]; - let second = onestore::Insertion::outline(page, 144.0, 36.0, "Second", "Author").unwrap(); - let original = PreparedEdit::insert(&original, sid, &second) - .unwrap() - .as_bytes() - .to_vec(); + let (add, _, _) = ops::new_outline(144.0, 36.0, "Second"); + let original = ops::page_op(&original, sid, add).unwrap().image; for seed in 1..=16_u64 { let mut rng = seed; let mut source = original.clone(); @@ -520,7 +498,7 @@ fn repeated_geometry_changes_and_expansion_match_an_independent_model() { (paragraph, Edit::Collapsed(rng & 1 != 0)) } }; - source = PreparedEdit::outline(&source, sid, object, edit) + source = ops::page_op(&source, sid, PageOp::Outline { object, edit }) .unwrap() .as_bytes() .to_vec(); @@ -574,7 +552,7 @@ fn export_native_outline_candidates() { let document = Document::parse(&index).unwrap(); let mut bytes = SOURCE.to_vec(); for (_, sid, object, edit) in cases(&document) { - bytes = PreparedEdit::outline(&bytes, sid, object, edit) + bytes = ops::page_op(&bytes, sid, PageOp::Outline { object, edit }) .unwrap() .as_bytes() .to_vec(); diff --git a/crates/onestore/tests/page.rs b/crates/onestore/tests/page.rs index c11eec4e82b8c8301a6b26b79e919f52548dfbef..53164ab0b1351c26968f26d46afbf59145d4a686 100644 --- a/crates/onestore/tests/page.rs +++ b/crates/onestore/tests/page.rs @@ -1,6 +1,7 @@ use onestore::{ - ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store, + ExGuid, PageCreation, RevisionIndex, Store, document::{Document, Kind}, + op::{PageOp, SectionOp}, page::{Page, PageObject}, }; @@ -8,6 +9,8 @@ use onestore::{ mod current; #[path = "support/disk.rs"] mod disk; +#[path = "support/ops.rs"] +mod ops; const SOURCE: &[u8] = include_bytes!("../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one"); @@ -28,7 +31,7 @@ fn native_section_accepts_empty_and_titled_pages_with_preserved_history() { let serialized = serde_json::to_vec(&intent).unwrap(); let retained = serde_json::from_slice(&serialized).unwrap(); assert_eq!(intent, retained); - let prepared = PreparedEdit::create_page(&source, &retained).unwrap(); + let prepared = ops::section_op(&source, SectionOp::Create(retained.clone())).unwrap(); let store = Store::parse(prepared.as_bytes()).unwrap(); assert_eq!( store.header.transaction_count, @@ -78,7 +81,7 @@ fn native_section_accepts_empty_and_titled_pages_with_preserved_history() { assert_eq!(space.labels, index.spaces[sid].labels); } } - assert!(PreparedEdit::create_page(prepared.as_bytes(), &intent).is_err()); + assert!(ops::section_op(prepared.as_bytes(), SectionOp::Create(intent.clone())).is_err()); source = prepared.as_bytes().to_vec(); } if let Some(output) = std::env::var_os("ONESTORE_PAGE_CREATION_OUTPUT") { @@ -92,13 +95,12 @@ fn created_pages_support_title_edits_and_body_insertion() { let mut source = onestore::create_section("pages.one", "Original", "Author").unwrap(); for title in [None, Some(""), Some("Explicit 🦋 é")] { let intent = PageCreation::new(None, title, "Author").unwrap(); - source = PreparedEdit::create_page(&source, &intent) + source = ops::section_op(&source, SectionOp::Create(intent.clone())) .unwrap() .as_bytes() .to_vec(); - let insertion = - Insertion::outline(intent.object(), 36.0, 36.0, "Body 🦀 é", "Author").unwrap(); - source = PreparedEdit::insert(&source, intent.space(), &insertion) + let (add, ..) = ops::new_outline(36.0, 36.0, "Body 🦀 é"); + source = ops::page_op(&source, intent.space(), add) .unwrap() .as_bytes() .to_vec(); @@ -115,13 +117,7 @@ fn created_pages_support_title_edits_and_body_insertion() { Some(title.filter(|s| !s.is_empty()).unwrap_or("Body 🦀 é")) ); if let Some(text) = intent.title_object() { - source = PreparedEdit::text( - &source, - intent.space(), - text, - 0..title.unwrap().encode_utf16().count() as u32, - "Renamed", - ) + source = ops::page_op(&source, intent.space(), PageOp::Text { text, range: 0..title.unwrap().encode_utf16().count() as u32, with: "Renamed".into() }) .unwrap() .as_bytes() .to_vec(); @@ -149,13 +145,13 @@ fn created_body_outlines_carry_the_indentation_table_onenote_writes() { let [first] = document.active(space).unwrap().nodes[&page].children[..] else { panic!() }; - let insertion = Insertion::outline(page, 72.0, 144.0, "Second", "Author").unwrap(); - let written = PreparedEdit::insert(&source, space, &insertion).unwrap(); + let (add, insertion, _) = ops::new_outline(72.0, 144.0, "Second"); + let written = ops::page_op(&source, space, add).unwrap(); let store = Store::parse(written.as_bytes()).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); let view = document.active(space).unwrap(); - for outline in [first, insertion.object()] { + for outline in [first, insertion] { let Kind::Outline { indents } = &view.nodes[&outline].kind else { panic!() }; @@ -167,7 +163,7 @@ fn created_body_outlines_carry_the_indentation_table_onenote_writes() { fn a_created_title_outline_is_stored_and_read_as_onenote_stores_it() { let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); let intent = PageCreation::new(None, Some("Title"), "Author").unwrap(); - let prepared = PreparedEdit::create_page(&source, &intent).unwrap(); + let prepared = ops::section_op(&source, SectionOp::Create(intent.clone())).unwrap(); let store = Store::parse(prepared.as_bytes()).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); @@ -222,11 +218,11 @@ fn invalid_page_intents_and_nonleading_anchors_reject_before_publication() { let index = RevisionIndex::parse(&store).unwrap(); let pages = Document::parse(&index).unwrap().pages().unwrap(); let intent = PageCreation::new(Some(pages[4].0), Some("Child"), "Author").unwrap(); - assert!(PreparedEdit::create_page(source, &intent).is_err()); + assert!(ops::section_op(source, SectionOp::Create(intent.clone())).is_err()); let mut value = serde_json::to_value(PageCreation::new(None, None, "Author").unwrap()).unwrap(); value["guid"] = serde_json::to_value([0_u8; 16]).unwrap(); let invalid: PageCreation = serde_json::from_value(value.clone()).unwrap(); - assert!(PreparedEdit::create_page(source, &invalid).is_err()); + assert!(ops::section_op(source, SectionOp::Create(invalid.clone())).is_err()); value["unknown"] = true.into(); assert!(serde_json::from_value::(value).is_err()); } @@ -235,7 +231,7 @@ fn invalid_page_intents_and_nonleading_anchors_reject_before_publication() { fn new_space_and_section_entry_publish_as_one_complete_action() { let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); let intent = PageCreation::new(None, Some("New 🦋 é"), "Author").unwrap(); - let prepared = PreparedEdit::create_page(&source, &intent).unwrap(); + let prepared = ops::section_op(&source, SectionOp::Create(intent.clone())).unwrap(); let old = current::current(&source); let new = current::current(prepared.as_bytes()); assert_eq!(new.len(), old.len() + 1); @@ -285,7 +281,7 @@ fn repeated_page_creation_crosses_root_fragments_counters_and_section_checkpoint "Stress author", ) .unwrap(); - let prepared = PreparedEdit::create_page(&source, &intent).unwrap(); + let prepared = ops::section_op(&source, SectionOp::Create(intent.clone())).unwrap(); let mut unpublished = prepared.as_bytes().to_vec(); unpublished[96..100].copy_from_slice(&source[96..100]); let old_store = Store::parse(&unpublished).unwrap(); @@ -363,7 +359,7 @@ fn native_changes_on_created_pages_accept_rust_followups() { let mut source = initial.to_vec(); for (sid, title, body) in targets { for (object, prefix) in [(body, "Rust + "), (title, "Reviewed ")] { - source = PreparedEdit::text(&source, sid, object, 0..0, prefix) + source = ops::page_op(&source, sid, PageOp::Text { text: object, range: 0..0, with: prefix.into() }) .unwrap() .as_bytes() .to_vec(); @@ -400,7 +396,7 @@ fn page_creation_preserves_native_features_and_file_data() { let old = RevisionIndex::parse(&before).unwrap(); let original = Document::parse(&old).unwrap(); let intent = PageCreation::new(None, Some("Preserved features"), "Author").unwrap(); - let prepared = PreparedEdit::create_page(source, &intent).unwrap(); + let prepared = ops::section_op(source, SectionOp::Create(intent.clone())).unwrap(); let after = Store::parse(prepared.as_bytes()).unwrap(); let new = RevisionIndex::parse(&after).unwrap(); let document = Document::parse(&new).unwrap(); @@ -427,3 +423,77 @@ fn page_creation_preserves_native_features_and_file_data() { } } } + +/// A page created into an empty section as OneNote 2010 creates one (its title's date and +/// time fields, then a colour) reads back with both; a page kept from elsewhere keeps its +/// identity and creation time. +#[test] +fn dated_pages_in_an_empty_section_read_back_with_their_date_and_colour() { + let source = onestore::create_empty_section("New Section 1.one", Some(0x00e4a88a)).unwrap(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty()); + let creation = PageCreation::new(None, Some(""), "Rust Author") + .unwrap() + .dated("Sunday, September 27, 2026", "2:23 AM") + .unwrap(); + let retained: PageCreation = + serde_json::from_slice(&serde_json::to_vec(&creation).unwrap()).unwrap(); + assert_eq!(retained, creation); + let space = creation.space(); + let identity = [7; 16]; + let kept = PageCreation::new(None, Some("Kept"), "Rust Author") + .unwrap() + .keeping(identity, 133_700_000_001_234_567) + .unwrap(); + let image = ops::edited( + &source, + vec![ + onestore::op::Op::Section(SectionOp::Create(creation.clone())), + onestore::op::Op::Page { + space, + op: PageOp::Color(Some(0x00f2f9d4)), + }, + onestore::op::Op::Section(SectionOp::Create(kept.clone())), + ], + ) + .unwrap(); + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + let document = Document::parse(&index).unwrap(); + let page = Page::from_space(&document, space).unwrap(); + assert_eq!(page.color, Some(0x00f2f9d4)); + assert_eq!(page.created, Some(creation.created())); + let [PageObject::Title(title)] = &page.objects[..] else { + panic!("{:?}", page.objects) + }; + let shown: Vec<_> = title.outlines[1] + .paragraphs + .iter() + .map(|paragraph| { + let text = paragraph.text().unwrap(); + (text.date_field, text.text.text().to_owned()) + }) + .collect(); + assert_eq!(title.date, Some(title.outlines[1].id)); + assert_eq!( + shown, + [ + // OneNote's own new pages read the same: the fields' order names their roles. + (None, "Sunday, September 27, 2026".to_owned()), + (None, "2:23 AM".to_owned()), + ] + ); + let kept_page = Page::from_space(&document, kept.space()).unwrap(); + assert_eq!(kept_page.identity, Some(identity)); + assert_eq!(kept_page.created, Some(133_700_000_001_234_567)); + assert_eq!(kept_page.color, None); + // Clearing the colour stores OneNote's "No color". + let cleared = ops::page_edited(&image, space, vec![PageOp::Color(None)]).unwrap(); + let store = Store::parse(&cleared).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + assert_eq!(Page::from_space(&document, space).unwrap().color, None); +} diff --git a/crates/onestore/tests/page_attachments.rs b/crates/onestore/tests/page_attachments.rs index 7304fb6d0355f13b8917f4df9f886235f2e48968..d37e475a5e83bf32ef0aebeb4ab2e9ec5f3c048d 100644 --- a/crates/onestore/tests/page_attachments.rs +++ b/crates/onestore/tests/page_attachments.rs @@ -1,5 +1,7 @@ +#[path = "support/ops.rs"] +mod ops; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, document::{Document, Format}, page::{ Attachment, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id, @@ -9,7 +11,6 @@ use std::sync::Arc; const NATIVE: &[u8] = include_bytes!("../../../corpus/native/cold-05-06-attachment/notebook/synthetic.one"); -const AUTHOR: &str = "Attachment author"; const PAYLOAD: &[u8] = b"Rust wrote this attachment.\n"; fn first_page(bytes: &[u8]) -> (ExGuid, Page) { @@ -117,8 +118,8 @@ fn insert_attachment(section: &str, preview: Option>) -> Vec { }); body_paragraphs(&mut after).push(holder.clone()); body_paragraphs(&mut after).push(plain_paragraph(&template, "After the file")); - let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); - let stored = page_in(written.as_bytes(), space); + let written = ops::saved(&source, space, &after).unwrap(); + let stored = page_in(written.as_slice(), space); let mut expected = after.clone(); expected.title = stored.title.clone(); assert_eq!(stored, expected); @@ -129,16 +130,16 @@ fn insert_attachment(section: &str, preview: Option>) -> Vec { assert_eq!(stored_attachment.bytes.as_deref(), Some(PAYLOAD)); assert_eq!(stored_attachment.preview, preview); assert_eq!( - PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + ops::saved(written.as_slice(), space, &stored) .unwrap() - .as_bytes(), - written.as_bytes() + .as_slice(), + written.as_slice() ); let mut removed = stored.clone(); body_paragraphs(&mut removed).retain(|p| p.id != holder.id); - let again = PreparedEdit::page(written.as_bytes(), space, &removed, AUTHOR).unwrap(); - assert!(attachments(&page_in(again.as_bytes(), space)).is_empty()); - written.as_bytes().to_vec() + let again = ops::saved(written.as_slice(), space, &removed).unwrap(); + assert!(attachments(&page_in(again.as_slice(), space)).is_empty()); + written.as_slice().to_vec() } /// `ONESTORE_ATTACHMENT_EXPORT` names a directory receiving both candidates for a cold reopen: @@ -186,7 +187,7 @@ fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() { recording: None, }); body_paragraphs(&mut after).push(holder); - assert!(PreparedEdit::page(&source, space, &after, AUTHOR).is_err()); + assert!(ops::saved(&source, space, &after).is_err()); let (space, native) = first_page(NATIVE); let mut renamed = native.clone(); let mut found = 0; @@ -202,8 +203,8 @@ fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() { } } assert_eq!(found, 1); - let written = PreparedEdit::page(NATIVE, space, &renamed, AUTHOR).unwrap(); - let stored = page_in(written.as_bytes(), space); + let written = ops::saved(NATIVE, space, &renamed).unwrap(); + let stored = page_in(written.as_slice(), space); let (before, after) = (attachments(&native)[0], attachments(&stored)[0]); assert_eq!(after.filename, "renamed \u{1f980}.txt"); assert_eq!( @@ -216,8 +217,8 @@ fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() { if let Some(directory) = std::env::var_os("ONESTORE_ATTACHMENT_RENAME_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir_all(&directory).unwrap(); - std::fs::write(directory.join("synthetic.one"), written.as_bytes()).unwrap(); - let file_id = Store::parse(written.as_bytes()).unwrap().header.file_id; + std::fs::write(directory.join("synthetic.one"), written.as_slice()).unwrap(); + let file_id = Store::parse(written.as_slice()).unwrap().header.file_id; std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( diff --git a/crates/onestore/tests/page_deletion.rs b/crates/onestore/tests/page_deletion.rs index c3ceaa3706ce9617c7fcd4a6edfee76340991093..109c789c7d860fb3c0c35f6939aa710f858089a2 100644 --- a/crates/onestore/tests/page_deletion.rs +++ b/crates/onestore/tests/page_deletion.rs @@ -1,6 +1,7 @@ use onestore::{ - ExGuid, FileDataReference, ObjectData, PreparedEdit, PropertySets, RevisionIndex, Store, + ExGuid, FileDataReference, ObjectData, PropertySets, RevisionIndex, Store, document::{Document, Kind}, + op::{PageOp, SectionOp}, }; use std::collections::BTreeSet; @@ -130,7 +131,7 @@ fn removal_matches_all_native_cases_and_retains_every_old_revision() { .into_iter() .map(|ordinal| pages[ordinal].0) .collect(); - let prepared = PreparedEdit::delete_pages_permanently(&original, &removed).unwrap(); + let prepared = ops::section_op(&original, SectionOp::Delete(removed.to_vec())).unwrap(); if let Some(output) = std::env::var_os("ONESTORE_PAGE_REMOVAL_OUTPUT") { let output = std::path::Path::new(&output); assert!(output.is_absolute()); @@ -140,7 +141,7 @@ fn removal_matches_all_native_cases_and_retains_every_old_revision() { std::fs::write(case.join("Lifecycle.one"), prepared.as_bytes()).unwrap(); } verify(&original, prepared.as_bytes(), &native, &removed); - assert!(PreparedEdit::delete_pages_permanently(prepared.as_bytes(), &removed).is_err()); + assert!(ops::section_op(prepared.as_bytes(), SectionOp::Delete(removed.to_vec())).is_err()); } } @@ -160,22 +161,22 @@ fn removal_rejects_duplicate_missing_and_non_page_spaces_without_publication() { }], vec![pages[0].0, index.root], ] { - assert!(PreparedEdit::delete_pages_permanently(SOURCE, &selected).is_err()); + assert!(ops::section_op(SOURCE, SectionOp::Delete(selected.to_vec())).is_err()); } assert_eq!( - PreparedEdit::delete_pages_permanently(SOURCE, &[]) + ops::section_op(SOURCE, SectionOp::Delete([].to_vec())) .unwrap() .as_bytes(), SOURCE ); - let written = PreparedEdit::delete_pages_permanently(SOURCE, &[pages[0].0]).unwrap(); + let written = ops::section_op(SOURCE, SectionOp::Delete([pages[0].0].to_vec())).unwrap(); assert!( - PreparedEdit::delete_pages_permanently(written.as_bytes(), &[pages[1].0, pages[0].0]) + ops::section_op(written.as_bytes(), SectionOp::Delete([pages[1].0, pages[0].0].to_vec())) .is_err() ); } -use page_schedule::{current, disk}; +use page_schedule::{current, disk, ops}; /// OneNote created a page in each section after Rust permanently removed pages from it; /// the surviving pages keep their revisions through the native save and a cold reopen. @@ -267,7 +268,7 @@ fn tombstones_and_first_page_promotion_survive_each_storage_interruption() { .take(if all { pages.len() } else { 1 }) .map(|page| page.0) .collect(); - let prepared = PreparedEdit::delete_pages_permanently(source, &removed).unwrap(); + let prepared = ops::section_op(source, SectionOp::Delete(removed.to_vec())).unwrap(); let old = current::current(source); let new = current::current(prepared.as_bytes()); assert_ne!(old, new); @@ -331,8 +332,17 @@ fn stale_removal_cannot_delete_a_newer_page_edit() { _ => None, }) .unwrap(); - let removal = PreparedEdit::delete_pages_permanently(SOURCE, &[sid]).unwrap(); - let remote = onestore::replace_text(SOURCE, sid, text, 0..0, "New remote content ").unwrap(); + let removal = ops::section_op(SOURCE, SectionOp::Delete([sid].to_vec())).unwrap(); + let remote = ops::page_edited( + SOURCE, + sid, + vec![PageOp::Text { + text, + range: 0..0, + with: "New remote content ".into(), + }], + ) + .unwrap(); let mut disk = disk::Disk { visible: remote.clone(), durable: remote.clone(), @@ -380,7 +390,7 @@ fn external_payloads_and_their_historical_references_survive_file_removal() { let pages = Document::parse(&index).unwrap().pages().unwrap(); assert_eq!(pages.len(), 3); let selected: Vec<_> = pages.iter().map(|page| page.0).collect(); - let prepared = PreparedEdit::delete_pages_permanently(&original, &selected).unwrap(); + let prepared = ops::section_op(&original, SectionOp::Delete(selected.to_vec())).unwrap(); let output = std::env::var_os("ONESTORE_PAGE_REMOVAL_EXTERNAL_OUTPUT"); let root = output .as_ref() @@ -409,7 +419,7 @@ fn external_payloads_and_their_historical_references_survive_file_removal() { assert_eq!(payloads.len(), 3); let path = root.join("synthetic.one"); std::fs::write(&path, &original).unwrap(); - prepared.commit_file(&path).unwrap(); + prepared.transaction.as_ref().unwrap().commit_file(&path).unwrap(); let written = onestore::read_file(&path).unwrap(); assert_eq!(written, prepared.as_bytes()); let current_store = Store::parse(&written).unwrap(); diff --git a/crates/onestore/tests/page_images.rs b/crates/onestore/tests/page_images.rs index ad0f14d9bd2b0bff181adbfd3f888f28cc7a8182..3ed266fe54c2a4d322a4f65c3494813bc6c770a3 100644 --- a/crates/onestore/tests/page_images.rs +++ b/crates/onestore/tests/page_images.rs @@ -1,5 +1,7 @@ +#[path = "support/ops.rs"] +mod ops; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, document::{Document, Format, Layout}, page::{Image, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id}, }; @@ -11,7 +13,6 @@ const NATIVE_RESIZE: &[u8] = include_bytes!("../../../corpus/picture-edit/native-resize/notebook/pictures.one"); const NATIVE_PAGE_LEVEL: &[u8] = include_bytes!("../../../corpus/picture-edit/native-page-level/notebook/pictures.one"); -const AUTHOR: &str = "Picture author"; /// A one-pixel PNG. const PNG: &[u8] = &[ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, @@ -120,8 +121,8 @@ fn a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed() { holder.content = ParagraphContent::Image(image.clone()); body_paragraphs(&mut after).push(holder.clone()); body_paragraphs(&mut after).push(plain_paragraph(&template, "After the picture")); - let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); - let stored = page_in(written.as_bytes(), space); + let written = ops::saved(&source, space, &after).unwrap(); + let stored = page_in(written.as_slice(), space); let mut expected = after.clone(); expected.title = stored.title.clone(); assert_eq!(stored, expected); @@ -131,16 +132,16 @@ fn a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed() { }; assert_eq!(picture.bytes.as_deref(), Some(PNG)); assert_eq!( - PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + ops::saved(written.as_slice(), space, &stored) .unwrap() - .as_bytes(), - written.as_bytes() + .as_slice(), + written.as_slice() ); if let Some(directory) = std::env::var_os("ONESTORE_IMAGE_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir(&directory).unwrap(); - std::fs::write(directory.join("pictures.one"), written.as_bytes()).unwrap(); - let written_store = Store::parse(written.as_bytes()).unwrap(); + std::fs::write(directory.join("pictures.one"), written.as_slice()).unwrap(); + let written_store = Store::parse(written.as_slice()).unwrap(); std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( @@ -153,8 +154,8 @@ fn a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed() { } let mut removed = stored.clone(); body_paragraphs(&mut removed).retain(|p| p.id != holder.id); - let again = PreparedEdit::page(written.as_bytes(), space, &removed, AUTHOR).unwrap(); - let stored = page_in(again.as_bytes(), space); + let again = ops::saved(written.as_slice(), space, &removed).unwrap(); + let stored = page_in(again.as_slice(), space); assert!(pictures(&stored).is_empty()); assert_eq!(body_paragraphs(&mut stored.clone()).len(), 2); } @@ -187,20 +188,20 @@ fn a_native_picture_is_resized_and_described_then_reset() { ..Default::default() }; resize(&mut resized, layout, Some("Resized in Rust")); - let written = PreparedEdit::page(NATIVE, space, &resized, AUTHOR).unwrap(); - let stored = page_in(written.as_bytes(), space); + let written = ops::saved(NATIVE, space, &resized).unwrap(); + let stored = page_in(written.as_slice(), space); assert_eq!(stored, resized); assert_eq!( - PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + ops::saved(written.as_slice(), space, &stored) .unwrap() - .as_bytes(), - written.as_bytes() + .as_slice(), + written.as_slice() ); if let Some(directory) = std::env::var_os("ONESTORE_IMAGE_RESIZE_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir(&directory).unwrap(); - std::fs::write(directory.join("synthetic.one"), written.as_bytes()).unwrap(); - let written_store = Store::parse(written.as_bytes()).unwrap(); + std::fs::write(directory.join("synthetic.one"), written.as_slice()).unwrap(); + let written_store = Store::parse(written.as_slice()).unwrap(); std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( @@ -213,8 +214,8 @@ fn a_native_picture_is_resized_and_described_then_reset() { } let mut reset = stored.clone(); resize(&mut reset, Layout::default(), None); - let again = PreparedEdit::page(written.as_bytes(), space, &reset, AUTHOR).unwrap(); - assert_eq!(page_in(again.as_bytes(), space), native); + let again = ops::saved(written.as_slice(), space, &reset).unwrap(); + assert_eq!(page_in(again.as_slice(), space), native); } #[test] @@ -234,8 +235,8 @@ fn a_rust_picture_resized_natively_reads_back_with_its_size_and_description() { let mut layout = picture.layout.clone(); layout.max_width = Some(200.0); resize(&mut wider, layout, Some("Wider in Rust")); - let written = PreparedEdit::page(NATIVE_RESIZE, space, &wider, AUTHOR).unwrap(); - assert_eq!(page_in(written.as_bytes(), space), wider); + let written = ops::saved(NATIVE_RESIZE, space, &wider).unwrap(); + assert_eq!(page_in(written.as_slice(), space), wider); } fn page_pictures(page: &Page) -> Vec<&Image> { @@ -270,8 +271,8 @@ fn a_page_level_picture_placed_natively_reads_as_a_page_object() { image.layout.y = Some(400.0); } } - let written = PreparedEdit::page(NATIVE_PAGE_LEVEL, space, &moved, AUTHOR).unwrap(); - assert_eq!(page_in(written.as_bytes(), space), moved); + let written = ops::saved(NATIVE_PAGE_LEVEL, space, &moved).unwrap(); + assert_eq!(page_in(written.as_slice(), space), moved); } /// `ONESTORE_PAGE_IMAGE_EXPORT` names a new directory receiving the candidate for a cold reopen. @@ -296,23 +297,23 @@ fn a_page_level_picture_is_inserted_moved_and_removed() { background: false, }; after.objects.push(PageObject::Image(image.clone())); - let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); - let stored = page_in(written.as_bytes(), space); + let written = ops::saved(&source, space, &after).unwrap(); + let stored = page_in(written.as_slice(), space); let mut expected = after.clone(); expected.title = stored.title.clone(); assert_eq!(stored, expected); assert_eq!(page_pictures(&stored)[0].bytes.as_deref(), Some(PNG)); assert_eq!( - PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + ops::saved(written.as_slice(), space, &stored) .unwrap() - .as_bytes(), - written.as_bytes() + .as_slice(), + written.as_slice() ); if let Some(directory) = std::env::var_os("ONESTORE_PAGE_IMAGE_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir(&directory).unwrap(); - std::fs::write(directory.join("pictures.one"), written.as_bytes()).unwrap(); - let written_store = Store::parse(written.as_bytes()).unwrap(); + std::fs::write(directory.join("pictures.one"), written.as_slice()).unwrap(); + let written_store = Store::parse(written.as_slice()).unwrap(); std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( @@ -330,19 +331,19 @@ fn a_page_level_picture_is_inserted_moved_and_removed() { image.alt = None; } } - let again = PreparedEdit::page(written.as_bytes(), space, &moved, AUTHOR).unwrap(); - assert_eq!(page_in(again.as_bytes(), space), moved); + let again = ops::saved(written.as_slice(), space, &moved).unwrap(); + assert_eq!(page_in(again.as_slice(), space), moved); let mut removed = moved.clone(); removed.objects.retain(|object| object.id() != image.id); - let last = PreparedEdit::page(again.as_bytes(), space, &removed, AUTHOR).unwrap(); - let stored = page_in(last.as_bytes(), space); + let last = ops::saved(again.as_slice(), space, &removed).unwrap(); + let stored = page_in(last.as_slice(), space); assert!(page_pictures(&stored).is_empty()); assert_eq!(stored, removed); let mut unplaced = stored.clone(); let mut nowhere = image.clone(); nowhere.layout = Layout::default(); unplaced.objects.push(PageObject::Image(nowhere)); - assert!(PreparedEdit::page(last.as_bytes(), space, &unplaced, AUTHOR).is_err()); + assert!(ops::saved(last.as_slice(), space, &unplaced).is_err()); } #[test] @@ -361,7 +362,7 @@ fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() { background: false, }); body_paragraphs(&mut after).push(holder); - assert!(PreparedEdit::page(&source, space, &after, AUTHOR).is_err()); + assert!(ops::saved(&source, space, &after).is_err()); let (space, native) = first_page(NATIVE); let mut resized = native.clone(); let mut found = 0; @@ -376,5 +377,5 @@ fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() { } } assert_eq!(found, 1); - assert!(PreparedEdit::page(NATIVE, space, &resized, AUTHOR).is_err()); + assert!(ops::saved(NATIVE, space, &resized).is_err()); } diff --git a/crates/onestore/tests/page_ink.rs b/crates/onestore/tests/page_ink.rs index a27a31732ea125864e778834dab907ad05fbee22..c986b4d402e308f051bd5f42fffb3c693ca55e42 100644 --- a/crates/onestore/tests/page_ink.rs +++ b/crates/onestore/tests/page_ink.rs @@ -1,10 +1,11 @@ +#[path = "support/ops.rs"] +mod ops; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, document::Document, page::{Ink, InkStroke, Page, PageObject, text::new_id}, }; -const AUTHOR: &str = "Ink author"; /// Two drawings made with the mouse in OneNote 2010 (`tools/native/ink.ahk`): a diamond and a /// diagonal line, whose native read reports their positions and sizes. @@ -142,8 +143,8 @@ fn an_ink_drawing_is_written_erased_stroke_by_stroke_and_removed() { let mut after = before.clone(); let ink = drawing(); after.objects.push(PageObject::Ink(ink.clone())); - let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); - let stored = page_in(written.as_bytes(), space); + let written = ops::saved(&source, space, &after).unwrap(); + let stored = page_in(written.as_slice(), space); let mut expected = after.clone(); expected.title = stored.title.clone(); assert_eq!(stored, expected); @@ -157,12 +158,12 @@ fn an_ink_drawing_is_written_erased_stroke_by_stroke_and_removed() { ] )); assert_eq!( - PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + ops::saved(written.as_slice(), space, &stored) .unwrap() - .as_bytes(), - written.as_bytes() + .as_slice(), + written.as_slice() ); - export("ONESTORE_INK_EXPORT", "ink.one", written.as_bytes()); + export("ONESTORE_INK_EXPORT", "ink.one", written.as_slice()); let mut erased = stored.clone(); for object in &mut erased.objects { if let PageObject::Ink(ink) = object { @@ -171,13 +172,13 @@ fn an_ink_drawing_is_written_erased_stroke_by_stroke_and_removed() { .push(stroke(&[[330.0, 100.0], [330.0, 200.0]], Some(0xff0000))); } } - let again = PreparedEdit::page(written.as_bytes(), space, &erased, AUTHOR).unwrap(); - let stored = page_in(again.as_bytes(), space); + let again = ops::saved(written.as_slice(), space, &erased).unwrap(); + let stored = page_in(again.as_slice(), space); assert_eq!(stored, erased); let mut removed = stored.clone(); removed.objects.retain(|object| object.id() != ink.id); - let last = PreparedEdit::page(again.as_bytes(), space, &removed, AUTHOR).unwrap(); - let stored = page_in(last.as_bytes(), space); + let last = ops::saved(again.as_slice(), space, &removed).unwrap(); + let stored = page_in(last.as_slice(), space); assert!(drawings(&stored).is_empty()); assert_eq!(stored, removed); } @@ -209,15 +210,15 @@ fn handwriting_is_written_as_paragraph_content() { paragraph.format = Default::default(); paragraph.content = ParagraphContent::Ink(drawing()); outline.paragraphs.push(paragraph); - let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); - let stored = page_in(written.as_bytes(), space); + let written = ops::saved(&source, space, &after).unwrap(); + let stored = page_in(written.as_slice(), space); let mut expected = after.clone(); expected.title = stored.title.clone(); assert_eq!(stored, expected); export( "ONESTORE_INK_PARAGRAPH_EXPORT", "ink.one", - written.as_bytes(), + written.as_slice(), ); } @@ -230,5 +231,5 @@ fn stored_strokes_keep_their_paths() { ink.strokes[0].points[0][0] += 1.0; } } - assert!(PreparedEdit::page(NATIVE, space, &moved, AUTHOR).is_err()); + assert!(ops::saved(NATIVE, space, &moved).is_err()); } diff --git a/crates/onestore/tests/page_links.rs b/crates/onestore/tests/page_links.rs index e315f23c148917354be9a7414cda46504b95938b..f7472b47220a8ca5fbef6c482cc337100aec9fac 100644 --- a/crates/onestore/tests/page_links.rs +++ b/crates/onestore/tests/page_links.rs @@ -1,5 +1,7 @@ +#[path = "support/ops.rs"] +mod ops; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, document::Document, page::{ Page, PageObject, PageParagraph, Paragraph, @@ -13,7 +15,6 @@ const OUTLINES: &[u8] = const NATIVE_LINKS: &[u8] = include_bytes!("../../../corpus/link-edit/native-links/notebook/links.one"); const NATIVE_BASE_PATH: &str = r"C:\one-tests\runs\capture\notebook\links.one"; -const AUTHOR: &str = "Link author"; const CODE: &str = "\u{fddf}HYPERLINK \"https://example.invalid/rust\""; fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) { @@ -133,8 +134,8 @@ fn a_native_link_is_removed_leaving_plain_label_text() { replacement: Paragraph::new(content[end..].to_owned(), plain), }) .unwrap(); - let written = PreparedEdit::page(OUTLINES, space, &after, AUTHOR).unwrap(); - let stored = assert_same(written.as_bytes(), space, &after); + let written = ops::saved(OUTLINES, space, &after).unwrap(); + let stored = assert_same(written.as_slice(), space, &after); let mut stored = stored; let text = &body_paragraphs(&mut stored)[at].text().unwrap().text; assert!(text.text().ends_with(" Link label")); @@ -159,8 +160,8 @@ fn a_link_is_added_to_a_fresh_page_and_reads_back() { &mut body_paragraphs(&mut after)[0].text_mut().unwrap().text, "the Rust site", ); - let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); - let stored = assert_same(written.as_bytes(), space, &after); + let written = ops::saved(&source, space, &after).unwrap(); + let stored = assert_same(written.as_slice(), space, &after); let mut stored = stored; let text = &body_paragraphs(&mut stored)[0].text().unwrap().text; let flags: Vec<(Option, Option, Option)> = text @@ -185,8 +186,8 @@ fn a_link_is_added_to_a_fresh_page_and_reads_back() { if let Some(directory) = std::env::var_os("ONESTORE_LINK_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir(&directory).unwrap(); - std::fs::write(directory.join("links.one"), written.as_bytes()).unwrap(); - let written_store = Store::parse(written.as_bytes()).unwrap(); + std::fs::write(directory.join("links.one"), written.as_slice()).unwrap(); + let written_store = Store::parse(written.as_slice()).unwrap(); std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( @@ -292,10 +293,11 @@ fn internal_links_match_what_onenote_stores() { fn a_page_links_to_another_page_and_its_paragraph() { let source = onestore::create_section("links.one", "Linking page", "Author").unwrap(); let creation = onestore::PageCreation::new(None, Some("Link target"), "Author").unwrap(); - let with_target = onestore::PreparedEdit::create_page(&source, &creation) - .unwrap() - .as_bytes() - .to_vec(); + let with_target = ops::edited( + &source, + vec![onestore::op::Op::Section(onestore::op::SectionOp::Create(creation))], + ) + .unwrap(); let section = Store::parse(&with_target).unwrap().header.file_id; let (_, target) = page_by_title(&with_target, "Link target"); let (space, before) = page_by_title(&with_target, "Linking page"); @@ -351,12 +353,12 @@ fn a_page_links_to_another_page_and_its_paragraph() { replacement: Paragraph::from_runs(runs), }) .unwrap(); - let written = PreparedEdit::page(&with_target, space, &after, AUTHOR).unwrap(); - assert_same(written.as_bytes(), space, &after); + let written = ops::saved(&with_target, space, &after).unwrap(); + assert_same(written.as_slice(), space, &after); if let Some(directory) = std::env::var_os("ONESTORE_INTERNAL_LINK_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir(&directory).unwrap(); - std::fs::write(directory.join("links.one"), written.as_bytes()).unwrap(); + std::fs::write(directory.join("links.one"), written.as_slice()).unwrap(); std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents("Open Notebook.onetoc2", &[("links.one", section)]) diff --git a/crates/onestore/tests/page_lists.rs b/crates/onestore/tests/page_lists.rs index af757f3d277fe932987552b273abdd2c83d08bcb..70236cc007b6ea3046909d8176eff9bef1c9ce4c 100644 --- a/crates/onestore/tests/page_lists.rs +++ b/crates/onestore/tests/page_lists.rs @@ -1,5 +1,7 @@ +#[path = "support/ops.rs"] +mod ops; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, document::{Document, Format, Kind}, page::{ Definition, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id, @@ -9,7 +11,6 @@ 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(); @@ -114,8 +115,8 @@ fn a_numbered_paragraph_becomes_plain_while_its_neighbours_keep_their_nodes() { 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 written = ops::saved(TREES, space, &after).unwrap(); + let stored = page_in(written.as_slice(), space); let mut expected = after.clone(); expected.title = stored.title.clone(); expected.definitions.remove(&lists[numbered[0]][0]); @@ -123,16 +124,16 @@ fn a_numbered_paragraph_becomes_plain_while_its_neighbours_keep_their_nodes() { for other in &numbered[1..] { for node in &lists[*other] { assert_eq!( - raw_object(written.as_bytes(), space, *node), + raw_object(written.as_slice(), space, *node), raw_object(TREES, space, *node) ); } } assert_eq!( - PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + ops::saved(written.as_slice(), space, &stored) .unwrap() - .as_bytes(), - written.as_bytes() + .as_slice(), + written.as_slice() ); } @@ -157,16 +158,16 @@ fn bullets_and_numbering_write_from_definitions_and_read_back() { 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 written = ops::saved(TREES, space, &after).unwrap(); + let stored = page_in(written.as_slice(), 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) + ops::saved(written.as_slice(), space, &stored) .unwrap() - .as_bytes(), - written.as_bytes() + .as_slice(), + written.as_slice() ); } @@ -183,8 +184,8 @@ fn a_list_definition_changes_in_place() { }; *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 written = ops::saved(TREES, space, &after).unwrap(); + let stored = page_in(written.as_slice(), space); let mut expected = after.clone(); expected.title = stored.title.clone(); assert_eq!(stored, expected); @@ -199,8 +200,8 @@ fn a_definition_shared_between_paragraphs_becomes_a_node_per_paragraph() { 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 written = ops::saved(TREES, space, &shared).unwrap(); + let stored = page_in(written.as_slice(), space); let after = list_ids(&stored); assert_eq!(after[numbered], lists[numbered]); assert_eq!(after[plain].len(), 1); @@ -218,12 +219,12 @@ fn missing_and_foreign_list_definitions_are_refused() { 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()); + assert!(ops::saved(TREES, space, &missing).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()); + assert!(ops::saved(TREES, space, &foreign).is_err()); let mut style: BTreeMap = before.definitions.clone(); if let Some((id, definition)) = style .iter_mut() @@ -232,7 +233,7 @@ fn missing_and_foreign_list_definitions_are_refused() { 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()); + assert!(ops::saved(TREES, space, &edited).is_err()); } } @@ -299,16 +300,16 @@ fn bullets_numbering_nesting_and_restarts_publish_on_a_fresh_page() { 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 written = ops::saved(&source, space, &after).unwrap(); + let stored = page_in(written.as_slice(), 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("lists.one"), written.as_slice()).unwrap(); + let written_store = Store::parse(written.as_slice()).unwrap(); std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( diff --git a/crates/onestore/tests/page_math.rs b/crates/onestore/tests/page_math.rs index e0787b8af3027dfe0f14351118f304ddade40ba3..b7f707d5a90475972f35e4597a32fa6abb023daf 100644 --- a/crates/onestore/tests/page_math.rs +++ b/crates/onestore/tests/page_math.rs @@ -1,3 +1,5 @@ +#[path = "support/ops.rs"] +mod ops; use onestore::{ RevisionIndex, Store, document::Document, @@ -272,8 +274,8 @@ fn equations_are_written_and_read_back() { } outline.paragraphs.push(paragraph); } - let written = onestore::PreparedEdit::page(&source, space, &after, "Math author").unwrap(); - let store = Store::parse(written.as_bytes()).unwrap(); + let written = ops::saved(&source, space, &after).unwrap(); + let store = Store::parse(written.as_slice()).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); let stored = Page::from_space(&document, space).unwrap(); @@ -305,13 +307,13 @@ fn equations_are_written_and_read_back() { } assert_eq!(stored, expected); assert_eq!( - rendered_equations(written.as_bytes(), &stored.title), + rendered_equations(written.as_slice(), &stored.title), expected_mathml ); if let Some(directory) = std::env::var_os("ONESTORE_MATH_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir(&directory).unwrap(); - std::fs::write(directory.join("math.one"), written.as_bytes()).unwrap(); + std::fs::write(directory.join("math.one"), written.as_slice()).unwrap(); std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( @@ -361,8 +363,8 @@ fn a_paragraph_emptied_of_its_inline_math_saves() { let plain = text.text.spans()[0].format.clone(); assert_ne!(plain.math, Some(true)); text.text = onestore::page::Paragraph::new(String::new(), plain); - let written = onestore::PreparedEdit::page(NATIVE, space, &after, "Author").unwrap(); - let store = Store::parse(written.as_bytes()).unwrap(); + let written = ops::saved(NATIVE, space, &after).unwrap(); + let store = Store::parse(written.as_slice()).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); assert_eq!(Page::from_space(&document, space).unwrap(), after); diff --git a/crates/onestore/tests/page_media.rs b/crates/onestore/tests/page_media.rs index 9f24c297c880a8041b58fd91eb6d2b9f604f13d9..c18be4ca6372b03407a962d7dce9f053837ab5dc 100644 --- a/crates/onestore/tests/page_media.rs +++ b/crates/onestore/tests/page_media.rs @@ -1,5 +1,7 @@ +#[path = "support/ops.rs"] +mod ops; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, document::Document, page::{MediaIndex, Page, PageObject, ParagraphContent, Recording}, }; @@ -96,8 +98,8 @@ fn editing_a_recorded_page_keeps_the_recording_and_its_annotation() { tags: Vec::new(), }); outline.paragraphs.push(added.clone()); - let written = PreparedEdit::page(NATIVE, space, &after, "Media author").unwrap(); - let (_, stored) = page(written.as_bytes()); + let written = ops::saved(NATIVE, space, &after).unwrap(); + let (_, stored) = page(written.as_slice()); assert_eq!(recording(&stored), recording(&before)); assert_eq!(stored.objects.len(), before.objects.len()); @@ -112,7 +114,7 @@ fn editing_a_recorded_page_keeps_the_recording_and_its_annotation() { .unwrap(); let last = outline.paragraphs.last_mut().unwrap(); last.media = recording(&before).1; - assert!(PreparedEdit::page(written.as_bytes(), space, &annotated, "x").is_err()); + assert!(ops::saved(written.as_slice(), space, &annotated).is_err()); let mut recaptured = after.clone(); for object in &mut recaptured.objects { if let PageObject::Outline(outline) = object { @@ -123,13 +125,13 @@ fn editing_a_recorded_page_keeps_the_recording_and_its_annotation() { } } } - assert!(PreparedEdit::page(written.as_bytes(), space, &recaptured, "x").is_err()); + assert!(ops::saved(written.as_slice(), space, &recaptured).is_err()); if let Some(directory) = std::env::var_os("ONESTORE_MEDIA_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir_all(&directory).unwrap(); - std::fs::write(directory.join("Features.one"), written.as_bytes()).unwrap(); - let file_id = Store::parse(written.as_bytes()).unwrap().header.file_id; + std::fs::write(directory.join("Features.one"), written.as_slice()).unwrap(); + let file_id = Store::parse(written.as_slice()).unwrap().header.file_id; std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( @@ -139,7 +141,7 @@ fn editing_a_recorded_page_keeps_the_recording_and_its_annotation() { .unwrap(), ) .unwrap(); - let store = Store::parse(written.as_bytes()).unwrap(); + let store = Store::parse(written.as_slice()).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let count = Document::parse(&index).unwrap().pages().unwrap().len(); std::fs::write(directory.join("expected-count.txt"), count.to_string()).unwrap(); diff --git a/crates/onestore/tests/page_movement.rs b/crates/onestore/tests/page_movement.rs index d726678e26b2b9275ddad8283af0bfb9d8927305..fed316aac3be21bc7a789ace471b01cc87da864d 100644 --- a/crates/onestore/tests/page_movement.rs +++ b/crates/onestore/tests/page_movement.rs @@ -1,6 +1,10 @@ +#[path = "support/ops.rs"] +mod ops; + use onestore::{ - ExGuid, PageEdit, PreparedEdit, RevisionIndex, Store, + ExGuid, PageEdit, RevisionIndex, Store, document::{Document, Kind}, + op::{PageOp, SectionOp}, }; #[path = "support/current.rs"] @@ -175,10 +179,10 @@ fn explicit_page_edits_match_native_selection_and_indentation() { let serialized = serde_json::to_vec(&edits).unwrap(); let retained: Vec = serde_json::from_slice(&serialized).unwrap(); assert_eq!(edits, retained); - let prepared = PreparedEdit::pages(&source, &retained).unwrap(); + let prepared = ops::section_op(&source, SectionOp::Pages(retained.to_vec())).unwrap(); verify(&source, prepared.as_bytes(), native); assert_eq!( - PreparedEdit::pages(prepared.as_bytes(), &retained) + ops::section_op(prepared.as_bytes(), SectionOp::Pages(retained.to_vec())) .unwrap() .as_bytes(), prepared.as_bytes() @@ -187,7 +191,7 @@ fn explicit_page_edits_match_native_selection_and_indentation() { source = prepared.as_bytes().to_vec(); } let edit = PageEdit::move_to(ids[3], None, 1).unwrap(); - let single = PreparedEdit::pages(SOURCE, &[edit]).unwrap(); + let single = ops::section_op(SOURCE, SectionOp::Pages([edit].to_vec())).unwrap(); verify( SOURCE, single.as_bytes(), @@ -206,32 +210,29 @@ fn invalid_and_empty_page_edits_do_not_publish() { } assert!(PageEdit::move_to(ids[0], Some(ids[0]), 1).is_err()); assert!(PageEdit::set_level(ExGuid::default(), 1).is_err()); - assert_eq!(PreparedEdit::pages(SOURCE, &[]).unwrap().as_bytes(), SOURCE); + assert_eq!(ops::section_op(SOURCE, SectionOp::Pages([].to_vec())).unwrap().as_bytes(), SOURCE); assert_eq!( - PreparedEdit::pages(SOURCE, &[PageEdit::set_level(ids[0], 1).unwrap()]) + ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(ids[0], 1).unwrap()].to_vec())) .unwrap() .as_bytes(), SOURCE ); - assert!(PreparedEdit::pages(SOURCE, &[PageEdit::set_level(ids[0], 2).unwrap()]).is_err()); + assert!(ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(ids[0], 2).unwrap()].to_vec())).is_err()); let duplicate = PageEdit::set_level(ids[3], 2).unwrap(); - assert!(PreparedEdit::pages(SOURCE, &[duplicate.clone(), duplicate]).is_err()); + assert!(ops::section_op(SOURCE, SectionOp::Pages([duplicate.clone(), duplicate].to_vec())).is_err()); let missing = ExGuid { guid: [0x77; 16], n: 1, }; - assert!(PreparedEdit::pages(SOURCE, &[PageEdit::set_level(missing, 1).unwrap()]).is_err()); + assert!(ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(missing, 1).unwrap()].to_vec())).is_err()); assert!( - PreparedEdit::pages( - SOURCE, - &[PageEdit::move_to(ids[3], Some(missing), 1).unwrap()] - ) + ops::section_op(SOURCE, SectionOp::Pages([PageEdit::move_to(ids[3], Some(missing), 1).unwrap()].to_vec())) .is_err() ); let mut value = serde_json::to_value(PageEdit::set_level(ids[3], 2).unwrap()).unwrap(); value["level"] = 4.into(); let invalid: PageEdit = serde_json::from_value(value.clone()).unwrap(); - assert!(PreparedEdit::pages(SOURCE, &[invalid]).is_err()); + assert!(ops::section_op(SOURCE, SectionOp::Pages([invalid].to_vec())).is_err()); value["unknown"] = true.into(); assert!(serde_json::from_value::(value).is_err()); } @@ -246,12 +247,12 @@ fn moving_pages_preserves_ink_file_data_and_native_feature_objects() { let first = pages(source)[0]; let created = onestore::PageCreation::new(Some(first), Some("Movement anchor"), "Author").unwrap(); - let source = PreparedEdit::create_page(source, &created) + let source = ops::section_op(source, SectionOp::Create(created.clone())) .unwrap() .as_bytes() .to_vec(); let moved = - PreparedEdit::pages(&source, &[PageEdit::move_to(first, None, 1).unwrap()]).unwrap(); + ops::section_op(&source, SectionOp::Pages([PageEdit::move_to(first, None, 1).unwrap()].to_vec())).unwrap(); assert_eq!(pages(moved.as_bytes()).last(), Some(&first)); let stores = [&source, moved.as_bytes()].map(|bytes| Store::parse(bytes).unwrap()); let indexes = stores @@ -295,7 +296,7 @@ fn section_reordering_refreshes_stale_native_metadata_levels() { .enumerate() .map(|(i, sid)| PageEdit::move_to(*sid, None, u32::try_from(i + 1).unwrap()).unwrap()) .collect(); - let prepared = PreparedEdit::pages(source, &edits).unwrap(); + let prepared = ops::section_op(source, SectionOp::Pages(edits.to_vec())).unwrap(); let stores = [source.as_slice(), prepared.as_bytes()].map(|b| Store::parse(b).unwrap()); let indexes = stores.each_ref().map(|s| RevisionIndex::parse(s).unwrap()); let documents = indexes.each_ref().map(|i| Document::parse(i).unwrap()); @@ -335,7 +336,7 @@ fn metadata_copy_order_does_not_change_page_identity() { "../../../corpus/page-lifecycle/page-edits/optional-cache/source-cold/notebook/Lifecycle.one" ); let sid = pages(source)[3]; - let edited = PreparedEdit::pages(source, &[PageEdit::set_level(sid, 2).unwrap()]).unwrap(); + let edited = ops::section_op(source, SectionOp::Pages([PageEdit::set_level(sid, 2).unwrap()].to_vec())).unwrap(); let expected = include_bytes!( "../../../corpus/page-lifecycle/page-edits/optional-cache/candidate/Lifecycle.one" ); @@ -371,8 +372,8 @@ fn native_edits_on_moved_pages_support_more_rust_changes() { let bodies: Vec<_> = view.nodes.iter().filter_map(|(id, node)| matches!(&node.kind, Kind::RichText { text, .. } if text == "Native body after a Rust page move.").then_some(*id)).collect(); let [body] = bodies.as_slice() else { panic!() }; - let moved = PreparedEdit::pages(source, &[PageEdit::set_level(sid, 2).unwrap()]).unwrap(); - let edited = PreparedEdit::text(moved.as_bytes(), sid, *body, 0..0, "Rust + ").unwrap(); + let moved = ops::section_op(source, SectionOp::Pages([PageEdit::set_level(sid, 2).unwrap()].to_vec())).unwrap(); + let edited = ops::page_op(moved.as_bytes(), sid, PageOp::Text { text: *body, range: 0..0, with: "Rust + ".into() }).unwrap(); let after_store = Store::parse(edited.as_bytes()).unwrap(); let after_index = RevisionIndex::parse(&after_store).unwrap(); let after = Document::parse(&after_index).unwrap(); @@ -401,7 +402,7 @@ fn grouped_page_levels_and_membership_survive_each_storage_interruption() { let mut source = onestore::create_section("movement.one", "Original", "Author").unwrap(); for _ in 0..2 { let page = onestore::PageCreation::new(None, Some("Same title"), "Author").unwrap(); - source = PreparedEdit::create_page(&source, &page) + source = ops::section_op(&source, SectionOp::Create(page.clone())) .unwrap() .as_bytes() .to_vec(); @@ -421,7 +422,7 @@ fn grouped_page_levels_and_membership_survive_each_storage_interruption() { PageEdit::set_level(ids[first], 2).unwrap(), PageEdit::set_level(ids[first + 1], 3).unwrap(), ]; - let prepared = PreparedEdit::pages(source, &edits).unwrap(); + let prepared = ops::section_op(source, SectionOp::Pages(edits.to_vec())).unwrap(); let old = current::current(source); let new = current::current(prepared.as_bytes()); assert_eq!(new.len(), old.len()); @@ -460,7 +461,7 @@ fn grouped_page_levels_and_membership_survive_each_storage_interruption() { fn repeated_nesting_retains_series_history_across_revision_checkpoints() { let mut source = onestore::create_section("movement.one", "Original", "Author").unwrap(); let page = onestore::PageCreation::new(None, Some("Child"), "Author").unwrap(); - source = PreparedEdit::create_page(&source, &page) + source = ops::section_op(&source, SectionOp::Create(page.clone())) .unwrap() .as_bytes() .to_vec(); @@ -468,7 +469,7 @@ fn repeated_nesting_retains_series_history_across_revision_checkpoints() { for step in 0..520 { let level = if step % 2 == 0 { 2 } else { 1 }; let edit = PageEdit::set_level(page.space(), level).unwrap(); - let written = PreparedEdit::pages(&source, &[edit]) + let written = ops::section_op(&source, SectionOp::Pages([edit].to_vec())) .unwrap() .as_bytes() .to_vec(); diff --git a/crates/onestore/tests/page_removal.rs b/crates/onestore/tests/page_removal.rs index 08a1e7ad2894c15dae67183c9a9009bf37b0ec69..19b9d2101a0f3e3b1c70e4333c056637d711ada4 100644 --- a/crates/onestore/tests/page_removal.rs +++ b/crates/onestore/tests/page_removal.rs @@ -1,3 +1,5 @@ +#[path = "support/ops.rs"] +mod ops; use onestore::{ ExGuid, FileDataReference, IdStream, ObjectData, PropertySets, RevisionIndex, Store, Value, document::Document, @@ -525,7 +527,16 @@ fn copy_comparison_rejects_text_and_payload_changes() { .then_some(*oid) }) .unwrap(); - let changed = onestore::replace_text(copied, copy_sid, text, 0..0, "Changed ").unwrap(); + let changed = ops::page_edited( + copied, + copy_sid, + vec![onestore::op::PageOp::Text { + text, + range: 0..0, + with: "Changed ".into(), + }], + ) + .unwrap(); let changed_store = Store::parse(&changed).unwrap(); RevisionIndex::parse(&changed_store) .unwrap() diff --git a/crates/onestore/tests/page_tables.rs b/crates/onestore/tests/page_tables.rs index 85a87143eaabcd75db21683f49bd1c500934529b..8beeeba1c8d9d9d0ba61cf393716559f5ae74c75 100644 --- a/crates/onestore/tests/page_tables.rs +++ b/crates/onestore/tests/page_tables.rs @@ -1,5 +1,7 @@ +#[path = "support/ops.rs"] +mod ops; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, document::{Document, Format}, page::{ Page, PageObject, PageParagraph, ParagraphContent, Table, TableCell, TableColumn, TableRow, @@ -9,7 +11,6 @@ use onestore::{ const TREES: &[u8] = include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one"); -const AUTHOR: &str = "Table author"; fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) { let store = Store::parse(bytes).unwrap(); @@ -174,19 +175,19 @@ fn a_row_and_a_column_are_added_to_a_native_table_and_removed_again() { new_cell(&template, "Second row, third column"), ], }); - let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); - let stored = assert_same(written.as_bytes(), space, &after); + let written = ops::saved(TREES, space, &after).unwrap(); + let stored = assert_same(written.as_slice(), space, &after); assert_eq!( - PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + ops::saved(written.as_slice(), space, &stored) .unwrap() - .as_bytes(), - written.as_bytes() + .as_slice(), + written.as_slice() ); if let Some(directory) = std::env::var_os("ONESTORE_TABLE_EDIT_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir(&directory).unwrap(); - std::fs::write(directory.join("synthetic.one"), written.as_bytes()).unwrap(); - let written_store = Store::parse(written.as_bytes()).unwrap(); + std::fs::write(directory.join("synthetic.one"), written.as_slice()).unwrap(); + let written_store = Store::parse(written.as_slice()).unwrap(); std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( @@ -204,8 +205,8 @@ fn a_row_and_a_column_are_added_to_a_native_table_and_removed_again() { row.cells.remove(1); } table.rows.remove(0); - let again = PreparedEdit::page(written.as_bytes(), space, &smaller, AUTHOR).unwrap(); - let mut stored = assert_same(again.as_bytes(), space, &smaller); + let again = ops::saved(written.as_slice(), space, &smaller).unwrap(); + let mut stored = assert_same(again.as_slice(), space, &smaller); let table = table_mut(&mut stored); assert_eq!((table.rows.len(), table.columns.len()), (1, 2)); } @@ -218,8 +219,8 @@ fn column_widths_locks_and_borders_change_in_place() { table.columns[0].width = 300.0; table.columns[1].locked = false; table.borders = Some(false); - let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); - assert_same(written.as_bytes(), space, &after); + let written = ops::saved(TREES, space, &after).unwrap(); + assert_same(written.as_slice(), space, &after); } #[test] @@ -232,17 +233,17 @@ fn cell_shading_and_indents_change_in_place() { cell.shading = Some(0x0000ffff); let original_indents = cell.indents.clone(); cell.indents.clear(); - assert!(PreparedEdit::page(TREES, space, &after, AUTHOR).is_err()); + assert!(ops::saved(TREES, space, &after).is_err()); let cell = &mut table_mut(&mut after).rows[0].cells[0]; cell.indents = vec![18.0, 0.0, 27.0, 27.0]; - let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); - let stored = assert_same(written.as_bytes(), space, &after); + let written = ops::saved(TREES, space, &after).unwrap(); + let stored = assert_same(written.as_slice(), space, &after); let mut reset = stored.clone(); let cell = &mut table_mut(&mut reset).rows[0].cells[0]; cell.shading = None; cell.indents = original_indents; - let again = PreparedEdit::page(written.as_bytes(), space, &reset, AUTHOR).unwrap(); - assert_eq!(assert_same(again.as_bytes(), space, &reset), before); + let again = ops::saved(written.as_slice(), space, &reset).unwrap(); + assert_eq!(assert_same(again.as_slice(), space, &reset), before); } /// `ONESTORE_NESTED_TABLE_EXPORT` names a new directory receiving the candidate for a cold reopen. @@ -286,7 +287,7 @@ fn a_table_nests_inside_a_cell_and_is_removed_again() { table_mut(&mut after).rows[0].cells[1] .paragraphs .push(holder); - let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); + let written = ops::saved(TREES, space, &after).unwrap(); let ParagraphContent::Table(nested) = &mut table_mut(&mut after).rows[0].cells[1] .paragraphs .last_mut() @@ -296,12 +297,12 @@ fn a_table_nests_inside_a_cell_and_is_removed_again() { panic!() }; with_native_indents(nested); - let stored = assert_same(written.as_bytes(), space, &after); + let stored = assert_same(written.as_slice(), space, &after); if let Some(directory) = std::env::var_os("ONESTORE_NESTED_TABLE_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir(&directory).unwrap(); - std::fs::write(directory.join("synthetic.one"), written.as_bytes()).unwrap(); - let written_store = Store::parse(written.as_bytes()).unwrap(); + std::fs::write(directory.join("synthetic.one"), written.as_slice()).unwrap(); + let written_store = Store::parse(written.as_slice()).unwrap(); std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( @@ -316,8 +317,8 @@ fn a_table_nests_inside_a_cell_and_is_removed_again() { table_mut(&mut removed).rows[0].cells[1] .paragraphs .retain(|paragraph| paragraph.id != holder_id); - let again = PreparedEdit::page(written.as_bytes(), space, &removed, AUTHOR).unwrap(); - assert_eq!(assert_same(again.as_bytes(), space, &removed), before); + let again = ops::saved(written.as_slice(), space, &removed).unwrap(); + assert_eq!(assert_same(again.as_slice(), space, &removed), before); } #[test] @@ -328,20 +329,17 @@ fn inconsistent_tables_are_refused() { table_mut(&mut ragged).rows[0] .cells .push(new_cell(&template, "Extra")); - assert!(PreparedEdit::page(TREES, space, &ragged, AUTHOR).is_err()); + assert!(ops::saved(TREES, space, &ragged).is_err()); + // A cell keeps a paragraph. let mut empty = before.clone(); table_mut(&mut empty).rows[0].cells[0].paragraphs.clear(); - let written = PreparedEdit::page(TREES, space, &empty, AUTHOR).unwrap(); - let mut stored = page_in(written.as_bytes(), space); - let cell = &table_mut(&mut stored).rows[0].cells[0]; - assert_eq!(cell.paragraphs.len(), 1); - assert_eq!(cell.paragraphs[0].text().unwrap().text.text(), ""); + assert!(ops::saved(TREES, space, &empty).is_err()); let mut narrow = before.clone(); table_mut(&mut narrow).columns[0].width = 10.0; - assert!(PreparedEdit::page(TREES, space, &narrow, AUTHOR).is_err()); + assert!(ops::saved(TREES, space, &narrow).is_err()); let mut rowless = before.clone(); table_mut(&mut rowless).rows.clear(); - assert!(PreparedEdit::page(TREES, space, &rowless, AUTHOR).is_err()); + assert!(ops::saved(TREES, space, &rowless).is_err()); } /// `ONESTORE_TABLE_EXPORT` names a new directory receiving the candidate for a cold reopen. @@ -394,14 +392,14 @@ fn a_new_table_is_created_on_a_fresh_page() { holder.format = Format::default(); body_paragraphs(&mut after).push(holder); body_paragraphs(&mut after).push(cell_paragraph(&template, "After the table")); - let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); + let written = ops::saved(&source, space, &after).unwrap(); with_native_indents(table_mut(&mut after)); - assert_same(written.as_bytes(), space, &after); + assert_same(written.as_slice(), space, &after); if let Some(directory) = std::env::var_os("ONESTORE_TABLE_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir(&directory).unwrap(); - std::fs::write(directory.join("tables.one"), written.as_bytes()).unwrap(); - let written_store = Store::parse(written.as_bytes()).unwrap(); + std::fs::write(directory.join("tables.one"), written.as_slice()).unwrap(); + let written_store = Store::parse(written.as_slice()).unwrap(); std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( diff --git a/crates/onestore/tests/page_tags.rs b/crates/onestore/tests/page_tags.rs index 16bd65cbd386c77452913718e578ed4a179a37d4..eec322c51ca55e55bd0f5e1c8199fb83bda499e6 100644 --- a/crates/onestore/tests/page_tags.rs +++ b/crates/onestore/tests/page_tags.rs @@ -1,5 +1,7 @@ +#[path = "support/ops.rs"] +mod ops; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, document::{Document, Format, Kind, Tag}, page::{ Definition, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id, @@ -8,7 +10,6 @@ use onestore::{ const TREES: &[u8] = include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one"); -const AUTHOR: &str = "Tag author"; fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) { let store = Store::parse(bytes).unwrap(); @@ -161,13 +162,13 @@ fn removing_a_tag_leaves_its_definition_and_the_other_paragraphs_untouched() { .unwrap() .tags .clear(); - let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); + let written = ops::saved(TREES, space, &after).unwrap(); let mut expected = after.clone(); expected.definitions.remove(&definition); - let stored = assert_same(written.as_bytes(), space, &expected); + let stored = assert_same(written.as_slice(), space, &expected); assert!(!stored.definitions.contains_key(&definition)); assert_eq!( - raw_object(written.as_bytes(), space, definition), + raw_object(written.as_slice(), space, definition), raw_object(TREES, space, definition) ); let count = body_paragraphs(&mut after).len(); @@ -176,7 +177,7 @@ fn removing_a_tag_leaves_its_definition_and_the_other_paragraphs_untouched() { .unwrap() .id; assert_eq!( - raw_object(written.as_bytes(), space, other), + raw_object(written.as_slice(), space, other), raw_object(TREES, space, other) ); } @@ -195,24 +196,17 @@ fn an_existing_definition_tags_another_paragraph_and_a_task_completes_in_place() .unwrap() .tags .push(tag(definition, false)); - let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); - let stored = assert_same(written.as_bytes(), space, &after); + let written = ops::saved(TREES, space, &after).unwrap(); + let stored = assert_same(written.as_slice(), space, &after); let mut completed = stored.clone(); let text = body_paragraphs(&mut completed)[other].text_mut().unwrap(); text.tags[0].status = 1; text.tags[0].completed = Some(1_262_405_106); - let again = PreparedEdit::page(written.as_bytes(), space, &completed, AUTHOR).unwrap(); - assert_same(again.as_bytes(), space, &completed); + let again = ops::saved(written.as_slice(), space, &completed).unwrap(); + assert_same(again.as_slice(), space, &completed); assert_eq!( - PreparedEdit::page( - again.as_bytes(), - space, - &page_in(again.as_bytes(), space), - AUTHOR - ) - .unwrap() - .as_bytes(), - again.as_bytes() + ops::saved(&again, space, &page_in(&again, space)).unwrap(), + again ); } @@ -225,7 +219,7 @@ fn tags_without_a_known_definition_are_refused() { .unwrap() .tags .push(tag(new_id().unwrap(), false)); - assert!(PreparedEdit::page(TREES, space, &missing, AUTHOR).is_err()); + assert!(ops::saved(TREES, space, &missing).is_err()); let mut repeated = before.clone(); let at = tagged(&repeated); let existing = body_paragraphs(&mut repeated)[at].text().unwrap().tags[0].clone(); @@ -234,7 +228,7 @@ fn tags_without_a_known_definition_are_refused() { .unwrap() .tags .push(tag(existing.definition.unwrap(), false)); - assert!(PreparedEdit::page(TREES, space, &repeated, AUTHOR).is_err()); + assert!(ops::saved(TREES, space, &repeated).is_err()); let mut wrong = before.clone(); let list = wrong .definitions @@ -247,7 +241,7 @@ fn tags_without_a_known_definition_are_refused() { .unwrap() .tags .push(tag(list, false)); - assert!(PreparedEdit::page(TREES, space, &wrong, AUTHOR).is_err()); + assert!(ops::saved(TREES, space, &wrong).is_err()); } } @@ -293,13 +287,13 @@ fn new_definitions_and_tags_publish_on_a_fresh_page() { both.text_mut().unwrap().tags.push(tag(task, false)); body_paragraphs(&mut after).push(both); body_paragraphs(&mut after).push(plain_paragraph(&template, "Untagged")); - let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); - assert_same(written.as_bytes(), space, &after); + let written = ops::saved(&source, space, &after).unwrap(); + assert_same(written.as_slice(), space, &after); if let Some(directory) = std::env::var_os("ONESTORE_TAG_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir(&directory).unwrap(); - std::fs::write(directory.join("tags.one"), written.as_bytes()).unwrap(); - let written_store = Store::parse(written.as_bytes()).unwrap(); + std::fs::write(directory.join("tags.one"), written.as_slice()).unwrap(); + let written_store = Store::parse(written.as_slice()).unwrap(); std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( diff --git a/crates/onestore/tests/page_writer.rs b/crates/onestore/tests/page_writer.rs index 01a19853c98863b7b2887875d3e09da08c2a3b83..db9f45ce7ad0db0e4b478d8c12c71a314a9ce3da 100644 --- a/crates/onestore/tests/page_writer.rs +++ b/crates/onestore/tests/page_writer.rs @@ -1,15 +1,34 @@ -//! The page writer must publish the same graph the typed writers publish for the same edit, -//! in one transaction, and must round-trip the model it was given. +//! A page edit lowered whole (`op::lower_page`) must publish the same graph its ops applied +//! one by one publish, in one transaction, and must round-trip the model it was given. + +#[path = "support/ops.rs"] +mod ops; use onestore::{ - ExGuid, Insertion, OutlineEdit, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex, - Store, TextAttribute, TreeEdit, + ExGuid, OutlineEdit, RevisionIndex, Store, TextAttribute, document::{Document, Element, Kind, Revision}, + op::PageOp, page::{Page, PageObject, ParagraphContent, text::new_id}, }; use std::collections::BTreeSet; -const AUTHOR: &str = "Page author"; +/// `source` after `ops` on the page in `space`. +fn edited(source: &[u8], space: ExGuid, ops: Vec) -> Vec { + ops::page_edited(source, space, ops).unwrap() +} + +/// A plain paragraph of `text` inserted in `container` before `before`, at `level`. +fn insert(container: ExGuid, before: Option, text: &str, level: u32) -> PageOp { + let mut paragraph = ops::paragraph(text); + paragraph.level = level; + PageOp::Insert { + container, + before, + paragraphs: vec![paragraph], + } +} + +const AUTHOR: &str = "Author"; const PARAGRAPHS: &[u8] = include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"); const OUTLINES: &[u8] = @@ -144,7 +163,7 @@ fn same_element(revision_a: &Revision<'_>, revision_b: &Revision<'_>, id: ExGuid } } -/// Every active revision the typed writers produced is reproduced by the page writer, +/// Every active revision the ops produced one by one is reproduced by the page lowered whole, /// ignoring writer-allocated immutable styles and author records. Modification times are /// not compared: a reorder can be attributed to either sibling, and the model cannot say which. fn equivalent(reference: &[u8], written: &[u8]) { @@ -182,11 +201,11 @@ fn equivalent(reference: &[u8], written: &[u8]) { } } -/// Runs the page writer on the model the typed writers produced and checks both contracts. +/// Lowers the page the ops produced whole and checks both contracts. fn oracle(source: &[u8], space: ExGuid, reference: &[u8]) -> Vec { let after = page_in(reference, space); - let prepared = PreparedEdit::page(source, space, &after, AUTHOR).unwrap(); - let written = prepared.as_bytes().to_vec(); + let prepared = ops::saved(source, space, &after).unwrap(); + let written = prepared.as_slice().to_vec(); assert_eq!(page_in(&written, space), after, "model round trip"); equivalent(reference, &written); let transactions = |bytes: &[u8]| Store::parse(bytes).unwrap().header.transaction_count; @@ -209,9 +228,12 @@ fn text_replacement_matches_replace_text() { (end..end, " end"), (0..end, ""), ] { - let reference = - onestore::replace_text(PARAGRAPHS, space, text, range, replacement).unwrap(); - oracle(PARAGRAPHS, space, &reference); + let op = PageOp::Text { + text, + range, + with: replacement.into(), + }; + oracle(PARAGRAPHS, space, &edited(PARAGRAPHS, space, vec![op])); } } @@ -220,28 +242,16 @@ fn formatting_matches_format_text() { let (space, page) = page_by_title(PARAGRAPHS, "Split style boundary"); let (_, text, content) = text_ids(body(&page)[0])[0].clone(); let end = u32::try_from(content.encode_utf16().count()).unwrap(); - let reference = PreparedEdit::format( - PARAGRAPHS, - space, - text, - 1..end.min(4), - &[ + let reference = ops::page_op(PARAGRAPHS, space, PageOp::Format { text, range: 1..end.min(4), set: vec![ TextAttribute::Bold(true), TextAttribute::Color(Some([255, 0, 0])), TextAttribute::FontSize(14.0), - ], - ) + ], clear: Vec::new() }) .unwrap(); - let reference = PreparedEdit::format( - reference.as_bytes(), - space, - text, - 0..1, - &[ + let reference = ops::page_op(reference.as_bytes(), space, PageOp::Format { text, range: 0..1, set: vec![ TextAttribute::Italic(true), TextAttribute::Font("Consolas".into()), - ], - ) + ], clear: Vec::new() }) .unwrap(); oracle(PARAGRAPHS, space, reference.as_bytes()); } @@ -257,14 +267,13 @@ fn paragraph_insertions_match_insertion() { .find(|p| p.parent.is_none()) .unwrap() .id; - for intent in [ - Insertion::paragraph(outline.id, None, "Appended 🦋 é", AUTHOR).unwrap(), - Insertion::paragraph(outline.id, Some(paragraphs[0].0), "First", AUTHOR).unwrap(), - Insertion::paragraph(parent, None, "Nested child", AUTHOR).unwrap(), - Insertion::paragraph(outline.id, None, "", AUTHOR).unwrap(), + for op in [ + insert(outline.id, None, "Appended 🦋 é", 1), + insert(outline.id, Some(paragraphs[0].0), "First", 1), + insert(parent, None, "Nested child", 2), + insert(outline.id, None, "", 1), ] { - let reference = PreparedEdit::insert(OUTLINES, space, &intent).unwrap(); - oracle(OUTLINES, space, reference.as_bytes()); + oracle(OUTLINES, space, &edited(OUTLINES, space, vec![op])); } } @@ -281,27 +290,23 @@ fn cell_insertion_and_deletion_match() { }) .unwrap(); let cell = &table.rows[0].cells[0]; - let intent = Insertion::paragraph(cell.id, None, "Cell text", AUTHOR).unwrap(); - let reference = PreparedEdit::insert(TREES, space, &intent).unwrap(); - oracle(TREES, space, reference.as_bytes()); + let reference = edited(TREES, space, vec![insert(cell.id, None, "Cell text", 1)]); + oracle(TREES, space, &reference); let victim = cell .paragraphs .iter() .find(|p| p.parent.is_none()) .unwrap() .id; - let reference = - PreparedEdit::tree(TREES, space, &TreeEdit::delete(victim, AUTHOR).unwrap()).unwrap(); - oracle(TREES, space, reference.as_bytes()); + let reference = edited(&reference, space, vec![PageOp::Delete { object: victim }]); + oracle(TREES, space, &reference); } #[test] fn outline_insertion_matches_insertion() { let (space, _) = page_by_title(OUTLINES, "Move leaf down"); - let page = page_id(OUTLINES, space); - let intent = Insertion::outline(page, 144.0, 200.0, "New outline 東京", AUTHOR).unwrap(); - let reference = PreparedEdit::insert(OUTLINES, space, &intent).unwrap(); - oracle(OUTLINES, space, reference.as_bytes()); + let (add, ..) = ops::new_outline(144.0, 200.0, "New outline 東京"); + oracle(OUTLINES, space, &edited(OUTLINES, space, vec![add])); } #[test] @@ -325,16 +330,24 @@ fn splits_and_joins_match_their_writers() { .is_none_or(|u| !(0xd800..=0xdbff).contains(&u)) }) .unwrap(); - let split = ParagraphSplit::new(text, offset, AUTHOR).unwrap(); - let reference = PreparedEdit::split(PARAGRAPHS, space, &split).unwrap(); - oracle(PARAGRAPHS, space, reference.as_bytes()); + let lists = body(&page)[0].paragraphs[0].lists.len(); + let split = PageOp::Split { + text, + at: offset, + paragraph: new_id().unwrap(), + right: new_id().unwrap(), + lists: (0..lists).map(|_| new_id().unwrap()).collect(), + }; + oracle(PARAGRAPHS, space, &edited(PARAGRAPHS, space, vec![split])); } for title in ["Split middle", "Split empty"] { let (space, page) = page_by_title(PARAGRAPHS, title); let paragraphs = text_ids(body(&page)[0]); - let join = ParagraphJoin::new(paragraphs[0].1, paragraphs[1].1, AUTHOR).unwrap(); - let reference = PreparedEdit::join(PARAGRAPHS, space, &join).unwrap(); - oracle(PARAGRAPHS, space, reference.as_bytes()); + let join = PageOp::Join { + left: paragraphs[0].1, + right: paragraphs[1].1, + }; + oracle(PARAGRAPHS, space, &edited(PARAGRAPHS, space, vec![join])); } } @@ -357,18 +370,28 @@ fn subtree_moves_and_deletions_match_tree_edits() { .filter(|p| p.parent.is_none()) .map(|p| p.id) .collect(); - let edit = match build { - 0 | 1 => TreeEdit::move_to(top[0], outline.id, top.get(2).copied(), AUTHOR).unwrap(), - 2 | 3 => TreeEdit::delete(top[0], AUTHOR).unwrap(), - 4 => TreeEdit::move_to(outline.id, page_id(OUTLINES, space), None, AUTHOR).unwrap(), - _ => TreeEdit::delete(outline.id, AUTHOR).unwrap(), + let op = match build { + 0 | 1 => PageOp::Move { + object: top[0], + parent: Some(outline.id), + before: top.get(2).copied(), + }, + 2 | 3 => PageOp::Delete { object: top[0] }, + 4 => PageOp::Move { + object: outline.id, + parent: None, + before: None, + }, + _ => PageOp::Delete { object: outline.id }, }; - let reference = PreparedEdit::tree(OUTLINES, space, &edit).unwrap(); - oracle(OUTLINES, space, reference.as_bytes()); + oracle(OUTLINES, space, &edited(OUTLINES, space, vec![op])); if outlines.len() > 1 && build == 0 { - let across = TreeEdit::move_to(top[1], outlines[1].id, None, AUTHOR).unwrap(); - let reference = PreparedEdit::tree(OUTLINES, space, &across).unwrap(); - oracle(OUTLINES, space, reference.as_bytes()); + let across = PageOp::Move { + object: top[1], + parent: Some(outlines[1].id), + before: None, + }; + oracle(OUTLINES, space, &edited(OUTLINES, space, vec![across])); } } } @@ -401,7 +424,7 @@ fn outline_layout_and_collapse_match_outline_edits() { ), (parent, OutlineEdit::Collapsed(true)), ] { - let reference = PreparedEdit::outline(OUTLINES, space, object, edit).unwrap(); + let reference = ops::page_op(OUTLINES, space, PageOp::Outline { object, edit }).unwrap(); oracle(OUTLINES, space, reference.as_bytes()); } } @@ -411,38 +434,41 @@ fn composed_edits_publish_one_transaction() { let (space, page) = page_by_title(OUTLINES, "Move subtree up"); let outline = body(&page)[0]; let paragraphs = text_ids(outline); - let mut reference = - onestore::replace_text(OUTLINES, space, paragraphs[0].1, 0..0, "Lead ").unwrap(); - let insertion = - Insertion::paragraph(outline.id, Some(paragraphs[1].0), "Inserted", AUTHOR).unwrap(); - reference = PreparedEdit::insert(&reference, space, &insertion) - .unwrap() - .as_bytes() - .to_vec(); - reference = PreparedEdit::format( + let mut reference = edited( + OUTLINES, + space, + vec![PageOp::Text { + text: paragraphs[0].1, + range: 0..0, + with: "Lead ".into(), + }], + ); + let insertion = insert(outline.id, Some(paragraphs[1].0), "Inserted", 1); + let PageOp::Insert { paragraphs: inserted, .. } = &insertion else { + unreachable!() + }; + let inserted = inserted[0].text().unwrap().id; + reference = edited(&reference, space, vec![insertion]); + reference = edited( &reference, space, - insertion.text_object(), - 0..3, - &[TextAttribute::Bold(true)], - ) - .unwrap() - .as_bytes() - .to_vec(); + vec![PageOp::Format { + text: inserted, + range: 0..3, + set: vec![TextAttribute::Bold(true)], + clear: Vec::new(), + }], + ); let last = paragraphs.last().unwrap().0; - reference = PreparedEdit::tree(&reference, space, &TreeEdit::delete(last, AUTHOR).unwrap()) - .unwrap() - .as_bytes() - .to_vec(); - reference = PreparedEdit::outline( + reference = edited(&reference, space, vec![PageOp::Delete { object: last }]); + reference = edited( &reference, space, - outline.id, - OutlineEdit::Position { x: 60.0, y: 60.0 }, - ) - .unwrap() - .as_bytes() - .to_vec(); + vec![PageOp::Outline { + object: outline.id, + edit: OutlineEdit::Position { x: 60.0, y: 60.0 }, + }], + ); let written = oracle(OUTLINES, space, &reference); assert_eq!( Store::parse(&reference).unwrap().header.transaction_count, @@ -535,8 +561,8 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() { ); outline.paragraphs.push(paragraph); after.objects.push(PageObject::Outline(outline)); - let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); - let stored = page_in(written.as_bytes(), space); + let written = ops::saved(TREES, space, &after).unwrap(); + let stored = page_in(written.as_slice(), space); let normalize = |page: &Page| -> Vec<(ExGuid, Vec<(ExGuid, String)>)> { body(page) .iter() @@ -565,7 +591,7 @@ fn model_edits_round_trip_and_leave_other_objects_untouched() { let store = Store::parse(TREES).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let raw_before = index.resolve_active(space).unwrap(); - let store = Store::parse(written.as_bytes()).unwrap(); + let store = Store::parse(written.as_slice()).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let raw_after = index.resolve_active(space).unwrap(); let touched: BTreeSet = body(&after) @@ -604,7 +630,7 @@ fn unsupported_model_edits_are_rejected_before_writing() { } } } - assert!(PreparedEdit::page(TREES, space, &widened, AUTHOR).is_ok()); + assert!(ops::saved(TREES, space, &widened).is_ok()); let mut relisted = page_in(TREES, space); let outline = body(&page)[0]; for object in &mut relisted.objects { @@ -614,11 +640,33 @@ fn unsupported_model_edits_are_rejected_before_writing() { o.paragraphs[0].lists.push(new_id().unwrap()); } } - assert!(PreparedEdit::page(TREES, space, &relisted, AUTHOR).is_err()); - let mut renamed = page_in(TREES, space); - renamed.created = Some(1); - assert!(PreparedEdit::page(TREES, space, &renamed, AUTHOR).is_err()); - assert!(PreparedEdit::page(TREES, space, &page, "a\0b").is_err()); + assert!(ops::saved(TREES, space, &relisted).is_err()); + // A page's date is an op of its own now. + let mut redated = page_in(TREES, space); + redated.created = Some(1); + let written = ops::saved(TREES, space, &redated).unwrap(); + assert_eq!(page_in(&written, space).created, Some(1)); + let text = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Title(title) => title + .outlines + .iter() + .flat_map(|outline| &outline.paragraphs) + .find_map(|paragraph| paragraph.text().map(|text| text.id)), + _ => None, + }) + .unwrap(); + let op = onestore::op::Op::Page { + space, + op: PageOp::Text { + text, + range: 0..0, + with: "x".into(), + }, + }; + assert!(ops::transaction(TREES, "a\0b", vec![op]).is_err()); } /// Unset run values stay unset; only the language, which MS-ONE requires on every run, comes @@ -655,8 +703,8 @@ fn a_new_paragraph_with_unspecified_formatting_rereads_with_only_the_required_la unsupported: Vec::new(), }; page.objects.insert(0, PageObject::Outline(outline)); - let written = PreparedEdit::page(OUTLINES, space, &page, AUTHOR).unwrap(); - let stored = page_in(written.as_bytes(), space); + let written = ops::saved(OUTLINES, space, &page).unwrap(); + let stored = page_in(written.as_slice(), space); let read_back = body(&stored) .iter() .flat_map(|outline| outline.paragraphs.iter()) @@ -673,18 +721,18 @@ fn a_new_paragraph_with_unspecified_formatting_rereads_with_only_the_required_la } ); assert_eq!( - PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + ops::saved(written.as_slice(), space, &stored) .unwrap() - .as_bytes(), - written.as_bytes() + .as_slice(), + written.as_slice() ); } #[test] fn an_unchanged_model_publishes_nothing() { let (space, page) = page_by_title(OUTLINES, "Resize outline"); - let prepared = PreparedEdit::page(OUTLINES, space, &page, AUTHOR).unwrap(); - assert_eq!(prepared.as_bytes(), OUTLINES); + let prepared = ops::saved(OUTLINES, space, &page).unwrap(); + assert_eq!(prepared.as_slice(), OUTLINES); } const JOIN_PAGE: &str = "Bold 🦀 italic e\u{301} color 東京"; @@ -1001,8 +1049,8 @@ fn export_native_page_model_candidates() { }); } } - let edit = PreparedEdit::page(&bytes, space, &after, "Rust page writer").unwrap(); - bytes = edit.as_bytes().to_vec(); + let edit = ops::saved(&bytes, space, &after).unwrap(); + bytes = edit.as_slice().to_vec(); let stored = page_in(&bytes, space); let index = { let store = Store::parse(&bytes).unwrap(); diff --git a/crates/onestore/tests/paragraph.rs b/crates/onestore/tests/paragraph.rs index 03d0651e6459e3d38e82104853235443a4aee73b..4904916f1c240763bbe1e49f350406e8c768d53d 100644 --- a/crates/onestore/tests/paragraph.rs +++ b/crates/onestore/tests/paragraph.rs @@ -1,6 +1,8 @@ use onestore::{ - ExGuid, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, document::{Document, Kind, Revision}, + op::{Op, OpError, PageOp}, + page::text::new_id, }; use serde_json::Value; @@ -10,6 +12,40 @@ mod checkpoint; mod current; #[path = "support/disk.rs"] mod disk; +#[path = "support/ops.rs"] +mod ops; + +/// Enter at `at` in `text`, with fresh identities for the new paragraph, its text and a +/// copy of each list node of the paragraph: the op, the new paragraph and its text. +fn split(source: &[u8], space: ExGuid, text: ExGuid, at: u32) -> (Op, ExGuid, ExGuid) { + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let lists = document.spaces[&space] + .active() + .unwrap() + .nodes + .values() + .find_map(|node| match &node.kind { + Kind::Paragraph { lists, .. } if node.content == [text] => Some(lists.len()), + _ => None, + }) + .unwrap_or(0); + let (paragraph, right) = (new_id().unwrap(), new_id().unwrap()); + let op = PageOp::Split { + text, + at, + paragraph, + right, + lists: (0..lists).map(|_| new_id().unwrap()).collect(), + }; + (Op::Page { space, op }, paragraph, right) +} + +/// `source` after joining the paragraph of text `right` to the one of text `left`. +fn join(source: &[u8], space: ExGuid, left: ExGuid, right: ExGuid) -> Result, OpError> { + ops::edited(source, vec![Op::Page { space, op: PageOp::Join { left, right } }]) +} const SOURCE: &[u8] = include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"); @@ -114,12 +150,9 @@ fn joins_match_native_graphs_tags_and_inherited_character_styles() { .contains_key(&left) }) .unwrap(); - let intent = ParagraphJoin::new(left, right, "Join author").unwrap(); - let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); - assert_eq!(intent, restored); - let edited = PreparedEdit::join(source, *sid, &restored) - .unwrap_or_else(|error| panic!("{name}: {error}")); - let current_store = Store::parse(edited.as_bytes()).unwrap(); + let edited = + join(source, *sid, left, right).unwrap_or_else(|error| panic!("{name}: {error}")); + let current_store = Store::parse(&edited).unwrap(); assert_eq!( current_store.header.transaction_count, store.header.transaction_count + 1 @@ -276,7 +309,7 @@ fn joins_match_native_graphs_tags_and_inherited_character_styles() { } } } - assert!(PreparedEdit::join(edited.as_bytes(), *sid, &intent).is_err()); + assert!(join(&edited, *sid, left, right).is_err()); assert_eq!( space.contexts.len(), current_document.spaces[sid].contexts.len() @@ -321,10 +354,8 @@ fn splits_partition_native_paragraphs_at_every_scalar_boundary() { })) .collect(); for (position, offset) in offsets.iter().enumerate() { - let intent = ParagraphSplit::new(text, *offset, "Split author").unwrap(); - let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); - assert_eq!(intent, restored); - let edited = PreparedEdit::split(SOURCE, *sid, &restored); + let (op, new_paragraph, new_text) = split(SOURCE, *sid, text, *offset); + let edited = ops::edited(SOURCE, vec![op.clone()]); // OneNote 2010 splits before or after a hyperlink and ignores Enter inside one. let link = |at: usize| { expected @@ -341,7 +372,7 @@ fn splits_partition_native_paragraphs_at_every_scalar_boundary() { } let edited = edited.unwrap_or_else(|error| panic!("{} at {offset}: {error}", case["case"])); - let current_store = Store::parse(edited.as_bytes()).unwrap(); + let current_store = Store::parse(&edited).unwrap(); assert_eq!( current_store.header.transaction_count, store.header.transaction_count + 1 @@ -384,7 +415,7 @@ fn splits_partition_native_paragraphs_at_every_scalar_boundary() { case["case"] ); assert_eq!( - characters(after, intent.text_object()), + characters(after, new_text), expected[position..], "{} suffix at {offset}", case["case"] @@ -392,7 +423,7 @@ fn splits_partition_native_paragraphs_at_every_scalar_boundary() { if case["offset_utf16"] == *offset { let native = &native_document.spaces[sid]; let native = &native.revisions[&native.contexts[&ExGuid::default()]]; - let new_text = serde_json::from_value(case["new_text"].clone()).unwrap(); + let native_text = serde_json::from_value(case["new_text"].clone()).unwrap(); assert_eq!( characters(after, text), characters(native, text), @@ -400,8 +431,8 @@ fn splits_partition_native_paragraphs_at_every_scalar_boundary() { case["case"] ); assert_eq!( - characters(after, intent.text_object()), - characters(native, new_text), + characters(after, new_text), + characters(native, native_text), "{}", case["case"] ); @@ -409,31 +440,31 @@ fn splits_partition_native_paragraphs_at_every_scalar_boundary() { let mut children = parent_node.children.clone(); children.insert( children.iter().position(|id| *id == paragraph).unwrap() + 1, - intent.object(), + new_paragraph, ); assert_eq!(after.nodes[parent].children, children); assert_eq!(after.nodes[¶graph].content, [text]); assert!(after.nodes[¶graph].children.is_empty()); assert_eq!( - after.nodes[&intent.object()].content, - [intent.text_object()] + after.nodes[&new_paragraph].content, + [new_text] ); assert_eq!( - after.nodes[&intent.object()].children, + after.nodes[&new_paragraph].children, before.nodes[¶graph].children ); assert_eq!( serde_json::to_value(&after.nodes[&text].tags).unwrap(), serde_json::to_value(&before.nodes[&text].tags).unwrap() ); - assert!(after.nodes[&intent.text_object()].tags.is_empty()); + assert!(after.nodes[&new_text].tags.is_empty()); let Kind::Paragraph { lists: old_lists, .. } = &before.nodes[¶graph].kind else { panic!() }; - let Kind::Paragraph { lists, .. } = &after.nodes[&intent.object()].kind else { + let Kind::Paragraph { lists, .. } = &after.nodes[&new_paragraph].kind else { panic!() }; assert_eq!(old_lists.len(), lists.len()); @@ -453,26 +484,23 @@ fn splits_partition_native_paragraphs_at_every_scalar_boundary() { } } } - assert!(PreparedEdit::split(edited.as_bytes(), *sid, &intent).is_err()); + assert!(ops::edited(&edited, vec![op]).is_err()); } for offset in 0..=*offsets.last().unwrap() + 1 { if offsets.contains(&offset) { continue; } - let intent = ParagraphSplit::new(text, offset, "Author").unwrap(); - assert!(PreparedEdit::split(SOURCE, *sid, &intent).is_err()); + assert!(ops::edited(SOURCE, vec![split(SOURCE, *sid, text, offset).0]).is_err()); } } } #[test] fn invalid_split_identities_and_title_targets_are_rejected() { - assert!(ParagraphSplit::new(ExGuid::default(), 0, "Author").is_err()); let manifest: Value = serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")).unwrap(); let text: ExGuid = serde_json::from_value(manifest["cases"][0]["original_text"].clone()).unwrap(); - assert!(ParagraphSplit::new(text, 0, "a\0b").is_err()); let store = Store::parse(SOURCE).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); @@ -486,18 +514,19 @@ fn invalid_split_identities_and_title_targets_are_rejected() { }) .unwrap() .0; - let intent = ParagraphSplit::new(text, 1, "Author").unwrap(); - for (field, value) in [ - ("guid", serde_json::to_value([0_u8; 16]).unwrap()), - ("guid", serde_json::to_value(text.guid).unwrap()), - ("text", serde_json::to_value(ExGuid::default()).unwrap()), - ("author", serde_json::json!("a\0b")), - ("offset", serde_json::json!(u32::MAX)), + let (op, ..) = split(SOURCE, sid, text, 1); + assert!(ops::transaction(SOURCE, "a\0b", vec![op.clone()]).is_err()); + let Op::Page { op: PageOp::Split { paragraph, right, lists, .. }, .. } = op else { + unreachable!() + }; + for (text, at, paragraph) in [ + (ExGuid::default(), 1, paragraph), + (text, u32::MAX, paragraph), + (text, 1, ExGuid::default()), + (text, 1, text), ] { - let mut encoded = serde_json::to_value(&intent).unwrap(); - encoded[field] = value; - let forged = serde_json::from_value(encoded).unwrap(); - assert!(PreparedEdit::split(SOURCE, sid, &forged).is_err()); + let op = PageOp::Split { text, at, paragraph, right, lists: lists.clone() }; + assert!(ops::page_edited(SOURCE, sid, vec![op]).is_err()); } let mut titles = 0; for (sid, _) in document.pages().unwrap() { @@ -516,8 +545,7 @@ fn invalid_split_identities_and_title_targets_are_rejected() { let node = &view.nodes[&id]; pending.extend(node.children.iter().chain(&node.content).copied()); if matches!(node.kind, Kind::RichText { .. }) { - let intent = ParagraphSplit::new(id, 0, "Author").unwrap(); - assert!(PreparedEdit::split(SOURCE, sid, &intent).is_err()); + assert!(ops::edited(SOURCE, vec![split(SOURCE, sid, id, 0).0]).is_err()); titles += 1; } } @@ -551,16 +579,14 @@ fn export_native_paragraph_splits() { }) .unwrap() .0; - let intent = ParagraphSplit::new( - text, - serde_json::from_value(case["offset_utf16"].clone()).unwrap(), - "Rust split author", - ) - .unwrap(); - let edited = PreparedEdit::split(&source, sid, &intent).unwrap(); - written.push(serde_json::json!({"case": case["case"], "intent": intent, - "new_paragraph": intent.object(), "new_text": intent.text_object()})); - source = edited.as_bytes().to_vec(); + let at = serde_json::from_value(case["offset_utf16"].clone()).unwrap(); + let (op, new_paragraph, new_text) = split(&source, sid, text, at); + let transaction = ops::transaction(&source, "Rust split author", vec![op.clone()]) + .unwrap() + .unwrap(); + written.push(serde_json::json!({"case": case["case"], "op": op, + "new_paragraph": new_paragraph, "new_text": new_text})); + transaction.apply(&mut source).unwrap(); } let candidate = output.join("candidate"); fs::create_dir(&candidate).unwrap(); @@ -587,18 +613,36 @@ fn interrupted_splits_publish_a_complete_graph_or_retain_the_original() { .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) .unwrap(); let paragraph = view.nodes[outline].children[0]; - let insertion = onestore::Insertion::paragraph(paragraph, None, "a🦀b", "Author") - .unwrap() - .with_formatting(1..3, &[onestore::TextAttribute::Bold(true)]) - .unwrap(); - let inserted = PreparedEdit::insert(&original, sid, &insertion).unwrap(); - let source = inserted.as_bytes(); - let checkpoint = checkpoint::pending(source, sid, insertion.text_object(), 0x14001d7a); + let mut child = ops::paragraph("a🦀b"); + child.level = 2; + let text = child.text().unwrap().id; + let inserted = ops::page_edited( + &original, + sid, + vec![ + PageOp::Insert { + container: paragraph, + before: None, + paragraphs: vec![child], + }, + PageOp::Format { + text, + range: 1..3, + set: vec![onestore::TextAttribute::Bold(true)], + clear: Vec::new(), + }, + ], + ) + .unwrap(); + let source = inserted.as_slice(); + let checkpoint = checkpoint::pending(source, sid, text); for source in [source, &checkpoint] { - let intent = ParagraphSplit::new(insertion.text_object(), 1, "Author").unwrap(); - let edit = PreparedEdit::split(source, sid, &intent).unwrap(); + let (op, ..) = split(source, sid, text, 1); + let edit = ops::transaction(source, "Author", vec![op]).unwrap().unwrap(); + let mut written = source.to_vec(); + edit.apply(&mut written).unwrap(); let before = current::current(source); - let after = current::current(edit.as_bytes()); + let after = current::current(&written); for write_limit in [17, 4096] { let disk = |fail_at| disk::Disk { visible: source.to_vec(), @@ -610,7 +654,7 @@ fn interrupted_splits_publish_a_complete_graph_or_retain_the_original() { }; let mut successful = disk(None); edit.commit(&mut successful).unwrap(); - assert_eq!(successful.durable, edit.as_bytes()); + assert_eq!(successful.durable, written); for at in 1..=successful.operation { let mut interrupted = disk(Some(at)); let error = edit.commit(&mut interrupted).unwrap_err(); @@ -654,10 +698,12 @@ fn export_native_paragraph_joins() { }) .unwrap() .0; - let intent = ParagraphJoin::new(left, right, "Rust join author").unwrap(); - let edit = PreparedEdit::join(&source, sid, &intent).unwrap(); - source = edit.as_bytes().to_vec(); - manifest.push(serde_json::json!({"case":name,"space":sid,"intent":intent})); + let op = Op::Page { space: sid, op: PageOp::Join { left, right } }; + let transaction = ops::transaction(&source, "Rust join author", vec![op.clone()]) + .unwrap() + .unwrap(); + transaction.apply(&mut source).unwrap(); + manifest.push(serde_json::json!({"case":name,"space":sid,"op":op})); } let folder = output.join(["split", "inheritance", "tags"][number]); fs::create_dir_all(folder.join("candidate")).unwrap(); @@ -685,22 +731,48 @@ fn interrupted_joins_preserve_complete_graphs_and_empty_text_adoption() { .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) .unwrap(); let left = view.nodes[&view.nodes[&outline].children[0]].content[0]; - let right = onestore::Insertion::paragraph(outline, None, "Right 🦀", "Author") - .unwrap() - .with_formatting(0..5, &[onestore::TextAttribute::Bold(true)]) - .unwrap(); - let inserted = PreparedEdit::insert(&source, sid, &right).unwrap(); - let child = - onestore::Insertion::paragraph(right.object(), None, "Retained child", "Author").unwrap(); - let original = PreparedEdit::insert(inserted.as_bytes(), sid, &child).unwrap(); - let original = original.as_bytes(); - let empty = onestore::replace_text(original, sid, left, 0..4, "").unwrap(); - let checkpoint = checkpoint::pending(original, sid, left, 0x14001d7a); + let right = ops::paragraph("Right 🦀"); + let right_text = right.text().unwrap().id; + let mut child = ops::paragraph("Retained child"); + child.parent = Some(right.id); + child.level = 2; + let original = ops::page_edited( + &source, + sid, + vec![ + PageOp::Insert { + container: outline, + before: None, + paragraphs: vec![right, child], + }, + PageOp::Format { + text: right_text, + range: 0..5, + set: vec![onestore::TextAttribute::Bold(true)], + clear: Vec::new(), + }, + ], + ) + .unwrap(); + let original = original.as_slice(); + let empty = ops::page_edited( + original, + sid, + vec![PageOp::Text { + text: left, + range: 0..4, + with: String::new(), + }], + ) + .unwrap(); + let checkpoint = checkpoint::pending(original, sid, left); for source in [original, &empty, &checkpoint] { - let intent = ParagraphJoin::new(left, right.text_object(), "Join author").unwrap(); - let edit = PreparedEdit::join(source, sid, &intent).unwrap(); + let op = Op::Page { space: sid, op: PageOp::Join { left, right: right_text } }; + let edit = ops::transaction(source, "Join author", vec![op]).unwrap().unwrap(); + let mut written = source.to_vec(); + edit.apply(&mut written).unwrap(); let before = current::current(source); - let after = current::current(edit.as_bytes()); + let after = current::current(&written); for write_limit in [17, 4096] { let disk = |fail_at| disk::Disk { visible: source.to_vec(), @@ -712,8 +784,8 @@ fn interrupted_joins_preserve_complete_graphs_and_empty_text_adoption() { }; let mut successful = disk(None); edit.commit(&mut successful).unwrap(); - assert_eq!(successful.durable, edit.as_bytes()); - for at in std::iter::once(1).chain(source.len().div_ceil(193)..=successful.operation) { + assert_eq!(successful.durable, written); + for at in 1..=successful.operation { let mut interrupted = disk(Some(at)); let error = edit.commit(&mut interrupted).unwrap_err(); let recovered = current::current(&interrupted.durable); @@ -748,26 +820,16 @@ fn joins_reject_invalid_identities_wrong_order_and_unrelated_pages() { .contains_key(left) }) .unwrap(); - assert!(ParagraphJoin::new(*left, *left, "Author").is_err()); - assert!(ParagraphJoin::new(ExGuid::default(), *right, "Author").is_err()); - assert!(ParagraphJoin::new(*left, *right, "a\0b").is_err()); - let intent = ParagraphJoin::new(*left, *right, "Author").unwrap(); - for (field, value) in [ - ("left", serde_json::to_value(ExGuid::default()).unwrap()), - ("right", serde_json::to_value(left).unwrap()), - ("author", serde_json::json!("a\0b")), + for (a, b) in [ + (*left, *left), + (ExGuid::default(), *right), + (*right, *left), + (*left, cases[1].2), ] { - let mut encoded = serde_json::to_value(&intent).unwrap(); - encoded[field] = value; - let invalid = serde_json::from_value(encoded).unwrap(); - assert!(PreparedEdit::join(source, *sid, &invalid).is_err()); - } - for intent in [ - ParagraphJoin::new(*right, *left, "Author").unwrap(), - ParagraphJoin::new(*left, cases[1].2, "Author").unwrap(), - ] { - assert!(PreparedEdit::join(source, *sid, &intent).is_err()); + assert!(join(source, *sid, a, b).is_err()); } + let op = Op::Page { space: *sid, op: PageOp::Join { left: *left, right: *right } }; + assert!(ops::transaction(source, "a\0b", vec![op]).is_err()); let view = &space.revisions[&space.contexts[&ExGuid::default()]]; let paragraph = *view .nodes @@ -781,8 +843,7 @@ fn joins_reject_invalid_identities_wrong_order_and_unrelated_pages() { .find(|node| node.children.contains(¶graph)) .unwrap(); let last = view.nodes[parent.children.last().unwrap()].content[0]; - let nonadjacent = ParagraphJoin::new(*left, last, "Author").unwrap(); - assert!(PreparedEdit::join(source, *sid, &nonadjacent).is_err()); + assert!(join(source, *sid, *left, last).is_err()); let title = view .nodes .iter() @@ -794,8 +855,7 @@ fn joins_reject_invalid_identities_wrong_order_and_unrelated_pages() { let node = &view.nodes[&id]; pending.extend(node.children.iter().chain(&node.content).copied()); if matches!(node.kind, Kind::RichText { .. }) { - let intent = ParagraphJoin::new(id, *right, "Author").unwrap(); - assert!(PreparedEdit::join(source, *sid, &intent).is_err()); + assert!(join(source, *sid, id, *right).is_err()); rejected += 1; } } diff --git a/crates/onestore/tests/paragraph_formatting.rs b/crates/onestore/tests/paragraph_formatting.rs index 2ef24da7969f4fa40082a3f0734871722c379972..6e2f855de5a431223628d61740988d6e8f96cac1 100644 --- a/crates/onestore/tests/paragraph_formatting.rs +++ b/crates/onestore/tests/paragraph_formatting.rs @@ -1,5 +1,7 @@ +#[path = "support/ops.rs"] +mod ops; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, document::{Document, Kind}, page::{Page, PageObject, Paragraph}, }; @@ -48,12 +50,12 @@ fn paragraph_properties_preserve_native_mixed_runs_and_shared_styles() { } } assert_eq!(count, 6); - let prepared = PreparedEdit::page(SOURCE, space, &after, "Format author").unwrap(); - assert_eq!(page(prepared.as_bytes()).1, after); + let prepared = ops::saved(SOURCE, space, &after).unwrap(); + assert_eq!(page(prepared.as_slice()).1, after); let before_store = Store::parse(SOURCE).unwrap(); let before_index = RevisionIndex::parse(&before_store).unwrap(); let before_doc = Document::parse(&before_index).unwrap(); - let after_store = Store::parse(prepared.as_bytes()).unwrap(); + let after_store = Store::parse(prepared.as_slice()).unwrap(); let after_index = RevisionIndex::parse(&after_store).unwrap(); let old = before_index.resolve_active(space).unwrap(); let new = after_index.resolve_active(space).unwrap(); @@ -65,7 +67,7 @@ fn paragraph_properties_preserve_native_mixed_runs_and_shared_styles() { if let Some(directory) = std::env::var_os("ONESTORE_PARAGRAPH_FORMAT_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir(&directory).unwrap(); - std::fs::write(directory.join("synthetic.one"), prepared.as_bytes()).unwrap(); + std::fs::write(directory.join("synthetic.one"), prepared.as_slice()).unwrap(); std::fs::write( directory.join("Open Notebook.onetoc2"), onestore::create_table_of_contents( @@ -103,7 +105,7 @@ fn paragraph_formatting_rejects_invalid_values_and_conflicting_run_settings() { let mut format = text.text.format_at(0).unwrap().clone(); change(&mut format); text.text = Paragraph::new(text.text.text().to_owned(), format); - assert!(PreparedEdit::page(SOURCE, space, &after, "Author").is_err()); + assert!(ops::saved(SOURCE, space, &after).is_err()); } let mut after = before.clone(); let text = after @@ -121,5 +123,5 @@ fn paragraph_formatting_rejects_invalid_values_and_conflicting_run_settings() { left.alignment = Some(1); right.alignment = Some(2); text.text = Paragraph::from_runs([("Left".into(), left), ("Right".into(), right)]); - assert!(PreparedEdit::page(SOURCE, space, &after, "Author").is_err()); + assert!(ops::saved(SOURCE, space, &after).is_err()); } diff --git a/crates/onestore/tests/protected.rs b/crates/onestore/tests/protected.rs index 5f676566a198e5ea31a33d50a6c003d331c5ba09..3abf6caf9a2d75ad1b4f5f0a314ba73ead315bef 100644 --- a/crates/onestore/tests/protected.rs +++ b/crates/onestore/tests/protected.rs @@ -3,6 +3,7 @@ use onestore::{ RevisionIndex, Store, document::{Document, Kind}, + op::{Edit, Op, PageOp}, protected::{Error, Limits, UnlockedSection}, }; use std::{fs, path::Path}; @@ -33,10 +34,19 @@ fn known_passwords_open_independent_native_fixtures() { .iter() .find(|(_, node)| matches!(node.kind, Kind::RichText { .. })) .unwrap(); - assert!( - onestore::PreparedEdit::text(&bytes, space, *object, 0..0, "Must remain protected") - .is_err() - ); + // The ordinary section writer refuses a protected section. + let arena = onestore::Arena::default(); + assert!(onestore::Section::open(&arena, bytes.clone()).is_err()); + let op = PageOp::Text { + text: *object, + range: 0..0, + with: "Must remain protected".into(), + }; + let edit = Edit { + at: 134_000_000_000_000_000, + ops: vec![Op::Page { space, op }], + }; + assert!(unlocked.apply("Rust", &edit).is_ok()); assert!( document .spaces @@ -139,6 +149,7 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { let mut expected = pages(&bytes); let mut edited = 0; for (space, page) in &mut expected { + let before = page.clone(); let paragraphs = page.objects.iter_mut().find_map(|object| match object { PageObject::Outline(outline) if outline.paragraphs.iter().any(|p| p.text().is_some()) => @@ -174,14 +185,21 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { text.text .append(Paragraph::new(" still protected".to_owned(), format)) .unwrap(); - let edit = - onestore::PreparedEdit::page_protected(&bytes, password, *space, page, "Rust") - .unwrap(); + let ops = onestore::op::lower_page(&before, page).unwrap(); + let edit = Edit { + at: 134_000_000_000_000_000, + ops: ops.into_iter().map(|op| Op::Page { space: *space, op }).collect(), + }; + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); assert!(matches!( - onestore::PreparedEdit::page_protected(&bytes, "wrong", *space, page, "Rust"), + UnlockedSection::open(&index, "wrong", Limits::default()), Err(Error::PasswordMismatch) )); - let written = edit.as_bytes().to_vec(); + let unlocked = UnlockedSection::open(&index, password, Limits::default()).unwrap(); + let transaction = unlocked.apply("Rust", &edit).unwrap(); + let mut written = bytes.clone(); + transaction.apply(&mut written).unwrap(); for clear in [&b" still protected"[..], b"stays sealed"] { assert!(!written.windows(clear.len()).any(|w| w == clear)); } diff --git a/crates/onestore/tests/shared_snapshot.rs b/crates/onestore/tests/shared_snapshot.rs index ba87504a4d63e614baf6d71eb86f48fc2a361bcf..12cb6c3134a9c09d13806f6800dd2f56be4ec254 100644 --- a/crates/onestore/tests/shared_snapshot.rs +++ b/crates/onestore/tests/shared_snapshot.rs @@ -4,6 +4,8 @@ use current::current; #[path = "support/checkpoint.rs"] mod checkpoint; +#[path = "support/ops.rs"] +mod ops; #[path = "support/trace.rs"] mod trace; @@ -15,6 +17,26 @@ use onestore::{ use std::{fs, io}; use trace::{Event, Trace}; +/// Types `with` over `range` of the text object in `source` and commits it to `io`. +fn commit_text( + io: &mut impl onestore::CommitIo, + source: &[u8], + space: ExGuid, + text: ExGuid, + range: std::ops::Range, + with: &str, +) -> Result<(), onestore::CommitError> { + let op = onestore::op::PageOp::Text { + text, + range, + with: with.into(), + }; + ops::transaction(source, "Author", vec![onestore::op::Op::Page { space, op }]) + .unwrap() + .unwrap() + .commit(io) +} + #[test] fn storage_inspection_preserves_opaque_images_without_claiming_edit_readiness() { for path in [ @@ -111,7 +133,7 @@ fn version_cached_readers_cannot_miss_a_completed_publication() { bytes: source.clone(), events: Vec::new(), }; - onestore::commit_text(&mut trace, &source, sid, oid, end..end, " [cached reader]").unwrap(); + commit_text(&mut trace, &source, sid, oid, end..end, " [cached reader]").unwrap(); let final_content = current(&trace.bytes); let mut visible = source; for event in &trace.events { @@ -168,7 +190,7 @@ fn published_snapshots_survive_interleaved_commit_io() { let mut source = fs::read(format!("../../corpus/{path}")).unwrap(); let (sid, oid, _) = target(&source); if name == "checkpoint" { - source = checkpoint::pending(&source, sid, oid, 0x14001d7a); + source = checkpoint::pending(&source, sid, oid); } let (_, _, end) = target(&source); let before = current(&source); @@ -176,7 +198,7 @@ fn published_snapshots_survive_interleaved_commit_io() { bytes: source.clone(), events: Vec::new(), }; - onestore::commit_text(&mut trace, &source, sid, oid, end..end, " [reader café 🦀]") + commit_text(&mut trace, &source, sid, oid, end..end, " [reader café 🦀]") .unwrap(); let after = current(&trace.bytes); assert_ne!(before, after); @@ -407,7 +429,7 @@ fn unpublished_append_remains_available_for_retry() { bytes: source.clone(), events: Vec::new(), }; - onestore::commit_text(&mut trace, &source, sid, oid, end..end, " abandoned").unwrap(); + commit_text(&mut trace, &source, sid, oid, end..end, " abandoned").unwrap(); let Event::Write(offset, append) = &trace.events[0] else { panic!() }; @@ -432,7 +454,7 @@ fn unpublished_append_remains_available_for_retry() { bytes: persisted, events: Vec::new(), }; - onestore::commit_text(&mut retry, &snapshot, sid, oid, end..end, " retry").unwrap(); + commit_text(&mut retry, &snapshot, sid, oid, end..end, " retry").unwrap(); assert_ne!(current(&retry.bytes), current(&source)); } } diff --git a/crates/onestore/tests/storage.rs b/crates/onestore/tests/storage.rs index 49d0c2040b567c6dadabeac7ede732559a33f1b4..8e49748e40d1ef3b7f4add8b1786ccb7f622c23c 100644 --- a/crates/onestore/tests/storage.rs +++ b/crates/onestore/tests/storage.rs @@ -23,7 +23,7 @@ fn native_file_data_alignment_is_relative_to_the_object() { assert_eq!(payload.len(), 10056); assert_eq!( format!("{:x}", md5::compute(payload)), - "e1b57a8851177dd25dc05b50b904656a" + "fec8f53bd6db9305eabac4cd4b71fb08" ); let blob = store.chunk_data(chunk).unwrap(); for alignment in 0..8 { diff --git a/crates/onestore/tests/support/checkpoint.rs b/crates/onestore/tests/support/checkpoint.rs index 4c048644b3445f3223cc7d0b396ea925597c873b..03ccf9f8e8d770536dfe2cb2ff2ea81d7d50d137 100644 --- a/crates/onestore/tests/support/checkpoint.rs +++ b/crates/onestore/tests/support/checkpoint.rs @@ -1,21 +1,38 @@ -use onestore::{ExGuid, RevisionIndex, Store}; +use onestore::{ + Arena, ExGuid, RevisionIndex, Section, Store, + op::{Edit, Op, PageOp}, +}; -pub fn pending(source: &[u8], sid: ExGuid, oid: ExGuid, property: u32) -> Vec { - let mut source = source.to_vec(); - for value in 1_000_000_u32.. { - let store = Store::parse(&source).unwrap(); +/// `source` with revisions of `space` appended until its chain is 512 deep, so its next +/// revision is a checkpoint. Each retouches the text object `text`, typing a character and +/// removing it again. +pub fn pending(source: &[u8], space: ExGuid, text: ExGuid) -> Vec { + let depth = |image: &[u8]| { + let store = Store::parse(image).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); - let rid = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; - let depth = - std::iter::successors(Some(rid), |id| index.spaces[&sid].revisions[id].dependency) - .count(); - assert!(depth <= 512); - if depth == 512 { - return source; - } - source = - onestore::replace_property_bytes(&source, sid, oid, property, &value.to_le_bytes()) - .unwrap(); + let rid = index.spaces[&space].labels[&(ExGuid::default(), 1)]; + std::iter::successors(Some(rid), |id| { + index.spaces[&space].revisions[id].dependency + }) + .count() + }; + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + let retouch = |range, with: &str| Op::Page { + space, + op: PageOp::Text { + text, + range, + with: with.into(), + }, + }; + for at in 0..512 - depth(source) as u64 { + let ops = vec![retouch(0..0, "x"), retouch(0..1, "")]; + let at = 134_000_000_000_000_000 + at * 10_000_000; + section.apply("Author", &Edit { at, ops }).unwrap(); + section.seal().unwrap().unwrap(); } - unreachable!() + let image = section.image(); + assert_eq!(depth(&image), 512); + image } diff --git a/crates/onestore/tests/support/ops.rs b/crates/onestore/tests/support/ops.rs new file mode 100644 index 0000000000000000000000000000000000000000..6e128ce55bfdcdf92ca0d1b539f700bf071b7cd0 --- /dev/null +++ b/crates/onestore/tests/support/ops.rs @@ -0,0 +1,187 @@ +//! Edits as ops through a `Section`, sealed: the image an edit leaves, for tests that read +//! it back. +#![allow(dead_code)] + +use onestore::{ + Arena, ExGuid, Section, Transaction, + op::{Edit, Op, OpError, PageOp}, + page::Page, +}; + +/// FILETIME now, as an edit's `at`. +pub fn now() -> u64 { + let unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap(); + (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100) +} + +/// The transaction `ops` seal into on `source`, as one edit by `author`; none when they +/// store nothing. +pub fn transaction( + source: &[u8], + author: &str, + ops: Vec, +) -> Result, OpError> { + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).map_err(OpError::Failed)?; + section.apply(author, &Edit { at: now(), ops })?; + section.seal().map_err(OpError::Failed) +} + +/// `source` after `ops`, applied as one edit and sealed. +pub fn edited(source: &[u8], ops: Vec) -> Result, OpError> { + let mut image = source.to_vec(); + if let Some(transaction) = transaction(source, "Author", ops)? { + transaction.apply(&mut image).map_err(OpError::Failed)?; + } + Ok(image) +} + +/// `source` after `ops` on the page in `space`. +pub fn page_edited(source: &[u8], space: ExGuid, ops: Vec) -> Result, OpError> { + edited( + source, + ops.into_iter().map(|op| Op::Page { space, op }).collect(), + ) +} + +/// `source` with the page in `space` taken to `after` by the ops `op::lower_page` finds, +/// as a test states an edit by the page it leaves. +pub fn saved(source: &[u8], space: ExGuid, after: &Page) -> Result, String> { + let arena = Arena::default(); + let section = Section::open(&arena, source.to_vec()).map_err(|error| error.to_string())?; + let before = section.page(space).map_err(|error| error.to_string())?; + let ops = onestore::op::lower_page(&before, after).map_err(|error| error.to_string())?; + page_edited(source, space, ops).map_err(|error| error.to_string()) +} + +/// A plain paragraph holding `text` in OneNote's default text style, with fresh identities. +pub fn paragraph(text: &str) -> onestore::page::PageParagraph { + use onestore::page::{PageParagraph, Paragraph, ParagraphContent, TextObject, text::new_id}; + let format = onestore::document::Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + language: Some(0x409), + ..Default::default() + }; + PageParagraph { + id: new_id().unwrap(), + parent: None, + level: 1, + style: None, + format: Default::default(), + content: ParagraphContent::Text(TextObject { + id: new_id().unwrap(), + date_field: None, + text: Paragraph::new(text.into(), format), + tags: Vec::new(), + }), + lists: Vec::new(), + tags: Vec::new(), + media: Default::default(), + collapsed: false, + } +} + +/// A new outline at `(x, y)` in points holding `paragraphs`. +pub fn outline( + x: f32, + y: f32, + paragraphs: Vec, +) -> onestore::page::PageObject { + use onestore::page::{Outline, PageObject, text::new_id}; + PageObject::Outline(Outline { + id: new_id().unwrap(), + title: false, + min_width: None, + layout: onestore::document::Layout { + x: Some(x), + y: Some(y), + ..Default::default() + }, + indents: Vec::new(), + paragraphs, + unsupported: Vec::new(), + }) +} + +/// An edit applied through a `Section`: the image it leaves and the transaction publishing +/// it, none when it stores nothing. +pub struct Applied { + pub image: Vec, + pub transaction: Option, +} + +impl Applied { + pub fn as_bytes(&self) -> &[u8] { + &self.image + } + + /// Publishes the transaction under the filesystem adapter's exclusion. + #[cfg(any(unix, windows))] + pub fn commit_file( + &self, + path: impl AsRef, + ) -> Result<(), onestore::CommitError> { + self.transaction + .as_ref() + .expect("the edit stores a revision") + .commit_file(path) + } + + /// Publishes the transaction under caller-held exclusion. + pub fn commit(&self, io: &mut impl onestore::CommitIo) -> Result<(), onestore::CommitError> { + self.transaction + .as_ref() + .expect("the edit stores a revision") + .commit(io) + } +} + +/// `ops` applied to `source` as one edit by `author`. +pub fn apply(source: &[u8], author: &str, ops: Vec) -> Result { + let transaction = transaction(source, author, ops)?; + let mut image = source.to_vec(); + if let Some(transaction) = &transaction { + transaction.apply(&mut image).map_err(OpError::Failed)?; + } + Ok(Applied { image, transaction }) +} + +/// `saved` as an `Applied`, to publish it. +pub fn save(source: &[u8], space: ExGuid, after: &Page) -> Result { + let arena = Arena::default(); + let section = Section::open(&arena, source.to_vec()).map_err(|error| error.to_string())?; + let before = section.page(space).map_err(|error| error.to_string())?; + let ops = onestore::op::lower_page(&before, after).map_err(|error| error.to_string())?; + let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); + apply(source, "Author", ops).map_err(|error| error.to_string()) +} + +/// `op` on the page in `space`, applied to `source`. +pub fn page_op(source: &[u8], space: ExGuid, op: PageOp) -> Result { + apply(source, "Author", vec![Op::Page { space, op }]) +} + +/// `op` on the section, applied to `source`. +pub fn section_op(source: &[u8], op: onestore::op::SectionOp) -> Result { + apply(source, "Author", vec![Op::Section(op)]) +} + +/// Adds an outline at `(x, y)` holding one paragraph of `text`: the op and the identities +/// of the outline and its text. +pub fn new_outline(x: f32, y: f32, text: &str) -> (PageOp, ExGuid, ExGuid) { + let paragraph = paragraph(text); + let text = paragraph.text().unwrap().id; + let object = outline(x, y, vec![paragraph]); + let id = object.id(); + ( + PageOp::Add { + object, + before: None, + }, + id, + text, + ) +} diff --git a/crates/onestore/tests/support/page_edits.rs b/crates/onestore/tests/support/page_edits.rs index 3db5882ab0acb2d314129d091e815d9b9f97b8ad..d3b6ca0067f4275e3597ed812a96015e374b63eb 100644 --- a/crates/onestore/tests/support/page_edits.rs +++ b/crates/onestore/tests/support/page_edits.rs @@ -1,8 +1,11 @@ -//! Drives random page-model mutations through the page writer and checks its contract: -//! a published model reads back as written, and objects outside the edit keep their bytes. +//! Drives random page-model mutations through `op::lower_page` and a `Section` and checks +//! the contract: the page the section holds is what the model oracle predicts from the ops, +//! the sealed image reads back as the edited model, and objects outside the edit keep their +//! bytes. use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + Arena, ExGuid, RevisionIndex, Section, Store, + op::{self, Op}, document::{Document, Format, Layout}, page::{ Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, @@ -564,13 +567,31 @@ pub fn run(input: &[u8]) { let Some(mut after) = model(source, space) else { return; }; + let before = after.clone(); mutate(&mut after, &mut bytes); - let Ok(edit) = PreparedEdit::page(source, space, &after, "Fuzz author") else { + let Ok(ops) = onestore::op::lower_page(&before, &after) else { return; }; - let written = edit.as_bytes(); - if written == source.as_slice() { - let before = model(source, space).unwrap(); + let arena = Arena::default(); + let mut section = Section::open(&arena, source.clone()).unwrap(); + let edit = op::Edit { + at: 134_000_000_000_000_000, + ops: ops.iter().map(|op| Op::Page { space, op: op.clone() }).collect(), + }; + if section.apply("Fuzz author", &edit).is_err() { + assert_eq!(section.page(space).unwrap(), before, "a refused edit changes nothing"); + return; + } + let mut predicted = before.clone(); + for op in &ops { + onestore::op::predict(&mut predicted, op).unwrap(); + } + assert_eq!( + projection(§ion.page(space).unwrap()), + projection(&predicted), + "the model oracle" + ); + if section.seal().unwrap().is_none() { assert_eq!( projection(&before), projection(&after), @@ -578,6 +599,7 @@ pub fn run(input: &[u8]) { ); return; } + let written = §ion.image(); current::current(written); let stored = model(written, space).expect("the written page reads back"); let (stored, expected) = (projection(&stored), projection(&after)); diff --git a/crates/onestore/tests/support/page_schedule.rs b/crates/onestore/tests/support/page_schedule.rs index d25a2fa72ac9d86fd4d661d66dcbc159e9ca5d75..71ee9c5729ad21e9665df7009ad01c097746e4d3 100644 --- a/crates/onestore/tests/support/page_schedule.rs +++ b/crates/onestore/tests/support/page_schedule.rs @@ -1,6 +1,7 @@ use onestore::{ - CommitState, ExGuid, Insertion, PageCreation, PageEdit, PreparedEdit, RevisionIndex, Store, + CommitState, ExGuid, PageCreation, PageEdit, RevisionIndex, Store, document::{Document, FieldValue, Kind}, + op::{PageOp, SectionOp}, }; use std::{collections::BTreeMap, sync::LazyLock}; @@ -8,6 +9,8 @@ use std::{collections::BTreeMap, sync::LazyLock}; pub(crate) mod current; #[path = "disk.rs"] pub(crate) mod disk; +#[path = "ops.rs"] +pub(crate) mod ops; static SOURCE: LazyLock> = LazyLock::new(|| { onestore::create_section("pages.one", "Original 🦀 é 東京", "Author").unwrap() @@ -24,17 +27,17 @@ pub fn run(input: &[u8]) { _ => SOURCE.as_slice(), }; if let Ok(intent) = serde_json::from_slice::(input) - && let Ok(prepared) = PreparedEdit::create_page(source, &intent) + && let Ok(prepared) = ops::section_op(source, SectionOp::Create(intent.clone())) { current::current(prepared.as_bytes()); } if let Ok(edits) = serde_json::from_slice::>(input) - && let Ok(prepared) = PreparedEdit::pages(source, &edits) + && let Ok(prepared) = ops::section_op(source, SectionOp::Pages(edits.to_vec())) { current::current(prepared.as_bytes()); } if let Ok(pages) = serde_json::from_slice::>(input) - && let Ok(prepared) = PreparedEdit::delete_pages_permanently(source, &pages) + && let Ok(prepared) = ops::section_op(source, SectionOp::Delete(pages.to_vec())) { current::current(prepared.as_bytes()); } @@ -65,7 +68,7 @@ pub fn run(input: &[u8]) { .get(usize::from(step[2]) % pages.len().max(1)) .copied(); let text = ["", "Same title", "é 🦋 東京", " spaces "][usize::from(step[3]) % 4]; - let existing = if let Some((sid, page)) = selected { + let existing = if let Some((sid, _)) = selected { let space = &document.spaces[&sid]; let view = &space.revisions[&space.contexts[&ExGuid::default()]]; let titles: Vec<_> = view @@ -91,7 +94,7 @@ pub fn run(input: &[u8]) { let removed: Vec<_> = (0..count) .map(|i| pages[(usize::from(step[2]) + i) % pages.len()].0) .collect(); - let prepared = PreparedEdit::delete_pages_permanently(source, &removed).unwrap(); + let prepared = ops::section_op(source, SectionOp::Delete(removed.to_vec())).unwrap(); pages.retain(|page| !removed.contains(&page.0)); for sid in removed { levels.remove(&sid); @@ -128,7 +131,7 @@ pub fn run(input: &[u8]) { let restored: Vec = serde_json::from_value(serde_json::to_value(&edits).unwrap()).unwrap(); assert_eq!(edits, restored); - let prepared = PreparedEdit::pages(source, &restored); + let prepared = ops::section_op(source, SectionOp::Pages(restored.to_vec())); if levels[&pages[0].0] != 1 { assert!(prepared.is_err()); continue; @@ -140,21 +143,15 @@ pub fn run(input: &[u8]) { unreachable!() }; Some(( - PreparedEdit::text( - source, - sid, - object, - 0..u32::try_from(original.encode_utf16().count()).unwrap(), - text, - ) + ops::page_op(source, sid, PageOp::Text { text: object, range: 0..u32::try_from(original.encode_utf16().count()).unwrap(), with: text.into() }) .unwrap(), Some((sid, object, text)), )) } else if step[1] & 16 != 0 { - let intent = Insertion::outline(page, 36.0, 36.0, text, "Page fuzz").unwrap(); + let (add, _, inserted) = ops::new_outline(36.0, 36.0, text); Some(( - PreparedEdit::insert(source, sid, &intent).unwrap(), - Some((sid, intent.text_object(), text)), + ops::page_op(source, sid, add).unwrap(), + Some((sid, inserted, text)), )) } else { None @@ -182,7 +179,7 @@ pub fn run(input: &[u8]) { let intent = PageCreation::new(before, title, "Page fuzz").unwrap(); let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); assert_eq!(intent, restored); - let edit = PreparedEdit::create_page(source, &restored).unwrap(); + let edit = ops::section_op(source, SectionOp::Create(restored.clone())).unwrap(); let position = before.map_or(pages.len(), |sid| { pages.iter().position(|p| p.0 == sid).unwrap() }); @@ -257,7 +254,10 @@ pub fn run(input: &[u8]) { write_limit: if step[6] & 1 == 0 { 17 } else { 4096 }, random: u64::from(step[7]) + 1, }; - let result = edit.commit(&mut disk); + let Some(transaction) = &edit.transaction else { + continue; + }; + let result = transaction.commit(&mut disk); let observed = current::current(&disk.durable); match result { Ok(()) => assert_eq!(observed, after), diff --git a/crates/onestore/tests/support/tree_model.rs b/crates/onestore/tests/support/tree_model.rs index 117a41157772e63c51134b0323e30c187d2444bb..9b7680d9ceb309f688d059460db542982a087c68 100644 --- a/crates/onestore/tests/support/tree_model.rs +++ b/crates/onestore/tests/support/tree_model.rs @@ -1,16 +1,18 @@ use onestore::{ - CommitState, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TreeEdit, + CommitState, ExGuid, RevisionIndex, Store, document::{Document, Kind, Revision}, + op::{Op, PageOp}, + page::Page, }; use std::{ - collections::{BTreeMap, BTreeSet}, + collections::BTreeMap, sync::LazyLock, }; -use crate::{current, disk}; +use crate::{current, disk, ops}; static SOURCE: LazyLock> = LazyLock::new(|| { - let mut source = onestore::create_section("tree.one", "Original", "Author").unwrap(); + let source = onestore::create_section("tree.one", "Original", "Author").unwrap(); let store = Store::parse(&source).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); @@ -22,26 +24,24 @@ static SOURCE: LazyLock> = LazyLock::new(|| { .iter() .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) .unwrap(); - let other = Insertion::outline(page, 360.0, 72.0, "Other", "Author").unwrap(); - source = PreparedEdit::insert(&source, sid, &other) - .unwrap() - .as_bytes() - .to_vec(); - for parent in [outline, other.object()] { - for text in ["First 🦀 é", "Second 東京", "Third"] { - let insertion = Insertion::paragraph(parent, None, text, "Author").unwrap(); - source = PreparedEdit::insert(&source, sid, &insertion) - .unwrap() - .as_bytes() - .to_vec(); - let child = Insertion::paragraph(insertion.object(), None, "Child", "Author").unwrap(); - source = PreparedEdit::insert(&source, sid, &child) - .unwrap() - .as_bytes() - .to_vec(); + let (add, other, _) = ops::new_outline(360.0, 72.0, "Other"); + let mut edits = vec![add]; + for container in [outline, other] { + let mut paragraphs = Vec::new(); + for text in ["First 🦀 é", "Second 東京", "Third"] { + let paragraph = ops::paragraph(text); + let mut child = ops::paragraph("Child"); + child.parent = Some(paragraph.id); + child.level = 2; + paragraphs.extend([paragraph, child]); } + edits.push(PageOp::Insert { + container, + before: None, + paragraphs, + }); } - source + ops::page_edited(&source, sid, edits).unwrap() }); fn forest(view: &Revision<'_>, root: ExGuid) -> BTreeMap { @@ -69,6 +69,9 @@ fn forest(view: &Revision<'_>, root: ExGuid) -> BTreeMap = before - .keys() - .flat_map(|id| view.nodes[id].children.iter().map(|child| (*child, *id))) - .collect(); - let parent = parents[&target]; + let outline = matches!(view.nodes[&target].kind, Kind::Outline { .. }); let deleting = step[1] & 6 == 0; - let mut expected: BTreeMap<_, _> = before - .iter() - .filter_map(|(id, pos)| { - matches!(view.nodes[id].kind, Kind::Paragraph { .. }) - .then_some((*id, (pos.0, pos.1))) - }) - .collect(); - let mut ordered: Vec<_> = expected.keys().copied().collect(); - ordered.sort_by_key(|id| before[id].2); - let mut expected_order = BTreeMap::<_, Vec<_>>::new(); - for id in ordered { - expected_order.entry(before[&id].0).or_default().push(id); - } - let intent; - let mut reversal = None; - let edit = if deleting { - expected.retain(|id, _| !subtree.contains_key(id)); - for ids in expected_order.values_mut() { - ids.retain(|id| !subtree.contains_key(id)); - } - intent = TreeEdit::delete(target, "Tree schedule").unwrap(); - PreparedEdit::tree(source, sid, &intent) + let (op, cycle) = if deleting { + (PageOp::Delete { object: target }, false) } else { let mut destinations: Vec<_> = before .keys() .copied() .filter(|id| { - if matches!(view.nodes[&target].kind, Kind::Outline { .. }) { + if outline { *id == page } else { matches!( view.nodes[id].kind, - Kind::Outline { .. } - | Kind::OutlineGroup - | Kind::Paragraph { .. } - | Kind::Cell { .. } + Kind::Outline { .. } | Kind::Paragraph { .. } | Kind::Cell { .. } ) } }) .collect(); destinations.sort_by_key(|id| before[id].2); - if destinations.is_empty() { - continue; - } let destination = destinations[usize::from(step[4]) % destinations.len()]; - let children = &view.nodes[&destination].children; - let anchor = children + // Outline groups are the writers' business: anchors are the paragraphs in them. + let mut children = Vec::new(); + let mut pending: Vec = view.nodes[&destination].children.iter().rev().copied().collect(); + while let Some(id) = pending.pop() { + match view.nodes[&id].kind { + Kind::OutlineGroup => pending.extend(view.nodes[&id].children.iter().rev()), + _ if id == target => {} + _ => children.push(id), + } + } + let before = children .get(usize::from(step[5]) % (children.len() + 1)) .copied(); - intent = TreeEdit::move_to(target, destination, anchor, "Tree schedule").unwrap(); - let edit = PreparedEdit::tree(source, sid, &intent); - if subtree.contains_key(&destination) { - assert!(edit.is_err()); - continue; - } - if destination == parent && step[9] & 3 == 0 { - let original = &view.nodes[&parent].children; - let next = original.iter().position(|id| *id == target).unwrap() + 1; - reversal = Some( - TreeEdit::move_to(target, parent, original.get(next).copied(), "Tree schedule") - .unwrap(), - ); - } - if matches!(view.nodes[&target].kind, Kind::Paragraph { .. }) { - let (scope, base, _) = before[&destination]; - let level = base + u32::from(view.nodes[&destination].child_level.unwrap_or(1)); - let (old_scope, old_level, _) = before[&target]; - if anchor != Some(target) { - let moved: Vec<_> = expected_order[&old_scope] - .iter() - .copied() - .filter(|id| subtree.contains_key(id)) - .collect(); - expected_order - .get_mut(&old_scope) - .unwrap() - .retain(|id| !subtree.contains_key(id)); - let boundary = anchor.map(|id| before[&id].2).unwrap_or_else(|| { - forest(view, destination) - .keys() - .map(|id| before[id].2) - .max() - .unwrap() - + 1 - }); - let sequence = expected_order.entry(scope).or_default(); - let position = sequence - .iter() - .position(|id| before[id].2 >= boundary) - .unwrap_or(sequence.len()); - sequence.splice(position..position, moved); - } - for (id, pos) in &mut expected { - if subtree.contains_key(id) && pos.0 == old_scope { - *pos = (scope, level + pos.1 - old_level); - } - } - } - edit + let op = PageOp::Move { + object: target, + parent: (!outline).then_some(destination), + before, + }; + (op, subtree.contains_key(&destination)) }; - let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); - assert_eq!(intent, restored); - let edit = match edit { - Ok(edit) => edit, - Err(error) - if error.message - == "Removing this group would exceed 31 child indentation levels" => - { - continue; - } - Err(error) => panic!("{intent:?}: {error:?}"), + let model = Page::from_space(&document, sid).unwrap(); + let ops = vec![Op::Page { space: sid, op: op.clone() }]; + let Ok(edit) = ops::apply(source, "Tree schedule", ops) else { + continue; }; + assert!(!cycle, "{op:?}"); let after_store = Store::parse(edit.as_bytes()).unwrap(); let after_index = RevisionIndex::parse(&after_store).unwrap(); after_index.validate_current().unwrap(); let after_document = Document::parse(&after_index).unwrap(); + let mut predicted = model.clone(); + onestore::op::predict(&mut predicted, &op).unwrap(); + let stored = Page::from_space(&after_document, sid).unwrap(); + if stored != predicted { + let (a, b) = (format!("{stored:?}"), format!("{predicted:?}")); + let at = a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count(); + panic!("{op:?}\n stored …{}\n predicted …{}", &a[a.floor_char_boundary(at.saturating_sub(300))..a.floor_char_boundary((at + 200).min(a.len()))], &b[b.floor_char_boundary(at.saturating_sub(300))..b.floor_char_boundary((at + 200).min(b.len()))]); + } let space = &after_document.spaces[&sid]; let after = &space.revisions[&space.contexts[&ExGuid::default()]]; - let actual = forest(after, page); - let mut added = BTreeSet::new(); - for (id, pos) in &actual { - let node = &after.nodes[id]; - if let Kind::Paragraph { .. } = node.kind { - if let Some(wanted) = expected.remove(id) { - assert_eq!((pos.0, pos.1), wanted, "{intent:?}: {id}"); - } else { - assert!(!before.contains_key(id)); - assert!(matches!(view.nodes[&parent].kind, Kind::Cell { .. })); - assert_eq!(view.nodes[&parent].children, [target]); - assert_eq!(node.children.len(), 0); - assert_eq!(node.content.len(), 1); - assert!(node.tags.is_empty()); - assert!( - matches!(&after.nodes[&node.content[0]].kind,Kind::RichText{text,..} if text.is_empty()) - ); - added.insert(*id); - } - } - if before.contains_key(id) { - assert_eq!(node.content, view.nodes[id].content); - let mut expected_kind = serde_json::to_value(&view.nodes[id].kind).unwrap(); - if *id == page && expected_kind["alternate_title"].is_string() { - let mut roots = after.nodes[&page].children.clone(); - roots.sort_by(|a, b| { - let a = &after.nodes[a].layout; - let b = &after.nodes[b].layout; - a.y.unwrap_or(0.0) - .total_cmp(&b.y.unwrap_or(0.0)) - .then_with(|| a.x.unwrap_or(0.0).total_cmp(&b.x.unwrap_or(0.0))) - }); - let mut title = String::new(); - for root in roots { - let mut nodes: Vec<_> = forest(after, root).into_iter().collect(); - nodes.sort_by_key(|(_, pos)| pos.2); - for (id, _) in nodes { - if let Kind::RichText { text, .. } = &after.nodes[&id].kind { - title = text - .trim_start() - .split('\r') - .next() - .unwrap() - .trim_end() - .to_owned(); - if !title.is_empty() { - break; - } - } - } - if !title.is_empty() { - break; - } - } - expected_kind["alternate_title"] = title.into(); - let Kind::Metadata { title, .. } = &after.nodes[&after.roots[&2]].kind else { - panic!() - }; - assert_eq!(title.as_deref(), expected_kind["alternate_title"].as_str()); - } - assert_eq!(serde_json::to_value(&node.kind).unwrap(), expected_kind); + for (id, _) in forest(after, page) { + let node = &after.nodes[&id]; + if before.contains_key(&id) { + assert_eq!(node.content, view.nodes[&id].content); assert_eq!( serde_json::to_value(&node.tags).unwrap(), - serde_json::to_value(&view.nodes[id].tags).unwrap() + serde_json::to_value(&view.nodes[&id].tags).unwrap() ); } if matches!( @@ -311,31 +194,6 @@ pub fn run(input: &[u8]) { ); } } - assert!(expected.is_empty()); - assert!(added.len() <= 1); - let mut ordered: Vec<_> = actual - .iter() - .filter(|(id, _)| { - matches!(after.nodes[id].kind, Kind::Paragraph { .. }) && !added.contains(id) - }) - .collect(); - ordered.sort_by_key(|(_, pos)| pos.2); - let mut actual_order = BTreeMap::<_, Vec<_>>::new(); - for (id, pos) in ordered { - actual_order.entry(pos.0).or_default().push(*id); - } - expected_order.retain(|_, ids| !ids.is_empty()); - assert_eq!(actual_order, expected_order, "{intent:?}"); - if let Some(reversal) = reversal { - let reversed = PreparedEdit::tree(edit.as_bytes(), sid, &reversal).unwrap(); - let replayed = PreparedEdit::tree(reversed.as_bytes(), sid, &intent).unwrap(); - let store = Store::parse(replayed.as_bytes()).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let space = &document.spaces[&sid]; - let replayed = &space.revisions[&space.contexts[&ExGuid::default()]]; - assert_eq!(forest(replayed, page), actual); - } let before = current::current(&persisted); let after = current::current(edit.as_bytes()); let mut disk = disk::Disk { @@ -346,7 +204,10 @@ pub fn run(input: &[u8]) { write_limit: if step[8] & 1 == 0 { 17 } else { 4096 }, random: u64::from(step[9]) + 1, }; - let result = edit.commit(&mut disk); + let Some(transaction) = &edit.transaction else { + continue; + }; + let result = transaction.commit(&mut disk); let actual = current::current(&disk.durable); match result { Ok(()) => assert_eq!(actual, after), diff --git a/crates/onestore/tests/table_growth.rs b/crates/onestore/tests/table_growth.rs index d57186f5aab72dea0d3a5a1308d49f9dae067450..6f61607ee69f62514c697bc1037e1a67acb56c03 100644 --- a/crates/onestore/tests/table_growth.rs +++ b/crates/onestore/tests/table_growth.rs @@ -1,8 +1,11 @@ //! A rewritten object stores the global id table entries it names, not the table of the //! revision it was read from. +#[path = "support/ops.rs"] +mod ops; + use onestore::{ - PreparedEdit, RevisionIndex, Store, + RevisionIndex, Store, document::Document, page::{Page, PageObject, Paragraph}, }; @@ -64,17 +67,17 @@ fn a_text_edit_stores_the_table_entries_it_names() { text.text .append(Paragraph::new(" and a few more words".to_owned(), format)) .unwrap(); - let written = PreparedEdit::page(&bytes, space, &page, "Rust") + let written = ops::saved(&bytes, space, &page) .unwrap() - .as_bytes() + .as_slice() .to_vec(); for (entries, span) in tables(&written, bytes.len()) { sparse |= (entries as u32) < span; } assert_eq!( - PreparedEdit::page(&written, space, &page, "Rust") + ops::saved(&written, space, &page) .unwrap() - .as_bytes(), + .as_slice(), written ); bytes = written; diff --git a/crates/onestore/tests/toc.rs b/crates/onestore/tests/toc.rs index 7a71d89380e88a3faf41b338025b848435ece205..6a6e2f3b8fcb9db2304458f6177b3ce972fc3b14 100644 --- a/crates/onestore/tests/toc.rs +++ b/crates/onestore/tests/toc.rs @@ -1,4 +1,25 @@ -use onestore::{PreparedEdit, RevisionIndex, Store, TocEdit, document::Document}; +use onestore::{RevisionIndex, Store, TocEdit, document::Document}; + +/// `source` with `edits` applied. +fn edited(source: &[u8], edits: &[TocEdit]) -> Result, onestore::Error> { + let mut image = source.to_vec(); + if let Some(transaction) = onestore::edit_table_of_contents(source, edits)? { + transaction.apply(&mut image)?; + } + Ok(image) +} + +/// The notebook's colour. +fn color(bytes: &[u8]) -> Option { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let revision = document.active(document.root).unwrap(); + match &revision.nodes[&revision.roots[&1]].kind { + onestore::document::Kind::Toc { color, .. } => *color, + _ => panic!(), + } +} fn entries(bytes: &[u8]) -> Vec<(String, [u8; 16], u32, Option)> { let store = Store::parse(bytes).unwrap(); @@ -31,7 +52,7 @@ fn entries(bytes: &[u8]) -> Vec<(String, [u8; 16], u32, Option)> { } #[test] -fn sections_and_groups_are_added_renamed_coloured_ordered_and_removed() { +fn sections_and_groups_are_added_renamed_ordered_and_removed_and_the_notebook_coloured() { let a = [1; 16]; let b = [2; 16]; let toc = onestore::create_table_of_contents( @@ -41,7 +62,7 @@ fn sections_and_groups_are_added_renamed_coloured_ordered_and_removed() { .unwrap(); let c = [3; 16]; let group = [4; 16]; - let written = PreparedEdit::table_of_contents( + let written = edited( &toc, &[ TocEdit::Add { @@ -58,7 +79,7 @@ fn sections_and_groups_are_added_renamed_coloured_ordered_and_removed() { ) .unwrap(); assert_eq!( - entries(written.as_bytes()), + entries(&written), [ ("Alpha.one".to_owned(), a, 1, Some(0xffff_ffff)), ("Beta.one".to_owned(), b, 2, Some(0xffff_ffff)), @@ -66,33 +87,31 @@ fn sections_and_groups_are_added_renamed_coloured_ordered_and_removed() { ("Archive".to_owned(), group, 4, None), ] ); - let again = PreparedEdit::table_of_contents( - written.as_bytes(), + let again = edited( + &written, &[ TocEdit::Rename { identity: b, filename: "Renamed.one".into(), }, - TocEdit::Color { - identity: c, - color: Some(0x00d7ff), - }, + TocEdit::Color(0x00d7ff), TocEdit::Order(vec![group, c]), TocEdit::Remove { identity: a }, ], ) .unwrap(); assert_eq!( - entries(again.as_bytes()), + entries(&again), [ ("Archive".to_owned(), group, 1, None), - ("Gamma.one".to_owned(), c, 2, Some(0x00d7ff)), + ("Gamma.one".to_owned(), c, 2, Some(0xffff_ffff)), ("Renamed.one".to_owned(), b, 3, Some(0xffff_ffff)), ] ); + assert_eq!(color(&again), Some(0x00d7ff)); assert!( - PreparedEdit::table_of_contents( - again.as_bytes(), + edited( + &again, &[TocEdit::Add { filename: "renamed.one".into(), identity: [9; 16], @@ -101,8 +120,5 @@ fn sections_and_groups_are_added_renamed_coloured_ordered_and_removed() { ) .is_err() ); - assert!( - PreparedEdit::table_of_contents(again.as_bytes(), &[TocEdit::Remove { identity: a }]) - .is_err() - ); + assert!(edited(&again, &[TocEdit::Remove { identity: a }]).is_err()); } diff --git a/crates/onestore/tests/transaction.rs b/crates/onestore/tests/transaction.rs index 72108fe5cc11db75ac0e9b6c645f4693d9b16fde..14671a5bb4f3a73e18c5faba354810cd3beffa24 100644 --- a/crates/onestore/tests/transaction.rs +++ b/crates/onestore/tests/transaction.rs @@ -1,6 +1,10 @@ +#[path = "support/ops.rs"] +mod ops; + use onestore::{ - CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Stamp, Store, + CommitIo, CommitState, ExGuid, RevisionIndex, Stamp, Store, Transaction, document::{Document, Kind}, + op::{Op, PageOp}, }; use std::io; @@ -23,6 +27,21 @@ fn text(source: &[u8]) -> (ExGuid, ExGuid) { panic!("Missing text fixture") } +/// The transaction typing `with` at the start of the text object, and the image it leaves. +fn typed(space: ExGuid, text: ExGuid, with: &str) -> (Transaction, Vec) { + let op = PageOp::Text { + text, + range: 0..0, + with: with.into(), + }; + let transaction = ops::transaction(SOURCE, "Author", vec![Op::Page { space, op }]) + .unwrap() + .unwrap(); + let mut image = SOURCE.to_vec(); + transaction.apply(&mut image).unwrap(); + (transaction, image) +} + /// A file that records how many bytes a commit reads and writes. struct Counted { bytes: Vec, @@ -54,20 +73,19 @@ impl CommitIo for Counted { #[test] fn a_transaction_reads_the_header_and_writes_only_its_edit() { let (sid, oid) = text(SOURCE); - let edit = PreparedEdit::text(SOURCE, sid, oid, 0..0, "é").unwrap(); - let transaction = edit.transaction(); + let (transaction, written) = typed(sid, oid, "é"); let mut image = SOURCE.to_vec(); transaction.apply(&mut image).unwrap(); - assert_eq!(image, edit.as_bytes()); + assert_eq!(image, written); let mut file = Counted { bytes: SOURCE.to_vec(), read: 0, written: 0, }; transaction.commit(&mut file).unwrap(); - assert_eq!(file.bytes, edit.as_bytes()); + assert_eq!(file.bytes, written); assert!(file.read <= 1024 + 2, "read {} bytes", file.read); - let appended = edit.as_bytes().len() - SOURCE.len(); + let appended = written.len() - SOURCE.len(); assert!( file.written < appended + 2048, "wrote {} bytes for {appended} appended", @@ -86,9 +104,9 @@ fn a_transaction_reads_the_header_and_writes_only_its_edit() { #[test] fn stamps_name_the_committed_image() { let (sid, oid) = text(SOURCE); - let edit = PreparedEdit::text(SOURCE, sid, oid, 0..0, "x").unwrap(); + let (transaction, written) = typed(sid, oid, "x"); let before = Stamp::of(SOURCE).unwrap(); - let after = Stamp::of(edit.as_bytes()).unwrap(); + let after = Stamp::of(&written).unwrap(); assert_ne!(before, after); assert_eq!(before.length, SOURCE.len() as u64); // An unpublished tail changes the length, so a commit built without it is refused. @@ -100,7 +118,7 @@ fn stamps_name_the_committed_image() { read: 0, written: 0, }; - let error = edit.commit(&mut file).unwrap_err(); + let error = transaction.commit(&mut file).unwrap_err(); assert_eq!(error.error.kind(), io::ErrorKind::ResourceBusy); assert_eq!(file.written, 0); assert!(Stamp::of(&SOURCE[..1023]).is_err()); diff --git a/crates/onestore/tests/tree.rs b/crates/onestore/tests/tree.rs index 13d30afbc15613b51832d82b13f66bddeca68376..1041f367712c9b3cb17fddd6000abe3a4bb54905 100644 --- a/crates/onestore/tests/tree.rs +++ b/crates/onestore/tests/tree.rs @@ -1,6 +1,7 @@ use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, TreeEdit, + ExGuid, RevisionIndex, Store, document::{Document, Kind, Revision}, + op::{Op, PageOp}, }; use std::collections::{BTreeMap, BTreeSet}; @@ -8,6 +9,8 @@ use std::collections::{BTreeMap, BTreeSet}; mod current; #[path = "support/disk.rs"] mod disk; +#[path = "support/ops.rs"] +mod ops; #[path = "support/tree_model.rs"] mod tree_model; @@ -55,7 +58,35 @@ fn active(view: &Revision<'_>, page: ExGuid) -> BTreeSet { ids } -fn operation(view: &Revision<'_>, page: ExGuid) -> Option<(String, ExGuid, TreeEdit)> { +/// The container `id` lies in, outline groups passed over. +fn container(view: &Revision<'_>, ids: &BTreeSet, id: ExGuid) -> ExGuid { + let mut at = *ids + .iter() + .find(|parent| view.nodes[parent].children.contains(&id)) + .unwrap(); + while matches!(view.nodes[&at].kind, Kind::OutlineGroup) { + at = container(view, ids, at); + } + at +} + +/// The outline level of each paragraph in `outline`. +fn levels(view: &Revision<'_>, outline: ExGuid) -> BTreeMap { + let mut levels = BTreeMap::new(); + let mut pending = vec![(outline, 0)]; + while let Some((id, level)) = pending.pop() { + let node = &view.nodes[&id]; + if matches!(node.kind, Kind::Paragraph { .. }) { + levels.insert(id, level); + } + let child = level + u32::from(node.child_level.unwrap_or(1)); + pending.extend(node.children.iter().map(|id| (*id, child))); + } + levels +} + +/// The named edit of a fixture page as ops, with the object it acts on. +fn operation(view: &Revision<'_>, page: ExGuid) -> Option<(String, ExGuid, Vec)> { let Kind::Metadata { title: Some(name), .. } = &view.nodes[&view.roots[&2]].kind @@ -85,32 +116,46 @@ fn operation(view: &Revision<'_>, page: ExGuid) -> Option<(String, ExGuid, TreeE .iter() .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) .unwrap(); - let parent = *ids - .iter() - .find(|id| view.nodes[id].children.contains(&target)) - .unwrap(); + let parent = container(view, &ids, target); + let move_to = |parent, before| PageOp::Move { + object: target, + parent: Some(parent), + before, + }; let edit = match name.as_str() { - "Delete outline" => TreeEdit::delete(outline, "Tree author"), - name if name.starts_with("Delete") => TreeEdit::delete(target, "Tree author"), + "Delete outline" => vec![PageOp::Delete { object: outline }], + "Delete sole cell paragraph" => vec![ + PageOp::Insert { + container: parent, + before: None, + paragraphs: vec![ops::paragraph("")], + }, + PageOp::Delete { object: target }, + ], + name if name.starts_with("Delete") => vec![PageOp::Delete { object: target }], "Move leaf down" | "Move subtree down" | "Move numbered subtree down" - | "Move cell subtree down" => TreeEdit::move_to(target, parent, None, "Tree author"), - "Move subtree up" => { - TreeEdit::move_to(target, parent, Some(paragraphs["Anchor"]), "Tree author") + | "Move cell subtree down" => vec![move_to(parent, None)], + "Move subtree up" => vec![move_to(parent, Some(paragraphs["Anchor"]))], + "Indent subtree" | "Indent bullet subtree" => vec![move_to(paragraphs["Anchor"], None)], + "Outdent subtree" | "Outdent first group" => { + // Levels are absolute: the subtree moves up to the outline's first level. + let levels = levels(view, outline); + let shift = levels[&target] - 1; + let mut ops = vec![move_to(outline, Some(paragraphs["Trailing sibling"]))]; + let mut pending = vec![target]; + while let Some(paragraph) = pending.pop() { + ops.push(PageOp::Level { + paragraph, + level: levels[¶graph] - shift, + }); + pending.extend(view.nodes[¶graph].children.iter().rev()); + } + ops } - "Indent subtree" | "Indent bullet subtree" => { - TreeEdit::move_to(target, paragraphs["Anchor"], None, "Tree author") - } - "Outdent subtree" | "Outdent first group" => TreeEdit::move_to( - target, - outline, - Some(paragraphs["Trailing sibling"]), - "Tree author", - ), _ => return None, - } - .unwrap(); + }; Some(( name.clone(), if name == "Delete outline" { @@ -139,9 +184,9 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() { let Some((name, object, edit)) = operation(before, page) else { continue; }; - let restored = serde_json::from_value(serde_json::to_value(&edit).unwrap()).unwrap(); - assert_eq!(edit, restored); - let prepared = PreparedEdit::tree(source, sid, &restored).unwrap(); + let edit: Vec = edit.into_iter().map(|op| Op::Page { space: sid, op }).collect(); + let prepared = ops::apply(source, "Tree author", edit.clone()) + .unwrap_or_else(|error| panic!("{name}: {error}")); let after_store = Store::parse(prepared.as_bytes()).unwrap(); assert!(after_store.checksum_mismatches.is_empty()); let after_index = RevisionIndex::parse(&after_store).unwrap(); @@ -267,11 +312,19 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() { serde_json::to_value(&old.tags).unwrap(), "{name}: tags {id}" ); - } else if matches!(node.kind, Kind::RichText { .. }) { - assert_eq!( - serde_json::to_value(&node.kind).unwrap(), - serde_json::to_value(&wanted.kind).unwrap() - ); + } else if let ( + Kind::RichText { + text, boilerplate, .. + }, + Kind::RichText { + text: native, + boilerplate: native_boilerplate, + .. + }, + ) = (&node.kind, &wanted.kind) + { + // A new run carries the language MS-ONE requires, which OneNote omits. + assert_eq!((text, boilerplate), (native, native_boilerplate)); } } for (sid, space) in &index.spaces { @@ -284,7 +337,7 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() { } } } - let another = PreparedEdit::tree(source, sid, &edit).unwrap(); + let another = ops::apply(source, "Tree author", edit.clone()).unwrap(); let second_store = Store::parse(another.as_bytes()).unwrap(); let second_index = RevisionIndex::parse(&second_store).unwrap(); let second_doc = Document::parse(&second_index).unwrap(); @@ -293,10 +346,7 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() { actual, active(&space.revisions[&space.contexts[&ExGuid::default()]], page) ); - candidate = PreparedEdit::tree(&candidate, sid, &edit) - .unwrap() - .as_bytes() - .to_vec(); + candidate = ops::apply(&candidate, "Tree author", edit).unwrap().image; count += 1; } if let Some(output) = std::env::var_os("ONESTORE_TREE_OUTPUT") { @@ -329,22 +379,24 @@ fn moves_reject_cycles_wrong_parents_and_stale_siblings() { .unwrap(); let paragraph = view.nodes[&outline].children[0]; let text = view.nodes[¶graph].content[0]; - for intent in [ - TreeEdit::delete(page, "Author"), - TreeEdit::delete(text, "Author"), - TreeEdit::move_to(outline, paragraph, None, "Author"), - TreeEdit::move_to(paragraph, paragraph, None, "Author"), - TreeEdit::move_to(paragraph, text, None, "Author"), - TreeEdit::move_to(paragraph, outline, Some(page), "Author"), + let move_to = |object, parent, before| PageOp::Move { + object, + parent: Some(parent), + before, + }; + for op in [ + PageOp::Delete { object: page }, + PageOp::Delete { object: text }, + move_to(outline, paragraph, None), + move_to(paragraph, paragraph, None), + move_to(paragraph, text, None), + move_to(paragraph, outline, Some(page)), ] { - assert!(PreparedEdit::tree(&source, sid, &intent.unwrap()).is_err()); + assert!(ops::page_op(&source, sid, op).is_err()); } for before in [None, Some(paragraph)] { - let edit = TreeEdit::move_to(paragraph, outline, before, "Author").unwrap(); - assert_eq!( - PreparedEdit::tree(&source, sid, &edit).unwrap().as_bytes(), - source - ); + let op = move_to(paragraph, outline, before); + assert_eq!(ops::page_op(&source, sid, op).unwrap().as_bytes(), source); } } @@ -370,7 +422,8 @@ fn publication_interruptions_expose_complete_old_or_new_trees() { ) { continue; } - let edit = PreparedEdit::tree(source, sid, &intent).unwrap(); + let intent = intent.into_iter().map(|op| Op::Page { space: sid, op }).collect(); + let edit = ops::apply(source, "Tree author", intent).unwrap(); let after = current::current(edit.as_bytes()); for write_limit in [17, 4096] { let disk = |fail_at| disk::Disk { @@ -453,8 +506,24 @@ fn cross_container_moves_keep_tables_and_replace_emptied_cells() { "Move cell subtree down" => (target, outlines[1]), _ => (view.nodes[&outlines[0]].children[0], outlines[1]), }; - let intent = TreeEdit::move_to(object, destination, None, "Tree author").unwrap(); - let edit = PreparedEdit::tree(source, sid, &intent).unwrap(); + let mut intent = vec![Op::Page { + space: sid, + op: PageOp::Move { + object, + parent: Some(destination), + before: None, + }, + }]; + // A cell keeps a paragraph: its replacement goes in first. + if name == "Delete sole cell paragraph" { + let op = PageOp::Insert { + container: parents[&target], + before: None, + paragraphs: vec![ops::paragraph("")], + }; + intent.insert(0, Op::Page { space: sid, op }); + } + let edit = ops::apply(source, "Tree author", intent.clone()).unwrap(); let store = Store::parse(edit.as_bytes()).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); @@ -503,10 +572,7 @@ fn cross_container_moves_keep_tables_and_replace_emptied_cells() { "Delete cell subtree" => assert!(!actual.contains(&outlines[0])), _ => assert_eq!(after.nodes[&parents[&target]].children.len(), 1), } - candidate = PreparedEdit::tree(&candidate, sid, &intent) - .unwrap() - .as_bytes() - .to_vec(); + candidate = ops::apply(&candidate, "Tree author", intent).unwrap().image; count += 1; } assert_eq!(count, 4); diff --git a/crates/onestore/tests/writer.rs b/crates/onestore/tests/writer.rs index 72f7619da77b191bf78333e2e0e836fc09aa15f4..b515b41859e354db80e52b00cd52eb13f01ba21e 100644 --- a/crates/onestore/tests/writer.rs +++ b/crates/onestore/tests/writer.rs @@ -1,39 +1,63 @@ +#[path = "support/ops.rs"] +mod ops; + use onestore::{ - ExGuid, ObjectData, PropertySets, RevisionIndex, Store, Value, replace_property_bytes, + ExGuid, ObjectData, PropertySets, RevisionIndex, Store, TocEdit, Value, + document::{Document, Kind}, + op::{Edit, Op, PageOp}, }; use std::fs; +/// The text object holding `text` in `source` and its page space. +fn text_object(source: &[u8], text: &str) -> (ExGuid, ExGuid) { + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut found = document.spaces.iter().flat_map(|(space, view)| { + view.active() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| matches!(&node.kind, Kind::RichText { text: stored, .. } if stored == text)) + .map(|(id, _)| (*space, *id)) + .collect::>() + }); + let target = found.next().unwrap(); + assert!(found.next().is_none()); + target +} + +/// `source`, a table of contents, with the notebook recoloured. +fn recolored(source: &[u8], color: u32) -> Vec { + let mut image = source.to_vec(); + let edit = TocEdit::Color(color); + if let Some(transaction) = onestore::edit_table_of_contents(source, &[edit]).unwrap() { + transaction.apply(&mut image).unwrap(); + } + image +} + #[test] -fn scalar_edit_appends_a_revision_and_preserves_every_prior_object() { +fn text_edit_appends_a_revision_and_preserves_every_prior_object() { let source = fs::read("../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one") .unwrap(); let store = Store::parse(&source).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); - let mut selected = None; - for (osid, space) in &index.spaces { - let revision = index - .resolve(*osid, space.labels[&(ExGuid::default(), 1)]) - .unwrap(); - for oid in revision.reachable().unwrap() { - let object = &revision.objects[&oid]; - if let ObjectData::Properties(bytes) = object.data { - for property in &PropertySets::parse(bytes).unwrap().sets[0] { - if property.value == Value::Bytes(b"Fictitious plain text.") { - assert!(selected.replace((*osid, oid, property.id)).is_none()); - } - } - } - } - } - let (osid, oid, property) = selected.unwrap(); - assert_eq!( - replace_property_bytes(&source, osid, oid, property, b"Fictitious plain text.").unwrap(), - source - ); + let (osid, oid) = text_object(&source, "Fictitious plain text."); + let whole = 0.."Fictitious plain text.".encode_utf16().count() as u32; + let typed = |with: &str| { + let op = PageOp::Text { + text: oid, + range: whole.clone(), + with: with.into(), + }; + ops::page_edited(&source, osid, vec![op]).unwrap() + }; + assert_eq!(typed("Fictitious plain text."), source); for length in 0..=40 { - let value = vec![b'x'; length]; - let written = replace_property_bytes(&source, osid, oid, property, &value).unwrap(); + let value = "x".repeat(length); + let written = typed(&value); assert_preserved_committed_bytes(&source, &written); let parsed = Store::parse(&written).unwrap(); assert!(parsed.checksum_mismatches.is_empty()); @@ -64,22 +88,11 @@ fn scalar_edit_appends_a_revision_and_preserves_every_prior_object() { } } } - let space = &after.spaces[&osid]; - let active = after - .resolve(osid, space.labels[&(ExGuid::default(), 1)]) - .unwrap(); - let ObjectData::Properties(data) = active.objects[&oid].data else { + let document = Document::parse(&after).unwrap(); + let Kind::RichText { text, .. } = &document.active(osid).unwrap().nodes[&oid].kind else { panic!() }; - let properties = PropertySets::parse(data).unwrap(); - assert_eq!( - properties.sets[0] - .iter() - .find(|candidate| candidate.id == property) - .unwrap() - .value, - Value::Bytes(&value) - ); + assert_eq!(*text, value); } } @@ -93,26 +106,33 @@ fn toc_color_edit_uses_its_native_revision_encoding_and_crc() { let index = RevisionIndex::parse(&store).unwrap(); let rid = index.spaces[&index.root].labels[&(ExGuid::default(), 1)]; let revision = index.resolve(index.root, rid).unwrap(); - let oid = revision.roots[&1]; - let written = - replace_property_bytes(&source, index.root, oid, 0x14001cbe, &[0x33, 0x66, 0x99, 0]) - .unwrap(); + let written = recolored(&source, 0x996633); let after = Store::parse(&written).unwrap(); assert!(after.checksum_mismatches.is_empty()); assert_preserved_committed_bytes(&source, &written); let current = RevisionIndex::parse(&after).unwrap(); current.validate_current().unwrap(); - assert_eq!( - current.resolve(index.root, rid).unwrap().objects[&oid].data, - revision.objects[&oid].data - ); let active = current .resolve( index.root, current.spaces[&index.root].labels[&(ExGuid::default(), 1)], ) .unwrap(); - let ObjectData::Properties(blob) = active.objects[&oid].data else { + let recoloured: Vec<_> = active + .objects + .iter() + .filter(|(id, object)| { + revision + .objects + .get(id) + .is_none_or(|old| old.data != object.data) + }) + .map(|(_, object)| object) + .collect(); + let [entry] = recoloured[..] else { + panic!("{} objects changed", recoloured.len()) + }; + let ObjectData::Properties(blob) = entry.data else { panic!() }; assert!( @@ -302,13 +322,8 @@ fn toc_writes_cross_fragment_and_counter_boundaries_without_crc_drift() { let store = Store::parse(&bytes).unwrap(); assert!(store.checksum_mismatches.is_empty(), "edit {i}"); let index = RevisionIndex::parse(&store).unwrap(); - let root = index.root; - let revision = index - .resolve(root, index.spaces[&root].labels[&(ExGuid::default(), 1)]) - .unwrap(); - let oid = revision.roots[&1]; - let written = - replace_property_bytes(&bytes, root, oid, 0x14001cbe, &i.to_le_bytes()).unwrap(); + index.validate_current().unwrap(); + let written = recolored(&bytes, i); assert_preserved_committed_bytes(&bytes, &written); bytes = written; } @@ -332,31 +347,31 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { let mut bytes = fs::read(path).unwrap(); let initial = Store::parse(&bytes).unwrap(); let index = RevisionIndex::parse(&initial).unwrap(); + // A section retypes text objects of one page; a table of contents recolours the notebook. + let document = Document::parse(&index).unwrap(); let mut candidates = Vec::new(); - for (sid, space) in &index.spaces { - let revision = index - .resolve(*sid, space.labels[&(ExGuid::default(), 1)]) - .unwrap(); - for oid in revision.reachable().unwrap() { - let object = &revision.objects[&oid]; - if object.jcid != if section { 0x6000e } else { 0x20001 } { - continue; - } - let ObjectData::Properties(blob) = object.data else { - panic!() - }; - let property = if section { 0x14001d7a } else { 0x14001cbe }; - if PropertySets::parse(blob).unwrap().sets[0] - .iter() - .any(|p| p.id == property) - { - candidates.push((*sid, oid, property)); + for (sid, view) in &document.spaces { + for (oid, node) in &view.active().unwrap().nodes { + match &node.kind { + Kind::RichText { + text, + boilerplate: false, + .. + } if section && !text.is_empty() => { + candidates.push((*sid, *oid)); + } + Kind::Toc { entries, .. } if !section && !entries.is_empty() => { + candidates.push((*sid, *oid)); + } + _ => {} } } } assert!(!candidates.is_empty()); let sid = candidates[0].0; candidates.retain(|candidate| candidate.0 == sid); + // What each edit rewrites: modification times, or the notebook's colour. + let property = if section { 0x14001d7a } else { 0x14001cbe }; if section { let revision = index .resolve(sid, index.spaces[&sid].labels[&(ExGuid::default(), 1)]) @@ -371,12 +386,30 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { } let mut checkpoints = 0; for operation in 0_u32..1025 { - let (_, oid, property) = candidates[operation as usize % candidates.len()]; + let (_, oid) = candidates[operation as usize % candidates.len()]; let store = Store::parse(&bytes).unwrap(); let before = RevisionIndex::parse(&store).unwrap(); let rid = before.spaces[&sid].labels[&(ExGuid::default(), 1)]; - let value = (1_000_000 + operation).to_le_bytes(); - let written = replace_property_bytes(&bytes, sid, oid, property, &value).unwrap(); + let written = if section { + let typed = |range, with: &str| Op::Page { + space: sid, + op: PageOp::Text { + text: oid, + range, + with: with.into(), + }, + }; + // Modification times count seconds: each edit is a second later. + let at = 134_000_000_000_000_000 + u64::from(operation) * 10_000_000; + let ops = vec![typed(0..0, "x"), typed(0..1, "")]; + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, bytes.clone()).unwrap(); + section.apply("Author", &Edit { at, ops }).unwrap(); + section.seal().unwrap().unwrap(); + section.image() + } else { + recolored(&bytes, 1_000_000 + operation) + }; let updated = Store::parse(&written).unwrap(); assert!(updated.checksum_mismatches.is_empty()); assert_eq!( @@ -412,9 +445,6 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { .iter() .all(|(entry, guid)| object.global_ids.get(entry) == Some(guid)) ); - if *id != oid { - assert_eq!(object.data, same.data); - } } if let (ObjectData::Properties(a), ObjectData::Properties(b)) = (object.data, same.data) @@ -426,10 +456,9 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { assert_eq!(a.len(), b.len()); for (a, b) in a.iter().zip(b) { assert_eq!(a.id, b.id); - if *id == oid && a.id == property { - assert_eq!(b.value, Value::Bytes(&value)); - } else if !matches!(a.value, Value::References { .. }) { - assert_eq!(a.value, b.value); + if a.id != property && !matches!(a.value, Value::References { .. }) + { + assert_eq!(a.value, b.value, "{id} {:#x}", a.id); } } } diff --git a/tools/canvas/README.md b/tools/canvas/README.md index 38db69057cc63274e27bd39ce70373baeb5904d8..db8d615275c3f880c97e845b47b1777660446c0e 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -82,9 +82,11 @@ Superscript and subscript runs draw at two thirds of their size, a third of the A paragraph stored as an equation draws in two dimensions from the tree `onestore::page::Math` parses: sub- and superscripts at 70%, fractions over a rule on the math axis, radicals drawn with the pen, n-ary operators at display size with limits above and below (integrals take them as scripts), stretched fences, accents and bars above, boxes, matrices and equation arrays. Latin letters draw in mathematical italic. Cambria Math falls back to STIX Two Math on macOS, and atoms are placed by their glyph ink rather than the math font's tall line box; an equation line is at least as tall as a line of text. The linear text stays the editable source, laid out in the body font so the caret keeps a text height; editing equations in two dimensions is a follow-up. -## Optional font substitution +## Font substitution -The host and `layout-probe` accept repeated `--substitute-font FONT_FILE` options. Supply Arimo regular/italic variable fonts or Carlito regular/bold/italic/bold-italic files. Each file is registered only in this process, as Arial or Calibri respectively; this explicitly replaces that family even when a system copy exists. Stored `Format.font` values remain unchanged. No font files are installed or bundled by this option. +`TextEngine::default()` lays out Calibri, Arial, Times New Roman and Courier New in the bundled Carlito 1.104, Arimo 1.341, Tinos 1.340 and Cousine 1.241 (`crates/canvas/assets/fonts`, SIL Open Font License) wherever the system lacks them. The font box names such a family with its substitute, as "Carlito (Calibri)"; stored `Format.font` values keep the document's name. + +The host and `layout-probe` also accept repeated `--substitute-font FONT_FILE` options for another Arimo, Carlito, Tinos or Cousine file. Each file is registered only in this process, as its family's counterpart; this explicitly replaces that family even when a system copy exists. No font files are installed. ```sh "target/Snowbound.app/Contents/MacOS/Snowbound" TEXT_FILE 240 --substitute-font ARIMO_FILE diff --git a/tools/document_model.py b/tools/document_model.py index f497a67eec36569e411ec3a0ea8374b03a95ca84..843438411d85558ec843aca54b8397a2da092def 100644 --- a/tools/document_model.py +++ b/tools/document_model.py @@ -1,8 +1,24 @@ """Traversal of the exported document's referenced revisions.""" import os from pathlib import Path +import shutil +import subprocess -EXPORTER = Path(os.environ.get('ONESTORE_DOCUMENT', Path(__file__).resolve().parent.parent / 'target/debug/examples/document')) + +def _exporter(): + """The document exporter built with protected sections, copied aside because a workspace + build without the feature overwrites target/debug/examples/document.""" + if 'ONESTORE_DOCUMENT' in os.environ: + return Path(os.environ['ONESTORE_DOCUMENT']) + root = Path(__file__).resolve().parent.parent + subprocess.run(['cargo', 'build', '--quiet', '-p', 'notebook', '--features', 'protected', + '--example', 'document'], cwd=root, check=True) + protected = root / 'target/debug/examples/document-protected' + shutil.copy2(root / 'target/debug/examples/document', protected) + return protected + + +EXPORTER = _exporter() BRIDGE = Path(__file__).resolve().parent.parent / 'target/debug/onestore-diagnostic' DEFAULT_CONTEXT = '{00000000-0000-0000-0000-000000000000},0' diff --git a/tools/native/collaborate.ps1 b/tools/native/collaborate.ps1 index 4a13a4320675250d49eac958bd3a020ca3b882f6..6a42a3d526db486f022b032583704b0f4aee26d3 100644 --- a/tools/native/collaborate.ps1 +++ b/tools/native/collaborate.ps1 @@ -47,6 +47,46 @@ try { Invoke-Stress $app $section $command $output $shared } elseif ($command.action -eq 'sync') { $app.SyncHierarchy($notebook) } + elseif ($command.action -in @('pages', 'move', 'delete-page')) { + $namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$tree = $hierarchy + $listed = @($tree.SelectNodes('//*[local-name()="Page"]')) + $named = { param($title) @($listed | Where-Object { $_.GetAttribute('name') -eq $title }) } + if ($command.action -eq 'pages') { + # New titled pages with one body paragraph each, appended in order. + foreach ($title in $command.titles) { + $page = '' + $app.CreateNewPage($section, [ref]$page, 2) + $body = [Security.SecurityElement]::Escape('Body ' + $title + '.') + $name = [Security.SecurityElement]::Escape($title) + $xml = '' + $name + '' + $body + '' + $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false) + } + } elseif ($command.action -eq 'move') { + # Moves the page titled `page` before the one titled `before`, or last. + $moved = @(& $named $command.page) + if ($moved.Count -ne 1) { throw 'Expected one page to move.' } + $order = [Collections.ArrayList]@($listed | Where-Object { $_ -ne $moved[0] }) + $at = $order.Count + if ($command.before) { + $anchor = @(& $named $command.before) + if ($anchor.Count -ne 1) { throw 'Expected one anchor page.' } + $at = $order.IndexOf($anchor[0]) + } + $order.Insert($at, $moved[0]) + $xml = '' + foreach ($node in $order) { + $xml += '' + } + $app.UpdateHierarchy($xml + '', 1) + } else { + $doomed = @(& $named $command.page) + if ($doomed.Count -ne 1) { throw 'Expected one page to delete.' } + $app.DeleteHierarchy($doomed[0].GetAttribute('ID'), [DateTime]::MinValue, $false) + } + } elseif ($command.action -eq 'kill-process') { Get-Process ONENOTE | Stop-Process -Force [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) diff --git a/tools/native_collaboration.py b/tools/native_collaboration.py index e716c23436a708b6dd718b9f80da5a51941fca2d..cadcd2ed5eaaa6501a8962801b5e1ea5036a3079 100644 --- a/tools/native_collaboration.py +++ b/tools/native_collaboration.py @@ -18,7 +18,7 @@ import xml.etree.ElementTree as ET from native_runner import ROOT, clone, command, windows, vm, collect_artifacts from native_xml import texts -from document_model import ordered_pages, view, walk +from document_model import EXPORTER, ordered_pages, view, walk import linux_vm @@ -260,7 +260,7 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1, target = output / label / 'notebook' target.mkdir(parents=True) for name, data in snapshot.items(): (target / name).write_bytes(data) - result = subprocess.run([ROOT / 'target/debug/examples/document', target / 'synthetic.one', target.parent / 'model'], capture_output=True, text=True) + result = subprocess.run([EXPORTER, target / 'synthetic.one', target.parent / 'model'], capture_output=True, text=True) if result.returncode == 0: (target.parent / 'locks.txt').write_text(ssh('sudo smbstatus --locks')) print('Captured collaboration state:', label, flush=True) @@ -318,7 +318,7 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1, recovered.mkdir() with (recovered / 'synthetic.one').open('wb') as stream: subprocess.run(linux_vm.ssh_argv(server, 'cat /srv/agent/m6-collaboration/synthetic.one'), stdout=stream, check=True, timeout=60) - subprocess.run([ROOT / 'target/debug/examples/document', recovered / 'synthetic.one', recovered / 'model'], check=True) + subprocess.run([EXPORTER, recovered / 'synthetic.one', recovered / 'model'], check=True) model = json.loads((recovered / 'model/document.json').read_text()) text = active_text(model) retained = verify_text('Concurrent edits:', text, logs) @@ -459,7 +459,7 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1, if temporary.exists(): shutil.rmtree(temporary) snapshot = output / 'conflict.one' snapshot.write_bytes(snapshot_file('synthetic.one')) - subprocess.run([ROOT / 'target/debug/examples/document', snapshot, temporary], check=True) + subprocess.run([EXPORTER, snapshot, temporary], check=True) model = json.loads((temporary / 'document.json').read_text()) values = [node['kind'].get('text') for space in model['spaces'].values() for revision in space['revisions'].values() for node in revision['nodes'].values()] if 'Client A concurrent edit.' in values and 'Client B concurrent edit.' in values: break diff --git a/tools/native_conflict.py b/tools/native_conflict.py new file mode 100644 index 0000000000000000000000000000000000000000..ccb4026acf69f3df84fdfbb01e9b6e39461762d3 --- /dev/null +++ b/tools/native_conflict.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""OneNote 2010 conflict pages: how they arise, what they store and how OneNote shows them. + +`sync LINUX OUTPUT` runs two disposable OneNote clients on one Samba notebook: client A edits +two paragraphs offline while client B edits one of them and a table cell online; A then +reconnects, which makes OneNote write a conflict page. The server file is kept before and +after, A and B are screenshotted with the information bar, the page list shown, the conflict +page open and its menu open, and A then deletes the conflict page through that menu. + +`pages LINUX OUTPUT` runs the same two clients on page-list changes: client A, offline, moves +a page that client B also moves, moves another page, and edits a page B deletes; A then +reconnects. The server's page order and pages, with the recycle bin, are kept at each step. + +`restore LINUX OUTPUT` runs them on a page in the middle of the list: client A, offline, edits +it and moves a page B leaves alone while B deletes it; A then reconnects. + +`cold NOTEBOOK OUTPUT [--delete]` cold-opens a notebook holding a conflict page in a fresh +clone and screenshots the same views; `--delete` deletes the conflict page through the menu +and captures the notebook OneNote leaves. +""" +import argparse +from contextlib import ExitStack +from concurrent.futures import ThreadPoolExecutor +import base64 +import json +from pathlib import Path +import shutil +import subprocess +import sys +import time +import xml.etree.ElementTree as ET + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent / 'w7')) +import native_runner as runner +from native_runner import ROOT, clone, command, windows, vm +import linux_vm + +FIXTURE = ROOT / 'corpus/native-ink/cold-ui-ink/notebook' +SHARE = 'm6-conflict' +ORIGINAL = 'Fictitious: café, 東京, مرحبا' +OTHER_OUTLINE = 'Fictitious positioned outline.' +CELL = 'Left cell' + +# Coordinates on OneNote 2010 maximized at 800x600, from the stage-5, m8 and sync-01 captures: +# the information bar's text, and the first conflict page listed under its page. +BAR = (332, 100) +FIRST_CONFLICT = (725, 142) +# Delete Conflict Page, first in the menu the bar opens on a conflict page. +DELETE = (420, 113) + + +def shot(name, output, label, script): + """Runs an AutoHotkey step on the clone, keeping its screenshot as `label.png`.""" + (output / f'{label}.ahk').write_text(script) + result = windows.do_exec(script, target=name, timeout_ms=120000, shot_delay_ms=1500) + screenshot = result.pop('png_b64', None) + if screenshot: + (output / f'{label}.png').write_bytes(base64.b64decode(screenshot)) + (output / f'{label}.json').write_text(json.dumps(result, indent=2)) + if result.get('error') or result.get('exit') != 0: + raise RuntimeError(f'{label}: {result}') + return result + + +def click(x, y): + return ('WinActivate("ahk_exe ONENOTE.EXE")\nCoordMode("Mouse", "Screen")\n' + f'Click({x}, {y})\nSleep(1200)\n') + + +# Reports any dialog OneNote raised, so a confirmation shows in the step's record. +DIALOG = ('if WinExist("ahk_class #32770 ahk_exe ONENOTE.EXE") {\n' + ' FileAppend("dialog: " WinGetTitle() " | " WinGetText() "`n", "*", "UTF-8")\n' + '}\n') + + +def conflict_views(name, output, page, prefix, delete=False): + """Screenshots a page with a conflict page: the bar, the list shown, the conflict page and + its menu; `delete` then chooses Delete Conflict Page.""" + shot(name, output, f'{prefix}-main', runner.navigation_script(page) + 'Sleep(2000)\n') + shot(name, output, f'{prefix}-shown', click(*BAR)) + shot(name, output, f'{prefix}-conflict', click(*FIRST_CONFLICT)) + shot(name, output, f'{prefix}-menu', click(*BAR)) + if delete: + shot(name, output, f'{prefix}-delete', click(*DELETE) + DIALOG) + else: + shot(name, output, f'{prefix}-closed', 'WinActivate("ahk_exe ONENOTE.EXE")\nSend("{Esc}")\nSleep(800)\n') + + +def cold(notebook, output, delete): + def interaction(name, output): + page = ET.parse(sorted((output / 'read').glob('page-*.xml'))[0]).getroot().attrib['ID'] + conflict_views(name, output, page, 'cold', delete) + if delete: + # OneNote writes the deletion in the background; give it time before the re-read. + time.sleep(20) + runner.capture(notebook, output, collect_notebook=True, interaction=interaction) + + +class Lab: + """Two OneNote clients on one Samba notebook: `action` runs a controller command, + `state` copies the share and exports its section, `settle` syncs until a check holds.""" + + +def page_list(model): + """The section's pages in order, each as its sorted texts.""" + from document_model import ordered_pages, walk + return [sorted(node['kind']['text'] for _, node in walk(page, oid) if node['kind']['type'] == 'RichText') + for _, _, page, oid in ordered_pages(model)] + + +def edits(lab): + from native_collaboration import reachable_page_text + a, b, output, action, settle = lab.a, lab.b, lab.output, lab.action, lab.settle + state = lambda label: reachable_page_text(lab.state(label)) + texts = lambda state: {text for values in state.values() for text in values} + state('initial') + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': False}) + try: + action(a, 'edit', expected=ORIGINAL, text='Client A conflicting edit.') + action(a, 'edit', expected=OTHER_OUTLINE, text='Client A disjoint edit.') + action(a, 'snapshot') + action(b, 'edit', expected=ORIGINAL, text='Client B conflicting edit.') + action(b, 'edit', expected=CELL, text='Client B disjoint cell.') + settle(b, lambda label: {'Client B conflicting edit.', 'Client B disjoint cell.'} <= texts(state(label)), 'b-published') + finally: + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': True}) + settle(a, lambda label: len(found := state(label)) > 1 and 'Client A disjoint edit.' in texts(found), 'conflict') + (output / 'conflict.json').write_text(json.dumps(state('conflict'), indent=2, ensure_ascii=False)) + page = action(a, 'snapshot')[0].attrib['ID'] + conflict_views(a['name'], output / 'a', page, 'a') + action(b, 'sync') + time.sleep(5) + page_b = action(b, 'snapshot')[0].attrib['ID'] + conflict_views(b['name'], output / 'b', page_b, 'b') + conflict_views(a['name'], output / 'a', page, 'a-again', delete=True) + settle(a, lambda label: len(state(label)) == 1, 'deleted') + action(b, 'sync') + time.sleep(5) + shot(b['name'], output / 'b', 'b-after-delete', runner.navigation_script(page_b) + 'Sleep(2000)\n') + + +PAGES = ['One', 'Two', 'Three', 'Four', 'Target'] + + +def titled(model, titles): + """The section's pages in order, each named by the title among `titles` it holds.""" + return [next((text for text in texts if text in titles), '') for texts in page_list(model)] + + +def offline(lab, titles, a_actions, b_actions): + """Creates the titled pages; A runs its actions offline while B runs and publishes its + own; A then reconnects. Keeps the merged order and pages, and what each client lists.""" + a, b, output, action = lab.a, lab.b, lab.output, lab.action + action(a, 'pages', titles=titles) + lab.settle(a, lambda label: 'Body Target.' in str(page_list(lab.state(label))), 'initial') + deadline = time.monotonic() + 300 + while not any(node.get('name') == 'Target' for node in action(b, 'snapshot')): + action(b, 'sync') + if time.monotonic() > deadline: raise TimeoutError('B never listed the new pages') + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': False}) + try: + for kind, parameters in a_actions: + action(a, kind, **parameters) + action(a, 'snapshot') + for kind, parameters in b_actions: + action(b, kind, **parameters) + action(b, 'snapshot') + lab.settle(b, lambda label: 'Body Target.' not in str(page_list(lab.state(label))), 'b-published') + finally: + vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': True}) + # A's changes reach the server when its sync writes; wait until the file stops changing. + previous, steady = None, 0 + deadline = time.monotonic() + 300 + while steady < 3 and time.monotonic() < deadline: + action(a, 'sync') + time.sleep(10) + current = page_list(lab.state('merged')) + steady = steady + 1 if current == previous else 0 + previous = current + (output / 'merged.json').write_text(json.dumps({'order': titled(lab.state('merged'), titles), 'pages': previous}, indent=2, ensure_ascii=False)) + for client, label in ((a, 'a'), (b, 'b')): + action(client, 'sync') + time.sleep(5) + listed = action(client, 'snapshot') + (output / label / 'listed.json').write_text(json.dumps([node.get('name') for node in listed], ensure_ascii=False)) + if listed: + shot(client['name'], output / label, f'{label}-merged', runner.navigation_script(listed[0].get('ID')) + 'Sleep(2000)\n') + + +def pages(lab): + """Competing page moves, and an offline edit of a page the other client deletes.""" + offline(lab, PAGES, [ + ('move', {'page': 'Four', 'before': 'One'}), + ('move', {'page': 'Two', 'before': None}), + ('edit', {'expected': 'Body Target.', 'text': 'Body Target edited offline.'}), + ], [ + ('move', {'page': 'Four', 'before': None}), + ('move', {'page': 'Three', 'before': 'One'}), + ('delete-page', {'page': 'Target'}), + ]) + + +def restore(lab): + """An offline edit of a page in the middle of the list the other client deletes, with an + offline move of a page the other client leaves alone.""" + offline(lab, ['One', 'Two', 'Target', 'Three', 'Four'], [ + ('edit', {'expected': 'Body Target.', 'text': 'Body Target edited offline.'}), + ('move', {'page': 'Four', 'before': 'One'}), + ], [ + ('delete-page', {'page': 'Target'}), + ]) + + +def sync(server, output, scenario): + from document_model import EXPORTER + + if linux_vm.instance_path(server).exists(): + raise ValueError('Choose a new Linux VM name; existing machines are not owned by this run.') + output = output.resolve() + output.mkdir(parents=True, exist_ok=False) + scripts = output / 'scripts' + scripts.mkdir() + for name in ('cold.ps1', 'collaborate.ps1', 'network.ps1', 'text.ps1', 'stress.ps1'): + shutil.copyfile(ROOT / 'tools/native' / name, scripts / name) + shutil.copyfile(Path(__file__), output / 'native_conflict.py') + + def ssh(text, timeout=90): + result = linux_vm.run_ssh(server, text, timeout=timeout) + with (output / 'server.jsonl').open('a') as log: + log.write(json.dumps({'command': text, 'exit': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr}) + '\n') + result.check_returncode() + return result.stdout + + def server_state(label): + """Copies the server's notebook, recycle bin included, until its section parses, + keeping it as `label/notebook`; returns the section's exported model.""" + deadline = time.monotonic() + 120 + while True: + target = output / label + shutil.rmtree(target, ignore_errors=True) + (target / 'notebook').mkdir(parents=True) + archive = subprocess.run(linux_vm.ssh_argv(server, f"tar cf - -C '/srv/agent/{SHARE}' ."), capture_output=True, check=True, timeout=60).stdout + subprocess.run(['tar', 'xf', '-', '-C', target / 'notebook'], input=archive, check=True) + result = subprocess.run([EXPORTER, target / 'notebook/synthetic.one', target / 'model'], capture_output=True, text=True) + if result.returncode == 0: + return json.loads((target / 'model/document.json').read_text()) + if time.monotonic() > deadline: + raise RuntimeError(f'{label}: {result.stderr}') + time.sleep(1) + + linux_vm.create_instance(server) + try: + linux_vm.launch(server) + linux_vm.wait_instance(server, 600) + (output / 'linux.json').write_text(json.dumps(linux_vm.load_instance(server), indent=2)) + ssh(f'mkdir /srv/agent/{SHARE}') + for name in ('synthetic.one', 'Open Notebook.onetoc2'): + with (FIXTURE / name).open('rb') as stream: + subprocess.run(linux_vm.ssh_argv(server, f"cat > '/srv/agent/{SHARE}/{name}'"), stdin=stream, check=True) + ssh(f'chmod u+w /srv/agent/{SHARE}/*') + with ExitStack() as stack: + labels = ['a', 'b'] + for label in labels: + (output / label).mkdir() + + def start(label): + manager = clone(output / label) + name = manager.__enter__() + return label, name, manager + with ThreadPoolExecutor(max_workers=2) as pool: + started = list(pool.map(start, labels)) + for _, _, manager in started: + stack.push(manager) + clients = {} + for label, name, _ in started: + folder = output / label + for local in scripts.iterdir(): + remote = 'cold-current.ps1' if local.name == 'cold.ps1' else local.name + result = windows.do_put(local, 'C:\\one-tests\\' + remote, name) + if result.get('error'): raise RuntimeError(result['error']) + command(name, 'mkdir C:\\one-tests\\runs\\capture', folder) + command(name, 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac ' + vm.lab_mac(name), folder) + result = windows.do_spawn('cmd /c powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\collaborate.ps1 -Root C:\\one-tests\\runs\\capture -SharedPath \\\\192.168.77.1\\agent\\' + SHARE + ' -CloneHost ONE-' + name.upper() + ' > C:\\one-tests\\runs\\capture\\controller.log 2>&1', name) + if result.get('error'): raise RuntimeError(result['error']) + deadline = time.monotonic() + 180 + while windows.do_cmd('if exist C:\\one-tests\\runs\\capture\\ready (echo ready)', target=name).get('stdout', '').find('ready') < 0: + if time.monotonic() > deadline: raise RuntimeError('The collaboration controller did not open the notebook') + time.sleep(1) + clients[label] = {'name': name, 'folder': folder, 'sequence': 0} + (output / 'clients.json').write_text(json.dumps({label: client['name'] for label, client in clients.items()}, indent=2)) + + def action(client, kind, **parameters): + client['sequence'] += 1 + sequence = client['sequence'] + local = client['folder'] / f'command-{sequence:04}.json' + local.write_text(json.dumps({'action': kind, **parameters}, ensure_ascii=False)) + inbox = f'C:\\one-tests\\runs\\capture\\inbox\\{sequence:04}' + result = windows.do_put(local, inbox + '.tmp', client['name']) + if result.get('error'): raise RuntimeError(result['error']) + command(client['name'], f'move {inbox}.tmp {inbox}.json', client['folder']) + remote = f'C:\\one-tests\\runs\\capture\\outbox\\{sequence}' + deadline = time.monotonic() + 600 + while True: + result = windows.do_cmd(f'if exist {remote}\\error (type {remote}\\error & exit /b 1) else (if exist {remote}\\done (echo complete))', target=client['name']) + if result.get('exit') != 0: raise RuntimeError(str(result)) + if 'complete' in result.get('stdout', ''): break + if time.monotonic() > deadline: raise TimeoutError(f'{kind} did not complete') + time.sleep(.5) + if kind != 'snapshot': + return None + hierarchy = client['folder'] / f'hierarchy-{sequence:04}.xml' + windows.do_get(remote + '\\hierarchy.xml', hierarchy, client['name']) + pages = [node for node in ET.fromstring(hierarchy.read_text(encoding='utf-8-sig').strip()).iter() if node.tag.endswith('}Page')] + for i in range(len(pages)): + windows.do_get(remote + f'\\page-{i}.xml', client['folder'] / f'snapshot-{sequence:04}-{i}.xml', client['name']) + return pages + + def settle(client, check, label): + deadline = time.monotonic() + 300 + while True: + action(client, 'sync') + if check(label): return + if time.monotonic() > deadline: raise TimeoutError(label) + time.sleep(2) + + lab = Lab() + lab.a, lab.b, lab.output = clients['a'], clients['b'], output + lab.action, lab.state, lab.settle = action, server_state, settle + scenario(lab) + + for client in clients.values(): + try: + action(client, 'close') + except Exception as error: + (client['folder'] / 'close-failure.txt').write_text(str(error)) + server_state('final') + finally: + if linux_vm.running(server): + linux_vm.shutdown(server, 120) + linux_vm.delete_instance(server) + (output / 'linux-teardown.json').write_text(json.dumps({'absent': not linux_vm.instance_path(server).exists()}) + '\n') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + commands = parser.add_subparsers(dest='mode', required=True) + scenarios = {'sync': edits, 'pages': pages, 'restore': restore} + for mode in scenarios: + lab = commands.add_parser(mode) + lab.add_argument('linux') + lab.add_argument('output', type=Path) + reopen = commands.add_parser('cold') + reopen.add_argument('notebook', type=Path) + reopen.add_argument('output', type=Path) + reopen.add_argument('--delete', action='store_true') + args = parser.parse_args() + if args.mode in scenarios: + sync(args.linux, args.output, scenarios[args.mode]) + else: + cold(args.notebook, args.output, args.delete) + print('Captured', args.output) diff --git a/tools/smb-proxy.py b/tools/smb-proxy.py index 1e9024d778b065f85b293946b3d4c9972ffe476d..310d0af8b49a996ed0d04439d9d32ee1562c7ce2 100644 --- a/tools/smb-proxy.py +++ b/tools/smb-proxy.py @@ -8,6 +8,18 @@ import struct import time +def lease_state(frame, at, contexts): + """The lease state of a CREATE's `RqLs` context, if any (MS-SMB2 2.2.13.2.8, 2.2.14.2.10).""" + offset, length = struct.unpack_from('