authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 01:29:08-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 02:01:11-07:00
logf3bfb4de18ec03dca625dd95d5d5f2ae34383837
tree6283c6abbc31cbfac63fca17a2de9d91730b3f6b
parentf0c2aa56db4f3d91b99d0ac66f91a6c53f66f4cf
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: make bullets into a to-do list and back

Make To-Do List, in the page context menu and the palette when the selection holds bulleted or numbered paragraphs, removes their lists and gives each the first check box tag in the tag list; Make Bulleted List trades that tag back for the default bullet. Each is one undo step and one revision. OneNote 2010 has no such command, and its Ctrl+1 keeps a list beside the new To Do box (corpus/to-do-list/native). Fixes #53. Assisted-by: claude-opus-5.5

42 files changed, 839 insertions(+), 2 deletions(-)

arc/canvas.md+6
...@@ -129,6 +129,12 @@ it; a click checks its box (`corpus/object-tags`). A picture's link follows on...@@ -129,6 +129,12 @@ it; a click checks its box (`corpus/object-tags`). A picture's link follows on
129Ctrl+click, Command+click on macOS, while a click selects it, as OneNote's129Ctrl+click, Command+click on macOS, while a click selects it, as OneNote's
130tooltip says (`corpus/picture-link`).130tooltip says (`corpus/picture-link`).
131131
132OneNote 2010's Ctrl+1 leaves a bulleted or numbered paragraph's list beside its
133new To Do box, and it has no command that converts a list. Make To-Do List, in
134the page's context menu and the palette, trades the selected paragraphs' bullets
135and numbers for the first check box tag in the user's list as one undo step;
136Make Bulleted List trades the tag back for a bullet (`corpus/to-do-list`).
137
132An outline holding only pictures or files takes a paragraph after them where a138An outline holding only pictures or files takes a paragraph after them where a
133click beside them lands, as OneNote 2010 adds one when typing there. It is139click beside them lands, as OneNote 2010 adds one when typing there. It is
134stored with the first edit that reaches it, and undoing back to it empty takes it140stored with the first edit that reaches it, and undoing back to it empty takes it
corpus/to-do-list/README.md created+22
...@@ -0,0 +1,22 @@
1# Bullets into a to-do list
2
3`native/` is OneNote 2010 on a copy of `corpus/list-edit/cold/notebook`: the
4outline's paragraphs selected with Ctrl+A twice (`1-select.ahk`), then Ctrl+1
5(`3-tag.ahk`). OneNote keeps every bullet and number and puts the To Do box
6beside them (`3-tag.png`); the selected top-level paragraphs take the tag and
7their nested children do not. Its context menu on the selection offers no
8command that converts a list (`2-menu.png`).
9
10`candidate/` is the output of `lists_become_to_do_lists_and_back_one_revision_each`
11in `crates/canvas/tests/to_do_list.rs`, on the same section: Make To-Do List over
12"Bullet item" through "Nested numbered" removes their bullets and numbers and
13tags each To Do, then Make Bulleted List over "Second numbered" and "Nested
14numbered" removes the tag and gives each OneNote's default bullet, each published
15as its own revision.
16
17`cold/` is a fresh OneNote 2010 read: the first two paragraphs are open To Do
18items with no list, the next two are bulleted and untagged, and "Restarted at
19three" keeps its number. `tools/test_to_do_list.py` checks both reads without a
20VM. Regenerate with `SNOWBOUND_TO_DO_LIST_EXPORT` set to a new absolute directory
21while running the test, then cold-open it with
22`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook --screenshots`.
corpus/to-do-list/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/to-do-list/candidate/Open Notebook.onetoc2 differ
corpus/to-do-list/candidate/lists.one created
Binary files /dev/null and b/corpus/to-do-list/candidate/lists.one differ
corpus/to-do-list/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-75942477 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
3{"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/to-do-list/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-75942477", "hostname": "ONE-M6-75942477"}
corpus/to-do-list/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/to-do-list/cold/notebook/Open Notebook.onetoc2 differ
corpus/to-do-list/cold/notebook/lists.one created
Binary files /dev/null and b/corpus/to-do-list/cold/notebook/lists.one differ
corpus/to-do-list/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-75942477",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/to-do-list/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="{1BF74201-150E-4FCD-855B-8D162E08C4F1}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-02T08:26:40.000Z" color="#B49EDE"><one:Section name="lists" ID="{65C6C81D-BD8D-03A7-2A49-252E3F340D4F}{1}{B0}" path="C:\one-tests\runs\capture\notebook\lists.one" lastModifiedTime="2026-10-02T08:26:40.000Z" color="#8AA8E4"><one:Page ID="{0ADF36CB-F1EC-004E-042C-11D19A8B6EF3}{1}{B0}" name="Bullet item" dateTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-10-02T08:26:40.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/to-do-list/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": "Bullet item - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/to-do-list/cold/read/page-000.png created
Binary files /dev/null and b/corpus/to-do-list/cold/read/page-000.png differ
corpus/to-do-list/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="{0ADF36CB-F1EC-004E-042C-11D19A8B6EF3}{1}{B0}" name="Bullet item" dateTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-10-02T08:26:40.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-12T09:13:26.000Z" objectID="{65C6C81D-BD8D-03A7-2A49-252E3F340D4F}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="174.7353515625" height="93.99401092529296"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{65C6C81D-BD8D-03A7-2A49-252E3F340D4F}{24}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-10-02T08:25:00.000Z"/><one:T><![CDATA[Bullet item]]></one:T></one:OE><one:OE author="List author" lastModifiedBy="List author" creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{FFF4CE9E-F0A5-0334-1410-C70E923B6CB6}{0}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-10-02T08:25:00.000Z"/><one:T><![CDATA[First numbered]]></one:T></one:OE><one:OE author="List author" lastModifiedBy="List author" creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{9CB6516E-457B-05AF-0679-076C2A0CB7F6}{0}{B0}" alignment="left"><one:List><one:Bullet bullet="2" fontSize="11.0"/></one:List><one:T><![CDATA[Second numbered]]></one:T><one:OEChildren><one:OE author="List author" lastModifiedBy="List author" creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{A8F3CE78-4059-0A6E-1E84-62C4E04CB918}{0}{B0}" alignment="left"><one:List><one:Bullet bullet="2" fontSize="11.0"/></one:List><one:T><![CDATA[Nested numbered]]></one:T></one:OE></one:OEChildren></one:OE><one:OE author="List author" lastModifiedBy="List author" creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-09-12T09:13:26.000Z" objectID="{1BDFB891-3F87-0F05-0EF2-8DDB27FF6FC2}{0}{B0}" alignment="left"><one:List><one:Number numberSequence="1" numberFormat="##." restartNumberingAt="3" fontSize="11.0" font="Calibri" text="III."/></one:List><one:T><![CDATA[Restarted at three]]></one:T><one:OEChildren><one:OE author="List author" lastModifiedBy="List author" creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-09-12T09:13:26.000Z" objectID="{B93EA47A-5617-099B-2BCB-FD1D6737867B}{0}{B0}" alignment="left"><one:T><![CDATA[Nested plain]]></one:T></one:OE></one:OEChildren></one:OE><one:OE author="List author" lastModifiedBy="List author" creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-09-12T09:13:26.000Z" objectID="{49A59F0D-5038-0CAE-0D5F-181E2009541C}{0}{B0}" alignment="left"><one:T><![CDATA[Plain again]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/to-do-list/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/to-do-list/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/todo-lab/candidate",
3 "expected_pages": 1,
4 "author": null,
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 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/to-do-list/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/to-do-list/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
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 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/to-do-list/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "c61c516ccbd4afb37584948f03296eb8308d54caa05f243e2f740fe462afad7c",
6 "mtime_ns": 1790929500289569401
7 },
8 {
9 "path": "lists.one",
10 "bytes": 15712,
11 "sha256": "83da9e79e5801f6add606031cb833390bf5694d1cdbf3afdca194fe9285a4c20",
12 "mtime_ns": 1790929500289401194
13 }
14]
corpus/to-do-list/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/to-do-list/native/1-select.ahk created+14
...@@ -0,0 +1,14 @@
1app := ComObject("OneNote.Application")
2app.NavigateTo("{3A318476-D3AE-0D72-0308-0A93E64A687F}{1}{B0}", "{55287AA0-9FCF-0E9B-2D6D-3E6C43F50BC3}{24}{B0}", false)
3hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 20)
4WinMaximize(hwnd)
5WinActivate(hwnd)
6WinWaitActive(hwnd,, 10)
7app.NavigateTo("{3A318476-D3AE-0D72-0308-0A93E64A687F}{1}{B0}", "{55287AA0-9FCF-0E9B-2D6D-3E6C43F50BC3}{24}{B0}", false)
8Sleep(1500)
9Send("{Home}")
10Sleep(300)
11Send("^a")
12Sleep(400)
13Send("^a")
14Sleep(800)
corpus/to-do-list/native/2-menu.png created
Binary files /dev/null and b/corpus/to-do-list/native/2-menu.png differ
corpus/to-do-list/native/3-tag.ahk created+4
...@@ -0,0 +1,4 @@
1Send("{Escape}")
2Sleep(500)
3Send("^1")
4Sleep(1500)
corpus/to-do-list/native/3-tag.png created
Binary files /dev/null and b/corpus/to-do-list/native/3-tag.png differ
corpus/to-do-list/native/commands.jsonl created+6
...@@ -0,0 +1,6 @@
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-6ED8D009 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
3{"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}
4{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
5{"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}
6{"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/to-do-list/native/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-6ed8d009", "hostname": "ONE-M6-6ED8D009"}
corpus/to-do-list/native/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/to-do-list/native/notebook/Open Notebook.onetoc2 differ
corpus/to-do-list/native/notebook/lists.one created
Binary files /dev/null and b/corpus/to-do-list/native/notebook/lists.one differ
corpus/to-do-list/native/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-6ED8D009",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/to-do-list/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="{5E4EB4D3-F5AC-4A7B-8DD9-CB68792DA7A9}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-02T08:23:48.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="lists" ID="{55287AA0-9FCF-0E9B-2D6D-3E6C43F50BC3}{1}{B0}" path="C:\one-tests\runs\capture\notebook\lists.one" lastModifiedTime="2026-10-02T08:23:48.000Z" color="#8AA8E4" isCurrentlyViewed="true"><one:Page ID="{3A318476-D3AE-0D72-0308-0A93E64A687F}{1}{B0}" name="Bullet item" dateTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-10-02T08:23:43.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section></one:Notebook>
corpus/to-do-list/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="{3A318476-D3AE-0D72-0308-0A93E64A687F}{1}{B0}" name="Bullet item" dateTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-10-02T08:23:43.000Z" pageLevel="1" isCurrentlyViewed="true" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-10-02T08:23:43.000Z" objectID="{55287AA0-9FCF-0E9B-2D6D-3E6C43F50BC3}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="174.7353515625" height="93.99401092529296"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-10-02T08:23:43.000Z" objectID="{55287AA0-9FCF-0E9B-2D6D-3E6C43F50BC3}{24}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-10-02T08:23:43.000Z"/><one:List><one:Bullet bullet="3" fontSize="11.0"/></one:List><one:T><![CDATA[Bullet item]]></one:T></one:OE><one:OE author="List author" creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-10-02T08:23:43.000Z" objectID="{CF1A7C23-D2E7-0E08-1334-DC4CEEFA6A3A}{0}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-10-02T08:23:43.000Z"/><one:List><one:Number numberSequence="1" numberFormat="##." fontSize="11.0" font="Calibri" text="I."/></one:List><one:T><![CDATA[First numbered]]></one:T></one:OE><one:OE author="List author" creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-10-02T08:23:43.000Z" objectID="{AC58E3D3-6739-0893-015D-1C2E56CDB17A}{0}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-10-02T08:23:43.000Z"/><one:List><one:Number numberSequence="1" numberFormat="##." fontSize="11.0" font="Calibri" text="II."/></one:List><one:T><![CDATA[Second numbered]]></one:T><one:OEChildren><one:OE author="List author" lastModifiedBy="List author" creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-10-02T08:23:36.000Z" objectID="{981D7CC5-621B-0752-19A0-79869C8DBF94}{0}{B0}" alignment="left"><one:List><one:Number numberSequence="1" numberFormat="##." fontSize="11.0" font="Calibri" text="I."/></one:List><one:T><![CDATA[Nested numbered]]></one:T></one:OE></one:OEChildren></one:OE><one:OE author="List author" creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-10-02T08:23:43.000Z" objectID="{2B310A2C-1DC5-0239-09D6-96995B3E694E}{0}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-10-02T08:23:43.000Z"/><one:List><one:Number numberSequence="1" numberFormat="##." fontSize="11.0" font="Calibri" text="III."/></one:List><one:T><![CDATA[Restarted at three]]></one:T><one:OEChildren><one:OE author="List author" lastModifiedBy="List author" creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-10-02T08:23:36.000Z" objectID="{89D016C7-7455-04A7-2CEF-E65F1BF680F7}{0}{B0}" alignment="left"><one:T><![CDATA[Nested plain]]></one:T></one:OE></one:OEChildren></one:OE><one:OE author="List author" creationTime="2026-09-12T09:13:26.000Z" lastModifiedTime="2026-10-02T08:23:43.000Z" objectID="{794B2DB0-727A-0192-0A7B-035C5CC85290}{0}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-10-02T08:23:43.000Z"/><one:T><![CDATA[Plain again]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/to-do-list/native/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/to-do-list/native/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/todo-lab/source",
3 "expected_pages": 1,
4 "author": null,
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 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/to-do-list/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/to-do-list/native/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
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 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/to-do-list/native/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 3764,
5 "sha256": "45d6059529eb5c2d05c107a93ba81842132102c03135e02687709f72662bde09",
6 "mtime_ns": 1790929228488500810
7 },
8 {
9 "path": "lists.one",
10 "bytes": 11328,
11 "sha256": "c2c9bdb4a58e747a6dfacb98492fad9c0c19f222f9a59e94abc7273213a93c9f",
12 "mtime_ns": 1790929228488058521
13 }
14]
corpus/to-do-list/native/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
crates/canvas/src/editor/format.rs+101
...@@ -40,6 +40,10 @@ pub enum Formatting {...@@ -40,6 +40,10 @@ pub enum Formatting {
40 RemoveTags,40 RemoveTags,
41 /// Checks the selected paragraphs' check boxes, or clears them when all are checked.41 /// Checks the selected paragraphs' check boxes, or clears them when all are checked.
42 Check,42 Check,
43 /// Gives the selected bulleted and numbered paragraphs this tag in place of their list.
44 ToDoList(NoteTag, u16),
45 /// Gives the selected paragraphs with this tag OneNote's default bullet in its place.
46 BulletedList(NoteTag, u16),
43 /// Format Painter: gives the selection the character formatting and alignment of text47 /// Format Painter: gives the selection the character formatting and alignment of text
44 /// picked up with [`CanvasEditor::painted_format`], as OneNote's does.48 /// picked up with [`CanvasEditor::painted_format`], as OneNote's does.
45 Paint(Format),49 Paint(Format),
...@@ -344,6 +348,8 @@ pub struct FormatState {...@@ -344,6 +348,8 @@ pub struct FormatState {
344 /// Whether every selected paragraph is bulleted or numbered.348 /// Whether every selected paragraph is bulleted or numbered.
345 pub bullets: bool,349 pub bullets: bool,
346 pub numbering: bool,350 pub numbering: bool,
351 /// Whether any selected paragraph is bulleted or numbered.
352 pub listed: bool,
347 /// The library style every selected paragraph's list shares.353 /// The library style every selected paragraph's list shares.
348 pub list: Option<ListStyle>,354 pub list: Option<ListStyle>,
349 /// Tags every selected paragraph has, with their action types.355 /// Tags every selected paragraph has, with their action types.
...@@ -701,6 +707,7 @@ impl CanvasEditor {...@@ -701,6 +707,7 @@ impl CanvasEditor {
701 })),707 })),
702 bullets: list(Formatting::Bullets),708 bullets: list(Formatting::Bullets),
703 numbering: list(Formatting::Numbering),709 numbering: list(Formatting::Numbering),
710 listed: paragraphs.iter().any(|node| self.list(node).is_some()),
704 list: common(paragraphs.iter().map(|node| self.list_style(node))),711 list: common(paragraphs.iter().map(|node| self.list_style(node))),
705 tags: paragraphs.first().map_or_else(Vec::new, |first| {712 tags: paragraphs.first().map_or_else(Vec::new, |first| {
706 let mut shared: Vec<_> = tags(first)713 let mut shared: Vec<_> = tags(first)
...@@ -753,6 +760,8 @@ impl CanvasEditor {...@@ -753,6 +760,8 @@ impl CanvasEditor {
753 | Formatting::Tag(..)760 | Formatting::Tag(..)
754 | Formatting::RemoveTags761 | Formatting::RemoveTags
755 | Formatting::Check762 | Formatting::Check
763 | Formatting::ToDoList(..)
764 | Formatting::BulletedList(..)
756 | Formatting::Style(_)765 | Formatting::Style(_)
757 if title =>766 if title =>
758 {767 {
...@@ -860,6 +869,46 @@ impl CanvasEditor {...@@ -860,6 +869,46 @@ impl CanvasEditor {
860 }869 }
861 }870 }
862 Formatting::Check => self.check(&mut replacement, &ranges),871 Formatting::Check => self.check(&mut replacement, &ranges),
872 Formatting::ToDoList(tag, action_type) => {
873 let definition = tag.definition(*action_type);
874 let id = self.define_tag(&definition)?;
875 let created = time32();
876 for node in leaves_mut(&mut replacement) {
877 if ranges.remove(&node.id).is_none() || self.list(node).is_none() {
878 continue;
879 }
880 node.lists.clear();
881 if tags(node).any(|tag| self.tag_kind(tag) == Some(&definition.kind)) {
882 continue;
883 }
884 let ParagraphContent::Text(text) = &mut node.content else {
885 unreachable!()
886 };
887 let added = Some((id, tag.shape, created));
888 self.retag([&mut node.tags, &mut text.tags], &definition.kind, added);
889 }
890 }
891 Formatting::BulletedList(tag, action_type) => {
892 let kind = tag.definition(*action_type).kind;
893 for node in leaves_mut(&mut replacement) {
894 if ranges.remove(&node.id).is_none()
895 || !tags(node).any(|tag| self.tag_kind(tag) == Some(&kind))
896 {
897 continue;
898 }
899 if self.list(node) != Some(Formatting::Bullets) {
900 let format = &node.text().unwrap().text.spans()[0].format;
901 let list = list_definition(ListStyle::BULLET, format);
902 let id = new_id()?;
903 self.definitions.insert(id, list);
904 node.lists = vec![id];
905 }
906 let ParagraphContent::Text(text) = &mut node.content else {
907 unreachable!()
908 };
909 self.retag([&mut node.tags, &mut text.tags], &kind, None);
910 }
911 }
863 Formatting::Style(definition) => {912 Formatting::Style(definition) => {
864 let olds = bases(&replacement)?;913 let olds = bases(&replacement)?;
865 let style = self.define_style(definition)?;914 let style = self.define_style(definition)?;
...@@ -2300,6 +2349,57 @@ mod tests {...@@ -2300,6 +2349,57 @@ mod tests {
2300 assert_eq!(editor.active_outline().document, original);2349 assert_eq!(editor.active_outline().document, original);
2301 }2350 }
23022351
2352 #[test]
2353 fn lists_become_to_do_lists_and_back_as_one_undo_step_each() {
2354 let mut engine = TextEngine::default();
2355 let mut editor = plain(&mut engine, &["one", "two", "three"]);
2356 editor.select([at(0, 0), at(1, 3)].into()).unwrap();
2357 editor.format(&mut engine, Formatting::Bullets).unwrap();
2358 editor.select([at(1, 0); 2].into()).unwrap();
2359 editor.format(&mut engine, Formatting::Numbering).unwrap();
2360 let listed = editor.active_outline().document.clone();
2361 let all = Selection::from([at(0, 0), at(2, 5)]);
2362 editor.select(all).unwrap();
2363 assert!(editor.format_state().unwrap().listed);
2364 let to_do = NoteTag::defaults()[0].clone();
2365 editor
2366 .format(&mut engine, Formatting::ToDoList(to_do.clone(), 0))
2367 .unwrap();
2368 let lists = |editor: &CanvasEditor| {
2369 let document = &editor.active_outline().document;
2370 document
2371 .nodes()
2372 .iter()
2373 .map(|node| editor.list(node))
2374 .collect::<Vec<_>>()
2375 };
2376 assert_eq!(lists(&editor), [None, None, None]);
2377 let task = || vec![("To Do".to_owned(), 0, true)];
2378 assert_eq!(
2379 (0..3).map(|at| text_tags(&editor, at)).collect::<Vec<_>>(),
2380 [task(), task(), vec![]]
2381 );
2382 let state = editor.format_state().unwrap();
2383 assert!(!state.listed && state.tags.is_empty());
2384 editor.select([at(0, 0), at(1, 3)].into()).unwrap();
2385 assert_eq!(editor.format_state().unwrap().tags, [(to_do.clone(), 0)]);
2386 editor
2387 .format(&mut engine, Formatting::BulletedList(to_do, 0))
2388 .unwrap();
2389 assert_eq!(
2390 lists(&editor),
2391 [Some(Formatting::Bullets), Some(Formatting::Bullets), None]
2392 );
2393 assert_eq!(text_tags(&editor, 0), []);
2394 assert_eq!(text_tags(&editor, 1), []);
2395 let state = editor.format_state().unwrap();
2396 assert_eq!((state.list, state.tags), (Some(ListStyle::BULLET), vec![]));
2397 editor.undo(&mut engine).unwrap();
2398 assert_eq!(text_tags(&editor, 1), task());
2399 editor.undo(&mut engine).unwrap();
2400 assert_eq!(editor.active_outline().document, listed);
2401 }
2402
2303 #[test]2403 #[test]
2304 fn the_default_tags_are_onenotes_stored_definitions_and_all_draw() {2404 fn the_default_tags_are_onenotes_stored_definitions_and_all_draw() {
2305 use onestore::{RevisionIndex, Store, document::Document};2405 use onestore::{RevisionIndex, Store, document::Document};
...@@ -2522,6 +2622,7 @@ mod tests {...@@ -2522,6 +2622,7 @@ mod tests {
2522 alignment: Some(Alignment::Center),2622 alignment: Some(Alignment::Center),
2523 bullets: false,2623 bullets: false,
2524 numbering: true,2624 numbering: true,
2625 listed: true,
2525 list: Some(ListStyle::NUMBER),2626 list: Some(ListStyle::NUMBER),
2526 tags: vec![2627 tags: vec![
2527 (NoteTag::defaults()[0].clone(), 0),2628 (NoteTag::defaults()[0].clone(), 0),
crates/canvas/src/outline.rs+1-1
...@@ -324,7 +324,7 @@ impl TagIcon {...@@ -324,7 +324,7 @@ impl TagIcon {
324}324}
325325
326/// Whether tag symbol `shape` is a check box, as MS-ONE's NoteTagShape marks them.326/// Whether tag symbol `shape` is a check box, as MS-ONE's NoteTagShape marks them.
327pub(crate) fn checkable(shape: u16) -> bool {327pub fn checkable(shape: u16) -> bool {
328 matches!(328 matches!(
329 shape,329 shape,
330 1..=12 | 28 | 30 | 32 | 48 | 50 | 52 | 69 | 71 | 73 | 89..=99330 1..=12 | 28 | 30 | 32 | 48 | 50 | 52 | 69 | 71 | 73 | 89..=99
crates/canvas/tests/to_do_list.rs created+119
...@@ -0,0 +1,119 @@
1//! Make To-Do List trades bulleted and numbered paragraphs' lists for the To Do tag, and Make
2//! Bulleted List trades it back, each published as one revision (`corpus/to-do-list`).
3
4use canvas::{
5 document::TextPosition,
6 editor::{CanvasEditor, Formatting, NoteTag},
7 layout::TextEngine,
8};
9use onestore::{
10 Arena, ExGuid, Section, Store,
11 op::{Edit, Op},
12 page::{Page, PageObject},
13};
14
15const LISTS: &[u8] = include_bytes!("../../../corpus/list-edit/cold/notebook/lists.one");
16
17fn body(page: &Page) -> ExGuid {
18 page.objects
19 .iter()
20 .find_map(|object| match object {
21 PageObject::Outline(outline) => Some(outline.id),
22 _ => None,
23 })
24 .unwrap()
25}
26
27fn at(paragraph: usize) -> TextPosition {
28 TextPosition {
29 paragraph,
30 offset: 0,
31 }
32}
33
34/// Each paragraph's list, as `bullet`, `number` or none, and its tags' labels.
35fn items(page: &Page, engine: &mut TextEngine) -> Vec<(Option<&'static str>, Vec<String>)> {
36 let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap();
37 editor.focus_outline(body(page)).unwrap();
38 (0..7)
39 .map(|paragraph| {
40 editor.select([at(paragraph); 2].into()).unwrap();
41 let state = editor.format_state().unwrap();
42 let list = match (state.bullets, state.numbering) {
43 (true, _) => Some("bullet"),
44 (_, true) => Some("number"),
45 _ => None,
46 };
47 (
48 list,
49 state.tags.into_iter().map(|(tag, _)| tag.label).collect(),
50 )
51 })
52 .collect()
53}
54
55/// `SNOWBOUND_TO_DO_LIST_EXPORT` names a new directory receiving the candidate for a cold
56/// reopen.
57#[test]
58fn lists_become_to_do_lists_and_back_one_revision_each() {
59 let arena = Arena::default();
60 let mut section = Section::open(&arena, LISTS.to_vec()).unwrap();
61 let space = section.pages().unwrap()[0].0;
62 let page = section.page(space).unwrap();
63 let mut engine = TextEngine::default();
64 let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
65 editor.focus_outline(body(&page)).unwrap();
66 let to_do = NoteTag::defaults()[0].clone();
67 let mut publish = |editor: &mut CanvasEditor, at| {
68 let ops = editor
69 .take_ops()
70 .unwrap()
71 .into_iter()
72 .map(|op| Op::Page { space, op })
73 .collect();
74 section.apply("Author", &Edit { at, ops }).unwrap();
75 };
76 // Through "Nested numbered", the last paragraph each edit takes.
77 let nested = TextPosition {
78 paragraph: 3,
79 offset: 15,
80 };
81 editor.select([at(0), nested].into()).unwrap();
82 editor
83 .format(&mut engine, Formatting::ToDoList(to_do.clone(), 0))
84 .unwrap();
85 publish(&mut editor, 134_000_000_000_000_000);
86 editor.select([at(2), nested].into()).unwrap();
87 editor
88 .format(&mut engine, Formatting::BulletedList(to_do, 0))
89 .unwrap();
90 publish(&mut editor, 134_000_000_010_000_000);
91
92 let task = || vec!["To Do".to_owned()];
93 let expected = [
94 (None, task()),
95 (None, task()),
96 (Some("bullet"), vec![]),
97 (Some("bullet"), vec![]),
98 (Some("number"), vec![]),
99 (None, vec![]),
100 (None, vec![]),
101 ];
102 assert_eq!(items(&section.page(space).unwrap(), &mut engine), expected);
103 section.seal().unwrap();
104 let written = section.image();
105 let reread = Section::open(&arena, written.clone()).unwrap();
106 assert_eq!(items(&reread.page(space).unwrap(), &mut engine), expected);
107 if let Some(directory) = std::env::var_os("SNOWBOUND_TO_DO_LIST_EXPORT") {
108 let directory = std::path::PathBuf::from(directory);
109 std::fs::create_dir(&directory).unwrap();
110 std::fs::write(directory.join("lists.one"), &written).unwrap();
111 let file_id = Store::parse(&written).unwrap().header.file_id;
112 std::fs::write(
113 directory.join("Open Notebook.onetoc2"),
114 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("lists.one", file_id)])
115 .unwrap(),
116 )
117 .unwrap();
118 }
119}
crates/snowbound/src/commands.rs+34
...@@ -113,6 +113,11 @@ pub enum Id {...@@ -113,6 +113,11 @@ pub enum Id {
113 CustomizeTags,113 CustomizeTags,
114 RemoveTags,114 RemoveTags,
115 FindTags,115 FindTags,
116 /// Gives the selected bulleted and numbered paragraphs the To Do tag in place of
117 /// their list.
118 ToDoList,
119 /// Gives the selected To Do paragraphs a bullet in place of their tag.
120 BulletedList,
116 Help,121 Help,
117 CheckForUpdates,122 CheckForUpdates,
118}123}
...@@ -598,6 +603,8 @@ pub const COMMANDS: &[Command] = &[...@@ -598,6 +603,8 @@ pub const COMMANDS: &[Command] = &[
598 &[cmd('0')]603 &[cmd('0')]
599 ),604 ),
600 row!(Id::FindTags, "Find Tags", NONE, NONE),605 row!(Id::FindTags, "Find Tags", NONE, NONE),
606 row!(Id::ToDoList, "Make To-Do List", NONE, NONE),
607 row!(Id::BulletedList, "Make Bulleted List", NONE, NONE),
601 row!(Id::Help, "Snowbound Help", NONE, NONE),608 row!(Id::Help, "Snowbound Help", NONE, NONE),
602 row!(Id::CheckForUpdates, "Check for Updates…", NONE, NONE),609 row!(Id::CheckForUpdates, "Check for Updates…", NONE, NONE),
603];610];
...@@ -1290,6 +1297,14 @@ impl State {...@@ -1290,6 +1297,14 @@ impl State {
1290 Id::Theme(scope) => enabled(!modal && self.theme_target(scope).is_some()),1297 Id::Theme(scope) => enabled(!modal && self.theme_target(scope).is_some()),
1291 // A selected picture or file takes tags too, as in OneNote.1298 // A selected picture or file takes tags too, as in OneNote.
1292 Id::RemoveTags => enabled(text || tagged),1299 Id::RemoveTags => enabled(text || tagged),
1300 Id::ToDoList => enabled(text && format.listed && self.to_do().is_some()),
1301 Id::BulletedList => enabled(
1302 text && self.to_do().is_some_and(|place| {
1303 format
1304 .tags
1305 .contains(&(self.tags[place].stored(), place as u16))
1306 }),
1307 ),
1293 Id::Toggle(toggle) => checked(format.toggles.contains(&toggle)),1308 Id::Toggle(toggle) => checked(format.toggles.contains(&toggle)),
1294 Id::Bullets => checked(format.bullets),1309 Id::Bullets => checked(format.bullets),
1295 Id::Numbering => checked(format.numbering),1310 Id::Numbering => checked(format.numbering),
...@@ -1329,6 +1344,11 @@ impl State {...@@ -1329,6 +1344,11 @@ impl State {
1329 Ok(())1344 Ok(())
1330 }1345 }
13311346
1347 /// The place of the first check box tag in the user's list, To Do in OneNote's.
1348 fn to_do(&self) -> Option<usize> {
1349 (self.tags.iter()).position(|tag| canvas::outline::checkable(tag.shape))
1350 }
1351
1332 /// The notebook the open section, or the notebook showing none, belongs to.1352 /// The notebook the open section, or the notebook showing none, belongs to.
1333 fn notebook(&self) -> Option<&Arc<crate::Library>> {1353 fn notebook(&self) -> Option<&Arc<crate::Library>> {
1334 self.session1354 self.session
...@@ -1683,6 +1703,20 @@ impl State {...@@ -1683,6 +1703,20 @@ impl State {
1683 return Ok(());1703 return Ok(());
1684 }1704 }
1685 Id::RemoveTags => return format(self, Formatting::RemoveTags),1705 Id::RemoveTags => return format(self, Formatting::RemoveTags),
1706 Id::ToDoList | Id::BulletedList => {
1707 let place = self.to_do().expect("Enabled with a To Do tag");
1708 let tag = self.tags[place].clone();
1709 format(
1710 self,
1711 if id == Id::ToDoList {
1712 Formatting::ToDoList(tag, place as u16)
1713 } else {
1714 Formatting::BulletedList(tag, place as u16)
1715 },
1716 )?;
1717 self.keep_tag_art(place);
1718 return Ok(());
1719 }
1686 };1720 };
1687 self.commands.push(response);1721 self.commands.push(response);
1688 Ok(())1722 Ok(())
crates/snowbound/src/link.rs+21-1
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
2//! the page's context menu, and following a clicked link, to a page2//! the page's context menu, and following a clicked link, to a page
3//! of the notebook or with the system's handler.3//! of the notebook or with the system's handler.
44
5use crate::{Command, State, page, platform};5use crate::{Command, State, commands, page, platform};
6use onestore::ExGuid;6use onestore::ExGuid;
7use onestore::page::link::{LinkTarget, internal_link};7use onestore::page::link::{LinkTarget, internal_link};
8use std::error::Error;8use std::error::Error;
...@@ -330,6 +330,14 @@ impl State {...@@ -330,6 +330,14 @@ impl State {
330 None => Vec::new(),330 None => Vec::new(),
331 };331 };
332 let corrections = items.len();332 let corrections = items.len();
333 let format = self.format_state();
334 let lists = [commands::Id::ToDoList, commands::Id::BulletedList]
335 .into_iter()
336 .filter(|id| {
337 let choice = commands::Choice::Command(*id);
338 self.status(&choice, &format).enabled
339 })
340 .collect::<Vec<_>>();
333 items.extend(if context.attachment.is_some() {341 items.extend(if context.attachment.is_some() {
334 // OneNote 2010's commands for the file itself; its clipboard holds text alone.342 // OneNote 2010's commands for the file itself; its clipboard holds text alone.
335 vec![item("Open"), item("Save As…")]343 vec![item("Open"), item("Save As…")]
...@@ -368,6 +376,12 @@ impl State {...@@ -368,6 +376,12 @@ impl State {
368 item("Copy Link to Paragraph"),376 item("Copy Link to Paragraph"),
369 ]),377 ]),
370 }378 }
379 for (at, id) in lists.iter().enumerate() {
380 items.push(Item {
381 separated: at == 0,
382 ..item(commands::command(*id).title)
383 });
384 }
371 if context.equation {385 if context.equation {
372 items.extend([386 items.extend([
373 Item {387 Item {
...@@ -414,6 +428,12 @@ impl State {...@@ -414,6 +428,12 @@ impl State {
414 self.respond(response);428 self.respond(response);
415 return Ok(());429 return Ok(());
416 }430 }
431 if let Some(id) = lists
432 .into_iter()
433 .find(|id| commands::command(*id).title == chosen_text)
434 {
435 return self.run(commands::Choice::Command(id));
436 }
417 let response = match chosen_text.as_str() {437 let response = match chosen_text.as_str() {
418 "Open" => return self.open_attachment(&context.attachment.unwrap()),438 "Open" => return self.open_attachment(&context.attachment.unwrap()),
419 "Save As…" => return self.save_attachment(&context.attachment.unwrap()),439 "Save As…" => return self.save_attachment(&context.attachment.unwrap()),
tools/test_to_do_list.py created+57
...@@ -0,0 +1,57 @@
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/to-do-list'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13
14
15def items(read):
16 """Each paragraph's text, list kind and tags' names, in page order."""
17 page, = (ET.parse(path).getroot() for path in sorted(read.glob('page-*.xml')))
18 names = {d.get('index'): d.get('name') for d in page.findall('one:TagDef', ns)}
19 result = []
20 for oe in page.iter('{%s}OE' % ns['one']):
21 marker = oe.find('one:List', ns)
22 kind = None if marker is None else list(marker)[0].tag.split('}')[1]
23 tags = [names[tag.get('index')] for tag in oe.findall('one:Tag', ns)]
24 result.append((oe.find('one:T', ns).text, kind, tags))
25 return result
26
27
28class ToDoListTest(unittest.TestCase):
29 def test_onenote_tags_lists_without_removing_them(self):
30 self.assertEqual(items(FIXTURE / 'native/read'), [
31 ('Bullet item', 'Bullet', ['To Do']),
32 ('First numbered', 'Number', ['To Do']),
33 ('Second numbered', 'Number', ['To Do']),
34 ('Nested numbered', 'Number', []),
35 ('Restarted at three', 'Number', ['To Do']),
36 ('Nested plain', None, []),
37 ('Plain again', None, ['To Do']),
38 ])
39
40 def test_snowbound_to_do_and_bulleted_lists_reopen_natively(self):
41 with TemporaryDirectory() as temporary:
42 read = Path(temporary) / 'read'
43 shutil.copytree(FIXTURE / 'cold/read', read)
44 compare(FIXTURE / 'candidate', read)
45 self.assertEqual(items(FIXTURE / 'cold/read'), [
46 ('Bullet item', None, ['To Do']),
47 ('First numbered', None, ['To Do']),
48 ('Second numbered', 'Bullet', []),
49 ('Nested numbered', 'Bullet', []),
50 ('Restarted at three', 'Number', []),
51 ('Nested plain', None, []),
52 ('Plain again', None, []),
53 ])
54
55
56if __name__ == '__main__':
57 unittest.main()