From ed9e69d6d927206decb398664df5f135e7244603 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 26 Sep 2026 12:32:57 -0700 Subject: [PATCH] feat: the editor records ops and the app saves them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every editor change records the ops it made at edit time, and undo records the ops its inverse produces, restoring original identities. The app hands one Edit per frame to the notebook session, so the per-edit whole-page read and compare is gone; a keystroke on a 3000-paragraph page records in ~18 µs. Across ~56,000 differential steps the ops store the same page as the old whole-page path. Our own publishes never refresh the open page; a real remote change refreshes it in place, keeping caret, selection, scroll, drafts and the undo history it did not reach. A rejected edit restores the saved page and puts the text on the clipboard. Page dates save as OneNote 2010 stores them (new corpus/page-date row), page reads never wait behind a rebase, and the iOS host drains its edits. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5 --- Cargo.lock | 2 + apps/ios/Snowbound/CanvasView.swift | 9 + corpus/page-date/README.md | 19 + .../page-date/candidate/Open Notebook.onetoc2 | Bin 0 -> 3764 bytes corpus/page-date/candidate/pictures.one | Bin 0 -> 24184 bytes corpus/page-date/cold/commands.jsonl | 3 + corpus/page-date/cold/machine.json | 1 + .../cold/notebook/Open Notebook.onetoc2 | Bin 0 -> 3764 bytes corpus/page-date/cold/notebook/pictures.one | Bin 0 -> 24184 bytes corpus/page-date/cold/read/environment.json | 7 + corpus/page-date/cold/read/hierarchy.xml | 2 + .../cold/read/page-000.navigation.json | 13 + corpus/page-date/cold/read/page-000.png | Bin 0 -> 38370 bytes corpus/page-date/cold/read/page-000.xml | 2 + .../cold/read/page-001.navigation.json | 13 + corpus/page-date/cold/read/page-001.png | Bin 0 -> 38046 bytes corpus/page-date/cold/read/page-001.xml | 2 + .../cold/read/page-002.navigation.json | 13 + corpus/page-date/cold/read/page-002.png | Bin 0 -> 38352 bytes corpus/page-date/cold/read/page-002.xml | 6 + corpus/page-date/cold/read/payloads.json | 3 + corpus/page-date/cold/run.json | 18 + corpus/page-date/cold/scripts/cold.ps1 | 27 + corpus/page-date/cold/scripts/read.ps1 | 142 ++++ corpus/page-date/cold/source.json | 14 + corpus/page-date/cold/teardown.json | 1 + corpus/page-date/native/after.xml | 2 + corpus/page-date/native/before.xml | 2 + corpus/page-date/native/commands.jsonl | 4 + corpus/page-date/native/kept.xml | 2 + corpus/page-date/native/machine.json | 1 + .../native/notebook/Open Notebook.onetoc2 | Bin 0 -> 3764 bytes corpus/page-date/native/notebook/pictures.one | Bin 0 -> 23208 bytes corpus/page-date/native/read/environment.json | 7 + corpus/page-date/native/read/hierarchy.xml | 2 + corpus/page-date/native/read/page-000.xml | 2 + corpus/page-date/native/read/page-001.xml | 2 + corpus/page-date/native/read/page-002.xml | 6 + corpus/page-date/native/read/payloads.json | 3 + corpus/page-date/native/run.json | 19 + corpus/page-date/native/scripts/author.ps1 | 46 ++ corpus/page-date/native/scripts/cold.ps1 | 27 + corpus/page-date/native/scripts/read.ps1 | 142 ++++ corpus/page-date/native/source.json | 14 + corpus/page-date/native/teardown.json | 1 + corpus/page-date/native/update.xml | 1 + crates/canvas/src/editor.rs | 394 +++++++++- crates/canvas/src/editor/ops.rs | 743 ++++++++++++++++++ crates/canvas/src/gpu/page.rs | 27 + crates/canvas/src/gpu/page/background.rs | 5 + crates/canvas/src/gpu/page/picture.rs | 5 + crates/canvas/src/interaction/mod.rs | 15 + crates/canvas/tests/ops_differential.rs | 670 ++++++++++++++++ crates/canvas/tests/structural_roundtrip.rs | 47 +- crates/mobile/Cargo.toml | 1 + crates/mobile/include/snowbound.h | 1 + crates/mobile/src/lib.rs | 60 +- crates/notebook/README.md | 4 + crates/notebook/examples/open_probe.rs | 236 ++++++ crates/notebook/src/lib.rs | 22 +- crates/notebook/src/merge.rs | 5 + crates/notebook/src/working.rs | 581 +++++++++----- crates/notebook/tests/session.rs | 7 +- crates/onestore/src/edit.rs | 71 +- crates/onestore/src/op/apply.rs | 22 + crates/onestore/src/op/lower.rs | 28 +- crates/onestore/src/op/mod.rs | 9 + crates/onestore/src/op/model.rs | 36 +- crates/onestore/src/op/tests.rs | 98 +++ crates/onestore/src/page/write.rs | 3 + crates/snowbound/src/macos.rs | 14 + crates/snowbound/src/main.rs | 452 ++++++++--- tools/native/page-date.ps1 | 46 ++ tools/test_page_date.py | 39 + 74 files changed, 3858 insertions(+), 363 deletions(-) create mode 100644 corpus/page-date/README.md create mode 100644 corpus/page-date/candidate/Open Notebook.onetoc2 create mode 100644 corpus/page-date/candidate/pictures.one create mode 100644 corpus/page-date/cold/commands.jsonl create mode 100644 corpus/page-date/cold/machine.json create mode 100644 corpus/page-date/cold/notebook/Open Notebook.onetoc2 create mode 100644 corpus/page-date/cold/notebook/pictures.one create mode 100644 corpus/page-date/cold/read/environment.json create mode 100644 corpus/page-date/cold/read/hierarchy.xml create mode 100644 corpus/page-date/cold/read/page-000.navigation.json create mode 100644 corpus/page-date/cold/read/page-000.png create mode 100644 corpus/page-date/cold/read/page-000.xml create mode 100644 corpus/page-date/cold/read/page-001.navigation.json create mode 100644 corpus/page-date/cold/read/page-001.png create mode 100644 corpus/page-date/cold/read/page-001.xml create mode 100644 corpus/page-date/cold/read/page-002.navigation.json create mode 100644 corpus/page-date/cold/read/page-002.png create mode 100644 corpus/page-date/cold/read/page-002.xml create mode 100644 corpus/page-date/cold/read/payloads.json create mode 100644 corpus/page-date/cold/run.json create mode 100644 corpus/page-date/cold/scripts/cold.ps1 create mode 100644 corpus/page-date/cold/scripts/read.ps1 create mode 100644 corpus/page-date/cold/source.json create mode 100644 corpus/page-date/cold/teardown.json create mode 100644 corpus/page-date/native/after.xml create mode 100644 corpus/page-date/native/before.xml create mode 100644 corpus/page-date/native/commands.jsonl create mode 100644 corpus/page-date/native/kept.xml create mode 100644 corpus/page-date/native/machine.json create mode 100644 corpus/page-date/native/notebook/Open Notebook.onetoc2 create mode 100644 corpus/page-date/native/notebook/pictures.one create mode 100644 corpus/page-date/native/read/environment.json create mode 100644 corpus/page-date/native/read/hierarchy.xml create mode 100644 corpus/page-date/native/read/page-000.xml create mode 100644 corpus/page-date/native/read/page-001.xml create mode 100644 corpus/page-date/native/read/page-002.xml create mode 100644 corpus/page-date/native/read/payloads.json create mode 100644 corpus/page-date/native/run.json create mode 100644 corpus/page-date/native/scripts/author.ps1 create mode 100644 corpus/page-date/native/scripts/cold.ps1 create mode 100644 corpus/page-date/native/scripts/read.ps1 create mode 100644 corpus/page-date/native/source.json create mode 100644 corpus/page-date/native/teardown.json create mode 100644 corpus/page-date/native/update.xml create mode 100644 crates/canvas/src/editor/ops.rs create mode 100644 crates/canvas/tests/ops_differential.rs create mode 100644 crates/notebook/examples/open_probe.rs create mode 100644 tools/native/page-date.ps1 create mode 100644 tools/test_page_date.py diff --git a/Cargo.lock b/Cargo.lock index 1c8b8268362b464f9eaa30a2ca0c8c66c5c8f1c4..6e5c6d204ad123c550aef66d2de29e52bfbdb520 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1560,6 +1560,7 @@ dependencies = [ "onestore", "parley", "pollster", + "serde_json", "wgpu", ] @@ -1657,6 +1658,7 @@ dependencies = [ name = "notebook" version = "0.1.0" dependencies = [ + "libc", "onestore", "rusqlite", "serde", diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift index ef59eaa3fedca7b425b9d89f0b582df180894a77..75eb32851ea184dd48d19775463e56ca2c51d890 100644 --- a/apps/ios/Snowbound/CanvasView.swift +++ b/apps/ios/Snowbound/CanvasView.swift @@ -241,6 +241,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI inputDelegate?.textWillChange(self) } let changed = change() + dropEdits() if external { inputDelegate?.textDidChange(self) inputDelegate?.selectionDidChange(self) @@ -251,6 +252,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } } + /// Takes the edit the page recorded, so its ops do not pile up; the host drops it + /// until it holds a notebook session that stores edits. + private func dropEdits() { + guard let handle, let edit = sb_view_edit(handle) else { return } + sb_string_free(edit) + } + private func revealCaret() { guard let handle, isFirstResponder else { return } var range: [UInt32] = [0, 0] @@ -354,6 +362,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI func unmarkText() { guard let handle else { return } sb_unmark(handle) + dropEdits() dirty = true } diff --git a/corpus/page-date/README.md b/corpus/page-date/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f571772840d7d92fbbd4f562ef4b17301f37ccb1 --- /dev/null +++ b/corpus/page-date/README.md @@ -0,0 +1,19 @@ +# Page date + +`native` is OneNote 2010 changing a page's date (`tools/native/page-date.ps1` on +`picture-edit/native-page-level/notebook`): it creates two pages, "Kept date" and +"Changed date", each with OneNote's own title, date and time, then gives "Changed date" +the `dateTime` 2024-03-05T14:30:00Z through the COM API (`before.xml`, `update.xml`, +`after.xml`; `kept.xml` is the untouched page). In `notebook/`, the revision that change +stored sets the page metadata's `TopologyCreationTimeStamp` (`0x18001c65`), rewrites the +date and time fields' text (`TextExtendedAscii`, `0x1c003498`, in the clone's Pacific +time: "Tuesday, March 05, 2024", "7:30 AM"), and gives the two fields' elements the +change time as `CreationTimeStamp` (`0x14001d09`) and `LastModifiedTime`, with the +date outline's `LastModifiedTime`. The title, the page and the section's page metadata +keep theirs. + +`candidate` is `a_page_date_stores_what_onenote_stores` in `crates/onestore/src/op/tests.rs` +(`ONESTORE_PAGE_DATE_EXPORT`): the `Date` op dates "Kept date" 2025-07-04T16:45:00Z, +showing "Friday, July 04, 2025" and "9:45 AM", and changes the same objects OneNote did. +`cold` is its fresh OneNote 2010 read: the page reads back with that `dateTime`, and +`read/page-001.png` shows the new date and time under the title. diff --git a/corpus/page-date/candidate/Open Notebook.onetoc2 b/corpus/page-date/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..a7bcb6b4cf63c11f7984c722d32485a2ba522578 GIT binary patch literal 3764 zcmZ3e|KIuM`!b(-j|5(YE0x8s-QSbsU--|r_$D{+NrZTWxHDZQJEjqSo$}uH%j}=mVDuJ`A7b0|EXKm43$PdHV zRg8*`hQN>yfd&?aDY6VQJ7pL|vVrtIAT0p&)aI^BBDuFkDjQ_g4ptCrOeU}(^*VCZ0A0qJ632moS;7eG9a z4v=gBR8|5c3l;~-fN-P%(78~y4A3lwEzmLxWT5vhaXe zOu&I*LI%_-u<1ZCNJ$4N>(I>;1{nqf920>0pyeaT1K7-F!C@yh^Fi?h@)XFupfVg} z4~Pc2aoM)|DO*62AOLhLP#hcppg>~+$2%wufmCmqcMq(Hp#ggu`#JI2uIn80eY+a3 z)`jQtT?Uy>IE}%=668jZ4(S&S4Yt9$&u3V&drd8!RlF>+Bn>2vEe#nkPmTEJbe9n% z&%mH4(f~>?4Gkdfn}XKOU&@^wJ6`Unn8&{SwW(bnQlW9T0+Ycal-GGLk6kzmoAtfM3i-sN-pco4m&?MHD6oww;00-H?P@~E4 wO9p5xm@`U@hQMeDKtljItH8=WP`(!hVo-+DockdIs&teZ4S~@R7y%&w0N&m@_y7O^ literal 0 HcmV?d00001 diff --git a/corpus/page-date/candidate/pictures.one b/corpus/page-date/candidate/pictures.one new file mode 100644 index 0000000000000000000000000000000000000000..ffd10d36c6059003feeb168663d492f3cf08bcf4 GIT binary patch literal 24184 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 literal 0 HcmV?d00001 diff --git a/corpus/page-date/cold/notebook/pictures.one b/corpus/page-date/cold/notebook/pictures.one new file mode 100644 index 0000000000000000000000000000000000000000..ffd10d36c6059003feeb168663d492f3cf08bcf4 GIT binary patch literal 24184 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 + diff --git a/corpus/page-date/cold/read/page-000.navigation.json b/corpus/page-date/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..b9d4d9124849ecffacac7e0b640250c408a9afcd --- /dev/null +++ b/corpus/page-date/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Changed date - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-date/cold/read/page-000.png b/corpus/page-date/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..3ebdf650758fc364e2b5a5f916019149b77afec1 GIT binary patch literal 38370 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 + diff --git a/corpus/page-date/cold/read/page-001.navigation.json b/corpus/page-date/cold/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..7337ad212809f6ef8ffa0a1c57c120a2574507da --- /dev/null +++ b/corpus/page-date/cold/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Kept date - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-date/cold/read/page-001.png b/corpus/page-date/cold/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..8f1a1560e286fc7970be279a4d06c4f7a7ebe267 GIT binary patch literal 38046 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�JkV={e0@PndKg*tWsd6t}KRC>W?d0XAy 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 oc~9jy2pjWeWQ#a#`_H~R-Y@~ literal 0 HcmV?d00001 diff --git a/corpus/page-date/cold/read/page-001.xml b/corpus/page-date/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..ee7e861468376d19b58f7c4aea5909db67c50ca9 --- /dev/null +++ b/corpus/page-date/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-date/cold/read/page-002.navigation.json b/corpus/page-date/cold/read/page-002.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..b65a0c422e83efcb89dd26be239dd1906202f810 --- /dev/null +++ b/corpus/page-date/cold/read/page-002.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Before the picture - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-date/cold/read/page-002.png b/corpus/page-date/cold/read/page-002.png new file mode 100644 index 0000000000000000000000000000000000000000..148935b7ed8d1c1ee988154c7dfb5f3c730f7d6c GIT binary patch literal 38352 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 +AAAAAElFTkSuQmCC +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w +AAAAAElFTkSuQmCC + diff --git a/corpus/page-date/cold/read/payloads.json b/corpus/page-date/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/page-date/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/page-date/cold/run.json b/corpus/page-date/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..9e148fa431dd55ae9c8edd5133b0e12554ec74a1 --- /dev/null +++ b/corpus/page-date/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/pagedate/candidate/notebook", + "expected_pages": 3, + "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/page-date/cold/scripts/cold.ps1 b/corpus/page-date/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/page-date/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/page-date/cold/scripts/read.ps1 b/corpus/page-date/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/page-date/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/page-date/cold/source.json b/corpus/page-date/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..362e409c5d0b3ad88042c418479bd136cfefaec6 --- /dev/null +++ b/corpus/page-date/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 3764, + "sha256": "8bf472d0a9c5829b049c69b817f40b766025431a61c71689bca583a60d28365d", + "mtime_ns": 1790452811018151759 + }, + { + "path": "pictures.one", + "bytes": 24184, + "sha256": "0fea376be55a62c76dd61e311513b41a4212d671a9316eb8d18897a5baa5a125", + "mtime_ns": 1790453075875161561 + } +] diff --git a/corpus/page-date/cold/teardown.json b/corpus/page-date/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/page-date/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/page-date/native/after.xml b/corpus/page-date/native/after.xml new file mode 100644 index 0000000000000000000000000000000000000000..8268312536edeccf9d167357fb577761f5be4bd0 --- /dev/null +++ b/corpus/page-date/native/after.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-date/native/before.xml b/corpus/page-date/native/before.xml new file mode 100644 index 0000000000000000000000000000000000000000..3bd9a83fe09ff593da13c5f2b154817263b5e593 --- /dev/null +++ b/corpus/page-date/native/before.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-date/native/commands.jsonl b/corpus/page-date/native/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..9be10279c7fff972b889d4664b0e049a1bed9863 --- /dev/null +++ b/corpus/page-date/native/commands.jsonl @@ -0,0 +1,4 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-CE0C154F", "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-CE0C154F -ExpectedPages 3 -UseCurrentCache", "exit": 0, "stdout": "Read 1 sections and 3 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/page-date/native/kept.xml b/corpus/page-date/native/kept.xml new file mode 100644 index 0000000000000000000000000000000000000000..d8f81f6e11887924b4a0506d51269c4ff8539a0e --- /dev/null +++ b/corpus/page-date/native/kept.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-date/native/machine.json b/corpus/page-date/native/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..9a4e440a3afaae97d40590a5d8b7e6690d5e8953 --- /dev/null +++ b/corpus/page-date/native/machine.json @@ -0,0 +1 @@ +{"name": "m6-ce0c154f", "hostname": "ONE-M6-CE0C154F"} diff --git a/corpus/page-date/native/notebook/Open Notebook.onetoc2 b/corpus/page-date/native/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..a7bcb6b4cf63c11f7984c722d32485a2ba522578 GIT binary patch literal 3764 zcmZ3e|KIuM`!b(-j|5(YE0x8s-QSbsU--|r_$D{+NrZTWxHDZQJEjqSo$}uH%j}=mVDuJ`A7b0|EXKm43$PdHV zRg8*`hQN>yfd&?aDY6VQJ7pL|vVrtIAT0p&)aI^BBDuFkDjQ_g4ptCrOeU}(^*VCZ0A0qJ632moS;7eG9a z4v=gBR8|5c3l;~-fN-P%(78~y4A3lwEzmLxWT5vhaXe zOu&I*LI%_-u<1ZCNJ$4N>(I>;1{nqf920>0pyeaT1K7-F!C@yh^Fi?h@)XFupfVg} z4~Pc2aoM)|DO*62AOLhLP#hcppg>~+$2%wufmCmqcMq(Hp#ggu`#JI2uIn80eY+a3 z)`jQtT?Uy>IE}%=668jZ4(S&S4Yt9$&u3V&drd8!RlF>+Bn>2vEe#nkPmTEJbe9n% z&%mH4(f~>?4Gkdfn}XKOU&@^wJ6`Unn8&{SwW(bnQlW9T0+Ycal-GGLk6kzmoAtfM3i-sN-pco4m&?MHD6oww;00-H?P@~E4 wO9p5xm@`U@hQMeDKtljItH8=WP`(!hVo-+DockdIs&teZ4S~@R7y%&w0N&m@_y7O^ literal 0 HcmV?d00001 diff --git a/corpus/page-date/native/notebook/pictures.one b/corpus/page-date/native/notebook/pictures.one new file mode 100644 index 0000000000000000000000000000000000000000..afa9af53962f787548e1b29e3e3f12075df9f5c2 GIT binary patch literal 23208 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 XUo2)f9Ie)wN8VE8`zrmDn@9fzv=$G1 literal 0 HcmV?d00001 diff --git a/corpus/page-date/native/read/environment.json b/corpus/page-date/native/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..3decb628b4205e11247b739b79595a80660a1980 --- /dev/null +++ b/corpus/page-date/native/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-CE0C154F", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/page-date/native/read/hierarchy.xml b/corpus/page-date/native/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..f5aba1f698660d2972e1b09d90c385483ce7c475 --- /dev/null +++ b/corpus/page-date/native/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-date/native/read/page-000.xml b/corpus/page-date/native/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..5556b84c2dfb85dae83861ce2d88dc220a38401c --- /dev/null +++ b/corpus/page-date/native/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-date/native/read/page-001.xml b/corpus/page-date/native/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..f58c14ea32d3a52a94abdea710da3495d416c3bf --- /dev/null +++ b/corpus/page-date/native/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-date/native/read/page-002.xml b/corpus/page-date/native/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..22b9a3272304419443e0ab40794ce4a70f74c776 --- /dev/null +++ b/corpus/page-date/native/read/page-002.xml @@ -0,0 +1,6 @@ + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w +AAAAAElFTkSuQmCC +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w +AAAAAElFTkSuQmCC + diff --git a/corpus/page-date/native/read/payloads.json b/corpus/page-date/native/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/page-date/native/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/page-date/native/run.json b/corpus/page-date/native/run.json new file mode 100644 index 0000000000000000000000000000000000000000..9d8d8c78273d3bff01aabb508a50b161f1f1587b --- /dev/null +++ b/corpus/page-date/native/run.json @@ -0,0 +1,19 @@ +{ + "notebook": "/Users/clo/dev/one/corpus/picture-edit/native-page-level/notebook", + "expected_pages": 3, + "author": "tools/native/page-date.ps1", + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "author.ps1": "f7a55c35305dc63b41efdff5bba9a5c0eef621948727f7630c2ebda3248c3490", + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/page-date/native/scripts/author.ps1 b/corpus/page-date/native/scripts/author.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..4003849226b50011740d4a45c123ff734d64f7ce --- /dev/null +++ b/corpus/page-date/native/scripts/author.ps1 @@ -0,0 +1,46 @@ +param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost +$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +function Save-Page([string]$pageId, [string]$name) { + $content = '' + $app.GetPageContent($pageId, [ref]$content, 0, 1) + [IO.File]::WriteAllText((Join-Path $Root $name), $content, [Text.Encoding]::UTF8) +} +try { + $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0) + $sectionId = '' + $app.OpenHierarchy('pictures.one', $notebookId, [ref]$sectionId, 0) + # Two new pages with OneNote's own title, date and time: one keeps its date, the + # other has its date and time changed after it was saved once. + $ids = @() + foreach ($title in @('Kept date', 'Changed date')) { + $pageId = '' + $app.CreateNewPage($sectionId, [ref]$pageId, 0) + $escaped = [Security.SecurityElement]::Escape($pageId) + $xml = "$titleBody of $title" + $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false) + $ids += $pageId + } + $app.SyncHierarchy($notebookId) + Start-Sleep -Seconds 3 + Save-Page $ids[1] 'before.xml' + $escaped = [Security.SecurityElement]::Escape($ids[1]) + $update = "" + [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $update, [Text.Encoding]::UTF8) + $app.UpdatePageContent($update, [DateTime]::MinValue, 1, $false) + $app.SyncHierarchy($notebookId) + Start-Sleep -Seconds 3 + Save-Page $ids[1] 'after.xml' + Save-Page $ids[0] 'kept.xml' + $app.CloseNotebook($notebookId, $false) + $notebookId = '' +} finally { + if ($notebookId) { $app.CloseNotebook($notebookId, $false) } + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + [GC]::Collect() + [GC]::WaitForPendingFinalizers() +} diff --git a/corpus/page-date/native/scripts/cold.ps1 b/corpus/page-date/native/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/page-date/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/page-date/native/scripts/read.ps1 b/corpus/page-date/native/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/page-date/native/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/page-date/native/source.json b/corpus/page-date/native/source.json new file mode 100644 index 0000000000000000000000000000000000000000..eb9b3087f7a5fd4919a32627e71c4a8b8ee191cd --- /dev/null +++ b/corpus/page-date/native/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 3764, + "sha256": "8bf472d0a9c5829b049c69b817f40b766025431a61c71689bca583a60d28365d", + "mtime_ns": 1789249099590321649 + }, + { + "path": "pictures.one", + "bytes": 17504, + "sha256": "ea585992f7c450e426e0b98d8d0ce972ecdaf7602df962c938166d3d1b54c820", + "mtime_ns": 1789249099590147690 + } +] diff --git a/corpus/page-date/native/teardown.json b/corpus/page-date/native/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/page-date/native/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/page-date/native/update.xml b/corpus/page-date/native/update.xml new file mode 100644 index 0000000000000000000000000000000000000000..6d64b05e7b1c6bf275d56190109235fb439a291c --- /dev/null +++ b/corpus/page-date/native/update.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index dc9e1c2458938d2b05f6bc1b109ab5b7f0d23eee..46fbe3fbfbfca8c9a725832dffe20881fa980c71 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -7,6 +7,7 @@ use crate::{ use draw::edit::{self, Movement, SelectionUnit}; use onestore::ExGuid; use onestore::document::{Format, Kind}; +use onestore::op::PageOp; use onestore::page::text::{EditError, Paragraph}; use onestore::page::{ Definition, Outline, Page, PageObject, PageParagraph, ParagraphContent, Title, @@ -26,6 +27,7 @@ pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0; #[cfg(test)] mod evidence; mod format; +mod ops; pub(crate) mod page; mod table; pub use format::{Alignment, FormatState, Formatting, NoteTag, Toggle}; @@ -92,6 +94,12 @@ pub struct CanvasEditor { preferred_x: Option, /// Formatting chosen at a caret for the text typed there next, until an edit. pending: Option<(ExGuid, TextPosition, onestore::document::Format)>, + /// What `take_ops` hands over next. + ops: Result, onestore::Error>, + /// The page as stored when the editor last read it and the ops `take_ops` handed out + /// since, which `refresh` compares a changed stored page with; none once the editor + /// holds an edit that could not be stored. + stored: Option<(Page, Vec)>, } /// Imported page state the editable content does not carry. @@ -660,10 +668,13 @@ impl CanvasEditor { composition: None, preferred_x: None, pending: None, + ops: Ok(Vec::new()), + stored: None, }) } pub fn from_page(mut page: Page, engine: &mut TextEngine) -> Result { + let stored = page.clone(); let page::Import { objects, mut outlines, @@ -706,9 +717,175 @@ impl CanvasEditor { if !editor.object_layouts().all(|(id, _)| ids.insert(id)) { return Err(EditError::InvalidStructure.into()); } + editor.stored = Some((stored, Vec::new())); Ok(editor) } + /// Shows `page`, the stored page after a change made elsewhere. What the change did not + /// reach stays as it is, history included: the page as stored before it (as last read, + /// with the ops handed out since) is compared with `page`; outlines the change reached + /// show anew with the caret and selection kept by paragraph identity, and history entries + /// editing them are dropped. False when the change reached nothing shown. Marked text + /// must be committed or cancelled first. + pub fn refresh(&mut self, page: Page, engine: &mut TextEngine) -> Result { + let known = self.stored.take().and_then(|(mut stored, sent)| { + sent.iter() + .try_for_each(|op| onestore::op::predict(&mut stored, op)) + .ok() + .map(|()| stored) + }); + // Without that, the editor's page stands in, which storage may normalize apart. + let known = match known { + Some(known) => known, + None => self.page()?, + }; + self.stored = Some((page.clone(), Vec::new())); + if known == page { + return Ok(false); + } + fn outlines(page: &Page) -> BTreeMap { + page.objects + .iter() + .flat_map(|object| match object { + PageObject::Outline(outline) => std::slice::from_ref(outline), + PageObject::Title(title) => title.outlines.as_slice(), + _ => &[], + }) + .map(|outline| (outline.id, outline)) + .collect() + } + let (before, after) = (outlines(&known), outlines(&page)); + let changed: BTreeSet = before + .keys() + .chain(after.keys()) + .filter(|id| before.get(id) != after.get(id)) + .copied() + .collect(); + // Everything but outline content: the page's objects, geometry and date. + let frame = |page: &Page| { + let hollow = |outline: &Outline| Outline { + id: outline.id, + title: false, + min_width: None, + layout: Default::default(), + indents: Vec::new(), + paragraphs: Vec::new(), + unsupported: Vec::new(), + }; + let objects: Vec = page + .objects + .iter() + .map(|object| match object { + PageObject::Outline(outline) => PageObject::Outline(hollow(outline)), + PageObject::Title(title) => PageObject::Title(Title { + id: title.id, + date: title.date, + layout: title.layout.clone(), + outlines: title.outlines.iter().map(hollow).collect(), + }), + object => object.clone(), + }) + .collect(); + (page.created, page.margin_origin, objects) + }; + let objects_changed = frame(&known) != frame(&page); + let date_changed = known.created != page.created + || self + .date + .as_ref() + .is_some_and(|date| changed.contains(&date.source().id)); + let mut fresh = Self::from_page(page, engine)?; + let shown = self.active_outline(); + let (id, selection) = (shown.id, shown.selection); + let reached = changed.contains(&id); + let document = reached.then(|| shown.document.clone()); + let mut own: BTreeMap = std::mem::take(&mut self.outlines) + .into_iter() + .map(|outline| (outline.id, outline)) + .collect(); + for outline in &mut fresh.outlines { + if !changed.contains(&outline.id) + && let Some(kept) = own.remove(&outline.id) + { + *outline = kept; + } + } + let position = fresh.outlines.iter().position(|outline| outline.id == id); + match ( + std::mem::replace(&mut self.active, Focus::Outline(0)), + position, + ) { + (Focus::Caret { outline, index }, _) => { + fresh.active = Focus::Caret { + outline, + index: index.min(fresh.outlines.len()), + }; + } + (Focus::Draft { outline, .. }, Some(index)) if !reached => { + fresh.active = Focus::Draft { index, outline }; + } + (_, Some(index)) if !reached => fresh.active = Focus::Outline(index), + (_, Some(index)) => { + fresh.active = Focus::Outline(index); + let document = document.expect("a reached outline's document was kept"); + let mapped = Selection { + positions: selection.positions.map(|position| { + follow(&document, &fresh.outlines[index].document, position) + }), + affinities: selection.affinities, + }; + let _ = fresh.select(mapped); + self.pending = self.pending.take().filter(|(_, at, _)| { + follow(&document, &fresh.active_outline().document, *at) == *at + }); + } + (_, None) => self.pending = None, + } + if !date_changed { + fresh.date = self.date.take(); + } + if !objects_changed { + fresh.objects = std::mem::take(&mut self.objects); + } + for (id, definition) in std::mem::take(&mut self.definitions) { + fresh.definitions.entry(id).or_insert(definition); + } + // Undoing an outline's creation focuses the outline focused before, if it is there. + let gone = |focus: &RestoreFocus| matches!(focus, RestoreFocus::Outline(id) if !fresh.outlines.iter().any(|o| o.id == *id)); + let reaches = |history: &History| match history { + History::Date(_) => date_changed, + History::Image { .. } | History::Picture { .. } => objects_changed, + History::Draft { outlines, .. } => changed.contains(&outlines[0].id), + History::Text { outline, change } => { + changed.contains(outline) + || change + .positions + .iter() + .any(|placement| objects_changed || changed.contains(&placement.id)) + } + History::Position { outline, .. } | History::Layout { outline, .. } => { + changed.contains(outline) + } + History::Remove { outline, focus } => changed.contains(outline) || gone(focus), + History::Insert { outline, focus, .. } => changed.contains(&outline.id) || gone(focus), + History::Restore { source, focus, .. } => changed.contains(&source.id) || gone(focus), + }; + fresh.undo = std::mem::take(&mut self.undo) + .into_iter() + .filter(|history| !reaches(history)) + .collect(); + fresh.redo = std::mem::take(&mut self.redo) + .into_iter() + .filter(|history| !reaches(history)) + .collect(); + fresh.pending = self.pending.take(); + fresh.preferred_x = self.preferred_x; + fresh.ops = std::mem::replace(&mut self.ops, Ok(Vec::new())); + fresh.stored = self.stored.take(); + *self = fresh; + Ok(true) + } + /// Rebuilds the stored page, restoring the title areas and read-only objects import split up. pub fn page(&self) -> Result { let mut objects: Vec = Vec::new(); @@ -781,18 +958,7 @@ impl CanvasEditor { .iter() .flat_map(|outline| descendants(&outline.paragraphs, None)) { - let content_tags = match &node.content { - ParagraphContent::Text(text) => text.tags.as_slice(), - ParagraphContent::Table(table) => table.tags.as_slice(), - _ => &[], - }; - referenced.extend(node.lists.iter().copied().chain(node.style)); - referenced.extend( - node.tags - .iter() - .chain(content_tags) - .filter_map(|tag| tag.definition), - ); + referenced.extend(ops::references(node)); } } let mut definitions = self.definitions.clone(); @@ -1027,6 +1193,8 @@ impl CanvasEditor { composition: None, preferred_x: None, pending: None, + ops: Ok(Vec::new()), + stored: None, }) } @@ -1055,6 +1223,7 @@ impl CanvasEditor { self.undo .push(History::Date(Box::new(self.date.replace(updated).unwrap()))); self.redo.clear(); + self.record(Ok(self.date_ops())); Ok(true) } @@ -1261,6 +1430,7 @@ impl CanvasEditor { self.redo.clear(); self.active = Focus::Outline(self.outlines.len()); self.outlines.push(outline); + self.record(self.outline_ops(id, None)); self.preferred_x = None; Ok(id) } @@ -1301,13 +1471,18 @@ impl CanvasEditor { .position(|outline| outline.id == id) .unwrap(); let layout = &mut self.outlines[index].layout; + let previous = [layout.x, layout.y]; self.undo.push(History::Position { outline: id, - position: [layout.x, layout.y], + position: previous, }); self.redo.clear(); layout.x = Some(position[0]); layout.y = Some(position[1]); + self.record(self.placement_ops(&Placement { + id, + position: previous, + })); self.active = Focus::Outline(index); self.preferred_x = None; Ok(()) @@ -1429,6 +1604,8 @@ impl CanvasEditor { layout: previous, }); self.redo.clear(); + let image = self.image(id).unwrap(); + self.record(Ok(ops::picture_layout(image))); Ok(()) } @@ -1531,6 +1708,7 @@ impl CanvasEditor { image: Some(Box::new(image)), }); self.redo.clear(); + self.record(Ok(vec![PageOp::Delete { object: id }])); Ok(()) } @@ -1559,6 +1737,11 @@ impl CanvasEditor { let id = resized.id; *self.active_outline_mut() = resized; if self.caret_outline().is_none() { + self.record(ops::layout_ops( + id, + &previous, + &self.active_outline().layout, + )); self.undo.push(History::Layout { outline: id, layout: previous, @@ -2669,7 +2852,11 @@ impl CanvasEditor { return Err((history, EditError::InvalidRange.into())); } let inverse = match history { - History::Date(date) => History::Date(Box::new(self.date.replace(*date).unwrap())), + History::Date(date) => { + let shown = self.date.replace(*date).unwrap(); + self.record(Ok(self.date_ops())); + History::Date(Box::new(shown)) + } History::Draft { outlines, index, @@ -2700,13 +2887,12 @@ impl CanvasEditor { None }; let outline = outlines[usize::from(!restore_caret)].clone(); - if self + let id = outline.id; + let stored = self .outlines .get(index) - .is_some_and(|item| item.id == outline.id) - { - self.outlines.remove(index); - } + .is_some_and(|item| item.id == id) + .then(|| self.outlines.remove(index)); self.active = if outline.is_empty() { Focus::Caret { outline: Box::new(outline), @@ -2722,6 +2908,7 @@ impl CanvasEditor { None => Focus::Outline(index), } }; + self.record(self.outline_ops(id, stored.as_ref())); History::Draft { outlines, index, @@ -2750,6 +2937,7 @@ impl CanvasEditor { return Err((History::Text { outline, change }, error)); } }; + self.record(self.change_ops(&self.outlines[index], &inverse)); History::Text { outline, change: Box::new(inverse), @@ -2762,13 +2950,17 @@ impl CanvasEditor { .position(|item| item.id == outline) .unwrap(); let layout = &mut self.outlines[index].layout; - let inverse = History::Position { - outline, - position: [layout.x, layout.y], - }; + let previous = [layout.x, layout.y]; [layout.x, layout.y] = position; self.active = Focus::Outline(index); - inverse + self.record(self.placement_ops(&Placement { + id: outline, + position: previous, + })); + History::Position { + outline, + position: previous, + } } History::Layout { outline, layout } => { let index = self @@ -2793,25 +2985,46 @@ impl CanvasEditor { }; resized.selection = self.outlines[index].selection; self.outlines[index] = resized; + self.record(ops::layout_ops( + outline, + &previous, + &self.outlines[index].layout, + )); self.active = Focus::Outline(index); History::Layout { outline, layout: previous, } } - History::Image { image, layout } => History::Image { - image, - layout: std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout), - }, + History::Image { image, layout } => { + let previous = + std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout); + self.record(Ok(ops::picture_layout(self.image(image).unwrap()))); + History::Image { + image, + layout: previous, + } + } History::Picture { index, image } => History::Picture { index, image: match image { Some(image) => { self.objects.insert(index, page::Content::Image(*image)); + let page::Content::Image(image) = &self.objects[index] else { + unreachable!() + }; + let ops = vec![PageOp::Add { + object: PageObject::Image(image.clone()), + before: self.successor(image.id), + }]; + self.record(Ok(ops)); None } None => match self.objects.remove(index) { - page::Content::Image(image) => Some(Box::new(image)), + page::Content::Image(image) => { + self.record(Ok(vec![PageOp::Delete { object: image.id }])); + Some(Box::new(image)) + } _ => unreachable!(), }, }, @@ -2847,6 +3060,7 @@ impl CanvasEditor { } }; let outline = Box::new(self.outlines.remove(index)); + self.record(Ok(vec![PageOp::Delete { object: outline.id }])); self.active = next_focus; History::Insert { index, @@ -2892,6 +3106,7 @@ impl CanvasEditor { } => { let id = outline.id; self.outlines.insert(index, *outline); + self.record(self.outline_ops(id, None)); self.active = Focus::Outline(index); History::Remove { outline: id, focus } } @@ -3152,6 +3367,7 @@ impl CanvasEditor { } else { self.outlines[index] = *outline; } + self.record(self.outline_ops(versions[0].id, Some(&versions[0]))); self.undo.push(History::Draft { outlines: versions, index, @@ -3173,8 +3389,9 @@ impl CanvasEditor { }; let mut source = outline.snapshot(); source.paragraphs = change.edit.replacement; + let id = outline.id; self.undo.push(History::Remove { - outline: outline.id, + outline: id, focus: RestoreFocus::Caret { source: Box::new(source), selection: change.selection, @@ -3182,11 +3399,13 @@ impl CanvasEditor { }, }); self.outlines.insert(index, *outline); + self.record(self.outline_ops(id, None)); } else if self.active_outline().is_empty() && !self.active_outline().title { let Focus::Outline(index) = self.active else { unreachable!() }; let outline = self.outlines.remove(index); + self.record(Ok(vec![PageOp::Delete { object: outline.id }])); debug_assert_eq!(change.edit.range, 0..1); let mut source = outline.snapshot(); source.paragraphs = change.edit.replacement; @@ -3205,6 +3424,7 @@ impl CanvasEditor { index, }; } else { + self.record(self.change_ops(self.active_outline(), &change)); self.undo.push(History::Text { outline: self.active_outline().id, change: Box::new(change), @@ -3311,6 +3531,59 @@ impl CanvasEditor { } } +/// Where `position` in `old` lies in `new`, the same outline changed elsewhere: in the same +/// paragraph, past what changed in its text when it lies after it; at the start of the +/// paragraph now at its place when that one is gone. +fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition { + let found = old.leaf(position.paragraph).and_then(|(_, _, node)| { + let paragraph = new + .text_nodes() + .position(|candidate| candidate.id == node.id)?; + Some((paragraph, node, new.leaf(paragraph)?.2)) + }); + let Some((paragraph, before, after)) = found else { + let count = new.text_nodes().count(); + return TextPosition { + paragraph: position.paragraph.min(count.saturating_sub(1)), + offset: 0, + }; + }; + let units = |text: &str| text.encode_utf16().count() as u32; + let (a, b) = ( + before.text().unwrap().text.text(), + after.text().unwrap().text.text(), + ); + let prefix = units( + &a[..a + .char_indices() + .zip(b.chars()) + .find(|((_, x), y)| x != y) + .map_or(a.len().min(b.len()), |((at, _), _)| at)], + ); + let suffix = a + .chars() + .rev() + .zip(b.chars().rev()) + .take_while(|(x, y)| x == y) + .map(|(x, _)| x.len_utf16() as u32) + .scan(0, |sum, units| { + *sum += units; + Some(*sum) + }) + .take_while(|sum| prefix + sum <= units(a).min(units(b))) + .last() + .unwrap_or(0); + let (old_length, new_length) = (units(a), units(b)); + let offset = if position.offset <= prefix { + position.offset + } else if position.offset >= old_length - suffix { + new_length - (old_length - position.offset) + } else { + new_length - suffix + }; + TextPosition { paragraph, offset } +} + fn inserted_position( mut position: TextPosition, text: &str, @@ -8360,4 +8633,65 @@ mod tests { assert_eq!(body_text(&reread, body).as_deref(), Some(edited.as_str())); assert_ne!(body_text(&source, body).as_deref(), Some(edited.as_str())); } + + /// A page changed elsewhere shows in place: the caret keeps its paragraph and moves + /// past text inserted before it; history goes, unless nothing changed. + #[test] + fn a_refresh_keeps_the_caret_by_identity() { + let mut engine = TextEngine::default(); + let lines = + ["Hello world", "Second"].map(|line| Paragraph::new(line.into(), Format::default())); + let document = TextDocument::new(lines.to_vec()).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, document, 400.0).unwrap(); + editor + .select( + [TextPosition { + paragraph: 1, + offset: 3, + }; 2] + .into(), + ) + .unwrap(); + editor.insert(&mut engine, "x").unwrap(); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 8, + }; 2] + .into(), + ) + .unwrap(); + let _ = editor.take_ops(); + let page = editor.page().unwrap(); + assert!(!editor.refresh(page.clone(), &mut engine).unwrap()); + assert!(!editor.undo.is_empty(), "an unchanged page keeps history"); + + let mut remote = page; + let PageObject::Outline(outline) = &mut remote.objects[0] else { + unreachable!() + }; + for (paragraph, text) in outline + .paragraphs + .iter_mut() + .zip(["Hey, Hello world", "Other"]) + { + paragraph.text_mut().unwrap().text = Paragraph::new(text.into(), Format::default()); + } + let (shown, affinities) = (editor.active_outline().id, editor.selection().affinities); + assert!(editor.refresh(remote, &mut engine).unwrap()); + assert_eq!(editor.active_outline().id, shown); + assert_eq!( + editor.selection(), + Selection { + positions: [TextPosition { + paragraph: 0, + offset: 13 + }; 2], + affinities, + } + ); + assert!(editor.undo.is_empty()); + assert_eq!(editor.take_ops().unwrap(), []); + } } diff --git a/crates/canvas/src/editor/ops.rs b/crates/canvas/src/editor/ops.rs new file mode 100644 index 0000000000000000000000000000000000000000..f28952bc4503632331c0ba5666d9cc542095b4fd --- /dev/null +++ b/crates/canvas/src/editor/ops.rs @@ -0,0 +1,743 @@ +//! The ops each stored change lowers to, recorded as the change applies so a page is never +//! rebuilt to be saved. + +use super::*; +use onestore::op; +use onestore::{OutlineEdit, document::Layout}; + +type Lowered = Result, onestore::Error>; + +fn refused(message: &'static str) -> onestore::Error { + onestore::Error { offset: 0, message } +} + +fn stale(_: EditError) -> onestore::Error { + refused("The editor lost track of the stored page") +} + +/// Definitions a paragraph names: its lists, style and note tags. +pub(super) fn references(node: &PageParagraph) -> impl Iterator + '_ { + let content_tags = match &node.content { + ParagraphContent::Text(text) => text.tags.as_slice(), + ParagraphContent::Table(table) => table.tags.as_slice(), + _ => &[], + }; + node.lists.iter().copied().chain(node.style).chain( + node.tags + .iter() + .chain(content_tags) + .filter_map(|tag| tag.definition), + ) +} + +/// The definitions `paragraphs` and their cells name. +fn named( + definitions: &BTreeMap, + paragraphs: &[&[PageParagraph]], +) -> BTreeMap { + paragraphs + .iter() + .flat_map(|list| descendants(list, None)) + .flat_map(|(_, _, node)| references(node)) + .filter_map(|id| Some((id, definitions.get(&id)?.clone()))) + .collect() +} + +/// `op::lower` of paragraphs `before` of `container` replaced by `nodes[range]` and the +/// definitions they name, so it stays O(edit). When the structure changes, it also sees the +/// unchanged paragraphs an op may split, join, move or anchor to: the ones either side and +/// their ancestors, the range's descendants past it, and after them the next child of each +/// ancestor. +fn lower( + container: ExGuid, + nodes: &[PageParagraph], + range: Range, + before: &[PageParagraph], + definitions: &BTreeMap, +) -> Lowered { + let after = &nodes[range.clone()]; + if before.is_empty() && after.is_empty() { + return Ok(Vec::new()); + } + let mut context = BTreeSet::new(); + let shape = |node: &PageParagraph| (node.id, node.parent, node.level); + if before.iter().map(shape).ne(after.iter().map(shape)) { + context.extend(range.start.checked_sub(1)); + context.extend((range.end < nodes.len()).then_some(range.end)); + let held: BTreeSet = before.iter().chain(after).map(|node| node.id).collect(); + let mut wanted: Vec = before + .iter() + .chain(after) + .chain(context.iter().map(|&at| &nodes[at])) + .filter_map(|node| node.parent) + .collect(); + let mut open = BTreeMap::from([(None, 0)]); + while let Some(id) = wanted.pop() { + if held.contains(&id) || context.iter().any(|&at| nodes[at].id == id) { + continue; + } + if let Some(at) = nodes[..range.start].iter().rposition(|node| node.id == id) { + context.insert(at); + open.insert(Some(id), nodes[at].level); + wanted.extend(nodes[at].parent); + } + } + // Descendants of the range move with the paragraphs it splits, joins and moves. + let mut members = held; + let mut end = range.end; + while let Some(node) = nodes.get(end) + && node.parent.is_some_and(|parent| members.contains(&parent)) + { + members.insert(node.id); + context.insert(end); + end += 1; + } + for (at, node) in nodes.iter().enumerate().skip(end) { + if open.remove(&node.parent).is_some() { + context.insert(at); + } + // A container's children end where its subtree does. + open.retain(|_, level| *level < node.level); + if open.is_empty() { + break; + } + } + } + let around = |middle: &[PageParagraph]| { + let (head, tail): (Vec, Vec) = + context.iter().partition(|&&at| at < range.start); + head.iter() + .map(|&at| &nodes[at]) + .chain(middle) + .chain(tail.iter().map(|&at| &nodes[at])) + .cloned() + .collect::>() + }; + let (before, after) = (around(before), around(after)); + let named = named(definitions, &[&before, &after]); + // What `op::lower` cannot move or change in place is removed and inserted again under + // the identities it had: stored paragraphs a new table cell holds, and, where lowering + // fails, a paragraph whose text object a join replaced outside what `Join` expresses + // (between empty paragraphs, or back on undo) with the paragraph whose text it takes. + let text = |node: &PageParagraph| node.text().map(|text| text.id); + let stored: BTreeMap> = descendants(&before, None) + .map(|(_, _, node)| (node.id, text(node))) + .collect(); + let cells: BTreeSet = descendants(&before, None) + .filter_map(|(cell, _, _)| cell) + .collect(); + let mut removed: BTreeSet = descendants(&after, None) + .filter(|(cell, _, node)| { + stored.contains_key(&node.id) && cell.is_some_and(|cell| !cells.contains(&cell)) + }) + .map(|(_, _, node)| node.id) + .collect(); + let lowered = |removed: &BTreeSet| { + let mut ops = Vec::new(); + let mut deleted = Vec::new(); + let mut kept = without(&before, removed, &mut ops, &mut deleted).map_err(stale)?; + if kept.is_empty() { + let anchor = anchor().map_err(stale)?; + ops.push(PageOp::Insert { + container, + before: None, + paragraphs: vec![anchor.clone()], + }); + kept.push(anchor); + } + ops.extend(deleted.into_iter().map(|object| PageOp::Delete { object })); + ops.extend(op::lower(container, &kept, &after, None, &named)?); + Ok(ops) + }; + let error = match lowered(&removed) { + Ok(ops) => return Ok(ops), + Err(error) => error, + }; + let taken: BTreeSet = descendants(&after, None) + .filter(|(_, _, node)| stored.get(&node.id).is_some_and(|old| *old != text(node))) + .filter_map(|(_, _, node)| { + removed.insert(node.id); + text(node) + }) + .collect(); + if taken.is_empty() { + return Err(error); + } + removed.extend( + descendants(&before, None) + .filter(|(_, _, node)| text(node).is_some_and(|id| taken.contains(&id))) + .map(|(_, _, node)| node.id), + ); + lowered(&removed) +} + +/// An empty paragraph keeping a container from emptying while its paragraphs are removed +/// and inserted again; the lowering removes it after. +fn anchor() -> Result { + crate::document::node( + Paragraph::new(String::new(), Format::default()), + Format::default(), + ) +} + +/// `nodes` without the subtrees of `removed`, whose roots `deleted` receives; a table cell +/// they would empty first receives an anchor, which `ops` inserts. +fn without( + nodes: &[PageParagraph], + removed: &BTreeSet, + ops: &mut Vec, + deleted: &mut Vec, +) -> Result, EditError> { + let mut gone = BTreeSet::new(); + let mut kept = Vec::new(); + for node in nodes { + if node.parent.is_some_and(|parent| gone.contains(&parent)) { + gone.insert(node.id); + } else if removed.contains(&node.id) { + gone.insert(node.id); + deleted.push(node.id); + } else { + let mut node = node.clone(); + if let ParagraphContent::Table(table) = &mut node.content { + for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { + cell.paragraphs = without(&cell.paragraphs, removed, ops, deleted)?; + if cell.paragraphs.is_empty() { + let anchor = anchor()?; + ops.push(PageOp::Insert { + container: cell.id, + before: None, + paragraphs: vec![anchor.clone()], + }); + cell.paragraphs.push(anchor); + } + } + } + kept.push(node); + } + } + Ok(kept) +} + +/// A page-level outline's position and width, as `op::lower_page` compares them. +pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered { + let mut ops = Vec::new(); + if (new.x, new.y) != (old.x, old.y) { + let (Some(x), Some(y)) = (new.x, new.y) else { + return Err(refused("An outline position needs both coordinates")); + }; + ops.push(PageOp::Outline { + object: id, + edit: OutlineEdit::Position { x, y }, + }); + } + if (new.max_width, new.width_set_by_user) != (old.max_width, old.width_set_by_user) { + ops.push(PageOp::Outline { + object: id, + edit: OutlineEdit::Width { + points: new + .max_width + .ok_or_else(|| refused("An outline width cannot be removed"))?, + user_set: new.width_set_by_user == Some(true), + }, + }); + } + Ok(ops) +} + +/// A page picture's stored position, size and description. +pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec { + vec![PageOp::Picture { + picture: image.id, + layout: image.layout.clone(), + alt: image.alt.clone(), + }] +} + +impl CanvasEditor { + /// The ops the stored page took since the last call, in order; an error means an edit + /// since then cannot be stored, and the page should be reopened from storage. + pub fn take_ops(&mut self) -> Result, onestore::Error> { + let taken = std::mem::replace(&mut self.ops, Ok(Vec::new())); + match (&taken, &mut self.stored) { + (Ok(ops), Some((_, sent))) => sent.extend(ops.iter().cloned()), + (Err(_), stored) => *stored = None, + (Ok(_), None) => {} + } + taken + } + + pub(super) fn record(&mut self, lowered: Lowered) { + if let Ok(ops) = &mut self.ops { + match lowered { + Ok(mut lowered) => ops.append(&mut lowered), + Err(error) => self.ops = Err(error), + } + } + } + + /// Ops for `change`, the inverse of an edit just applied to stored outline `outline`. + pub(super) fn change_ops(&self, outline: &TextOutline, change: &TextChange) -> Lowered { + let edit = &change.edit; + let nodes = outline.document.container(edit.container).map_err(stale)?; + let mut ops = lower( + edit.container.unwrap_or(outline.id), + nodes, + edit.range.clone(), + &edit.replacement, + &self.definitions, + )?; + for (_, _, node) in descendants(outline.document.nodes(), None) { + if let ParagraphContent::Table(table) = &node.content + && edit.columns.contains_key(&table.id) + { + ops.push(PageOp::Table { + table: table.id, + edit: op::TableEdit::Columns(table.columns.clone()), + }); + } + } + for placement in &change.positions { + ops.extend(self.placement_ops(placement)?); + } + Ok(ops) + } + + /// A page object's move from `placement`, as title flow makes it. + pub(super) fn placement_ops(&self, placement: &Placement) -> Lowered { + let old = Layout { + x: placement.position[0], + y: placement.position[1], + ..Default::default() + }; + let moved = |layout: &Layout| Layout { + x: layout.x, + y: layout.y, + ..Default::default() + }; + let picture = |image: &onestore::page::Image| { + Ok(if [image.layout.x, image.layout.y] == placement.position { + Vec::new() + } else { + picture_layout(image) + }) + }; + if let Some(outline) = self.outlines.iter().find(|item| item.id == placement.id) { + return layout_ops(outline.id, &old, &moved(&outline.layout)); + } + for object in &self.objects { + match object { + page::Content::Image(image) if image.id == placement.id => return picture(image), + page::Content::Outline { source, .. } if source.id == placement.id => { + return layout_ops(source.id, &old, &moved(&source.layout)); + } + page::Content::ReadOnly(object) if object.source.id() == placement.id => { + return match &object.source { + PageObject::Outline(source) => { + layout_ops(source.id, &old, &moved(&source.layout)) + } + PageObject::Image(image) => picture(image), + _ => Err(refused("Unsupported objects cannot be moved")), + }; + } + _ => {} + } + } + Err(refused("The editor lost track of the stored page")) + } + + /// Ops turning stored outline `old` into what `outlines` holds under `id` now: added, + /// removed, or its changed paragraphs and geometry. + pub(super) fn outline_ops(&self, id: ExGuid, old: Option<&TextOutline>) -> Lowered { + let new = self.outlines.iter().find(|outline| outline.id == id); + match (old, new) { + (None, None) => Ok(Vec::new()), + (Some(_), None) => Ok(vec![PageOp::Delete { object: id }]), + (None, Some(new)) => { + let outline = new.snapshot(); + let page = |objects| Page { + title: String::new(), + identity: None, + created: None, + margin_origin: [0.0; 2], + objects, + definitions: named(&self.definitions, &[&outline.paragraphs]), + }; + let mut ops = op::lower_page( + &page(Vec::new()), + &page(vec![PageObject::Outline(outline.clone())]), + )?; + let successor = self.successor(id); + for op in &mut ops { + if let PageOp::Add { before, .. } = op { + *before = successor; + } + } + Ok(ops) + } + (Some(old), Some(new)) => { + let (a, b) = (old.document.nodes(), new.document.nodes()); + let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count(); + let suffix = a[prefix..] + .iter() + .rev() + .zip(b[prefix..].iter().rev()) + .take_while(|(x, y)| x == y) + .count(); + let mut ops = lower( + id, + b, + prefix..b.len() - suffix, + &a[prefix..a.len() - suffix], + &self.definitions, + )?; + ops.extend(layout_ops(id, &old.layout, &new.layout)?); + Ok(ops) + } + } + } + + /// The page's date as the editor shows it. + pub(super) fn date_ops(&self) -> Vec { + let Some(date) = &self.date else { + return Vec::new(); + }; + vec![PageOp::Date { + created: date.timestamp(), + fields: date + .fields() + .filter_map(|(_, paragraph)| { + let text = paragraph.text()?; + Some((text.id, text.text.text().to_owned())) + }) + .collect(), + }] + } + + /// The page child `page()` places after `id`, titles aside. + pub(super) fn successor(&self, id: ExGuid) -> Option { + let mut children: Vec = Vec::new(); + for content in &self.objects { + let child = match content { + page::Content::Editable(outline) + if self.outlines.iter().any(|o| o.id == *outline) => + { + *outline + } + page::Content::Editable(_) => continue, + page::Content::Outline { source, .. } => source.id, + page::Content::Date { .. } => continue, + page::Content::Image(image) => image.id, + page::Content::Ink(ink) => ink.id, + page::Content::ReadOnly(object) + if matches!(object.source, PageObject::Title(_)) => + { + continue; + } + page::Content::ReadOnly(object) => object.source.id(), + }; + // Titles, the date's among them, are listed apart from what an op places. + if !self + .header + .areas + .iter() + .any(|area| area.origins.contains_key(&child)) + { + children.push(child); + } + } + children.extend( + self.outlines + .iter() + .filter(|outline| !self.has_page_outline(outline.id)) + .map(|outline| outline.id), + ); + let at = children.iter().position(|child| *child == id)?; + children.get(at + 1).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layout::TextEngine; + use std::time::{Duration, Instant}; + + fn median(mut samples: Vec) -> Duration { + samples.sort(); + samples[samples.len() / 2] + } + + /// The rows of the data-layer plan's table: each edit and its undo as ops, the undo + /// of a join splitting the paragraph back under its own identities. + #[test] + fn edits_and_their_undo_record_the_plan_s_ops() { + let mut engine = TextEngine::default(); + let lines = ["Hello", "World"].map(|line| Paragraph::new(line.into(), Format::default())); + let document = crate::document::TextDocument::new(lines.to_vec()).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, document, 400.0).unwrap(); + let nodes = editor.outlines()[0].document().nodes().to_vec(); + let text = |index: usize| nodes[index].text().unwrap().id; + let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into(); + let undone = |editor: &mut CanvasEditor, engine: &mut TextEngine| { + editor.undo(engine).unwrap(); + editor.take_ops().unwrap() + }; + + editor.select(at(0, 3)).unwrap(); + editor.insert(&mut engine, "ab").unwrap(); + let typed = PageOp::Text { + text: text(0), + range: 3..3, + with: "ab".into(), + }; + assert_eq!(editor.take_ops().unwrap(), [typed]); + let erased = PageOp::Text { + text: text(0), + range: 3..5, + with: String::new(), + }; + assert_eq!(undone(&mut editor, &mut engine), [erased]); + + editor.select(at(1, 0)).unwrap(); + editor.delete(&mut engine, true).unwrap(); + let joined = PageOp::Join { + left: text(0), + right: text(1), + }; + assert_eq!(editor.take_ops().unwrap(), [joined]); + let split = PageOp::Split { + text: text(0), + at: 5, + paragraph: nodes[1].id, + right: text(1), + lists: Vec::new(), + }; + assert_eq!(undone(&mut editor, &mut engine), [split]); + + editor.select(at(1, 0)).unwrap(); + editor.tab(&mut engine, false).unwrap(); + let [PageOp::Move { object, parent, .. }] = &editor.take_ops().unwrap()[..] else { + panic!("Tab moves the paragraph under its sibling") + }; + assert_eq!((*object, *parent), (nodes[1].id, Some(nodes[0].id))); + // A moved paragraph keeps its level where it lies deeper than its new parent. + let ops = undone(&mut editor, &mut engine); + let [ + PageOp::Move { object, parent, .. }, + PageOp::Level { + paragraph, + level: 1, + }, + ] = &ops[..] + else { + panic!("undoing Tab moves it back and outdents it: {ops:?}") + }; + assert_eq!( + (*object, *parent, *paragraph), + (nodes[1].id, Some(editor.outlines()[0].id), nodes[1].id) + ); + + editor + .select( + [ + TextPosition { + paragraph: 0, + offset: 1, + }, + TextPosition { + paragraph: 0, + offset: 4, + }, + ] + .into(), + ) + .unwrap(); + editor + .format(&mut engine, Formatting::Toggle(format::Toggle::Bold)) + .unwrap(); + let bold = PageOp::Format { + text: text(0), + range: 1..4, + set: vec![onestore::TextAttribute::Bold(true)], + clear: Vec::new(), + }; + assert_eq!(editor.take_ops().unwrap(), [bold]); + let [ + PageOp::Format { + text: target, + range, + .. + }, + ] = &undone(&mut editor, &mut engine)[..] + else { + panic!("undoing bold formats the range back") + }; + assert_eq!((*target, range.clone()), (text(0), 1..4)); + } + + /// A change from elsewhere reaches only what it changed: a refresh whose stored page is + /// what the editor's ops left (though storage normalized it) changes nothing, history + /// included; one that changes an outline drops that outline's history and keeps the + /// rest, which still undoes into the section. + #[test] + fn a_refresh_keeps_what_the_change_did_not_reach() { + use onestore::op::{Edit, Op}; + let image = + include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one"); + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, image.to_vec()).unwrap(); + let space = section.pages().unwrap()[0].0; + let mut engine = TextEngine::default(); + let mut editor = + CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); + let mut at = 133_000_000_000_000_000; + let mut store = |section: &mut onestore::Section<'_>, ops: Vec| { + at += 10_000_000; + let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); + section.apply("Test", &Edit { at, ops }).unwrap(); + }; + let local = editor.outlines().iter().find(|o| !o.title).unwrap().id; + editor.focus_outline(local).unwrap(); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 0, + }; 2] + .into(), + ) + .unwrap(); + editor.insert(&mut engine, "Local ").unwrap(); + let created = editor + .create_outline(&mut engine, [400.0, 600.0], 300.0) + .unwrap(); + editor.insert(&mut engine, "Mine").unwrap(); + store(&mut section, editor.take_ops().unwrap()); + + let (shown, history) = (editor.page().unwrap(), editor.undo.len()); + let stored = section.page(space).unwrap(); + assert_ne!( + Page { + title: shown.title.clone(), + ..stored.clone() + }, + shown, + "storage normalizes" + ); + assert!(!editor.refresh(stored, &mut engine).unwrap()); + assert_eq!( + ( + editor.page().unwrap(), + editor.undo.len(), + editor.active_outline().id + ), + (shown, history, created) + ); + + let text = section + .page(space) + .unwrap() + .objects + .iter() + .find_map(|object| match object { + onestore::page::PageObject::Outline(outline) if outline.id == local => outline + .paragraphs + .last()? + .text() + .map(|text| (text.id, text.text.text().encode_utf16().count() as u32)), + _ => None, + }); + let (text, length) = text.unwrap(); + store( + &mut section, + vec![PageOp::Text { + text, + range: length..length, + with: " remote".into(), + }], + ); + assert!( + editor + .refresh(section.page(space).unwrap(), &mut engine) + .unwrap() + ); + assert_eq!( + editor.undo.len(), + history - 1, + "the typing in the changed outline goes" + ); + assert_eq!(editor.active_outline().id, created); + assert!(editor.undo(&mut engine).unwrap()); + assert!(editor.undo(&mut engine).unwrap()); + store(&mut section, editor.take_ops().unwrap()); + assert!(!editor.undo(&mut engine).unwrap()); + let page = section.page(space).unwrap(); + assert!(!page.objects.iter().any(|object| object.id() == created)); + let texts: Vec = editor + .outlines() + .iter() + .flat_map(|o| o.document().paragraphs().map(|p| p.text().to_owned())) + .collect(); + assert!(texts.iter().any(|text| text.starts_with("Local "))); + assert!(texts.iter().any(|text| text.ends_with(" remote"))); + } + + /// One keystroke on the 3000-paragraph probe page, on the frame thread: + /// `SECTION_PROBE=path cargo test --release -p canvas --lib keystroke_recording -- --ignored --nocapture`. + #[test] + #[ignore] + fn keystroke_recording() { + let path = std::env::var("SECTION_PROBE").unwrap_or("/tmp/probe3000.one".into()); + let image = std::fs::read(&path).unwrap(); + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, image).unwrap(); + let page = section + .pages() + .unwrap() + .into_iter() + .map(|(space, ..)| section.page(space).unwrap()) + .max_by_key(|page| format!("{page:?}").len()) + .unwrap(); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); + let outline = editor + .outlines() + .iter() + .max_by_key(|outline| outline.document().nodes().len()) + .unwrap(); + let (id, count) = (outline.id, outline.document().nodes().len()); + editor.focus_outline(id).unwrap(); + let caret = TextPosition { + paragraph: count / 2, + offset: 0, + }; + editor.select([caret; 2].into()).unwrap(); + let (mut typing, mut recording, mut rebuilding) = (Vec::new(), Vec::new(), Vec::new()); + let mut previous = editor.page().unwrap(); + for _ in 0..200 { + let start = Instant::now(); + editor.insert(&mut engine, "x").unwrap(); + typing.push(start.elapsed()); + let Some(History::Text { change, .. }) = editor.undo.last() else { + unreachable!() + }; + let start = Instant::now(); + let ops = editor.change_ops(editor.active_outline(), change).unwrap(); + recording.push(start.elapsed()); + assert_eq!(ops.len(), 1, "{ops:?}"); + assert_eq!(editor.take_ops().unwrap().len(), 1); + let start = Instant::now(); + let page = editor.page().unwrap(); + assert!(page != previous); + rebuilding.push(start.elapsed()); + previous = page; + } + println!( + "{path}: {count} paragraphs; keystroke {:?}, of which recording its op {:?}; \ + the page rebuilt and compared as persist did {:?}", + median(typing), + median(recording), + median(rebuilding) + ); + } +} diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index b49ccd5091da072c02cc7cab8a98818336be046c..49af7234797d07cb248cb404c35f6b31f6085a4e 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -213,6 +213,33 @@ impl PageScene { Ok((scene, editor)) } + /// Follows `editor` after it refreshed from storage, keeping the rasters of pictures + /// whose bytes did not change so they do not blink. + pub fn refresh( + &mut self, + editor: &mut CanvasEditor, + engine: &mut TextEngine, + ) -> Result<(), SceneError> { + let mut scene = Self::pictures(&editor.objects, Some(editor))?; + for (id, picture) in &mut scene.pictures { + if let Some(shown) = self.pictures.remove(id) + && shown.same(picture) + { + *picture = shown; + } + } + for (id, art) in &mut scene.backgrounds { + if let Some(shown) = self.backgrounds.remove(id) + && shown.same(art) + { + *art = shown; + } + } + scene.mark_unavailable(&mut editor.objects, engine)?; + *self = scene; + Ok(()) + } + /// Turns page pictures the renderer cannot decode into placeholders; their stored data /// is kept. fn mark_unavailable( diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs index 8b34f76d6df5d07ed956381f9bc8f06f7b879b97..1cd2c1445aaafc81e1da53fd8359ae3aa57698ec 100644 --- a/crates/canvas/src/gpu/page/background.rs +++ b/crates/canvas/src/gpu/page/background.rs @@ -330,6 +330,11 @@ struct Variant { } impl Background { + /// Whether both recreate the same template picture at the same size. + pub fn same(&self, other: &Self) -> bool { + self.svg == other.svg && self.size == other.size + } + pub fn recognise(picture: &onestore::page::Image) -> Option { let digest: String = Sha256::digest(picture.bytes.as_deref()?) .iter() diff --git a/crates/canvas/src/gpu/page/picture.rs b/crates/canvas/src/gpu/page/picture.rs index 18516bef894d37e7b9684a6910d0faa872bab01e..bd44b77d4d43ec0c89ff94ed6ecce472864b688b 100644 --- a/crates/canvas/src/gpu/page/picture.rs +++ b/crates/canvas/src/gpu/page/picture.rs @@ -31,6 +31,11 @@ pub(super) struct Picture { impl Picture { /// Reads the size from the picture's header; None when the renderer cannot decode it. + /// Whether both show the same stored bytes. + pub fn same(&self, other: &Self) -> bool { + self.bytes == other.bytes + } + pub fn new(bytes: &Arc<[u8]>) -> Option { Some(Self { native: RasterImage::measure(bytes).ok()?, diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index d4ca7e656af4367f77cb57af9af4386847b2c0da..8d5f66f9874d43c8c6ed406f8c210f7088a139dd 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -257,6 +257,21 @@ impl PageView { self.object_focus = None; } + /// Shows the stored page after a change made elsewhere in place of the one shown, + /// keeping the scroll, the caret and selection, and the pictures already drawn. Marked + /// text must be committed or cancelled first. + pub fn refresh(&mut self, page: onestore::page::Page) -> Result { + if !self.editor.refresh(page, &mut self.engine)? { + return Ok(Response::default()); + } + if let Some((scene, _)) = &mut self.scene { + scene.refresh(&mut self.editor, &mut self.engine)?; + } + self.drag = None; + self.object_focus = None; + self.moved() + } + /// Shows another page as OneNote opens one, keeping the zoom. pub fn open(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) { self.replace(editor, scene); diff --git a/crates/canvas/tests/ops_differential.rs b/crates/canvas/tests/ops_differential.rs new file mode 100644 index 0000000000000000000000000000000000000000..5b2654946e68b12f9e7ea5205d9f2a0d16b43f4a --- /dev/null +++ b/crates/canvas/tests/ops_differential.rs @@ -0,0 +1,670 @@ +//! The ops the editor records for each edit, applied to the section, store what the edited +//! page lowered whole (`Section::apply_page(editor.page())`) stores: every editor operation +//! alone and in random sequences with undo and redo, on every page of the corpus sections +//! below and of any named in `OPS_SWEEP_SECTIONS` (`:`-separated paths; the structural +//! probe section, which `structural_roundtrip` sweeps, takes minutes). + +use canvas::{ + document::TextPosition, + editor::{Alignment, CanvasEditor, Formatting, NoteTag, Toggle}, + layout::TextEngine, +}; +use draw::edit::Movement; +use onestore::{ + Arena, ExGuid, Section, + op::{Edit, Op}, + page::Page, +}; +use std::{collections::BTreeMap, path::Path}; + +const SECTIONS: [&str; 15] = [ + "corpus/outline-edit/before/notebook/synthetic.one", + "corpus/paragraph-edit/before/notebook/synthetic.one", + "corpus/outline-edit/tree/before/notebook/synthetic.one", + "corpus/m6/native-features-01/notebook/Features.one", + "corpus/table-edit/nested/cold/notebook/synthetic.one", + "corpus/m6/native-table-controls-01/notebook/synthetic.one", + "corpus/list-edit/cold/notebook/lists.one", + "corpus/tag-edit/cold/notebook/tags.one", + "corpus/picture-edit/native-page-level/notebook/pictures.one", + "corpus/attachment-edit/plain/cold/notebook/files.one", + "corpus/ink-edit/drawing/cold/notebook/ink.one", + "corpus/math-edit/native-editor/notebook/links.one", + "corpus/link-edit/native-links/notebook/links.one", + "corpus/paragraph-format/cold/notebook/synthetic.one", + "corpus/canvas/baseline-anchors.one", +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Kind { + Type, + TypeEnd, + Compose, + ComposeCancel, + DeleteBackward, + DeleteForward, + DeleteRange, + DeleteWord, + EnterStart, + EnterMiddle, + EnterEnd, + SoftEnter, + Backspace, + Delete, + Tab, + ShiftTab, + TabMiddle, + Paste, + PasteLine, + Bold, + Italic, + Font, + FontSize, + Color, + Highlight, + Clear, + Center, + Bullets, + Numbering, + Tag, + Check, + ClickCheck, + MoveOutline, + ResizeOutline, + CreateOutline, + EmptyOutline, + ArrowPastEnd, + PlaceImage, + RemoveImage, + Date, + Undo, + Redo, +} + +const KINDS: [Kind; 42] = [ + Kind::Type, + Kind::TypeEnd, + Kind::Compose, + Kind::ComposeCancel, + Kind::DeleteBackward, + Kind::DeleteForward, + Kind::DeleteRange, + Kind::DeleteWord, + Kind::EnterStart, + Kind::EnterMiddle, + Kind::EnterEnd, + Kind::SoftEnter, + Kind::Backspace, + Kind::Delete, + Kind::Tab, + Kind::ShiftTab, + Kind::TabMiddle, + Kind::Paste, + Kind::PasteLine, + Kind::Bold, + Kind::Italic, + Kind::Font, + Kind::FontSize, + Kind::Color, + Kind::Highlight, + Kind::Clear, + Kind::Center, + Kind::Bullets, + Kind::Numbering, + Kind::Tag, + Kind::Check, + Kind::ClickCheck, + Kind::MoveOutline, + Kind::ResizeOutline, + Kind::CreateOutline, + Kind::EmptyOutline, + Kind::ArrowPastEnd, + Kind::PlaceImage, + Kind::RemoveImage, + Kind::Date, + Kind::Undo, + Kind::Redo, +]; + +struct Random(u64); + +impl Random { + fn below(&mut self, bound: usize) -> usize { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 % bound.max(1) as u64) as usize + } +} + +/// A random caret in the active outline: the paragraph and its UTF-16 length. +fn caret(editor: &CanvasEditor, random: &mut Random) -> (usize, u32) { + let count = editor.active_outline().document().paragraphs().count(); + let paragraph = random.below(count); + let text = editor + .active_outline() + .document() + .paragraphs() + .nth(paragraph) + .unwrap(); + (paragraph, text.text().encode_utf16().count() as u32) +} + +/// A scalar boundary of the paragraph near its middle, or a random one. +fn inside(editor: &CanvasEditor, paragraph: usize, random: &mut Random) -> u32 { + let text = editor + .active_outline() + .document() + .paragraphs() + .nth(paragraph) + .unwrap(); + let chars: Vec = text.text().char_indices().map(|(at, _)| at).collect(); + let at = chars + .get(random.below(chars.len() + 1)) + .copied() + .unwrap_or(text.text().len()); + text.utf16_offset(at).unwrap() +} + +/// Performs `kind` somewhere on the page; false when the editor declines it. +fn perform( + editor: &mut CanvasEditor, + engine: &mut TextEngine, + kind: Kind, + random: &mut Random, +) -> bool { + let outlines: Vec = editor.outlines().iter().map(|outline| outline.id).collect(); + if !matches!( + kind, + Kind::Undo + | Kind::Redo + | Kind::PlaceImage + | Kind::RemoveImage + | Kind::CreateOutline + | Kind::Date + ) { + if outlines.is_empty() { + return false; + } + let outline = outlines[random.below(outlines.len())]; + if editor.focus_outline(outline).is_err() { + return false; + } + } + let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into(); + let (paragraph, length) = if outlines.is_empty() { + (0, 0) + } else { + caret(editor, random) + }; + let middle = if outlines.is_empty() { + 0 + } else { + inside(editor, paragraph, random) + }; + let place = |editor: &mut CanvasEditor, offset| editor.select(at(paragraph, offset)).is_ok(); + let range = |editor: &mut CanvasEditor, random: &mut Random| { + let (last, _) = caret(editor, random); + let (first, last) = (paragraph.min(last), paragraph.max(last)); + let start = inside(editor, first, random); + let end = inside(editor, last, random); + let (start, end) = if first == last { + (start.min(end), start.max(end)) + } else { + (start, end) + }; + editor + .select( + [ + TextPosition { + paragraph: first, + offset: start, + }, + TextPosition { + paragraph: last, + offset: end, + }, + ] + .into(), + ) + .is_ok() + }; + let format = + |editor: &mut CanvasEditor, engine: &mut TextEngine, random: &mut Random, command| { + range(editor, random) && editor.format(engine, command).is_ok() + }; + match kind { + Kind::Type => place(editor, middle) && editor.insert(engine, "ab").is_ok(), + Kind::TypeEnd => place(editor, length) && editor.commit_text(engine, "z".into()).is_ok(), + Kind::Compose => { + place(editor, middle) + && editor.compose(engine, "k".into(), 1..1).is_ok() + && editor.compose(engine, "ka".into(), 2..2).is_ok() + && editor.commit_text(engine, "か".into()).is_ok() + } + Kind::ComposeCancel => { + place(editor, middle) + && editor.compose(engine, "k".into(), 1..1).is_ok() + && editor.cancel_composition(engine).is_ok() + } + Kind::DeleteBackward => place(editor, middle) && editor.delete(engine, true).is_ok(), + Kind::DeleteForward => place(editor, middle) && editor.delete(engine, false).is_ok(), + Kind::DeleteRange => range(editor, random) && editor.delete(engine, true).is_ok(), + Kind::DeleteWord => { + place(editor, middle) && editor.delete_to(engine, Movement::WordLeft).is_ok() + } + Kind::EnterStart => place(editor, 0) && editor.enter(engine, false).is_ok(), + Kind::EnterMiddle => place(editor, middle) && editor.enter(engine, false).is_ok(), + Kind::EnterEnd => place(editor, length) && editor.enter(engine, false).is_ok(), + Kind::SoftEnter => place(editor, middle) && editor.enter(engine, true).is_ok(), + Kind::Backspace => place(editor, 0) && editor.delete(engine, true).is_ok(), + Kind::Delete => place(editor, length) && editor.delete(engine, false).is_ok(), + Kind::Tab => place(editor, 0) && editor.tab(engine, false).is_ok(), + Kind::ShiftTab => place(editor, 0) && editor.tab(engine, true).is_ok(), + Kind::TabMiddle => place(editor, middle) && editor.tab(engine, false).is_ok(), + Kind::Paste => { + range(editor, random) && editor.paste(engine, "Line one\r\nLine two", 1033).is_ok() + } + Kind::PasteLine => place(editor, middle) && editor.paste(engine, "pasted", 1033).is_ok(), + Kind::Bold => format(editor, engine, random, Formatting::Toggle(Toggle::Bold)), + Kind::Italic => format(editor, engine, random, Formatting::Toggle(Toggle::Italic)), + Kind::Font => format(editor, engine, random, Formatting::Font("Georgia".into())), + Kind::FontSize => format(editor, engine, random, Formatting::FontSize(14.0)), + Kind::Color => format(editor, engine, random, Formatting::Color(Some(0x0000_00ff))), + Kind::Highlight => format( + editor, + engine, + random, + Formatting::Highlight(Some(0x0000_ffff)), + ), + Kind::Clear => format(editor, engine, random, Formatting::Clear), + Kind::Center => format(editor, engine, random, Formatting::Align(Alignment::Center)), + Kind::Bullets => format(editor, engine, random, Formatting::Bullets), + Kind::Numbering => format(editor, engine, random, Formatting::Numbering), + Kind::Tag => { + let tag = [ + NoteTag::ToDo, + NoteTag::Important, + NoteTag::Question, + NoteTag::RememberForLater, + NoteTag::Definition, + NoteTag::Highlight, + NoteTag::Contact, + NoteTag::Address, + NoteTag::PhoneNumber, + ][random.below(9)]; + format(editor, engine, random, Formatting::Tag(tag)) + } + Kind::Check => format(editor, engine, random, Formatting::Check), + Kind::ClickCheck => { + let outline = editor.active_outline(); + let id = outline.document().text_nodes().nth(paragraph).unwrap().id; + let outline = outline.id; + editor.click_check(engine, outline, id).is_ok() + } + Kind::MoveOutline => { + let outline = editor.active_outline(); + let [x, y] = outline.origin(); + let id = outline.id; + editor.move_outline(id, [x + 36.0, y + 18.0]).is_ok() + } + Kind::ResizeOutline => { + let width = editor.active_outline().wrap_width(); + editor.resize(engine, width + 72.0).is_ok() + } + Kind::CreateOutline => { + editor + .create_outline( + engine, + [400.0, 600.0 + 20.0 * random.below(10) as f32], + 300.0, + ) + .is_ok() + && editor.insert(engine, "Fresh").is_ok() + } + Kind::EmptyOutline => editor.select_all().is_ok() && editor.delete(engine, true).is_ok(), + Kind::ArrowPastEnd => { + editor + .move_selection(engine, Movement::DocumentEnd, false) + .is_ok() + && editor.move_selection(engine, Movement::Down, false).is_ok() + && editor.move_selection(engine, Movement::Down, false).is_ok() + && editor.insert(engine, "Below").is_ok() + } + Kind::PlaceImage | Kind::RemoveImage => { + let images: Vec = editor + .object_layouts() + .map(|(id, _)| id) + .filter(|id| editor.image_placement(*id).is_some()) + .collect(); + if images.is_empty() { + return false; + } + let id = images[random.below(images.len())]; + if kind == Kind::RemoveImage { + return editor.remove_image(engine, id).is_ok(); + } + let (origin, size) = editor.image_placement(id).unwrap(); + editor + .place_image( + engine, + id, + [origin[0] + 18.0, origin[1] + 9.0], + [size[0] * 1.5, size[1] * 1.5], + ) + .is_ok() + } + Kind::Date => { + let Some(date) = editor.date() else { + return false; + }; + let timestamp = date.timestamp() + 864_000_000_000 * (1 + random.below(30) as u64); + editor + .change_date( + engine, + timestamp, + ["Friday, July 04, 2025".into(), "9:45 AM".into()], + ) + .unwrap_or(false) + } + Kind::Undo => editor.undo(engine).unwrap_or(false), + Kind::Redo => editor.redo(engine).unwrap_or(false), + } +} + +/// How a step's ops compared with its page lowered whole. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Outcome { + /// The ops store the editor's page, or what that page lowered whole stores. + Same, + /// Both refuse the edit. + Refused, + /// The ops store; the page lowered whole cannot reach the editor's page from what is + /// stored. + OpsOnly, + /// The paths part where the section already stored the page differently from the + /// editor's model, as the writers normalize some values. + Normalized, + Differs(String), +} + +/// The section the editor's ops are applied to, sealed after each step. +struct Stored<'a> { + section: Section<'a>, + space: ExGuid, + at: u64, +} + +impl Stored<'_> { + /// Stores the editor's step as its ops and, unless they store the editor's page, + /// compares with that page lowered whole onto the section as it was before the step. + fn step(&mut self, editor: &mut CanvasEditor, model: &Page) -> Outcome { + self.at += 10_000_000; + let image = self.section.image(); + let reads_as = |stored: Page, model: &Page| { + Page { + title: model.title.clone(), + ..stored + } == *model + }; + let normalized = !reads_as(self.section.page(self.space).unwrap(), model); + let ops = editor.take_ops().map_err(|error| error.to_string()); + let edit = ops.clone().map(|ops| Edit { + at: self.at, + ops: ops + .into_iter() + .map(|op| Op::Page { + space: self.space, + op, + }) + .collect(), + }); + let applied = edit.and_then(|edit| { + if edit.ops.is_empty() { + return Ok(()); + } + self.section + .apply("Sweep", &edit) + .map_err(|error| error.to_string()) + }); + let edited = editor.page().unwrap(); + if applied.is_ok() && reads_as(self.section.page(self.space).unwrap(), &edited) { + self.section.seal().unwrap(); + return Outcome::Same; + } + let arena = Arena::default(); + let mut whole = Section::open(&arena, image).unwrap(); + let lowered = onestore::op::lower_page(&whole.page(self.space).unwrap(), &edited) + .map_err(|error| error.to_string()) + .and_then(|ops| { + let edit = Edit { + at: self.at, + ops: ops + .iter() + .map(|op| Op::Page { + space: self.space, + op: op.clone(), + }) + .collect(), + }; + whole + .apply("Sweep", &edit) + .map_err(|error| error.to_string())?; + Ok(ops) + }); + let outcome = match (applied, &lowered) { + (Err(error), Err(whole)) => { + if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { + eprintln!("BOTH {error} | {whole}"); + } + Outcome::Refused + } + (Ok(()), Ok(_)) => { + let (a, b) = ( + self.section.page(self.space).unwrap(), + whole.page(self.space).unwrap(), + ); + if a == b { + Outcome::Same + } else if normalized { + Outcome::Normalized + } else { + Outcome::Differs(difference(&a, &b)) + } + } + (Err(_), Ok(_)) if normalized => Outcome::Normalized, + (Err(error), Ok(_)) => Outcome::Differs(format!("ops refused: {error}")), + (Ok(()), Err(_)) => { + if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { + let stored = self.section.page(self.space).unwrap(); + eprintln!("OPSONLY-MODEL {}", difference(&stored, &edited)); + } + Outcome::OpsOnly + } + }; + if !matches!(outcome, Outcome::Same | Outcome::Refused) + && std::env::var_os("OPS_SWEEP_DEBUG").is_some() + { + let show = |ops: &[onestore::op::PageOp]| { + for op in ops { + let op = format!("{op:?}"); + eprintln!(" {}", &op[..op.len().min(600)]); + } + }; + eprintln!("DIFFERS {outcome:?}\n ops:"); + if let Ok(ops) = &ops { + show(ops); + } + eprintln!(" whole:"); + if let Ok(ops) = &lowered { + show(ops); + } + } + self.section.seal().unwrap(); + outcome + } +} + +/// The first place two pages part: an object missing from one, then objects by identity, +/// then their order. +fn difference(a: &Page, b: &Page) -> String { + let objects = |page: &Page| -> BTreeMap { + page.objects + .iter() + .map(|object| (object.id(), format!("{object:?}"))) + .collect() + }; + let (x, y) = (objects(a), objects(b)); + if let Some(id) = x + .keys() + .chain(y.keys()) + .find(|id| x.contains_key(id) != y.contains_key(id)) + { + return format!("object {id} on one side only"); + } + for (id, x) in &x { + let y = &y[id]; + if x != y { + let at = x.bytes().zip(y.bytes()).take_while(|(p, q)| p == q).count(); + let from = at.saturating_sub(160); + return format!( + "object {id} differs\n one: …{}\n other: …{}", + &x[from..(at + 160).min(x.len())], + &y[from..(at + 160).min(y.len())] + ); + } + } + if a.objects + .iter() + .map(|o| o.id()) + .ne(b.objects.iter().map(|o| o.id())) + { + return "object order".into(); + } + if a.definitions != b.definitions { + return "definitions".into(); + } + "page fields".into() +} + +#[derive(Default)] +struct Tally { + outcomes: BTreeMap<(Kind, Outcome), usize>, + examples: BTreeMap, +} + +fn sweep( + path: &Path, + random: &mut Random, + engine: &mut TextEngine, + tally: &mut Tally, + sequences: usize, +) { + let image = std::fs::read(path).unwrap(); + let arena = Arena::default(); + let mut section = Section::open(&arena, image.clone()).unwrap(); + for (space, title, _) in section.pages().unwrap() { + let Ok(page) = section.page(space) else { + continue; + }; + if CanvasEditor::from_page(page.clone(), engine).is_err() { + continue; + } + let plans = KINDS + .iter() + .map(|kind| vec![*kind]) + .chain(KINDS.iter().map(|kind| vec![*kind, Kind::Undo, Kind::Redo])) + .chain((0..sequences).map(|_| { + (0..3 + random.below(8)) + .map(|_| KINDS[random.below(KINDS.len())]) + .collect() + })) + .collect::>>(); + for plan in plans { + let arena = Arena::default(); + let mut stored = Stored { + section: Section::open(&arena, image.clone()).unwrap(), + space, + at: 133_000_000_000_000_000, + }; + let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); + for kind in &plan { + let model = editor.page().unwrap(); + if !perform(&mut editor, engine, *kind, random) { + continue; + } + if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { + eprintln!("STEP {kind:?} of {plan:?} on {title:?}"); + } + let outcome = stored.step(&mut editor, &model); + *tally.outcomes.entry((*kind, outcome.clone())).or_default() += 1; + if let Outcome::Differs(reason) = &outcome { + tally + .examples + .entry(reason.clone()) + .or_insert_with(|| format!("{plan:?} on {title:?} in {}", path.display())); + } + if outcome != Outcome::Same { + break; + } + } + } + } +} + +#[test] +fn editor_ops_store_what_the_page_lowered_whole_stores() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut paths: Vec<_> = SECTIONS.iter().map(|path| root.join(path)).collect(); + if let Some(extra) = std::env::var_os("OPS_SWEEP_SECTIONS") { + paths.extend(std::env::split_paths(&extra)); + } + let seed = std::env::var("OPS_SWEEP_SEED").map_or(1, |seed| seed.parse().unwrap()); + let sequences = std::env::var("OPS_SWEEP_SEQUENCES").map_or(4, |n| n.parse().unwrap()); + let mut random = Random(seed); + let mut engine = TextEngine::default(); + let mut tally = Tally::default(); + for path in &paths { + let start = std::time::Instant::now(); + sweep(path, &mut random, &mut engine, &mut tally, sequences); + if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { + eprintln!("TIME {:?} {}", start.elapsed(), path.display()); + } + } + let mut report = String::new(); + let mut per_kind: BTreeMap = BTreeMap::new(); + for ((kind, outcome), count) in &tally.outcomes { + let slot = match outcome { + Outcome::Same => 0, + Outcome::Refused => 1, + Outcome::OpsOnly => 2, + Outcome::Normalized => 3, + Outcome::Differs(_) => 4, + }; + per_kind.entry(*kind).or_default()[slot] += count; + } + let mut totals = [0; 5]; + for (kind, counts) in &per_kind { + let [same, refused, only, normalized, differs] = counts; + report += &format!( + "{kind:?}: {same} same, {refused} both refused, {only} only as ops, {normalized} after normalization, {differs} differ\n" + ); + for (total, count) in totals.iter_mut().zip(counts) { + *total += count; + } + } + report += &format!( + "all: {totals:?} (same, both refused, only as ops, after normalization, differ)\n" + ); + for ((kind, outcome), count) in &tally.outcomes { + if let Outcome::Differs(reason) = outcome { + report += &format!( + "{count} x {kind:?} {reason}\n e.g. {}\n", + tally.examples.get(reason).map_or("", String::as_str) + ); + } + } + println!("{report}"); + let differs = totals[4]; + assert_eq!(differs, 0, "{report}"); +} diff --git a/crates/canvas/tests/structural_roundtrip.rs b/crates/canvas/tests/structural_roundtrip.rs index 0eefdb43a1876d99934f5cfe19a057f66af64373..78c330f6aa39ac99ba2c9aa088c32204d206be2f 100644 --- a/crates/canvas/tests/structural_roundtrip.rs +++ b/crates/canvas/tests/structural_roundtrip.rs @@ -1,7 +1,7 @@ -//! Every structural edit the editor makes saves through the page writer and rereads as the -//! model it saved: random Enter, Backspace, Delete, Tab, Shift+Tab, list and paste edits, alone -//! and several to a save, on every outline of the probe section and of any sections named in -//! `CANVAS_SWEEP_SECTIONS` (`:`-separated paths). +//! Every structural edit the editor makes saves, through the page writer and as the ops the +//! editor recorded, and rereads as the model it saved: random Enter, Backspace, Delete, Tab, +//! Shift+Tab, list and paste edits, alone and several to a save, on every outline of the probe +//! section and of any sections named in `CANVAS_SWEEP_SECTIONS` (`:`-separated paths). use canvas::{ document::TextPosition, @@ -9,7 +9,7 @@ use canvas::{ layout::TextEngine, }; use onestore::{ - ExGuid, PreparedEdit, RevisionIndex, Store, + Arena, ExGuid, PreparedEdit, RevisionIndex, Section, Store, document::Document, page::{Outline, Page, PageObject, PageParagraph, ParagraphContent}, }; @@ -334,26 +334,43 @@ fn sweep(path: &Path, random: &mut Random, engine: &mut TextEngine, tally: &mut ) .unwrap(); } + // An untitled page's title follows its first line as the writer stores it. + let rereads = |reread: &Page| { + Page { + title: after.title.clone(), + ..reread.clone() + } == after + }; + let arena = Arena::default(); + let mut stored = Section::open(&arena, section.clone()).unwrap(); + let edit = onestore::op::Edit { + at: 133_000_000_000_000_000, + ops: editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| onestore::op::Op::Page { space, op }) + .collect(), + }; + let ops = match stored.apply("Sweep", &edit) { + Err(error) => Some(format!("ops: {error}")), + Ok(()) => { + let reread = stored.page(space).unwrap(); + (!rereads(&reread)).then(|| format!("ops: {}", difference(&after, &reread))) + } + }; let failure = match PreparedEdit::page(§ion, space, &after, "Sweep") { Err(error) => Some(error.message.to_owned()), Ok(saved) => match pages(saved.as_bytes()) .into_iter() .find(|(id, _)| *id == space) { - // An untitled page's title follows its first line as the writer stores it. - Some((_, reread)) - if Page { - title: after.title.clone(), - ..reread.clone() - } == after => - { - None - } + Some((_, reread)) if rereads(&reread) => None, Some((_, reread)) => Some(difference(&after, &reread)), None => Some("the page does not reread".into()), }, }; - if let Some(failure) = failure { + for failure in failure.into_iter().chain(ops) { tally.failures.entry(failure).or_default().push(format!( "{plan:?} on {:?} in {}", page.title, diff --git a/crates/mobile/Cargo.toml b/crates/mobile/Cargo.toml index a0d7d497d249ed0e144ff821c4438acd73d25715..08a0a19a63b232cd1d1f282c8472cc8ab8bdefb7 100644 --- a/crates/mobile/Cargo.toml +++ b/crates/mobile/Cargo.toml @@ -15,3 +15,4 @@ onestore = { path = "../onestore" } wgpu.workspace = true parley.workspace = true pollster = "0.4" +serde_json = "1" diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index 83fc816a3b1376404b412a395fd90d21bf6650f8..726e45fbef99d7ab41948a9a78dfc511bea9acaa 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -22,6 +22,7 @@ void sb_view_content(View *view, float bounds[4]); void sb_view_set_transform(View *view, float zoom, float x, float y); bool sb_view_tap(View *view, float x, float y); bool sb_view_in_active_text(const View *view, float x, float y); +char *sb_view_edit(View *view); uint32_t sb_text_length(const View *view); char *sb_text(const View *view, uint32_t start, uint32_t end); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index ff9b79c1e1462bd4b14fff642abc2cb85aac528e..da46f88aed76a848482c779d784621f072b549c8 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -14,7 +14,12 @@ use canvas::{ layout::TextEngine, }; use draw::edit::{Key, NamedKey, SelectionUnit}; -use onestore::{RevisionIndex, Store, document::Document, page::Page}; +use onestore::{ + ExGuid, RevisionIndex, Store, + document::Document, + op::{Edit, Op}, + page::Page, +}; use parley::{Affinity, BoundingBox}; use std::{ error::Error, @@ -40,7 +45,8 @@ const COLORS: TextColors = TextColors { }; pub struct Section { - pages: Vec, + /// Each page with the object space holding it. + pages: Vec<(ExGuid, Page)>, titles: Vec, } @@ -52,11 +58,11 @@ impl Section { let pages = document .pages()? .into_iter() - .map(|(space, id)| Page::from_revision(document.active(space)?, id)) - .collect::, _>>()?; + .map(|(space, id)| Ok((space, Page::from_revision(document.active(space)?, id)?))) + .collect::, onestore::Error>>()?; let titles = pages .iter() - .map(|page| CString::new(page.title.replace('\0', ""))) + .map(|(_, page)| CString::new(page.title.replace('\0', ""))) .collect::>()?; Ok(Self { pages, titles }) } @@ -74,6 +80,8 @@ impl Wake for Frame { pub struct View { page: PageView, + /// The object space of the page shown, which its edits name. + space: ExGuid, surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration, renderer: draw::Renderer, @@ -89,7 +97,12 @@ fn moved(response: Response) -> bool { } impl View { - fn new(layer: *mut c_void, page: Page, size: [f32; 2], scale: f32) -> Result { + fn new( + layer: *mut c_void, + (space, page): (ExGuid, Page), + size: [f32; 2], + scale: f32, + ) -> Result { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); // SAFETY: the host passes a live CAMetalLayer that outlives the view. let surface = unsafe { @@ -122,6 +135,7 @@ impl View { scale, Duration::from_millis(350), ), + space, surface, config, renderer, @@ -129,6 +143,27 @@ impl View { }) } + /// What the page took since the last call as one edit, or none when it took nothing. + fn edit(&mut self) -> Result> { + let ops = self.page.editor.take_ops()?; + if ops.is_empty() { + return Ok(None); + } + let unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?; + Ok(Some(Edit { + // FILETIME: 100 ns ticks since 1601. + at: (unix.as_secs() + 11_644_473_600) * 10_000_000 + + u64::from(unix.subsec_nanos() / 100), + ops: ops + .into_iter() + .map(|op| Op::Page { + space: self.space, + op, + }) + .collect(), + })) + } + /// Device pixels per host point. fn display_scale(&self) -> f32 { self.page.viewport.scale / (self.page.zoom() * POINT) @@ -515,8 +550,19 @@ pub extern "C" fn sb_text(view: &View, start: u32, end: u32) -> *mut c_char { .map_or(std::ptr::null_mut(), CString::into_raw) } +/// The edit the page took since the last call, as `onestore::op::Edit` JSON for the +/// section to apply, freed with `sb_string_free`; null when it took none or cannot be +/// stored, in which case the page should be opened again. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_edit(view: &mut View) -> *mut c_char { + report(view.edit()) + .flatten() + .and_then(|edit| CString::new(serde_json::to_string(&edit).ok()?).ok()) + .map_or(std::ptr::null_mut(), CString::into_raw) +} + /// # Safety -/// `text` came from `sb_text` and is not used again. +/// `text` came from `sb_text` or `sb_view_edit` and is not used again. #[unsafe(no_mangle)] pub unsafe extern "C" fn sb_string_free(text: *mut c_char) { drop(unsafe { CString::from_raw(text) }); diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 02f47cf807b27b685101f351a8ee6d8c1992fc9c..aca6350886a28468c7c89088df71beb923831914 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -38,6 +38,10 @@ assert_eq!(reopened.pending()?.last().map(|edit| edit.id), Some(id)); # } ``` +Page reads never wait for the sync thread: a rebase or reread of the queue runs on a +new thread while the current one keeps answering `page` and `pages` from the section as +it was, then hands its requests over; edits sent meanwhile apply to the rebuilt section. + Share one `Replica` between threads. Keep the cache on a local filesystem: the connection holds exclusive ownership between transactions, and a second open fails busy. No network wait occurs in a local edit. After a database error the section diff --git a/crates/notebook/examples/open_probe.rs b/crates/notebook/examples/open_probe.rs new file mode 100644 index 0000000000000000000000000000000000000000..d093914bf64d997dbad657b4246d415fcf8f7639 --- /dev/null +++ b/crates/notebook/examples/open_probe.rs @@ -0,0 +1,236 @@ +//! `open_probe SECTION | --paragraphs N`: times opening a section through a fresh and a +//! reopened cache up to its page list and first page, then how long page reads wait while +//! the sync thread rebases the queue onto a changed remote. + +use notebook::{Remote, Replica, session::Section}; +use onestore::{ + CommitError, ExGuid, Stamp, Transaction, + document::{Format, Layout}, + op::{Edit, Op, PageOp, SectionOp}, + page::{ + Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, + text::new_id, + }, +}; +use std::{ + io, + time::{Duration, Instant}, +}; + +/// A section holding one page of `count` paragraphs. +fn generated(count: usize) -> Result, Box> { + 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} of the probe"), Format::default()), + tags: Vec::new(), + }), + lists: Vec::new(), + tags: Vec::new(), + media: Default::default(), + collapsed: false, + }; + let arena = onestore::Arena::default(); + let mut section = + onestore::Section::open(&arena, onestore::create_section("probe.one", "", "Probe")?)?; + let page = Page { + title: "Probe".into(), + identity: None, + created: None, + margin_origin: [0.0; 2], + objects: vec![PageObject::Outline(Outline { + id: new_id()?, + title: false, + min_width: None, + layout: Layout { + x: Some(36.0), + y: Some(86.4), + ..Layout::default() + }, + indents: Vec::new(), + paragraphs: (0..count).map(paragraph).collect(), + unsupported: Vec::new(), + })], + definitions: Default::default(), + }; + let creation = onestore::PageCreation::new(None, Some("Probe"), "Probe")?; + section.apply( + "Probe", + &Edit { + at: 133_000_000_000_000_000, + ops: vec![Op::Section(SectionOp::Import { creation, page })], + }, + )?; + section.seal()?; + Ok(section.image()) +} + +/// The first text of the page with the most outline paragraphs, and that page's space. +fn text(page: &Page) -> Option { + page.objects.iter().find_map(|object| match object { + PageObject::Outline(outline) => outline + .paragraphs + .iter() + .find_map(|p| p.text().map(|t| t.id)), + _ => None, + }) +} + +struct Memory(Vec); + +impl onestore::CommitIo for Memory { + fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result { + let offset = offset as usize; + let size = output.len().min(self.0.len().saturating_sub(offset)); + output[..size].copy_from_slice(&self.0[offset..offset + size]); + Ok(size) + } + fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result { + let offset = offset as usize; + self.0.resize(self.0.len().max(offset + bytes.len()), 0); + self.0[offset..offset + bytes.len()].copy_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +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 publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { + transaction.commit(self) + } + fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + onestore::confirm_snapshot(self, snapshot) + } +} + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let source = match (args.next().ok_or("SECTION | --paragraphs N")?, args.next()) { + (flag, Some(count)) if flag == "--paragraphs" => generated(count.parse()?)?, + (path, _) => std::fs::read(path)?, + }; + let directory = tempfile::tempdir()?; + let file = directory.path().join("section.one"); + std::fs::write(&file, &source)?; + let cache = directory.path().join("cache"); + println!("section {} bytes", source.len()); + for label in ["fresh cache", "reopened cache"] { + let start = Instant::now(); + let section = Section::open(&file, &cache, || {})?; + let opened = start.elapsed(); + let pages = section.pages()?; + let listed = start.elapsed(); + let page = section.page(pages[0].0)?; + let read = start.elapsed(); + println!( + "{label}: open {opened:.2?}, page list ({} pages) at {listed:.2?}, first page ({} objects) at {read:.2?}", + pages.len(), + page.objects.len() + ); + section.close()?; + } + + // A local edit queued against a remote another writer changed elsewhere: the next + // step rebases, while this thread keeps reading the page list and a page. + let replica = std::sync::Arc::new(Replica::create( + directory.path().join("rebase.sqlite"), + &source, + )?); + let (space, ..) = replica + .pages()? + .into_iter() + .max_by_key(|(space, ..)| { + replica + .page(*space) + .map(|page| page.objects.len()) + .unwrap_or(0) + }) + .ok_or("a page")?; + let target = text(&replica.page(space)?).ok_or("a text")?; + let typed = |with: &str| Edit { + at: 133_000_000_000_000_000, + ops: vec![Op::Page { + space, + op: PageOp::Text { + text: target, + range: 0..0, + with: with.into(), + }, + }], + }; + replica.apply("Probe", typed("Local "))?; + let remote = { + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, source.clone())?; + let root = section.root(); + let other = section + .pages()? + .into_iter() + .map(|(space, ..)| space) + .find(|other| *other != space); + match other.and_then(|other| Some((other, text(§ion.page(other).ok()?)?))) { + Some((other, text)) => section.apply( + "Native", + &Edit { + at: 133_000_000_000_000_000, + ops: vec![Op::Page { + space: other, + op: PageOp::Text { + text, + range: 0..0, + with: "Remote ".into(), + }, + }], + }, + )?, + None => section.apply( + "Native", + &Edit { + at: 133_000_000_000_000_000, + ops: vec![Op::Section(SectionOp::Create(onestore::PageCreation::new( + None, + Some("Remote"), + "Native", + )?))], + }, + )?, + } + let _ = root; + section.seal()?; + section.image() + }; + let syncing = std::sync::Arc::clone(&replica); + let sync = std::thread::spawn(move || { + let start = Instant::now(); + let result = syncing.sync_once(&mut Memory(remote)); + (start.elapsed(), result.map(|synced| synced.edit.is_some())) + }); + let (mut slowest, mut reads) = (Duration::ZERO, 0); + while !sync.is_finished() { + let start = Instant::now(); + replica.pages()?; + replica.page(space)?; + slowest = slowest.max(start.elapsed()); + reads += 1; + } + let (rebase, published) = sync.join().map_err(|_| "the sync step panicked")?; + println!( + "rebase and publish {rebase:.2?} (published: {:?}); {reads} page-list + page reads meanwhile, slowest {slowest:.2?}", + published? + ); + Ok(()) +} diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index 15ed5979a9d09bc29ffcd15f6b9164fc52f14c76..75c9457dbe646828d4687291cc1c9576df7e6a4c 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -16,7 +16,6 @@ use std::{ io, path::Path, sync::{Arc, Mutex, MutexGuard, mpsc}, - thread::JoinHandle, time::Duration, }; @@ -97,8 +96,7 @@ pub enum Resolution { /// exclusive connection retains ownership between local transactions. pub struct Replica { connection: Arc>, - working: Option>, - thread: Option>, + section: Arc, synchronization: Mutex<()>, worker: Arc>>, /// The section's root object space, which names the document. @@ -182,11 +180,10 @@ impl Replica { fn start(connection: Connection) -> Result { let connection = Arc::new(Mutex::new(connection)); let worker = Arc::new(Mutex::new(std::sync::Weak::new())); - let (sender, thread, root) = working::spawn(Arc::clone(&connection), Arc::clone(&worker))?; + let (section, root) = working::spawn(Arc::clone(&connection), Arc::clone(&worker))?; Ok(Self { connection, - working: Some(sender), - thread: Some(thread), + section, synchronization: Mutex::new(()), worker, root, @@ -199,10 +196,7 @@ impl Replica { /// Hands `request` to the section thread. fn send(&self, request: working::Request) -> Result<()> { - self.working - .as_ref() - .and_then(|working| working.send(request).ok()) - .ok_or_else(|| io::Error::other("The section thread stopped").into()) + self.section.send(request) } /// Asks the section thread and waits for its answer. @@ -257,7 +251,8 @@ impl Replica { /// 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> { - self.ask(|reply| working::Request::Image { reply }) + self.ask(|reply| working::Request::Flush { reply })?; + working::image(&*self.lock()?) } /// The section file's identity, which internal links name as `section-id`. @@ -279,10 +274,7 @@ impl Replica { impl Drop for Replica { fn drop(&mut self) { - drop(self.working.take()); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); - } + self.section.stop(); } } diff --git a/crates/notebook/src/merge.rs b/crates/notebook/src/merge.rs index 1d22b0fd051443e7c5906bb349faad6e909ce4d3..c29a016bd5a9f1af16ffb12c22e80e5860801cd5 100644 --- a/crates/notebook/src/merge.rs +++ b/crates/notebook/src/merge.rs @@ -532,6 +532,11 @@ impl Diff { PageOp::Link { text, .. } | PageOp::Equation { text, .. } => { require(self.region(*text)?.is_none())?; } + PageOp::Date { fields, .. } => { + for (text, _) in fields.iter() { + require(self.region(*text)?.is_none())?; + } + } PageOp::Split { text, at, right, .. } => { diff --git a/crates/notebook/src/working.rs b/crates/notebook/src/working.rs index 6a2411809aec020e3182584f7a1697f89079641e..ec937105e69c6ba4a4d9fba33a3483b46e8ed16d 100644 --- a/crates/notebook/src/working.rs +++ b/crates/notebook/src/working.rs @@ -44,8 +44,9 @@ pub(crate) enum Request { Pages { reply: Reply>, }, - Image { - reply: Reply>, + /// Answers once the edits before it are written. + Flush { + reply: Reply<()>, }, /// Seals the open batch, when no sealed batch waits for publication, recording the /// attempt to publish it. @@ -62,8 +63,15 @@ pub(crate) enum Request { Reopen { reply: Reply<()>, }, + /// From the thread that rebuilt the section: hand it the requests. + Handover(mpsc::SyncSender), + /// From the thread that rebuilt nothing: carry on with the section as it is. + Resume, } +/// The request channel and the requests held while the section was rebuilt. +type Takeover = (mpsc::Receiver, VecDeque); + /// A sealed batch: the transaction publishing it, or none when its edits changed nothing. pub(crate) struct Sealed { pub batch: i64, @@ -71,7 +79,7 @@ pub(crate) struct Sealed { } /// A conflict's resolution: the batch it holds and every later edit drop their ops on -/// `space`; `Mine` first rewrites the rebased page to the local one (or to `page`). +/// `space`; `Mine` first rewrites the rebased page to `page`, the local one unless given. pub(crate) struct Resolve { pub first: u64, pub space: ExGuid, @@ -98,34 +106,88 @@ impl Request { Self::Save { reply, .. } => reply(Err(error())), Self::Page { reply, .. } => reply(Err(error())), Self::Pages { reply } => reply(Err(error())), - Self::Image { reply } => reply(Err(error())), + Self::Flush { reply } => reply(Err(error())), Self::Seal { reply } => reply(Err(error())), Self::Rebase { reply, .. } => reply(Err(error())), Self::Reopen { reply } => reply(Err(error())), + Self::Handover(_) | Self::Resume => {} } } } type Worker = Arc>>; +/// The section thread as the replica sees it. Rereading the section after the queue was +/// replaced (a rebase, a released attempt) happens on a new thread while the current one +/// keeps answering page reads from the section as it was; the new thread then takes the +/// requests over, so reads never wait for a rebuild. +pub(crate) struct Thread { + connection: Arc>, + worker: Worker, + /// Taken when the replica drops, which ends every section thread. + sender: Mutex>>, + threads: Mutex>>, +} + +impl Thread { + pub(crate) fn send(&self, request: Request) -> Result<()> { + self.sender + .lock() + .ok() + .and_then(|sender| sender.as_ref()?.send(request).ok()) + .ok_or_else(|| io::Error::other("The section thread stopped").into()) + } + + /// Ends the section threads and waits for them, so the cache is released. + pub(crate) fn stop(&self) { + if let Ok(mut sender) = self.sender.lock() { + sender.take(); + } + while let Some(thread) = self + .threads + .lock() + .ok() + .and_then(|mut threads| threads.pop()) + { + let _ = thread.join(); + } + } + + fn start(self: &Arc, run: impl FnOnce(Arc) + Send + 'static) -> Result<()> { + let shared = Arc::clone(self); + let thread = thread::Builder::new() + .name("onestore-section".into()) + .spawn(move || run(shared))?; + self.threads + .lock() + .map_err(|_| io::Error::other("The section thread panicked"))? + .push(thread); + Ok(()) + } +} + /// Starts the section thread once the queue opens. pub(crate) fn spawn( connection: Arc>, worker: Worker, -) -> Result<(mpsc::Sender, thread::JoinHandle<()>, ExGuid)> { - let (sender, receiver) = mpsc::channel(); +) -> Result<(Arc, ExGuid)> { + let (sender, requests) = mpsc::channel(); + let shared = Arc::new(Thread { + connection, + worker, + sender: Mutex::new(Some(sender)), + threads: Mutex::new(Vec::new()), + }); let (ready, opened) = mpsc::sync_channel(1); - let thread = thread::Builder::new() - .name("onestore-section".into()) - .spawn(move || run(&connection, &worker, &receiver, ready))?; + shared.start(move |shared| run(shared, requests, VecDeque::new(), Some(ready)))?; match opened.recv() { - Ok(Ok(root)) => Ok((sender, thread, root)), + Ok(Ok(root)) => Ok((shared, root)), Ok(Err(error)) => { - let _ = thread.join(); + shared.stop(); Err(error) } Err(_) => { - let _ = thread.join(); + shared.stop(); Err(io::Error::other("The section thread panicked").into()) } } @@ -134,28 +196,24 @@ pub(crate) fn spawn( enum Next { Stop, Reopen, + Handover(mpsc::SyncSender, VecDeque), } fn run( - connection: &Mutex, - worker: &Worker, - requests: &mpsc::Receiver, - ready: mpsc::SyncSender>, + shared: Arc, + requests: mpsc::Receiver, + mut backlog: VecDeque, + mut ready: Option>>, ) { - let mut ready = Some(ready); - let mut backlog = VecDeque::new(); loop { let arena = Arena::default(); - let working = match Working::open(&arena, connection) { + let working = match Working::open(&arena, &shared.connection) { Ok(working) => working, Err(error) => { - let message = error.to_string(); if let Some(ready) = ready.take() { let _ = ready.send(Err(error)); - return; - } - for request in backlog.drain(..).chain(requests.iter()) { - request.fail(&message); + } else { + fail(error, backlog, requests); } return; } @@ -163,9 +221,75 @@ fn run( if let Some(ready) = ready.take() { let _ = ready.send(Ok(working.section.root())); } - match working.serve(connection, worker, requests, &mut backlog) { + match working.serve(&shared, &requests, &mut backlog) { Next::Stop => return, Next::Reopen => {} + Next::Handover(to, held) => { + let _ = to.send((requests, held)); + return; + } + } + } +} + +/// Answers every request with `error` until the replica drops. +fn fail(error: crate::Error, backlog: VecDeque, requests: mpsc::Receiver) { + let message = error.to_string(); + for request in backlog.into_iter().chain(requests.iter()) { + request.fail(&message); + } +} + +/// What a rebuilding thread does before it rereads the section. +enum Job { + Rebase { + image: Option>, + resolve: Option, + reply: Reply, + }, + Reopen { + reply: Reply<()>, + }, +} + +/// 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. +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); + let _ = signal.send(Request::Resume); + return; + } + }, + }; + let arena = Arena::default(); + let opened = Working::open(&arena, &shared.connection); + let (to, from) = mpsc::sync_channel(1); + if signal.send(Request::Handover(to)).is_err() { + return; + } + drop(signal); + answer(); + let Ok((requests, mut backlog)) = from.recv() else { + return; + }; + let working = match opened { + Ok(working) => working, + Err(error) => return fail(error, backlog, requests), + }; + match working.serve(&shared, &requests, &mut backlog) { + Next::Stop => {} + Next::Reopen => run(shared, requests, backlog, None), + Next::Handover(to, held) => { + let _ = to.send((requests, held)); } } } @@ -214,11 +338,14 @@ impl<'a> Working<'a> { fn serve( mut self, - connection: &Mutex, - worker: &Worker, + shared: &Arc, requests: &mpsc::Receiver, backlog: &mut VecDeque, ) -> Next { + let (connection, worker) = (&*shared.connection, &shared.worker); + // While another thread rebuilds the section, reads answer from this one and every + // other request waits for the rebuilt section. + let mut held: Option> = None; loop { let first = match backlog.pop_front() { Some(request) => request, @@ -231,7 +358,28 @@ impl<'a> Working<'a> { burst.extend(backlog.drain(..)); burst.extend(requests.try_iter()); let mut accepted = Vec::new(); - while let Some(request) = burst.pop_front() { + // Requests that write the queue beyond the burst's edits run after its reads. + let mut later = VecDeque::new(); + while let Some(request) = burst.pop_front().or_else(|| later.pop_front()) { + if let Some(waiting) = &mut held { + match request { + Request::Page { space, reply } => { + reply(self.section.page(space).map_err(Into::into)) + } + Request::Pages { reply } => reply(self.section.pages().map_err(Into::into)), + Request::Handover(to) => { + let mut waiting = held.take().unwrap_or_default(); + waiting.extend(burst.drain(..).chain(later.drain(..))); + return Next::Handover(to, waiting); + } + Request::Resume => { + let waiting = held.take().unwrap_or_default(); + burst = waiting.into_iter().chain(burst.drain(..)).collect(); + } + other => waiting.push_back(other), + } + continue; + } let reopen = match request { Request::Apply { author, @@ -326,16 +474,20 @@ impl<'a> Working<'a> { reply(self.section.pages().map_err(Into::into)); false } - Request::Image { reply } => { - if self.flush(connection, worker, &mut accepted) { - reply(lock(connection).and_then(|connection| image(&connection))); - false + request @ (Request::Flush { .. } | Request::Seal { .. }) + if !burst.is_empty() => + { + later.push_back(request); + false + } + Request::Flush { reply } => { + let written = self.flush(connection, worker, &mut accepted); + reply(if written { + Ok(()) } else { - reply(Err( - io::Error::other("The queue could not be written").into() - )); - true - } + Err(io::Error::other("The queue could not be written").into()) + }); + !written } Request::Seal { reply } => { if !self.flush(connection, worker, &mut accepted) { @@ -352,29 +504,61 @@ impl<'a> Working<'a> { } Request::Rebase { image, - resolve, + mut resolve, reply, } => { - if !self.flush(connection, worker, &mut accepted) { - reply(Err( - io::Error::other("The queue could not be written").into() - )); - true - } else { - let rebased = self.rebase(connection, image, resolve); - let reopen = !matches!(rebased, Ok(Rebased::Conflict { .. })); - reply(rebased); - reopen + 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::Reopen { reply } => { self.flush(connection, worker, &mut accepted); - reply(Ok(())); - true + held = self + .rebuild(shared, Job::Reopen { reply }) + .then(VecDeque::new); + false } + Request::Handover(_) | Request::Resume => false, }; if reopen { - backlog.extend(burst); + backlog.extend(burst.drain(..).chain(later.drain(..))); return Next::Reopen; } } @@ -384,6 +568,30 @@ impl<'a> Working<'a> { } } + /// Starts a thread that runs `job` and rereads the section; false when the replica is + /// stopping, which answers the job's request. + fn rebuild(&self, shared: &Arc, job: Job) -> bool { + let signal = shared + .sender + .lock() + .ok() + .and_then(|sender| sender.as_ref().cloned()); + let Some(signal) = signal else { + match job { + Job::Rebase { reply, .. } => { + reply(Err(io::Error::other("The section thread stopped").into())) + } + Job::Reopen { reply } => { + reply(Err(io::Error::other("The section thread stopped").into())) + } + } + return false; + }; + shared + .start(move |shared| build(shared, job, signal)) + .is_ok() + } + /// Applies an edit, keeping it to be written with the burst. A refused edit is answered /// here; `Err(true)` when it failed part way and the section must be reread. fn accept( @@ -536,150 +744,145 @@ impl<'a> Working<'a> { self.touched.clear(); Ok(Some(Sealed { batch, transaction })) } +} - /// Replays every queued edit on `image`, keeping it as the base when all apply. - fn rebase( - &mut self, - connection: &Mutex, - image: Option>, - resolve: Option, - ) -> Result { - let (base_image, image, edits) = { - let connection = lock(connection)?; - if connection.query_row( - "SELECT EXISTS(SELECT 1 FROM batches WHERE attempted=1)", - [], - |row| row.get::<_, bool>(0), - )? { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "An uncertain attempt is never rebased", - ) - .into()); +/// Replays every queued edit on `image`, keeping it as the base when all apply. +fn rebase( + connection: &Mutex, + image: Option>, + resolve: Option, +) -> Result { + let (base_image, image, edits) = { + let connection = lock(connection)?; + if connection.query_row( + "SELECT EXISTS(SELECT 1 FROM batches WHERE attempted=1)", + [], + |row| row.get::<_, bool>(0), + )? { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "An uncertain attempt is never rebased", + ) + .into()); + } + let base_image = base::read(&connection, base::Image::Base)?.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") + })?; + let image = match image { + Some(image) => image, + None => { + base::read(&connection, base::Image::Remote)?.unwrap_or_else(|| base_image.clone()) } - let base_image = base::read(&connection, base::Image::Base)?.ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") - })?; - let image = match image { - Some(image) => image, - None => base::read(&connection, base::Image::Remote)? - .unwrap_or_else(|| base_image.clone()), - }; - (base_image, image, queue::load(&connection, None)?) }; - // The page the local edits leave, which `Mine` rewrites the remote page to; the - // root space lists pages and holds none. - let target = match &resolve { - Some(Resolve { - keep: Resolution::Mine, - page: None, - space, - .. - }) if *space != self.section.root() => Some(self.section.page(*space)?), - Some(Resolve { page, .. }) => page.clone(), - None => None, - }; - let old_arena = Arena::default(); - let mut old = Section::open(&old_arena, base_image.clone())?; - let before: BTreeMap = old.revisions().collect(); - let mut after: BTreeMap; - // Pages the remote already holds as the local edits leave them: their ops are done. - let mut converged = BTreeSet::new(); - let outcome = 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()); - } - 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: target.as_ref(), - }), + (base_image, image, queue::load(&connection, None)?) + }; + let old_arena = Arena::default(); + let mut old = Section::open(&old_arena, base_image.clone())?; + let before: BTreeMap = old.revisions().collect(); + let mut after: BTreeMap; + // Pages the remote already holds as the local edits leave them: their ops are done. + let mut converged = BTreeSet::new(); + let outcome = 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()); + } + 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); + continue; + } + break outcome; + }; + 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 let merge::Outcome::Conflict { space, .. } = &outcome - && !converged.contains(space) - && let Ok(local) = self.section.page(*space) - && Section::open(&Arena::default(), image.clone())? - .page(*space) - .is_ok_and(|remote| remote == local) - { - converged.insert(*space); - continue; - } - break outcome; - }; - 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)?; + if 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(), - } + } else { + base::write(&transaction, base::Image::Remote, &image)?; } - }; - transaction.commit()?; - Ok(rebased) - } + // 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(), + } + } + }; + 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 { diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index 0592efe480ceee679ec5bdf9b4918d37e6cc7e73..1f3800abc52273f37826eb2687a9ee06b76c0fdb 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -115,7 +115,12 @@ fn a_section_opens_through_its_replica_and_a_save_reaches_the_file_and_survives_ }; assert_same(section.page(space).unwrap(), &after); published(§ion, id); - assert!(notified.load(Ordering::SeqCst) > 0); + // The worker reports the step after writing its receipt. + let deadline = Instant::now() + Duration::from_secs(20); + while notified.load(Ordering::SeqCst) == 0 { + assert!(Instant::now() < deadline, "the host was not woken"); + std::thread::sleep(Duration::from_millis(10)); + } assert_same(stored_page(&file, space), &after); assert!(matches!( section.status(id).unwrap(), diff --git a/crates/onestore/src/edit.rs b/crates/onestore/src/edit.rs index 61be7cc4df0f8faf574b328f7131f39bed057223..4e97286956a4152a579493ae92dca906b56380a4 100644 --- a/crates/onestore/src/edit.rs +++ b/crates/onestore/src/edit.rs @@ -53,6 +53,58 @@ pub(crate) fn text_changes( object: ExGuid, range: Range, replacement: &str, +) -> Result { + rewrite(active, object, range, replacement, false) +} + +/// The title's date or time field showing `text`, as OneNote 2010 rewrites both when a +/// page's date changes: the field's element takes the change time as its creation time. +pub(crate) fn field_changes( + active: &ActivePage<'_>, + object: ExGuid, + text: &str, +) -> Result { + let length = crate::page::text_of(&active.view, &active.parents, object)? + .text() + .encode_utf16() + .count(); + let mut changes = rewrite(active, object, 0..length as u32, text, true)?; + let [element] = active + .parents + .get(&object) + .map(Vec::as_slice) + .unwrap_or_default() + else { + return Err(Error { + offset: 0, + message: "Select a date field belonging to one element", + }); + }; + // OneNote 2010 dates the field's element and outline and leaves the title and page + // above them as they were. + let outline = active + .parents + .get(element) + .and_then(|parents| parents.first()) + .copied(); + changes.retain(|id, _| *id == object || id == element || Some(*id) == outline); + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + let element = match changes.entry(*element) { + std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::btree_map::Entry::Vacant(entry) => entry.insert( + crate::write::PropertyObject::from_object(&active.live.revision.objects[element])?, + ), + }; + element.set(&[(0x14001d09, &modified)])?; + Ok(changes) +} + +fn rewrite( + active: &ActivePage<'_>, + object: ExGuid, + range: Range, + replacement: &str, + field: bool, ) -> Result { let invalid = |message| Error { offset: 0, message }; let raw = &active.live.revision; @@ -60,7 +112,12 @@ pub(crate) fn text_changes( return Err(invalid("Object is not reachable in the active revision")); } let revision = &active.view; - let parents = active.editable_parents(object)?; + // Generated fields are read-only to ordinary edits; only a date change rewrites them. + let parents = if field { + &active.parents + } else { + active.editable_parents(object)? + }; let node = revision .nodes .get(&object) @@ -74,10 +131,12 @@ pub(crate) fn text_changes( else { return Err(invalid("Select a rich-text object")); }; - if *boilerplate { - return Err(invalid( - "Generated title fields cannot be edited as ordinary text", - )); + if *boilerplate != field { + return Err(invalid(if field { + "Select the title's date or time" + } else { + "Generated title fields cannot be edited as ordinary text" + })); } let selected = runs .iter() @@ -103,7 +162,7 @@ pub(crate) fn text_changes( let properties = PropertySets::parse(blob)?; if properties.sets[0] .iter() - .any(|p| p.id == 0x88001cde || p.id == 0x24003458) + .any(|p| (p.id == 0x88001cde && !field) || p.id == 0x24003458) { return Err(invalid( "This text object is read-only or contains associated run data", diff --git a/crates/onestore/src/op/apply.rs b/crates/onestore/src/op/apply.rs index dbbe31ef9e3761bc88b6fa968da0291194759b60..d82c371bd17b36a2e86298e7d4b2981d446390cc 100644 --- a/crates/onestore/src/op/apply.rs +++ b/crates/onestore/src/op/apply.rs @@ -326,6 +326,28 @@ impl<'a> Writer<'_, 'a> { self.target(*text)?; self.write(|page| content::equation_changes(page, *text, math)) } + PageOp::Date { created, fields } => { + for (text, shown) in fields { + self.target(*text)?; + if shown.contains(['\0', '\n', '\r', '\u{fffc}']) { + return Err(OpError::Unsupported("Use a date as one line of text").into()); + } + self.write(|page| crate::edit::field_changes(page, *text, shown))?; + } + self.write(|page| { + let metadata = page + .view + .roots + .get(&2) + .filter(|id| matches!(page.view.nodes[id].kind, Kind::Metadata { .. })) + .ok_or_else(|| invalid("The page has no metadata to date"))?; + let mut object = crate::write::PropertyObject::from_object( + &page.live.revision.objects[metadata], + )?; + object.set(&[(0x18001c65, &created.to_le_bytes())])?; + Ok(BTreeMap::from([(*metadata, object)])) + }) + } PageOp::Insert { container, before, diff --git a/crates/onestore/src/op/lower.rs b/crates/onestore/src/op/lower.rs index ccdb388c0eef145e51c55b07c561ea0334be69e9..249ac6809d2244831cbf5feb195cf5cc73494ed8 100644 --- a/crates/onestore/src/op/lower.rs +++ b/crates/onestore/src/op/lower.rs @@ -236,8 +236,8 @@ pub(crate) fn validate( old: &View<'_>, new: &View<'_>, ) -> Result<(), Error> { - if after.created != before.created || after.margin_origin != before.margin_origin { - return Err(invalid("Page creation time and margins cannot be edited")); + if after.margin_origin != before.margin_origin { + return Err(invalid("Page margins cannot be edited")); } for (id, definition) in &after.definitions { if before @@ -806,6 +806,7 @@ impl Lowering { self.delete(old, new, &consumed)?; self.levels(new)?; self.equations(new)?; + self.date(before, after)?; self.text(new)?; self.styles(after, new)?; self.lists(after, new)?; @@ -832,7 +833,10 @@ impl Lowering { 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 { + } else if let Some(left) = left + // A new cell arrives with its paragraphs. + && old.children.contains_key(&root(new, paragraph.id)) + { let container = new.container[¶graph.id]; let at = new.children[&container] .iter() @@ -1542,6 +1546,24 @@ impl Lowering { Ok(()) } + /// The page's date, with every date and time field of the title showing it. + fn date(&mut self, before: &Page, after: &Page) -> Result<(), Error> { + let fields: Vec<(ExGuid, String)> = model::date_fields(after) + .into_iter() + .map(|text| (text.id, text.text.text().to_owned())) + .collect(); + let shown = fields.iter().all(|(id, shown)| { + model::paragraph_text(&self.current, *id).is_some_and(|text| text.text.text() == shown) + }); + if before.created == after.created && shown { + return Ok(()); + } + let created = after + .created + .ok_or_else(|| invalid("A page date cannot be removed"))?; + self.emit(PageOp::Date { created, fields }) + } + fn text(&mut self, new: &View<'_>) -> Result<(), Error> { for (id, paragraph) in &new.paragraphs { let Some(text) = paragraph.text() else { diff --git a/crates/onestore/src/op/mod.rs b/crates/onestore/src/op/mod.rs index ce0ac5b4bfda3a172ad7e216be2c8c1c3b79dca2..32eefd577750ba90de58345808041dca1fb91851 100644 --- a/crates/onestore/src/op/mod.rs +++ b/crates/onestore/src/op/mod.rs @@ -22,6 +22,9 @@ pub(crate) mod table; mod tests; pub use lower::{lower, lower_page}; +/// `op` applied to `page`, the model of what the section holds: the page `Section::apply` +/// leaves, as reading it back shows it. +pub use model::apply as predict; /// Property identifiers with their encoded values. pub(crate) type Values = Vec<(u32, Vec)>; @@ -71,6 +74,12 @@ pub enum PageOp { }, /// Rewrites an equation's linear text and spans whole. Equation { text: ExGuid, math: Paragraph }, + /// The page's date: its creation time, FILETIME, and the text each of the title's date + /// and time fields shows for it, as OneNote 2010 writes both when the date changes. + Date { + created: u64, + fields: Vec<(ExGuid, String)>, + }, /// 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. diff --git a/crates/onestore/src/op/model.rs b/crates/onestore/src/op/model.rs index c6e0a7b86edaffe4e27a7a1e5d6da04a485f03ec..474982b04a5f52b4a6e518f2b65fa24b3e8be954 100644 --- a/crates/onestore/src/op/model.rs +++ b/crates/onestore/src/op/model.rs @@ -203,6 +203,29 @@ fn text_holder(page: &Page, text: ExGuid) -> Option<(Path, usize)> { }) } +/// The text of the title's date and time fields, in order. +pub(crate) fn date_fields(page: &Page) -> Vec<&TextObject> { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Title(title) => { + let date = title.date?; + title.outlines.iter().find(|outline| outline.id == date) + } + _ => None, + }) + .flat_map(|outline| outline.paragraphs.iter().filter_map(PageParagraph::text)) + .collect() +} + +/// Text object `text` wherever the page holds it. +pub(crate) fn paragraph_text(page: &Page, text: ExGuid) -> Option<&TextObject> { + lists(page).into_iter().find_map(|(_, _, list)| { + list.iter() + .find_map(|paragraph| paragraph.text().filter(|object| object.id == text)) + }) +} + fn text_mut(page: &mut Page, text: ExGuid) -> Result<&mut TextObject, Error> { let (path, index) = text_holder(page, text).ok_or_else(|| invalid("Text is not on the page"))?; Ok(list_at(page, &path)[index].text_mut().unwrap()) @@ -547,7 +570,7 @@ fn automatic(page: &mut Page) { } /// Applies `op` to `page` as reading the page back after `Section::apply` would show it. -pub(crate) fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> { +pub fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> { interpret(page, op)?; // A page lists the definitions its paragraphs reference. let mut referenced = BTreeSet::new(); @@ -569,6 +592,17 @@ pub(crate) fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> { fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { match op { + PageOp::Date { created, fields } => { + for (text, shown) in fields { + if !date_fields(page).iter().any(|field| field.id == *text) { + return Err(invalid("Select the title's date or time")); + } + let object = text_mut(page, *text)?; + let format = object.text.spans()[0].format.clone(); + object.text = Paragraph::new(shown.clone(), format); + } + page.created = Some(*created); + } PageOp::Text { text, range, with } => { let object = text_mut(page, *text)?; object.text = replace_text(&object.text, range.clone(), with)?; diff --git a/crates/onestore/src/op/tests.rs b/crates/onestore/src/op/tests.rs index 0928cf607cc8a7963b7a502fb866267280495ecf..4501ae30eb9dadd83ce5bbb2c3e23f7027025eab 100644 --- a/crates/onestore/src/op/tests.rs +++ b/crates/onestore/src/op/tests.rs @@ -2148,3 +2148,101 @@ fn op_gate_candidate() { std::fs::write(directory.join("expected-count.txt"), count.to_string()).unwrap(); println!("{} imported pages, {edits} lowered edits, {count} pages", imported.len()); } + +/// OneNote 2010's own date change (`corpus/page-date/native`, COM `UpdatePageContent` +/// with a new `dateTime`): the page's second page, "Changed date", took it; "Kept date" +/// did not. +const DATED: &[u8] = include_bytes!("../../../../corpus/page-date/native/notebook/pictures.one"); + +/// The objects of `space`'s active revision that differ from its previous one, by jcid. +fn changed_jcids(image: &[u8], space: ExGuid) -> Vec { + let store = Store::parse(image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let active = index.active(space).unwrap(); + let previous = index.spaces[&space].revisions[&active].dependency.unwrap(); + let (old, new) = ( + index.resolve(space, previous).unwrap(), + index.resolve(space, active).unwrap(), + ); + let mut jcids: Vec = new + .objects + .iter() + .filter(|(id, object)| { + old.objects.get(id).is_none_or(|old| { + format!("{:?}", old.data) != format!("{:?}", object.data) + }) + }) + .map(|(_, object)| object.jcid) + .collect(); + jcids.sort(); + jcids +} + +/// The title's date and time fields of `page`, by text object. +fn date_fields(page: &Page) -> Vec<(ExGuid, String)> { + model::date_fields(page) + .into_iter() + .map(|text| (text.id, text.text.text().to_owned())) + .collect() +} + +/// FILETIME of 2025-07-04 16:45 UTC, which a Pacific clock shows as 9:45 AM. +const DATED_AT: u64 = 133_961_211_000_000_000; + +/// The `Date` op writes what OneNote writes for a new page date: the metadata's creation +/// time, the date and time field text, and the modification times above them. +#[test] +fn a_page_date_stores_what_onenote_stores() { + let spaces = pages(DATED); + let title = |space: &ExGuid| read(DATED, *space).title; + let kept = *spaces.iter().find(|space| title(space) == "Kept date").unwrap(); + let changed = *spaces.iter().find(|space| title(space) == "Changed date").unwrap(); + let native = changed_jcids(DATED, changed); + assert_eq!(native, [0x20030, 0x6000c, 0x6000d, 0x6000d, 0x6000e, 0x6000e]); + + let arena = Arena::default(); + let mut section = Section::open(&arena, DATED.to_vec()).unwrap(); + let before = section.page(kept).unwrap(); + // The date field precedes the time field. + let fields: Vec<(ExGuid, String)> = date_fields(&before) + .into_iter() + .zip(["Friday, July 04, 2025", "9:45 AM"]) + .map(|((id, _), shown)| (id, shown.to_owned())) + .collect(); + assert_eq!(fields.len(), 2); + let op = PageOp::Date { + created: DATED_AT, + fields: fields.clone(), + }; + let mut predicted = before.clone(); + model::apply(&mut predicted, &op).unwrap(); + section + .apply("Author", &Edit { at: AT, ops: vec![Op::Page { space: kept, op }] }) + .unwrap(); + section.seal().unwrap(); + let image = section.image(); + let stored = read(&image, kept); + assert_eq!(stored.created, Some(DATED_AT)); + assert_eq!(date_fields(&stored), fields); + assert_eq!(stored, predicted); + assert_eq!(changed_jcids(&image, kept), native); + let store = Store::parse(&image).unwrap(); + RevisionIndex::parse(&store).unwrap().validate_current().unwrap(); + + // The whole-page lowering reaches the same page through the same op. + let mut after = before.clone(); + after.created = Some(DATED_AT); + model::apply(&mut after, &PageOp::Date { created: DATED_AT, fields }).unwrap(); + assert!(matches!(&lower_page(&before, &after).unwrap()[..], [PageOp::Date { .. }])); + + if let Some(directory) = std::env::var_os("ONESTORE_PAGE_DATE_EXPORT") { + let directory = std::path::Path::new(&directory); + std::fs::create_dir_all(directory).unwrap(); + std::fs::write(directory.join("pictures.one"), &image).unwrap(); + std::fs::copy( + concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/page-date/native/notebook/Open Notebook.onetoc2"), + directory.join("Open Notebook.onetoc2"), + ) + .unwrap(); + } +} diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index 7a1190d904d25ea88ade3b8888b213a679f24e1e..edc1011d82cc4fe8892b6ae0a304ad74c389f8fc 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -142,6 +142,9 @@ impl<'a> Lowering<'a> { 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) diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 929ef7ede236b3c0d095f721b68670465b1d7169..f35307d3c5254383e11aae8099e1b51856e0b0b8 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -458,6 +458,20 @@ pub fn discard_changes() -> bool { } } +/// Tells the user an edit could not be stored and where their text went. +pub fn change_not_saved() { + let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); + // The alert and its strings stay on AppKit's main thread for the modal call. + unsafe { + let alert = NSAlert::new(mtm); + alert.setMessageText(&NSString::from_str("Change not saved")); + alert.setInformativeText(&NSString::from_str( + "The page now shows what was last saved. Your text is on the clipboard to paste back.", + )); + alert.runModal(); + } +} + /// End AppKit preedit after a canvas action commits or cancels the composition. pub fn clear_marked_text(window: &Window) { MainThreadMarker::new().expect("Text input belongs to the main thread"); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 3a1aecb10e056809f7a4104083624c40539da393..161ee6900abc621c0cca0d6cf641c6f9dc2c0239 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -198,14 +198,13 @@ impl Library { } } -/// The open section and the stored model the editor's page was loaded from. +/// The open section and the page the editor shows. struct Session { section: notebook::session::Section, tab: usize, /// Spaces, titles and outline levels in section order. pages: Vec<(ExGuid, String, u32)>, space: ExGuid, - before: Page, status: &'static str, /// The editor's page has an unreviewed conflict with another machine's change. conflict: bool, @@ -222,21 +221,20 @@ impl Session { fn refresh_conflict(&mut self) -> Result<(), Box> { self.conflict = self .section - .conflicts()? - .iter() - .any(|(edit, _)| edit.space == self.space); + .conflict()? + .is_some_and(|conflict| conflict.space == self.space); Ok(()) } } -/// A section or page read on a loader thread. +/// What a loader thread opened: a section, or another page of the open one. enum Loaded { - Section(Session), - Page { space: ExGuid, page: Page }, + Section(Box), + Page(ExGuid), } -/// A loader thread's request number and what it read. -type Read = (u64, Result); +/// A loader thread's request number, what it opened and the page it read. +type Read = (u64, Result<(Loaded, Page), String>); /// The newest page read, laid out and waiting for the pictures it shows first, so it /// never appears without them. @@ -289,6 +287,9 @@ struct State { changed: bool, /// Only the view scrolled or zoomed during this frame, to be announced after it. moved: bool, + /// The stored page changed elsewhere while marked text was pending; it is shown once + /// the composition ends. + stale: bool, /// Whether the page last heard it had keyboard focus. page_focused: bool, /// The pointer in logical pixels, for window drags from the strip. @@ -425,8 +426,8 @@ impl State { .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))? .0; library = Some(Arc::new(opened)); - let opened = read_session(section, 0, Some(space))?; - let (scene, editor) = PageScene::from_page(opened.before.clone(), &mut engine)?; + let (opened, page) = read_session(section, 0, Some(space))?; + let (scene, editor) = PageScene::from_page(page, &mut engine)?; session = Some(opened); (editor, Some((scene, [0.0; 2]))) } @@ -444,8 +445,8 @@ impl State { }; let section = opened.open(0, proxy.clone())?; library = Some(Arc::new(opened)); - let opened = read_session(section, 0, None)?; - let (scene, editor) = PageScene::from_page(opened.before.clone(), &mut engine)?; + let (opened, page) = read_session(section, 0, None)?; + let (scene, editor) = PageScene::from_page(page, &mut engine)?; session = Some(opened); (editor, Some((scene, [0.0; 2]))) } @@ -511,6 +512,7 @@ impl State { commands: Vec::new(), changed: false, moved: false, + stale: false, page_focused: true, pointer: [0.0; 2], strip_press: None, @@ -587,6 +589,9 @@ impl State { self.after_move()?; } self.moved = false; + if self.stale { + self.refresh()?; + } // A covered window shows nothing, so animations wait for it to be uncovered. if follow && !self.occluded { self.window.request_redraw(); @@ -1483,18 +1488,14 @@ impl State { let proxy = self.proxy.clone(); self.load(move || { let section = library.open(tab, proxy)?; - Ok(Loaded::Section(read_session(section, tab, None)?)) + let (session, page) = read_session(section, tab, None)?; + Ok((Loaded::Section(Box::new(session)), page)) }); } Command::OpenPage(space) => { let session = self.session.as_ref().ok_or("No section is open")?; let replica = Arc::clone(session.section.replica()); - self.load(move || { - Ok(Loaded::Page { - space, - page: replica.page(space)?, - }) - }); + self.load(move || Ok((Loaded::Page(space), replica.page(space)?))); } Command::Resolve { keep_mine } => self.resolve_conflict(keep_mine)?, Command::Page(Request::EditDate(field)) => self.edit_date(field)?, @@ -1512,7 +1513,10 @@ impl State { /// Runs `read` on a thread of its own; `open_loaded` shows what it read unless a newer /// read was asked for meanwhile. - fn load(&mut self, read: impl FnOnce() -> Result> + Send + 'static) { + fn load( + &mut self, + read: impl FnOnce() -> Result<(Loaded, Page), Box> + Send + 'static, + ) { self.loading += 1; let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone()); std::thread::spawn(move || { @@ -1528,12 +1532,8 @@ impl State { if id != self.loading { continue; } - let loaded = loaded?; - let page = match &loaded { - Loaded::Section(session) => &session.before, - Loaded::Page { page, .. } => page, - }; - let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?; + let (loaded, page) = loaded?; + let (scene, editor) = PageScene::from_page(page, &mut self.view.engine)?; self.opening = Some(Opening { loaded, scene: (scene, [0.0; 2]), @@ -1556,15 +1556,16 @@ impl State { self.opening = Some(opening); return Ok(()); } + // The page shown so far keeps what was typed while the next one loaded. + self.persist()?; match opening.loaded { Loaded::Section(session) => { - self.session = Some(session); + self.session = Some(*session); self.filter.clear(); } - Loaded::Page { space, page } => { + Loaded::Page(space) => { let session = self.session.as_mut().ok_or("No section is open")?; session.space = space; - session.before = page; session.refresh_conflict()?; } } @@ -1663,104 +1664,131 @@ impl State { Ok(()) } - /// Saves the edited page to the section's replica; a page changed underneath the - /// editor is reloaded in place of the edit. + /// Hands the section the ops the editor recorded since the last call, as one edit; the + /// section thread stores it. fn persist(&mut self) -> Result<(), Box> { + let ops = match self.view.editor.take_ops() { + Ok(ops) => ops, + Err(error) => return self.refused(&error.to_string()), + }; let Some(session) = &mut self.session else { return Ok(()); }; - let after = self.view.editor.page()?; - if after == session.before { + if ops.is_empty() { return Ok(()); } - if let Some(entry) = session - .pages - .iter_mut() - .find(|(space, ..)| *space == session.space) - { - entry.1.clone_from(&after.title); - } - // Queued saves chain: each one's `after` is the next one's `before`. - let before = std::mem::replace(&mut session.before, after.clone()); - session - .section - .queue_save(session.space, before, after, "snowbound")?; + let space = session.space; + session.section.apply( + "snowbound", + onestore::op::Edit { + at: filetime(), + ops: ops + .into_iter() + .map(|op| onestore::op::Op::Page { space, op }) + .collect(), + }, + )?; + session.status = "Saving"; Ok(()) } - /// Replaces the editor with the page currently stored in the section. - fn reload(&mut self) -> Result<(), Box> { - let Some(session) = &mut self.session else { + /// Shows the page as stored after a change made elsewhere, in place: the caret, + /// selection, scroll and drawn pictures stay. Waits for marked text to end. + fn refresh(&mut self) -> Result<(), Box> { + if self.view.editor.marked_range().is_some() { + self.stale = true; + return Ok(()); + } + self.stale = false; + self.persist()?; + let Some(session) = &self.session else { return Ok(()); }; let page = session.section.page(session.space)?; - let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?; - session.before = page; - self.view.replace(editor, Some((scene, [0.0; 2]))); + let response = self.view.refresh(page)?; + self.respond(response); self.title(); - self.update_accessibility()?; self.window.request_redraw(); Ok(()) } - /// Reviews the oldest conflict on this page: `keep_mine` publishes the editor's page - /// over the remote change, otherwise the remote page replaces the editor's. + /// An edit the section could not store: the page returns to what is stored, and the + /// text the editor showed goes on the clipboard so nothing typed is lost unannounced. + fn refused(&mut self, error: &str) -> Result<(), Box> { + eprintln!("Saving failed: {error}"); + self.clipboard.set_text(page_text(&self.view.editor))?; + // What the editor recorded since builds on the refused edit. + let _ = self.view.editor.take_ops(); + self.view.editor.cancel_composition(&mut self.view.engine)?; + macos::clear_marked_text(&self.window); + if let Some(session) = &mut self.session { + session.status = "Not saving"; + } + self.refresh()?; + macos::change_not_saved(); + Ok(()) + } + + /// Ends the conflict on this page: `keep_mine` publishes the local page over the remote + /// change, otherwise the remote page replaces it. fn resolve_conflict(&mut self, keep_mine: bool) -> Result<(), Box> { + self.persist()?; let Some(session) = &mut self.session else { return Ok(()); }; - let Some((edit, _)) = session + let Some(conflict) = session .section - .conflicts()? - .into_iter() - .find(|(edit, _)| edit.space == session.space) + .conflict()? + .filter(|conflict| conflict.space == session.space) else { return Ok(()); }; - let reviewed = if keep_mine { - self.view.editor.page()? + let resolution = if keep_mine { + notebook::Resolution::Mine } else { - session.section.remote_page(session.space)? + notebook::Resolution::Theirs }; - session.section.review(edit.id, &reviewed)?; + session.section.resolve(conflict.id, resolution)?; session.status = "Saving"; session.refresh_conflict()?; if keep_mine { - session.before = session.section.page(session.space)?; self.window.request_redraw(); Ok(()) } else { - self.reload() + self.refresh() } } - /// Applies what the synchronization thread reported since the last poll. + /// Applies what the section reported since the last poll. fn synced(&mut self) -> Result<(), Box> { let Some(session) = &mut self.session else { return Ok(()); }; let shown = (session.status, session.conflict); - let mut refreshed = false; - let mut stale = false; + let mut listed = false; + let mut changed = false; + let mut rejected = None; let mut conflicted = session.conflict; - for (space, outcome) in session.section.saved() { - match outcome { - Ok(notebook::session::Save::Queued(_)) => session.status = "Saving", - Ok(notebook::session::Save::Unchanged) => {} - Ok(notebook::session::Save::Stale) => stale |= space == session.space, - Err(error) => { - eprintln!("Saving failed: {error}"); - session.status = "Not saving"; - } - } - } for event in session.section.events() { use notebook::session::Event; session.status = match event { Event::Refreshed => { - refreshed = true; + listed = true; continue; } + Event::Changed(spaces) => { + listed = true; + changed |= spaces.contains(&session.space); + continue; + } + Event::Rejected { spaces, error } => { + if spaces.contains(&session.space) { + rejected = Some(error); + } else { + eprintln!("Saving failed: {error}"); + } + "Not saving" + } Event::Attempt { status: notebook::EditStatus::Published { .. }, .. @@ -1780,15 +1808,15 @@ impl State { } }; } - // Reading the queue waits on the save thread, so only a conflict shown or reported - // sends the frame thread there. + // Reading the queue waits on the section thread, so only a conflict shown or + // reported sends the frame thread there. if conflicted { session.refresh_conflict()?; } - if refreshed || shown != (session.status, session.conflict) { + if listed || changed || shown != (session.status, session.conflict) { self.window.request_redraw(); } - if refreshed { + if listed { session.pages = session.section.pages()?; if !session .pages @@ -1797,13 +1825,13 @@ impl State { { let first = session.pages.first().ok_or("The section has no pages")?.0; self.commands.push(Command::OpenPage(first)); - } else if !session.section.saving() - && session.section.page(session.space)? != session.before - { - self.reload()?; + return Ok(()); } - } else if stale { - self.reload()?; + } + if let Some(error) = rejected { + self.refused(&error)?; + } else if changed { + self.refresh()?; } Ok(()) } @@ -2206,29 +2234,65 @@ fn tabs(notebook: ¬ebook::session::Notebook) -> Vec { .collect() } -/// The session for `section` showing `space`, or its first page. +/// The session for `section` showing `space`, or its first page, and that page. fn read_session( section: notebook::session::Section, tab: usize, space: Option, -) -> Result> { +) -> Result<(Session, Page), Box> { let pages = section.pages()?; let space = match space { Some(space) => space, None => pages.first().ok_or("The section has no pages")?.0, }; - let before = section.page(space)?; + let page = section.page(space)?; let mut session = Session { section, tab, pages, space, - before, status: "", conflict: false, }; session.refresh_conflict()?; - Ok(session) + Ok((session, page)) +} + +/// The text the editor shows, outline by outline, without hidden field codes. +fn page_text(editor: &CanvasEditor) -> String { + editor + .visible_outlines() + .chain(editor.caret_outline()) + .map(|outline| { + outline + .document() + .paragraphs() + .map(|paragraph| { + let mut start = 0; + paragraph + .spans() + .iter() + .filter_map(|span| { + let run = ¶graph.text()[start..span.end]; + start = span.end; + (span.format.hidden != Some(true)).then_some(run) + }) + .collect::() + }) + .collect::>() + .join("\n") + }) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n\n") +} + +/// FILETIME now: when an edit happened, which its modification times record. +fn filetime() -> u64 { + let unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100) } impl App { @@ -2716,3 +2780,199 @@ fn main() -> Result<(), Box> { event_loop.run_app(&mut app)?; app.startup_error.map_or(Ok(()), Err) } + +#[cfg(test)] +mod tests { + use super::*; + use canvas::document::TextPosition; + + /// The ops an editing session records reach the section through `apply` and read back + /// as the editor's page. + #[test] + fn recorded_ops_reach_the_section() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let directory = std::env::temp_dir().join(format!("snowbound-ops-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + std::fs::create_dir_all(directory.join("cache")).unwrap(); + let file = directory.join("section.one"); + std::fs::copy( + root.join("corpus/paragraph-edit/before/notebook/synthetic.one"), + &file, + ) + .unwrap(); + let section = + notebook::session::Section::open(&file, directory.join("cache"), || {}).unwrap(); + let mut engine = TextEngine::default(); + for (space, ..) in section.pages().unwrap().into_iter().take(3) { + let mut editor = + CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); + let outline = editor.outlines().iter().find(|o| !o.title).unwrap().id; + editor.focus_outline(outline).unwrap(); + let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into(); + editor.select(at(0, 0)).unwrap(); + editor.insert(&mut engine, "Typed ").unwrap(); + editor.enter(&mut engine, false).unwrap(); + editor.delete(&mut engine, true).unwrap(); + editor.undo(&mut engine).unwrap(); + let ops = editor.take_ops().unwrap(); + section + .apply( + "Test", + onestore::op::Edit { + at: filetime(), + ops: ops + .into_iter() + .map(|op| onestore::op::Op::Page { space, op }) + .collect(), + }, + ) + .unwrap(); + let (stored, model) = (section.page(space).unwrap(), editor.page().unwrap()); + assert_eq!( + Page { + title: model.title.clone(), + ..stored + }, + model + ); + } + assert!( + !section + .events() + .iter() + .any(|event| matches!(event, notebook::session::Event::Rejected { .. })) + ); + section.close().unwrap(); + std::fs::remove_dir_all(&directory).unwrap(); + } + + use notebook::session::{Event, Section}; + + /// Edits `editor` made, as one edit of `space`. + fn edit(editor: &mut CanvasEditor, space: ExGuid) -> onestore::op::Edit { + onestore::op::Edit { + at: filetime(), + ops: editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| onestore::op::Op::Page { space, op }) + .collect(), + } + } + + /// Polls `section`, keeping what it reports in `seen`, until `done` holds of an event. + fn wait(section: &Section, seen: &mut Vec, done: impl Fn(&Event) -> bool) { + let deadline = Instant::now() + std::time::Duration::from_secs(120); + loop { + let events = section.events(); + let finished = events.iter().any(&done); + seen.extend(events); + if finished { + return; + } + assert!(Instant::now() < deadline, "no such event: {seen:?}"); + section.wake(); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + } + + fn published(event: &Event) -> bool { + matches!( + event, + Event::Attempt { + status: notebook::EditStatus::Published { .. }, + .. + } + ) + } + + /// Publishing this machine's edits never reports the page it edits as changed, which + /// would reload the editor under the caret; another writer's edit does, and the editor + /// takes it in place with the caret where it was. + #[test] + fn only_another_writer_changes_the_open_page() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let directory = + std::env::temp_dir().join(format!("snowbound-changed-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + std::fs::create_dir_all(&directory).unwrap(); + let file = directory.join("section.one"); + std::fs::copy( + root.join("corpus/paragraph-edit/before/notebook/synthetic.one"), + &file, + ) + .unwrap(); + let ours = Section::open(&file, directory.join("ours"), || {}).unwrap(); + let space = ours.pages().unwrap()[0].0; + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(ours.page(space).unwrap(), &mut engine).unwrap(); + let outline = editor.outlines().iter().find(|o| !o.title).unwrap().id; + editor.focus_outline(outline).unwrap(); + let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into(); + editor.select(at(0, 0)).unwrap(); + let mut seen = Vec::new(); + for text in ["One ", "two ", "three "] { + editor.insert(&mut engine, text).unwrap(); + ours.apply("Ours", edit(&mut editor, space)).unwrap(); + wait(&ours, &mut seen, published); + } + editor.enter(&mut engine, false).unwrap(); + editor.delete(&mut engine, true).unwrap(); + ours.apply("Ours", edit(&mut editor, space)).unwrap(); + wait(&ours, &mut seen, published); + for _ in 0..3 { + ours.wake(); + std::thread::sleep(std::time::Duration::from_millis(500)); + seen.extend(ours.events()); + } + assert!( + !seen + .iter() + .any(|event| matches!(event, Event::Changed(spaces) if spaces.contains(&space))), + "{seen:?}" + ); + + let theirs = Section::open(&file, directory.join("theirs"), || {}).unwrap(); + let mut other = CanvasEditor::from_page(theirs.page(space).unwrap(), &mut engine).unwrap(); + other.focus_outline(outline).unwrap(); + let last = other.active_outline().document().paragraphs().count() - 1; + let end = other + .active_outline() + .document() + .paragraphs() + .last() + .unwrap() + .text() + .encode_utf16() + .count() as u32; + other.select(at(last, end)).unwrap(); + other.insert(&mut engine, " and theirs").unwrap(); + theirs.apply("Theirs", edit(&mut other, space)).unwrap(); + wait(&theirs, &mut Vec::new(), published); + wait( + &ours, + &mut seen, + |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space)), + ); + + let (shown, selection) = (editor.active_outline().id, editor.selection()); + editor + .refresh(ours.page(space).unwrap(), &mut engine) + .unwrap(); + assert_eq!( + (editor.active_outline().id, editor.selection()), + (shown, selection) + ); + assert!( + editor + .active_outline() + .document() + .paragraphs() + .any(|paragraph| paragraph.text().ends_with(" and theirs")) + ); + ours.close().unwrap(); + theirs.close().unwrap(); + std::fs::remove_dir_all(&directory).unwrap(); + } +} diff --git a/tools/native/page-date.ps1 b/tools/native/page-date.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..4003849226b50011740d4a45c123ff734d64f7ce --- /dev/null +++ b/tools/native/page-date.ps1 @@ -0,0 +1,46 @@ +param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost +$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +function Save-Page([string]$pageId, [string]$name) { + $content = '' + $app.GetPageContent($pageId, [ref]$content, 0, 1) + [IO.File]::WriteAllText((Join-Path $Root $name), $content, [Text.Encoding]::UTF8) +} +try { + $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0) + $sectionId = '' + $app.OpenHierarchy('pictures.one', $notebookId, [ref]$sectionId, 0) + # Two new pages with OneNote's own title, date and time: one keeps its date, the + # other has its date and time changed after it was saved once. + $ids = @() + foreach ($title in @('Kept date', 'Changed date')) { + $pageId = '' + $app.CreateNewPage($sectionId, [ref]$pageId, 0) + $escaped = [Security.SecurityElement]::Escape($pageId) + $xml = "$titleBody of $title" + $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false) + $ids += $pageId + } + $app.SyncHierarchy($notebookId) + Start-Sleep -Seconds 3 + Save-Page $ids[1] 'before.xml' + $escaped = [Security.SecurityElement]::Escape($ids[1]) + $update = "" + [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $update, [Text.Encoding]::UTF8) + $app.UpdatePageContent($update, [DateTime]::MinValue, 1, $false) + $app.SyncHierarchy($notebookId) + Start-Sleep -Seconds 3 + Save-Page $ids[1] 'after.xml' + Save-Page $ids[0] 'kept.xml' + $app.CloseNotebook($notebookId, $false) + $notebookId = '' +} finally { + if ($notebookId) { $app.CloseNotebook($notebookId, $false) } + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + [GC]::Collect() + [GC]::WaitForPendingFinalizers() +} diff --git a/tools/test_page_date.py b/tools/test_page_date.py new file mode 100644 index 0000000000000000000000000000000000000000..b12fe329fe7fabf3b2f9d8b97bc32183f29432d9 --- /dev/null +++ b/tools/test_page_date.py @@ -0,0 +1,39 @@ +from pathlib import Path +import runpy +import shutil +from tempfile import TemporaryDirectory +import unittest +import xml.etree.ElementTree as ET + +from native_xml import ns + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/page-date' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] + + +def native_pages(notebook, capture): + with TemporaryDirectory() as temporary: + read = Path(temporary) / 'read' + shutil.copytree(capture / 'read', read) + compare(notebook, read) + pages = (ET.parse(path).getroot() for path in sorted((capture / 'read').glob('page-*.xml'))) + return {page.get('name'): page for page in pages} + + +class PageDateTest(unittest.TestCase): + def test_onenote_dates_a_page_through_its_creation_time(self): + pages = native_pages(FIXTURE / 'native/notebook', FIXTURE / 'native') + self.assertEqual(pages['Changed date'].get('dateTime'), '2024-03-05T14:30:00.000Z') + self.assertNotEqual(pages['Kept date'].get('dateTime'), '2024-03-05T14:30:00.000Z') + + def test_a_rust_dated_page_reads_back_with_its_new_date(self): + pages = native_pages(FIXTURE / 'candidate', FIXTURE / 'cold') + self.assertEqual(pages['Kept date'].get('dateTime'), '2025-07-04T16:45:00.000Z') + self.assertEqual(pages['Changed date'].get('dateTime'), '2024-03-05T14:30:00.000Z') + title = pages['Kept date'].find('one:Title/one:OE/one:T', ns) + self.assertEqual(title.text, 'Kept date') + + +if __name__ == '__main__': + unittest.main() -- 2.54.0