authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-26 12:32:57-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:49-07:00
loged9e69d6d927206decb398664df5f135e7244603
treea7035fa2e8cf0c5f4d404c706ea49e38c502ce51
parent4292e4deb721a9526b18e9d9385d225c033b245c
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the editor records ops and the app saves them

Every editor change records the ops it made at edit time, and undo records the ops its inverse produces, restoring original identities. The app hands one Edit per frame to the notebook session, so the per-edit whole-page read and compare is gone; a keystroke on a 3000-paragraph page records in ~18 µs. Across ~56,000 differential steps the ops store the same page as the old whole-page path. Our own publishes never refresh the open page; a real remote change refreshes it in place, keeping caret, selection, scroll, drafts and the undo history it did not reach. A rejected edit restores the saved page and puts the text on the clipboard. Page dates save as OneNote 2010 stores them (new corpus/page-date row), page reads never wait behind a rebase, and the iOS host drains its edits. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5

74 files changed, 3857 insertions(+), 362 deletions(-)

Cargo.lock+2
......@@ -1560,6 +1560,7 @@ dependencies = [
15601560 "onestore",
15611561 "parley",
15621562 "pollster",
1563 "serde_json",
15631564 "wgpu",
15641565]
15651566
......@@ -1657,6 +1658,7 @@ dependencies = [
16571658name = "notebook"
16581659version = "0.1.0"
16591660dependencies = [
1661 "libc",
16601662 "onestore",
16611663 "rusqlite",
16621664 "serde",
apps/ios/Snowbound/CanvasView.swift+9
......@@ -241,6 +241,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
241241 inputDelegate?.textWillChange(self)
242242 }
243243 let changed = change()
244 dropEdits()
244245 if external {
245246 inputDelegate?.textDidChange(self)
246247 inputDelegate?.selectionDidChange(self)
......@@ -251,6 +252,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
251252 }
252253 }
253254
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
254262 private func revealCaret() {
255263 guard let handle, isFirstResponder else { return }
256264 var range: [UInt32] = [0, 0]
......@@ -354,6 +362,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
354362 func unmarkText() {
355363 guard let handle else { return }
356364 sb_unmark(handle)
365 dropEdits()
357366 dirty = true
358367 }
359368
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"
6the `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
8stored sets the page metadata's `TopologyCreationTimeStamp` (`0x18001c65`), rewrites the
9date and time fields' text (`TextExtendedAscii`, `0x1c003498`, in the clone's Pacific
10time: "Tuesday, March 05, 2024", "7:30 AM"), and gives the two fields' elements the
11change time as `CreationTimeStamp` (`0x14001d09`) and `LastModifiedTime`, with the
12date outline's `LastModifiedTime`. The title, the page and the section's page metadata
13keep 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,
17showing "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
3AAAAAElFTkSuQmCC
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
5AAAAAElFTkSuQmCC
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 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($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))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-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 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([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'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($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}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
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
3AAAAAElFTkSuQmCC
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
5AAAAAElFTkSuQmCC
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 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-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 = ''
8function 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}
13try {
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 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($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))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-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 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([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'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($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}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
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::{
77use draw::edit::{self, Movement, SelectionUnit};
88use onestore::ExGuid;
99use onestore::document::{Format, Kind};
10use onestore::op::PageOp;
1011use onestore::page::text::{EditError, Paragraph};
1112use onestore::page::{
1213 Definition, Outline, Page, PageObject, PageParagraph, ParagraphContent, Title,
......@@ -26,6 +27,7 @@ pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;
2627#[cfg(test)]
2728mod evidence;
2829mod format;
30mod ops;
2931pub(crate) mod page;
3032mod table;
3133pub use format::{Alignment, FormatState, Formatting, NoteTag, Toggle};
......@@ -92,6 +94,12 @@ pub struct CanvasEditor {
9294 preferred_x: Option<f32>,
9395 /// Formatting chosen at a caret for the text typed there next, until an edit.
9496 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>)>,
95103}
96104
97105/// Imported page state the editable content does not carry.
......@@ -660,10 +668,13 @@ impl CanvasEditor {
660668 composition: None,
661669 preferred_x: None,
662670 pending: None,
671 ops: Ok(Vec::new()),
672 stored: None,
663673 })
664674 }
665675
666676 pub fn from_page(mut page: Page, engine: &mut TextEngine) -> Result<Self, EditorError> {
677 let stored = page.clone();
667678 let page::Import {
668679 objects,
669680 mut outlines,
......@@ -706,9 +717,175 @@ impl CanvasEditor {
706717 if !editor.object_layouts().all(|(id, _)| ids.insert(id)) {
707718 return Err(EditError::InvalidStructure.into());
708719 }
720 editor.stored = Some((stored, Vec::new()));
709721 Ok(editor)
710722 }
711723
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
712889 /// Rebuilds the stored page, restoring the title areas and read-only objects import split up.
713890 pub fn page(&self) -> Result<Page, EditorError> {
714891 let mut objects: Vec<PageObject> = Vec::new();
......@@ -781,18 +958,7 @@ impl CanvasEditor {
781958 .iter()
782959 .flat_map(|outline| descendants(&outline.paragraphs, None))
783960 {
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));
796962 }
797963 }
798964 let mut definitions = self.definitions.clone();
......@@ -1027,6 +1193,8 @@ impl CanvasEditor {
10271193 composition: None,
10281194 preferred_x: None,
10291195 pending: None,
1196 ops: Ok(Vec::new()),
1197 stored: None,
10301198 })
10311199 }
10321200
......@@ -1055,6 +1223,7 @@ impl CanvasEditor {
10551223 self.undo
10561224 .push(History::Date(Box::new(self.date.replace(updated).unwrap())));
10571225 self.redo.clear();
1226 self.record(Ok(self.date_ops()));
10581227 Ok(true)
10591228 }
10601229
......@@ -1261,6 +1430,7 @@ impl CanvasEditor {
12611430 self.redo.clear();
12621431 self.active = Focus::Outline(self.outlines.len());
12631432 self.outlines.push(outline);
1433 self.record(self.outline_ops(id, None));
12641434 self.preferred_x = None;
12651435 Ok(id)
12661436 }
......@@ -1301,13 +1471,18 @@ impl CanvasEditor {
13011471 .position(|outline| outline.id == id)
13021472 .unwrap();
13031473 let layout = &mut self.outlines[index].layout;
1474 let previous = [layout.x, layout.y];
13041475 self.undo.push(History::Position {
13051476 outline: id,
1306 position: [layout.x, layout.y],
1477 position: previous,
13071478 });
13081479 self.redo.clear();
13091480 layout.x = Some(position[0]);
13101481 layout.y = Some(position[1]);
1482 self.record(self.placement_ops(&Placement {
1483 id,
1484 position: previous,
1485 }));
13111486 self.active = Focus::Outline(index);
13121487 self.preferred_x = None;
13131488 Ok(())
......@@ -1429,6 +1604,8 @@ impl CanvasEditor {
14291604 layout: previous,
14301605 });
14311606 self.redo.clear();
1607 let image = self.image(id).unwrap();
1608 self.record(Ok(ops::picture_layout(image)));
14321609 Ok(())
14331610 }
14341611
......@@ -1531,6 +1708,7 @@ impl CanvasEditor {
15311708 image: Some(Box::new(image)),
15321709 });
15331710 self.redo.clear();
1711 self.record(Ok(vec![PageOp::Delete { object: id }]));
15341712 Ok(())
15351713 }
15361714
......@@ -1559,6 +1737,11 @@ impl CanvasEditor {
15591737 let id = resized.id;
15601738 *self.active_outline_mut() = resized;
15611739 if self.caret_outline().is_none() {
1740 self.record(ops::layout_ops(
1741 id,
1742 &previous,
1743 &self.active_outline().layout,
1744 ));
15621745 self.undo.push(History::Layout {
15631746 outline: id,
15641747 layout: previous,
......@@ -2669,7 +2852,11 @@ impl CanvasEditor {
26692852 return Err((history, EditError::InvalidRange.into()));
26702853 }
26712854 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 }
26732860 History::Draft {
26742861 outlines,
26752862 index,
......@@ -2700,13 +2887,12 @@ impl CanvasEditor {
27002887 None
27012888 };
27022889 let outline = outlines[usize::from(!restore_caret)].clone();
2703 if self
2890 let id = outline.id;
2891 let stored = self
27042892 .outlines
27052893 .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));
27102896 self.active = if outline.is_empty() {
27112897 Focus::Caret {
27122898 outline: Box::new(outline),
......@@ -2722,6 +2908,7 @@ impl CanvasEditor {
27222908 None => Focus::Outline(index),
27232909 }
27242910 };
2911 self.record(self.outline_ops(id, stored.as_ref()));
27252912 History::Draft {
27262913 outlines,
27272914 index,
......@@ -2750,6 +2937,7 @@ impl CanvasEditor {
27502937 return Err((History::Text { outline, change }, error));
27512938 }
27522939 };
2940 self.record(self.change_ops(&self.outlines[index], &inverse));
27532941 History::Text {
27542942 outline,
27552943 change: Box::new(inverse),
......@@ -2762,13 +2950,17 @@ impl CanvasEditor {
27622950 .position(|item| item.id == outline)
27632951 .unwrap();
27642952 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];
27692954 [layout.x, layout.y] = position;
27702955 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 }
27722964 }
27732965 History::Layout { outline, layout } => {
27742966 let index = self
......@@ -2793,25 +2985,46 @@ impl CanvasEditor {
27932985 };
27942986 resized.selection = self.outlines[index].selection;
27952987 self.outlines[index] = resized;
2988 self.record(ops::layout_ops(
2989 outline,
2990 &previous,
2991 &self.outlines[index].layout,
2992 ));
27962993 self.active = Focus::Outline(index);
27972994 History::Layout {
27982995 outline,
27992996 layout: previous,
28002997 }
28012998 }
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 }
28063008 History::Picture { index, image } => History::Picture {
28073009 index,
28083010 image: match image {
28093011 Some(image) => {
28103012 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));
28113021 None
28123022 }
28133023 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 }
28153028 _ => unreachable!(),
28163029 },
28173030 },
......@@ -2847,6 +3060,7 @@ impl CanvasEditor {
28473060 }
28483061 };
28493062 let outline = Box::new(self.outlines.remove(index));
3063 self.record(Ok(vec![PageOp::Delete { object: outline.id }]));
28503064 self.active = next_focus;
28513065 History::Insert {
28523066 index,
......@@ -2892,6 +3106,7 @@ impl CanvasEditor {
28923106 } => {
28933107 let id = outline.id;
28943108 self.outlines.insert(index, *outline);
3109 self.record(self.outline_ops(id, None));
28953110 self.active = Focus::Outline(index);
28963111 History::Remove { outline: id, focus }
28973112 }
......@@ -3152,6 +3367,7 @@ impl CanvasEditor {
31523367 } else {
31533368 self.outlines[index] = *outline;
31543369 }
3370 self.record(self.outline_ops(versions[0].id, Some(&versions[0])));
31553371 self.undo.push(History::Draft {
31563372 outlines: versions,
31573373 index,
......@@ -3173,8 +3389,9 @@ impl CanvasEditor {
31733389 };
31743390 let mut source = outline.snapshot();
31753391 source.paragraphs = change.edit.replacement;
3392 let id = outline.id;
31763393 self.undo.push(History::Remove {
3177 outline: outline.id,
3394 outline: id,
31783395 focus: RestoreFocus::Caret {
31793396 source: Box::new(source),
31803397 selection: change.selection,
......@@ -3182,11 +3399,13 @@ impl CanvasEditor {
31823399 },
31833400 });
31843401 self.outlines.insert(index, *outline);
3402 self.record(self.outline_ops(id, None));
31853403 } else if self.active_outline().is_empty() && !self.active_outline().title {
31863404 let Focus::Outline(index) = self.active else {
31873405 unreachable!()
31883406 };
31893407 let outline = self.outlines.remove(index);
3408 self.record(Ok(vec![PageOp::Delete { object: outline.id }]));
31903409 debug_assert_eq!(change.edit.range, 0..1);
31913410 let mut source = outline.snapshot();
31923411 source.paragraphs = change.edit.replacement;
......@@ -3205,6 +3424,7 @@ impl CanvasEditor {
32053424 index,
32063425 };
32073426 } else {
3427 self.record(self.change_ops(self.active_outline(), &change));
32083428 self.undo.push(History::Text {
32093429 outline: self.active_outline().id,
32103430 change: Box::new(change),
......@@ -3311,6 +3531,59 @@ impl CanvasEditor {
33113531 }
33123532}
33133533
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.
3537fn 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
33143587fn inserted_position(
33153588 mut position: TextPosition,
33163589 text: &str,
......@@ -8360,4 +8633,65 @@ mod tests {
83608633 assert_eq!(body_text(&reread, body).as_deref(), Some(edited.as_str()));
83618634 assert_ne!(body_text(&source, body).as_deref(), Some(edited.as_str()));
83628635 }
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 }
83638697}
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
4use super::*;
5use onestore::op;
6use onestore::{OutlineEdit, document::Layout};
7
8type Lowered = Result<Vec<PageOp>, onestore::Error>;
9
10fn refused(message: &'static str) -> onestore::Error {
11 onestore::Error { offset: 0, message }
12}
13
14fn 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.
19pub(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.
34fn 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.
51fn 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.
176fn 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.
185fn 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.
222pub(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.
248pub(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
256impl 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)]
460mod 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 {
213213 Ok((scene, editor))
214214 }
215215
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
216243 /// Turns page pictures the renderer cannot decode into placeholders; their stored data
217244 /// is kept.
218245 fn mark_unavailable(
crates/canvas/src/gpu/page/background.rs+5
......@@ -330,6 +330,11 @@ struct Variant {
330330}
331331
332332impl 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
333338 pub fn recognise(picture: &onestore::page::Image) -> Option<Self> {
334339 let digest: String = Sha256::digest(picture.bytes.as_deref()?)
335340 .iter()
crates/canvas/src/gpu/page/picture.rs+5
......@@ -31,6 +31,11 @@ pub(super) struct Picture {
3131
3232impl Picture {
3333 /// 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
3439 pub fn new(bytes: &Arc<[u8]>) -> Option<Self> {
3540 Some(Self {
3641 native: RasterImage::measure(bytes).ok()?,
crates/canvas/src/interaction/mod.rs+15
......@@ -257,6 +257,21 @@ impl PageView {
257257 self.object_focus = None;
258258 }
259259
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
260275 /// Shows another page as OneNote opens one, keeping the zoom.
261276 pub fn open(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) {
262277 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
7use canvas::{
8 document::TextPosition,
9 editor::{Alignment, CanvasEditor, Formatting, NoteTag, Toggle},
10 layout::TextEngine,
11};
12use draw::edit::Movement;
13use onestore::{
14 Arena, ExGuid, Section,
15 op::{Edit, Op},
16 page::Page,
17};
18use std::{collections::BTreeMap, path::Path};
19
20const 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)]
39enum 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
84const 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
129struct Random(u64);
130
131impl 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.
141fn 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.
154fn 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.
170fn 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)]
377enum 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.
392struct Stored<'a> {
393 section: Section<'a>,
394 space: ExGuid,
395 at: u64,
396}
397
398impl 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.
510fn 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)]
551struct Tally {
552 outcomes: BTreeMap<(Kind, Outcome), usize>,
553 examples: BTreeMap<String, String>,
554}
555
556fn 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]
616fn 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).
55
66use canvas::{
77 document::TextPosition,
......@@ -9,7 +9,7 @@ use canvas::{
99 layout::TextEngine,
1010};
1111use onestore::{
12 ExGuid, PreparedEdit, RevisionIndex, Store,
12 Arena, ExGuid, PreparedEdit, RevisionIndex, Section, Store,
1313 document::Document,
1414 page::{Outline, Page, PageObject, PageParagraph, ParagraphContent},
1515};
......@@ -334,26 +334,43 @@ fn sweep(path: &Path, random: &mut Random, engine: &mut TextEngine, tally: &mut
334334 )
335335 .unwrap();
336336 }
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 };
337362 let failure = match PreparedEdit::page(&section, space, &after, "Sweep") {
338363 Err(error) => Some(error.message.to_owned()),
339364 Ok(saved) => match pages(saved.as_bytes())
340365 .into_iter()
341366 .find(|(id, _)| *id == space)
342367 {
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,
352369 Some((_, reread)) => Some(difference(&after, &reread)),
353370 None => Some("the page does not reread".into()),
354371 },
355372 };
356 if let Some(failure) = failure {
373 for failure in failure.into_iter().chain(ops) {
357374 tally.failures.entry(failure).or_default().push(format!(
358375 "{plan:?} on {:?} in {}",
359376 page.title,
crates/mobile/Cargo.toml+1
......@@ -15,3 +15,4 @@ onestore = { path = "../onestore" }
1515wgpu.workspace = true
1616parley.workspace = true
1717pollster = "0.4"
18serde_json = "1"
crates/mobile/include/snowbound.h+1
......@@ -22,6 +22,7 @@ void sb_view_content(View *view, float bounds[4]);
2222void sb_view_set_transform(View *view, float zoom, float x, float y);
2323bool sb_view_tap(View *view, float x, float y);
2424bool sb_view_in_active_text(const View *view, float x, float y);
25char *sb_view_edit(View *view);
2526
2627uint32_t sb_text_length(const View *view);
2728char *sb_text(const View *view, uint32_t start, uint32_t end);
crates/mobile/src/lib.rs+53-7
......@@ -14,7 +14,12 @@ use canvas::{
1414 layout::TextEngine,
1515};
1616use draw::edit::{Key, NamedKey, SelectionUnit};
17use onestore::{RevisionIndex, Store, document::Document, page::Page};
17use onestore::{
18 ExGuid, RevisionIndex, Store,
19 document::Document,
20 op::{Edit, Op},
21 page::Page,
22};
1823use parley::{Affinity, BoundingBox};
1924use std::{
2025 error::Error,
......@@ -40,7 +45,8 @@ const COLORS: TextColors = TextColors {
4045};
4146
4247pub struct Section {
43 pages: Vec<Page>,
48 /// Each page with the object space holding it.
49 pages: Vec<(ExGuid, Page)>,
4450 titles: Vec<CString>,
4551}
4652
......@@ -52,11 +58,11 @@ impl Section {
5258 let pages = document
5359 .pages()?
5460 .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>>()?;
5763 let titles = pages
5864 .iter()
59 .map(|page| CString::new(page.title.replace('\0', "")))
65 .map(|(_, page)| CString::new(page.title.replace('\0', "")))
6066 .collect::<std::result::Result<_, _>>()?;
6167 Ok(Self { pages, titles })
6268 }
......@@ -74,6 +80,8 @@ impl Wake for Frame {
7480
7581pub struct View {
7682 page: PageView,
83 /// The object space of the page shown, which its edits name.
84 space: ExGuid,
7785 surface: wgpu::Surface<'static>,
7886 config: wgpu::SurfaceConfiguration,
7987 renderer: draw::Renderer,
......@@ -89,7 +97,12 @@ fn moved(response: Response) -> bool {
8997}
9098
9199impl 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> {
93106 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
94107 // SAFETY: the host passes a live CAMetalLayer that outlives the view.
95108 let surface = unsafe {
......@@ -122,6 +135,7 @@ impl View {
122135 scale,
123136 Duration::from_millis(350),
124137 ),
138 space,
125139 surface,
126140 config,
127141 renderer,
......@@ -129,6 +143,27 @@ impl View {
129143 })
130144 }
131145
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
132167 /// Device pixels per host point.
133168 fn display_scale(&self) -> f32 {
134169 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 {
515550 .map_or(std::ptr::null_mut(), CString::into_raw)
516551}
517552
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)]
557pub 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
518564/// # 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.
520566#[unsafe(no_mangle)]
521567pub unsafe extern "C" fn sb_string_free(text: *mut c_char) {
522568 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));
3838# }
3939```
4040
41Page reads never wait for the sync thread: a rebase or reread of the queue runs on a
42new thread while the current one keeps answering `page` and `pages` from the section as
43it was, then hands its requests over; edits sent meanwhile apply to the rebuilt section.
44
4145Share one `Replica` between threads. Keep the cache on a local filesystem: the
4246connection holds exclusive ownership between transactions, and a second open fails
4347busy. 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
5use notebook::{Remote, Replica, session::Section};
6use 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};
15use std::{
16 io,
17 time::{Duration, Instant},
18};
19
20/// A section holding one page of `count` paragraphs.
21fn 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.
75fn 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
85struct Memory(Vec<u8>);
86
87impl 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
105impl 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
120fn 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::{
1616 io,
1717 path::Path,
1818 sync::{Arc, Mutex, MutexGuard, mpsc},
19 thread::JoinHandle,
2019 time::Duration,
2120};
2221
......@@ -97,8 +96,7 @@ pub enum Resolution {
9796/// exclusive connection retains ownership between local transactions.
9897pub struct Replica {
9998 connection: Arc<Mutex<Connection>>,
100 working: Option<mpsc::Sender<working::Request>>,
101 thread: Option<JoinHandle<()>>,
99 section: Arc<working::Thread>,
102100 synchronization: Mutex<()>,
103101 worker: Arc<Mutex<std::sync::Weak<worker::Signal>>>,
104102 /// The section's root object space, which names the document.
......@@ -182,11 +180,10 @@ impl Replica {
182180 fn start(connection: Connection) -> Result<Self> {
183181 let connection = Arc::new(Mutex::new(connection));
184182 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))?;
186184 Ok(Self {
187185 connection,
188 working: Some(sender),
189 thread: Some(thread),
186 section,
190187 synchronization: Mutex::new(()),
191188 worker,
192189 root,
......@@ -199,10 +196,7 @@ impl Replica {
199196
200197 /// Hands `request` to the section thread.
201198 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)
206200 }
207201
208202 /// Asks the section thread and waits for its answer.
......@@ -257,7 +251,8 @@ impl Replica {
257251 /// The section image the queued edits leave, the unsealed ones sealed as one more
258252 /// revision whose identities differ per call: O(section), for tests and diagnostics.
259253 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()?)
261256 }
262257
263258 /// The section file's identity, which internal links name as `section-id`.
......@@ -279,10 +274,7 @@ impl Replica {
279274
280275impl Drop for Replica {
281276 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();
286278 }
287279}
288280
crates/notebook/src/merge.rs+5
......@@ -532,6 +532,11 @@ impl Diff {
532532 PageOp::Link { text, .. } | PageOp::Equation { text, .. } => {
533533 require(self.region(*text)?.is_none())?;
534534 }
535 PageOp::Date { fields, .. } => {
536 for (text, _) in fields.iter() {
537 require(self.region(*text)?.is_none())?;
538 }
539 }
535540 PageOp::Split {
536541 text, at, right, ..
537542 } => {
crates/notebook/src/working.rs+391-188
......@@ -44,8 +44,9 @@ pub(crate) enum Request {
4444 Pages {
4545 reply: Reply<Vec<(ExGuid, String, u32)>>,
4646 },
47 Image {
48 reply: Reply<Vec<u8>>,
47 /// Answers once the edits before it are written.
48 Flush {
49 reply: Reply<()>,
4950 },
5051 /// Seals the open batch, when no sealed batch waits for publication, recording the
5152 /// attempt to publish it.
......@@ -62,8 +63,15 @@ pub(crate) enum Request {
6263 Reopen {
6364 reply: Reply<()>,
6465 },
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,
6570}
6671
72/// The request channel and the requests held while the section was rebuilt.
73type Takeover = (mpsc::Receiver<Request>, VecDeque<Request>);
74
6775/// A sealed batch: the transaction publishing it, or none when its edits changed nothing.
6876pub(crate) struct Sealed {
6977 pub batch: i64,
......@@ -71,7 +79,7 @@ pub(crate) struct Sealed {
7179}
7280
7381/// 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.
7583pub(crate) struct Resolve {
7684 pub first: u64,
7785 pub space: ExGuid,
......@@ -98,34 +106,88 @@ impl Request {
98106 Self::Save { reply, .. } => reply(Err(error())),
99107 Self::Page { reply, .. } => reply(Err(error())),
100108 Self::Pages { reply } => reply(Err(error())),
101 Self::Image { reply } => reply(Err(error())),
109 Self::Flush { reply } => reply(Err(error())),
102110 Self::Seal { reply } => reply(Err(error())),
103111 Self::Rebase { reply, .. } => reply(Err(error())),
104112 Self::Reopen { reply } => reply(Err(error())),
113 Self::Handover(_) | Self::Resume => {}
105114 }
106115 }
107116}
108117
109118type Worker = Arc<Mutex<Weak<Signal>>>;
110119
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.
124pub(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
132impl 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
111169/// Starts the section thread once the queue opens.
112170pub(crate) fn spawn(
113171 connection: Arc<Mutex<Connection>>,
114172 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 });
117181 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)))?;
121183 match opened.recv() {
122 Ok(Ok(root)) => Ok((sender, thread, root)),
184 Ok(Ok(root)) => Ok((shared, root)),
123185 Ok(Err(error)) => {
124 let _ = thread.join();
186 shared.stop();
125187 Err(error)
126188 }
127189 Err(_) => {
128 let _ = thread.join();
190 shared.stop();
129191 Err(io::Error::other("The section thread panicked").into())
130192 }
131193 }
......@@ -134,28 +196,24 @@ pub(crate) fn spawn(
134196enum Next {
135197 Stop,
136198 Reopen,
199 Handover(mpsc::SyncSender<Takeover>, VecDeque<Request>),
137200}
138201
139202fn 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>>>,
144207) {
145 let mut ready = Some(ready);
146 let mut backlog = VecDeque::new();
147208 loop {
148209 let arena = Arena::default();
149 let working = match Working::open(&arena, connection) {
210 let working = match Working::open(&arena, &shared.connection) {
150211 Ok(working) => working,
151212 Err(error) => {
152 let message = error.to_string();
153213 if let Some(ready) = ready.take() {
154214 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);
159217 }
160218 return;
161219 }
......@@ -163,9 +221,75 @@ fn run(
163221 if let Some(ready) = ready.take() {
164222 let _ = ready.send(Ok(working.section.root()));
165223 }
166 match working.serve(connection, worker, requests, &mut backlog) {
224 match working.serve(&shared, &requests, &mut backlog) {
167225 Next::Stop => return,
168226 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.
236fn 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.
244enum 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.
257fn 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));
169293 }
170294 }
171295}
......@@ -214,11 +338,14 @@ impl<'a> Working<'a> {
214338
215339 fn serve(
216340 mut self,
217 connection: &Mutex<Connection>,
218 worker: &Worker,
341 shared: &Arc<Thread>,
219342 requests: &mpsc::Receiver<Request>,
220343 backlog: &mut VecDeque<Request>,
221344 ) -> 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;
222349 loop {
223350 let first = match backlog.pop_front() {
224351 Some(request) => request,
......@@ -231,7 +358,28 @@ impl<'a> Working<'a> {
231358 burst.extend(backlog.drain(..));
232359 burst.extend(requests.try_iter());
233360 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 }
235383 let reopen = match request {
236384 Request::Apply {
237385 author,
......@@ -326,16 +474,20 @@ impl<'a> Working<'a> {
326474 reply(self.section.pages().map_err(Into::into));
327475 false
328476 }
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(())
333487 } 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
339491 }
340492 Request::Seal { reply } => {
341493 if !self.flush(connection, worker, &mut accepted) {
......@@ -352,29 +504,61 @@ impl<'a> Working<'a> {
352504 }
353505 Request::Rebase {
354506 image,
355 resolve,
507 mut resolve,
356508 reply,
357509 } => {
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 }
368549 }
369550 }
370551 Request::Reopen { reply } => {
371552 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
374557 }
558 Request::Handover(_) | Request::Resume => false,
375559 };
376560 if reopen {
377 backlog.extend(burst);
561 backlog.extend(burst.drain(..).chain(later.drain(..)));
378562 return Next::Reopen;
379563 }
380564 }
......@@ -384,6 +568,30 @@ impl<'a> Working<'a> {
384568 }
385569 }
386570
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
387595 /// Applies an edit, keeping it to be written with the burst. A refused edit is answered
388596 /// here; `Err(true)` when it failed part way and the section must be reread.
389597 fn accept(
......@@ -536,150 +744,145 @@ impl<'a> Working<'a> {
536744 self.touched.clear();
537745 Ok(Some(Sealed { batch, transaction }))
538746 }
747}
539748
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.
750fn 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())
559775 }
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)?)
569776 };
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)?],
610828 )?;
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)?;
620833 }
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)?;
644851 }
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", [])?;
678857 }
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).
882fn 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())
683886}
684887
685888enum 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_
115115 };
116116 assert_same(section.page(space).unwrap(), &after);
117117 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 }
119124 assert_same(stored_page(&file, space), &after);
120125 assert!(matches!(
121126 section.status(id).unwrap(),
crates/onestore/src/edit.rs+65-6
......@@ -53,6 +53,58 @@ pub(crate) fn text_changes(
5353 object: ExGuid,
5454 range: Range<u32>,
5555 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.
62pub(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
102fn rewrite(
103 active: &ActivePage<'_>,
104 object: ExGuid,
105 range: Range<u32>,
106 replacement: &str,
107 field: bool,
56108) -> Result<Changes, Error> {
57109 let invalid = |message| Error { offset: 0, message };
58110 let raw = &active.live.revision;
......@@ -60,7 +112,12 @@ pub(crate) fn text_changes(
60112 return Err(invalid("Object is not reachable in the active revision"));
61113 }
62114 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 };
64121 let node = revision
65122 .nodes
66123 .get(&object)
......@@ -74,10 +131,12 @@ pub(crate) fn text_changes(
74131 else {
75132 return Err(invalid("Select a rich-text object"));
76133 };
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 }));
81140 }
82141 let selected = runs
83142 .iter()
......@@ -103,7 +162,7 @@ pub(crate) fn text_changes(
103162 let properties = PropertySets::parse(blob)?;
104163 if properties.sets[0]
105164 .iter()
106 .any(|p| p.id == 0x88001cde || p.id == 0x24003458)
165 .any(|p| (p.id == 0x88001cde && !field) || p.id == 0x24003458)
107166 {
108167 return Err(invalid(
109168 "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> {
326326 self.target(*text)?;
327327 self.write(|page| content::equation_changes(page, *text, math))
328328 }
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 }
329351 PageOp::Insert {
330352 container,
331353 before,
crates/onestore/src/op/lower.rs+25-3
......@@ -236,8 +236,8 @@ pub(crate) fn validate(
236236 old: &View<'_>,
237237 new: &View<'_>,
238238) -> 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"));
241241 }
242242 for (id, definition) in &after.definitions {
243243 if before
......@@ -806,6 +806,7 @@ impl Lowering {
806806 self.delete(old, new, &consumed)?;
807807 self.levels(new)?;
808808 self.equations(new)?;
809 self.date(before, after)?;
809810 self.text(new)?;
810811 self.styles(after, new)?;
811812 self.lists(after, new)?;
......@@ -832,7 +833,10 @@ impl Lowering {
832833 for paragraph in document {
833834 if old.paragraphs.contains_key(&paragraph.id) {
834835 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 {
836840 let container = new.container[&paragraph.id];
837841 let at = new.children[&container]
838842 .iter()
......@@ -1542,6 +1546,24 @@ impl Lowering {
15421546 Ok(())
15431547 }
15441548
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
15451567 fn text(&mut self, new: &View<'_>) -> Result<(), Error> {
15461568 for (id, paragraph) in &new.paragraphs {
15471569 let Some(text) = paragraph.text() else {
crates/onestore/src/op/mod.rs+9
......@@ -22,6 +22,9 @@ pub(crate) mod table;
2222mod tests;
2323
2424pub 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.
27pub use model::apply as predict;
2528
2629/// Property identifiers with their encoded values.
2730pub(crate) type Values = Vec<(u32, Vec<u8>)>;
......@@ -71,6 +74,12 @@ pub enum PageOp {
7174 },
7275 /// Rewrites an equation's linear text and spans whole.
7376 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 },
7483 /// Inserts paragraphs before a direct child of `container`, or last. Children follow
7584 /// their parents in `paragraphs`; levels are absolute. Text keeps its spans' formats;
7685 /// 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)> {
203203 })
204204}
205205
206/// The text of the title's date and time fields, in order.
207pub(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.
222pub(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
206229fn text_mut(page: &mut Page, text: ExGuid) -> Result<&mut TextObject, Error> {
207230 let (path, index) = text_holder(page, text).ok_or_else(|| invalid("Text is not on the page"))?;
208231 Ok(list_at(page, &path)[index].text_mut().unwrap())
......@@ -547,7 +570,7 @@ fn automatic(page: &mut Page) {
547570}
548571
549572/// Applies `op` to `page` as reading the page back after `Section::apply` would show it.
550pub(crate) fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> {
573pub fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> {
551574 interpret(page, op)?;
552575 // A page lists the definitions its paragraphs reference.
553576 let mut referenced = BTreeSet::new();
......@@ -569,6 +592,17 @@ pub(crate) fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> {
569592
570593fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
571594 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 }
572606 PageOp::Text { text, range, with } => {
573607 let object = text_mut(page, *text)?;
574608 object.text = replace_text(&object.text, range.clone(), with)?;
crates/onestore/src/op/tests.rs+98
......@@ -2148,3 +2148,101 @@ fn op_gate_candidate() {
21482148 std::fs::write(directory.join("expected-count.txt"), count.to_string()).unwrap();
21492149 println!("{} imported pages, {edits} lowered edits, {count} pages", imported.len());
21502150}
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.
2155const 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.
2158fn 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.
2182fn 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.
2190const 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]
2195fn 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> {
142142 let old = View::new(before, false)?;
143143 let new = View::new(after, false)?;
144144 validate(before, after, &old, &new)?;
145 if after.created != before.created {
146 return Err(invalid("Page creation time cannot be edited"));
147 }
145148 for id in new.outlines.keys().chain(new.paragraphs.keys()) {
146149 if !old.outlines.contains_key(id)
147150 && !old.paragraphs.contains_key(id)
crates/snowbound/src/macos.rs+14
......@@ -458,6 +458,20 @@ pub fn discard_changes() -> bool {
458458 }
459459}
460460
461/// Tells the user an edit could not be stored and where their text went.
462pub 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
461475/// End AppKit preedit after a canvas action commits or cancels the composition.
462476pub fn clear_marked_text(window: &Window) {
463477 MainThreadMarker::new().expect("Text input belongs to the main thread");
crates/snowbound/src/main.rs+356-96
......@@ -198,14 +198,13 @@ impl Library {
198198 }
199199}
200200
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.
202202struct Session {
203203 section: notebook::session::Section,
204204 tab: usize,
205205 /// Spaces, titles and outline levels in section order.
206206 pages: Vec<(ExGuid, String, u32)>,
207207 space: ExGuid,
208 before: Page,
209208 status: &'static str,
210209 /// The editor's page has an unreviewed conflict with another machine's change.
211210 conflict: bool,
......@@ -222,21 +221,20 @@ impl Session {
222221 fn refresh_conflict(&mut self) -> Result<(), Box<dyn Error>> {
223222 self.conflict = self
224223 .section
225 .conflicts()?
226 .iter()
227 .any(|(edit, _)| edit.space == self.space);
224 .conflict()?
225 .is_some_and(|conflict| conflict.space == self.space);
228226 Ok(())
229227 }
230228}
231229
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.
233231enum Loaded {
234 Section(Session),
235 Page { space: ExGuid, page: Page },
232 Section(Box<Session>),
233 Page(ExGuid),
236234}
237235
238/// A loader thread's request number and what it read.
239type Read = (u64, Result<Loaded, String>);
236/// A loader thread's request number, what it opened and the page it read.
237type Read = (u64, Result<(Loaded, Page), String>);
240238
241239/// The newest page read, laid out and waiting for the pictures it shows first, so it
242240/// never appears without them.
......@@ -289,6 +287,9 @@ struct State {
289287 changed: bool,
290288 /// Only the view scrolled or zoomed during this frame, to be announced after it.
291289 moved: bool,
290 /// The stored page changed elsewhere while marked text was pending; it is shown once
291 /// the composition ends.
292 stale: bool,
292293 /// Whether the page last heard it had keyboard focus.
293294 page_focused: bool,
294295 /// The pointer in logical pixels, for window drags from the strip.
......@@ -425,8 +426,8 @@ impl State {
425426 .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))?
426427 .0;
427428 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)?;
430431 session = Some(opened);
431432 (editor, Some((scene, [0.0; 2])))
432433 }
......@@ -444,8 +445,8 @@ impl State {
444445 };
445446 let section = opened.open(0, proxy.clone())?;
446447 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)?;
449450 session = Some(opened);
450451 (editor, Some((scene, [0.0; 2])))
451452 }
......@@ -511,6 +512,7 @@ impl State {
511512 commands: Vec::new(),
512513 changed: false,
513514 moved: false,
515 stale: false,
514516 page_focused: true,
515517 pointer: [0.0; 2],
516518 strip_press: None,
......@@ -587,6 +589,9 @@ impl State {
587589 self.after_move()?;
588590 }
589591 self.moved = false;
592 if self.stale {
593 self.refresh()?;
594 }
590595 // A covered window shows nothing, so animations wait for it to be uncovered.
591596 if follow && !self.occluded {
592597 self.window.request_redraw();
......@@ -1483,18 +1488,14 @@ impl State {
14831488 let proxy = self.proxy.clone();
14841489 self.load(move || {
14851490 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))
14871493 });
14881494 }
14891495 Command::OpenPage(space) => {
14901496 let session = self.session.as_ref().ok_or("No section is open")?;
14911497 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)?)));
14981499 }
14991500 Command::Resolve { keep_mine } => self.resolve_conflict(keep_mine)?,
15001501 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,
......@@ -1512,7 +1513,10 @@ impl State {
15121513
15131514 /// Runs `read` on a thread of its own; `open_loaded` shows what it read unless a newer
15141515 /// 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 ) {
15161520 self.loading += 1;
15171521 let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone());
15181522 std::thread::spawn(move || {
......@@ -1528,12 +1532,8 @@ impl State {
15281532 if id != self.loading {
15291533 continue;
15301534 }
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)?;
15371537 self.opening = Some(Opening {
15381538 loaded,
15391539 scene: (scene, [0.0; 2]),
......@@ -1556,15 +1556,16 @@ impl State {
15561556 self.opening = Some(opening);
15571557 return Ok(());
15581558 }
1559 // The page shown so far keeps what was typed while the next one loaded.
1560 self.persist()?;
15591561 match opening.loaded {
15601562 Loaded::Section(session) => {
1561 self.session = Some(session);
1563 self.session = Some(*session);
15621564 self.filter.clear();
15631565 }
1564 Loaded::Page { space, page } => {
1566 Loaded::Page(space) => {
15651567 let session = self.session.as_mut().ok_or("No section is open")?;
15661568 session.space = space;
1567 session.before = page;
15681569 session.refresh_conflict()?;
15691570 }
15701571 }
......@@ -1663,104 +1664,131 @@ impl State {
16631664 Ok(())
16641665 }
16651666
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.
16681669 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 };
16691674 let Some(session) = &mut self.session else {
16701675 return Ok(());
16711676 };
1672 let after = self.view.editor.page()?;
1673 if after == session.before {
1677 if ops.is_empty() {
16741678 return Ok(());
16751679 }
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";
16881692 Ok(())
16891693 }
16901694
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 {
16941705 return Ok(());
16951706 };
16961707 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);
17001710 self.title();
1701 self.update_accessibility()?;
17021711 self.window.request_redraw();
17031712 Ok(())
17041713 }
17051714
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.
17081734 fn resolve_conflict(&mut self, keep_mine: bool) -> Result<(), Box<dyn Error>> {
1735 self.persist()?;
17091736 let Some(session) = &mut self.session else {
17101737 return Ok(());
17111738 };
1712 let Some((edit, _)) = session
1739 let Some(conflict) = session
17131740 .section
1714 .conflicts()?
1715 .into_iter()
1716 .find(|(edit, _)| edit.space == session.space)
1741 .conflict()?
1742 .filter(|conflict| conflict.space == session.space)
17171743 else {
17181744 return Ok(());
17191745 };
1720 let reviewed = if keep_mine {
1721 self.view.editor.page()?
1746 let resolution = if keep_mine {
1747 notebook::Resolution::Mine
17221748 } else {
1723 session.section.remote_page(session.space)?
1749 notebook::Resolution::Theirs
17241750 };
1725 session.section.review(edit.id, &reviewed)?;
1751 session.section.resolve(conflict.id, resolution)?;
17261752 session.status = "Saving";
17271753 session.refresh_conflict()?;
17281754 if keep_mine {
1729 session.before = session.section.page(session.space)?;
17301755 self.window.request_redraw();
17311756 Ok(())
17321757 } else {
1733 self.reload()
1758 self.refresh()
17341759 }
17351760 }
17361761
1737 /// Applies what the synchronization thread reported since the last poll.
1762 /// Applies what the section reported since the last poll.
17381763 fn synced(&mut self) -> Result<(), Box<dyn Error>> {
17391764 let Some(session) = &mut self.session else {
17401765 return Ok(());
17411766 };
17421767 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;
17451771 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 }
17571772 for event in session.section.events() {
17581773 use notebook::session::Event;
17591774 session.status = match event {
17601775 Event::Refreshed => {
1761 refreshed = true;
1776 listed = true;
17621777 continue;
17631778 }
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 }
17641792 Event::Attempt {
17651793 status: notebook::EditStatus::Published { .. },
17661794 ..
......@@ -1780,15 +1808,15 @@ impl State {
17801808 }
17811809 };
17821810 }
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.
17851813 if conflicted {
17861814 session.refresh_conflict()?;
17871815 }
1788 if refreshed || shown != (session.status, session.conflict) {
1816 if listed || changed || shown != (session.status, session.conflict) {
17891817 self.window.request_redraw();
17901818 }
1791 if refreshed {
1819 if listed {
17921820 session.pages = session.section.pages()?;
17931821 if !session
17941822 .pages
......@@ -1797,13 +1825,13 @@ impl State {
17971825 {
17981826 let first = session.pages.first().ok_or("The section has no pages")?.0;
17991827 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(());
18041829 }
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()?;
18071835 }
18081836 Ok(())
18091837 }
......@@ -2206,29 +2234,65 @@ fn tabs(notebook: &notebook::session::Notebook) -> Vec<Tab> {
22062234 .collect()
22072235}
22082236
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.
22102238fn read_session(
22112239 section: notebook::session::Section,
22122240 tab: usize,
22132241 space: Option<ExGuid>,
2214) -> Result<Session, Box<dyn Error>> {
2242) -> Result<(Session, Page), Box<dyn Error>> {
22152243 let pages = section.pages()?;
22162244 let space = match space {
22172245 Some(space) => space,
22182246 None => pages.first().ok_or("The section has no pages")?.0,
22192247 };
2220 let before = section.page(space)?;
2248 let page = section.page(space)?;
22212249 let mut session = Session {
22222250 section,
22232251 tab,
22242252 pages,
22252253 space,
2226 before,
22272254 status: "",
22282255 conflict: false,
22292256 };
22302257 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.
2262fn 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.
2291fn 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)
22322296}
22332297
22342298impl App {
......@@ -2716,3 +2780,199 @@ fn main() -> Result<(), Box<dyn Error>> {
27162780 event_loop.run_app(&mut app)?;
27172781 app.startup_error.map_or(Ok(()), Err)
27182782}
2783
2784#[cfg(test)]
2785mod 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 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-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 = ''
8function 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}
13try {
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 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_xml import ns
9
10ROOT = Path(__file__).resolve().parent.parent
11FIXTURE = ROOT / 'corpus/page-date'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13
14
15def 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
24class 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
38if __name__ == '__main__':
39 unittest.main()