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.
176176
177177## Sections that aren't open
178178
179OneNote keeps every section of an open notebook in sync, not just the one on screen: it
180reads a section another client changed within seconds and publishes a closed section's
181offline edits the moment the share is back. Snowbound's `session::Background` does the
182same with one thread per notebook. Each round reads every section file's stamp. Only a
183section whose replica has edits waiting, or whose file moved past the replica's base,
184has its replica opened for the usual sync steps, and it is closed again afterwards. The
185open section is left to its own session.
179OneNote keeps every section of an open notebook in sync and on this computer, not just
180the one on screen. Watched in the lab with a notebook of 200 sections on Samba, OneNote
1812010:
182
183- reads every section file once when the notebook opens (all 200 within 15 seconds), so a
184 section never shown before opens with the share gone;
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
187209## 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
1818`native/shapes.one` is OneNote 2010's own work in a lab clone:
1919
2020- "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.
2224- "Custom tags" holds a tag made with New Tag (Snow check, as above), applied at the top
2325 of the list (action type 0), then after moving it below To Do (a second definition,
2426 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 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
22 <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>
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>
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>
64 </defs>
7 <path d="M5 5H15V15H5Z" fill="url(#layer0)"/>
8 <path d="M5.75 5.75H14.25V14.25H5.75Z" fill="#ffffff"/>
9 <path d="M6.5 6.5H13.5V13.5H6.5Z" fill="url(#layer1)"/>
10 <path d="M7.25 7.25H12.75V12.75H7.25Z" fill="url(#layer2)"/>
5 <path d="M3 3H16V16H3Z" fill="#90a0ac"/>
6 <path d="M4 4H15V15H4Z" fill="#f7f8fa"/>
7 <path d="M5 5H14V14H5Z" fill="#b4bfc7"/>
8 <path d="M5 5H14V6H6V13H5Z" fill="#ccd5d9"/>
9 <path d="M6 6H13V13H6Z" fill="url(#interior)"/>
1110</svg>
crates/canvas/assets/tags/checkbox-green.svg+11-17
......@@ -1,24 +1,18 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
22 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" 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">
3 <linearGradient id="layer3" x1="4" y1="4" x2="13" y2="13" gradientUnits="userSpaceOnUse">
164 <stop offset="0" stop-color="#ffffff"/>
17 <stop offset="1" stop-color="#e2f3da"/>
5 <stop offset="1" stop-color="#e7f6e1"/>
186 </linearGradient>
197 </defs>
20 <path d="M0 0H16V16H0Z" fill="url(#layer0)"/>
21 <path d="M1 1H15V15H1Z" fill="url(#layer1)"/>
22 <path d="M2 2H14V14H2Z" fill="url(#layer2)"/>
8 <path d="M0 0h16v1h-16zM0 1h1v15h-1z" fill="#a0d495"/>
9 <path d="M1 1h14v1h-14zM1 2h1v1h-1zM14 2h1v13h-1zM1 11h1v4h-1z" fill="#f7f8f7"/>
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"/>
2317 <path d="M3 3H13V13H3Z" fill="url(#layer3)"/>
2418</svg>
crates/canvas/assets/tags/checkbox-yellow.svg+11-17
......@@ -1,24 +1,18 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
22 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" 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">
3 <linearGradient id="layer3" x1="4" y1="4" x2="13" y2="13" gradientUnits="userSpaceOnUse">
164 <stop offset="0" stop-color="#ffffff"/>
17 <stop offset="1" stop-color="#fcf1d8"/>
5 <stop offset="1" stop-color="#fbf8e9"/>
186 </linearGradient>
197 </defs>
20 <path d="M0 0H16V16H0Z" fill="url(#layer0)"/>
21 <path d="M1 1H15V15H1Z" fill="url(#layer1)"/>
22 <path d="M2 2H14V14H2Z" fill="url(#layer2)"/>
8 <path d="M0 0h16v1h-16zM0 1h1v15h-1z" fill="#dbbc6a"/>
9 <path d="M1 1h14v1h-14zM1 2h1v1h-1zM14 2h1v13h-1zM1 14h1v1h-1z" fill="#f7f8fa"/>
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"/>
2317 <path d="M3 3H13V13H3Z" fill="url(#layer3)"/>
2418</svg>
crates/canvas/assets/tags/checkbox.svg+12-17
......@@ -1,24 +1,19 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
22 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" 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">
3 <linearGradient id="layer3" x1="4" y1="4" x2="13" y2="13" gradientUnits="userSpaceOnUse">
164 <stop offset="0" stop-color="#ffffff"/>
17 <stop offset="1" stop-color="#d6e6f5"/>
5 <stop offset="1" stop-color="#daecfe"/>
186 </linearGradient>
197 </defs>
20 <path d="M0 0H16V16H0Z" fill="url(#layer0)"/>
21 <path d="M1 1H15V15H1Z" fill="url(#layer1)"/>
22 <path d="M2 2H14V14H2Z" fill="url(#layer2)"/>
8 <path d="M0 0h16v1h-16zM0 1h1v15h-1z" fill="#83a9e9"/>
9 <path d="M1 1h14v1h-14zM1 2h1v1h-1zM14 2h1v13h-1zM1 9h1v6h-1z" fill="#f7f8fa"/>
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"/>
2318 <path d="M3 3H13V13H3Z" fill="url(#layer3)"/>
2419</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 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/>
2 <path d="M3 0h1v1h-1z" fill="#0577e3" fill-opacity="0.82"/>
3 <path d="M4 0h1v1h-1z" fill="#348fd9" fill-opacity="0.2"/>
4 <path d="M3 1h1v1h-1z" fill="#1f78e0" fill-opacity="0.84"/>
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"/>
642</svg>
crates/canvas/assets/tags/mark-arrow-green.svg+34-4
......@@ -1,6 +1,36 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/>
2 <path d="M3 0h1v1h-1zM0 2h1v1h-1z" fill="#72ce6d"/>
3 <path d="M4 0h1v1h-1z" fill="#4dc634" fill-opacity="0.2"/>
4 <path d="M3 1h1v1h-1zM0 3h1v1h-1z" fill="#6fcb6a"/>
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"/>
636</svg>
crates/canvas/assets/tags/mark-arrow-yellow.svg+39-4
......@@ -1,6 +1,41 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/>
2 <path d="M3 0h1v1h-1z" fill="#e8c20f" fill-opacity="0.92"/>
3 <path d="M4 0h1v1h-1z" fill="#e9d134" fill-opacity="0.24"/>
4 <path d="M3 1h1v1h-1z" fill="#e3be01" fill-opacity="0.92"/>
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"/>
641</svg>
crates/canvas/assets/tags/mark-exclamation-blue.svg+42-7
......@@ -1,9 +1,44 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
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>
5 </defs>
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)"/>
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)"/>
8 <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/>
2 <path d="M1 0h1v1h-1zM2 7h1v1h-1z" fill="#19a4e7" fill-opacity="0.52"/>
3 <path d="M2 0h1v1h-1z" fill="#3f97e7"/>
4 <path d="M3 0h1v1h-1z" fill="#1c7ed6"/>
5 <path d="M4 0h1v1h-1z" fill="#3d87d3"/>
6 <path d="M5 0h1v1h-1z" fill="#0e87c7" fill-opacity="0.59"/>
7 <path d="M1 1h1v1h-1z" fill="#4f8cc8"/>
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"/>
944</svg>
crates/canvas/assets/tags/mark-exclamation-green.svg+43-7
......@@ -1,9 +1,45 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
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>
5 </defs>
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)"/>
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)"/>
8 <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/>
2 <path d="M1 0h1v1h-1z" fill="#72d042" fill-opacity="0.5"/>
3 <path d="M2 0h1v1h-1z" fill="#73cd58"/>
4 <path d="M3 0h1v1h-1z" fill="#56ba37"/>
5 <path d="M4 0h1v1h-1z" fill="#67bc53"/>
6 <path d="M5 0h1v1h-1z" fill="#48af05" fill-opacity="0.55"/>
7 <path d="M1 1h1v1h-1z" fill="#72b560"/>
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"/>
945</svg>
crates/canvas/assets/tags/mark-exclamation-yellow.svg+42-7
......@@ -1,9 +1,44 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
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>
5 </defs>
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)"/>
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)"/>
8 <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/>
2 <path d="M1 0h1v1h-1zM2 7h1v1h-1z" fill="#ecbb1c" fill-opacity="0.55"/>
3 <path d="M2 0h1v1h-1z" fill="#eba23a"/>
4 <path d="M3 0h1v1h-1z" fill="#db8a16"/>
5 <path d="M4 0h1v1h-1z" fill="#db9e34"/>
6 <path d="M5 0h1v1h-1z" fill="#d3aa07" fill-opacity="0.62"/>
7 <path d="M1 1h1v1h-1z" fill="#d29e43"/>
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"/>
944</svg>
crates/canvas/assets/tags/mark-flag-blue.svg+53-5
......@@ -1,7 +1,55 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M1.4 1.2L5.4 11.6" fill="none" stroke="#2a3a6a" stroke-width="1"/>
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)"/>
2 <path d="M0 0h1v1h-1z" fill="#6c7aa0"/>
3 <path d="M1 0h1v1h-1z" fill="#556fa8"/>
4 <path d="M2 0h1v1h-1zM2 7h1v1h-1z" fill="#4d7ccb" fill-opacity="0.13"/>
5 <path d="M3 0h1v1h-1z" fill="#1c71c9" fill-opacity="0.25"/>
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"/>
755</svg>
crates/canvas/assets/tags/mark-flag-green.svg+56-5
......@@ -1,7 +1,58 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M1.4 1.2L5.4 11.6" fill="none" stroke="#2a3a6a" stroke-width="1"/>
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)"/>
2 <path d="M0 0h1v1h-1z" fill="#6c7aa0"/>
3 <path d="M1 0h1v1h-1z" fill="#5f9e68"/>
4 <path d="M2 0h1v1h-1z" fill="#44a344" fill-opacity="0.11"/>
5 <path d="M3 0h1v1h-1z" fill="#2cb156" fill-opacity="0.23"/>
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"/>
758</svg>
crates/canvas/assets/tags/mark-flag-yellow.svg+55-5
......@@ -1,7 +1,57 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M1.4 1.2L5.4 11.6" fill="none" stroke="#2a3a6a" stroke-width="1"/>
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)"/>
2 <path d="M0 0h1v1h-1z" fill="#6c7aa0"/>
3 <path d="M1 0h1v1h-1z" fill="#a88d55"/>
4 <path d="M2 0h1v1h-1z" fill="#dbad49" fill-opacity="0.12"/>
5 <path d="M3 0h1v1h-1z" fill="#c1a907" fill-opacity="0.24"/>
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"/>
757</svg>
crates/canvas/assets/tags/mark-one-blue.svg+4-5
......@@ -1,7 +1,6 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
6 <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/>
2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#6888b9"/>
3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#2e5898"/>
4 <path d="M1 1h6v1h-6zM1 2h3v1h-3zM5 2h2v6h-2zM1 3h2v5h-2zM3 4h1v4h-1zM4 7h1v1h-1z" fill="#4974ba"/>
5 <path d="M4 2h1v5h-1zM3 3h1v1h-1z" fill="#ffffff"/>
76</svg>
crates/canvas/assets/tags/mark-one-green.svg+4-5
......@@ -1,7 +1,6 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
6 <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/>
2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#1e8d26"/>
3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#006100"/>
4 <path d="M1 1h6v1h-6zM1 2h3v1h-3zM5 2h2v6h-2zM1 3h2v5h-2zM3 4h1v4h-1zM4 7h1v1h-1z" fill="#23a92d"/>
5 <path d="M4 2h1v5h-1zM3 3h1v1h-1z" fill="#ffffff"/>
76</svg>
crates/canvas/assets/tags/mark-one-yellow.svg+4-5
......@@ -1,7 +1,6 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
6 <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/>
2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#c19b00"/>
3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#a88405"/>
4 <path d="M1 1h6v1h-6zM1 2h3v1h-3zM5 2h2v6h-2zM1 3h2v5h-2zM3 4h1v4h-1zM4 7h1v1h-1z" fill="#caa100"/>
5 <path d="M4 2h1v5h-1zM3 3h1v1h-1z" fill="#ffffff"/>
76</svg>
crates/canvas/assets/tags/mark-person-blue.svg+62-7
......@@ -1,9 +1,64 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
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>
5 </defs>
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)"/>
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)"/>
8 <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/>
2 <path d="M0 0h1v1h-1z" fill="#7b7b7b" fill-opacity="0.14"/>
3 <path d="M1 0h1v1h-1z" fill="#6f6f6f" fill-opacity="0.62"/>
4 <path d="M2 0h1v1h-1zM4 1h1v1h-1z" fill="#8d8d8d"/>
5 <path d="M3 0h1v1h-1z" fill="#888888"/>
6 <path d="M4 0h1v1h-1zM6 2h1v1h-1zM5 4h1v1h-1zM4 5h1v1h-1z" fill="#858585"/>
7 <path d="M5 0h1v1h-1z" fill="#565656" fill-opacity="0.6"/>
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"/>
964</svg>
crates/canvas/assets/tags/mark-person-green.svg+67-7
......@@ -1,9 +1,69 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
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>
5 </defs>
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)"/>
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)"/>
8 <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/>
2 <path d="M0 0h1v1h-1z" fill="#7b7b7b" fill-opacity="0.14"/>
3 <path d="M1 0h1v1h-1z" fill="#6f6f6f" fill-opacity="0.62"/>
4 <path d="M2 0h1v1h-1zM4 1h1v1h-1z" fill="#8d8d8d"/>
5 <path d="M3 0h1v1h-1z" fill="#888888"/>
6 <path d="M4 0h1v1h-1zM6 2h1v1h-1zM5 4h1v1h-1zM4 5h1v1h-1z" fill="#858585"/>
7 <path d="M5 0h1v1h-1z" fill="#565656" fill-opacity="0.6"/>
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"/>
969</svg>
crates/canvas/assets/tags/mark-person-yellow.svg+69-7
......@@ -1,9 +1,71 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
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>
5 </defs>
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)"/>
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)"/>
8 <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/>
2 <path d="M0 0h1v1h-1z" fill="#7b7b7b" fill-opacity="0.14"/>
3 <path d="M1 0h1v1h-1z" fill="#6f6f6f" fill-opacity="0.62"/>
4 <path d="M2 0h1v1h-1zM4 1h1v1h-1z" fill="#8d8d8d"/>
5 <path d="M3 0h1v1h-1z" fill="#888888"/>
6 <path d="M4 0h1v1h-1zM6 2h1v1h-1zM5 4h1v1h-1zM4 5h1v1h-1z" fill="#858585"/>
7 <path d="M5 0h1v1h-1z" fill="#565656" fill-opacity="0.6"/>
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"/>
971</svg>
crates/canvas/assets/tags/mark-star-blue.svg+57-5
......@@ -1,7 +1,59 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
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"/>
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)"/>
2 <path d="M4 0h2v1h-2z" fill="#6693c3" fill-opacity="0.37"/>
3 <path d="M4 1h1v1h-1z" fill="#738eb9"/>
4 <path d="M5 1h1v1h-1z" fill="#6d8bbc"/>
5 <path d="M3 2h1v1h-1z" fill="#0475b5" fill-opacity="0.39"/>
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"/>
759</svg>
crates/canvas/assets/tags/mark-star-green.svg+57-5
......@@ -1,7 +1,59 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
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"/>
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)"/>
2 <path d="M4 0h2v1h-2z" fill="#7fb479" fill-opacity="0.37"/>
3 <path d="M4 1h1v1h-1z" fill="#82ab7e"/>
4 <path d="M5 1h1v1h-1z" fill="#7dad79"/>
5 <path d="M3 2h1v1h-1z" fill="#5ca34b" fill-opacity="0.39"/>
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"/>
759</svg>
crates/canvas/assets/tags/mark-star-yellow.svg+55-5
......@@ -1,7 +1,57 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
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"/>
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)"/>
2 <path d="M4 0h2v1h-2z" fill="#bdad5d" fill-opacity="0.36"/>
3 <path d="M4 1h1v1h-1z" fill="#b7a672"/>
4 <path d="M5 1h1v1h-1z" fill="#b9a56b"/>
5 <path d="M3 2h1v1h-1z" fill="#b29b04" fill-opacity="0.39"/>
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"/>
757</svg>
crates/canvas/assets/tags/mark-three-blue.svg+4-5
......@@ -1,7 +1,6 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#g0)"/>
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"/>
2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#6888b9"/>
3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#2e5898"/>
4 <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v2h-2zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#4974ba"/>
5 <path d="M2 2h4v1h-4zM5 3h1v4h-1zM4 4h1v1h-1zM2 6h3v1h-3z" fill="#ffffff"/>
76</svg>
crates/canvas/assets/tags/mark-three-green.svg+4-5
......@@ -1,7 +1,6 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#g0)"/>
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"/>
2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#1e8d26"/>
3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#006100"/>
4 <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v2h-2zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#23a92d"/>
5 <path d="M2 2h4v1h-4zM5 3h1v4h-1zM4 4h1v1h-1zM2 6h3v1h-3z" fill="#ffffff"/>
76</svg>
crates/canvas/assets/tags/mark-three-yellow.svg+4-5
......@@ -1,7 +1,6 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#g0)"/>
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"/>
2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#c19b00"/>
3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#a88405"/>
4 <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v2h-2zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#caa100"/>
5 <path d="M2 2h4v1h-4zM5 3h1v4h-1zM4 4h1v1h-1zM2 6h3v1h-3z" fill="#ffffff"/>
76</svg>
crates/canvas/assets/tags/mark-two-blue.svg+4-5
......@@ -1,7 +1,6 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
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"/>
2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#6888b9"/>
3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#2e5898"/>
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="M2 2h4v1h-4zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v2h-1zM2 6h1v1h-1zM4 6h2v1h-2z" fill="#ffffff"/>
76</svg>
crates/canvas/assets/tags/mark-two-green.svg+4-5
......@@ -1,7 +1,6 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
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"/>
2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#1e8d26"/>
3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#006100"/>
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="M2 2h4v1h-4zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v2h-1zM2 6h1v1h-1zM4 6h2v1h-2z" fill="#ffffff"/>
76</svg>
crates/canvas/assets/tags/mark-two-yellow.svg+4-5
......@@ -1,7 +1,6 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
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>
4 </defs>
5 <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/>
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"/>
2 <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#c19b00"/>
3 <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#a88405"/>
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="M2 2h4v1h-4zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v2h-1zM2 6h1v1h-1zM4 6h2v1h-2z" fill="#ffffff"/>
76</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 {
15781578 nodes[1].tags.push(onestore::document::Tag {
15791579 definition: Some(new_id().unwrap()),
15801580 action_type: Some(0),
1581 shape: None,
1582 property_status: None,
15811583 status: 1,
15821584 created: Some(123),
15831585 completed: None,
crates/canvas/src/editor/evidence.rs+2
......@@ -97,6 +97,8 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {
9797 node.text_mut().unwrap().tags.push(Tag {
9898 definition: Some(definition),
9999 action_type: None,
100 shape: None,
101 property_status: None,
100102 status: u16::from(completed),
101103 created: Some(1),
102104 completed: Some(u32::from(completed)),
crates/canvas/src/editor/format.rs+5-1
......@@ -769,6 +769,8 @@ impl CanvasEditor {
769769 Tag {
770770 definition: Some(id),
771771 action_type: None,
772 shape: None,
773 property_status: None,
772774 status: u16::from(!checkable),
773775 created,
774776 completed: if checkable { Some(0) } else { created },
......@@ -1809,6 +1811,8 @@ mod tests {
18091811 nodes[2].tags.push(Tag {
18101812 definition: Some(music),
18111813 action_type: None,
1814 shape: None,
1815 property_status: None,
18121816 status: 1,
18131817 created: Some(1),
18141818 completed: Some(1),
......@@ -1938,7 +1942,7 @@ mod tests {
19381942 .iter()
19391943 .map(|tag| match tag.icon {
19401944 crate::outline::TagIcon::Symbol { shape, .. } => shape,
1941 crate::outline::TagIcon::Task => unreachable!(),
1945 crate::outline::TagIcon::Task { .. } => unreachable!(),
19421946 })
19431947 .collect::<Vec<_>>();
19441948 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 {
141141}
142142
143143const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg");
144const FLAG: &str = include_str!("../../assets/tags/flag.svg");
145144const BOX_SMALL: &str = include_str!("../../assets/tags/box-small.svg");
146145const CHECK_SMALL: &str = include_str!("../../assets/tags/check-small.svg");
147146const TAG: &str = include_str!("../../assets/tags/tag.svg");
......@@ -155,8 +154,7 @@ enum Art {
155154}
156155
157156/// 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 a
159/// plain tag.
157/// page. A shape MS-ONE does not list shows a plain tag.
160158fn art(shape: u16) -> Art {
161159 use Art::{Badged, Box, Plain};
162160 macro_rules! tag {
......@@ -218,7 +216,7 @@ fn art(shape: u16) -> Art {
218216 51 => Plain(tag!("circle-2-green")),
219217 52 => Badged(tag!("mark-three-green")),
220218 53 => Plain(tag!("circle-3-green")),
221 54 => Plain(tag!("star8-blue")),
219 54 => Plain(tag!("star8-green")),
222220 55 => Plain(tag!("tick-green")),
223221 56 => Plain(tag!("circle-green")),
224222 57 => Plain(tag!("arrow-down-green")),
......@@ -318,8 +316,9 @@ const SYMBOLS: u16 = 143;
318316/// The tag's artwork, drawn in order.
319317pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
320318 static SOURCES: std::sync::OnceLock<Vec<[Vec<&'static str>; 2]>> = std::sync::OnceLock::new();
321 let TagIcon::Symbol { shape, checked } = icon else {
322 return &[FLAG];
319 let (shape, checked) = match icon {
320 TagIcon::Symbol { shape, checked } => (shape, checked),
321 TagIcon::Task { shape } => (shape, false),
323322 };
324323 let sources = SOURCES.get_or_init(|| {
325324 (0..=SYMBOLS)
......@@ -350,6 +349,83 @@ mod tests {
350349 use onestore::page::text::Paragraph;
351350 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
353429 /// A page colour paints as OneNote paints it on white paper, and on dark paper as a
354430 /// dark paper of its hue; no colour leaves the paper as it was.
355431 #[test]
......@@ -465,7 +541,7 @@ mod tests {
465541 ]
466542 .into_iter()
467543 .map(|(shape, checked)| TagIcon::of(shape, checked).unwrap())
468 .chain([TagIcon::Task])
544 .chain([TagIcon::Task { shape: 91 }])
469545 .collect();
470546 let text = TextEngine::default()
471547 .layout(
crates/canvas/src/gpu/page.rs+2
......@@ -1777,6 +1777,8 @@ mod tests {
17771777 definition: Some(tag),
17781778 status: 1,
17791779 action_type: None,
1780 shape: None,
1781 property_status: None,
17801782 created: None,
17811783 completed: None,
17821784 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>
598598 let label = match tag.icon {
599599 _ if !tag.label.is_empty() => &tag.label,
600600 TagIcon::Symbol { shape, .. } => symbol_name(shape).unwrap_or("Tag"),
601 TagIcon::Task => "Outlook task",
601 TagIcon::Task { .. } => "Outlook task",
602602 };
603603 let state = match tag.icon {
604604 TagIcon::Symbol { checked: true, .. } => ", completed",
......@@ -946,6 +946,8 @@ pub(super) mod tests {
946946 paragraphs[index].text_mut().unwrap().tags.push(Tag {
947947 definition: Some(id),
948948 action_type: None,
949 shape: None,
950 property_status: None,
949951 status,
950952 created: None,
951953 completed: None,
crates/canvas/src/interaction/mod.rs+31
......@@ -504,6 +504,37 @@ impl PageView {
504504 self.hit_test(self.viewport.document_point(position))
505505 }
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
507538 fn set_object_focus(&mut self, focus: Option<ObjectFocus>) {
508539 if self.object_focus != focus {
509540 self.editor.finish_composition();
crates/canvas/src/outline.rs+22-7
......@@ -227,8 +227,8 @@ pub struct ParagraphLayout {
227227pub enum TagIcon {
228228 /// Symbol `shape` of MS-ONE's NoteTagShape, `checked` only where it is a check box.
229229 Symbol { shape: u16, checked: bool },
230 /// An Outlook task, which OneNote 2010 flags.
231 Task,
230 /// An Outlook task, drawn with its stored follow-up flag and never checked from the page.
231 Task { shape: u16 },
232232}
233233
234234impl TagIcon {
......@@ -887,8 +887,8 @@ impl ParagraphLayout {
887887 return Err(LayoutError::InvalidSpacing);
888888 }
889889 // Stored newest first; OneNote lists and paints tags oldest first.
890 // An Outlook task tag carries no definition; a tag whose definition is missing shows
891 // nothing, as OneNote draws no icon for it.
890 // An Outlook task tag carries no definition but its own icon; a tag whose definition
891 // is missing shows nothing, as OneNote draws no icon for it.
892892 let mut tag_definitions: Vec<_> = paragraph
893893 .tags
894894 .iter()
......@@ -1065,7 +1065,9 @@ impl ParagraphLayout {
10651065 for tag in &tag_definitions {
10661066 let (tag, shape, label) = (tag.0, tag.1, tag.2);
10671067 let icon = match shape {
1068 None => TagIcon::Task,
1068 None => TagIcon::Task {
1069 shape: tag.shape.unwrap_or(0),
1070 },
10691071 Some(shape) => match TagIcon::of(shape.unwrap_or(0), tag.status & 1 != 0) {
10701072 Some(icon) => icon,
10711073 None => continue,
......@@ -2241,6 +2243,8 @@ mod tests {
22412243 definition: Some(id),
22422244 status: 3,
22432245 action_type: None,
2246 shape: None,
2247 property_status: None,
22442248 created: None,
22452249 completed: None,
22462250 start: None,
......@@ -2356,8 +2360,13 @@ mod tests {
23562360 }
23572361 );
23582362 let mut task = node.clone();
2359 task.text_mut().unwrap().tags[0].status |= 4;
2360 assert_eq!(shaped(&task, &definitions).tags[0].icon, TagIcon::Task);
2363 let stored = &mut task.text_mut().unwrap().tags[0];
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 );
23612370 let mut untagged = definitions.clone();
23622371 untagged.remove(&id);
23632372 assert!(shaped(&node, &untagged).tags.is_empty());
......@@ -2403,6 +2412,8 @@ mod tests {
24032412 let tag = Tag {
24042413 definition: Some(id(902)),
24052414 action_type: None,
2415 shape: None,
2416 property_status: None,
24062417 status: 0,
24072418 created: None,
24082419 completed: None,
......@@ -2499,6 +2510,8 @@ mod tests {
24992510 let tag = Tag {
25002511 definition: Some(id(902)),
25012512 action_type: None,
2513 shape: None,
2514 property_status: None,
25022515 status: 0,
25032516 created: None,
25042517 completed: None,
......@@ -2665,6 +2678,8 @@ mod tests {
26652678 definition: Some(id),
26662679 status: 1,
26672680 action_type: None,
2681 shape: None,
2682 property_status: None,
26682683 created: None,
26692684 completed: None,
26702685 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 {
224224 Icon {
225225 sources: &'static [&'static str],
226226 size: u32,
227 phase: [u8; 2],
228227 /// The bits of the colour `currentColor` paints.
229228 ink: [u32; 3],
230229 palette: [Option<[u32; 3]>; 3],
......@@ -1104,27 +1103,24 @@ impl Renderer {
11041103 tint: [f32; 4],
11051104 palette: &Palette,
11061105 ) -> Result<(), RenderError> {
1107 let size = size * space.scale;
1108 let [x, y] = space.origin.map(|v| (v * 4.0).round() * 0.25);
1106 // Whole device pixels keep a sprite's pixel-art edges crisp at every scale.
1107 let size = (size * space.scale).round().max(1.0);
1108 let [x, y] = space.origin.map(f32::round);
11091109 if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) {
11101110 return Err(RenderError::InvalidPrimitive);
11111111 }
1112 if x + size + 1.0 < 0.0
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
1112 if x + size < 0.0 || y + size < 0.0 || x > space.size[0] as f32 || y > space.size[1] as f32
11161113 {
11171114 return Ok(());
11181115 }
1119 if size + 3.0 > self.atlas_side() as f32 {
1116 if size + 2.0 > self.atlas_side() as f32 {
11201117 return Err(RenderError::AtlasFull);
11211118 }
1122 let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8];
1119 let size = size as u32;
11231120 let ink = [tint[0], tint[1], tint[2]];
11241121 let key = AtlasKey::Icon {
11251122 sources,
1126 size: size.to_bits(),
1127 phase,
1123 size,
11281124 ink: ink.map(f32::to_bits),
11291125 palette: palette.bits(),
11301126 };
......@@ -1134,20 +1130,13 @@ impl Renderer {
11341130 if self.glyphs.len() >= self.glyph_limit() {
11351131 return Err(RenderError::AtlasFull);
11361132 }
1137 let glyph = self.upload(icon::rasterize(sources, size, phase, ink, palette))?;
1133 let glyph = self.upload(icon::rasterize(sources, size, ink, palette))?;
11381134 self.glyphs.insert(key, Some(glyph));
11391135 glyph
11401136 };
1141 let left = x.floor();
1142 let top = y.floor();
11431137 self.quad(
11441138 space,
1145 [
1146 left,
1147 top,
1148 left + glyph.width as f32,
1149 top + glyph.height as f32,
1150 ],
1139 [x, y, x + glyph.width as f32, y + glyph.height as f32],
11511140 self.uv(glyph),
11521141 [1.0, 1.0, 1.0, tint[3]],
11531142 )
crates/draw/src/render/icon.rs+32-51
......@@ -1,9 +1,6 @@
11use swash::{
22 scale::image::{Content, Image},
3 zeno::{
4 self, Cap, Fill, Join, Mask, PathBuilder, Placement, Point, Stroke, Style, Transform,
5 Vector,
6 },
3 zeno::{self, Cap, Fill, Join, Mask, PathBuilder, Placement, Point, Stroke, Style, Transform},
74};
85
96/// Colours for an icon's slots, linear RGB: paths of `class="accent"`, `"highlight"` or
......@@ -32,24 +29,17 @@ impl Palette {
3229 }
3330}
3431
35/// Rasterizes 16×16 SVG sources over each other at `size` device pixels, offset by
36/// `phase` quarter pixels. Paths are filled and stroked with `#rrggbb`, `currentColor`,
32/// Rasterizes 16×16 SVG sources over each other at a whole `size` of device pixels. Paths are filled and stroked with `#rrggbb`, `currentColor`,
3733/// which paints the linear `ink`, or two-stop `linearGradient`s with SVG's defaults
3834/// (`objectBoundingBox` unless `userSpaceOnUse`, `x1`..`y2`, stop offsets, pad spread; no
3935/// `gradientTransform`). `fill-opacity` and `stroke-opacity` apply, and strokes have round
4036/// caps and joins. A slot's paths keep the shading among their colours: the hue turn,
4137/// saturation scale and lightness shift that carry the mean of the slot's colours in a
4238/// source onto the palette's colour apply to each of them.
43pub(crate) fn rasterize(
44 sources: &[&str],
45 size: f32,
46 phase: [u8; 2],
47 ink: [f32; 3],
48 palette: &Palette,
49) -> Image {
50 let side = (size + 1.0).ceil() as u32;
39pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Palette) -> Image {
40 let side = size;
41 let size = size as f32;
5142 let mut pixels = vec![[0.0_f32; 4]; (side * side) as usize];
52 let offset = phase.map(|quarters| f32::from(quarters) * 0.25);
5343 for source in sources {
5444 let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid");
5545 assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16"));
......@@ -135,7 +125,6 @@ pub(crate) fn rasterize(
135125 let (mask, _) = Mask::new(data)
136126 .style(style)
137127 .transform(Some(Transform::scale(size / 16.0, size / 16.0)))
138 .render_offset(Vector::new(offset[0], offset[1]))
139128 .size(side, side)
140129 .render();
141130 for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() {
......@@ -147,7 +136,7 @@ pub(crate) fn rasterize(
147136 let [x, y] = [index % side as usize, index / side as usize]
148137 .map(|device| device as f32 + 0.5)
149138 .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]);
151140 for channel in 0..3 {
152141 pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha);
153142 }
......@@ -468,7 +457,7 @@ mod tests {
468457 /// The grey painted at a pixel of `source` rasterized at 16 pixels, and the grey a
469458 /// black-to-white ramp paints at fraction `t`.
470459 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]
472461 }
473462
474463 fn ramp(t: f32) -> u8 {
......@@ -516,8 +505,8 @@ mod tests {
516505 // Two blues of one hue in the accent slot, a red outside it.
517506 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>"##;
518507 let pixel = |palette: &Palette, y: usize| {
519 let image = rasterize(&[ART], 16.0, [0, 0], [0.0; 3], palette);
520 let at = (y * 17 + 8) * 4;
508 let image = rasterize(&[ART], 16, [0.0; 3], palette);
509 let at = (y * 16 + 8) * 4;
521510 let [r, g, b] = [0, 1, 2].map(|channel| f32::from(image.data[at + channel]) / 255.0);
522511 hsl([r, g, b])
523512 };
......@@ -542,14 +531,8 @@ mod tests {
542531 fn current_color_paints_the_ink_and_fixed_colours_keep_theirs() {
543532 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>"##;
544533 let ink = super::super::srgb(0x20, 0x60, 0xa0);
545 let image = rasterize(
546 &[LINE],
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];
534 let image = rasterize(&[LINE], 16, [ink[0], ink[1], ink[2]], &Palette::default());
535 let pixel = |x: usize, y: usize| &image.data[(y * 16 + x) * 4..][..4];
553536 assert_eq!(pixel(8, 7), [0x20, 0x60, 0xa0, 255]);
554537 assert_eq!(pixel(8, 11), [255, 0, 0, 255]);
555538 assert_eq!(pixel(8, 2)[3], 0);
......@@ -560,8 +543,8 @@ mod tests {
560543 #[test]
561544 fn opacity_scales_each_paint_on_its_own() {
562545 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());
564 let alpha = |x: usize, y: usize| image.data[(y * 17 + x) * 4 + 3];
546 let image = rasterize(&[HALF], 16, [1.0; 3], &Palette::default());
547 let alpha = |x: usize, y: usize| image.data[(y * 16 + x) * 4 + 3];
565548 assert_eq!(alpha(8, 8), 128);
566549 // The stroke's outer half covers only what the fill leaves.
567550 assert_eq!(alpha(1, 8), 64);
......@@ -569,28 +552,26 @@ mod tests {
569552
570553 #[test]
571554 fn icons_keep_transparency_and_overlays_across_scales() {
572 for size in [8.0, 16.0, 32.0, 57.5] {
573 for phase in [[0, 0], [1, 2], [3, 3]] {
574 let plain = rasterize(&[BOX], size, phase, [1.0; 3], &Palette::default());
575 let marked = rasterize(&[BOX, MARK], size, phase, [1.0; 3], &Palette::default());
576 for image in [&plain, &marked] {
577 assert_eq!(
578 image.data.len(),
579 (image.placement.width * image.placement.height * 4) as usize
580 );
581 assert!(image.data.chunks_exact(4).any(|p| p[3] > 0));
582 assert!(image.data.chunks_exact(4).any(|p| p[3] < 255));
583 assert!(
584 image
585 .data
586 .chunks_exact(4)
587 .filter(|p| p[3] == 0)
588 .all(|p| p[..3] == [0; 3])
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));
555 for size in [8, 16, 32, 57] {
556 let plain = rasterize(&[BOX], size, [1.0; 3], &Palette::default());
557 let marked = rasterize(&[BOX, MARK], size, [1.0; 3], &Palette::default());
558 for image in [&plain, &marked] {
559 assert_eq!(
560 image.data.len(),
561 (image.placement.width * image.placement.height * 4) as usize
562 );
563 assert!(image.data.chunks_exact(4).any(|p| p[3] > 0));
564 assert!(image.data.chunks_exact(4).any(|p| p[3] < 255));
565 assert!(
566 image
567 .data
568 .chunks_exact(4)
569 .filter(|p| p[3] == 0)
570 .all(|p| p[..3] == [0; 3])
571 );
593572 }
573 assert_ne!(plain.data, marked.data);
574 assert!(marked.data.chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0));
594575 }
595576 }
596577}
crates/mobile/src/library.rs+5-11
......@@ -33,8 +33,6 @@ const TIMEOUT: Duration = Duration::from_secs(10);
3333const LIMIT: usize = 256 * 1024 * 1024;
3434/// The tab colour OneNote gives a section that stores none, as the desktop shows it.
3535const 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
3937/// Called on a background thread when a notebook's sync status may have changed.
4038static SYNC_WAKE: OnceLock<extern "C" fn()> = OnceLock::new();
......@@ -239,7 +237,7 @@ fn stem(path: &str) -> String {
239237fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
240238 for section in &folder.sections {
241239 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),
243241 SectionState::Locked | SectionState::Unreadable(_) => (None, None, false),
244242 };
245243 tabs.push(Tab {
......@@ -272,8 +270,10 @@ impl Library {
272270 (None, Place::File(path.to_owned()), None)
273271 } else {
274272 let notebook = Notebook::open(path, cache)?;
273 // Nothing here tells a file provider's folder on this device from one elsewhere.
275274 let background = notebook.background_with(
276 BACKGROUND,
275 Background::UNWATCHED,
276 false,
277277 |file| Coordinated(file.to_owned()),
278278 sync_woken,
279279 )?;
......@@ -300,13 +300,7 @@ impl Library {
300300 pub(crate) fn server(server: Server, root: &str, cache: &Path) -> Result<Self> {
301301 let server = Arc::new(server);
302302 let connect = Arc::clone(&server);
303 let background = Background::smb(
304 root,
305 LIMIT,
306 BACKGROUND,
307 move || connect.connect(),
308 sync_woken,
309 )?;
303 let background = Background::smb(root, LIMIT, move || connect.connect(), sync_woken)?;
310304 let library = Self {
311305 notebook: Mutex::default(),
312306 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
6565worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offline(false)`.
6666
6767`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 mounted
69notebook, `Background::smb(root, limit, interval, connect, notify)` on a share, then
70`watch(notebook.replicas())`. Each round reads every watched file's stamp; a section whose
71replica has edits waiting, or whose file moved past the replica's base, has the replica
72opened for the synchronization steps that publish or rebase it and closed again. A replica
73a session holds is skipped (`Error::busy`), so opening a section may wait out one step.
74`status()` gives each section's `SyncStatus`, `changed()` the sections another client
75changed, and `wake` and `set_offline` follow Sync Now and Work Offline.
68every section of an open notebook: `Background::smb(root, limit, connect, notify)` on a
69share, `Notebook::background(interval, copies, notify)` for a mounted notebook, then
70`watch(notebook.replicas())`. On a share, one CHANGE_NOTIFY on the notebook's folder
71(`smb::Client::watch`) reports what changed; a host watching a mounted folder passes the
72changed paths to `touched`. A reported section is checked a second later, a failing one
73every 31 seconds, and any other every `interval` (`Background::BACKSTOP` while a watch
74reports). Newly watched sections, and every section after a reconnect, are checked one at a
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.
7683`Notebook::replica_path(path)` names a section's replica for either kind of notebook.
7784
7885`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
373380directory names or sizes. Missing paths, denied access and non-directory paths
374381have 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
376389`Client::read_asset(path, byte_limit)` reads an external payload under a read-only
377390share handle that excludes writes and deletion. Empty files succeed; limits,
378391interrupted 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;
1111use std::{
1212 io,
1313 path::{Path, PathBuf},
14 sync::{Arc, Mutex, atomic::Ordering},
14 sync::{
15 Arc, Mutex, Weak,
16 atomic::{AtomicBool, Ordering},
17 },
1518 thread,
16 time::Duration,
19 time::{Duration, Instant},
1720};
1821
19/// Polls each watched section file's stamp every interval. A section whose replica has
20/// edits waiting, or whose file moved past the replica's base, has its replica opened for
21/// the synchronization steps that publish or rebase it, then closed again; any other costs
22/// one stamp read. A replica a session holds is left to that session's worker. Dropping
23/// requests cancellation without waiting for the step in flight.
24pub struct Background {
22/// How long a section that could not be reached waits to be tried again; OneNote 2010
23/// retries a failing section about every 31 seconds.
24const RETRY: Duration = Duration::from_secs(31);
25/// Between one section's first check and the next's, so opening a notebook reads its files
26/// one after another rather than in a burst.
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 {
2543 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,
2753}
2854
2955#[derive(Default)]
......@@ -31,6 +57,10 @@ struct Watched {
3157 sections: Vec<Watch>,
3258 /// Sections whose file changed since `changed` was last asked.
3359 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,
3464}
3565
3666struct Watch {
......@@ -39,12 +69,24 @@ struct Watch {
3969 /// The file's stamp when last reached.
4070 stamp: Option<Stamp>,
4171 status: SyncStatus,
72 /// When the section is next checked.
73 due: Instant,
4274}
4375
4476impl 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.
4587 pub(crate) fn start<R, B>(
46 interval: Duration,
47 mut connect: impl FnMut() -> io::Result<B> + Send + 'static,
88 copies: bool,
89 mut connect: impl FnMut(Reports) -> io::Result<(B, Duration)> + Send + 'static,
4890 notify: impl Fn() + Send + 'static,
4991 ) -> Result<Self>
5092 where
......@@ -52,165 +94,201 @@ impl Background {
5294 B: FnMut(&str) -> R,
5395 {
5496 let (signal, receiver) = Signal::new();
55 let watched = Arc::new(Mutex::new(Watched::default()));
56 let (shared, sections) = (Arc::clone(&signal), Arc::clone(&watched));
97 let shared = Arc::new(Shared {
98 signal,
99 watched: Mutex::default(),
100 discard: AtomicBool::new(false),
101 });
102 let weak = Arc::downgrade(&shared);
103 let owner = Arc::clone(&shared);
57104 thread::Builder::new()
58105 .name("onestore-background".into())
59106 .spawn(move || {
60 let mut bound: Option<B> = None;
61 while !shared.stopped.load(Ordering::Acquire) {
62 if shared.offline.load(Ordering::Acquire)
63 && !shared.requested.load(Ordering::Acquire)
107 let signal = &owner.signal;
108 let mut bound: Option<(B, Duration)> = None;
109 let mut news = false;
110 while !signal.stopped.load(Ordering::Acquire) {
111 if signal.offline.load(Ordering::Acquire)
112 && !signal.requested.load(Ordering::Acquire)
64113 {
65114 bound = None;
66115 let _ = receiver.recv();
67116 continue;
68117 }
69 shared.requested.store(false, Ordering::Release);
70 let round: Vec<_> = match sections.lock() {
71 Ok(watched) => watched
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) {
118 let now = Instant::now();
119 let (path, replica, seen, connection) = {
120 let Ok(mut watched) = owner.watched.lock() else {
87121 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 },
110122 };
111 let (queued, outcome) =
112 step(&mut bind(&path), replica.as_deref(), seen.as_ref());
113 if matches!(outcome, Err(Error::RemoteIo(_) | Error::Remote(_))) {
123 if std::mem::take(&mut watched.lost) {
114124 bound = None;
125 watched.rescan(now);
115126 }
116 let Ok(mut watched) = sections.lock() else {
117 return;
118 };
119 let Watched { sections, changed } = &mut *watched;
120 let Some(watch) = sections.iter_mut().find(|watch| watch.path == path)
121 else {
122 continue;
123 };
124 news |= match outcome {
125 Ok((stamp, moved)) => {
126 watch.stamp = Some(stamp);
127 if moved {
128 changed.push(path);
127 let due = watched.next().map(|index| &watched.sections[index]);
128 match due.filter(|watch| watch.due <= now) {
129 Some(watch) => (
130 watch.path.clone(),
131 watch.replica.clone(),
132 watch.stamp.clone(),
133 watched.connection,
134 ),
135 None => {
136 let wait = due.map(|watch| watch.due - now);
137 drop(watched);
138 signal.requested.store(false, Ordering::Release);
139 if std::mem::take(&mut news) {
140 notify();
129141 }
130 let before = summary(&watch.status);
131 if let Some(queued) = queued {
132 watch.status = SyncStatus {
133 synced: Some(crate::now()),
134 error: None,
135 queued,
136 };
142 let _ = match wait {
143 Some(wait) => receiver.recv_timeout(wait).ok(),
144 None => receiver.recv().ok(),
145 };
146 continue;
147 }
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 }
137176 }
138 moved || before != summary(&watch.status)
139177 }
140 Err(error) => watch.fail(&error, queued),
141 };
178 continue;
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;
142186 }
143 if news {
144 notify();
187 let Ok(mut watched) = owner.watched.lock() else {
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);
145229 }
146 let _ = receiver.recv_timeout(interval);
147230 }
148231 })?;
149 Ok(Self { signal, watched })
232 Ok(Self(shared))
150233 }
151234
152 /// Keeps the sections of a notebook on a share in sync while they are not open;
153 /// `connect` runs again after a transport failure. Paths are relative to `root`.
235 /// Keeps the sections of a notebook on a share in sync while they are not open, with an
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`.
154239 #[cfg(feature = "smb")]
155240 pub fn smb(
156241 root: &str,
157242 limit: usize,
158 interval: Duration,
159243 mut connect: impl FnMut() -> io::Result<crate::smb::Client> + Send + 'static,
160244 notify: impl Fn() + Send + 'static,
161245 ) -> Result<Self> {
162246 let root = root.replace('\\', "/");
163247 Self::start(
164 interval,
165 move || {
248 true,
249 move |reports| {
166250 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 };
167259 let root = root.clone();
168 Ok(move |path: &str| {
260 let bind = move |path: &str| {
169261 let file = match root.as_str() {
170262 "" => path.to_owned(),
171263 root => format!("{root}/{path}"),
172264 };
173265 crate::SmbRemote::new(Arc::clone(&client), file, limit)
174 })
266 };
267 Ok((bind, interval))
175268 },
176269 notify,
177270 )
178271 }
179272
180 /// Watches these sections, by catalog path and replica (`Notebook::replicas`), from
181 /// the next round, which starts now.
273 /// Watches these sections, by catalog path and replica (`Notebook::replicas`); a section
274 /// not watched before is first checked soon after, the next one a little later.
182275 pub fn watch(&self, sections: Vec<(String, Option<PathBuf>)>) {
183 if let Ok(mut watched) = self.watched.lock() {
184 let mut previous = std::mem::take(&mut watched.sections);
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();
276 if let Ok(mut watched) = self.0.watched.lock() {
277 watched.watch(sections, Instant::now());
206278 }
207 self.signal.wake();
279 self.0.signal.wake();
208280 }
209281
210 /// Each watched section's status as its last round left it, in watch order. A section a
282 /// 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
211289 /// session holds keeps the status it had before.
212290 pub fn status(&self) -> Vec<(String, SyncStatus)> {
213 self.watched.lock().map_or_else(
291 self.0.watched.lock().map_or_else(
214292 |_| Vec::new(),
215293 |watched| {
216294 watched
......@@ -237,32 +315,165 @@ impl Background {
237315
238316 /// Sections whose file changed since the last call, by catalog path.
239317 pub fn changed(&self) -> Vec<String> {
240 self.watched
318 self.0
319 .watched
241320 .lock()
242321 .map(|mut watched| std::mem::take(&mut watched.changed))
243322 .unwrap_or_default()
244323 }
245324
246 /// Runs a round now, working offline included (Sync Now).
325 /// Checks every section now, working offline included (Sync Now).
247326 pub fn wake(&self) {
248 self.signal.requested.store(true, Ordering::Release);
249 self.signal.wake();
327 if let Ok(mut watched) = self.0.watched.lock() {
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();
250335 }
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.
253347 pub fn set_offline(&self, offline: bool) {
254 self.signal.offline.store(offline, Ordering::Release);
255 self.signal.wake();
348 self.0.signal.offline.store(offline, Ordering::Release);
349 self.0.signal.wake();
256350 }
257351}
258352
259353impl Drop for Background {
260354 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 }
262365 self.signal.wake();
263366 }
264367}
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
266477impl Watch {
267478 /// Records a failed step and what it found waiting, answering whether the status shown
268479 /// changes.
......@@ -284,20 +495,43 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) {
284495}
285496
286497/// 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.
288500fn step<R: Remote>(
289501 remote: &mut R,
290502 replica: Option<&Path>,
291503 seen: Option<&Stamp>,
504 copies: bool,
292505) -> (Option<u64>, Result<(Stamp, bool)>) {
293506 let stamp = match remote.stamp() {
294507 Ok(stamp) => stamp,
295508 Err(error) => return (None, Err(Error::RemoteIo(error))),
296509 };
297510 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 {
299512 return (Some(0), Ok((stamp, moved)));
300513 };
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 }
301535 match crate::peek(replica) {
302536 Ok((base, 0)) if base == stamp => return (Some(0), Ok((stamp, moved))),
303537 Err(error) if error.busy() => return (None, Ok((stamp, false))),
......@@ -324,3 +558,176 @@ fn step<R: Remote>(
324558 .map(|summary| summary.queued_edits);
325559 (queued, synced)
326560}
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 {
3030 name: Option<String>,
3131 /// The tab colour as a COLORREF; OneNote assigns one when absent.
3232 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],
3336 },
3437 Locked,
3538 Unreadable(onestore::Error),
......@@ -310,6 +313,7 @@ fn scan(
310313 state: SectionState::Readable {
311314 name,
312315 color: color.filter(|color| *color != 0xffff_ffff),
316 document: index.root.guid,
313317 },
314318 });
315319 } else {
crates/notebook/src/lib.rs+16-11
......@@ -105,23 +105,28 @@ impl Replica {
105105 /// Seeds a new cache from a validated section image, refusing any existing path.
106106 /// An initialization error preserves the created file for inspection.
107107 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<()> {
108114 validate(source)?;
109 let path = path.as_ref();
110 // Built under another name and linked into place whole, so that a cache that exists
111 // is complete: a background step or a failed creation never leaves one half made.
115 // Built under a name of its own and linked into place whole, so that a cache that
116 // exists is complete, and two threads making the same one never share a build.
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);
112119 let mut building = path.as_os_str().to_owned();
113 building.push(".creating");
120 building.push(format!(".creating-{}-{build}", std::process::id()));
114121 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"] {
116125 let mut file = building.as_os_str().to_owned();
117 file.push(stale);
126 file.push(suffix);
118127 let _ = std::fs::remove_file(file);
119128 }
120 let built = Self::build(&building, source);
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)?)
129 linked
125130 }
126131
127132 fn build(path: &Path, source: &[u8]) -> Result<()> {
crates/notebook/src/merge.rs+2
......@@ -164,6 +164,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> {
164164 PageOp::Level { paragraph, .. }
165165 | PageOp::Paragraph { paragraph, .. }
166166 | PageOp::Style { paragraph, .. }
167 | PageOp::Media { paragraph, .. }
167168 | PageOp::List { paragraph, .. } => vec![*paragraph],
168169 PageOp::Tags { target, .. } => vec![*target],
169170 PageOp::Add { object, .. } => vec![object.id()],
......@@ -736,6 +737,7 @@ impl Diff {
736737 }
737738 PageOp::Paragraph { paragraph, .. }
738739 | PageOp::Style { paragraph, .. }
740 | PageOp::Media { paragraph, .. }
739741 | PageOp::List { paragraph, .. } => {
740742 require(self.kept(*paragraph) && self.new_has(*paragraph))?
741743 }
crates/notebook/src/session.rs+15-8
......@@ -887,13 +887,16 @@ impl Notebook {
887887 /// `smb` on a share. It exists once the section has been opened.
888888 pub fn replica_path(&self, path: &str) -> Result<PathBuf> {
889889 let section = self.section_path(path)?;
890 Ok(match &self.root {
891 Some(_) => {
890 Ok(match (&self.root, &section.state) {
891 (Some(_), discover::SectionState::Readable { document, .. }) => {
892 replica_file(&self.cache, document)
893 }
894 (Some(_), _) => {
892895 let image = self.storage.read(&section.path)?;
893896 let root = RevisionIndex::parse(&Store::parse(&image)?)?.root;
894897 replica_file(&self.cache, &root.guid)
895898 }
896 None => replica_file(&self.cache.join("smb"), &section.file_id),
899 (None, _) => replica_file(&self.cache.join("smb"), &section.file_id),
897900 })
898901 }
899902
......@@ -914,13 +917,16 @@ impl Notebook {
914917 }
915918
916919 /// 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.
918923 pub fn background(
919924 &self,
920925 interval: Duration,
926 copies: bool,
921927 notify: impl Fn() + Send + 'static,
922928 ) -> 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)
924930 }
925931
926932 /// `background`, reaching each section file through the remote `remote` makes for it
......@@ -928,6 +934,7 @@ impl Notebook {
928934 pub fn background_with<R: Remote + 'static>(
929935 &self,
930936 interval: Duration,
937 copies: bool,
931938 remote: impl Fn(&Path) -> R + Clone + Send + 'static,
932939 notify: impl Fn() + Send + 'static,
933940 ) -> Result<Background> {
......@@ -939,10 +946,10 @@ impl Notebook {
939946 .into());
940947 };
941948 Background::start(
942 interval,
943 move || {
949 copies,
950 move |_| {
944951 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))
946953 },
947954 notify,
948955 )
crates/notebook/src/smb/directory.rs+67
......@@ -96,6 +96,73 @@ impl Client {
9696 })
9797 .collect())
9898 }
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 }
99166}
100167
101168fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> {
crates/notebook/src/smb/tests.rs+38
......@@ -421,6 +421,44 @@ fn live_stamp_and_transaction() {
421421 poller.delete(&path).unwrap();
422422}
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
424462#[test]
425463#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
426464fn 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() {
10291029 let notebook = two_sections(directory.path());
10301030 let file = directory.path().join("Shared/Second.one");
10311031 let background = notebook
1032 .background(Duration::from_millis(20), || {})
1032 .background(Duration::from_millis(20), false, || {})
10331033 .unwrap();
10341034 background.set_offline(true);
10351035 background.watch(notebook.replicas());
......@@ -1083,7 +1083,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
10831083 let notified = Arc::new(AtomicUsize::new(0));
10841084 let counter = Arc::clone(&notified);
10851085 let background = notebook
1086 .background(Duration::from_millis(20), move || {
1086 .background(Duration::from_millis(20), false, move || {
10871087 counter.fetch_add(1, Ordering::SeqCst);
10881088 })
10891089 .unwrap();
......@@ -1125,3 +1125,77 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
11251125 });
11261126 assert_same(opened.unwrap().page(*space).unwrap(), native);
11271127}
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<'_> {
405405
406406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
407407/// 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.
408409pub struct Tag {
409410 pub definition: Option<ExGuid>,
410411 pub action_type: Option<u16>,
412 pub shape: Option<u16>,
413 pub property_status: Option<u32>,
411414 pub status: u16,
412415 pub created: Option<u32>,
413416 pub completed: Option<u32>,
......@@ -446,6 +449,8 @@ pub enum Kind<'a> {
446449 margin_origin_x: Option<f32>,
447450 margin_origin_y: Option<f32>,
448451 rtl: Option<bool>,
452 /// Every audio and video recording on the page (AudioRecordingGuids).
453 recordings: Vec<[u8; 16]>,
449454 },
450455 Metadata {
451456 title: Option<String>,
......@@ -475,11 +480,16 @@ pub enum Kind<'a> {
475480 },
476481 OutlineGroup,
477482 /// 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.
479486 Ink {
480487 data: Option<ExGuid>,
481488 scale_x: Option<f32>,
482489 scale_y: Option<f32>,
490 shape_kind: Option<u8>,
491 line: Option<Vec<u8>>,
492 anchors: Option<Vec<u8>>,
483493 },
484494 InkData {
485495 strokes: Vec<ExGuid>,
......@@ -555,6 +565,8 @@ pub enum Kind<'a> {
555565 source_path: Option<String>,
556566 recording_id: Option<[u8; 16]>,
557567 recording_type: Option<u32>,
568 /// AudioRecordingDuration, in milliseconds.
569 recording_duration: Option<u32>,
558570 /// Displayed icon width and height in points.
559571 icon_width: Option<f32>,
560572 icon_height: Option<f32>,
......@@ -675,6 +687,16 @@ impl<'a, 'o> Fields<'a, 'o> {
675687 fn u32(&mut self, id: u32) -> Result<Option<u32>> {
676688 Ok(self.fixed(id)?.map(u32::from_le_bytes))
677689 }
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 }
678700 fn u16(&mut self, id: u32) -> Result<Option<u16>> {
679701 Ok(self.fixed(id)?.map(u16::from_le_bytes))
680702 }
......@@ -1058,6 +1080,8 @@ impl<'a> Element<'a> {
10581080 tags.push(Tag {
10591081 definition: tag.one(0x20003488)?,
10601082 action_type: tag.u16(0x10003463)?,
1083 shape: tag.u16(0x10003464)?,
1084 property_status: tag.u32(0x14003467)?,
10611085 status: tag
10621086 .u16(0x10003470)?
10631087 .ok_or_else(|| invalid("Note tag has no status"))?,
......@@ -1114,6 +1138,7 @@ impl<'a> Element<'a> {
11141138 margin_origin_x: f.float(0x14001d0f, 36.0)?,
11151139 margin_origin_y: f.float(0x14001d10, 36.0)?,
11161140 rtl: f.boolean(0x08001c92)?,
1141 recordings: f.guids(0x1c001ca3)?,
11171142 },
11181143 0x20030 => Kind::Metadata {
11191144 title: f.text(0x1c001cf3)?,
......@@ -1128,6 +1153,9 @@ impl<'a> Element<'a> {
11281153 data: f.one(0x20003415)?,
11291154 scale_x: f.float(0x14001c46, 1.0)?,
11301155 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),
11311159 },
11321160 0x2003b => Kind::InkData {
11331161 strokes: f.refs(0x24003416, IdStream::Objects)?,
......@@ -1327,6 +1355,7 @@ impl<'a> Element<'a> {
13271355 source_path: f.text(0x1c001d9d)?,
13281356 recording_id: f.fixed(0x1c001c97)?,
13291357 recording_type: f.u32(0x14001d24)?,
1358 recording_duration: f.u32(0x14001cfd)?,
13301359 icon_width: f.float(0x140034cd, 36.0)?,
13311360 icon_height: f.float(0x140034ce, 36.0)?,
13321361 },
......@@ -1349,14 +1378,7 @@ impl<'a> Element<'a> {
13491378 },
13501379 _ => Kind::Unknown,
13511380 };
1352 let media_ids = f.bytes(0x1c001c98)?.unwrap_or_default();
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();
1381 let media_ids = f.guids(0x1c001c98)?;
13601382 Ok(Self {
13611383 jcid: object.jcid,
13621384 children,
crates/onestore/src/op/apply.rs+82-17
......@@ -63,9 +63,16 @@ impl<'a> Section<'a> {
6363 .collect();
6464 for space in spaces {
6565 // 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() {
6767 continue;
68 };
68 }
69 Writer {
70 section: &mut *section,
71 space,
72 author,
73 }
74 .playlist()?;
75 let page = section.active(space)?;
6976 if page.view.nodes.values().any(|node| {
7077 matches!(node.kind, Kind::Cell { .. }) && node.children.is_empty()
7178 }) {
......@@ -474,6 +481,41 @@ impl<'a> Writer<'_, 'a> {
474481 Ok(())
475482 }
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
477519 /// Requires `id` reachable on the page.
478520 fn target(&mut self, id: ExGuid) -> Result<(), Failure> {
479521 let page = self.page()?;
......@@ -746,6 +788,33 @@ impl<'a> Writer<'_, 'a> {
746788 let text = self.text_of(*paragraph)?;
747789 self.write(|page| properties::style_changes(page, text, *style, Some(definition)))
748790 }
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 }
749818 PageOp::List { paragraph, lists } => {
750819 self.target(*paragraph)?;
751820 let page = self.page()?;
......@@ -772,17 +841,16 @@ impl<'a> Writer<'_, 'a> {
772841 definitions,
773842 } => {
774843 self.target(*target)?;
775 let mut entries = Vec::new();
776 for tag in tags {
777 let definition = tag
778 .definition
779 .ok_or(OpError::Unsupported("A note tag names its definition"))?;
780 let model = definitions
781 .iter()
782 .find(|(id, _)| *id == definition)
783 .map(|(_, definition)| definition);
784 entries.push((definition, tag, model));
785 }
844 let entries: Vec<_> = tags
845 .iter()
846 .map(|tag| {
847 let model = definitions
848 .iter()
849 .find(|(id, _)| Some(*id) == tag.definition)
850 .map(|(_, definition)| definition);
851 (tag, model)
852 })
853 .collect();
786854 self.write(|page| properties::tag_changes(page, *target, &entries))
787855 }
788856 PageOp::Add { object, before } => self.add(object, *before),
......@@ -1449,7 +1517,7 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> {
14491517 }
14501518 if paragraph.media != Default::default() {
14511519 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",
14531521 ));
14541522 }
14551523 match &paragraph.content {
......@@ -1470,9 +1538,6 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> {
14701538 }
14711539 Ok(())
14721540 }
1473 ParagraphContent::Attachment(attachment) if attachment.recording.is_some() => Err(
1474 OpError::Unsupported("Recordings are captured by OneNote, not inserted"),
1475 ),
14761541 _ => Ok(()),
14771542 }
14781543}
crates/onestore/src/op/content.rs+124-40
......@@ -6,7 +6,7 @@ use crate::{
66 Error, ExGuid,
77 active::{ActivePage, Changes},
88 document::{Format, Layout},
9 page::{Attachment, Image, Ink, InkStroke, Paragraph},
9 page::{Attachment, Image, Ink, InkStroke, Paragraph, Recording},
1010 write::PropertyObject,
1111};
1212use std::collections::BTreeMap;
......@@ -319,8 +319,10 @@ pub(crate) fn attachment_changes(
319319 "An attachment needs a file name without path separators",
320320 ));
321321 }
322 if attachment.recording.is_some() {
323 return Err(invalid("Recordings are captured by OneNote, not inserted"));
322 if Recording::kind_of(name).is_some() != attachment.recording.is_some() {
323 return Err(invalid(
324 "Audio and video files are recordings, and only they are",
325 ));
324326 }
325327 if attachment.bytes.is_none() {
326328 return Err(invalid("A new attachment needs its payload"));
......@@ -374,6 +376,19 @@ pub(crate) fn attachment_changes(
374376 if let Some(path) = &attachment.source_path {
375377 values.push((0x1c001d9d, crate::create::string(path)));
376378 }
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 }
377392 values.extend(layout_values(&Default::default(), &attachment.layout)?.0);
378393 let mut changed = BTreeMap::new();
379394 let mut file = PropertyObject::file(ids.file, &payload_reference(ids.payload), extension)?;
......@@ -400,6 +415,21 @@ pub(crate) fn attachment_changes(
400415 Ok(changed)
401416}
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
403433/// A stored attachment keeps its payload and preview; its shown name, recorded source
404434/// path and icon size change in place, as OneNote's rename does.
405435pub(crate) fn attachment_edit_changes(
......@@ -415,7 +445,7 @@ pub(crate) fn attachment_edit_changes(
415445 .to_vec(),
416446 )];
417447 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"));
419449 }
420450 let name = attachment.filename.as_str();
421451 if name.is_empty() || name.contains(['\0', '/', '\\']) {
......@@ -425,6 +455,11 @@ pub(crate) fn attachment_edit_changes(
425455 }
426456 let mut removed = Vec::new();
427457 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 }
428463 let name = crate::create::string(&attachment.filename);
429464 values.push((0x1c001c22, name.clone()));
430465 values.push((0x1c001d9c, name));
......@@ -457,19 +492,31 @@ pub(crate) fn attachment_edit_changes(
457492 Ok(BTreeMap::from([(object, node)]))
458493}
459494
460/// Creates stroke objects (numbered from `first`) and one drawing-attribute object per
461/// distinct pen; returns the compact references for the data node's stroke list.
495/// Creates stroke objects (numbered on from the page's last stroke) and one
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.
462498fn write_strokes(
499 active: &ActivePage<'_>,
463500 changed: &mut Changes,
464501 data_object: &mut PropertyObject,
465502 strokes: &[(ExGuid, &InkStroke)],
466 first: usize,
503 shape: bool,
467504 filetime: u64,
468505) -> 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);
469516 let mut styles: Vec<(InkPen, ExGuid)> = Vec::new();
470517 let mut references = Vec::new();
471518 for (offset, (stroke_id, stroke)) in strokes.iter().enumerate() {
472 let pen = InkPen::of(stroke);
519 let pen = InkPen::of(stroke, shape);
473520 let style = match styles.iter().find(|(known, _)| *known == pen) {
474521 Some((_, id)) => *id,
475522 None => {
......@@ -488,13 +535,14 @@ fn write_strokes(
488535 id
489536 }
490537 };
538 let values = if shape {
539 stroke_path(stroke)?
540 } else {
541 stroke_values(stroke, first + offset as u32 + 1, filetime)?
542 };
491543 let mut object = PropertyObject {
492544 jcid: 0x20047,
493 bytes: crate::create::properties(&stroke_values(
494 stroke,
495 (first + offset) as u32 + 1,
496 filetime,
497 )?)?,
545 bytes: crate::create::properties(&values)?,
498546 global_ids: std::sync::Arc::new(BTreeMap::from([(0, stroke_id.guid)])),
499547 };
500548 object.reference(*stroke_id)?;
......@@ -506,7 +554,8 @@ fn write_strokes(
506554 Ok(references)
507555}
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.
510559#[derive(PartialEq)]
511560struct InkPen {
512561 width: u32,
......@@ -514,22 +563,31 @@ struct InkPen {
514563 color: Option<u32>,
515564 transparency: Option<u8>,
516565 pen_tip: Option<u8>,
566 raster_operation: Option<u8>,
567 shape: bool,
517568}
518569
519570impl InkPen {
520 fn of(stroke: &InkStroke) -> Self {
571 fn of(stroke: &InkStroke, shape: bool) -> Self {
521572 Self {
522573 width: (stroke.width * 2540.0 / 72.0).to_bits(),
523574 height: (stroke.height * 2540.0 / 72.0).to_bits(),
524575 color: stroke.color,
525576 transparency: stroke.transparency,
526577 pen_tip: stroke.pen_tip,
578 raster_operation: stroke.raster_operation,
579 shape,
527580 }
528581 }
529582
530583 fn values(&self) -> Values {
584 let dimensions = if self.shape {
585 crate::page::ink::SHAPE_DIMENSIONS
586 } else {
587 crate::page::ink::DIMENSIONS
588 };
531589 let mut values: Values = vec![
532 (0x1c00340a, crate::page::ink::DIMENSIONS.to_vec()),
590 (0x1c00340a, dimensions.to_vec()),
533591 (0x1400340c, self.height.to_le_bytes().to_vec()),
534592 (0x1400340d, self.width.to_le_bytes().to_vec()),
535593 ];
......@@ -542,11 +600,17 @@ impl InkPen {
542600 if let Some(tip) = self.pen_tip {
543601 values.push((0x0c003412, vec![tip]));
544602 }
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 }
545609 values
546610 }
547611}
548612
549fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values, Error> {
613fn stroke_path(stroke: &InkStroke) -> Result<Values, Error> {
550614 if stroke.points.is_empty() {
551615 return Err(invalid("A stroke needs at least one point"));
552616 }
......@@ -562,27 +626,32 @@ fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values
562626 "Stroke points and pen size must be finite and positive",
563627 ));
564628 }
565 let left = stroke
566 .points
567 .iter()
568 .map(|p| p[0])
569 .fold(f32::INFINITY, f32::min);
570 let top = stroke
571 .points
572 .iter()
573 .map(|p| p[1])
574 .fold(f32::INFINITY, f32::min);
629 Ok(vec![(0x1c00340b, stroke.packet())])
630}
631
632/// A drawn stroke as OneNote stores one: its path, index on the page, language, identity,
633/// time and half-inch origin, and a bias of 2 for a highlighter.
634fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values, Error> {
635 let mut values = stroke_path(stroke)?;
636 let [left, top] = [0, 1].map(|axis| {
637 stroke
638 .points
639 .iter()
640 .map(|p| p[axis])
641 .fold(f32::INFINITY, f32::min)
642 });
575643 let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec();
576644 origin.extend_from_slice(&((top - stroke.height / 2.0) / 36.0).to_le_bytes());
577 Ok(vec![
578 (0x1c00340b, stroke.packet()),
645 let bias = if stroke.raster_operation == Some(9) { 2 } else { 0 };
646 values.extend([
579647 (0x14003419, index.to_le_bytes().to_vec()),
580648 (0x1000341b, 0x409_u16.to_le_bytes().to_vec()),
581 (0x0c00341c, vec![0]),
649 (0x0c00341c, vec![bias]),
582650 (0x1c00341a, crate::write::fresh_guid()?.to_vec()),
583651 (0x1c00341d, filetime.to_le_bytes().to_vec()),
584652 (0x1c00345b, origin),
585 ])
653 ]);
654 Ok(values)
586655}
587656
588657/// 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(
614683 global_ids: std::sync::Arc::new(BTreeMap::from([(0, data.guid)])),
615684 };
616685 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 )?;
618694 data_object.set(&[(0x24003416, &references)])?;
619695 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()));
620704 let mut object = PropertyObject {
621705 jcid: 0x60014,
622 bytes: crate::create::properties(&[
623 (0x14001d7a, modified.to_vec()),
624 (0x14001d4e, 1u32.to_le_bytes().to_vec()),
625 ])?,
706 bytes: crate::create::properties(&values)?,
626707 global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])),
627708 };
628709 object.reference(id)?;
......@@ -641,10 +722,12 @@ pub(crate) fn strokes_changes(
641722 kept: &[ExGuid],
642723 added: &[(ExGuid, &InkStroke)],
643724) -> 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) {
645726 Some(crate::document::Kind::Ink {
646 data: Some(data), ..
647 }) => *data,
727 data: Some(data),
728 shape_kind,
729 ..
730 }) => (*data, shape_kind.is_some()),
648731 _ => return Err(invalid("Stored ink has no stroke data to rewrite")),
649732 };
650733 let (modified, filetime) = crate::create::current_timestamps()?;
......@@ -657,10 +740,11 @@ pub(crate) fn strokes_changes(
657740 references.extend(data_object.reference(*id)?);
658741 }
659742 references.extend(write_strokes(
743 active,
660744 &mut changed,
661745 &mut data_object,
662746 added,
663 kept.len(),
747 shape,
664748 filetime,
665749 )?);
666750 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>)
225225
226226/// What the writers keep as stored, checked before any op: titles, unsupported objects,
227227/// definitions other than lists and tags, outline roles, paragraph styles and formats,
228/// recording links, fields and content types.
228/// fields and content types.
229229pub(crate) fn validate(
230230 before: &Page,
231231 after: &Page,
......@@ -309,17 +309,13 @@ pub(crate) fn validate(
309309 let Some(previous) = old.paragraphs.get(id) else {
310310 continue;
311311 };
312 // A join moves a text object, with its style and recording link, into an emptied
313 // paragraph; one new to the page starts as a copy without the link.
312 // A join moves a text object, with its style, into an emptied paragraph.
314313 let owner = paragraph.text().map(|text| owners.get(&text.id));
315 let (style, media) = match owner {
316 Some(Some(owner)) => (owner.style, owner.media.clone()),
317 Some(None) => (paragraph.style, Default::default()),
318 None => (previous.style, previous.media.clone()),
314 let style = match owner {
315 Some(Some(owner)) => owner.style,
316 Some(None) => paragraph.style,
317 None => previous.style,
319318 };
320 if paragraph.media != media {
321 return Err(invalid("Recording annotations cannot be edited"));
322 }
323319 if paragraph.style != style || paragraph.format != previous.format {
324320 return Err(invalid(
325321 "Paragraph styles and paragraph formatting cannot be edited",
......@@ -695,6 +691,8 @@ pub(crate) fn same_tags(a: &[Tag], b: &[Tag]) -> bool {
695691 (
696692 t.definition,
697693 t.action_type,
694 t.shape,
695 t.property_status,
698696 t.status,
699697 t.created,
700698 t.completed,
......@@ -745,6 +743,7 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph {
745743 stripped.lists.clear();
746744 stripped.tags.clear();
747745 stripped.collapsed = false;
746 stripped.media = Default::default();
748747 match &mut stripped.content {
749748 ParagraphContent::Text(text) => text.tags.clear(),
750749 ParagraphContent::Table(table) => {
......@@ -820,6 +819,7 @@ impl Lowering {
820819 self.styles(after, new)?;
821820 self.lists(after, new)?;
822821 self.tags(after, new)?;
822 self.media(new)?;
823823 self.paragraph_formatting(new)?;
824824 self.formatting(old, new)?;
825825 self.layout(old, new)?;
......@@ -1775,9 +1775,9 @@ impl Lowering {
17751775 }
17761776 let mut definitions = Vec::new();
17771777 for tag in tags {
1778 let definition = tag
1779 .definition
1780 .ok_or_else(|| invalid("A note tag names its definition"))?;
1778 let Some(definition) = tag.definition else {
1779 continue;
1780 };
17811781 if let Some(model) = after.definitions.get(&definition)
17821782 && !definitions.iter().any(|(id, _)| *id == definition)
17831783 {
......@@ -1794,6 +1794,20 @@ impl Lowering {
17941794 Ok(())
17951795 }
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
17971811 fn paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> {
17981812 for (id, paragraph) in &new.paragraphs {
17991813 let Some(text) = paragraph.text() else {
crates/onestore/src/op/mod.rs+8-2
......@@ -6,8 +6,8 @@ use crate::{
66 ExGuid, OutlineEdit, PageCreation, PageEdit, TextAttribute,
77 document::{Layout, Tag},
88 page::{
9 Definition, InkStroke, Page, PageObject, PageParagraph, Paragraph, TableCell, TableColumn,
10 TableRow,
9 Definition, InkStroke, MediaIndex, Page, PageObject, PageParagraph, Paragraph, TableCell,
10 TableColumn, TableRow,
1111 },
1212};
1313use serde::{Deserialize, Serialize};
......@@ -151,6 +151,12 @@ pub enum PageOp {
151151 style: ExGuid,
152152 definition: Definition,
153153 },
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 },
154160 /// Replaces a paragraph's list nodes; a node another paragraph owns is copied.
155161 List {
156162 paragraph: ExGuid,
crates/onestore/src/op/model.rs+16
......@@ -468,6 +468,7 @@ fn stroked(stroke: &crate::page::InkStroke) -> crate::page::InkStroke {
468468fn inked(ink: &crate::page::Ink) -> crate::page::Ink {
469469 crate::page::Ink {
470470 strokes: ink.strokes.iter().map(stroked).collect(),
471 shape: ink.shape.as_ref().map(crate::page::ink::InkShape::snapped),
471472 ..ink.clone()
472473 }
473474}
......@@ -1042,6 +1043,21 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
10421043 text.text = Paragraph::from_runs(runs);
10431044 }
10441045 }
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 }
10451061 PageOp::List { paragraph, lists } => {
10461062 paragraph_mut(page, *paragraph)?.lists = lists.iter().map(|(id, _)| *id).collect();
10471063 for (id, definition) in lists {
crates/onestore/src/op/properties.rs+91-38
......@@ -5,7 +5,7 @@ use crate::{
55 Error, ExGuid, PropertySets, Value,
66 active::{ActivePage, Changes},
77 document::{Kind, Tag},
8 page::Definition,
8 page::{Definition, MediaIndex},
99 write::PropertyObject,
1010};
1111use std::collections::{BTreeMap, BTreeSet};
......@@ -165,54 +165,78 @@ fn tag_definition_values(definition: &Definition) -> Result<Values, Error> {
165165 Ok(values)
166166}
167167
168/// Replaces the note tags of `object`. Each entry names the stored identity of its tag's
169/// definition and, where the tag does not carry its action type or the page lacks the
170/// definition, the definition itself.
168/// Replaces the note tags of `object`. A normal tag comes with its definition where the page
169/// lacks it; a task tag has none and stores its own icon, in the order OneNote writes it.
171170pub(crate) fn tag_changes(
172171 active: &ActivePage<'_>,
173172 object: ExGuid,
174 tags: &[(ExGuid, &Tag, Option<&Definition>)],
173 tags: &[(&Tag, Option<&Definition>)],
175174) -> Result<Changes, Error> {
176175 let raw = &active.live.revision;
177176 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();
179178 let mut action_types = BTreeSet::new();
180 for (written, tag, definition) in tags {
181 let action_type = if tag.status & 4 != 0 {
182 tag.action_type
183 } else {
184 match definition.map(|d| &d.kind) {
185 Some(Kind::TagDefinition { action_type, .. }) => *action_type,
186 _ => return Err(invalid("A note tag must reference a tag definition")),
179 for (tag, definition) in tags {
180 let dates = [
181 (0x1400346e, tag.created),
182 (0x1400346f, tag.completed),
183 (0x1400346a, tag.start),
184 (0x1400346b, tag.due),
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"));
187197 }
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")),
188222 };
189223 if !action_types.insert(action_type.unwrap_or(0)) {
190224 return Err(invalid("An element holds one note tag per action type"));
191225 }
192 let index = match definitions.iter().position(|(id, _)| id == written) {
193 Some(index) => index,
194 None => {
195 let values = if raw.objects.contains_key(written) {
196 None
197 } else {
198 let definition = definition
199 .ok_or_else(|| invalid("A note tag references a missing tag definition"))?;
200 Some(tag_definition_values(definition)?)
201 };
202 definitions.push((*written, values));
203 definitions.len() - 1
204 }
205 };
206 let mut fields: Values = Vec::new();
226 if !definitions.iter().any(|(id, _)| *id == written) {
227 let values = if raw.objects.contains_key(&written) {
228 None
229 } else {
230 let definition = definition
231 .ok_or_else(|| invalid("A note tag references a missing tag definition"))?;
232 Some(tag_definition_values(definition)?)
233 };
234 definitions.push((written, values));
235 }
207236 if let Some(action_type) = tag.action_type {
208237 fields.push((0x10003463, action_type.to_le_bytes().to_vec()));
209238 }
210 for (id, value) in [
211 (0x1400346e, tag.created),
212 (0x1400346f, tag.completed),
213 (0x1400346a, tag.start),
214 (0x1400346b, tag.due),
215 ] {
239 for (id, value) in dates {
216240 if let Some(value) = value {
217241 fields.push((id, value.to_le_bytes().to_vec()));
218242 }
......@@ -221,7 +245,7 @@ pub(crate) fn tag_changes(
221245 if let Some(task) = tag.task_id {
222246 fields.push((0x1c003469, task.to_vec()));
223247 }
224 sets.push((index, fields));
248 sets.push((Some(written), fields));
225249 }
226250 let parents = active.editable_parents(object)?;
227251 let modified = crate::create::current_timestamps()?.0.to_le_bytes();
......@@ -240,9 +264,12 @@ pub(crate) fn tag_changes(
240264 }
241265 let mut target = PropertyObject::from_object(&raw.objects[&object])?;
242266 let mut encoded = Vec::new();
243 for (index, fields) in &sets {
244 let reference = target.reference(definitions[*index].0)?;
245 let mut set = vec![(0x20003488, reference.to_vec())];
267 for (definition, fields) in &sets {
268 let mut set = Vec::new();
269 if let Some(definition) = definition {
270 let reference = target.reference(*definition)?;
271 set.push((0x20003488, reference.to_vec()));
272 }
246273 set.extend(fields.iter().cloned());
247274 encoded.push(set);
248275 }
......@@ -380,3 +407,29 @@ pub(crate) fn paragraph_format_changes(
380407 crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?;
381408 Ok(changed)
382409}
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 {
834834 text.tags.push(crate::document::Tag {
835835 definition: Some(definition),
836836 action_type: None,
837 shape: None,
838 property_status: None,
837839 status,
838840 created: Some(1_262_401_445),
839841 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 {
11591161 layout: Layout::default(),
11601162 strokes: vec![stroke(rng)],
11611163 groups: Vec::new(),
1164 shape: None,
11621165 })
11631166 };
11641167 page.objects.insert(at, object);
......@@ -1251,6 +1254,8 @@ fn stroke(rng: &mut Rng) -> InkStroke {
12511254 color: Some(0x0000ff),
12521255 transparency: None,
12531256 pen_tip: None,
1257 // Every other stroke a highlighter's.
1258 raster_operation: (x as u32).is_multiple_of(2).then_some(9),
12541259 }
12551260}
12561261
......@@ -1595,6 +1600,8 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
15951600 tags: vec![crate::document::Tag {
15961601 definition: Some(definition),
15971602 action_type: None,
1603 shape: None,
1604 property_status: None,
15981605 status: 0,
15991606 created: Some(1_262_401_445),
16001607 completed: None,
......@@ -1639,15 +1646,20 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
16391646 }),
16401647 before: None,
16411648 },
1642 _ => PageOp::Add {
1643 object: PageObject::Ink(Ink {
1644 id: id(),
1645 layout: Layout::default(),
1646 strokes: vec![stroke(rng)],
1647 groups: Vec::new(),
1648 }),
1649 before: None,
1650 },
1649 _ => {
1650 let stroke = stroke(rng);
1651 let ends = [stroke.points[0], stroke.points[stroke.points.len() - 1]];
1652 PageOp::Add {
1653 object: PageObject::Ink(Ink {
1654 id: id(),
1655 layout: Layout::default(),
1656 strokes: vec![stroke],
1657 groups: Vec::new(),
1658 shape: Some(crate::page::ink::InkShape::Line(ends)),
1659 }),
1660 before: None,
1661 }
1662 }
16511663 })
16521664}
16531665
crates/onestore/src/page/ink.rs+200
......@@ -32,6 +32,119 @@ pub struct Ink {
3232 pub strokes: Vec<InkStroke>,
3333 /// Nested ink containers, as newer OneNote versions group handwriting.
3434 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 },
35148}
36149
37150#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
......@@ -48,6 +161,10 @@ pub struct InkStroke {
48161 pub transparency: Option<u8>,
49162 /// 0 is a round (ball) tip, 1 a rectangle.
50163 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>,
51168}
52169
53170impl Ink {
......@@ -61,6 +178,9 @@ impl Ink {
61178 data,
62179 scale_x,
63180 scale_y,
181 shape_kind,
182 line,
183 anchors,
64184 } = &node.kind
65185 else {
66186 unreachable!()
......@@ -98,6 +218,7 @@ impl Ink {
98218 layout: node.layout.clone(),
99219 strokes,
100220 groups,
221 shape: InkShape::read(*shape_kind, line.as_deref(), anchors.as_deref()),
101222 })
102223 }
103224
......@@ -151,6 +272,7 @@ impl InkStroke {
151272 color,
152273 transparency,
153274 pen_tip,
275 raster_operation,
154276 ..
155277 } = &style.kind
156278 else {
......@@ -190,10 +312,78 @@ impl InkStroke {
190312 color: *color,
191313 transparency: *transparency,
192314 pen_tip: *pen_tip,
315 raster_operation: *raster_operation,
193316 })
194317 }
195318}
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
197387/// ISF multi-byte decoding: a count, then that many 7-bit little-endian varints whose low bit
198388/// is the sign.
199389fn multi_byte(bytes: &[u8]) -> Option<Vec<i64>> {
......@@ -268,6 +458,15 @@ pub(crate) const DIMENSIONS: [u8; 64] = [
268458 0x00, 0x00, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x8b, 0xc5, 0xe2, 0x41,
269459];
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
271470#[cfg(test)]
272471mod tests {
273472 use super::*;
......@@ -282,6 +481,7 @@ mod tests {
282481 color: None,
283482 transparency: None,
284483 pen_tip: None,
484 raster_operation: None,
285485 };
286486 let values = multi_byte(&stroke.packet()).unwrap();
287487 assert_eq!(values.len(), 6);
crates/onestore/src/page/mod.rs+26-6
......@@ -282,21 +282,34 @@ pub struct Attachment {
282282 pub bytes: Option<Arc<[u8]>>,
283283 #[serde(with = "payload")]
284284 pub preview: Option<Arc<[u8]>>,
285 /// Set when the file is an audio or video recording OneNote captured; annotations on
286 /// the page refer to it by identity (`PageParagraph::media`).
285 /// Set when the file is audio or video OneNote plays; notes on the page refer to it by
286 /// identity (`PageParagraph::media`).
287287 pub recording: Option<Recording>,
288288}
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.
291291#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
292292pub struct Recording {
293293 pub id: [u8; 16],
294294 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 }
295309}
296310
297311/// 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 through
299/// edits; OneNote alone creates them while recording.
312/// `time_ms` when the paragraph is chosen, and links what is written while it records.
300313#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
301314pub struct MediaIndex {
302315 pub recordings: Vec<[u8; 16]>,
......@@ -328,6 +341,7 @@ impl Attachment {
328341 source_path,
329342 recording_id,
330343 recording_type,
344 recording_duration,
331345 icon_width,
332346 icon_height,
333347 } = &node.kind
......@@ -357,7 +371,10 @@ impl Attachment {
357371 preview: payload(preview)?,
358372 recording: recording_id.map(|id| Recording {
359373 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,
361378 }),
362379 })
363380 }
......@@ -1369,6 +1386,7 @@ mod tests {
13691386 margin_origin_x: Some(36.0),
13701387 margin_origin_y: Some(12.0),
13711388 rtl: None,
1389 recordings: Vec::new(),
13721390 });
13731391 page.children.push(id(2));
13741392 let mut outline = element(Kind::Outline {
......@@ -1621,6 +1639,8 @@ mod tests {
16211639 source.nodes.get_mut(&id(4)).unwrap().tags.push(Tag {
16221640 definition: Some(id(7)),
16231641 action_type: None,
1642 shape: None,
1643 property_status: None,
16241644 status: 0,
16251645 created: Some(123),
16261646 completed: None,
crates/onestore/tests/page_ink.rs+371
......@@ -88,6 +88,7 @@ fn stroke(points: &[[f32; 2]], color: Option<u32>) -> InkStroke {
8888 color,
8989 transparency: None,
9090 pen_tip: None,
91 raster_operation: None,
9192 }
9293}
9394
......@@ -109,6 +110,7 @@ fn drawing() -> Ink {
109110 stroke(&[[300.0, 120.0], [360.0, 180.0]], Some(0x0000ff)),
110111 ],
111112 groups: Vec::new(),
113 shape: None,
112114 }
113115}
114116
......@@ -232,3 +234,372 @@ fn stored_strokes_keep_their_paths() {
232234 }
233235 assert!(ops::saved(NATIVE, space, &moved).is_err());
234236}
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() {
103103 assert_eq!(recording(&stored), recording(&before));
104104 assert_eq!(stored.objects.len(), before.objects.len());
105105
106 let mut annotated = after.clone();
107 let outline = annotated
108 .objects
109 .iter_mut()
110 .find_map(|object| match object {
111 PageObject::Outline(outline) if !outline.title => Some(outline),
112 _ => None,
113 })
114 .unwrap();
115 let last = outline.paragraphs.last_mut().unwrap();
116 last.media = recording(&before).1;
117 assert!(ops::saved(written.as_slice(), space, &annotated).is_err());
106 // A note linked to the recording, then unlinked, as Snowbound links notes taken while
107 // it records.
108 let annotate = |page: &Page, media: MediaIndex| {
109 let mut page = page.clone();
110 for object in &mut page.objects {
111 if let PageObject::Outline(outline) = object
112 && !outline.title
113 {
114 outline.paragraphs.last_mut().unwrap().media = media.clone();
115 }
116 }
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);
118129 let mut recaptured = after.clone();
119130 for object in &mut recaptured.objects {
120131 if let PageObject::Outline(outline) = object {
crates/onestore/tests/page_tags.rs+48
......@@ -113,6 +113,8 @@ fn tag(definition: ExGuid, completed: bool) -> Tag {
113113 Tag {
114114 definition: Some(definition),
115115 action_type: None,
116 shape: None,
117 property_status: None,
116118 status: u16::from(completed),
117119 created: Some(1_262_401_445),
118120 completed: completed.then_some(1_262_405_106),
......@@ -305,3 +307,49 @@ fn new_definitions_and_tags_publish_on_a_fresh_page() {
305307 .unwrap();
306308 }
307309}
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<'_>) {
256256 text.tags.push(onestore::document::Tag {
257257 definition: Some(definition),
258258 action_type: None,
259 shape: None,
260 property_status: None,
259261 status: u16::from(bytes.next().unwrap_or(0) % 2),
260262 created: Some(1_262_401_445),
261263 completed: None,
crates/snowbound/src/library.rs+52-16
......@@ -33,10 +33,6 @@ pub fn on_background(notify: impl Fn() + Send + Sync + 'static) {
3333 let _ = NOTIFY.set(Box::new(notify));
3434}
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
4036fn notify_background() {
4137 if let Some(notify) = NOTIFY.get() {
4238 notify();
......@@ -171,6 +167,8 @@ pub struct Library {
171167 /// Syncs the notebook's sections while no tab shows them, as OneNote syncs every
172168 /// section of an open notebook.
173169 pub background: Option<Arc<Background>>,
170 /// Reports the changes to a folder on this computer to `background`.
171 watch: Option<Arc<crate::watch::Watch>>,
174172}
175173
176174impl Library {
......@@ -192,10 +190,15 @@ impl Library {
192190 }
193191 }
194192 let notebook = Notebook::open(location, cache);
193 let (background, watch) = match &notebook {
194 Ok(notebook) => local_background(notebook, location),
195 Err(_) => (None, None),
196 };
195197 Self {
196198 location: location.to_owned(),
197199 name: file_name(Path::new(location)),
198 background: notebook.as_ref().ok().and_then(local_background),
200 background,
201 watch,
199202 notebook: notebook.map(Some).map_err(|error| error.to_string()),
200203 server: None,
201204 notice,
......@@ -219,7 +222,6 @@ impl Library {
219222 let background = Background::smb(
220223 &server.mount.root,
221224 LIMIT,
222 BACKGROUND,
223225 move || connect.connect(),
224226 notify_background,
225227 )
......@@ -234,6 +236,7 @@ impl Library {
234236 notice: None,
235237 cache: cache.to_owned(),
236238 background: Some(Arc::new(background)),
239 watch: None,
237240 })
238241 }
239242
......@@ -260,15 +263,18 @@ impl Library {
260263 notice: self.notice.clone(),
261264 cache: self.cache.clone(),
262265 background: self.background.clone(),
266 watch: self.watch.clone(),
263267 }
264268 }
265269
266270 /// A notebook `Notebook::create` just made in the folder at `location`.
267271 pub fn created(location: &str, notebook: Notebook, cache: &Path) -> Self {
272 let (background, watch) = local_background(&notebook, location);
268273 Self {
269274 location: location.to_owned(),
270275 name: file_name(Path::new(location)),
271 background: local_background(&notebook),
276 background,
277 watch,
272278 notebook: Ok(Some(notebook)),
273279 server: None,
274280 notice: None,
......@@ -286,6 +292,7 @@ impl Library {
286292 notice: None,
287293 cache: cache.to_owned(),
288294 background: None,
295 watch: None,
289296 }
290297 }
291298
......@@ -296,7 +303,8 @@ impl Library {
296303 path: &str,
297304 notify: impl Fn() + Send + 'static,
298305 ) -> 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.
300308 let deadline = Instant::now() + TIMEOUT * 3;
301309 let notify = Arc::new(Mutex::new(notify));
302310 let notifier = || {
......@@ -335,7 +343,12 @@ impl Library {
335343 (Err(error), _) => return Err(error.clone().into()),
336344 };
337345 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 {
339352 std::thread::sleep(Duration::from_millis(50));
340353 }
341354 opened => break opened?,
......@@ -446,15 +459,37 @@ pub fn recycle_bin(path: &str) -> bool {
446459 path.rsplit('/').next() == Some("OneNote_RecycleBin")
447460}
448461
449/// A mounted notebook's background sync, following Work Offline.
450fn local_background(notebook: &Notebook) -> Option<Arc<Background>> {
451 let background = notebook
452 .background(BACKGROUND, notify_background)
462/// A mounted notebook's background sync, following Work Offline, and the watch that reports
463/// the folder's changes to it. Only a folder on this computer reports every change; one on a
464/// network volume is checked more often and kept in offline copies, as on a share.
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)
453483 .inspect_err(|error| eprintln!("Background sync did not start: {error}"))
454 .ok()?;
484 .ok()
485 else {
486 return (None, None);
487 };
455488 background.set_offline(offline());
456489 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))
458493}
459494
460495pub fn file_name(path: &Path) -> String {
......@@ -483,7 +518,7 @@ fn tabs(catalog: &Folder) -> Vec<Tab> {
483518 .sections
484519 .iter()
485520 .filter_map(|section| match &section.state {
486 SectionState::Readable { name, color } => Some(Tab {
521 SectionState::Readable { name, color, .. } => Some(Tab {
487522 name: section_name(&section.path, name),
488523 path: section.path.clone(),
489524 color: *color,
......@@ -668,6 +703,7 @@ mod tests {
668703 notice: None,
669704 cache: PathBuf::new(),
670705 background: None,
706 watch: None,
671707 };
672708 let shown = |root, file| library(root).local(Path::new(file));
673709 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::{
2525const APP_ID: &str = "snowbound";
2626/// The title bar's leading margin; the window controls sit at its trailing end.
2727pub const LEADING: f32 = 8.0;
28/// The margin past the window controls.
29pub const TRAILING: f32 = 8.0;
2830/// How far inside the window's edges a press resizes it, and how far along them a corner
2931/// reaches, in logical pixels.
3032const EDGE: f32 = 5.0;
crates/snowbound/src/macos.rs+2
......@@ -86,6 +86,8 @@ fn ns_window(window: &Window) -> Retained<AnyObject> {
8686
8787/// Room the traffic lights take at the title bar's leading edge.
8888pub 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;
8991/// How far AppKit rounds a window's corners.
9092pub const CORNER_RADIUS: f32 = 10.0;
9193
crates/snowbound/src/main.rs+36-1
......@@ -31,6 +31,7 @@ mod surface;
3131mod sync;
3232mod tags;
3333mod templates;
34mod watch;
3435
3536use canvas::gpu::page::PageScene;
3637use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility};
......@@ -1200,6 +1201,13 @@ impl State {
12001201 if self.session.is_some() {
12011202 self.page_tools(&theme);
12021203 }
1204 self.ui.leaf(
1205 "trail",
1206 Spec {
1207 size: [px(self.trailing() - FRAME), px(1.0)],
1208 ..Spec::default()
1209 },
1210 );
12031211 self.ui.close();
12041212
12051213 self.ui.open_as(
......@@ -1268,6 +1276,23 @@ impl State {
12681276 }
12691277 }
12701278 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 }
12711296 let signal = self.ui.signal(page());
12721297 let focused = self.ui.window_focused && self.ui.focused() == Some(page());
12731298 if focused != self.page_focused {
......@@ -1411,6 +1436,16 @@ impl State {
14111436 );
14121437 }
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
14141449 /// Whether the chrome's empty space drags the window: where it is the title bar, or
14151450 /// over the title's gradient.
14161451 fn chrome_drags(&self) -> bool {
......@@ -1468,7 +1503,7 @@ impl State {
14681503 self.ui.leaf(
14691504 "trail",
14701505 Spec {
1471 size: [px(FRAME - GAP), px(1.0)],
1506 size: [px(self.trailing() - GAP), px(1.0)],
14721507 ..Spec::default()
14731508 },
14741509 );
crates/snowbound/src/manage.rs+5-1
......@@ -189,9 +189,13 @@ impl State {
189189 Ok(())
190190 }
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.
193194 pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) {
194195 self.notebooks.retain(|open| !Arc::ptr_eq(open, library));
196 if let Some(background) = &library.background {
197 background.discard();
198 }
195199 let shown = |shown: &Arc<Library>| shown.location == library.location;
196200 if self.sectionless.as_ref().is_some_and(shown)
197201 || self
crates/snowbound/src/meeting.rs+2
......@@ -164,6 +164,8 @@ pub fn content(page: &Page) -> Result<Page, Box<dyn Error>> {
164164 vec![Tag {
165165 definition: Some(to_do),
166166 action_type: None,
167 shape: None,
168 property_status: None,
167169 status: 0,
168170 created: Some(created),
169171 completed: Some(0),
crates/snowbound/src/sidebar.rs+1-1
......@@ -260,7 +260,7 @@ fn folder(
260260 };
261261 for section in &folder.sections {
262262 let (name, color, readable) = match &section.state {
263 SectionState::Readable { name, color } => (
263 SectionState::Readable { name, color, .. } => (
264264 crate::library::section_name(&section.path, name),
265265 *color,
266266 true,
crates/snowbound/src/tags.rs+8-4
......@@ -103,13 +103,13 @@ fn label(ui: &mut Ui, part: &str, text: &str, tag: &NoteTag) {
103103 );
104104}
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]) {
107107 ui.leaf(
108108 part,
109109 Spec {
110110 size: [px(SIDE + 4.0), px(ROW)],
111111 icon: art,
112 color: Some([1.0; 4]),
112 color: Some(tint),
113113 center: true,
114114 ..Spec::default()
115115 },
......@@ -324,7 +324,11 @@ impl State {
324324 ..Spec::default()
325325 },
326326 );
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 }
328332 let text = match commands::tag_chord(place) {
329333 Some(chord) => format!("{} ({})", tag.label, chord.label(Platform::CURRENT)),
330334 None => tag.label.clone(),
......@@ -585,7 +589,7 @@ fn tag_editor(
585589 ..Spec::default()
586590 },
587591 );
588 icon(ui, "icon", artwork(tag));
592 icon(ui, "icon", artwork(tag), [1.0; 4]);
589593 label(ui, "label", &tag.label, tag);
590594 ui.leaf(
591595 "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(
138138/// delay, or at once while another has just shown. A press or the wheel hides it until the
139139/// pointer leaves.
140140pub 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) {
141157 let Some(&index) = ui.nodes[*ui.stack.last().unwrap()].children.last() else {
142158 return;
143159 };
......@@ -183,7 +199,7 @@ pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>)
183199 } else {
184200 format!("{title} ({keys})")
185201 };
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();
187203 let theme = &ui.theme;
188204 let spec = Spec {
189205 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()