authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 16:20:50-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 17:42:34-07:00
log3b58d8f8a9667e9578deab17b4bcd96e05515ad8
tree4c0740d87818583c33a717df08f7ab7af682ddbe
parentc514498e33b81d02e7ad056ab3de8df11182d1fe
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: change-driven sync with offline copies, Outlook task tags, OneNote-exact tag art, and pixel-snapped icons

- Closed sections sync from change notifications instead of a timer: one recursive CHANGE_NOTIFY per notebook on the embedded SMB client and the OS watcher for local folders, with staggered checks, a retry for failing sections, and an hourly backstop. Every section of a notebook on a share gets an offline copy, as OneNote 2010 keeps. - Outlook task tags keep their shape and property status, never write a definition reference, stay read-only, and are removable; undo of a page delete restores them. - Tag art matches OneNote's rendering pixel for pixel on white: symbol 54 is green, check boxes and badges are redrawn from OneNote's pixels, with translucent edges for dark pages. - Icons land on whole device pixels. - The macOS and Linux toolbar rows use equal leading and trailing insets. Assisted-by: claude-opus-5.5

130 files changed, 4572 insertions(+), 634 deletions(-)

arc/sync.md+29-7
...@@ -176,13 +176,35 @@ online again....@@ -176,13 +176,35 @@ online again.
176176
177## Sections that aren't open177## Sections that aren't open
178178
179OneNote keeps every section of an open notebook in sync, not just the one on screen: it179OneNote keeps every section of an open notebook in sync and on this computer, not just
180reads a section another client changed within seconds and publishes a closed section's180the one on screen. Watched in the lab with a notebook of 200 sections on Samba, OneNote
181offline edits the moment the share is back. Snowbound's `session::Background` does the1812010:
182same with one thread per notebook. Each round reads every section file's stamp. Only a182
183section whose replica has edits waiting, or whose file moved past the replica's base,183- reads every section file once when the notebook opens (all 200 within 15 seconds), so a
184has its replica opened for the usual sync steps, and it is closed again afterwards. The184 section never shown before opens with the share gone;
185open section is left to its own session.185- arms one CHANGE_NOTIFY on the notebook's folder, with WATCH_TREE and a filter of names,
186 attributes, size and last write, and otherwise sends nothing: 22 idle minutes put no SMB
187 request on the wire, not even an echo;
188- lists the folder once when the server reports a change, and reads only the section that
189 changed, some seconds later;
190- tries a section it could not reach again about every 31 seconds.
191
192Snowbound's `session::Background` does the same, with one thread per notebook:
193
194```text
195share ─ CHANGE_NOTIFY (tree) ─► the sections it names are due in 1 s ─► stamp ─► moved: rebase
196first pass: one section every 100 ms; a section without a replica gets its offline copy
197failing: again in 31 s otherwise: again in an hour, against a lost notification
198```
199
200A check costs one stamp read. Only a section whose replica has edits waiting, or whose file
201moved past the replica's base, has its replica opened for the usual sync steps, and it is
202closed again afterwards; the open section is left to its own session. A connection that
203drops takes its watch with it, so the next one checks every section again. A notebook in a
204folder on this computer has no copies and learns of changes from FSEvents or inotify; one on
205a network volume the system mounted, whose server's changes those never report, keeps copies
206and is checked every 15 seconds. Closing a notebook deletes its copies, except any with
207edits still waiting.
186208
187## Notebook structure209## Notebook structure
188210
corpus/custom-tags/README.md+3-1
...@@ -18,7 +18,9 @@ absolute directory while running the test, then cold-open it with...@@ -18,7 +18,9 @@ absolute directory while running the test, then cold-open it with
18`native/shapes.one` is OneNote 2010's own work in a lab clone:18`native/shapes.one` is OneNote 2010's own work in a lab clone:
1919
20- "Shapes 1-72" and "Shapes 73-143" tag a paragraph with every NoteTagShape, set through20- "Shapes 1-72" and "Shapes 73-143" tag a paragraph with every NoteTagShape, set through
21 COM (its definitions store NoteTagPropertyStatus 0).21 COM (its definitions store NoteTagPropertyStatus 0). `shapes-1-72.png` and
22 `shapes-73-143.png` are OneNote's render of them at 100%, which
23 `crates/canvas/src/gpu/mod.rs` pins each shape's art to.
22- "Custom tags" holds a tag made with New Tag (Snow check, as above), applied at the top24- "Custom tags" holds a tag made with New Tag (Snow check, as above), applied at the top
23 of the list (action type 0), then after moving it below To Do (a second definition,25 of the list (action type 0), then after moving it below To Do (a second definition,
24 action type 1), with To Do applied from both places. The UI stores status 15 and 9.26 action type 1), with To Do applied from both places. The UI stores status 15 and 9.
corpus/custom-tags/native/shapes-1-72.png created
Binary files /dev/null and b/corpus/custom-tags/native/shapes-1-72.png differ
corpus/custom-tags/native/shapes-73-143.png created
Binary files /dev/null and b/corpus/custom-tags/native/shapes-73-143.png differ
corpus/ink-tools/native-ui/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-tools/native-ui/notebook/Open Notebook.onetoc2 differ
corpus/ink-tools/native-ui/notebook/ink.one created
Binary files /dev/null and b/corpus/ink-tools/native-ui/notebook/ink.one differ
corpus/task-tags/README.md created+20
...@@ -0,0 +1,20 @@
1# Outlook task tags through the editor
2
3`native/` is OneNote 2010 authoring `tools/native/pages.ps1` input (two identical
4pages in `Tasks.one`: three paragraphs carrying disabled Outlook tasks, the third
5also a To Do tag, and an untagged one). OneNote stores each task as a note tag
6without a definition, with ActionItemType 100, NoteTagShape 89 and
7NoteTagPropertyStatus 0.
8
9`candidate/` is the output of `task_tags_survive_tag_removal_and_page_delete_undo`
10in `crates/canvas/tests/task_tags.rs`: on "Remove a task" the editor's Remove Tag
11clears the first paragraph's task and toggling To Do off the third keeps its
12task; "Delete and undo" has all its content deleted and the deletion undone,
13each published as its own revision.
14
15`cold/` is a fresh OneNote 2010 read: the first paragraph has no task, the other
16tasks keep their identities, dates and disabled state, and the restored page
17reads as OneNote authored it. `tools/test_task_tags.py` checks this without a
18VM. Regenerate with `SNOWBOUND_TASK_TAGS_EXPORT` set to a new absolute directory
19while running the test, then cold-open it with
20`tools/native_runner.py OUTPUT COLD --expected-pages 2 --collect-notebook`.
corpus/task-tags/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/task-tags/candidate/Open Notebook.onetoc2 differ
corpus/task-tags/candidate/Tasks.one created
Binary files /dev/null and b/corpus/task-tags/candidate/Tasks.one differ
corpus/task-tags/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-7A22B21E -ExpectedPages 2 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 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/task-tags/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-7a22b21e", "hostname": "ONE-M6-7A22B21E"}
corpus/task-tags/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/task-tags/cold/notebook/Open Notebook.onetoc2 differ
corpus/task-tags/cold/notebook/Tasks.one created
Binary files /dev/null and b/corpus/task-tags/cold/notebook/Tasks.one differ
corpus/task-tags/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-7A22B21E",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/task-tags/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="{4F747CD9-2758-443D-AA14-AB7D6C27F6C2}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-29T23:40:23.000Z" color="#B49EDE"><one:Section name="Tasks" ID="{B2D88F6B-6681-0502-0D9C-B3FBE614824C}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Tasks.one" lastModifiedTime="2026-09-29T23:40:23.000Z" color="#91BAAE"><one:Page ID="{7591E8E7-1D24-0E3D-3F12-F3F599954229}{1}{B0}" name="Remove a task" dateTime="2026-09-29T23:34:17.000Z" lastModifiedTime="2026-09-29T23:40:23.000Z" pageLevel="1"/><one:Page ID="{2E230F7E-DA8D-0A02-3447-158159AAF947}{1}{B0}" name="Delete and undo" dateTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:40:23.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/task-tags/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": "Delete and undo - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/task-tags/cold/read/page-000.png created
Binary files /dev/null and b/corpus/task-tags/cold/read/page-000.png differ
corpus/task-tags/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="{2E230F7E-DA8D-0A02-3447-158159AAF947}{1}{B0}" name="Delete and undo" dateTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:40:23.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{71DC0849-E26F-0725-09B1-2AD88B7B974A}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete and undo]]></one:T></one:OE></one:Title><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{71DC0849-E26F-0725-09B1-2AD88B7B974A}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="93.03126525878906" height="53.71086883544922"/><one:OEChildren><one:OE creationTime="2025-08-18T14:13:22.000Z" lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{71DC0849-E26F-0725-09B1-2AD88B7B974A}{40}{B0}" alignment="left" quickStyleIndex="0"><one:OutlookTask startDate="2020-01-02T00:00:00.000Z" dueDate="2020-01-03T00:00:00.000Z" guidTask="{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E61}" completed="false" disabled="true" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[First task]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:22.000Z" lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{71DC0849-E26F-0725-09B1-2AD88B7B974A}{45}{B0}" alignment="left" quickStyleIndex="0"><one:OutlookTask startDate="2020-01-06T00:00:00.000Z" dueDate="2020-01-10T00:00:00.000Z" guidTask="{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E62}" completed="false" disabled="true" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Second task]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:22.000Z" lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{71DC0849-E26F-0725-09B1-2AD88B7B974A}{50}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-29T23:34:19.000Z"/><one:OutlookTask startDate="2020-01-02T00:00:00.000Z" dueDate="2020-01-02T00:00:00.000Z" guidTask="{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E63}" completed="false" disabled="true" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Tagged task]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:22.000Z" lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{71DC0849-E26F-0725-09B1-2AD88B7B974A}{56}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Untagged]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/task-tags/cold/read/page-001.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Remove a task - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/task-tags/cold/read/page-001.png created
Binary files /dev/null and b/corpus/task-tags/cold/read/page-001.png differ
corpus/task-tags/cold/read/page-001.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{7591E8E7-1D24-0E3D-3F12-F3F599954229}{1}{B0}" name="Remove a task" dateTime="2026-09-29T23:34:17.000Z" lastModifiedTime="2026-09-29T23:40:23.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{DD486546-65DE-0816-3ACC-D7E309D0EC41}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Remove a task]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{DD486546-65DE-0816-3ACC-D7E309D0EC41}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="93.03126525878906" height="53.71086883544922"/><one:OEChildren><one:OE creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{DD486546-65DE-0816-3ACC-D7E309D0EC41}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[First task]]></one:T></one:OE><one:OE creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{DD486546-65DE-0816-3ACC-D7E309D0EC41}{45}{B0}" alignment="left" quickStyleIndex="0"><one:OutlookTask startDate="2020-01-06T00:00:00.000Z" dueDate="2020-01-10T00:00:00.000Z" guidTask="{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E62}" completed="false" disabled="true" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Second task]]></one:T></one:OE><one:OE creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{DD486546-65DE-0816-3ACC-D7E309D0EC41}{50}{B0}" alignment="left" quickStyleIndex="0"><one:OutlookTask startDate="2020-01-02T00:00:00.000Z" dueDate="2020-01-02T00:00:00.000Z" guidTask="{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E63}" completed="false" disabled="true" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Tagged task]]></one:T></one:OE><one:OE creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{DD486546-65DE-0816-3ACC-D7E309D0EC41}{56}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Untagged]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/task-tags/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/task-tags/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/tasktags/candidate",
3 "expected_pages": 2,
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/task-tags/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/task-tags/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/task-tags/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "a1609ca18a7e5a25f51bd75d7f7eaeb815b339406cb4c60c7cc14cd8422e6272",
6 "mtime_ns": 1790725054434635462
7 },
8 {
9 "path": "Tasks.one",
10 "bytes": 20912,
11 "sha256": "a40370dd47c7591f67162db6d1448bdd306705cccbbc575076daa99080fda3ac",
12 "mtime_ns": 1790725054434461086
13 }
14]
corpus/task-tags/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/task-tags/native/commands.jsonl created+4
...@@ -0,0 +1,4 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-7E19D637", "exit": 0, "stdout": "", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-7E19D637 -ExpectedPages 3 -UseCurrentCache -KeepOpen", "exit": 0, "stdout": "Read 2 sections and 3 pages.\r\n", "stderr": "", "error": null}
4{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/task-tags/native/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-7e19d637", "hostname": "ONE-M6-7E19D637"}
corpus/task-tags/native/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/task-tags/native/notebook/Open Notebook.onetoc2 differ
corpus/task-tags/native/notebook/Tasks.one created
Binary files /dev/null and b/corpus/task-tags/native/notebook/Tasks.one differ
corpus/task-tags/native/notebook/fixture.json created+15
...@@ -0,0 +1,15 @@
1{
2 "sections": [
3 "Tasks.one"
4 ],
5 "pages": [
6 {
7 "section": "Tasks.one",
8 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:TagDef index=\"0\" type=\"0\" symbol=\"3\" name=\"To Do\"/><one:Title><one:OE><one:T>Remove a task</one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"480\" height=\"200\"/><one:OEChildren><one:OE><one:OutlookTask guidTask=\"{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E61}\" completed=\"false\" disabled=\"true\" creationDate=\"2020-01-02T03:04:05Z\" startDate=\"2020-01-02T00:00:00Z\" dueDate=\"2020-01-03T00:00:00Z\"/><one:T>First task</one:T></one:OE><one:OE><one:OutlookTask guidTask=\"{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E62}\" completed=\"false\" disabled=\"true\" creationDate=\"2020-01-02T03:04:05Z\" startDate=\"2020-01-06T00:00:00Z\" dueDate=\"2020-01-10T00:00:00Z\"/><one:T>Second task</one:T></one:OE><one:OE><one:Tag index=\"0\" completed=\"false\"/><one:OutlookTask guidTask=\"{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E63}\" completed=\"false\" disabled=\"true\" creationDate=\"2020-01-02T03:04:05Z\" startDate=\"2020-01-02T00:00:00Z\" dueDate=\"2020-01-02T00:00:00Z\"/><one:T>Tagged task</one:T></one:OE><one:OE><one:T>Untagged</one:T></one:OE></one:OEChildren></one:Outline></one:Page>"
9 },
10 {
11 "section": "Tasks.one",
12 "xml": "<one:Page xmlns:one=\"http://schemas.microsoft.com/office/onenote/2010/onenote\"><one:TagDef index=\"0\" type=\"0\" symbol=\"3\" name=\"To Do\"/><one:Title><one:OE><one:T>Delete and undo</one:T></one:OE></one:Title><one:Outline><one:Position x=\"72\" y=\"108\"/><one:Size width=\"480\" height=\"200\"/><one:OEChildren><one:OE><one:OutlookTask guidTask=\"{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E61}\" completed=\"false\" disabled=\"true\" creationDate=\"2020-01-02T03:04:05Z\" startDate=\"2020-01-02T00:00:00Z\" dueDate=\"2020-01-03T00:00:00Z\"/><one:T>First task</one:T></one:OE><one:OE><one:OutlookTask guidTask=\"{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E62}\" completed=\"false\" disabled=\"true\" creationDate=\"2020-01-02T03:04:05Z\" startDate=\"2020-01-06T00:00:00Z\" dueDate=\"2020-01-10T00:00:00Z\"/><one:T>Second task</one:T></one:OE><one:OE><one:Tag index=\"0\" completed=\"false\"/><one:OutlookTask guidTask=\"{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E63}\" completed=\"false\" disabled=\"true\" creationDate=\"2020-01-02T03:04:05Z\" startDate=\"2020-01-02T00:00:00Z\" dueDate=\"2020-01-02T00:00:00Z\"/><one:T>Tagged task</one:T></one:OE><one:OE><one:T>Untagged</one:T></one:OE></one:OEChildren></one:Outline></one:Page>"
13 }
14 ]
15}
\ No newline at end of file
corpus/task-tags/native/notebook/synthetic.one created
Binary files /dev/null and b/corpus/task-tags/native/notebook/synthetic.one differ
corpus/task-tags/native/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-7E19D637",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/task-tags/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="{6FCF99C6-7C4C-44AE-81A8-BDECA7841570}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-29T23:34:34.000Z" color="#B49EDE"><one:Section name="synthetic" ID="{5D807A7B-779F-0524-3973-DD523A4B2EF7}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-29T23:34:34.000Z" color="#8AA8E4"><one:Page ID="{320E6749-7582-06D4-1D2E-FD18C4C5677E}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-29T23:34:34.000Z" pageLevel="1"/></one:Section><one:Section name="Tasks" ID="{28DA97E1-8F7B-016A-1D7E-1A96E571E16B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Tasks.one" lastModifiedTime="2026-09-29T23:34:34.000Z" color="#91BAAE"><one:Page ID="{EF93F06D-F4DE-0A55-2FF0-5A989AF0210E}{1}{B0}" name="Remove a task" dateTime="2026-09-29T23:34:17.000Z" lastModifiedTime="2026-09-29T23:34:34.000Z" pageLevel="1"/><one:Page ID="{B42117F4-3377-0E6A-24A5-BCEC5ACF9A60}{1}{B0}" name="Delete and undo" dateTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:34.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/task-tags/native/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": "A notebook created in Rust. - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/task-tags/native/read/page-000.png created
Binary files /dev/null and b/corpus/task-tags/native/read/page-000.png differ
corpus/task-tags/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="{320E6749-7582-06D4-1D2E-FD18C4C5677E}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-29T23:34:34.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Fixture Author" lastModifiedBy="Fixture Author" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{5D807A7B-779F-0524-3973-DD523A4B2EF7}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="191.1661224365234" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{5D807A7B-779F-0524-3973-DD523A4B2EF7}{24}{B0}" alignment="left"><one:T><![CDATA[A notebook created in Rust.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/task-tags/native/read/page-001.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Delete and undo - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/task-tags/native/read/page-001.png created
Binary files /dev/null and b/corpus/task-tags/native/read/page-001.png differ
corpus/task-tags/native/read/page-001.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B42117F4-3377-0E6A-24A5-BCEC5ACF9A60}{1}{B0}" name="Delete and undo" dateTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:34.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{EBDE10C3-0B95-034D-1953-83B5881EF46D}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete and undo]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{EBDE10C3-0B95-034D-1953-83B5881EF46D}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="93.03126525878906" height="53.71086883544922"/><one:OEChildren><one:OE creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{EBDE10C3-0B95-034D-1953-83B5881EF46D}{40}{B0}" alignment="left" quickStyleIndex="0"><one:OutlookTask startDate="2020-01-02T00:00:00.000Z" dueDate="2020-01-03T00:00:00.000Z" guidTask="{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E61}" completed="false" disabled="true" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[First task]]></one:T></one:OE><one:OE creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{EBDE10C3-0B95-034D-1953-83B5881EF46D}{45}{B0}" alignment="left" quickStyleIndex="0"><one:OutlookTask startDate="2020-01-06T00:00:00.000Z" dueDate="2020-01-10T00:00:00.000Z" guidTask="{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E62}" completed="false" disabled="true" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Second task]]></one:T></one:OE><one:OE creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{EBDE10C3-0B95-034D-1953-83B5881EF46D}{50}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-29T23:34:19.000Z"/><one:OutlookTask startDate="2020-01-02T00:00:00.000Z" dueDate="2020-01-02T00:00:00.000Z" guidTask="{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E63}" completed="false" disabled="true" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Tagged task]]></one:T></one:OE><one:OE creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{EBDE10C3-0B95-034D-1953-83B5881EF46D}{56}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Untagged]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/task-tags/native/read/page-002.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Remove a task - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/task-tags/native/read/page-002.png created
Binary files /dev/null and b/corpus/task-tags/native/read/page-002.png differ
corpus/task-tags/native/read/page-002.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="{EF93F06D-F4DE-0A55-2FF0-5A989AF0210E}{1}{B0}" name="Remove a task" dateTime="2026-09-29T23:34:17.000Z" lastModifiedTime="2026-09-29T23:34:34.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{474A7DCC-8C24-0C7E-2A2E-7E8E0AB58F66}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Remove a task]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{474A7DCC-8C24-0C7E-2A2E-7E8E0AB58F66}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="93.03126525878906" height="53.71086883544922"/><one:OEChildren><one:OE creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{474A7DCC-8C24-0C7E-2A2E-7E8E0AB58F66}{40}{B0}" alignment="left" quickStyleIndex="0"><one:OutlookTask startDate="2020-01-02T00:00:00.000Z" dueDate="2020-01-03T00:00:00.000Z" guidTask="{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E61}" completed="false" disabled="true" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[First task]]></one:T></one:OE><one:OE creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{474A7DCC-8C24-0C7E-2A2E-7E8E0AB58F66}{45}{B0}" alignment="left" quickStyleIndex="0"><one:OutlookTask startDate="2020-01-06T00:00:00.000Z" dueDate="2020-01-10T00:00:00.000Z" guidTask="{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E62}" completed="false" disabled="true" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Second task]]></one:T></one:OE><one:OE creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{474A7DCC-8C24-0C7E-2A2E-7E8E0AB58F66}{50}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-29T23:34:19.000Z"/><one:OutlookTask startDate="2020-01-02T00:00:00.000Z" dueDate="2020-01-02T00:00:00.000Z" guidTask="{3C1A5E10-6B2F-4C8E-9D71-0A2B3C4D5E63}" completed="false" disabled="true" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Tagged task]]></one:T></one:OE><one:OE creationTime="2026-09-29T23:34:19.000Z" lastModifiedTime="2026-09-29T23:34:19.000Z" objectID="{474A7DCC-8C24-0C7E-2A2E-7E8E0AB58F66}{56}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Untagged]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/task-tags/native/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/task-tags/native/run.json created+19
...@@ -0,0 +1,19 @@
1{
2 "notebook": "/private/tmp/tasktags/input/notebook",
3 "expected_pages": 3,
4 "author": "tools/native/pages.ps1",
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "author.ps1": "dfbf0e016aa4c8486ecea9f39768c633de05ac0db8214139052ca2e9c43bbd02",
16 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
17 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
18 }
19}
corpus/task-tags/native/scripts/author.ps1 created+42
...@@ -0,0 +1,42 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5$fixture = Get-Content (Join-Path $Root 'notebook\fixture.json') -Raw -Encoding UTF8 | ConvertFrom-Json
6$inputs = Join-Path $Root 'inputs'
7New-Item -ItemType Directory -Path $inputs | Out-Null
8$app = New-Object -ComObject OneNote.Application
9$notebookId = ''
10try {
11 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
12 $sections = @{}
13 foreach ($path in $fixture.sections) {
14 $parts = $path.Split('/')
15 if (@($parts | Where-Object { $_ -in @('', '.', '..') -or $_.Contains('\') -or $_.Contains(':') }).Count) { throw 'Invalid fixture section path.' }
16 $parent = $notebookId
17 for ($index = 0; $index -lt $parts.Count; $index++) {
18 $id = ''
19 $app.OpenHierarchy($parts[$index], $parent, [ref]$id, $(if ($index -eq $parts.Count - 1) {3} else {2}))
20 $parent = $id
21 }
22 $sections[$path] = $id
23 }
24 $index = 0
25 foreach ($item in $fixture.pages) {
26 $pageId = ''
27 $app.CreateNewPage($sections[$item.section], [ref]$pageId, 0)
28 [xml]$xml = $item.xml.Replace('__ASSETS__', [Security.SecurityElement]::Escape((Join-Path $Root 'notebook\fixture-data')))
29 $xml.DocumentElement.SetAttribute('ID', $pageId)
30 [IO.File]::WriteAllText((Join-Path $inputs ('page-{0:d3}.xml' -f $index)), $xml.OuterXml, [Text.Encoding]::UTF8)
31 $app.UpdatePageContent($xml.OuterXml, [DateTime]::MinValue, 1, $true)
32 $index++
33 }
34 $app.SyncHierarchy($notebookId)
35 $app.CloseNotebook($notebookId, $false)
36 $notebookId = ''
37} finally {
38 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
39 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
40 [GC]::Collect()
41 [GC]::WaitForPendingFinalizers()
42}
corpus/task-tags/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/task-tags/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/task-tags/native/source.json created+20
...@@ -0,0 +1,20 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1612,
5 "sha256": "fafa5dcb3cd827c7edd30c8ea2fbc957a59fb4d2a8b28b667a3c551c8261ab86",
6 "mtime_ns": 1790724703557857638
7 },
8 {
9 "path": "fixture.json",
10 "bytes": 2596,
11 "sha256": "7b243286f1ae5147ae8c97d87ae911e82a6ebfd264fc15450c43176067fbf067",
12 "mtime_ns": 1790724703583723809
13 },
14 {
15 "path": "synthetic.one",
16 "bytes": 3348,
17 "sha256": "b0662387c3fdc256d8dbe809e3b8f73b3586559696467fb1fff8f589095fa797",
18 "mtime_ns": 1790724703558075431
19 }
20]
corpus/task-tags/native/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
crates/canvas/assets/tags/box-small.svg+6-7
...@@ -1,11 +1,10 @@...@@ -1,11 +1,10 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#91b6f7"/><stop offset="1" stop-color="#6498ef"/></linearGradient>3 <linearGradient id="interior" x1="7.5" y1="7.5" x2="12.5" y2="12.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e8f3fe"/></linearGradient>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7caff6"/><stop offset="1" stop-color="#5587e9"/></linearGradient>
5 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#d6e6f5"/></linearGradient>
6 </defs>4 </defs>
7 <path d="M5 5H15V15H5Z" fill="url(#layer0)"/>5 <path d="M3 3H16V16H3Z" fill="#90a0ac"/>
8 <path d="M5.75 5.75H14.25V14.25H5.75Z" fill="#ffffff"/>6 <path d="M4 4H15V15H4Z" fill="#f7f8fa"/>
9 <path d="M6.5 6.5H13.5V13.5H6.5Z" fill="url(#layer1)"/>7 <path d="M5 5H14V14H5Z" fill="#b4bfc7"/>
10 <path d="M7.25 7.25H12.75V12.75H7.25Z" fill="url(#layer2)"/>8 <path d="M5 5H14V6H6V13H5Z" fill="#ccd5d9"/>
9 <path d="M6 6H13V13H6Z" fill="url(#interior)"/>
11</svg>10</svg>
crates/canvas/assets/tags/checkbox-green.svg+11-17
...@@ -1,24 +1,18 @@...@@ -1,24 +1,18 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">3 <linearGradient id="layer3" x1="4" y1="4" x2="13" y2="13" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#a6d596"/>
5 <stop offset="1" stop-color="#6cb055"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#ffffff"/>
9 <stop offset="1" stop-color="#ffffff"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#98cf84"/>
13 <stop offset="1" stop-color="#5ea347"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#ffffff"/>4 <stop offset="0" stop-color="#ffffff"/>
17 <stop offset="1" stop-color="#e2f3da"/>5 <stop offset="1" stop-color="#e7f6e1"/>
18 </linearGradient>6 </linearGradient>
19 </defs>7 </defs>
20 <path d="M0 0H16V16H0Z" fill="url(#layer0)"/>8 <path d="M0 0h16v1h-16zM0 1h1v15h-1z" fill="#a0d495"/>
21 <path d="M1 1H15V15H1Z" fill="url(#layer1)"/>9 <path d="M1 1h14v1h-14zM1 2h1v1h-1zM14 2h1v13h-1zM1 11h1v4h-1z" fill="#f7f8f7"/>
22 <path d="M2 2H14V14H2Z" fill="url(#layer2)"/>10 <path d="M15 1h1v15h-1zM1 15h14v1h-14z" fill="#5ca051"/>
11 <path d="M2 2h12v1h-12zM2 3h1v11h-1z" fill="#bfdeb5"/>
12 <path d="M1 3h1v8h-1z" fill="#fafbfa"/>
13 <path d="M13 3h1v11h-1zM3 13h10v1h-10z" fill="#92c584"/>
14 <path d="M2 14h4v1h-4z" fill="#f4f9f2"/>
15 <path d="M6 14h4v1h-4zM13 14h1v1h-1z" fill="#f2f8ef"/>
16 <path d="M10 14h3v1h-3z" fill="#eff7ec"/>
23 <path d="M3 3H13V13H3Z" fill="url(#layer3)"/>17 <path d="M3 3H13V13H3Z" fill="url(#layer3)"/>
24</svg>18</svg>
crates/canvas/assets/tags/checkbox-yellow.svg+11-17
...@@ -1,24 +1,18 @@...@@ -1,24 +1,18 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">3 <linearGradient id="layer3" x1="4" y1="4" x2="13" y2="13" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#f6d993"/>
5 <stop offset="1" stop-color="#e0ad3e"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#ffffff"/>
9 <stop offset="1" stop-color="#ffffff"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#f3d182"/>
13 <stop offset="1" stop-color="#d9a133"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#ffffff"/>4 <stop offset="0" stop-color="#ffffff"/>
17 <stop offset="1" stop-color="#fcf1d8"/>5 <stop offset="1" stop-color="#fbf8e9"/>
18 </linearGradient>6 </linearGradient>
19 </defs>7 </defs>
20 <path d="M0 0H16V16H0Z" fill="url(#layer0)"/>8 <path d="M0 0h16v1h-16zM0 1h1v15h-1z" fill="#dbbc6a"/>
21 <path d="M1 1H15V15H1Z" fill="url(#layer1)"/>9 <path d="M1 1h14v1h-14zM1 2h1v1h-1zM14 2h1v13h-1zM1 14h1v1h-1z" fill="#f7f8fa"/>
22 <path d="M2 2H14V14H2Z" fill="url(#layer2)"/>10 <path d="M15 1h1v15h-1zM1 15h14v1h-14z" fill="#c18d1c"/>
11 <path d="M2 2h12v1h-12zM2 3h1v11h-1z" fill="#e8d39f"/>
12 <path d="M1 3h1v9h-1z" fill="#fbfbfc"/>
13 <path d="M13 3h1v11h-1zM3 13h10v1h-10z" fill="#e6b44d"/>
14 <path d="M1 12h1v2h-1zM2 14h2v1h-2z" fill="#fafaf9"/>
15 <path d="M4 14h6v1h-6zM13 14h1v1h-1z" fill="#f9f9f5"/>
16 <path d="M10 14h3v1h-3z" fill="#f9f8f2"/>
23 <path d="M3 3H13V13H3Z" fill="url(#layer3)"/>17 <path d="M3 3H13V13H3Z" fill="url(#layer3)"/>
24</svg>18</svg>
crates/canvas/assets/tags/checkbox.svg+12-17
...@@ -1,24 +1,19 @@...@@ -1,24 +1,19 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">3 <linearGradient id="layer3" x1="4" y1="4" x2="13" y2="13" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#91b6f7"/>
5 <stop offset="1" stop-color="#6498ef"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#ffffff"/>
9 <stop offset="1" stop-color="#ffffff"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#7caff6"/>
13 <stop offset="1" stop-color="#5587e9"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#ffffff"/>4 <stop offset="0" stop-color="#ffffff"/>
17 <stop offset="1" stop-color="#d6e6f5"/>5 <stop offset="1" stop-color="#daecfe"/>
18 </linearGradient>6 </linearGradient>
19 </defs>7 </defs>
20 <path d="M0 0H16V16H0Z" fill="url(#layer0)"/>8 <path d="M0 0h16v1h-16zM0 1h1v15h-1z" fill="#83a9e9"/>
21 <path d="M1 1H15V15H1Z" fill="url(#layer1)"/>9 <path d="M1 1h14v1h-14zM1 2h1v1h-1zM14 2h1v13h-1zM1 9h1v6h-1z" fill="#f7f8fa"/>
22 <path d="M2 2H14V14H2Z" fill="url(#layer2)"/>10 <path d="M15 1h1v15h-1zM1 15h14v1h-14z" fill="#3763bb"/>
11 <path d="M2 2h12v1h-12zM2 3h1v11h-1z" fill="#a8c8ed"/>
12 <path d="M1 3h1v6h-1z" fill="#fbfbfc"/>
13 <path d="M13 3h1v11h-1zM3 13h10v1h-10z" fill="#709ddb"/>
14 <path d="M2 14h3v1h-3z" fill="#f2f7fc"/>
15 <path d="M5 14h2v1h-2zM13 14h1v1h-1z" fill="#eff5fc"/>
16 <path d="M7 14h3v1h-3z" fill="#ecf4fc"/>
17 <path d="M10 14h3v1h-3z" fill="#e9f2fc"/>
23 <path d="M3 3H13V13H3Z" fill="url(#layer3)"/>18 <path d="M3 3H13V13H3Z" fill="url(#layer3)"/>
24</svg>19</svg>
crates/canvas/assets/tags/flag.svg deleted-10
...@@ -1,10 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="cloth" x1="0" y1="2" x2="0" y2="10" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#f25c54"/>
5 <stop offset="1" stop-color="#c8342c"/>
6 </linearGradient>
7 </defs>
8 <path d="M3 2H4V14H3Z" fill="#6b6b6b"/>
9 <path d="M4 2.5C6 1.5 8 3.5 10 2.5C11 2 12 2 13 2.5V9C12 8.5 11 8.5 10 9C8 10 6 8 4 9Z" fill="url(#cloth)"/>
10</svg>
crates/canvas/assets/tags/mark-arrow-blue.svg+40-4
...@@ -1,6 +1,42 @@...@@ -1,6 +1,42 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M3 0h1v1h-1z" fill="#0577e3" fill-opacity="0.82"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6fb0ff"/><stop offset="1" stop-color="#1e5ab8"/></linearGradient>3 <path d="M4 0h1v1h-1z" fill="#348fd9" fill-opacity="0.2"/>
4 </defs>4 <path d="M3 1h1v1h-1z" fill="#1f78e0" fill-opacity="0.84"/>
5 <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/>5 <path d="M4 1h1v1h-1z" fill="#4e76c5"/>
6 <path d="M5 1h1v1h-1zM6 2h1v1h-1z" fill="#3083ce" fill-opacity="0.27"/>
7 <path d="M0 2h1v1h-1z" fill="#5d8fe6" fill-opacity="0.92"/>
8 <path d="M1 2h1v1h-1zM3 2h1v1h-1z" fill="#688eda"/>
9 <path d="M2 2h1v1h-1zM0 6h1v1h-1z" fill="#5f85cf"/>
10 <path d="M4 2h1v1h-1z" fill="#6b9af7"/>
11 <path d="M5 2h1v1h-1zM6 3h1v1h-1zM7 4h1v1h-1z" fill="#2b58ae"/>
12 <path d="M0 3h1v1h-1z" fill="#618fe3" fill-opacity="0.94"/>
13 <path d="M1 3h1v1h-1z" fill="#9ebefd"/>
14 <path d="M2 3h1v1h-1z" fill="#85aefe"/>
15 <path d="M3 3h1v1h-1z" fill="#7da8ff"/>
16 <path d="M4 3h1v1h-1z" fill="#6f9df8"/>
17 <path d="M5 3h1v1h-1zM4 4h1v1h-1z" fill="#5183e5"/>
18 <path d="M7 3h1v1h-1z" fill="#8296ac"/>
19 <path d="M0 4h1v1h-1z" fill="#6c92de"/>
20 <path d="M1 4h1v1h-1z" fill="#78a5fd"/>
21 <path d="M2 4h1v1h-1z" fill="#5e93fc"/>
22 <path d="M3 4h1v1h-1z" fill="#598cf2"/>
23 <path d="M5 4h1v1h-1z" fill="#4c7bd8"/>
24 <path d="M6 4h1v1h-1zM5 5h1v1h-1z" fill="#416ec6"/>
25 <path d="M0 5h1v1h-1z" fill="#658bd7"/>
26 <path d="M1 5h1v1h-1z" fill="#5c90f7"/>
27 <path d="M2 5h1v1h-1z" fill="#5688ec"/>
28 <path d="M3 5h1v1h-1z" fill="#4f7fde"/>
29 <path d="M4 5h1v1h-1z" fill="#4876d1"/>
30 <path d="M6 5h1v1h-1z" fill="#3156a2"/>
31 <path d="M7 5h1v1h-1zM5 7h1v1h-1z" fill="#aab9cc"/>
32 <path d="M1 6h1v1h-1z" fill="#254b9a"/>
33 <path d="M2 6h1v1h-1z" fill="#274fa2"/>
34 <path d="M3 6h1v1h-1z" fill="#2954ab"/>
35 <path d="M4 6h1v1h-1z" fill="#3f6abf"/>
36 <path d="M5 6h1v1h-1z" fill="#2a50a0"/>
37 <path d="M6 6h1v1h-1z" fill="#d3dbea"/>
38 <path d="M3 7h1v1h-1z" fill="#254d9d"/>
39 <path d="M4 7h1v1h-1z" fill="#284e9d"/>
40 <path d="M3 8h1v1h-1z" fill="#244996"/>
41 <path d="M4 8h1v1h-1z" fill="#cdd5e6"/>
6</svg>42</svg>
crates/canvas/assets/tags/mark-arrow-green.svg+34-4
...@@ -1,6 +1,36 @@...@@ -1,6 +1,36 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M3 0h1v1h-1zM0 2h1v1h-1z" fill="#72ce6d"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8ee07a"/><stop offset="1" stop-color="#2e8a1e"/></linearGradient>3 <path d="M4 0h1v1h-1z" fill="#4dc634" fill-opacity="0.2"/>
4 </defs>4 <path d="M3 1h1v1h-1zM0 3h1v1h-1z" fill="#6fcb6a"/>
5 <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/>5 <path d="M4 1h1v1h-1z" fill="#53a84f"/>
6 <path d="M5 1h1v1h-1zM6 2h1v1h-1z" fill="#3aad1c" fill-opacity="0.25"/>
7 <path d="M1 2h1v1h-1zM3 2h1v1h-1zM0 5h1v1h-1z" fill="#66bf62"/>
8 <path d="M2 2h1v1h-1zM0 6h1v1h-1z" fill="#5fb65b"/>
9 <path d="M4 2h1v2h-1z" fill="#6adb64"/>
10 <path d="M5 2h1v1h-1zM6 3h1v1h-1zM7 4h1v1h-1z" fill="#3c8b38"/>
11 <path d="M1 3h1v1h-1z" fill="#8ee78a"/>
12 <path d="M2 3h1v1h-1z" fill="#7be576"/>
13 <path d="M3 3h1v1h-1z" fill="#75e570"/>
14 <path d="M5 3h1v1h-1zM4 4h1v1h-1z" fill="#56c750"/>
15 <path d="M7 3h1v1h-1z" fill="#849d9c"/>
16 <path d="M0 4h1v1h-1z" fill="#6ac366"/>
17 <path d="M1 4h1v1h-1z" fill="#72e36c"/>
18 <path d="M2 4h1v1h-1z" fill="#5fde59"/>
19 <path d="M3 4h1v1h-1z" fill="#5bd455"/>
20 <path d="M5 4h1v1h-1z" fill="#51bb4c"/>
21 <path d="M6 4h1v1h-1zM5 5h1v1h-1z" fill="#4da249"/>
22 <path d="M1 5h1v1h-1z" fill="#5ed958"/>
23 <path d="M2 5h1v1h-1z" fill="#59ce53"/>
24 <path d="M3 5h1v1h-1z" fill="#52c04d"/>
25 <path d="M4 5h1v1h-1z" fill="#4eb249"/>
26 <path d="M6 5h1v1h-1z" fill="#3c823b"/>
27 <path d="M7 5h1v1h-1zM5 7h1v1h-1z" fill="#adc2b7"/>
28 <path d="M1 6h1v1h-1z" fill="#327b30"/>
29 <path d="M2 6h1v1h-1zM5 6h1v1h-1z" fill="#358033"/>
30 <path d="M3 6h1v1h-1z" fill="#398736"/>
31 <path d="M4 6h1v1h-1z" fill="#4e9b4a"/>
32 <path d="M6 6h1v1h-1z" fill="#d6e4d5"/>
33 <path d="M3 7h2v1h-2z" fill="#357d32"/>
34 <path d="M3 8h1v1h-1z" fill="#31782f"/>
35 <path d="M4 8h1v1h-1z" fill="#cfded1"/>
6</svg>36</svg>
crates/canvas/assets/tags/mark-arrow-yellow.svg+39-4
...@@ -1,6 +1,41 @@...@@ -1,6 +1,41 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M3 0h1v1h-1z" fill="#e8c20f" fill-opacity="0.92"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffe07a"/><stop offset="1" stop-color="#c28a12"/></linearGradient>3 <path d="M4 0h1v1h-1z" fill="#e9d134" fill-opacity="0.24"/>
4 </defs>4 <path d="M3 1h1v1h-1z" fill="#e3be01" fill-opacity="0.92"/>
5 <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/>5 <path d="M4 1h1v1h-1z" fill="#c6a531"/>
6 <path d="M5 1h1v1h-1zM6 2h1v1h-1z" fill="#c7b31b" fill-opacity="0.28"/>
7 <path d="M0 2h1v1h-1z" fill="#e8c321" fill-opacity="0.93"/>
8 <path d="M1 2h1v1h-1zM3 2h1v1h-1z" fill="#dcba45"/>
9 <path d="M2 2h1v1h-1zM0 6h1v1h-1z" fill="#d4b23d"/>
10 <path d="M4 2h1v1h-1z" fill="#f6cf49"/>
11 <path d="M5 2h1v1h-1zM6 3h1v1h-1zM7 4h1v1h-1z" fill="#a4861f"/>
12 <path d="M0 3h1v1h-1z" fill="#e4c032" fill-opacity="0.95"/>
13 <path d="M1 3h1v1h-1z" fill="#fcde75"/>
14 <path d="M2 3h1v1h-1z" fill="#fdda5e"/>
15 <path d="M3 3h1v1h-1z" fill="#fed957"/>
16 <path d="M4 3h1v1h-1z" fill="#f7d14c"/>
17 <path d="M5 3h1v1h-1zM4 4h1v1h-1z" fill="#e6be31"/>
18 <path d="M7 3h1v1h-1z" fill="#939c98"/>
19 <path d="M0 4h1v1h-1z" fill="#dfbe4a"/>
20 <path d="M1 4h1v1h-1z" fill="#fcd653"/>
21 <path d="M2 4h1v1h-1z" fill="#fbd13c"/>
22 <path d="M3 4h1v1h-1z" fill="#f2c837"/>
23 <path d="M5 4h1v1h-1z" fill="#dbb42c"/>
24 <path d="M6 4h1v1h-1zM5 5h1v1h-1z" fill="#c09e2b"/>
25 <path d="M0 5h1v1h-1z" fill="#d9b844"/>
26 <path d="M1 5h1v1h-1z" fill="#f7cc3a"/>
27 <path d="M2 5h1v1h-1z" fill="#ecc335"/>
28 <path d="M3 5h1v1h-1z" fill="#e0b82d"/>
29 <path d="M4 5h1v1h-1z" fill="#d2ac29"/>
30 <path d="M6 5h1v1h-1z" fill="#9a8223"/>
31 <path d="M7 5h1v1h-1zM5 7h1v1h-1z" fill="#bfc2b3"/>
32 <path d="M1 6h1v1h-1z" fill="#91791a"/>
33 <path d="M2 6h1v1h-1zM5 6h1v1h-1z" fill="#977f1c"/>
34 <path d="M3 6h1v1h-1z" fill="#a0831d"/>
35 <path d="M4 6h1v1h-1z" fill="#b8992d"/>
36 <path d="M6 6h1v1h-1z" fill="#e8e4d1"/>
37 <path d="M3 7h1v1h-1z" fill="#94791b"/>
38 <path d="M4 7h1v1h-1z" fill="#947c1d"/>
39 <path d="M3 8h1v1h-1z" fill="#8d761a"/>
40 <path d="M4 8h1v1h-1z" fill="#e2decd"/>
6</svg>41</svg>
crates/canvas/assets/tags/mark-exclamation-blue.svg+42-7
...@@ -1,9 +1,44 @@...@@ -1,9 +1,44 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M1 0h1v1h-1zM2 7h1v1h-1z" fill="#19a4e7" fill-opacity="0.52"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d8e6ff"/><stop offset="1" stop-color="#4b78c9"/></linearGradient>3 <path d="M2 0h1v1h-1z" fill="#3f97e7"/>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#3d62ad"/><stop offset="1" stop-color="#1e4494"/></linearGradient>4 <path d="M3 0h1v1h-1z" fill="#1c7ed6"/>
5 </defs>5 <path d="M4 0h1v1h-1z" fill="#3d87d3"/>
6 <path d="M3.5 0.25C5.2 0.25 5.8 1.3 5.4 3L4.4 7.25H2.6L1.6 3C1.2 1.3 1.8 0.25 3.5 0.25Z" fill="url(#layer1)"/>6 <path d="M5 0h1v1h-1z" fill="#0e87c7" fill-opacity="0.59"/>
7 <path d="M3.5 1C4.6 1 4.9 1.7 4.6 3L3.8 6.5H3.2L2.4 3C2.1 1.7 2.4 1 3.5 1Z" fill="url(#layer0)"/>7 <path d="M1 1h1v1h-1z" fill="#4f8cc8"/>
8 <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/>8 <path d="M2 1h1v1h-1z" fill="#ecf1ff"/>
9 <path d="M3 1h1v1h-1z" fill="#c8dafd"/>
10 <path d="M4 1h1v1h-1z" fill="#81aff7"/>
11 <path d="M5 1h1v1h-1z" fill="#3d679c"/>
12 <path d="M1 2h1v1h-1z" fill="#2d7ac7"/>
13 <path d="M2 2h1v1h-1z" fill="#d4e2fe"/>
14 <path d="M3 2h1v1h-1z" fill="#a4c9fd"/>
15 <path d="M4 2h1v1h-1z" fill="#669ae8"/>
16 <path d="M5 2h1v1h-1z" fill="#294f85"/>
17 <path d="M1 3h1v1h-1z" fill="#6a9fd2"/>
18 <path d="M2 3h1v1h-1z" fill="#b0cffd"/>
19 <path d="M3 3h1v1h-1z" fill="#87b7fa"/>
20 <path d="M4 3h1v1h-1z" fill="#588ad1"/>
21 <path d="M5 3h1v1h-1z" fill="#486b91"/>
22 <path d="M1 4h1v1h-1z" fill="#0a8ac7" fill-opacity="0.66"/>
23 <path d="M2 4h1v1h-1z" fill="#5283cd"/>
24 <path d="M3 4h1v1h-1z" fill="#6fa2eb"/>
25 <path d="M4 4h1v1h-1z" fill="#466fab"/>
26 <path d="M5 4h1v1h-1z" fill="#7490b0"/>
27 <path d="M1 5h1v1h-1z" fill="#2f89bd" fill-opacity="0.33"/>
28 <path d="M2 5h1v1h-1z" fill="#2e60aa"/>
29 <path d="M3 5h1v1h-1z" fill="#5c90da"/>
30 <path d="M4 5h1v1h-1z" fill="#365d8b"/>
31 <path d="M5 5h1v1h-1z" fill="#9aafd3"/>
32 <path d="M2 6h1v1h-1z" fill="#0882bd" fill-opacity="0.61"/>
33 <path d="M3 6h1v1h-1z" fill="#204d85"/>
34 <path d="M4 6h1v1h-1z" fill="#859eba"/>
35 <path d="M3 7h1v1h-1z" fill="#127dd8"/>
36 <path d="M4 7h1v1h-1z" fill="#8bbcf2"/>
37 <path d="M2 8h1v1h-1z" fill="#3479ca"/>
38 <path d="M3 8h1v1h-1z" fill="#c9daf4"/>
39 <path d="M4 8h1v1h-1z" fill="#083357"/>
40 <path d="M2 9h1v1h-1z" fill="#1c87c4" fill-opacity="0.55"/>
41 <path d="M3 9h1v1h-1z" fill="#0c3a5f"/>
42 <path d="M4 9h1v1h-1z" fill="#628dc4"/>
43 <path d="M3 10h1v1h-1z" fill="#4d75c1"/>
9</svg>44</svg>
crates/canvas/assets/tags/mark-exclamation-green.svg+43-7
...@@ -1,9 +1,45 @@...@@ -1,9 +1,45 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M1 0h1v1h-1z" fill="#72d042" fill-opacity="0.5"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d8f5c8"/><stop offset="1" stop-color="#5aa83c"/></linearGradient>3 <path d="M2 0h1v1h-1z" fill="#73cd58"/>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4a8a2e"/><stop offset="1" stop-color="#2d6a1a"/></linearGradient>4 <path d="M3 0h1v1h-1z" fill="#56ba37"/>
5 </defs>5 <path d="M4 0h1v1h-1z" fill="#67bc53"/>
6 <path d="M3.5 0.25C5.2 0.25 5.8 1.3 5.4 3L4.4 7.25H2.6L1.6 3C1.2 1.3 1.8 0.25 3.5 0.25Z" fill="url(#layer1)"/>6 <path d="M5 0h1v1h-1z" fill="#48af05" fill-opacity="0.55"/>
7 <path d="M3.5 1C4.6 1 4.9 1.7 4.6 3L3.8 6.5H3.2L2.4 3C2.1 1.7 2.4 1 3.5 1Z" fill="url(#layer0)"/>7 <path d="M1 1h1v1h-1z" fill="#72b560"/>
8 <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/>8 <path d="M2 1h1v1h-1z" fill="#edfcee"/>
9 <path d="M3 1h1v1h-1z" fill="#d0f4cf"/>
10 <path d="M4 1h1v1h-1z" fill="#9ae592"/>
11 <path d="M5 1h1v1h-1z" fill="#548d4a"/>
12 <path d="M1 2h1v1h-1z" fill="#5aae43"/>
13 <path d="M2 2h1v1h-1z" fill="#dbf7da"/>
14 <path d="M3 2h1v1h-1z" fill="#b7efb0"/>
15 <path d="M4 2h1v1h-1z" fill="#82d479"/>
16 <path d="M5 2h1v1h-1z" fill="#3e7736"/>
17 <path d="M1 3h1v1h-1z" fill="#88c279"/>
18 <path d="M2 3h1v1h-1z" fill="#c1f1ba"/>
19 <path d="M3 3h1v1h-1z" fill="#a1e897"/>
20 <path d="M4 3h1v1h-1z" fill="#73be69"/>
21 <path d="M5 3h1v1h-1z" fill="#5c8552"/>
22 <path d="M1 4h1v1h-1z" fill="#53af1a" fill-opacity="0.62"/>
23 <path d="M2 4h1v1h-1z" fill="#6cba63"/>
24 <path d="M3 4h1v1h-1z" fill="#8bd881"/>
25 <path d="M4 4h1v1h-1z" fill="#5d9b54"/>
26 <path d="M5 4h1v1h-1z" fill="#83a67d"/>
27 <path d="M1 5h1v1h-1z" fill="#5ea93b" fill-opacity="0.32"/>
28 <path d="M2 5h1v1h-1z" fill="#4a9740"/>
29 <path d="M3 5h1v1h-1z" fill="#79c66f"/>
30 <path d="M4 5h1v1h-1z" fill="#4c7d42"/>
31 <path d="M5 5h1v1h-1z" fill="#a4caa1"/>
32 <path d="M2 6h1v1h-1z" fill="#51a721" fill-opacity="0.58"/>
33 <path d="M3 6h1v1h-1z" fill="#38752e"/>
34 <path d="M4 6h1v1h-1z" fill="#93b18c"/>
35 <path d="M2 7h1v1h-1z" fill="#66ce20" fill-opacity="0.48"/>
36 <path d="M3 7h1v1h-1z" fill="#50ba2f"/>
37 <path d="M4 7h1v1h-1z" fill="#a7e299"/>
38 <path d="M2 8h1v1h-1z" fill="#5bb34a"/>
39 <path d="M3 8h1v1h-1z" fill="#d1edce"/>
40 <path d="M4 8h1v1h-1z" fill="#214a13"/>
41 <path d="M2 9h1v1h-1z" fill="#4fae19" fill-opacity="0.52"/>
42 <path d="M3 9h1v1h-1z" fill="#275118"/>
43 <path d="M4 9h1v1h-1z" fill="#7ab570"/>
44 <path d="M3 10h1v1h-1z" fill="#61af5e"/>
9</svg>45</svg>
crates/canvas/assets/tags/mark-exclamation-yellow.svg+42-7
...@@ -1,9 +1,44 @@...@@ -1,9 +1,44 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M1 0h1v1h-1zM2 7h1v1h-1z" fill="#ecbb1c" fill-opacity="0.55"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff1b0"/><stop offset="1" stop-color="#e0a82e"/></linearGradient>3 <path d="M2 0h1v1h-1z" fill="#eba23a"/>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b58a3a"/><stop offset="1" stop-color="#8a6420"/></linearGradient>4 <path d="M3 0h1v1h-1z" fill="#db8a16"/>
5 </defs>5 <path d="M4 0h1v1h-1z" fill="#db9e34"/>
6 <path d="M3.5 0.25C5.2 0.25 5.8 1.3 5.4 3L4.4 7.25H2.6L1.6 3C1.2 1.3 1.8 0.25 3.5 0.25Z" fill="url(#layer1)"/>6 <path d="M5 0h1v1h-1z" fill="#d3aa07" fill-opacity="0.62"/>
7 <path d="M3.5 1C4.6 1 4.9 1.7 4.6 3L3.8 6.5H3.2L2.4 3C2.1 1.7 2.4 1 3.5 1Z" fill="url(#layer0)"/>7 <path d="M1 1h1v1h-1z" fill="#d29e43"/>
8 <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/>8 <path d="M2 1h1v1h-1z" fill="#fffbeb"/>
9 <path d="M3 1h1v1h-1z" fill="#fdf1c6"/>
10 <path d="M4 1h1v1h-1z" fill="#f8da7f"/>
11 <path d="M5 1h1v1h-1z" fill="#a68131"/>
12 <path d="M1 2h1v1h-1z" fill="#cd8f24"/>
13 <path d="M2 2h1v1h-1z" fill="#fef4d3"/>
14 <path d="M3 2h1v1h-1z" fill="#fde2a2"/>
15 <path d="M4 2h1v1h-1z" fill="#ecca61"/>
16 <path d="M5 2h1v1h-1z" fill="#8c6d21"/>
17 <path d="M1 3h1v1h-1z" fill="#d5a01b" fill-opacity="0.89"/>
18 <path d="M2 3h1v1h-1z" fill="#fde6ae"/>
19 <path d="M3 3h1v1h-1z" fill="#fad885"/>
20 <path d="M4 3h1v1h-1z" fill="#d9b04e"/>
21 <path d="M5 3h1v1h-1z" fill="#9e7939"/>
22 <path d="M1 4h1v1h-1z" fill="#d4a71f" fill-opacity="0.7"/>
23 <path d="M2 4h1v1h-1z" fill="#d6b347"/>
24 <path d="M3 4h1v1h-1z" fill="#eec86b"/>
25 <path d="M4 4h1v1h-1z" fill="#b79238"/>
26 <path d="M5 4h1v1h-1z" fill="#bfa064"/>
27 <path d="M1 5h1v1h-1z" fill="#cea51d" fill-opacity="0.36"/>
28 <path d="M2 5h1v1h-1z" fill="#b28925"/>
29 <path d="M3 5h1v1h-1z" fill="#e1b754"/>
30 <path d="M4 5h1v1h-1z" fill="#94702b"/>
31 <path d="M5 5h1v1h-1z" fill="#dbc890"/>
32 <path d="M2 6h1v1h-1z" fill="#cea208" fill-opacity="0.65"/>
33 <path d="M3 6h1v1h-1z" fill="#8a6619"/>
34 <path d="M4 6h1v1h-1z" fill="#c7a976"/>
35 <path d="M3 7h1v1h-1z" fill="#db870e"/>
36 <path d="M4 7h1v1h-1z" fill="#f4cc87"/>
37 <path d="M2 8h1v1h-1z" fill="#d49a29"/>
38 <path d="M3 8h1v1h-1z" fill="#f5eac6"/>
39 <path d="M4 8h1v1h-1z" fill="#573406"/>
40 <path d="M2 9h1v1h-1z" fill="#d2ab1f" fill-opacity="0.59"/>
41 <path d="M3 9h1v1h-1z" fill="#603a09"/>
42 <path d="M4 9h1v1h-1z" fill="#cfa956"/>
43 <path d="M3 10h1v1h-1z" fill="#cdad40"/>
9</svg>44</svg>
crates/canvas/assets/tags/mark-flag-blue.svg+53-5
...@@ -1,7 +1,55 @@...@@ -1,7 +1,55 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h1v1h-1z" fill="#6c7aa0"/>
3 <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="5.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6f9ae8"/><stop offset="1" stop-color="#2450b0"/></linearGradient>3 <path d="M1 0h1v1h-1z" fill="#556fa8"/>
4 </defs>4 <path d="M2 0h1v1h-1zM2 7h1v1h-1z" fill="#4d7ccb" fill-opacity="0.13"/>
5 <path d="M1.4 1.2L5.4 11.6" fill="none" stroke="#2a3a6a" stroke-width="1"/>5 <path d="M3 0h1v1h-1z" fill="#1c71c9" fill-opacity="0.25"/>
6 <path d="M1.5 1.4C3.5 0.4 5.2 2.2 8.8 1.2C8 2.8 8.4 3.6 9.6 4.6C6.4 5.6 4.9 4.1 2.8 5Z" fill="url(#g0)"/>6 <path d="M4 0h1v1h-1z" fill="#3f5faf"/>
7 <path d="M5 0h1v1h-1z" fill="#3255ab"/>
8 <path d="M6 0h1v1h-1z" fill="#267cd6" fill-opacity="0.29"/>
9 <path d="M0 1h1v1h-1z" fill="#465786"/>
10 <path d="M1 1h2v1h-2z" fill="#3758ac"/>
11 <path d="M3 1h1v1h-1z" fill="#284dac"/>
12 <path d="M4 1h1v1h-1z" fill="#1b47b6"/>
13 <path d="M5 1h1v1h-1z" fill="#2d71ee"/>
14 <path d="M6 1h1v1h-1z" fill="#1c3fa5"/>
15 <path d="M7 1h1v1h-1z" fill="#0f3296"/>
16 <path d="M8 1h1v1h-1z" fill="#072f8a"/>
17 <path d="M9 1h1v1h-1z" fill="#073084"/>
18 <path d="M0 2h1v1h-1z" fill="#2777a6" fill-opacity="0.47"/>
19 <path d="M1 2h1v1h-1z" fill="#495d90"/>
20 <path d="M2 2h1v1h-1zM6 2h1v1h-1z" fill="#3074f1"/>
21 <path d="M3 2h1v1h-1z" fill="#3478f5"/>
22 <path d="M4 2h1v1h-1z" fill="#1139a0"/>
23 <path d="M5 2h1v1h-1z" fill="#105ae1"/>
24 <path d="M7 2h1v1h-1z" fill="#2353b7"/>
25 <path d="M8 2h1v1h-1zM7 3h1v1h-1z" fill="#02336b"/>
26 <path d="M9 2h1v1h-1z" fill="#2780b7" fill-opacity="0.47"/>
27 <path d="M1 3h1v1h-1zM2 5h1v1h-1z" fill="#425485"/>
28 <path d="M2 3h1v1h-1z" fill="#336bb0"/>
29 <path d="M3 3h1v1h-1z" fill="#0e57eb"/>
30 <path d="M4 3h1v1h-1z" fill="#1c4dbd"/>
31 <path d="M5 3h1v1h-1z" fill="#0f52cc"/>
32 <path d="M6 3h1v1h-1z" fill="#0e4ac5"/>
33 <path d="M8 3h1v1h-1z" fill="#7288a1"/>
34 <path d="M1 4h1v1h-1z" fill="#084c94" fill-opacity="0.61"/>
35 <path d="M2 4h1v1h-1zM3 5h1v1h-1z" fill="#0d4386"/>
36 <path d="M3 4h1v1h-1z" fill="#084fef"/>
37 <path d="M4 4h1v1h-1z" fill="#2257ba"/>
38 <path d="M5 4h1v1h-1z" fill="#043475"/>
39 <path d="M6 4h1v1h-1z" fill="#0a3b77"/>
40 <path d="M7 4h1v1h-1z" fill="#ccd5e4"/>
41 <path d="M1 5h1v1h-1z" fill="#5e5ec4" fill-opacity="0.02"/>
42 <path d="M4 5h1v1h-1z" fill="#073870"/>
43 <path d="M5 5h1v1h-1z" fill="#8ea3bb"/>
44 <path d="M2 6h1v1h-1z" fill="#8c94b0"/>
45 <path d="M3 6h1v1h-1z" fill="#3d4c7a"/>
46 <path d="M4 6h1v1h-1z" fill="#c8d0e2"/>
47 <path d="M3 7h1v1h-1z" fill="#29457a"/>
48 <path d="M4 7h1v1h-1z" fill="#95a2be"/>
49 <path d="M3 8h1v1h-1z" fill="#576d90"/>
50 <path d="M4 8h1v1h-1z" fill="#465d8d"/>
51 <path d="M3 9h1v1h-1z" fill="#8193a4"/>
52 <path d="M4 9h1v1h-1z" fill="#264178"/>
53 <path d="M5 9h1v1h-1z" fill="#bfcad1"/>
54 <path d="M4 10h1v1h-1z" fill="#f2f4f9"/>
7</svg>55</svg>
crates/canvas/assets/tags/mark-flag-green.svg+56-5
...@@ -1,7 +1,58 @@...@@ -1,7 +1,58 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h1v1h-1z" fill="#6c7aa0"/>
3 <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="5.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7fe07a"/><stop offset="1" stop-color="#2a9a25"/></linearGradient>3 <path d="M1 0h1v1h-1z" fill="#5f9e68"/>
4 </defs>4 <path d="M2 0h1v1h-1z" fill="#44a344" fill-opacity="0.11"/>
5 <path d="M1.4 1.2L5.4 11.6" fill="none" stroke="#2a3a6a" stroke-width="1"/>5 <path d="M3 0h1v1h-1z" fill="#2cb156" fill-opacity="0.23"/>
6 <path d="M1.5 1.4C3.5 0.4 5.2 2.2 8.8 1.2C8 2.8 8.4 3.6 9.6 4.6C6.4 5.6 4.9 4.1 2.8 5Z" fill="url(#g0)"/>6 <path d="M4 0h1v1h-1z" fill="#4ea15a"/>
7 <path d="M5 0h1v1h-1z" fill="#419d4e"/>
8 <path d="M6 0h1v1h-1z" fill="#30c452" fill-opacity="0.27"/>
9 <path d="M0 1h1v1h-1z" fill="#465786"/>
10 <path d="M1 1h1v1h-1z" fill="#7a9774"/>
11 <path d="M2 1h1v1h-1z" fill="#469f53"/>
12 <path d="M3 1h1v1h-1z" fill="#389c46"/>
13 <path d="M4 1h1v1h-1z" fill="#2ea33f"/>
14 <path d="M5 1h1v1h-1z" fill="#45d64f"/>
15 <path d="M6 1h1v1h-1z" fill="#2d9440"/>
16 <path d="M7 1h1v1h-1z" fill="#208533"/>
17 <path d="M8 1h1v1h-1z" fill="#177a25"/>
18 <path d="M9 1h1v1h-1z" fill="#177420"/>
19 <path d="M0 2h1v1h-1z" fill="#1d68a9" fill-opacity="0.46"/>
20 <path d="M1 2h1v1h-1z" fill="#495d90"/>
21 <path d="M2 2h1v1h-1zM6 2h1v1h-1z" fill="#48d952"/>
22 <path d="M3 2h1v1h-1z" fill="#4cdd56"/>
23 <path d="M4 2h1v1h-1z" fill="#228f32"/>
24 <path d="M5 2h1v1h-1z" fill="#2ac735"/>
25 <path d="M7 2h1v1h-1z" fill="#36a440"/>
26 <path d="M8 2h1v1h-1zM7 3h1v1h-1z" fill="#125e0f"/>
27 <path d="M9 2h1v1h-1z" fill="#24a13e" fill-opacity="0.43"/>
28 <path d="M1 3h1v1h-1zM2 5h1v1h-1z" fill="#425485"/>
29 <path d="M2 3h1v1h-1z" fill="#45a043"/>
30 <path d="M3 3h1v1h-1z" fill="#29d039"/>
31 <path d="M4 3h1v1h-1z" fill="#30a941"/>
32 <path d="M5 3h1v1h-1z" fill="#26b530"/>
33 <path d="M6 3h1v1h-1z" fill="#25af32"/>
34 <path d="M8 3h1v1h-1z" fill="#75958c"/>
35 <path d="M1 4h1v1h-1z" fill="#084c94" fill-opacity="0.61"/>
36 <path d="M2 4h1v1h-1z" fill="#3b4e80"/>
37 <path d="M3 4h1v1h-1z" fill="#24d33d"/>
38 <path d="M4 4h1v1h-1z" fill="#35a73c"/>
39 <path d="M5 4h1v1h-1z" fill="#126714"/>
40 <path d="M6 4h1v1h-1z" fill="#186916"/>
41 <path d="M7 4h1v1h-1z" fill="#cfdfd2"/>
42 <path d="M1 5h1v1h-1z" fill="#5e5ec4" fill-opacity="0.02"/>
43 <path d="M3 5h1v1h-1z" fill="#1e771c"/>
44 <path d="M4 5h1v1h-1z" fill="#186314"/>
45 <path d="M5 5h1v1h-1z" fill="#93b49c"/>
46 <path d="M2 6h1v1h-1z" fill="#8c94b0"/>
47 <path d="M3 6h1v1h-1z" fill="#3d4c7a"/>
48 <path d="M4 6h1v1h-1z" fill="#cbded0"/>
49 <path d="M2 7h1v1h-1z" fill="#49679f" fill-opacity="0.12"/>
50 <path d="M3 7h1v1h-1z" fill="#29457a"/>
51 <path d="M4 7h1v1h-1z" fill="#95a2be"/>
52 <path d="M3 8h1v1h-1z" fill="#576d90"/>
53 <path d="M4 8h1v1h-1z" fill="#465d8d"/>
54 <path d="M3 9h1v1h-1z" fill="#8193a4"/>
55 <path d="M4 9h1v1h-1z" fill="#264178"/>
56 <path d="M5 9h1v1h-1z" fill="#bfcad1"/>
57 <path d="M4 10h1v1h-1z" fill="#f2f4f7"/>
7</svg>58</svg>
crates/canvas/assets/tags/mark-flag-yellow.svg+55-5
...@@ -1,7 +1,57 @@...@@ -1,7 +1,57 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h1v1h-1z" fill="#6c7aa0"/>
3 <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="5.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffd27a"/><stop offset="1" stop-color="#e08a1a"/></linearGradient>3 <path d="M1 0h1v1h-1z" fill="#a88d55"/>
4 </defs>4 <path d="M2 0h1v1h-1z" fill="#dbad49" fill-opacity="0.12"/>
5 <path d="M1.4 1.2L5.4 11.6" fill="none" stroke="#2a3a6a" stroke-width="1"/>5 <path d="M3 0h1v1h-1z" fill="#c1a907" fill-opacity="0.24"/>
6 <path d="M1.5 1.4C3.5 0.4 5.2 2.2 8.8 1.2C8 2.8 8.4 3.6 9.6 4.6C6.4 5.6 4.9 4.1 2.8 5Z" fill="url(#g0)"/>6 <path d="M4 0h1v1h-1z" fill="#ae8b40"/>
7 <path d="M5 0h1v1h-1z" fill="#ab8532"/>
8 <path d="M6 0h1v1h-1z" fill="#d2aa06" fill-opacity="0.27"/>
9 <path d="M0 1h1v1h-1z" fill="#465786"/>
10 <path d="M1 1h1v1h-1z" fill="#9d816e"/>
11 <path d="M2 1h1v1h-1z" fill="#ad8839"/>
12 <path d="M3 1h1v1h-1z" fill="#ac8228"/>
13 <path d="M4 1h1v1h-1z" fill="#b6851b"/>
14 <path d="M5 1h1v1h-1z" fill="#eea82d"/>
15 <path d="M6 1h1v1h-1z" fill="#a5801c"/>
16 <path d="M7 1h1v1h-1z" fill="#96720f"/>
17 <path d="M8 1h1v1h-1z" fill="#8a6107"/>
18 <path d="M9 1h1v1h-1z" fill="#845607"/>
19 <path d="M0 2h1v1h-1z" fill="#1d68a9" fill-opacity="0.46"/>
20 <path d="M1 2h1v1h-1z" fill="#495d90"/>
21 <path d="M2 2h1v1h-1zM6 2h1v1h-1z" fill="#f1ab30"/>
22 <path d="M3 2h1v1h-1z" fill="#f5af34"/>
23 <path d="M4 2h1v1h-1z" fill="#a07311"/>
24 <path d="M5 2h1v1h-1z" fill="#e19510"/>
25 <path d="M7 2h1v1h-1zM4 4h1v1h-1z" fill="#b78123"/>
26 <path d="M8 2h1v1h-1zM7 3h1v1h-1z" fill="#6b3802"/>
27 <path d="M9 2h1v1h-1z" fill="#b39225" fill-opacity="0.45"/>
28 <path d="M1 3h1v1h-1zM2 5h1v1h-1z" fill="#425485"/>
29 <path d="M2 3h1v1h-1z" fill="#b07433"/>
30 <path d="M3 3h1v1h-1z" fill="#eb9a0e"/>
31 <path d="M4 3h1v1h-1z" fill="#bd8a1c"/>
32 <path d="M5 3h1v1h-1z" fill="#cc870f"/>
33 <path d="M6 3h1v1h-1z" fill="#c5830f"/>
34 <path d="M8 3h1v1h-1z" fill="#8b8e87"/>
35 <path d="M1 4h1v1h-1z" fill="#084c94" fill-opacity="0.61"/>
36 <path d="M2 4h1v1h-1z" fill="#564c70"/>
37 <path d="M3 4h1v1h-1z" fill="#efa608"/>
38 <path d="M5 4h1v1h-1z" fill="#754404"/>
39 <path d="M6 4h1v1h-1z" fill="#77410a"/>
40 <path d="M7 4h1v1h-1z" fill="#e1dacf"/>
41 <path d="M1 5h1v1h-1z" fill="#5e5ec4" fill-opacity="0.02"/>
42 <path d="M3 5h1v1h-1z" fill="#864c0d"/>
43 <path d="M4 5h1v1h-1z" fill="#703d07"/>
44 <path d="M5 5h1v1h-1z" fill="#b2a997"/>
45 <path d="M2 6h1v1h-1z" fill="#8c94b0"/>
46 <path d="M3 6h1v1h-1z" fill="#3d4c7a"/>
47 <path d="M4 6h1v1h-1z" fill="#e0d9ca"/>
48 <path d="M2 7h1v1h-1z" fill="#49679f" fill-opacity="0.12"/>
49 <path d="M3 7h1v1h-1z" fill="#29457a"/>
50 <path d="M4 7h1v1h-1z" fill="#95a2be"/>
51 <path d="M3 8h1v1h-1z" fill="#576d90"/>
52 <path d="M4 8h1v1h-1z" fill="#465d8d"/>
53 <path d="M3 9h1v1h-1z" fill="#8193a4"/>
54 <path d="M4 9h1v1h-1z" fill="#264178"/>
55 <path d="M5 9h1v1h-1z" fill="#bfcad1"/>
56 <path d="M4 10h1v1h-1z" fill="#f2f4f7"/>
7</svg>57</svg>
crates/canvas/assets/tags/mark-one-blue.svg+4-5
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#6888b9"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5a8ad8"/><stop offset="1" stop-color="#1e4494"/></linearGradient>3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#2e5898"/>
4 </defs>4 <path d="M1 1h6v1h-6zM1 2h3v1h-3zM5 2h2v6h-2zM1 3h2v5h-2zM3 4h1v4h-1zM4 7h1v1h-1z" fill="#4974ba"/>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>5 <path d="M4 2h1v5h-1zM3 3h1v1h-1z" fill="#ffffff"/>
6 <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/>
7</svg>6</svg>
crates/canvas/assets/tags/mark-one-green.svg+4-5
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#1e8d26"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62b556"/><stop offset="1" stop-color="#237a1c"/></linearGradient>3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#006100"/>
4 </defs>4 <path d="M1 1h6v1h-6zM1 2h3v1h-3zM5 2h2v6h-2zM1 3h2v5h-2zM3 4h1v4h-1zM4 7h1v1h-1z" fill="#23a92d"/>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>5 <path d="M4 2h1v5h-1zM3 3h1v1h-1z" fill="#ffffff"/>
6 <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/>
7</svg>6</svg>
crates/canvas/assets/tags/mark-one-yellow.svg+4-5
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#c19b00"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8b84a"/><stop offset="1" stop-color="#a8781a"/></linearGradient>3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#a88405"/>
4 </defs>4 <path d="M1 1h6v1h-6zM1 2h3v1h-3zM5 2h2v6h-2zM1 3h2v5h-2zM3 4h1v4h-1zM4 7h1v1h-1z" fill="#caa100"/>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>5 <path d="M4 2h1v5h-1zM3 3h1v1h-1z" fill="#ffffff"/>
6 <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/>
7</svg>6</svg>
crates/canvas/assets/tags/mark-person-blue.svg+62-7
...@@ -1,9 +1,64 @@...@@ -1,9 +1,64 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h1v1h-1z" fill="#7b7b7b" fill-opacity="0.14"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa2ae"/><stop offset="1" stop-color="#4a525e"/></linearGradient>3 <path d="M1 0h1v1h-1z" fill="#6f6f6f" fill-opacity="0.62"/>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6f9ae0"/><stop offset="1" stop-color="#1e4494"/></linearGradient>4 <path d="M2 0h1v1h-1zM4 1h1v1h-1z" fill="#8d8d8d"/>
5 </defs>5 <path d="M3 0h1v1h-1z" fill="#888888"/>
6 <path d="M4 0.5A2.5 2.5 0 1 1 4 5.5A2.5 2.5 0 1 1 4 0.5Z" fill="url(#layer0)"/>6 <path d="M4 0h1v1h-1zM6 2h1v1h-1zM5 4h1v1h-1zM4 5h1v1h-1z" fill="#858585"/>
7 <path d="M4 5.5C6.4 5.5 7.75 6.5 7.75 8.5V10H0.25V8.5C0.25 6.5 1.6 5.5 4 5.5Z" fill="url(#layer1)"/>7 <path d="M5 0h1v1h-1z" fill="#565656" fill-opacity="0.6"/>
8 <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/>8 <path d="M6 0h1v1h-1z" fill="#686868" fill-opacity="0.13"/>
9 <path d="M0 1h1v1h-1z" fill="#666666" fill-opacity="0.51"/>
10 <path d="M1 1h1v1h-1zM3 2h1v1h-1z" fill="#6c6c6c"/>
11 <path d="M2 1h1v1h-1z" fill="#828282"/>
12 <path d="M3 1h1v1h-1zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v1h-1z" fill="#a2a2a2"/>
13 <path d="M5 1h1v1h-1zM0 3h1v1h-1zM5 5h1v1h-1zM3 6h1v1h-1z" fill="#555555"/>
14 <path d="M6 1h1v1h-1z" fill="#5b5b5b" fill-opacity="0.65"/>
15 <path d="M0 2h1v1h-1z" fill="#7e7e7e"/>
16 <path d="M1 2h1v1h-1zM6 4h1v1h-1zM4 6h1v1h-1z" fill="#636363"/>
17 <path d="M2 2h1v1h-1zM2 6h1v1h-1z" fill="#5d5d5d"/>
18 <path d="M4 2h1v1h-1z" fill="#585858"/>
19 <path d="M5 2h1v1h-1zM4 3h1v1h-1zM3 4h1v1h-1zM2 5h1v1h-1z" fill="#c1c1c1"/>
20 <path d="M1 3h1v1h-1z" fill="#494949"/>
21 <path d="M2 3h1v1h-1z" fill="#b4b4b4"/>
22 <path d="M3 3h1v1h-1zM2 4h1v1h-1z" fill="#d5d5d5"/>
23 <path d="M6 3h1v1h-1z" fill="#757575"/>
24 <path d="M0 4h1v1h-1z" fill="#383838"/>
25 <path d="M1 4h1v1h-1z" fill="#2c2c2c"/>
26 <path d="M0 5h1v1h-1z" fill="#252525" fill-opacity="0.45"/>
27 <path d="M1 5h1v1h-1z" fill="#000000"/>
28 <path d="M6 5h1v1h-1z" fill="#7f8284"/>
29 <path d="M0 6h1v1h-1z" fill="#636363" fill-opacity="0.07"/>
30 <path d="M1 6h1v1h-1z" fill="#b9bbc6"/>
31 <path d="M5 6h1v1h-1z" fill="#3246ac"/>
32 <path d="M6 6h1v1h-1z" fill="#b4b8cb"/>
33 <path d="M13 6h1v8h-1zM5 13h8v1h-8z" fill="#ccd5d9"/>
34 <path d="M0 7h1v1h-1z" fill="#6b6b6b" fill-opacity="0.4"/>
35 <path d="M1 7h1v1h-1z" fill="#6c7bc9"/>
36 <path d="M2 7h1v1h-1z" fill="#596bc7"/>
37 <path d="M3 7h1v1h-1z" fill="#f3f5f2"/>
38 <path d="M4 7h1v1h-1z" fill="#f8f8f8"/>
39 <path d="M5 7h1v2h-1zM4 9h1v1h-1z" fill="#485bbb"/>
40 <path d="M6 7h1v1h-1z" fill="#3548ad"/>
41 <path d="M7 7h1v1h-1z" fill="#e9e9e9"/>
42 <path d="M0 8h1v2h-1z" fill="#6d7dd4"/>
43 <path d="M1 8h1v1h-1z" fill="#a3afea"/>
44 <path d="M2 8h1v3h-1zM3 9h1v2h-1z" fill="#889af8"/>
45 <path d="M3 8h1v1h-1z" fill="#8191e4"/>
46 <path d="M4 8h1v1h-1z" fill="#afbaee"/>
47 <path d="M6 8h1v1h-1zM5 10h1v1h-1z" fill="#3a56e7"/>
48 <path d="M7 8h1v1h-1z" fill="#2236a4"/>
49 <path d="M1 9h1v1h-1z" fill="#8797f3"/>
50 <path d="M5 9h1v1h-1z" fill="#3855ea"/>
51 <path d="M6 9h1v1h-1z" fill="#324fdb"/>
52 <path d="M7 9h1v2h-1z" fill="#102490"/>
53 <path d="M0 10h1v1h-1z" fill="#6a7bd3"/>
54 <path d="M1 10h1v1h-1z" fill="#7b8deb"/>
55 <path d="M4 10h1v1h-1zM3 11h1v1h-1z" fill="#4354b4"/>
56 <path d="M6 10h1v1h-1z" fill="#253fbf"/>
57 <path d="M0 11h1v1h-1z" fill="#8a8a8a" fill-opacity="0.39"/>
58 <path d="M1 11h1v1h-1z" fill="#6374c5"/>
59 <path d="M2 11h1v1h-1z" fill="#4f62be"/>
60 <path d="M4 11h1v1h-1z" fill="#2f40a6"/>
61 <path d="M5 11h1v1h-1z" fill="#1c2f9d"/>
62 <path d="M6 11h1v1h-1z" fill="#1d2f8d"/>
63 <path d="M7 11h1v1h-1z" fill="#c3c8d3"/>
9</svg>64</svg>
crates/canvas/assets/tags/mark-person-green.svg+67-7
...@@ -1,9 +1,69 @@...@@ -1,9 +1,69 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h1v1h-1z" fill="#7b7b7b" fill-opacity="0.14"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa2ae"/><stop offset="1" stop-color="#4a525e"/></linearGradient>3 <path d="M1 0h1v1h-1z" fill="#6f6f6f" fill-opacity="0.62"/>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#86d070"/><stop offset="1" stop-color="#2e7a1e"/></linearGradient>4 <path d="M2 0h1v1h-1zM4 1h1v1h-1z" fill="#8d8d8d"/>
5 </defs>5 <path d="M3 0h1v1h-1z" fill="#888888"/>
6 <path d="M4 0.5A2.5 2.5 0 1 1 4 5.5A2.5 2.5 0 1 1 4 0.5Z" fill="url(#layer0)"/>6 <path d="M4 0h1v1h-1zM6 2h1v1h-1zM5 4h1v1h-1zM4 5h1v1h-1z" fill="#858585"/>
7 <path d="M4 5.5C6.4 5.5 7.75 6.5 7.75 8.5V10H0.25V8.5C0.25 6.5 1.6 5.5 4 5.5Z" fill="url(#layer1)"/>7 <path d="M5 0h1v1h-1z" fill="#565656" fill-opacity="0.6"/>
8 <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/>8 <path d="M6 0h1v1h-1z" fill="#686868" fill-opacity="0.13"/>
9 <path d="M0 1h1v1h-1z" fill="#666666" fill-opacity="0.51"/>
10 <path d="M1 1h1v1h-1zM3 2h1v1h-1z" fill="#6c6c6c"/>
11 <path d="M2 1h1v1h-1z" fill="#828282"/>
12 <path d="M3 1h1v1h-1zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v1h-1z" fill="#a2a2a2"/>
13 <path d="M5 1h1v1h-1zM0 3h1v1h-1zM5 5h1v1h-1zM3 6h1v1h-1z" fill="#555555"/>
14 <path d="M6 1h1v1h-1z" fill="#5b5b5b" fill-opacity="0.65"/>
15 <path d="M0 2h1v1h-1z" fill="#7e7e7e"/>
16 <path d="M1 2h1v1h-1zM6 4h1v1h-1zM4 6h1v1h-1z" fill="#636363"/>
17 <path d="M2 2h1v1h-1z" fill="#5d5d5d"/>
18 <path d="M4 2h1v1h-1z" fill="#585858"/>
19 <path d="M5 2h1v1h-1zM4 3h1v1h-1zM3 4h1v1h-1zM2 5h1v1h-1z" fill="#c1c1c1"/>
20 <path d="M1 3h1v1h-1z" fill="#494949"/>
21 <path d="M2 3h1v1h-1z" fill="#b4b4b4"/>
22 <path d="M3 3h1v1h-1zM2 4h1v1h-1z" fill="#d5d5d5"/>
23 <path d="M6 3h1v1h-1z" fill="#757575"/>
24 <path d="M0 4h1v1h-1z" fill="#383838"/>
25 <path d="M1 4h1v1h-1z" fill="#2c2c2c"/>
26 <path d="M0 5h1v1h-1z" fill="#252525" fill-opacity="0.45"/>
27 <path d="M1 5h1v1h-1z" fill="#000000"/>
28 <path d="M6 5h1v1h-1z" fill="#868a8c"/>
29 <path d="M0 6h1v1h-1z" fill="#343434" fill-opacity="0.09"/>
30 <path d="M1 6h1v1h-1z" fill="#d3d0cd"/>
31 <path d="M2 6h1v1h-1z" fill="#6c6661"/>
32 <path d="M5 6h1v1h-1z" fill="#209417"/>
33 <path d="M6 6h1v1h-1z" fill="#bcc7bb"/>
34 <path d="M13 6h1v8h-1zM5 13h8v1h-8z" fill="#ccd5d9"/>
35 <path d="M0 7h1v1h-1z" fill="#2f8a24" fill-opacity="0.53"/>
36 <path d="M1 7h1v1h-1z" fill="#2f9b23"/>
37 <path d="M2 7h1v1h-1z" fill="#33a32a"/>
38 <path d="M3 7h1v1h-1z" fill="#c9d6cc"/>
39 <path d="M4 7h1v1h-1z" fill="#f4f1f8"/>
40 <path d="M5 7h1v2h-1z" fill="#26a518"/>
41 <path d="M6 7h1v1h-1zM4 11h1v1h-1z" fill="#1c7f14"/>
42 <path d="M7 7h1v1h-1z" fill="#d1d6d0"/>
43 <path d="M0 8h1v1h-1z" fill="#35ab28"/>
44 <path d="M1 8h1v1h-1z" fill="#90d88b"/>
45 <path d="M2 8h1v1h-1z" fill="#77d66d"/>
46 <path d="M3 8h1v1h-1z" fill="#1c8c13"/>
47 <path d="M4 8h1v1h-1zM6 8h1v1h-1z" fill="#3dcb2e"/>
48 <path d="M7 8h1v1h-1z" fill="#2c9421"/>
49 <path d="M0 9h1v2h-1z" fill="#30a524"/>
50 <path d="M1 9h1v1h-1z" fill="#7eda75"/>
51 <path d="M2 9h1v1h-1z" fill="#6bd760"/>
52 <path d="M3 9h1v1h-1z" fill="#69d65d"/>
53 <path d="M4 9h1v2h-1z" fill="#1b8813"/>
54 <path d="M5 9h1v2h-1z" fill="#38c82d"/>
55 <path d="M6 9h1v1h-1z" fill="#30b326"/>
56 <path d="M7 9h1v1h-1z" fill="#1a7310"/>
57 <path d="M1 10h1v1h-1z" fill="#51c348"/>
58 <path d="M2 10h1v1h-1z" fill="#64d259"/>
59 <path d="M3 10h1v1h-1z" fill="#5ccc53"/>
60 <path d="M6 10h1v1h-1z" fill="#27951c"/>
61 <path d="M7 10h1v1h-1z" fill="#297024"/>
62 <path d="M0 11h1v1h-1z" fill="#54854b" fill-opacity="0.39"/>
63 <path d="M1 11h1v1h-1z" fill="#329e2a"/>
64 <path d="M2 11h1v1h-1z" fill="#229a15"/>
65 <path d="M3 11h1v1h-1z" fill="#1f8d14"/>
66 <path d="M5 11h1v1h-1z" fill="#19780f"/>
67 <path d="M6 11h1v1h-1z" fill="#146e0d"/>
68 <path d="M7 11h1v1h-1z" fill="#c8d1ce"/>
9</svg>69</svg>
crates/canvas/assets/tags/mark-person-yellow.svg+69-7
...@@ -1,9 +1,71 @@...@@ -1,9 +1,71 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h1v1h-1z" fill="#7b7b7b" fill-opacity="0.14"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa2ae"/><stop offset="1" stop-color="#4a525e"/></linearGradient>3 <path d="M1 0h1v1h-1z" fill="#6f6f6f" fill-opacity="0.62"/>
4 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f5d45c"/><stop offset="1" stop-color="#b8901e"/></linearGradient>4 <path d="M2 0h1v1h-1zM4 1h1v1h-1z" fill="#8d8d8d"/>
5 </defs>5 <path d="M3 0h1v1h-1z" fill="#888888"/>
6 <path d="M4 0.5A2.5 2.5 0 1 1 4 5.5A2.5 2.5 0 1 1 4 0.5Z" fill="url(#layer0)"/>6 <path d="M4 0h1v1h-1zM6 2h1v1h-1zM5 4h1v1h-1zM4 5h1v1h-1z" fill="#858585"/>
7 <path d="M4 5.5C6.4 5.5 7.75 6.5 7.75 8.5V10H0.25V8.5C0.25 6.5 1.6 5.5 4 5.5Z" fill="url(#layer1)"/>7 <path d="M5 0h1v1h-1z" fill="#565656" fill-opacity="0.6"/>
8 <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/>8 <path d="M6 0h1v1h-1z" fill="#686868" fill-opacity="0.13"/>
9 <path d="M0 1h1v1h-1z" fill="#666666" fill-opacity="0.51"/>
10 <path d="M1 1h1v1h-1zM3 2h1v1h-1z" fill="#6c6c6c"/>
11 <path d="M2 1h1v1h-1z" fill="#828282"/>
12 <path d="M3 1h1v1h-1zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v1h-1z" fill="#a2a2a2"/>
13 <path d="M5 1h1v1h-1zM0 3h1v1h-1zM5 5h1v1h-1zM3 6h1v1h-1z" fill="#555555"/>
14 <path d="M6 1h1v1h-1z" fill="#5b5b5b" fill-opacity="0.65"/>
15 <path d="M0 2h1v1h-1z" fill="#7e7e7e"/>
16 <path d="M1 2h1v1h-1zM6 4h1v1h-1zM4 6h1v1h-1z" fill="#636363"/>
17 <path d="M2 2h1v1h-1z" fill="#5d5d5d"/>
18 <path d="M4 2h1v1h-1z" fill="#585858"/>
19 <path d="M5 2h1v1h-1zM4 3h1v1h-1zM3 4h1v1h-1zM2 5h1v1h-1z" fill="#c1c1c1"/>
20 <path d="M1 3h1v1h-1z" fill="#494949"/>
21 <path d="M2 3h1v1h-1z" fill="#b4b4b4"/>
22 <path d="M3 3h1v1h-1zM2 4h1v1h-1z" fill="#d5d5d5"/>
23 <path d="M6 3h1v1h-1z" fill="#757575"/>
24 <path d="M0 4h1v1h-1z" fill="#383838"/>
25 <path d="M1 4h1v1h-1z" fill="#2c2c2c"/>
26 <path d="M0 5h1v1h-1z" fill="#252525" fill-opacity="0.45"/>
27 <path d="M1 5h1v1h-1z" fill="#000000"/>
28 <path d="M6 5h1v1h-1z" fill="#868a8c"/>
29 <path d="M0 6h1v1h-1z" fill="#343434" fill-opacity="0.09"/>
30 <path d="M1 6h1v1h-1z" fill="#d3d0cd"/>
31 <path d="M2 6h1v1h-1z" fill="#6c6661"/>
32 <path d="M5 6h1v1h-1z" fill="#a59806"/>
33 <path d="M6 6h1v1h-1z" fill="#c9c7b9"/>
34 <path d="M13 6h1v8h-1zM5 13h8v1h-8z" fill="#ccd5d9"/>
35 <path d="M0 7h1v1h-1z" fill="#968d0c" fill-opacity="0.54"/>
36 <path d="M1 7h1v1h-1z" fill="#ab9813"/>
37 <path d="M2 7h1v1h-1z" fill="#b3a71a"/>
38 <path d="M3 7h1v1h-1z" fill="#d4d8c7"/>
39 <path d="M4 7h1v1h-1z" fill="#f0f6f9"/>
40 <path d="M5 7h1v2h-1z" fill="#b8a105"/>
41 <path d="M6 7h1v1h-1z" fill="#8d8206"/>
42 <path d="M7 7h1v1h-1z" fill="#d7d6cf"/>
43 <path d="M0 8h1v1h-1z" fill="#bbae18"/>
44 <path d="M1 8h1v1h-1z" fill="#e3db80"/>
45 <path d="M2 8h1v1h-1z" fill="#e4d35f"/>
46 <path d="M3 8h1v1h-1z" fill="#9c9003"/>
47 <path d="M4 8h1v1h-1z" fill="#e1c718"/>
48 <path d="M6 8h1v1h-1z" fill="#dec519"/>
49 <path d="M7 8h1v1h-1z" fill="#a39012"/>
50 <path d="M0 9h1v2h-1z" fill="#b6a113"/>
51 <path d="M1 9h1v1h-1z" fill="#e8d867"/>
52 <path d="M2 9h1v1h-1z" fill="#e7d450"/>
53 <path d="M3 9h1v1h-1z" fill="#e6d24d"/>
54 <path d="M4 9h1v2h-1z" fill="#988c03"/>
55 <path d="M5 9h1v2h-1z" fill="#ddcd18"/>
56 <path d="M6 9h1v1h-1z" fill="#c6b713"/>
57 <path d="M7 9h1v1h-1z" fill="#807003"/>
58 <path d="M1 10h1v1h-1z" fill="#d4c737"/>
59 <path d="M2 10h1v1h-1z" fill="#e3cf48"/>
60 <path d="M3 10h1v1h-1z" fill="#ddd042"/>
61 <path d="M6 10h1v1h-1z" fill="#a6920b"/>
62 <path d="M7 10h1v1h-1z" fill="#7a721a"/>
63 <path d="M0 11h1v1h-1z" fill="#9a9269" fill-opacity="0.43"/>
64 <path d="M1 11h1v1h-1z" fill="#aea21a"/>
65 <path d="M2 11h1v1h-1z" fill="#ac9603"/>
66 <path d="M3 11h1v1h-1z" fill="#9e8a03"/>
67 <path d="M4 11h1v1h-1z" fill="#8e7c03"/>
68 <path d="M5 11h1v1h-1z" fill="#867501"/>
69 <path d="M6 11h1v1h-1z" fill="#7c7100"/>
70 <path d="M7 11h1v1h-1z" fill="#cfd1cd"/>
9</svg>71</svg>
crates/canvas/assets/tags/mark-star-blue.svg+57-5
...@@ -1,7 +1,59 @@...@@ -1,7 +1,59 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M4 0h2v1h-2z" fill="#6693c3" fill-opacity="0.37"/>
3 <linearGradient id="g0" x1="0" y1="0" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d3e1fa"/><stop offset="1" stop-color="#8eaee9"/></linearGradient>3 <path d="M4 1h1v1h-1z" fill="#738eb9"/>
4 </defs>4 <path d="M5 1h1v1h-1z" fill="#6d8bbc"/>
5 <path d="M4.4 0.1L5.72 2.78L8.68 3.21L6.54 5.3L7.05 8.24L4.4 6.85L1.75 8.24L2.26 5.3L0.12 3.21L3.08 2.78Z" fill="#3d62ad"/>5 <path d="M3 2h1v1h-1z" fill="#0475b5" fill-opacity="0.39"/>
6 <path d="M4.4 1.2L5.43 3.28L7.73 3.62L6.06 5.24L6.46 7.53L4.4 6.45L2.34 7.53L2.74 5.24L1.07 3.62L3.37 3.28Z" fill="url(#g0)"/>6 <path d="M4 2h1v1h-1z" fill="#6888b9"/>
7 <path d="M5 2h1v1h-1z" fill="#5d7eb2"/>
8 <path d="M6 2h1v1h-1zM1 6h1v1h-1z" fill="#1d79b5" fill-opacity="0.36"/>
9 <path d="M0 3h1v1h-1z" fill="#338ac1" fill-opacity="0.15"/>
10 <path d="M1 3h1v1h-1zM2 10h1v1h-1z" fill="#2f7db9" fill-opacity="0.33"/>
11 <path d="M2 3h1v1h-1z" fill="#1974b8" fill-opacity="0.52"/>
12 <path d="M3 3h1v1h-1z" fill="#486b9b"/>
13 <path d="M4 3h2v1h-2z" fill="#88a5d5"/>
14 <path d="M6 3h1v1h-1z" fill="#4f709d"/>
15 <path d="M7 3h1v1h-1z" fill="#7188a4"/>
16 <path d="M8 3h1v1h-1z" fill="#7c91a7"/>
17 <path d="M9 3h1v1h-1z" fill="#8799aa"/>
18 <path d="M0 4h1v1h-1z" fill="#456ba8"/>
19 <path d="M1 4h1v1h-1z" fill="#4c72b0"/>
20 <path d="M2 4h1v1h-1z" fill="#5a7ebc"/>
21 <path d="M3 4h1v1h-1z" fill="#7496d1"/>
22 <path d="M4 4h1v1h-1z" fill="#99b6ef"/>
23 <path d="M5 4h1v1h-1z" fill="#96b4ed"/>
24 <path d="M6 4h1v1h-1z" fill="#6b8dc9"/>
25 <path d="M7 4h1v1h-1z" fill="#5d81be"/>
26 <path d="M8 4h1v1h-1z" fill="#4970ae"/>
27 <path d="M9 4h1v1h-1zM7 8h1v1h-1z" fill="#466dab"/>
28 <path d="M0 5h1v1h-1zM1 9h1v1h-1z" fill="#1d76bb" fill-opacity="0.46"/>
29 <path d="M1 5h1v1h-1z" fill="#2e5898"/>
30 <path d="M2 5h1v1h-1z" fill="#6a91d9"/>
31 <path d="M3 5h4v1h-4z" fill="#7ca2ec"/>
32 <path d="M7 5h1v1h-1z" fill="#668ed5"/>
33 <path d="M8 5h1v1h-1zM7 6h1v1h-1z" fill="#3760a1"/>
34 <path d="M9 5h1v1h-1z" fill="#a6b7c9"/>
35 <path d="M2 6h1v1h-1z" fill="#3f68ab"/>
36 <path d="M3 6h1v1h-1zM6 6h1v1h-1z" fill="#6d93dc"/>
37 <path d="M4 6h2v1h-2z" fill="#7297e1"/>
38 <path d="M8 6h1v1h-1z" fill="#dee5ef"/>
39 <path d="M1 7h1v1h-1z" fill="#63a4cf" fill-opacity="0.07"/>
40 <path d="M2 7h1v1h-1z" fill="#6987b4"/>
41 <path d="M3 7h3v1h-3z" fill="#8aa8e3"/>
42 <path d="M6 7h1v1h-1z" fill="#84a3de"/>
43 <path d="M7 7h1v1h-1z" fill="#6c89b5"/>
44 <path d="M1 8h1v1h-1z" fill="#067bba" fill-opacity="0.27"/>
45 <path d="M2 8h1v1h-1zM4 8h2v1h-2z" fill="#5377b0"/>
46 <path d="M3 8h1v1h-1z" fill="#7b9acd"/>
47 <path d="M6 8h1v1h-1z" fill="#7594c8"/>
48 <path d="M8 8h1v1h-1z" fill="#eaeff6"/>
49 <path d="M2 9h1v1h-1z" fill="#496ea7"/>
50 <path d="M3 9h1v1h-1z" fill="#56749e"/>
51 <path d="M4 9h1v1h-1z" fill="#d2dbe8"/>
52 <path d="M5 9h1v1h-1z" fill="#b0bfcd"/>
53 <path d="M6 9h1v1h-1z" fill="#8099c0"/>
54 <path d="M7 9h1v1h-1z" fill="#3c649f"/>
55 <path d="M8 9h1v1h-1z" fill="#d8e1ef"/>
56 <path d="M1 10h1v1h-1z" fill="#497dba" fill-opacity="0.12"/>
57 <path d="M7 10h1v1h-1z" fill="#d4deec"/>
58 <path d="M8 10h1v1h-1z" fill="#f4f8fe"/>
7</svg>59</svg>
crates/canvas/assets/tags/mark-star-green.svg+57-5
...@@ -1,7 +1,59 @@...@@ -1,7 +1,59 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M4 0h2v1h-2z" fill="#7fb479" fill-opacity="0.37"/>
3 <linearGradient id="g0" x1="0" y1="0" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#dcf2d0"/><stop offset="1" stop-color="#a4da8c"/></linearGradient>3 <path d="M4 1h1v1h-1z" fill="#82ab7e"/>
4 </defs>4 <path d="M5 1h1v1h-1z" fill="#7dad79"/>
5 <path d="M4.4 0.1L5.72 2.78L8.68 3.21L6.54 5.3L7.05 8.24L4.4 6.85L1.75 8.24L2.26 5.3L0.12 3.21L3.08 2.78Z" fill="#3f8a2c"/>5 <path d="M3 2h1v1h-1z" fill="#5ca34b" fill-opacity="0.39"/>
6 <path d="M4.4 1.2L5.43 3.28L7.73 3.62L6.06 5.24L6.46 7.53L4.4 6.45L2.34 7.53L2.74 5.24L1.07 3.62L3.37 3.28Z" fill="url(#g0)"/>6 <path d="M4 2h1v1h-1z" fill="#7aaa75"/>
7 <path d="M5 2h1v1h-1z" fill="#70a26b"/>
8 <path d="M6 2h1v1h-1z" fill="#5da54b" fill-opacity="0.36"/>
9 <path d="M0 3h1v1h-1z" fill="#50a650" fill-opacity="0.14"/>
10 <path d="M1 3h1v1h-1z" fill="#54a449" fill-opacity="0.32"/>
11 <path d="M2 3h1v1h-1z" fill="#55a443" fill-opacity="0.52"/>
12 <path d="M3 3h1v1h-1z" fill="#598a5d"/>
13 <path d="M4 3h2v1h-2z" fill="#99c794"/>
14 <path d="M6 3h1v1h-1z" fill="#5e8c64"/>
15 <path d="M7 3h1v1h-1z" fill="#789688"/>
16 <path d="M8 3h1v1h-1z" fill="#809995"/>
17 <path d="M9 3h1v1h-1z" fill="#899da2"/>
18 <path d="M0 4h1v1h-1z" fill="#5a9554"/>
19 <path d="M1 4h1v1h-1z" fill="#639e5d"/>
20 <path d="M2 4h1v1h-1zM7 4h1v1h-1z" fill="#6faa6b"/>
21 <path d="M3 4h1v1h-1z" fill="#87c083"/>
22 <path d="M4 4h2v1h-2z" fill="#a9dfa6"/>
23 <path d="M6 4h1v1h-1z" fill="#7fb87b"/>
24 <path d="M8 4h1v1h-1z" fill="#609b5a"/>
25 <path d="M9 4h1v1h-1zM7 8h1v1h-1zM2 9h1v1h-1z" fill="#5d9857"/>
26 <path d="M0 5h1v1h-1z" fill="#3ea124" fill-opacity="0.43"/>
27 <path d="M1 5h1v1h-1z" fill="#468540"/>
28 <path d="M2 5h1v1h-1z" fill="#7fc57c"/>
29 <path d="M3 5h4v1h-4z" fill="#92d88f"/>
30 <path d="M7 5h1v1h-1z" fill="#7dc178"/>
31 <path d="M8 5h1v1h-1zM7 6h1v1h-1z" fill="#4f8e49"/>
32 <path d="M9 5h1v1h-1z" fill="#acc1b4"/>
33 <path d="M1 6h1v1h-1z" fill="#52a23b" fill-opacity="0.35"/>
34 <path d="M2 6h1v1h-1z" fill="#589851"/>
35 <path d="M3 6h1v1h-1zM6 6h1v1h-1z" fill="#83c87f"/>
36 <path d="M4 6h2v1h-2z" fill="#88cd84"/>
37 <path d="M8 6h1v1h-1z" fill="#e2ece1"/>
38 <path d="M1 7h1v1h-1z" fill="#62ac62" fill-opacity="0.06"/>
39 <path d="M2 7h1v1h-1z" fill="#7aa675"/>
40 <path d="M3 7h3v1h-3z" fill="#9bd398"/>
41 <path d="M6 7h1v1h-1z" fill="#96ce93"/>
42 <path d="M7 7h1v1h-1z" fill="#7da778"/>
43 <path d="M1 8h1v1h-1z" fill="#5eaa52" fill-opacity="0.27"/>
44 <path d="M2 8h1v1h-1zM4 8h2v1h-2z" fill="#689f62"/>
45 <path d="M3 8h1v1h-1z" fill="#8ebe89"/>
46 <path d="M6 8h1v1h-1z" fill="#86b982"/>
47 <path d="M8 8h1v1h-1z" fill="#ecf3ef"/>
48 <path d="M1 9h1v1h-1z" fill="#55a746" fill-opacity="0.45"/>
49 <path d="M3 9h1v1h-1z" fill="#638e6b"/>
50 <path d="M4 9h1v1h-1z" fill="#d7e3d8"/>
51 <path d="M5 9h1v1h-1z" fill="#b4c6be"/>
52 <path d="M6 9h1v1h-1z" fill="#8eb38b"/>
53 <path d="M7 9h1v1h-1z" fill="#548d4c"/>
54 <path d="M8 9h1v1h-1z" fill="#dce8e1"/>
55 <path d="M1 10h1v1h-1z" fill="#67ad67" fill-opacity="0.12"/>
56 <path d="M2 10h1v1h-1z" fill="#60a857" fill-opacity="0.33"/>
57 <path d="M7 10h1v1h-1z" fill="#d8e5dc"/>
58 <path d="M8 10h1v1h-1z" fill="#f4f9fd"/>
7</svg>59</svg>
crates/canvas/assets/tags/mark-star-yellow.svg+55-5
...@@ -1,7 +1,57 @@...@@ -1,7 +1,57 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M4 0h2v1h-2z" fill="#bdad5d" fill-opacity="0.36"/>
3 <linearGradient id="g0" x1="0" y1="0" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcf0c0"/><stop offset="1" stop-color="#f7d66e"/></linearGradient>3 <path d="M4 1h1v1h-1z" fill="#b7a672"/>
4 </defs>4 <path d="M5 1h1v1h-1z" fill="#b9a56b"/>
5 <path d="M4.4 0.1L5.72 2.78L8.68 3.21L6.54 5.3L7.05 8.24L4.4 6.85L1.75 8.24L2.26 5.3L0.12 3.21L3.08 2.78Z" fill="#b0801a"/>5 <path d="M3 2h1v1h-1z" fill="#b29b04" fill-opacity="0.39"/>
6 <path d="M4.4 1.2L5.43 3.28L7.73 3.62L6.06 5.24L6.46 7.53L4.4 6.45L2.34 7.53L2.74 5.24L1.07 3.62L3.37 3.28Z" fill="url(#g0)"/>6 <path d="M4 2h1v1h-1z" fill="#b8a467"/>
7 <path d="M5 2h1v1h-1z" fill="#b19c5c"/>
8 <path d="M6 2h1v1h-1zM1 6h1v1h-1z" fill="#b59c1d" fill-opacity="0.36"/>
9 <path d="M0 3h1v1h-1z" fill="#b8a333" fill-opacity="0.15"/>
10 <path d="M1 3h1v1h-1zM2 10h1v1h-1z" fill="#b5a32f" fill-opacity="0.33"/>
11 <path d="M2 3h1v1h-1z" fill="#b39b24" fill-opacity="0.53"/>
12 <path d="M3 3h1v1h-1z" fill="#918450"/>
13 <path d="M4 3h2v1h-2z" fill="#d4c187"/>
14 <path d="M6 3h1v1h-1z" fill="#918759"/>
15 <path d="M7 3h1v1h-1z" fill="#919382"/>
16 <path d="M8 3h1v1h-1z" fill="#909891"/>
17 <path d="M9 3h1v1h-1z" fill="#909da1"/>
18 <path d="M0 4h1v1h-1z" fill="#a78e45"/>
19 <path d="M1 4h1v1h-1z" fill="#af974c"/>
20 <path d="M2 4h1v1h-1zM7 4h1v1h-1z" fill="#bba35a"/>
21 <path d="M3 4h1v1h-1z" fill="#d0b973"/>
22 <path d="M4 4h2v1h-2z" fill="#eedb97"/>
23 <path d="M6 4h1v1h-1z" fill="#c8b16b"/>
24 <path d="M8 4h1v1h-1z" fill="#ad9448"/>
25 <path d="M9 4h1v1h-1zM7 8h1v1h-1z" fill="#a99146"/>
26 <path d="M0 5h1v1h-1z" fill="#b29c1d" fill-opacity="0.46"/>
27 <path d="M1 5h1v1h-1z" fill="#977d2e"/>
28 <path d="M2 5h1v1h-1z" fill="#d8bf69"/>
29 <path d="M3 5h4v1h-4z" fill="#ebd27c"/>
30 <path d="M7 5h1v1h-1z" fill="#d4b965"/>
31 <path d="M8 5h1v1h-1zM7 6h1v1h-1z" fill="#a08637"/>
32 <path d="M9 5h1v1h-1z" fill="#bebfb0"/>
33 <path d="M2 6h1v1h-1z" fill="#aa903f"/>
34 <path d="M3 6h1v1h-1zM6 6h1v1h-1z" fill="#dbc26c"/>
35 <path d="M4 6h2v1h-2z" fill="#e0c771"/>
36 <path d="M8 6h1v1h-1z" fill="#eeeade"/>
37 <path d="M1 7h1v1h-1z" fill="#cfbb63" fill-opacity="0.07"/>
38 <path d="M2 7h1v1h-1zM7 7h1v1h-1z" fill="#b2a069"/>
39 <path d="M3 7h3v1h-3z" fill="#e2ce89"/>
40 <path d="M6 7h1v1h-1z" fill="#ddc984"/>
41 <path d="M1 8h1v1h-1z" fill="#b59e06" fill-opacity="0.27"/>
42 <path d="M2 8h1v1h-1zM4 8h2v1h-2z" fill="#af9852"/>
43 <path d="M3 8h1v1h-1z" fill="#ccb87b"/>
44 <path d="M6 8h1v1h-1z" fill="#c7b274"/>
45 <path d="M8 8h1v1h-1z" fill="#f2f2ed"/>
46 <path d="M1 9h1v1h-1z" fill="#b49f33" fill-opacity="0.47"/>
47 <path d="M2 9h1v1h-1z" fill="#a68f49"/>
48 <path d="M3 9h1v1h-1z" fill="#918961"/>
49 <path d="M4 9h1v1h-1z" fill="#e5e1d5"/>
50 <path d="M5 9h1v1h-1z" fill="#c2c5bb"/>
51 <path d="M6 9h1v1h-1z" fill="#beaf80"/>
52 <path d="M7 9h1v1h-1z" fill="#9e813b"/>
53 <path d="M8 9h1v1h-1z" fill="#e8e7de"/>
54 <path d="M1 10h1v1h-1z" fill="#baad49" fill-opacity="0.12"/>
55 <path d="M7 10h1v1h-1z" fill="#e6e4d9"/>
56 <path d="M8 10h1v1h-1z" fill="#f5f8fc"/>
7</svg>57</svg>
crates/canvas/assets/tags/mark-three-blue.svg+4-5
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#6888b9"/>
3 <linearGradient id="g0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5a8ad8"/><stop offset="1" stop-color="#1e4494"/></linearGradient>3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#2e5898"/>
4 </defs>4 <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v2h-2zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#4974ba"/>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#g0)"/>5 <path d="M2 2h4v1h-4zM5 3h1v4h-1zM4 4h1v1h-1zM2 6h3v1h-3z" fill="#ffffff"/>
6 <path d="M2.3 2.4C2.6 1.8 3.1 1.5 3.8 1.5C4.7 1.5 5.3 2 5.3 2.7C5.3 3.5 4.6 4 3.7 4C4.8 4 5.5 4.6 5.5 5.5C5.5 6.4 4.8 7.1 3.8 7.1C3.1 7.1 2.5 6.8 2.1 6.2" fill="none" stroke="#ffffff" stroke-width="1.1"/>
7</svg>6</svg>
crates/canvas/assets/tags/mark-three-green.svg+4-5
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#1e8d26"/>
3 <linearGradient id="g0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62b556"/><stop offset="1" stop-color="#237a1c"/></linearGradient>3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#006100"/>
4 </defs>4 <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v2h-2zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#23a92d"/>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#g0)"/>5 <path d="M2 2h4v1h-4zM5 3h1v4h-1zM4 4h1v1h-1zM2 6h3v1h-3z" fill="#ffffff"/>
6 <path d="M2.3 2.4C2.6 1.8 3.1 1.5 3.8 1.5C4.7 1.5 5.3 2 5.3 2.7C5.3 3.5 4.6 4 3.7 4C4.8 4 5.5 4.6 5.5 5.5C5.5 6.4 4.8 7.1 3.8 7.1C3.1 7.1 2.5 6.8 2.1 6.2" fill="none" stroke="#ffffff" stroke-width="1.1"/>
7</svg>6</svg>
crates/canvas/assets/tags/mark-three-yellow.svg+4-5
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#c19b00"/>
3 <linearGradient id="g0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8b84a"/><stop offset="1" stop-color="#a8781a"/></linearGradient>3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#a88405"/>
4 </defs>4 <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v2h-2zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#caa100"/>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#g0)"/>5 <path d="M2 2h4v1h-4zM5 3h1v4h-1zM4 4h1v1h-1zM2 6h3v1h-3z" fill="#ffffff"/>
6 <path d="M2.3 2.4C2.6 1.8 3.1 1.5 3.8 1.5C4.7 1.5 5.3 2 5.3 2.7C5.3 3.5 4.6 4 3.7 4C4.8 4 5.5 4.6 5.5 5.5C5.5 6.4 4.8 7.1 3.8 7.1C3.1 7.1 2.5 6.8 2.1 6.2" fill="none" stroke="#ffffff" stroke-width="1.1"/>
7</svg>6</svg>
crates/canvas/assets/tags/mark-two-blue.svg+4-5
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#6888b9"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5a8ad8"/><stop offset="1" stop-color="#1e4494"/></linearGradient>3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#2e5898"/>
4 </defs>4 <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v1h-2zM5 4h1v2h-1zM2 5h1v1h-1zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#4974ba"/>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>5 <path d="M2 2h4v1h-4zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v2h-1zM2 6h1v1h-1zM4 6h2v1h-2z" fill="#ffffff"/>
6 <path d="M2 2.9C2.1 1.9 2.8 1.5 3.75 1.5C4.9 1.5 5.5 2.1 5.5 3C5.5 3.9 4.9 4.5 3.6 5.7L3.3 6H5.6V7.25H1.9V6.2L3.3 4.9C4.1 4.1 4.25 3.7 4.25 3.2C4.25 2.8 4 2.6 3.7 2.6C3.35 2.6 3.2 2.85 3.15 3.2Z" fill="#ffffff"/>
7</svg>6</svg>
crates/canvas/assets/tags/mark-two-green.svg+4-5
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#1e8d26"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62b556"/><stop offset="1" stop-color="#237a1c"/></linearGradient>3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#006100"/>
4 </defs>4 <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v1h-2zM5 4h1v2h-1zM2 5h1v1h-1zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#23a92d"/>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>5 <path d="M2 2h4v1h-4zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v2h-1zM2 6h1v1h-1zM4 6h2v1h-2z" fill="#ffffff"/>
6 <path d="M2 2.9C2.1 1.9 2.8 1.5 3.75 1.5C4.9 1.5 5.5 2.1 5.5 3C5.5 3.9 4.9 4.5 3.6 5.7L3.3 6H5.6V7.25H1.9V6.2L3.3 4.9C4.1 4.1 4.25 3.7 4.25 3.2C4.25 2.8 4 2.6 3.7 2.6C3.35 2.6 3.2 2.85 3.15 3.2Z" fill="#ffffff"/>
7</svg>6</svg>
crates/canvas/assets/tags/mark-two-yellow.svg+4-5
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#c19b00"/>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8b84a"/><stop offset="1" stop-color="#a8781a"/></linearGradient>3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#a88405"/>
4 </defs>4 <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v1h-2zM5 4h1v2h-1zM2 5h1v1h-1zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#caa100"/>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>5 <path d="M2 2h4v1h-4zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v2h-1zM2 6h1v1h-1zM4 6h2v1h-2z" fill="#ffffff"/>
6 <path d="M2 2.9C2.1 1.9 2.8 1.5 3.75 1.5C4.9 1.5 5.5 2.1 5.5 3C5.5 3.9 4.9 4.5 3.6 5.7L3.3 6H5.6V7.25H1.9V6.2L3.3 4.9C4.1 4.1 4.25 3.7 4.25 3.2C4.25 2.8 4 2.6 3.7 2.6C3.35 2.6 3.2 2.85 3.15 3.2Z" fill="#ffffff"/>
7</svg>6</svg>
crates/canvas/assets/tags/star8-green.svg created+4
...@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 1.2V14.8M1.2 8H14.8M3.2 3.2L12.8 12.8M12.8 3.2L3.2 12.8" fill="none" stroke="#5aa83c" stroke-width="1"/>
3 <path d="M6.6 8A1.4 1.4 0 1 1 9.4 8A1.4 1.4 0 1 1 6.6 8Z" fill="#3f8a2c"/>
4</svg>
crates/canvas/src/document.rs+2
...@@ -1578,6 +1578,8 @@ mod tests {...@@ -1578,6 +1578,8 @@ mod tests {
1578 nodes[1].tags.push(onestore::document::Tag {1578 nodes[1].tags.push(onestore::document::Tag {
1579 definition: Some(new_id().unwrap()),1579 definition: Some(new_id().unwrap()),
1580 action_type: Some(0),1580 action_type: Some(0),
1581 shape: None,
1582 property_status: None,
1581 status: 1,1583 status: 1,
1582 created: Some(123),1584 created: Some(123),
1583 completed: None,1585 completed: None,
crates/canvas/src/editor/evidence.rs+2
...@@ -97,6 +97,8 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {...@@ -97,6 +97,8 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {
97 node.text_mut().unwrap().tags.push(Tag {97 node.text_mut().unwrap().tags.push(Tag {
98 definition: Some(definition),98 definition: Some(definition),
99 action_type: None,99 action_type: None,
100 shape: None,
101 property_status: None,
100 status: u16::from(completed),102 status: u16::from(completed),
101 created: Some(1),103 created: Some(1),
102 completed: Some(u32::from(completed)),104 completed: Some(u32::from(completed)),
crates/canvas/src/editor/format.rs+5-1
...@@ -769,6 +769,8 @@ impl CanvasEditor {...@@ -769,6 +769,8 @@ impl CanvasEditor {
769 Tag {769 Tag {
770 definition: Some(id),770 definition: Some(id),
771 action_type: None,771 action_type: None,
772 shape: None,
773 property_status: None,
772 status: u16::from(!checkable),774 status: u16::from(!checkable),
773 created,775 created,
774 completed: if checkable { Some(0) } else { created },776 completed: if checkable { Some(0) } else { created },
...@@ -1809,6 +1811,8 @@ mod tests {...@@ -1809,6 +1811,8 @@ mod tests {
1809 nodes[2].tags.push(Tag {1811 nodes[2].tags.push(Tag {
1810 definition: Some(music),1812 definition: Some(music),
1811 action_type: None,1813 action_type: None,
1814 shape: None,
1815 property_status: None,
1812 status: 1,1816 status: 1,
1813 created: Some(1),1817 created: Some(1),
1814 completed: Some(1),1818 completed: Some(1),
...@@ -1938,7 +1942,7 @@ mod tests {...@@ -1938,7 +1942,7 @@ mod tests {
1938 .iter()1942 .iter()
1939 .map(|tag| match tag.icon {1943 .map(|tag| match tag.icon {
1940 crate::outline::TagIcon::Symbol { shape, .. } => shape,1944 crate::outline::TagIcon::Symbol { shape, .. } => shape,
1941 crate::outline::TagIcon::Task => unreachable!(),1945 crate::outline::TagIcon::Task { .. } => unreachable!(),
1942 })1946 })
1943 .collect::<Vec<_>>();1947 .collect::<Vec<_>>();
1944 assert_eq!(shapes.len(), defaults.len() - 2);1948 assert_eq!(shapes.len(), defaults.len() - 2);
crates/canvas/src/gpu/mod.rs+83-7
...@@ -141,7 +141,6 @@ pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout {...@@ -141,7 +141,6 @@ pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout {
141}141}
142142
143const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg");143const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg");
144const FLAG: &str = include_str!("../../assets/tags/flag.svg");
145const BOX_SMALL: &str = include_str!("../../assets/tags/box-small.svg");144const BOX_SMALL: &str = include_str!("../../assets/tags/box-small.svg");
146const CHECK_SMALL: &str = include_str!("../../assets/tags/check-small.svg");145const CHECK_SMALL: &str = include_str!("../../assets/tags/check-small.svg");
147const TAG: &str = include_str!("../../assets/tags/tag.svg");146const TAG: &str = include_str!("../../assets/tags/tag.svg");
...@@ -155,8 +154,7 @@ enum Art {...@@ -155,8 +154,7 @@ enum Art {
155}154}
156155
157/// The artwork of symbol `shape` of MS-ONE's NoteTagShape, as OneNote 2010 draws it on the156/// The artwork of symbol `shape` of MS-ONE's NoteTagShape, as OneNote 2010 draws it on the
158/// page: it draws 54, Green 8-Point Star, blue there. A shape MS-ONE does not list shows a157/// page. A shape MS-ONE does not list shows a plain tag.
159/// plain tag.
160fn art(shape: u16) -> Art {158fn art(shape: u16) -> Art {
161 use Art::{Badged, Box, Plain};159 use Art::{Badged, Box, Plain};
162 macro_rules! tag {160 macro_rules! tag {
...@@ -218,7 +216,7 @@ fn art(shape: u16) -> Art {...@@ -218,7 +216,7 @@ fn art(shape: u16) -> Art {
218 51 => Plain(tag!("circle-2-green")),216 51 => Plain(tag!("circle-2-green")),
219 52 => Badged(tag!("mark-three-green")),217 52 => Badged(tag!("mark-three-green")),
220 53 => Plain(tag!("circle-3-green")),218 53 => Plain(tag!("circle-3-green")),
221 54 => Plain(tag!("star8-blue")),219 54 => Plain(tag!("star8-green")),
222 55 => Plain(tag!("tick-green")),220 55 => Plain(tag!("tick-green")),
223 56 => Plain(tag!("circle-green")),221 56 => Plain(tag!("circle-green")),
224 57 => Plain(tag!("arrow-down-green")),222 57 => Plain(tag!("arrow-down-green")),
...@@ -318,8 +316,9 @@ const SYMBOLS: u16 = 143;...@@ -318,8 +316,9 @@ const SYMBOLS: u16 = 143;
318/// The tag's artwork, drawn in order.316/// The tag's artwork, drawn in order.
319pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {317pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
320 static SOURCES: std::sync::OnceLock<Vec<[Vec<&'static str>; 2]>> = std::sync::OnceLock::new();318 static SOURCES: std::sync::OnceLock<Vec<[Vec<&'static str>; 2]>> = std::sync::OnceLock::new();
321 let TagIcon::Symbol { shape, checked } = icon else {319 let (shape, checked) = match icon {
322 return &[FLAG];320 TagIcon::Symbol { shape, checked } => (shape, checked),
321 TagIcon::Task { shape } => (shape, false),
323 };322 };
324 let sources = SOURCES.get_or_init(|| {323 let sources = SOURCES.get_or_init(|| {
325 (0..=SYMBOLS)324 (0..=SYMBOLS)
...@@ -350,6 +349,83 @@ mod tests {...@@ -350,6 +349,83 @@ mod tests {
350 use onestore::page::text::Paragraph;349 use onestore::page::text::Paragraph;
351 use std::time::Duration;350 use std::time::Duration;
352351
352 /// OneNote 2010 draws paragraph `shape N` of `corpus/custom-tags/native/shapes.one` with
353 /// the art `DRAWN`'s Nth word names (its render: `shapes-1-72.png`, `shapes-73-143.png`);
354 /// each paragraph's tag definition stores the NoteTagShape.
355 #[test]
356 fn symbols_draw_the_art_onenote_draws_for_their_number() {
357 use onestore::page::{PageObject, ParagraphContent};
358 use onestore::{RevisionIndex, Store, document::Document, document::Kind, page::Page};
359 const DRAWN: &str = "\
360 checkbox-green checkbox-yellow checkbox mark-star-green mark-star-yellow \
361 mark-star-blue mark-exclamation-green mark-exclamation-yellow mark-exclamation-blue \
362 mark-arrow-green mark-arrow-yellow mark-arrow-blue star follow-up question \
363 arrow-right-blue exclamation phone calendar clock idea pushpin address blog smiley \
364 ribbon key mark-one-blue circle-1-blue mark-two-blue circle-2-blue mark-three-blue \
365 circle-3-blue star8-blue tick-blue circle-blue arrow-down-blue arrow-left-blue \
366 solid-target-blue star-blue sun-blue target-blue triangle-blue umbrella-blue \
367 arrow-up-blue x-dots-blue x-blue mark-one-green circle-1-green mark-two-green \
368 circle-2-green mark-three-green circle-3-green star8-green tick-green circle-green \
369 arrow-down-green arrow-left-green arrow-right-green solid-target-green star-green \
370 sun-green target-green triangle-green umbrella-green arrow-up-green x-dots-green \
371 x-green mark-one-yellow circle-1-yellow mark-two-yellow circle-2-yellow \
372 mark-three-yellow circle-3-yellow star8-yellow tick-yellow circle-yellow \
373 arrow-down-yellow arrow-left-yellow arrow-right-yellow solid-target-yellow \
374 sun-yellow target-yellow triangle-yellow umbrella-yellow arrow-up-yellow \
375 x-dots-yellow x-yellow flag-today flag-tomorrow flag-this-week flag-next-week \
376 flag-no-date mark-person-blue mark-person-yellow mark-person-green mark-flag-blue \
377 mark-flag-yellow mark-flag-green red-square yellow-square blue-square green-square \
378 orange-square pink-square email envelope envelope-open mobile phone-clock \
379 question-balloon paperclip frown im-contact person people bell contact rose date \
380 music movie quote globe web laptop plane car binoculars presentation password book \
381 notebook paper research highlight dollar coins schedule lightning cloud heart \
382 sunflower";
383 let bytes = include_bytes!("../../../../corpus/custom-tags/native/shapes.one");
384 let store = Store::parse(bytes).unwrap();
385 let index = RevisionIndex::parse(&store).unwrap();
386 let document = Document::parse(&index).unwrap();
387 let files: Vec<&str> = DRAWN.split_whitespace().collect();
388 let mut seen = Vec::new();
389 for (space, _) in document.pages().unwrap() {
390 let page = Page::from_space(&document, space).unwrap();
391 if !page.title.starts_with("Shapes") {
392 continue;
393 }
394 for object in &page.objects {
395 let PageObject::Outline(outline) = object else {
396 continue;
397 };
398 for paragraph in &outline.paragraphs {
399 let ParagraphContent::Text(text) = &paragraph.content else {
400 continue;
401 };
402 let label: usize = text.text.text()["shape ".len()..].parse().unwrap();
403 let definition = text.tags[0].definition.unwrap();
404 let Kind::TagDefinition {
405 shape: Some(shape), ..
406 } = page.definitions[&definition].kind
407 else {
408 panic!("shape {label} stores no symbol");
409 };
410 let (Art::Box(drawn) | Art::Badged(drawn) | Art::Plain(drawn)) = art(shape);
411 let file = files[label - 1];
412 let expected = std::fs::read_to_string(format!(
413 "{}/assets/tags/{file}.svg",
414 env!("CARGO_MANIFEST_DIR")
415 ))
416 .unwrap();
417 assert_eq!(
418 drawn, expected,
419 "shape {label}, stored {shape}, draws {file}"
420 );
421 seen.push(label);
422 }
423 }
424 }
425 seen.sort_unstable();
426 assert_eq!(seen, (1..=143).collect::<Vec<_>>());
427 }
428
353 /// A page colour paints as OneNote paints it on white paper, and on dark paper as a429 /// A page colour paints as OneNote paints it on white paper, and on dark paper as a
354 /// dark paper of its hue; no colour leaves the paper as it was.430 /// dark paper of its hue; no colour leaves the paper as it was.
355 #[test]431 #[test]
...@@ -465,7 +541,7 @@ mod tests {...@@ -465,7 +541,7 @@ mod tests {
465 ]541 ]
466 .into_iter()542 .into_iter()
467 .map(|(shape, checked)| TagIcon::of(shape, checked).unwrap())543 .map(|(shape, checked)| TagIcon::of(shape, checked).unwrap())
468 .chain([TagIcon::Task])544 .chain([TagIcon::Task { shape: 91 }])
469 .collect();545 .collect();
470 let text = TextEngine::default()546 let text = TextEngine::default()
471 .layout(547 .layout(
crates/canvas/src/gpu/page.rs+2
...@@ -1777,6 +1777,8 @@ mod tests {...@@ -1777,6 +1777,8 @@ mod tests {
1777 definition: Some(tag),1777 definition: Some(tag),
1778 status: 1,1778 status: 1,
1779 action_type: None,1779 action_type: None,
1780 shape: None,
1781 property_status: None,
1780 created: None,1782 created: None,
1781 completed: None,1783 completed: None,
1782 start: None,1784 start: None,
crates/canvas/src/interaction/accessibility.rs+3-1
...@@ -598,7 +598,7 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>...@@ -598,7 +598,7 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>
598 let label = match tag.icon {598 let label = match tag.icon {
599 _ if !tag.label.is_empty() => &tag.label,599 _ if !tag.label.is_empty() => &tag.label,
600 TagIcon::Symbol { shape, .. } => symbol_name(shape).unwrap_or("Tag"),600 TagIcon::Symbol { shape, .. } => symbol_name(shape).unwrap_or("Tag"),
601 TagIcon::Task => "Outlook task",601 TagIcon::Task { .. } => "Outlook task",
602 };602 };
603 let state = match tag.icon {603 let state = match tag.icon {
604 TagIcon::Symbol { checked: true, .. } => ", completed",604 TagIcon::Symbol { checked: true, .. } => ", completed",
...@@ -946,6 +946,8 @@ pub(super) mod tests {...@@ -946,6 +946,8 @@ pub(super) mod tests {
946 paragraphs[index].text_mut().unwrap().tags.push(Tag {946 paragraphs[index].text_mut().unwrap().tags.push(Tag {
947 definition: Some(id),947 definition: Some(id),
948 action_type: None,948 action_type: None,
949 shape: None,
950 property_status: None,
949 status,951 status,
950 created: None,952 created: None,
951 completed: None,953 completed: None,
crates/canvas/src/interaction/mod.rs+31
...@@ -504,6 +504,37 @@ impl PageView {...@@ -504,6 +504,37 @@ impl PageView {
504 self.hit_test(self.viewport.document_point(position))504 self.hit_test(self.viewport.document_point(position))
505 }505 }
506506
507 /// The Outlook task icon under the pointer, in view device pixels, for the host to name.
508 pub fn task_under_pointer(&self) -> Option<[f32; 4]> {
509 if !self.pointer_inside || self.drag.is_some() {
510 return None;
511 }
512 let [x, y] = self.viewport.document_point(self.pointer);
513 let view = |v: f32, axis: usize| v * self.viewport.scale + self.viewport.origin[axis];
514 self.editor.visible_outlines().find_map(|outline| {
515 let offset = match &self.scene {
516 Some((_, offset)) if self.editor.has_page_outline(outline.id) => *offset,
517 _ => [0.0; 2],
518 };
519 let shaped = outline.shaped();
520 let left = offset[0] + outline.origin()[0] + shaped.tag_column_offset();
521 let top = offset[1] + outline.origin()[1];
522 shaped.paragraphs.iter().find_map(|paragraph| {
523 paragraph.tags.iter().find_map(|tag| {
524 let x0 = left + tag.origin[0];
525 let y0 = top + paragraph.origin[1] + tag.origin[1];
526 (matches!(tag.icon, crate::outline::TagIcon::Task { .. })
527 && (x0..=x0 + tag.size).contains(&x)
528 && (y0..=y0 + tag.size).contains(&y))
529 .then(|| {
530 let [x1, y1] = [x0 + tag.size, y0 + tag.size];
531 [view(x0, 0), view(y0, 1), view(x1, 0), view(y1, 1)]
532 })
533 })
534 })
535 })
536 }
537
507 fn set_object_focus(&mut self, focus: Option<ObjectFocus>) {538 fn set_object_focus(&mut self, focus: Option<ObjectFocus>) {
508 if self.object_focus != focus {539 if self.object_focus != focus {
509 self.editor.finish_composition();540 self.editor.finish_composition();
crates/canvas/src/outline.rs+22-7
...@@ -227,8 +227,8 @@ pub struct ParagraphLayout {...@@ -227,8 +227,8 @@ pub struct ParagraphLayout {
227pub enum TagIcon {227pub enum TagIcon {
228 /// Symbol `shape` of MS-ONE's NoteTagShape, `checked` only where it is a check box.228 /// Symbol `shape` of MS-ONE's NoteTagShape, `checked` only where it is a check box.
229 Symbol { shape: u16, checked: bool },229 Symbol { shape: u16, checked: bool },
230 /// An Outlook task, which OneNote 2010 flags.230 /// An Outlook task, drawn with its stored follow-up flag and never checked from the page.
231 Task,231 Task { shape: u16 },
232}232}
233233
234impl TagIcon {234impl TagIcon {
...@@ -887,8 +887,8 @@ impl ParagraphLayout {...@@ -887,8 +887,8 @@ impl ParagraphLayout {
887 return Err(LayoutError::InvalidSpacing);887 return Err(LayoutError::InvalidSpacing);
888 }888 }
889 // Stored newest first; OneNote lists and paints tags oldest first.889 // Stored newest first; OneNote lists and paints tags oldest first.
890 // An Outlook task tag carries no definition; a tag whose definition is missing shows890 // An Outlook task tag carries no definition but its own icon; a tag whose definition
891 // nothing, as OneNote draws no icon for it.891 // is missing shows nothing, as OneNote draws no icon for it.
892 let mut tag_definitions: Vec<_> = paragraph892 let mut tag_definitions: Vec<_> = paragraph
893 .tags893 .tags
894 .iter()894 .iter()
...@@ -1065,7 +1065,9 @@ impl ParagraphLayout {...@@ -1065,7 +1065,9 @@ impl ParagraphLayout {
1065 for tag in &tag_definitions {1065 for tag in &tag_definitions {
1066 let (tag, shape, label) = (tag.0, tag.1, tag.2);1066 let (tag, shape, label) = (tag.0, tag.1, tag.2);
1067 let icon = match shape {1067 let icon = match shape {
1068 None => TagIcon::Task,1068 None => TagIcon::Task {
1069 shape: tag.shape.unwrap_or(0),
1070 },
1069 Some(shape) => match TagIcon::of(shape.unwrap_or(0), tag.status & 1 != 0) {1071 Some(shape) => match TagIcon::of(shape.unwrap_or(0), tag.status & 1 != 0) {
1070 Some(icon) => icon,1072 Some(icon) => icon,
1071 None => continue,1073 None => continue,
...@@ -2241,6 +2243,8 @@ mod tests {...@@ -2241,6 +2243,8 @@ mod tests {
2241 definition: Some(id),2243 definition: Some(id),
2242 status: 3,2244 status: 3,
2243 action_type: None,2245 action_type: None,
2246 shape: None,
2247 property_status: None,
2244 created: None,2248 created: None,
2245 completed: None,2249 completed: None,
2246 start: None,2250 start: None,
...@@ -2356,8 +2360,13 @@ mod tests {...@@ -2356,8 +2360,13 @@ mod tests {
2356 }2360 }
2357 );2361 );
2358 let mut task = node.clone();2362 let mut task = node.clone();
2359 task.text_mut().unwrap().tags[0].status |= 4;2363 let stored = &mut task.text_mut().unwrap().tags[0];
2360 assert_eq!(shaped(&task, &definitions).tags[0].icon, TagIcon::Task);2364 stored.status |= 4;
2365 stored.shape = Some(89);
2366 assert_eq!(
2367 shaped(&task, &definitions).tags[0].icon,
2368 TagIcon::Task { shape: 89 }
2369 );
2361 let mut untagged = definitions.clone();2370 let mut untagged = definitions.clone();
2362 untagged.remove(&id);2371 untagged.remove(&id);
2363 assert!(shaped(&node, &untagged).tags.is_empty());2372 assert!(shaped(&node, &untagged).tags.is_empty());
...@@ -2403,6 +2412,8 @@ mod tests {...@@ -2403,6 +2412,8 @@ mod tests {
2403 let tag = Tag {2412 let tag = Tag {
2404 definition: Some(id(902)),2413 definition: Some(id(902)),
2405 action_type: None,2414 action_type: None,
2415 shape: None,
2416 property_status: None,
2406 status: 0,2417 status: 0,
2407 created: None,2418 created: None,
2408 completed: None,2419 completed: None,
...@@ -2499,6 +2510,8 @@ mod tests {...@@ -2499,6 +2510,8 @@ mod tests {
2499 let tag = Tag {2510 let tag = Tag {
2500 definition: Some(id(902)),2511 definition: Some(id(902)),
2501 action_type: None,2512 action_type: None,
2513 shape: None,
2514 property_status: None,
2502 status: 0,2515 status: 0,
2503 created: None,2516 created: None,
2504 completed: None,2517 completed: None,
...@@ -2665,6 +2678,8 @@ mod tests {...@@ -2665,6 +2678,8 @@ mod tests {
2665 definition: Some(id),2678 definition: Some(id),
2666 status: 1,2679 status: 1,
2667 action_type: None,2680 action_type: None,
2681 shape: None,
2682 property_status: None,
2668 created: None,2683 created: None,
2669 completed: None,2684 completed: None,
2670 start: None,2685 start: None,
crates/canvas/tests/task_tags.rs created+145
...@@ -0,0 +1,145 @@
1//! Outlook task tags stay read-only through the editor: Remove Tag drops one, removing a
2//! normal tag beside one keeps it, and deleting a page and undoing restores them as OneNote
3//! 2010 stored them (`corpus/task-tags`).
4
5use canvas::{
6 document::TextPosition,
7 editor::{CanvasEditor, Formatting, NoteTag, Whole},
8 layout::TextEngine,
9};
10use onestore::{
11 Arena, ExGuid, Section, Store,
12 document::Tag,
13 op::{Edit, Op},
14 page::{Page, PageObject},
15};
16
17const TASKS: &[u8] = include_bytes!("../../../corpus/task-tags/native/notebook/Tasks.one");
18
19/// Each body paragraph's text and tags, their arena positions cleared.
20fn tagged(page: &Page) -> Vec<(String, Vec<Tag>)> {
21 page.objects
22 .iter()
23 .find_map(|object| match object {
24 PageObject::Outline(outline) if !outline.title => Some(&outline.paragraphs),
25 _ => None,
26 })
27 .unwrap()
28 .iter()
29 .map(|paragraph| {
30 let text = paragraph.text().unwrap();
31 let tags = paragraph
32 .tags
33 .iter()
34 .chain(&text.tags)
35 .map(|tag| Tag {
36 extra_set: 0,
37 ..tag.clone()
38 })
39 .collect();
40 (text.text.text().to_owned(), tags)
41 })
42 .collect()
43}
44
45fn body(editor: &mut CanvasEditor) {
46 let body = editor
47 .outlines()
48 .iter()
49 .find(|outline| !outline.title)
50 .unwrap()
51 .id;
52 editor.focus_outline(body).unwrap();
53}
54
55fn at(paragraph: usize) -> canvas::editor::Selection {
56 [TextPosition {
57 paragraph,
58 offset: 0,
59 }; 2]
60 .into()
61}
62
63/// Applies the editor's pending ops to the page in `space` as one edit.
64fn publish(section: &mut Section<'_>, space: ExGuid, editor: &mut CanvasEditor, at: u64) {
65 let ops = editor
66 .take_ops()
67 .unwrap()
68 .into_iter()
69 .map(|op| Op::Page { space, op })
70 .collect();
71 section.apply("Author", &Edit { at, ops }).unwrap();
72}
73
74/// `SNOWBOUND_TASK_TAGS_EXPORT` names a new directory receiving the candidate for a cold
75/// reopen.
76#[test]
77fn task_tags_survive_tag_removal_and_page_delete_undo() {
78 let arena = Arena::default();
79 let mut section = Section::open(&arena, TASKS.to_vec()).unwrap();
80 let pages = section.pages().unwrap();
81 let space = |title: &str| pages.iter().find(|page| page.1 == title).unwrap().0;
82 let (remove, restore) = (space("Remove a task"), space("Delete and undo"));
83 let before = tagged(&section.page(remove).unwrap());
84 let shapes: Vec<_> = before
85 .iter()
86 .flat_map(|(_, tags)| tags)
87 .map(|tag| (tag.definition.is_some(), tag.status & 4 != 0, tag.shape))
88 .collect();
89 assert_eq!(
90 shapes,
91 [
92 (false, true, Some(89)),
93 (false, true, Some(89)),
94 (false, true, Some(89)),
95 (true, false, None),
96 ]
97 );
98 let mut engine = TextEngine::default();
99
100 let mut editor = CanvasEditor::from_page(section.page(remove).unwrap(), &mut engine).unwrap();
101 body(&mut editor);
102 editor.select(at(0)).unwrap();
103 editor.format(&mut engine, Formatting::RemoveTags).unwrap();
104 editor.select(at(2)).unwrap();
105 let to_do = NoteTag::defaults()[0].clone();
106 editor
107 .format(&mut engine, Formatting::Tag(to_do, 0))
108 .unwrap();
109 publish(&mut section, remove, &mut editor, 134_000_000_000_000_000);
110 let mut expected = before.clone();
111 expected[0].1.clear();
112 expected[2].1.retain(|tag| tag.definition.is_none());
113 assert_eq!(tagged(&section.page(remove).unwrap()), expected);
114
115 let original = section.page(restore).unwrap();
116 let kept = tagged(&original);
117 let mut editor = CanvasEditor::from_page(original, &mut engine).unwrap();
118 body(&mut editor);
119 while editor.whole() != Some(Whole::Page) {
120 editor.widen_selection().unwrap();
121 }
122 editor.delete(&mut engine, false).unwrap();
123 publish(&mut section, restore, &mut editor, 134_000_000_010_000_000);
124 assert!(editor.undo(&mut engine).unwrap());
125 publish(&mut section, restore, &mut editor, 134_000_000_020_000_000);
126 assert_eq!(tagged(&section.page(restore).unwrap()), kept);
127
128 section.seal().unwrap();
129 let written = section.image();
130 let reread = Section::open(&arena, written.clone()).unwrap();
131 assert_eq!(tagged(&reread.page(remove).unwrap()), expected);
132 assert_eq!(tagged(&reread.page(restore).unwrap()), kept);
133 if let Some(directory) = std::env::var_os("SNOWBOUND_TASK_TAGS_EXPORT") {
134 let directory = std::path::PathBuf::from(directory);
135 std::fs::create_dir(&directory).unwrap();
136 std::fs::write(directory.join("Tasks.one"), &written).unwrap();
137 let file_id = Store::parse(&written).unwrap().header.file_id;
138 std::fs::write(
139 directory.join("Open Notebook.onetoc2"),
140 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("Tasks.one", file_id)])
141 .unwrap(),
142 )
143 .unwrap();
144 }
145}
crates/draw/src/render.rs+9-20
...@@ -224,7 +224,6 @@ enum AtlasKey {...@@ -224,7 +224,6 @@ enum AtlasKey {
224 Icon {224 Icon {
225 sources: &'static [&'static str],225 sources: &'static [&'static str],
226 size: u32,226 size: u32,
227 phase: [u8; 2],
228 /// The bits of the colour `currentColor` paints.227 /// The bits of the colour `currentColor` paints.
229 ink: [u32; 3],228 ink: [u32; 3],
230 palette: [Option<[u32; 3]>; 3],229 palette: [Option<[u32; 3]>; 3],
...@@ -1104,27 +1103,24 @@ impl Renderer {...@@ -1104,27 +1103,24 @@ impl Renderer {
1104 tint: [f32; 4],1103 tint: [f32; 4],
1105 palette: &Palette,1104 palette: &Palette,
1106 ) -> Result<(), RenderError> {1105 ) -> Result<(), RenderError> {
1107 let size = size * space.scale;1106 // Whole device pixels keep a sprite's pixel-art edges crisp at every scale.
1108 let [x, y] = space.origin.map(|v| (v * 4.0).round() * 0.25);1107 let size = (size * space.scale).round().max(1.0);
1108 let [x, y] = space.origin.map(f32::round);
1109 if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) {1109 if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) {
1110 return Err(RenderError::InvalidPrimitive);1110 return Err(RenderError::InvalidPrimitive);
1111 }1111 }
1112 if x + size + 1.0 < 0.01112 if x + size < 0.0 || y + size < 0.0 || x > space.size[0] as f32 || y > space.size[1] as f32
1113 || y + size + 1.0 < 0.0
1114 || x - 1.0 > space.size[0] as f32
1115 || y - 1.0 > space.size[1] as f32
1116 {1113 {
1117 return Ok(());1114 return Ok(());
1118 }1115 }
1119 if size + 3.0 > self.atlas_side() as f32 {1116 if size + 2.0 > self.atlas_side() as f32 {
1120 return Err(RenderError::AtlasFull);1117 return Err(RenderError::AtlasFull);
1121 }1118 }
1122 let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8];1119 let size = size as u32;
1123 let ink = [tint[0], tint[1], tint[2]];1120 let ink = [tint[0], tint[1], tint[2]];
1124 let key = AtlasKey::Icon {1121 let key = AtlasKey::Icon {
1125 sources,1122 sources,
1126 size: size.to_bits(),1123 size,
1127 phase,
1128 ink: ink.map(f32::to_bits),1124 ink: ink.map(f32::to_bits),
1129 palette: palette.bits(),1125 palette: palette.bits(),
1130 };1126 };
...@@ -1134,20 +1130,13 @@ impl Renderer {...@@ -1134,20 +1130,13 @@ impl Renderer {
1134 if self.glyphs.len() >= self.glyph_limit() {1130 if self.glyphs.len() >= self.glyph_limit() {
1135 return Err(RenderError::AtlasFull);1131 return Err(RenderError::AtlasFull);
1136 }1132 }
1137 let glyph = self.upload(icon::rasterize(sources, size, phase, ink, palette))?;1133 let glyph = self.upload(icon::rasterize(sources, size, ink, palette))?;
1138 self.glyphs.insert(key, Some(glyph));1134 self.glyphs.insert(key, Some(glyph));
1139 glyph1135 glyph
1140 };1136 };
1141 let left = x.floor();
1142 let top = y.floor();
1143 self.quad(1137 self.quad(
1144 space,1138 space,
1145 [1139 [x, y, x + glyph.width as f32, y + glyph.height as f32],
1146 left,
1147 top,
1148 left + glyph.width as f32,
1149 top + glyph.height as f32,
1150 ],
1151 self.uv(glyph),1140 self.uv(glyph),
1152 [1.0, 1.0, 1.0, tint[3]],1141 [1.0, 1.0, 1.0, tint[3]],
1153 )1142 )
crates/draw/src/render/icon.rs+32-51
...@@ -1,9 +1,6 @@...@@ -1,9 +1,6 @@
1use swash::{1use swash::{
2 scale::image::{Content, Image},2 scale::image::{Content, Image},
3 zeno::{3 zeno::{self, Cap, Fill, Join, Mask, PathBuilder, Placement, Point, Stroke, Style, Transform},
4 self, Cap, Fill, Join, Mask, PathBuilder, Placement, Point, Stroke, Style, Transform,
5 Vector,
6 },
7};4};
85
9/// Colours for an icon's slots, linear RGB: paths of `class="accent"`, `"highlight"` or6/// Colours for an icon's slots, linear RGB: paths of `class="accent"`, `"highlight"` or
...@@ -32,24 +29,17 @@ impl Palette {...@@ -32,24 +29,17 @@ impl Palette {
32 }29 }
33}30}
3431
35/// Rasterizes 16×16 SVG sources over each other at `size` device pixels, offset by32/// Rasterizes 16×16 SVG sources over each other at a whole `size` of device pixels. Paths are filled and stroked with `#rrggbb`, `currentColor`,
36/// `phase` quarter pixels. Paths are filled and stroked with `#rrggbb`, `currentColor`,
37/// which paints the linear `ink`, or two-stop `linearGradient`s with SVG's defaults33/// which paints the linear `ink`, or two-stop `linearGradient`s with SVG's defaults
38/// (`objectBoundingBox` unless `userSpaceOnUse`, `x1`..`y2`, stop offsets, pad spread; no34/// (`objectBoundingBox` unless `userSpaceOnUse`, `x1`..`y2`, stop offsets, pad spread; no
39/// `gradientTransform`). `fill-opacity` and `stroke-opacity` apply, and strokes have round35/// `gradientTransform`). `fill-opacity` and `stroke-opacity` apply, and strokes have round
40/// caps and joins. A slot's paths keep the shading among their colours: the hue turn,36/// caps and joins. A slot's paths keep the shading among their colours: the hue turn,
41/// saturation scale and lightness shift that carry the mean of the slot's colours in a37/// saturation scale and lightness shift that carry the mean of the slot's colours in a
42/// source onto the palette's colour apply to each of them.38/// source onto the palette's colour apply to each of them.
43pub(crate) fn rasterize(39pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Palette) -> Image {
44 sources: &[&str],40 let side = size;
45 size: f32,41 let size = size as f32;
46 phase: [u8; 2],
47 ink: [f32; 3],
48 palette: &Palette,
49) -> Image {
50 let side = (size + 1.0).ceil() as u32;
51 let mut pixels = vec![[0.0_f32; 4]; (side * side) as usize];42 let mut pixels = vec![[0.0_f32; 4]; (side * side) as usize];
52 let offset = phase.map(|quarters| f32::from(quarters) * 0.25);
53 for source in sources {43 for source in sources {
54 let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid");44 let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid");
55 assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16"));45 assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16"));
...@@ -135,7 +125,6 @@ pub(crate) fn rasterize(...@@ -135,7 +125,6 @@ pub(crate) fn rasterize(
135 let (mask, _) = Mask::new(data)125 let (mask, _) = Mask::new(data)
136 .style(style)126 .style(style)
137 .transform(Some(Transform::scale(size / 16.0, size / 16.0)))127 .transform(Some(Transform::scale(size / 16.0, size / 16.0)))
138 .render_offset(Vector::new(offset[0], offset[1]))
139 .size(side, side)128 .size(side, side)
140 .render();129 .render();
141 for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() {130 for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() {
...@@ -147,7 +136,7 @@ pub(crate) fn rasterize(...@@ -147,7 +136,7 @@ pub(crate) fn rasterize(
147 let [x, y] = [index % side as usize, index / side as usize]136 let [x, y] = [index % side as usize, index / side as usize]
148 .map(|device| device as f32 + 0.5)137 .map(|device| device as f32 + 0.5)
149 .map(|device| device * 16.0 / size);138 .map(|device| device * 16.0 / size);
150 let color = color([x - offset[0] * 16.0 / size, y - offset[1] * 16.0 / size]);139 let color = color([x, y]);
151 for channel in 0..3 {140 for channel in 0..3 {
152 pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha);141 pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha);
153 }142 }
...@@ -468,7 +457,7 @@ mod tests {...@@ -468,7 +457,7 @@ mod tests {
468 /// The grey painted at a pixel of `source` rasterized at 16 pixels, and the grey a457 /// The grey painted at a pixel of `source` rasterized at 16 pixels, and the grey a
469 /// black-to-white ramp paints at fraction `t`.458 /// black-to-white ramp paints at fraction `t`.
470 fn grey(source: &str, x: usize, y: usize) -> u8 {459 fn grey(source: &str, x: usize, y: usize) -> u8 {
471 rasterize(&[source], 16.0, [0, 0], [1.0; 3], &Palette::default()).data[(y * 17 + x) * 4]460 rasterize(&[source], 16, [1.0; 3], &Palette::default()).data[(y * 16 + x) * 4]
472 }461 }
473462
474 fn ramp(t: f32) -> u8 {463 fn ramp(t: f32) -> u8 {
...@@ -516,8 +505,8 @@ mod tests {...@@ -516,8 +505,8 @@ mod tests {
516 // Two blues of one hue in the accent slot, a red outside it.505 // Two blues of one hue in the accent slot, a red outside it.
517 const ART: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path class="accent" d="M0 0H16V4H0Z" fill="#80a0ff"/><path class="accent" d="M0 4H16V8H0Z" fill="#0040e0"/><path d="M0 8H16V12H0Z" fill="#ff0000"/><path class="accent" d="M0 12H16V16H0Z" fill="currentColor"/></svg>"##;506 const ART: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path class="accent" d="M0 0H16V4H0Z" fill="#80a0ff"/><path class="accent" d="M0 4H16V8H0Z" fill="#0040e0"/><path d="M0 8H16V12H0Z" fill="#ff0000"/><path class="accent" d="M0 12H16V16H0Z" fill="currentColor"/></svg>"##;
518 let pixel = |palette: &Palette, y: usize| {507 let pixel = |palette: &Palette, y: usize| {
519 let image = rasterize(&[ART], 16.0, [0, 0], [0.0; 3], palette);508 let image = rasterize(&[ART], 16, [0.0; 3], palette);
520 let at = (y * 17 + 8) * 4;509 let at = (y * 16 + 8) * 4;
521 let [r, g, b] = [0, 1, 2].map(|channel| f32::from(image.data[at + channel]) / 255.0);510 let [r, g, b] = [0, 1, 2].map(|channel| f32::from(image.data[at + channel]) / 255.0);
522 hsl([r, g, b])511 hsl([r, g, b])
523 };512 };
...@@ -542,14 +531,8 @@ mod tests {...@@ -542,14 +531,8 @@ mod tests {
542 fn current_color_paints_the_ink_and_fixed_colours_keep_theirs() {531 fn current_color_paints_the_ink_and_fixed_colours_keep_theirs() {
543 const LINE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 8H14" fill="none" stroke="currentColor" stroke-width="2"/><path d="M2 12H14" fill="none" stroke="#ff0000" stroke-width="2"/></svg>"##;532 const LINE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 8H14" fill="none" stroke="currentColor" stroke-width="2"/><path d="M2 12H14" fill="none" stroke="#ff0000" stroke-width="2"/></svg>"##;
544 let ink = super::super::srgb(0x20, 0x60, 0xa0);533 let ink = super::super::srgb(0x20, 0x60, 0xa0);
545 let image = rasterize(534 let image = rasterize(&[LINE], 16, [ink[0], ink[1], ink[2]], &Palette::default());
546 &[LINE],535 let pixel = |x: usize, y: usize| &image.data[(y * 16 + x) * 4..][..4];
547 16.0,
548 [0, 0],
549 [ink[0], ink[1], ink[2]],
550 &Palette::default(),
551 );
552 let pixel = |x: usize, y: usize| &image.data[(y * 17 + x) * 4..][..4];
553 assert_eq!(pixel(8, 7), [0x20, 0x60, 0xa0, 255]);536 assert_eq!(pixel(8, 7), [0x20, 0x60, 0xa0, 255]);
554 assert_eq!(pixel(8, 11), [255, 0, 0, 255]);537 assert_eq!(pixel(8, 11), [255, 0, 0, 255]);
555 assert_eq!(pixel(8, 2)[3], 0);538 assert_eq!(pixel(8, 2)[3], 0);
...@@ -560,8 +543,8 @@ mod tests {...@@ -560,8 +543,8 @@ mod tests {
560 #[test]543 #[test]
561 fn opacity_scales_each_paint_on_its_own() {544 fn opacity_scales_each_paint_on_its_own() {
562 const HALF: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 2H14V14H2Z" fill="#ffffff" fill-opacity="0.5" stroke="#000000" stroke-width="2" stroke-opacity="0.25"/></svg>"##;545 const HALF: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 2H14V14H2Z" fill="#ffffff" fill-opacity="0.5" stroke="#000000" stroke-width="2" stroke-opacity="0.25"/></svg>"##;
563 let image = rasterize(&[HALF], 16.0, [0, 0], [1.0; 3], &Palette::default());546 let image = rasterize(&[HALF], 16, [1.0; 3], &Palette::default());
564 let alpha = |x: usize, y: usize| image.data[(y * 17 + x) * 4 + 3];547 let alpha = |x: usize, y: usize| image.data[(y * 16 + x) * 4 + 3];
565 assert_eq!(alpha(8, 8), 128);548 assert_eq!(alpha(8, 8), 128);
566 // The stroke's outer half covers only what the fill leaves.549 // The stroke's outer half covers only what the fill leaves.
567 assert_eq!(alpha(1, 8), 64);550 assert_eq!(alpha(1, 8), 64);
...@@ -569,28 +552,26 @@ mod tests {...@@ -569,28 +552,26 @@ mod tests {
569552
570 #[test]553 #[test]
571 fn icons_keep_transparency_and_overlays_across_scales() {554 fn icons_keep_transparency_and_overlays_across_scales() {
572 for size in [8.0, 16.0, 32.0, 57.5] {555 for size in [8, 16, 32, 57] {
573 for phase in [[0, 0], [1, 2], [3, 3]] {556 let plain = rasterize(&[BOX], size, [1.0; 3], &Palette::default());
574 let plain = rasterize(&[BOX], size, phase, [1.0; 3], &Palette::default());557 let marked = rasterize(&[BOX, MARK], size, [1.0; 3], &Palette::default());
575 let marked = rasterize(&[BOX, MARK], size, phase, [1.0; 3], &Palette::default());558 for image in [&plain, &marked] {
576 for image in [&plain, &marked] {559 assert_eq!(
577 assert_eq!(560 image.data.len(),
578 image.data.len(),561 (image.placement.width * image.placement.height * 4) as usize
579 (image.placement.width * image.placement.height * 4) as usize562 );
580 );563 assert!(image.data.chunks_exact(4).any(|p| p[3] > 0));
581 assert!(image.data.chunks_exact(4).any(|p| p[3] > 0));564 assert!(image.data.chunks_exact(4).any(|p| p[3] < 255));
582 assert!(image.data.chunks_exact(4).any(|p| p[3] < 255));565 assert!(
583 assert!(566 image
584 image567 .data
585 .data568 .chunks_exact(4)
586 .chunks_exact(4)569 .filter(|p| p[3] == 0)
587 .filter(|p| p[3] == 0)570 .all(|p| p[..3] == [0; 3])
588 .all(|p| p[..3] == [0; 3])571 );
589 );
590 }
591 assert_ne!(plain.data, marked.data);
592 assert!(marked.data.chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0));
593 }572 }
573 assert_ne!(plain.data, marked.data);
574 assert!(marked.data.chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0));
594 }575 }
595 }576 }
596}577}
crates/mobile/src/library.rs+5-11
...@@ -33,8 +33,6 @@ const TIMEOUT: Duration = Duration::from_secs(10);...@@ -33,8 +33,6 @@ const TIMEOUT: Duration = Duration::from_secs(10);
33const LIMIT: usize = 256 * 1024 * 1024;33const LIMIT: usize = 256 * 1024 * 1024;
34/// The tab colour OneNote gives a section that stores none, as the desktop shows it.34/// The tab colour OneNote gives a section that stores none, as the desktop shows it.
35const SECTION_COLOR: u32 = 0x00e4_a88a;35const SECTION_COLOR: u32 = 0x00e4_a88a;
36/// How often sections no page shows are polled, as the desktop polls them.
37const BACKGROUND: Duration = Duration::from_secs(15);
3836
39/// Called on a background thread when a notebook's sync status may have changed.37/// Called on a background thread when a notebook's sync status may have changed.
40static SYNC_WAKE: OnceLock<extern "C" fn()> = OnceLock::new();38static SYNC_WAKE: OnceLock<extern "C" fn()> = OnceLock::new();
...@@ -239,7 +237,7 @@ fn stem(path: &str) -> String {...@@ -239,7 +237,7 @@ fn stem(path: &str) -> String {
239fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {237fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
240 for section in &folder.sections {238 for section in &folder.sections {
241 let (name, color, readable) = match &section.state {239 let (name, color, readable) = match &section.state {
242 SectionState::Readable { name, color } => (name.clone(), *color, true),240 SectionState::Readable { name, color, .. } => (name.clone(), *color, true),
243 SectionState::Locked | SectionState::Unreadable(_) => (None, None, false),241 SectionState::Locked | SectionState::Unreadable(_) => (None, None, false),
244 };242 };
245 tabs.push(Tab {243 tabs.push(Tab {
...@@ -272,8 +270,10 @@ impl Library {...@@ -272,8 +270,10 @@ impl Library {
272 (None, Place::File(path.to_owned()), None)270 (None, Place::File(path.to_owned()), None)
273 } else {271 } else {
274 let notebook = Notebook::open(path, cache)?;272 let notebook = Notebook::open(path, cache)?;
273 // Nothing here tells a file provider's folder on this device from one elsewhere.
275 let background = notebook.background_with(274 let background = notebook.background_with(
276 BACKGROUND,275 Background::UNWATCHED,
276 false,
277 |file| Coordinated(file.to_owned()),277 |file| Coordinated(file.to_owned()),
278 sync_woken,278 sync_woken,
279 )?;279 )?;
...@@ -300,13 +300,7 @@ impl Library {...@@ -300,13 +300,7 @@ impl Library {
300 pub(crate) fn server(server: Server, root: &str, cache: &Path) -> Result<Self> {300 pub(crate) fn server(server: Server, root: &str, cache: &Path) -> Result<Self> {
301 let server = Arc::new(server);301 let server = Arc::new(server);
302 let connect = Arc::clone(&server);302 let connect = Arc::clone(&server);
303 let background = Background::smb(303 let background = Background::smb(root, LIMIT, move || connect.connect(), sync_woken)?;
304 root,
305 LIMIT,
306 BACKGROUND,
307 move || connect.connect(),
308 sync_woken,
309 )?;
310 let library = Self {304 let library = Self {
311 notebook: Mutex::default(),305 notebook: Mutex::default(),
312 place: Place::Share {306 place: Place::Share {
crates/notebook/README.md+21-8
...@@ -65,14 +65,21 @@ and how many edits wait for it. `set_offline(true)` works offline as OneNote doe...@@ -65,14 +65,21 @@ and how many edits wait for it. `set_offline(true)` works offline as OneNote doe
65worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offline(false)`.65worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offline(false)`.
6666
67`session::Background` keeps the sections no session holds in sync, as OneNote 2010 keeps67`session::Background` keeps the sections no session holds in sync, as OneNote 2010 keeps
68every section of an open notebook: `Notebook::background(interval, notify)` for a mounted68every section of an open notebook: `Background::smb(root, limit, connect, notify)` on a
69notebook, `Background::smb(root, limit, interval, connect, notify)` on a share, then69share, `Notebook::background(interval, copies, notify)` for a mounted notebook, then
70`watch(notebook.replicas())`. Each round reads every watched file's stamp; a section whose70`watch(notebook.replicas())`. On a share, one CHANGE_NOTIFY on the notebook's folder
71replica has edits waiting, or whose file moved past the replica's base, has the replica71(`smb::Client::watch`) reports what changed; a host watching a mounted folder passes the
72opened for the synchronization steps that publish or rebase it and closed again. A replica72changed paths to `touched`. A reported section is checked a second later, a failing one
73a session holds is skipped (`Error::busy`), so opening a section may wait out one step.73every 31 seconds, and any other every `interval` (`Background::BACKSTOP` while a watch
74`status()` gives each section's `SyncStatus`, `changed()` the sections another client74reports). Newly watched sections, and every section after a reconnect, are checked one at a
75changed, and `wake` and `set_offline` follow Sync Now and Work Offline.75time 100 ms apart. A check reads the file's stamp; a section whose replica has edits waiting,
76or whose file moved past the replica's base, has the replica opened for the synchronization
77steps that publish or rebase it and closed again, and a section without a replica gets one
78from the file, its offline copy, on a share or with `copies`. A replica a session holds is
79skipped (`Error::busy`), so opening a section may wait out one step. `status()` gives each
80section's `SyncStatus`, `changed()` the sections another client changed, `wake` and
81`set_offline` follow Sync Now and Work Offline, and `discard` stops the thread and deletes the
82replicas holding nothing unpublished, as when the notebook closes.
76`Notebook::replica_path(path)` names a section's replica for either kind of notebook.83`Notebook::replica_path(path)` names a section's replica for either kind of notebook.
7784
78`Section::resume(file, replica, notify)` starts from an owned `Replica` without85`Section::resume(file, replica, notify)` starts from an owned `Replica` without
...@@ -373,6 +380,12 @@ are rejected with `ResourceBusy`. Notebook identities come from the files, not...@@ -373,6 +380,12 @@ are rejected with `ResourceBusy`. Notebook identities come from the files, not
373directory names or sizes. Missing paths, denied access and non-directory paths380directory names or sizes. Missing paths, denied access and non-directory paths
374have distinct I/O error kinds.381have distinct I/O error kinds.
375382
383`Client::watch(path, changed)` arms one CHANGE_NOTIFY with WATCH_TREE on a directory, as
384OneNote 2010 watches a notebook's folder, and keeps it armed on the client's runtime without
385probing the connection, so an idle watch sends nothing. `changed` hears each batch of changed
386paths relative to the directory, `""` when the server lost count, and an error when the watch
387ends with its connection.
388
376`Client::read_asset(path, byte_limit)` reads an external payload under a read-only389`Client::read_asset(path, byte_limit)` reads an external payload under a read-only
377share handle that excludes writes and deletion. Empty files succeed; limits,390share handle that excludes writes and deletion. Empty files succeed; limits,
378interrupted reads and failed close never return partial bytes. This payload read391interrupted reads and failed close never return partial bytes. This payload read
crates/notebook/examples/launch_probe.rs created+228
...@@ -0,0 +1,228 @@
1//! Temporary: measures a notebook's launch (catalog and background first pass).
2//! `generate DIR N` | `local DIR CACHE` | `upload ADDRESS SHARE DIR ROOT` |
3//! `smb ADDRESS SHARE ROOT CACHE [SECONDS]`
4
5use notebook::{
6 discover::{Entry, Limits, Local, Source, discover},
7 session::{Background, Notebook},
8};
9use onestore::{
10 document::{Format, Layout},
11 op::{Edit, Op, SectionOp},
12 page::{
13 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,
14 text::new_id,
15 },
16};
17use std::{
18 io,
19 path::Path,
20 time::{Duration, Instant},
21};
22
23type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
24
25struct Counting<S>(S, u64, u64);
26
27impl<S: Source> Source for Counting<S> {
28 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
29 self.2 += 1;
30 self.0.entries(path, limit)
31 }
32 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
33 let bytes = self.0.read(path, limit)?;
34 self.1 += bytes.len() as u64;
35 Ok(bytes)
36 }
37}
38
39const LIMITS: Limits = Limits {
40 entries: 100_000,
41 bytes_per_file: 256 * 1024 * 1024,
42 depth: 64,
43};
44
45fn page(count: usize) -> Result<Page> {
46 let paragraph = |i: usize| PageParagraph {
47 id: new_id().unwrap(),
48 parent: None,
49 level: 1,
50 style: None,
51 format: Format::default(),
52 content: ParagraphContent::Text(TextObject {
53 id: new_id().unwrap(),
54 date_field: None,
55 text: Paragraph::new(
56 format!("Paragraph {i} of the launch probe, with some words."),
57 Format::default(),
58 ),
59 tags: Vec::new(),
60 }),
61 lists: Vec::new(),
62 tags: Vec::new(),
63 media: Default::default(),
64 collapsed: false,
65 };
66 Ok(Page {
67 title: "Probe".into(),
68 identity: None,
69 created: None,
70 margin_origin: [0.0; 2],
71 color: None,
72 rule_lines: None,
73 objects: vec![PageObject::Outline(Outline {
74 id: new_id()?,
75 title: false,
76 min_width: None,
77 layout: Layout {
78 x: Some(36.0),
79 y: Some(86.4),
80 ..Layout::default()
81 },
82 indents: Vec::new(),
83 paragraphs: (0..count).map(paragraph).collect(),
84 unsupported: Vec::new(),
85 })],
86 definitions: Default::default(),
87 })
88}
89
90fn generate(root: &Path, count: usize) -> Result<()> {
91 let cache = tempfile::tempdir()?;
92 let creation = onestore::PageCreation::new(None, Some("First"), "Probe")?;
93 let mut notebook = Notebook::create(root, cache.path(), 0x00e4a88a, &creation)?;
94 notebook.create_group("", "Group A")?;
95 notebook.create_group("", "Group B")?;
96 for n in 0..count {
97 let folder = ["", "Group A", "Group B"][n % 3];
98 let path = notebook.create_section(folder, &format!("Section {n:03}"), &creation)?;
99 let file = root.join(&path);
100 let arena = onestore::Arena::default();
101 let mut section = onestore::Section::open(&arena, std::fs::read(&file)?)?;
102 let creation = onestore::PageCreation::new(None, Some("Probe"), "Probe")?;
103 section.apply(
104 "Probe",
105 &Edit {
106 at: 133_000_000_000_000_000,
107 ops: vec![Op::Section(SectionOp::Import {
108 creation,
109 page: page(1200)?,
110 })],
111 },
112 )?;
113 let transaction = section.seal()?.ok_or("nothing sealed")?;
114 transaction.commit_file(&file)?;
115 }
116 Ok(())
117}
118
119fn size(root: &Path) -> u64 {
120 std::fs::read_dir(root)
121 .into_iter()
122 .flatten()
123 .flatten()
124 .map(|entry| match entry.file_type() {
125 Ok(kind) if kind.is_dir() => size(&entry.path()),
126 _ => entry.metadata().map_or(0, |metadata| metadata.len()),
127 })
128 .sum()
129}
130
131#[cfg(feature = "smb")]
132fn client(address: &str, share: &str) -> Result<notebook::smb::Client> {
133 Ok(notebook::smb::Client::connect(
134 address,
135 share,
136 notebook::smb::Credentials {
137 username: "",
138 password: "",
139 domain: "",
140 },
141 Duration::from_secs(10),
142 )?)
143}
144
145#[cfg(feature = "smb")]
146fn upload(client: &notebook::smb::Client, local: &Path, remote: &str) -> Result<()> {
147 client.create_directory(remote)?;
148 for entry in std::fs::read_dir(local)? {
149 let entry = entry?;
150 let name = entry.file_name().into_string().map_err(|_| "name")?;
151 let target = format!("{remote}/{name}");
152 if entry.file_type()?.is_dir() {
153 upload(client, &entry.path(), &target)?;
154 } else {
155 client.create(&target, &std::fs::read(entry.path())?)?;
156 }
157 }
158 Ok(())
159}
160
161fn main() -> Result<()> {
162 let args: Vec<String> = std::env::args().skip(1).collect();
163 match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
164 ["generate", root, count] => {
165 generate(Path::new(root), count.parse()?)?;
166 println!("{} bytes", size(Path::new(root)));
167 }
168 ["local", root, cache] => {
169 let start = Instant::now();
170 let notebook = Notebook::open(root, cache)?;
171 let replicas = notebook.replicas();
172 println!(
173 "open: {:?}, {} sections",
174 start.elapsed(),
175 replicas.len()
176 );
177 let mut counting = Counting(Local::open(root)?, 0, 0);
178 discover(&mut counting, LIMITS)?;
179 println!("discovery reads {} bytes, {} listings", counting.1, counting.2);
180 }
181 #[cfg(feature = "smb")]
182 ["upload", address, share, local, root] => {
183 upload(&client(address, share)?, Path::new(local), root)?;
184 }
185 #[cfg(feature = "smb")]
186 ["smb", address, share, root, cache, rest @ ..] => {
187 let seconds: u64 = rest.first().map_or(Ok(0), |s| s.parse())?;
188 let start = Instant::now();
189 let connected = client(address, share)?;
190 let notebook = Notebook::open_smb(std::sync::Arc::new(connected), root, cache)?;
191 let replicas = notebook.replicas();
192 println!(
193 "open: {:?}, {} sections",
194 start.elapsed(),
195 replicas.len()
196 );
197 if seconds > 0 {
198 let (address, share) = (address.to_string(), share.to_string());
199 let background = Background::smb(
200 root,
201 256 * 1024 * 1024,
202 move || {
203 client(&address, &share)
204 .map_err(|error| io::Error::other(error.to_string()))
205 },
206 || {},
207 )?;
208 background.watch(replicas);
209 let start = Instant::now();
210 loop {
211 let status = background.status();
212 if status.iter().all(|(_, status)| status.synced.is_some()) {
213 println!("background first pass: {:?}", start.elapsed());
214 break;
215 }
216 if start.elapsed() > Duration::from_secs(seconds) {
217 println!("background still going after {seconds} s");
218 break;
219 }
220 std::thread::sleep(Duration::from_millis(50));
221 }
222 std::thread::sleep(Duration::from_secs(2));
223 }
224 }
225 _ => return Err("usage".into()),
226 }
227 Ok(())
228}
crates/notebook/src/background.rs+539-132
...@@ -11,19 +11,45 @@ use onestore::Stamp;...@@ -11,19 +11,45 @@ use onestore::Stamp;
11use std::{11use std::{
12 io,12 io,
13 path::{Path, PathBuf},13 path::{Path, PathBuf},
14 sync::{Arc, Mutex, atomic::Ordering},14 sync::{
15 Arc, Mutex, Weak,
16 atomic::{AtomicBool, Ordering},
17 },
15 thread,18 thread,
16 time::Duration,19 time::{Duration, Instant},
17};20};
1821
19/// Polls each watched section file's stamp every interval. A section whose replica has22/// How long a section that could not be reached waits to be tried again; OneNote 2010
20/// edits waiting, or whose file moved past the replica's base, has its replica opened for23/// retries a failing section about every 31 seconds.
21/// the synchronization steps that publish or rebase it, then closed again; any other costs24const RETRY: Duration = Duration::from_secs(31);
22/// one stamp read. A replica a session holds is left to that session's worker. Dropping25/// Between one section's first check and the next's, so opening a notebook reads its files
23/// requests cancellation without waiting for the step in flight.26/// one after another rather than in a burst.
24pub struct Background {27const STAGGER: Duration = Duration::from_millis(100);
28/// How long a reported change settles before its section is checked, so the writes of one
29/// commit cost one check.
30const SETTLE: Duration = Duration::from_secs(1);
31
32/// Keeps each watched section in sync: a section is checked when a watch on the notebook's
33/// folder reports it changed, when it could not be reached, and otherwise every interval.
34/// A check costs one stamp read; a section whose replica has edits waiting, or whose file
35/// moved past the replica's base, has its replica opened for the synchronization steps that
36/// publish or rebase it, then closed again. A section without a replica gets one, its offline
37/// copy, where the notebook keeps them. A replica a session holds is left to that session's
38/// worker. Dropping requests cancellation without waiting for the step in flight.
39pub struct Background(Arc<Shared>);
40
41/// What the background thread and whatever reports changes to it share.
42pub(crate) struct Shared {
25 signal: Arc<Signal>,43 signal: Arc<Signal>,
26 watched: Arc<Mutex<Watched>>,44 watched: Mutex<Watched>,
45 /// Asked for by `discard`, once the thread stops.
46 discard: AtomicBool,
47}
48
49/// A connection's report of its notebook folder's changes, from a watch armed on connecting.
50pub(crate) struct Reports {
51 shared: Weak<Shared>,
52 connection: u64,
27}53}
2854
29#[derive(Default)]55#[derive(Default)]
...@@ -31,6 +57,10 @@ struct Watched {...@@ -31,6 +57,10 @@ struct Watched {
31 sections: Vec<Watch>,57 sections: Vec<Watch>,
32 /// Sections whose file changed since `changed` was last asked.58 /// Sections whose file changed since `changed` was last asked.
33 changed: Vec<String>,59 changed: Vec<String>,
60 /// Counts connections, so a watch that ended with an earlier one is not this one's.
61 connection: u64,
62 /// The current connection's watch ended: changes since went unreported.
63 lost: bool,
34}64}
3565
36struct Watch {66struct Watch {
...@@ -39,12 +69,24 @@ struct Watch {...@@ -39,12 +69,24 @@ struct Watch {
39 /// The file's stamp when last reached.69 /// The file's stamp when last reached.
40 stamp: Option<Stamp>,70 stamp: Option<Stamp>,
41 status: SyncStatus,71 status: SyncStatus,
72 /// When the section is next checked.
73 due: Instant,
42}74}
4375
44impl Background {76impl Background {
77 /// How often each section is checked while a watch reports its folder's changes, against
78 /// a report that went missing; OneNote 2010, watching, checks nothing on a timer.
79 pub const BACKSTOP: Duration = Duration::from_secs(60 * 60);
80 /// How often each section is checked where nothing reports its folder's changes.
81 pub const UNWATCHED: Duration = Duration::from_secs(15);
82
83 /// Starts the thread. `connect` binds each catalog path to its file, arming any watch
84 /// that reports the folder's changes through `Reports`, and answers how long a section
85 /// may go unchecked while nothing reports it; it runs again after a transport failure.
86 /// `copies` keeps an offline copy of every section.
45 pub(crate) fn start<R, B>(87 pub(crate) fn start<R, B>(
46 interval: Duration,88 copies: bool,
47 mut connect: impl FnMut() -> io::Result<B> + Send + 'static,89 mut connect: impl FnMut(Reports) -> io::Result<(B, Duration)> + Send + 'static,
48 notify: impl Fn() + Send + 'static,90 notify: impl Fn() + Send + 'static,
49 ) -> Result<Self>91 ) -> Result<Self>
50 where92 where
...@@ -52,165 +94,201 @@ impl Background {...@@ -52,165 +94,201 @@ impl Background {
52 B: FnMut(&str) -> R,94 B: FnMut(&str) -> R,
53 {95 {
54 let (signal, receiver) = Signal::new();96 let (signal, receiver) = Signal::new();
55 let watched = Arc::new(Mutex::new(Watched::default()));97 let shared = Arc::new(Shared {
56 let (shared, sections) = (Arc::clone(&signal), Arc::clone(&watched));98 signal,
99 watched: Mutex::default(),
100 discard: AtomicBool::new(false),
101 });
102 let weak = Arc::downgrade(&shared);
103 let owner = Arc::clone(&shared);
57 thread::Builder::new()104 thread::Builder::new()
58 .name("onestore-background".into())105 .name("onestore-background".into())
59 .spawn(move || {106 .spawn(move || {
60 let mut bound: Option<B> = None;107 let signal = &owner.signal;
61 while !shared.stopped.load(Ordering::Acquire) {108 let mut bound: Option<(B, Duration)> = None;
62 if shared.offline.load(Ordering::Acquire)109 let mut news = false;
63 && !shared.requested.load(Ordering::Acquire)110 while !signal.stopped.load(Ordering::Acquire) {
111 if signal.offline.load(Ordering::Acquire)
112 && !signal.requested.load(Ordering::Acquire)
64 {113 {
65 bound = None;114 bound = None;
66 let _ = receiver.recv();115 let _ = receiver.recv();
67 continue;116 continue;
68 }117 }
69 shared.requested.store(false, Ordering::Release);118 let now = Instant::now();
70 let round: Vec<_> = match sections.lock() {119 let (path, replica, seen, connection) = {
71 Ok(watched) => watched120 let Ok(mut watched) = owner.watched.lock() else {
72 .sections
73 .iter()
74 .map(|watch| {
75 (
76 watch.path.clone(),
77 watch.replica.clone(),
78 watch.stamp.clone(),
79 )
80 })
81 .collect(),
82 Err(_) => return,
83 };
84 let mut news = false;
85 for (path, replica, seen) in round {
86 if shared.stopped.load(Ordering::Acquire) {
87 return;121 return;
88 }
89 let bind = match &mut bound {
90 Some(bind) => bind,
91 None => match connect() {
92 Ok(bind) => bound.insert(bind),
93 Err(error) => {
94 // Nothing on the share is reachable this round.
95 let Ok(mut watched) = sections.lock() else {
96 return;
97 };
98 for watch in &mut watched.sections {
99 news |= watch.fail(
100 &Error::RemoteIo(io::Error::new(
101 error.kind(),
102 error.to_string(),
103 )),
104 None,
105 );
106 }
107 break;
108 }
109 },
110 };122 };
111 let (queued, outcome) =123 if std::mem::take(&mut watched.lost) {
112 step(&mut bind(&path), replica.as_deref(), seen.as_ref());
113 if matches!(outcome, Err(Error::RemoteIo(_) | Error::Remote(_))) {
114 bound = None;124 bound = None;
125 watched.rescan(now);
115 }126 }
116 let Ok(mut watched) = sections.lock() else {127 let due = watched.next().map(|index| &watched.sections[index]);
117 return;128 match due.filter(|watch| watch.due <= now) {
118 };129 Some(watch) => (
119 let Watched { sections, changed } = &mut *watched;130 watch.path.clone(),
120 let Some(watch) = sections.iter_mut().find(|watch| watch.path == path)131 watch.replica.clone(),
121 else {132 watch.stamp.clone(),
122 continue;133 watched.connection,
123 };134 ),
124 news |= match outcome {135 None => {
125 Ok((stamp, moved)) => {136 let wait = due.map(|watch| watch.due - now);
126 watch.stamp = Some(stamp);137 drop(watched);
127 if moved {138 signal.requested.store(false, Ordering::Release);
128 changed.push(path);139 if std::mem::take(&mut news) {
140 notify();
129 }141 }
130 let before = summary(&watch.status);142 let _ = match wait {
131 if let Some(queued) = queued {143 Some(wait) => receiver.recv_timeout(wait).ok(),
132 watch.status = SyncStatus {144 None => receiver.recv().ok(),
133 synced: Some(crate::now()),145 };
134 error: None,146 continue;
135 queued,147 }
136 };148 }
149 };
150 let (bind, interval) = match &mut bound {
151 Some(bound) => bound,
152 None => {
153 let reports = Reports {
154 shared: weak.clone(),
155 connection: connection + 1,
156 };
157 let connected = connect(reports);
158 let Ok(mut watched) = owner.watched.lock() else {
159 return;
160 };
161 // Whatever ended before now ended with the last connection.
162 watched.connection += 1;
163 watched.lost = false;
164 match connected {
165 // Changes while unwatched went unreported.
166 Ok(connected) => {
167 watched.rescan(now);
168 bound = Some(connected);
169 }
170 Err(error) => {
171 let error = Error::RemoteIo(error);
172 for watch in &mut watched.sections {
173 news |= watch.fail(&error, None);
174 watch.due = now + RETRY;
175 }
137 }176 }
138 moved || before != summary(&watch.status)
139 }177 }
140 Err(error) => watch.fail(&error, queued),178 continue;
141 };179 }
180 };
181 let (queued, outcome) =
182 step(&mut bind(&path), replica.as_deref(), seen.as_ref(), copies);
183 let interval = *interval;
184 if outcome.as_ref().is_err_and(disconnected) {
185 bound = None;
142 }186 }
143 if news {187 let Ok(mut watched) = owner.watched.lock() else {
144 notify();188 return;
189 };
190 let Watched {
191 sections, changed, ..
192 } = &mut *watched;
193 let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) else {
194 continue;
195 };
196 let now = Instant::now();
197 news |= match outcome {
198 Ok((stamp, moved)) => {
199 watch.stamp = Some(stamp);
200 watch.due = now + interval;
201 if moved {
202 changed.push(path);
203 }
204 let before = summary(&watch.status);
205 if let Some(queued) = queued {
206 watch.status = SyncStatus {
207 synced: Some(crate::now()),
208 error: None,
209 queued,
210 };
211 }
212 moved || before != summary(&watch.status)
213 }
214 Err(error) => {
215 watch.due = now + RETRY;
216 watch.fail(&error, queued)
217 }
218 };
219 }
220 if owner.discard.load(Ordering::Acquire)
221 && let Ok(watched) = owner.watched.lock()
222 {
223 for replica in watched
224 .sections
225 .iter()
226 .filter_map(|watch| watch.replica.as_ref())
227 {
228 discard(replica);
145 }229 }
146 let _ = receiver.recv_timeout(interval);
147 }230 }
148 })?;231 })?;
149 Ok(Self { signal, watched })232 Ok(Self(shared))
150 }233 }
151234
152 /// Keeps the sections of a notebook on a share in sync while they are not open;235 /// Keeps the sections of a notebook on a share in sync while they are not open, with an
153 /// `connect` runs again after a transport failure. Paths are relative to `root`.236 /// offline copy of each, and one watch on the notebook's folder reporting what changed,
237 /// as OneNote 2010 watches it; `connect` runs again after a transport failure. Paths are
238 /// relative to `root`.
154 #[cfg(feature = "smb")]239 #[cfg(feature = "smb")]
155 pub fn smb(240 pub fn smb(
156 root: &str,241 root: &str,
157 limit: usize,242 limit: usize,
158 interval: Duration,
159 mut connect: impl FnMut() -> io::Result<crate::smb::Client> + Send + 'static,243 mut connect: impl FnMut() -> io::Result<crate::smb::Client> + Send + 'static,
160 notify: impl Fn() + Send + 'static,244 notify: impl Fn() + Send + 'static,
161 ) -> Result<Self> {245 ) -> Result<Self> {
162 let root = root.replace('\\', "/");246 let root = root.replace('\\', "/");
163 Self::start(247 Self::start(
164 interval,248 true,
165 move || {249 move |reports| {
166 let client = Arc::new(connect()?);250 let client = Arc::new(connect()?);
251 let interval = match client.watch(&root, move |changed| match changed {
252 Ok(paths) => reports.touched(&paths),
253 Err(_) => reports.lost(),
254 }) {
255 Ok(()) => Self::BACKSTOP,
256 Err(error) if error.kind() == io::ErrorKind::Unsupported => Self::UNWATCHED,
257 Err(error) => return Err(error),
258 };
167 let root = root.clone();259 let root = root.clone();
168 Ok(move |path: &str| {260 let bind = move |path: &str| {
169 let file = match root.as_str() {261 let file = match root.as_str() {
170 "" => path.to_owned(),262 "" => path.to_owned(),
171 root => format!("{root}/{path}"),263 root => format!("{root}/{path}"),
172 };264 };
173 crate::SmbRemote::new(Arc::clone(&client), file, limit)265 crate::SmbRemote::new(Arc::clone(&client), file, limit)
174 })266 };
267 Ok((bind, interval))
175 },268 },
176 notify,269 notify,
177 )270 )
178 }271 }
179272
180 /// Watches these sections, by catalog path and replica (`Notebook::replicas`), from273 /// Watches these sections, by catalog path and replica (`Notebook::replicas`); a section
181 /// the next round, which starts now.274 /// not watched before is first checked soon after, the next one a little later.
182 pub fn watch(&self, sections: Vec<(String, Option<PathBuf>)>) {275 pub fn watch(&self, sections: Vec<(String, Option<PathBuf>)>) {
183 if let Ok(mut watched) = self.watched.lock() {276 if let Ok(mut watched) = self.0.watched.lock() {
184 let mut previous = std::mem::take(&mut watched.sections);277 watched.watch(sections, Instant::now());
185 watched.sections = sections
186 .into_iter()
187 .map(
188 |(path, replica)| match previous.iter().position(|watch| watch.path == path) {
189 Some(index) => Watch {
190 replica,
191 ..previous.swap_remove(index)
192 },
193 None => Watch {
194 path,
195 replica,
196 stamp: None,
197 status: SyncStatus {
198 synced: None,
199 error: None,
200 queued: 0,
201 },
202 },
203 },
204 )
205 .collect();
206 }278 }
207 self.signal.wake();279 self.0.signal.wake();
208 }280 }
209281
210 /// Each watched section's status as its last round left it, in watch order. A section a282 /// Checks the sections at or below these catalog paths soon, as a watch on the
283 /// notebook's folder reports them changed; `""` names every section.
284 pub fn touched(&self, paths: &[String]) {
285 self.0.touched(paths);
286 }
287
288 /// Each watched section's status as its last check left it, in watch order. A section a
211 /// session holds keeps the status it had before.289 /// session holds keeps the status it had before.
212 pub fn status(&self) -> Vec<(String, SyncStatus)> {290 pub fn status(&self) -> Vec<(String, SyncStatus)> {
213 self.watched.lock().map_or_else(291 self.0.watched.lock().map_or_else(
214 |_| Vec::new(),292 |_| Vec::new(),
215 |watched| {293 |watched| {
216 watched294 watched
...@@ -237,32 +315,165 @@ impl Background {...@@ -237,32 +315,165 @@ impl Background {
237315
238 /// Sections whose file changed since the last call, by catalog path.316 /// Sections whose file changed since the last call, by catalog path.
239 pub fn changed(&self) -> Vec<String> {317 pub fn changed(&self) -> Vec<String> {
240 self.watched318 self.0
319 .watched
241 .lock()320 .lock()
242 .map(|mut watched| std::mem::take(&mut watched.changed))321 .map(|mut watched| std::mem::take(&mut watched.changed))
243 .unwrap_or_default()322 .unwrap_or_default()
244 }323 }
245324
246 /// Runs a round now, working offline included (Sync Now).325 /// Checks every section now, working offline included (Sync Now).
247 pub fn wake(&self) {326 pub fn wake(&self) {
248 self.signal.requested.store(true, Ordering::Release);327 if let Ok(mut watched) = self.0.watched.lock() {
249 self.signal.wake();328 let now = Instant::now();
329 for watch in &mut watched.sections {
330 watch.due = now;
331 }
332 }
333 self.0.signal.requested.store(true, Ordering::Release);
334 self.0.signal.wake();
250 }335 }
251336
252 /// Working offline, no round runs until `wake`, or until working online again.337 /// Stops for good, then deletes each section's replica that holds nothing unpublished, as
338 /// OneNote lets go of a notebook it closes; one with edits waiting, or that a session
339 /// holds, stays.
340 pub fn discard(&self) {
341 self.0.discard.store(true, Ordering::Release);
342 self.0.signal.stopped.store(true, Ordering::Release);
343 self.0.signal.wake();
344 }
345
346 /// Working offline, nothing is checked until `wake`, or until working online again.
253 pub fn set_offline(&self, offline: bool) {347 pub fn set_offline(&self, offline: bool) {
254 self.signal.offline.store(offline, Ordering::Release);348 self.0.signal.offline.store(offline, Ordering::Release);
255 self.signal.wake();349 self.0.signal.wake();
256 }350 }
257}351}
258352
259impl Drop for Background {353impl Drop for Background {
260 fn drop(&mut self) {354 fn drop(&mut self) {
261 self.signal.stopped.store(true, Ordering::Release);355 self.0.signal.stopped.store(true, Ordering::Release);
356 self.0.signal.wake();
357 }
358}
359
360impl Shared {
361 fn touched(&self, paths: &[String]) {
362 if let Ok(mut watched) = self.watched.lock() {
363 watched.touched(paths, Instant::now());
364 }
262 self.signal.wake();365 self.signal.wake();
263 }366 }
264}367}
265368
369impl Reports {
370 /// The sections at or below these paths changed.
371 pub(crate) fn touched(&self, paths: &[String]) {
372 if let Some(shared) = self.shared.upgrade() {
373 shared.touched(paths);
374 }
375 }
376
377 /// The watch ended, and with it, as far as anyone can tell, the connection.
378 pub(crate) fn lost(&self) {
379 if let Some(shared) = self.shared.upgrade() {
380 if let Ok(mut watched) = shared.watched.lock()
381 && watched.connection == self.connection
382 {
383 watched.lost = true;
384 }
385 shared.signal.wake();
386 }
387 }
388}
389
390impl Watched {
391 fn watch(&mut self, sections: Vec<(String, Option<PathBuf>)>, now: Instant) {
392 let mut previous = std::mem::take(&mut self.sections);
393 let mut new = 0;
394 self.sections = sections
395 .into_iter()
396 .map(
397 |(path, replica)| match previous.iter().position(|watch| watch.path == path) {
398 Some(index) => Watch {
399 replica,
400 ..previous.swap_remove(index)
401 },
402 None => {
403 new += 1;
404 Watch {
405 path,
406 replica,
407 stamp: None,
408 status: SyncStatus {
409 synced: None,
410 error: None,
411 queued: 0,
412 },
413 due: now + STAGGER * (new - 1),
414 }
415 }
416 },
417 )
418 .collect();
419 }
420
421 /// Checks every section from `now`, one `STAGGER` after another.
422 fn rescan(&mut self, now: Instant) {
423 for (index, watch) in (0..).zip(&mut self.sections) {
424 watch.due = now + STAGGER * index;
425 }
426 }
427
428 fn touched(&mut self, paths: &[String], now: Instant) {
429 for watch in &mut self.sections {
430 if paths.iter().any(|path| within(&watch.path, path)) {
431 watch.due = watch.due.min(now + SETTLE);
432 }
433 }
434 }
435
436 /// The section checked next.
437 fn next(&self) -> Option<usize> {
438 (0..self.sections.len()).min_by_key(|&index| self.sections[index].due)
439 }
440}
441
442/// Deletes the replica at `replica` if it holds nothing unpublished and no one holds it.
443fn discard(replica: &Path) {
444 if matches!(crate::peek(replica), Ok((_, 0))) {
445 for suffix in ["-wal", "-shm", ""] {
446 let mut file = replica.as_os_str().to_owned();
447 file.push(suffix);
448 let _ = std::fs::remove_file(file);
449 }
450 }
451}
452
453/// Whether the catalog path `section` is `path` or lies below it, as a share compares names.
454fn within(section: &str, path: &str) -> bool {
455 let path = path.trim_matches('/');
456 path.is_empty()
457 || section.len() >= path.len()
458 && section.is_char_boundary(path.len())
459 && section[..path.len()].eq_ignore_ascii_case(path)
460 && matches!(section.as_bytes().get(path.len()), None | Some(b'/'))
461}
462
463/// Whether a failed step lost the connection, rather than failing for its one file.
464fn disconnected(error: &Error) -> bool {
465 use io::ErrorKind::*;
466 let kind = match error {
467 Error::RemoteIo(error) => error.kind(),
468 Error::Remote(error) => error.error.kind(),
469 _ => return false,
470 };
471 matches!(
472 kind,
473 NotConnected | TimedOut | ConnectionReset | ConnectionAborted | BrokenPipe
474 )
475}
476
266impl Watch {477impl Watch {
267 /// Records a failed step and what it found waiting, answering whether the status shown478 /// Records a failed step and what it found waiting, answering whether the status shown
268 /// changes.479 /// changes.
...@@ -284,20 +495,43 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) {...@@ -284,20 +495,43 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) {
284}495}
285496
286/// One section's step: how many of its edits wait (`None` while unknown, as while a session497/// One section's step: how many of its edits wait (`None` while unknown, as while a session
287/// holds its replica), then the file's stamp now and whether it changed since `seen`.498/// holds its replica), then the file's stamp now and whether it changed since `seen`. With
499/// `copies`, a section without a replica gets one from the file as it is now.
288fn step<R: Remote>(500fn step<R: Remote>(
289 remote: &mut R,501 remote: &mut R,
290 replica: Option<&Path>,502 replica: Option<&Path>,
291 seen: Option<&Stamp>,503 seen: Option<&Stamp>,
504 copies: bool,
292) -> (Option<u64>, Result<(Stamp, bool)>) {505) -> (Option<u64>, Result<(Stamp, bool)>) {
293 let stamp = match remote.stamp() {506 let stamp = match remote.stamp() {
294 Ok(stamp) => stamp,507 Ok(stamp) => stamp,
295 Err(error) => return (None, Err(Error::RemoteIo(error))),508 Err(error) => return (None, Err(Error::RemoteIo(error))),
296 };509 };
297 let moved = seen.is_some_and(|seen| *seen != stamp);510 let moved = seen.is_some_and(|seen| *seen != stamp);
298 let Some(replica) = replica.filter(|replica| replica.exists()) else {511 let Some(replica) = replica else {
299 return (Some(0), Ok((stamp, moved)));512 return (Some(0), Ok((stamp, moved)));
300 };513 };
514 if !replica.exists() {
515 if !copies {
516 return (Some(0), Ok((stamp, moved)));
517 }
518 let copied = (|| {
519 let image = remote.read().map_err(Error::RemoteIo)?;
520 if let Some(folder) = replica.parent() {
521 std::fs::create_dir_all(folder)?;
522 }
523 Replica::seed(replica, &image)?;
524 Ok(Stamp::of(&image)?)
525 })();
526 return match copied {
527 Ok(stamp) => (Some(0), Ok((stamp, moved))),
528 // A session made it first.
529 Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {
530 (None, Ok((stamp, false)))
531 }
532 Err(error) => (None, Err(error)),
533 };
534 }
301 match crate::peek(replica) {535 match crate::peek(replica) {
302 Ok((base, 0)) if base == stamp => return (Some(0), Ok((stamp, moved))),536 Ok((base, 0)) if base == stamp => return (Some(0), Ok((stamp, moved))),
303 Err(error) if error.busy() => return (None, Ok((stamp, false))),537 Err(error) if error.busy() => return (None, Ok((stamp, false))),
...@@ -324,3 +558,176 @@ fn step<R: Remote>(...@@ -324,3 +558,176 @@ fn step<R: Remote>(
324 .map(|summary| summary.queued_edits);558 .map(|summary| summary.queued_edits);
325 (queued, synced)559 (queued, synced)
326}560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 const BACKSTOP: Duration = Background::BACKSTOP;
566 use onestore::{CommitError, Transaction};
567 use std::{
568 collections::HashMap,
569 sync::mpsc::{self, Receiver},
570 };
571
572 fn sections(count: usize) -> Vec<(String, Option<PathBuf>)> {
573 (0..count)
574 .map(|n| {
575 let folder = if n % 2 == 0 { "" } else { "Group/" };
576 (format!("{folder}Section {n:03}.one"), None)
577 })
578 .collect()
579 }
580
581 /// Runs the schedule as the thread would with every check succeeding, up to `end`,
582 /// answering which sections were checked when.
583 fn run(watched: &mut Watched, end: Instant, interval: Duration) -> Vec<(Instant, String)> {
584 let mut checks = Vec::new();
585 while let Some(index) = watched.next() {
586 let watch = &mut watched.sections[index];
587 if watch.due > end {
588 break;
589 }
590 checks.push((watch.due, watch.path.clone()));
591 watch.due += interval;
592 }
593 checks
594 }
595
596 #[test]
597 fn an_idle_notebook_is_read_once_staggered_then_left_alone() {
598 let start = Instant::now();
599 let mut watched = Watched::default();
600 watched.watch(sections(200), start);
601 let checks = run(&mut watched, start + BACKSTOP - SETTLE, BACKSTOP);
602 assert_eq!(checks.len(), 200, "one first check each, then none");
603 for pair in checks.windows(2) {
604 assert!(pair[1].0 - pair[0].0 >= STAGGER, "never a burst");
605 }
606 // Then each section costs one backstop check an interval: over a day, 24 stamp reads
607 // where a 15-second poll made 5,760.
608 let day = run(
609 &mut watched,
610 start + Duration::from_secs(86_400) - SETTLE,
611 BACKSTOP,
612 );
613 assert_eq!(day.len(), 200 * (86_400 / BACKSTOP.as_secs() as usize - 1));
614 }
615
616 #[test]
617 fn a_reported_change_checks_exactly_the_sections_it_names() {
618 let start = Instant::now();
619 let mut watched = Watched::default();
620 watched.watch(sections(200), start);
621 let now = start + Duration::from_secs(60);
622 run(&mut watched, now, BACKSTOP);
623 // A commit's several writes are reported apiece, and settle into one check.
624 for _ in 0..3 {
625 watched.touched(&["section 004.ONE".into()], now);
626 }
627 watched.touched(
628 &["Section 004.one".into()],
629 now + Duration::from_millis(300),
630 );
631 let checks = run(&mut watched, now + Duration::from_secs(60), BACKSTOP);
632 assert_eq!(checks, vec![(now + SETTLE, "Section 004.one".to_owned())]);
633 // A group's folder names the sections in it, and "" every section.
634 let now = now + Duration::from_secs(60);
635 watched.touched(&["Group".into()], now);
636 let group = run(&mut watched, now + SETTLE, BACKSTOP);
637 assert_eq!(group.len(), 100);
638 assert!(group.iter().all(|(_, path)| path.starts_with("Group/")));
639 watched.touched(&["Grou".into(), String::new()], now);
640 assert_eq!(run(&mut watched, now + SETTLE, BACKSTOP).len(), 200);
641 }
642
643 /// Section files in memory, each stamp read reported as it happens.
644 #[derive(Clone)]
645 struct Files {
646 images: Arc<Mutex<HashMap<String, Vec<u8>>>>,
647 stamps: mpsc::Sender<String>,
648 }
649
650 struct File(Files, String);
651
652 impl Remote for File {
653 fn read(&mut self) -> io::Result<Vec<u8>> {
654 let images = self.0.images.lock().unwrap();
655 Ok(images[&self.1].clone())
656 }
657
658 fn stamp(&mut self) -> io::Result<Stamp> {
659 let _ = self.0.stamps.send(self.1.clone());
660 Stamp::of(&self.read()?).map_err(io::Error::other)
661 }
662
663 fn publish(&mut self, _: &Transaction) -> std::result::Result<(), CommitError> {
664 unreachable!("nothing is queued")
665 }
666
667 fn confirm(&mut self, _: &Stamp) -> std::result::Result<(), CommitError> {
668 unreachable!("nothing is queued")
669 }
670 }
671
672 fn quiet(stamps: &Receiver<String>, wait: Duration) -> Vec<String> {
673 let mut read = Vec::new();
674 while let Ok(path) = stamps.recv_timeout(wait) {
675 read.push(path);
676 }
677 read
678 }
679
680 #[test]
681 fn a_watch_wakes_the_section_it_reports_and_a_lost_one_rescans() {
682 let (sender, stamps) = mpsc::channel();
683 let files = Files {
684 images: Arc::default(),
685 stamps: sender,
686 };
687 let mut paths: Vec<String> = sections(6).into_iter().map(|(path, _)| path).collect();
688 paths.sort();
689 for path in &paths {
690 let image = onestore::create_section("s.one", path, "Author").unwrap();
691 files.images.lock().unwrap().insert(path.clone(), image);
692 }
693 let (armed, watches) = mpsc::channel();
694 let remote = files.clone();
695 let background = Background::start(
696 false,
697 move |reports| {
698 armed.send(reports).unwrap();
699 let files = remote.clone();
700 Ok((
701 move |path: &str| File(files.clone(), path.to_owned()),
702 Duration::from_secs(3600),
703 ))
704 },
705 || {},
706 )
707 .unwrap();
708 background.watch(sections(6));
709 let mut first = quiet(&stamps, STAGGER * 4);
710 first.sort();
711 assert_eq!(first, paths, "each section's first check");
712 let watch = watches.recv().unwrap();
713 assert!(quiet(&stamps, SETTLE).is_empty(), "idle reads nothing");
714
715 let changed = &paths[3];
716 files.images.lock().unwrap().insert(
717 changed.clone(),
718 onestore::create_section("s.one", "Changed", "Author").unwrap(),
719 );
720 watch.touched(std::slice::from_ref(changed));
721 assert_eq!(quiet(&stamps, SETTLE * 2), vec![changed.clone()]);
722 assert_eq!(background.changed(), vec![changed.clone()]);
723
724 // A watch that ended leaves changes unreported, so everything is read again.
725 watch.lost();
726 let rewatch = watches.recv_timeout(SETTLE).unwrap();
727 assert_eq!(quiet(&stamps, STAGGER * 4).len(), 6);
728 // The ended watch's connection is gone; only the current one's loss counts.
729 watch.lost();
730 assert!(quiet(&stamps, SETTLE).is_empty());
731 drop(rewatch);
732 }
733}
crates/notebook/src/discover.rs+4
...@@ -30,6 +30,9 @@ pub enum SectionState {...@@ -30,6 +30,9 @@ pub enum SectionState {
30 name: Option<String>,30 name: Option<String>,
31 /// The tab colour as a COLORREF; OneNote assigns one when absent.31 /// The tab colour as a COLORREF; OneNote assigns one when absent.
32 color: Option<u32>,32 color: Option<u32>,
33 /// The root object space's GUID, the document identity that names the section's
34 /// replica in a mounted notebook.
35 document: [u8; 16],
33 },36 },
34 Locked,37 Locked,
35 Unreadable(onestore::Error),38 Unreadable(onestore::Error),
...@@ -310,6 +313,7 @@ fn scan(...@@ -310,6 +313,7 @@ fn scan(
310 state: SectionState::Readable {313 state: SectionState::Readable {
311 name,314 name,
312 color: color.filter(|color| *color != 0xffff_ffff),315 color: color.filter(|color| *color != 0xffff_ffff),
316 document: index.root.guid,
313 },317 },
314 });318 });
315 } else {319 } else {
crates/notebook/src/lib.rs+16-11
...@@ -105,23 +105,28 @@ impl Replica {...@@ -105,23 +105,28 @@ impl Replica {
105 /// Seeds a new cache from a validated section image, refusing any existing path.105 /// Seeds a new cache from a validated section image, refusing any existing path.
106 /// An initialization error preserves the created file for inspection.106 /// An initialization error preserves the created file for inspection.
107 pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> {107 pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> {
108 Self::seed(path.as_ref(), source)?;
109 Self::start(cache_connection(path.as_ref())?)
110 }
111
112 /// `create` without opening the cache it made, as for an offline copy.
113 pub(crate) fn seed(path: &Path, source: &[u8]) -> Result<()> {
108 validate(source)?;114 validate(source)?;
109 let path = path.as_ref();115 // Built under a name of its own and linked into place whole, so that a cache that
110 // Built under another name and linked into place whole, so that a cache that exists116 // exists is complete, and two threads making the same one never share a build.
111 // is complete: a background step or a failed creation never leaves one half made.117 static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
118 let build = BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
112 let mut building = path.as_os_str().to_owned();119 let mut building = path.as_os_str().to_owned();
113 building.push(".creating");120 building.push(format!(".creating-{}-{build}", std::process::id()));
114 let building = PathBuf::from(building);121 let building = PathBuf::from(building);
115 for stale in ["", "-wal", "-shm"] {122 let built = Self::build(&building, source);
123 let linked = built.and_then(|()| Ok(std::fs::hard_link(&building, path)?));
124 for suffix in ["", "-wal", "-shm"] {
116 let mut file = building.as_os_str().to_owned();125 let mut file = building.as_os_str().to_owned();
117 file.push(stale);126 file.push(suffix);
118 let _ = std::fs::remove_file(file);127 let _ = std::fs::remove_file(file);
119 }128 }
120 let built = Self::build(&building, source);129 linked
121 let linked = built.and_then(|()| Ok(std::fs::hard_link(&building, path)?));
122 let _ = std::fs::remove_file(&building);
123 linked?;
124 Self::start(cache_connection(path)?)
125 }130 }
126131
127 fn build(path: &Path, source: &[u8]) -> Result<()> {132 fn build(path: &Path, source: &[u8]) -> Result<()> {
crates/notebook/src/merge.rs+2
...@@ -164,6 +164,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> {...@@ -164,6 +164,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> {
164 PageOp::Level { paragraph, .. }164 PageOp::Level { paragraph, .. }
165 | PageOp::Paragraph { paragraph, .. }165 | PageOp::Paragraph { paragraph, .. }
166 | PageOp::Style { paragraph, .. }166 | PageOp::Style { paragraph, .. }
167 | PageOp::Media { paragraph, .. }
167 | PageOp::List { paragraph, .. } => vec![*paragraph],168 | PageOp::List { paragraph, .. } => vec![*paragraph],
168 PageOp::Tags { target, .. } => vec![*target],169 PageOp::Tags { target, .. } => vec![*target],
169 PageOp::Add { object, .. } => vec![object.id()],170 PageOp::Add { object, .. } => vec![object.id()],
...@@ -736,6 +737,7 @@ impl Diff {...@@ -736,6 +737,7 @@ impl Diff {
736 }737 }
737 PageOp::Paragraph { paragraph, .. }738 PageOp::Paragraph { paragraph, .. }
738 | PageOp::Style { paragraph, .. }739 | PageOp::Style { paragraph, .. }
740 | PageOp::Media { paragraph, .. }
739 | PageOp::List { paragraph, .. } => {741 | PageOp::List { paragraph, .. } => {
740 require(self.kept(*paragraph) && self.new_has(*paragraph))?742 require(self.kept(*paragraph) && self.new_has(*paragraph))?
741 }743 }
crates/notebook/src/session.rs+15-8
...@@ -887,13 +887,16 @@ impl Notebook {...@@ -887,13 +887,16 @@ impl Notebook {
887 /// `smb` on a share. It exists once the section has been opened.887 /// `smb` on a share. It exists once the section has been opened.
888 pub fn replica_path(&self, path: &str) -> Result<PathBuf> {888 pub fn replica_path(&self, path: &str) -> Result<PathBuf> {
889 let section = self.section_path(path)?;889 let section = self.section_path(path)?;
890 Ok(match &self.root {890 Ok(match (&self.root, &section.state) {
891 Some(_) => {891 (Some(_), discover::SectionState::Readable { document, .. }) => {
892 replica_file(&self.cache, document)
893 }
894 (Some(_), _) => {
892 let image = self.storage.read(&section.path)?;895 let image = self.storage.read(&section.path)?;
893 let root = RevisionIndex::parse(&Store::parse(&image)?)?.root;896 let root = RevisionIndex::parse(&Store::parse(&image)?)?.root;
894 replica_file(&self.cache, &root.guid)897 replica_file(&self.cache, &root.guid)
895 }898 }
896 None => replica_file(&self.cache.join("smb"), &section.file_id),899 (None, _) => replica_file(&self.cache.join("smb"), &section.file_id),
897 })900 })
898 }901 }
899902
...@@ -914,13 +917,16 @@ impl Notebook {...@@ -914,13 +917,16 @@ impl Notebook {
914 }917 }
915918
916 /// Keeps the sections of a mounted notebook in sync while they are not open919 /// Keeps the sections of a mounted notebook in sync while they are not open
917 /// (`Background`), polling each file every `interval` once watched.920 /// (`Background`): each file is checked every `interval`, and sooner when
921 /// `Background::touched` reports it changed. `copies` keeps an offline copy of every
922 /// section, for a folder that is not on this computer.
918 pub fn background(923 pub fn background(
919 &self,924 &self,
920 interval: Duration,925 interval: Duration,
926 copies: bool,
921 notify: impl Fn() + Send + 'static,927 notify: impl Fn() + Send + 'static,
922 ) -> Result<Background> {928 ) -> Result<Background> {
923 self.background_with(interval, |file| FileRemote(file.to_owned()), notify)929 self.background_with(interval, copies, |file| FileRemote(file.to_owned()), notify)
924 }930 }
925931
926 /// `background`, reaching each section file through the remote `remote` makes for it932 /// `background`, reaching each section file through the remote `remote` makes for it
...@@ -928,6 +934,7 @@ impl Notebook {...@@ -928,6 +934,7 @@ impl Notebook {
928 pub fn background_with<R: Remote + 'static>(934 pub fn background_with<R: Remote + 'static>(
929 &self,935 &self,
930 interval: Duration,936 interval: Duration,
937 copies: bool,
931 remote: impl Fn(&Path) -> R + Clone + Send + 'static,938 remote: impl Fn(&Path) -> R + Clone + Send + 'static,
932 notify: impl Fn() + Send + 'static,939 notify: impl Fn() + Send + 'static,
933 ) -> Result<Background> {940 ) -> Result<Background> {
...@@ -939,10 +946,10 @@ impl Notebook {...@@ -939,10 +946,10 @@ impl Notebook {
939 .into());946 .into());
940 };947 };
941 Background::start(948 Background::start(
942 interval,949 copies,
943 move || {950 move |_| {
944 let (root, remote) = (root.clone(), remote.clone());951 let (root, remote) = (root.clone(), remote.clone());
945 Ok(move |path: &str| remote(&root.join(path)))952 Ok((move |path: &str| remote(&root.join(path)), interval))
946 },953 },
947 notify,954 notify,
948 )955 )
crates/notebook/src/smb/directory.rs+67
...@@ -96,6 +96,73 @@ impl Client {...@@ -96,6 +96,73 @@ impl Client {
96 })96 })
97 .collect())97 .collect())
98 }98 }
99
100 /// Watches the directory tree at `path` as OneNote 2010 watches a notebook's folder: one
101 /// CHANGE_NOTIFY with WATCH_TREE, kept armed on the server, so an idle watch sends
102 /// nothing. `changed` runs on the client's runtime with each batch of changed paths,
103 /// relative to `path` and `/`-separated, or `""` when the server lost count of them, then
104 /// `Err(_)` once the watch ends with its connection or the client.
105 pub fn watch(
106 &self,
107 path: &str,
108 changed: impl FnMut(io::Result<Vec<String>>) + Send + 'static,
109 ) -> io::Result<()> {
110 if Handle::try_current().is_ok() {
111 return Err(io::ErrorKind::InvalidInput.into());
112 }
113 let mut connection = self
114 .connection
115 .lock()
116 .map_err(|_| io::ErrorKind::Other)?
117 .clone()
118 .ok_or(io::ErrorKind::NotConnected)?;
119 // Probing a connection whose only request is the watch would end OneNote's silence.
120 connection.set_keepalive(None);
121 let runtime = self.runtime.lock().map_err(|_| io::ErrorKind::Other)?;
122 let runtime = runtime.as_ref().ok_or(io::ErrorKind::NotConnected)?;
123 let path = path.replace('\\', "/");
124 let mut watcher = runtime
125 .block_on(async {
126 tokio::time::timeout(self.timeout, self.tree.watch(&mut connection, &path, true))
127 .await
128 })
129 .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))?
130 .map_err(|error| match &error {
131 smb2::Error::Protocol { status, .. } if status.0 == 0xc0000034 => {
132 io::Error::new(io::ErrorKind::NotFound, error)
133 }
134 // STATUS_NOT_SUPPORTED, STATUS_INVALID_DEVICE_REQUEST
135 smb2::Error::Protocol { status, .. }
136 if matches!(status.0, 0xc00000bb | 0xc0000010) =>
137 {
138 io::Error::new(io::ErrorKind::Unsupported, error)
139 }
140 _ => io::Error::other(error),
141 })?;
142 // However the task ends, the connection failing or the client retiring its runtime,
143 // `changed` hears that the watch did.
144 struct Ends<F: FnMut(io::Result<Vec<String>>)>(F);
145 impl<F: FnMut(io::Result<Vec<String>>)> Drop for Ends<F> {
146 fn drop(&mut self) {
147 (self.0)(Err(io::ErrorKind::NotConnected.into()));
148 }
149 }
150 let mut ends = Ends(changed);
151 runtime.spawn(async move {
152 loop {
153 match watcher.next_events().await {
154 Ok(events) => {
155 (ends.0)(Ok(events.into_iter().map(|event| event.filename).collect()))
156 }
157 Err(smb2::Error::Protocol { status, .. }) if status.0 == 0x0000010c => {
158 (ends.0)(Ok(vec![String::new()]))
159 }
160 Err(_) => return,
161 }
162 }
163 });
164 Ok(())
165 }
99}166}
100167
101fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> {168fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> {
crates/notebook/src/smb/tests.rs+38
...@@ -421,6 +421,44 @@ fn live_stamp_and_transaction() {...@@ -421,6 +421,44 @@ fn live_stamp_and_transaction() {
421 poller.delete(&path).unwrap();421 poller.delete(&path).unwrap();
422}422}
423423
424/// A watch names the file another client commits to in the watched folder.
425#[test]
426#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
427fn live_watch() {
428 let watcher = client();
429 let writer = client();
430 let folder = format!(
431 "watch-{}",
432 SystemTime::now()
433 .duration_since(UNIX_EPOCH)
434 .unwrap()
435 .as_nanos()
436 );
437 writer.create_directory(&folder).unwrap();
438 let path = format!("{folder}/Watched.one");
439 let source = onestore::create_section("Watched.one", "Watched", "Author").unwrap();
440 create(&writer, &path, &source);
441 let (sender, changes) = std::sync::mpsc::channel();
442 watcher
443 .watch(&folder, move |changed| {
444 let _ = sender.send(changed.map_err(|error| error.kind()));
445 })
446 .unwrap();
447 std::thread::sleep(Duration::from_secs(1));
448 writer
449 .commit_transaction(&path, &replaced(&source, 0..0, "Changed "))
450 .unwrap();
451 let changed = changes.recv_timeout(Duration::from_secs(10)).unwrap();
452 assert!(changed.unwrap().contains(&"Watched.one".to_owned()));
453 // An idle watch hears nothing more.
454 while let Ok(changed) = changes.recv_timeout(Duration::from_secs(2)) {
455 assert!(changed.unwrap().iter().all(|path| path == "Watched.one"));
456 }
457 drop(watcher);
458 writer.delete(&path).unwrap();
459 writer.delete(&folder).unwrap();
460}
461
424#[test]462#[test]
425#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]463#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
426fn live_storage_inspection() {464fn live_storage_inspection() {
crates/notebook/tests/session.rs+76-2
...@@ -1029,7 +1029,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {...@@ -1029,7 +1029,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {
1029 let notebook = two_sections(directory.path());1029 let notebook = two_sections(directory.path());
1030 let file = directory.path().join("Shared/Second.one");1030 let file = directory.path().join("Shared/Second.one");
1031 let background = notebook1031 let background = notebook
1032 .background(Duration::from_millis(20), || {})1032 .background(Duration::from_millis(20), false, || {})
1033 .unwrap();1033 .unwrap();
1034 background.set_offline(true);1034 background.set_offline(true);
1035 background.watch(notebook.replicas());1035 background.watch(notebook.replicas());
...@@ -1083,7 +1083,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {...@@ -1083,7 +1083,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
1083 let notified = Arc::new(AtomicUsize::new(0));1083 let notified = Arc::new(AtomicUsize::new(0));
1084 let counter = Arc::clone(&notified);1084 let counter = Arc::clone(&notified);
1085 let background = notebook1085 let background = notebook
1086 .background(Duration::from_millis(20), move || {1086 .background(Duration::from_millis(20), false, move || {
1087 counter.fetch_add(1, Ordering::SeqCst);1087 counter.fetch_add(1, Ordering::SeqCst);
1088 })1088 })
1089 .unwrap();1089 .unwrap();
...@@ -1125,3 +1125,77 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {...@@ -1125,3 +1125,77 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
1125 });1125 });
1126 assert_same(opened.unwrap().page(*space).unwrap(), native);1126 assert_same(opened.unwrap().page(*space).unwrap(), native);
1127}1127}
1128
1129#[test]
1130fn a_reported_change_wakes_its_section_without_polling() {
1131 let directory = tempfile::tempdir().unwrap();
1132 let notebook = two_sections(directory.path());
1133 let background = notebook
1134 .background(Duration::from_secs(3600), false, || {})
1135 .unwrap();
1136 background.watch(notebook.replicas());
1137 until("both sections were reached", || {
1138 background
1139 .status()
1140 .iter()
1141 .all(|(_, status)| status.synced.is_some())
1142 });
1143 let file = directory.path().join("Shared/Second.one");
1144 let bytes = onestore::read_file(&file).unwrap();
1145 let space = notebook::session::stored_pages(&bytes).unwrap()[0].space;
1146 let native = edited(&model_ops::page_of(&bytes, space), "Native ");
1147 ops::save(&bytes, space, &native)
1148 .unwrap()
1149 .commit_file(&file)
1150 .unwrap();
1151 std::thread::sleep(Duration::from_secs(2));
1152 assert!(
1153 background.changed().is_empty(),
1154 "nothing polls an idle notebook"
1155 );
1156 background.touched(&["Second.one".to_owned()]);
1157 let mut noticed = Vec::new();
1158 until("the reported change was noticed", || {
1159 noticed.extend(background.changed());
1160 !noticed.is_empty()
1161 });
1162 assert_eq!(noticed, ["Second.one"]);
1163}
1164
1165#[test]
1166fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {
1167 let directory = tempfile::tempdir().unwrap();
1168 let notebook = two_sections(directory.path());
1169 let replicas: Vec<_> = notebook
1170 .replicas()
1171 .into_iter()
1172 .map(|(_, replica)| replica.unwrap())
1173 .collect();
1174 assert!(replicas.iter().all(|replica| !replica.exists()));
1175 let background = notebook
1176 .background(Duration::from_secs(3600), true, || {})
1177 .unwrap();
1178 background.watch(notebook.replicas());
1179 until("both sections have offline copies", || {
1180 replicas.iter().all(|replica| replica.exists())
1181 && background
1182 .status()
1183 .iter()
1184 .all(|(_, status)| status.synced.is_some())
1185 });
1186 // Opening a section starts from its copy, and edits made offline wait in it.
1187 let section = notebook.section("Second.one", || {}).unwrap();
1188 section.set_offline(true);
1189 let space = section.pages().unwrap()[0].0;
1190 let before = section.page(space).unwrap();
1191 typed(&section, space, &before, 0..0, "Waiting ");
1192 section.close().unwrap();
1193 background.discard();
1194 let second = notebook.replica_path("Second.one").unwrap();
1195 until("the copy with nothing waiting was discarded", || {
1196 !notebook.replica_path("First.one").unwrap().exists()
1197 });
1198 assert!(second.exists(), "a copy with edits waiting stays");
1199 let kept = notebook::Replica::open(&second).unwrap();
1200 assert_eq!(kept.pending().unwrap().len(), 1);
1201}
crates/onestore/examples/ink_lab.rs created+25
...@@ -0,0 +1,25 @@
1use onestore::{RevisionIndex, Store, document::Document};
2fn main() {
3 let path = std::env::args().nth(1).unwrap();
4 let needle = std::env::args().nth(2).unwrap();
5 let bytes = std::fs::read(path).unwrap();
6 let store = Store::parse(&bytes).unwrap();
7 let index = RevisionIndex::parse(&store).unwrap();
8 let document = Document::parse(&index).unwrap();
9 for (space, _) in document.pages().unwrap() {
10 let page = onestore::page::Page::from_space(&document, space).unwrap();
11 if page.title != needle {
12 continue;
13 }
14 if std::env::var_os("INK_MODEL").is_some() {
15 for object in &page.objects {
16 if let onestore::page::PageObject::Ink(ink) = object {
17 println!("{}", serde_json::to_string(ink).unwrap());
18 }
19 }
20 continue;
21 }
22 let revision = document.spaces[&space].active().unwrap();
23 println!("{}", serde_json::to_string_pretty(revision).unwrap());
24 }
25}
crates/onestore/examples/scratch_dump.rs created+29
...@@ -0,0 +1,29 @@
1use onestore::{RevisionIndex, Store, document::Document};
2fn main() {
3 let path = std::env::args().nth(1).unwrap();
4 let needle = std::env::args().nth(2).unwrap();
5 let bytes = std::fs::read(path).unwrap();
6 let store = Store::parse(&bytes).unwrap();
7 let index = RevisionIndex::parse(&store).unwrap();
8 let document = Document::parse(&index).unwrap();
9 for (space, _) in document.pages().unwrap() {
10 let page = onestore::page::Page::from_space(&document, space).unwrap();
11 if page.title != needle { continue; }
12 let revision = document.spaces[&space].active().unwrap();
13 println!("{}", serde_json::to_string_pretty(revision).unwrap());
14 if let Ok(out) = std::env::var("PAYLOAD_OUT") {
15 for object in &page.objects {
16 if let onestore::page::PageObject::Outline(o) = object {
17 for p in &o.paragraphs {
18 if let onestore::page::ParagraphContent::Attachment(a) = &p.content {
19 std::fs::write(format!("{out}/{}", a.filename), a.bytes.as_deref().unwrap()).unwrap();
20 if let Some(i) = &a.preview { std::fs::write(format!("{out}/{}.png", a.filename), i).unwrap(); }
21 }
22 }
23 }
24 }
25 }
26 }
27}
28#[allow(dead_code)]
29fn unused() {}
crates/onestore/src/document.rs+31-9
...@@ -405,9 +405,12 @@ impl Revision<'_> {...@@ -405,9 +405,12 @@ impl Revision<'_> {
405405
406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
407/// Tag dates count seconds since 1980-01-01 UTC; status retains the ActionItemStatus bits.407/// Tag dates count seconds since 1980-01-01 UTC; status retains the ActionItemStatus bits.
408/// A task tag (status bit 4) has no definition and carries its own icon and property status.
408pub struct Tag {409pub struct Tag {
409 pub definition: Option<ExGuid>,410 pub definition: Option<ExGuid>,
410 pub action_type: Option<u16>,411 pub action_type: Option<u16>,
412 pub shape: Option<u16>,
413 pub property_status: Option<u32>,
411 pub status: u16,414 pub status: u16,
412 pub created: Option<u32>,415 pub created: Option<u32>,
413 pub completed: Option<u32>,416 pub completed: Option<u32>,
...@@ -446,6 +449,8 @@ pub enum Kind<'a> {...@@ -446,6 +449,8 @@ pub enum Kind<'a> {
446 margin_origin_x: Option<f32>,449 margin_origin_x: Option<f32>,
447 margin_origin_y: Option<f32>,450 margin_origin_y: Option<f32>,
448 rtl: Option<bool>,451 rtl: Option<bool>,
452 /// Every audio and video recording on the page (AudioRecordingGuids).
453 recordings: Vec<[u8; 16]>,
449 },454 },
450 Metadata {455 Metadata {
451 title: Option<String>,456 title: Option<String>,
...@@ -475,11 +480,16 @@ pub enum Kind<'a> {...@@ -475,11 +480,16 @@ pub enum Kind<'a> {
475 },480 },
476 OutlineGroup,481 OutlineGroup,
477 /// An ink drawing or handwriting container: strokes hang off `data`, nested containers off482 /// An ink drawing or handwriting container: strokes hang off `data`, nested containers off
478 /// the element's content list. Scaling multiplies stroke coordinates.483 /// the element's content list. Scaling multiplies stroke coordinates. A drawn shape adds
484 /// its kind (11 a line, 12 a closed shape) and geometry: a line's ends, or a closed
485 /// shape's transform and anchors, as `page::InkShape` reads them.
479 Ink {486 Ink {
480 data: Option<ExGuid>,487 data: Option<ExGuid>,
481 scale_x: Option<f32>,488 scale_x: Option<f32>,
482 scale_y: Option<f32>,489 scale_y: Option<f32>,
490 shape_kind: Option<u8>,
491 line: Option<Vec<u8>>,
492 anchors: Option<Vec<u8>>,
483 },493 },
484 InkData {494 InkData {
485 strokes: Vec<ExGuid>,495 strokes: Vec<ExGuid>,
...@@ -555,6 +565,8 @@ pub enum Kind<'a> {...@@ -555,6 +565,8 @@ pub enum Kind<'a> {
555 source_path: Option<String>,565 source_path: Option<String>,
556 recording_id: Option<[u8; 16]>,566 recording_id: Option<[u8; 16]>,
557 recording_type: Option<u32>,567 recording_type: Option<u32>,
568 /// AudioRecordingDuration, in milliseconds.
569 recording_duration: Option<u32>,
558 /// Displayed icon width and height in points.570 /// Displayed icon width and height in points.
559 icon_width: Option<f32>,571 icon_width: Option<f32>,
560 icon_height: Option<f32>,572 icon_height: Option<f32>,
...@@ -675,6 +687,16 @@ impl<'a, 'o> Fields<'a, 'o> {...@@ -675,6 +687,16 @@ impl<'a, 'o> Fields<'a, 'o> {
675 fn u32(&mut self, id: u32) -> Result<Option<u32>> {687 fn u32(&mut self, id: u32) -> Result<Option<u32>> {
676 Ok(self.fixed(id)?.map(u32::from_le_bytes))688 Ok(self.fixed(id)?.map(u32::from_le_bytes))
677 }689 }
690 fn guids(&mut self, id: u32) -> Result<Vec<[u8; 16]>> {
691 let bytes = self.bytes(id)?.unwrap_or_default();
692 if !bytes.len().is_multiple_of(16) {
693 return Err(invalid("Media identifiers have an invalid length"));
694 }
695 Ok(bytes
696 .chunks_exact(16)
697 .map(|id| id.try_into().unwrap())
698 .collect())
699 }
678 fn u16(&mut self, id: u32) -> Result<Option<u16>> {700 fn u16(&mut self, id: u32) -> Result<Option<u16>> {
679 Ok(self.fixed(id)?.map(u16::from_le_bytes))701 Ok(self.fixed(id)?.map(u16::from_le_bytes))
680 }702 }
...@@ -1058,6 +1080,8 @@ impl<'a> Element<'a> {...@@ -1058,6 +1080,8 @@ impl<'a> Element<'a> {
1058 tags.push(Tag {1080 tags.push(Tag {
1059 definition: tag.one(0x20003488)?,1081 definition: tag.one(0x20003488)?,
1060 action_type: tag.u16(0x10003463)?,1082 action_type: tag.u16(0x10003463)?,
1083 shape: tag.u16(0x10003464)?,
1084 property_status: tag.u32(0x14003467)?,
1061 status: tag1085 status: tag
1062 .u16(0x10003470)?1086 .u16(0x10003470)?
1063 .ok_or_else(|| invalid("Note tag has no status"))?,1087 .ok_or_else(|| invalid("Note tag has no status"))?,
...@@ -1114,6 +1138,7 @@ impl<'a> Element<'a> {...@@ -1114,6 +1138,7 @@ impl<'a> Element<'a> {
1114 margin_origin_x: f.float(0x14001d0f, 36.0)?,1138 margin_origin_x: f.float(0x14001d0f, 36.0)?,
1115 margin_origin_y: f.float(0x14001d10, 36.0)?,1139 margin_origin_y: f.float(0x14001d10, 36.0)?,
1116 rtl: f.boolean(0x08001c92)?,1140 rtl: f.boolean(0x08001c92)?,
1141 recordings: f.guids(0x1c001ca3)?,
1117 },1142 },
1118 0x20030 => Kind::Metadata {1143 0x20030 => Kind::Metadata {
1119 title: f.text(0x1c001cf3)?,1144 title: f.text(0x1c001cf3)?,
...@@ -1128,6 +1153,9 @@ impl<'a> Element<'a> {...@@ -1128,6 +1153,9 @@ impl<'a> Element<'a> {
1128 data: f.one(0x20003415)?,1153 data: f.one(0x20003415)?,
1129 scale_x: f.float(0x14001c46, 1.0)?,1154 scale_x: f.float(0x14001c46, 1.0)?,
1130 scale_y: f.float(0x14001c47, 1.0)?,1155 scale_y: f.float(0x14001c47, 1.0)?,
1156 shape_kind: f.bytes(0x0c001d4f)?.and_then(|b| b.first().copied()),
1157 line: f.bytes(0x1c001dac)?.map(<[u8]>::to_vec),
1158 anchors: f.bytes(0x1c001daa)?.map(<[u8]>::to_vec),
1131 },1159 },
1132 0x2003b => Kind::InkData {1160 0x2003b => Kind::InkData {
1133 strokes: f.refs(0x24003416, IdStream::Objects)?,1161 strokes: f.refs(0x24003416, IdStream::Objects)?,
...@@ -1327,6 +1355,7 @@ impl<'a> Element<'a> {...@@ -1327,6 +1355,7 @@ impl<'a> Element<'a> {
1327 source_path: f.text(0x1c001d9d)?,1355 source_path: f.text(0x1c001d9d)?,
1328 recording_id: f.fixed(0x1c001c97)?,1356 recording_id: f.fixed(0x1c001c97)?,
1329 recording_type: f.u32(0x14001d24)?,1357 recording_type: f.u32(0x14001d24)?,
1358 recording_duration: f.u32(0x14001cfd)?,
1330 icon_width: f.float(0x140034cd, 36.0)?,1359 icon_width: f.float(0x140034cd, 36.0)?,
1331 icon_height: f.float(0x140034ce, 36.0)?,1360 icon_height: f.float(0x140034ce, 36.0)?,
1332 },1361 },
...@@ -1349,14 +1378,7 @@ impl<'a> Element<'a> {...@@ -1349,14 +1378,7 @@ impl<'a> Element<'a> {
1349 },1378 },
1350 _ => Kind::Unknown,1379 _ => Kind::Unknown,
1351 };1380 };
1352 let media_ids = f.bytes(0x1c001c98)?.unwrap_or_default();1381 let media_ids = f.guids(0x1c001c98)?;
1353 if !media_ids.len().is_multiple_of(16) {
1354 return Err(invalid("Media identifiers have an invalid length"));
1355 }
1356 let media_ids = media_ids
1357 .chunks_exact(16)
1358 .map(|id| id.try_into().unwrap())
1359 .collect();
1360 Ok(Self {1382 Ok(Self {
1361 jcid: object.jcid,1383 jcid: object.jcid,
1362 children,1384 children,
crates/onestore/src/op/apply.rs+82-17
...@@ -63,9 +63,16 @@ impl<'a> Section<'a> {...@@ -63,9 +63,16 @@ impl<'a> Section<'a> {
63 .collect();63 .collect();
64 for space in spaces {64 for space in spaces {
65 // A page the edit went on to delete has no cells left to check.65 // A page the edit went on to delete has no cells left to check.
66 let Ok(page) = section.active(space) else {66 if section.active(space).is_err() {
67 continue;67 continue;
68 };68 }
69 Writer {
70 section: &mut *section,
71 space,
72 author,
73 }
74 .playlist()?;
75 let page = section.active(space)?;
69 if page.view.nodes.values().any(|node| {76 if page.view.nodes.values().any(|node| {
70 matches!(node.kind, Kind::Cell { .. }) && node.children.is_empty()77 matches!(node.kind, Kind::Cell { .. }) && node.children.is_empty()
71 }) {78 }) {
...@@ -474,6 +481,41 @@ impl<'a> Writer<'_, 'a> {...@@ -474,6 +481,41 @@ impl<'a> Writer<'_, 'a> {
474 Ok(())481 Ok(())
475 }482 }
476483
484 /// Lists the page's recordings in its AudioRecordingGuids as OneNote keeps them: in
485 /// the order they arrived, without those since removed, absent when there are none.
486 fn playlist(&mut self) -> Result<(), Failure> {
487 let page = self.page()?;
488 let [id] = page.pages[..] else {
489 return Ok(());
490 };
491 let Kind::Page { recordings, .. } = &page.view.nodes[&id].kind else {
492 return Ok(());
493 };
494 let present: Vec<[u8; 16]> = page
495 .view
496 .nodes
497 .values()
498 .filter_map(|node| match node.kind {
499 Kind::Attachment { recording_id, .. } => recording_id,
500 _ => None,
501 })
502 .collect();
503 let mut listed: Vec<[u8; 16]> = recordings
504 .iter()
505 .filter(|recording| present.contains(recording))
506 .copied()
507 .collect();
508 for recording in present {
509 if !listed.contains(&recording) {
510 listed.push(recording);
511 }
512 }
513 if listed == *recordings {
514 return Ok(());
515 }
516 self.write(|page| content::playlist_changes(page, id, &listed))
517 }
518
477 /// Requires `id` reachable on the page.519 /// Requires `id` reachable on the page.
478 fn target(&mut self, id: ExGuid) -> Result<(), Failure> {520 fn target(&mut self, id: ExGuid) -> Result<(), Failure> {
479 let page = self.page()?;521 let page = self.page()?;
...@@ -746,6 +788,33 @@ impl<'a> Writer<'_, 'a> {...@@ -746,6 +788,33 @@ impl<'a> Writer<'_, 'a> {
746 let text = self.text_of(*paragraph)?;788 let text = self.text_of(*paragraph)?;
747 self.write(|page| properties::style_changes(page, text, *style, Some(definition)))789 self.write(|page| properties::style_changes(page, text, *style, Some(definition)))
748 }790 }
791 PageOp::Media { paragraph, media } => {
792 self.target(*paragraph)?;
793 let page = self.page()?;
794 let content = match page.view.nodes[paragraph].content.as_slice() {
795 [content]
796 if matches!(
797 page.view.nodes[content].kind,
798 Kind::RichText { .. } | Kind::Attachment { .. }
799 ) =>
800 {
801 *content
802 }
803 _ => {
804 return Err(OpError::Unsupported(
805 "Link a paragraph of text or a file to a recording",
806 )
807 .into());
808 }
809 };
810 if media.recordings.is_empty() != media.time_ms.is_none() {
811 return Err(OpError::Unsupported(
812 "A recording link names recordings and a moment in them",
813 )
814 .into());
815 }
816 self.write(|page| properties::media_changes(page, content, media))
817 }
749 PageOp::List { paragraph, lists } => {818 PageOp::List { paragraph, lists } => {
750 self.target(*paragraph)?;819 self.target(*paragraph)?;
751 let page = self.page()?;820 let page = self.page()?;
...@@ -772,17 +841,16 @@ impl<'a> Writer<'_, 'a> {...@@ -772,17 +841,16 @@ impl<'a> Writer<'_, 'a> {
772 definitions,841 definitions,
773 } => {842 } => {
774 self.target(*target)?;843 self.target(*target)?;
775 let mut entries = Vec::new();844 let entries: Vec<_> = tags
776 for tag in tags {845 .iter()
777 let definition = tag846 .map(|tag| {
778 .definition847 let model = definitions
779 .ok_or(OpError::Unsupported("A note tag names its definition"))?;848 .iter()
780 let model = definitions849 .find(|(id, _)| Some(*id) == tag.definition)
781 .iter()850 .map(|(_, definition)| definition);
782 .find(|(id, _)| *id == definition)851 (tag, model)
783 .map(|(_, definition)| definition);852 })
784 entries.push((definition, tag, model));853 .collect();
785 }
786 self.write(|page| properties::tag_changes(page, *target, &entries))854 self.write(|page| properties::tag_changes(page, *target, &entries))
787 }855 }
788 PageOp::Add { object, before } => self.add(object, *before),856 PageOp::Add { object, before } => self.add(object, *before),
...@@ -1449,7 +1517,7 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> {...@@ -1449,7 +1517,7 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> {
1449 }1517 }
1450 if paragraph.media != Default::default() {1518 if paragraph.media != Default::default() {
1451 return Err(OpError::Unsupported(1519 return Err(OpError::Unsupported(
1452 "Recording annotations are made by OneNote while it records",1520 "Link a paragraph to a recording with its own op",
1453 ));1521 ));
1454 }1522 }
1455 match &paragraph.content {1523 match &paragraph.content {
...@@ -1470,9 +1538,6 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> {...@@ -1470,9 +1538,6 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> {
1470 }1538 }
1471 Ok(())1539 Ok(())
1472 }1540 }
1473 ParagraphContent::Attachment(attachment) if attachment.recording.is_some() => Err(
1474 OpError::Unsupported("Recordings are captured by OneNote, not inserted"),
1475 ),
1476 _ => Ok(()),1541 _ => Ok(()),
1477 }1542 }
1478}1543}
crates/onestore/src/op/content.rs+124-40
...@@ -6,7 +6,7 @@ use crate::{...@@ -6,7 +6,7 @@ use crate::{
6 Error, ExGuid,6 Error, ExGuid,
7 active::{ActivePage, Changes},7 active::{ActivePage, Changes},
8 document::{Format, Layout},8 document::{Format, Layout},
9 page::{Attachment, Image, Ink, InkStroke, Paragraph},9 page::{Attachment, Image, Ink, InkStroke, Paragraph, Recording},
10 write::PropertyObject,10 write::PropertyObject,
11};11};
12use std::collections::BTreeMap;12use std::collections::BTreeMap;
...@@ -319,8 +319,10 @@ pub(crate) fn attachment_changes(...@@ -319,8 +319,10 @@ pub(crate) fn attachment_changes(
319 "An attachment needs a file name without path separators",319 "An attachment needs a file name without path separators",
320 ));320 ));
321 }321 }
322 if attachment.recording.is_some() {322 if Recording::kind_of(name).is_some() != attachment.recording.is_some() {
323 return Err(invalid("Recordings are captured by OneNote, not inserted"));323 return Err(invalid(
324 "Audio and video files are recordings, and only they are",
325 ));
324 }326 }
325 if attachment.bytes.is_none() {327 if attachment.bytes.is_none() {
326 return Err(invalid("A new attachment needs its payload"));328 return Err(invalid("A new attachment needs its payload"));
...@@ -374,6 +376,19 @@ pub(crate) fn attachment_changes(...@@ -374,6 +376,19 @@ pub(crate) fn attachment_changes(
374 if let Some(path) = &attachment.source_path {376 if let Some(path) = &attachment.source_path {
375 values.push((0x1c001d9d, crate::create::string(path)));377 values.push((0x1c001d9d, crate::create::string(path)));
376 }378 }
379 if let Some(recording) = attachment.recording {
380 if recording.id == [0; 16] || !matches!(recording.kind, 1 | 2) {
381 return Err(invalid(
382 "A recording needs an identity and is audio or video",
383 ));
384 }
385 // AudioRecordingGuid, IRecordMedia and AudioRecordingDuration.
386 values.push((0x1c001c97, recording.id.to_vec()));
387 values.push((0x14001d24, recording.kind.to_le_bytes().to_vec()));
388 if let Some(duration) = recording.duration_ms {
389 values.push((0x14001cfd, duration.to_le_bytes().to_vec()));
390 }
391 }
377 values.extend(layout_values(&Default::default(), &attachment.layout)?.0);392 values.extend(layout_values(&Default::default(), &attachment.layout)?.0);
378 let mut changed = BTreeMap::new();393 let mut changed = BTreeMap::new();
379 let mut file = PropertyObject::file(ids.file, &payload_reference(ids.payload), extension)?;394 let mut file = PropertyObject::file(ids.file, &payload_reference(ids.payload), extension)?;
...@@ -400,6 +415,21 @@ pub(crate) fn attachment_changes(...@@ -400,6 +415,21 @@ pub(crate) fn attachment_changes(
400 Ok(changed)415 Ok(changed)
401}416}
402417
418/// Page `page` listing `recordings` in its AudioRecordingGuids, or none.
419pub(crate) fn playlist_changes(
420 active: &ActivePage<'_>,
421 page: ExGuid,
422 recordings: &[[u8; 16]],
423) -> Result<Changes, Error> {
424 let mut node = PropertyObject::from_object(&active.live.revision.objects[&page])?;
425 if recordings.is_empty() {
426 node.remove(&[0x1c001ca3])?;
427 } else {
428 node.set(&[(0x1c001ca3, recordings.concat().as_slice())])?;
429 }
430 Ok(BTreeMap::from([(page, node)]))
431}
432
403/// A stored attachment keeps its payload and preview; its shown name, recorded source433/// A stored attachment keeps its payload and preview; its shown name, recorded source
404/// path and icon size change in place, as OneNote's rename does.434/// path and icon size change in place, as OneNote's rename does.
405pub(crate) fn attachment_edit_changes(435pub(crate) fn attachment_edit_changes(
...@@ -415,7 +445,7 @@ pub(crate) fn attachment_edit_changes(...@@ -415,7 +445,7 @@ pub(crate) fn attachment_edit_changes(
415 .to_vec(),445 .to_vec(),
416 )];446 )];
417 if attachment.recording != stored.recording {447 if attachment.recording != stored.recording {
418 return Err(invalid("A recording stays the recording OneNote captured"));448 return Err(invalid("A file keeps its recording"));
419 }449 }
420 let name = attachment.filename.as_str();450 let name = attachment.filename.as_str();
421 if name.is_empty() || name.contains(['\0', '/', '\\']) {451 if name.is_empty() || name.contains(['\0', '/', '\\']) {
...@@ -425,6 +455,11 @@ pub(crate) fn attachment_edit_changes(...@@ -425,6 +455,11 @@ pub(crate) fn attachment_edit_changes(
425 }455 }
426 let mut removed = Vec::new();456 let mut removed = Vec::new();
427 if attachment.filename != stored.filename {457 if attachment.filename != stored.filename {
458 if Recording::kind_of(name).is_some() != attachment.recording.is_some() {
459 return Err(invalid(
460 "Audio and video files are recordings, and only they are",
461 ));
462 }
428 let name = crate::create::string(&attachment.filename);463 let name = crate::create::string(&attachment.filename);
429 values.push((0x1c001c22, name.clone()));464 values.push((0x1c001c22, name.clone()));
430 values.push((0x1c001d9c, name));465 values.push((0x1c001d9c, name));
...@@ -457,19 +492,31 @@ pub(crate) fn attachment_edit_changes(...@@ -457,19 +492,31 @@ pub(crate) fn attachment_edit_changes(
457 Ok(BTreeMap::from([(object, node)]))492 Ok(BTreeMap::from([(object, node)]))
458}493}
459494
460/// Creates stroke objects (numbered from `first`) and one drawing-attribute object per495/// Creates stroke objects (numbered on from the page's last stroke) and one
461/// distinct pen; returns the compact references for the data node's stroke list.496/// drawing-attribute object per distinct pen; returns the compact references for the data
497/// node's stroke list. A shape's strokes carry only their path and pen, as OneNote's do.
462fn write_strokes(498fn write_strokes(
499 active: &ActivePage<'_>,
463 changed: &mut Changes,500 changed: &mut Changes,
464 data_object: &mut PropertyObject,501 data_object: &mut PropertyObject,
465 strokes: &[(ExGuid, &InkStroke)],502 strokes: &[(ExGuid, &InkStroke)],
466 first: usize,503 shape: bool,
467 filetime: u64,504 filetime: u64,
468) -> Result<Vec<u8>, Error> {505) -> Result<Vec<u8>, Error> {
506 let first = active
507 .view
508 .nodes
509 .values()
510 .filter_map(|node| match node.kind {
511 crate::document::Kind::InkStroke { index, .. } => index,
512 _ => None,
513 })
514 .max()
515 .unwrap_or(0);
469 let mut styles: Vec<(InkPen, ExGuid)> = Vec::new();516 let mut styles: Vec<(InkPen, ExGuid)> = Vec::new();
470 let mut references = Vec::new();517 let mut references = Vec::new();
471 for (offset, (stroke_id, stroke)) in strokes.iter().enumerate() {518 for (offset, (stroke_id, stroke)) in strokes.iter().enumerate() {
472 let pen = InkPen::of(stroke);519 let pen = InkPen::of(stroke, shape);
473 let style = match styles.iter().find(|(known, _)| *known == pen) {520 let style = match styles.iter().find(|(known, _)| *known == pen) {
474 Some((_, id)) => *id,521 Some((_, id)) => *id,
475 None => {522 None => {
...@@ -488,13 +535,14 @@ fn write_strokes(...@@ -488,13 +535,14 @@ fn write_strokes(
488 id535 id
489 }536 }
490 };537 };
538 let values = if shape {
539 stroke_path(stroke)?
540 } else {
541 stroke_values(stroke, first + offset as u32 + 1, filetime)?
542 };
491 let mut object = PropertyObject {543 let mut object = PropertyObject {
492 jcid: 0x20047,544 jcid: 0x20047,
493 bytes: crate::create::properties(&stroke_values(545 bytes: crate::create::properties(&values)?,
494 stroke,
495 (first + offset) as u32 + 1,
496 filetime,
497 )?)?,
498 global_ids: std::sync::Arc::new(BTreeMap::from([(0, stroke_id.guid)])),546 global_ids: std::sync::Arc::new(BTreeMap::from([(0, stroke_id.guid)])),
499 };547 };
500 object.reference(*stroke_id)?;548 object.reference(*stroke_id)?;
...@@ -506,7 +554,8 @@ fn write_strokes(...@@ -506,7 +554,8 @@ fn write_strokes(
506 Ok(references)554 Ok(references)
507}555}
508556
509/// The drawing attributes OneNote shares between strokes drawn with the same pen.557/// The drawing attributes OneNote shares between strokes drawn with the same pen. A pen
558/// drawn with the mouse ignores pressure; a shape's pen spans every coordinate instead.
510#[derive(PartialEq)]559#[derive(PartialEq)]
511struct InkPen {560struct InkPen {
512 width: u32,561 width: u32,
...@@ -514,22 +563,31 @@ struct InkPen {...@@ -514,22 +563,31 @@ struct InkPen {
514 color: Option<u32>,563 color: Option<u32>,
515 transparency: Option<u8>,564 transparency: Option<u8>,
516 pen_tip: Option<u8>,565 pen_tip: Option<u8>,
566 raster_operation: Option<u8>,
567 shape: bool,
517}568}
518569
519impl InkPen {570impl InkPen {
520 fn of(stroke: &InkStroke) -> Self {571 fn of(stroke: &InkStroke, shape: bool) -> Self {
521 Self {572 Self {
522 width: (stroke.width * 2540.0 / 72.0).to_bits(),573 width: (stroke.width * 2540.0 / 72.0).to_bits(),
523 height: (stroke.height * 2540.0 / 72.0).to_bits(),574 height: (stroke.height * 2540.0 / 72.0).to_bits(),
524 color: stroke.color,575 color: stroke.color,
525 transparency: stroke.transparency,576 transparency: stroke.transparency,
526 pen_tip: stroke.pen_tip,577 pen_tip: stroke.pen_tip,
578 raster_operation: stroke.raster_operation,
579 shape,
527 }580 }
528 }581 }
529582
530 fn values(&self) -> Values {583 fn values(&self) -> Values {
584 let dimensions = if self.shape {
585 crate::page::ink::SHAPE_DIMENSIONS
586 } else {
587 crate::page::ink::DIMENSIONS
588 };
531 let mut values: Values = vec![589 let mut values: Values = vec![
532 (0x1c00340a, crate::page::ink::DIMENSIONS.to_vec()),590 (0x1c00340a, dimensions.to_vec()),
533 (0x1400340c, self.height.to_le_bytes().to_vec()),591 (0x1400340c, self.height.to_le_bytes().to_vec()),
534 (0x1400340d, self.width.to_le_bytes().to_vec()),592 (0x1400340d, self.width.to_le_bytes().to_vec()),
535 ];593 ];
...@@ -542,11 +600,17 @@ impl InkPen {...@@ -542,11 +600,17 @@ impl InkPen {
542 if let Some(tip) = self.pen_tip {600 if let Some(tip) = self.pen_tip {
543 values.push((0x0c003412, vec![tip]));601 values.push((0x0c003412, vec![tip]));
544 }602 }
603 if let Some(operation) = self.raster_operation {
604 values.push((0x0c003413, vec![operation]));
605 }
606 if !self.shape {
607 values.push((0x88003411, Vec::new()));
608 }
545 values609 values
546 }610 }
547}611}
548612
549fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values, Error> {613fn stroke_path(stroke: &InkStroke) -> Result<Values, Error> {
550 if stroke.points.is_empty() {614 if stroke.points.is_empty() {
551 return Err(invalid("A stroke needs at least one point"));615 return Err(invalid("A stroke needs at least one point"));
552 }616 }
...@@ -562,27 +626,32 @@ fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values...@@ -562,27 +626,32 @@ fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values
562 "Stroke points and pen size must be finite and positive",626 "Stroke points and pen size must be finite and positive",
563 ));627 ));
564 }628 }
565 let left = stroke629 Ok(vec![(0x1c00340b, stroke.packet())])
566 .points630}
567 .iter()631
568 .map(|p| p[0])632/// A drawn stroke as OneNote stores one: its path, index on the page, language, identity,
569 .fold(f32::INFINITY, f32::min);633/// time and half-inch origin, and a bias of 2 for a highlighter.
570 let top = stroke634fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values, Error> {
571 .points635 let mut values = stroke_path(stroke)?;
572 .iter()636 let [left, top] = [0, 1].map(|axis| {
573 .map(|p| p[1])637 stroke
574 .fold(f32::INFINITY, f32::min);638 .points
639 .iter()
640 .map(|p| p[axis])
641 .fold(f32::INFINITY, f32::min)
642 });
575 let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec();643 let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec();
576 origin.extend_from_slice(&((top - stroke.height / 2.0) / 36.0).to_le_bytes());644 origin.extend_from_slice(&((top - stroke.height / 2.0) / 36.0).to_le_bytes());
577 Ok(vec![645 let bias = if stroke.raster_operation == Some(9) { 2 } else { 0 };
578 (0x1c00340b, stroke.packet()),646 values.extend([
579 (0x14003419, index.to_le_bytes().to_vec()),647 (0x14003419, index.to_le_bytes().to_vec()),
580 (0x1000341b, 0x409_u16.to_le_bytes().to_vec()),648 (0x1000341b, 0x409_u16.to_le_bytes().to_vec()),
581 (0x0c00341c, vec![0]),649 (0x0c00341c, vec![bias]),
582 (0x1c00341a, crate::write::fresh_guid()?.to_vec()),650 (0x1c00341a, crate::write::fresh_guid()?.to_vec()),
583 (0x1c00341d, filetime.to_le_bytes().to_vec()),651 (0x1c00341d, filetime.to_le_bytes().to_vec()),
584 (0x1c00345b, origin),652 (0x1c00345b, origin),
585 ])653 ]);
654 Ok(values)
586}655}
587656
588/// New ink as OneNote 2010 stores a drawing: container `id` that the page lists as a child657/// New ink as OneNote 2010 stores a drawing: container `id` that the page lists as a child
...@@ -614,15 +683,27 @@ pub(crate) fn ink_changes(...@@ -614,15 +683,27 @@ pub(crate) fn ink_changes(
614 global_ids: std::sync::Arc::new(BTreeMap::from([(0, data.guid)])),683 global_ids: std::sync::Arc::new(BTreeMap::from([(0, data.guid)])),
615 };684 };
616 data_object.reference(data)?;685 data_object.reference(data)?;
617 let references = write_strokes(&mut changed, &mut data_object, strokes, 0, filetime)?;686 let references = write_strokes(
687 active,
688 &mut changed,
689 &mut data_object,
690 strokes,
691 ink.shape.is_some(),
692 filetime,
693 )?;
618 data_object.set(&[(0x24003416, &references)])?;694 data_object.set(&[(0x24003416, &references)])?;
619 changed.insert(data, data_object);695 changed.insert(data, data_object);
696 // A shape keeps its kind and geometry beside the strokes, and counts as a second kind of
697 // drawing.
698 let mut values: Values = vec![(0x14001d7a, modified.to_vec())];
699 if let Some(shape) = &ink.shape {
700 let (kind, property, bytes) = shape.stored();
701 values.extend([(0x0c001d4f, vec![kind]), (property, bytes)]);
702 }
703 values.push((0x14001d4e, (1 + u32::from(ink.shape.is_some())).to_le_bytes().to_vec()));
620 let mut object = PropertyObject {704 let mut object = PropertyObject {
621 jcid: 0x60014,705 jcid: 0x60014,
622 bytes: crate::create::properties(&[706 bytes: crate::create::properties(&values)?,
623 (0x14001d7a, modified.to_vec()),
624 (0x14001d4e, 1u32.to_le_bytes().to_vec()),
625 ])?,
626 global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])),707 global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])),
627 };708 };
628 object.reference(id)?;709 object.reference(id)?;
...@@ -641,10 +722,12 @@ pub(crate) fn strokes_changes(...@@ -641,10 +722,12 @@ pub(crate) fn strokes_changes(
641 kept: &[ExGuid],722 kept: &[ExGuid],
642 added: &[(ExGuid, &InkStroke)],723 added: &[(ExGuid, &InkStroke)],
643) -> Result<Changes, Error> {724) -> Result<Changes, Error> {
644 let data = match active.view.nodes.get(&container).map(|node| &node.kind) {725 let (data, shape) = match active.view.nodes.get(&container).map(|node| &node.kind) {
645 Some(crate::document::Kind::Ink {726 Some(crate::document::Kind::Ink {
646 data: Some(data), ..727 data: Some(data),
647 }) => *data,728 shape_kind,
729 ..
730 }) => (*data, shape_kind.is_some()),
648 _ => return Err(invalid("Stored ink has no stroke data to rewrite")),731 _ => return Err(invalid("Stored ink has no stroke data to rewrite")),
649 };732 };
650 let (modified, filetime) = crate::create::current_timestamps()?;733 let (modified, filetime) = crate::create::current_timestamps()?;
...@@ -657,10 +740,11 @@ pub(crate) fn strokes_changes(...@@ -657,10 +740,11 @@ pub(crate) fn strokes_changes(
657 references.extend(data_object.reference(*id)?);740 references.extend(data_object.reference(*id)?);
658 }741 }
659 references.extend(write_strokes(742 references.extend(write_strokes(
743 active,
660 &mut changed,744 &mut changed,
661 &mut data_object,745 &mut data_object,
662 added,746 added,
663 kept.len(),747 shape,
664 filetime,748 filetime,
665 )?);749 )?);
666 data_object.set(&[(0x24003416, &references)])?;750 data_object.set(&[(0x24003416, &references)])?;
crates/onestore/src/op/lower.rs+27-13
...@@ -225,7 +225,7 @@ pub(crate) fn collect_containers(list: &[PageParagraph], out: &mut Vec<ExGuid>)...@@ -225,7 +225,7 @@ pub(crate) fn collect_containers(list: &[PageParagraph], out: &mut Vec<ExGuid>)
225225
226/// What the writers keep as stored, checked before any op: titles, unsupported objects,226/// What the writers keep as stored, checked before any op: titles, unsupported objects,
227/// definitions other than lists and tags, outline roles, paragraph styles and formats,227/// definitions other than lists and tags, outline roles, paragraph styles and formats,
228/// recording links, fields and content types.228/// fields and content types.
229pub(crate) fn validate(229pub(crate) fn validate(
230 before: &Page,230 before: &Page,
231 after: &Page,231 after: &Page,
...@@ -309,17 +309,13 @@ pub(crate) fn validate(...@@ -309,17 +309,13 @@ pub(crate) fn validate(
309 let Some(previous) = old.paragraphs.get(id) else {309 let Some(previous) = old.paragraphs.get(id) else {
310 continue;310 continue;
311 };311 };
312 // A join moves a text object, with its style and recording link, into an emptied312 // A join moves a text object, with its style, into an emptied paragraph.
313 // paragraph; one new to the page starts as a copy without the link.
314 let owner = paragraph.text().map(|text| owners.get(&text.id));313 let owner = paragraph.text().map(|text| owners.get(&text.id));
315 let (style, media) = match owner {314 let style = match owner {
316 Some(Some(owner)) => (owner.style, owner.media.clone()),315 Some(Some(owner)) => owner.style,
317 Some(None) => (paragraph.style, Default::default()),316 Some(None) => paragraph.style,
318 None => (previous.style, previous.media.clone()),317 None => previous.style,
319 };318 };
320 if paragraph.media != media {
321 return Err(invalid("Recording annotations cannot be edited"));
322 }
323 if paragraph.style != style || paragraph.format != previous.format {319 if paragraph.style != style || paragraph.format != previous.format {
324 return Err(invalid(320 return Err(invalid(
325 "Paragraph styles and paragraph formatting cannot be edited",321 "Paragraph styles and paragraph formatting cannot be edited",
...@@ -695,6 +691,8 @@ pub(crate) fn same_tags(a: &[Tag], b: &[Tag]) -> bool {...@@ -695,6 +691,8 @@ pub(crate) fn same_tags(a: &[Tag], b: &[Tag]) -> bool {
695 (691 (
696 t.definition,692 t.definition,
697 t.action_type,693 t.action_type,
694 t.shape,
695 t.property_status,
698 t.status,696 t.status,
699 t.created,697 t.created,
700 t.completed,698 t.completed,
...@@ -745,6 +743,7 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph {...@@ -745,6 +743,7 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph {
745 stripped.lists.clear();743 stripped.lists.clear();
746 stripped.tags.clear();744 stripped.tags.clear();
747 stripped.collapsed = false;745 stripped.collapsed = false;
746 stripped.media = Default::default();
748 match &mut stripped.content {747 match &mut stripped.content {
749 ParagraphContent::Text(text) => text.tags.clear(),748 ParagraphContent::Text(text) => text.tags.clear(),
750 ParagraphContent::Table(table) => {749 ParagraphContent::Table(table) => {
...@@ -820,6 +819,7 @@ impl Lowering {...@@ -820,6 +819,7 @@ impl Lowering {
820 self.styles(after, new)?;819 self.styles(after, new)?;
821 self.lists(after, new)?;820 self.lists(after, new)?;
822 self.tags(after, new)?;821 self.tags(after, new)?;
822 self.media(new)?;
823 self.paragraph_formatting(new)?;823 self.paragraph_formatting(new)?;
824 self.formatting(old, new)?;824 self.formatting(old, new)?;
825 self.layout(old, new)?;825 self.layout(old, new)?;
...@@ -1775,9 +1775,9 @@ impl Lowering {...@@ -1775,9 +1775,9 @@ impl Lowering {
1775 }1775 }
1776 let mut definitions = Vec::new();1776 let mut definitions = Vec::new();
1777 for tag in tags {1777 for tag in tags {
1778 let definition = tag1778 let Some(definition) = tag.definition else {
1779 .definition1779 continue;
1780 .ok_or_else(|| invalid("A note tag names its definition"))?;1780 };
1781 if let Some(model) = after.definitions.get(&definition)1781 if let Some(model) = after.definitions.get(&definition)
1782 && !definitions.iter().any(|(id, _)| *id == definition)1782 && !definitions.iter().any(|(id, _)| *id == definition)
1783 {1783 {
...@@ -1794,6 +1794,20 @@ impl Lowering {...@@ -1794,6 +1794,20 @@ impl Lowering {
1794 Ok(())1794 Ok(())
1795 }1795 }
17961796
1797 fn media(&mut self, new: &View<'_>) -> Result<(), Error> {
1798 for (id, paragraph) in &new.paragraphs {
1799 let current = model::paragraph(&self.current, *id)
1800 .ok_or_else(|| invalid("A paragraph is missing after text edits"))?;
1801 if current.media != paragraph.media {
1802 self.emit(PageOp::Media {
1803 paragraph: *id,
1804 media: paragraph.media.clone(),
1805 })?;
1806 }
1807 }
1808 Ok(())
1809 }
1810
1797 fn paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> {1811 fn paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> {
1798 for (id, paragraph) in &new.paragraphs {1812 for (id, paragraph) in &new.paragraphs {
1799 let Some(text) = paragraph.text() else {1813 let Some(text) = paragraph.text() else {
crates/onestore/src/op/mod.rs+8-2
...@@ -6,8 +6,8 @@ use crate::{...@@ -6,8 +6,8 @@ use crate::{
6 ExGuid, OutlineEdit, PageCreation, PageEdit, TextAttribute,6 ExGuid, OutlineEdit, PageCreation, PageEdit, TextAttribute,
7 document::{Layout, Tag},7 document::{Layout, Tag},
8 page::{8 page::{
9 Definition, InkStroke, Page, PageObject, PageParagraph, Paragraph, TableCell, TableColumn,9 Definition, InkStroke, MediaIndex, Page, PageObject, PageParagraph, Paragraph, TableCell,
10 TableRow,10 TableColumn, TableRow,
11 },11 },
12};12};
13use serde::{Deserialize, Serialize};13use serde::{Deserialize, Serialize};
...@@ -151,6 +151,12 @@ pub enum PageOp {...@@ -151,6 +151,12 @@ pub enum PageOp {
151 style: ExGuid,151 style: ExGuid,
152 definition: Definition,152 definition: Definition,
153 },153 },
154 /// Links a paragraph's text or file to a moment in recordings on the page, or with
155 /// empty `media` unlinks it.
156 Media {
157 paragraph: ExGuid,
158 media: MediaIndex,
159 },
154 /// Replaces a paragraph's list nodes; a node another paragraph owns is copied.160 /// Replaces a paragraph's list nodes; a node another paragraph owns is copied.
155 List {161 List {
156 paragraph: ExGuid,162 paragraph: ExGuid,
crates/onestore/src/op/model.rs+16
...@@ -468,6 +468,7 @@ fn stroked(stroke: &crate::page::InkStroke) -> crate::page::InkStroke {...@@ -468,6 +468,7 @@ fn stroked(stroke: &crate::page::InkStroke) -> crate::page::InkStroke {
468fn inked(ink: &crate::page::Ink) -> crate::page::Ink {468fn inked(ink: &crate::page::Ink) -> crate::page::Ink {
469 crate::page::Ink {469 crate::page::Ink {
470 strokes: ink.strokes.iter().map(stroked).collect(),470 strokes: ink.strokes.iter().map(stroked).collect(),
471 shape: ink.shape.as_ref().map(crate::page::ink::InkShape::snapped),
471 ..ink.clone()472 ..ink.clone()
472 }473 }
473}474}
...@@ -1042,6 +1043,21 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {...@@ -1042,6 +1043,21 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
1042 text.text = Paragraph::from_runs(runs);1043 text.text = Paragraph::from_runs(runs);
1043 }1044 }
1044 }1045 }
1046 PageOp::Media { paragraph, media } => {
1047 let paragraph = paragraph_mut(page, *paragraph)?;
1048 if !matches!(
1049 paragraph.content,
1050 ParagraphContent::Text(_) | ParagraphContent::Attachment(_)
1051 ) {
1052 return Err(invalid("Link a paragraph of text or a file to a recording"));
1053 }
1054 if media.recordings.is_empty() != media.time_ms.is_none() {
1055 return Err(invalid(
1056 "A recording link names recordings and a moment in them",
1057 ));
1058 }
1059 paragraph.media = media.clone();
1060 }
1045 PageOp::List { paragraph, lists } => {1061 PageOp::List { paragraph, lists } => {
1046 paragraph_mut(page, *paragraph)?.lists = lists.iter().map(|(id, _)| *id).collect();1062 paragraph_mut(page, *paragraph)?.lists = lists.iter().map(|(id, _)| *id).collect();
1047 for (id, definition) in lists {1063 for (id, definition) in lists {
crates/onestore/src/op/properties.rs+91-38
...@@ -5,7 +5,7 @@ use crate::{...@@ -5,7 +5,7 @@ use crate::{
5 Error, ExGuid, PropertySets, Value,5 Error, ExGuid, PropertySets, Value,
6 active::{ActivePage, Changes},6 active::{ActivePage, Changes},
7 document::{Kind, Tag},7 document::{Kind, Tag},
8 page::Definition,8 page::{Definition, MediaIndex},
9 write::PropertyObject,9 write::PropertyObject,
10};10};
11use std::collections::{BTreeMap, BTreeSet};11use std::collections::{BTreeMap, BTreeSet};
...@@ -165,54 +165,78 @@ fn tag_definition_values(definition: &Definition) -> Result<Values, Error> {...@@ -165,54 +165,78 @@ fn tag_definition_values(definition: &Definition) -> Result<Values, Error> {
165 Ok(values)165 Ok(values)
166}166}
167167
168/// Replaces the note tags of `object`. Each entry names the stored identity of its tag's168/// Replaces the note tags of `object`. A normal tag comes with its definition where the page
169/// definition and, where the tag does not carry its action type or the page lacks the169/// lacks it; a task tag has none and stores its own icon, in the order OneNote writes it.
170/// definition, the definition itself.
171pub(crate) fn tag_changes(170pub(crate) fn tag_changes(
172 active: &ActivePage<'_>,171 active: &ActivePage<'_>,
173 object: ExGuid,172 object: ExGuid,
174 tags: &[(ExGuid, &Tag, Option<&Definition>)],173 tags: &[(&Tag, Option<&Definition>)],
175) -> Result<Changes, Error> {174) -> Result<Changes, Error> {
176 let raw = &active.live.revision;175 let raw = &active.live.revision;
177 let mut definitions: Vec<(ExGuid, Option<Values>)> = Vec::new();176 let mut definitions: Vec<(ExGuid, Option<Values>)> = Vec::new();
178 let mut sets: Vec<(usize, Values)> = Vec::new();177 let mut sets: Vec<(Option<ExGuid>, Values)> = Vec::new();
179 let mut action_types = BTreeSet::new();178 let mut action_types = BTreeSet::new();
180 for (written, tag, definition) in tags {179 for (tag, definition) in tags {
181 let action_type = if tag.status & 4 != 0 {180 let dates = [
182 tag.action_type181 (0x1400346e, tag.created),
183 } else {182 (0x1400346f, tag.completed),
184 match definition.map(|d| &d.kind) {183 (0x1400346a, tag.start),
185 Some(Kind::TagDefinition { action_type, .. }) => *action_type,184 (0x1400346b, tag.due),
186 _ => return Err(invalid("A note tag must reference a tag definition")),185 ];
186 let mut fields: Values = Vec::new();
187 if tag.status & 4 != 0 {
188 let (Some(action_type), Some(shape), Some(property_status)) =
189 (tag.action_type, tag.shape, tag.property_status)
190 else {
191 return Err(invalid(
192 "A task tag stores its action type, icon and property status",
193 ));
194 };
195 if !action_types.insert(action_type) {
196 return Err(invalid("An element holds one note tag per action type"));
187 }197 }
198 fields.extend([
199 (0x0c003473, vec![0]),
200 (0x10003463, action_type.to_le_bytes().to_vec()),
201 (0x10003464, shape.to_le_bytes().to_vec()),
202 (0x14003467, property_status.to_le_bytes().to_vec()),
203 ]);
204 if let Some(task) = tag.task_id {
205 fields.push((0x1c003469, task.to_vec()));
206 }
207 for (id, value) in [dates[2], dates[3], dates[0], dates[1]] {
208 if let Some(value) = value {
209 fields.push((id, value.to_le_bytes().to_vec()));
210 }
211 }
212 fields.push((0x10003470, tag.status.to_le_bytes().to_vec()));
213 sets.push((None, fields));
214 continue;
215 }
216 let written = tag
217 .definition
218 .ok_or_else(|| invalid("A note tag must reference a tag definition"))?;
219 let action_type = match definition.map(|d| &d.kind) {
220 Some(Kind::TagDefinition { action_type, .. }) => *action_type,
221 _ => return Err(invalid("A note tag must reference a tag definition")),
188 };222 };
189 if !action_types.insert(action_type.unwrap_or(0)) {223 if !action_types.insert(action_type.unwrap_or(0)) {
190 return Err(invalid("An element holds one note tag per action type"));224 return Err(invalid("An element holds one note tag per action type"));
191 }225 }
192 let index = match definitions.iter().position(|(id, _)| id == written) {226 if !definitions.iter().any(|(id, _)| *id == written) {
193 Some(index) => index,227 let values = if raw.objects.contains_key(&written) {
194 None => {228 None
195 let values = if raw.objects.contains_key(written) {229 } else {
196 None230 let definition = definition
197 } else {231 .ok_or_else(|| invalid("A note tag references a missing tag definition"))?;
198 let definition = definition232 Some(tag_definition_values(definition)?)
199 .ok_or_else(|| invalid("A note tag references a missing tag definition"))?;233 };
200 Some(tag_definition_values(definition)?)234 definitions.push((written, values));
201 };235 }
202 definitions.push((*written, values));
203 definitions.len() - 1
204 }
205 };
206 let mut fields: Values = Vec::new();
207 if let Some(action_type) = tag.action_type {236 if let Some(action_type) = tag.action_type {
208 fields.push((0x10003463, action_type.to_le_bytes().to_vec()));237 fields.push((0x10003463, action_type.to_le_bytes().to_vec()));
209 }238 }
210 for (id, value) in [239 for (id, value) in dates {
211 (0x1400346e, tag.created),
212 (0x1400346f, tag.completed),
213 (0x1400346a, tag.start),
214 (0x1400346b, tag.due),
215 ] {
216 if let Some(value) = value {240 if let Some(value) = value {
217 fields.push((id, value.to_le_bytes().to_vec()));241 fields.push((id, value.to_le_bytes().to_vec()));
218 }242 }
...@@ -221,7 +245,7 @@ pub(crate) fn tag_changes(...@@ -221,7 +245,7 @@ pub(crate) fn tag_changes(
221 if let Some(task) = tag.task_id {245 if let Some(task) = tag.task_id {
222 fields.push((0x1c003469, task.to_vec()));246 fields.push((0x1c003469, task.to_vec()));
223 }247 }
224 sets.push((index, fields));248 sets.push((Some(written), fields));
225 }249 }
226 let parents = active.editable_parents(object)?;250 let parents = active.editable_parents(object)?;
227 let modified = crate::create::current_timestamps()?.0.to_le_bytes();251 let modified = crate::create::current_timestamps()?.0.to_le_bytes();
...@@ -240,9 +264,12 @@ pub(crate) fn tag_changes(...@@ -240,9 +264,12 @@ pub(crate) fn tag_changes(
240 }264 }
241 let mut target = PropertyObject::from_object(&raw.objects[&object])?;265 let mut target = PropertyObject::from_object(&raw.objects[&object])?;
242 let mut encoded = Vec::new();266 let mut encoded = Vec::new();
243 for (index, fields) in &sets {267 for (definition, fields) in &sets {
244 let reference = target.reference(definitions[*index].0)?;268 let mut set = Vec::new();
245 let mut set = vec![(0x20003488, reference.to_vec())];269 if let Some(definition) = definition {
270 let reference = target.reference(*definition)?;
271 set.push((0x20003488, reference.to_vec()));
272 }
246 set.extend(fields.iter().cloned());273 set.extend(fields.iter().cloned());
247 encoded.push(set);274 encoded.push(set);
248 }275 }
...@@ -380,3 +407,29 @@ pub(crate) fn paragraph_format_changes(...@@ -380,3 +407,29 @@ pub(crate) fn paragraph_format_changes(
380 crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?;407 crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?;
381 Ok(changed)408 Ok(changed)
382}409}
410
411/// Links content `object` to a moment in recordings (MediaIndex), or unlinks it.
412pub(crate) fn media_changes(
413 active: &ActivePage<'_>,
414 object: ExGuid,
415 media: &MediaIndex,
416) -> Result<Changes, Error> {
417 let parents = active.editable_parents(object)?;
418 let modified = crate::create::current_timestamps()?.0.to_le_bytes();
419 let raw = &active.live.revision;
420 let mut target = PropertyObject::from_object(&raw.objects[&object])?;
421 match media.time_ms {
422 Some(time) => {
423 let recordings = media.recordings.concat();
424 target.set(&[
425 (0x1c001c98, recordings.as_slice()),
426 (0x14001c99, &time.to_le_bytes()),
427 ])?;
428 }
429 None => target.remove(&[0x1c001c98, 0x14001c99])?,
430 }
431 target.set(&[(0x14001d7a, &modified)])?;
432 let mut changed = BTreeMap::from([(object, target)]);
433 crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?;
434 Ok(changed)
435}
crates/onestore/src/op/tests.rs+21-9
...@@ -834,6 +834,8 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {...@@ -834,6 +834,8 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
834 text.tags.push(crate::document::Tag {834 text.tags.push(crate::document::Tag {
835 definition: Some(definition),835 definition: Some(definition),
836 action_type: None,836 action_type: None,
837 shape: None,
838 property_status: None,
837 status,839 status,
838 created: Some(1_262_401_445),840 created: Some(1_262_401_445),
839 completed: (status == 1).then_some(1_262_402_000),841 completed: (status == 1).then_some(1_262_402_000),
...@@ -1159,6 +1161,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {...@@ -1159,6 +1161,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
1159 layout: Layout::default(),1161 layout: Layout::default(),
1160 strokes: vec![stroke(rng)],1162 strokes: vec![stroke(rng)],
1161 groups: Vec::new(),1163 groups: Vec::new(),
1164 shape: None,
1162 })1165 })
1163 };1166 };
1164 page.objects.insert(at, object);1167 page.objects.insert(at, object);
...@@ -1251,6 +1254,8 @@ fn stroke(rng: &mut Rng) -> InkStroke {...@@ -1251,6 +1254,8 @@ fn stroke(rng: &mut Rng) -> InkStroke {
1251 color: Some(0x0000ff),1254 color: Some(0x0000ff),
1252 transparency: None,1255 transparency: None,
1253 pen_tip: None,1256 pen_tip: None,
1257 // Every other stroke a highlighter's.
1258 raster_operation: (x as u32).is_multiple_of(2).then_some(9),
1254 }1259 }
1255}1260}
12561261
...@@ -1595,6 +1600,8 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {...@@ -1595,6 +1600,8 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
1595 tags: vec![crate::document::Tag {1600 tags: vec![crate::document::Tag {
1596 definition: Some(definition),1601 definition: Some(definition),
1597 action_type: None,1602 action_type: None,
1603 shape: None,
1604 property_status: None,
1598 status: 0,1605 status: 0,
1599 created: Some(1_262_401_445),1606 created: Some(1_262_401_445),
1600 completed: None,1607 completed: None,
...@@ -1639,15 +1646,20 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {...@@ -1639,15 +1646,20 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
1639 }),1646 }),
1640 before: None,1647 before: None,
1641 },1648 },
1642 _ => PageOp::Add {1649 _ => {
1643 object: PageObject::Ink(Ink {1650 let stroke = stroke(rng);
1644 id: id(),1651 let ends = [stroke.points[0], stroke.points[stroke.points.len() - 1]];
1645 layout: Layout::default(),1652 PageOp::Add {
1646 strokes: vec![stroke(rng)],1653 object: PageObject::Ink(Ink {
1647 groups: Vec::new(),1654 id: id(),
1648 }),1655 layout: Layout::default(),
1649 before: None,1656 strokes: vec![stroke],
1650 },1657 groups: Vec::new(),
1658 shape: Some(crate::page::ink::InkShape::Line(ends)),
1659 }),
1660 before: None,
1661 }
1662 }
1651 })1663 })
1652}1664}
16531665
crates/onestore/src/page/ink.rs+200
...@@ -32,6 +32,119 @@ pub struct Ink {...@@ -32,6 +32,119 @@ pub struct Ink {
32 pub strokes: Vec<InkStroke>,32 pub strokes: Vec<InkStroke>,
33 /// Nested ink containers, as newer OneNote versions group handwriting.33 /// Nested ink containers, as newer OneNote versions group handwriting.
34 pub groups: Vec<Ink>,34 pub groups: Vec<Ink>,
35 /// The shape drawn, for a drawing made from Draw's Insert Shapes.
36 #[serde(default)]
37 pub shape: Option<InkShape>,
38}
39
40/// The shapes of Draw's Insert Shapes that Snowbound draws.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum ShapeKind {
43 Line,
44 Arrow,
45 Rectangle,
46 Ellipse,
47}
48
49impl Ink {
50 /// A shape dragged from `from` to `to` in page points, drawn with `pen`'s width and
51 /// colour as OneNote 2010 draws it (`corpus/ink-tools`): a rectangle clockwise from its
52 /// top left corner, an ellipse in 50 steps clockwise from its right end, and an arrow's
53 /// head as a second stroke of two barbs at atan(1/2) to the line, 8 points plus twice
54 /// the pen's width long. The result reads back unchanged once stored.
55 pub fn drawn(
56 kind: ShapeKind,
57 from: [f32; 2],
58 to: [f32; 2],
59 pen: &InkStroke,
60 ) -> Result<Self, Error> {
61 let [x0, y0] = [from[0].min(to[0]), from[1].min(to[1])];
62 let [x1, y1] = [from[0].max(to[0]), from[1].max(to[1])];
63 let (shape, paths) = match kind {
64 ShapeKind::Line | ShapeKind::Arrow => {
65 let mut paths = vec![vec![from, to]];
66 let [dx, dy] = [to[0] - from[0], to[1] - from[1]];
67 if kind == ShapeKind::Arrow && dx.hypot(dy) > 0.0 {
68 let back = (-dy).atan2(-dx);
69 let length = 8.0 + 2.0 * pen.width;
70 let barb = |turn: f32| {
71 let angle = back + turn;
72 [to[0] + length * angle.cos(), to[1] + length * angle.sin()]
73 };
74 let spread = 0.5f32.atan();
75 paths.push(vec![barb(spread), to, barb(-spread)]);
76 }
77 (InkShape::Line([from, to]), paths)
78 }
79 ShapeKind::Rectangle | ShapeKind::Ellipse => {
80 let [w, h] = [x1 - x0, y1 - y0];
81 let transform = [w, 0.0, 0.0, h, x0, y0];
82 let (anchors, path): (&[[f32; 2]], Vec<[f32; 2]>) = if kind == ShapeKind::Rectangle
83 {
84 (
85 &[
86 [0.0, 0.0],
87 [0.5, 0.0],
88 [1.0, 0.0],
89 [1.0, 0.5],
90 [1.0, 1.0],
91 [0.5, 1.0],
92 [0.0, 1.0],
93 [0.0, 0.5],
94 ],
95 vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]],
96 )
97 } else {
98 let [cx, cy, rx, ry] = [x0 + w / 2.0, y0 + h / 2.0, w / 2.0, h / 2.0];
99 (
100 &[[0.5, 0.0], [1.0, 0.5], [0.5, 1.0], [0.0, 0.5]],
101 (0..=50)
102 .map(|step| {
103 let angle = step as f32 * std::f32::consts::TAU / 50.0;
104 [cx + rx * angle.cos(), cy + ry * angle.sin()]
105 })
106 .collect(),
107 )
108 };
109 (
110 InkShape::Closed {
111 transform,
112 anchors: anchors.to_vec(),
113 },
114 vec![path],
115 )
116 }
117 };
118 let mut strokes = Vec::new();
119 for path in paths {
120 strokes.push(InkStroke {
121 id: super::text::new_id()?,
122 points: path.into_iter().map(|p| p.map(snap)).collect(),
123 ..pen.clone()
124 });
125 }
126 Ok(Self {
127 id: super::text::new_id()?,
128 layout: Layout::default(),
129 strokes,
130 groups: Vec::new(),
131 shape: Some(shape.snapped()),
132 })
133 }
134}
135
136/// What OneNote keeps beside a drawn shape's strokes to edit it by (`corpus/ink-tools`), in
137/// page points before the drawing's offset.
138#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
139pub enum InkShape {
140 /// A line or arrow from one end to the other.
141 Line([[f32; 2]; 2]),
142 /// A closed shape: `transform` (`[a, b, c, d, x, y]`) takes its anchors, in the unit
143 /// square, onto the page.
144 Closed {
145 transform: [f32; 6],
146 anchors: Vec<[f32; 2]>,
147 },
35}148}
36149
37#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]150#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
...@@ -48,6 +161,10 @@ pub struct InkStroke {...@@ -48,6 +161,10 @@ pub struct InkStroke {
48 pub transparency: Option<u8>,161 pub transparency: Option<u8>,
49 /// 0 is a round (ball) tip, 1 a rectangle.162 /// 0 is a round (ball) tip, 1 a rectangle.
50 pub pen_tip: Option<u8>,163 pub pen_tip: Option<u8>,
164 /// The ISF raster operation: 9 (MaskPen) for a highlighter, whose colour multiplies
165 /// what lies beneath.
166 #[serde(default)]
167 pub raster_operation: Option<u8>,
51}168}
52169
53impl Ink {170impl Ink {
...@@ -61,6 +178,9 @@ impl Ink {...@@ -61,6 +178,9 @@ impl Ink {
61 data,178 data,
62 scale_x,179 scale_x,
63 scale_y,180 scale_y,
181 shape_kind,
182 line,
183 anchors,
64 } = &node.kind184 } = &node.kind
65 else {185 else {
66 unreachable!()186 unreachable!()
...@@ -98,6 +218,7 @@ impl Ink {...@@ -98,6 +218,7 @@ impl Ink {
98 layout: node.layout.clone(),218 layout: node.layout.clone(),
99 strokes,219 strokes,
100 groups,220 groups,
221 shape: InkShape::read(*shape_kind, line.as_deref(), anchors.as_deref()),
101 })222 })
102 }223 }
103224
...@@ -151,6 +272,7 @@ impl InkStroke {...@@ -151,6 +272,7 @@ impl InkStroke {
151 color,272 color,
152 transparency,273 transparency,
153 pen_tip,274 pen_tip,
275 raster_operation,
154 ..276 ..
155 } = &style.kind277 } = &style.kind
156 else {278 else {
...@@ -190,10 +312,78 @@ impl InkStroke {...@@ -190,10 +312,78 @@ impl InkStroke {
190 color: *color,312 color: *color,
191 transparency: *transparency,313 transparency: *transparency,
192 pen_tip: *pen_tip,314 pen_tip: *pen_tip,
315 raster_operation: *raster_operation,
193 })316 })
194 }317 }
195}318}
196319
320impl InkShape {
321 /// Any geometry this does not recognise reads as no shape: the strokes still draw it.
322 fn read(kind: Option<u8>, line: Option<&[u8]>, anchors: Option<&[u8]>) -> Option<Self> {
323 let floats = |bytes: &[u8]| -> Vec<f32> {
324 bytes
325 .chunks_exact(4)
326 .map(|b| f32::from_le_bytes(b.try_into().unwrap()))
327 .collect()
328 };
329 match (kind?, line, anchors) {
330 (11, Some(line), _) if line.len() == 16 => {
331 let v = floats(line);
332 Some(Self::Line([[v[0], v[1]], [v[2], v[3]]].map(|end| end.map(|v| v * 36.0))))
333 .filter(Self::finite)
334 }
335 (12, _, Some(bytes)) if bytes.len() >= 24 && (bytes.len() - 24) % 8 == 0 => {
336 let v = floats(bytes);
337 Some(Self::Closed {
338 transform: std::array::from_fn(|i| v[i] * 36.0),
339 anchors: v[6..].chunks_exact(2).map(|p| [p[0], p[1]]).collect(),
340 })
341 .filter(Self::finite)
342 }
343 _ => None,
344 }
345 }
346
347 fn finite(&self) -> bool {
348 match self {
349 Self::Line(ends) => ends.iter().flatten().all(|v| v.is_finite()),
350 Self::Closed { transform, anchors } => transform
351 .iter()
352 .chain(anchors.iter().flatten())
353 .all(|v| v.is_finite()),
354 }
355 }
356
357 /// The kind, property and bytes OneNote stores the shape as.
358 pub(crate) fn stored(&self) -> (u8, u32, Vec<u8>) {
359 let half = |v: &f32| (v / 36.0).to_le_bytes();
360 match self {
361 Self::Line(ends) => (11, 0x1c001dac, ends.iter().flatten().flat_map(half).collect()),
362 Self::Closed { transform, anchors } => (
363 12,
364 0x1c001daa,
365 transform
366 .iter()
367 .flat_map(half)
368 .chain(anchors.iter().flatten().flat_map(|v| v.to_le_bytes()))
369 .collect(),
370 ),
371 }
372 }
373
374 /// This shape as it reads back once stored.
375 pub fn snapped(&self) -> Self {
376 let half = |v: f32| (v / 36.0) * 36.0;
377 match self {
378 Self::Line(ends) => Self::Line(ends.map(|end| end.map(half))),
379 Self::Closed { transform, anchors } => Self::Closed {
380 transform: transform.map(half),
381 anchors: anchors.clone(),
382 },
383 }
384 }
385}
386
197/// ISF multi-byte decoding: a count, then that many 7-bit little-endian varints whose low bit387/// ISF multi-byte decoding: a count, then that many 7-bit little-endian varints whose low bit
198/// is the sign.388/// is the sign.
199fn multi_byte(bytes: &[u8]) -> Option<Vec<i64>> {389fn multi_byte(bytes: &[u8]) -> Option<Vec<i64>> {
...@@ -268,6 +458,15 @@ pub(crate) const DIMENSIONS: [u8; 64] = [...@@ -268,6 +458,15 @@ pub(crate) const DIMENSIONS: [u8; 64] = [
268 0x00, 0x00, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x8b, 0xc5, 0xe2, 0x41,458 0x00, 0x00, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x8b, 0xc5, 0xe2, 0x41,
269];459];
270460
461/// The dimension table OneNote 2010 writes for a drawn shape's pen: X and Y in HIMETRIC over
462/// the whole 32-bit range, at a resolution of 1000.
463pub(crate) const SHAPE_DIMENSIONS: [u8; 64] = [
464 0x8f, 0x6a, 0x8a, 0x59, 0xc0, 0x52, 0xa0, 0x4b, 0x93, 0xaf, 0xaf, 0x35, 0x74, 0x11, 0xa5, 0x61,
465 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0xff, 0x7f, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7a, 0x44,
466 0x75, 0x9f, 0x3f, 0xb5, 0xe0, 0x04, 0x98, 0x44, 0xa7, 0xee, 0xc3, 0x0d, 0xbb, 0x5a, 0x90, 0x11,
467 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0xff, 0x7f, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7a, 0x44,
468];
469
271#[cfg(test)]470#[cfg(test)]
272mod tests {471mod tests {
273 use super::*;472 use super::*;
...@@ -282,6 +481,7 @@ mod tests {...@@ -282,6 +481,7 @@ mod tests {
282 color: None,481 color: None,
283 transparency: None,482 transparency: None,
284 pen_tip: None,483 pen_tip: None,
484 raster_operation: None,
285 };485 };
286 let values = multi_byte(&stroke.packet()).unwrap();486 let values = multi_byte(&stroke.packet()).unwrap();
287 assert_eq!(values.len(), 6);487 assert_eq!(values.len(), 6);
crates/onestore/src/page/mod.rs+26-6
...@@ -282,21 +282,34 @@ pub struct Attachment {...@@ -282,21 +282,34 @@ pub struct Attachment {
282 pub bytes: Option<Arc<[u8]>>,282 pub bytes: Option<Arc<[u8]>>,
283 #[serde(with = "payload")]283 #[serde(with = "payload")]
284 pub preview: Option<Arc<[u8]>>,284 pub preview: Option<Arc<[u8]>>,
285 /// Set when the file is an audio or video recording OneNote captured; annotations on285 /// Set when the file is audio or video OneNote plays; notes on the page refer to it by
286 /// the page refer to it by identity (`PageParagraph::media`).286 /// identity (`PageParagraph::media`).
287 pub recording: Option<Recording>,287 pub recording: Option<Recording>,
288}288}
289289
290/// A recording's identity and OneNote's type code for it.290/// A recording's identity, whether it is audio (1) or video (2), and its length.
291#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]291#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
292pub struct Recording {292pub struct Recording {
293 pub id: [u8; 16],293 pub id: [u8; 16],
294 pub kind: u32,294 pub kind: u32,
295 pub duration_ms: Option<u32>,
296}
297
298impl Recording {
299 /// The kind of recording MS-ONE requires a file named `filename` to be, if any: audio
300 /// for .wma, .mp3 and .wav, video for .wmv, .avi and .mpg.
301 pub fn kind_of(filename: &str) -> Option<u32> {
302 let extension = filename.rsplit_once('.')?.1.to_ascii_lowercase();
303 match extension.as_str() {
304 "wma" | "mp3" | "wav" => Some(1),
305 "wmv" | "avi" | "mpg" => Some(2),
306 _ => None,
307 }
308 }
295}309}
296310
297/// A paragraph's link to a moment in recordings on the page: OneNote plays from311/// A paragraph's link to a moment in recordings on the page: OneNote plays from
298/// `time_ms` when the paragraph is chosen. Read from native pages and preserved through312/// `time_ms` when the paragraph is chosen, and links what is written while it records.
299/// edits; OneNote alone creates them while recording.
300#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]313#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
301pub struct MediaIndex {314pub struct MediaIndex {
302 pub recordings: Vec<[u8; 16]>,315 pub recordings: Vec<[u8; 16]>,
...@@ -328,6 +341,7 @@ impl Attachment {...@@ -328,6 +341,7 @@ impl Attachment {
328 source_path,341 source_path,
329 recording_id,342 recording_id,
330 recording_type,343 recording_type,
344 recording_duration,
331 icon_width,345 icon_width,
332 icon_height,346 icon_height,
333 } = &node.kind347 } = &node.kind
...@@ -357,7 +371,10 @@ impl Attachment {...@@ -357,7 +371,10 @@ impl Attachment {
357 preview: payload(preview)?,371 preview: payload(preview)?,
358 recording: recording_id.map(|id| Recording {372 recording: recording_id.map(|id| Recording {
359 id,373 id,
360 kind: recording_type.unwrap_or(0),374 kind: recording_type
375 .or_else(|| Recording::kind_of(filename.as_deref().unwrap_or_default()))
376 .unwrap_or(1),
377 duration_ms: *recording_duration,
361 }),378 }),
362 })379 })
363 }380 }
...@@ -1369,6 +1386,7 @@ mod tests {...@@ -1369,6 +1386,7 @@ mod tests {
1369 margin_origin_x: Some(36.0),1386 margin_origin_x: Some(36.0),
1370 margin_origin_y: Some(12.0),1387 margin_origin_y: Some(12.0),
1371 rtl: None,1388 rtl: None,
1389 recordings: Vec::new(),
1372 });1390 });
1373 page.children.push(id(2));1391 page.children.push(id(2));
1374 let mut outline = element(Kind::Outline {1392 let mut outline = element(Kind::Outline {
...@@ -1621,6 +1639,8 @@ mod tests {...@@ -1621,6 +1639,8 @@ mod tests {
1621 source.nodes.get_mut(&id(4)).unwrap().tags.push(Tag {1639 source.nodes.get_mut(&id(4)).unwrap().tags.push(Tag {
1622 definition: Some(id(7)),1640 definition: Some(id(7)),
1623 action_type: None,1641 action_type: None,
1642 shape: None,
1643 property_status: None,
1624 status: 0,1644 status: 0,
1625 created: Some(123),1645 created: Some(123),
1626 completed: None,1646 completed: None,
crates/onestore/tests/page_ink.rs+371
...@@ -88,6 +88,7 @@ fn stroke(points: &[[f32; 2]], color: Option<u32>) -> InkStroke {...@@ -88,6 +88,7 @@ fn stroke(points: &[[f32; 2]], color: Option<u32>) -> InkStroke {
88 color,88 color,
89 transparency: None,89 transparency: None,
90 pen_tip: None,90 pen_tip: None,
91 raster_operation: None,
91 }92 }
92}93}
9394
...@@ -109,6 +110,7 @@ fn drawing() -> Ink {...@@ -109,6 +110,7 @@ fn drawing() -> Ink {
109 stroke(&[[300.0, 120.0], [360.0, 180.0]], Some(0x0000ff)),110 stroke(&[[300.0, 120.0], [360.0, 180.0]], Some(0x0000ff)),
110 ],111 ],
111 groups: Vec::new(),112 groups: Vec::new(),
113 shape: None,
112 }114 }
113}115}
114116
...@@ -232,3 +234,372 @@ fn stored_strokes_keep_their_paths() {...@@ -232,3 +234,372 @@ fn stored_strokes_keep_their_paths() {
232 }234 }
233 assert!(ops::saved(NATIVE, space, &moved).is_err());235 assert!(ops::saved(NATIVE, space, &moved).is_err());
234}236}
237
238/// OneNote 2010's Draw tab used with the mouse (`corpus/ink-tools/native-ui`): every favourite
239/// pen and highlighter, shapes, and ink erased, moved and deleted.
240const TOOLS: &[u8] = include_bytes!("../../../corpus/ink-tools/native-ui/notebook/ink.one");
241
242fn titled(bytes: &[u8], title: &str) -> (ExGuid, Page) {
243 let store = Store::parse(bytes).unwrap();
244 let index = RevisionIndex::parse(&store).unwrap();
245 let document = Document::parse(&index).unwrap();
246 document
247 .pages()
248 .unwrap()
249 .into_iter()
250 .map(|(space, _)| (space, Page::from_space(&document, space).unwrap()))
251 .find(|(_, page)| page.title == title)
252 .unwrap()
253}
254
255fn himetric(value: f32) -> f32 {
256 value * 72.0 / 2540.0
257}
258
259use onestore::page::ink::{InkShape, ShapeKind};
260
261#[test]
262fn native_pens_highlighters_and_shapes_read_as_drawn() {
263 let (_, gallery) = titled(TOOLS, "Gallery");
264 let pens: Vec<_> = drawings(&gallery)
265 .iter()
266 .map(|ink| {
267 let [stroke] = ink.strokes.as_slice() else {
268 panic!("OneNote keeps each stroke a drawing of its own")
269 };
270 (
271 (stroke.width / himetric(1.0)).round(),
272 (stroke.height / himetric(1.0)).round(),
273 stroke.color,
274 stroke.raster_operation,
275 )
276 })
277 .collect();
278 let (red, blue, green, grey) = (0x241ced, 0xbb6531, 0x367d17, 0x808080);
279 let pen = |width, color| (width, width, color, None);
280 let marker = |color| (70.0, 400.0, Some(color), Some(9));
281 assert_eq!(
282 pens,
283 [
284 pen(35.0, None),
285 pen(35.0, Some(red)),
286 pen(35.0, Some(blue)),
287 pen(35.0, Some(green)),
288 pen(35.0, Some(grey)),
289 marker(0x00ffff),
290 marker(0xffff00),
291 pen(50.0, None),
292 pen(50.0, Some(red)),
293 pen(50.0, Some(blue)),
294 pen(50.0, Some(green)),
295 pen(50.0, Some(grey)),
296 marker(0x00ff00),
297 marker(0xff00ff),
298 ]
299 );
300 let highlighter = &drawings(&gallery)[5].strokes[0];
301 assert_eq!(
302 (highlighter.transparency, highlighter.pen_tip),
303 (Some(127), Some(1))
304 );
305
306 let (_, pens) = titled(TOOLS, "Pens");
307 let shapes: Vec<&Ink> = drawings(&pens)
308 .into_iter()
309 .filter(|ink| ink.shape.is_some())
310 .collect();
311 let [rectangle, line, arrow, ellipse] = shapes.as_slice() else {
312 panic!("{}", shapes.len())
313 };
314 let pen = InkStroke {
315 points: Vec::new(),
316 ..rectangle.strokes[0].clone()
317 };
318 assert!((pen.width - himetric(50.0)).abs() < 1e-5);
319 let close_points = |a: &[[f32; 2]], b: &[[f32; 2]]| {
320 a.len() == b.len()
321 && a.iter()
322 .zip(b)
323 .all(|(a, b)| (a[0] - b[0]).abs() < 0.05 && (a[1] - b[1]).abs() < 0.05)
324 };
325 for (native, kind, from, to) in [
326 (rectangle, ShapeKind::Rectangle, [252.0, 86.4], [324.0, 122.4]),
327 (line, ShapeKind::Line, [342.0, 86.4], [414.0, 122.4]),
328 (arrow, ShapeKind::Arrow, [252.0, 158.4], [324.0, 194.4]),
329 (ellipse, ShapeKind::Ellipse, [342.0, 158.4], [414.0, 194.4]),
330 ] {
331 let drawn = Ink::drawn(kind, from, to, &pen).unwrap();
332 assert_eq!(drawn.strokes.len(), native.strokes.len(), "{kind:?}");
333 for (drawn, native) in drawn.strokes.iter().zip(&native.strokes) {
334 assert!(
335 close_points(&drawn.points, &native.points),
336 "{kind:?}: {:?} against {:?}",
337 drawn.points,
338 native.points
339 );
340 }
341 match (drawn.shape.unwrap(), native.shape.clone().unwrap()) {
342 (InkShape::Line(a), InkShape::Line(b)) => assert!(close_points(&a, &b)),
343 (
344 InkShape::Closed { transform, anchors },
345 InkShape::Closed {
346 transform: native_transform,
347 anchors: native_anchors,
348 },
349 ) => {
350 assert_eq!(anchors, native_anchors);
351 assert!(
352 transform
353 .iter()
354 .zip(native_transform)
355 .all(|(a, b)| (a - b).abs() < 0.05)
356 );
357 }
358 shapes => panic!("{shapes:?}"),
359 }
360 }
361
362 // Erased and deleted drawings leave the page; a moved one keeps its strokes and takes
363 // an offset.
364 let (_, edits) = titled(TOOLS, "Edits");
365 let moved: Vec<_> = drawings(&edits)
366 .into_iter()
367 .filter(|ink| ink.shape.is_none())
368 .map(|ink| (ink.layout.x, ink.layout.y))
369 .collect();
370 assert_eq!(moved.len(), 2);
371 assert!(moved.iter().any(|&(x, y)| x == Some(45.0)
372 && y.is_some_and(|y| (y - 15.0).abs() < 1e-4)));
373}
374
375fn tool_page(source: &[u8]) -> (ExGuid, Page, Vec<Ink>) {
376 let (space, page) = first_page(source);
377 let accent = InkStroke {
378 id: new_id().unwrap(),
379 points: Vec::new(),
380 width: snap(himetric(35.0)),
381 height: snap(himetric(35.0)),
382 color: Some(0x7a9a1f),
383 transparency: None,
384 pen_tip: None,
385 raster_operation: None,
386 };
387 let marker = InkStroke {
388 width: himetric(70.0),
389 height: himetric(400.0),
390 color: Some(0x00ffff),
391 transparency: Some(127),
392 pen_tip: Some(1),
393 raster_operation: Some(9),
394 ..accent.clone()
395 };
396 let shape_pen = InkStroke {
397 width: himetric(50.0),
398 height: himetric(50.0),
399 ..accent.clone()
400 };
401 let free = |pen: &InkStroke, points: Vec<[f32; 2]>| Ink {
402 id: new_id().unwrap(),
403 layout: Default::default(),
404 strokes: vec![InkStroke {
405 id: new_id().unwrap(),
406 points: points.into_iter().map(|p| p.map(snap)).collect(),
407 ..pen.clone()
408 }],
409 groups: Vec::new(),
410 shape: None,
411 };
412 let wave: Vec<[f32; 2]> = (0..=40)
413 .map(|step| {
414 let x = 60.0 + step as f32 * 4.0;
415 [x, 150.0 + 12.0 * (x / 18.0).sin()]
416 })
417 .collect();
418 let inks = vec![
419 free(&marker, vec![[60.0, 190.0], [220.0, 190.0]]),
420 free(&accent, wave),
421 Ink::drawn(ShapeKind::Rectangle, [252.0, 140.4], [324.0, 194.4], &shape_pen).unwrap(),
422 Ink::drawn(ShapeKind::Ellipse, [342.0, 140.4], [432.0, 194.4], &shape_pen).unwrap(),
423 Ink::drawn(ShapeKind::Arrow, [252.0, 230.4], [360.0, 266.4], &shape_pen).unwrap(),
424 Ink::drawn(ShapeKind::Line, [378.0, 230.4], [450.0, 230.4], &shape_pen).unwrap(),
425 ];
426 (space, page, inks)
427}
428
429fn added(source: &[u8], space: ExGuid, inks: &[Ink]) -> Vec<u8> {
430 let mut bytes = source.to_vec();
431 for ink in inks {
432 bytes = ops::page_edited(
433 &bytes,
434 space,
435 vec![onestore::op::PageOp::Add {
436 object: PageObject::Ink(ink.clone()),
437 before: None,
438 }],
439 )
440 .unwrap();
441 }
442 bytes
443}
444
445/// `ONESTORE_INK_TOOLS_EXPORT` names a new directory receiving the candidate for a cold
446/// reopen.
447#[test]
448fn pens_highlighters_and_shapes_are_written_as_onenote_draws_them() {
449 use onestore::document::{FieldValue, Kind};
450 let source = onestore::create_section("ink.one", "Drawn in Snowbound", "Author").unwrap();
451 let (space, before, inks) = tool_page(&source);
452 // Each stroke is one edit, as the canvas stores it.
453 let written = added(&source, space, &inks);
454 let stored = page_in(&written, space);
455 let mut expected = before.clone();
456 expected
457 .objects
458 .extend(inks.iter().cloned().map(PageObject::Ink));
459 expected.title = stored.title.clone();
460 assert_eq!(stored, expected);
461 export("ONESTORE_INK_TOOLS_EXPORT", "ink.one", &written);
462
463 let store = Store::parse(&written).unwrap();
464 let index = RevisionIndex::parse(&store).unwrap();
465 let document = Document::parse(&index).unwrap();
466 let revision = document.active(space).unwrap();
467 let node = |id: ExGuid| &revision.nodes[&id];
468 let extra = |id: ExGuid, property: u32| {
469 node(id)
470 .extra
471 .iter()
472 .flatten()
473 .find(|field| field.id == property)
474 .map(|field| match field.value {
475 FieldValue::Bytes(bytes) => bytes.to_vec(),
476 _ => Vec::new(),
477 })
478 };
479 let style = |stroke: ExGuid| match node(stroke).kind {
480 Kind::InkStroke {
481 style: Some(style),
482 bias,
483 index,
484 ..
485 } => (node(style).kind.clone(), bias, index),
486 _ => panic!(),
487 };
488 let [marker, wave, rectangle, _, arrow, _] = inks.as_slice() else {
489 unreachable!()
490 };
491 match style(marker.strokes[0].id) {
492 (
493 Kind::InkStyle {
494 raster_operation: Some(9),
495 pen_tip: Some(1),
496 transparency: Some(127),
497 ignore_pressure: Some(true),
498 ..
499 },
500 Some(2),
501 Some(1),
502 ) => {}
503 other => panic!("{other:?}"),
504 }
505 match style(wave.strokes[0].id) {
506 (
507 Kind::InkStyle {
508 raster_operation: None,
509 ignore_pressure: Some(true),
510 color: Some(0x7a9a1f),
511 ..
512 },
513 Some(0),
514 Some(2),
515 ) => {}
516 other => panic!("{other:?}"),
517 }
518 for stroke in &arrow.strokes {
519 match style(stroke.id) {
520 (
521 Kind::InkStyle {
522 ignore_pressure: None,
523 ..
524 },
525 None,
526 None,
527 ) => {}
528 other => panic!("{other:?}"),
529 }
530 }
531 assert_eq!(extra(wave.id, 0x14001d4e), Some(1u32.to_le_bytes().to_vec()));
532 assert_eq!(extra(rectangle.id, 0x14001d4e), Some(2u32.to_le_bytes().to_vec()));
533 match &node(rectangle.id).kind {
534 Kind::Ink {
535 shape_kind: Some(12),
536 anchors: Some(anchors),
537 ..
538 } => assert_eq!(anchors.len(), 24 + 8 * 8),
539 other => panic!("{other:?}"),
540 }
541}
542
543/// `ONESTORE_INK_TOOLS_EDIT_EXPORT` names a new directory receiving the candidate for a cold
544/// reopen: OneNote's own drawings after Snowbound erases one, moves another and draws more.
545#[test]
546fn onenote_drawings_take_erasing_moving_and_more_strokes() {
547 use onestore::{OutlineEdit, op::PageOp};
548 let (space, page) = titled(TOOLS, "Pens");
549 let native = drawings(&page);
550 let zigzag = native
551 .iter()
552 .find(|ink| ink.strokes[0].color == Some(0x241ced))
553 .unwrap()
554 .id;
555 let line = native[1].id;
556 let edited = ops::page_edited(
557 TOOLS,
558 space,
559 vec![
560 PageOp::Delete { object: zigzag },
561 PageOp::Outline {
562 object: line,
563 edit: OutlineEdit::Position { x: 36.0, y: 18.0 },
564 },
565 ],
566 )
567 .unwrap();
568 let (_, _, inks) = tool_page(&onestore::create_section("x.one", "x", "x").unwrap());
569 let moved: Vec<Ink> = inks
570 .into_iter()
571 .map(|mut ink| {
572 for stroke in &mut ink.strokes {
573 for point in &mut stroke.points {
574 point[1] = snap(point[1] + 200.0);
575 }
576 }
577 if let Some(shape) = &mut ink.shape {
578 *shape = match shape {
579 InkShape::Line([a, b]) => InkShape::Line([[a[0], a[1] + 200.0], [b[0], b[1] + 200.0]]),
580 InkShape::Closed { transform, anchors } => InkShape::Closed {
581 transform: {
582 let mut t = *transform;
583 t[5] += 200.0;
584 t
585 },
586 anchors: anchors.clone(),
587 },
588 }
589 .snapped();
590 }
591 ink
592 })
593 .collect();
594 let written = added(&edited, space, &moved);
595 let stored = page_in(&written, space);
596 let found = drawings(&stored);
597 assert!(!found.iter().any(|ink| ink.id == zigzag));
598 let line = found.iter().find(|ink| ink.id == line).unwrap();
599 assert_eq!((line.layout.x, line.layout.y), (Some(36.0), Some(18.0)));
600 assert_eq!(line.strokes, native[1].strokes);
601 for ink in &moved {
602 assert_eq!(found.iter().find(|found| found.id == ink.id), Some(&ink));
603 }
604 export("ONESTORE_INK_TOOLS_EDIT_EXPORT", "ink.one", &written);
605}
crates/onestore/tests/page_media.rs+23-12
...@@ -103,18 +103,29 @@ fn editing_a_recorded_page_keeps_the_recording_and_its_annotation() {...@@ -103,18 +103,29 @@ fn editing_a_recorded_page_keeps_the_recording_and_its_annotation() {
103 assert_eq!(recording(&stored), recording(&before));103 assert_eq!(recording(&stored), recording(&before));
104 assert_eq!(stored.objects.len(), before.objects.len());104 assert_eq!(stored.objects.len(), before.objects.len());
105105
106 let mut annotated = after.clone();106 // A note linked to the recording, then unlinked, as Snowbound links notes taken while
107 let outline = annotated107 // it records.
108 .objects108 let annotate = |page: &Page, media: MediaIndex| {
109 .iter_mut()109 let mut page = page.clone();
110 .find_map(|object| match object {110 for object in &mut page.objects {
111 PageObject::Outline(outline) if !outline.title => Some(outline),111 if let PageObject::Outline(outline) = object
112 _ => None,112 && !outline.title
113 })113 {
114 .unwrap();114 outline.paragraphs.last_mut().unwrap().media = media.clone();
115 let last = outline.paragraphs.last_mut().unwrap();115 }
116 last.media = recording(&before).1;116 }
117 assert!(ops::saved(written.as_slice(), space, &annotated).is_err());117 page
118 };
119 let linked = ops::saved(
120 written.as_slice(),
121 space,
122 &annotate(&stored, recording(&before).1),
123 )
124 .unwrap();
125 let (_, relinked) = page(linked.as_slice());
126 assert_eq!(relinked, annotate(&stored, recording(&before).1));
127 let unlinked = ops::saved(linked.as_slice(), space, &stored).unwrap();
128 assert_eq!(page(unlinked.as_slice()).1, stored);
118 let mut recaptured = after.clone();129 let mut recaptured = after.clone();
119 for object in &mut recaptured.objects {130 for object in &mut recaptured.objects {
120 if let PageObject::Outline(outline) = object {131 if let PageObject::Outline(outline) = object {
crates/onestore/tests/page_tags.rs+48
...@@ -113,6 +113,8 @@ fn tag(definition: ExGuid, completed: bool) -> Tag {...@@ -113,6 +113,8 @@ fn tag(definition: ExGuid, completed: bool) -> Tag {
113 Tag {113 Tag {
114 definition: Some(definition),114 definition: Some(definition),
115 action_type: None,115 action_type: None,
116 shape: None,
117 property_status: None,
116 status: u16::from(completed),118 status: u16::from(completed),
117 created: Some(1_262_401_445),119 created: Some(1_262_401_445),
118 completed: completed.then_some(1_262_405_106),120 completed: completed.then_some(1_262_405_106),
...@@ -305,3 +307,49 @@ fn new_definitions_and_tags_publish_on_a_fresh_page() {...@@ -305,3 +307,49 @@ fn new_definitions_and_tags_publish_on_a_fresh_page() {
305 .unwrap();307 .unwrap();
306 }308 }
307}309}
310
311/// An Outlook task tag names no definition: rewritten beside a normal tag it keeps its icon,
312/// property status and dates, and one missing its icon is refused.
313#[test]
314fn a_task_tag_rewrites_without_a_definition() {
315 let features = include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one");
316 let (space, before) = page_by_title(features, "Task controls");
317 let at = tagged(&before);
318 let task = body_paragraphs(&mut before.clone())[at]
319 .text()
320 .unwrap()
321 .tags[0]
322 .clone();
323 assert_eq!(
324 (
325 task.definition,
326 task.shape,
327 task.property_status,
328 task.status
329 ),
330 (None, Some(89), Some(0), 6)
331 );
332 let mut after = before.clone();
333 let id = new_id().unwrap();
334 after.definitions.insert(id, definition("To Do", 3));
335 body_paragraphs(&mut after)[at]
336 .text_mut()
337 .unwrap()
338 .tags
339 .insert(0, tag(id, false));
340 let written = ops::saved(features, space, &after).unwrap();
341 let stored = assert_same(written.as_slice(), space, &after);
342 let mut untagged = stored.clone();
343 body_paragraphs(&mut untagged)[at]
344 .text_mut()
345 .unwrap()
346 .tags
347 .remove(0);
348 untagged.definitions.remove(&id);
349 let again = ops::saved(&written, space, &untagged).unwrap();
350 assert_same(again.as_slice(), space, &before);
351 let mut shapeless = before.clone();
352 body_paragraphs(&mut shapeless)[at].text_mut().unwrap().tags[0].shape = None;
353 body_paragraphs(&mut shapeless)[at].text_mut().unwrap().tags[0].created = Some(1);
354 assert!(ops::saved(features, space, &shapeless).is_err());
355}
crates/onestore/tests/support/page_edits.rs+2
...@@ -256,6 +256,8 @@ fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) {...@@ -256,6 +256,8 @@ fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) {
256 text.tags.push(onestore::document::Tag {256 text.tags.push(onestore::document::Tag {
257 definition: Some(definition),257 definition: Some(definition),
258 action_type: None,258 action_type: None,
259 shape: None,
260 property_status: None,
259 status: u16::from(bytes.next().unwrap_or(0) % 2),261 status: u16::from(bytes.next().unwrap_or(0) % 2),
260 created: Some(1_262_401_445),262 created: Some(1_262_401_445),
261 completed: None,263 completed: None,
crates/snowbound/src/library.rs+52-16
...@@ -33,10 +33,6 @@ pub fn on_background(notify: impl Fn() + Send + Sync + 'static) {...@@ -33,10 +33,6 @@ pub fn on_background(notify: impl Fn() + Send + Sync + 'static) {
33 let _ = NOTIFY.set(Box::new(notify));33 let _ = NOTIFY.set(Box::new(notify));
34}34}
3535
36/// How often the sections no tab shows are polled; OneNote 2010 read a closed section
37/// fifteen seconds after another client changed it.
38const BACKGROUND: Duration = Duration::from_secs(15);
39
40fn notify_background() {36fn notify_background() {
41 if let Some(notify) = NOTIFY.get() {37 if let Some(notify) = NOTIFY.get() {
42 notify();38 notify();
...@@ -171,6 +167,8 @@ pub struct Library {...@@ -171,6 +167,8 @@ pub struct Library {
171 /// Syncs the notebook's sections while no tab shows them, as OneNote syncs every167 /// Syncs the notebook's sections while no tab shows them, as OneNote syncs every
172 /// section of an open notebook.168 /// section of an open notebook.
173 pub background: Option<Arc<Background>>,169 pub background: Option<Arc<Background>>,
170 /// Reports the changes to a folder on this computer to `background`.
171 watch: Option<Arc<crate::watch::Watch>>,
174}172}
175173
176impl Library {174impl Library {
...@@ -192,10 +190,15 @@ impl Library {...@@ -192,10 +190,15 @@ impl Library {
192 }190 }
193 }191 }
194 let notebook = Notebook::open(location, cache);192 let notebook = Notebook::open(location, cache);
193 let (background, watch) = match &notebook {
194 Ok(notebook) => local_background(notebook, location),
195 Err(_) => (None, None),
196 };
195 Self {197 Self {
196 location: location.to_owned(),198 location: location.to_owned(),
197 name: file_name(Path::new(location)),199 name: file_name(Path::new(location)),
198 background: notebook.as_ref().ok().and_then(local_background),200 background,
201 watch,
199 notebook: notebook.map(Some).map_err(|error| error.to_string()),202 notebook: notebook.map(Some).map_err(|error| error.to_string()),
200 server: None,203 server: None,
201 notice,204 notice,
...@@ -219,7 +222,6 @@ impl Library {...@@ -219,7 +222,6 @@ impl Library {
219 let background = Background::smb(222 let background = Background::smb(
220 &server.mount.root,223 &server.mount.root,
221 LIMIT,224 LIMIT,
222 BACKGROUND,
223 move || connect.connect(),225 move || connect.connect(),
224 notify_background,226 notify_background,
225 )227 )
...@@ -234,6 +236,7 @@ impl Library {...@@ -234,6 +236,7 @@ impl Library {
234 notice: None,236 notice: None,
235 cache: cache.to_owned(),237 cache: cache.to_owned(),
236 background: Some(Arc::new(background)),238 background: Some(Arc::new(background)),
239 watch: None,
237 })240 })
238 }241 }
239242
...@@ -260,15 +263,18 @@ impl Library {...@@ -260,15 +263,18 @@ impl Library {
260 notice: self.notice.clone(),263 notice: self.notice.clone(),
261 cache: self.cache.clone(),264 cache: self.cache.clone(),
262 background: self.background.clone(),265 background: self.background.clone(),
266 watch: self.watch.clone(),
263 }267 }
264 }268 }
265269
266 /// A notebook `Notebook::create` just made in the folder at `location`.270 /// A notebook `Notebook::create` just made in the folder at `location`.
267 pub fn created(location: &str, notebook: Notebook, cache: &Path) -> Self {271 pub fn created(location: &str, notebook: Notebook, cache: &Path) -> Self {
272 let (background, watch) = local_background(&notebook, location);
268 Self {273 Self {
269 location: location.to_owned(),274 location: location.to_owned(),
270 name: file_name(Path::new(location)),275 name: file_name(Path::new(location)),
271 background: local_background(&notebook),276 background,
277 watch,
272 notebook: Ok(Some(notebook)),278 notebook: Ok(Some(notebook)),
273 server: None,279 server: None,
274 notice: None,280 notice: None,
...@@ -286,6 +292,7 @@ impl Library {...@@ -286,6 +292,7 @@ impl Library {
286 notice: None,292 notice: None,
287 cache: cache.to_owned(),293 cache: cache.to_owned(),
288 background: None,294 background: None,
295 watch: None,
289 }296 }
290 }297 }
291298
...@@ -296,7 +303,8 @@ impl Library {...@@ -296,7 +303,8 @@ impl Library {
296 path: &str,303 path: &str,
297 notify: impl Fn() + Send + 'static,304 notify: impl Fn() + Send + 'static,
298 ) -> Result<Section, Box<dyn Error>> {305 ) -> Result<Section, Box<dyn Error>> {
299 // The background may hold the replica for a step, which takes a network round trip.306 // The background may hold the replica for a step, which takes a network round trip,
307 // or be making it as an offline copy.
300 let deadline = Instant::now() + TIMEOUT * 3;308 let deadline = Instant::now() + TIMEOUT * 3;
301 let notify = Arc::new(Mutex::new(notify));309 let notify = Arc::new(Mutex::new(notify));
302 let notifier = || {310 let notifier = || {
...@@ -335,7 +343,12 @@ impl Library {...@@ -335,7 +343,12 @@ impl Library {
335 (Err(error), _) => return Err(error.clone().into()),343 (Err(error), _) => return Err(error.clone().into()),
336 };344 };
337 match opened {345 match opened {
338 Err(error) if error.busy() && Instant::now() < deadline => {346 Err(error)
347 if (error.busy()
348 || matches!(&error, notebook::Error::Io(error)
349 if error.kind() == io::ErrorKind::AlreadyExists))
350 && Instant::now() < deadline =>
351 {
339 std::thread::sleep(Duration::from_millis(50));352 std::thread::sleep(Duration::from_millis(50));
340 }353 }
341 opened => break opened?,354 opened => break opened?,
...@@ -446,15 +459,37 @@ pub fn recycle_bin(path: &str) -> bool {...@@ -446,15 +459,37 @@ pub fn recycle_bin(path: &str) -> bool {
446 path.rsplit('/').next() == Some("OneNote_RecycleBin")459 path.rsplit('/').next() == Some("OneNote_RecycleBin")
447}460}
448461
449/// A mounted notebook's background sync, following Work Offline.462/// A mounted notebook's background sync, following Work Offline, and the watch that reports
450fn local_background(notebook: &Notebook) -> Option<Arc<Background>> {463/// the folder's changes to it. Only a folder on this computer reports every change; one on a
451 let background = notebook464/// network volume is checked more often and kept in offline copies, as on a share.
452 .background(BACKGROUND, notify_background)465fn local_background(
466 notebook: &Notebook,
467 location: &str,
468) -> (Option<Arc<Background>>, Option<Arc<crate::watch::Watch>>) {
469 let reports = Arc::new(OnceLock::<std::sync::Weak<Background>>::new());
470 let report = Arc::clone(&reports);
471 let watch = crate::watch::watch(Path::new(location), move |paths| {
472 if let Some(background) = report.get().and_then(std::sync::Weak::upgrade) {
473 background.touched(&paths);
474 }
475 });
476 let remote = !crate::watch::on_this_computer(Path::new(location));
477 let interval = match watch {
478 Some(_) if !remote => Background::BACKSTOP,
479 _ => Background::UNWATCHED,
480 };
481 let Some(background) = notebook
482 .background(interval, remote, notify_background)
453 .inspect_err(|error| eprintln!("Background sync did not start: {error}"))483 .inspect_err(|error| eprintln!("Background sync did not start: {error}"))
454 .ok()?;484 .ok()
485 else {
486 return (None, None);
487 };
455 background.set_offline(offline());488 background.set_offline(offline());
456 background.watch(notebook.replicas());489 background.watch(notebook.replicas());
457 Some(Arc::new(background))490 let background = Arc::new(background);
491 let _ = reports.set(Arc::downgrade(&background));
492 (Some(background), watch.map(Arc::new))
458}493}
459494
460pub fn file_name(path: &Path) -> String {495pub fn file_name(path: &Path) -> String {
...@@ -483,7 +518,7 @@ fn tabs(catalog: &Folder) -> Vec<Tab> {...@@ -483,7 +518,7 @@ fn tabs(catalog: &Folder) -> Vec<Tab> {
483 .sections518 .sections
484 .iter()519 .iter()
485 .filter_map(|section| match &section.state {520 .filter_map(|section| match &section.state {
486 SectionState::Readable { name, color } => Some(Tab {521 SectionState::Readable { name, color, .. } => Some(Tab {
487 name: section_name(&section.path, name),522 name: section_name(&section.path, name),
488 path: section.path.clone(),523 path: section.path.clone(),
489 color: *color,524 color: *color,
...@@ -668,6 +703,7 @@ mod tests {...@@ -668,6 +703,7 @@ mod tests {
668 notice: None,703 notice: None,
669 cache: PathBuf::new(),704 cache: PathBuf::new(),
670 background: None,705 background: None,
706 watch: None,
671 };707 };
672 let shown = |root, file| library(root).local(Path::new(file));708 let shown = |root, file| library(root).local(Path::new(file));
673 let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one"));709 let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one"));
crates/snowbound/src/linux.rs+2
...@@ -25,6 +25,8 @@ use winit::{...@@ -25,6 +25,8 @@ use winit::{
25const APP_ID: &str = "snowbound";25const APP_ID: &str = "snowbound";
26/// The title bar's leading margin; the window controls sit at its trailing end.26/// The title bar's leading margin; the window controls sit at its trailing end.
27pub const LEADING: f32 = 8.0;27pub const LEADING: f32 = 8.0;
28/// The margin past the window controls.
29pub const TRAILING: f32 = 8.0;
28/// How far inside the window's edges a press resizes it, and how far along them a corner30/// How far inside the window's edges a press resizes it, and how far along them a corner
29/// reaches, in logical pixels.31/// reaches, in logical pixels.
30const EDGE: f32 = 5.0;32const EDGE: f32 = 5.0;
crates/snowbound/src/macos.rs+2
...@@ -86,6 +86,8 @@ fn ns_window(window: &Window) -> Retained<AnyObject> {...@@ -86,6 +86,8 @@ fn ns_window(window: &Window) -> Retained<AnyObject> {
8686
87/// Room the traffic lights take at the title bar's leading edge.87/// Room the traffic lights take at the title bar's leading edge.
88pub const LEADING: f32 = 78.0;88pub const LEADING: f32 = 78.0;
89/// The title bar's trailing margin, the gap the traffic lights leave before the toolbar.
90pub const TRAILING: f32 = 12.0;
89/// How far AppKit rounds a window's corners.91/// How far AppKit rounds a window's corners.
90pub const CORNER_RADIUS: f32 = 10.0;92pub const CORNER_RADIUS: f32 = 10.0;
9193
crates/snowbound/src/main.rs+36-1
...@@ -31,6 +31,7 @@ mod surface;...@@ -31,6 +31,7 @@ mod surface;
31mod sync;31mod sync;
32mod tags;32mod tags;
33mod templates;33mod templates;
34mod watch;
3435
35use canvas::gpu::page::PageScene;36use canvas::gpu::page::PageScene;
36use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility};37use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility};
...@@ -1200,6 +1201,13 @@ impl State {...@@ -1200,6 +1201,13 @@ impl State {
1200 if self.session.is_some() {1201 if self.session.is_some() {
1201 self.page_tools(&theme);1202 self.page_tools(&theme);
1202 }1203 }
1204 self.ui.leaf(
1205 "trail",
1206 Spec {
1207 size: [px(self.trailing() - FRAME), px(1.0)],
1208 ..Spec::default()
1209 },
1210 );
1203 self.ui.close();1211 self.ui.close();
12041212
1205 self.ui.open_as(1213 self.ui.open_as(
...@@ -1268,6 +1276,23 @@ impl State {...@@ -1268,6 +1276,23 @@ impl State {
1268 }1276 }
1269 }1277 }
1270 self.ui.close();1278 self.ui.close();
1279 if let Some(task) = self.view.task_under_pointer() {
1280 let [left, top, ..] = self.ui.rect(page()).unwrap_or_default();
1281 let scale = self.ui.scale();
1282 let [x0, y0, x1, y1] = task;
1283 let part = [
1284 left + x0 / scale,
1285 top + y0 / scale,
1286 left + x1 / scale,
1287 top + y1 / scale,
1288 ];
1289 ui::popup::tooltip_over(
1290 &mut self.ui,
1291 part,
1292 "Outlook task",
1293 Some("Edit it in OneNote with Outlook"),
1294 );
1295 }
1271 let signal = self.ui.signal(page());1296 let signal = self.ui.signal(page());
1272 let focused = self.ui.window_focused && self.ui.focused() == Some(page());1297 let focused = self.ui.window_focused && self.ui.focused() == Some(page());
1273 if focused != self.page_focused {1298 if focused != self.page_focused {
...@@ -1411,6 +1436,16 @@ impl State {...@@ -1411,6 +1436,16 @@ impl State {
1411 );1436 );
1412 }1437 }
14131438
1439 /// The toolbar and tab row's margin at the window's trailing side, which matches the
1440 /// leading gap where the row is the title bar.
1441 fn trailing(&self) -> f32 {
1442 if platform::system_titlebar(&self.window) {
1443 FRAME
1444 } else {
1445 platform::TRAILING
1446 }
1447 }
1448
1414 /// Whether the chrome's empty space drags the window: where it is the title bar, or1449 /// Whether the chrome's empty space drags the window: where it is the title bar, or
1415 /// over the title's gradient.1450 /// over the title's gradient.
1416 fn chrome_drags(&self) -> bool {1451 fn chrome_drags(&self) -> bool {
...@@ -1468,7 +1503,7 @@ impl State {...@@ -1468,7 +1503,7 @@ impl State {
1468 self.ui.leaf(1503 self.ui.leaf(
1469 "trail",1504 "trail",
1470 Spec {1505 Spec {
1471 size: [px(FRAME - GAP), px(1.0)],1506 size: [px(self.trailing() - GAP), px(1.0)],
1472 ..Spec::default()1507 ..Spec::default()
1473 },1508 },
1474 );1509 );
crates/snowbound/src/manage.rs+5-1
...@@ -189,9 +189,13 @@ impl State {...@@ -189,9 +189,13 @@ impl State {
189 Ok(())189 Ok(())
190 }190 }
191191
192 /// Closes `library`: its files stay, and the sidebar and the next launch leave it out.192 /// Closes `library`: its files stay, its offline copies go unless edits wait in them,
193 /// and the sidebar and the next launch leave it out.
193 pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) {194 pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) {
194 self.notebooks.retain(|open| !Arc::ptr_eq(open, library));195 self.notebooks.retain(|open| !Arc::ptr_eq(open, library));
196 if let Some(background) = &library.background {
197 background.discard();
198 }
195 let shown = |shown: &Arc<Library>| shown.location == library.location;199 let shown = |shown: &Arc<Library>| shown.location == library.location;
196 if self.sectionless.as_ref().is_some_and(shown)200 if self.sectionless.as_ref().is_some_and(shown)
197 || self201 || self
crates/snowbound/src/meeting.rs+2
...@@ -164,6 +164,8 @@ pub fn content(page: &Page) -> Result<Page, Box<dyn Error>> {...@@ -164,6 +164,8 @@ pub fn content(page: &Page) -> Result<Page, Box<dyn Error>> {
164 vec![Tag {164 vec![Tag {
165 definition: Some(to_do),165 definition: Some(to_do),
166 action_type: None,166 action_type: None,
167 shape: None,
168 property_status: None,
167 status: 0,169 status: 0,
168 created: Some(created),170 created: Some(created),
169 completed: Some(0),171 completed: Some(0),
crates/snowbound/src/sidebar.rs+1-1
...@@ -260,7 +260,7 @@ fn folder(...@@ -260,7 +260,7 @@ fn folder(
260 };260 };
261 for section in &folder.sections {261 for section in &folder.sections {
262 let (name, color, readable) = match &section.state {262 let (name, color, readable) = match &section.state {
263 SectionState::Readable { name, color } => (263 SectionState::Readable { name, color, .. } => (
264 crate::library::section_name(&section.path, name),264 crate::library::section_name(&section.path, name),
265 *color,265 *color,
266 true,266 true,
crates/snowbound/src/tags.rs+8-4
...@@ -103,13 +103,13 @@ fn label(ui: &mut Ui, part: &str, text: &str, tag: &NoteTag) {...@@ -103,13 +103,13 @@ fn label(ui: &mut Ui, part: &str, text: &str, tag: &NoteTag) {
103 );103 );
104}104}
105105
106fn icon(ui: &mut Ui, part: &str, art: Option<&'static [&'static str]>) {106fn icon(ui: &mut Ui, part: &str, art: Option<&'static [&'static str]>, tint: [f32; 4]) {
107 ui.leaf(107 ui.leaf(
108 part,108 part,
109 Spec {109 Spec {
110 size: [px(SIDE + 4.0), px(ROW)],110 size: [px(SIDE + 4.0), px(ROW)],
111 icon: art,111 icon: art,
112 color: Some([1.0; 4]),112 color: Some(tint),
113 center: true,113 center: true,
114 ..Spec::default()114 ..Spec::default()
115 },115 },
...@@ -324,7 +324,11 @@ impl State {...@@ -324,7 +324,11 @@ impl State {
324 ..Spec::default()324 ..Spec::default()
325 },325 },
326 );326 );
327 icon(ui, "icon", artwork(tag));327 // OneNote's list marks a tag without a symbol with its "None" symbol art.
328 match artwork(tag) {
329 Some(art) => icon(ui, "icon", Some(art), [1.0; 4]),
330 None => icon(ui, "icon", Some(crate::art::FONT_COLOR), theme.text),
331 }
328 let text = match commands::tag_chord(place) {332 let text = match commands::tag_chord(place) {
329 Some(chord) => format!("{} ({})", tag.label, chord.label(Platform::CURRENT)),333 Some(chord) => format!("{} ({})", tag.label, chord.label(Platform::CURRENT)),
330 None => tag.label.clone(),334 None => tag.label.clone(),
...@@ -585,7 +589,7 @@ fn tag_editor(...@@ -585,7 +589,7 @@ fn tag_editor(
585 ..Spec::default()589 ..Spec::default()
586 },590 },
587 );591 );
588 icon(ui, "icon", artwork(tag));592 icon(ui, "icon", artwork(tag), [1.0; 4]);
589 label(ui, "label", &tag.label, tag);593 label(ui, "label", &tag.label, tag);
590 ui.leaf(594 ui.leaf(
591 "end",595 "end",
crates/snowbound/src/watch.rs created+441
...@@ -0,0 +1,441 @@
1//! Changes below a notebook folder on this computer, as the system reports them: FSEvents on
2//! macOS, inotify on Linux. A notebook on a share learns of its changes from the server
3//! instead (`notebook::session::Background::smb`).
4
5use std::path::Path;
6
7/// Reports until dropped.
8pub struct Watch {
9 _stream: platform::Stream,
10}
11
12/// Watches the folder at `root`: `changed` hears the paths that changed below it, relative to
13/// it and `/`-separated, or `""` when the system can only say something did. `None` where the
14/// system cannot watch it.
15pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static) -> Option<Watch> {
16 let root = root.canonicalize().ok()?;
17 let folder = root.clone();
18 let relative = move |paths: Vec<std::path::PathBuf>| {
19 changed(
20 paths
21 .iter()
22 .map(|path| match path.strip_prefix(&folder) {
23 Ok(path) => path.to_string_lossy().replace('\\', "/"),
24 Err(_) => String::new(),
25 })
26 .collect(),
27 )
28 };
29 platform::Stream::start(&root, Box::new(relative))
30 .inspect_err(|error| eprintln!("{}: changes go unwatched: {error}", root.display()))
31 .ok()
32 .map(|stream| Watch { _stream: stream })
33}
34
35/// Whether the folder at `path` is on this computer's own disks, where every change to it is
36/// one the system reports, rather than on a network volume.
37pub fn on_this_computer(path: &Path) -> bool {
38 use std::os::unix::ffi::OsStrExt;
39 let Ok(name) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
40 return true;
41 };
42 let mut volume: libc::statfs = unsafe { std::mem::zeroed() };
43 if unsafe { libc::statfs(name.as_ptr(), &mut volume) } != 0 {
44 return true;
45 }
46 #[cfg(target_os = "macos")]
47 return volume.f_flags & libc::MNT_LOCAL as u32 != 0;
48 // NFS, SMB, CIFS, SMB2, FUSE (sshfs and the like), 9P, Ceph, AFS.
49 #[cfg(target_os = "linux")]
50 return ![
51 0x6969,
52 0x517b,
53 0xff53_4d42,
54 0xfe53_4d42,
55 0x6573_5546,
56 0x0102_1997,
57 0x00c3_6400,
58 0x5346_414f,
59 ]
60 .contains(&(volume.f_type as u64));
61}
62
63type Changed = Box<dyn Fn(Vec<std::path::PathBuf>) + Send + Sync>;
64
65#[cfg(target_os = "macos")]
66mod platform {
67 use super::Changed;
68 use std::{
69 ffi::{CStr, c_char, c_void},
70 io,
71 os::unix::ffi::OsStrExt,
72 path::{Path, PathBuf},
73 };
74
75 type Stream_ = *mut c_void;
76
77 #[repr(C)]
78 struct Context {
79 version: isize,
80 info: *mut c_void,
81 retain: *const c_void,
82 release: Option<unsafe extern "C" fn(*const c_void)>,
83 description: *const c_void,
84 }
85
86 type Callback =
87 unsafe extern "C" fn(Stream_, *mut c_void, usize, *mut c_void, *const u32, *const u64);
88
89 #[link(name = "CoreServices", kind = "framework")]
90 unsafe extern "C" {
91 fn FSEventStreamCreate(
92 allocator: *const c_void,
93 callback: Callback,
94 context: *const Context,
95 paths: *const c_void,
96 since: u64,
97 latency: f64,
98 flags: u32,
99 ) -> Stream_;
100 fn FSEventStreamSetDispatchQueue(stream: Stream_, queue: *mut c_void);
101 fn FSEventStreamStart(stream: Stream_) -> u8;
102 fn FSEventStreamStop(stream: Stream_);
103 fn FSEventStreamInvalidate(stream: Stream_);
104 fn FSEventStreamRelease(stream: Stream_);
105 }
106
107 #[link(name = "CoreFoundation", kind = "framework")]
108 unsafe extern "C" {
109 static kCFTypeArrayCallBacks: c_void;
110 fn CFStringCreateWithBytes(
111 allocator: *const c_void,
112 bytes: *const u8,
113 length: isize,
114 encoding: u32,
115 external: u8,
116 ) -> *const c_void;
117 fn CFArrayCreate(
118 allocator: *const c_void,
119 values: *const *const c_void,
120 count: isize,
121 callbacks: *const c_void,
122 ) -> *const c_void;
123 fn CFRelease(object: *const c_void);
124 }
125
126 unsafe extern "C" {
127 fn dispatch_queue_create(label: *const c_char, attributes: *const c_void) -> *mut c_void;
128 fn dispatch_sync_f(
129 queue: *mut c_void,
130 context: *mut c_void,
131 work: extern "C" fn(*mut c_void),
132 );
133 fn dispatch_release(object: *mut c_void);
134 }
135
136 const SINCE_NOW: u64 = u64::MAX;
137 const NO_DEFER: u32 = 0x2;
138 const WATCH_ROOT: u32 = 0x4;
139 /// Mac OS X 10.7 and later; 10.6 reports the folders that changed.
140 const FILE_EVENTS: u32 = 0x10;
141 const UTF8: u32 = 0x0800_0100;
142
143 pub struct Stream {
144 stream: Stream_,
145 queue: *mut c_void,
146 }
147
148 // The stream and queue are only started and torn down, each call safe from any thread.
149 unsafe impl Send for Stream {}
150 unsafe impl Sync for Stream {}
151
152 unsafe extern "C" fn events(
153 _: Stream_,
154 info: *mut c_void,
155 count: usize,
156 paths: *mut c_void,
157 _: *const u32,
158 _: *const u64,
159 ) {
160 let changed = unsafe { &*(info as *const Changed) };
161 let paths = unsafe { std::slice::from_raw_parts(paths as *const *const c_char, count) };
162 changed(
163 paths
164 .iter()
165 .map(|&path| {
166 let path = unsafe { CStr::from_ptr(path) };
167 PathBuf::from(std::ffi::OsStr::from_bytes(path.to_bytes()))
168 })
169 .collect(),
170 );
171 }
172
173 unsafe extern "C" fn release(info: *const c_void) {
174 drop(unsafe { Box::from_raw(info as *mut Changed) });
175 }
176
177 extern "C" fn drained(_: *mut c_void) {}
178
179 /// Whether this is Mac OS X 10.7 or later (Darwin 11).
180 fn file_events() -> bool {
181 let mut name: libc::utsname = unsafe { std::mem::zeroed() };
182 (unsafe { libc::uname(&mut name) }) == 0
183 && unsafe { CStr::from_ptr(name.release.as_ptr()) }
184 .to_str()
185 .ok()
186 .and_then(|release| release.split('.').next()?.parse::<u32>().ok())
187 .is_some_and(|major| major >= 11)
188 }
189
190 impl Stream {
191 pub fn start(root: &Path, changed: Changed) -> io::Result<Self> {
192 let root = root.as_os_str().as_bytes();
193 let flags = NO_DEFER | WATCH_ROOT | if file_events() { FILE_EVENTS } else { 0 };
194 unsafe {
195 let path = CFStringCreateWithBytes(
196 std::ptr::null(),
197 root.as_ptr(),
198 root.len() as isize,
199 UTF8,
200 0,
201 );
202 let paths = CFArrayCreate(std::ptr::null(), &path, 1, &kCFTypeArrayCallBacks);
203 CFRelease(path);
204 let context = Context {
205 version: 0,
206 info: Box::into_raw(Box::new(changed)).cast(),
207 retain: std::ptr::null(),
208 release: Some(release),
209 description: std::ptr::null(),
210 };
211 let stream = FSEventStreamCreate(
212 std::ptr::null(),
213 events,
214 &context,
215 paths,
216 SINCE_NOW,
217 0.3,
218 flags,
219 );
220 CFRelease(paths);
221 if stream.is_null() {
222 release(context.info);
223 return Err(io::Error::other("FSEvents refused the folder"));
224 }
225 let queue = dispatch_queue_create(c"snowbound.watch".as_ptr(), std::ptr::null());
226 FSEventStreamSetDispatchQueue(stream, queue);
227 let stream = Self { stream, queue };
228 if FSEventStreamStart(stream.stream) == 0 {
229 return Err(io::Error::other("FSEvents did not start"));
230 }
231 Ok(stream)
232 }
233 }
234 }
235
236 impl Drop for Stream {
237 fn drop(&mut self) {
238 unsafe {
239 FSEventStreamStop(self.stream);
240 FSEventStreamInvalidate(self.stream);
241 // A callback already on the queue finishes before the closure goes.
242 dispatch_sync_f(self.queue, std::ptr::null_mut(), drained);
243 FSEventStreamRelease(self.stream);
244 dispatch_release(self.queue);
245 }
246 }
247 }
248}
249
250#[cfg(target_os = "linux")]
251mod platform {
252 use super::Changed;
253 use std::{
254 collections::HashMap,
255 ffi::CString,
256 io,
257 os::unix::ffi::OsStrExt,
258 path::{Path, PathBuf},
259 thread::JoinHandle,
260 };
261
262 const MASK: u32 = libc::IN_MODIFY
263 | libc::IN_CLOSE_WRITE
264 | libc::IN_ATTRIB
265 | libc::IN_CREATE
266 | libc::IN_DELETE
267 | libc::IN_MOVED_FROM
268 | libc::IN_MOVED_TO
269 | libc::IN_DELETE_SELF
270 | libc::IN_MOVE_SELF;
271
272 /// inotify watches one folder at a time, so every folder below the root has its own.
273 pub struct Stream {
274 inotify: i32,
275 stop: i32,
276 thread: Option<JoinHandle<()>>,
277 }
278
279 fn check(result: i32) -> io::Result<i32> {
280 if result < 0 {
281 Err(io::Error::last_os_error())
282 } else {
283 Ok(result)
284 }
285 }
286
287 fn add(inotify: i32, folder: &Path, folders: &mut HashMap<i32, PathBuf>) {
288 let Ok(name) = CString::new(folder.as_os_str().as_bytes()) else {
289 return;
290 };
291 if let Ok(watch) = check(unsafe { libc::inotify_add_watch(inotify, name.as_ptr(), MASK) }) {
292 folders.insert(watch, folder.to_owned());
293 }
294 for entry in std::fs::read_dir(folder).into_iter().flatten().flatten() {
295 if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
296 add(inotify, &entry.path(), folders);
297 }
298 }
299 }
300
301 impl Stream {
302 pub fn start(root: &Path, changed: Changed) -> io::Result<Self> {
303 let inotify = check(unsafe { libc::inotify_init1(libc::IN_CLOEXEC) })?;
304 let stop = match check(unsafe { libc::eventfd(0, libc::EFD_CLOEXEC) }) {
305 Ok(stop) => stop,
306 Err(error) => {
307 unsafe { libc::close(inotify) };
308 return Err(error);
309 }
310 };
311 let mut folders = HashMap::new();
312 add(inotify, root, &mut folders);
313 let root = root.to_owned();
314 let thread = std::thread::Builder::new()
315 .name("snowbound-watch".into())
316 .spawn(move || {
317 let mut buffer = vec![0u8; 64 * 1024];
318 loop {
319 let mut polled = [
320 libc::pollfd {
321 fd: inotify,
322 events: libc::POLLIN,
323 revents: 0,
324 },
325 libc::pollfd {
326 fd: stop,
327 events: libc::POLLIN,
328 revents: 0,
329 },
330 ];
331 if unsafe { libc::poll(polled.as_mut_ptr(), 2, -1) } < 0 {
332 if io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
333 continue;
334 }
335 return;
336 }
337 if polled[1].revents != 0 {
338 return;
339 }
340 let read = unsafe {
341 libc::read(inotify, buffer.as_mut_ptr().cast(), buffer.len())
342 };
343 let Ok(read) = usize::try_from(read) else {
344 return;
345 };
346 let mut paths = Vec::new();
347 let mut at = 0;
348 while at + std::mem::size_of::<libc::inotify_event>() <= read {
349 let event: libc::inotify_event =
350 unsafe { std::ptr::read_unaligned(buffer[at..].as_ptr().cast()) };
351 let start = at + std::mem::size_of::<libc::inotify_event>();
352 let end = (start + event.len as usize).min(read);
353 at = end;
354 if event.mask & libc::IN_Q_OVERFLOW != 0 {
355 paths.push(root.clone());
356 continue;
357 }
358 let Some(folder) = folders.get(&event.wd).cloned() else {
359 continue;
360 };
361 let name = &buffer[start..end];
362 let name =
363 &name[..name.iter().position(|&b| b == 0).unwrap_or(name.len())];
364 let path = if name.is_empty() {
365 folder
366 } else {
367 folder.join(std::ffi::OsStr::from_bytes(name))
368 };
369 if event.mask & libc::IN_ISDIR != 0
370 && event.mask & (libc::IN_CREATE | libc::IN_MOVED_TO) != 0
371 {
372 add(inotify, &path, &mut folders);
373 }
374 if event.mask & libc::IN_IGNORED != 0 {
375 folders.remove(&event.wd);
376 }
377 paths.push(path);
378 }
379 if !paths.is_empty() {
380 changed(paths);
381 }
382 }
383 })?;
384 Ok(Self {
385 inotify,
386 stop,
387 thread: Some(thread),
388 })
389 }
390 }
391
392 impl Drop for Stream {
393 fn drop(&mut self) {
394 unsafe { libc::write(self.stop, (&1u64 as *const u64).cast(), 8) };
395 if let Some(thread) = self.thread.take() {
396 let _ = thread.join();
397 }
398 unsafe {
399 libc::close(self.inotify);
400 libc::close(self.stop);
401 }
402 }
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use std::{sync::mpsc, time::Duration};
410
411 #[test]
412 fn a_folder_on_this_computer_reports_the_file_that_changed() {
413 let folder = std::env::temp_dir().join(format!("snowbound-watch-{}", std::process::id()));
414 let _ = std::fs::remove_dir_all(&folder);
415 std::fs::create_dir_all(folder.join("Group")).unwrap();
416 std::fs::write(folder.join("Group/Section.one"), b"before").unwrap();
417 assert!(on_this_computer(&folder));
418 let (sender, changes) = mpsc::channel();
419 let sender = std::sync::Mutex::new(sender);
420 let watching = watch(&folder, move |paths| {
421 let _ = sender.lock().unwrap().send(paths);
422 })
423 .unwrap();
424 // FSEvents starts reporting a moment after the stream starts.
425 std::thread::sleep(Duration::from_millis(500));
426 std::fs::write(folder.join("Group/Section.one"), b"after").unwrap();
427 let mut reported = Vec::new();
428 while let Ok(paths) = changes.recv_timeout(Duration::from_secs(5)) {
429 reported.extend(paths);
430 if reported.iter().any(|path| path == "Group/Section.one") {
431 break;
432 }
433 }
434 drop(watching);
435 std::fs::remove_dir_all(&folder).unwrap();
436 assert!(
437 reported.iter().any(|path| path == "Group/Section.one"),
438 "{reported:?}"
439 );
440 }
441}
crates/ui/src/popup.rs+17-1
...@@ -138,6 +138,22 @@ pub fn menu(...@@ -138,6 +138,22 @@ pub fn menu(
138/// delay, or at once while another has just shown. A press or the wheel hides it until the138/// delay, or at once while another has just shown. A press or the wheel hides it until the
139/// pointer leaves.139/// pointer leaves.
140pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) {140pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) {
141 tooltip_below(ui, None, title, keys, description);
142}
143
144/// A `tooltip` below `part`, a window rectangle within the box built last, as a custom
145/// box names what it draws.
146pub fn tooltip_over(ui: &mut Ui, part: [f32; 4], title: &str, description: Option<&str>) {
147 tooltip_below(ui, Some(part), title, "", description);
148}
149
150fn tooltip_below(
151 ui: &mut Ui,
152 part: Option<[f32; 4]>,
153 title: &str,
154 keys: &str,
155 description: Option<&str>,
156) {
141 let Some(&index) = ui.nodes[*ui.stack.last().unwrap()].children.last() else {157 let Some(&index) = ui.nodes[*ui.stack.last().unwrap()].children.last() else {
142 return;158 return;
143 };159 };
...@@ -183,7 +199,7 @@ pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>)...@@ -183,7 +199,7 @@ pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>)
183 } else {199 } else {
184 format!("{title} ({keys})")200 format!("{title} ({keys})")
185 };201 };
186 let [left, top, right, bottom] = ui.rect(id).unwrap_or_default();202 let [left, top, right, bottom] = part.or(ui.rect(id)).unwrap_or_default();
187 let theme = &ui.theme;203 let theme = &ui.theme;
188 let spec = Spec {204 let spec = Spec {
189 axis: Axis::Y,205 axis: Axis::Y,
tools/test_task_tags.py created+52
...@@ -0,0 +1,52 @@
1from pathlib import Path
2import re
3import runpy
4import shutil
5from tempfile import TemporaryDirectory
6import unittest
7import xml.etree.ElementTree as ET
8
9from native_xml import ns
10
11ROOT = Path(__file__).resolve().parent.parent
12FIXTURE = ROOT / 'corpus/task-tags'
13compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
14
15
16def tasks(read):
17 """Each page's paragraphs by title: their text, task identities and normal tag count."""
18 result = {}
19 for path in sorted(read.glob('page-*.xml')):
20 page = ET.parse(path).getroot()
21 title = page.find('one:Title/one:OE/one:T', ns)
22 if title is None:
23 continue
24 result[title.text] = [(re.sub(r'<[^>]*>', '', oe.find('one:T', ns).text or ''),
25 [(t.get('guidTask'), t.get('disabled'), t.get('startDate'), t.get('dueDate'))
26 for t in oe.findall('one:OutlookTask', ns)],
27 len(oe.findall('one:Tag', ns)))
28 for oe in page.find('one:Outline', ns).iter('{%s}OE' % ns['one'])]
29 return result
30
31
32class TaskTagsTest(unittest.TestCase):
33 def test_removed_and_restored_task_tags_reopen_natively(self):
34 with TemporaryDirectory() as temporary:
35 read = Path(temporary) / 'read'
36 shutil.copytree(FIXTURE / 'cold/read', read)
37 compare(FIXTURE / 'candidate', read)
38 native = tasks(FIXTURE / 'native/read')
39 cold = tasks(FIXTURE / 'cold/read')
40 self.assertEqual(cold['Delete and undo'], native['Delete and undo'])
41 removed = native['Remove a task']
42 self.assertEqual([len(t) for _, t, _ in removed], [1, 1, 1, 0])
43 self.assertEqual(cold['Remove a task'], [
44 (removed[0][0], [], 0),
45 removed[1],
46 (removed[2][0], removed[2][1], 0),
47 removed[3],
48 ])
49
50
51if __name__ == '__main__':
52 unittest.main()