diff --git a/arc/canvas.md b/arc/canvas.md index 7143de8e0c4f4d1beffd9a683f567f3c6a63a5d9..5862a04d174252785767351437265c51fba55de4 100644 --- a/arc/canvas.md +++ b/arc/canvas.md @@ -129,6 +129,12 @@ it; a click checks its box (`corpus/object-tags`). A picture's link follows on Ctrl+click, Command+click on macOS, while a click selects it, as OneNote's tooltip says (`corpus/picture-link`). +OneNote 2010's Ctrl+1 leaves a bulleted or numbered paragraph's list beside its +new To Do box, and it has no command that converts a list. Make To-Do List, in +the page's context menu and the palette, trades the selected paragraphs' bullets +and numbers for the first check box tag in the user's list as one undo step; +Make Bulleted List trades the tag back for a bullet (`corpus/to-do-list`). + An outline holding only pictures or files takes a paragraph after them where a click beside them lands, as OneNote 2010 adds one when typing there. It is stored with the first edit that reaches it, and undoing back to it empty takes it diff --git a/corpus/to-do-list/README.md b/corpus/to-do-list/README.md new file mode 100644 index 0000000000000000000000000000000000000000..dfde95c532df18b9dd7a48ed06dc70a975c97db2 --- /dev/null +++ b/corpus/to-do-list/README.md @@ -0,0 +1,22 @@ +# Bullets into a to-do list + +`native/` is OneNote 2010 on a copy of `corpus/list-edit/cold/notebook`: the +outline's paragraphs selected with Ctrl+A twice (`1-select.ahk`), then Ctrl+1 +(`3-tag.ahk`). OneNote keeps every bullet and number and puts the To Do box +beside them (`3-tag.png`); the selected top-level paragraphs take the tag and +their nested children do not. Its context menu on the selection offers no +command that converts a list (`2-menu.png`). + +`candidate/` is the output of `lists_become_to_do_lists_and_back_one_revision_each` +in `crates/canvas/tests/to_do_list.rs`, on the same section: Make To-Do List over +"Bullet item" through "Nested numbered" removes their bullets and numbers and +tags each To Do, then Make Bulleted List over "Second numbered" and "Nested +numbered" removes the tag and gives each OneNote's default bullet, each published +as its own revision. + +`cold/` is a fresh OneNote 2010 read: the first two paragraphs are open To Do +items with no list, the next two are bulleted and untagged, and "Restarted at +three" keeps its number. `tools/test_to_do_list.py` checks both reads without a +VM. Regenerate with `SNOWBOUND_TO_DO_LIST_EXPORT` set to a new absolute directory +while running the test, then cold-open it with +`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook --screenshots`. diff --git a/corpus/to-do-list/candidate/Open Notebook.onetoc2 b/corpus/to-do-list/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..228852a0246bdc5cb0c3c241d0351ed7a27282be Binary files /dev/null and b/corpus/to-do-list/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/to-do-list/candidate/lists.one b/corpus/to-do-list/candidate/lists.one new file mode 100644 index 0000000000000000000000000000000000000000..cf9a66403e76262dadd94619cf202069c693c532 Binary files /dev/null and b/corpus/to-do-list/candidate/lists.one differ diff --git a/corpus/to-do-list/cold/commands.jsonl b/corpus/to-do-list/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..4b221e4acaeccedb8fbeecde359a46c5acc12b6f --- /dev/null +++ b/corpus/to-do-list/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-75942477 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/to-do-list/cold/machine.json b/corpus/to-do-list/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..52309a863eac4481a6c4de954a0d25ae6a7b4ebe --- /dev/null +++ b/corpus/to-do-list/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-75942477", "hostname": "ONE-M6-75942477"} diff --git a/corpus/to-do-list/cold/notebook/Open Notebook.onetoc2 b/corpus/to-do-list/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..092d3ca56165b150c47e38f7102f06a47d5e55af Binary files /dev/null and b/corpus/to-do-list/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/to-do-list/cold/notebook/lists.one b/corpus/to-do-list/cold/notebook/lists.one new file mode 100644 index 0000000000000000000000000000000000000000..3ecc96975526a5f3f48cb52e8a7e27b0ff8a378d Binary files /dev/null and b/corpus/to-do-list/cold/notebook/lists.one differ diff --git a/corpus/to-do-list/cold/read/environment.json b/corpus/to-do-list/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..501a3e94aac336c6a735d184581204220a8cb678 --- /dev/null +++ b/corpus/to-do-list/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-75942477", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/to-do-list/cold/read/hierarchy.xml b/corpus/to-do-list/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..8d7caf5bf859fef79cf205ba2bcb9ac5aa90edec --- /dev/null +++ b/corpus/to-do-list/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/to-do-list/cold/read/page-000.navigation.json b/corpus/to-do-list/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..b8578e6a2f723044d70c505e33406a480433361c --- /dev/null +++ b/corpus/to-do-list/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Bullet item - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/to-do-list/cold/read/page-000.png b/corpus/to-do-list/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..383688262e44c7a12754b290a01bac5a7dac1d5e Binary files /dev/null and b/corpus/to-do-list/cold/read/page-000.png differ diff --git a/corpus/to-do-list/cold/read/page-000.xml b/corpus/to-do-list/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..d1d3c45bc4921160953aa6c2aabdd0e93cce3199 --- /dev/null +++ b/corpus/to-do-list/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/to-do-list/cold/read/payloads.json b/corpus/to-do-list/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/to-do-list/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/to-do-list/cold/run.json b/corpus/to-do-list/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..8835702bf0fe63b97b650245dfbb686799936fca --- /dev/null +++ b/corpus/to-do-list/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/todo-lab/candidate", + "expected_pages": 1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/to-do-list/cold/scripts/cold.ps1 b/corpus/to-do-list/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/to-do-list/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/to-do-list/cold/scripts/read.ps1 b/corpus/to-do-list/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/to-do-list/cold/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/to-do-list/cold/source.json b/corpus/to-do-list/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..1cd559fd77b9f15854dfee92e20955f00eaa8a74 --- /dev/null +++ b/corpus/to-do-list/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "c61c516ccbd4afb37584948f03296eb8308d54caa05f243e2f740fe462afad7c", + "mtime_ns": 1790929500289569401 + }, + { + "path": "lists.one", + "bytes": 15712, + "sha256": "83da9e79e5801f6add606031cb833390bf5694d1cdbf3afdca194fe9285a4c20", + "mtime_ns": 1790929500289401194 + } +] diff --git a/corpus/to-do-list/cold/teardown.json b/corpus/to-do-list/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/to-do-list/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/to-do-list/native/1-select.ahk b/corpus/to-do-list/native/1-select.ahk new file mode 100644 index 0000000000000000000000000000000000000000..77ee982350771b0243337b04cb5ee5f255b65597 --- /dev/null +++ b/corpus/to-do-list/native/1-select.ahk @@ -0,0 +1,14 @@ +app := ComObject("OneNote.Application") +app.NavigateTo("{3A318476-D3AE-0D72-0308-0A93E64A687F}{1}{B0}", "{55287AA0-9FCF-0E9B-2D6D-3E6C43F50BC3}{24}{B0}", false) +hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 20) +WinMaximize(hwnd) +WinActivate(hwnd) +WinWaitActive(hwnd,, 10) +app.NavigateTo("{3A318476-D3AE-0D72-0308-0A93E64A687F}{1}{B0}", "{55287AA0-9FCF-0E9B-2D6D-3E6C43F50BC3}{24}{B0}", false) +Sleep(1500) +Send("{Home}") +Sleep(300) +Send("^a") +Sleep(400) +Send("^a") +Sleep(800) diff --git a/corpus/to-do-list/native/2-menu.png b/corpus/to-do-list/native/2-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..93ad142bd5becf31aa60528c23296e6bdaf20cec Binary files /dev/null and b/corpus/to-do-list/native/2-menu.png differ diff --git a/corpus/to-do-list/native/3-tag.ahk b/corpus/to-do-list/native/3-tag.ahk new file mode 100644 index 0000000000000000000000000000000000000000..31640930eeb534b966bf64e108a25288e8e9dc6b --- /dev/null +++ b/corpus/to-do-list/native/3-tag.ahk @@ -0,0 +1,4 @@ +Send("{Escape}") +Sleep(500) +Send("^1") +Sleep(1500) diff --git a/corpus/to-do-list/native/3-tag.png b/corpus/to-do-list/native/3-tag.png new file mode 100644 index 0000000000000000000000000000000000000000..83b3997e5d571325155515e0b3ca01a32ae10004 Binary files /dev/null and b/corpus/to-do-list/native/3-tag.png differ diff --git a/corpus/to-do-list/native/commands.jsonl b/corpus/to-do-list/native/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..53ea2a4f1a71135fbb7b06b5eec67973382cc778 --- /dev/null +++ b/corpus/to-do-list/native/commands.jsonl @@ -0,0 +1,6 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-6ED8D009 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-6ED8D009 -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/to-do-list/native/machine.json b/corpus/to-do-list/native/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..00a020dd7903552ad0e4a06599be1b48e8dc6fcf --- /dev/null +++ b/corpus/to-do-list/native/machine.json @@ -0,0 +1 @@ +{"name": "m6-6ed8d009", "hostname": "ONE-M6-6ED8D009"} diff --git a/corpus/to-do-list/native/notebook/Open Notebook.onetoc2 b/corpus/to-do-list/native/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..ab1de4e83f613d517c06f3f412aa67871090671c Binary files /dev/null and b/corpus/to-do-list/native/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/to-do-list/native/notebook/lists.one b/corpus/to-do-list/native/notebook/lists.one new file mode 100644 index 0000000000000000000000000000000000000000..473eb32d4b5fc0d218bbbbd47ae9de38c19893c0 Binary files /dev/null and b/corpus/to-do-list/native/notebook/lists.one differ diff --git a/corpus/to-do-list/native/read/environment.json b/corpus/to-do-list/native/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..6f5a651a9c02725503a4a1fffb02d698fd7bfc19 --- /dev/null +++ b/corpus/to-do-list/native/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-6ED8D009", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/to-do-list/native/read/hierarchy.xml b/corpus/to-do-list/native/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..36412a2c260d05467cda6b4d67c50eccb8b2dcc0 --- /dev/null +++ b/corpus/to-do-list/native/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/to-do-list/native/read/page-000.xml b/corpus/to-do-list/native/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..ef7f2f4cc1c1c4416d07d5f3a6647cd090567c0a --- /dev/null +++ b/corpus/to-do-list/native/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/to-do-list/native/read/payloads.json b/corpus/to-do-list/native/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/to-do-list/native/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/to-do-list/native/run.json b/corpus/to-do-list/native/run.json new file mode 100644 index 0000000000000000000000000000000000000000..4e223f3c098e1a862c5487c116733a672a906ac8 --- /dev/null +++ b/corpus/to-do-list/native/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/todo-lab/source", + "expected_pages": 1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/to-do-list/native/scripts/cold.ps1 b/corpus/to-do-list/native/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/to-do-list/native/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/to-do-list/native/scripts/read.ps1 b/corpus/to-do-list/native/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/to-do-list/native/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/to-do-list/native/source.json b/corpus/to-do-list/native/source.json new file mode 100644 index 0000000000000000000000000000000000000000..bc892050aaabef04b0a794320853de30f6d93f4f --- /dev/null +++ b/corpus/to-do-list/native/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 3764, + "sha256": "45d6059529eb5c2d05c107a93ba81842132102c03135e02687709f72662bde09", + "mtime_ns": 1790929228488500810 + }, + { + "path": "lists.one", + "bytes": 11328, + "sha256": "c2c9bdb4a58e747a6dfacb98492fad9c0c19f222f9a59e94abc7273213a93c9f", + "mtime_ns": 1790929228488058521 + } +] diff --git a/corpus/to-do-list/native/teardown.json b/corpus/to-do-list/native/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/to-do-list/native/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs index 17ec0895649772d4d23fb70f0feb6bf9bcd55e4c..eb92e8b91882da9907e59dcbb965bf4766eef097 100644 --- a/crates/canvas/src/editor/format.rs +++ b/crates/canvas/src/editor/format.rs @@ -40,6 +40,10 @@ pub enum Formatting { RemoveTags, /// Checks the selected paragraphs' check boxes, or clears them when all are checked. Check, + /// Gives the selected bulleted and numbered paragraphs this tag in place of their list. + ToDoList(NoteTag, u16), + /// Gives the selected paragraphs with this tag OneNote's default bullet in its place. + BulletedList(NoteTag, u16), /// Format Painter: gives the selection the character formatting and alignment of text /// picked up with [`CanvasEditor::painted_format`], as OneNote's does. Paint(Format), @@ -344,6 +348,8 @@ pub struct FormatState { /// Whether every selected paragraph is bulleted or numbered. pub bullets: bool, pub numbering: bool, + /// Whether any selected paragraph is bulleted or numbered. + pub listed: bool, /// The library style every selected paragraph's list shares. pub list: Option, /// Tags every selected paragraph has, with their action types. @@ -701,6 +707,7 @@ impl CanvasEditor { })), bullets: list(Formatting::Bullets), numbering: list(Formatting::Numbering), + listed: paragraphs.iter().any(|node| self.list(node).is_some()), list: common(paragraphs.iter().map(|node| self.list_style(node))), tags: paragraphs.first().map_or_else(Vec::new, |first| { let mut shared: Vec<_> = tags(first) @@ -753,6 +760,8 @@ impl CanvasEditor { | Formatting::Tag(..) | Formatting::RemoveTags | Formatting::Check + | Formatting::ToDoList(..) + | Formatting::BulletedList(..) | Formatting::Style(_) if title => { @@ -860,6 +869,46 @@ impl CanvasEditor { } } Formatting::Check => self.check(&mut replacement, &ranges), + Formatting::ToDoList(tag, action_type) => { + let definition = tag.definition(*action_type); + let id = self.define_tag(&definition)?; + let created = time32(); + for node in leaves_mut(&mut replacement) { + if ranges.remove(&node.id).is_none() || self.list(node).is_none() { + continue; + } + node.lists.clear(); + if tags(node).any(|tag| self.tag_kind(tag) == Some(&definition.kind)) { + continue; + } + let ParagraphContent::Text(text) = &mut node.content else { + unreachable!() + }; + let added = Some((id, tag.shape, created)); + self.retag([&mut node.tags, &mut text.tags], &definition.kind, added); + } + } + Formatting::BulletedList(tag, action_type) => { + let kind = tag.definition(*action_type).kind; + for node in leaves_mut(&mut replacement) { + if ranges.remove(&node.id).is_none() + || !tags(node).any(|tag| self.tag_kind(tag) == Some(&kind)) + { + continue; + } + if self.list(node) != Some(Formatting::Bullets) { + let format = &node.text().unwrap().text.spans()[0].format; + let list = list_definition(ListStyle::BULLET, format); + let id = new_id()?; + self.definitions.insert(id, list); + node.lists = vec![id]; + } + let ParagraphContent::Text(text) = &mut node.content else { + unreachable!() + }; + self.retag([&mut node.tags, &mut text.tags], &kind, None); + } + } Formatting::Style(definition) => { let olds = bases(&replacement)?; let style = self.define_style(definition)?; @@ -2300,6 +2349,57 @@ mod tests { assert_eq!(editor.active_outline().document, original); } + #[test] + fn lists_become_to_do_lists_and_back_as_one_undo_step_each() { + let mut engine = TextEngine::default(); + let mut editor = plain(&mut engine, &["one", "two", "three"]); + editor.select([at(0, 0), at(1, 3)].into()).unwrap(); + editor.format(&mut engine, Formatting::Bullets).unwrap(); + editor.select([at(1, 0); 2].into()).unwrap(); + editor.format(&mut engine, Formatting::Numbering).unwrap(); + let listed = editor.active_outline().document.clone(); + let all = Selection::from([at(0, 0), at(2, 5)]); + editor.select(all).unwrap(); + assert!(editor.format_state().unwrap().listed); + let to_do = NoteTag::defaults()[0].clone(); + editor + .format(&mut engine, Formatting::ToDoList(to_do.clone(), 0)) + .unwrap(); + let lists = |editor: &CanvasEditor| { + let document = &editor.active_outline().document; + document + .nodes() + .iter() + .map(|node| editor.list(node)) + .collect::>() + }; + assert_eq!(lists(&editor), [None, None, None]); + let task = || vec![("To Do".to_owned(), 0, true)]; + assert_eq!( + (0..3).map(|at| text_tags(&editor, at)).collect::>(), + [task(), task(), vec![]] + ); + let state = editor.format_state().unwrap(); + assert!(!state.listed && state.tags.is_empty()); + editor.select([at(0, 0), at(1, 3)].into()).unwrap(); + assert_eq!(editor.format_state().unwrap().tags, [(to_do.clone(), 0)]); + editor + .format(&mut engine, Formatting::BulletedList(to_do, 0)) + .unwrap(); + assert_eq!( + lists(&editor), + [Some(Formatting::Bullets), Some(Formatting::Bullets), None] + ); + assert_eq!(text_tags(&editor, 0), []); + assert_eq!(text_tags(&editor, 1), []); + let state = editor.format_state().unwrap(); + assert_eq!((state.list, state.tags), (Some(ListStyle::BULLET), vec![])); + editor.undo(&mut engine).unwrap(); + assert_eq!(text_tags(&editor, 1), task()); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, listed); + } + #[test] fn the_default_tags_are_onenotes_stored_definitions_and_all_draw() { use onestore::{RevisionIndex, Store, document::Document}; @@ -2522,6 +2622,7 @@ mod tests { alignment: Some(Alignment::Center), bullets: false, numbering: true, + listed: true, list: Some(ListStyle::NUMBER), tags: vec![ (NoteTag::defaults()[0].clone(), 0), diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index 264c522ba12e23d9ba36937af6bb4bb528ce995a..fb1e8d3f5a5bc1521ef82a52e6c8bf4aeed5c92d 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -324,7 +324,7 @@ impl TagIcon { } /// Whether tag symbol `shape` is a check box, as MS-ONE's NoteTagShape marks them. -pub(crate) fn checkable(shape: u16) -> bool { +pub fn checkable(shape: u16) -> bool { matches!( shape, 1..=12 | 28 | 30 | 32 | 48 | 50 | 52 | 69 | 71 | 73 | 89..=99 diff --git a/crates/canvas/tests/to_do_list.rs b/crates/canvas/tests/to_do_list.rs new file mode 100644 index 0000000000000000000000000000000000000000..f64e5eb08eb02e2e78d2213eba745dcb6bda36ee --- /dev/null +++ b/crates/canvas/tests/to_do_list.rs @@ -0,0 +1,119 @@ +//! Make To-Do List trades bulleted and numbered paragraphs' lists for the To Do tag, and Make +//! Bulleted List trades it back, each published as one revision (`corpus/to-do-list`). + +use canvas::{ + document::TextPosition, + editor::{CanvasEditor, Formatting, NoteTag}, + layout::TextEngine, +}; +use onestore::{ + Arena, ExGuid, Section, Store, + op::{Edit, Op}, + page::{Page, PageObject}, +}; + +const LISTS: &[u8] = include_bytes!("../../../corpus/list-edit/cold/notebook/lists.one"); + +fn body(page: &Page) -> ExGuid { + page.objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => Some(outline.id), + _ => None, + }) + .unwrap() +} + +fn at(paragraph: usize) -> TextPosition { + TextPosition { + paragraph, + offset: 0, + } +} + +/// Each paragraph's list, as `bullet`, `number` or none, and its tags' labels. +fn items(page: &Page, engine: &mut TextEngine) -> Vec<(Option<&'static str>, Vec)> { + let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); + editor.focus_outline(body(page)).unwrap(); + (0..7) + .map(|paragraph| { + editor.select([at(paragraph); 2].into()).unwrap(); + let state = editor.format_state().unwrap(); + let list = match (state.bullets, state.numbering) { + (true, _) => Some("bullet"), + (_, true) => Some("number"), + _ => None, + }; + ( + list, + state.tags.into_iter().map(|(tag, _)| tag.label).collect(), + ) + }) + .collect() +} + +/// `SNOWBOUND_TO_DO_LIST_EXPORT` names a new directory receiving the candidate for a cold +/// reopen. +#[test] +fn lists_become_to_do_lists_and_back_one_revision_each() { + let arena = Arena::default(); + let mut section = Section::open(&arena, LISTS.to_vec()).unwrap(); + let space = section.pages().unwrap()[0].0; + let page = section.page(space).unwrap(); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap(); + editor.focus_outline(body(&page)).unwrap(); + let to_do = NoteTag::defaults()[0].clone(); + let mut publish = |editor: &mut CanvasEditor, at| { + let ops = editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| Op::Page { space, op }) + .collect(); + section.apply("Author", &Edit { at, ops }).unwrap(); + }; + // Through "Nested numbered", the last paragraph each edit takes. + let nested = TextPosition { + paragraph: 3, + offset: 15, + }; + editor.select([at(0), nested].into()).unwrap(); + editor + .format(&mut engine, Formatting::ToDoList(to_do.clone(), 0)) + .unwrap(); + publish(&mut editor, 134_000_000_000_000_000); + editor.select([at(2), nested].into()).unwrap(); + editor + .format(&mut engine, Formatting::BulletedList(to_do, 0)) + .unwrap(); + publish(&mut editor, 134_000_000_010_000_000); + + let task = || vec!["To Do".to_owned()]; + let expected = [ + (None, task()), + (None, task()), + (Some("bullet"), vec![]), + (Some("bullet"), vec![]), + (Some("number"), vec![]), + (None, vec![]), + (None, vec![]), + ]; + assert_eq!(items(§ion.page(space).unwrap(), &mut engine), expected); + section.seal().unwrap(); + let written = section.image(); + let reread = Section::open(&arena, written.clone()).unwrap(); + assert_eq!(items(&reread.page(space).unwrap(), &mut engine), expected); + if let Some(directory) = std::env::var_os("SNOWBOUND_TO_DO_LIST_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("lists.one"), &written).unwrap(); + let file_id = Store::parse(&written).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents("Open Notebook.onetoc2", &[("lists.one", file_id)]) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index 11594c38f40b653e98581acf5da8ee0902dd21f1..5ab5f9bfa049f2dbf8fbdc50343336ad5caf3333 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -113,6 +113,11 @@ pub enum Id { CustomizeTags, RemoveTags, FindTags, + /// Gives the selected bulleted and numbered paragraphs the To Do tag in place of + /// their list. + ToDoList, + /// Gives the selected To Do paragraphs a bullet in place of their tag. + BulletedList, Help, CheckForUpdates, } @@ -598,6 +603,8 @@ pub const COMMANDS: &[Command] = &[ &[cmd('0')] ), row!(Id::FindTags, "Find Tags", NONE, NONE), + row!(Id::ToDoList, "Make To-Do List", NONE, NONE), + row!(Id::BulletedList, "Make Bulleted List", NONE, NONE), row!(Id::Help, "Snowbound Help", NONE, NONE), row!(Id::CheckForUpdates, "Check for Updates…", NONE, NONE), ]; @@ -1290,6 +1297,14 @@ impl State { Id::Theme(scope) => enabled(!modal && self.theme_target(scope).is_some()), // A selected picture or file takes tags too, as in OneNote. Id::RemoveTags => enabled(text || tagged), + Id::ToDoList => enabled(text && format.listed && self.to_do().is_some()), + Id::BulletedList => enabled( + text && self.to_do().is_some_and(|place| { + format + .tags + .contains(&(self.tags[place].stored(), place as u16)) + }), + ), Id::Toggle(toggle) => checked(format.toggles.contains(&toggle)), Id::Bullets => checked(format.bullets), Id::Numbering => checked(format.numbering), @@ -1329,6 +1344,11 @@ impl State { Ok(()) } + /// The place of the first check box tag in the user's list, To Do in OneNote's. + fn to_do(&self) -> Option { + (self.tags.iter()).position(|tag| canvas::outline::checkable(tag.shape)) + } + /// The notebook the open section, or the notebook showing none, belongs to. fn notebook(&self) -> Option<&Arc> { self.session @@ -1683,6 +1703,20 @@ impl State { return Ok(()); } Id::RemoveTags => return format(self, Formatting::RemoveTags), + Id::ToDoList | Id::BulletedList => { + let place = self.to_do().expect("Enabled with a To Do tag"); + let tag = self.tags[place].clone(); + format( + self, + if id == Id::ToDoList { + Formatting::ToDoList(tag, place as u16) + } else { + Formatting::BulletedList(tag, place as u16) + }, + )?; + self.keep_tag_art(place); + return Ok(()); + } }; self.commands.push(response); Ok(()) diff --git a/crates/snowbound/src/link.rs b/crates/snowbound/src/link.rs index fa9547b60abfb4afdb3767712869c8e65e0a58ef..82f86b5059a3e8d201a44a9aacf44e271c512545 100644 --- a/crates/snowbound/src/link.rs +++ b/crates/snowbound/src/link.rs @@ -2,7 +2,7 @@ //! the page's context menu, and following a clicked link, to a page //! of the notebook or with the system's handler. -use crate::{Command, State, page, platform}; +use crate::{Command, State, commands, page, platform}; use onestore::ExGuid; use onestore::page::link::{LinkTarget, internal_link}; use std::error::Error; @@ -330,6 +330,14 @@ impl State { None => Vec::new(), }; let corrections = items.len(); + let format = self.format_state(); + let lists = [commands::Id::ToDoList, commands::Id::BulletedList] + .into_iter() + .filter(|id| { + let choice = commands::Choice::Command(*id); + self.status(&choice, &format).enabled + }) + .collect::>(); items.extend(if context.attachment.is_some() { // OneNote 2010's commands for the file itself; its clipboard holds text alone. vec![item("Open"), item("Save As…")] @@ -368,6 +376,12 @@ impl State { item("Copy Link to Paragraph"), ]), } + for (at, id) in lists.iter().enumerate() { + items.push(Item { + separated: at == 0, + ..item(commands::command(*id).title) + }); + } if context.equation { items.extend([ Item { @@ -414,6 +428,12 @@ impl State { self.respond(response); return Ok(()); } + if let Some(id) = lists + .into_iter() + .find(|id| commands::command(*id).title == chosen_text) + { + return self.run(commands::Choice::Command(id)); + } let response = match chosen_text.as_str() { "Open" => return self.open_attachment(&context.attachment.unwrap()), "Save As…" => return self.save_attachment(&context.attachment.unwrap()), diff --git a/tools/test_to_do_list.py b/tools/test_to_do_list.py new file mode 100644 index 0000000000000000000000000000000000000000..35a18471d7b44f90b33c9aafb54c9941b28b922a --- /dev/null +++ b/tools/test_to_do_list.py @@ -0,0 +1,57 @@ +from pathlib import Path +import runpy +import shutil +from tempfile import TemporaryDirectory +import unittest +import xml.etree.ElementTree as ET + +from native_xml import ns + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/to-do-list' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] + + +def items(read): + """Each paragraph's text, list kind and tags' names, in page order.""" + page, = (ET.parse(path).getroot() for path in sorted(read.glob('page-*.xml'))) + names = {d.get('index'): d.get('name') for d in page.findall('one:TagDef', ns)} + result = [] + for oe in page.iter('{%s}OE' % ns['one']): + marker = oe.find('one:List', ns) + kind = None if marker is None else list(marker)[0].tag.split('}')[1] + tags = [names[tag.get('index')] for tag in oe.findall('one:Tag', ns)] + result.append((oe.find('one:T', ns).text, kind, tags)) + return result + + +class ToDoListTest(unittest.TestCase): + def test_onenote_tags_lists_without_removing_them(self): + self.assertEqual(items(FIXTURE / 'native/read'), [ + ('Bullet item', 'Bullet', ['To Do']), + ('First numbered', 'Number', ['To Do']), + ('Second numbered', 'Number', ['To Do']), + ('Nested numbered', 'Number', []), + ('Restarted at three', 'Number', ['To Do']), + ('Nested plain', None, []), + ('Plain again', None, ['To Do']), + ]) + + def test_snowbound_to_do_and_bulleted_lists_reopen_natively(self): + with TemporaryDirectory() as temporary: + read = Path(temporary) / 'read' + shutil.copytree(FIXTURE / 'cold/read', read) + compare(FIXTURE / 'candidate', read) + self.assertEqual(items(FIXTURE / 'cold/read'), [ + ('Bullet item', None, ['To Do']), + ('First numbered', None, ['To Do']), + ('Second numbered', 'Bullet', []), + ('Nested numbered', 'Bullet', []), + ('Restarted at three', 'Number', []), + ('Nested plain', None, []), + ('Plain again', None, []), + ]) + + +if __name__ == '__main__': + unittest.main()