| author | |
| committer | |
| log | 3b58d8f8a9667e9578deab17b4bcd96e05515ad8 |
| tree | 4c0740d87818583c33a717df08f7ab7af682ddbe |
| parent | c514498e33b81d02e7ad056ab3de8df11182d1fe |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- 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.5130 files changed, 4572 insertions(+), 634 deletions(-)
arc/sync.md+29-7| ... | ... | @@ -176,13 +176,35 @@ online again. |
| 176 | 176 | |
| 177 | 177 | ## Sections that aren't open |
| 178 | 178 | |
| 179 | OneNote keeps every section of an open notebook in sync, not just the one on screen: it | |
| 180 | reads a section another client changed within seconds and publishes a closed section's | |
| 181 | offline edits the moment the share is back. Snowbound's `session::Background` does the | |
| 182 | same with one thread per notebook. Each round reads every section file's stamp. Only a | |
| 183 | section whose replica has edits waiting, or whose file moved past the replica's base, | |
| 184 | has its replica opened for the usual sync steps, and it is closed again afterwards. The | |
| 185 | open section is left to its own session. | |
| 179 | OneNote keeps every section of an open notebook in sync and on this computer, not just | |
| 180 | the one on screen. Watched in the lab with a notebook of 200 sections on Samba, OneNote | |
| 181 | 2010: | |
| 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 | ||
| 192 | Snowbound's `session::Background` does the same, with one thread per notebook: | |
| 193 | ||
| 194 | ```text | |
| 195 | share ─ CHANGE_NOTIFY (tree) ─► the sections it names are due in 1 s ─► stamp ─► moved: rebase | |
| 196 | first pass: one section every 100 ms; a section without a replica gets its offline copy | |
| 197 | failing: again in 31 s otherwise: again in an hour, against a lost notification | |
| 198 | ``` | |
| 199 | ||
| 200 | A check costs one stamp read. Only a section whose replica has edits waiting, or whose file | |
| 201 | moved past the replica's base, has its replica opened for the usual sync steps, and it is | |
| 202 | closed again afterwards; the open section is left to its own session. A connection that | |
| 203 | drops takes its watch with it, so the next one checks every section again. A notebook in a | |
| 204 | folder on this computer has no copies and learns of changes from FSEvents or inotify; one on | |
| 205 | a network volume the system mounted, whose server's changes those never report, keeps copies | |
| 206 | and is checked every 15 seconds. Closing a notebook deletes its copies, except any with | |
| 207 | edits still waiting. | |
| 186 | 208 | |
| 187 | 209 | ## Notebook structure |
| 188 | 210 |
corpus/custom-tags/README.md+3-1| ... | ... | @@ -18,7 +18,9 @@ absolute directory while running the test, then cold-open it with |
| 18 | 18 | `native/shapes.one` is OneNote 2010's own work in a lab clone: |
| 19 | 19 | |
| 20 | 20 | - "Shapes 1-72" and "Shapes 73-143" tag a paragraph with every NoteTagShape, set through |
| 21 | COM (its definitions store NoteTagPropertyStatus 0). | |
| 21 | COM (its definitions store NoteTagPropertyStatus 0). `shapes-1-72.png` and | |
| 22 | `shapes-73-143.png` are OneNote's render of them at 100%, which | |
| 23 | `crates/canvas/src/gpu/mod.rs` pins each shape's art to. | |
| 22 | 24 | - "Custom tags" holds a tag made with New Tag (Snow check, as above), applied at the top |
| 23 | 25 | of the list (action type 0), then after moving it below To Do (a second definition, |
| 24 | 26 | action type 1), with To Do applied from both places. The UI stores status 15 and 9. |
corpus/custom-tags/native/shapes-1-72.png created| Binary files /dev/null and b/corpus/custom-tags/native/shapes-1-72.png differ |
corpus/custom-tags/native/shapes-73-143.png created| Binary files /dev/null and b/corpus/custom-tags/native/shapes-73-143.png differ |
corpus/ink-tools/native-ui/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/ink-tools/native-ui/notebook/Open Notebook.onetoc2 differ |
corpus/ink-tools/native-ui/notebook/ink.one created| Binary files /dev/null and b/corpus/ink-tools/native-ui/notebook/ink.one differ |
corpus/task-tags/README.md created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | # Outlook task tags through the editor | |
| 2 | ||
| 3 | `native/` is OneNote 2010 authoring `tools/native/pages.ps1` input (two identical | |
| 4 | pages in `Tasks.one`: three paragraphs carrying disabled Outlook tasks, the third | |
| 5 | also a To Do tag, and an untagged one). OneNote stores each task as a note tag | |
| 6 | without a definition, with ActionItemType 100, NoteTagShape 89 and | |
| 7 | NoteTagPropertyStatus 0. | |
| 8 | ||
| 9 | `candidate/` is the output of `task_tags_survive_tag_removal_and_page_delete_undo` | |
| 10 | in `crates/canvas/tests/task_tags.rs`: on "Remove a task" the editor's Remove Tag | |
| 11 | clears the first paragraph's task and toggling To Do off the third keeps its | |
| 12 | task; "Delete and undo" has all its content deleted and the deletion undone, | |
| 13 | each published as its own revision. | |
| 14 | ||
| 15 | `cold/` is a fresh OneNote 2010 read: the first paragraph has no task, the other | |
| 16 | tasks keep their identities, dates and disabled state, and the restored page | |
| 17 | reads as OneNote authored it. `tools/test_task_tags.py` checks this without a | |
| 18 | VM. Regenerate with `SNOWBOUND_TASK_TAGS_EXPORT` set to a new absolute directory | |
| 19 | while 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/task-tags/cold/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-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' | |
| 7 | New-Item -ItemType Directory -Path $inputs | Out-Null | |
| 8 | $app = New-Object -ComObject OneNote.Application | |
| 9 | $notebookId = '' | |
| 10 | try { | |
| 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/task-tags/native/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | # A paragraph's file is named by its paragraph, one on the page by itself. | |
| 103 | $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } | |
| 104 | $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); | |
| 105 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 106 | sha256 = $hash; bytes = $bytes.Length } | |
| 107 | } | |
| 108 | $index++ | |
| 109 | } | |
| 110 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 111 | $all = '' | |
| 112 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 113 | [xml]$finalTree = $all | |
| 114 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 115 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 116 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 117 | } | |
| 118 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 119 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 120 | } catch { | |
| 121 | $failure = $_ | |
| 122 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 123 | throw | |
| 124 | } finally { | |
| 125 | try { | |
| 126 | try { | |
| 127 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 128 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 129 | $app.CloseNotebook($notebookId, $false) | |
| 130 | } | |
| 131 | } catch { | |
| 132 | if ($null -eq $failure) { throw } | |
| 133 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 134 | } | |
| 135 | } finally { | |
| 136 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 137 | } | |
| 138 | $app = $null | |
| 139 | [GC]::Collect() | |
| 140 | [GC]::WaitForPendingFinalizers() | |
| 141 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 142 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 143 | } | |
| 144 | } |
corpus/task-tags/native/source.json created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "path": "Open Notebook.onetoc2", | |
| 4 | "bytes": 1612, | |
| 5 | "sha256": "fafa5dcb3cd827c7edd30c8ea2fbc957a59fb4d2a8b28b667a3c551c8261ab86", | |
| 6 | "mtime_ns": 1790724703557857638 | |
| 7 | }, | |
| 8 | { | |
| 9 | "path": "fixture.json", | |
| 10 | "bytes": 2596, | |
| 11 | "sha256": "7b243286f1ae5147ae8c97d87ae911e82a6ebfd264fc15450c43176067fbf067", | |
| 12 | "mtime_ns": 1790724703583723809 | |
| 13 | }, | |
| 14 | { | |
| 15 | "path": "synthetic.one", | |
| 16 | "bytes": 3348, | |
| 17 | "sha256": "b0662387c3fdc256d8dbe809e3b8f73b3586559696467fb1fff8f589095fa797", | |
| 18 | "mtime_ns": 1790724703558075431 | |
| 19 | } | |
| 20 | ] |
corpus/task-tags/native/teardown.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"absent": true} |
crates/canvas/assets/tags/box-small.svg+6-7| ... | ... | @@ -1,11 +1,10 @@ |
| 1 | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | 2 | <defs> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#91b6f7"/><stop offset="1" stop-color="#6498ef"/></linearGradient> | |
| 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> | |
| 6 | 4 | </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)"/> | |
| 11 | 10 | </svg> |
crates/canvas/assets/tags/checkbox-green.svg+11-17| ... | ... | @@ -1,24 +1,18 @@ |
| 1 | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | 2 | <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"> | |
| 16 | 4 | <stop offset="0" stop-color="#ffffff"/> |
| 17 | <stop offset="1" stop-color="#e2f3da"/> | |
| 5 | <stop offset="1" stop-color="#e7f6e1"/> | |
| 18 | 6 | </linearGradient> |
| 19 | 7 | </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"/> | |
| 23 | 17 | <path d="M3 3H13V13H3Z" fill="url(#layer3)"/> |
| 24 | 18 | </svg> |
crates/canvas/assets/tags/checkbox-yellow.svg+11-17| ... | ... | @@ -1,24 +1,18 @@ |
| 1 | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | 2 | <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"> | |
| 16 | 4 | <stop offset="0" stop-color="#ffffff"/> |
| 17 | <stop offset="1" stop-color="#fcf1d8"/> | |
| 5 | <stop offset="1" stop-color="#fbf8e9"/> | |
| 18 | 6 | </linearGradient> |
| 19 | 7 | </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"/> | |
| 23 | 17 | <path d="M3 3H13V13H3Z" fill="url(#layer3)"/> |
| 24 | 18 | </svg> |
crates/canvas/assets/tags/checkbox.svg+12-17| ... | ... | @@ -1,24 +1,19 @@ |
| 1 | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | 2 | <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"> | |
| 16 | 4 | <stop offset="0" stop-color="#ffffff"/> |
| 17 | <stop offset="1" stop-color="#d6e6f5"/> | |
| 5 | <stop offset="1" stop-color="#daecfe"/> | |
| 18 | 6 | </linearGradient> |
| 19 | 7 | </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"/> | |
| 23 | 18 | <path d="M3 3H13V13H3Z" fill="url(#layer3)"/> |
| 24 | 19 | </svg> |
crates/canvas/assets/tags/flag.svg deleted-10| ... | ... | @@ -1,10 +0,0 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <defs> | |
| 3 | <linearGradient id="cloth" x1="0" y1="2" x2="0" y2="10" gradientUnits="userSpaceOnUse"> | |
| 4 | <stop offset="0" stop-color="#f25c54"/> | |
| 5 | <stop offset="1" stop-color="#c8342c"/> | |
| 6 | </linearGradient> | |
| 7 | </defs> | |
| 8 | <path d="M3 2H4V14H3Z" fill="#6b6b6b"/> | |
| 9 | <path d="M4 2.5C6 1.5 8 3.5 10 2.5C11 2 12 2 13 2.5V9C12 8.5 11 8.5 10 9C8 10 6 8 4 9Z" fill="url(#cloth)"/> | |
| 10 | </svg> |
crates/canvas/assets/tags/mark-arrow-blue.svg+40-4| ... | ... | @@ -1,6 +1,42 @@ |
| 1 | 1 | <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"/> | |
| 6 | 42 | </svg> |
crates/canvas/assets/tags/mark-arrow-green.svg+34-4| ... | ... | @@ -1,6 +1,36 @@ |
| 1 | 1 | <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"/> | |
| 6 | 36 | </svg> |
crates/canvas/assets/tags/mark-arrow-yellow.svg+39-4| ... | ... | @@ -1,6 +1,41 @@ |
| 1 | 1 | <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"/> | |
| 6 | 41 | </svg> |
crates/canvas/assets/tags/mark-exclamation-blue.svg+42-7| ... | ... | @@ -1,9 +1,44 @@ |
| 1 | 1 | <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"/> | |
| 9 | 44 | </svg> |
crates/canvas/assets/tags/mark-exclamation-green.svg+43-7| ... | ... | @@ -1,9 +1,45 @@ |
| 1 | 1 | <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"/> | |
| 9 | 45 | </svg> |
crates/canvas/assets/tags/mark-exclamation-yellow.svg+42-7| ... | ... | @@ -1,9 +1,44 @@ |
| 1 | 1 | <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"/> | |
| 9 | 44 | </svg> |
crates/canvas/assets/tags/mark-flag-blue.svg+53-5| ... | ... | @@ -1,7 +1,55 @@ |
| 1 | 1 | <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"/> | |
| 7 | 55 | </svg> |
crates/canvas/assets/tags/mark-flag-green.svg+56-5| ... | ... | @@ -1,7 +1,58 @@ |
| 1 | 1 | <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"/> | |
| 7 | 58 | </svg> |
crates/canvas/assets/tags/mark-flag-yellow.svg+55-5| ... | ... | @@ -1,7 +1,57 @@ |
| 1 | 1 | <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"/> | |
| 7 | 57 | </svg> |
crates/canvas/assets/tags/mark-one-blue.svg+4-5| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | <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"/> | |
| 7 | 6 | </svg> |
crates/canvas/assets/tags/mark-one-green.svg+4-5| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | <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"/> | |
| 7 | 6 | </svg> |
crates/canvas/assets/tags/mark-one-yellow.svg+4-5| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | <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"/> | |
| 7 | 6 | </svg> |
crates/canvas/assets/tags/mark-person-blue.svg+62-7| ... | ... | @@ -1,9 +1,64 @@ |
| 1 | 1 | <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"/> | |
| 9 | 64 | </svg> |
crates/canvas/assets/tags/mark-person-green.svg+67-7| ... | ... | @@ -1,9 +1,69 @@ |
| 1 | 1 | <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"/> | |
| 9 | 69 | </svg> |
crates/canvas/assets/tags/mark-person-yellow.svg+69-7| ... | ... | @@ -1,9 +1,71 @@ |
| 1 | 1 | <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"/> | |
| 9 | 71 | </svg> |
crates/canvas/assets/tags/mark-star-blue.svg+57-5| ... | ... | @@ -1,7 +1,59 @@ |
| 1 | 1 | <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"/> | |
| 7 | 59 | </svg> |
crates/canvas/assets/tags/mark-star-green.svg+57-5| ... | ... | @@ -1,7 +1,59 @@ |
| 1 | 1 | <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"/> | |
| 7 | 59 | </svg> |
crates/canvas/assets/tags/mark-star-yellow.svg+55-5| ... | ... | @@ -1,7 +1,57 @@ |
| 1 | 1 | <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"/> | |
| 7 | 57 | </svg> |
crates/canvas/assets/tags/mark-three-blue.svg+4-5| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | <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"/> | |
| 7 | 6 | </svg> |
crates/canvas/assets/tags/mark-three-green.svg+4-5| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | <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"/> | |
| 7 | 6 | </svg> |
crates/canvas/assets/tags/mark-three-yellow.svg+4-5| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | <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"/> | |
| 7 | 6 | </svg> |
crates/canvas/assets/tags/mark-two-blue.svg+4-5| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | <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"/> | |
| 7 | 6 | </svg> |
crates/canvas/assets/tags/mark-two-green.svg+4-5| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | <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"/> | |
| 7 | 6 | </svg> |
crates/canvas/assets/tags/mark-two-yellow.svg+4-5| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | <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"/> | |
| 7 | 6 | </svg> |
crates/canvas/assets/tags/star8-green.svg created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | |
| 2 | <path d="M8 1.2V14.8M1.2 8H14.8M3.2 3.2L12.8 12.8M12.8 3.2L3.2 12.8" fill="none" stroke="#5aa83c" stroke-width="1"/> | |
| 3 | <path d="M6.6 8A1.4 1.4 0 1 1 9.4 8A1.4 1.4 0 1 1 6.6 8Z" fill="#3f8a2c"/> | |
| 4 | </svg> |
crates/canvas/src/document.rs+2| ... | ... | @@ -1578,6 +1578,8 @@ mod tests { |
| 1578 | 1578 | nodes[1].tags.push(onestore::document::Tag { |
| 1579 | 1579 | definition: Some(new_id().unwrap()), |
| 1580 | 1580 | action_type: Some(0), |
| 1581 | shape: None, | |
| 1582 | property_status: None, | |
| 1581 | 1583 | status: 1, |
| 1582 | 1584 | created: Some(123), |
| 1583 | 1585 | completed: None, |
crates/canvas/src/editor/evidence.rs+2| ... | ... | @@ -97,6 +97,8 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor { |
| 97 | 97 | node.text_mut().unwrap().tags.push(Tag { |
| 98 | 98 | definition: Some(definition), |
| 99 | 99 | action_type: None, |
| 100 | shape: None, | |
| 101 | property_status: None, | |
| 100 | 102 | status: u16::from(completed), |
| 101 | 103 | created: Some(1), |
| 102 | 104 | completed: Some(u32::from(completed)), |
crates/canvas/src/editor/format.rs+5-1| ... | ... | @@ -769,6 +769,8 @@ impl CanvasEditor { |
| 769 | 769 | Tag { |
| 770 | 770 | definition: Some(id), |
| 771 | 771 | action_type: None, |
| 772 | shape: None, | |
| 773 | property_status: None, | |
| 772 | 774 | status: u16::from(!checkable), |
| 773 | 775 | created, |
| 774 | 776 | completed: if checkable { Some(0) } else { created }, |
| ... | ... | @@ -1809,6 +1811,8 @@ mod tests { |
| 1809 | 1811 | nodes[2].tags.push(Tag { |
| 1810 | 1812 | definition: Some(music), |
| 1811 | 1813 | action_type: None, |
| 1814 | shape: None, | |
| 1815 | property_status: None, | |
| 1812 | 1816 | status: 1, |
| 1813 | 1817 | created: Some(1), |
| 1814 | 1818 | completed: Some(1), |
| ... | ... | @@ -1938,7 +1942,7 @@ mod tests { |
| 1938 | 1942 | .iter() |
| 1939 | 1943 | .map(|tag| match tag.icon { |
| 1940 | 1944 | crate::outline::TagIcon::Symbol { shape, .. } => shape, |
| 1941 | crate::outline::TagIcon::Task => unreachable!(), | |
| 1945 | crate::outline::TagIcon::Task { .. } => unreachable!(), | |
| 1942 | 1946 | }) |
| 1943 | 1947 | .collect::<Vec<_>>(); |
| 1944 | 1948 | assert_eq!(shapes.len(), defaults.len() - 2); |
crates/canvas/src/gpu/mod.rs+83-7| ... | ... | @@ -141,7 +141,6 @@ pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout { |
| 141 | 141 | } |
| 142 | 142 | |
| 143 | 143 | const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg"); |
| 144 | const FLAG: &str = include_str!("../../assets/tags/flag.svg"); | |
| 145 | 144 | const BOX_SMALL: &str = include_str!("../../assets/tags/box-small.svg"); |
| 146 | 145 | const CHECK_SMALL: &str = include_str!("../../assets/tags/check-small.svg"); |
| 147 | 146 | const TAG: &str = include_str!("../../assets/tags/tag.svg"); |
| ... | ... | @@ -155,8 +154,7 @@ enum Art { |
| 155 | 154 | } |
| 156 | 155 | |
| 157 | 156 | /// 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. | |
| 160 | 158 | fn art(shape: u16) -> Art { |
| 161 | 159 | use Art::{Badged, Box, Plain}; |
| 162 | 160 | macro_rules! tag { |
| ... | ... | @@ -218,7 +216,7 @@ fn art(shape: u16) -> Art { |
| 218 | 216 | 51 => Plain(tag!("circle-2-green")), |
| 219 | 217 | 52 => Badged(tag!("mark-three-green")), |
| 220 | 218 | 53 => Plain(tag!("circle-3-green")), |
| 221 | 54 => Plain(tag!("star8-blue")), | |
| 219 | 54 => Plain(tag!("star8-green")), | |
| 222 | 220 | 55 => Plain(tag!("tick-green")), |
| 223 | 221 | 56 => Plain(tag!("circle-green")), |
| 224 | 222 | 57 => Plain(tag!("arrow-down-green")), |
| ... | ... | @@ -318,8 +316,9 @@ const SYMBOLS: u16 = 143; |
| 318 | 316 | /// The tag's artwork, drawn in order. |
| 319 | 317 | pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { |
| 320 | 318 | 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), | |
| 323 | 322 | }; |
| 324 | 323 | let sources = SOURCES.get_or_init(|| { |
| 325 | 324 | (0..=SYMBOLS) |
| ... | ... | @@ -350,6 +349,83 @@ mod tests { |
| 350 | 349 | use onestore::page::text::Paragraph; |
| 351 | 350 | use std::time::Duration; |
| 352 | 351 | |
| 352 | /// OneNote 2010 draws paragraph `shape N` of `corpus/custom-tags/native/shapes.one` with | |
| 353 | /// the art `DRAWN`'s Nth word names (its render: `shapes-1-72.png`, `shapes-73-143.png`); | |
| 354 | /// each paragraph's tag definition stores the NoteTagShape. | |
| 355 | #[test] | |
| 356 | fn symbols_draw_the_art_onenote_draws_for_their_number() { | |
| 357 | use onestore::page::{PageObject, ParagraphContent}; | |
| 358 | use onestore::{RevisionIndex, Store, document::Document, document::Kind, page::Page}; | |
| 359 | const DRAWN: &str = "\ | |
| 360 | checkbox-green checkbox-yellow checkbox mark-star-green mark-star-yellow \ | |
| 361 | mark-star-blue mark-exclamation-green mark-exclamation-yellow mark-exclamation-blue \ | |
| 362 | mark-arrow-green mark-arrow-yellow mark-arrow-blue star follow-up question \ | |
| 363 | arrow-right-blue exclamation phone calendar clock idea pushpin address blog smiley \ | |
| 364 | ribbon key mark-one-blue circle-1-blue mark-two-blue circle-2-blue mark-three-blue \ | |
| 365 | circle-3-blue star8-blue tick-blue circle-blue arrow-down-blue arrow-left-blue \ | |
| 366 | solid-target-blue star-blue sun-blue target-blue triangle-blue umbrella-blue \ | |
| 367 | arrow-up-blue x-dots-blue x-blue mark-one-green circle-1-green mark-two-green \ | |
| 368 | circle-2-green mark-three-green circle-3-green star8-green tick-green circle-green \ | |
| 369 | arrow-down-green arrow-left-green arrow-right-green solid-target-green star-green \ | |
| 370 | sun-green target-green triangle-green umbrella-green arrow-up-green x-dots-green \ | |
| 371 | x-green mark-one-yellow circle-1-yellow mark-two-yellow circle-2-yellow \ | |
| 372 | mark-three-yellow circle-3-yellow star8-yellow tick-yellow circle-yellow \ | |
| 373 | arrow-down-yellow arrow-left-yellow arrow-right-yellow solid-target-yellow \ | |
| 374 | sun-yellow target-yellow triangle-yellow umbrella-yellow arrow-up-yellow \ | |
| 375 | x-dots-yellow x-yellow flag-today flag-tomorrow flag-this-week flag-next-week \ | |
| 376 | flag-no-date mark-person-blue mark-person-yellow mark-person-green mark-flag-blue \ | |
| 377 | mark-flag-yellow mark-flag-green red-square yellow-square blue-square green-square \ | |
| 378 | orange-square pink-square email envelope envelope-open mobile phone-clock \ | |
| 379 | question-balloon paperclip frown im-contact person people bell contact rose date \ | |
| 380 | music movie quote globe web laptop plane car binoculars presentation password book \ | |
| 381 | notebook paper research highlight dollar coins schedule lightning cloud heart \ | |
| 382 | sunflower"; | |
| 383 | let bytes = include_bytes!("../../../../corpus/custom-tags/native/shapes.one"); | |
| 384 | let store = Store::parse(bytes).unwrap(); | |
| 385 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 386 | let document = Document::parse(&index).unwrap(); | |
| 387 | let files: Vec<&str> = DRAWN.split_whitespace().collect(); | |
| 388 | let mut seen = Vec::new(); | |
| 389 | for (space, _) in document.pages().unwrap() { | |
| 390 | let page = Page::from_space(&document, space).unwrap(); | |
| 391 | if !page.title.starts_with("Shapes") { | |
| 392 | continue; | |
| 393 | } | |
| 394 | for object in &page.objects { | |
| 395 | let PageObject::Outline(outline) = object else { | |
| 396 | continue; | |
| 397 | }; | |
| 398 | for paragraph in &outline.paragraphs { | |
| 399 | let ParagraphContent::Text(text) = &paragraph.content else { | |
| 400 | continue; | |
| 401 | }; | |
| 402 | let label: usize = text.text.text()["shape ".len()..].parse().unwrap(); | |
| 403 | let definition = text.tags[0].definition.unwrap(); | |
| 404 | let Kind::TagDefinition { | |
| 405 | shape: Some(shape), .. | |
| 406 | } = page.definitions[&definition].kind | |
| 407 | else { | |
| 408 | panic!("shape {label} stores no symbol"); | |
| 409 | }; | |
| 410 | let (Art::Box(drawn) | Art::Badged(drawn) | Art::Plain(drawn)) = art(shape); | |
| 411 | let file = files[label - 1]; | |
| 412 | let expected = std::fs::read_to_string(format!( | |
| 413 | "{}/assets/tags/{file}.svg", | |
| 414 | env!("CARGO_MANIFEST_DIR") | |
| 415 | )) | |
| 416 | .unwrap(); | |
| 417 | assert_eq!( | |
| 418 | drawn, expected, | |
| 419 | "shape {label}, stored {shape}, draws {file}" | |
| 420 | ); | |
| 421 | seen.push(label); | |
| 422 | } | |
| 423 | } | |
| 424 | } | |
| 425 | seen.sort_unstable(); | |
| 426 | assert_eq!(seen, (1..=143).collect::<Vec<_>>()); | |
| 427 | } | |
| 428 | ||
| 353 | 429 | /// A page colour paints as OneNote paints it on white paper, and on dark paper as a |
| 354 | 430 | /// dark paper of its hue; no colour leaves the paper as it was. |
| 355 | 431 | #[test] |
| ... | ... | @@ -465,7 +541,7 @@ mod tests { |
| 465 | 541 | ] |
| 466 | 542 | .into_iter() |
| 467 | 543 | .map(|(shape, checked)| TagIcon::of(shape, checked).unwrap()) |
| 468 | .chain([TagIcon::Task]) | |
| 544 | .chain([TagIcon::Task { shape: 91 }]) | |
| 469 | 545 | .collect(); |
| 470 | 546 | let text = TextEngine::default() |
| 471 | 547 | .layout( |
crates/canvas/src/gpu/page.rs+2| ... | ... | @@ -1777,6 +1777,8 @@ mod tests { |
| 1777 | 1777 | definition: Some(tag), |
| 1778 | 1778 | status: 1, |
| 1779 | 1779 | action_type: None, |
| 1780 | shape: None, | |
| 1781 | property_status: None, | |
| 1780 | 1782 | created: None, |
| 1781 | 1783 | completed: None, |
| 1782 | 1784 | start: None, |
crates/canvas/src/interaction/accessibility.rs+3-1| ... | ... | @@ -598,7 +598,7 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32> |
| 598 | 598 | let label = match tag.icon { |
| 599 | 599 | _ if !tag.label.is_empty() => &tag.label, |
| 600 | 600 | TagIcon::Symbol { shape, .. } => symbol_name(shape).unwrap_or("Tag"), |
| 601 | TagIcon::Task => "Outlook task", | |
| 601 | TagIcon::Task { .. } => "Outlook task", | |
| 602 | 602 | }; |
| 603 | 603 | let state = match tag.icon { |
| 604 | 604 | TagIcon::Symbol { checked: true, .. } => ", completed", |
| ... | ... | @@ -946,6 +946,8 @@ pub(super) mod tests { |
| 946 | 946 | paragraphs[index].text_mut().unwrap().tags.push(Tag { |
| 947 | 947 | definition: Some(id), |
| 948 | 948 | action_type: None, |
| 949 | shape: None, | |
| 950 | property_status: None, | |
| 949 | 951 | status, |
| 950 | 952 | created: None, |
| 951 | 953 | completed: None, |
crates/canvas/src/interaction/mod.rs+31| ... | ... | @@ -504,6 +504,37 @@ impl PageView { |
| 504 | 504 | self.hit_test(self.viewport.document_point(position)) |
| 505 | 505 | } |
| 506 | 506 | |
| 507 | /// The Outlook task icon under the pointer, in view device pixels, for the host to name. | |
| 508 | pub fn task_under_pointer(&self) -> Option<[f32; 4]> { | |
| 509 | if !self.pointer_inside || self.drag.is_some() { | |
| 510 | return None; | |
| 511 | } | |
| 512 | let [x, y] = self.viewport.document_point(self.pointer); | |
| 513 | let view = |v: f32, axis: usize| v * self.viewport.scale + self.viewport.origin[axis]; | |
| 514 | self.editor.visible_outlines().find_map(|outline| { | |
| 515 | let offset = match &self.scene { | |
| 516 | Some((_, offset)) if self.editor.has_page_outline(outline.id) => *offset, | |
| 517 | _ => [0.0; 2], | |
| 518 | }; | |
| 519 | let shaped = outline.shaped(); | |
| 520 | let left = offset[0] + outline.origin()[0] + shaped.tag_column_offset(); | |
| 521 | let top = offset[1] + outline.origin()[1]; | |
| 522 | shaped.paragraphs.iter().find_map(|paragraph| { | |
| 523 | paragraph.tags.iter().find_map(|tag| { | |
| 524 | let x0 = left + tag.origin[0]; | |
| 525 | let y0 = top + paragraph.origin[1] + tag.origin[1]; | |
| 526 | (matches!(tag.icon, crate::outline::TagIcon::Task { .. }) | |
| 527 | && (x0..=x0 + tag.size).contains(&x) | |
| 528 | && (y0..=y0 + tag.size).contains(&y)) | |
| 529 | .then(|| { | |
| 530 | let [x1, y1] = [x0 + tag.size, y0 + tag.size]; | |
| 531 | [view(x0, 0), view(y0, 1), view(x1, 0), view(y1, 1)] | |
| 532 | }) | |
| 533 | }) | |
| 534 | }) | |
| 535 | }) | |
| 536 | } | |
| 537 | ||
| 507 | 538 | fn set_object_focus(&mut self, focus: Option<ObjectFocus>) { |
| 508 | 539 | if self.object_focus != focus { |
| 509 | 540 | self.editor.finish_composition(); |
crates/canvas/src/outline.rs+22-7| ... | ... | @@ -227,8 +227,8 @@ pub struct ParagraphLayout { |
| 227 | 227 | pub enum TagIcon { |
| 228 | 228 | /// Symbol `shape` of MS-ONE's NoteTagShape, `checked` only where it is a check box. |
| 229 | 229 | 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 }, | |
| 232 | 232 | } |
| 233 | 233 | |
| 234 | 234 | impl TagIcon { |
| ... | ... | @@ -887,8 +887,8 @@ impl ParagraphLayout { |
| 887 | 887 | return Err(LayoutError::InvalidSpacing); |
| 888 | 888 | } |
| 889 | 889 | // Stored newest first; OneNote lists and paints tags oldest first. |
| 890 | // An Outlook task tag carries no definition; a tag whose definition is missing 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. | |
| 892 | 892 | let mut tag_definitions: Vec<_> = paragraph |
| 893 | 893 | .tags |
| 894 | 894 | .iter() |
| ... | ... | @@ -1065,7 +1065,9 @@ impl ParagraphLayout { |
| 1065 | 1065 | for tag in &tag_definitions { |
| 1066 | 1066 | let (tag, shape, label) = (tag.0, tag.1, tag.2); |
| 1067 | 1067 | let icon = match shape { |
| 1068 | None => TagIcon::Task, | |
| 1068 | None => TagIcon::Task { | |
| 1069 | shape: tag.shape.unwrap_or(0), | |
| 1070 | }, | |
| 1069 | 1071 | Some(shape) => match TagIcon::of(shape.unwrap_or(0), tag.status & 1 != 0) { |
| 1070 | 1072 | Some(icon) => icon, |
| 1071 | 1073 | None => continue, |
| ... | ... | @@ -2241,6 +2243,8 @@ mod tests { |
| 2241 | 2243 | definition: Some(id), |
| 2242 | 2244 | status: 3, |
| 2243 | 2245 | action_type: None, |
| 2246 | shape: None, | |
| 2247 | property_status: None, | |
| 2244 | 2248 | created: None, |
| 2245 | 2249 | completed: None, |
| 2246 | 2250 | start: None, |
| ... | ... | @@ -2356,8 +2360,13 @@ mod tests { |
| 2356 | 2360 | } |
| 2357 | 2361 | ); |
| 2358 | 2362 | 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 | ); | |
| 2361 | 2370 | let mut untagged = definitions.clone(); |
| 2362 | 2371 | untagged.remove(&id); |
| 2363 | 2372 | assert!(shaped(&node, &untagged).tags.is_empty()); |
| ... | ... | @@ -2403,6 +2412,8 @@ mod tests { |
| 2403 | 2412 | let tag = Tag { |
| 2404 | 2413 | definition: Some(id(902)), |
| 2405 | 2414 | action_type: None, |
| 2415 | shape: None, | |
| 2416 | property_status: None, | |
| 2406 | 2417 | status: 0, |
| 2407 | 2418 | created: None, |
| 2408 | 2419 | completed: None, |
| ... | ... | @@ -2499,6 +2510,8 @@ mod tests { |
| 2499 | 2510 | let tag = Tag { |
| 2500 | 2511 | definition: Some(id(902)), |
| 2501 | 2512 | action_type: None, |
| 2513 | shape: None, | |
| 2514 | property_status: None, | |
| 2502 | 2515 | status: 0, |
| 2503 | 2516 | created: None, |
| 2504 | 2517 | completed: None, |
| ... | ... | @@ -2665,6 +2678,8 @@ mod tests { |
| 2665 | 2678 | definition: Some(id), |
| 2666 | 2679 | status: 1, |
| 2667 | 2680 | action_type: None, |
| 2681 | shape: None, | |
| 2682 | property_status: None, | |
| 2668 | 2683 | created: None, |
| 2669 | 2684 | completed: None, |
| 2670 | 2685 | start: None, |
crates/canvas/tests/task_tags.rs created+145| ... | ... | @@ -0,0 +1,145 @@ |
| 1 | //! Outlook task tags stay read-only through the editor: Remove Tag drops one, removing a | |
| 2 | //! normal tag beside one keeps it, and deleting a page and undoing restores them as OneNote | |
| 3 | //! 2010 stored them (`corpus/task-tags`). | |
| 4 | ||
| 5 | use canvas::{ | |
| 6 | document::TextPosition, | |
| 7 | editor::{CanvasEditor, Formatting, NoteTag, Whole}, | |
| 8 | layout::TextEngine, | |
| 9 | }; | |
| 10 | use onestore::{ | |
| 11 | Arena, ExGuid, Section, Store, | |
| 12 | document::Tag, | |
| 13 | op::{Edit, Op}, | |
| 14 | page::{Page, PageObject}, | |
| 15 | }; | |
| 16 | ||
| 17 | const TASKS: &[u8] = include_bytes!("../../../corpus/task-tags/native/notebook/Tasks.one"); | |
| 18 | ||
| 19 | /// Each body paragraph's text and tags, their arena positions cleared. | |
| 20 | fn 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 | ||
| 45 | fn 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 | ||
| 55 | fn 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. | |
| 64 | fn 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] | |
| 77 | fn task_tags_survive_tag_removal_and_page_delete_undo() { | |
| 78 | let arena = Arena::default(); | |
| 79 | let mut section = Section::open(&arena, TASKS.to_vec()).unwrap(); | |
| 80 | let pages = section.pages().unwrap(); | |
| 81 | let space = |title: &str| pages.iter().find(|page| page.1 == title).unwrap().0; | |
| 82 | let (remove, restore) = (space("Remove a task"), space("Delete and undo")); | |
| 83 | let before = tagged(&section.page(remove).unwrap()); | |
| 84 | let shapes: Vec<_> = before | |
| 85 | .iter() | |
| 86 | .flat_map(|(_, tags)| tags) | |
| 87 | .map(|tag| (tag.definition.is_some(), tag.status & 4 != 0, tag.shape)) | |
| 88 | .collect(); | |
| 89 | assert_eq!( | |
| 90 | shapes, | |
| 91 | [ | |
| 92 | (false, true, Some(89)), | |
| 93 | (false, true, Some(89)), | |
| 94 | (false, true, Some(89)), | |
| 95 | (true, false, None), | |
| 96 | ] | |
| 97 | ); | |
| 98 | let mut engine = TextEngine::default(); | |
| 99 | ||
| 100 | let mut editor = CanvasEditor::from_page(section.page(remove).unwrap(), &mut engine).unwrap(); | |
| 101 | body(&mut editor); | |
| 102 | editor.select(at(0)).unwrap(); | |
| 103 | editor.format(&mut engine, Formatting::RemoveTags).unwrap(); | |
| 104 | editor.select(at(2)).unwrap(); | |
| 105 | let to_do = NoteTag::defaults()[0].clone(); | |
| 106 | editor | |
| 107 | .format(&mut engine, Formatting::Tag(to_do, 0)) | |
| 108 | .unwrap(); | |
| 109 | publish(&mut section, remove, &mut editor, 134_000_000_000_000_000); | |
| 110 | let mut expected = before.clone(); | |
| 111 | expected[0].1.clear(); | |
| 112 | expected[2].1.retain(|tag| tag.definition.is_none()); | |
| 113 | assert_eq!(tagged(&section.page(remove).unwrap()), expected); | |
| 114 | ||
| 115 | let original = section.page(restore).unwrap(); | |
| 116 | let kept = tagged(&original); | |
| 117 | let mut editor = CanvasEditor::from_page(original, &mut engine).unwrap(); | |
| 118 | body(&mut editor); | |
| 119 | while editor.whole() != Some(Whole::Page) { | |
| 120 | editor.widen_selection().unwrap(); | |
| 121 | } | |
| 122 | editor.delete(&mut engine, false).unwrap(); | |
| 123 | publish(&mut section, restore, &mut editor, 134_000_000_010_000_000); | |
| 124 | assert!(editor.undo(&mut engine).unwrap()); | |
| 125 | publish(&mut section, restore, &mut editor, 134_000_000_020_000_000); | |
| 126 | assert_eq!(tagged(&section.page(restore).unwrap()), kept); | |
| 127 | ||
| 128 | section.seal().unwrap(); | |
| 129 | let written = section.image(); | |
| 130 | let reread = Section::open(&arena, written.clone()).unwrap(); | |
| 131 | assert_eq!(tagged(&reread.page(remove).unwrap()), expected); | |
| 132 | assert_eq!(tagged(&reread.page(restore).unwrap()), kept); | |
| 133 | if let Some(directory) = std::env::var_os("SNOWBOUND_TASK_TAGS_EXPORT") { | |
| 134 | let directory = std::path::PathBuf::from(directory); | |
| 135 | std::fs::create_dir(&directory).unwrap(); | |
| 136 | std::fs::write(directory.join("Tasks.one"), &written).unwrap(); | |
| 137 | let file_id = Store::parse(&written).unwrap().header.file_id; | |
| 138 | std::fs::write( | |
| 139 | directory.join("Open Notebook.onetoc2"), | |
| 140 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("Tasks.one", file_id)]) | |
| 141 | .unwrap(), | |
| 142 | ) | |
| 143 | .unwrap(); | |
| 144 | } | |
| 145 | } |
crates/draw/src/render.rs+9-20| ... | ... | @@ -224,7 +224,6 @@ enum AtlasKey { |
| 224 | 224 | Icon { |
| 225 | 225 | sources: &'static [&'static str], |
| 226 | 226 | size: u32, |
| 227 | phase: [u8; 2], | |
| 228 | 227 | /// The bits of the colour `currentColor` paints. |
| 229 | 228 | ink: [u32; 3], |
| 230 | 229 | palette: [Option<[u32; 3]>; 3], |
| ... | ... | @@ -1104,27 +1103,24 @@ impl Renderer { |
| 1104 | 1103 | tint: [f32; 4], |
| 1105 | 1104 | palette: &Palette, |
| 1106 | 1105 | ) -> 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); | |
| 1109 | 1109 | if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) { |
| 1110 | 1110 | return Err(RenderError::InvalidPrimitive); |
| 1111 | 1111 | } |
| 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 | |
| 1116 | 1113 | { |
| 1117 | 1114 | return Ok(()); |
| 1118 | 1115 | } |
| 1119 | if size + 3.0 > self.atlas_side() as f32 { | |
| 1116 | if size + 2.0 > self.atlas_side() as f32 { | |
| 1120 | 1117 | return Err(RenderError::AtlasFull); |
| 1121 | 1118 | } |
| 1122 | let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; | |
| 1119 | let size = size as u32; | |
| 1123 | 1120 | let ink = [tint[0], tint[1], tint[2]]; |
| 1124 | 1121 | let key = AtlasKey::Icon { |
| 1125 | 1122 | sources, |
| 1126 | size: size.to_bits(), | |
| 1127 | phase, | |
| 1123 | size, | |
| 1128 | 1124 | ink: ink.map(f32::to_bits), |
| 1129 | 1125 | palette: palette.bits(), |
| 1130 | 1126 | }; |
| ... | ... | @@ -1134,20 +1130,13 @@ impl Renderer { |
| 1134 | 1130 | if self.glyphs.len() >= self.glyph_limit() { |
| 1135 | 1131 | return Err(RenderError::AtlasFull); |
| 1136 | 1132 | } |
| 1137 | let glyph = self.upload(icon::rasterize(sources, size, phase, ink, palette))?; | |
| 1133 | let glyph = self.upload(icon::rasterize(sources, size, ink, palette))?; | |
| 1138 | 1134 | self.glyphs.insert(key, Some(glyph)); |
| 1139 | 1135 | glyph |
| 1140 | 1136 | }; |
| 1141 | let left = x.floor(); | |
| 1142 | let top = y.floor(); | |
| 1143 | 1137 | self.quad( |
| 1144 | 1138 | 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], | |
| 1151 | 1140 | self.uv(glyph), |
| 1152 | 1141 | [1.0, 1.0, 1.0, tint[3]], |
| 1153 | 1142 | ) |
crates/draw/src/render/icon.rs+32-51| ... | ... | @@ -1,9 +1,6 @@ |
| 1 | 1 | use swash::{ |
| 2 | 2 | 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}, | |
| 7 | 4 | }; |
| 8 | 5 | |
| 9 | 6 | /// Colours for an icon's slots, linear RGB: paths of `class="accent"`, `"highlight"` or |
| ... | ... | @@ -32,24 +29,17 @@ impl Palette { |
| 32 | 29 | } |
| 33 | 30 | } |
| 34 | 31 | |
| 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`, | |
| 37 | 33 | /// which paints the linear `ink`, or two-stop `linearGradient`s with SVG's defaults |
| 38 | 34 | /// (`objectBoundingBox` unless `userSpaceOnUse`, `x1`..`y2`, stop offsets, pad spread; no |
| 39 | 35 | /// `gradientTransform`). `fill-opacity` and `stroke-opacity` apply, and strokes have round |
| 40 | 36 | /// caps and joins. A slot's paths keep the shading among their colours: the hue turn, |
| 41 | 37 | /// saturation scale and lightness shift that carry the mean of the slot's colours in a |
| 42 | 38 | /// source onto the palette's colour apply to each of them. |
| 43 | pub(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; | |
| 39 | pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Palette) -> Image { | |
| 40 | let side = size; | |
| 41 | let size = size as f32; | |
| 51 | 42 | let mut pixels = vec![[0.0_f32; 4]; (side * side) as usize]; |
| 52 | let offset = phase.map(|quarters| f32::from(quarters) * 0.25); | |
| 53 | 43 | for source in sources { |
| 54 | 44 | let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid"); |
| 55 | 45 | assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16")); |
| ... | ... | @@ -135,7 +125,6 @@ pub(crate) fn rasterize( |
| 135 | 125 | let (mask, _) = Mask::new(data) |
| 136 | 126 | .style(style) |
| 137 | 127 | .transform(Some(Transform::scale(size / 16.0, size / 16.0))) |
| 138 | .render_offset(Vector::new(offset[0], offset[1])) | |
| 139 | 128 | .size(side, side) |
| 140 | 129 | .render(); |
| 141 | 130 | for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { |
| ... | ... | @@ -147,7 +136,7 @@ pub(crate) fn rasterize( |
| 147 | 136 | let [x, y] = [index % side as usize, index / side as usize] |
| 148 | 137 | .map(|device| device as f32 + 0.5) |
| 149 | 138 | .map(|device| device * 16.0 / size); |
| 150 | let color = color([x - offset[0] * 16.0 / size, y - offset[1] * 16.0 / size]); | |
| 139 | let color = color([x, y]); | |
| 151 | 140 | for channel in 0..3 { |
| 152 | 141 | pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha); |
| 153 | 142 | } |
| ... | ... | @@ -468,7 +457,7 @@ mod tests { |
| 468 | 457 | /// The grey painted at a pixel of `source` rasterized at 16 pixels, and the grey a |
| 469 | 458 | /// black-to-white ramp paints at fraction `t`. |
| 470 | 459 | fn grey(source: &str, x: usize, y: usize) -> u8 { |
| 471 | rasterize(&[source], 16.0, [0, 0], [1.0; 3], &Palette::default()).data[(y * 17 + x) * 4] | |
| 460 | rasterize(&[source], 16, [1.0; 3], &Palette::default()).data[(y * 16 + x) * 4] | |
| 472 | 461 | } |
| 473 | 462 | |
| 474 | 463 | fn ramp(t: f32) -> u8 { |
| ... | ... | @@ -516,8 +505,8 @@ mod tests { |
| 516 | 505 | // Two blues of one hue in the accent slot, a red outside it. |
| 517 | 506 | const ART: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path class="accent" d="M0 0H16V4H0Z" fill="#80a0ff"/><path class="accent" d="M0 4H16V8H0Z" fill="#0040e0"/><path d="M0 8H16V12H0Z" fill="#ff0000"/><path class="accent" d="M0 12H16V16H0Z" fill="currentColor"/></svg>"##; |
| 518 | 507 | 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; | |
| 521 | 510 | let [r, g, b] = [0, 1, 2].map(|channel| f32::from(image.data[at + channel]) / 255.0); |
| 522 | 511 | hsl([r, g, b]) |
| 523 | 512 | }; |
| ... | ... | @@ -542,14 +531,8 @@ mod tests { |
| 542 | 531 | fn current_color_paints_the_ink_and_fixed_colours_keep_theirs() { |
| 543 | 532 | const LINE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 8H14" fill="none" stroke="currentColor" stroke-width="2"/><path d="M2 12H14" fill="none" stroke="#ff0000" stroke-width="2"/></svg>"##; |
| 544 | 533 | 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]; | |
| 553 | 536 | assert_eq!(pixel(8, 7), [0x20, 0x60, 0xa0, 255]); |
| 554 | 537 | assert_eq!(pixel(8, 11), [255, 0, 0, 255]); |
| 555 | 538 | assert_eq!(pixel(8, 2)[3], 0); |
| ... | ... | @@ -560,8 +543,8 @@ mod tests { |
| 560 | 543 | #[test] |
| 561 | 544 | fn opacity_scales_each_paint_on_its_own() { |
| 562 | 545 | const HALF: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 2H14V14H2Z" fill="#ffffff" fill-opacity="0.5" stroke="#000000" stroke-width="2" stroke-opacity="0.25"/></svg>"##; |
| 563 | let image = rasterize(&[HALF], 16.0, [0, 0], [1.0; 3], &Palette::default()); | |
| 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]; | |
| 565 | 548 | assert_eq!(alpha(8, 8), 128); |
| 566 | 549 | // The stroke's outer half covers only what the fill leaves. |
| 567 | 550 | assert_eq!(alpha(1, 8), 64); |
| ... | ... | @@ -569,28 +552,26 @@ mod tests { |
| 569 | 552 | |
| 570 | 553 | #[test] |
| 571 | 554 | 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 | ); | |
| 593 | 572 | } |
| 573 | assert_ne!(plain.data, marked.data); | |
| 574 | assert!(marked.data.chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0)); | |
| 594 | 575 | } |
| 595 | 576 | } |
| 596 | 577 | } |
crates/mobile/src/library.rs+5-11| ... | ... | @@ -33,8 +33,6 @@ const TIMEOUT: Duration = Duration::from_secs(10); |
| 33 | 33 | const LIMIT: usize = 256 * 1024 * 1024; |
| 34 | 34 | /// The tab colour OneNote gives a section that stores none, as the desktop shows it. |
| 35 | 35 | const SECTION_COLOR: u32 = 0x00e4_a88a; |
| 36 | /// How often sections no page shows are polled, as the desktop polls them. | |
| 37 | const BACKGROUND: Duration = Duration::from_secs(15); | |
| 38 | 36 | |
| 39 | 37 | /// Called on a background thread when a notebook's sync status may have changed. |
| 40 | 38 | static SYNC_WAKE: OnceLock<extern "C" fn()> = OnceLock::new(); |
| ... | ... | @@ -239,7 +237,7 @@ fn stem(path: &str) -> String { |
| 239 | 237 | fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { |
| 240 | 238 | for section in &folder.sections { |
| 241 | 239 | let (name, color, readable) = match &section.state { |
| 242 | SectionState::Readable { name, color } => (name.clone(), *color, true), | |
| 240 | SectionState::Readable { name, color, .. } => (name.clone(), *color, true), | |
| 243 | 241 | SectionState::Locked | SectionState::Unreadable(_) => (None, None, false), |
| 244 | 242 | }; |
| 245 | 243 | tabs.push(Tab { |
| ... | ... | @@ -272,8 +270,10 @@ impl Library { |
| 272 | 270 | (None, Place::File(path.to_owned()), None) |
| 273 | 271 | } else { |
| 274 | 272 | let notebook = Notebook::open(path, cache)?; |
| 273 | // Nothing here tells a file provider's folder on this device from one elsewhere. | |
| 275 | 274 | let background = notebook.background_with( |
| 276 | BACKGROUND, | |
| 275 | Background::UNWATCHED, | |
| 276 | false, | |
| 277 | 277 | |file| Coordinated(file.to_owned()), |
| 278 | 278 | sync_woken, |
| 279 | 279 | )?; |
| ... | ... | @@ -300,13 +300,7 @@ impl Library { |
| 300 | 300 | pub(crate) fn server(server: Server, root: &str, cache: &Path) -> Result<Self> { |
| 301 | 301 | let server = Arc::new(server); |
| 302 | 302 | 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)?; | |
| 310 | 304 | let library = Self { |
| 311 | 305 | notebook: Mutex::default(), |
| 312 | 306 | place: Place::Share { |
crates/notebook/README.md+21-8| ... | ... | @@ -65,14 +65,21 @@ and how many edits wait for it. `set_offline(true)` works offline as OneNote doe |
| 65 | 65 | worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offline(false)`. |
| 66 | 66 | |
| 67 | 67 | `session::Background` keeps the sections no session holds in sync, as OneNote 2010 keeps |
| 68 | every section of an open notebook: `Notebook::background(interval, notify)` for a mounted | |
| 69 | notebook, `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 | |
| 71 | replica has edits waiting, or whose file moved past the replica's base, has the replica | |
| 72 | opened for the synchronization steps that publish or rebase it and closed again. A replica | |
| 73 | a 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 | |
| 75 | changed, and `wake` and `set_offline` follow Sync Now and Work Offline. | |
| 68 | every section of an open notebook: `Background::smb(root, limit, connect, notify)` on a | |
| 69 | share, `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 | |
| 72 | changed paths to `touched`. A reported section is checked a second later, a failing one | |
| 73 | every 31 seconds, and any other every `interval` (`Background::BACKSTOP` while a watch | |
| 74 | reports). Newly watched sections, and every section after a reconnect, are checked one at a | |
| 75 | time 100 ms apart. A check reads the file's stamp; a section whose replica has edits waiting, | |
| 76 | or whose file moved past the replica's base, has the replica opened for the synchronization | |
| 77 | steps that publish or rebase it and closed again, and a section without a replica gets one | |
| 78 | from the file, its offline copy, on a share or with `copies`. A replica a session holds is | |
| 79 | skipped (`Error::busy`), so opening a section may wait out one step. `status()` gives each | |
| 80 | section'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 | |
| 82 | replicas holding nothing unpublished, as when the notebook closes. | |
| 76 | 83 | `Notebook::replica_path(path)` names a section's replica for either kind of notebook. |
| 77 | 84 | |
| 78 | 85 | `Section::resume(file, replica, notify)` starts from an owned `Replica` without |
| ... | ... | @@ -373,6 +380,12 @@ are rejected with `ResourceBusy`. Notebook identities come from the files, not |
| 373 | 380 | directory names or sizes. Missing paths, denied access and non-directory paths |
| 374 | 381 | have distinct I/O error kinds. |
| 375 | 382 | |
| 383 | `Client::watch(path, changed)` arms one CHANGE_NOTIFY with WATCH_TREE on a directory, as | |
| 384 | OneNote 2010 watches a notebook's folder, and keeps it armed on the client's runtime without | |
| 385 | probing the connection, so an idle watch sends nothing. `changed` hears each batch of changed | |
| 386 | paths relative to the directory, `""` when the server lost count, and an error when the watch | |
| 387 | ends with its connection. | |
| 388 | ||
| 376 | 389 | `Client::read_asset(path, byte_limit)` reads an external payload under a read-only |
| 377 | 390 | share handle that excludes writes and deletion. Empty files succeed; limits, |
| 378 | 391 | interrupted 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 | ||
| 5 | use notebook::{ | |
| 6 | discover::{Entry, Limits, Local, Source, discover}, | |
| 7 | session::{Background, Notebook}, | |
| 8 | }; | |
| 9 | use 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 | }; | |
| 17 | use std::{ | |
| 18 | io, | |
| 19 | path::Path, | |
| 20 | time::{Duration, Instant}, | |
| 21 | }; | |
| 22 | ||
| 23 | type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; | |
| 24 | ||
| 25 | struct Counting<S>(S, u64, u64); | |
| 26 | ||
| 27 | impl<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 | ||
| 39 | const LIMITS: Limits = Limits { | |
| 40 | entries: 100_000, | |
| 41 | bytes_per_file: 256 * 1024 * 1024, | |
| 42 | depth: 64, | |
| 43 | }; | |
| 44 | ||
| 45 | fn 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 | ||
| 90 | fn 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 | ||
| 119 | fn 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")] | |
| 132 | fn 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")] | |
| 146 | fn 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 | ||
| 161 | fn main() -> Result<()> { | |
| 162 | let args: Vec<String> = std::env::args().skip(1).collect(); | |
| 163 | match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() { | |
| 164 | ["generate", root, count] => { | |
| 165 | generate(Path::new(root), count.parse()?)?; | |
| 166 | println!("{} bytes", size(Path::new(root))); | |
| 167 | } | |
| 168 | ["local", root, cache] => { | |
| 169 | let start = Instant::now(); | |
| 170 | let notebook = Notebook::open(root, cache)?; | |
| 171 | let replicas = notebook.replicas(); | |
| 172 | println!( | |
| 173 | "open: {:?}, {} sections", | |
| 174 | start.elapsed(), | |
| 175 | replicas.len() | |
| 176 | ); | |
| 177 | let mut counting = Counting(Local::open(root)?, 0, 0); | |
| 178 | discover(&mut counting, LIMITS)?; | |
| 179 | println!("discovery reads {} bytes, {} listings", counting.1, counting.2); | |
| 180 | } | |
| 181 | #[cfg(feature = "smb")] | |
| 182 | ["upload", address, share, local, root] => { | |
| 183 | upload(&client(address, share)?, Path::new(local), root)?; | |
| 184 | } | |
| 185 | #[cfg(feature = "smb")] | |
| 186 | ["smb", address, share, root, cache, rest @ ..] => { | |
| 187 | let seconds: u64 = rest.first().map_or(Ok(0), |s| s.parse())?; | |
| 188 | let start = Instant::now(); | |
| 189 | let connected = client(address, share)?; | |
| 190 | let notebook = Notebook::open_smb(std::sync::Arc::new(connected), root, cache)?; | |
| 191 | let replicas = notebook.replicas(); | |
| 192 | println!( | |
| 193 | "open: {:?}, {} sections", | |
| 194 | start.elapsed(), | |
| 195 | replicas.len() | |
| 196 | ); | |
| 197 | if seconds > 0 { | |
| 198 | let (address, share) = (address.to_string(), share.to_string()); | |
| 199 | let background = Background::smb( | |
| 200 | root, | |
| 201 | 256 * 1024 * 1024, | |
| 202 | move || { | |
| 203 | client(&address, &share) | |
| 204 | .map_err(|error| io::Error::other(error.to_string())) | |
| 205 | }, | |
| 206 | || {}, | |
| 207 | )?; | |
| 208 | background.watch(replicas); | |
| 209 | let start = Instant::now(); | |
| 210 | loop { | |
| 211 | let status = background.status(); | |
| 212 | if status.iter().all(|(_, status)| status.synced.is_some()) { | |
| 213 | println!("background first pass: {:?}", start.elapsed()); | |
| 214 | break; | |
| 215 | } | |
| 216 | if start.elapsed() > Duration::from_secs(seconds) { | |
| 217 | println!("background still going after {seconds} s"); | |
| 218 | break; | |
| 219 | } | |
| 220 | std::thread::sleep(Duration::from_millis(50)); | |
| 221 | } | |
| 222 | std::thread::sleep(Duration::from_secs(2)); | |
| 223 | } | |
| 224 | } | |
| 225 | _ => return Err("usage".into()), | |
| 226 | } | |
| 227 | Ok(()) | |
| 228 | } |
crates/notebook/src/background.rs+539-132| ... | ... | @@ -11,19 +11,45 @@ use onestore::Stamp; |
| 11 | 11 | use std::{ |
| 12 | 12 | io, |
| 13 | 13 | path::{Path, PathBuf}, |
| 14 | sync::{Arc, Mutex, atomic::Ordering}, | |
| 14 | sync::{ | |
| 15 | Arc, Mutex, Weak, | |
| 16 | atomic::{AtomicBool, Ordering}, | |
| 17 | }, | |
| 15 | 18 | thread, |
| 16 | time::Duration, | |
| 19 | time::{Duration, Instant}, | |
| 17 | 20 | }; |
| 18 | 21 | |
| 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. | |
| 24 | pub 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. | |
| 24 | const 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. | |
| 27 | const 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. | |
| 30 | const 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. | |
| 39 | pub struct Background(Arc<Shared>); | |
| 40 | ||
| 41 | /// What the background thread and whatever reports changes to it share. | |
| 42 | pub(crate) struct Shared { | |
| 25 | 43 | signal: Arc<Signal>, |
| 26 | watched: Arc<Mutex<Watched>>, | |
| 44 | watched: Mutex<Watched>, | |
| 45 | /// Asked for by `discard`, once the thread stops. | |
| 46 | discard: AtomicBool, | |
| 47 | } | |
| 48 | ||
| 49 | /// A connection's report of its notebook folder's changes, from a watch armed on connecting. | |
| 50 | pub(crate) struct Reports { | |
| 51 | shared: Weak<Shared>, | |
| 52 | connection: u64, | |
| 27 | 53 | } |
| 28 | 54 | |
| 29 | 55 | #[derive(Default)] |
| ... | ... | @@ -31,6 +57,10 @@ struct Watched { |
| 31 | 57 | sections: Vec<Watch>, |
| 32 | 58 | /// Sections whose file changed since `changed` was last asked. |
| 33 | 59 | changed: Vec<String>, |
| 60 | /// Counts connections, so a watch that ended with an earlier one is not this one's. | |
| 61 | connection: u64, | |
| 62 | /// The current connection's watch ended: changes since went unreported. | |
| 63 | lost: bool, | |
| 34 | 64 | } |
| 35 | 65 | |
| 36 | 66 | struct Watch { |
| ... | ... | @@ -39,12 +69,24 @@ struct Watch { |
| 39 | 69 | /// The file's stamp when last reached. |
| 40 | 70 | stamp: Option<Stamp>, |
| 41 | 71 | status: SyncStatus, |
| 72 | /// When the section is next checked. | |
| 73 | due: Instant, | |
| 42 | 74 | } |
| 43 | 75 | |
| 44 | 76 | impl Background { |
| 77 | /// How often each section is checked while a watch reports its folder's changes, against | |
| 78 | /// a report that went missing; OneNote 2010, watching, checks nothing on a timer. | |
| 79 | pub const BACKSTOP: Duration = Duration::from_secs(60 * 60); | |
| 80 | /// How often each section is checked where nothing reports its folder's changes. | |
| 81 | pub const UNWATCHED: Duration = Duration::from_secs(15); | |
| 82 | ||
| 83 | /// Starts the thread. `connect` binds each catalog path to its file, arming any watch | |
| 84 | /// that reports the folder's changes through `Reports`, and answers how long a section | |
| 85 | /// may go unchecked while nothing reports it; it runs again after a transport failure. | |
| 86 | /// `copies` keeps an offline copy of every section. | |
| 45 | 87 | 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, | |
| 48 | 90 | notify: impl Fn() + Send + 'static, |
| 49 | 91 | ) -> Result<Self> |
| 50 | 92 | where |
| ... | ... | @@ -52,165 +94,201 @@ impl Background { |
| 52 | 94 | B: FnMut(&str) -> R, |
| 53 | 95 | { |
| 54 | 96 | 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); | |
| 57 | 104 | thread::Builder::new() |
| 58 | 105 | .name("onestore-background".into()) |
| 59 | 106 | .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) | |
| 64 | 113 | { |
| 65 | 114 | bound = None; |
| 66 | 115 | let _ = receiver.recv(); |
| 67 | 116 | continue; |
| 68 | 117 | } |
| 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 { | |
| 87 | 121 | return; |
| 88 | } | |
| 89 | let bind = match &mut bound { | |
| 90 | Some(bind) => bind, | |
| 91 | None => match connect() { | |
| 92 | Ok(bind) => bound.insert(bind), | |
| 93 | Err(error) => { | |
| 94 | // Nothing on the share is reachable this round. | |
| 95 | let Ok(mut watched) = sections.lock() else { | |
| 96 | return; | |
| 97 | }; | |
| 98 | for watch in &mut watched.sections { | |
| 99 | news |= watch.fail( | |
| 100 | &Error::RemoteIo(io::Error::new( | |
| 101 | error.kind(), | |
| 102 | error.to_string(), | |
| 103 | )), | |
| 104 | None, | |
| 105 | ); | |
| 106 | } | |
| 107 | break; | |
| 108 | } | |
| 109 | }, | |
| 110 | 122 | }; |
| 111 | let (queued, outcome) = | |
| 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) { | |
| 114 | 124 | bound = None; |
| 125 | watched.rescan(now); | |
| 115 | 126 | } |
| 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(); | |
| 129 | 141 | } |
| 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 | } | |
| 137 | 176 | } |
| 138 | moved || before != summary(&watch.status) | |
| 139 | 177 | } |
| 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; | |
| 142 | 186 | } |
| 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); | |
| 145 | 229 | } |
| 146 | let _ = receiver.recv_timeout(interval); | |
| 147 | 230 | } |
| 148 | 231 | })?; |
| 149 | Ok(Self { signal, watched }) | |
| 232 | Ok(Self(shared)) | |
| 150 | 233 | } |
| 151 | 234 | |
| 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`. | |
| 154 | 239 | #[cfg(feature = "smb")] |
| 155 | 240 | pub fn smb( |
| 156 | 241 | root: &str, |
| 157 | 242 | limit: usize, |
| 158 | interval: Duration, | |
| 159 | 243 | mut connect: impl FnMut() -> io::Result<crate::smb::Client> + Send + 'static, |
| 160 | 244 | notify: impl Fn() + Send + 'static, |
| 161 | 245 | ) -> Result<Self> { |
| 162 | 246 | let root = root.replace('\\', "/"); |
| 163 | 247 | Self::start( |
| 164 | interval, | |
| 165 | move || { | |
| 248 | true, | |
| 249 | move |reports| { | |
| 166 | 250 | let client = Arc::new(connect()?); |
| 251 | let interval = match client.watch(&root, move |changed| match changed { | |
| 252 | Ok(paths) => reports.touched(&paths), | |
| 253 | Err(_) => reports.lost(), | |
| 254 | }) { | |
| 255 | Ok(()) => Self::BACKSTOP, | |
| 256 | Err(error) if error.kind() == io::ErrorKind::Unsupported => Self::UNWATCHED, | |
| 257 | Err(error) => return Err(error), | |
| 258 | }; | |
| 167 | 259 | let root = root.clone(); |
| 168 | Ok(move |path: &str| { | |
| 260 | let bind = move |path: &str| { | |
| 169 | 261 | let file = match root.as_str() { |
| 170 | 262 | "" => path.to_owned(), |
| 171 | 263 | root => format!("{root}/{path}"), |
| 172 | 264 | }; |
| 173 | 265 | crate::SmbRemote::new(Arc::clone(&client), file, limit) |
| 174 | }) | |
| 266 | }; | |
| 267 | Ok((bind, interval)) | |
| 175 | 268 | }, |
| 176 | 269 | notify, |
| 177 | 270 | ) |
| 178 | 271 | } |
| 179 | 272 | |
| 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. | |
| 182 | 275 | 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()); | |
| 206 | 278 | } |
| 207 | self.signal.wake(); | |
| 279 | self.0.signal.wake(); | |
| 208 | 280 | } |
| 209 | 281 | |
| 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 | |
| 211 | 289 | /// session holds keeps the status it had before. |
| 212 | 290 | pub fn status(&self) -> Vec<(String, SyncStatus)> { |
| 213 | self.watched.lock().map_or_else( | |
| 291 | self.0.watched.lock().map_or_else( | |
| 214 | 292 | |_| Vec::new(), |
| 215 | 293 | |watched| { |
| 216 | 294 | watched |
| ... | ... | @@ -237,32 +315,165 @@ impl Background { |
| 237 | 315 | |
| 238 | 316 | /// Sections whose file changed since the last call, by catalog path. |
| 239 | 317 | pub fn changed(&self) -> Vec<String> { |
| 240 | self.watched | |
| 318 | self.0 | |
| 319 | .watched | |
| 241 | 320 | .lock() |
| 242 | 321 | .map(|mut watched| std::mem::take(&mut watched.changed)) |
| 243 | 322 | .unwrap_or_default() |
| 244 | 323 | } |
| 245 | 324 | |
| 246 | /// Runs a round now, working offline included (Sync Now). | |
| 325 | /// Checks every section now, working offline included (Sync Now). | |
| 247 | 326 | 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(); | |
| 250 | 335 | } |
| 251 | 336 | |
| 252 | /// Working offline, no round runs until `wake`, or until working online again. | |
| 337 | /// Stops for good, then deletes each section's replica that holds nothing unpublished, as | |
| 338 | /// OneNote lets go of a notebook it closes; one with edits waiting, or that a session | |
| 339 | /// holds, stays. | |
| 340 | pub fn discard(&self) { | |
| 341 | self.0.discard.store(true, Ordering::Release); | |
| 342 | self.0.signal.stopped.store(true, Ordering::Release); | |
| 343 | self.0.signal.wake(); | |
| 344 | } | |
| 345 | ||
| 346 | /// Working offline, nothing is checked until `wake`, or until working online again. | |
| 253 | 347 | 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(); | |
| 256 | 350 | } |
| 257 | 351 | } |
| 258 | 352 | |
| 259 | 353 | impl Drop for Background { |
| 260 | 354 | fn drop(&mut self) { |
| 261 | self.signal.stopped.store(true, Ordering::Release); | |
| 355 | self.0.signal.stopped.store(true, Ordering::Release); | |
| 356 | self.0.signal.wake(); | |
| 357 | } | |
| 358 | } | |
| 359 | ||
| 360 | impl Shared { | |
| 361 | fn touched(&self, paths: &[String]) { | |
| 362 | if let Ok(mut watched) = self.watched.lock() { | |
| 363 | watched.touched(paths, Instant::now()); | |
| 364 | } | |
| 262 | 365 | self.signal.wake(); |
| 263 | 366 | } |
| 264 | 367 | } |
| 265 | 368 | |
| 369 | impl 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 | ||
| 390 | impl 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. | |
| 443 | fn 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. | |
| 454 | fn 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. | |
| 464 | fn 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 | ||
| 266 | 477 | impl Watch { |
| 267 | 478 | /// Records a failed step and what it found waiting, answering whether the status shown |
| 268 | 479 | /// changes. |
| ... | ... | @@ -284,20 +495,43 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) { |
| 284 | 495 | } |
| 285 | 496 | |
| 286 | 497 | /// 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. | |
| 288 | 500 | fn step<R: Remote>( |
| 289 | 501 | remote: &mut R, |
| 290 | 502 | replica: Option<&Path>, |
| 291 | 503 | seen: Option<&Stamp>, |
| 504 | copies: bool, | |
| 292 | 505 | ) -> (Option<u64>, Result<(Stamp, bool)>) { |
| 293 | 506 | let stamp = match remote.stamp() { |
| 294 | 507 | Ok(stamp) => stamp, |
| 295 | 508 | Err(error) => return (None, Err(Error::RemoteIo(error))), |
| 296 | 509 | }; |
| 297 | 510 | let moved = seen.is_some_and(|seen| *seen != stamp); |
| 298 | let Some(replica) = replica.filter(|replica| replica.exists()) else { | |
| 511 | let Some(replica) = replica else { | |
| 299 | 512 | return (Some(0), Ok((stamp, moved))); |
| 300 | 513 | }; |
| 514 | if !replica.exists() { | |
| 515 | if !copies { | |
| 516 | return (Some(0), Ok((stamp, moved))); | |
| 517 | } | |
| 518 | let copied = (|| { | |
| 519 | let image = remote.read().map_err(Error::RemoteIo)?; | |
| 520 | if let Some(folder) = replica.parent() { | |
| 521 | std::fs::create_dir_all(folder)?; | |
| 522 | } | |
| 523 | Replica::seed(replica, &image)?; | |
| 524 | Ok(Stamp::of(&image)?) | |
| 525 | })(); | |
| 526 | return match copied { | |
| 527 | Ok(stamp) => (Some(0), Ok((stamp, moved))), | |
| 528 | // A session made it first. | |
| 529 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => { | |
| 530 | (None, Ok((stamp, false))) | |
| 531 | } | |
| 532 | Err(error) => (None, Err(error)), | |
| 533 | }; | |
| 534 | } | |
| 301 | 535 | match crate::peek(replica) { |
| 302 | 536 | Ok((base, 0)) if base == stamp => return (Some(0), Ok((stamp, moved))), |
| 303 | 537 | Err(error) if error.busy() => return (None, Ok((stamp, false))), |
| ... | ... | @@ -324,3 +558,176 @@ fn step<R: Remote>( |
| 324 | 558 | .map(|summary| summary.queued_edits); |
| 325 | 559 | (queued, synced) |
| 326 | 560 | } |
| 561 | ||
| 562 | #[cfg(test)] | |
| 563 | mod tests { | |
| 564 | use super::*; | |
| 565 | const BACKSTOP: Duration = Background::BACKSTOP; | |
| 566 | use onestore::{CommitError, Transaction}; | |
| 567 | use std::{ | |
| 568 | collections::HashMap, | |
| 569 | sync::mpsc::{self, Receiver}, | |
| 570 | }; | |
| 571 | ||
| 572 | fn sections(count: usize) -> Vec<(String, Option<PathBuf>)> { | |
| 573 | (0..count) | |
| 574 | .map(|n| { | |
| 575 | let folder = if n % 2 == 0 { "" } else { "Group/" }; | |
| 576 | (format!("{folder}Section {n:03}.one"), None) | |
| 577 | }) | |
| 578 | .collect() | |
| 579 | } | |
| 580 | ||
| 581 | /// Runs the schedule as the thread would with every check succeeding, up to `end`, | |
| 582 | /// answering which sections were checked when. | |
| 583 | fn run(watched: &mut Watched, end: Instant, interval: Duration) -> Vec<(Instant, String)> { | |
| 584 | let mut checks = Vec::new(); | |
| 585 | while let Some(index) = watched.next() { | |
| 586 | let watch = &mut watched.sections[index]; | |
| 587 | if watch.due > end { | |
| 588 | break; | |
| 589 | } | |
| 590 | checks.push((watch.due, watch.path.clone())); | |
| 591 | watch.due += interval; | |
| 592 | } | |
| 593 | checks | |
| 594 | } | |
| 595 | ||
| 596 | #[test] | |
| 597 | fn an_idle_notebook_is_read_once_staggered_then_left_alone() { | |
| 598 | let start = Instant::now(); | |
| 599 | let mut watched = Watched::default(); | |
| 600 | watched.watch(sections(200), start); | |
| 601 | let checks = run(&mut watched, start + BACKSTOP - SETTLE, BACKSTOP); | |
| 602 | assert_eq!(checks.len(), 200, "one first check each, then none"); | |
| 603 | for pair in checks.windows(2) { | |
| 604 | assert!(pair[1].0 - pair[0].0 >= STAGGER, "never a burst"); | |
| 605 | } | |
| 606 | // Then each section costs one backstop check an interval: over a day, 24 stamp reads | |
| 607 | // where a 15-second poll made 5,760. | |
| 608 | let day = run( | |
| 609 | &mut watched, | |
| 610 | start + Duration::from_secs(86_400) - SETTLE, | |
| 611 | BACKSTOP, | |
| 612 | ); | |
| 613 | assert_eq!(day.len(), 200 * (86_400 / BACKSTOP.as_secs() as usize - 1)); | |
| 614 | } | |
| 615 | ||
| 616 | #[test] | |
| 617 | fn a_reported_change_checks_exactly_the_sections_it_names() { | |
| 618 | let start = Instant::now(); | |
| 619 | let mut watched = Watched::default(); | |
| 620 | watched.watch(sections(200), start); | |
| 621 | let now = start + Duration::from_secs(60); | |
| 622 | run(&mut watched, now, BACKSTOP); | |
| 623 | // A commit's several writes are reported apiece, and settle into one check. | |
| 624 | for _ in 0..3 { | |
| 625 | watched.touched(&["section 004.ONE".into()], now); | |
| 626 | } | |
| 627 | watched.touched( | |
| 628 | &["Section 004.one".into()], | |
| 629 | now + Duration::from_millis(300), | |
| 630 | ); | |
| 631 | let checks = run(&mut watched, now + Duration::from_secs(60), BACKSTOP); | |
| 632 | assert_eq!(checks, vec![(now + SETTLE, "Section 004.one".to_owned())]); | |
| 633 | // A group's folder names the sections in it, and "" every section. | |
| 634 | let now = now + Duration::from_secs(60); | |
| 635 | watched.touched(&["Group".into()], now); | |
| 636 | let group = run(&mut watched, now + SETTLE, BACKSTOP); | |
| 637 | assert_eq!(group.len(), 100); | |
| 638 | assert!(group.iter().all(|(_, path)| path.starts_with("Group/"))); | |
| 639 | watched.touched(&["Grou".into(), String::new()], now); | |
| 640 | assert_eq!(run(&mut watched, now + SETTLE, BACKSTOP).len(), 200); | |
| 641 | } | |
| 642 | ||
| 643 | /// Section files in memory, each stamp read reported as it happens. | |
| 644 | #[derive(Clone)] | |
| 645 | struct Files { | |
| 646 | images: Arc<Mutex<HashMap<String, Vec<u8>>>>, | |
| 647 | stamps: mpsc::Sender<String>, | |
| 648 | } | |
| 649 | ||
| 650 | struct File(Files, String); | |
| 651 | ||
| 652 | impl Remote for File { | |
| 653 | fn read(&mut self) -> io::Result<Vec<u8>> { | |
| 654 | let images = self.0.images.lock().unwrap(); | |
| 655 | Ok(images[&self.1].clone()) | |
| 656 | } | |
| 657 | ||
| 658 | fn stamp(&mut self) -> io::Result<Stamp> { | |
| 659 | let _ = self.0.stamps.send(self.1.clone()); | |
| 660 | Stamp::of(&self.read()?).map_err(io::Error::other) | |
| 661 | } | |
| 662 | ||
| 663 | fn publish(&mut self, _: &Transaction) -> std::result::Result<(), CommitError> { | |
| 664 | unreachable!("nothing is queued") | |
| 665 | } | |
| 666 | ||
| 667 | fn confirm(&mut self, _: &Stamp) -> std::result::Result<(), CommitError> { | |
| 668 | unreachable!("nothing is queued") | |
| 669 | } | |
| 670 | } | |
| 671 | ||
| 672 | fn quiet(stamps: &Receiver<String>, wait: Duration) -> Vec<String> { | |
| 673 | let mut read = Vec::new(); | |
| 674 | while let Ok(path) = stamps.recv_timeout(wait) { | |
| 675 | read.push(path); | |
| 676 | } | |
| 677 | read | |
| 678 | } | |
| 679 | ||
| 680 | #[test] | |
| 681 | fn a_watch_wakes_the_section_it_reports_and_a_lost_one_rescans() { | |
| 682 | let (sender, stamps) = mpsc::channel(); | |
| 683 | let files = Files { | |
| 684 | images: Arc::default(), | |
| 685 | stamps: sender, | |
| 686 | }; | |
| 687 | let mut paths: Vec<String> = sections(6).into_iter().map(|(path, _)| path).collect(); | |
| 688 | paths.sort(); | |
| 689 | for path in &paths { | |
| 690 | let image = onestore::create_section("s.one", path, "Author").unwrap(); | |
| 691 | files.images.lock().unwrap().insert(path.clone(), image); | |
| 692 | } | |
| 693 | let (armed, watches) = mpsc::channel(); | |
| 694 | let remote = files.clone(); | |
| 695 | let background = Background::start( | |
| 696 | false, | |
| 697 | move |reports| { | |
| 698 | armed.send(reports).unwrap(); | |
| 699 | let files = remote.clone(); | |
| 700 | Ok(( | |
| 701 | move |path: &str| File(files.clone(), path.to_owned()), | |
| 702 | Duration::from_secs(3600), | |
| 703 | )) | |
| 704 | }, | |
| 705 | || {}, | |
| 706 | ) | |
| 707 | .unwrap(); | |
| 708 | background.watch(sections(6)); | |
| 709 | let mut first = quiet(&stamps, STAGGER * 4); | |
| 710 | first.sort(); | |
| 711 | assert_eq!(first, paths, "each section's first check"); | |
| 712 | let watch = watches.recv().unwrap(); | |
| 713 | assert!(quiet(&stamps, SETTLE).is_empty(), "idle reads nothing"); | |
| 714 | ||
| 715 | let changed = &paths[3]; | |
| 716 | files.images.lock().unwrap().insert( | |
| 717 | changed.clone(), | |
| 718 | onestore::create_section("s.one", "Changed", "Author").unwrap(), | |
| 719 | ); | |
| 720 | watch.touched(std::slice::from_ref(changed)); | |
| 721 | assert_eq!(quiet(&stamps, SETTLE * 2), vec![changed.clone()]); | |
| 722 | assert_eq!(background.changed(), vec![changed.clone()]); | |
| 723 | ||
| 724 | // A watch that ended leaves changes unreported, so everything is read again. | |
| 725 | watch.lost(); | |
| 726 | let rewatch = watches.recv_timeout(SETTLE).unwrap(); | |
| 727 | assert_eq!(quiet(&stamps, STAGGER * 4).len(), 6); | |
| 728 | // The ended watch's connection is gone; only the current one's loss counts. | |
| 729 | watch.lost(); | |
| 730 | assert!(quiet(&stamps, SETTLE).is_empty()); | |
| 731 | drop(rewatch); | |
| 732 | } | |
| 733 | } |
crates/notebook/src/discover.rs+4| ... | ... | @@ -30,6 +30,9 @@ pub enum SectionState { |
| 30 | 30 | name: Option<String>, |
| 31 | 31 | /// The tab colour as a COLORREF; OneNote assigns one when absent. |
| 32 | 32 | color: Option<u32>, |
| 33 | /// The root object space's GUID, the document identity that names the section's | |
| 34 | /// replica in a mounted notebook. | |
| 35 | document: [u8; 16], | |
| 33 | 36 | }, |
| 34 | 37 | Locked, |
| 35 | 38 | Unreadable(onestore::Error), |
| ... | ... | @@ -310,6 +313,7 @@ fn scan( |
| 310 | 313 | state: SectionState::Readable { |
| 311 | 314 | name, |
| 312 | 315 | color: color.filter(|color| *color != 0xffff_ffff), |
| 316 | document: index.root.guid, | |
| 313 | 317 | }, |
| 314 | 318 | }); |
| 315 | 319 | } else { |
crates/notebook/src/lib.rs+16-11| ... | ... | @@ -105,23 +105,28 @@ impl Replica { |
| 105 | 105 | /// Seeds a new cache from a validated section image, refusing any existing path. |
| 106 | 106 | /// An initialization error preserves the created file for inspection. |
| 107 | 107 | pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> { |
| 108 | Self::seed(path.as_ref(), source)?; | |
| 109 | Self::start(cache_connection(path.as_ref())?) | |
| 110 | } | |
| 111 | ||
| 112 | /// `create` without opening the cache it made, as for an offline copy. | |
| 113 | pub(crate) fn seed(path: &Path, source: &[u8]) -> Result<()> { | |
| 108 | 114 | 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); | |
| 112 | 119 | let mut building = path.as_os_str().to_owned(); |
| 113 | building.push(".creating"); | |
| 120 | building.push(format!(".creating-{}-{build}", std::process::id())); | |
| 114 | 121 | let building = PathBuf::from(building); |
| 115 | for stale in ["", "-wal", "-shm"] { | |
| 122 | let built = Self::build(&building, source); | |
| 123 | let linked = built.and_then(|()| Ok(std::fs::hard_link(&building, path)?)); | |
| 124 | for suffix in ["", "-wal", "-shm"] { | |
| 116 | 125 | let mut file = building.as_os_str().to_owned(); |
| 117 | file.push(stale); | |
| 126 | file.push(suffix); | |
| 118 | 127 | let _ = std::fs::remove_file(file); |
| 119 | 128 | } |
| 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 | |
| 125 | 130 | } |
| 126 | 131 | |
| 127 | 132 | fn build(path: &Path, source: &[u8]) -> Result<()> { |
crates/notebook/src/merge.rs+2| ... | ... | @@ -164,6 +164,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> { |
| 164 | 164 | PageOp::Level { paragraph, .. } |
| 165 | 165 | | PageOp::Paragraph { paragraph, .. } |
| 166 | 166 | | PageOp::Style { paragraph, .. } |
| 167 | | PageOp::Media { paragraph, .. } | |
| 167 | 168 | | PageOp::List { paragraph, .. } => vec![*paragraph], |
| 168 | 169 | PageOp::Tags { target, .. } => vec![*target], |
| 169 | 170 | PageOp::Add { object, .. } => vec![object.id()], |
| ... | ... | @@ -736,6 +737,7 @@ impl Diff { |
| 736 | 737 | } |
| 737 | 738 | PageOp::Paragraph { paragraph, .. } |
| 738 | 739 | | PageOp::Style { paragraph, .. } |
| 740 | | PageOp::Media { paragraph, .. } | |
| 739 | 741 | | PageOp::List { paragraph, .. } => { |
| 740 | 742 | require(self.kept(*paragraph) && self.new_has(*paragraph))? |
| 741 | 743 | } |
crates/notebook/src/session.rs+15-8| ... | ... | @@ -887,13 +887,16 @@ impl Notebook { |
| 887 | 887 | /// `smb` on a share. It exists once the section has been opened. |
| 888 | 888 | pub fn replica_path(&self, path: &str) -> Result<PathBuf> { |
| 889 | 889 | 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(_), _) => { | |
| 892 | 895 | let image = self.storage.read(&section.path)?; |
| 893 | 896 | let root = RevisionIndex::parse(&Store::parse(&image)?)?.root; |
| 894 | 897 | replica_file(&self.cache, &root.guid) |
| 895 | 898 | } |
| 896 | None => replica_file(&self.cache.join("smb"), &section.file_id), | |
| 899 | (None, _) => replica_file(&self.cache.join("smb"), &section.file_id), | |
| 897 | 900 | }) |
| 898 | 901 | } |
| 899 | 902 | |
| ... | ... | @@ -914,13 +917,16 @@ impl Notebook { |
| 914 | 917 | } |
| 915 | 918 | |
| 916 | 919 | /// Keeps the sections of a mounted notebook in sync while they are not open |
| 917 | /// (`Background`), polling each file every `interval` once watched. | |
| 920 | /// (`Background`): each file is checked every `interval`, and sooner when | |
| 921 | /// `Background::touched` reports it changed. `copies` keeps an offline copy of every | |
| 922 | /// section, for a folder that is not on this computer. | |
| 918 | 923 | pub fn background( |
| 919 | 924 | &self, |
| 920 | 925 | interval: Duration, |
| 926 | copies: bool, | |
| 921 | 927 | notify: impl Fn() + Send + 'static, |
| 922 | 928 | ) -> Result<Background> { |
| 923 | self.background_with(interval, |file| FileRemote(file.to_owned()), notify) | |
| 929 | self.background_with(interval, copies, |file| FileRemote(file.to_owned()), notify) | |
| 924 | 930 | } |
| 925 | 931 | |
| 926 | 932 | /// `background`, reaching each section file through the remote `remote` makes for it |
| ... | ... | @@ -928,6 +934,7 @@ impl Notebook { |
| 928 | 934 | pub fn background_with<R: Remote + 'static>( |
| 929 | 935 | &self, |
| 930 | 936 | interval: Duration, |
| 937 | copies: bool, | |
| 931 | 938 | remote: impl Fn(&Path) -> R + Clone + Send + 'static, |
| 932 | 939 | notify: impl Fn() + Send + 'static, |
| 933 | 940 | ) -> Result<Background> { |
| ... | ... | @@ -939,10 +946,10 @@ impl Notebook { |
| 939 | 946 | .into()); |
| 940 | 947 | }; |
| 941 | 948 | Background::start( |
| 942 | interval, | |
| 943 | move || { | |
| 949 | copies, | |
| 950 | move |_| { | |
| 944 | 951 | let (root, remote) = (root.clone(), remote.clone()); |
| 945 | Ok(move |path: &str| remote(&root.join(path))) | |
| 952 | Ok((move |path: &str| remote(&root.join(path)), interval)) | |
| 946 | 953 | }, |
| 947 | 954 | notify, |
| 948 | 955 | ) |
crates/notebook/src/smb/directory.rs+67| ... | ... | @@ -96,6 +96,73 @@ impl Client { |
| 96 | 96 | }) |
| 97 | 97 | .collect()) |
| 98 | 98 | } |
| 99 | ||
| 100 | /// Watches the directory tree at `path` as OneNote 2010 watches a notebook's folder: one | |
| 101 | /// CHANGE_NOTIFY with WATCH_TREE, kept armed on the server, so an idle watch sends | |
| 102 | /// nothing. `changed` runs on the client's runtime with each batch of changed paths, | |
| 103 | /// relative to `path` and `/`-separated, or `""` when the server lost count of them, then | |
| 104 | /// `Err(_)` once the watch ends with its connection or the client. | |
| 105 | pub fn watch( | |
| 106 | &self, | |
| 107 | path: &str, | |
| 108 | changed: impl FnMut(io::Result<Vec<String>>) + Send + 'static, | |
| 109 | ) -> io::Result<()> { | |
| 110 | if Handle::try_current().is_ok() { | |
| 111 | return Err(io::ErrorKind::InvalidInput.into()); | |
| 112 | } | |
| 113 | let mut connection = self | |
| 114 | .connection | |
| 115 | .lock() | |
| 116 | .map_err(|_| io::ErrorKind::Other)? | |
| 117 | .clone() | |
| 118 | .ok_or(io::ErrorKind::NotConnected)?; | |
| 119 | // Probing a connection whose only request is the watch would end OneNote's silence. | |
| 120 | connection.set_keepalive(None); | |
| 121 | let runtime = self.runtime.lock().map_err(|_| io::ErrorKind::Other)?; | |
| 122 | let runtime = runtime.as_ref().ok_or(io::ErrorKind::NotConnected)?; | |
| 123 | let path = path.replace('\\', "/"); | |
| 124 | let mut watcher = runtime | |
| 125 | .block_on(async { | |
| 126 | tokio::time::timeout(self.timeout, self.tree.watch(&mut connection, &path, true)) | |
| 127 | .await | |
| 128 | }) | |
| 129 | .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))? | |
| 130 | .map_err(|error| match &error { | |
| 131 | smb2::Error::Protocol { status, .. } if status.0 == 0xc0000034 => { | |
| 132 | io::Error::new(io::ErrorKind::NotFound, error) | |
| 133 | } | |
| 134 | // STATUS_NOT_SUPPORTED, STATUS_INVALID_DEVICE_REQUEST | |
| 135 | smb2::Error::Protocol { status, .. } | |
| 136 | if matches!(status.0, 0xc00000bb | 0xc0000010) => | |
| 137 | { | |
| 138 | io::Error::new(io::ErrorKind::Unsupported, error) | |
| 139 | } | |
| 140 | _ => io::Error::other(error), | |
| 141 | })?; | |
| 142 | // However the task ends, the connection failing or the client retiring its runtime, | |
| 143 | // `changed` hears that the watch did. | |
| 144 | struct Ends<F: FnMut(io::Result<Vec<String>>)>(F); | |
| 145 | impl<F: FnMut(io::Result<Vec<String>>)> Drop for Ends<F> { | |
| 146 | fn drop(&mut self) { | |
| 147 | (self.0)(Err(io::ErrorKind::NotConnected.into())); | |
| 148 | } | |
| 149 | } | |
| 150 | let mut ends = Ends(changed); | |
| 151 | runtime.spawn(async move { | |
| 152 | loop { | |
| 153 | match watcher.next_events().await { | |
| 154 | Ok(events) => { | |
| 155 | (ends.0)(Ok(events.into_iter().map(|event| event.filename).collect())) | |
| 156 | } | |
| 157 | Err(smb2::Error::Protocol { status, .. }) if status.0 == 0x0000010c => { | |
| 158 | (ends.0)(Ok(vec![String::new()])) | |
| 159 | } | |
| 160 | Err(_) => return, | |
| 161 | } | |
| 162 | } | |
| 163 | }); | |
| 164 | Ok(()) | |
| 165 | } | |
| 99 | 166 | } |
| 100 | 167 | |
| 101 | 168 | fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> { |
crates/notebook/src/smb/tests.rs+38| ... | ... | @@ -421,6 +421,44 @@ fn live_stamp_and_transaction() { |
| 421 | 421 | poller.delete(&path).unwrap(); |
| 422 | 422 | } |
| 423 | 423 | |
| 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"] | |
| 427 | fn live_watch() { | |
| 428 | let watcher = client(); | |
| 429 | let writer = client(); | |
| 430 | let folder = format!( | |
| 431 | "watch-{}", | |
| 432 | SystemTime::now() | |
| 433 | .duration_since(UNIX_EPOCH) | |
| 434 | .unwrap() | |
| 435 | .as_nanos() | |
| 436 | ); | |
| 437 | writer.create_directory(&folder).unwrap(); | |
| 438 | let path = format!("{folder}/Watched.one"); | |
| 439 | let source = onestore::create_section("Watched.one", "Watched", "Author").unwrap(); | |
| 440 | create(&writer, &path, &source); | |
| 441 | let (sender, changes) = std::sync::mpsc::channel(); | |
| 442 | watcher | |
| 443 | .watch(&folder, move |changed| { | |
| 444 | let _ = sender.send(changed.map_err(|error| error.kind())); | |
| 445 | }) | |
| 446 | .unwrap(); | |
| 447 | std::thread::sleep(Duration::from_secs(1)); | |
| 448 | writer | |
| 449 | .commit_transaction(&path, &replaced(&source, 0..0, "Changed ")) | |
| 450 | .unwrap(); | |
| 451 | let changed = changes.recv_timeout(Duration::from_secs(10)).unwrap(); | |
| 452 | assert!(changed.unwrap().contains(&"Watched.one".to_owned())); | |
| 453 | // An idle watch hears nothing more. | |
| 454 | while let Ok(changed) = changes.recv_timeout(Duration::from_secs(2)) { | |
| 455 | assert!(changed.unwrap().iter().all(|path| path == "Watched.one")); | |
| 456 | } | |
| 457 | drop(watcher); | |
| 458 | writer.delete(&path).unwrap(); | |
| 459 | writer.delete(&folder).unwrap(); | |
| 460 | } | |
| 461 | ||
| 424 | 462 | #[test] |
| 425 | 463 | #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] |
| 426 | 464 | fn live_storage_inspection() { |
crates/notebook/tests/session.rs+76-2| ... | ... | @@ -1029,7 +1029,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() { |
| 1029 | 1029 | let notebook = two_sections(directory.path()); |
| 1030 | 1030 | let file = directory.path().join("Shared/Second.one"); |
| 1031 | 1031 | let background = notebook |
| 1032 | .background(Duration::from_millis(20), || {}) | |
| 1032 | .background(Duration::from_millis(20), false, || {}) | |
| 1033 | 1033 | .unwrap(); |
| 1034 | 1034 | background.set_offline(true); |
| 1035 | 1035 | background.watch(notebook.replicas()); |
| ... | ... | @@ -1083,7 +1083,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { |
| 1083 | 1083 | let notified = Arc::new(AtomicUsize::new(0)); |
| 1084 | 1084 | let counter = Arc::clone(&notified); |
| 1085 | 1085 | let background = notebook |
| 1086 | .background(Duration::from_millis(20), move || { | |
| 1086 | .background(Duration::from_millis(20), false, move || { | |
| 1087 | 1087 | counter.fetch_add(1, Ordering::SeqCst); |
| 1088 | 1088 | }) |
| 1089 | 1089 | .unwrap(); |
| ... | ... | @@ -1125,3 +1125,77 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { |
| 1125 | 1125 | }); |
| 1126 | 1126 | assert_same(opened.unwrap().page(*space).unwrap(), native); |
| 1127 | 1127 | } |
| 1128 | ||
| 1129 | #[test] | |
| 1130 | fn 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] | |
| 1166 | fn 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 @@ |
| 1 | use onestore::{RevisionIndex, Store, document::Document}; | |
| 2 | fn 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 @@ |
| 1 | use onestore::{RevisionIndex, Store, document::Document}; | |
| 2 | fn 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)] | |
| 29 | fn unused() {} |
crates/onestore/src/document.rs+31-9| ... | ... | @@ -405,9 +405,12 @@ impl Revision<'_> { |
| 405 | 405 | |
| 406 | 406 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 407 | 407 | /// Tag dates count seconds since 1980-01-01 UTC; status retains the ActionItemStatus bits. |
| 408 | /// A task tag (status bit 4) has no definition and carries its own icon and property status. | |
| 408 | 409 | pub struct Tag { |
| 409 | 410 | pub definition: Option<ExGuid>, |
| 410 | 411 | pub action_type: Option<u16>, |
| 412 | pub shape: Option<u16>, | |
| 413 | pub property_status: Option<u32>, | |
| 411 | 414 | pub status: u16, |
| 412 | 415 | pub created: Option<u32>, |
| 413 | 416 | pub completed: Option<u32>, |
| ... | ... | @@ -446,6 +449,8 @@ pub enum Kind<'a> { |
| 446 | 449 | margin_origin_x: Option<f32>, |
| 447 | 450 | margin_origin_y: Option<f32>, |
| 448 | 451 | rtl: Option<bool>, |
| 452 | /// Every audio and video recording on the page (AudioRecordingGuids). | |
| 453 | recordings: Vec<[u8; 16]>, | |
| 449 | 454 | }, |
| 450 | 455 | Metadata { |
| 451 | 456 | title: Option<String>, |
| ... | ... | @@ -475,11 +480,16 @@ pub enum Kind<'a> { |
| 475 | 480 | }, |
| 476 | 481 | OutlineGroup, |
| 477 | 482 | /// An ink drawing or handwriting container: strokes hang off `data`, nested containers off |
| 478 | /// the element's content list. Scaling multiplies stroke coordinates. | |
| 483 | /// the element's content list. Scaling multiplies stroke coordinates. A drawn shape adds | |
| 484 | /// its kind (11 a line, 12 a closed shape) and geometry: a line's ends, or a closed | |
| 485 | /// shape's transform and anchors, as `page::InkShape` reads them. | |
| 479 | 486 | Ink { |
| 480 | 487 | data: Option<ExGuid>, |
| 481 | 488 | scale_x: Option<f32>, |
| 482 | 489 | scale_y: Option<f32>, |
| 490 | shape_kind: Option<u8>, | |
| 491 | line: Option<Vec<u8>>, | |
| 492 | anchors: Option<Vec<u8>>, | |
| 483 | 493 | }, |
| 484 | 494 | InkData { |
| 485 | 495 | strokes: Vec<ExGuid>, |
| ... | ... | @@ -555,6 +565,8 @@ pub enum Kind<'a> { |
| 555 | 565 | source_path: Option<String>, |
| 556 | 566 | recording_id: Option<[u8; 16]>, |
| 557 | 567 | recording_type: Option<u32>, |
| 568 | /// AudioRecordingDuration, in milliseconds. | |
| 569 | recording_duration: Option<u32>, | |
| 558 | 570 | /// Displayed icon width and height in points. |
| 559 | 571 | icon_width: Option<f32>, |
| 560 | 572 | icon_height: Option<f32>, |
| ... | ... | @@ -675,6 +687,16 @@ impl<'a, 'o> Fields<'a, 'o> { |
| 675 | 687 | fn u32(&mut self, id: u32) -> Result<Option<u32>> { |
| 676 | 688 | Ok(self.fixed(id)?.map(u32::from_le_bytes)) |
| 677 | 689 | } |
| 690 | fn guids(&mut self, id: u32) -> Result<Vec<[u8; 16]>> { | |
| 691 | let bytes = self.bytes(id)?.unwrap_or_default(); | |
| 692 | if !bytes.len().is_multiple_of(16) { | |
| 693 | return Err(invalid("Media identifiers have an invalid length")); | |
| 694 | } | |
| 695 | Ok(bytes | |
| 696 | .chunks_exact(16) | |
| 697 | .map(|id| id.try_into().unwrap()) | |
| 698 | .collect()) | |
| 699 | } | |
| 678 | 700 | fn u16(&mut self, id: u32) -> Result<Option<u16>> { |
| 679 | 701 | Ok(self.fixed(id)?.map(u16::from_le_bytes)) |
| 680 | 702 | } |
| ... | ... | @@ -1058,6 +1080,8 @@ impl<'a> Element<'a> { |
| 1058 | 1080 | tags.push(Tag { |
| 1059 | 1081 | definition: tag.one(0x20003488)?, |
| 1060 | 1082 | action_type: tag.u16(0x10003463)?, |
| 1083 | shape: tag.u16(0x10003464)?, | |
| 1084 | property_status: tag.u32(0x14003467)?, | |
| 1061 | 1085 | status: tag |
| 1062 | 1086 | .u16(0x10003470)? |
| 1063 | 1087 | .ok_or_else(|| invalid("Note tag has no status"))?, |
| ... | ... | @@ -1114,6 +1138,7 @@ impl<'a> Element<'a> { |
| 1114 | 1138 | margin_origin_x: f.float(0x14001d0f, 36.0)?, |
| 1115 | 1139 | margin_origin_y: f.float(0x14001d10, 36.0)?, |
| 1116 | 1140 | rtl: f.boolean(0x08001c92)?, |
| 1141 | recordings: f.guids(0x1c001ca3)?, | |
| 1117 | 1142 | }, |
| 1118 | 1143 | 0x20030 => Kind::Metadata { |
| 1119 | 1144 | title: f.text(0x1c001cf3)?, |
| ... | ... | @@ -1128,6 +1153,9 @@ impl<'a> Element<'a> { |
| 1128 | 1153 | data: f.one(0x20003415)?, |
| 1129 | 1154 | scale_x: f.float(0x14001c46, 1.0)?, |
| 1130 | 1155 | scale_y: f.float(0x14001c47, 1.0)?, |
| 1156 | shape_kind: f.bytes(0x0c001d4f)?.and_then(|b| b.first().copied()), | |
| 1157 | line: f.bytes(0x1c001dac)?.map(<[u8]>::to_vec), | |
| 1158 | anchors: f.bytes(0x1c001daa)?.map(<[u8]>::to_vec), | |
| 1131 | 1159 | }, |
| 1132 | 1160 | 0x2003b => Kind::InkData { |
| 1133 | 1161 | strokes: f.refs(0x24003416, IdStream::Objects)?, |
| ... | ... | @@ -1327,6 +1355,7 @@ impl<'a> Element<'a> { |
| 1327 | 1355 | source_path: f.text(0x1c001d9d)?, |
| 1328 | 1356 | recording_id: f.fixed(0x1c001c97)?, |
| 1329 | 1357 | recording_type: f.u32(0x14001d24)?, |
| 1358 | recording_duration: f.u32(0x14001cfd)?, | |
| 1330 | 1359 | icon_width: f.float(0x140034cd, 36.0)?, |
| 1331 | 1360 | icon_height: f.float(0x140034ce, 36.0)?, |
| 1332 | 1361 | }, |
| ... | ... | @@ -1349,14 +1378,7 @@ impl<'a> Element<'a> { |
| 1349 | 1378 | }, |
| 1350 | 1379 | _ => Kind::Unknown, |
| 1351 | 1380 | }; |
| 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)?; | |
| 1360 | 1382 | Ok(Self { |
| 1361 | 1383 | jcid: object.jcid, |
| 1362 | 1384 | children, |
crates/onestore/src/op/apply.rs+82-17| ... | ... | @@ -63,9 +63,16 @@ impl<'a> Section<'a> { |
| 63 | 63 | .collect(); |
| 64 | 64 | for space in spaces { |
| 65 | 65 | // A page the edit went on to delete has no cells left to check. |
| 66 | let Ok(page) = section.active(space) else { | |
| 66 | if section.active(space).is_err() { | |
| 67 | 67 | continue; |
| 68 | }; | |
| 68 | } | |
| 69 | Writer { | |
| 70 | section: &mut *section, | |
| 71 | space, | |
| 72 | author, | |
| 73 | } | |
| 74 | .playlist()?; | |
| 75 | let page = section.active(space)?; | |
| 69 | 76 | if page.view.nodes.values().any(|node| { |
| 70 | 77 | matches!(node.kind, Kind::Cell { .. }) && node.children.is_empty() |
| 71 | 78 | }) { |
| ... | ... | @@ -474,6 +481,41 @@ impl<'a> Writer<'_, 'a> { |
| 474 | 481 | Ok(()) |
| 475 | 482 | } |
| 476 | 483 | |
| 484 | /// Lists the page's recordings in its AudioRecordingGuids as OneNote keeps them: in | |
| 485 | /// the order they arrived, without those since removed, absent when there are none. | |
| 486 | fn playlist(&mut self) -> Result<(), Failure> { | |
| 487 | let page = self.page()?; | |
| 488 | let [id] = page.pages[..] else { | |
| 489 | return Ok(()); | |
| 490 | }; | |
| 491 | let Kind::Page { recordings, .. } = &page.view.nodes[&id].kind else { | |
| 492 | return Ok(()); | |
| 493 | }; | |
| 494 | let present: Vec<[u8; 16]> = page | |
| 495 | .view | |
| 496 | .nodes | |
| 497 | .values() | |
| 498 | .filter_map(|node| match node.kind { | |
| 499 | Kind::Attachment { recording_id, .. } => recording_id, | |
| 500 | _ => None, | |
| 501 | }) | |
| 502 | .collect(); | |
| 503 | let mut listed: Vec<[u8; 16]> = recordings | |
| 504 | .iter() | |
| 505 | .filter(|recording| present.contains(recording)) | |
| 506 | .copied() | |
| 507 | .collect(); | |
| 508 | for recording in present { | |
| 509 | if !listed.contains(&recording) { | |
| 510 | listed.push(recording); | |
| 511 | } | |
| 512 | } | |
| 513 | if listed == *recordings { | |
| 514 | return Ok(()); | |
| 515 | } | |
| 516 | self.write(|page| content::playlist_changes(page, id, &listed)) | |
| 517 | } | |
| 518 | ||
| 477 | 519 | /// Requires `id` reachable on the page. |
| 478 | 520 | fn target(&mut self, id: ExGuid) -> Result<(), Failure> { |
| 479 | 521 | let page = self.page()?; |
| ... | ... | @@ -746,6 +788,33 @@ impl<'a> Writer<'_, 'a> { |
| 746 | 788 | let text = self.text_of(*paragraph)?; |
| 747 | 789 | self.write(|page| properties::style_changes(page, text, *style, Some(definition))) |
| 748 | 790 | } |
| 791 | PageOp::Media { paragraph, media } => { | |
| 792 | self.target(*paragraph)?; | |
| 793 | let page = self.page()?; | |
| 794 | let content = match page.view.nodes[paragraph].content.as_slice() { | |
| 795 | [content] | |
| 796 | if matches!( | |
| 797 | page.view.nodes[content].kind, | |
| 798 | Kind::RichText { .. } | Kind::Attachment { .. } | |
| 799 | ) => | |
| 800 | { | |
| 801 | *content | |
| 802 | } | |
| 803 | _ => { | |
| 804 | return Err(OpError::Unsupported( | |
| 805 | "Link a paragraph of text or a file to a recording", | |
| 806 | ) | |
| 807 | .into()); | |
| 808 | } | |
| 809 | }; | |
| 810 | if media.recordings.is_empty() != media.time_ms.is_none() { | |
| 811 | return Err(OpError::Unsupported( | |
| 812 | "A recording link names recordings and a moment in them", | |
| 813 | ) | |
| 814 | .into()); | |
| 815 | } | |
| 816 | self.write(|page| properties::media_changes(page, content, media)) | |
| 817 | } | |
| 749 | 818 | PageOp::List { paragraph, lists } => { |
| 750 | 819 | self.target(*paragraph)?; |
| 751 | 820 | let page = self.page()?; |
| ... | ... | @@ -772,17 +841,16 @@ impl<'a> Writer<'_, 'a> { |
| 772 | 841 | definitions, |
| 773 | 842 | } => { |
| 774 | 843 | 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(); | |
| 786 | 854 | self.write(|page| properties::tag_changes(page, *target, &entries)) |
| 787 | 855 | } |
| 788 | 856 | PageOp::Add { object, before } => self.add(object, *before), |
| ... | ... | @@ -1449,7 +1517,7 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> { |
| 1449 | 1517 | } |
| 1450 | 1518 | if paragraph.media != Default::default() { |
| 1451 | 1519 | return Err(OpError::Unsupported( |
| 1452 | "Recording annotations are made by OneNote while it records", | |
| 1520 | "Link a paragraph to a recording with its own op", | |
| 1453 | 1521 | )); |
| 1454 | 1522 | } |
| 1455 | 1523 | match &paragraph.content { |
| ... | ... | @@ -1470,9 +1538,6 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> { |
| 1470 | 1538 | } |
| 1471 | 1539 | Ok(()) |
| 1472 | 1540 | } |
| 1473 | ParagraphContent::Attachment(attachment) if attachment.recording.is_some() => Err( | |
| 1474 | OpError::Unsupported("Recordings are captured by OneNote, not inserted"), | |
| 1475 | ), | |
| 1476 | 1541 | _ => Ok(()), |
| 1477 | 1542 | } |
| 1478 | 1543 | } |
crates/onestore/src/op/content.rs+124-40| ... | ... | @@ -6,7 +6,7 @@ use crate::{ |
| 6 | 6 | Error, ExGuid, |
| 7 | 7 | active::{ActivePage, Changes}, |
| 8 | 8 | document::{Format, Layout}, |
| 9 | page::{Attachment, Image, Ink, InkStroke, Paragraph}, | |
| 9 | page::{Attachment, Image, Ink, InkStroke, Paragraph, Recording}, | |
| 10 | 10 | write::PropertyObject, |
| 11 | 11 | }; |
| 12 | 12 | use std::collections::BTreeMap; |
| ... | ... | @@ -319,8 +319,10 @@ pub(crate) fn attachment_changes( |
| 319 | 319 | "An attachment needs a file name without path separators", |
| 320 | 320 | )); |
| 321 | 321 | } |
| 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 | )); | |
| 324 | 326 | } |
| 325 | 327 | if attachment.bytes.is_none() { |
| 326 | 328 | return Err(invalid("A new attachment needs its payload")); |
| ... | ... | @@ -374,6 +376,19 @@ pub(crate) fn attachment_changes( |
| 374 | 376 | if let Some(path) = &attachment.source_path { |
| 375 | 377 | values.push((0x1c001d9d, crate::create::string(path))); |
| 376 | 378 | } |
| 379 | if let Some(recording) = attachment.recording { | |
| 380 | if recording.id == [0; 16] || !matches!(recording.kind, 1 | 2) { | |
| 381 | return Err(invalid( | |
| 382 | "A recording needs an identity and is audio or video", | |
| 383 | )); | |
| 384 | } | |
| 385 | // AudioRecordingGuid, IRecordMedia and AudioRecordingDuration. | |
| 386 | values.push((0x1c001c97, recording.id.to_vec())); | |
| 387 | values.push((0x14001d24, recording.kind.to_le_bytes().to_vec())); | |
| 388 | if let Some(duration) = recording.duration_ms { | |
| 389 | values.push((0x14001cfd, duration.to_le_bytes().to_vec())); | |
| 390 | } | |
| 391 | } | |
| 377 | 392 | values.extend(layout_values(&Default::default(), &attachment.layout)?.0); |
| 378 | 393 | let mut changed = BTreeMap::new(); |
| 379 | 394 | let mut file = PropertyObject::file(ids.file, &payload_reference(ids.payload), extension)?; |
| ... | ... | @@ -400,6 +415,21 @@ pub(crate) fn attachment_changes( |
| 400 | 415 | Ok(changed) |
| 401 | 416 | } |
| 402 | 417 | |
| 418 | /// Page `page` listing `recordings` in its AudioRecordingGuids, or none. | |
| 419 | pub(crate) fn playlist_changes( | |
| 420 | active: &ActivePage<'_>, | |
| 421 | page: ExGuid, | |
| 422 | recordings: &[[u8; 16]], | |
| 423 | ) -> Result<Changes, Error> { | |
| 424 | let mut node = PropertyObject::from_object(&active.live.revision.objects[&page])?; | |
| 425 | if recordings.is_empty() { | |
| 426 | node.remove(&[0x1c001ca3])?; | |
| 427 | } else { | |
| 428 | node.set(&[(0x1c001ca3, recordings.concat().as_slice())])?; | |
| 429 | } | |
| 430 | Ok(BTreeMap::from([(page, node)])) | |
| 431 | } | |
| 432 | ||
| 403 | 433 | /// A stored attachment keeps its payload and preview; its shown name, recorded source |
| 404 | 434 | /// path and icon size change in place, as OneNote's rename does. |
| 405 | 435 | pub(crate) fn attachment_edit_changes( |
| ... | ... | @@ -415,7 +445,7 @@ pub(crate) fn attachment_edit_changes( |
| 415 | 445 | .to_vec(), |
| 416 | 446 | )]; |
| 417 | 447 | if attachment.recording != stored.recording { |
| 418 | return Err(invalid("A recording stays the recording OneNote captured")); | |
| 448 | return Err(invalid("A file keeps its recording")); | |
| 419 | 449 | } |
| 420 | 450 | let name = attachment.filename.as_str(); |
| 421 | 451 | if name.is_empty() || name.contains(['\0', '/', '\\']) { |
| ... | ... | @@ -425,6 +455,11 @@ pub(crate) fn attachment_edit_changes( |
| 425 | 455 | } |
| 426 | 456 | let mut removed = Vec::new(); |
| 427 | 457 | if attachment.filename != stored.filename { |
| 458 | if Recording::kind_of(name).is_some() != attachment.recording.is_some() { | |
| 459 | return Err(invalid( | |
| 460 | "Audio and video files are recordings, and only they are", | |
| 461 | )); | |
| 462 | } | |
| 428 | 463 | let name = crate::create::string(&attachment.filename); |
| 429 | 464 | values.push((0x1c001c22, name.clone())); |
| 430 | 465 | values.push((0x1c001d9c, name)); |
| ... | ... | @@ -457,19 +492,31 @@ pub(crate) fn attachment_edit_changes( |
| 457 | 492 | Ok(BTreeMap::from([(object, node)])) |
| 458 | 493 | } |
| 459 | 494 | |
| 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. | |
| 462 | 498 | fn write_strokes( |
| 499 | active: &ActivePage<'_>, | |
| 463 | 500 | changed: &mut Changes, |
| 464 | 501 | data_object: &mut PropertyObject, |
| 465 | 502 | strokes: &[(ExGuid, &InkStroke)], |
| 466 | first: usize, | |
| 503 | shape: bool, | |
| 467 | 504 | filetime: u64, |
| 468 | 505 | ) -> Result<Vec<u8>, Error> { |
| 506 | let first = active | |
| 507 | .view | |
| 508 | .nodes | |
| 509 | .values() | |
| 510 | .filter_map(|node| match node.kind { | |
| 511 | crate::document::Kind::InkStroke { index, .. } => index, | |
| 512 | _ => None, | |
| 513 | }) | |
| 514 | .max() | |
| 515 | .unwrap_or(0); | |
| 469 | 516 | let mut styles: Vec<(InkPen, ExGuid)> = Vec::new(); |
| 470 | 517 | let mut references = Vec::new(); |
| 471 | 518 | for (offset, (stroke_id, stroke)) in strokes.iter().enumerate() { |
| 472 | let pen = InkPen::of(stroke); | |
| 519 | let pen = InkPen::of(stroke, shape); | |
| 473 | 520 | let style = match styles.iter().find(|(known, _)| *known == pen) { |
| 474 | 521 | Some((_, id)) => *id, |
| 475 | 522 | None => { |
| ... | ... | @@ -488,13 +535,14 @@ fn write_strokes( |
| 488 | 535 | id |
| 489 | 536 | } |
| 490 | 537 | }; |
| 538 | let values = if shape { | |
| 539 | stroke_path(stroke)? | |
| 540 | } else { | |
| 541 | stroke_values(stroke, first + offset as u32 + 1, filetime)? | |
| 542 | }; | |
| 491 | 543 | let mut object = PropertyObject { |
| 492 | 544 | 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)?, | |
| 498 | 546 | global_ids: std::sync::Arc::new(BTreeMap::from([(0, stroke_id.guid)])), |
| 499 | 547 | }; |
| 500 | 548 | object.reference(*stroke_id)?; |
| ... | ... | @@ -506,7 +554,8 @@ fn write_strokes( |
| 506 | 554 | Ok(references) |
| 507 | 555 | } |
| 508 | 556 | |
| 509 | /// The drawing attributes OneNote shares between strokes drawn with the same pen. | |
| 557 | /// The drawing attributes OneNote shares between strokes drawn with the same pen. A pen | |
| 558 | /// drawn with the mouse ignores pressure; a shape's pen spans every coordinate instead. | |
| 510 | 559 | #[derive(PartialEq)] |
| 511 | 560 | struct InkPen { |
| 512 | 561 | width: u32, |
| ... | ... | @@ -514,22 +563,31 @@ struct InkPen { |
| 514 | 563 | color: Option<u32>, |
| 515 | 564 | transparency: Option<u8>, |
| 516 | 565 | pen_tip: Option<u8>, |
| 566 | raster_operation: Option<u8>, | |
| 567 | shape: bool, | |
| 517 | 568 | } |
| 518 | 569 | |
| 519 | 570 | impl InkPen { |
| 520 | fn of(stroke: &InkStroke) -> Self { | |
| 571 | fn of(stroke: &InkStroke, shape: bool) -> Self { | |
| 521 | 572 | Self { |
| 522 | 573 | width: (stroke.width * 2540.0 / 72.0).to_bits(), |
| 523 | 574 | height: (stroke.height * 2540.0 / 72.0).to_bits(), |
| 524 | 575 | color: stroke.color, |
| 525 | 576 | transparency: stroke.transparency, |
| 526 | 577 | pen_tip: stroke.pen_tip, |
| 578 | raster_operation: stroke.raster_operation, | |
| 579 | shape, | |
| 527 | 580 | } |
| 528 | 581 | } |
| 529 | 582 | |
| 530 | 583 | fn values(&self) -> Values { |
| 584 | let dimensions = if self.shape { | |
| 585 | crate::page::ink::SHAPE_DIMENSIONS | |
| 586 | } else { | |
| 587 | crate::page::ink::DIMENSIONS | |
| 588 | }; | |
| 531 | 589 | let mut values: Values = vec![ |
| 532 | (0x1c00340a, crate::page::ink::DIMENSIONS.to_vec()), | |
| 590 | (0x1c00340a, dimensions.to_vec()), | |
| 533 | 591 | (0x1400340c, self.height.to_le_bytes().to_vec()), |
| 534 | 592 | (0x1400340d, self.width.to_le_bytes().to_vec()), |
| 535 | 593 | ]; |
| ... | ... | @@ -542,11 +600,17 @@ impl InkPen { |
| 542 | 600 | if let Some(tip) = self.pen_tip { |
| 543 | 601 | values.push((0x0c003412, vec![tip])); |
| 544 | 602 | } |
| 603 | if let Some(operation) = self.raster_operation { | |
| 604 | values.push((0x0c003413, vec![operation])); | |
| 605 | } | |
| 606 | if !self.shape { | |
| 607 | values.push((0x88003411, Vec::new())); | |
| 608 | } | |
| 545 | 609 | values |
| 546 | 610 | } |
| 547 | 611 | } |
| 548 | 612 | |
| 549 | fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values, Error> { | |
| 613 | fn stroke_path(stroke: &InkStroke) -> Result<Values, Error> { | |
| 550 | 614 | if stroke.points.is_empty() { |
| 551 | 615 | return Err(invalid("A stroke needs at least one point")); |
| 552 | 616 | } |
| ... | ... | @@ -562,27 +626,32 @@ fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values |
| 562 | 626 | "Stroke points and pen size must be finite and positive", |
| 563 | 627 | )); |
| 564 | 628 | } |
| 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. | |
| 634 | fn 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 | }); | |
| 575 | 643 | let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec(); |
| 576 | 644 | 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([ | |
| 579 | 647 | (0x14003419, index.to_le_bytes().to_vec()), |
| 580 | 648 | (0x1000341b, 0x409_u16.to_le_bytes().to_vec()), |
| 581 | (0x0c00341c, vec![0]), | |
| 649 | (0x0c00341c, vec![bias]), | |
| 582 | 650 | (0x1c00341a, crate::write::fresh_guid()?.to_vec()), |
| 583 | 651 | (0x1c00341d, filetime.to_le_bytes().to_vec()), |
| 584 | 652 | (0x1c00345b, origin), |
| 585 | ]) | |
| 653 | ]); | |
| 654 | Ok(values) | |
| 586 | 655 | } |
| 587 | 656 | |
| 588 | 657 | /// New ink as OneNote 2010 stores a drawing: container `id` that the page lists as a child |
| ... | ... | @@ -614,15 +683,27 @@ pub(crate) fn ink_changes( |
| 614 | 683 | global_ids: std::sync::Arc::new(BTreeMap::from([(0, data.guid)])), |
| 615 | 684 | }; |
| 616 | 685 | data_object.reference(data)?; |
| 617 | let references = write_strokes(&mut changed, &mut data_object, strokes, 0, filetime)?; | |
| 686 | let references = write_strokes( | |
| 687 | active, | |
| 688 | &mut changed, | |
| 689 | &mut data_object, | |
| 690 | strokes, | |
| 691 | ink.shape.is_some(), | |
| 692 | filetime, | |
| 693 | )?; | |
| 618 | 694 | data_object.set(&[(0x24003416, &references)])?; |
| 619 | 695 | changed.insert(data, data_object); |
| 696 | // A shape keeps its kind and geometry beside the strokes, and counts as a second kind of | |
| 697 | // drawing. | |
| 698 | let mut values: Values = vec![(0x14001d7a, modified.to_vec())]; | |
| 699 | if let Some(shape) = &ink.shape { | |
| 700 | let (kind, property, bytes) = shape.stored(); | |
| 701 | values.extend([(0x0c001d4f, vec![kind]), (property, bytes)]); | |
| 702 | } | |
| 703 | values.push((0x14001d4e, (1 + u32::from(ink.shape.is_some())).to_le_bytes().to_vec())); | |
| 620 | 704 | let mut object = PropertyObject { |
| 621 | 705 | 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)?, | |
| 626 | 707 | global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), |
| 627 | 708 | }; |
| 628 | 709 | object.reference(id)?; |
| ... | ... | @@ -641,10 +722,12 @@ pub(crate) fn strokes_changes( |
| 641 | 722 | kept: &[ExGuid], |
| 642 | 723 | added: &[(ExGuid, &InkStroke)], |
| 643 | 724 | ) -> Result<Changes, Error> { |
| 644 | let data = match active.view.nodes.get(&container).map(|node| &node.kind) { | |
| 725 | let (data, shape) = match active.view.nodes.get(&container).map(|node| &node.kind) { | |
| 645 | 726 | 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()), | |
| 648 | 731 | _ => return Err(invalid("Stored ink has no stroke data to rewrite")), |
| 649 | 732 | }; |
| 650 | 733 | let (modified, filetime) = crate::create::current_timestamps()?; |
| ... | ... | @@ -657,10 +740,11 @@ pub(crate) fn strokes_changes( |
| 657 | 740 | references.extend(data_object.reference(*id)?); |
| 658 | 741 | } |
| 659 | 742 | references.extend(write_strokes( |
| 743 | active, | |
| 660 | 744 | &mut changed, |
| 661 | 745 | &mut data_object, |
| 662 | 746 | added, |
| 663 | kept.len(), | |
| 747 | shape, | |
| 664 | 748 | filetime, |
| 665 | 749 | )?); |
| 666 | 750 | data_object.set(&[(0x24003416, &references)])?; |
crates/onestore/src/op/lower.rs+27-13| ... | ... | @@ -225,7 +225,7 @@ pub(crate) fn collect_containers(list: &[PageParagraph], out: &mut Vec<ExGuid>) |
| 225 | 225 | |
| 226 | 226 | /// What the writers keep as stored, checked before any op: titles, unsupported objects, |
| 227 | 227 | /// definitions other than lists and tags, outline roles, paragraph styles and formats, |
| 228 | /// recording links, fields and content types. | |
| 228 | /// fields and content types. | |
| 229 | 229 | pub(crate) fn validate( |
| 230 | 230 | before: &Page, |
| 231 | 231 | after: &Page, |
| ... | ... | @@ -309,17 +309,13 @@ pub(crate) fn validate( |
| 309 | 309 | let Some(previous) = old.paragraphs.get(id) else { |
| 310 | 310 | continue; |
| 311 | 311 | }; |
| 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. | |
| 314 | 313 | 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, | |
| 319 | 318 | }; |
| 320 | if paragraph.media != media { | |
| 321 | return Err(invalid("Recording annotations cannot be edited")); | |
| 322 | } | |
| 323 | 319 | if paragraph.style != style || paragraph.format != previous.format { |
| 324 | 320 | return Err(invalid( |
| 325 | 321 | "Paragraph styles and paragraph formatting cannot be edited", |
| ... | ... | @@ -695,6 +691,8 @@ pub(crate) fn same_tags(a: &[Tag], b: &[Tag]) -> bool { |
| 695 | 691 | ( |
| 696 | 692 | t.definition, |
| 697 | 693 | t.action_type, |
| 694 | t.shape, | |
| 695 | t.property_status, | |
| 698 | 696 | t.status, |
| 699 | 697 | t.created, |
| 700 | 698 | t.completed, |
| ... | ... | @@ -745,6 +743,7 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph { |
| 745 | 743 | stripped.lists.clear(); |
| 746 | 744 | stripped.tags.clear(); |
| 747 | 745 | stripped.collapsed = false; |
| 746 | stripped.media = Default::default(); | |
| 748 | 747 | match &mut stripped.content { |
| 749 | 748 | ParagraphContent::Text(text) => text.tags.clear(), |
| 750 | 749 | ParagraphContent::Table(table) => { |
| ... | ... | @@ -820,6 +819,7 @@ impl Lowering { |
| 820 | 819 | self.styles(after, new)?; |
| 821 | 820 | self.lists(after, new)?; |
| 822 | 821 | self.tags(after, new)?; |
| 822 | self.media(new)?; | |
| 823 | 823 | self.paragraph_formatting(new)?; |
| 824 | 824 | self.formatting(old, new)?; |
| 825 | 825 | self.layout(old, new)?; |
| ... | ... | @@ -1775,9 +1775,9 @@ impl Lowering { |
| 1775 | 1775 | } |
| 1776 | 1776 | let mut definitions = Vec::new(); |
| 1777 | 1777 | 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 | }; | |
| 1781 | 1781 | if let Some(model) = after.definitions.get(&definition) |
| 1782 | 1782 | && !definitions.iter().any(|(id, _)| *id == definition) |
| 1783 | 1783 | { |
| ... | ... | @@ -1794,6 +1794,20 @@ impl Lowering { |
| 1794 | 1794 | Ok(()) |
| 1795 | 1795 | } |
| 1796 | 1796 | |
| 1797 | fn media(&mut self, new: &View<'_>) -> Result<(), Error> { | |
| 1798 | for (id, paragraph) in &new.paragraphs { | |
| 1799 | let current = model::paragraph(&self.current, *id) | |
| 1800 | .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; | |
| 1801 | if current.media != paragraph.media { | |
| 1802 | self.emit(PageOp::Media { | |
| 1803 | paragraph: *id, | |
| 1804 | media: paragraph.media.clone(), | |
| 1805 | })?; | |
| 1806 | } | |
| 1807 | } | |
| 1808 | Ok(()) | |
| 1809 | } | |
| 1810 | ||
| 1797 | 1811 | fn paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { |
| 1798 | 1812 | for (id, paragraph) in &new.paragraphs { |
| 1799 | 1813 | let Some(text) = paragraph.text() else { |
crates/onestore/src/op/mod.rs+8-2| ... | ... | @@ -6,8 +6,8 @@ use crate::{ |
| 6 | 6 | ExGuid, OutlineEdit, PageCreation, PageEdit, TextAttribute, |
| 7 | 7 | document::{Layout, Tag}, |
| 8 | 8 | page::{ |
| 9 | Definition, InkStroke, Page, PageObject, PageParagraph, Paragraph, TableCell, TableColumn, | |
| 10 | TableRow, | |
| 9 | Definition, InkStroke, MediaIndex, Page, PageObject, PageParagraph, Paragraph, TableCell, | |
| 10 | TableColumn, TableRow, | |
| 11 | 11 | }, |
| 12 | 12 | }; |
| 13 | 13 | use serde::{Deserialize, Serialize}; |
| ... | ... | @@ -151,6 +151,12 @@ pub enum PageOp { |
| 151 | 151 | style: ExGuid, |
| 152 | 152 | definition: Definition, |
| 153 | 153 | }, |
| 154 | /// Links a paragraph's text or file to a moment in recordings on the page, or with | |
| 155 | /// empty `media` unlinks it. | |
| 156 | Media { | |
| 157 | paragraph: ExGuid, | |
| 158 | media: MediaIndex, | |
| 159 | }, | |
| 154 | 160 | /// Replaces a paragraph's list nodes; a node another paragraph owns is copied. |
| 155 | 161 | List { |
| 156 | 162 | paragraph: ExGuid, |
crates/onestore/src/op/model.rs+16| ... | ... | @@ -468,6 +468,7 @@ fn stroked(stroke: &crate::page::InkStroke) -> crate::page::InkStroke { |
| 468 | 468 | fn inked(ink: &crate::page::Ink) -> crate::page::Ink { |
| 469 | 469 | crate::page::Ink { |
| 470 | 470 | strokes: ink.strokes.iter().map(stroked).collect(), |
| 471 | shape: ink.shape.as_ref().map(crate::page::ink::InkShape::snapped), | |
| 471 | 472 | ..ink.clone() |
| 472 | 473 | } |
| 473 | 474 | } |
| ... | ... | @@ -1042,6 +1043,21 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { |
| 1042 | 1043 | text.text = Paragraph::from_runs(runs); |
| 1043 | 1044 | } |
| 1044 | 1045 | } |
| 1046 | PageOp::Media { paragraph, media } => { | |
| 1047 | let paragraph = paragraph_mut(page, *paragraph)?; | |
| 1048 | if !matches!( | |
| 1049 | paragraph.content, | |
| 1050 | ParagraphContent::Text(_) | ParagraphContent::Attachment(_) | |
| 1051 | ) { | |
| 1052 | return Err(invalid("Link a paragraph of text or a file to a recording")); | |
| 1053 | } | |
| 1054 | if media.recordings.is_empty() != media.time_ms.is_none() { | |
| 1055 | return Err(invalid( | |
| 1056 | "A recording link names recordings and a moment in them", | |
| 1057 | )); | |
| 1058 | } | |
| 1059 | paragraph.media = media.clone(); | |
| 1060 | } | |
| 1045 | 1061 | PageOp::List { paragraph, lists } => { |
| 1046 | 1062 | paragraph_mut(page, *paragraph)?.lists = lists.iter().map(|(id, _)| *id).collect(); |
| 1047 | 1063 | for (id, definition) in lists { |
crates/onestore/src/op/properties.rs+91-38| ... | ... | @@ -5,7 +5,7 @@ use crate::{ |
| 5 | 5 | Error, ExGuid, PropertySets, Value, |
| 6 | 6 | active::{ActivePage, Changes}, |
| 7 | 7 | document::{Kind, Tag}, |
| 8 | page::Definition, | |
| 8 | page::{Definition, MediaIndex}, | |
| 9 | 9 | write::PropertyObject, |
| 10 | 10 | }; |
| 11 | 11 | use std::collections::{BTreeMap, BTreeSet}; |
| ... | ... | @@ -165,54 +165,78 @@ fn tag_definition_values(definition: &Definition) -> Result<Values, Error> { |
| 165 | 165 | Ok(values) |
| 166 | 166 | } |
| 167 | 167 | |
| 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. | |
| 171 | 170 | pub(crate) fn tag_changes( |
| 172 | 171 | active: &ActivePage<'_>, |
| 173 | 172 | object: ExGuid, |
| 174 | tags: &[(ExGuid, &Tag, Option<&Definition>)], | |
| 173 | tags: &[(&Tag, Option<&Definition>)], | |
| 175 | 174 | ) -> Result<Changes, Error> { |
| 176 | 175 | let raw = &active.live.revision; |
| 177 | 176 | let mut definitions: Vec<(ExGuid, Option<Values>)> = Vec::new(); |
| 178 | let mut sets: Vec<(usize, Values)> = Vec::new(); | |
| 177 | let mut sets: Vec<(Option<ExGuid>, Values)> = Vec::new(); | |
| 179 | 178 | 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")); | |
| 187 | 197 | } |
| 198 | fields.extend([ | |
| 199 | (0x0c003473, vec![0]), | |
| 200 | (0x10003463, action_type.to_le_bytes().to_vec()), | |
| 201 | (0x10003464, shape.to_le_bytes().to_vec()), | |
| 202 | (0x14003467, property_status.to_le_bytes().to_vec()), | |
| 203 | ]); | |
| 204 | if let Some(task) = tag.task_id { | |
| 205 | fields.push((0x1c003469, task.to_vec())); | |
| 206 | } | |
| 207 | for (id, value) in [dates[2], dates[3], dates[0], dates[1]] { | |
| 208 | if let Some(value) = value { | |
| 209 | fields.push((id, value.to_le_bytes().to_vec())); | |
| 210 | } | |
| 211 | } | |
| 212 | fields.push((0x10003470, tag.status.to_le_bytes().to_vec())); | |
| 213 | sets.push((None, fields)); | |
| 214 | continue; | |
| 215 | } | |
| 216 | let written = tag | |
| 217 | .definition | |
| 218 | .ok_or_else(|| invalid("A note tag must reference a tag definition"))?; | |
| 219 | let action_type = match definition.map(|d| &d.kind) { | |
| 220 | Some(Kind::TagDefinition { action_type, .. }) => *action_type, | |
| 221 | _ => return Err(invalid("A note tag must reference a tag definition")), | |
| 188 | 222 | }; |
| 189 | 223 | if !action_types.insert(action_type.unwrap_or(0)) { |
| 190 | 224 | return Err(invalid("An element holds one note tag per action type")); |
| 191 | 225 | } |
| 192 | let index = match definitions.iter().position(|(id, _)| id == written) { | |
| 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 | } | |
| 207 | 236 | if let Some(action_type) = tag.action_type { |
| 208 | 237 | fields.push((0x10003463, action_type.to_le_bytes().to_vec())); |
| 209 | 238 | } |
| 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 { | |
| 216 | 240 | if let Some(value) = value { |
| 217 | 241 | fields.push((id, value.to_le_bytes().to_vec())); |
| 218 | 242 | } |
| ... | ... | @@ -221,7 +245,7 @@ pub(crate) fn tag_changes( |
| 221 | 245 | if let Some(task) = tag.task_id { |
| 222 | 246 | fields.push((0x1c003469, task.to_vec())); |
| 223 | 247 | } |
| 224 | sets.push((index, fields)); | |
| 248 | sets.push((Some(written), fields)); | |
| 225 | 249 | } |
| 226 | 250 | let parents = active.editable_parents(object)?; |
| 227 | 251 | let modified = crate::create::current_timestamps()?.0.to_le_bytes(); |
| ... | ... | @@ -240,9 +264,12 @@ pub(crate) fn tag_changes( |
| 240 | 264 | } |
| 241 | 265 | let mut target = PropertyObject::from_object(&raw.objects[&object])?; |
| 242 | 266 | 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 | } | |
| 246 | 273 | set.extend(fields.iter().cloned()); |
| 247 | 274 | encoded.push(set); |
| 248 | 275 | } |
| ... | ... | @@ -380,3 +407,29 @@ pub(crate) fn paragraph_format_changes( |
| 380 | 407 | crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?; |
| 381 | 408 | Ok(changed) |
| 382 | 409 | } |
| 410 | ||
| 411 | /// Links content `object` to a moment in recordings (MediaIndex), or unlinks it. | |
| 412 | pub(crate) fn media_changes( | |
| 413 | active: &ActivePage<'_>, | |
| 414 | object: ExGuid, | |
| 415 | media: &MediaIndex, | |
| 416 | ) -> Result<Changes, Error> { | |
| 417 | let parents = active.editable_parents(object)?; | |
| 418 | let modified = crate::create::current_timestamps()?.0.to_le_bytes(); | |
| 419 | let raw = &active.live.revision; | |
| 420 | let mut target = PropertyObject::from_object(&raw.objects[&object])?; | |
| 421 | match media.time_ms { | |
| 422 | Some(time) => { | |
| 423 | let recordings = media.recordings.concat(); | |
| 424 | target.set(&[ | |
| 425 | (0x1c001c98, recordings.as_slice()), | |
| 426 | (0x14001c99, &time.to_le_bytes()), | |
| 427 | ])?; | |
| 428 | } | |
| 429 | None => target.remove(&[0x1c001c98, 0x14001c99])?, | |
| 430 | } | |
| 431 | target.set(&[(0x14001d7a, &modified)])?; | |
| 432 | let mut changed = BTreeMap::from([(object, target)]); | |
| 433 | crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?; | |
| 434 | Ok(changed) | |
| 435 | } |
crates/onestore/src/op/tests.rs+21-9| ... | ... | @@ -834,6 +834,8 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { |
| 834 | 834 | text.tags.push(crate::document::Tag { |
| 835 | 835 | definition: Some(definition), |
| 836 | 836 | action_type: None, |
| 837 | shape: None, | |
| 838 | property_status: None, | |
| 837 | 839 | status, |
| 838 | 840 | created: Some(1_262_401_445), |
| 839 | 841 | completed: (status == 1).then_some(1_262_402_000), |
| ... | ... | @@ -1159,6 +1161,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { |
| 1159 | 1161 | layout: Layout::default(), |
| 1160 | 1162 | strokes: vec![stroke(rng)], |
| 1161 | 1163 | groups: Vec::new(), |
| 1164 | shape: None, | |
| 1162 | 1165 | }) |
| 1163 | 1166 | }; |
| 1164 | 1167 | page.objects.insert(at, object); |
| ... | ... | @@ -1251,6 +1254,8 @@ fn stroke(rng: &mut Rng) -> InkStroke { |
| 1251 | 1254 | color: Some(0x0000ff), |
| 1252 | 1255 | transparency: None, |
| 1253 | 1256 | pen_tip: None, |
| 1257 | // Every other stroke a highlighter's. | |
| 1258 | raster_operation: (x as u32).is_multiple_of(2).then_some(9), | |
| 1254 | 1259 | } |
| 1255 | 1260 | } |
| 1256 | 1261 | |
| ... | ... | @@ -1595,6 +1600,8 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> { |
| 1595 | 1600 | tags: vec![crate::document::Tag { |
| 1596 | 1601 | definition: Some(definition), |
| 1597 | 1602 | action_type: None, |
| 1603 | shape: None, | |
| 1604 | property_status: None, | |
| 1598 | 1605 | status: 0, |
| 1599 | 1606 | created: Some(1_262_401_445), |
| 1600 | 1607 | completed: None, |
| ... | ... | @@ -1639,15 +1646,20 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> { |
| 1639 | 1646 | }), |
| 1640 | 1647 | before: None, |
| 1641 | 1648 | }, |
| 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 | } | |
| 1651 | 1663 | }) |
| 1652 | 1664 | } |
| 1653 | 1665 |
crates/onestore/src/page/ink.rs+200| ... | ... | @@ -32,6 +32,119 @@ pub struct Ink { |
| 32 | 32 | pub strokes: Vec<InkStroke>, |
| 33 | 33 | /// Nested ink containers, as newer OneNote versions group handwriting. |
| 34 | 34 | pub groups: Vec<Ink>, |
| 35 | /// The shape drawn, for a drawing made from Draw's Insert Shapes. | |
| 36 | #[serde(default)] | |
| 37 | pub shape: Option<InkShape>, | |
| 38 | } | |
| 39 | ||
| 40 | /// The shapes of Draw's Insert Shapes that Snowbound draws. | |
| 41 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 42 | pub enum ShapeKind { | |
| 43 | Line, | |
| 44 | Arrow, | |
| 45 | Rectangle, | |
| 46 | Ellipse, | |
| 47 | } | |
| 48 | ||
| 49 | impl 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)] | |
| 139 | pub enum InkShape { | |
| 140 | /// A line or arrow from one end to the other. | |
| 141 | Line([[f32; 2]; 2]), | |
| 142 | /// A closed shape: `transform` (`[a, b, c, d, x, y]`) takes its anchors, in the unit | |
| 143 | /// square, onto the page. | |
| 144 | Closed { | |
| 145 | transform: [f32; 6], | |
| 146 | anchors: Vec<[f32; 2]>, | |
| 147 | }, | |
| 35 | 148 | } |
| 36 | 149 | |
| 37 | 150 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| ... | ... | @@ -48,6 +161,10 @@ pub struct InkStroke { |
| 48 | 161 | pub transparency: Option<u8>, |
| 49 | 162 | /// 0 is a round (ball) tip, 1 a rectangle. |
| 50 | 163 | pub pen_tip: Option<u8>, |
| 164 | /// The ISF raster operation: 9 (MaskPen) for a highlighter, whose colour multiplies | |
| 165 | /// what lies beneath. | |
| 166 | #[serde(default)] | |
| 167 | pub raster_operation: Option<u8>, | |
| 51 | 168 | } |
| 52 | 169 | |
| 53 | 170 | impl Ink { |
| ... | ... | @@ -61,6 +178,9 @@ impl Ink { |
| 61 | 178 | data, |
| 62 | 179 | scale_x, |
| 63 | 180 | scale_y, |
| 181 | shape_kind, | |
| 182 | line, | |
| 183 | anchors, | |
| 64 | 184 | } = &node.kind |
| 65 | 185 | else { |
| 66 | 186 | unreachable!() |
| ... | ... | @@ -98,6 +218,7 @@ impl Ink { |
| 98 | 218 | layout: node.layout.clone(), |
| 99 | 219 | strokes, |
| 100 | 220 | groups, |
| 221 | shape: InkShape::read(*shape_kind, line.as_deref(), anchors.as_deref()), | |
| 101 | 222 | }) |
| 102 | 223 | } |
| 103 | 224 | |
| ... | ... | @@ -151,6 +272,7 @@ impl InkStroke { |
| 151 | 272 | color, |
| 152 | 273 | transparency, |
| 153 | 274 | pen_tip, |
| 275 | raster_operation, | |
| 154 | 276 | .. |
| 155 | 277 | } = &style.kind |
| 156 | 278 | else { |
| ... | ... | @@ -190,10 +312,78 @@ impl InkStroke { |
| 190 | 312 | color: *color, |
| 191 | 313 | transparency: *transparency, |
| 192 | 314 | pen_tip: *pen_tip, |
| 315 | raster_operation: *raster_operation, | |
| 193 | 316 | }) |
| 194 | 317 | } |
| 195 | 318 | } |
| 196 | 319 | |
| 320 | impl InkShape { | |
| 321 | /// Any geometry this does not recognise reads as no shape: the strokes still draw it. | |
| 322 | fn read(kind: Option<u8>, line: Option<&[u8]>, anchors: Option<&[u8]>) -> Option<Self> { | |
| 323 | let floats = |bytes: &[u8]| -> Vec<f32> { | |
| 324 | bytes | |
| 325 | .chunks_exact(4) | |
| 326 | .map(|b| f32::from_le_bytes(b.try_into().unwrap())) | |
| 327 | .collect() | |
| 328 | }; | |
| 329 | match (kind?, line, anchors) { | |
| 330 | (11, Some(line), _) if line.len() == 16 => { | |
| 331 | let v = floats(line); | |
| 332 | Some(Self::Line([[v[0], v[1]], [v[2], v[3]]].map(|end| end.map(|v| v * 36.0)))) | |
| 333 | .filter(Self::finite) | |
| 334 | } | |
| 335 | (12, _, Some(bytes)) if bytes.len() >= 24 && (bytes.len() - 24) % 8 == 0 => { | |
| 336 | let v = floats(bytes); | |
| 337 | Some(Self::Closed { | |
| 338 | transform: std::array::from_fn(|i| v[i] * 36.0), | |
| 339 | anchors: v[6..].chunks_exact(2).map(|p| [p[0], p[1]]).collect(), | |
| 340 | }) | |
| 341 | .filter(Self::finite) | |
| 342 | } | |
| 343 | _ => None, | |
| 344 | } | |
| 345 | } | |
| 346 | ||
| 347 | fn finite(&self) -> bool { | |
| 348 | match self { | |
| 349 | Self::Line(ends) => ends.iter().flatten().all(|v| v.is_finite()), | |
| 350 | Self::Closed { transform, anchors } => transform | |
| 351 | .iter() | |
| 352 | .chain(anchors.iter().flatten()) | |
| 353 | .all(|v| v.is_finite()), | |
| 354 | } | |
| 355 | } | |
| 356 | ||
| 357 | /// The kind, property and bytes OneNote stores the shape as. | |
| 358 | pub(crate) fn stored(&self) -> (u8, u32, Vec<u8>) { | |
| 359 | let half = |v: &f32| (v / 36.0).to_le_bytes(); | |
| 360 | match self { | |
| 361 | Self::Line(ends) => (11, 0x1c001dac, ends.iter().flatten().flat_map(half).collect()), | |
| 362 | Self::Closed { transform, anchors } => ( | |
| 363 | 12, | |
| 364 | 0x1c001daa, | |
| 365 | transform | |
| 366 | .iter() | |
| 367 | .flat_map(half) | |
| 368 | .chain(anchors.iter().flatten().flat_map(|v| v.to_le_bytes())) | |
| 369 | .collect(), | |
| 370 | ), | |
| 371 | } | |
| 372 | } | |
| 373 | ||
| 374 | /// This shape as it reads back once stored. | |
| 375 | pub fn snapped(&self) -> Self { | |
| 376 | let half = |v: f32| (v / 36.0) * 36.0; | |
| 377 | match self { | |
| 378 | Self::Line(ends) => Self::Line(ends.map(|end| end.map(half))), | |
| 379 | Self::Closed { transform, anchors } => Self::Closed { | |
| 380 | transform: transform.map(half), | |
| 381 | anchors: anchors.clone(), | |
| 382 | }, | |
| 383 | } | |
| 384 | } | |
| 385 | } | |
| 386 | ||
| 197 | 387 | /// ISF multi-byte decoding: a count, then that many 7-bit little-endian varints whose low bit |
| 198 | 388 | /// is the sign. |
| 199 | 389 | fn multi_byte(bytes: &[u8]) -> Option<Vec<i64>> { |
| ... | ... | @@ -268,6 +458,15 @@ pub(crate) const DIMENSIONS: [u8; 64] = [ |
| 268 | 458 | 0x00, 0x00, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x8b, 0xc5, 0xe2, 0x41, |
| 269 | 459 | ]; |
| 270 | 460 | |
| 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. | |
| 463 | pub(crate) const SHAPE_DIMENSIONS: [u8; 64] = [ | |
| 464 | 0x8f, 0x6a, 0x8a, 0x59, 0xc0, 0x52, 0xa0, 0x4b, 0x93, 0xaf, 0xaf, 0x35, 0x74, 0x11, 0xa5, 0x61, | |
| 465 | 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0xff, 0x7f, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7a, 0x44, | |
| 466 | 0x75, 0x9f, 0x3f, 0xb5, 0xe0, 0x04, 0x98, 0x44, 0xa7, 0xee, 0xc3, 0x0d, 0xbb, 0x5a, 0x90, 0x11, | |
| 467 | 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0xff, 0x7f, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7a, 0x44, | |
| 468 | ]; | |
| 469 | ||
| 271 | 470 | #[cfg(test)] |
| 272 | 471 | mod tests { |
| 273 | 472 | use super::*; |
| ... | ... | @@ -282,6 +481,7 @@ mod tests { |
| 282 | 481 | color: None, |
| 283 | 482 | transparency: None, |
| 284 | 483 | pen_tip: None, |
| 484 | raster_operation: None, | |
| 285 | 485 | }; |
| 286 | 486 | let values = multi_byte(&stroke.packet()).unwrap(); |
| 287 | 487 | assert_eq!(values.len(), 6); |
crates/onestore/src/page/mod.rs+26-6| ... | ... | @@ -282,21 +282,34 @@ pub struct Attachment { |
| 282 | 282 | pub bytes: Option<Arc<[u8]>>, |
| 283 | 283 | #[serde(with = "payload")] |
| 284 | 284 | 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`). | |
| 287 | 287 | pub recording: Option<Recording>, |
| 288 | 288 | } |
| 289 | 289 | |
| 290 | /// A recording's identity and OneNote's type code for it. | |
| 290 | /// A recording's identity, whether it is audio (1) or video (2), and its length. | |
| 291 | 291 | #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| 292 | 292 | pub struct Recording { |
| 293 | 293 | pub id: [u8; 16], |
| 294 | 294 | pub kind: u32, |
| 295 | pub duration_ms: Option<u32>, | |
| 296 | } | |
| 297 | ||
| 298 | impl Recording { | |
| 299 | /// The kind of recording MS-ONE requires a file named `filename` to be, if any: audio | |
| 300 | /// for .wma, .mp3 and .wav, video for .wmv, .avi and .mpg. | |
| 301 | pub fn kind_of(filename: &str) -> Option<u32> { | |
| 302 | let extension = filename.rsplit_once('.')?.1.to_ascii_lowercase(); | |
| 303 | match extension.as_str() { | |
| 304 | "wma" | "mp3" | "wav" => Some(1), | |
| 305 | "wmv" | "avi" | "mpg" => Some(2), | |
| 306 | _ => None, | |
| 307 | } | |
| 308 | } | |
| 295 | 309 | } |
| 296 | 310 | |
| 297 | 311 | /// 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. | |
| 300 | 313 | #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| 301 | 314 | pub struct MediaIndex { |
| 302 | 315 | pub recordings: Vec<[u8; 16]>, |
| ... | ... | @@ -328,6 +341,7 @@ impl Attachment { |
| 328 | 341 | source_path, |
| 329 | 342 | recording_id, |
| 330 | 343 | recording_type, |
| 344 | recording_duration, | |
| 331 | 345 | icon_width, |
| 332 | 346 | icon_height, |
| 333 | 347 | } = &node.kind |
| ... | ... | @@ -357,7 +371,10 @@ impl Attachment { |
| 357 | 371 | preview: payload(preview)?, |
| 358 | 372 | recording: recording_id.map(|id| Recording { |
| 359 | 373 | id, |
| 360 | kind: recording_type.unwrap_or(0), | |
| 374 | kind: recording_type | |
| 375 | .or_else(|| Recording::kind_of(filename.as_deref().unwrap_or_default())) | |
| 376 | .unwrap_or(1), | |
| 377 | duration_ms: *recording_duration, | |
| 361 | 378 | }), |
| 362 | 379 | }) |
| 363 | 380 | } |
| ... | ... | @@ -1369,6 +1386,7 @@ mod tests { |
| 1369 | 1386 | margin_origin_x: Some(36.0), |
| 1370 | 1387 | margin_origin_y: Some(12.0), |
| 1371 | 1388 | rtl: None, |
| 1389 | recordings: Vec::new(), | |
| 1372 | 1390 | }); |
| 1373 | 1391 | page.children.push(id(2)); |
| 1374 | 1392 | let mut outline = element(Kind::Outline { |
| ... | ... | @@ -1621,6 +1639,8 @@ mod tests { |
| 1621 | 1639 | source.nodes.get_mut(&id(4)).unwrap().tags.push(Tag { |
| 1622 | 1640 | definition: Some(id(7)), |
| 1623 | 1641 | action_type: None, |
| 1642 | shape: None, | |
| 1643 | property_status: None, | |
| 1624 | 1644 | status: 0, |
| 1625 | 1645 | created: Some(123), |
| 1626 | 1646 | completed: None, |
crates/onestore/tests/page_ink.rs+371| ... | ... | @@ -88,6 +88,7 @@ fn stroke(points: &[[f32; 2]], color: Option<u32>) -> InkStroke { |
| 88 | 88 | color, |
| 89 | 89 | transparency: None, |
| 90 | 90 | pen_tip: None, |
| 91 | raster_operation: None, | |
| 91 | 92 | } |
| 92 | 93 | } |
| 93 | 94 | |
| ... | ... | @@ -109,6 +110,7 @@ fn drawing() -> Ink { |
| 109 | 110 | stroke(&[[300.0, 120.0], [360.0, 180.0]], Some(0x0000ff)), |
| 110 | 111 | ], |
| 111 | 112 | groups: Vec::new(), |
| 113 | shape: None, | |
| 112 | 114 | } |
| 113 | 115 | } |
| 114 | 116 | |
| ... | ... | @@ -232,3 +234,372 @@ fn stored_strokes_keep_their_paths() { |
| 232 | 234 | } |
| 233 | 235 | assert!(ops::saved(NATIVE, space, &moved).is_err()); |
| 234 | 236 | } |
| 237 | ||
| 238 | /// OneNote 2010's Draw tab used with the mouse (`corpus/ink-tools/native-ui`): every favourite | |
| 239 | /// pen and highlighter, shapes, and ink erased, moved and deleted. | |
| 240 | const TOOLS: &[u8] = include_bytes!("../../../corpus/ink-tools/native-ui/notebook/ink.one"); | |
| 241 | ||
| 242 | fn 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 | ||
| 255 | fn himetric(value: f32) -> f32 { | |
| 256 | value * 72.0 / 2540.0 | |
| 257 | } | |
| 258 | ||
| 259 | use onestore::page::ink::{InkShape, ShapeKind}; | |
| 260 | ||
| 261 | #[test] | |
| 262 | fn 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 | ||
| 375 | fn 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 | ||
| 429 | fn 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] | |
| 448 | fn 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] | |
| 546 | fn onenote_drawings_take_erasing_moving_and_more_strokes() { | |
| 547 | use onestore::{OutlineEdit, op::PageOp}; | |
| 548 | let (space, page) = titled(TOOLS, "Pens"); | |
| 549 | let native = drawings(&page); | |
| 550 | let zigzag = native | |
| 551 | .iter() | |
| 552 | .find(|ink| ink.strokes[0].color == Some(0x241ced)) | |
| 553 | .unwrap() | |
| 554 | .id; | |
| 555 | let line = native[1].id; | |
| 556 | let edited = ops::page_edited( | |
| 557 | TOOLS, | |
| 558 | space, | |
| 559 | vec![ | |
| 560 | PageOp::Delete { object: zigzag }, | |
| 561 | PageOp::Outline { | |
| 562 | object: line, | |
| 563 | edit: OutlineEdit::Position { x: 36.0, y: 18.0 }, | |
| 564 | }, | |
| 565 | ], | |
| 566 | ) | |
| 567 | .unwrap(); | |
| 568 | let (_, _, inks) = tool_page(&onestore::create_section("x.one", "x", "x").unwrap()); | |
| 569 | let moved: Vec<Ink> = inks | |
| 570 | .into_iter() | |
| 571 | .map(|mut ink| { | |
| 572 | for stroke in &mut ink.strokes { | |
| 573 | for point in &mut stroke.points { | |
| 574 | point[1] = snap(point[1] + 200.0); | |
| 575 | } | |
| 576 | } | |
| 577 | if let Some(shape) = &mut ink.shape { | |
| 578 | *shape = match shape { | |
| 579 | InkShape::Line([a, b]) => InkShape::Line([[a[0], a[1] + 200.0], [b[0], b[1] + 200.0]]), | |
| 580 | InkShape::Closed { transform, anchors } => InkShape::Closed { | |
| 581 | transform: { | |
| 582 | let mut t = *transform; | |
| 583 | t[5] += 200.0; | |
| 584 | t | |
| 585 | }, | |
| 586 | anchors: anchors.clone(), | |
| 587 | }, | |
| 588 | } | |
| 589 | .snapped(); | |
| 590 | } | |
| 591 | ink | |
| 592 | }) | |
| 593 | .collect(); | |
| 594 | let written = added(&edited, space, &moved); | |
| 595 | let stored = page_in(&written, space); | |
| 596 | let found = drawings(&stored); | |
| 597 | assert!(!found.iter().any(|ink| ink.id == zigzag)); | |
| 598 | let line = found.iter().find(|ink| ink.id == line).unwrap(); | |
| 599 | assert_eq!((line.layout.x, line.layout.y), (Some(36.0), Some(18.0))); | |
| 600 | assert_eq!(line.strokes, native[1].strokes); | |
| 601 | for ink in &moved { | |
| 602 | assert_eq!(found.iter().find(|found| found.id == ink.id), Some(&ink)); | |
| 603 | } | |
| 604 | export("ONESTORE_INK_TOOLS_EDIT_EXPORT", "ink.one", &written); | |
| 605 | } |
crates/onestore/tests/page_media.rs+23-12| ... | ... | @@ -103,18 +103,29 @@ fn editing_a_recorded_page_keeps_the_recording_and_its_annotation() { |
| 103 | 103 | assert_eq!(recording(&stored), recording(&before)); |
| 104 | 104 | assert_eq!(stored.objects.len(), before.objects.len()); |
| 105 | 105 | |
| 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); | |
| 118 | 129 | let mut recaptured = after.clone(); |
| 119 | 130 | for object in &mut recaptured.objects { |
| 120 | 131 | if let PageObject::Outline(outline) = object { |
crates/onestore/tests/page_tags.rs+48| ... | ... | @@ -113,6 +113,8 @@ fn tag(definition: ExGuid, completed: bool) -> Tag { |
| 113 | 113 | Tag { |
| 114 | 114 | definition: Some(definition), |
| 115 | 115 | action_type: None, |
| 116 | shape: None, | |
| 117 | property_status: None, | |
| 116 | 118 | status: u16::from(completed), |
| 117 | 119 | created: Some(1_262_401_445), |
| 118 | 120 | completed: completed.then_some(1_262_405_106), |
| ... | ... | @@ -305,3 +307,49 @@ fn new_definitions_and_tags_publish_on_a_fresh_page() { |
| 305 | 307 | .unwrap(); |
| 306 | 308 | } |
| 307 | 309 | } |
| 310 | ||
| 311 | /// An Outlook task tag names no definition: rewritten beside a normal tag it keeps its icon, | |
| 312 | /// property status and dates, and one missing its icon is refused. | |
| 313 | #[test] | |
| 314 | fn a_task_tag_rewrites_without_a_definition() { | |
| 315 | let features = include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one"); | |
| 316 | let (space, before) = page_by_title(features, "Task controls"); | |
| 317 | let at = tagged(&before); | |
| 318 | let task = body_paragraphs(&mut before.clone())[at] | |
| 319 | .text() | |
| 320 | .unwrap() | |
| 321 | .tags[0] | |
| 322 | .clone(); | |
| 323 | assert_eq!( | |
| 324 | ( | |
| 325 | task.definition, | |
| 326 | task.shape, | |
| 327 | task.property_status, | |
| 328 | task.status | |
| 329 | ), | |
| 330 | (None, Some(89), Some(0), 6) | |
| 331 | ); | |
| 332 | let mut after = before.clone(); | |
| 333 | let id = new_id().unwrap(); | |
| 334 | after.definitions.insert(id, definition("To Do", 3)); | |
| 335 | body_paragraphs(&mut after)[at] | |
| 336 | .text_mut() | |
| 337 | .unwrap() | |
| 338 | .tags | |
| 339 | .insert(0, tag(id, false)); | |
| 340 | let written = ops::saved(features, space, &after).unwrap(); | |
| 341 | let stored = assert_same(written.as_slice(), space, &after); | |
| 342 | let mut untagged = stored.clone(); | |
| 343 | body_paragraphs(&mut untagged)[at] | |
| 344 | .text_mut() | |
| 345 | .unwrap() | |
| 346 | .tags | |
| 347 | .remove(0); | |
| 348 | untagged.definitions.remove(&id); | |
| 349 | let again = ops::saved(&written, space, &untagged).unwrap(); | |
| 350 | assert_same(again.as_slice(), space, &before); | |
| 351 | let mut shapeless = before.clone(); | |
| 352 | body_paragraphs(&mut shapeless)[at].text_mut().unwrap().tags[0].shape = None; | |
| 353 | body_paragraphs(&mut shapeless)[at].text_mut().unwrap().tags[0].created = Some(1); | |
| 354 | assert!(ops::saved(features, space, &shapeless).is_err()); | |
| 355 | } |
crates/onestore/tests/support/page_edits.rs+2| ... | ... | @@ -256,6 +256,8 @@ fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) { |
| 256 | 256 | text.tags.push(onestore::document::Tag { |
| 257 | 257 | definition: Some(definition), |
| 258 | 258 | action_type: None, |
| 259 | shape: None, | |
| 260 | property_status: None, | |
| 259 | 261 | status: u16::from(bytes.next().unwrap_or(0) % 2), |
| 260 | 262 | created: Some(1_262_401_445), |
| 261 | 263 | completed: None, |
crates/snowbound/src/library.rs+52-16| ... | ... | @@ -33,10 +33,6 @@ pub fn on_background(notify: impl Fn() + Send + Sync + 'static) { |
| 33 | 33 | let _ = NOTIFY.set(Box::new(notify)); |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | /// How often the sections no tab shows are polled; OneNote 2010 read a closed section | |
| 37 | /// fifteen seconds after another client changed it. | |
| 38 | const BACKGROUND: Duration = Duration::from_secs(15); | |
| 39 | ||
| 40 | 36 | fn notify_background() { |
| 41 | 37 | if let Some(notify) = NOTIFY.get() { |
| 42 | 38 | notify(); |
| ... | ... | @@ -171,6 +167,8 @@ pub struct Library { |
| 171 | 167 | /// Syncs the notebook's sections while no tab shows them, as OneNote syncs every |
| 172 | 168 | /// section of an open notebook. |
| 173 | 169 | pub background: Option<Arc<Background>>, |
| 170 | /// Reports the changes to a folder on this computer to `background`. | |
| 171 | watch: Option<Arc<crate::watch::Watch>>, | |
| 174 | 172 | } |
| 175 | 173 | |
| 176 | 174 | impl Library { |
| ... | ... | @@ -192,10 +190,15 @@ impl Library { |
| 192 | 190 | } |
| 193 | 191 | } |
| 194 | 192 | let notebook = Notebook::open(location, cache); |
| 193 | let (background, watch) = match &notebook { | |
| 194 | Ok(notebook) => local_background(notebook, location), | |
| 195 | Err(_) => (None, None), | |
| 196 | }; | |
| 195 | 197 | Self { |
| 196 | 198 | location: location.to_owned(), |
| 197 | 199 | name: file_name(Path::new(location)), |
| 198 | background: notebook.as_ref().ok().and_then(local_background), | |
| 200 | background, | |
| 201 | watch, | |
| 199 | 202 | notebook: notebook.map(Some).map_err(|error| error.to_string()), |
| 200 | 203 | server: None, |
| 201 | 204 | notice, |
| ... | ... | @@ -219,7 +222,6 @@ impl Library { |
| 219 | 222 | let background = Background::smb( |
| 220 | 223 | &server.mount.root, |
| 221 | 224 | LIMIT, |
| 222 | BACKGROUND, | |
| 223 | 225 | move || connect.connect(), |
| 224 | 226 | notify_background, |
| 225 | 227 | ) |
| ... | ... | @@ -234,6 +236,7 @@ impl Library { |
| 234 | 236 | notice: None, |
| 235 | 237 | cache: cache.to_owned(), |
| 236 | 238 | background: Some(Arc::new(background)), |
| 239 | watch: None, | |
| 237 | 240 | }) |
| 238 | 241 | } |
| 239 | 242 | |
| ... | ... | @@ -260,15 +263,18 @@ impl Library { |
| 260 | 263 | notice: self.notice.clone(), |
| 261 | 264 | cache: self.cache.clone(), |
| 262 | 265 | background: self.background.clone(), |
| 266 | watch: self.watch.clone(), | |
| 263 | 267 | } |
| 264 | 268 | } |
| 265 | 269 | |
| 266 | 270 | /// A notebook `Notebook::create` just made in the folder at `location`. |
| 267 | 271 | pub fn created(location: &str, notebook: Notebook, cache: &Path) -> Self { |
| 272 | let (background, watch) = local_background(&notebook, location); | |
| 268 | 273 | Self { |
| 269 | 274 | location: location.to_owned(), |
| 270 | 275 | name: file_name(Path::new(location)), |
| 271 | background: local_background(&notebook), | |
| 276 | background, | |
| 277 | watch, | |
| 272 | 278 | notebook: Ok(Some(notebook)), |
| 273 | 279 | server: None, |
| 274 | 280 | notice: None, |
| ... | ... | @@ -286,6 +292,7 @@ impl Library { |
| 286 | 292 | notice: None, |
| 287 | 293 | cache: cache.to_owned(), |
| 288 | 294 | background: None, |
| 295 | watch: None, | |
| 289 | 296 | } |
| 290 | 297 | } |
| 291 | 298 | |
| ... | ... | @@ -296,7 +303,8 @@ impl Library { |
| 296 | 303 | path: &str, |
| 297 | 304 | notify: impl Fn() + Send + 'static, |
| 298 | 305 | ) -> Result<Section, Box<dyn Error>> { |
| 299 | // The background may hold the replica for a step, which takes a network round trip. | |
| 306 | // The background may hold the replica for a step, which takes a network round trip, | |
| 307 | // or be making it as an offline copy. | |
| 300 | 308 | let deadline = Instant::now() + TIMEOUT * 3; |
| 301 | 309 | let notify = Arc::new(Mutex::new(notify)); |
| 302 | 310 | let notifier = || { |
| ... | ... | @@ -335,7 +343,12 @@ impl Library { |
| 335 | 343 | (Err(error), _) => return Err(error.clone().into()), |
| 336 | 344 | }; |
| 337 | 345 | match opened { |
| 338 | Err(error) if error.busy() && Instant::now() < deadline => { | |
| 346 | Err(error) | |
| 347 | if (error.busy() | |
| 348 | || matches!(&error, notebook::Error::Io(error) | |
| 349 | if error.kind() == io::ErrorKind::AlreadyExists)) | |
| 350 | && Instant::now() < deadline => | |
| 351 | { | |
| 339 | 352 | std::thread::sleep(Duration::from_millis(50)); |
| 340 | 353 | } |
| 341 | 354 | opened => break opened?, |
| ... | ... | @@ -446,15 +459,37 @@ pub fn recycle_bin(path: &str) -> bool { |
| 446 | 459 | path.rsplit('/').next() == Some("OneNote_RecycleBin") |
| 447 | 460 | } |
| 448 | 461 | |
| 449 | /// A mounted notebook's background sync, following Work Offline. | |
| 450 | fn 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. | |
| 465 | fn local_background( | |
| 466 | notebook: &Notebook, | |
| 467 | location: &str, | |
| 468 | ) -> (Option<Arc<Background>>, Option<Arc<crate::watch::Watch>>) { | |
| 469 | let reports = Arc::new(OnceLock::<std::sync::Weak<Background>>::new()); | |
| 470 | let report = Arc::clone(&reports); | |
| 471 | let watch = crate::watch::watch(Path::new(location), move |paths| { | |
| 472 | if let Some(background) = report.get().and_then(std::sync::Weak::upgrade) { | |
| 473 | background.touched(&paths); | |
| 474 | } | |
| 475 | }); | |
| 476 | let remote = !crate::watch::on_this_computer(Path::new(location)); | |
| 477 | let interval = match watch { | |
| 478 | Some(_) if !remote => Background::BACKSTOP, | |
| 479 | _ => Background::UNWATCHED, | |
| 480 | }; | |
| 481 | let Some(background) = notebook | |
| 482 | .background(interval, remote, notify_background) | |
| 453 | 483 | .inspect_err(|error| eprintln!("Background sync did not start: {error}")) |
| 454 | .ok()?; | |
| 484 | .ok() | |
| 485 | else { | |
| 486 | return (None, None); | |
| 487 | }; | |
| 455 | 488 | background.set_offline(offline()); |
| 456 | 489 | background.watch(notebook.replicas()); |
| 457 | Some(Arc::new(background)) | |
| 490 | let background = Arc::new(background); | |
| 491 | let _ = reports.set(Arc::downgrade(&background)); | |
| 492 | (Some(background), watch.map(Arc::new)) | |
| 458 | 493 | } |
| 459 | 494 | |
| 460 | 495 | pub fn file_name(path: &Path) -> String { |
| ... | ... | @@ -483,7 +518,7 @@ fn tabs(catalog: &Folder) -> Vec<Tab> { |
| 483 | 518 | .sections |
| 484 | 519 | .iter() |
| 485 | 520 | .filter_map(|section| match &section.state { |
| 486 | SectionState::Readable { name, color } => Some(Tab { | |
| 521 | SectionState::Readable { name, color, .. } => Some(Tab { | |
| 487 | 522 | name: section_name(&section.path, name), |
| 488 | 523 | path: section.path.clone(), |
| 489 | 524 | color: *color, |
| ... | ... | @@ -668,6 +703,7 @@ mod tests { |
| 668 | 703 | notice: None, |
| 669 | 704 | cache: PathBuf::new(), |
| 670 | 705 | background: None, |
| 706 | watch: None, | |
| 671 | 707 | }; |
| 672 | 708 | let shown = |root, file| library(root).local(Path::new(file)); |
| 673 | 709 | let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); |
crates/snowbound/src/linux.rs+2| ... | ... | @@ -25,6 +25,8 @@ use winit::{ |
| 25 | 25 | const APP_ID: &str = "snowbound"; |
| 26 | 26 | /// The title bar's leading margin; the window controls sit at its trailing end. |
| 27 | 27 | pub const LEADING: f32 = 8.0; |
| 28 | /// The margin past the window controls. | |
| 29 | pub const TRAILING: f32 = 8.0; | |
| 28 | 30 | /// How far inside the window's edges a press resizes it, and how far along them a corner |
| 29 | 31 | /// reaches, in logical pixels. |
| 30 | 32 | const EDGE: f32 = 5.0; |
crates/snowbound/src/macos.rs+2| ... | ... | @@ -86,6 +86,8 @@ fn ns_window(window: &Window) -> Retained<AnyObject> { |
| 86 | 86 | |
| 87 | 87 | /// Room the traffic lights take at the title bar's leading edge. |
| 88 | 88 | pub const LEADING: f32 = 78.0; |
| 89 | /// The title bar's trailing margin, the gap the traffic lights leave before the toolbar. | |
| 90 | pub const TRAILING: f32 = 12.0; | |
| 89 | 91 | /// How far AppKit rounds a window's corners. |
| 90 | 92 | pub const CORNER_RADIUS: f32 = 10.0; |
| 91 | 93 |
crates/snowbound/src/main.rs+36-1| ... | ... | @@ -31,6 +31,7 @@ mod surface; |
| 31 | 31 | mod sync; |
| 32 | 32 | mod tags; |
| 33 | 33 | mod templates; |
| 34 | mod watch; | |
| 34 | 35 | |
| 35 | 36 | use canvas::gpu::page::PageScene; |
| 36 | 37 | use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility}; |
| ... | ... | @@ -1200,6 +1201,13 @@ impl State { |
| 1200 | 1201 | if self.session.is_some() { |
| 1201 | 1202 | self.page_tools(&theme); |
| 1202 | 1203 | } |
| 1204 | self.ui.leaf( | |
| 1205 | "trail", | |
| 1206 | Spec { | |
| 1207 | size: [px(self.trailing() - FRAME), px(1.0)], | |
| 1208 | ..Spec::default() | |
| 1209 | }, | |
| 1210 | ); | |
| 1203 | 1211 | self.ui.close(); |
| 1204 | 1212 | |
| 1205 | 1213 | self.ui.open_as( |
| ... | ... | @@ -1268,6 +1276,23 @@ impl State { |
| 1268 | 1276 | } |
| 1269 | 1277 | } |
| 1270 | 1278 | self.ui.close(); |
| 1279 | if let Some(task) = self.view.task_under_pointer() { | |
| 1280 | let [left, top, ..] = self.ui.rect(page()).unwrap_or_default(); | |
| 1281 | let scale = self.ui.scale(); | |
| 1282 | let [x0, y0, x1, y1] = task; | |
| 1283 | let part = [ | |
| 1284 | left + x0 / scale, | |
| 1285 | top + y0 / scale, | |
| 1286 | left + x1 / scale, | |
| 1287 | top + y1 / scale, | |
| 1288 | ]; | |
| 1289 | ui::popup::tooltip_over( | |
| 1290 | &mut self.ui, | |
| 1291 | part, | |
| 1292 | "Outlook task", | |
| 1293 | Some("Edit it in OneNote with Outlook"), | |
| 1294 | ); | |
| 1295 | } | |
| 1271 | 1296 | let signal = self.ui.signal(page()); |
| 1272 | 1297 | let focused = self.ui.window_focused && self.ui.focused() == Some(page()); |
| 1273 | 1298 | if focused != self.page_focused { |
| ... | ... | @@ -1411,6 +1436,16 @@ impl State { |
| 1411 | 1436 | ); |
| 1412 | 1437 | } |
| 1413 | 1438 | |
| 1439 | /// The toolbar and tab row's margin at the window's trailing side, which matches the | |
| 1440 | /// leading gap where the row is the title bar. | |
| 1441 | fn trailing(&self) -> f32 { | |
| 1442 | if platform::system_titlebar(&self.window) { | |
| 1443 | FRAME | |
| 1444 | } else { | |
| 1445 | platform::TRAILING | |
| 1446 | } | |
| 1447 | } | |
| 1448 | ||
| 1414 | 1449 | /// Whether the chrome's empty space drags the window: where it is the title bar, or |
| 1415 | 1450 | /// over the title's gradient. |
| 1416 | 1451 | fn chrome_drags(&self) -> bool { |
| ... | ... | @@ -1468,7 +1503,7 @@ impl State { |
| 1468 | 1503 | self.ui.leaf( |
| 1469 | 1504 | "trail", |
| 1470 | 1505 | Spec { |
| 1471 | size: [px(FRAME - GAP), px(1.0)], | |
| 1506 | size: [px(self.trailing() - GAP), px(1.0)], | |
| 1472 | 1507 | ..Spec::default() |
| 1473 | 1508 | }, |
| 1474 | 1509 | ); |
crates/snowbound/src/manage.rs+5-1| ... | ... | @@ -189,9 +189,13 @@ impl State { |
| 189 | 189 | Ok(()) |
| 190 | 190 | } |
| 191 | 191 | |
| 192 | /// Closes `library`: its files stay, and the sidebar and the next launch leave it out. | |
| 192 | /// Closes `library`: its files stay, its offline copies go unless edits wait in them, | |
| 193 | /// and the sidebar and the next launch leave it out. | |
| 193 | 194 | pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) { |
| 194 | 195 | self.notebooks.retain(|open| !Arc::ptr_eq(open, library)); |
| 196 | if let Some(background) = &library.background { | |
| 197 | background.discard(); | |
| 198 | } | |
| 195 | 199 | let shown = |shown: &Arc<Library>| shown.location == library.location; |
| 196 | 200 | if self.sectionless.as_ref().is_some_and(shown) |
| 197 | 201 | || self |
crates/snowbound/src/meeting.rs+2| ... | ... | @@ -164,6 +164,8 @@ pub fn content(page: &Page) -> Result<Page, Box<dyn Error>> { |
| 164 | 164 | vec![Tag { |
| 165 | 165 | definition: Some(to_do), |
| 166 | 166 | action_type: None, |
| 167 | shape: None, | |
| 168 | property_status: None, | |
| 167 | 169 | status: 0, |
| 168 | 170 | created: Some(created), |
| 169 | 171 | completed: Some(0), |
crates/snowbound/src/sidebar.rs+1-1| ... | ... | @@ -260,7 +260,7 @@ fn folder( |
| 260 | 260 | }; |
| 261 | 261 | for section in &folder.sections { |
| 262 | 262 | let (name, color, readable) = match &section.state { |
| 263 | SectionState::Readable { name, color } => ( | |
| 263 | SectionState::Readable { name, color, .. } => ( | |
| 264 | 264 | crate::library::section_name(&section.path, name), |
| 265 | 265 | *color, |
| 266 | 266 | true, |
crates/snowbound/src/tags.rs+8-4| ... | ... | @@ -103,13 +103,13 @@ fn label(ui: &mut Ui, part: &str, text: &str, tag: &NoteTag) { |
| 103 | 103 | ); |
| 104 | 104 | } |
| 105 | 105 | |
| 106 | fn icon(ui: &mut Ui, part: &str, art: Option<&'static [&'static str]>) { | |
| 106 | fn icon(ui: &mut Ui, part: &str, art: Option<&'static [&'static str]>, tint: [f32; 4]) { | |
| 107 | 107 | ui.leaf( |
| 108 | 108 | part, |
| 109 | 109 | Spec { |
| 110 | 110 | size: [px(SIDE + 4.0), px(ROW)], |
| 111 | 111 | icon: art, |
| 112 | color: Some([1.0; 4]), | |
| 112 | color: Some(tint), | |
| 113 | 113 | center: true, |
| 114 | 114 | ..Spec::default() |
| 115 | 115 | }, |
| ... | ... | @@ -324,7 +324,11 @@ impl State { |
| 324 | 324 | ..Spec::default() |
| 325 | 325 | }, |
| 326 | 326 | ); |
| 327 | icon(ui, "icon", artwork(tag)); | |
| 327 | // OneNote's list marks a tag without a symbol with its "None" symbol art. | |
| 328 | match artwork(tag) { | |
| 329 | Some(art) => icon(ui, "icon", Some(art), [1.0; 4]), | |
| 330 | None => icon(ui, "icon", Some(crate::art::FONT_COLOR), theme.text), | |
| 331 | } | |
| 328 | 332 | let text = match commands::tag_chord(place) { |
| 329 | 333 | Some(chord) => format!("{} ({})", tag.label, chord.label(Platform::CURRENT)), |
| 330 | 334 | None => tag.label.clone(), |
| ... | ... | @@ -585,7 +589,7 @@ fn tag_editor( |
| 585 | 589 | ..Spec::default() |
| 586 | 590 | }, |
| 587 | 591 | ); |
| 588 | icon(ui, "icon", artwork(tag)); | |
| 592 | icon(ui, "icon", artwork(tag), [1.0; 4]); | |
| 589 | 593 | label(ui, "label", &tag.label, tag); |
| 590 | 594 | ui.leaf( |
| 591 | 595 | "end", |
crates/snowbound/src/watch.rs created+441| ... | ... | @@ -0,0 +1,441 @@ |
| 1 | //! Changes below a notebook folder on this computer, as the system reports them: FSEvents on | |
| 2 | //! macOS, inotify on Linux. A notebook on a share learns of its changes from the server | |
| 3 | //! instead (`notebook::session::Background::smb`). | |
| 4 | ||
| 5 | use std::path::Path; | |
| 6 | ||
| 7 | /// Reports until dropped. | |
| 8 | pub 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. | |
| 15 | pub 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. | |
| 37 | pub 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 | ||
| 63 | type Changed = Box<dyn Fn(Vec<std::path::PathBuf>) + Send + Sync>; | |
| 64 | ||
| 65 | #[cfg(target_os = "macos")] | |
| 66 | mod 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")] | |
| 251 | mod 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)] | |
| 407 | mod tests { | |
| 408 | use super::*; | |
| 409 | use std::{sync::mpsc, time::Duration}; | |
| 410 | ||
| 411 | #[test] | |
| 412 | fn a_folder_on_this_computer_reports_the_file_that_changed() { | |
| 413 | let folder = std::env::temp_dir().join(format!("snowbound-watch-{}", std::process::id())); | |
| 414 | let _ = std::fs::remove_dir_all(&folder); | |
| 415 | std::fs::create_dir_all(folder.join("Group")).unwrap(); | |
| 416 | std::fs::write(folder.join("Group/Section.one"), b"before").unwrap(); | |
| 417 | assert!(on_this_computer(&folder)); | |
| 418 | let (sender, changes) = mpsc::channel(); | |
| 419 | let sender = std::sync::Mutex::new(sender); | |
| 420 | let watching = watch(&folder, move |paths| { | |
| 421 | let _ = sender.lock().unwrap().send(paths); | |
| 422 | }) | |
| 423 | .unwrap(); | |
| 424 | // FSEvents starts reporting a moment after the stream starts. | |
| 425 | std::thread::sleep(Duration::from_millis(500)); | |
| 426 | std::fs::write(folder.join("Group/Section.one"), b"after").unwrap(); | |
| 427 | let mut reported = Vec::new(); | |
| 428 | while let Ok(paths) = changes.recv_timeout(Duration::from_secs(5)) { | |
| 429 | reported.extend(paths); | |
| 430 | if reported.iter().any(|path| path == "Group/Section.one") { | |
| 431 | break; | |
| 432 | } | |
| 433 | } | |
| 434 | drop(watching); | |
| 435 | std::fs::remove_dir_all(&folder).unwrap(); | |
| 436 | assert!( | |
| 437 | reported.iter().any(|path| path == "Group/Section.one"), | |
| 438 | "{reported:?}" | |
| 439 | ); | |
| 440 | } | |
| 441 | } |
crates/ui/src/popup.rs+17-1| ... | ... | @@ -138,6 +138,22 @@ pub fn menu( |
| 138 | 138 | /// delay, or at once while another has just shown. A press or the wheel hides it until the |
| 139 | 139 | /// pointer leaves. |
| 140 | 140 | pub 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. | |
| 146 | pub fn tooltip_over(ui: &mut Ui, part: [f32; 4], title: &str, description: Option<&str>) { | |
| 147 | tooltip_below(ui, Some(part), title, "", description); | |
| 148 | } | |
| 149 | ||
| 150 | fn tooltip_below( | |
| 151 | ui: &mut Ui, | |
| 152 | part: Option<[f32; 4]>, | |
| 153 | title: &str, | |
| 154 | keys: &str, | |
| 155 | description: Option<&str>, | |
| 156 | ) { | |
| 141 | 157 | let Some(&index) = ui.nodes[*ui.stack.last().unwrap()].children.last() else { |
| 142 | 158 | return; |
| 143 | 159 | }; |
| ... | ... | @@ -183,7 +199,7 @@ pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) |
| 183 | 199 | } else { |
| 184 | 200 | format!("{title} ({keys})") |
| 185 | 201 | }; |
| 186 | let [left, top, right, bottom] = ui.rect(id).unwrap_or_default(); | |
| 202 | let [left, top, right, bottom] = part.or(ui.rect(id)).unwrap_or_default(); | |
| 187 | 203 | let theme = &ui.theme; |
| 188 | 204 | let spec = Spec { |
| 189 | 205 | axis: Axis::Y, |
tools/test_task_tags.py created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | from pathlib import Path | |
| 2 | import re | |
| 3 | import runpy | |
| 4 | import shutil | |
| 5 | from tempfile import TemporaryDirectory | |
| 6 | import unittest | |
| 7 | import xml.etree.ElementTree as ET | |
| 8 | ||
| 9 | from native_xml import ns | |
| 10 | ||
| 11 | ROOT = Path(__file__).resolve().parent.parent | |
| 12 | FIXTURE = ROOT / 'corpus/task-tags' | |
| 13 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 14 | ||
| 15 | ||
| 16 | def 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 | ||
| 32 | class 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 | ||
| 51 | if __name__ == '__main__': | |
| 52 | unittest.main() |