| author | |
| committer | |
| log | ed9e69d6d927206decb398664df5f135e7244603 |
| tree | a7035fa2e8cf0c5f4d404c706ea49e38c502ce51 |
| parent | 4292e4deb721a9526b18e9d9385d225c033b245c |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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-574 files changed, 3857 insertions(+), 362 deletions(-)
Cargo.lock+2| ... | ... | @@ -1560,6 +1560,7 @@ dependencies = [ |
| 1560 | 1560 | "onestore", |
| 1561 | 1561 | "parley", |
| 1562 | 1562 | "pollster", |
| 1563 | "serde_json", | |
| 1563 | 1564 | "wgpu", |
| 1564 | 1565 | ] |
| 1565 | 1566 | |
| ... | ... | @@ -1657,6 +1658,7 @@ dependencies = [ |
| 1657 | 1658 | name = "notebook" |
| 1658 | 1659 | version = "0.1.0" |
| 1659 | 1660 | dependencies = [ |
| 1661 | "libc", | |
| 1660 | 1662 | "onestore", |
| 1661 | 1663 | "rusqlite", |
| 1662 | 1664 | "serde", |
apps/ios/Snowbound/CanvasView.swift+9| ... | ... | @@ -241,6 +241,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 241 | 241 | inputDelegate?.textWillChange(self) |
| 242 | 242 | } |
| 243 | 243 | let changed = change() |
| 244 | dropEdits() | |
| 244 | 245 | if external { |
| 245 | 246 | inputDelegate?.textDidChange(self) |
| 246 | 247 | inputDelegate?.selectionDidChange(self) |
| ... | ... | @@ -251,6 +252,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 251 | 252 | } |
| 252 | 253 | } |
| 253 | 254 | |
| 255 | /// Takes the edit the page recorded, so its ops do not pile up; the host drops it | |
| 256 | /// until it holds a notebook session that stores edits. | |
| 257 | private func dropEdits() { | |
| 258 | guard let handle, let edit = sb_view_edit(handle) else { return } | |
| 259 | sb_string_free(edit) | |
| 260 | } | |
| 261 | ||
| 254 | 262 | private func revealCaret() { |
| 255 | 263 | guard let handle, isFirstResponder else { return } |
| 256 | 264 | var range: [UInt32] = [0, 0] |
| ... | ... | @@ -354,6 +362,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 354 | 362 | func unmarkText() { |
| 355 | 363 | guard let handle else { return } |
| 356 | 364 | sb_unmark(handle) |
| 365 | dropEdits() | |
| 357 | 366 | dirty = true |
| 358 | 367 | } |
| 359 | 368 |
corpus/page-date/README.md created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | # Page date | |
| 2 | ||
| 3 | `native` is OneNote 2010 changing a page's date (`tools/native/page-date.ps1` on | |
| 4 | `picture-edit/native-page-level/notebook`): it creates two pages, "Kept date" and | |
| 5 | "Changed date", each with OneNote's own title, date and time, then gives "Changed date" | |
| 6 | the `dateTime` 2024-03-05T14:30:00Z through the COM API (`before.xml`, `update.xml`, | |
| 7 | `after.xml`; `kept.xml` is the untouched page). In `notebook/`, the revision that change | |
| 8 | stored sets the page metadata's `TopologyCreationTimeStamp` (`0x18001c65`), rewrites the | |
| 9 | date and time fields' text (`TextExtendedAscii`, `0x1c003498`, in the clone's Pacific | |
| 10 | time: "Tuesday, March 05, 2024", "7:30 AM"), and gives the two fields' elements the | |
| 11 | change time as `CreationTimeStamp` (`0x14001d09`) and `LastModifiedTime`, with the | |
| 12 | date outline's `LastModifiedTime`. The title, the page and the section's page metadata | |
| 13 | keep theirs. | |
| 14 | ||
| 15 | `candidate` is `a_page_date_stores_what_onenote_stores` in `crates/onestore/src/op/tests.rs` | |
| 16 | (`ONESTORE_PAGE_DATE_EXPORT`): the `Date` op dates "Kept date" 2025-07-04T16:45:00Z, | |
| 17 | showing "Friday, July 04, 2025" and "9:45 AM", and changes the same objects OneNote did. | |
| 18 | `cold` is its fresh OneNote 2010 read: the page reads back with that `dateTime`, and | |
| 19 | `read/page-001.png` shows the new date and time under the title. |
corpus/page-date/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/page-date/candidate/Open Notebook.onetoc2 differ |
corpus/page-date/candidate/pictures.one created| Binary files /dev/null and b/corpus/page-date/candidate/pictures.one differ |
corpus/page-date/cold/commands.jsonl created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} | |
| 2 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-814B0AC0 -ExpectedPages 3 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 3 pages.\r\n", "stderr": "", "error": null} | |
| 3 | {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\read -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} |
corpus/page-date/cold/machine.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"name": "m6-814b0ac0", "hostname": "ONE-M6-814B0AC0"} |
corpus/page-date/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/page-date/cold/notebook/Open Notebook.onetoc2 differ |
corpus/page-date/cold/notebook/pictures.one created| Binary files /dev/null and b/corpus/page-date/cold/notebook/pictures.one differ |
corpus/page-date/cold/read/environment.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "powershell": "5.1.14409.1005", | |
| 3 | "schema": "xs2010", | |
| 4 | "hostname": "ONE-M6-814B0AC0", | |
| 5 | "cold": true, | |
| 6 | "onenote": "14.0.4763.1000" | |
| 7 | } |
corpus/page-date/cold/read/hierarchy.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{6E8D0D7E-1934-4DC2-857A-630E9460B7F1}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-26T20:06:05.000Z" color="#B49EDE"><one:Section name="pictures" ID="{34C411B6-3D7F-0DC2-298A-428D7FC85C9F}{1}{B0}" path="C:\one-tests\runs\capture\notebook\pictures.one" lastModifiedTime="2026-09-26T20:06:05.000Z" color="#8AA8E4"><one:Page ID="{B3BAECBD-18EC-04BC-3C9B-164409888097}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-26T20:06:05.000Z" pageLevel="1"/><one:Page ID="{49CDB2F6-36B7-471C-8D7D-3D398DE1A534}{1}{B0}" name="Kept date" dateTime="2025-07-04T16:45:00.000Z" lastModifiedTime="2026-09-26T20:06:05.000Z" pageLevel="1"/><one:Page ID="{03681E04-D275-400C-82BF-CA0CC3EA28B5}{1}{B0}" name="Changed date" dateTime="2024-03-05T14:30:00.000Z" lastModifiedTime="2026-09-26T20:06:05.000Z" pageLevel="1"/></one:Section></one:Notebook> |
corpus/page-date/cold/read/page-000.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Changed date - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-date/cold/read/page-000.png created| Binary files /dev/null and b/corpus/page-date/cold/read/page-000.png differ |
corpus/page-date/cold/read/page-000.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{03681E04-D275-400C-82BF-CA0CC3EA28B5}{1}{B0}" name="Changed date" dateTime="2024-03-05T14:30:00.000Z" lastModifiedTime="2026-09-26T20:06:05.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{F04CB668-A62A-4D7A-8563-46BF3F0EC756}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Changed date]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{F04CB668-A62A-4D7A-8563-46BF3F0EC756}{39}{B0}"><one:Position x="72.0" y="144.0" z="0"/><one:Size width="138.23974609375" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{F04CB668-A62A-4D7A-8563-46BF3F0EC756}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Body of Changed date]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
corpus/page-date/cold/read/page-001.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Kept date - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-date/cold/read/page-001.png created| Binary files /dev/null and b/corpus/page-date/cold/read/page-001.png differ |
corpus/page-date/cold/read/page-001.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{49CDB2F6-36B7-471C-8D7D-3D398DE1A534}{1}{B0}" name="Kept date" dateTime="2025-07-04T16:45:00.000Z" lastModifiedTime="2026-09-26T20:06:05.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{C712011B-3CD6-4506-B8FA-12ABCA55B4A6}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Kept date]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{C712011B-3CD6-4506-B8FA-12ABCA55B4A6}{39}{B0}"><one:Position x="72.0" y="144.0" z="0"/><one:Size width="119.768653869629" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{C712011B-3CD6-4506-B8FA-12ABCA55B4A6}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Body of Kept date]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
corpus/page-date/cold/read/page-002.navigation.json created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "exit": 0, | |
| 3 | "stdout": "", | |
| 4 | "stderr": "", | |
| 5 | "w": 800, | |
| 6 | "h": 600, | |
| 7 | "error": null, | |
| 8 | "win": { | |
| 9 | "title": "Before the picture - Microsoft OneNote", | |
| 10 | "class": "Framework::CFrame", | |
| 11 | "dialog": false | |
| 12 | } | |
| 13 | } | |
| \ No newline at end of file |
corpus/page-date/cold/read/page-002.png created| Binary files /dev/null and b/corpus/page-date/cold/read/page-002.png differ |
corpus/page-date/cold/read/page-002.xml created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B3BAECBD-18EC-04BC-3C9B-164409888097}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-26T20:06:05.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{34C411B6-3D7F-0DC2-298A-428D7FC85C9F}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="148.3100738525391" height="27.60543251037598"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T21:33:31.000Z" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{34C411B6-3D7F-0DC2-298A-428D7FC85C9F}{24}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before the picture]]></one:T></one:OE><one:OE author="Picture author" lastModifiedBy="Picture author" creationTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{297715EE-7E8D-0F14-0718-A91EF8EC77E3}{0}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w | |
| 3 | AAAAAElFTkSuQmCC | |
| 4 | </one:Data></one:Image></one:OE><one:OE creationTime="2026-09-12T21:33:31.000Z" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{59E81ED3-F6C9-01FF-2CF9-AC863A851EC3}{0}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After the picture]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" alt="Page-level picture" originalPageNumber="0" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{530BAA13-E461-42E0-87AB-F1442FC1A9CC}{29}{B0}"><one:Position x="360.0" y="240.0" z="1"/><one:Size width="96.00000762939453" height="71.99998474121093" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w | |
| 5 | AAAAAElFTkSuQmCC | |
| 6 | </one:Data></one:Image></one:Page> |
corpus/page-date/cold/read/payloads.json created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | [ | |
| 2 | ||
| 3 | ] | |
| \ No newline at end of file |
corpus/page-date/cold/run.json created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | { | |
| 2 | "notebook": "/private/tmp/pagedate/candidate/notebook", | |
| 3 | "expected_pages": 3, | |
| 4 | "author": null, | |
| 5 | "author_timeout_seconds": 600, | |
| 6 | "inspect": false, | |
| 7 | "collect_notebook": false, | |
| 8 | "base": { | |
| 9 | "file": "win7-office-base.qcow2", | |
| 10 | "format": "qcow2", | |
| 11 | "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", | |
| 12 | "virtual_size": 68719476736 | |
| 13 | }, | |
| 14 | "scripts": { | |
| 15 | "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", | |
| 16 | "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" | |
| 17 | } | |
| 18 | } |
corpus/page-date/cold/scripts/cold.ps1 created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/page-date/cold/scripts/read.ps1 created+142| ... | ... | @@ -0,0 +1,142 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); | |
| 103 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 104 | sha256 = $hash; bytes = $bytes.Length } | |
| 105 | } | |
| 106 | $index++ | |
| 107 | } | |
| 108 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 109 | $all = '' | |
| 110 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 111 | [xml]$finalTree = $all | |
| 112 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 113 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 114 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 115 | } | |
| 116 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 117 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 118 | } catch { | |
| 119 | $failure = $_ | |
| 120 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 121 | throw | |
| 122 | } finally { | |
| 123 | try { | |
| 124 | try { | |
| 125 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 126 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 127 | $app.CloseNotebook($notebookId, $false) | |
| 128 | } | |
| 129 | } catch { | |
| 130 | if ($null -eq $failure) { throw } | |
| 131 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 132 | } | |
| 133 | } finally { | |
| 134 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 135 | } | |
| 136 | $app = $null | |
| 137 | [GC]::Collect() | |
| 138 | [GC]::WaitForPendingFinalizers() | |
| 139 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 140 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 141 | } | |
| 142 | } |
corpus/page-date/cold/source.json created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "path": "Open Notebook.onetoc2", | |
| 4 | "bytes": 3764, | |
| 5 | "sha256": "8bf472d0a9c5829b049c69b817f40b766025431a61c71689bca583a60d28365d", | |
| 6 | "mtime_ns": 1790452811018151759 | |
| 7 | }, | |
| 8 | { | |
| 9 | "path": "pictures.one", | |
| 10 | "bytes": 24184, | |
| 11 | "sha256": "0fea376be55a62c76dd61e311513b41a4212d671a9316eb8d18897a5baa5a125", | |
| 12 | "mtime_ns": 1790453075875161561 | |
| 13 | } | |
| 14 | ] |
corpus/page-date/cold/teardown.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"absent": true} |
corpus/page-date/native/after.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D808D4AA-E0C0-46FD-A358-822E3F9124F3}{1}{B0}" name="Changed date" dateTime="2024-03-05T14:30:00.000Z" lastModifiedTime="2026-09-26T19:49:10.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{2B2C7CC6-949F-4B8B-A484-0E9DC375CB10}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Changed date]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{2B2C7CC6-949F-4B8B-A484-0E9DC375CB10}{39}{B0}"><one:Position x="72.0" y="144.0" z="0"/><one:Size width="138.23974609375" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{2B2C7CC6-949F-4B8B-A484-0E9DC375CB10}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Body of Changed date]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
corpus/page-date/native/before.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D808D4AA-E0C0-46FD-A358-822E3F9124F3}{1}{B0}" name="Changed date" dateTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{2B2C7CC6-949F-4B8B-A484-0E9DC375CB10}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Changed date]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{2B2C7CC6-949F-4B8B-A484-0E9DC375CB10}{39}{B0}"><one:Position x="72.0" y="144.0" z="0"/><one:Size width="138.23974609375" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{2B2C7CC6-949F-4B8B-A484-0E9DC375CB10}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Body of Changed date]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
corpus/page-date/native/commands.jsonl created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} | |
| 2 | {"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-CE0C154F", "exit": 0, "stdout": "", "stderr": "", "error": null} | |
| 3 | {"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} | |
| 4 | {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} |
corpus/page-date/native/kept.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{92AD7858-0402-41ED-AC9A-751B719AA972}{1}{B0}" name="Kept date" dateTime="2026-09-26T19:49:05.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{1C72CBB5-0E63-43F7-991D-5A89362EB8E0}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Kept date]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{1C72CBB5-0E63-43F7-991D-5A89362EB8E0}{39}{B0}"><one:Position x="72.0" y="144.0" z="0"/><one:Size width="119.768653869629" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{1C72CBB5-0E63-43F7-991D-5A89362EB8E0}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Body of Kept date]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
corpus/page-date/native/machine.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"name": "m6-ce0c154f", "hostname": "ONE-M6-CE0C154F"} |
corpus/page-date/native/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/page-date/native/notebook/Open Notebook.onetoc2 differ |
corpus/page-date/native/notebook/pictures.one created| Binary files /dev/null and b/corpus/page-date/native/notebook/pictures.one differ |
corpus/page-date/native/read/environment.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "powershell": "5.1.14409.1005", | |
| 3 | "schema": "xs2010", | |
| 4 | "hostname": "ONE-M6-CE0C154F", | |
| 5 | "cold": false, | |
| 6 | "onenote": "14.0.4763.1000" | |
| 7 | } |
corpus/page-date/native/read/hierarchy.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{C87B50AA-AF27-48F6-AE17-E800EDF388D0}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-26T19:50:43.000Z" color="#B49EDE"><one:Section name="pictures" ID="{0DB4922F-274B-037B-0B75-47FECCD1E476}{1}{B0}" path="C:\one-tests\runs\capture\notebook\pictures.one" lastModifiedTime="2026-09-26T19:50:43.000Z" color="#8AA8E4"><one:Page ID="{8ACA6F24-02D8-0A05-1E64-1337BA91387E}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-26T19:50:43.000Z" pageLevel="1"/><one:Page ID="{70BD316F-2C83-49A5-AF82-384A3EF81DDD}{1}{B0}" name="Kept date" dateTime="2026-09-26T19:49:05.000Z" lastModifiedTime="2026-09-26T19:50:43.000Z" pageLevel="1"/><one:Page ID="{3A189D9D-C841-4EB5-A040-CF7F70F3905C}{1}{B0}" name="Changed date" dateTime="2024-03-05T14:30:00.000Z" lastModifiedTime="2026-09-26T19:50:43.000Z" pageLevel="1"/></one:Section></one:Notebook> |
corpus/page-date/native/read/page-000.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3A189D9D-C841-4EB5-A040-CF7F70F3905C}{1}{B0}" name="Changed date" dateTime="2024-03-05T14:30:00.000Z" lastModifiedTime="2026-09-26T19:50:43.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{C93C35F1-BC1E-43C3-A79C-43CC8C177FBF}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Changed date]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{C93C35F1-BC1E-43C3-A79C-43CC8C177FBF}{39}{B0}"><one:Position x="72.0" y="144.0" z="0"/><one:Size width="138.23974609375" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{C93C35F1-BC1E-43C3-A79C-43CC8C177FBF}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Body of Changed date]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
corpus/page-date/native/read/page-001.xml created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{70BD316F-2C83-49A5-AF82-384A3EF81DDD}{1}{B0}" name="Kept date" dateTime="2026-09-26T19:49:05.000Z" lastModifiedTime="2026-09-26T19:50:43.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{FE628282-26E2-4BBF-9A05-17D8794C0C4F}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Kept date]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{FE628282-26E2-4BBF-9A05-17D8794C0C4F}{39}{B0}"><one:Position x="72.0" y="144.0" z="0"/><one:Size width="119.768653869629" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-26T19:49:06.000Z" lastModifiedTime="2026-09-26T19:49:06.000Z" objectID="{FE628282-26E2-4BBF-9A05-17D8794C0C4F}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Body of Kept date]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> |
corpus/page-date/native/read/page-002.xml created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | <?xml version="1.0"?> | |
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8ACA6F24-02D8-0A05-1E64-1337BA91387E}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-26T19:50:43.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{0DB4922F-274B-037B-0B75-47FECCD1E476}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="148.3100738525391" height="27.60543251037598"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T21:33:31.000Z" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{0DB4922F-274B-037B-0B75-47FECCD1E476}{24}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before the picture]]></one:T></one:OE><one:OE author="Picture author" lastModifiedBy="Picture author" creationTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{10079677-64B9-01AD-25E7-AC6D4BF5CF0A}{0}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w | |
| 3 | AAAAAElFTkSuQmCC | |
| 4 | </one:Data></one:Image></one:OE><one:OE creationTime="2026-09-12T21:33:31.000Z" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{60989D4A-ECFD-0F46-0E06-A9F5899CA62A}{0}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After the picture]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" alt="Page-level picture" originalPageNumber="0" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{6A7B298A-FE55-4C59-A554-F4379CD81125}{29}{B0}"><one:Position x="360.0" y="240.0" z="1"/><one:Size width="96.00000762939453" height="71.99998474121093" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w | |
| 5 | AAAAAElFTkSuQmCC | |
| 6 | </one:Data></one:Image></one:Page> |
corpus/page-date/native/read/payloads.json created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | [ | |
| 2 | ||
| 3 | ] | |
| \ No newline at end of file |
corpus/page-date/native/run.json created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | { | |
| 2 | "notebook": "/Users/clo/dev/one/corpus/picture-edit/native-page-level/notebook", | |
| 3 | "expected_pages": 3, | |
| 4 | "author": "tools/native/page-date.ps1", | |
| 5 | "author_timeout_seconds": 600, | |
| 6 | "inspect": false, | |
| 7 | "collect_notebook": true, | |
| 8 | "base": { | |
| 9 | "file": "win7-office-base.qcow2", | |
| 10 | "format": "qcow2", | |
| 11 | "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", | |
| 12 | "virtual_size": 68719476736 | |
| 13 | }, | |
| 14 | "scripts": { | |
| 15 | "author.ps1": "f7a55c35305dc63b41efdff5bba9a5c0eef621948727f7630c2ebda3248c3490", | |
| 16 | "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", | |
| 17 | "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" | |
| 18 | } | |
| 19 | } |
corpus/page-date/native/scripts/author.ps1 created+46| ... | ... | @@ -0,0 +1,46 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | & "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost | |
| 5 | $namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' | |
| 6 | $app = New-Object -ComObject OneNote.Application | |
| 7 | $notebookId = '' | |
| 8 | function Save-Page([string]$pageId, [string]$name) { | |
| 9 | $content = '' | |
| 10 | $app.GetPageContent($pageId, [ref]$content, 0, 1) | |
| 11 | [IO.File]::WriteAllText((Join-Path $Root $name), $content, [Text.Encoding]::UTF8) | |
| 12 | } | |
| 13 | try { | |
| 14 | $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0) | |
| 15 | $sectionId = '' | |
| 16 | $app.OpenHierarchy('pictures.one', $notebookId, [ref]$sectionId, 0) | |
| 17 | # Two new pages with OneNote's own title, date and time: one keeps its date, the | |
| 18 | # other has its date and time changed after it was saved once. | |
| 19 | $ids = @() | |
| 20 | foreach ($title in @('Kept date', 'Changed date')) { | |
| 21 | $pageId = '' | |
| 22 | $app.CreateNewPage($sectionId, [ref]$pageId, 0) | |
| 23 | $escaped = [Security.SecurityElement]::Escape($pageId) | |
| 24 | $xml = "<one:Page xmlns:one='$namespace' ID='$escaped'><one:Title><one:OE><one:T>$title</one:T></one:OE></one:Title><one:Outline><one:Position x='72' y='144'/><one:OEChildren><one:OE><one:T>Body of $title</one:T></one:OE></one:OEChildren></one:Outline></one:Page>" | |
| 25 | $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false) | |
| 26 | $ids += $pageId | |
| 27 | } | |
| 28 | $app.SyncHierarchy($notebookId) | |
| 29 | Start-Sleep -Seconds 3 | |
| 30 | Save-Page $ids[1] 'before.xml' | |
| 31 | $escaped = [Security.SecurityElement]::Escape($ids[1]) | |
| 32 | $update = "<one:Page xmlns:one='$namespace' ID='$escaped' dateTime='2024-03-05T14:30:00.000Z'/>" | |
| 33 | [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $update, [Text.Encoding]::UTF8) | |
| 34 | $app.UpdatePageContent($update, [DateTime]::MinValue, 1, $false) | |
| 35 | $app.SyncHierarchy($notebookId) | |
| 36 | Start-Sleep -Seconds 3 | |
| 37 | Save-Page $ids[1] 'after.xml' | |
| 38 | Save-Page $ids[0] 'kept.xml' | |
| 39 | $app.CloseNotebook($notebookId, $false) | |
| 40 | $notebookId = '' | |
| 41 | } finally { | |
| 42 | if ($notebookId) { $app.CloseNotebook($notebookId, $false) } | |
| 43 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 44 | [GC]::Collect() | |
| 45 | [GC]::WaitForPendingFinalizers() | |
| 46 | } |
corpus/page-date/native/scripts/cold.ps1 created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/page-date/native/scripts/read.ps1 created+142| ... | ... | @@ -0,0 +1,142 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); | |
| 103 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 104 | sha256 = $hash; bytes = $bytes.Length } | |
| 105 | } | |
| 106 | $index++ | |
| 107 | } | |
| 108 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 109 | $all = '' | |
| 110 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 111 | [xml]$finalTree = $all | |
| 112 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 113 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 114 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 115 | } | |
| 116 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 117 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 118 | } catch { | |
| 119 | $failure = $_ | |
| 120 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 121 | throw | |
| 122 | } finally { | |
| 123 | try { | |
| 124 | try { | |
| 125 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 126 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 127 | $app.CloseNotebook($notebookId, $false) | |
| 128 | } | |
| 129 | } catch { | |
| 130 | if ($null -eq $failure) { throw } | |
| 131 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 132 | } | |
| 133 | } finally { | |
| 134 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 135 | } | |
| 136 | $app = $null | |
| 137 | [GC]::Collect() | |
| 138 | [GC]::WaitForPendingFinalizers() | |
| 139 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 140 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 141 | } | |
| 142 | } |
corpus/page-date/native/source.json created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "path": "Open Notebook.onetoc2", | |
| 4 | "bytes": 3764, | |
| 5 | "sha256": "8bf472d0a9c5829b049c69b817f40b766025431a61c71689bca583a60d28365d", | |
| 6 | "mtime_ns": 1789249099590321649 | |
| 7 | }, | |
| 8 | { | |
| 9 | "path": "pictures.one", | |
| 10 | "bytes": 17504, | |
| 11 | "sha256": "ea585992f7c450e426e0b98d8d0ce972ecdaf7602df962c938166d3d1b54c820", | |
| 12 | "mtime_ns": 1789249099590147690 | |
| 13 | } | |
| 14 | ] |
corpus/page-date/native/teardown.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"absent": true} |
corpus/page-date/native/update.xml created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | <one:Page xmlns:one='http://schemas.microsoft.com/office/onenote/2010/onenote' ID='{D808D4AA-E0C0-46FD-A358-822E3F9124F3}{1}{B0}' dateTime='2024-03-05T14:30:00.000Z'/> | |
| \ No newline at end of file |
crates/canvas/src/editor.rs+364-30| ... | ... | @@ -7,6 +7,7 @@ use crate::{ |
| 7 | 7 | use draw::edit::{self, Movement, SelectionUnit}; |
| 8 | 8 | use onestore::ExGuid; |
| 9 | 9 | use onestore::document::{Format, Kind}; |
| 10 | use onestore::op::PageOp; | |
| 10 | 11 | use onestore::page::text::{EditError, Paragraph}; |
| 11 | 12 | use onestore::page::{ |
| 12 | 13 | Definition, Outline, Page, PageObject, PageParagraph, ParagraphContent, Title, |
| ... | ... | @@ -26,6 +27,7 @@ pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0; |
| 26 | 27 | #[cfg(test)] |
| 27 | 28 | mod evidence; |
| 28 | 29 | mod format; |
| 30 | mod ops; | |
| 29 | 31 | pub(crate) mod page; |
| 30 | 32 | mod table; |
| 31 | 33 | pub use format::{Alignment, FormatState, Formatting, NoteTag, Toggle}; |
| ... | ... | @@ -92,6 +94,12 @@ pub struct CanvasEditor { |
| 92 | 94 | preferred_x: Option<f32>, |
| 93 | 95 | /// Formatting chosen at a caret for the text typed there next, until an edit. |
| 94 | 96 | pending: Option<(ExGuid, TextPosition, onestore::document::Format)>, |
| 97 | /// What `take_ops` hands over next. | |
| 98 | ops: Result<Vec<PageOp>, onestore::Error>, | |
| 99 | /// The page as stored when the editor last read it and the ops `take_ops` handed out | |
| 100 | /// since, which `refresh` compares a changed stored page with; none once the editor | |
| 101 | /// holds an edit that could not be stored. | |
| 102 | stored: Option<(Page, Vec<PageOp>)>, | |
| 95 | 103 | } |
| 96 | 104 | |
| 97 | 105 | /// Imported page state the editable content does not carry. |
| ... | ... | @@ -660,10 +668,13 @@ impl CanvasEditor { |
| 660 | 668 | composition: None, |
| 661 | 669 | preferred_x: None, |
| 662 | 670 | pending: None, |
| 671 | ops: Ok(Vec::new()), | |
| 672 | stored: None, | |
| 663 | 673 | }) |
| 664 | 674 | } |
| 665 | 675 | |
| 666 | 676 | pub fn from_page(mut page: Page, engine: &mut TextEngine) -> Result<Self, EditorError> { |
| 677 | let stored = page.clone(); | |
| 667 | 678 | let page::Import { |
| 668 | 679 | objects, |
| 669 | 680 | mut outlines, |
| ... | ... | @@ -706,9 +717,175 @@ impl CanvasEditor { |
| 706 | 717 | if !editor.object_layouts().all(|(id, _)| ids.insert(id)) { |
| 707 | 718 | return Err(EditError::InvalidStructure.into()); |
| 708 | 719 | } |
| 720 | editor.stored = Some((stored, Vec::new())); | |
| 709 | 721 | Ok(editor) |
| 710 | 722 | } |
| 711 | 723 | |
| 724 | /// Shows `page`, the stored page after a change made elsewhere. What the change did not | |
| 725 | /// reach stays as it is, history included: the page as stored before it (as last read, | |
| 726 | /// with the ops handed out since) is compared with `page`; outlines the change reached | |
| 727 | /// show anew with the caret and selection kept by paragraph identity, and history entries | |
| 728 | /// editing them are dropped. False when the change reached nothing shown. Marked text | |
| 729 | /// must be committed or cancelled first. | |
| 730 | pub fn refresh(&mut self, page: Page, engine: &mut TextEngine) -> Result<bool, EditorError> { | |
| 731 | let known = self.stored.take().and_then(|(mut stored, sent)| { | |
| 732 | sent.iter() | |
| 733 | .try_for_each(|op| onestore::op::predict(&mut stored, op)) | |
| 734 | .ok() | |
| 735 | .map(|()| stored) | |
| 736 | }); | |
| 737 | // Without that, the editor's page stands in, which storage may normalize apart. | |
| 738 | let known = match known { | |
| 739 | Some(known) => known, | |
| 740 | None => self.page()?, | |
| 741 | }; | |
| 742 | self.stored = Some((page.clone(), Vec::new())); | |
| 743 | if known == page { | |
| 744 | return Ok(false); | |
| 745 | } | |
| 746 | fn outlines(page: &Page) -> BTreeMap<ExGuid, &Outline> { | |
| 747 | page.objects | |
| 748 | .iter() | |
| 749 | .flat_map(|object| match object { | |
| 750 | PageObject::Outline(outline) => std::slice::from_ref(outline), | |
| 751 | PageObject::Title(title) => title.outlines.as_slice(), | |
| 752 | _ => &[], | |
| 753 | }) | |
| 754 | .map(|outline| (outline.id, outline)) | |
| 755 | .collect() | |
| 756 | } | |
| 757 | let (before, after) = (outlines(&known), outlines(&page)); | |
| 758 | let changed: BTreeSet<ExGuid> = before | |
| 759 | .keys() | |
| 760 | .chain(after.keys()) | |
| 761 | .filter(|id| before.get(id) != after.get(id)) | |
| 762 | .copied() | |
| 763 | .collect(); | |
| 764 | // Everything but outline content: the page's objects, geometry and date. | |
| 765 | let frame = |page: &Page| { | |
| 766 | let hollow = |outline: &Outline| Outline { | |
| 767 | id: outline.id, | |
| 768 | title: false, | |
| 769 | min_width: None, | |
| 770 | layout: Default::default(), | |
| 771 | indents: Vec::new(), | |
| 772 | paragraphs: Vec::new(), | |
| 773 | unsupported: Vec::new(), | |
| 774 | }; | |
| 775 | let objects: Vec<PageObject> = page | |
| 776 | .objects | |
| 777 | .iter() | |
| 778 | .map(|object| match object { | |
| 779 | PageObject::Outline(outline) => PageObject::Outline(hollow(outline)), | |
| 780 | PageObject::Title(title) => PageObject::Title(Title { | |
| 781 | id: title.id, | |
| 782 | date: title.date, | |
| 783 | layout: title.layout.clone(), | |
| 784 | outlines: title.outlines.iter().map(hollow).collect(), | |
| 785 | }), | |
| 786 | object => object.clone(), | |
| 787 | }) | |
| 788 | .collect(); | |
| 789 | (page.created, page.margin_origin, objects) | |
| 790 | }; | |
| 791 | let objects_changed = frame(&known) != frame(&page); | |
| 792 | let date_changed = known.created != page.created | |
| 793 | || self | |
| 794 | .date | |
| 795 | .as_ref() | |
| 796 | .is_some_and(|date| changed.contains(&date.source().id)); | |
| 797 | let mut fresh = Self::from_page(page, engine)?; | |
| 798 | let shown = self.active_outline(); | |
| 799 | let (id, selection) = (shown.id, shown.selection); | |
| 800 | let reached = changed.contains(&id); | |
| 801 | let document = reached.then(|| shown.document.clone()); | |
| 802 | let mut own: BTreeMap<ExGuid, TextOutline> = std::mem::take(&mut self.outlines) | |
| 803 | .into_iter() | |
| 804 | .map(|outline| (outline.id, outline)) | |
| 805 | .collect(); | |
| 806 | for outline in &mut fresh.outlines { | |
| 807 | if !changed.contains(&outline.id) | |
| 808 | && let Some(kept) = own.remove(&outline.id) | |
| 809 | { | |
| 810 | *outline = kept; | |
| 811 | } | |
| 812 | } | |
| 813 | let position = fresh.outlines.iter().position(|outline| outline.id == id); | |
| 814 | match ( | |
| 815 | std::mem::replace(&mut self.active, Focus::Outline(0)), | |
| 816 | position, | |
| 817 | ) { | |
| 818 | (Focus::Caret { outline, index }, _) => { | |
| 819 | fresh.active = Focus::Caret { | |
| 820 | outline, | |
| 821 | index: index.min(fresh.outlines.len()), | |
| 822 | }; | |
| 823 | } | |
| 824 | (Focus::Draft { outline, .. }, Some(index)) if !reached => { | |
| 825 | fresh.active = Focus::Draft { index, outline }; | |
| 826 | } | |
| 827 | (_, Some(index)) if !reached => fresh.active = Focus::Outline(index), | |
| 828 | (_, Some(index)) => { | |
| 829 | fresh.active = Focus::Outline(index); | |
| 830 | let document = document.expect("a reached outline's document was kept"); | |
| 831 | let mapped = Selection { | |
| 832 | positions: selection.positions.map(|position| { | |
| 833 | follow(&document, &fresh.outlines[index].document, position) | |
| 834 | }), | |
| 835 | affinities: selection.affinities, | |
| 836 | }; | |
| 837 | let _ = fresh.select(mapped); | |
| 838 | self.pending = self.pending.take().filter(|(_, at, _)| { | |
| 839 | follow(&document, &fresh.active_outline().document, *at) == *at | |
| 840 | }); | |
| 841 | } | |
| 842 | (_, None) => self.pending = None, | |
| 843 | } | |
| 844 | if !date_changed { | |
| 845 | fresh.date = self.date.take(); | |
| 846 | } | |
| 847 | if !objects_changed { | |
| 848 | fresh.objects = std::mem::take(&mut self.objects); | |
| 849 | } | |
| 850 | for (id, definition) in std::mem::take(&mut self.definitions) { | |
| 851 | fresh.definitions.entry(id).or_insert(definition); | |
| 852 | } | |
| 853 | // Undoing an outline's creation focuses the outline focused before, if it is there. | |
| 854 | let gone = |focus: &RestoreFocus| matches!(focus, RestoreFocus::Outline(id) if !fresh.outlines.iter().any(|o| o.id == *id)); | |
| 855 | let reaches = |history: &History| match history { | |
| 856 | History::Date(_) => date_changed, | |
| 857 | History::Image { .. } | History::Picture { .. } => objects_changed, | |
| 858 | History::Draft { outlines, .. } => changed.contains(&outlines[0].id), | |
| 859 | History::Text { outline, change } => { | |
| 860 | changed.contains(outline) | |
| 861 | || change | |
| 862 | .positions | |
| 863 | .iter() | |
| 864 | .any(|placement| objects_changed || changed.contains(&placement.id)) | |
| 865 | } | |
| 866 | History::Position { outline, .. } | History::Layout { outline, .. } => { | |
| 867 | changed.contains(outline) | |
| 868 | } | |
| 869 | History::Remove { outline, focus } => changed.contains(outline) || gone(focus), | |
| 870 | History::Insert { outline, focus, .. } => changed.contains(&outline.id) || gone(focus), | |
| 871 | History::Restore { source, focus, .. } => changed.contains(&source.id) || gone(focus), | |
| 872 | }; | |
| 873 | fresh.undo = std::mem::take(&mut self.undo) | |
| 874 | .into_iter() | |
| 875 | .filter(|history| !reaches(history)) | |
| 876 | .collect(); | |
| 877 | fresh.redo = std::mem::take(&mut self.redo) | |
| 878 | .into_iter() | |
| 879 | .filter(|history| !reaches(history)) | |
| 880 | .collect(); | |
| 881 | fresh.pending = self.pending.take(); | |
| 882 | fresh.preferred_x = self.preferred_x; | |
| 883 | fresh.ops = std::mem::replace(&mut self.ops, Ok(Vec::new())); | |
| 884 | fresh.stored = self.stored.take(); | |
| 885 | *self = fresh; | |
| 886 | Ok(true) | |
| 887 | } | |
| 888 | ||
| 712 | 889 | /// Rebuilds the stored page, restoring the title areas and read-only objects import split up. |
| 713 | 890 | pub fn page(&self) -> Result<Page, EditorError> { |
| 714 | 891 | let mut objects: Vec<PageObject> = Vec::new(); |
| ... | ... | @@ -781,18 +958,7 @@ impl CanvasEditor { |
| 781 | 958 | .iter() |
| 782 | 959 | .flat_map(|outline| descendants(&outline.paragraphs, None)) |
| 783 | 960 | { |
| 784 | let content_tags = match &node.content { | |
| 785 | ParagraphContent::Text(text) => text.tags.as_slice(), | |
| 786 | ParagraphContent::Table(table) => table.tags.as_slice(), | |
| 787 | _ => &[], | |
| 788 | }; | |
| 789 | referenced.extend(node.lists.iter().copied().chain(node.style)); | |
| 790 | referenced.extend( | |
| 791 | node.tags | |
| 792 | .iter() | |
| 793 | .chain(content_tags) | |
| 794 | .filter_map(|tag| tag.definition), | |
| 795 | ); | |
| 961 | referenced.extend(ops::references(node)); | |
| 796 | 962 | } |
| 797 | 963 | } |
| 798 | 964 | let mut definitions = self.definitions.clone(); |
| ... | ... | @@ -1027,6 +1193,8 @@ impl CanvasEditor { |
| 1027 | 1193 | composition: None, |
| 1028 | 1194 | preferred_x: None, |
| 1029 | 1195 | pending: None, |
| 1196 | ops: Ok(Vec::new()), | |
| 1197 | stored: None, | |
| 1030 | 1198 | }) |
| 1031 | 1199 | } |
| 1032 | 1200 | |
| ... | ... | @@ -1055,6 +1223,7 @@ impl CanvasEditor { |
| 1055 | 1223 | self.undo |
| 1056 | 1224 | .push(History::Date(Box::new(self.date.replace(updated).unwrap()))); |
| 1057 | 1225 | self.redo.clear(); |
| 1226 | self.record(Ok(self.date_ops())); | |
| 1058 | 1227 | Ok(true) |
| 1059 | 1228 | } |
| 1060 | 1229 | |
| ... | ... | @@ -1261,6 +1430,7 @@ impl CanvasEditor { |
| 1261 | 1430 | self.redo.clear(); |
| 1262 | 1431 | self.active = Focus::Outline(self.outlines.len()); |
| 1263 | 1432 | self.outlines.push(outline); |
| 1433 | self.record(self.outline_ops(id, None)); | |
| 1264 | 1434 | self.preferred_x = None; |
| 1265 | 1435 | Ok(id) |
| 1266 | 1436 | } |
| ... | ... | @@ -1301,13 +1471,18 @@ impl CanvasEditor { |
| 1301 | 1471 | .position(|outline| outline.id == id) |
| 1302 | 1472 | .unwrap(); |
| 1303 | 1473 | let layout = &mut self.outlines[index].layout; |
| 1474 | let previous = [layout.x, layout.y]; | |
| 1304 | 1475 | self.undo.push(History::Position { |
| 1305 | 1476 | outline: id, |
| 1306 | position: [layout.x, layout.y], | |
| 1477 | position: previous, | |
| 1307 | 1478 | }); |
| 1308 | 1479 | self.redo.clear(); |
| 1309 | 1480 | layout.x = Some(position[0]); |
| 1310 | 1481 | layout.y = Some(position[1]); |
| 1482 | self.record(self.placement_ops(&Placement { | |
| 1483 | id, | |
| 1484 | position: previous, | |
| 1485 | })); | |
| 1311 | 1486 | self.active = Focus::Outline(index); |
| 1312 | 1487 | self.preferred_x = None; |
| 1313 | 1488 | Ok(()) |
| ... | ... | @@ -1429,6 +1604,8 @@ impl CanvasEditor { |
| 1429 | 1604 | layout: previous, |
| 1430 | 1605 | }); |
| 1431 | 1606 | self.redo.clear(); |
| 1607 | let image = self.image(id).unwrap(); | |
| 1608 | self.record(Ok(ops::picture_layout(image))); | |
| 1432 | 1609 | Ok(()) |
| 1433 | 1610 | } |
| 1434 | 1611 | |
| ... | ... | @@ -1531,6 +1708,7 @@ impl CanvasEditor { |
| 1531 | 1708 | image: Some(Box::new(image)), |
| 1532 | 1709 | }); |
| 1533 | 1710 | self.redo.clear(); |
| 1711 | self.record(Ok(vec![PageOp::Delete { object: id }])); | |
| 1534 | 1712 | Ok(()) |
| 1535 | 1713 | } |
| 1536 | 1714 | |
| ... | ... | @@ -1559,6 +1737,11 @@ impl CanvasEditor { |
| 1559 | 1737 | let id = resized.id; |
| 1560 | 1738 | *self.active_outline_mut() = resized; |
| 1561 | 1739 | if self.caret_outline().is_none() { |
| 1740 | self.record(ops::layout_ops( | |
| 1741 | id, | |
| 1742 | &previous, | |
| 1743 | &self.active_outline().layout, | |
| 1744 | )); | |
| 1562 | 1745 | self.undo.push(History::Layout { |
| 1563 | 1746 | outline: id, |
| 1564 | 1747 | layout: previous, |
| ... | ... | @@ -2669,7 +2852,11 @@ impl CanvasEditor { |
| 2669 | 2852 | return Err((history, EditError::InvalidRange.into())); |
| 2670 | 2853 | } |
| 2671 | 2854 | let inverse = match history { |
| 2672 | History::Date(date) => History::Date(Box::new(self.date.replace(*date).unwrap())), | |
| 2855 | History::Date(date) => { | |
| 2856 | let shown = self.date.replace(*date).unwrap(); | |
| 2857 | self.record(Ok(self.date_ops())); | |
| 2858 | History::Date(Box::new(shown)) | |
| 2859 | } | |
| 2673 | 2860 | History::Draft { |
| 2674 | 2861 | outlines, |
| 2675 | 2862 | index, |
| ... | ... | @@ -2700,13 +2887,12 @@ impl CanvasEditor { |
| 2700 | 2887 | None |
| 2701 | 2888 | }; |
| 2702 | 2889 | let outline = outlines[usize::from(!restore_caret)].clone(); |
| 2703 | if self | |
| 2890 | let id = outline.id; | |
| 2891 | let stored = self | |
| 2704 | 2892 | .outlines |
| 2705 | 2893 | .get(index) |
| 2706 | .is_some_and(|item| item.id == outline.id) | |
| 2707 | { | |
| 2708 | self.outlines.remove(index); | |
| 2709 | } | |
| 2894 | .is_some_and(|item| item.id == id) | |
| 2895 | .then(|| self.outlines.remove(index)); | |
| 2710 | 2896 | self.active = if outline.is_empty() { |
| 2711 | 2897 | Focus::Caret { |
| 2712 | 2898 | outline: Box::new(outline), |
| ... | ... | @@ -2722,6 +2908,7 @@ impl CanvasEditor { |
| 2722 | 2908 | None => Focus::Outline(index), |
| 2723 | 2909 | } |
| 2724 | 2910 | }; |
| 2911 | self.record(self.outline_ops(id, stored.as_ref())); | |
| 2725 | 2912 | History::Draft { |
| 2726 | 2913 | outlines, |
| 2727 | 2914 | index, |
| ... | ... | @@ -2750,6 +2937,7 @@ impl CanvasEditor { |
| 2750 | 2937 | return Err((History::Text { outline, change }, error)); |
| 2751 | 2938 | } |
| 2752 | 2939 | }; |
| 2940 | self.record(self.change_ops(&self.outlines[index], &inverse)); | |
| 2753 | 2941 | History::Text { |
| 2754 | 2942 | outline, |
| 2755 | 2943 | change: Box::new(inverse), |
| ... | ... | @@ -2762,13 +2950,17 @@ impl CanvasEditor { |
| 2762 | 2950 | .position(|item| item.id == outline) |
| 2763 | 2951 | .unwrap(); |
| 2764 | 2952 | let layout = &mut self.outlines[index].layout; |
| 2765 | let inverse = History::Position { | |
| 2766 | outline, | |
| 2767 | position: [layout.x, layout.y], | |
| 2768 | }; | |
| 2953 | let previous = [layout.x, layout.y]; | |
| 2769 | 2954 | [layout.x, layout.y] = position; |
| 2770 | 2955 | self.active = Focus::Outline(index); |
| 2771 | inverse | |
| 2956 | self.record(self.placement_ops(&Placement { | |
| 2957 | id: outline, | |
| 2958 | position: previous, | |
| 2959 | })); | |
| 2960 | History::Position { | |
| 2961 | outline, | |
| 2962 | position: previous, | |
| 2963 | } | |
| 2772 | 2964 | } |
| 2773 | 2965 | History::Layout { outline, layout } => { |
| 2774 | 2966 | let index = self |
| ... | ... | @@ -2793,25 +2985,46 @@ impl CanvasEditor { |
| 2793 | 2985 | }; |
| 2794 | 2986 | resized.selection = self.outlines[index].selection; |
| 2795 | 2987 | self.outlines[index] = resized; |
| 2988 | self.record(ops::layout_ops( | |
| 2989 | outline, | |
| 2990 | &previous, | |
| 2991 | &self.outlines[index].layout, | |
| 2992 | )); | |
| 2796 | 2993 | self.active = Focus::Outline(index); |
| 2797 | 2994 | History::Layout { |
| 2798 | 2995 | outline, |
| 2799 | 2996 | layout: previous, |
| 2800 | 2997 | } |
| 2801 | 2998 | } |
| 2802 | History::Image { image, layout } => History::Image { | |
| 2803 | image, | |
| 2804 | layout: std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout), | |
| 2805 | }, | |
| 2999 | History::Image { image, layout } => { | |
| 3000 | let previous = | |
| 3001 | std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout); | |
| 3002 | self.record(Ok(ops::picture_layout(self.image(image).unwrap()))); | |
| 3003 | History::Image { | |
| 3004 | image, | |
| 3005 | layout: previous, | |
| 3006 | } | |
| 3007 | } | |
| 2806 | 3008 | History::Picture { index, image } => History::Picture { |
| 2807 | 3009 | index, |
| 2808 | 3010 | image: match image { |
| 2809 | 3011 | Some(image) => { |
| 2810 | 3012 | self.objects.insert(index, page::Content::Image(*image)); |
| 3013 | let page::Content::Image(image) = &self.objects[index] else { | |
| 3014 | unreachable!() | |
| 3015 | }; | |
| 3016 | let ops = vec![PageOp::Add { | |
| 3017 | object: PageObject::Image(image.clone()), | |
| 3018 | before: self.successor(image.id), | |
| 3019 | }]; | |
| 3020 | self.record(Ok(ops)); | |
| 2811 | 3021 | None |
| 2812 | 3022 | } |
| 2813 | 3023 | None => match self.objects.remove(index) { |
| 2814 | page::Content::Image(image) => Some(Box::new(image)), | |
| 3024 | page::Content::Image(image) => { | |
| 3025 | self.record(Ok(vec![PageOp::Delete { object: image.id }])); | |
| 3026 | Some(Box::new(image)) | |
| 3027 | } | |
| 2815 | 3028 | _ => unreachable!(), |
| 2816 | 3029 | }, |
| 2817 | 3030 | }, |
| ... | ... | @@ -2847,6 +3060,7 @@ impl CanvasEditor { |
| 2847 | 3060 | } |
| 2848 | 3061 | }; |
| 2849 | 3062 | let outline = Box::new(self.outlines.remove(index)); |
| 3063 | self.record(Ok(vec![PageOp::Delete { object: outline.id }])); | |
| 2850 | 3064 | self.active = next_focus; |
| 2851 | 3065 | History::Insert { |
| 2852 | 3066 | index, |
| ... | ... | @@ -2892,6 +3106,7 @@ impl CanvasEditor { |
| 2892 | 3106 | } => { |
| 2893 | 3107 | let id = outline.id; |
| 2894 | 3108 | self.outlines.insert(index, *outline); |
| 3109 | self.record(self.outline_ops(id, None)); | |
| 2895 | 3110 | self.active = Focus::Outline(index); |
| 2896 | 3111 | History::Remove { outline: id, focus } |
| 2897 | 3112 | } |
| ... | ... | @@ -3152,6 +3367,7 @@ impl CanvasEditor { |
| 3152 | 3367 | } else { |
| 3153 | 3368 | self.outlines[index] = *outline; |
| 3154 | 3369 | } |
| 3370 | self.record(self.outline_ops(versions[0].id, Some(&versions[0]))); | |
| 3155 | 3371 | self.undo.push(History::Draft { |
| 3156 | 3372 | outlines: versions, |
| 3157 | 3373 | index, |
| ... | ... | @@ -3173,8 +3389,9 @@ impl CanvasEditor { |
| 3173 | 3389 | }; |
| 3174 | 3390 | let mut source = outline.snapshot(); |
| 3175 | 3391 | source.paragraphs = change.edit.replacement; |
| 3392 | let id = outline.id; | |
| 3176 | 3393 | self.undo.push(History::Remove { |
| 3177 | outline: outline.id, | |
| 3394 | outline: id, | |
| 3178 | 3395 | focus: RestoreFocus::Caret { |
| 3179 | 3396 | source: Box::new(source), |
| 3180 | 3397 | selection: change.selection, |
| ... | ... | @@ -3182,11 +3399,13 @@ impl CanvasEditor { |
| 3182 | 3399 | }, |
| 3183 | 3400 | }); |
| 3184 | 3401 | self.outlines.insert(index, *outline); |
| 3402 | self.record(self.outline_ops(id, None)); | |
| 3185 | 3403 | } else if self.active_outline().is_empty() && !self.active_outline().title { |
| 3186 | 3404 | let Focus::Outline(index) = self.active else { |
| 3187 | 3405 | unreachable!() |
| 3188 | 3406 | }; |
| 3189 | 3407 | let outline = self.outlines.remove(index); |
| 3408 | self.record(Ok(vec![PageOp::Delete { object: outline.id }])); | |
| 3190 | 3409 | debug_assert_eq!(change.edit.range, 0..1); |
| 3191 | 3410 | let mut source = outline.snapshot(); |
| 3192 | 3411 | source.paragraphs = change.edit.replacement; |
| ... | ... | @@ -3205,6 +3424,7 @@ impl CanvasEditor { |
| 3205 | 3424 | index, |
| 3206 | 3425 | }; |
| 3207 | 3426 | } else { |
| 3427 | self.record(self.change_ops(self.active_outline(), &change)); | |
| 3208 | 3428 | self.undo.push(History::Text { |
| 3209 | 3429 | outline: self.active_outline().id, |
| 3210 | 3430 | change: Box::new(change), |
| ... | ... | @@ -3311,6 +3531,59 @@ impl CanvasEditor { |
| 3311 | 3531 | } |
| 3312 | 3532 | } |
| 3313 | 3533 | |
| 3534 | /// Where `position` in `old` lies in `new`, the same outline changed elsewhere: in the same | |
| 3535 | /// paragraph, past what changed in its text when it lies after it; at the start of the | |
| 3536 | /// paragraph now at its place when that one is gone. | |
| 3537 | fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition { | |
| 3538 | let found = old.leaf(position.paragraph).and_then(|(_, _, node)| { | |
| 3539 | let paragraph = new | |
| 3540 | .text_nodes() | |
| 3541 | .position(|candidate| candidate.id == node.id)?; | |
| 3542 | Some((paragraph, node, new.leaf(paragraph)?.2)) | |
| 3543 | }); | |
| 3544 | let Some((paragraph, before, after)) = found else { | |
| 3545 | let count = new.text_nodes().count(); | |
| 3546 | return TextPosition { | |
| 3547 | paragraph: position.paragraph.min(count.saturating_sub(1)), | |
| 3548 | offset: 0, | |
| 3549 | }; | |
| 3550 | }; | |
| 3551 | let units = |text: &str| text.encode_utf16().count() as u32; | |
| 3552 | let (a, b) = ( | |
| 3553 | before.text().unwrap().text.text(), | |
| 3554 | after.text().unwrap().text.text(), | |
| 3555 | ); | |
| 3556 | let prefix = units( | |
| 3557 | &a[..a | |
| 3558 | .char_indices() | |
| 3559 | .zip(b.chars()) | |
| 3560 | .find(|((_, x), y)| x != y) | |
| 3561 | .map_or(a.len().min(b.len()), |((at, _), _)| at)], | |
| 3562 | ); | |
| 3563 | let suffix = a | |
| 3564 | .chars() | |
| 3565 | .rev() | |
| 3566 | .zip(b.chars().rev()) | |
| 3567 | .take_while(|(x, y)| x == y) | |
| 3568 | .map(|(x, _)| x.len_utf16() as u32) | |
| 3569 | .scan(0, |sum, units| { | |
| 3570 | *sum += units; | |
| 3571 | Some(*sum) | |
| 3572 | }) | |
| 3573 | .take_while(|sum| prefix + sum <= units(a).min(units(b))) | |
| 3574 | .last() | |
| 3575 | .unwrap_or(0); | |
| 3576 | let (old_length, new_length) = (units(a), units(b)); | |
| 3577 | let offset = if position.offset <= prefix { | |
| 3578 | position.offset | |
| 3579 | } else if position.offset >= old_length - suffix { | |
| 3580 | new_length - (old_length - position.offset) | |
| 3581 | } else { | |
| 3582 | new_length - suffix | |
| 3583 | }; | |
| 3584 | TextPosition { paragraph, offset } | |
| 3585 | } | |
| 3586 | ||
| 3314 | 3587 | fn inserted_position( |
| 3315 | 3588 | mut position: TextPosition, |
| 3316 | 3589 | text: &str, |
| ... | ... | @@ -8360,4 +8633,65 @@ mod tests { |
| 8360 | 8633 | assert_eq!(body_text(&reread, body).as_deref(), Some(edited.as_str())); |
| 8361 | 8634 | assert_ne!(body_text(&source, body).as_deref(), Some(edited.as_str())); |
| 8362 | 8635 | } |
| 8636 | ||
| 8637 | /// A page changed elsewhere shows in place: the caret keeps its paragraph and moves | |
| 8638 | /// past text inserted before it; history goes, unless nothing changed. | |
| 8639 | #[test] | |
| 8640 | fn a_refresh_keeps_the_caret_by_identity() { | |
| 8641 | let mut engine = TextEngine::default(); | |
| 8642 | let lines = | |
| 8643 | ["Hello world", "Second"].map(|line| Paragraph::new(line.into(), Format::default())); | |
| 8644 | let document = TextDocument::new(lines.to_vec()).unwrap(); | |
| 8645 | let mut editor = CanvasEditor::new(&mut engine, document, 400.0).unwrap(); | |
| 8646 | editor | |
| 8647 | .select( | |
| 8648 | [TextPosition { | |
| 8649 | paragraph: 1, | |
| 8650 | offset: 3, | |
| 8651 | }; 2] | |
| 8652 | .into(), | |
| 8653 | ) | |
| 8654 | .unwrap(); | |
| 8655 | editor.insert(&mut engine, "x").unwrap(); | |
| 8656 | editor | |
| 8657 | .select( | |
| 8658 | [TextPosition { | |
| 8659 | paragraph: 0, | |
| 8660 | offset: 8, | |
| 8661 | }; 2] | |
| 8662 | .into(), | |
| 8663 | ) | |
| 8664 | .unwrap(); | |
| 8665 | let _ = editor.take_ops(); | |
| 8666 | let page = editor.page().unwrap(); | |
| 8667 | assert!(!editor.refresh(page.clone(), &mut engine).unwrap()); | |
| 8668 | assert!(!editor.undo.is_empty(), "an unchanged page keeps history"); | |
| 8669 | ||
| 8670 | let mut remote = page; | |
| 8671 | let PageObject::Outline(outline) = &mut remote.objects[0] else { | |
| 8672 | unreachable!() | |
| 8673 | }; | |
| 8674 | for (paragraph, text) in outline | |
| 8675 | .paragraphs | |
| 8676 | .iter_mut() | |
| 8677 | .zip(["Hey, Hello world", "Other"]) | |
| 8678 | { | |
| 8679 | paragraph.text_mut().unwrap().text = Paragraph::new(text.into(), Format::default()); | |
| 8680 | } | |
| 8681 | let (shown, affinities) = (editor.active_outline().id, editor.selection().affinities); | |
| 8682 | assert!(editor.refresh(remote, &mut engine).unwrap()); | |
| 8683 | assert_eq!(editor.active_outline().id, shown); | |
| 8684 | assert_eq!( | |
| 8685 | editor.selection(), | |
| 8686 | Selection { | |
| 8687 | positions: [TextPosition { | |
| 8688 | paragraph: 0, | |
| 8689 | offset: 13 | |
| 8690 | }; 2], | |
| 8691 | affinities, | |
| 8692 | } | |
| 8693 | ); | |
| 8694 | assert!(editor.undo.is_empty()); | |
| 8695 | assert_eq!(editor.take_ops().unwrap(), []); | |
| 8696 | } | |
| 8363 | 8697 | } |
crates/canvas/src/editor/ops.rs created+743| ... | ... | @@ -0,0 +1,743 @@ |
| 1 | //! The ops each stored change lowers to, recorded as the change applies so a page is never | |
| 2 | //! rebuilt to be saved. | |
| 3 | ||
| 4 | use super::*; | |
| 5 | use onestore::op; | |
| 6 | use onestore::{OutlineEdit, document::Layout}; | |
| 7 | ||
| 8 | type Lowered = Result<Vec<PageOp>, onestore::Error>; | |
| 9 | ||
| 10 | fn refused(message: &'static str) -> onestore::Error { | |
| 11 | onestore::Error { offset: 0, message } | |
| 12 | } | |
| 13 | ||
| 14 | fn stale(_: EditError) -> onestore::Error { | |
| 15 | refused("The editor lost track of the stored page") | |
| 16 | } | |
| 17 | ||
| 18 | /// Definitions a paragraph names: its lists, style and note tags. | |
| 19 | pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> + '_ { | |
| 20 | let content_tags = match &node.content { | |
| 21 | ParagraphContent::Text(text) => text.tags.as_slice(), | |
| 22 | ParagraphContent::Table(table) => table.tags.as_slice(), | |
| 23 | _ => &[], | |
| 24 | }; | |
| 25 | node.lists.iter().copied().chain(node.style).chain( | |
| 26 | node.tags | |
| 27 | .iter() | |
| 28 | .chain(content_tags) | |
| 29 | .filter_map(|tag| tag.definition), | |
| 30 | ) | |
| 31 | } | |
| 32 | ||
| 33 | /// The definitions `paragraphs` and their cells name. | |
| 34 | fn named( | |
| 35 | definitions: &BTreeMap<ExGuid, Definition>, | |
| 36 | paragraphs: &[&[PageParagraph]], | |
| 37 | ) -> BTreeMap<ExGuid, Definition> { | |
| 38 | paragraphs | |
| 39 | .iter() | |
| 40 | .flat_map(|list| descendants(list, None)) | |
| 41 | .flat_map(|(_, _, node)| references(node)) | |
| 42 | .filter_map(|id| Some((id, definitions.get(&id)?.clone()))) | |
| 43 | .collect() | |
| 44 | } | |
| 45 | ||
| 46 | /// `op::lower` of paragraphs `before` of `container` replaced by `nodes[range]` and the | |
| 47 | /// definitions they name, so it stays O(edit). When the structure changes, it also sees the | |
| 48 | /// unchanged paragraphs an op may split, join, move or anchor to: the ones either side and | |
| 49 | /// their ancestors, the range's descendants past it, and after them the next child of each | |
| 50 | /// ancestor. | |
| 51 | fn lower( | |
| 52 | container: ExGuid, | |
| 53 | nodes: &[PageParagraph], | |
| 54 | range: Range<usize>, | |
| 55 | before: &[PageParagraph], | |
| 56 | definitions: &BTreeMap<ExGuid, Definition>, | |
| 57 | ) -> Lowered { | |
| 58 | let after = &nodes[range.clone()]; | |
| 59 | if before.is_empty() && after.is_empty() { | |
| 60 | return Ok(Vec::new()); | |
| 61 | } | |
| 62 | let mut context = BTreeSet::new(); | |
| 63 | let shape = |node: &PageParagraph| (node.id, node.parent, node.level); | |
| 64 | if before.iter().map(shape).ne(after.iter().map(shape)) { | |
| 65 | context.extend(range.start.checked_sub(1)); | |
| 66 | context.extend((range.end < nodes.len()).then_some(range.end)); | |
| 67 | let held: BTreeSet<ExGuid> = before.iter().chain(after).map(|node| node.id).collect(); | |
| 68 | let mut wanted: Vec<ExGuid> = before | |
| 69 | .iter() | |
| 70 | .chain(after) | |
| 71 | .chain(context.iter().map(|&at| &nodes[at])) | |
| 72 | .filter_map(|node| node.parent) | |
| 73 | .collect(); | |
| 74 | let mut open = BTreeMap::from([(None, 0)]); | |
| 75 | while let Some(id) = wanted.pop() { | |
| 76 | if held.contains(&id) || context.iter().any(|&at| nodes[at].id == id) { | |
| 77 | continue; | |
| 78 | } | |
| 79 | if let Some(at) = nodes[..range.start].iter().rposition(|node| node.id == id) { | |
| 80 | context.insert(at); | |
| 81 | open.insert(Some(id), nodes[at].level); | |
| 82 | wanted.extend(nodes[at].parent); | |
| 83 | } | |
| 84 | } | |
| 85 | // Descendants of the range move with the paragraphs it splits, joins and moves. | |
| 86 | let mut members = held; | |
| 87 | let mut end = range.end; | |
| 88 | while let Some(node) = nodes.get(end) | |
| 89 | && node.parent.is_some_and(|parent| members.contains(&parent)) | |
| 90 | { | |
| 91 | members.insert(node.id); | |
| 92 | context.insert(end); | |
| 93 | end += 1; | |
| 94 | } | |
| 95 | for (at, node) in nodes.iter().enumerate().skip(end) { | |
| 96 | if open.remove(&node.parent).is_some() { | |
| 97 | context.insert(at); | |
| 98 | } | |
| 99 | // A container's children end where its subtree does. | |
| 100 | open.retain(|_, level| *level < node.level); | |
| 101 | if open.is_empty() { | |
| 102 | break; | |
| 103 | } | |
| 104 | } | |
| 105 | } | |
| 106 | let around = |middle: &[PageParagraph]| { | |
| 107 | let (head, tail): (Vec<usize>, Vec<usize>) = | |
| 108 | context.iter().partition(|&&at| at < range.start); | |
| 109 | head.iter() | |
| 110 | .map(|&at| &nodes[at]) | |
| 111 | .chain(middle) | |
| 112 | .chain(tail.iter().map(|&at| &nodes[at])) | |
| 113 | .cloned() | |
| 114 | .collect::<Vec<_>>() | |
| 115 | }; | |
| 116 | let (before, after) = (around(before), around(after)); | |
| 117 | let named = named(definitions, &[&before, &after]); | |
| 118 | // What `op::lower` cannot move or change in place is removed and inserted again under | |
| 119 | // the identities it had: stored paragraphs a new table cell holds, and, where lowering | |
| 120 | // fails, a paragraph whose text object a join replaced outside what `Join` expresses | |
| 121 | // (between empty paragraphs, or back on undo) with the paragraph whose text it takes. | |
| 122 | let text = |node: &PageParagraph| node.text().map(|text| text.id); | |
| 123 | let stored: BTreeMap<ExGuid, Option<ExGuid>> = descendants(&before, None) | |
| 124 | .map(|(_, _, node)| (node.id, text(node))) | |
| 125 | .collect(); | |
| 126 | let cells: BTreeSet<ExGuid> = descendants(&before, None) | |
| 127 | .filter_map(|(cell, _, _)| cell) | |
| 128 | .collect(); | |
| 129 | let mut removed: BTreeSet<ExGuid> = descendants(&after, None) | |
| 130 | .filter(|(cell, _, node)| { | |
| 131 | stored.contains_key(&node.id) && cell.is_some_and(|cell| !cells.contains(&cell)) | |
| 132 | }) | |
| 133 | .map(|(_, _, node)| node.id) | |
| 134 | .collect(); | |
| 135 | let lowered = |removed: &BTreeSet<ExGuid>| { | |
| 136 | let mut ops = Vec::new(); | |
| 137 | let mut deleted = Vec::new(); | |
| 138 | let mut kept = without(&before, removed, &mut ops, &mut deleted).map_err(stale)?; | |
| 139 | if kept.is_empty() { | |
| 140 | let anchor = anchor().map_err(stale)?; | |
| 141 | ops.push(PageOp::Insert { | |
| 142 | container, | |
| 143 | before: None, | |
| 144 | paragraphs: vec![anchor.clone()], | |
| 145 | }); | |
| 146 | kept.push(anchor); | |
| 147 | } | |
| 148 | ops.extend(deleted.into_iter().map(|object| PageOp::Delete { object })); | |
| 149 | ops.extend(op::lower(container, &kept, &after, None, &named)?); | |
| 150 | Ok(ops) | |
| 151 | }; | |
| 152 | let error = match lowered(&removed) { | |
| 153 | Ok(ops) => return Ok(ops), | |
| 154 | Err(error) => error, | |
| 155 | }; | |
| 156 | let taken: BTreeSet<ExGuid> = descendants(&after, None) | |
| 157 | .filter(|(_, _, node)| stored.get(&node.id).is_some_and(|old| *old != text(node))) | |
| 158 | .filter_map(|(_, _, node)| { | |
| 159 | removed.insert(node.id); | |
| 160 | text(node) | |
| 161 | }) | |
| 162 | .collect(); | |
| 163 | if taken.is_empty() { | |
| 164 | return Err(error); | |
| 165 | } | |
| 166 | removed.extend( | |
| 167 | descendants(&before, None) | |
| 168 | .filter(|(_, _, node)| text(node).is_some_and(|id| taken.contains(&id))) | |
| 169 | .map(|(_, _, node)| node.id), | |
| 170 | ); | |
| 171 | lowered(&removed) | |
| 172 | } | |
| 173 | ||
| 174 | /// An empty paragraph keeping a container from emptying while its paragraphs are removed | |
| 175 | /// and inserted again; the lowering removes it after. | |
| 176 | fn anchor() -> Result<PageParagraph, EditError> { | |
| 177 | crate::document::node( | |
| 178 | Paragraph::new(String::new(), Format::default()), | |
| 179 | Format::default(), | |
| 180 | ) | |
| 181 | } | |
| 182 | ||
| 183 | /// `nodes` without the subtrees of `removed`, whose roots `deleted` receives; a table cell | |
| 184 | /// they would empty first receives an anchor, which `ops` inserts. | |
| 185 | fn without( | |
| 186 | nodes: &[PageParagraph], | |
| 187 | removed: &BTreeSet<ExGuid>, | |
| 188 | ops: &mut Vec<PageOp>, | |
| 189 | deleted: &mut Vec<ExGuid>, | |
| 190 | ) -> Result<Vec<PageParagraph>, EditError> { | |
| 191 | let mut gone = BTreeSet::new(); | |
| 192 | let mut kept = Vec::new(); | |
| 193 | for node in nodes { | |
| 194 | if node.parent.is_some_and(|parent| gone.contains(&parent)) { | |
| 195 | gone.insert(node.id); | |
| 196 | } else if removed.contains(&node.id) { | |
| 197 | gone.insert(node.id); | |
| 198 | deleted.push(node.id); | |
| 199 | } else { | |
| 200 | let mut node = node.clone(); | |
| 201 | if let ParagraphContent::Table(table) = &mut node.content { | |
| 202 | for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { | |
| 203 | cell.paragraphs = without(&cell.paragraphs, removed, ops, deleted)?; | |
| 204 | if cell.paragraphs.is_empty() { | |
| 205 | let anchor = anchor()?; | |
| 206 | ops.push(PageOp::Insert { | |
| 207 | container: cell.id, | |
| 208 | before: None, | |
| 209 | paragraphs: vec![anchor.clone()], | |
| 210 | }); | |
| 211 | cell.paragraphs.push(anchor); | |
| 212 | } | |
| 213 | } | |
| 214 | } | |
| 215 | kept.push(node); | |
| 216 | } | |
| 217 | } | |
| 218 | Ok(kept) | |
| 219 | } | |
| 220 | ||
| 221 | /// A page-level outline's position and width, as `op::lower_page` compares them. | |
| 222 | pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered { | |
| 223 | let mut ops = Vec::new(); | |
| 224 | if (new.x, new.y) != (old.x, old.y) { | |
| 225 | let (Some(x), Some(y)) = (new.x, new.y) else { | |
| 226 | return Err(refused("An outline position needs both coordinates")); | |
| 227 | }; | |
| 228 | ops.push(PageOp::Outline { | |
| 229 | object: id, | |
| 230 | edit: OutlineEdit::Position { x, y }, | |
| 231 | }); | |
| 232 | } | |
| 233 | if (new.max_width, new.width_set_by_user) != (old.max_width, old.width_set_by_user) { | |
| 234 | ops.push(PageOp::Outline { | |
| 235 | object: id, | |
| 236 | edit: OutlineEdit::Width { | |
| 237 | points: new | |
| 238 | .max_width | |
| 239 | .ok_or_else(|| refused("An outline width cannot be removed"))?, | |
| 240 | user_set: new.width_set_by_user == Some(true), | |
| 241 | }, | |
| 242 | }); | |
| 243 | } | |
| 244 | Ok(ops) | |
| 245 | } | |
| 246 | ||
| 247 | /// A page picture's stored position, size and description. | |
| 248 | pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec<PageOp> { | |
| 249 | vec![PageOp::Picture { | |
| 250 | picture: image.id, | |
| 251 | layout: image.layout.clone(), | |
| 252 | alt: image.alt.clone(), | |
| 253 | }] | |
| 254 | } | |
| 255 | ||
| 256 | impl CanvasEditor { | |
| 257 | /// The ops the stored page took since the last call, in order; an error means an edit | |
| 258 | /// since then cannot be stored, and the page should be reopened from storage. | |
| 259 | pub fn take_ops(&mut self) -> Result<Vec<PageOp>, onestore::Error> { | |
| 260 | let taken = std::mem::replace(&mut self.ops, Ok(Vec::new())); | |
| 261 | match (&taken, &mut self.stored) { | |
| 262 | (Ok(ops), Some((_, sent))) => sent.extend(ops.iter().cloned()), | |
| 263 | (Err(_), stored) => *stored = None, | |
| 264 | (Ok(_), None) => {} | |
| 265 | } | |
| 266 | taken | |
| 267 | } | |
| 268 | ||
| 269 | pub(super) fn record(&mut self, lowered: Lowered) { | |
| 270 | if let Ok(ops) = &mut self.ops { | |
| 271 | match lowered { | |
| 272 | Ok(mut lowered) => ops.append(&mut lowered), | |
| 273 | Err(error) => self.ops = Err(error), | |
| 274 | } | |
| 275 | } | |
| 276 | } | |
| 277 | ||
| 278 | /// Ops for `change`, the inverse of an edit just applied to stored outline `outline`. | |
| 279 | pub(super) fn change_ops(&self, outline: &TextOutline, change: &TextChange) -> Lowered { | |
| 280 | let edit = &change.edit; | |
| 281 | let nodes = outline.document.container(edit.container).map_err(stale)?; | |
| 282 | let mut ops = lower( | |
| 283 | edit.container.unwrap_or(outline.id), | |
| 284 | nodes, | |
| 285 | edit.range.clone(), | |
| 286 | &edit.replacement, | |
| 287 | &self.definitions, | |
| 288 | )?; | |
| 289 | for (_, _, node) in descendants(outline.document.nodes(), None) { | |
| 290 | if let ParagraphContent::Table(table) = &node.content | |
| 291 | && edit.columns.contains_key(&table.id) | |
| 292 | { | |
| 293 | ops.push(PageOp::Table { | |
| 294 | table: table.id, | |
| 295 | edit: op::TableEdit::Columns(table.columns.clone()), | |
| 296 | }); | |
| 297 | } | |
| 298 | } | |
| 299 | for placement in &change.positions { | |
| 300 | ops.extend(self.placement_ops(placement)?); | |
| 301 | } | |
| 302 | Ok(ops) | |
| 303 | } | |
| 304 | ||
| 305 | /// A page object's move from `placement`, as title flow makes it. | |
| 306 | pub(super) fn placement_ops(&self, placement: &Placement) -> Lowered { | |
| 307 | let old = Layout { | |
| 308 | x: placement.position[0], | |
| 309 | y: placement.position[1], | |
| 310 | ..Default::default() | |
| 311 | }; | |
| 312 | let moved = |layout: &Layout| Layout { | |
| 313 | x: layout.x, | |
| 314 | y: layout.y, | |
| 315 | ..Default::default() | |
| 316 | }; | |
| 317 | let picture = |image: &onestore::page::Image| { | |
| 318 | Ok(if [image.layout.x, image.layout.y] == placement.position { | |
| 319 | Vec::new() | |
| 320 | } else { | |
| 321 | picture_layout(image) | |
| 322 | }) | |
| 323 | }; | |
| 324 | if let Some(outline) = self.outlines.iter().find(|item| item.id == placement.id) { | |
| 325 | return layout_ops(outline.id, &old, &moved(&outline.layout)); | |
| 326 | } | |
| 327 | for object in &self.objects { | |
| 328 | match object { | |
| 329 | page::Content::Image(image) if image.id == placement.id => return picture(image), | |
| 330 | page::Content::Outline { source, .. } if source.id == placement.id => { | |
| 331 | return layout_ops(source.id, &old, &moved(&source.layout)); | |
| 332 | } | |
| 333 | page::Content::ReadOnly(object) if object.source.id() == placement.id => { | |
| 334 | return match &object.source { | |
| 335 | PageObject::Outline(source) => { | |
| 336 | layout_ops(source.id, &old, &moved(&source.layout)) | |
| 337 | } | |
| 338 | PageObject::Image(image) => picture(image), | |
| 339 | _ => Err(refused("Unsupported objects cannot be moved")), | |
| 340 | }; | |
| 341 | } | |
| 342 | _ => {} | |
| 343 | } | |
| 344 | } | |
| 345 | Err(refused("The editor lost track of the stored page")) | |
| 346 | } | |
| 347 | ||
| 348 | /// Ops turning stored outline `old` into what `outlines` holds under `id` now: added, | |
| 349 | /// removed, or its changed paragraphs and geometry. | |
| 350 | pub(super) fn outline_ops(&self, id: ExGuid, old: Option<&TextOutline>) -> Lowered { | |
| 351 | let new = self.outlines.iter().find(|outline| outline.id == id); | |
| 352 | match (old, new) { | |
| 353 | (None, None) => Ok(Vec::new()), | |
| 354 | (Some(_), None) => Ok(vec![PageOp::Delete { object: id }]), | |
| 355 | (None, Some(new)) => { | |
| 356 | let outline = new.snapshot(); | |
| 357 | let page = |objects| Page { | |
| 358 | title: String::new(), | |
| 359 | identity: None, | |
| 360 | created: None, | |
| 361 | margin_origin: [0.0; 2], | |
| 362 | objects, | |
| 363 | definitions: named(&self.definitions, &[&outline.paragraphs]), | |
| 364 | }; | |
| 365 | let mut ops = op::lower_page( | |
| 366 | &page(Vec::new()), | |
| 367 | &page(vec![PageObject::Outline(outline.clone())]), | |
| 368 | )?; | |
| 369 | let successor = self.successor(id); | |
| 370 | for op in &mut ops { | |
| 371 | if let PageOp::Add { before, .. } = op { | |
| 372 | *before = successor; | |
| 373 | } | |
| 374 | } | |
| 375 | Ok(ops) | |
| 376 | } | |
| 377 | (Some(old), Some(new)) => { | |
| 378 | let (a, b) = (old.document.nodes(), new.document.nodes()); | |
| 379 | let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count(); | |
| 380 | let suffix = a[prefix..] | |
| 381 | .iter() | |
| 382 | .rev() | |
| 383 | .zip(b[prefix..].iter().rev()) | |
| 384 | .take_while(|(x, y)| x == y) | |
| 385 | .count(); | |
| 386 | let mut ops = lower( | |
| 387 | id, | |
| 388 | b, | |
| 389 | prefix..b.len() - suffix, | |
| 390 | &a[prefix..a.len() - suffix], | |
| 391 | &self.definitions, | |
| 392 | )?; | |
| 393 | ops.extend(layout_ops(id, &old.layout, &new.layout)?); | |
| 394 | Ok(ops) | |
| 395 | } | |
| 396 | } | |
| 397 | } | |
| 398 | ||
| 399 | /// The page's date as the editor shows it. | |
| 400 | pub(super) fn date_ops(&self) -> Vec<PageOp> { | |
| 401 | let Some(date) = &self.date else { | |
| 402 | return Vec::new(); | |
| 403 | }; | |
| 404 | vec![PageOp::Date { | |
| 405 | created: date.timestamp(), | |
| 406 | fields: date | |
| 407 | .fields() | |
| 408 | .filter_map(|(_, paragraph)| { | |
| 409 | let text = paragraph.text()?; | |
| 410 | Some((text.id, text.text.text().to_owned())) | |
| 411 | }) | |
| 412 | .collect(), | |
| 413 | }] | |
| 414 | } | |
| 415 | ||
| 416 | /// The page child `page()` places after `id`, titles aside. | |
| 417 | pub(super) fn successor(&self, id: ExGuid) -> Option<ExGuid> { | |
| 418 | let mut children: Vec<ExGuid> = Vec::new(); | |
| 419 | for content in &self.objects { | |
| 420 | let child = match content { | |
| 421 | page::Content::Editable(outline) | |
| 422 | if self.outlines.iter().any(|o| o.id == *outline) => | |
| 423 | { | |
| 424 | *outline | |
| 425 | } | |
| 426 | page::Content::Editable(_) => continue, | |
| 427 | page::Content::Outline { source, .. } => source.id, | |
| 428 | page::Content::Date { .. } => continue, | |
| 429 | page::Content::Image(image) => image.id, | |
| 430 | page::Content::Ink(ink) => ink.id, | |
| 431 | page::Content::ReadOnly(object) | |
| 432 | if matches!(object.source, PageObject::Title(_)) => | |
| 433 | { | |
| 434 | continue; | |
| 435 | } | |
| 436 | page::Content::ReadOnly(object) => object.source.id(), | |
| 437 | }; | |
| 438 | // Titles, the date's among them, are listed apart from what an op places. | |
| 439 | if !self | |
| 440 | .header | |
| 441 | .areas | |
| 442 | .iter() | |
| 443 | .any(|area| area.origins.contains_key(&child)) | |
| 444 | { | |
| 445 | children.push(child); | |
| 446 | } | |
| 447 | } | |
| 448 | children.extend( | |
| 449 | self.outlines | |
| 450 | .iter() | |
| 451 | .filter(|outline| !self.has_page_outline(outline.id)) | |
| 452 | .map(|outline| outline.id), | |
| 453 | ); | |
| 454 | let at = children.iter().position(|child| *child == id)?; | |
| 455 | children.get(at + 1).copied() | |
| 456 | } | |
| 457 | } | |
| 458 | ||
| 459 | #[cfg(test)] | |
| 460 | mod tests { | |
| 461 | use super::*; | |
| 462 | use crate::layout::TextEngine; | |
| 463 | use std::time::{Duration, Instant}; | |
| 464 | ||
| 465 | fn median(mut samples: Vec<Duration>) -> Duration { | |
| 466 | samples.sort(); | |
| 467 | samples[samples.len() / 2] | |
| 468 | } | |
| 469 | ||
| 470 | /// The rows of the data-layer plan's table: each edit and its undo as ops, the undo | |
| 471 | /// of a join splitting the paragraph back under its own identities. | |
| 472 | #[test] | |
| 473 | fn edits_and_their_undo_record_the_plan_s_ops() { | |
| 474 | let mut engine = TextEngine::default(); | |
| 475 | let lines = ["Hello", "World"].map(|line| Paragraph::new(line.into(), Format::default())); | |
| 476 | let document = crate::document::TextDocument::new(lines.to_vec()).unwrap(); | |
| 477 | let mut editor = CanvasEditor::new(&mut engine, document, 400.0).unwrap(); | |
| 478 | let nodes = editor.outlines()[0].document().nodes().to_vec(); | |
| 479 | let text = |index: usize| nodes[index].text().unwrap().id; | |
| 480 | let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into(); | |
| 481 | let undone = |editor: &mut CanvasEditor, engine: &mut TextEngine| { | |
| 482 | editor.undo(engine).unwrap(); | |
| 483 | editor.take_ops().unwrap() | |
| 484 | }; | |
| 485 | ||
| 486 | editor.select(at(0, 3)).unwrap(); | |
| 487 | editor.insert(&mut engine, "ab").unwrap(); | |
| 488 | let typed = PageOp::Text { | |
| 489 | text: text(0), | |
| 490 | range: 3..3, | |
| 491 | with: "ab".into(), | |
| 492 | }; | |
| 493 | assert_eq!(editor.take_ops().unwrap(), [typed]); | |
| 494 | let erased = PageOp::Text { | |
| 495 | text: text(0), | |
| 496 | range: 3..5, | |
| 497 | with: String::new(), | |
| 498 | }; | |
| 499 | assert_eq!(undone(&mut editor, &mut engine), [erased]); | |
| 500 | ||
| 501 | editor.select(at(1, 0)).unwrap(); | |
| 502 | editor.delete(&mut engine, true).unwrap(); | |
| 503 | let joined = PageOp::Join { | |
| 504 | left: text(0), | |
| 505 | right: text(1), | |
| 506 | }; | |
| 507 | assert_eq!(editor.take_ops().unwrap(), [joined]); | |
| 508 | let split = PageOp::Split { | |
| 509 | text: text(0), | |
| 510 | at: 5, | |
| 511 | paragraph: nodes[1].id, | |
| 512 | right: text(1), | |
| 513 | lists: Vec::new(), | |
| 514 | }; | |
| 515 | assert_eq!(undone(&mut editor, &mut engine), [split]); | |
| 516 | ||
| 517 | editor.select(at(1, 0)).unwrap(); | |
| 518 | editor.tab(&mut engine, false).unwrap(); | |
| 519 | let [PageOp::Move { object, parent, .. }] = &editor.take_ops().unwrap()[..] else { | |
| 520 | panic!("Tab moves the paragraph under its sibling") | |
| 521 | }; | |
| 522 | assert_eq!((*object, *parent), (nodes[1].id, Some(nodes[0].id))); | |
| 523 | // A moved paragraph keeps its level where it lies deeper than its new parent. | |
| 524 | let ops = undone(&mut editor, &mut engine); | |
| 525 | let [ | |
| 526 | PageOp::Move { object, parent, .. }, | |
| 527 | PageOp::Level { | |
| 528 | paragraph, | |
| 529 | level: 1, | |
| 530 | }, | |
| 531 | ] = &ops[..] | |
| 532 | else { | |
| 533 | panic!("undoing Tab moves it back and outdents it: {ops:?}") | |
| 534 | }; | |
| 535 | assert_eq!( | |
| 536 | (*object, *parent, *paragraph), | |
| 537 | (nodes[1].id, Some(editor.outlines()[0].id), nodes[1].id) | |
| 538 | ); | |
| 539 | ||
| 540 | editor | |
| 541 | .select( | |
| 542 | [ | |
| 543 | TextPosition { | |
| 544 | paragraph: 0, | |
| 545 | offset: 1, | |
| 546 | }, | |
| 547 | TextPosition { | |
| 548 | paragraph: 0, | |
| 549 | offset: 4, | |
| 550 | }, | |
| 551 | ] | |
| 552 | .into(), | |
| 553 | ) | |
| 554 | .unwrap(); | |
| 555 | editor | |
| 556 | .format(&mut engine, Formatting::Toggle(format::Toggle::Bold)) | |
| 557 | .unwrap(); | |
| 558 | let bold = PageOp::Format { | |
| 559 | text: text(0), | |
| 560 | range: 1..4, | |
| 561 | set: vec![onestore::TextAttribute::Bold(true)], | |
| 562 | clear: Vec::new(), | |
| 563 | }; | |
| 564 | assert_eq!(editor.take_ops().unwrap(), [bold]); | |
| 565 | let [ | |
| 566 | PageOp::Format { | |
| 567 | text: target, | |
| 568 | range, | |
| 569 | .. | |
| 570 | }, | |
| 571 | ] = &undone(&mut editor, &mut engine)[..] | |
| 572 | else { | |
| 573 | panic!("undoing bold formats the range back") | |
| 574 | }; | |
| 575 | assert_eq!((*target, range.clone()), (text(0), 1..4)); | |
| 576 | } | |
| 577 | ||
| 578 | /// A change from elsewhere reaches only what it changed: a refresh whose stored page is | |
| 579 | /// what the editor's ops left (though storage normalized it) changes nothing, history | |
| 580 | /// included; one that changes an outline drops that outline's history and keeps the | |
| 581 | /// rest, which still undoes into the section. | |
| 582 | #[test] | |
| 583 | fn a_refresh_keeps_what_the_change_did_not_reach() { | |
| 584 | use onestore::op::{Edit, Op}; | |
| 585 | let image = | |
| 586 | include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one"); | |
| 587 | let arena = onestore::Arena::default(); | |
| 588 | let mut section = onestore::Section::open(&arena, image.to_vec()).unwrap(); | |
| 589 | let space = section.pages().unwrap()[0].0; | |
| 590 | let mut engine = TextEngine::default(); | |
| 591 | let mut editor = | |
| 592 | CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); | |
| 593 | let mut at = 133_000_000_000_000_000; | |
| 594 | let mut store = |section: &mut onestore::Section<'_>, ops: Vec<PageOp>| { | |
| 595 | at += 10_000_000; | |
| 596 | let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); | |
| 597 | section.apply("Test", &Edit { at, ops }).unwrap(); | |
| 598 | }; | |
| 599 | let local = editor.outlines().iter().find(|o| !o.title).unwrap().id; | |
| 600 | editor.focus_outline(local).unwrap(); | |
| 601 | editor | |
| 602 | .select( | |
| 603 | [TextPosition { | |
| 604 | paragraph: 0, | |
| 605 | offset: 0, | |
| 606 | }; 2] | |
| 607 | .into(), | |
| 608 | ) | |
| 609 | .unwrap(); | |
| 610 | editor.insert(&mut engine, "Local ").unwrap(); | |
| 611 | let created = editor | |
| 612 | .create_outline(&mut engine, [400.0, 600.0], 300.0) | |
| 613 | .unwrap(); | |
| 614 | editor.insert(&mut engine, "Mine").unwrap(); | |
| 615 | store(&mut section, editor.take_ops().unwrap()); | |
| 616 | ||
| 617 | let (shown, history) = (editor.page().unwrap(), editor.undo.len()); | |
| 618 | let stored = section.page(space).unwrap(); | |
| 619 | assert_ne!( | |
| 620 | Page { | |
| 621 | title: shown.title.clone(), | |
| 622 | ..stored.clone() | |
| 623 | }, | |
| 624 | shown, | |
| 625 | "storage normalizes" | |
| 626 | ); | |
| 627 | assert!(!editor.refresh(stored, &mut engine).unwrap()); | |
| 628 | assert_eq!( | |
| 629 | ( | |
| 630 | editor.page().unwrap(), | |
| 631 | editor.undo.len(), | |
| 632 | editor.active_outline().id | |
| 633 | ), | |
| 634 | (shown, history, created) | |
| 635 | ); | |
| 636 | ||
| 637 | let text = section | |
| 638 | .page(space) | |
| 639 | .unwrap() | |
| 640 | .objects | |
| 641 | .iter() | |
| 642 | .find_map(|object| match object { | |
| 643 | onestore::page::PageObject::Outline(outline) if outline.id == local => outline | |
| 644 | .paragraphs | |
| 645 | .last()? | |
| 646 | .text() | |
| 647 | .map(|text| (text.id, text.text.text().encode_utf16().count() as u32)), | |
| 648 | _ => None, | |
| 649 | }); | |
| 650 | let (text, length) = text.unwrap(); | |
| 651 | store( | |
| 652 | &mut section, | |
| 653 | vec![PageOp::Text { | |
| 654 | text, | |
| 655 | range: length..length, | |
| 656 | with: " remote".into(), | |
| 657 | }], | |
| 658 | ); | |
| 659 | assert!( | |
| 660 | editor | |
| 661 | .refresh(section.page(space).unwrap(), &mut engine) | |
| 662 | .unwrap() | |
| 663 | ); | |
| 664 | assert_eq!( | |
| 665 | editor.undo.len(), | |
| 666 | history - 1, | |
| 667 | "the typing in the changed outline goes" | |
| 668 | ); | |
| 669 | assert_eq!(editor.active_outline().id, created); | |
| 670 | assert!(editor.undo(&mut engine).unwrap()); | |
| 671 | assert!(editor.undo(&mut engine).unwrap()); | |
| 672 | store(&mut section, editor.take_ops().unwrap()); | |
| 673 | assert!(!editor.undo(&mut engine).unwrap()); | |
| 674 | let page = section.page(space).unwrap(); | |
| 675 | assert!(!page.objects.iter().any(|object| object.id() == created)); | |
| 676 | let texts: Vec<String> = editor | |
| 677 | .outlines() | |
| 678 | .iter() | |
| 679 | .flat_map(|o| o.document().paragraphs().map(|p| p.text().to_owned())) | |
| 680 | .collect(); | |
| 681 | assert!(texts.iter().any(|text| text.starts_with("Local "))); | |
| 682 | assert!(texts.iter().any(|text| text.ends_with(" remote"))); | |
| 683 | } | |
| 684 | ||
| 685 | /// One keystroke on the 3000-paragraph probe page, on the frame thread: | |
| 686 | /// `SECTION_PROBE=path cargo test --release -p canvas --lib keystroke_recording -- --ignored --nocapture`. | |
| 687 | #[test] | |
| 688 | #[ignore] | |
| 689 | fn keystroke_recording() { | |
| 690 | let path = std::env::var("SECTION_PROBE").unwrap_or("/tmp/probe3000.one".into()); | |
| 691 | let image = std::fs::read(&path).unwrap(); | |
| 692 | let arena = onestore::Arena::default(); | |
| 693 | let mut section = onestore::Section::open(&arena, image).unwrap(); | |
| 694 | let page = section | |
| 695 | .pages() | |
| 696 | .unwrap() | |
| 697 | .into_iter() | |
| 698 | .map(|(space, ..)| section.page(space).unwrap()) | |
| 699 | .max_by_key(|page| format!("{page:?}").len()) | |
| 700 | .unwrap(); | |
| 701 | let mut engine = TextEngine::default(); | |
| 702 | let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); | |
| 703 | let outline = editor | |
| 704 | .outlines() | |
| 705 | .iter() | |
| 706 | .max_by_key(|outline| outline.document().nodes().len()) | |
| 707 | .unwrap(); | |
| 708 | let (id, count) = (outline.id, outline.document().nodes().len()); | |
| 709 | editor.focus_outline(id).unwrap(); | |
| 710 | let caret = TextPosition { | |
| 711 | paragraph: count / 2, | |
| 712 | offset: 0, | |
| 713 | }; | |
| 714 | editor.select([caret; 2].into()).unwrap(); | |
| 715 | let (mut typing, mut recording, mut rebuilding) = (Vec::new(), Vec::new(), Vec::new()); | |
| 716 | let mut previous = editor.page().unwrap(); | |
| 717 | for _ in 0..200 { | |
| 718 | let start = Instant::now(); | |
| 719 | editor.insert(&mut engine, "x").unwrap(); | |
| 720 | typing.push(start.elapsed()); | |
| 721 | let Some(History::Text { change, .. }) = editor.undo.last() else { | |
| 722 | unreachable!() | |
| 723 | }; | |
| 724 | let start = Instant::now(); | |
| 725 | let ops = editor.change_ops(editor.active_outline(), change).unwrap(); | |
| 726 | recording.push(start.elapsed()); | |
| 727 | assert_eq!(ops.len(), 1, "{ops:?}"); | |
| 728 | assert_eq!(editor.take_ops().unwrap().len(), 1); | |
| 729 | let start = Instant::now(); | |
| 730 | let page = editor.page().unwrap(); | |
| 731 | assert!(page != previous); | |
| 732 | rebuilding.push(start.elapsed()); | |
| 733 | previous = page; | |
| 734 | } | |
| 735 | println!( | |
| 736 | "{path}: {count} paragraphs; keystroke {:?}, of which recording its op {:?}; \ | |
| 737 | the page rebuilt and compared as persist did {:?}", | |
| 738 | median(typing), | |
| 739 | median(recording), | |
| 740 | median(rebuilding) | |
| 741 | ); | |
| 742 | } | |
| 743 | } |
crates/canvas/src/gpu/page.rs+27| ... | ... | @@ -213,6 +213,33 @@ impl PageScene { |
| 213 | 213 | Ok((scene, editor)) |
| 214 | 214 | } |
| 215 | 215 | |
| 216 | /// Follows `editor` after it refreshed from storage, keeping the rasters of pictures | |
| 217 | /// whose bytes did not change so they do not blink. | |
| 218 | pub fn refresh( | |
| 219 | &mut self, | |
| 220 | editor: &mut CanvasEditor, | |
| 221 | engine: &mut TextEngine, | |
| 222 | ) -> Result<(), SceneError> { | |
| 223 | let mut scene = Self::pictures(&editor.objects, Some(editor))?; | |
| 224 | for (id, picture) in &mut scene.pictures { | |
| 225 | if let Some(shown) = self.pictures.remove(id) | |
| 226 | && shown.same(picture) | |
| 227 | { | |
| 228 | *picture = shown; | |
| 229 | } | |
| 230 | } | |
| 231 | for (id, art) in &mut scene.backgrounds { | |
| 232 | if let Some(shown) = self.backgrounds.remove(id) | |
| 233 | && shown.same(art) | |
| 234 | { | |
| 235 | *art = shown; | |
| 236 | } | |
| 237 | } | |
| 238 | scene.mark_unavailable(&mut editor.objects, engine)?; | |
| 239 | *self = scene; | |
| 240 | Ok(()) | |
| 241 | } | |
| 242 | ||
| 216 | 243 | /// Turns page pictures the renderer cannot decode into placeholders; their stored data |
| 217 | 244 | /// is kept. |
| 218 | 245 | fn mark_unavailable( |
crates/canvas/src/gpu/page/background.rs+5| ... | ... | @@ -330,6 +330,11 @@ struct Variant { |
| 330 | 330 | } |
| 331 | 331 | |
| 332 | 332 | impl Background { |
| 333 | /// Whether both recreate the same template picture at the same size. | |
| 334 | pub fn same(&self, other: &Self) -> bool { | |
| 335 | self.svg == other.svg && self.size == other.size | |
| 336 | } | |
| 337 | ||
| 333 | 338 | pub fn recognise(picture: &onestore::page::Image) -> Option<Self> { |
| 334 | 339 | let digest: String = Sha256::digest(picture.bytes.as_deref()?) |
| 335 | 340 | .iter() |
crates/canvas/src/gpu/page/picture.rs+5| ... | ... | @@ -31,6 +31,11 @@ pub(super) struct Picture { |
| 31 | 31 | |
| 32 | 32 | impl Picture { |
| 33 | 33 | /// Reads the size from the picture's header; None when the renderer cannot decode it. |
| 34 | /// Whether both show the same stored bytes. | |
| 35 | pub fn same(&self, other: &Self) -> bool { | |
| 36 | self.bytes == other.bytes | |
| 37 | } | |
| 38 | ||
| 34 | 39 | pub fn new(bytes: &Arc<[u8]>) -> Option<Self> { |
| 35 | 40 | Some(Self { |
| 36 | 41 | native: RasterImage::measure(bytes).ok()?, |
crates/canvas/src/interaction/mod.rs+15| ... | ... | @@ -257,6 +257,21 @@ impl PageView { |
| 257 | 257 | self.object_focus = None; |
| 258 | 258 | } |
| 259 | 259 | |
| 260 | /// Shows the stored page after a change made elsewhere in place of the one shown, | |
| 261 | /// keeping the scroll, the caret and selection, and the pictures already drawn. Marked | |
| 262 | /// text must be committed or cancelled first. | |
| 263 | pub fn refresh(&mut self, page: onestore::page::Page) -> Result<Response> { | |
| 264 | if !self.editor.refresh(page, &mut self.engine)? { | |
| 265 | return Ok(Response::default()); | |
| 266 | } | |
| 267 | if let Some((scene, _)) = &mut self.scene { | |
| 268 | scene.refresh(&mut self.editor, &mut self.engine)?; | |
| 269 | } | |
| 270 | self.drag = None; | |
| 271 | self.object_focus = None; | |
| 272 | self.moved() | |
| 273 | } | |
| 274 | ||
| 260 | 275 | /// Shows another page as OneNote opens one, keeping the zoom. |
| 261 | 276 | pub fn open(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) { |
| 262 | 277 | self.replace(editor, scene); |
crates/canvas/tests/ops_differential.rs created+670| ... | ... | @@ -0,0 +1,670 @@ |
| 1 | //! The ops the editor records for each edit, applied to the section, store what the edited | |
| 2 | //! page lowered whole (`Section::apply_page(editor.page())`) stores: every editor operation | |
| 3 | //! alone and in random sequences with undo and redo, on every page of the corpus sections | |
| 4 | //! below and of any named in `OPS_SWEEP_SECTIONS` (`:`-separated paths; the structural | |
| 5 | //! probe section, which `structural_roundtrip` sweeps, takes minutes). | |
| 6 | ||
| 7 | use canvas::{ | |
| 8 | document::TextPosition, | |
| 9 | editor::{Alignment, CanvasEditor, Formatting, NoteTag, Toggle}, | |
| 10 | layout::TextEngine, | |
| 11 | }; | |
| 12 | use draw::edit::Movement; | |
| 13 | use onestore::{ | |
| 14 | Arena, ExGuid, Section, | |
| 15 | op::{Edit, Op}, | |
| 16 | page::Page, | |
| 17 | }; | |
| 18 | use std::{collections::BTreeMap, path::Path}; | |
| 19 | ||
| 20 | const SECTIONS: [&str; 15] = [ | |
| 21 | "corpus/outline-edit/before/notebook/synthetic.one", | |
| 22 | "corpus/paragraph-edit/before/notebook/synthetic.one", | |
| 23 | "corpus/outline-edit/tree/before/notebook/synthetic.one", | |
| 24 | "corpus/m6/native-features-01/notebook/Features.one", | |
| 25 | "corpus/table-edit/nested/cold/notebook/synthetic.one", | |
| 26 | "corpus/m6/native-table-controls-01/notebook/synthetic.one", | |
| 27 | "corpus/list-edit/cold/notebook/lists.one", | |
| 28 | "corpus/tag-edit/cold/notebook/tags.one", | |
| 29 | "corpus/picture-edit/native-page-level/notebook/pictures.one", | |
| 30 | "corpus/attachment-edit/plain/cold/notebook/files.one", | |
| 31 | "corpus/ink-edit/drawing/cold/notebook/ink.one", | |
| 32 | "corpus/math-edit/native-editor/notebook/links.one", | |
| 33 | "corpus/link-edit/native-links/notebook/links.one", | |
| 34 | "corpus/paragraph-format/cold/notebook/synthetic.one", | |
| 35 | "corpus/canvas/baseline-anchors.one", | |
| 36 | ]; | |
| 37 | ||
| 38 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] | |
| 39 | enum Kind { | |
| 40 | Type, | |
| 41 | TypeEnd, | |
| 42 | Compose, | |
| 43 | ComposeCancel, | |
| 44 | DeleteBackward, | |
| 45 | DeleteForward, | |
| 46 | DeleteRange, | |
| 47 | DeleteWord, | |
| 48 | EnterStart, | |
| 49 | EnterMiddle, | |
| 50 | EnterEnd, | |
| 51 | SoftEnter, | |
| 52 | Backspace, | |
| 53 | Delete, | |
| 54 | Tab, | |
| 55 | ShiftTab, | |
| 56 | TabMiddle, | |
| 57 | Paste, | |
| 58 | PasteLine, | |
| 59 | Bold, | |
| 60 | Italic, | |
| 61 | Font, | |
| 62 | FontSize, | |
| 63 | Color, | |
| 64 | Highlight, | |
| 65 | Clear, | |
| 66 | Center, | |
| 67 | Bullets, | |
| 68 | Numbering, | |
| 69 | Tag, | |
| 70 | Check, | |
| 71 | ClickCheck, | |
| 72 | MoveOutline, | |
| 73 | ResizeOutline, | |
| 74 | CreateOutline, | |
| 75 | EmptyOutline, | |
| 76 | ArrowPastEnd, | |
| 77 | PlaceImage, | |
| 78 | RemoveImage, | |
| 79 | Date, | |
| 80 | Undo, | |
| 81 | Redo, | |
| 82 | } | |
| 83 | ||
| 84 | const KINDS: [Kind; 42] = [ | |
| 85 | Kind::Type, | |
| 86 | Kind::TypeEnd, | |
| 87 | Kind::Compose, | |
| 88 | Kind::ComposeCancel, | |
| 89 | Kind::DeleteBackward, | |
| 90 | Kind::DeleteForward, | |
| 91 | Kind::DeleteRange, | |
| 92 | Kind::DeleteWord, | |
| 93 | Kind::EnterStart, | |
| 94 | Kind::EnterMiddle, | |
| 95 | Kind::EnterEnd, | |
| 96 | Kind::SoftEnter, | |
| 97 | Kind::Backspace, | |
| 98 | Kind::Delete, | |
| 99 | Kind::Tab, | |
| 100 | Kind::ShiftTab, | |
| 101 | Kind::TabMiddle, | |
| 102 | Kind::Paste, | |
| 103 | Kind::PasteLine, | |
| 104 | Kind::Bold, | |
| 105 | Kind::Italic, | |
| 106 | Kind::Font, | |
| 107 | Kind::FontSize, | |
| 108 | Kind::Color, | |
| 109 | Kind::Highlight, | |
| 110 | Kind::Clear, | |
| 111 | Kind::Center, | |
| 112 | Kind::Bullets, | |
| 113 | Kind::Numbering, | |
| 114 | Kind::Tag, | |
| 115 | Kind::Check, | |
| 116 | Kind::ClickCheck, | |
| 117 | Kind::MoveOutline, | |
| 118 | Kind::ResizeOutline, | |
| 119 | Kind::CreateOutline, | |
| 120 | Kind::EmptyOutline, | |
| 121 | Kind::ArrowPastEnd, | |
| 122 | Kind::PlaceImage, | |
| 123 | Kind::RemoveImage, | |
| 124 | Kind::Date, | |
| 125 | Kind::Undo, | |
| 126 | Kind::Redo, | |
| 127 | ]; | |
| 128 | ||
| 129 | struct Random(u64); | |
| 130 | ||
| 131 | impl Random { | |
| 132 | fn below(&mut self, bound: usize) -> usize { | |
| 133 | self.0 ^= self.0 << 13; | |
| 134 | self.0 ^= self.0 >> 7; | |
| 135 | self.0 ^= self.0 << 17; | |
| 136 | (self.0 % bound.max(1) as u64) as usize | |
| 137 | } | |
| 138 | } | |
| 139 | ||
| 140 | /// A random caret in the active outline: the paragraph and its UTF-16 length. | |
| 141 | fn caret(editor: &CanvasEditor, random: &mut Random) -> (usize, u32) { | |
| 142 | let count = editor.active_outline().document().paragraphs().count(); | |
| 143 | let paragraph = random.below(count); | |
| 144 | let text = editor | |
| 145 | .active_outline() | |
| 146 | .document() | |
| 147 | .paragraphs() | |
| 148 | .nth(paragraph) | |
| 149 | .unwrap(); | |
| 150 | (paragraph, text.text().encode_utf16().count() as u32) | |
| 151 | } | |
| 152 | ||
| 153 | /// A scalar boundary of the paragraph near its middle, or a random one. | |
| 154 | fn inside(editor: &CanvasEditor, paragraph: usize, random: &mut Random) -> u32 { | |
| 155 | let text = editor | |
| 156 | .active_outline() | |
| 157 | .document() | |
| 158 | .paragraphs() | |
| 159 | .nth(paragraph) | |
| 160 | .unwrap(); | |
| 161 | let chars: Vec<usize> = text.text().char_indices().map(|(at, _)| at).collect(); | |
| 162 | let at = chars | |
| 163 | .get(random.below(chars.len() + 1)) | |
| 164 | .copied() | |
| 165 | .unwrap_or(text.text().len()); | |
| 166 | text.utf16_offset(at).unwrap() | |
| 167 | } | |
| 168 | ||
| 169 | /// Performs `kind` somewhere on the page; false when the editor declines it. | |
| 170 | fn perform( | |
| 171 | editor: &mut CanvasEditor, | |
| 172 | engine: &mut TextEngine, | |
| 173 | kind: Kind, | |
| 174 | random: &mut Random, | |
| 175 | ) -> bool { | |
| 176 | let outlines: Vec<ExGuid> = editor.outlines().iter().map(|outline| outline.id).collect(); | |
| 177 | if !matches!( | |
| 178 | kind, | |
| 179 | Kind::Undo | |
| 180 | | Kind::Redo | |
| 181 | | Kind::PlaceImage | |
| 182 | | Kind::RemoveImage | |
| 183 | | Kind::CreateOutline | |
| 184 | | Kind::Date | |
| 185 | ) { | |
| 186 | if outlines.is_empty() { | |
| 187 | return false; | |
| 188 | } | |
| 189 | let outline = outlines[random.below(outlines.len())]; | |
| 190 | if editor.focus_outline(outline).is_err() { | |
| 191 | return false; | |
| 192 | } | |
| 193 | } | |
| 194 | let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into(); | |
| 195 | let (paragraph, length) = if outlines.is_empty() { | |
| 196 | (0, 0) | |
| 197 | } else { | |
| 198 | caret(editor, random) | |
| 199 | }; | |
| 200 | let middle = if outlines.is_empty() { | |
| 201 | 0 | |
| 202 | } else { | |
| 203 | inside(editor, paragraph, random) | |
| 204 | }; | |
| 205 | let place = |editor: &mut CanvasEditor, offset| editor.select(at(paragraph, offset)).is_ok(); | |
| 206 | let range = |editor: &mut CanvasEditor, random: &mut Random| { | |
| 207 | let (last, _) = caret(editor, random); | |
| 208 | let (first, last) = (paragraph.min(last), paragraph.max(last)); | |
| 209 | let start = inside(editor, first, random); | |
| 210 | let end = inside(editor, last, random); | |
| 211 | let (start, end) = if first == last { | |
| 212 | (start.min(end), start.max(end)) | |
| 213 | } else { | |
| 214 | (start, end) | |
| 215 | }; | |
| 216 | editor | |
| 217 | .select( | |
| 218 | [ | |
| 219 | TextPosition { | |
| 220 | paragraph: first, | |
| 221 | offset: start, | |
| 222 | }, | |
| 223 | TextPosition { | |
| 224 | paragraph: last, | |
| 225 | offset: end, | |
| 226 | }, | |
| 227 | ] | |
| 228 | .into(), | |
| 229 | ) | |
| 230 | .is_ok() | |
| 231 | }; | |
| 232 | let format = | |
| 233 | |editor: &mut CanvasEditor, engine: &mut TextEngine, random: &mut Random, command| { | |
| 234 | range(editor, random) && editor.format(engine, command).is_ok() | |
| 235 | }; | |
| 236 | match kind { | |
| 237 | Kind::Type => place(editor, middle) && editor.insert(engine, "ab").is_ok(), | |
| 238 | Kind::TypeEnd => place(editor, length) && editor.commit_text(engine, "z".into()).is_ok(), | |
| 239 | Kind::Compose => { | |
| 240 | place(editor, middle) | |
| 241 | && editor.compose(engine, "k".into(), 1..1).is_ok() | |
| 242 | && editor.compose(engine, "ka".into(), 2..2).is_ok() | |
| 243 | && editor.commit_text(engine, "か".into()).is_ok() | |
| 244 | } | |
| 245 | Kind::ComposeCancel => { | |
| 246 | place(editor, middle) | |
| 247 | && editor.compose(engine, "k".into(), 1..1).is_ok() | |
| 248 | && editor.cancel_composition(engine).is_ok() | |
| 249 | } | |
| 250 | Kind::DeleteBackward => place(editor, middle) && editor.delete(engine, true).is_ok(), | |
| 251 | Kind::DeleteForward => place(editor, middle) && editor.delete(engine, false).is_ok(), | |
| 252 | Kind::DeleteRange => range(editor, random) && editor.delete(engine, true).is_ok(), | |
| 253 | Kind::DeleteWord => { | |
| 254 | place(editor, middle) && editor.delete_to(engine, Movement::WordLeft).is_ok() | |
| 255 | } | |
| 256 | Kind::EnterStart => place(editor, 0) && editor.enter(engine, false).is_ok(), | |
| 257 | Kind::EnterMiddle => place(editor, middle) && editor.enter(engine, false).is_ok(), | |
| 258 | Kind::EnterEnd => place(editor, length) && editor.enter(engine, false).is_ok(), | |
| 259 | Kind::SoftEnter => place(editor, middle) && editor.enter(engine, true).is_ok(), | |
| 260 | Kind::Backspace => place(editor, 0) && editor.delete(engine, true).is_ok(), | |
| 261 | Kind::Delete => place(editor, length) && editor.delete(engine, false).is_ok(), | |
| 262 | Kind::Tab => place(editor, 0) && editor.tab(engine, false).is_ok(), | |
| 263 | Kind::ShiftTab => place(editor, 0) && editor.tab(engine, true).is_ok(), | |
| 264 | Kind::TabMiddle => place(editor, middle) && editor.tab(engine, false).is_ok(), | |
| 265 | Kind::Paste => { | |
| 266 | range(editor, random) && editor.paste(engine, "Line one\r\nLine two", 1033).is_ok() | |
| 267 | } | |
| 268 | Kind::PasteLine => place(editor, middle) && editor.paste(engine, "pasted", 1033).is_ok(), | |
| 269 | Kind::Bold => format(editor, engine, random, Formatting::Toggle(Toggle::Bold)), | |
| 270 | Kind::Italic => format(editor, engine, random, Formatting::Toggle(Toggle::Italic)), | |
| 271 | Kind::Font => format(editor, engine, random, Formatting::Font("Georgia".into())), | |
| 272 | Kind::FontSize => format(editor, engine, random, Formatting::FontSize(14.0)), | |
| 273 | Kind::Color => format(editor, engine, random, Formatting::Color(Some(0x0000_00ff))), | |
| 274 | Kind::Highlight => format( | |
| 275 | editor, | |
| 276 | engine, | |
| 277 | random, | |
| 278 | Formatting::Highlight(Some(0x0000_ffff)), | |
| 279 | ), | |
| 280 | Kind::Clear => format(editor, engine, random, Formatting::Clear), | |
| 281 | Kind::Center => format(editor, engine, random, Formatting::Align(Alignment::Center)), | |
| 282 | Kind::Bullets => format(editor, engine, random, Formatting::Bullets), | |
| 283 | Kind::Numbering => format(editor, engine, random, Formatting::Numbering), | |
| 284 | Kind::Tag => { | |
| 285 | let tag = [ | |
| 286 | NoteTag::ToDo, | |
| 287 | NoteTag::Important, | |
| 288 | NoteTag::Question, | |
| 289 | NoteTag::RememberForLater, | |
| 290 | NoteTag::Definition, | |
| 291 | NoteTag::Highlight, | |
| 292 | NoteTag::Contact, | |
| 293 | NoteTag::Address, | |
| 294 | NoteTag::PhoneNumber, | |
| 295 | ][random.below(9)]; | |
| 296 | format(editor, engine, random, Formatting::Tag(tag)) | |
| 297 | } | |
| 298 | Kind::Check => format(editor, engine, random, Formatting::Check), | |
| 299 | Kind::ClickCheck => { | |
| 300 | let outline = editor.active_outline(); | |
| 301 | let id = outline.document().text_nodes().nth(paragraph).unwrap().id; | |
| 302 | let outline = outline.id; | |
| 303 | editor.click_check(engine, outline, id).is_ok() | |
| 304 | } | |
| 305 | Kind::MoveOutline => { | |
| 306 | let outline = editor.active_outline(); | |
| 307 | let [x, y] = outline.origin(); | |
| 308 | let id = outline.id; | |
| 309 | editor.move_outline(id, [x + 36.0, y + 18.0]).is_ok() | |
| 310 | } | |
| 311 | Kind::ResizeOutline => { | |
| 312 | let width = editor.active_outline().wrap_width(); | |
| 313 | editor.resize(engine, width + 72.0).is_ok() | |
| 314 | } | |
| 315 | Kind::CreateOutline => { | |
| 316 | editor | |
| 317 | .create_outline( | |
| 318 | engine, | |
| 319 | [400.0, 600.0 + 20.0 * random.below(10) as f32], | |
| 320 | 300.0, | |
| 321 | ) | |
| 322 | .is_ok() | |
| 323 | && editor.insert(engine, "Fresh").is_ok() | |
| 324 | } | |
| 325 | Kind::EmptyOutline => editor.select_all().is_ok() && editor.delete(engine, true).is_ok(), | |
| 326 | Kind::ArrowPastEnd => { | |
| 327 | editor | |
| 328 | .move_selection(engine, Movement::DocumentEnd, false) | |
| 329 | .is_ok() | |
| 330 | && editor.move_selection(engine, Movement::Down, false).is_ok() | |
| 331 | && editor.move_selection(engine, Movement::Down, false).is_ok() | |
| 332 | && editor.insert(engine, "Below").is_ok() | |
| 333 | } | |
| 334 | Kind::PlaceImage | Kind::RemoveImage => { | |
| 335 | let images: Vec<ExGuid> = editor | |
| 336 | .object_layouts() | |
| 337 | .map(|(id, _)| id) | |
| 338 | .filter(|id| editor.image_placement(*id).is_some()) | |
| 339 | .collect(); | |
| 340 | if images.is_empty() { | |
| 341 | return false; | |
| 342 | } | |
| 343 | let id = images[random.below(images.len())]; | |
| 344 | if kind == Kind::RemoveImage { | |
| 345 | return editor.remove_image(engine, id).is_ok(); | |
| 346 | } | |
| 347 | let (origin, size) = editor.image_placement(id).unwrap(); | |
| 348 | editor | |
| 349 | .place_image( | |
| 350 | engine, | |
| 351 | id, | |
| 352 | [origin[0] + 18.0, origin[1] + 9.0], | |
| 353 | [size[0] * 1.5, size[1] * 1.5], | |
| 354 | ) | |
| 355 | .is_ok() | |
| 356 | } | |
| 357 | Kind::Date => { | |
| 358 | let Some(date) = editor.date() else { | |
| 359 | return false; | |
| 360 | }; | |
| 361 | let timestamp = date.timestamp() + 864_000_000_000 * (1 + random.below(30) as u64); | |
| 362 | editor | |
| 363 | .change_date( | |
| 364 | engine, | |
| 365 | timestamp, | |
| 366 | ["Friday, July 04, 2025".into(), "9:45 AM".into()], | |
| 367 | ) | |
| 368 | .unwrap_or(false) | |
| 369 | } | |
| 370 | Kind::Undo => editor.undo(engine).unwrap_or(false), | |
| 371 | Kind::Redo => editor.redo(engine).unwrap_or(false), | |
| 372 | } | |
| 373 | } | |
| 374 | ||
| 375 | /// How a step's ops compared with its page lowered whole. | |
| 376 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] | |
| 377 | enum Outcome { | |
| 378 | /// The ops store the editor's page, or what that page lowered whole stores. | |
| 379 | Same, | |
| 380 | /// Both refuse the edit. | |
| 381 | Refused, | |
| 382 | /// The ops store; the page lowered whole cannot reach the editor's page from what is | |
| 383 | /// stored. | |
| 384 | OpsOnly, | |
| 385 | /// The paths part where the section already stored the page differently from the | |
| 386 | /// editor's model, as the writers normalize some values. | |
| 387 | Normalized, | |
| 388 | Differs(String), | |
| 389 | } | |
| 390 | ||
| 391 | /// The section the editor's ops are applied to, sealed after each step. | |
| 392 | struct Stored<'a> { | |
| 393 | section: Section<'a>, | |
| 394 | space: ExGuid, | |
| 395 | at: u64, | |
| 396 | } | |
| 397 | ||
| 398 | impl Stored<'_> { | |
| 399 | /// Stores the editor's step as its ops and, unless they store the editor's page, | |
| 400 | /// compares with that page lowered whole onto the section as it was before the step. | |
| 401 | fn step(&mut self, editor: &mut CanvasEditor, model: &Page) -> Outcome { | |
| 402 | self.at += 10_000_000; | |
| 403 | let image = self.section.image(); | |
| 404 | let reads_as = |stored: Page, model: &Page| { | |
| 405 | Page { | |
| 406 | title: model.title.clone(), | |
| 407 | ..stored | |
| 408 | } == *model | |
| 409 | }; | |
| 410 | let normalized = !reads_as(self.section.page(self.space).unwrap(), model); | |
| 411 | let ops = editor.take_ops().map_err(|error| error.to_string()); | |
| 412 | let edit = ops.clone().map(|ops| Edit { | |
| 413 | at: self.at, | |
| 414 | ops: ops | |
| 415 | .into_iter() | |
| 416 | .map(|op| Op::Page { | |
| 417 | space: self.space, | |
| 418 | op, | |
| 419 | }) | |
| 420 | .collect(), | |
| 421 | }); | |
| 422 | let applied = edit.and_then(|edit| { | |
| 423 | if edit.ops.is_empty() { | |
| 424 | return Ok(()); | |
| 425 | } | |
| 426 | self.section | |
| 427 | .apply("Sweep", &edit) | |
| 428 | .map_err(|error| error.to_string()) | |
| 429 | }); | |
| 430 | let edited = editor.page().unwrap(); | |
| 431 | if applied.is_ok() && reads_as(self.section.page(self.space).unwrap(), &edited) { | |
| 432 | self.section.seal().unwrap(); | |
| 433 | return Outcome::Same; | |
| 434 | } | |
| 435 | let arena = Arena::default(); | |
| 436 | let mut whole = Section::open(&arena, image).unwrap(); | |
| 437 | let lowered = onestore::op::lower_page(&whole.page(self.space).unwrap(), &edited) | |
| 438 | .map_err(|error| error.to_string()) | |
| 439 | .and_then(|ops| { | |
| 440 | let edit = Edit { | |
| 441 | at: self.at, | |
| 442 | ops: ops | |
| 443 | .iter() | |
| 444 | .map(|op| Op::Page { | |
| 445 | space: self.space, | |
| 446 | op: op.clone(), | |
| 447 | }) | |
| 448 | .collect(), | |
| 449 | }; | |
| 450 | whole | |
| 451 | .apply("Sweep", &edit) | |
| 452 | .map_err(|error| error.to_string())?; | |
| 453 | Ok(ops) | |
| 454 | }); | |
| 455 | let outcome = match (applied, &lowered) { | |
| 456 | (Err(error), Err(whole)) => { | |
| 457 | if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { | |
| 458 | eprintln!("BOTH {error} | {whole}"); | |
| 459 | } | |
| 460 | Outcome::Refused | |
| 461 | } | |
| 462 | (Ok(()), Ok(_)) => { | |
| 463 | let (a, b) = ( | |
| 464 | self.section.page(self.space).unwrap(), | |
| 465 | whole.page(self.space).unwrap(), | |
| 466 | ); | |
| 467 | if a == b { | |
| 468 | Outcome::Same | |
| 469 | } else if normalized { | |
| 470 | Outcome::Normalized | |
| 471 | } else { | |
| 472 | Outcome::Differs(difference(&a, &b)) | |
| 473 | } | |
| 474 | } | |
| 475 | (Err(_), Ok(_)) if normalized => Outcome::Normalized, | |
| 476 | (Err(error), Ok(_)) => Outcome::Differs(format!("ops refused: {error}")), | |
| 477 | (Ok(()), Err(_)) => { | |
| 478 | if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { | |
| 479 | let stored = self.section.page(self.space).unwrap(); | |
| 480 | eprintln!("OPSONLY-MODEL {}", difference(&stored, &edited)); | |
| 481 | } | |
| 482 | Outcome::OpsOnly | |
| 483 | } | |
| 484 | }; | |
| 485 | if !matches!(outcome, Outcome::Same | Outcome::Refused) | |
| 486 | && std::env::var_os("OPS_SWEEP_DEBUG").is_some() | |
| 487 | { | |
| 488 | let show = |ops: &[onestore::op::PageOp]| { | |
| 489 | for op in ops { | |
| 490 | let op = format!("{op:?}"); | |
| 491 | eprintln!(" {}", &op[..op.len().min(600)]); | |
| 492 | } | |
| 493 | }; | |
| 494 | eprintln!("DIFFERS {outcome:?}\n ops:"); | |
| 495 | if let Ok(ops) = &ops { | |
| 496 | show(ops); | |
| 497 | } | |
| 498 | eprintln!(" whole:"); | |
| 499 | if let Ok(ops) = &lowered { | |
| 500 | show(ops); | |
| 501 | } | |
| 502 | } | |
| 503 | self.section.seal().unwrap(); | |
| 504 | outcome | |
| 505 | } | |
| 506 | } | |
| 507 | ||
| 508 | /// The first place two pages part: an object missing from one, then objects by identity, | |
| 509 | /// then their order. | |
| 510 | fn difference(a: &Page, b: &Page) -> String { | |
| 511 | let objects = |page: &Page| -> BTreeMap<ExGuid, String> { | |
| 512 | page.objects | |
| 513 | .iter() | |
| 514 | .map(|object| (object.id(), format!("{object:?}"))) | |
| 515 | .collect() | |
| 516 | }; | |
| 517 | let (x, y) = (objects(a), objects(b)); | |
| 518 | if let Some(id) = x | |
| 519 | .keys() | |
| 520 | .chain(y.keys()) | |
| 521 | .find(|id| x.contains_key(id) != y.contains_key(id)) | |
| 522 | { | |
| 523 | return format!("object {id} on one side only"); | |
| 524 | } | |
| 525 | for (id, x) in &x { | |
| 526 | let y = &y[id]; | |
| 527 | if x != y { | |
| 528 | let at = x.bytes().zip(y.bytes()).take_while(|(p, q)| p == q).count(); | |
| 529 | let from = at.saturating_sub(160); | |
| 530 | return format!( | |
| 531 | "object {id} differs\n one: …{}\n other: …{}", | |
| 532 | &x[from..(at + 160).min(x.len())], | |
| 533 | &y[from..(at + 160).min(y.len())] | |
| 534 | ); | |
| 535 | } | |
| 536 | } | |
| 537 | if a.objects | |
| 538 | .iter() | |
| 539 | .map(|o| o.id()) | |
| 540 | .ne(b.objects.iter().map(|o| o.id())) | |
| 541 | { | |
| 542 | return "object order".into(); | |
| 543 | } | |
| 544 | if a.definitions != b.definitions { | |
| 545 | return "definitions".into(); | |
| 546 | } | |
| 547 | "page fields".into() | |
| 548 | } | |
| 549 | ||
| 550 | #[derive(Default)] | |
| 551 | struct Tally { | |
| 552 | outcomes: BTreeMap<(Kind, Outcome), usize>, | |
| 553 | examples: BTreeMap<String, String>, | |
| 554 | } | |
| 555 | ||
| 556 | fn sweep( | |
| 557 | path: &Path, | |
| 558 | random: &mut Random, | |
| 559 | engine: &mut TextEngine, | |
| 560 | tally: &mut Tally, | |
| 561 | sequences: usize, | |
| 562 | ) { | |
| 563 | let image = std::fs::read(path).unwrap(); | |
| 564 | let arena = Arena::default(); | |
| 565 | let mut section = Section::open(&arena, image.clone()).unwrap(); | |
| 566 | for (space, title, _) in section.pages().unwrap() { | |
| 567 | let Ok(page) = section.page(space) else { | |
| 568 | continue; | |
| 569 | }; | |
| 570 | if CanvasEditor::from_page(page.clone(), engine).is_err() { | |
| 571 | continue; | |
| 572 | } | |
| 573 | let plans = KINDS | |
| 574 | .iter() | |
| 575 | .map(|kind| vec![*kind]) | |
| 576 | .chain(KINDS.iter().map(|kind| vec![*kind, Kind::Undo, Kind::Redo])) | |
| 577 | .chain((0..sequences).map(|_| { | |
| 578 | (0..3 + random.below(8)) | |
| 579 | .map(|_| KINDS[random.below(KINDS.len())]) | |
| 580 | .collect() | |
| 581 | })) | |
| 582 | .collect::<Vec<Vec<Kind>>>(); | |
| 583 | for plan in plans { | |
| 584 | let arena = Arena::default(); | |
| 585 | let mut stored = Stored { | |
| 586 | section: Section::open(&arena, image.clone()).unwrap(), | |
| 587 | space, | |
| 588 | at: 133_000_000_000_000_000, | |
| 589 | }; | |
| 590 | let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); | |
| 591 | for kind in &plan { | |
| 592 | let model = editor.page().unwrap(); | |
| 593 | if !perform(&mut editor, engine, *kind, random) { | |
| 594 | continue; | |
| 595 | } | |
| 596 | if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { | |
| 597 | eprintln!("STEP {kind:?} of {plan:?} on {title:?}"); | |
| 598 | } | |
| 599 | let outcome = stored.step(&mut editor, &model); | |
| 600 | *tally.outcomes.entry((*kind, outcome.clone())).or_default() += 1; | |
| 601 | if let Outcome::Differs(reason) = &outcome { | |
| 602 | tally | |
| 603 | .examples | |
| 604 | .entry(reason.clone()) | |
| 605 | .or_insert_with(|| format!("{plan:?} on {title:?} in {}", path.display())); | |
| 606 | } | |
| 607 | if outcome != Outcome::Same { | |
| 608 | break; | |
| 609 | } | |
| 610 | } | |
| 611 | } | |
| 612 | } | |
| 613 | } | |
| 614 | ||
| 615 | #[test] | |
| 616 | fn editor_ops_store_what_the_page_lowered_whole_stores() { | |
| 617 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); | |
| 618 | let mut paths: Vec<_> = SECTIONS.iter().map(|path| root.join(path)).collect(); | |
| 619 | if let Some(extra) = std::env::var_os("OPS_SWEEP_SECTIONS") { | |
| 620 | paths.extend(std::env::split_paths(&extra)); | |
| 621 | } | |
| 622 | let seed = std::env::var("OPS_SWEEP_SEED").map_or(1, |seed| seed.parse().unwrap()); | |
| 623 | let sequences = std::env::var("OPS_SWEEP_SEQUENCES").map_or(4, |n| n.parse().unwrap()); | |
| 624 | let mut random = Random(seed); | |
| 625 | let mut engine = TextEngine::default(); | |
| 626 | let mut tally = Tally::default(); | |
| 627 | for path in &paths { | |
| 628 | let start = std::time::Instant::now(); | |
| 629 | sweep(path, &mut random, &mut engine, &mut tally, sequences); | |
| 630 | if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { | |
| 631 | eprintln!("TIME {:?} {}", start.elapsed(), path.display()); | |
| 632 | } | |
| 633 | } | |
| 634 | let mut report = String::new(); | |
| 635 | let mut per_kind: BTreeMap<Kind, [usize; 5]> = BTreeMap::new(); | |
| 636 | for ((kind, outcome), count) in &tally.outcomes { | |
| 637 | let slot = match outcome { | |
| 638 | Outcome::Same => 0, | |
| 639 | Outcome::Refused => 1, | |
| 640 | Outcome::OpsOnly => 2, | |
| 641 | Outcome::Normalized => 3, | |
| 642 | Outcome::Differs(_) => 4, | |
| 643 | }; | |
| 644 | per_kind.entry(*kind).or_default()[slot] += count; | |
| 645 | } | |
| 646 | let mut totals = [0; 5]; | |
| 647 | for (kind, counts) in &per_kind { | |
| 648 | let [same, refused, only, normalized, differs] = counts; | |
| 649 | report += &format!( | |
| 650 | "{kind:?}: {same} same, {refused} both refused, {only} only as ops, {normalized} after normalization, {differs} differ\n" | |
| 651 | ); | |
| 652 | for (total, count) in totals.iter_mut().zip(counts) { | |
| 653 | *total += count; | |
| 654 | } | |
| 655 | } | |
| 656 | report += &format!( | |
| 657 | "all: {totals:?} (same, both refused, only as ops, after normalization, differ)\n" | |
| 658 | ); | |
| 659 | for ((kind, outcome), count) in &tally.outcomes { | |
| 660 | if let Outcome::Differs(reason) = outcome { | |
| 661 | report += &format!( | |
| 662 | "{count} x {kind:?} {reason}\n e.g. {}\n", | |
| 663 | tally.examples.get(reason).map_or("", String::as_str) | |
| 664 | ); | |
| 665 | } | |
| 666 | } | |
| 667 | println!("{report}"); | |
| 668 | let differs = totals[4]; | |
| 669 | assert_eq!(differs, 0, "{report}"); | |
| 670 | } |
crates/canvas/tests/structural_roundtrip.rs+32-15| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | //! Every structural edit the editor makes saves through the page writer and rereads as the | |
| 2 | //! model it saved: random Enter, Backspace, Delete, Tab, Shift+Tab, list and paste edits, alone | |
| 3 | //! and several to a save, on every outline of the probe section and of any sections named in | |
| 4 | //! `CANVAS_SWEEP_SECTIONS` (`:`-separated paths). | |
| 1 | //! Every structural edit the editor makes saves, through the page writer and as the ops the | |
| 2 | //! editor recorded, and rereads as the model it saved: random Enter, Backspace, Delete, Tab, | |
| 3 | //! Shift+Tab, list and paste edits, alone and several to a save, on every outline of the probe | |
| 4 | //! section and of any sections named in `CANVAS_SWEEP_SECTIONS` (`:`-separated paths). | |
| 5 | 5 | |
| 6 | 6 | use canvas::{ |
| 7 | 7 | document::TextPosition, |
| ... | ... | @@ -9,7 +9,7 @@ use canvas::{ |
| 9 | 9 | layout::TextEngine, |
| 10 | 10 | }; |
| 11 | 11 | use onestore::{ |
| 12 | ExGuid, PreparedEdit, RevisionIndex, Store, | |
| 12 | Arena, ExGuid, PreparedEdit, RevisionIndex, Section, Store, | |
| 13 | 13 | document::Document, |
| 14 | 14 | page::{Outline, Page, PageObject, PageParagraph, ParagraphContent}, |
| 15 | 15 | }; |
| ... | ... | @@ -334,26 +334,43 @@ fn sweep(path: &Path, random: &mut Random, engine: &mut TextEngine, tally: &mut |
| 334 | 334 | ) |
| 335 | 335 | .unwrap(); |
| 336 | 336 | } |
| 337 | // An untitled page's title follows its first line as the writer stores it. | |
| 338 | let rereads = |reread: &Page| { | |
| 339 | Page { | |
| 340 | title: after.title.clone(), | |
| 341 | ..reread.clone() | |
| 342 | } == after | |
| 343 | }; | |
| 344 | let arena = Arena::default(); | |
| 345 | let mut stored = Section::open(&arena, section.clone()).unwrap(); | |
| 346 | let edit = onestore::op::Edit { | |
| 347 | at: 133_000_000_000_000_000, | |
| 348 | ops: editor | |
| 349 | .take_ops() | |
| 350 | .unwrap() | |
| 351 | .into_iter() | |
| 352 | .map(|op| onestore::op::Op::Page { space, op }) | |
| 353 | .collect(), | |
| 354 | }; | |
| 355 | let ops = match stored.apply("Sweep", &edit) { | |
| 356 | Err(error) => Some(format!("ops: {error}")), | |
| 357 | Ok(()) => { | |
| 358 | let reread = stored.page(space).unwrap(); | |
| 359 | (!rereads(&reread)).then(|| format!("ops: {}", difference(&after, &reread))) | |
| 360 | } | |
| 361 | }; | |
| 337 | 362 | let failure = match PreparedEdit::page(&section, space, &after, "Sweep") { |
| 338 | 363 | Err(error) => Some(error.message.to_owned()), |
| 339 | 364 | Ok(saved) => match pages(saved.as_bytes()) |
| 340 | 365 | .into_iter() |
| 341 | 366 | .find(|(id, _)| *id == space) |
| 342 | 367 | { |
| 343 | // An untitled page's title follows its first line as the writer stores it. | |
| 344 | Some((_, reread)) | |
| 345 | if Page { | |
| 346 | title: after.title.clone(), | |
| 347 | ..reread.clone() | |
| 348 | } == after => | |
| 349 | { | |
| 350 | None | |
| 351 | } | |
| 368 | Some((_, reread)) if rereads(&reread) => None, | |
| 352 | 369 | Some((_, reread)) => Some(difference(&after, &reread)), |
| 353 | 370 | None => Some("the page does not reread".into()), |
| 354 | 371 | }, |
| 355 | 372 | }; |
| 356 | if let Some(failure) = failure { | |
| 373 | for failure in failure.into_iter().chain(ops) { | |
| 357 | 374 | tally.failures.entry(failure).or_default().push(format!( |
| 358 | 375 | "{plan:?} on {:?} in {}", |
| 359 | 376 | page.title, |
crates/mobile/Cargo.toml+1| ... | ... | @@ -15,3 +15,4 @@ onestore = { path = "../onestore" } |
| 15 | 15 | wgpu.workspace = true |
| 16 | 16 | parley.workspace = true |
| 17 | 17 | pollster = "0.4" |
| 18 | serde_json = "1" |
crates/mobile/include/snowbound.h+1| ... | ... | @@ -22,6 +22,7 @@ void sb_view_content(View *view, float bounds[4]); |
| 22 | 22 | void sb_view_set_transform(View *view, float zoom, float x, float y); |
| 23 | 23 | bool sb_view_tap(View *view, float x, float y); |
| 24 | 24 | bool sb_view_in_active_text(const View *view, float x, float y); |
| 25 | char *sb_view_edit(View *view); | |
| 25 | 26 | |
| 26 | 27 | uint32_t sb_text_length(const View *view); |
| 27 | 28 | char *sb_text(const View *view, uint32_t start, uint32_t end); |
crates/mobile/src/lib.rs+53-7| ... | ... | @@ -14,7 +14,12 @@ use canvas::{ |
| 14 | 14 | layout::TextEngine, |
| 15 | 15 | }; |
| 16 | 16 | use draw::edit::{Key, NamedKey, SelectionUnit}; |
| 17 | use onestore::{RevisionIndex, Store, document::Document, page::Page}; | |
| 17 | use onestore::{ | |
| 18 | ExGuid, RevisionIndex, Store, | |
| 19 | document::Document, | |
| 20 | op::{Edit, Op}, | |
| 21 | page::Page, | |
| 22 | }; | |
| 18 | 23 | use parley::{Affinity, BoundingBox}; |
| 19 | 24 | use std::{ |
| 20 | 25 | error::Error, |
| ... | ... | @@ -40,7 +45,8 @@ const COLORS: TextColors = TextColors { |
| 40 | 45 | }; |
| 41 | 46 | |
| 42 | 47 | pub struct Section { |
| 43 | pages: Vec<Page>, | |
| 48 | /// Each page with the object space holding it. | |
| 49 | pages: Vec<(ExGuid, Page)>, | |
| 44 | 50 | titles: Vec<CString>, |
| 45 | 51 | } |
| 46 | 52 | |
| ... | ... | @@ -52,11 +58,11 @@ impl Section { |
| 52 | 58 | let pages = document |
| 53 | 59 | .pages()? |
| 54 | 60 | .into_iter() |
| 55 | .map(|(space, id)| Page::from_revision(document.active(space)?, id)) | |
| 56 | .collect::<std::result::Result<Vec<_>, _>>()?; | |
| 61 | .map(|(space, id)| Ok((space, Page::from_revision(document.active(space)?, id)?))) | |
| 62 | .collect::<std::result::Result<Vec<_>, onestore::Error>>()?; | |
| 57 | 63 | let titles = pages |
| 58 | 64 | .iter() |
| 59 | .map(|page| CString::new(page.title.replace('\0', ""))) | |
| 65 | .map(|(_, page)| CString::new(page.title.replace('\0', ""))) | |
| 60 | 66 | .collect::<std::result::Result<_, _>>()?; |
| 61 | 67 | Ok(Self { pages, titles }) |
| 62 | 68 | } |
| ... | ... | @@ -74,6 +80,8 @@ impl Wake for Frame { |
| 74 | 80 | |
| 75 | 81 | pub struct View { |
| 76 | 82 | page: PageView, |
| 83 | /// The object space of the page shown, which its edits name. | |
| 84 | space: ExGuid, | |
| 77 | 85 | surface: wgpu::Surface<'static>, |
| 78 | 86 | config: wgpu::SurfaceConfiguration, |
| 79 | 87 | renderer: draw::Renderer, |
| ... | ... | @@ -89,7 +97,12 @@ fn moved(response: Response) -> bool { |
| 89 | 97 | } |
| 90 | 98 | |
| 91 | 99 | impl View { |
| 92 | fn new(layer: *mut c_void, page: Page, size: [f32; 2], scale: f32) -> Result<Self> { | |
| 100 | fn new( | |
| 101 | layer: *mut c_void, | |
| 102 | (space, page): (ExGuid, Page), | |
| 103 | size: [f32; 2], | |
| 104 | scale: f32, | |
| 105 | ) -> Result<Self> { | |
| 93 | 106 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); |
| 94 | 107 | // SAFETY: the host passes a live CAMetalLayer that outlives the view. |
| 95 | 108 | let surface = unsafe { |
| ... | ... | @@ -122,6 +135,7 @@ impl View { |
| 122 | 135 | scale, |
| 123 | 136 | Duration::from_millis(350), |
| 124 | 137 | ), |
| 138 | space, | |
| 125 | 139 | surface, |
| 126 | 140 | config, |
| 127 | 141 | renderer, |
| ... | ... | @@ -129,6 +143,27 @@ impl View { |
| 129 | 143 | }) |
| 130 | 144 | } |
| 131 | 145 | |
| 146 | /// What the page took since the last call as one edit, or none when it took nothing. | |
| 147 | fn edit(&mut self) -> Result<Option<Edit>> { | |
| 148 | let ops = self.page.editor.take_ops()?; | |
| 149 | if ops.is_empty() { | |
| 150 | return Ok(None); | |
| 151 | } | |
| 152 | let unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?; | |
| 153 | Ok(Some(Edit { | |
| 154 | // FILETIME: 100 ns ticks since 1601. | |
| 155 | at: (unix.as_secs() + 11_644_473_600) * 10_000_000 | |
| 156 | + u64::from(unix.subsec_nanos() / 100), | |
| 157 | ops: ops | |
| 158 | .into_iter() | |
| 159 | .map(|op| Op::Page { | |
| 160 | space: self.space, | |
| 161 | op, | |
| 162 | }) | |
| 163 | .collect(), | |
| 164 | })) | |
| 165 | } | |
| 166 | ||
| 132 | 167 | /// Device pixels per host point. |
| 133 | 168 | fn display_scale(&self) -> f32 { |
| 134 | 169 | 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 { |
| 515 | 550 | .map_or(std::ptr::null_mut(), CString::into_raw) |
| 516 | 551 | } |
| 517 | 552 | |
| 553 | /// The edit the page took since the last call, as `onestore::op::Edit` JSON for the | |
| 554 | /// section to apply, freed with `sb_string_free`; null when it took none or cannot be | |
| 555 | /// stored, in which case the page should be opened again. | |
| 556 | #[unsafe(no_mangle)] | |
| 557 | pub extern "C" fn sb_view_edit(view: &mut View) -> *mut c_char { | |
| 558 | report(view.edit()) | |
| 559 | .flatten() | |
| 560 | .and_then(|edit| CString::new(serde_json::to_string(&edit).ok()?).ok()) | |
| 561 | .map_or(std::ptr::null_mut(), CString::into_raw) | |
| 562 | } | |
| 563 | ||
| 518 | 564 | /// # Safety |
| 519 | /// `text` came from `sb_text` and is not used again. | |
| 565 | /// `text` came from `sb_text` or `sb_view_edit` and is not used again. | |
| 520 | 566 | #[unsafe(no_mangle)] |
| 521 | 567 | pub unsafe extern "C" fn sb_string_free(text: *mut c_char) { |
| 522 | 568 | drop(unsafe { CString::from_raw(text) }); |
crates/notebook/README.md+4| ... | ... | @@ -38,6 +38,10 @@ assert_eq!(reopened.pending()?.last().map(|edit| edit.id), Some(id)); |
| 38 | 38 | # } |
| 39 | 39 | ``` |
| 40 | 40 | |
| 41 | Page reads never wait for the sync thread: a rebase or reread of the queue runs on a | |
| 42 | new thread while the current one keeps answering `page` and `pages` from the section as | |
| 43 | it was, then hands its requests over; edits sent meanwhile apply to the rebuilt section. | |
| 44 | ||
| 41 | 45 | Share one `Replica` between threads. Keep the cache on a local filesystem: the |
| 42 | 46 | connection holds exclusive ownership between transactions, and a second open fails |
| 43 | 47 | busy. No network wait occurs in a local edit. After a database error the section |
crates/notebook/examples/open_probe.rs created+236| ... | ... | @@ -0,0 +1,236 @@ |
| 1 | //! `open_probe SECTION | --paragraphs N`: times opening a section through a fresh and a | |
| 2 | //! reopened cache up to its page list and first page, then how long page reads wait while | |
| 3 | //! the sync thread rebases the queue onto a changed remote. | |
| 4 | ||
| 5 | use notebook::{Remote, Replica, session::Section}; | |
| 6 | use onestore::{ | |
| 7 | CommitError, ExGuid, Stamp, Transaction, | |
| 8 | document::{Format, Layout}, | |
| 9 | op::{Edit, Op, PageOp, SectionOp}, | |
| 10 | page::{ | |
| 11 | Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, | |
| 12 | text::new_id, | |
| 13 | }, | |
| 14 | }; | |
| 15 | use std::{ | |
| 16 | io, | |
| 17 | time::{Duration, Instant}, | |
| 18 | }; | |
| 19 | ||
| 20 | /// A section holding one page of `count` paragraphs. | |
| 21 | fn generated(count: usize) -> Result<Vec<u8>, Box<dyn std::error::Error>> { | |
| 22 | let paragraph = |i: usize| PageParagraph { | |
| 23 | id: new_id().unwrap(), | |
| 24 | parent: None, | |
| 25 | level: 1, | |
| 26 | style: None, | |
| 27 | format: Format::default(), | |
| 28 | content: ParagraphContent::Text(TextObject { | |
| 29 | id: new_id().unwrap(), | |
| 30 | date_field: None, | |
| 31 | text: Paragraph::new(format!("Paragraph {i} of the probe"), Format::default()), | |
| 32 | tags: Vec::new(), | |
| 33 | }), | |
| 34 | lists: Vec::new(), | |
| 35 | tags: Vec::new(), | |
| 36 | media: Default::default(), | |
| 37 | collapsed: false, | |
| 38 | }; | |
| 39 | let arena = onestore::Arena::default(); | |
| 40 | let mut section = | |
| 41 | onestore::Section::open(&arena, onestore::create_section("probe.one", "", "Probe")?)?; | |
| 42 | let page = Page { | |
| 43 | title: "Probe".into(), | |
| 44 | identity: None, | |
| 45 | created: None, | |
| 46 | margin_origin: [0.0; 2], | |
| 47 | objects: vec![PageObject::Outline(Outline { | |
| 48 | id: new_id()?, | |
| 49 | title: false, | |
| 50 | min_width: None, | |
| 51 | layout: Layout { | |
| 52 | x: Some(36.0), | |
| 53 | y: Some(86.4), | |
| 54 | ..Layout::default() | |
| 55 | }, | |
| 56 | indents: Vec::new(), | |
| 57 | paragraphs: (0..count).map(paragraph).collect(), | |
| 58 | unsupported: Vec::new(), | |
| 59 | })], | |
| 60 | definitions: Default::default(), | |
| 61 | }; | |
| 62 | let creation = onestore::PageCreation::new(None, Some("Probe"), "Probe")?; | |
| 63 | section.apply( | |
| 64 | "Probe", | |
| 65 | &Edit { | |
| 66 | at: 133_000_000_000_000_000, | |
| 67 | ops: vec![Op::Section(SectionOp::Import { creation, page })], | |
| 68 | }, | |
| 69 | )?; | |
| 70 | section.seal()?; | |
| 71 | Ok(section.image()) | |
| 72 | } | |
| 73 | ||
| 74 | /// The first text of the page with the most outline paragraphs, and that page's space. | |
| 75 | fn text(page: &Page) -> Option<ExGuid> { | |
| 76 | page.objects.iter().find_map(|object| match object { | |
| 77 | PageObject::Outline(outline) => outline | |
| 78 | .paragraphs | |
| 79 | .iter() | |
| 80 | .find_map(|p| p.text().map(|t| t.id)), | |
| 81 | _ => None, | |
| 82 | }) | |
| 83 | } | |
| 84 | ||
| 85 | struct Memory(Vec<u8>); | |
| 86 | ||
| 87 | impl onestore::CommitIo for Memory { | |
| 88 | fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> { | |
| 89 | let offset = offset as usize; | |
| 90 | let size = output.len().min(self.0.len().saturating_sub(offset)); | |
| 91 | output[..size].copy_from_slice(&self.0[offset..offset + size]); | |
| 92 | Ok(size) | |
| 93 | } | |
| 94 | fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> { | |
| 95 | let offset = offset as usize; | |
| 96 | self.0.resize(self.0.len().max(offset + bytes.len()), 0); | |
| 97 | self.0[offset..offset + bytes.len()].copy_from_slice(bytes); | |
| 98 | Ok(bytes.len()) | |
| 99 | } | |
| 100 | fn flush(&mut self) -> io::Result<()> { | |
| 101 | Ok(()) | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | impl Remote for Memory { | |
| 106 | fn read(&mut self) -> io::Result<Vec<u8>> { | |
| 107 | Ok(self.0.clone()) | |
| 108 | } | |
| 109 | fn stamp(&mut self) -> io::Result<Option<Stamp>> { | |
| 110 | Ok(Stamp::of(&self.0).ok()) | |
| 111 | } | |
| 112 | fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { | |
| 113 | transaction.commit(self) | |
| 114 | } | |
| 115 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | |
| 116 | onestore::confirm_snapshot(self, snapshot) | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | fn main() -> Result<(), Box<dyn std::error::Error>> { | |
| 121 | let mut args = std::env::args().skip(1); | |
| 122 | let source = match (args.next().ok_or("SECTION | --paragraphs N")?, args.next()) { | |
| 123 | (flag, Some(count)) if flag == "--paragraphs" => generated(count.parse()?)?, | |
| 124 | (path, _) => std::fs::read(path)?, | |
| 125 | }; | |
| 126 | let directory = tempfile::tempdir()?; | |
| 127 | let file = directory.path().join("section.one"); | |
| 128 | std::fs::write(&file, &source)?; | |
| 129 | let cache = directory.path().join("cache"); | |
| 130 | println!("section {} bytes", source.len()); | |
| 131 | for label in ["fresh cache", "reopened cache"] { | |
| 132 | let start = Instant::now(); | |
| 133 | let section = Section::open(&file, &cache, || {})?; | |
| 134 | let opened = start.elapsed(); | |
| 135 | let pages = section.pages()?; | |
| 136 | let listed = start.elapsed(); | |
| 137 | let page = section.page(pages[0].0)?; | |
| 138 | let read = start.elapsed(); | |
| 139 | println!( | |
| 140 | "{label}: open {opened:.2?}, page list ({} pages) at {listed:.2?}, first page ({} objects) at {read:.2?}", | |
| 141 | pages.len(), | |
| 142 | page.objects.len() | |
| 143 | ); | |
| 144 | section.close()?; | |
| 145 | } | |
| 146 | ||
| 147 | // A local edit queued against a remote another writer changed elsewhere: the next | |
| 148 | // step rebases, while this thread keeps reading the page list and a page. | |
| 149 | let replica = std::sync::Arc::new(Replica::create( | |
| 150 | directory.path().join("rebase.sqlite"), | |
| 151 | &source, | |
| 152 | )?); | |
| 153 | let (space, ..) = replica | |
| 154 | .pages()? | |
| 155 | .into_iter() | |
| 156 | .max_by_key(|(space, ..)| { | |
| 157 | replica | |
| 158 | .page(*space) | |
| 159 | .map(|page| page.objects.len()) | |
| 160 | .unwrap_or(0) | |
| 161 | }) | |
| 162 | .ok_or("a page")?; | |
| 163 | let target = text(&replica.page(space)?).ok_or("a text")?; | |
| 164 | let typed = |with: &str| Edit { | |
| 165 | at: 133_000_000_000_000_000, | |
| 166 | ops: vec![Op::Page { | |
| 167 | space, | |
| 168 | op: PageOp::Text { | |
| 169 | text: target, | |
| 170 | range: 0..0, | |
| 171 | with: with.into(), | |
| 172 | }, | |
| 173 | }], | |
| 174 | }; | |
| 175 | replica.apply("Probe", typed("Local "))?; | |
| 176 | let remote = { | |
| 177 | let arena = onestore::Arena::default(); | |
| 178 | let mut section = onestore::Section::open(&arena, source.clone())?; | |
| 179 | let root = section.root(); | |
| 180 | let other = section | |
| 181 | .pages()? | |
| 182 | .into_iter() | |
| 183 | .map(|(space, ..)| space) | |
| 184 | .find(|other| *other != space); | |
| 185 | match other.and_then(|other| Some((other, text(&section.page(other).ok()?)?))) { | |
| 186 | Some((other, text)) => section.apply( | |
| 187 | "Native", | |
| 188 | &Edit { | |
| 189 | at: 133_000_000_000_000_000, | |
| 190 | ops: vec![Op::Page { | |
| 191 | space: other, | |
| 192 | op: PageOp::Text { | |
| 193 | text, | |
| 194 | range: 0..0, | |
| 195 | with: "Remote ".into(), | |
| 196 | }, | |
| 197 | }], | |
| 198 | }, | |
| 199 | )?, | |
| 200 | None => section.apply( | |
| 201 | "Native", | |
| 202 | &Edit { | |
| 203 | at: 133_000_000_000_000_000, | |
| 204 | ops: vec![Op::Section(SectionOp::Create(onestore::PageCreation::new( | |
| 205 | None, | |
| 206 | Some("Remote"), | |
| 207 | "Native", | |
| 208 | )?))], | |
| 209 | }, | |
| 210 | )?, | |
| 211 | } | |
| 212 | let _ = root; | |
| 213 | section.seal()?; | |
| 214 | section.image() | |
| 215 | }; | |
| 216 | let syncing = std::sync::Arc::clone(&replica); | |
| 217 | let sync = std::thread::spawn(move || { | |
| 218 | let start = Instant::now(); | |
| 219 | let result = syncing.sync_once(&mut Memory(remote)); | |
| 220 | (start.elapsed(), result.map(|synced| synced.edit.is_some())) | |
| 221 | }); | |
| 222 | let (mut slowest, mut reads) = (Duration::ZERO, 0); | |
| 223 | while !sync.is_finished() { | |
| 224 | let start = Instant::now(); | |
| 225 | replica.pages()?; | |
| 226 | replica.page(space)?; | |
| 227 | slowest = slowest.max(start.elapsed()); | |
| 228 | reads += 1; | |
| 229 | } | |
| 230 | let (rebase, published) = sync.join().map_err(|_| "the sync step panicked")?; | |
| 231 | println!( | |
| 232 | "rebase and publish {rebase:.2?} (published: {:?}); {reads} page-list + page reads meanwhile, slowest {slowest:.2?}", | |
| 233 | published? | |
| 234 | ); | |
| 235 | Ok(()) | |
| 236 | } |
crates/notebook/src/lib.rs+7-15| ... | ... | @@ -16,7 +16,6 @@ use std::{ |
| 16 | 16 | io, |
| 17 | 17 | path::Path, |
| 18 | 18 | sync::{Arc, Mutex, MutexGuard, mpsc}, |
| 19 | thread::JoinHandle, | |
| 20 | 19 | time::Duration, |
| 21 | 20 | }; |
| 22 | 21 | |
| ... | ... | @@ -97,8 +96,7 @@ pub enum Resolution { |
| 97 | 96 | /// exclusive connection retains ownership between local transactions. |
| 98 | 97 | pub struct Replica { |
| 99 | 98 | connection: Arc<Mutex<Connection>>, |
| 100 | working: Option<mpsc::Sender<working::Request>>, | |
| 101 | thread: Option<JoinHandle<()>>, | |
| 99 | section: Arc<working::Thread>, | |
| 102 | 100 | synchronization: Mutex<()>, |
| 103 | 101 | worker: Arc<Mutex<std::sync::Weak<worker::Signal>>>, |
| 104 | 102 | /// The section's root object space, which names the document. |
| ... | ... | @@ -182,11 +180,10 @@ impl Replica { |
| 182 | 180 | fn start(connection: Connection) -> Result<Self> { |
| 183 | 181 | let connection = Arc::new(Mutex::new(connection)); |
| 184 | 182 | let worker = Arc::new(Mutex::new(std::sync::Weak::new())); |
| 185 | let (sender, thread, root) = working::spawn(Arc::clone(&connection), Arc::clone(&worker))?; | |
| 183 | let (section, root) = working::spawn(Arc::clone(&connection), Arc::clone(&worker))?; | |
| 186 | 184 | Ok(Self { |
| 187 | 185 | connection, |
| 188 | working: Some(sender), | |
| 189 | thread: Some(thread), | |
| 186 | section, | |
| 190 | 187 | synchronization: Mutex::new(()), |
| 191 | 188 | worker, |
| 192 | 189 | root, |
| ... | ... | @@ -199,10 +196,7 @@ impl Replica { |
| 199 | 196 | |
| 200 | 197 | /// Hands `request` to the section thread. |
| 201 | 198 | fn send(&self, request: working::Request) -> Result<()> { |
| 202 | self.working | |
| 203 | .as_ref() | |
| 204 | .and_then(|working| working.send(request).ok()) | |
| 205 | .ok_or_else(|| io::Error::other("The section thread stopped").into()) | |
| 199 | self.section.send(request) | |
| 206 | 200 | } |
| 207 | 201 | |
| 208 | 202 | /// Asks the section thread and waits for its answer. |
| ... | ... | @@ -257,7 +251,8 @@ impl Replica { |
| 257 | 251 | /// The section image the queued edits leave, the unsealed ones sealed as one more |
| 258 | 252 | /// revision whose identities differ per call: O(section), for tests and diagnostics. |
| 259 | 253 | pub fn snapshot(&self) -> Result<Vec<u8>> { |
| 260 | self.ask(|reply| working::Request::Image { reply }) | |
| 254 | self.ask(|reply| working::Request::Flush { reply })?; | |
| 255 | working::image(&*self.lock()?) | |
| 261 | 256 | } |
| 262 | 257 | |
| 263 | 258 | /// The section file's identity, which internal links name as `section-id`. |
| ... | ... | @@ -279,10 +274,7 @@ impl Replica { |
| 279 | 274 | |
| 280 | 275 | impl Drop for Replica { |
| 281 | 276 | fn drop(&mut self) { |
| 282 | drop(self.working.take()); | |
| 283 | if let Some(thread) = self.thread.take() { | |
| 284 | let _ = thread.join(); | |
| 285 | } | |
| 277 | self.section.stop(); | |
| 286 | 278 | } |
| 287 | 279 | } |
| 288 | 280 |
crates/notebook/src/merge.rs+5| ... | ... | @@ -532,6 +532,11 @@ impl Diff { |
| 532 | 532 | PageOp::Link { text, .. } | PageOp::Equation { text, .. } => { |
| 533 | 533 | require(self.region(*text)?.is_none())?; |
| 534 | 534 | } |
| 535 | PageOp::Date { fields, .. } => { | |
| 536 | for (text, _) in fields.iter() { | |
| 537 | require(self.region(*text)?.is_none())?; | |
| 538 | } | |
| 539 | } | |
| 535 | 540 | PageOp::Split { |
| 536 | 541 | text, at, right, .. |
| 537 | 542 | } => { |
crates/notebook/src/working.rs+391-188| ... | ... | @@ -44,8 +44,9 @@ pub(crate) enum Request { |
| 44 | 44 | Pages { |
| 45 | 45 | reply: Reply<Vec<(ExGuid, String, u32)>>, |
| 46 | 46 | }, |
| 47 | Image { | |
| 48 | reply: Reply<Vec<u8>>, | |
| 47 | /// Answers once the edits before it are written. | |
| 48 | Flush { | |
| 49 | reply: Reply<()>, | |
| 49 | 50 | }, |
| 50 | 51 | /// Seals the open batch, when no sealed batch waits for publication, recording the |
| 51 | 52 | /// attempt to publish it. |
| ... | ... | @@ -62,8 +63,15 @@ pub(crate) enum Request { |
| 62 | 63 | Reopen { |
| 63 | 64 | reply: Reply<()>, |
| 64 | 65 | }, |
| 66 | /// From the thread that rebuilt the section: hand it the requests. | |
| 67 | Handover(mpsc::SyncSender<Takeover>), | |
| 68 | /// From the thread that rebuilt nothing: carry on with the section as it is. | |
| 69 | Resume, | |
| 65 | 70 | } |
| 66 | 71 | |
| 72 | /// The request channel and the requests held while the section was rebuilt. | |
| 73 | type Takeover = (mpsc::Receiver<Request>, VecDeque<Request>); | |
| 74 | ||
| 67 | 75 | /// A sealed batch: the transaction publishing it, or none when its edits changed nothing. |
| 68 | 76 | pub(crate) struct Sealed { |
| 69 | 77 | pub batch: i64, |
| ... | ... | @@ -71,7 +79,7 @@ pub(crate) struct Sealed { |
| 71 | 79 | } |
| 72 | 80 | |
| 73 | 81 | /// A conflict's resolution: the batch it holds and every later edit drop their ops on |
| 74 | /// `space`; `Mine` first rewrites the rebased page to the local one (or to `page`). | |
| 82 | /// `space`; `Mine` first rewrites the rebased page to `page`, the local one unless given. | |
| 75 | 83 | pub(crate) struct Resolve { |
| 76 | 84 | pub first: u64, |
| 77 | 85 | pub space: ExGuid, |
| ... | ... | @@ -98,34 +106,88 @@ impl Request { |
| 98 | 106 | Self::Save { reply, .. } => reply(Err(error())), |
| 99 | 107 | Self::Page { reply, .. } => reply(Err(error())), |
| 100 | 108 | Self::Pages { reply } => reply(Err(error())), |
| 101 | Self::Image { reply } => reply(Err(error())), | |
| 109 | Self::Flush { reply } => reply(Err(error())), | |
| 102 | 110 | Self::Seal { reply } => reply(Err(error())), |
| 103 | 111 | Self::Rebase { reply, .. } => reply(Err(error())), |
| 104 | 112 | Self::Reopen { reply } => reply(Err(error())), |
| 113 | Self::Handover(_) | Self::Resume => {} | |
| 105 | 114 | } |
| 106 | 115 | } |
| 107 | 116 | } |
| 108 | 117 | |
| 109 | 118 | type Worker = Arc<Mutex<Weak<Signal>>>; |
| 110 | 119 | |
| 120 | /// The section thread as the replica sees it. Rereading the section after the queue was | |
| 121 | /// replaced (a rebase, a released attempt) happens on a new thread while the current one | |
| 122 | /// keeps answering page reads from the section as it was; the new thread then takes the | |
| 123 | /// requests over, so reads never wait for a rebuild. | |
| 124 | pub(crate) struct Thread { | |
| 125 | connection: Arc<Mutex<Connection>>, | |
| 126 | worker: Worker, | |
| 127 | /// Taken when the replica drops, which ends every section thread. | |
| 128 | sender: Mutex<Option<mpsc::Sender<Request>>>, | |
| 129 | threads: Mutex<Vec<thread::JoinHandle<()>>>, | |
| 130 | } | |
| 131 | ||
| 132 | impl Thread { | |
| 133 | pub(crate) fn send(&self, request: Request) -> Result<()> { | |
| 134 | self.sender | |
| 135 | .lock() | |
| 136 | .ok() | |
| 137 | .and_then(|sender| sender.as_ref()?.send(request).ok()) | |
| 138 | .ok_or_else(|| io::Error::other("The section thread stopped").into()) | |
| 139 | } | |
| 140 | ||
| 141 | /// Ends the section threads and waits for them, so the cache is released. | |
| 142 | pub(crate) fn stop(&self) { | |
| 143 | if let Ok(mut sender) = self.sender.lock() { | |
| 144 | sender.take(); | |
| 145 | } | |
| 146 | while let Some(thread) = self | |
| 147 | .threads | |
| 148 | .lock() | |
| 149 | .ok() | |
| 150 | .and_then(|mut threads| threads.pop()) | |
| 151 | { | |
| 152 | let _ = thread.join(); | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | fn start(self: &Arc<Self>, run: impl FnOnce(Arc<Self>) + Send + 'static) -> Result<()> { | |
| 157 | let shared = Arc::clone(self); | |
| 158 | let thread = thread::Builder::new() | |
| 159 | .name("onestore-section".into()) | |
| 160 | .spawn(move || run(shared))?; | |
| 161 | self.threads | |
| 162 | .lock() | |
| 163 | .map_err(|_| io::Error::other("The section thread panicked"))? | |
| 164 | .push(thread); | |
| 165 | Ok(()) | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 111 | 169 | /// Starts the section thread once the queue opens. |
| 112 | 170 | pub(crate) fn spawn( |
| 113 | 171 | connection: Arc<Mutex<Connection>>, |
| 114 | 172 | worker: Worker, |
| 115 | ) -> Result<(mpsc::Sender<Request>, thread::JoinHandle<()>, ExGuid)> { | |
| 116 | let (sender, receiver) = mpsc::channel(); | |
| 173 | ) -> Result<(Arc<Thread>, ExGuid)> { | |
| 174 | let (sender, requests) = mpsc::channel(); | |
| 175 | let shared = Arc::new(Thread { | |
| 176 | connection, | |
| 177 | worker, | |
| 178 | sender: Mutex::new(Some(sender)), | |
| 179 | threads: Mutex::new(Vec::new()), | |
| 180 | }); | |
| 117 | 181 | let (ready, opened) = mpsc::sync_channel(1); |
| 118 | let thread = thread::Builder::new() | |
| 119 | .name("onestore-section".into()) | |
| 120 | .spawn(move || run(&connection, &worker, &receiver, ready))?; | |
| 182 | shared.start(move |shared| run(shared, requests, VecDeque::new(), Some(ready)))?; | |
| 121 | 183 | match opened.recv() { |
| 122 | Ok(Ok(root)) => Ok((sender, thread, root)), | |
| 184 | Ok(Ok(root)) => Ok((shared, root)), | |
| 123 | 185 | Ok(Err(error)) => { |
| 124 | let _ = thread.join(); | |
| 186 | shared.stop(); | |
| 125 | 187 | Err(error) |
| 126 | 188 | } |
| 127 | 189 | Err(_) => { |
| 128 | let _ = thread.join(); | |
| 190 | shared.stop(); | |
| 129 | 191 | Err(io::Error::other("The section thread panicked").into()) |
| 130 | 192 | } |
| 131 | 193 | } |
| ... | ... | @@ -134,28 +196,24 @@ pub(crate) fn spawn( |
| 134 | 196 | enum Next { |
| 135 | 197 | Stop, |
| 136 | 198 | Reopen, |
| 199 | Handover(mpsc::SyncSender<Takeover>, VecDeque<Request>), | |
| 137 | 200 | } |
| 138 | 201 | |
| 139 | 202 | fn run( |
| 140 | connection: &Mutex<Connection>, | |
| 141 | worker: &Worker, | |
| 142 | requests: &mpsc::Receiver<Request>, | |
| 143 | ready: mpsc::SyncSender<Result<ExGuid>>, | |
| 203 | shared: Arc<Thread>, | |
| 204 | requests: mpsc::Receiver<Request>, | |
| 205 | mut backlog: VecDeque<Request>, | |
| 206 | mut ready: Option<mpsc::SyncSender<Result<ExGuid>>>, | |
| 144 | 207 | ) { |
| 145 | let mut ready = Some(ready); | |
| 146 | let mut backlog = VecDeque::new(); | |
| 147 | 208 | loop { |
| 148 | 209 | let arena = Arena::default(); |
| 149 | let working = match Working::open(&arena, connection) { | |
| 210 | let working = match Working::open(&arena, &shared.connection) { | |
| 150 | 211 | Ok(working) => working, |
| 151 | 212 | Err(error) => { |
| 152 | let message = error.to_string(); | |
| 153 | 213 | if let Some(ready) = ready.take() { |
| 154 | 214 | let _ = ready.send(Err(error)); |
| 155 | return; | |
| 156 | } | |
| 157 | for request in backlog.drain(..).chain(requests.iter()) { | |
| 158 | request.fail(&message); | |
| 215 | } else { | |
| 216 | fail(error, backlog, requests); | |
| 159 | 217 | } |
| 160 | 218 | return; |
| 161 | 219 | } |
| ... | ... | @@ -163,9 +221,75 @@ fn run( |
| 163 | 221 | if let Some(ready) = ready.take() { |
| 164 | 222 | let _ = ready.send(Ok(working.section.root())); |
| 165 | 223 | } |
| 166 | match working.serve(connection, worker, requests, &mut backlog) { | |
| 224 | match working.serve(&shared, &requests, &mut backlog) { | |
| 167 | 225 | Next::Stop => return, |
| 168 | 226 | Next::Reopen => {} |
| 227 | Next::Handover(to, held) => { | |
| 228 | let _ = to.send((requests, held)); | |
| 229 | return; | |
| 230 | } | |
| 231 | } | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | /// Answers every request with `error` until the replica drops. | |
| 236 | fn fail(error: crate::Error, backlog: VecDeque<Request>, requests: mpsc::Receiver<Request>) { | |
| 237 | let message = error.to_string(); | |
| 238 | for request in backlog.into_iter().chain(requests.iter()) { | |
| 239 | request.fail(&message); | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | /// What a rebuilding thread does before it rereads the section. | |
| 244 | enum Job { | |
| 245 | Rebase { | |
| 246 | image: Option<Vec<u8>>, | |
| 247 | resolve: Option<Resolve>, | |
| 248 | reply: Reply<Rebased>, | |
| 249 | }, | |
| 250 | Reopen { | |
| 251 | reply: Reply<()>, | |
| 252 | }, | |
| 253 | } | |
| 254 | ||
| 255 | /// Runs `job`, rereads the section and takes the requests over from the thread that | |
| 256 | /// started it; a rebase that found a conflict changed nothing that thread serves. | |
| 257 | fn build(shared: Arc<Thread>, job: Job, signal: mpsc::Sender<Request>) { | |
| 258 | let answer: Box<dyn FnOnce() + Send> = match job { | |
| 259 | Job::Reopen { reply } => Box::new(move || reply(Ok(()))), | |
| 260 | Job::Rebase { | |
| 261 | image, | |
| 262 | resolve, | |
| 263 | reply, | |
| 264 | } => match rebase(&shared.connection, image, resolve) { | |
| 265 | Ok(applied @ Rebased::Applied { .. }) => Box::new(move || reply(Ok(applied))), | |
| 266 | other => { | |
| 267 | reply(other); | |
| 268 | let _ = signal.send(Request::Resume); | |
| 269 | return; | |
| 270 | } | |
| 271 | }, | |
| 272 | }; | |
| 273 | let arena = Arena::default(); | |
| 274 | let opened = Working::open(&arena, &shared.connection); | |
| 275 | let (to, from) = mpsc::sync_channel(1); | |
| 276 | if signal.send(Request::Handover(to)).is_err() { | |
| 277 | return; | |
| 278 | } | |
| 279 | drop(signal); | |
| 280 | answer(); | |
| 281 | let Ok((requests, mut backlog)) = from.recv() else { | |
| 282 | return; | |
| 283 | }; | |
| 284 | let working = match opened { | |
| 285 | Ok(working) => working, | |
| 286 | Err(error) => return fail(error, backlog, requests), | |
| 287 | }; | |
| 288 | match working.serve(&shared, &requests, &mut backlog) { | |
| 289 | Next::Stop => {} | |
| 290 | Next::Reopen => run(shared, requests, backlog, None), | |
| 291 | Next::Handover(to, held) => { | |
| 292 | let _ = to.send((requests, held)); | |
| 169 | 293 | } |
| 170 | 294 | } |
| 171 | 295 | } |
| ... | ... | @@ -214,11 +338,14 @@ impl<'a> Working<'a> { |
| 214 | 338 | |
| 215 | 339 | fn serve( |
| 216 | 340 | mut self, |
| 217 | connection: &Mutex<Connection>, | |
| 218 | worker: &Worker, | |
| 341 | shared: &Arc<Thread>, | |
| 219 | 342 | requests: &mpsc::Receiver<Request>, |
| 220 | 343 | backlog: &mut VecDeque<Request>, |
| 221 | 344 | ) -> Next { |
| 345 | let (connection, worker) = (&*shared.connection, &shared.worker); | |
| 346 | // While another thread rebuilds the section, reads answer from this one and every | |
| 347 | // other request waits for the rebuilt section. | |
| 348 | let mut held: Option<VecDeque<Request>> = None; | |
| 222 | 349 | loop { |
| 223 | 350 | let first = match backlog.pop_front() { |
| 224 | 351 | Some(request) => request, |
| ... | ... | @@ -231,7 +358,28 @@ impl<'a> Working<'a> { |
| 231 | 358 | burst.extend(backlog.drain(..)); |
| 232 | 359 | burst.extend(requests.try_iter()); |
| 233 | 360 | let mut accepted = Vec::new(); |
| 234 | while let Some(request) = burst.pop_front() { | |
| 361 | // Requests that write the queue beyond the burst's edits run after its reads. | |
| 362 | let mut later = VecDeque::new(); | |
| 363 | while let Some(request) = burst.pop_front().or_else(|| later.pop_front()) { | |
| 364 | if let Some(waiting) = &mut held { | |
| 365 | match request { | |
| 366 | Request::Page { space, reply } => { | |
| 367 | reply(self.section.page(space).map_err(Into::into)) | |
| 368 | } | |
| 369 | Request::Pages { reply } => reply(self.section.pages().map_err(Into::into)), | |
| 370 | Request::Handover(to) => { | |
| 371 | let mut waiting = held.take().unwrap_or_default(); | |
| 372 | waiting.extend(burst.drain(..).chain(later.drain(..))); | |
| 373 | return Next::Handover(to, waiting); | |
| 374 | } | |
| 375 | Request::Resume => { | |
| 376 | let waiting = held.take().unwrap_or_default(); | |
| 377 | burst = waiting.into_iter().chain(burst.drain(..)).collect(); | |
| 378 | } | |
| 379 | other => waiting.push_back(other), | |
| 380 | } | |
| 381 | continue; | |
| 382 | } | |
| 235 | 383 | let reopen = match request { |
| 236 | 384 | Request::Apply { |
| 237 | 385 | author, |
| ... | ... | @@ -326,16 +474,20 @@ impl<'a> Working<'a> { |
| 326 | 474 | reply(self.section.pages().map_err(Into::into)); |
| 327 | 475 | false |
| 328 | 476 | } |
| 329 | Request::Image { reply } => { | |
| 330 | if self.flush(connection, worker, &mut accepted) { | |
| 331 | reply(lock(connection).and_then(|connection| image(&connection))); | |
| 332 | false | |
| 477 | request @ (Request::Flush { .. } | Request::Seal { .. }) | |
| 478 | if !burst.is_empty() => | |
| 479 | { | |
| 480 | later.push_back(request); | |
| 481 | false | |
| 482 | } | |
| 483 | Request::Flush { reply } => { | |
| 484 | let written = self.flush(connection, worker, &mut accepted); | |
| 485 | reply(if written { | |
| 486 | Ok(()) | |
| 333 | 487 | } else { |
| 334 | reply(Err( | |
| 335 | io::Error::other("The queue could not be written").into() | |
| 336 | )); | |
| 337 | true | |
| 338 | } | |
| 488 | Err(io::Error::other("The queue could not be written").into()) | |
| 489 | }); | |
| 490 | !written | |
| 339 | 491 | } |
| 340 | 492 | Request::Seal { reply } => { |
| 341 | 493 | if !self.flush(connection, worker, &mut accepted) { |
| ... | ... | @@ -352,29 +504,61 @@ impl<'a> Working<'a> { |
| 352 | 504 | } |
| 353 | 505 | Request::Rebase { |
| 354 | 506 | image, |
| 355 | resolve, | |
| 507 | mut resolve, | |
| 356 | 508 | reply, |
| 357 | 509 | } => { |
| 358 | if !self.flush(connection, worker, &mut accepted) { | |
| 359 | reply(Err( | |
| 360 | io::Error::other("The queue could not be written").into() | |
| 361 | )); | |
| 362 | true | |
| 363 | } else { | |
| 364 | let rebased = self.rebase(connection, image, resolve); | |
| 365 | let reopen = !matches!(rebased, Ok(Rebased::Conflict { .. })); | |
| 366 | reply(rebased); | |
| 367 | reopen | |
| 510 | let written = self.flush(connection, worker, &mut accepted); | |
| 511 | // The page a `Mine` resolution keeps is read here, before the | |
| 512 | // rebuild; the root space lists pages and holds none. | |
| 513 | let root = self.section.root(); | |
| 514 | let kept = match &mut resolve { | |
| 515 | Some(Resolve { | |
| 516 | keep: Resolution::Mine, | |
| 517 | page: page @ None, | |
| 518 | space, | |
| 519 | .. | |
| 520 | }) if *space != root => { | |
| 521 | self.section.page(*space).map(|local| *page = Some(local)) | |
| 522 | } | |
| 523 | _ => Ok(()), | |
| 524 | }; | |
| 525 | match (written, kept) { | |
| 526 | (true, Ok(())) => { | |
| 527 | held = self | |
| 528 | .rebuild( | |
| 529 | shared, | |
| 530 | Job::Rebase { | |
| 531 | image, | |
| 532 | resolve, | |
| 533 | reply, | |
| 534 | }, | |
| 535 | ) | |
| 536 | .then(VecDeque::new); | |
| 537 | false | |
| 538 | } | |
| 539 | (_, Err(error)) => { | |
| 540 | reply(Err(error.into())); | |
| 541 | !written | |
| 542 | } | |
| 543 | (false, _) => { | |
| 544 | reply(Err( | |
| 545 | io::Error::other("The queue could not be written").into() | |
| 546 | )); | |
| 547 | true | |
| 548 | } | |
| 368 | 549 | } |
| 369 | 550 | } |
| 370 | 551 | Request::Reopen { reply } => { |
| 371 | 552 | self.flush(connection, worker, &mut accepted); |
| 372 | reply(Ok(())); | |
| 373 | true | |
| 553 | held = self | |
| 554 | .rebuild(shared, Job::Reopen { reply }) | |
| 555 | .then(VecDeque::new); | |
| 556 | false | |
| 374 | 557 | } |
| 558 | Request::Handover(_) | Request::Resume => false, | |
| 375 | 559 | }; |
| 376 | 560 | if reopen { |
| 377 | backlog.extend(burst); | |
| 561 | backlog.extend(burst.drain(..).chain(later.drain(..))); | |
| 378 | 562 | return Next::Reopen; |
| 379 | 563 | } |
| 380 | 564 | } |
| ... | ... | @@ -384,6 +568,30 @@ impl<'a> Working<'a> { |
| 384 | 568 | } |
| 385 | 569 | } |
| 386 | 570 | |
| 571 | /// Starts a thread that runs `job` and rereads the section; false when the replica is | |
| 572 | /// stopping, which answers the job's request. | |
| 573 | fn rebuild(&self, shared: &Arc<Thread>, job: Job) -> bool { | |
| 574 | let signal = shared | |
| 575 | .sender | |
| 576 | .lock() | |
| 577 | .ok() | |
| 578 | .and_then(|sender| sender.as_ref().cloned()); | |
| 579 | let Some(signal) = signal else { | |
| 580 | match job { | |
| 581 | Job::Rebase { reply, .. } => { | |
| 582 | reply(Err(io::Error::other("The section thread stopped").into())) | |
| 583 | } | |
| 584 | Job::Reopen { reply } => { | |
| 585 | reply(Err(io::Error::other("The section thread stopped").into())) | |
| 586 | } | |
| 587 | } | |
| 588 | return false; | |
| 589 | }; | |
| 590 | shared | |
| 591 | .start(move |shared| build(shared, job, signal)) | |
| 592 | .is_ok() | |
| 593 | } | |
| 594 | ||
| 387 | 595 | /// Applies an edit, keeping it to be written with the burst. A refused edit is answered |
| 388 | 596 | /// here; `Err(true)` when it failed part way and the section must be reread. |
| 389 | 597 | fn accept( |
| ... | ... | @@ -536,150 +744,145 @@ impl<'a> Working<'a> { |
| 536 | 744 | self.touched.clear(); |
| 537 | 745 | Ok(Some(Sealed { batch, transaction })) |
| 538 | 746 | } |
| 747 | } | |
| 539 | 748 | |
| 540 | /// Replays every queued edit on `image`, keeping it as the base when all apply. | |
| 541 | fn rebase( | |
| 542 | &mut self, | |
| 543 | connection: &Mutex<Connection>, | |
| 544 | image: Option<Vec<u8>>, | |
| 545 | resolve: Option<Resolve>, | |
| 546 | ) -> Result<Rebased> { | |
| 547 | let (base_image, image, edits) = { | |
| 548 | let connection = lock(connection)?; | |
| 549 | if connection.query_row( | |
| 550 | "SELECT EXISTS(SELECT 1 FROM batches WHERE attempted=1)", | |
| 551 | [], | |
| 552 | |row| row.get::<_, bool>(0), | |
| 553 | )? { | |
| 554 | return Err(io::Error::new( | |
| 555 | io::ErrorKind::InvalidInput, | |
| 556 | "An uncertain attempt is never rebased", | |
| 557 | ) | |
| 558 | .into()); | |
| 749 | /// Replays every queued edit on `image`, keeping it as the base when all apply. | |
| 750 | fn rebase( | |
| 751 | connection: &Mutex<Connection>, | |
| 752 | image: Option<Vec<u8>>, | |
| 753 | resolve: Option<Resolve>, | |
| 754 | ) -> Result<Rebased> { | |
| 755 | let (base_image, image, edits) = { | |
| 756 | let connection = lock(connection)?; | |
| 757 | if connection.query_row( | |
| 758 | "SELECT EXISTS(SELECT 1 FROM batches WHERE attempted=1)", | |
| 759 | [], | |
| 760 | |row| row.get::<_, bool>(0), | |
| 761 | )? { | |
| 762 | return Err(io::Error::new( | |
| 763 | io::ErrorKind::InvalidInput, | |
| 764 | "An uncertain attempt is never rebased", | |
| 765 | ) | |
| 766 | .into()); | |
| 767 | } | |
| 768 | let base_image = base::read(&connection, base::Image::Base)?.ok_or_else(|| { | |
| 769 | io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") | |
| 770 | })?; | |
| 771 | let image = match image { | |
| 772 | Some(image) => image, | |
| 773 | None => { | |
| 774 | base::read(&connection, base::Image::Remote)?.unwrap_or_else(|| base_image.clone()) | |
| 559 | 775 | } |
| 560 | let base_image = base::read(&connection, base::Image::Base)?.ok_or_else(|| { | |
| 561 | io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") | |
| 562 | })?; | |
| 563 | let image = match image { | |
| 564 | Some(image) => image, | |
| 565 | None => base::read(&connection, base::Image::Remote)? | |
| 566 | .unwrap_or_else(|| base_image.clone()), | |
| 567 | }; | |
| 568 | (base_image, image, queue::load(&connection, None)?) | |
| 569 | 776 | }; |
| 570 | // The page the local edits leave, which `Mine` rewrites the remote page to; the | |
| 571 | // root space lists pages and holds none. | |
| 572 | let target = match &resolve { | |
| 573 | Some(Resolve { | |
| 574 | keep: Resolution::Mine, | |
| 575 | page: None, | |
| 576 | space, | |
| 577 | .. | |
| 578 | }) if *space != self.section.root() => Some(self.section.page(*space)?), | |
| 579 | Some(Resolve { page, .. }) => page.clone(), | |
| 580 | None => None, | |
| 581 | }; | |
| 582 | let old_arena = Arena::default(); | |
| 583 | let mut old = Section::open(&old_arena, base_image.clone())?; | |
| 584 | let before: BTreeMap<ExGuid, ExGuid> = old.revisions().collect(); | |
| 585 | let mut after: BTreeMap<ExGuid, ExGuid>; | |
| 586 | // Pages the remote already holds as the local edits leave them: their ops are done. | |
| 587 | let mut converged = BTreeSet::new(); | |
| 588 | let outcome = loop { | |
| 589 | let arena = Arena::default(); | |
| 590 | let mut new = Section::open(&arena, image.clone())?; | |
| 591 | if old.root() != new.root() { | |
| 592 | return Err(io::Error::new( | |
| 593 | io::ErrorKind::InvalidInput, | |
| 594 | "Remote snapshot belongs to another document", | |
| 595 | ) | |
| 596 | .into()); | |
| 597 | } | |
| 598 | after = new.revisions().collect(); | |
| 599 | let outcome = merge::rebase( | |
| 600 | &mut old, | |
| 601 | &mut new, | |
| 602 | &edits, | |
| 603 | &converged, | |
| 604 | resolve.as_ref().map(|resolve| merge::Resolving { | |
| 605 | first: resolve.first, | |
| 606 | space: resolve.space, | |
| 607 | mine: resolve.keep == Resolution::Mine, | |
| 608 | target: target.as_ref(), | |
| 609 | }), | |
| 777 | (base_image, image, queue::load(&connection, None)?) | |
| 778 | }; | |
| 779 | let old_arena = Arena::default(); | |
| 780 | let mut old = Section::open(&old_arena, base_image.clone())?; | |
| 781 | let before: BTreeMap<ExGuid, ExGuid> = old.revisions().collect(); | |
| 782 | let mut after: BTreeMap<ExGuid, ExGuid>; | |
| 783 | // Pages the remote already holds as the local edits leave them: their ops are done. | |
| 784 | let mut converged = BTreeSet::new(); | |
| 785 | let outcome = loop { | |
| 786 | let arena = Arena::default(); | |
| 787 | let mut new = Section::open(&arena, image.clone())?; | |
| 788 | if old.root() != new.root() { | |
| 789 | return Err(io::Error::new( | |
| 790 | io::ErrorKind::InvalidInput, | |
| 791 | "Remote snapshot belongs to another document", | |
| 792 | ) | |
| 793 | .into()); | |
| 794 | } | |
| 795 | after = new.revisions().collect(); | |
| 796 | let outcome = merge::rebase( | |
| 797 | &mut old, | |
| 798 | &mut new, | |
| 799 | &edits, | |
| 800 | &converged, | |
| 801 | resolve.as_ref().map(|resolve| merge::Resolving { | |
| 802 | first: resolve.first, | |
| 803 | space: resolve.space, | |
| 804 | mine: resolve.keep == Resolution::Mine, | |
| 805 | target: resolve.page.as_ref(), | |
| 806 | }), | |
| 807 | )?; | |
| 808 | if let merge::Outcome::Conflict { space, .. } = &outcome | |
| 809 | && !converged.contains(space) | |
| 810 | && let Some(local) = local_page(connection, *space)? | |
| 811 | && Section::open(&Arena::default(), image.clone())? | |
| 812 | .page(*space) | |
| 813 | .is_ok_and(|remote| remote == local) | |
| 814 | { | |
| 815 | converged.insert(*space); | |
| 816 | continue; | |
| 817 | } | |
| 818 | break outcome; | |
| 819 | }; | |
| 820 | let mut connection = lock(connection)?; | |
| 821 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | |
| 822 | let rebased = match outcome { | |
| 823 | merge::Outcome::Conflict { id, space, kind } => { | |
| 824 | transaction.execute("UPDATE batches SET conflict=NULL, space=NULL", [])?; | |
| 825 | transaction.execute( | |
| 826 | "UPDATE batches SET conflict=?1, space=?2 WHERE id=(SELECT batch FROM edits WHERE id=?3)", | |
| 827 | params![kind as i64, space.to_string(), signed(id)?], | |
| 610 | 828 | )?; |
| 611 | if let merge::Outcome::Conflict { space, .. } = &outcome | |
| 612 | && !converged.contains(space) | |
| 613 | && let Ok(local) = self.section.page(*space) | |
| 614 | && Section::open(&Arena::default(), image.clone())? | |
| 615 | .page(*space) | |
| 616 | .is_ok_and(|remote| remote == local) | |
| 617 | { | |
| 618 | converged.insert(*space); | |
| 619 | continue; | |
| 829 | if image == base_image { | |
| 830 | base::clear(&transaction, base::Image::Remote)?; | |
| 831 | } else { | |
| 832 | base::write(&transaction, base::Image::Remote, &image)?; | |
| 620 | 833 | } |
| 621 | break outcome; | |
| 622 | }; | |
| 623 | let mut connection = lock(connection)?; | |
| 624 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | |
| 625 | let rebased = match outcome { | |
| 626 | merge::Outcome::Conflict { id, space, kind } => { | |
| 627 | transaction.execute("UPDATE batches SET conflict=NULL, space=NULL", [])?; | |
| 628 | transaction.execute( | |
| 629 | "UPDATE batches SET conflict=?1, space=?2 WHERE id=(SELECT batch FROM edits WHERE id=?3)", | |
| 630 | params![kind as i64, space.to_string(), signed(id)?], | |
| 631 | )?; | |
| 632 | if image == base_image { | |
| 633 | base::clear(&transaction, base::Image::Remote)?; | |
| 634 | } else { | |
| 635 | base::write(&transaction, base::Image::Remote, &image)?; | |
| 636 | } | |
| 637 | // A conflict is reported under its batch's newest edit. | |
| 638 | let id = crate::unsigned(transaction.query_row( | |
| 639 | "SELECT max(id) FROM edits WHERE batch=(SELECT batch FROM edits WHERE id=?1)", | |
| 640 | [signed(id)?], | |
| 641 | |row| row.get(0), | |
| 642 | )?)?; | |
| 643 | Rebased::Conflict { id, space, kind } | |
| 834 | // A conflict is reported under its batch's newest edit. | |
| 835 | let id = crate::unsigned(transaction.query_row( | |
| 836 | "SELECT max(id) FROM edits WHERE batch=(SELECT batch FROM edits WHERE id=?1)", | |
| 837 | [signed(id)?], | |
| 838 | |row| row.get(0), | |
| 839 | )?)?; | |
| 840 | Rebased::Conflict { id, space, kind } | |
| 841 | } | |
| 842 | merge::Outcome::Applied(rewritten) => { | |
| 843 | base::write(&transaction, base::Image::Base, &image)?; | |
| 844 | base::clear(&transaction, base::Image::Remote)?; | |
| 845 | transaction.execute("INSERT INTO batches DEFAULT VALUES", [])?; | |
| 846 | let batch = transaction.last_insert_rowid(); | |
| 847 | transaction.execute("UPDATE edits SET batch=?1", [batch])?; | |
| 848 | transaction.execute("DELETE FROM batches WHERE id<>?1", [batch])?; | |
| 849 | for (id, edit) in rewritten { | |
| 850 | queue::rewrite(&transaction, id, &edit)?; | |
| 644 | 851 | } |
| 645 | merge::Outcome::Applied(rewritten) => { | |
| 646 | base::write(&transaction, base::Image::Base, &image)?; | |
| 647 | base::clear(&transaction, base::Image::Remote)?; | |
| 648 | transaction.execute("INSERT INTO batches DEFAULT VALUES", [])?; | |
| 649 | let batch = transaction.last_insert_rowid(); | |
| 650 | transaction.execute("UPDATE edits SET batch=?1", [batch])?; | |
| 651 | transaction.execute("DELETE FROM batches WHERE id<>?1", [batch])?; | |
| 652 | for (id, edit) in rewritten { | |
| 653 | queue::rewrite(&transaction, id, &edit)?; | |
| 654 | } | |
| 655 | if transaction | |
| 656 | .query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))? | |
| 657 | == 0 | |
| 658 | { | |
| 659 | transaction.execute("DELETE FROM batches", [])?; | |
| 660 | } | |
| 661 | queue::collect(&transaction)?; | |
| 662 | let mut changed: BTreeSet<ExGuid> = before | |
| 663 | .iter() | |
| 664 | .filter(|(space, rid)| after.get(space) != Some(rid)) | |
| 665 | .map(|(space, _)| *space) | |
| 666 | .chain( | |
| 667 | after | |
| 668 | .keys() | |
| 669 | .filter(|space| !before.contains_key(space)) | |
| 670 | .copied(), | |
| 671 | ) | |
| 672 | .collect(); | |
| 673 | changed.extend(resolve.as_ref().map(|resolve| resolve.space)); | |
| 674 | changed.extend(converged); | |
| 675 | Rebased::Applied { | |
| 676 | changed: changed.into_iter().collect(), | |
| 677 | } | |
| 852 | if transaction | |
| 853 | .query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))? | |
| 854 | == 0 | |
| 855 | { | |
| 856 | transaction.execute("DELETE FROM batches", [])?; | |
| 678 | 857 | } |
| 679 | }; | |
| 680 | transaction.commit()?; | |
| 681 | Ok(rebased) | |
| 682 | } | |
| 858 | queue::collect(&transaction)?; | |
| 859 | let mut changed: BTreeSet<ExGuid> = before | |
| 860 | .iter() | |
| 861 | .filter(|(space, rid)| after.get(space) != Some(rid)) | |
| 862 | .map(|(space, _)| *space) | |
| 863 | .chain( | |
| 864 | after | |
| 865 | .keys() | |
| 866 | .filter(|space| !before.contains_key(space)) | |
| 867 | .copied(), | |
| 868 | ) | |
| 869 | .collect(); | |
| 870 | changed.extend(resolve.as_ref().map(|resolve| resolve.space)); | |
| 871 | changed.extend(converged); | |
| 872 | Rebased::Applied { | |
| 873 | changed: changed.into_iter().collect(), | |
| 874 | } | |
| 875 | } | |
| 876 | }; | |
| 877 | transaction.commit()?; | |
| 878 | Ok(rebased) | |
| 879 | } | |
| 880 | ||
| 881 | /// A page as the queued edits leave it, read from the cache: O(section). | |
| 882 | fn local_page(connection: &Mutex<Connection>, space: ExGuid) -> Result<Option<Page>> { | |
| 883 | let arena = Arena::default(); | |
| 884 | let (section, ..) = replay(&arena, &*lock(connection)?)?; | |
| 885 | Ok(section.page(space).ok()) | |
| 683 | 886 | } |
| 684 | 887 | |
| 685 | 888 | enum Stale { |
crates/notebook/tests/session.rs+6-1| ... | ... | @@ -115,7 +115,12 @@ fn a_section_opens_through_its_replica_and_a_save_reaches_the_file_and_survives_ |
| 115 | 115 | }; |
| 116 | 116 | assert_same(section.page(space).unwrap(), &after); |
| 117 | 117 | published(&section, id); |
| 118 | assert!(notified.load(Ordering::SeqCst) > 0); | |
| 118 | // The worker reports the step after writing its receipt. | |
| 119 | let deadline = Instant::now() + Duration::from_secs(20); | |
| 120 | while notified.load(Ordering::SeqCst) == 0 { | |
| 121 | assert!(Instant::now() < deadline, "the host was not woken"); | |
| 122 | std::thread::sleep(Duration::from_millis(10)); | |
| 123 | } | |
| 119 | 124 | assert_same(stored_page(&file, space), &after); |
| 120 | 125 | assert!(matches!( |
| 121 | 126 | section.status(id).unwrap(), |
crates/onestore/src/edit.rs+65-6| ... | ... | @@ -53,6 +53,58 @@ pub(crate) fn text_changes( |
| 53 | 53 | object: ExGuid, |
| 54 | 54 | range: Range<u32>, |
| 55 | 55 | replacement: &str, |
| 56 | ) -> Result<Changes, Error> { | |
| 57 | rewrite(active, object, range, replacement, false) | |
| 58 | } | |
| 59 | ||
| 60 | /// The title's date or time field showing `text`, as OneNote 2010 rewrites both when a | |
| 61 | /// page's date changes: the field's element takes the change time as its creation time. | |
| 62 | pub(crate) fn field_changes( | |
| 63 | active: &ActivePage<'_>, | |
| 64 | object: ExGuid, | |
| 65 | text: &str, | |
| 66 | ) -> Result<Changes, Error> { | |
| 67 | let length = crate::page::text_of(&active.view, &active.parents, object)? | |
| 68 | .text() | |
| 69 | .encode_utf16() | |
| 70 | .count(); | |
| 71 | let mut changes = rewrite(active, object, 0..length as u32, text, true)?; | |
| 72 | let [element] = active | |
| 73 | .parents | |
| 74 | .get(&object) | |
| 75 | .map(Vec::as_slice) | |
| 76 | .unwrap_or_default() | |
| 77 | else { | |
| 78 | return Err(Error { | |
| 79 | offset: 0, | |
| 80 | message: "Select a date field belonging to one element", | |
| 81 | }); | |
| 82 | }; | |
| 83 | // OneNote 2010 dates the field's element and outline and leaves the title and page | |
| 84 | // above them as they were. | |
| 85 | let outline = active | |
| 86 | .parents | |
| 87 | .get(element) | |
| 88 | .and_then(|parents| parents.first()) | |
| 89 | .copied(); | |
| 90 | changes.retain(|id, _| *id == object || id == element || Some(*id) == outline); | |
| 91 | let modified = crate::create::current_timestamps()?.0.to_le_bytes(); | |
| 92 | let element = match changes.entry(*element) { | |
| 93 | std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(), | |
| 94 | std::collections::btree_map::Entry::Vacant(entry) => entry.insert( | |
| 95 | crate::write::PropertyObject::from_object(&active.live.revision.objects[element])?, | |
| 96 | ), | |
| 97 | }; | |
| 98 | element.set(&[(0x14001d09, &modified)])?; | |
| 99 | Ok(changes) | |
| 100 | } | |
| 101 | ||
| 102 | fn rewrite( | |
| 103 | active: &ActivePage<'_>, | |
| 104 | object: ExGuid, | |
| 105 | range: Range<u32>, | |
| 106 | replacement: &str, | |
| 107 | field: bool, | |
| 56 | 108 | ) -> Result<Changes, Error> { |
| 57 | 109 | let invalid = |message| Error { offset: 0, message }; |
| 58 | 110 | let raw = &active.live.revision; |
| ... | ... | @@ -60,7 +112,12 @@ pub(crate) fn text_changes( |
| 60 | 112 | return Err(invalid("Object is not reachable in the active revision")); |
| 61 | 113 | } |
| 62 | 114 | let revision = &active.view; |
| 63 | let parents = active.editable_parents(object)?; | |
| 115 | // Generated fields are read-only to ordinary edits; only a date change rewrites them. | |
| 116 | let parents = if field { | |
| 117 | &active.parents | |
| 118 | } else { | |
| 119 | active.editable_parents(object)? | |
| 120 | }; | |
| 64 | 121 | let node = revision |
| 65 | 122 | .nodes |
| 66 | 123 | .get(&object) |
| ... | ... | @@ -74,10 +131,12 @@ pub(crate) fn text_changes( |
| 74 | 131 | else { |
| 75 | 132 | return Err(invalid("Select a rich-text object")); |
| 76 | 133 | }; |
| 77 | if *boilerplate { | |
| 78 | return Err(invalid( | |
| 79 | "Generated title fields cannot be edited as ordinary text", | |
| 80 | )); | |
| 134 | if *boilerplate != field { | |
| 135 | return Err(invalid(if field { | |
| 136 | "Select the title's date or time" | |
| 137 | } else { | |
| 138 | "Generated title fields cannot be edited as ordinary text" | |
| 139 | })); | |
| 81 | 140 | } |
| 82 | 141 | let selected = runs |
| 83 | 142 | .iter() |
| ... | ... | @@ -103,7 +162,7 @@ pub(crate) fn text_changes( |
| 103 | 162 | let properties = PropertySets::parse(blob)?; |
| 104 | 163 | if properties.sets[0] |
| 105 | 164 | .iter() |
| 106 | .any(|p| p.id == 0x88001cde || p.id == 0x24003458) | |
| 165 | .any(|p| (p.id == 0x88001cde && !field) || p.id == 0x24003458) | |
| 107 | 166 | { |
| 108 | 167 | return Err(invalid( |
| 109 | 168 | "This text object is read-only or contains associated run data", |
crates/onestore/src/op/apply.rs+22| ... | ... | @@ -326,6 +326,28 @@ impl<'a> Writer<'_, 'a> { |
| 326 | 326 | self.target(*text)?; |
| 327 | 327 | self.write(|page| content::equation_changes(page, *text, math)) |
| 328 | 328 | } |
| 329 | PageOp::Date { created, fields } => { | |
| 330 | for (text, shown) in fields { | |
| 331 | self.target(*text)?; | |
| 332 | if shown.contains(['\0', '\n', '\r', '\u{fffc}']) { | |
| 333 | return Err(OpError::Unsupported("Use a date as one line of text").into()); | |
| 334 | } | |
| 335 | self.write(|page| crate::edit::field_changes(page, *text, shown))?; | |
| 336 | } | |
| 337 | self.write(|page| { | |
| 338 | let metadata = page | |
| 339 | .view | |
| 340 | .roots | |
| 341 | .get(&2) | |
| 342 | .filter(|id| matches!(page.view.nodes[id].kind, Kind::Metadata { .. })) | |
| 343 | .ok_or_else(|| invalid("The page has no metadata to date"))?; | |
| 344 | let mut object = crate::write::PropertyObject::from_object( | |
| 345 | &page.live.revision.objects[metadata], | |
| 346 | )?; | |
| 347 | object.set(&[(0x18001c65, &created.to_le_bytes())])?; | |
| 348 | Ok(BTreeMap::from([(*metadata, object)])) | |
| 349 | }) | |
| 350 | } | |
| 329 | 351 | PageOp::Insert { |
| 330 | 352 | container, |
| 331 | 353 | before, |
crates/onestore/src/op/lower.rs+25-3| ... | ... | @@ -236,8 +236,8 @@ pub(crate) fn validate( |
| 236 | 236 | old: &View<'_>, |
| 237 | 237 | new: &View<'_>, |
| 238 | 238 | ) -> Result<(), Error> { |
| 239 | if after.created != before.created || after.margin_origin != before.margin_origin { | |
| 240 | return Err(invalid("Page creation time and margins cannot be edited")); | |
| 239 | if after.margin_origin != before.margin_origin { | |
| 240 | return Err(invalid("Page margins cannot be edited")); | |
| 241 | 241 | } |
| 242 | 242 | for (id, definition) in &after.definitions { |
| 243 | 243 | if before |
| ... | ... | @@ -806,6 +806,7 @@ impl Lowering { |
| 806 | 806 | self.delete(old, new, &consumed)?; |
| 807 | 807 | self.levels(new)?; |
| 808 | 808 | self.equations(new)?; |
| 809 | self.date(before, after)?; | |
| 809 | 810 | self.text(new)?; |
| 810 | 811 | self.styles(after, new)?; |
| 811 | 812 | self.lists(after, new)?; |
| ... | ... | @@ -832,7 +833,10 @@ impl Lowering { |
| 832 | 833 | for paragraph in document { |
| 833 | 834 | if old.paragraphs.contains_key(&paragraph.id) { |
| 834 | 835 | left = paragraph.text().is_some().then_some(paragraph.id); |
| 835 | } else if let Some(left) = left { | |
| 836 | } else if let Some(left) = left | |
| 837 | // A new cell arrives with its paragraphs. | |
| 838 | && old.children.contains_key(&root(new, paragraph.id)) | |
| 839 | { | |
| 836 | 840 | let container = new.container[&paragraph.id]; |
| 837 | 841 | let at = new.children[&container] |
| 838 | 842 | .iter() |
| ... | ... | @@ -1542,6 +1546,24 @@ impl Lowering { |
| 1542 | 1546 | Ok(()) |
| 1543 | 1547 | } |
| 1544 | 1548 | |
| 1549 | /// The page's date, with every date and time field of the title showing it. | |
| 1550 | fn date(&mut self, before: &Page, after: &Page) -> Result<(), Error> { | |
| 1551 | let fields: Vec<(ExGuid, String)> = model::date_fields(after) | |
| 1552 | .into_iter() | |
| 1553 | .map(|text| (text.id, text.text.text().to_owned())) | |
| 1554 | .collect(); | |
| 1555 | let shown = fields.iter().all(|(id, shown)| { | |
| 1556 | model::paragraph_text(&self.current, *id).is_some_and(|text| text.text.text() == shown) | |
| 1557 | }); | |
| 1558 | if before.created == after.created && shown { | |
| 1559 | return Ok(()); | |
| 1560 | } | |
| 1561 | let created = after | |
| 1562 | .created | |
| 1563 | .ok_or_else(|| invalid("A page date cannot be removed"))?; | |
| 1564 | self.emit(PageOp::Date { created, fields }) | |
| 1565 | } | |
| 1566 | ||
| 1545 | 1567 | fn text(&mut self, new: &View<'_>) -> Result<(), Error> { |
| 1546 | 1568 | for (id, paragraph) in &new.paragraphs { |
| 1547 | 1569 | let Some(text) = paragraph.text() else { |
crates/onestore/src/op/mod.rs+9| ... | ... | @@ -22,6 +22,9 @@ pub(crate) mod table; |
| 22 | 22 | mod tests; |
| 23 | 23 | |
| 24 | 24 | pub use lower::{lower, lower_page}; |
| 25 | /// `op` applied to `page`, the model of what the section holds: the page `Section::apply` | |
| 26 | /// leaves, as reading it back shows it. | |
| 27 | pub use model::apply as predict; | |
| 25 | 28 | |
| 26 | 29 | /// Property identifiers with their encoded values. |
| 27 | 30 | pub(crate) type Values = Vec<(u32, Vec<u8>)>; |
| ... | ... | @@ -71,6 +74,12 @@ pub enum PageOp { |
| 71 | 74 | }, |
| 72 | 75 | /// Rewrites an equation's linear text and spans whole. |
| 73 | 76 | Equation { text: ExGuid, math: Paragraph }, |
| 77 | /// The page's date: its creation time, FILETIME, and the text each of the title's date | |
| 78 | /// and time fields shows for it, as OneNote 2010 writes both when the date changes. | |
| 79 | Date { | |
| 80 | created: u64, | |
| 81 | fields: Vec<(ExGuid, String)>, | |
| 82 | }, | |
| 74 | 83 | /// Inserts paragraphs before a direct child of `container`, or last. Children follow |
| 75 | 84 | /// their parents in `paragraphs`; levels are absolute. Text keeps its spans' formats; |
| 76 | 85 | /// lists, tags, styles and collapse state are set by their own ops. |
crates/onestore/src/op/model.rs+35-1| ... | ... | @@ -203,6 +203,29 @@ fn text_holder(page: &Page, text: ExGuid) -> Option<(Path, usize)> { |
| 203 | 203 | }) |
| 204 | 204 | } |
| 205 | 205 | |
| 206 | /// The text of the title's date and time fields, in order. | |
| 207 | pub(crate) fn date_fields(page: &Page) -> Vec<&TextObject> { | |
| 208 | page.objects | |
| 209 | .iter() | |
| 210 | .filter_map(|object| match object { | |
| 211 | PageObject::Title(title) => { | |
| 212 | let date = title.date?; | |
| 213 | title.outlines.iter().find(|outline| outline.id == date) | |
| 214 | } | |
| 215 | _ => None, | |
| 216 | }) | |
| 217 | .flat_map(|outline| outline.paragraphs.iter().filter_map(PageParagraph::text)) | |
| 218 | .collect() | |
| 219 | } | |
| 220 | ||
| 221 | /// Text object `text` wherever the page holds it. | |
| 222 | pub(crate) fn paragraph_text(page: &Page, text: ExGuid) -> Option<&TextObject> { | |
| 223 | lists(page).into_iter().find_map(|(_, _, list)| { | |
| 224 | list.iter() | |
| 225 | .find_map(|paragraph| paragraph.text().filter(|object| object.id == text)) | |
| 226 | }) | |
| 227 | } | |
| 228 | ||
| 206 | 229 | fn text_mut(page: &mut Page, text: ExGuid) -> Result<&mut TextObject, Error> { |
| 207 | 230 | let (path, index) = text_holder(page, text).ok_or_else(|| invalid("Text is not on the page"))?; |
| 208 | 231 | Ok(list_at(page, &path)[index].text_mut().unwrap()) |
| ... | ... | @@ -547,7 +570,7 @@ fn automatic(page: &mut Page) { |
| 547 | 570 | } |
| 548 | 571 | |
| 549 | 572 | /// Applies `op` to `page` as reading the page back after `Section::apply` would show it. |
| 550 | pub(crate) fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> { | |
| 573 | pub fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> { | |
| 551 | 574 | interpret(page, op)?; |
| 552 | 575 | // A page lists the definitions its paragraphs reference. |
| 553 | 576 | let mut referenced = BTreeSet::new(); |
| ... | ... | @@ -569,6 +592,17 @@ pub(crate) fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> { |
| 569 | 592 | |
| 570 | 593 | fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { |
| 571 | 594 | match op { |
| 595 | PageOp::Date { created, fields } => { | |
| 596 | for (text, shown) in fields { | |
| 597 | if !date_fields(page).iter().any(|field| field.id == *text) { | |
| 598 | return Err(invalid("Select the title's date or time")); | |
| 599 | } | |
| 600 | let object = text_mut(page, *text)?; | |
| 601 | let format = object.text.spans()[0].format.clone(); | |
| 602 | object.text = Paragraph::new(shown.clone(), format); | |
| 603 | } | |
| 604 | page.created = Some(*created); | |
| 605 | } | |
| 572 | 606 | PageOp::Text { text, range, with } => { |
| 573 | 607 | let object = text_mut(page, *text)?; |
| 574 | 608 | object.text = replace_text(&object.text, range.clone(), with)?; |
crates/onestore/src/op/tests.rs+98| ... | ... | @@ -2148,3 +2148,101 @@ fn op_gate_candidate() { |
| 2148 | 2148 | std::fs::write(directory.join("expected-count.txt"), count.to_string()).unwrap(); |
| 2149 | 2149 | println!("{} imported pages, {edits} lowered edits, {count} pages", imported.len()); |
| 2150 | 2150 | } |
| 2151 | ||
| 2152 | /// OneNote 2010's own date change (`corpus/page-date/native`, COM `UpdatePageContent` | |
| 2153 | /// with a new `dateTime`): the page's second page, "Changed date", took it; "Kept date" | |
| 2154 | /// did not. | |
| 2155 | const DATED: &[u8] = include_bytes!("../../../../corpus/page-date/native/notebook/pictures.one"); | |
| 2156 | ||
| 2157 | /// The objects of `space`'s active revision that differ from its previous one, by jcid. | |
| 2158 | fn changed_jcids(image: &[u8], space: ExGuid) -> Vec<u32> { | |
| 2159 | let store = Store::parse(image).unwrap(); | |
| 2160 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 2161 | let active = index.active(space).unwrap(); | |
| 2162 | let previous = index.spaces[&space].revisions[&active].dependency.unwrap(); | |
| 2163 | let (old, new) = ( | |
| 2164 | index.resolve(space, previous).unwrap(), | |
| 2165 | index.resolve(space, active).unwrap(), | |
| 2166 | ); | |
| 2167 | let mut jcids: Vec<u32> = new | |
| 2168 | .objects | |
| 2169 | .iter() | |
| 2170 | .filter(|(id, object)| { | |
| 2171 | old.objects.get(id).is_none_or(|old| { | |
| 2172 | format!("{:?}", old.data) != format!("{:?}", object.data) | |
| 2173 | }) | |
| 2174 | }) | |
| 2175 | .map(|(_, object)| object.jcid) | |
| 2176 | .collect(); | |
| 2177 | jcids.sort(); | |
| 2178 | jcids | |
| 2179 | } | |
| 2180 | ||
| 2181 | /// The title's date and time fields of `page`, by text object. | |
| 2182 | fn date_fields(page: &Page) -> Vec<(ExGuid, String)> { | |
| 2183 | model::date_fields(page) | |
| 2184 | .into_iter() | |
| 2185 | .map(|text| (text.id, text.text.text().to_owned())) | |
| 2186 | .collect() | |
| 2187 | } | |
| 2188 | ||
| 2189 | /// FILETIME of 2025-07-04 16:45 UTC, which a Pacific clock shows as 9:45 AM. | |
| 2190 | const DATED_AT: u64 = 133_961_211_000_000_000; | |
| 2191 | ||
| 2192 | /// The `Date` op writes what OneNote writes for a new page date: the metadata's creation | |
| 2193 | /// time, the date and time field text, and the modification times above them. | |
| 2194 | #[test] | |
| 2195 | fn a_page_date_stores_what_onenote_stores() { | |
| 2196 | let spaces = pages(DATED); | |
| 2197 | let title = |space: &ExGuid| read(DATED, *space).title; | |
| 2198 | let kept = *spaces.iter().find(|space| title(space) == "Kept date").unwrap(); | |
| 2199 | let changed = *spaces.iter().find(|space| title(space) == "Changed date").unwrap(); | |
| 2200 | let native = changed_jcids(DATED, changed); | |
| 2201 | assert_eq!(native, [0x20030, 0x6000c, 0x6000d, 0x6000d, 0x6000e, 0x6000e]); | |
| 2202 | ||
| 2203 | let arena = Arena::default(); | |
| 2204 | let mut section = Section::open(&arena, DATED.to_vec()).unwrap(); | |
| 2205 | let before = section.page(kept).unwrap(); | |
| 2206 | // The date field precedes the time field. | |
| 2207 | let fields: Vec<(ExGuid, String)> = date_fields(&before) | |
| 2208 | .into_iter() | |
| 2209 | .zip(["Friday, July 04, 2025", "9:45 AM"]) | |
| 2210 | .map(|((id, _), shown)| (id, shown.to_owned())) | |
| 2211 | .collect(); | |
| 2212 | assert_eq!(fields.len(), 2); | |
| 2213 | let op = PageOp::Date { | |
| 2214 | created: DATED_AT, | |
| 2215 | fields: fields.clone(), | |
| 2216 | }; | |
| 2217 | let mut predicted = before.clone(); | |
| 2218 | model::apply(&mut predicted, &op).unwrap(); | |
| 2219 | section | |
| 2220 | .apply("Author", &Edit { at: AT, ops: vec![Op::Page { space: kept, op }] }) | |
| 2221 | .unwrap(); | |
| 2222 | section.seal().unwrap(); | |
| 2223 | let image = section.image(); | |
| 2224 | let stored = read(&image, kept); | |
| 2225 | assert_eq!(stored.created, Some(DATED_AT)); | |
| 2226 | assert_eq!(date_fields(&stored), fields); | |
| 2227 | assert_eq!(stored, predicted); | |
| 2228 | assert_eq!(changed_jcids(&image, kept), native); | |
| 2229 | let store = Store::parse(&image).unwrap(); | |
| 2230 | RevisionIndex::parse(&store).unwrap().validate_current().unwrap(); | |
| 2231 | ||
| 2232 | // The whole-page lowering reaches the same page through the same op. | |
| 2233 | let mut after = before.clone(); | |
| 2234 | after.created = Some(DATED_AT); | |
| 2235 | model::apply(&mut after, &PageOp::Date { created: DATED_AT, fields }).unwrap(); | |
| 2236 | assert!(matches!(&lower_page(&before, &after).unwrap()[..], [PageOp::Date { .. }])); | |
| 2237 | ||
| 2238 | if let Some(directory) = std::env::var_os("ONESTORE_PAGE_DATE_EXPORT") { | |
| 2239 | let directory = std::path::Path::new(&directory); | |
| 2240 | std::fs::create_dir_all(directory).unwrap(); | |
| 2241 | std::fs::write(directory.join("pictures.one"), &image).unwrap(); | |
| 2242 | std::fs::copy( | |
| 2243 | concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/page-date/native/notebook/Open Notebook.onetoc2"), | |
| 2244 | directory.join("Open Notebook.onetoc2"), | |
| 2245 | ) | |
| 2246 | .unwrap(); | |
| 2247 | } | |
| 2248 | } |
crates/onestore/src/page/write.rs+3| ... | ... | @@ -142,6 +142,9 @@ impl<'a> Lowering<'a> { |
| 142 | 142 | let old = View::new(before, false)?; |
| 143 | 143 | let new = View::new(after, false)?; |
| 144 | 144 | validate(before, after, &old, &new)?; |
| 145 | if after.created != before.created { | |
| 146 | return Err(invalid("Page creation time cannot be edited")); | |
| 147 | } | |
| 145 | 148 | for id in new.outlines.keys().chain(new.paragraphs.keys()) { |
| 146 | 149 | if !old.outlines.contains_key(id) |
| 147 | 150 | && !old.paragraphs.contains_key(id) |
crates/snowbound/src/macos.rs+14| ... | ... | @@ -458,6 +458,20 @@ pub fn discard_changes() -> bool { |
| 458 | 458 | } |
| 459 | 459 | } |
| 460 | 460 | |
| 461 | /// Tells the user an edit could not be stored and where their text went. | |
| 462 | pub fn change_not_saved() { | |
| 463 | let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); | |
| 464 | // The alert and its strings stay on AppKit's main thread for the modal call. | |
| 465 | unsafe { | |
| 466 | let alert = NSAlert::new(mtm); | |
| 467 | alert.setMessageText(&NSString::from_str("Change not saved")); | |
| 468 | alert.setInformativeText(&NSString::from_str( | |
| 469 | "The page now shows what was last saved. Your text is on the clipboard to paste back.", | |
| 470 | )); | |
| 471 | alert.runModal(); | |
| 472 | } | |
| 473 | } | |
| 474 | ||
| 461 | 475 | /// End AppKit preedit after a canvas action commits or cancels the composition. |
| 462 | 476 | pub fn clear_marked_text(window: &Window) { |
| 463 | 477 | MainThreadMarker::new().expect("Text input belongs to the main thread"); |
crates/snowbound/src/main.rs+356-96| ... | ... | @@ -198,14 +198,13 @@ impl Library { |
| 198 | 198 | } |
| 199 | 199 | } |
| 200 | 200 | |
| 201 | /// The open section and the stored model the editor's page was loaded from. | |
| 201 | /// The open section and the page the editor shows. | |
| 202 | 202 | struct Session { |
| 203 | 203 | section: notebook::session::Section, |
| 204 | 204 | tab: usize, |
| 205 | 205 | /// Spaces, titles and outline levels in section order. |
| 206 | 206 | pages: Vec<(ExGuid, String, u32)>, |
| 207 | 207 | space: ExGuid, |
| 208 | before: Page, | |
| 209 | 208 | status: &'static str, |
| 210 | 209 | /// The editor's page has an unreviewed conflict with another machine's change. |
| 211 | 210 | conflict: bool, |
| ... | ... | @@ -222,21 +221,20 @@ impl Session { |
| 222 | 221 | fn refresh_conflict(&mut self) -> Result<(), Box<dyn Error>> { |
| 223 | 222 | self.conflict = self |
| 224 | 223 | .section |
| 225 | .conflicts()? | |
| 226 | .iter() | |
| 227 | .any(|(edit, _)| edit.space == self.space); | |
| 224 | .conflict()? | |
| 225 | .is_some_and(|conflict| conflict.space == self.space); | |
| 228 | 226 | Ok(()) |
| 229 | 227 | } |
| 230 | 228 | } |
| 231 | 229 | |
| 232 | /// A section or page read on a loader thread. | |
| 230 | /// What a loader thread opened: a section, or another page of the open one. | |
| 233 | 231 | enum Loaded { |
| 234 | Section(Session), | |
| 235 | Page { space: ExGuid, page: Page }, | |
| 232 | Section(Box<Session>), | |
| 233 | Page(ExGuid), | |
| 236 | 234 | } |
| 237 | 235 | |
| 238 | /// A loader thread's request number and what it read. | |
| 239 | type Read = (u64, Result<Loaded, String>); | |
| 236 | /// A loader thread's request number, what it opened and the page it read. | |
| 237 | type Read = (u64, Result<(Loaded, Page), String>); | |
| 240 | 238 | |
| 241 | 239 | /// The newest page read, laid out and waiting for the pictures it shows first, so it |
| 242 | 240 | /// never appears without them. |
| ... | ... | @@ -289,6 +287,9 @@ struct State { |
| 289 | 287 | changed: bool, |
| 290 | 288 | /// Only the view scrolled or zoomed during this frame, to be announced after it. |
| 291 | 289 | moved: bool, |
| 290 | /// The stored page changed elsewhere while marked text was pending; it is shown once | |
| 291 | /// the composition ends. | |
| 292 | stale: bool, | |
| 292 | 293 | /// Whether the page last heard it had keyboard focus. |
| 293 | 294 | page_focused: bool, |
| 294 | 295 | /// The pointer in logical pixels, for window drags from the strip. |
| ... | ... | @@ -425,8 +426,8 @@ impl State { |
| 425 | 426 | .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))? |
| 426 | 427 | .0; |
| 427 | 428 | library = Some(Arc::new(opened)); |
| 428 | let opened = read_session(section, 0, Some(space))?; | |
| 429 | let (scene, editor) = PageScene::from_page(opened.before.clone(), &mut engine)?; | |
| 429 | let (opened, page) = read_session(section, 0, Some(space))?; | |
| 430 | let (scene, editor) = PageScene::from_page(page, &mut engine)?; | |
| 430 | 431 | session = Some(opened); |
| 431 | 432 | (editor, Some((scene, [0.0; 2]))) |
| 432 | 433 | } |
| ... | ... | @@ -444,8 +445,8 @@ impl State { |
| 444 | 445 | }; |
| 445 | 446 | let section = opened.open(0, proxy.clone())?; |
| 446 | 447 | library = Some(Arc::new(opened)); |
| 447 | let opened = read_session(section, 0, None)?; | |
| 448 | let (scene, editor) = PageScene::from_page(opened.before.clone(), &mut engine)?; | |
| 448 | let (opened, page) = read_session(section, 0, None)?; | |
| 449 | let (scene, editor) = PageScene::from_page(page, &mut engine)?; | |
| 449 | 450 | session = Some(opened); |
| 450 | 451 | (editor, Some((scene, [0.0; 2]))) |
| 451 | 452 | } |
| ... | ... | @@ -511,6 +512,7 @@ impl State { |
| 511 | 512 | commands: Vec::new(), |
| 512 | 513 | changed: false, |
| 513 | 514 | moved: false, |
| 515 | stale: false, | |
| 514 | 516 | page_focused: true, |
| 515 | 517 | pointer: [0.0; 2], |
| 516 | 518 | strip_press: None, |
| ... | ... | @@ -587,6 +589,9 @@ impl State { |
| 587 | 589 | self.after_move()?; |
| 588 | 590 | } |
| 589 | 591 | self.moved = false; |
| 592 | if self.stale { | |
| 593 | self.refresh()?; | |
| 594 | } | |
| 590 | 595 | // A covered window shows nothing, so animations wait for it to be uncovered. |
| 591 | 596 | if follow && !self.occluded { |
| 592 | 597 | self.window.request_redraw(); |
| ... | ... | @@ -1483,18 +1488,14 @@ impl State { |
| 1483 | 1488 | let proxy = self.proxy.clone(); |
| 1484 | 1489 | self.load(move || { |
| 1485 | 1490 | let section = library.open(tab, proxy)?; |
| 1486 | Ok(Loaded::Section(read_session(section, tab, None)?)) | |
| 1491 | let (session, page) = read_session(section, tab, None)?; | |
| 1492 | Ok((Loaded::Section(Box::new(session)), page)) | |
| 1487 | 1493 | }); |
| 1488 | 1494 | } |
| 1489 | 1495 | Command::OpenPage(space) => { |
| 1490 | 1496 | let session = self.session.as_ref().ok_or("No section is open")?; |
| 1491 | 1497 | let replica = Arc::clone(session.section.replica()); |
| 1492 | self.load(move || { | |
| 1493 | Ok(Loaded::Page { | |
| 1494 | space, | |
| 1495 | page: replica.page(space)?, | |
| 1496 | }) | |
| 1497 | }); | |
| 1498 | self.load(move || Ok((Loaded::Page(space), replica.page(space)?))); | |
| 1498 | 1499 | } |
| 1499 | 1500 | Command::Resolve { keep_mine } => self.resolve_conflict(keep_mine)?, |
| 1500 | 1501 | Command::Page(Request::EditDate(field)) => self.edit_date(field)?, |
| ... | ... | @@ -1512,7 +1513,10 @@ impl State { |
| 1512 | 1513 | |
| 1513 | 1514 | /// Runs `read` on a thread of its own; `open_loaded` shows what it read unless a newer |
| 1514 | 1515 | /// read was asked for meanwhile. |
| 1515 | fn load(&mut self, read: impl FnOnce() -> Result<Loaded, Box<dyn Error>> + Send + 'static) { | |
| 1516 | fn load( | |
| 1517 | &mut self, | |
| 1518 | read: impl FnOnce() -> Result<(Loaded, Page), Box<dyn Error>> + Send + 'static, | |
| 1519 | ) { | |
| 1516 | 1520 | self.loading += 1; |
| 1517 | 1521 | let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone()); |
| 1518 | 1522 | std::thread::spawn(move || { |
| ... | ... | @@ -1528,12 +1532,8 @@ impl State { |
| 1528 | 1532 | if id != self.loading { |
| 1529 | 1533 | continue; |
| 1530 | 1534 | } |
| 1531 | let loaded = loaded?; | |
| 1532 | let page = match &loaded { | |
| 1533 | Loaded::Section(session) => &session.before, | |
| 1534 | Loaded::Page { page, .. } => page, | |
| 1535 | }; | |
| 1536 | let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?; | |
| 1535 | let (loaded, page) = loaded?; | |
| 1536 | let (scene, editor) = PageScene::from_page(page, &mut self.view.engine)?; | |
| 1537 | 1537 | self.opening = Some(Opening { |
| 1538 | 1538 | loaded, |
| 1539 | 1539 | scene: (scene, [0.0; 2]), |
| ... | ... | @@ -1556,15 +1556,16 @@ impl State { |
| 1556 | 1556 | self.opening = Some(opening); |
| 1557 | 1557 | return Ok(()); |
| 1558 | 1558 | } |
| 1559 | // The page shown so far keeps what was typed while the next one loaded. | |
| 1560 | self.persist()?; | |
| 1559 | 1561 | match opening.loaded { |
| 1560 | 1562 | Loaded::Section(session) => { |
| 1561 | self.session = Some(session); | |
| 1563 | self.session = Some(*session); | |
| 1562 | 1564 | self.filter.clear(); |
| 1563 | 1565 | } |
| 1564 | Loaded::Page { space, page } => { | |
| 1566 | Loaded::Page(space) => { | |
| 1565 | 1567 | let session = self.session.as_mut().ok_or("No section is open")?; |
| 1566 | 1568 | session.space = space; |
| 1567 | session.before = page; | |
| 1568 | 1569 | session.refresh_conflict()?; |
| 1569 | 1570 | } |
| 1570 | 1571 | } |
| ... | ... | @@ -1663,104 +1664,131 @@ impl State { |
| 1663 | 1664 | Ok(()) |
| 1664 | 1665 | } |
| 1665 | 1666 | |
| 1666 | /// Saves the edited page to the section's replica; a page changed underneath the | |
| 1667 | /// editor is reloaded in place of the edit. | |
| 1667 | /// Hands the section the ops the editor recorded since the last call, as one edit; the | |
| 1668 | /// section thread stores it. | |
| 1668 | 1669 | fn persist(&mut self) -> Result<(), Box<dyn Error>> { |
| 1670 | let ops = match self.view.editor.take_ops() { | |
| 1671 | Ok(ops) => ops, | |
| 1672 | Err(error) => return self.refused(&error.to_string()), | |
| 1673 | }; | |
| 1669 | 1674 | let Some(session) = &mut self.session else { |
| 1670 | 1675 | return Ok(()); |
| 1671 | 1676 | }; |
| 1672 | let after = self.view.editor.page()?; | |
| 1673 | if after == session.before { | |
| 1677 | if ops.is_empty() { | |
| 1674 | 1678 | return Ok(()); |
| 1675 | 1679 | } |
| 1676 | if let Some(entry) = session | |
| 1677 | .pages | |
| 1678 | .iter_mut() | |
| 1679 | .find(|(space, ..)| *space == session.space) | |
| 1680 | { | |
| 1681 | entry.1.clone_from(&after.title); | |
| 1682 | } | |
| 1683 | // Queued saves chain: each one's `after` is the next one's `before`. | |
| 1684 | let before = std::mem::replace(&mut session.before, after.clone()); | |
| 1685 | session | |
| 1686 | .section | |
| 1687 | .queue_save(session.space, before, after, "snowbound")?; | |
| 1680 | let space = session.space; | |
| 1681 | session.section.apply( | |
| 1682 | "snowbound", | |
| 1683 | onestore::op::Edit { | |
| 1684 | at: filetime(), | |
| 1685 | ops: ops | |
| 1686 | .into_iter() | |
| 1687 | .map(|op| onestore::op::Op::Page { space, op }) | |
| 1688 | .collect(), | |
| 1689 | }, | |
| 1690 | )?; | |
| 1691 | session.status = "Saving"; | |
| 1688 | 1692 | Ok(()) |
| 1689 | 1693 | } |
| 1690 | 1694 | |
| 1691 | /// Replaces the editor with the page currently stored in the section. | |
| 1692 | fn reload(&mut self) -> Result<(), Box<dyn Error>> { | |
| 1693 | let Some(session) = &mut self.session else { | |
| 1695 | /// Shows the page as stored after a change made elsewhere, in place: the caret, | |
| 1696 | /// selection, scroll and drawn pictures stay. Waits for marked text to end. | |
| 1697 | fn refresh(&mut self) -> Result<(), Box<dyn Error>> { | |
| 1698 | if self.view.editor.marked_range().is_some() { | |
| 1699 | self.stale = true; | |
| 1700 | return Ok(()); | |
| 1701 | } | |
| 1702 | self.stale = false; | |
| 1703 | self.persist()?; | |
| 1704 | let Some(session) = &self.session else { | |
| 1694 | 1705 | return Ok(()); |
| 1695 | 1706 | }; |
| 1696 | 1707 | let page = session.section.page(session.space)?; |
| 1697 | let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?; | |
| 1698 | session.before = page; | |
| 1699 | self.view.replace(editor, Some((scene, [0.0; 2]))); | |
| 1708 | let response = self.view.refresh(page)?; | |
| 1709 | self.respond(response); | |
| 1700 | 1710 | self.title(); |
| 1701 | self.update_accessibility()?; | |
| 1702 | 1711 | self.window.request_redraw(); |
| 1703 | 1712 | Ok(()) |
| 1704 | 1713 | } |
| 1705 | 1714 | |
| 1706 | /// Reviews the oldest conflict on this page: `keep_mine` publishes the editor's page | |
| 1707 | /// over the remote change, otherwise the remote page replaces the editor's. | |
| 1715 | /// An edit the section could not store: the page returns to what is stored, and the | |
| 1716 | /// text the editor showed goes on the clipboard so nothing typed is lost unannounced. | |
| 1717 | fn refused(&mut self, error: &str) -> Result<(), Box<dyn Error>> { | |
| 1718 | eprintln!("Saving failed: {error}"); | |
| 1719 | self.clipboard.set_text(page_text(&self.view.editor))?; | |
| 1720 | // What the editor recorded since builds on the refused edit. | |
| 1721 | let _ = self.view.editor.take_ops(); | |
| 1722 | self.view.editor.cancel_composition(&mut self.view.engine)?; | |
| 1723 | macos::clear_marked_text(&self.window); | |
| 1724 | if let Some(session) = &mut self.session { | |
| 1725 | session.status = "Not saving"; | |
| 1726 | } | |
| 1727 | self.refresh()?; | |
| 1728 | macos::change_not_saved(); | |
| 1729 | Ok(()) | |
| 1730 | } | |
| 1731 | ||
| 1732 | /// Ends the conflict on this page: `keep_mine` publishes the local page over the remote | |
| 1733 | /// change, otherwise the remote page replaces it. | |
| 1708 | 1734 | fn resolve_conflict(&mut self, keep_mine: bool) -> Result<(), Box<dyn Error>> { |
| 1735 | self.persist()?; | |
| 1709 | 1736 | let Some(session) = &mut self.session else { |
| 1710 | 1737 | return Ok(()); |
| 1711 | 1738 | }; |
| 1712 | let Some((edit, _)) = session | |
| 1739 | let Some(conflict) = session | |
| 1713 | 1740 | .section |
| 1714 | .conflicts()? | |
| 1715 | .into_iter() | |
| 1716 | .find(|(edit, _)| edit.space == session.space) | |
| 1741 | .conflict()? | |
| 1742 | .filter(|conflict| conflict.space == session.space) | |
| 1717 | 1743 | else { |
| 1718 | 1744 | return Ok(()); |
| 1719 | 1745 | }; |
| 1720 | let reviewed = if keep_mine { | |
| 1721 | self.view.editor.page()? | |
| 1746 | let resolution = if keep_mine { | |
| 1747 | notebook::Resolution::Mine | |
| 1722 | 1748 | } else { |
| 1723 | session.section.remote_page(session.space)? | |
| 1749 | notebook::Resolution::Theirs | |
| 1724 | 1750 | }; |
| 1725 | session.section.review(edit.id, &reviewed)?; | |
| 1751 | session.section.resolve(conflict.id, resolution)?; | |
| 1726 | 1752 | session.status = "Saving"; |
| 1727 | 1753 | session.refresh_conflict()?; |
| 1728 | 1754 | if keep_mine { |
| 1729 | session.before = session.section.page(session.space)?; | |
| 1730 | 1755 | self.window.request_redraw(); |
| 1731 | 1756 | Ok(()) |
| 1732 | 1757 | } else { |
| 1733 | self.reload() | |
| 1758 | self.refresh() | |
| 1734 | 1759 | } |
| 1735 | 1760 | } |
| 1736 | 1761 | |
| 1737 | /// Applies what the synchronization thread reported since the last poll. | |
| 1762 | /// Applies what the section reported since the last poll. | |
| 1738 | 1763 | fn synced(&mut self) -> Result<(), Box<dyn Error>> { |
| 1739 | 1764 | let Some(session) = &mut self.session else { |
| 1740 | 1765 | return Ok(()); |
| 1741 | 1766 | }; |
| 1742 | 1767 | let shown = (session.status, session.conflict); |
| 1743 | let mut refreshed = false; | |
| 1744 | let mut stale = false; | |
| 1768 | let mut listed = false; | |
| 1769 | let mut changed = false; | |
| 1770 | let mut rejected = None; | |
| 1745 | 1771 | let mut conflicted = session.conflict; |
| 1746 | for (space, outcome) in session.section.saved() { | |
| 1747 | match outcome { | |
| 1748 | Ok(notebook::session::Save::Queued(_)) => session.status = "Saving", | |
| 1749 | Ok(notebook::session::Save::Unchanged) => {} | |
| 1750 | Ok(notebook::session::Save::Stale) => stale |= space == session.space, | |
| 1751 | Err(error) => { | |
| 1752 | eprintln!("Saving failed: {error}"); | |
| 1753 | session.status = "Not saving"; | |
| 1754 | } | |
| 1755 | } | |
| 1756 | } | |
| 1757 | 1772 | for event in session.section.events() { |
| 1758 | 1773 | use notebook::session::Event; |
| 1759 | 1774 | session.status = match event { |
| 1760 | 1775 | Event::Refreshed => { |
| 1761 | refreshed = true; | |
| 1776 | listed = true; | |
| 1762 | 1777 | continue; |
| 1763 | 1778 | } |
| 1779 | Event::Changed(spaces) => { | |
| 1780 | listed = true; | |
| 1781 | changed |= spaces.contains(&session.space); | |
| 1782 | continue; | |
| 1783 | } | |
| 1784 | Event::Rejected { spaces, error } => { | |
| 1785 | if spaces.contains(&session.space) { | |
| 1786 | rejected = Some(error); | |
| 1787 | } else { | |
| 1788 | eprintln!("Saving failed: {error}"); | |
| 1789 | } | |
| 1790 | "Not saving" | |
| 1791 | } | |
| 1764 | 1792 | Event::Attempt { |
| 1765 | 1793 | status: notebook::EditStatus::Published { .. }, |
| 1766 | 1794 | .. |
| ... | ... | @@ -1780,15 +1808,15 @@ impl State { |
| 1780 | 1808 | } |
| 1781 | 1809 | }; |
| 1782 | 1810 | } |
| 1783 | // Reading the queue waits on the save thread, so only a conflict shown or reported | |
| 1784 | // sends the frame thread there. | |
| 1811 | // Reading the queue waits on the section thread, so only a conflict shown or | |
| 1812 | // reported sends the frame thread there. | |
| 1785 | 1813 | if conflicted { |
| 1786 | 1814 | session.refresh_conflict()?; |
| 1787 | 1815 | } |
| 1788 | if refreshed || shown != (session.status, session.conflict) { | |
| 1816 | if listed || changed || shown != (session.status, session.conflict) { | |
| 1789 | 1817 | self.window.request_redraw(); |
| 1790 | 1818 | } |
| 1791 | if refreshed { | |
| 1819 | if listed { | |
| 1792 | 1820 | session.pages = session.section.pages()?; |
| 1793 | 1821 | if !session |
| 1794 | 1822 | .pages |
| ... | ... | @@ -1797,13 +1825,13 @@ impl State { |
| 1797 | 1825 | { |
| 1798 | 1826 | let first = session.pages.first().ok_or("The section has no pages")?.0; |
| 1799 | 1827 | self.commands.push(Command::OpenPage(first)); |
| 1800 | } else if !session.section.saving() | |
| 1801 | && session.section.page(session.space)? != session.before | |
| 1802 | { | |
| 1803 | self.reload()?; | |
| 1828 | return Ok(()); | |
| 1804 | 1829 | } |
| 1805 | } else if stale { | |
| 1806 | self.reload()?; | |
| 1830 | } | |
| 1831 | if let Some(error) = rejected { | |
| 1832 | self.refused(&error)?; | |
| 1833 | } else if changed { | |
| 1834 | self.refresh()?; | |
| 1807 | 1835 | } |
| 1808 | 1836 | Ok(()) |
| 1809 | 1837 | } |
| ... | ... | @@ -2206,29 +2234,65 @@ fn tabs(notebook: &notebook::session::Notebook) -> Vec<Tab> { |
| 2206 | 2234 | .collect() |
| 2207 | 2235 | } |
| 2208 | 2236 | |
| 2209 | /// The session for `section` showing `space`, or its first page. | |
| 2237 | /// The session for `section` showing `space`, or its first page, and that page. | |
| 2210 | 2238 | fn read_session( |
| 2211 | 2239 | section: notebook::session::Section, |
| 2212 | 2240 | tab: usize, |
| 2213 | 2241 | space: Option<ExGuid>, |
| 2214 | ) -> Result<Session, Box<dyn Error>> { | |
| 2242 | ) -> Result<(Session, Page), Box<dyn Error>> { | |
| 2215 | 2243 | let pages = section.pages()?; |
| 2216 | 2244 | let space = match space { |
| 2217 | 2245 | Some(space) => space, |
| 2218 | 2246 | None => pages.first().ok_or("The section has no pages")?.0, |
| 2219 | 2247 | }; |
| 2220 | let before = section.page(space)?; | |
| 2248 | let page = section.page(space)?; | |
| 2221 | 2249 | let mut session = Session { |
| 2222 | 2250 | section, |
| 2223 | 2251 | tab, |
| 2224 | 2252 | pages, |
| 2225 | 2253 | space, |
| 2226 | before, | |
| 2227 | 2254 | status: "", |
| 2228 | 2255 | conflict: false, |
| 2229 | 2256 | }; |
| 2230 | 2257 | session.refresh_conflict()?; |
| 2231 | Ok(session) | |
| 2258 | Ok((session, page)) | |
| 2259 | } | |
| 2260 | ||
| 2261 | /// The text the editor shows, outline by outline, without hidden field codes. | |
| 2262 | fn page_text(editor: &CanvasEditor) -> String { | |
| 2263 | editor | |
| 2264 | .visible_outlines() | |
| 2265 | .chain(editor.caret_outline()) | |
| 2266 | .map(|outline| { | |
| 2267 | outline | |
| 2268 | .document() | |
| 2269 | .paragraphs() | |
| 2270 | .map(|paragraph| { | |
| 2271 | let mut start = 0; | |
| 2272 | paragraph | |
| 2273 | .spans() | |
| 2274 | .iter() | |
| 2275 | .filter_map(|span| { | |
| 2276 | let run = &paragraph.text()[start..span.end]; | |
| 2277 | start = span.end; | |
| 2278 | (span.format.hidden != Some(true)).then_some(run) | |
| 2279 | }) | |
| 2280 | .collect::<String>() | |
| 2281 | }) | |
| 2282 | .collect::<Vec<_>>() | |
| 2283 | .join("\n") | |
| 2284 | }) | |
| 2285 | .filter(|text| !text.trim().is_empty()) | |
| 2286 | .collect::<Vec<_>>() | |
| 2287 | .join("\n\n") | |
| 2288 | } | |
| 2289 | ||
| 2290 | /// FILETIME now: when an edit happened, which its modification times record. | |
| 2291 | fn filetime() -> u64 { | |
| 2292 | let unix = std::time::SystemTime::now() | |
| 2293 | .duration_since(std::time::UNIX_EPOCH) | |
| 2294 | .unwrap_or_default(); | |
| 2295 | (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100) | |
| 2232 | 2296 | } |
| 2233 | 2297 | |
| 2234 | 2298 | impl App { |
| ... | ... | @@ -2716,3 +2780,199 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 2716 | 2780 | event_loop.run_app(&mut app)?; |
| 2717 | 2781 | app.startup_error.map_or(Ok(()), Err) |
| 2718 | 2782 | } |
| 2783 | ||
| 2784 | #[cfg(test)] | |
| 2785 | mod tests { | |
| 2786 | use super::*; | |
| 2787 | use canvas::document::TextPosition; | |
| 2788 | ||
| 2789 | /// The ops an editing session records reach the section through `apply` and read back | |
| 2790 | /// as the editor's page. | |
| 2791 | #[test] | |
| 2792 | fn recorded_ops_reach_the_section() { | |
| 2793 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); | |
| 2794 | let directory = std::env::temp_dir().join(format!("snowbound-ops-{}", std::process::id())); | |
| 2795 | let _ = std::fs::remove_dir_all(&directory); | |
| 2796 | std::fs::create_dir_all(directory.join("cache")).unwrap(); | |
| 2797 | let file = directory.join("section.one"); | |
| 2798 | std::fs::copy( | |
| 2799 | root.join("corpus/paragraph-edit/before/notebook/synthetic.one"), | |
| 2800 | &file, | |
| 2801 | ) | |
| 2802 | .unwrap(); | |
| 2803 | let section = | |
| 2804 | notebook::session::Section::open(&file, directory.join("cache"), || {}).unwrap(); | |
| 2805 | let mut engine = TextEngine::default(); | |
| 2806 | for (space, ..) in section.pages().unwrap().into_iter().take(3) { | |
| 2807 | let mut editor = | |
| 2808 | CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); | |
| 2809 | let outline = editor.outlines().iter().find(|o| !o.title).unwrap().id; | |
| 2810 | editor.focus_outline(outline).unwrap(); | |
| 2811 | let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into(); | |
| 2812 | editor.select(at(0, 0)).unwrap(); | |
| 2813 | editor.insert(&mut engine, "Typed ").unwrap(); | |
| 2814 | editor.enter(&mut engine, false).unwrap(); | |
| 2815 | editor.delete(&mut engine, true).unwrap(); | |
| 2816 | editor.undo(&mut engine).unwrap(); | |
| 2817 | let ops = editor.take_ops().unwrap(); | |
| 2818 | section | |
| 2819 | .apply( | |
| 2820 | "Test", | |
| 2821 | onestore::op::Edit { | |
| 2822 | at: filetime(), | |
| 2823 | ops: ops | |
| 2824 | .into_iter() | |
| 2825 | .map(|op| onestore::op::Op::Page { space, op }) | |
| 2826 | .collect(), | |
| 2827 | }, | |
| 2828 | ) | |
| 2829 | .unwrap(); | |
| 2830 | let (stored, model) = (section.page(space).unwrap(), editor.page().unwrap()); | |
| 2831 | assert_eq!( | |
| 2832 | Page { | |
| 2833 | title: model.title.clone(), | |
| 2834 | ..stored | |
| 2835 | }, | |
| 2836 | model | |
| 2837 | ); | |
| 2838 | } | |
| 2839 | assert!( | |
| 2840 | !section | |
| 2841 | .events() | |
| 2842 | .iter() | |
| 2843 | .any(|event| matches!(event, notebook::session::Event::Rejected { .. })) | |
| 2844 | ); | |
| 2845 | section.close().unwrap(); | |
| 2846 | std::fs::remove_dir_all(&directory).unwrap(); | |
| 2847 | } | |
| 2848 | ||
| 2849 | use notebook::session::{Event, Section}; | |
| 2850 | ||
| 2851 | /// Edits `editor` made, as one edit of `space`. | |
| 2852 | fn edit(editor: &mut CanvasEditor, space: ExGuid) -> onestore::op::Edit { | |
| 2853 | onestore::op::Edit { | |
| 2854 | at: filetime(), | |
| 2855 | ops: editor | |
| 2856 | .take_ops() | |
| 2857 | .unwrap() | |
| 2858 | .into_iter() | |
| 2859 | .map(|op| onestore::op::Op::Page { space, op }) | |
| 2860 | .collect(), | |
| 2861 | } | |
| 2862 | } | |
| 2863 | ||
| 2864 | /// Polls `section`, keeping what it reports in `seen`, until `done` holds of an event. | |
| 2865 | fn wait(section: &Section, seen: &mut Vec<Event>, done: impl Fn(&Event) -> bool) { | |
| 2866 | let deadline = Instant::now() + std::time::Duration::from_secs(120); | |
| 2867 | loop { | |
| 2868 | let events = section.events(); | |
| 2869 | let finished = events.iter().any(&done); | |
| 2870 | seen.extend(events); | |
| 2871 | if finished { | |
| 2872 | return; | |
| 2873 | } | |
| 2874 | assert!(Instant::now() < deadline, "no such event: {seen:?}"); | |
| 2875 | section.wake(); | |
| 2876 | std::thread::sleep(std::time::Duration::from_millis(50)); | |
| 2877 | } | |
| 2878 | } | |
| 2879 | ||
| 2880 | fn published(event: &Event) -> bool { | |
| 2881 | matches!( | |
| 2882 | event, | |
| 2883 | Event::Attempt { | |
| 2884 | status: notebook::EditStatus::Published { .. }, | |
| 2885 | .. | |
| 2886 | } | |
| 2887 | ) | |
| 2888 | } | |
| 2889 | ||
| 2890 | /// Publishing this machine's edits never reports the page it edits as changed, which | |
| 2891 | /// would reload the editor under the caret; another writer's edit does, and the editor | |
| 2892 | /// takes it in place with the caret where it was. | |
| 2893 | #[test] | |
| 2894 | fn only_another_writer_changes_the_open_page() { | |
| 2895 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); | |
| 2896 | let directory = | |
| 2897 | std::env::temp_dir().join(format!("snowbound-changed-{}", std::process::id())); | |
| 2898 | let _ = std::fs::remove_dir_all(&directory); | |
| 2899 | std::fs::create_dir_all(&directory).unwrap(); | |
| 2900 | let file = directory.join("section.one"); | |
| 2901 | std::fs::copy( | |
| 2902 | root.join("corpus/paragraph-edit/before/notebook/synthetic.one"), | |
| 2903 | &file, | |
| 2904 | ) | |
| 2905 | .unwrap(); | |
| 2906 | let ours = Section::open(&file, directory.join("ours"), || {}).unwrap(); | |
| 2907 | let space = ours.pages().unwrap()[0].0; | |
| 2908 | let mut engine = TextEngine::default(); | |
| 2909 | let mut editor = CanvasEditor::from_page(ours.page(space).unwrap(), &mut engine).unwrap(); | |
| 2910 | let outline = editor.outlines().iter().find(|o| !o.title).unwrap().id; | |
| 2911 | editor.focus_outline(outline).unwrap(); | |
| 2912 | let at = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into(); | |
| 2913 | editor.select(at(0, 0)).unwrap(); | |
| 2914 | let mut seen = Vec::new(); | |
| 2915 | for text in ["One ", "two ", "three "] { | |
| 2916 | editor.insert(&mut engine, text).unwrap(); | |
| 2917 | ours.apply("Ours", edit(&mut editor, space)).unwrap(); | |
| 2918 | wait(&ours, &mut seen, published); | |
| 2919 | } | |
| 2920 | editor.enter(&mut engine, false).unwrap(); | |
| 2921 | editor.delete(&mut engine, true).unwrap(); | |
| 2922 | ours.apply("Ours", edit(&mut editor, space)).unwrap(); | |
| 2923 | wait(&ours, &mut seen, published); | |
| 2924 | for _ in 0..3 { | |
| 2925 | ours.wake(); | |
| 2926 | std::thread::sleep(std::time::Duration::from_millis(500)); | |
| 2927 | seen.extend(ours.events()); | |
| 2928 | } | |
| 2929 | assert!( | |
| 2930 | !seen | |
| 2931 | .iter() | |
| 2932 | .any(|event| matches!(event, Event::Changed(spaces) if spaces.contains(&space))), | |
| 2933 | "{seen:?}" | |
| 2934 | ); | |
| 2935 | ||
| 2936 | let theirs = Section::open(&file, directory.join("theirs"), || {}).unwrap(); | |
| 2937 | let mut other = CanvasEditor::from_page(theirs.page(space).unwrap(), &mut engine).unwrap(); | |
| 2938 | other.focus_outline(outline).unwrap(); | |
| 2939 | let last = other.active_outline().document().paragraphs().count() - 1; | |
| 2940 | let end = other | |
| 2941 | .active_outline() | |
| 2942 | .document() | |
| 2943 | .paragraphs() | |
| 2944 | .last() | |
| 2945 | .unwrap() | |
| 2946 | .text() | |
| 2947 | .encode_utf16() | |
| 2948 | .count() as u32; | |
| 2949 | other.select(at(last, end)).unwrap(); | |
| 2950 | other.insert(&mut engine, " and theirs").unwrap(); | |
| 2951 | theirs.apply("Theirs", edit(&mut other, space)).unwrap(); | |
| 2952 | wait(&theirs, &mut Vec::new(), published); | |
| 2953 | wait( | |
| 2954 | &ours, | |
| 2955 | &mut seen, | |
| 2956 | |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space)), | |
| 2957 | ); | |
| 2958 | ||
| 2959 | let (shown, selection) = (editor.active_outline().id, editor.selection()); | |
| 2960 | editor | |
| 2961 | .refresh(ours.page(space).unwrap(), &mut engine) | |
| 2962 | .unwrap(); | |
| 2963 | assert_eq!( | |
| 2964 | (editor.active_outline().id, editor.selection()), | |
| 2965 | (shown, selection) | |
| 2966 | ); | |
| 2967 | assert!( | |
| 2968 | editor | |
| 2969 | .active_outline() | |
| 2970 | .document() | |
| 2971 | .paragraphs() | |
| 2972 | .any(|paragraph| paragraph.text().ends_with(" and theirs")) | |
| 2973 | ); | |
| 2974 | ours.close().unwrap(); | |
| 2975 | theirs.close().unwrap(); | |
| 2976 | std::fs::remove_dir_all(&directory).unwrap(); | |
| 2977 | } | |
| 2978 | } |
tools/native/page-date.ps1 created+46| ... | ... | @@ -0,0 +1,46 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | & "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost | |
| 5 | $namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' | |
| 6 | $app = New-Object -ComObject OneNote.Application | |
| 7 | $notebookId = '' | |
| 8 | function Save-Page([string]$pageId, [string]$name) { | |
| 9 | $content = '' | |
| 10 | $app.GetPageContent($pageId, [ref]$content, 0, 1) | |
| 11 | [IO.File]::WriteAllText((Join-Path $Root $name), $content, [Text.Encoding]::UTF8) | |
| 12 | } | |
| 13 | try { | |
| 14 | $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0) | |
| 15 | $sectionId = '' | |
| 16 | $app.OpenHierarchy('pictures.one', $notebookId, [ref]$sectionId, 0) | |
| 17 | # Two new pages with OneNote's own title, date and time: one keeps its date, the | |
| 18 | # other has its date and time changed after it was saved once. | |
| 19 | $ids = @() | |
| 20 | foreach ($title in @('Kept date', 'Changed date')) { | |
| 21 | $pageId = '' | |
| 22 | $app.CreateNewPage($sectionId, [ref]$pageId, 0) | |
| 23 | $escaped = [Security.SecurityElement]::Escape($pageId) | |
| 24 | $xml = "<one:Page xmlns:one='$namespace' ID='$escaped'><one:Title><one:OE><one:T>$title</one:T></one:OE></one:Title><one:Outline><one:Position x='72' y='144'/><one:OEChildren><one:OE><one:T>Body of $title</one:T></one:OE></one:OEChildren></one:Outline></one:Page>" | |
| 25 | $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false) | |
| 26 | $ids += $pageId | |
| 27 | } | |
| 28 | $app.SyncHierarchy($notebookId) | |
| 29 | Start-Sleep -Seconds 3 | |
| 30 | Save-Page $ids[1] 'before.xml' | |
| 31 | $escaped = [Security.SecurityElement]::Escape($ids[1]) | |
| 32 | $update = "<one:Page xmlns:one='$namespace' ID='$escaped' dateTime='2024-03-05T14:30:00.000Z'/>" | |
| 33 | [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $update, [Text.Encoding]::UTF8) | |
| 34 | $app.UpdatePageContent($update, [DateTime]::MinValue, 1, $false) | |
| 35 | $app.SyncHierarchy($notebookId) | |
| 36 | Start-Sleep -Seconds 3 | |
| 37 | Save-Page $ids[1] 'after.xml' | |
| 38 | Save-Page $ids[0] 'kept.xml' | |
| 39 | $app.CloseNotebook($notebookId, $false) | |
| 40 | $notebookId = '' | |
| 41 | } finally { | |
| 42 | if ($notebookId) { $app.CloseNotebook($notebookId, $false) } | |
| 43 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 44 | [GC]::Collect() | |
| 45 | [GC]::WaitForPendingFinalizers() | |
| 46 | } |
tools/test_page_date.py created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | from pathlib import Path | |
| 2 | import runpy | |
| 3 | import shutil | |
| 4 | from tempfile import TemporaryDirectory | |
| 5 | import unittest | |
| 6 | import xml.etree.ElementTree as ET | |
| 7 | ||
| 8 | from native_xml import ns | |
| 9 | ||
| 10 | ROOT = Path(__file__).resolve().parent.parent | |
| 11 | FIXTURE = ROOT / 'corpus/page-date' | |
| 12 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 13 | ||
| 14 | ||
| 15 | def native_pages(notebook, capture): | |
| 16 | with TemporaryDirectory() as temporary: | |
| 17 | read = Path(temporary) / 'read' | |
| 18 | shutil.copytree(capture / 'read', read) | |
| 19 | compare(notebook, read) | |
| 20 | pages = (ET.parse(path).getroot() for path in sorted((capture / 'read').glob('page-*.xml'))) | |
| 21 | return {page.get('name'): page for page in pages} | |
| 22 | ||
| 23 | ||
| 24 | class PageDateTest(unittest.TestCase): | |
| 25 | def test_onenote_dates_a_page_through_its_creation_time(self): | |
| 26 | pages = native_pages(FIXTURE / 'native/notebook', FIXTURE / 'native') | |
| 27 | self.assertEqual(pages['Changed date'].get('dateTime'), '2024-03-05T14:30:00.000Z') | |
| 28 | self.assertNotEqual(pages['Kept date'].get('dateTime'), '2024-03-05T14:30:00.000Z') | |
| 29 | ||
| 30 | def test_a_rust_dated_page_reads_back_with_its_new_date(self): | |
| 31 | pages = native_pages(FIXTURE / 'candidate', FIXTURE / 'cold') | |
| 32 | self.assertEqual(pages['Kept date'].get('dateTime'), '2025-07-04T16:45:00.000Z') | |
| 33 | self.assertEqual(pages['Changed date'].get('dateTime'), '2024-03-05T14:30:00.000Z') | |
| 34 | title = pages['Kept date'].find('one:Title/one:OE/one:T', ns) | |
| 35 | self.assertEqual(title.text, 'Kept date') | |
| 36 | ||
| 37 | ||
| 38 | if __name__ == '__main__': | |
| 39 | unittest.main() |