| author | |
| committer | |
| log | f3bfb4de18ec03dca625dd95d5d5f2ae34383837 |
| tree | 6283c6abbc31cbfac63fca17a2de9d91730b3f6b |
| parent | f0c2aa56db4f3d91b99d0ac66f91a6c53f66f4cf |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.542 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 |
| 129 | Ctrl+click, Command+click on macOS, while a click selects it, as OneNote's | 129 | Ctrl+click, Command+click on macOS, while a click selects it, as OneNote's |
| 130 | tooltip says (`corpus/picture-link`). | 130 | tooltip says (`corpus/picture-link`). |
| 131 | 131 | ||
| 132 | OneNote 2010's Ctrl+1 leaves a bulleted or numbered paragraph's list beside its | ||
| 133 | new To Do box, and it has no command that converts a list. Make To-Do List, in | ||
| 134 | the page's context menu and the palette, trades the selected paragraphs' bullets | ||
| 135 | and numbers for the first check box tag in the user's list as one undo step; | ||
| 136 | Make Bulleted List trades the tag back for a bullet (`corpus/to-do-list`). | ||
| 137 | |||
| 132 | An outline holding only pictures or files takes a paragraph after them where a | 138 | An outline holding only pictures or files takes a paragraph after them where a |
| 133 | click beside them lands, as OneNote 2010 adds one when typing there. It is | 139 | click beside them lands, as OneNote 2010 adds one when typing there. It is |
| 134 | stored with the first edit that reaches it, and undoing back to it empty takes it | 140 | stored 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 | ||
| 4 | outline'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 | ||
| 6 | beside them (`3-tag.png`); the selected top-level paragraphs take the tag and | ||
| 7 | their nested children do not. Its context menu on the selection offers no | ||
| 8 | command that converts a list (`2-menu.png`). | ||
| 9 | |||
| 10 | `candidate/` is the output of `lists_become_to_do_lists_and_back_one_revision_each` | ||
| 11 | in `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 | ||
| 13 | tags each To Do, then Make Bulleted List over "Second numbered" and "Nested | ||
| 14 | numbered" removes the tag and gives each OneNote's default bullet, each published | ||
| 15 | as its own revision. | ||
| 16 | |||
| 17 | `cold/` is a fresh OneNote 2010 read: the first two paragraphs are open To Do | ||
| 18 | items with no list, the next two are bulleted and untagged, and "Restarted at | ||
| 19 | three" keeps its number. `tools/test_to_do_list.py` checks both reads without a | ||
| 20 | VM. Regenerate with `SNOWBOUND_TO_DO_LIST_EXPORT` set to a new absolute directory | ||
| 21 | while 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 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 7 | } | ||
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | ||
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | ||
| 10 | if ($CloneHost) { | ||
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | ||
| 12 | throw 'The disposable clone hostname does not match this machine.' | ||
| 13 | } | ||
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | ||
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | ||
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | ||
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | ||
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | ||
| 19 | } | ||
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | ||
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | ||
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | ||
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | ||
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | ||
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | ||
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | ||
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null | ||
corpus/to-do-list/cold/scripts/read.ps1 created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | param( | ||
| 2 | [Parameter(Mandatory=$true)][string]$Root, | ||
| 3 | [int]$ExpectedPages = -1, | ||
| 4 | [switch]$UseCurrentCache, | ||
| 5 | [switch]$Pdf, | ||
| 6 | [switch]$KeepOpen, | ||
| 7 | [string]$CloneHost = '' | ||
| 8 | ) | ||
| 9 | Set-StrictMode -Version Latest | ||
| 10 | $ErrorActionPreference = 'Stop' | ||
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 14 | } | ||
| 15 | $notebook = Join-Path $root 'notebook' | ||
| 16 | $output = Join-Path $root 'read' | ||
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | ||
| 18 | if ($UseCurrentCache) { | ||
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | ||
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | ||
| 21 | } | ||
| 22 | } else { | ||
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | ||
| 24 | } | ||
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | ||
| 26 | $app = New-Object -ComObject OneNote.Application | ||
| 27 | $notebookId = '' | ||
| 28 | $failure = $null | ||
| 29 | try { | ||
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | ||
| 31 | $process = Get-Process ONENOTE | ||
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | ||
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | ||
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | ||
| 35 | $sections = @() | ||
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | ||
| 37 | $id = '' | ||
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | ||
| 39 | $sections += $id | ||
| 40 | } | ||
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | ||
| 42 | $previous = '' | ||
| 43 | $lastChange = '' | ||
| 44 | $stableSince = [DateTime]::UtcNow | ||
| 45 | $settled = $false | ||
| 46 | do { | ||
| 47 | $pages = @{} | ||
| 48 | foreach ($section in $sections) { | ||
| 49 | $hierarchy = '' | ||
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | ||
| 51 | [xml]$xml = $hierarchy | ||
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | ||
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | ||
| 54 | throw 'OneNote opened a section outside the copied notebook.' | ||
| 55 | } | ||
| 56 | } | ||
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | ||
| 58 | $id = $node.GetAttribute('ID') | ||
| 59 | $content = '' | ||
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | ||
| 61 | $pages[$id] = $content | ||
| 62 | } | ||
| 63 | } | ||
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | ||
| 65 | if ($signature -ne $previous) { | ||
| 66 | $lastChange = $previous | ||
| 67 | $previous = $signature | ||
| 68 | $stableSince = [DateTime]::UtcNow | ||
| 69 | } | ||
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | ||
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | ||
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | ||
| 73 | Start-Sleep -Milliseconds 250 | ||
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | ||
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | ||
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | ||
| 77 | $index = 0 | ||
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | ||
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | ||
| 80 | $index++ | ||
| 81 | } | ||
| 82 | $hierarchy = '' | ||
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | ||
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | ||
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | ||
| 86 | } | ||
| 87 | $index = 0 | ||
| 88 | $payloads = @() | ||
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | ||
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | ||
| 91 | if ($Pdf) { | ||
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | ||
| 93 | $app.NavigateTo($id, '', $false) | ||
| 94 | $app.Publish($id, $pdfPath, 3, '') | ||
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | ||
| 96 | } | ||
| 97 | [xml]$page = $pages[$id] | ||
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | ||
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | ||
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | ||
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | ||
| 102 | # 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 @@ | ||
| 1 | app := ComObject("OneNote.Application") | ||
| 2 | app.NavigateTo("{3A318476-D3AE-0D72-0308-0A93E64A687F}{1}{B0}", "{55287AA0-9FCF-0E9B-2D6D-3E6C43F50BC3}{24}{B0}", false) | ||
| 3 | hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 20) | ||
| 4 | WinMaximize(hwnd) | ||
| 5 | WinActivate(hwnd) | ||
| 6 | WinWaitActive(hwnd,, 10) | ||
| 7 | app.NavigateTo("{3A318476-D3AE-0D72-0308-0A93E64A687F}{1}{B0}", "{55287AA0-9FCF-0E9B-2D6D-3E6C43F50BC3}{24}{B0}", false) | ||
| 8 | Sleep(1500) | ||
| 9 | Send("{Home}") | ||
| 10 | Sleep(300) | ||
| 11 | Send("^a") | ||
| 12 | Sleep(400) | ||
| 13 | Send("^a") | ||
| 14 | Sleep(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 @@ | ||
| 1 | Send("{Escape}") | ||
| 2 | Sleep(500) | ||
| 3 | Send("^1") | ||
| 4 | Sleep(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 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 7 | } | ||
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | ||
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | ||
| 10 | if ($CloneHost) { | ||
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | ||
| 12 | throw 'The disposable clone hostname does not match this machine.' | ||
| 13 | } | ||
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | ||
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | ||
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | ||
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | ||
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | ||
| 19 | } | ||
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | ||
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | ||
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | ||
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | ||
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | ||
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | ||
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | ||
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null | ||
corpus/to-do-list/native/scripts/read.ps1 created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | param( | ||
| 2 | [Parameter(Mandatory=$true)][string]$Root, | ||
| 3 | [int]$ExpectedPages = -1, | ||
| 4 | [switch]$UseCurrentCache, | ||
| 5 | [switch]$Pdf, | ||
| 6 | [switch]$KeepOpen, | ||
| 7 | [string]$CloneHost = '' | ||
| 8 | ) | ||
| 9 | Set-StrictMode -Version Latest | ||
| 10 | $ErrorActionPreference = 'Stop' | ||
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 14 | } | ||
| 15 | $notebook = Join-Path $root 'notebook' | ||
| 16 | $output = Join-Path $root 'read' | ||
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | ||
| 18 | if ($UseCurrentCache) { | ||
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | ||
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | ||
| 21 | } | ||
| 22 | } else { | ||
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | ||
| 24 | } | ||
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | ||
| 26 | $app = New-Object -ComObject OneNote.Application | ||
| 27 | $notebookId = '' | ||
| 28 | $failure = $null | ||
| 29 | try { | ||
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | ||
| 31 | $process = Get-Process ONENOTE | ||
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | ||
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | ||
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | ||
| 35 | $sections = @() | ||
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | ||
| 37 | $id = '' | ||
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | ||
| 39 | $sections += $id | ||
| 40 | } | ||
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | ||
| 42 | $previous = '' | ||
| 43 | $lastChange = '' | ||
| 44 | $stableSince = [DateTime]::UtcNow | ||
| 45 | $settled = $false | ||
| 46 | do { | ||
| 47 | $pages = @{} | ||
| 48 | foreach ($section in $sections) { | ||
| 49 | $hierarchy = '' | ||
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | ||
| 51 | [xml]$xml = $hierarchy | ||
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | ||
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | ||
| 54 | throw 'OneNote opened a section outside the copied notebook.' | ||
| 55 | } | ||
| 56 | } | ||
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | ||
| 58 | $id = $node.GetAttribute('ID') | ||
| 59 | $content = '' | ||
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | ||
| 61 | $pages[$id] = $content | ||
| 62 | } | ||
| 63 | } | ||
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | ||
| 65 | if ($signature -ne $previous) { | ||
| 66 | $lastChange = $previous | ||
| 67 | $previous = $signature | ||
| 68 | $stableSince = [DateTime]::UtcNow | ||
| 69 | } | ||
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | ||
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | ||
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | ||
| 73 | Start-Sleep -Milliseconds 250 | ||
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | ||
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | ||
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | ||
| 77 | $index = 0 | ||
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | ||
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | ||
| 80 | $index++ | ||
| 81 | } | ||
| 82 | $hierarchy = '' | ||
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | ||
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | ||
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | ||
| 86 | } | ||
| 87 | $index = 0 | ||
| 88 | $payloads = @() | ||
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | ||
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | ||
| 91 | if ($Pdf) { | ||
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | ||
| 93 | $app.NavigateTo($id, '', $false) | ||
| 94 | $app.Publish($id, $pdfPath, 3, '') | ||
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | ||
| 96 | } | ||
| 97 | [xml]$page = $pages[$id] | ||
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | ||
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | ||
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | ||
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | ||
| 102 | # 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 text | 47 | /// 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::RemoveTags | 761 | | Formatting::RemoveTags |
| 755 | | Formatting::Check | 762 | | 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 | } |
| 2302 | 2351 | ||
| 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 | } |
| 325 | 325 | ||
| 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. |
| 327 | pub(crate) fn checkable(shape: u16) -> bool { | 327 | pub 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..=99 | 330 | 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 | |||
| 4 | use canvas::{ | ||
| 5 | document::TextPosition, | ||
| 6 | editor::{CanvasEditor, Formatting, NoteTag}, | ||
| 7 | layout::TextEngine, | ||
| 8 | }; | ||
| 9 | use onestore::{ | ||
| 10 | Arena, ExGuid, Section, Store, | ||
| 11 | op::{Edit, Op}, | ||
| 12 | page::{Page, PageObject}, | ||
| 13 | }; | ||
| 14 | |||
| 15 | const LISTS: &[u8] = include_bytes!("../../../corpus/list-edit/cold/notebook/lists.one"); | ||
| 16 | |||
| 17 | fn 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 | |||
| 27 | fn 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. | ||
| 35 | fn 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] | ||
| 58 | fn 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 | } |
| 1331 | 1346 | ||
| 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.session | 1354 | 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 page | 2 | //! 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. |
| 4 | 4 | ||
| 5 | use crate::{Command, State, page, platform}; | 5 | use crate::{Command, State, commands, page, platform}; |
| 6 | use onestore::ExGuid; | 6 | use onestore::ExGuid; |
| 7 | use onestore::page::link::{LinkTarget, internal_link}; | 7 | use onestore::page::link::{LinkTarget, internal_link}; |
| 8 | use std::error::Error; | 8 | use 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 @@ | ||
| 1 | from pathlib import Path | ||
| 2 | import runpy | ||
| 3 | import shutil | ||
| 4 | from tempfile import TemporaryDirectory | ||
| 5 | import unittest | ||
| 6 | import xml.etree.ElementTree as ET | ||
| 7 | |||
| 8 | from native_xml import ns | ||
| 9 | |||
| 10 | ROOT = Path(__file__).resolve().parent.parent | ||
| 11 | FIXTURE = ROOT / 'corpus/to-do-list' | ||
| 12 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | ||
| 13 | |||
| 14 | |||
| 15 | def 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 | |||
| 28 | class 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 | |||
| 56 | if __name__ == '__main__': | ||
| 57 | unittest.main() | ||