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
129129Ctrl+click, Command+click on macOS, while a click selects it, as OneNote's
130130tooltip 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
132138An outline holding only pictures or files takes a paragraph after them where a
133139click beside them lands, as OneNote 2010 adds one when typing there. It is
134140stored 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 {
4040 RemoveTags,
4141 /// Checks the selected paragraphs' check boxes, or clears them when all are checked.
4242 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),
4347 /// Format Painter: gives the selection the character formatting and alignment of text
4448 /// picked up with [`CanvasEditor::painted_format`], as OneNote's does.
4549 Paint(Format),
......@@ -344,6 +348,8 @@ pub struct FormatState {
344348 /// Whether every selected paragraph is bulleted or numbered.
345349 pub bullets: bool,
346350 pub numbering: bool,
351 /// Whether any selected paragraph is bulleted or numbered.
352 pub listed: bool,
347353 /// The library style every selected paragraph's list shares.
348354 pub list: Option<ListStyle>,
349355 /// Tags every selected paragraph has, with their action types.
......@@ -701,6 +707,7 @@ impl CanvasEditor {
701707 })),
702708 bullets: list(Formatting::Bullets),
703709 numbering: list(Formatting::Numbering),
710 listed: paragraphs.iter().any(|node| self.list(node).is_some()),
704711 list: common(paragraphs.iter().map(|node| self.list_style(node))),
705712 tags: paragraphs.first().map_or_else(Vec::new, |first| {
706713 let mut shared: Vec<_> = tags(first)
......@@ -753,6 +760,8 @@ impl CanvasEditor {
753760 | Formatting::Tag(..)
754761 | Formatting::RemoveTags
755762 | Formatting::Check
763 | Formatting::ToDoList(..)
764 | Formatting::BulletedList(..)
756765 | Formatting::Style(_)
757766 if title =>
758767 {
......@@ -860,6 +869,46 @@ impl CanvasEditor {
860869 }
861870 }
862871 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 }
863912 Formatting::Style(definition) => {
864913 let olds = bases(&replacement)?;
865914 let style = self.define_style(definition)?;
......@@ -2300,6 +2349,57 @@ mod tests {
23002349 assert_eq!(editor.active_outline().document, original);
23012350 }
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
23032403 #[test]
23042404 fn the_default_tags_are_onenotes_stored_definitions_and_all_draw() {
23052405 use onestore::{RevisionIndex, Store, document::Document};
......@@ -2522,6 +2622,7 @@ mod tests {
25222622 alignment: Some(Alignment::Center),
25232623 bullets: false,
25242624 numbering: true,
2625 listed: true,
25252626 list: Some(ListStyle::NUMBER),
25262627 tags: vec![
25272628 (NoteTag::defaults()[0].clone(), 0),
crates/canvas/src/outline.rs+1-1
......@@ -324,7 +324,7 @@ impl TagIcon {
324324}
325325
326326/// 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 {
328328 matches!(
329329 shape,
330330 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 {
113113 CustomizeTags,
114114 RemoveTags,
115115 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,
116121 Help,
117122 CheckForUpdates,
118123}
......@@ -598,6 +603,8 @@ pub const COMMANDS: &[Command] = &[
598603 &[cmd('0')]
599604 ),
600605 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),
601608 row!(Id::Help, "Snowbound Help", NONE, NONE),
602609 row!(Id::CheckForUpdates, "Check for Updates…", NONE, NONE),
603610];
......@@ -1290,6 +1297,14 @@ impl State {
12901297 Id::Theme(scope) => enabled(!modal && self.theme_target(scope).is_some()),
12911298 // A selected picture or file takes tags too, as in OneNote.
12921299 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 ),
12931308 Id::Toggle(toggle) => checked(format.toggles.contains(&toggle)),
12941309 Id::Bullets => checked(format.bullets),
12951310 Id::Numbering => checked(format.numbering),
......@@ -1329,6 +1344,11 @@ impl State {
13291344 Ok(())
13301345 }
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
13321352 /// The notebook the open section, or the notebook showing none, belongs to.
13331353 fn notebook(&self) -> Option<&Arc<crate::Library>> {
13341354 self.session
......@@ -1683,6 +1703,20 @@ impl State {
16831703 return Ok(());
16841704 }
16851705 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 }
16861720 };
16871721 self.commands.push(response);
16881722 Ok(())
crates/snowbound/src/link.rs+21-1
......@@ -2,7 +2,7 @@
22//! the page's context menu, and following a clicked link, to a page
33//! of the notebook or with the system's handler.
44
5use crate::{Command, State, page, platform};
5use crate::{Command, State, commands, page, platform};
66use onestore::ExGuid;
77use onestore::page::link::{LinkTarget, internal_link};
88use std::error::Error;
......@@ -330,6 +330,14 @@ impl State {
330330 None => Vec::new(),
331331 };
332332 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<_>>();
333341 items.extend(if context.attachment.is_some() {
334342 // OneNote 2010's commands for the file itself; its clipboard holds text alone.
335343 vec![item("Open"), item("Save As…")]
......@@ -368,6 +376,12 @@ impl State {
368376 item("Copy Link to Paragraph"),
369377 ]),
370378 }
379 for (at, id) in lists.iter().enumerate() {
380 items.push(Item {
381 separated: at == 0,
382 ..item(commands::command(*id).title)
383 });
384 }
371385 if context.equation {
372386 items.extend([
373387 Item {
......@@ -414,6 +428,12 @@ impl State {
414428 self.respond(response);
415429 return Ok(());
416430 }
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 }
417437 let response = match chosen_text.as_str() {
418438 "Open" => return self.open_attachment(&context.attachment.unwrap()),
419439 "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()