| 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,13 +176,35 @@ online again. |
| 176 | 176 | ||
| 177 | ## Sections that aren't open | 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 | 179 | OneNote keeps every section of an open notebook in sync and on this computer, not just |
| 180 | reads a section another client changed within seconds and publishes a closed section's | 180 | the one on screen. Watched in the lab with a notebook of 200 sections on Samba, OneNote |
| 181 | offline edits the moment the share is back. Snowbound's `session::Background` does the | 181 | 2010: |
| 182 | same with one thread per notebook. Each round reads every section file's stamp. Only a | 182 | |
| 183 | section whose replica has edits waiting, or whose file moved past the replica's base, | 183 | - reads every section file once when the notebook opens (all 200 within 15 seconds), so a |
| 184 | has its replica opened for the usual sync steps, and it is closed again afterwards. The | 184 | section never shown before opens with the share gone; |
| 185 | open section is left to its own session. | 185 | - arms one CHANGE_NOTIFY on the notebook's folder, with WATCH_TREE and a filter of names, |
| 186 | attributes, size and last write, and otherwise sends nothing: 22 idle minutes put no SMB | ||
| 187 | request on the wire, not even an echo; | ||
| 188 | - lists the folder once when the server reports a change, and reads only the section that | ||
| 189 | changed, some seconds later; | ||
| 190 | - tries a section it could not reach again about every 31 seconds. | ||
| 191 | |||
| 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 | ## Notebook structure | 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,7 +18,9 @@ absolute directory while running the test, then cold-open it with |
| 18 | `native/shapes.one` is OneNote 2010's own work in a lab clone: | 18 | `native/shapes.one` is OneNote 2010's own work in a lab clone: |
| 19 | 19 | ||
| 20 | - "Shapes 1-72" and "Shapes 73-143" tag a paragraph with every NoteTagShape, set through | 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 | - "Custom tags" holds a tag made with New Tag (Snow check, as above), applied at the top | 24 | - "Custom tags" holds a tag made with New Tag (Snow check, as above), applied at the top |
| 23 | of the list (action type 0), then after moving it below To Do (a second definition, | 25 | of the list (action type 0), then after moving it below To Do (a second definition, |
| 24 | action type 1), with To Do applied from both places. The UI stores status 15 and 9. | 26 | action type 1), with To Do applied from both places. The UI stores status 15 and 9. |
corpus/custom-tags/native/shapes-1-72.png created| Binary files /dev/null and b/corpus/custom-tags/native/shapes-1-72.png differ | |||
corpus/custom-tags/native/shapes-73-143.png created| Binary files /dev/null and b/corpus/custom-tags/native/shapes-73-143.png differ | |||
corpus/ink-tools/native-ui/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/ink-tools/native-ui/notebook/Open Notebook.onetoc2 differ | |||
corpus/ink-tools/native-ui/notebook/ink.one created| Binary files /dev/null and b/corpus/ink-tools/native-ui/notebook/ink.one differ | |||
corpus/task-tags/README.md created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | # Outlook task tags through the editor | ||
| 2 | |||
| 3 | `native/` is OneNote 2010 authoring `tools/native/pages.ps1` input (two identical | ||
| 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,11 +1,10 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <defs> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#91b6f7"/><stop offset="1" stop-color="#6498ef"/></linearGradient> | 3 | <linearGradient id="interior" x1="7.5" y1="7.5" x2="12.5" y2="12.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e8f3fe"/></linearGradient> |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7caff6"/><stop offset="1" stop-color="#5587e9"/></linearGradient> | ||
| 5 | <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#d6e6f5"/></linearGradient> | ||
| 6 | </defs> | 4 | </defs> |
| 7 | <path d="M5 5H15V15H5Z" fill="url(#layer0)"/> | 5 | <path d="M3 3H16V16H3Z" fill="#90a0ac"/> |
| 8 | <path d="M5.75 5.75H14.25V14.25H5.75Z" fill="#ffffff"/> | 6 | <path d="M4 4H15V15H4Z" fill="#f7f8fa"/> |
| 9 | <path d="M6.5 6.5H13.5V13.5H6.5Z" fill="url(#layer1)"/> | 7 | <path d="M5 5H14V14H5Z" fill="#b4bfc7"/> |
| 10 | <path d="M7.25 7.25H12.75V12.75H7.25Z" fill="url(#layer2)"/> | 8 | <path d="M5 5H14V6H6V13H5Z" fill="#ccd5d9"/> |
| 9 | <path d="M6 6H13V13H6Z" fill="url(#interior)"/> | ||
| 11 | </svg> | 10 | </svg> |
crates/canvas/assets/tags/checkbox-green.svg+11-17| ... | @@ -1,24 +1,18 @@ | ... | @@ -1,24 +1,18 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <defs> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"> | 3 | <linearGradient id="layer3" x1="4" y1="4" x2="13" y2="13" gradientUnits="userSpaceOnUse"> |
| 4 | <stop offset="0" stop-color="#a6d596"/> | ||
| 5 | <stop offset="1" stop-color="#6cb055"/> | ||
| 6 | </linearGradient> | ||
| 7 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"> | ||
| 8 | <stop offset="0" stop-color="#ffffff"/> | ||
| 9 | <stop offset="1" stop-color="#ffffff"/> | ||
| 10 | </linearGradient> | ||
| 11 | <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"> | ||
| 12 | <stop offset="0" stop-color="#98cf84"/> | ||
| 13 | <stop offset="1" stop-color="#5ea347"/> | ||
| 14 | </linearGradient> | ||
| 15 | <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"> | ||
| 16 | <stop offset="0" stop-color="#ffffff"/> | 4 | <stop offset="0" stop-color="#ffffff"/> |
| 17 | <stop offset="1" stop-color="#e2f3da"/> | 5 | <stop offset="1" stop-color="#e7f6e1"/> |
| 18 | </linearGradient> | 6 | </linearGradient> |
| 19 | </defs> | 7 | </defs> |
| 20 | <path d="M0 0H16V16H0Z" fill="url(#layer0)"/> | 8 | <path d="M0 0h16v1h-16zM0 1h1v15h-1z" fill="#a0d495"/> |
| 21 | <path d="M1 1H15V15H1Z" fill="url(#layer1)"/> | 9 | <path d="M1 1h14v1h-14zM1 2h1v1h-1zM14 2h1v13h-1zM1 11h1v4h-1z" fill="#f7f8f7"/> |
| 22 | <path d="M2 2H14V14H2Z" fill="url(#layer2)"/> | 10 | <path d="M15 1h1v15h-1zM1 15h14v1h-14z" fill="#5ca051"/> |
| 11 | <path d="M2 2h12v1h-12zM2 3h1v11h-1z" fill="#bfdeb5"/> | ||
| 12 | <path d="M1 3h1v8h-1z" fill="#fafbfa"/> | ||
| 13 | <path d="M13 3h1v11h-1zM3 13h10v1h-10z" fill="#92c584"/> | ||
| 14 | <path d="M2 14h4v1h-4z" fill="#f4f9f2"/> | ||
| 15 | <path d="M6 14h4v1h-4zM13 14h1v1h-1z" fill="#f2f8ef"/> | ||
| 16 | <path d="M10 14h3v1h-3z" fill="#eff7ec"/> | ||
| 23 | <path d="M3 3H13V13H3Z" fill="url(#layer3)"/> | 17 | <path d="M3 3H13V13H3Z" fill="url(#layer3)"/> |
| 24 | </svg> | 18 | </svg> |
crates/canvas/assets/tags/checkbox-yellow.svg+11-17| ... | @@ -1,24 +1,18 @@ | ... | @@ -1,24 +1,18 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <defs> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"> | 3 | <linearGradient id="layer3" x1="4" y1="4" x2="13" y2="13" gradientUnits="userSpaceOnUse"> |
| 4 | <stop offset="0" stop-color="#f6d993"/> | ||
| 5 | <stop offset="1" stop-color="#e0ad3e"/> | ||
| 6 | </linearGradient> | ||
| 7 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"> | ||
| 8 | <stop offset="0" stop-color="#ffffff"/> | ||
| 9 | <stop offset="1" stop-color="#ffffff"/> | ||
| 10 | </linearGradient> | ||
| 11 | <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"> | ||
| 12 | <stop offset="0" stop-color="#f3d182"/> | ||
| 13 | <stop offset="1" stop-color="#d9a133"/> | ||
| 14 | </linearGradient> | ||
| 15 | <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"> | ||
| 16 | <stop offset="0" stop-color="#ffffff"/> | 4 | <stop offset="0" stop-color="#ffffff"/> |
| 17 | <stop offset="1" stop-color="#fcf1d8"/> | 5 | <stop offset="1" stop-color="#fbf8e9"/> |
| 18 | </linearGradient> | 6 | </linearGradient> |
| 19 | </defs> | 7 | </defs> |
| 20 | <path d="M0 0H16V16H0Z" fill="url(#layer0)"/> | 8 | <path d="M0 0h16v1h-16zM0 1h1v15h-1z" fill="#dbbc6a"/> |
| 21 | <path d="M1 1H15V15H1Z" fill="url(#layer1)"/> | 9 | <path d="M1 1h14v1h-14zM1 2h1v1h-1zM14 2h1v13h-1zM1 14h1v1h-1z" fill="#f7f8fa"/> |
| 22 | <path d="M2 2H14V14H2Z" fill="url(#layer2)"/> | 10 | <path d="M15 1h1v15h-1zM1 15h14v1h-14z" fill="#c18d1c"/> |
| 11 | <path d="M2 2h12v1h-12zM2 3h1v11h-1z" fill="#e8d39f"/> | ||
| 12 | <path d="M1 3h1v9h-1z" fill="#fbfbfc"/> | ||
| 13 | <path d="M13 3h1v11h-1zM3 13h10v1h-10z" fill="#e6b44d"/> | ||
| 14 | <path d="M1 12h1v2h-1zM2 14h2v1h-2z" fill="#fafaf9"/> | ||
| 15 | <path d="M4 14h6v1h-6zM13 14h1v1h-1z" fill="#f9f9f5"/> | ||
| 16 | <path d="M10 14h3v1h-3z" fill="#f9f8f2"/> | ||
| 23 | <path d="M3 3H13V13H3Z" fill="url(#layer3)"/> | 17 | <path d="M3 3H13V13H3Z" fill="url(#layer3)"/> |
| 24 | </svg> | 18 | </svg> |
crates/canvas/assets/tags/checkbox.svg+12-17| ... | @@ -1,24 +1,19 @@ | ... | @@ -1,24 +1,19 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <defs> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"> | 3 | <linearGradient id="layer3" x1="4" y1="4" x2="13" y2="13" gradientUnits="userSpaceOnUse"> |
| 4 | <stop offset="0" stop-color="#91b6f7"/> | ||
| 5 | <stop offset="1" stop-color="#6498ef"/> | ||
| 6 | </linearGradient> | ||
| 7 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"> | ||
| 8 | <stop offset="0" stop-color="#ffffff"/> | ||
| 9 | <stop offset="1" stop-color="#ffffff"/> | ||
| 10 | </linearGradient> | ||
| 11 | <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"> | ||
| 12 | <stop offset="0" stop-color="#7caff6"/> | ||
| 13 | <stop offset="1" stop-color="#5587e9"/> | ||
| 14 | </linearGradient> | ||
| 15 | <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"> | ||
| 16 | <stop offset="0" stop-color="#ffffff"/> | 4 | <stop offset="0" stop-color="#ffffff"/> |
| 17 | <stop offset="1" stop-color="#d6e6f5"/> | 5 | <stop offset="1" stop-color="#daecfe"/> |
| 18 | </linearGradient> | 6 | </linearGradient> |
| 19 | </defs> | 7 | </defs> |
| 20 | <path d="M0 0H16V16H0Z" fill="url(#layer0)"/> | 8 | <path d="M0 0h16v1h-16zM0 1h1v15h-1z" fill="#83a9e9"/> |
| 21 | <path d="M1 1H15V15H1Z" fill="url(#layer1)"/> | 9 | <path d="M1 1h14v1h-14zM1 2h1v1h-1zM14 2h1v13h-1zM1 9h1v6h-1z" fill="#f7f8fa"/> |
| 22 | <path d="M2 2H14V14H2Z" fill="url(#layer2)"/> | 10 | <path d="M15 1h1v15h-1zM1 15h14v1h-14z" fill="#3763bb"/> |
| 11 | <path d="M2 2h12v1h-12zM2 3h1v11h-1z" fill="#a8c8ed"/> | ||
| 12 | <path d="M1 3h1v6h-1z" fill="#fbfbfc"/> | ||
| 13 | <path d="M13 3h1v11h-1zM3 13h10v1h-10z" fill="#709ddb"/> | ||
| 14 | <path d="M2 14h3v1h-3z" fill="#f2f7fc"/> | ||
| 15 | <path d="M5 14h2v1h-2zM13 14h1v1h-1z" fill="#eff5fc"/> | ||
| 16 | <path d="M7 14h3v1h-3z" fill="#ecf4fc"/> | ||
| 17 | <path d="M10 14h3v1h-3z" fill="#e9f2fc"/> | ||
| 23 | <path d="M3 3H13V13H3Z" fill="url(#layer3)"/> | 18 | <path d="M3 3H13V13H3Z" fill="url(#layer3)"/> |
| 24 | </svg> | 19 | </svg> |
crates/canvas/assets/tags/flag.svg deleted-10| ... | @@ -1,10 +0,0 @@ | ||
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | ||
| 2 | <defs> | ||
| 3 | <linearGradient id="cloth" x1="0" y1="2" x2="0" y2="10" gradientUnits="userSpaceOnUse"> | ||
| 4 | <stop offset="0" stop-color="#f25c54"/> | ||
| 5 | <stop offset="1" stop-color="#c8342c"/> | ||
| 6 | </linearGradient> | ||
| 7 | </defs> | ||
| 8 | <path d="M3 2H4V14H3Z" fill="#6b6b6b"/> | ||
| 9 | <path d="M4 2.5C6 1.5 8 3.5 10 2.5C11 2 12 2 13 2.5V9C12 8.5 11 8.5 10 9C8 10 6 8 4 9Z" fill="url(#cloth)"/> | ||
| 10 | </svg> | ||
crates/canvas/assets/tags/mark-arrow-blue.svg+40-4| ... | @@ -1,6 +1,42 @@ | ... | @@ -1,6 +1,42 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M3 0h1v1h-1z" fill="#0577e3" fill-opacity="0.82"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6fb0ff"/><stop offset="1" stop-color="#1e5ab8"/></linearGradient> | 3 | <path d="M4 0h1v1h-1z" fill="#348fd9" fill-opacity="0.2"/> |
| 4 | </defs> | 4 | <path d="M3 1h1v1h-1z" fill="#1f78e0" fill-opacity="0.84"/> |
| 5 | <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/> | 5 | <path d="M4 1h1v1h-1z" fill="#4e76c5"/> |
| 6 | <path d="M5 1h1v1h-1zM6 2h1v1h-1z" fill="#3083ce" fill-opacity="0.27"/> | ||
| 7 | <path d="M0 2h1v1h-1z" fill="#5d8fe6" fill-opacity="0.92"/> | ||
| 8 | <path d="M1 2h1v1h-1zM3 2h1v1h-1z" fill="#688eda"/> | ||
| 9 | <path d="M2 2h1v1h-1zM0 6h1v1h-1z" fill="#5f85cf"/> | ||
| 10 | <path d="M4 2h1v1h-1z" fill="#6b9af7"/> | ||
| 11 | <path d="M5 2h1v1h-1zM6 3h1v1h-1zM7 4h1v1h-1z" fill="#2b58ae"/> | ||
| 12 | <path d="M0 3h1v1h-1z" fill="#618fe3" fill-opacity="0.94"/> | ||
| 13 | <path d="M1 3h1v1h-1z" fill="#9ebefd"/> | ||
| 14 | <path d="M2 3h1v1h-1z" fill="#85aefe"/> | ||
| 15 | <path d="M3 3h1v1h-1z" fill="#7da8ff"/> | ||
| 16 | <path d="M4 3h1v1h-1z" fill="#6f9df8"/> | ||
| 17 | <path d="M5 3h1v1h-1zM4 4h1v1h-1z" fill="#5183e5"/> | ||
| 18 | <path d="M7 3h1v1h-1z" fill="#8296ac"/> | ||
| 19 | <path d="M0 4h1v1h-1z" fill="#6c92de"/> | ||
| 20 | <path d="M1 4h1v1h-1z" fill="#78a5fd"/> | ||
| 21 | <path d="M2 4h1v1h-1z" fill="#5e93fc"/> | ||
| 22 | <path d="M3 4h1v1h-1z" fill="#598cf2"/> | ||
| 23 | <path d="M5 4h1v1h-1z" fill="#4c7bd8"/> | ||
| 24 | <path d="M6 4h1v1h-1zM5 5h1v1h-1z" fill="#416ec6"/> | ||
| 25 | <path d="M0 5h1v1h-1z" fill="#658bd7"/> | ||
| 26 | <path d="M1 5h1v1h-1z" fill="#5c90f7"/> | ||
| 27 | <path d="M2 5h1v1h-1z" fill="#5688ec"/> | ||
| 28 | <path d="M3 5h1v1h-1z" fill="#4f7fde"/> | ||
| 29 | <path d="M4 5h1v1h-1z" fill="#4876d1"/> | ||
| 30 | <path d="M6 5h1v1h-1z" fill="#3156a2"/> | ||
| 31 | <path d="M7 5h1v1h-1zM5 7h1v1h-1z" fill="#aab9cc"/> | ||
| 32 | <path d="M1 6h1v1h-1z" fill="#254b9a"/> | ||
| 33 | <path d="M2 6h1v1h-1z" fill="#274fa2"/> | ||
| 34 | <path d="M3 6h1v1h-1z" fill="#2954ab"/> | ||
| 35 | <path d="M4 6h1v1h-1z" fill="#3f6abf"/> | ||
| 36 | <path d="M5 6h1v1h-1z" fill="#2a50a0"/> | ||
| 37 | <path d="M6 6h1v1h-1z" fill="#d3dbea"/> | ||
| 38 | <path d="M3 7h1v1h-1z" fill="#254d9d"/> | ||
| 39 | <path d="M4 7h1v1h-1z" fill="#284e9d"/> | ||
| 40 | <path d="M3 8h1v1h-1z" fill="#244996"/> | ||
| 41 | <path d="M4 8h1v1h-1z" fill="#cdd5e6"/> | ||
| 6 | </svg> | 42 | </svg> |
crates/canvas/assets/tags/mark-arrow-green.svg+34-4| ... | @@ -1,6 +1,36 @@ | ... | @@ -1,6 +1,36 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M3 0h1v1h-1zM0 2h1v1h-1z" fill="#72ce6d"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8ee07a"/><stop offset="1" stop-color="#2e8a1e"/></linearGradient> | 3 | <path d="M4 0h1v1h-1z" fill="#4dc634" fill-opacity="0.2"/> |
| 4 | </defs> | 4 | <path d="M3 1h1v1h-1zM0 3h1v1h-1z" fill="#6fcb6a"/> |
| 5 | <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/> | 5 | <path d="M4 1h1v1h-1z" fill="#53a84f"/> |
| 6 | <path d="M5 1h1v1h-1zM6 2h1v1h-1z" fill="#3aad1c" fill-opacity="0.25"/> | ||
| 7 | <path d="M1 2h1v1h-1zM3 2h1v1h-1zM0 5h1v1h-1z" fill="#66bf62"/> | ||
| 8 | <path d="M2 2h1v1h-1zM0 6h1v1h-1z" fill="#5fb65b"/> | ||
| 9 | <path d="M4 2h1v2h-1z" fill="#6adb64"/> | ||
| 10 | <path d="M5 2h1v1h-1zM6 3h1v1h-1zM7 4h1v1h-1z" fill="#3c8b38"/> | ||
| 11 | <path d="M1 3h1v1h-1z" fill="#8ee78a"/> | ||
| 12 | <path d="M2 3h1v1h-1z" fill="#7be576"/> | ||
| 13 | <path d="M3 3h1v1h-1z" fill="#75e570"/> | ||
| 14 | <path d="M5 3h1v1h-1zM4 4h1v1h-1z" fill="#56c750"/> | ||
| 15 | <path d="M7 3h1v1h-1z" fill="#849d9c"/> | ||
| 16 | <path d="M0 4h1v1h-1z" fill="#6ac366"/> | ||
| 17 | <path d="M1 4h1v1h-1z" fill="#72e36c"/> | ||
| 18 | <path d="M2 4h1v1h-1z" fill="#5fde59"/> | ||
| 19 | <path d="M3 4h1v1h-1z" fill="#5bd455"/> | ||
| 20 | <path d="M5 4h1v1h-1z" fill="#51bb4c"/> | ||
| 21 | <path d="M6 4h1v1h-1zM5 5h1v1h-1z" fill="#4da249"/> | ||
| 22 | <path d="M1 5h1v1h-1z" fill="#5ed958"/> | ||
| 23 | <path d="M2 5h1v1h-1z" fill="#59ce53"/> | ||
| 24 | <path d="M3 5h1v1h-1z" fill="#52c04d"/> | ||
| 25 | <path d="M4 5h1v1h-1z" fill="#4eb249"/> | ||
| 26 | <path d="M6 5h1v1h-1z" fill="#3c823b"/> | ||
| 27 | <path d="M7 5h1v1h-1zM5 7h1v1h-1z" fill="#adc2b7"/> | ||
| 28 | <path d="M1 6h1v1h-1z" fill="#327b30"/> | ||
| 29 | <path d="M2 6h1v1h-1zM5 6h1v1h-1z" fill="#358033"/> | ||
| 30 | <path d="M3 6h1v1h-1z" fill="#398736"/> | ||
| 31 | <path d="M4 6h1v1h-1z" fill="#4e9b4a"/> | ||
| 32 | <path d="M6 6h1v1h-1z" fill="#d6e4d5"/> | ||
| 33 | <path d="M3 7h2v1h-2z" fill="#357d32"/> | ||
| 34 | <path d="M3 8h1v1h-1z" fill="#31782f"/> | ||
| 35 | <path d="M4 8h1v1h-1z" fill="#cfded1"/> | ||
| 6 | </svg> | 36 | </svg> |
crates/canvas/assets/tags/mark-arrow-yellow.svg+39-4| ... | @@ -1,6 +1,41 @@ | ... | @@ -1,6 +1,41 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M3 0h1v1h-1z" fill="#e8c20f" fill-opacity="0.92"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffe07a"/><stop offset="1" stop-color="#c28a12"/></linearGradient> | 3 | <path d="M4 0h1v1h-1z" fill="#e9d134" fill-opacity="0.24"/> |
| 4 | </defs> | 4 | <path d="M3 1h1v1h-1z" fill="#e3be01" fill-opacity="0.92"/> |
| 5 | <path d="M0.5 2H4.5V0L9 3.75L4.5 7.5V5.5H3V8.5H0.5Z" fill="url(#layer0)"/> | 5 | <path d="M4 1h1v1h-1z" fill="#c6a531"/> |
| 6 | <path d="M5 1h1v1h-1zM6 2h1v1h-1z" fill="#c7b31b" fill-opacity="0.28"/> | ||
| 7 | <path d="M0 2h1v1h-1z" fill="#e8c321" fill-opacity="0.93"/> | ||
| 8 | <path d="M1 2h1v1h-1zM3 2h1v1h-1z" fill="#dcba45"/> | ||
| 9 | <path d="M2 2h1v1h-1zM0 6h1v1h-1z" fill="#d4b23d"/> | ||
| 10 | <path d="M4 2h1v1h-1z" fill="#f6cf49"/> | ||
| 11 | <path d="M5 2h1v1h-1zM6 3h1v1h-1zM7 4h1v1h-1z" fill="#a4861f"/> | ||
| 12 | <path d="M0 3h1v1h-1z" fill="#e4c032" fill-opacity="0.95"/> | ||
| 13 | <path d="M1 3h1v1h-1z" fill="#fcde75"/> | ||
| 14 | <path d="M2 3h1v1h-1z" fill="#fdda5e"/> | ||
| 15 | <path d="M3 3h1v1h-1z" fill="#fed957"/> | ||
| 16 | <path d="M4 3h1v1h-1z" fill="#f7d14c"/> | ||
| 17 | <path d="M5 3h1v1h-1zM4 4h1v1h-1z" fill="#e6be31"/> | ||
| 18 | <path d="M7 3h1v1h-1z" fill="#939c98"/> | ||
| 19 | <path d="M0 4h1v1h-1z" fill="#dfbe4a"/> | ||
| 20 | <path d="M1 4h1v1h-1z" fill="#fcd653"/> | ||
| 21 | <path d="M2 4h1v1h-1z" fill="#fbd13c"/> | ||
| 22 | <path d="M3 4h1v1h-1z" fill="#f2c837"/> | ||
| 23 | <path d="M5 4h1v1h-1z" fill="#dbb42c"/> | ||
| 24 | <path d="M6 4h1v1h-1zM5 5h1v1h-1z" fill="#c09e2b"/> | ||
| 25 | <path d="M0 5h1v1h-1z" fill="#d9b844"/> | ||
| 26 | <path d="M1 5h1v1h-1z" fill="#f7cc3a"/> | ||
| 27 | <path d="M2 5h1v1h-1z" fill="#ecc335"/> | ||
| 28 | <path d="M3 5h1v1h-1z" fill="#e0b82d"/> | ||
| 29 | <path d="M4 5h1v1h-1z" fill="#d2ac29"/> | ||
| 30 | <path d="M6 5h1v1h-1z" fill="#9a8223"/> | ||
| 31 | <path d="M7 5h1v1h-1zM5 7h1v1h-1z" fill="#bfc2b3"/> | ||
| 32 | <path d="M1 6h1v1h-1z" fill="#91791a"/> | ||
| 33 | <path d="M2 6h1v1h-1zM5 6h1v1h-1z" fill="#977f1c"/> | ||
| 34 | <path d="M3 6h1v1h-1z" fill="#a0831d"/> | ||
| 35 | <path d="M4 6h1v1h-1z" fill="#b8992d"/> | ||
| 36 | <path d="M6 6h1v1h-1z" fill="#e8e4d1"/> | ||
| 37 | <path d="M3 7h1v1h-1z" fill="#94791b"/> | ||
| 38 | <path d="M4 7h1v1h-1z" fill="#947c1d"/> | ||
| 39 | <path d="M3 8h1v1h-1z" fill="#8d761a"/> | ||
| 40 | <path d="M4 8h1v1h-1z" fill="#e2decd"/> | ||
| 6 | </svg> | 41 | </svg> |
crates/canvas/assets/tags/mark-exclamation-blue.svg+42-7| ... | @@ -1,9 +1,44 @@ | ... | @@ -1,9 +1,44 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M1 0h1v1h-1zM2 7h1v1h-1z" fill="#19a4e7" fill-opacity="0.52"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d8e6ff"/><stop offset="1" stop-color="#4b78c9"/></linearGradient> | 3 | <path d="M2 0h1v1h-1z" fill="#3f97e7"/> |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#3d62ad"/><stop offset="1" stop-color="#1e4494"/></linearGradient> | 4 | <path d="M3 0h1v1h-1z" fill="#1c7ed6"/> |
| 5 | </defs> | 5 | <path d="M4 0h1v1h-1z" fill="#3d87d3"/> |
| 6 | <path d="M3.5 0.25C5.2 0.25 5.8 1.3 5.4 3L4.4 7.25H2.6L1.6 3C1.2 1.3 1.8 0.25 3.5 0.25Z" fill="url(#layer1)"/> | 6 | <path d="M5 0h1v1h-1z" fill="#0e87c7" fill-opacity="0.59"/> |
| 7 | <path d="M3.5 1C4.6 1 4.9 1.7 4.6 3L3.8 6.5H3.2L2.4 3C2.1 1.7 2.4 1 3.5 1Z" fill="url(#layer0)"/> | 7 | <path d="M1 1h1v1h-1z" fill="#4f8cc8"/> |
| 8 | <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/> | 8 | <path d="M2 1h1v1h-1z" fill="#ecf1ff"/> |
| 9 | <path d="M3 1h1v1h-1z" fill="#c8dafd"/> | ||
| 10 | <path d="M4 1h1v1h-1z" fill="#81aff7"/> | ||
| 11 | <path d="M5 1h1v1h-1z" fill="#3d679c"/> | ||
| 12 | <path d="M1 2h1v1h-1z" fill="#2d7ac7"/> | ||
| 13 | <path d="M2 2h1v1h-1z" fill="#d4e2fe"/> | ||
| 14 | <path d="M3 2h1v1h-1z" fill="#a4c9fd"/> | ||
| 15 | <path d="M4 2h1v1h-1z" fill="#669ae8"/> | ||
| 16 | <path d="M5 2h1v1h-1z" fill="#294f85"/> | ||
| 17 | <path d="M1 3h1v1h-1z" fill="#6a9fd2"/> | ||
| 18 | <path d="M2 3h1v1h-1z" fill="#b0cffd"/> | ||
| 19 | <path d="M3 3h1v1h-1z" fill="#87b7fa"/> | ||
| 20 | <path d="M4 3h1v1h-1z" fill="#588ad1"/> | ||
| 21 | <path d="M5 3h1v1h-1z" fill="#486b91"/> | ||
| 22 | <path d="M1 4h1v1h-1z" fill="#0a8ac7" fill-opacity="0.66"/> | ||
| 23 | <path d="M2 4h1v1h-1z" fill="#5283cd"/> | ||
| 24 | <path d="M3 4h1v1h-1z" fill="#6fa2eb"/> | ||
| 25 | <path d="M4 4h1v1h-1z" fill="#466fab"/> | ||
| 26 | <path d="M5 4h1v1h-1z" fill="#7490b0"/> | ||
| 27 | <path d="M1 5h1v1h-1z" fill="#2f89bd" fill-opacity="0.33"/> | ||
| 28 | <path d="M2 5h1v1h-1z" fill="#2e60aa"/> | ||
| 29 | <path d="M3 5h1v1h-1z" fill="#5c90da"/> | ||
| 30 | <path d="M4 5h1v1h-1z" fill="#365d8b"/> | ||
| 31 | <path d="M5 5h1v1h-1z" fill="#9aafd3"/> | ||
| 32 | <path d="M2 6h1v1h-1z" fill="#0882bd" fill-opacity="0.61"/> | ||
| 33 | <path d="M3 6h1v1h-1z" fill="#204d85"/> | ||
| 34 | <path d="M4 6h1v1h-1z" fill="#859eba"/> | ||
| 35 | <path d="M3 7h1v1h-1z" fill="#127dd8"/> | ||
| 36 | <path d="M4 7h1v1h-1z" fill="#8bbcf2"/> | ||
| 37 | <path d="M2 8h1v1h-1z" fill="#3479ca"/> | ||
| 38 | <path d="M3 8h1v1h-1z" fill="#c9daf4"/> | ||
| 39 | <path d="M4 8h1v1h-1z" fill="#083357"/> | ||
| 40 | <path d="M2 9h1v1h-1z" fill="#1c87c4" fill-opacity="0.55"/> | ||
| 41 | <path d="M3 9h1v1h-1z" fill="#0c3a5f"/> | ||
| 42 | <path d="M4 9h1v1h-1z" fill="#628dc4"/> | ||
| 43 | <path d="M3 10h1v1h-1z" fill="#4d75c1"/> | ||
| 9 | </svg> | 44 | </svg> |
crates/canvas/assets/tags/mark-exclamation-green.svg+43-7| ... | @@ -1,9 +1,45 @@ | ... | @@ -1,9 +1,45 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M1 0h1v1h-1z" fill="#72d042" fill-opacity="0.5"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d8f5c8"/><stop offset="1" stop-color="#5aa83c"/></linearGradient> | 3 | <path d="M2 0h1v1h-1z" fill="#73cd58"/> |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4a8a2e"/><stop offset="1" stop-color="#2d6a1a"/></linearGradient> | 4 | <path d="M3 0h1v1h-1z" fill="#56ba37"/> |
| 5 | </defs> | 5 | <path d="M4 0h1v1h-1z" fill="#67bc53"/> |
| 6 | <path d="M3.5 0.25C5.2 0.25 5.8 1.3 5.4 3L4.4 7.25H2.6L1.6 3C1.2 1.3 1.8 0.25 3.5 0.25Z" fill="url(#layer1)"/> | 6 | <path d="M5 0h1v1h-1z" fill="#48af05" fill-opacity="0.55"/> |
| 7 | <path d="M3.5 1C4.6 1 4.9 1.7 4.6 3L3.8 6.5H3.2L2.4 3C2.1 1.7 2.4 1 3.5 1Z" fill="url(#layer0)"/> | 7 | <path d="M1 1h1v1h-1z" fill="#72b560"/> |
| 8 | <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/> | 8 | <path d="M2 1h1v1h-1z" fill="#edfcee"/> |
| 9 | <path d="M3 1h1v1h-1z" fill="#d0f4cf"/> | ||
| 10 | <path d="M4 1h1v1h-1z" fill="#9ae592"/> | ||
| 11 | <path d="M5 1h1v1h-1z" fill="#548d4a"/> | ||
| 12 | <path d="M1 2h1v1h-1z" fill="#5aae43"/> | ||
| 13 | <path d="M2 2h1v1h-1z" fill="#dbf7da"/> | ||
| 14 | <path d="M3 2h1v1h-1z" fill="#b7efb0"/> | ||
| 15 | <path d="M4 2h1v1h-1z" fill="#82d479"/> | ||
| 16 | <path d="M5 2h1v1h-1z" fill="#3e7736"/> | ||
| 17 | <path d="M1 3h1v1h-1z" fill="#88c279"/> | ||
| 18 | <path d="M2 3h1v1h-1z" fill="#c1f1ba"/> | ||
| 19 | <path d="M3 3h1v1h-1z" fill="#a1e897"/> | ||
| 20 | <path d="M4 3h1v1h-1z" fill="#73be69"/> | ||
| 21 | <path d="M5 3h1v1h-1z" fill="#5c8552"/> | ||
| 22 | <path d="M1 4h1v1h-1z" fill="#53af1a" fill-opacity="0.62"/> | ||
| 23 | <path d="M2 4h1v1h-1z" fill="#6cba63"/> | ||
| 24 | <path d="M3 4h1v1h-1z" fill="#8bd881"/> | ||
| 25 | <path d="M4 4h1v1h-1z" fill="#5d9b54"/> | ||
| 26 | <path d="M5 4h1v1h-1z" fill="#83a67d"/> | ||
| 27 | <path d="M1 5h1v1h-1z" fill="#5ea93b" fill-opacity="0.32"/> | ||
| 28 | <path d="M2 5h1v1h-1z" fill="#4a9740"/> | ||
| 29 | <path d="M3 5h1v1h-1z" fill="#79c66f"/> | ||
| 30 | <path d="M4 5h1v1h-1z" fill="#4c7d42"/> | ||
| 31 | <path d="M5 5h1v1h-1z" fill="#a4caa1"/> | ||
| 32 | <path d="M2 6h1v1h-1z" fill="#51a721" fill-opacity="0.58"/> | ||
| 33 | <path d="M3 6h1v1h-1z" fill="#38752e"/> | ||
| 34 | <path d="M4 6h1v1h-1z" fill="#93b18c"/> | ||
| 35 | <path d="M2 7h1v1h-1z" fill="#66ce20" fill-opacity="0.48"/> | ||
| 36 | <path d="M3 7h1v1h-1z" fill="#50ba2f"/> | ||
| 37 | <path d="M4 7h1v1h-1z" fill="#a7e299"/> | ||
| 38 | <path d="M2 8h1v1h-1z" fill="#5bb34a"/> | ||
| 39 | <path d="M3 8h1v1h-1z" fill="#d1edce"/> | ||
| 40 | <path d="M4 8h1v1h-1z" fill="#214a13"/> | ||
| 41 | <path d="M2 9h1v1h-1z" fill="#4fae19" fill-opacity="0.52"/> | ||
| 42 | <path d="M3 9h1v1h-1z" fill="#275118"/> | ||
| 43 | <path d="M4 9h1v1h-1z" fill="#7ab570"/> | ||
| 44 | <path d="M3 10h1v1h-1z" fill="#61af5e"/> | ||
| 9 | </svg> | 45 | </svg> |
crates/canvas/assets/tags/mark-exclamation-yellow.svg+42-7| ... | @@ -1,9 +1,44 @@ | ... | @@ -1,9 +1,44 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M1 0h1v1h-1zM2 7h1v1h-1z" fill="#ecbb1c" fill-opacity="0.55"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff1b0"/><stop offset="1" stop-color="#e0a82e"/></linearGradient> | 3 | <path d="M2 0h1v1h-1z" fill="#eba23a"/> |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b58a3a"/><stop offset="1" stop-color="#8a6420"/></linearGradient> | 4 | <path d="M3 0h1v1h-1z" fill="#db8a16"/> |
| 5 | </defs> | 5 | <path d="M4 0h1v1h-1z" fill="#db9e34"/> |
| 6 | <path d="M3.5 0.25C5.2 0.25 5.8 1.3 5.4 3L4.4 7.25H2.6L1.6 3C1.2 1.3 1.8 0.25 3.5 0.25Z" fill="url(#layer1)"/> | 6 | <path d="M5 0h1v1h-1z" fill="#d3aa07" fill-opacity="0.62"/> |
| 7 | <path d="M3.5 1C4.6 1 4.9 1.7 4.6 3L3.8 6.5H3.2L2.4 3C2.1 1.7 2.4 1 3.5 1Z" fill="url(#layer0)"/> | 7 | <path d="M1 1h1v1h-1z" fill="#d29e43"/> |
| 8 | <path d="M3.5 8A1 1 0 1 1 3.5 10A1 1 0 1 1 3.5 8Z" fill="url(#layer1)"/> | 8 | <path d="M2 1h1v1h-1z" fill="#fffbeb"/> |
| 9 | <path d="M3 1h1v1h-1z" fill="#fdf1c6"/> | ||
| 10 | <path d="M4 1h1v1h-1z" fill="#f8da7f"/> | ||
| 11 | <path d="M5 1h1v1h-1z" fill="#a68131"/> | ||
| 12 | <path d="M1 2h1v1h-1z" fill="#cd8f24"/> | ||
| 13 | <path d="M2 2h1v1h-1z" fill="#fef4d3"/> | ||
| 14 | <path d="M3 2h1v1h-1z" fill="#fde2a2"/> | ||
| 15 | <path d="M4 2h1v1h-1z" fill="#ecca61"/> | ||
| 16 | <path d="M5 2h1v1h-1z" fill="#8c6d21"/> | ||
| 17 | <path d="M1 3h1v1h-1z" fill="#d5a01b" fill-opacity="0.89"/> | ||
| 18 | <path d="M2 3h1v1h-1z" fill="#fde6ae"/> | ||
| 19 | <path d="M3 3h1v1h-1z" fill="#fad885"/> | ||
| 20 | <path d="M4 3h1v1h-1z" fill="#d9b04e"/> | ||
| 21 | <path d="M5 3h1v1h-1z" fill="#9e7939"/> | ||
| 22 | <path d="M1 4h1v1h-1z" fill="#d4a71f" fill-opacity="0.7"/> | ||
| 23 | <path d="M2 4h1v1h-1z" fill="#d6b347"/> | ||
| 24 | <path d="M3 4h1v1h-1z" fill="#eec86b"/> | ||
| 25 | <path d="M4 4h1v1h-1z" fill="#b79238"/> | ||
| 26 | <path d="M5 4h1v1h-1z" fill="#bfa064"/> | ||
| 27 | <path d="M1 5h1v1h-1z" fill="#cea51d" fill-opacity="0.36"/> | ||
| 28 | <path d="M2 5h1v1h-1z" fill="#b28925"/> | ||
| 29 | <path d="M3 5h1v1h-1z" fill="#e1b754"/> | ||
| 30 | <path d="M4 5h1v1h-1z" fill="#94702b"/> | ||
| 31 | <path d="M5 5h1v1h-1z" fill="#dbc890"/> | ||
| 32 | <path d="M2 6h1v1h-1z" fill="#cea208" fill-opacity="0.65"/> | ||
| 33 | <path d="M3 6h1v1h-1z" fill="#8a6619"/> | ||
| 34 | <path d="M4 6h1v1h-1z" fill="#c7a976"/> | ||
| 35 | <path d="M3 7h1v1h-1z" fill="#db870e"/> | ||
| 36 | <path d="M4 7h1v1h-1z" fill="#f4cc87"/> | ||
| 37 | <path d="M2 8h1v1h-1z" fill="#d49a29"/> | ||
| 38 | <path d="M3 8h1v1h-1z" fill="#f5eac6"/> | ||
| 39 | <path d="M4 8h1v1h-1z" fill="#573406"/> | ||
| 40 | <path d="M2 9h1v1h-1z" fill="#d2ab1f" fill-opacity="0.59"/> | ||
| 41 | <path d="M3 9h1v1h-1z" fill="#603a09"/> | ||
| 42 | <path d="M4 9h1v1h-1z" fill="#cfa956"/> | ||
| 43 | <path d="M3 10h1v1h-1z" fill="#cdad40"/> | ||
| 9 | </svg> | 44 | </svg> |
crates/canvas/assets/tags/mark-flag-blue.svg+53-5| ... | @@ -1,7 +1,55 @@ | ... | @@ -1,7 +1,55 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h1v1h-1z" fill="#6c7aa0"/> |
| 3 | <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="5.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6f9ae8"/><stop offset="1" stop-color="#2450b0"/></linearGradient> | 3 | <path d="M1 0h1v1h-1z" fill="#556fa8"/> |
| 4 | </defs> | 4 | <path d="M2 0h1v1h-1zM2 7h1v1h-1z" fill="#4d7ccb" fill-opacity="0.13"/> |
| 5 | <path d="M1.4 1.2L5.4 11.6" fill="none" stroke="#2a3a6a" stroke-width="1"/> | 5 | <path d="M3 0h1v1h-1z" fill="#1c71c9" fill-opacity="0.25"/> |
| 6 | <path d="M1.5 1.4C3.5 0.4 5.2 2.2 8.8 1.2C8 2.8 8.4 3.6 9.6 4.6C6.4 5.6 4.9 4.1 2.8 5Z" fill="url(#g0)"/> | 6 | <path d="M4 0h1v1h-1z" fill="#3f5faf"/> |
| 7 | <path d="M5 0h1v1h-1z" fill="#3255ab"/> | ||
| 8 | <path d="M6 0h1v1h-1z" fill="#267cd6" fill-opacity="0.29"/> | ||
| 9 | <path d="M0 1h1v1h-1z" fill="#465786"/> | ||
| 10 | <path d="M1 1h2v1h-2z" fill="#3758ac"/> | ||
| 11 | <path d="M3 1h1v1h-1z" fill="#284dac"/> | ||
| 12 | <path d="M4 1h1v1h-1z" fill="#1b47b6"/> | ||
| 13 | <path d="M5 1h1v1h-1z" fill="#2d71ee"/> | ||
| 14 | <path d="M6 1h1v1h-1z" fill="#1c3fa5"/> | ||
| 15 | <path d="M7 1h1v1h-1z" fill="#0f3296"/> | ||
| 16 | <path d="M8 1h1v1h-1z" fill="#072f8a"/> | ||
| 17 | <path d="M9 1h1v1h-1z" fill="#073084"/> | ||
| 18 | <path d="M0 2h1v1h-1z" fill="#2777a6" fill-opacity="0.47"/> | ||
| 19 | <path d="M1 2h1v1h-1z" fill="#495d90"/> | ||
| 20 | <path d="M2 2h1v1h-1zM6 2h1v1h-1z" fill="#3074f1"/> | ||
| 21 | <path d="M3 2h1v1h-1z" fill="#3478f5"/> | ||
| 22 | <path d="M4 2h1v1h-1z" fill="#1139a0"/> | ||
| 23 | <path d="M5 2h1v1h-1z" fill="#105ae1"/> | ||
| 24 | <path d="M7 2h1v1h-1z" fill="#2353b7"/> | ||
| 25 | <path d="M8 2h1v1h-1zM7 3h1v1h-1z" fill="#02336b"/> | ||
| 26 | <path d="M9 2h1v1h-1z" fill="#2780b7" fill-opacity="0.47"/> | ||
| 27 | <path d="M1 3h1v1h-1zM2 5h1v1h-1z" fill="#425485"/> | ||
| 28 | <path d="M2 3h1v1h-1z" fill="#336bb0"/> | ||
| 29 | <path d="M3 3h1v1h-1z" fill="#0e57eb"/> | ||
| 30 | <path d="M4 3h1v1h-1z" fill="#1c4dbd"/> | ||
| 31 | <path d="M5 3h1v1h-1z" fill="#0f52cc"/> | ||
| 32 | <path d="M6 3h1v1h-1z" fill="#0e4ac5"/> | ||
| 33 | <path d="M8 3h1v1h-1z" fill="#7288a1"/> | ||
| 34 | <path d="M1 4h1v1h-1z" fill="#084c94" fill-opacity="0.61"/> | ||
| 35 | <path d="M2 4h1v1h-1zM3 5h1v1h-1z" fill="#0d4386"/> | ||
| 36 | <path d="M3 4h1v1h-1z" fill="#084fef"/> | ||
| 37 | <path d="M4 4h1v1h-1z" fill="#2257ba"/> | ||
| 38 | <path d="M5 4h1v1h-1z" fill="#043475"/> | ||
| 39 | <path d="M6 4h1v1h-1z" fill="#0a3b77"/> | ||
| 40 | <path d="M7 4h1v1h-1z" fill="#ccd5e4"/> | ||
| 41 | <path d="M1 5h1v1h-1z" fill="#5e5ec4" fill-opacity="0.02"/> | ||
| 42 | <path d="M4 5h1v1h-1z" fill="#073870"/> | ||
| 43 | <path d="M5 5h1v1h-1z" fill="#8ea3bb"/> | ||
| 44 | <path d="M2 6h1v1h-1z" fill="#8c94b0"/> | ||
| 45 | <path d="M3 6h1v1h-1z" fill="#3d4c7a"/> | ||
| 46 | <path d="M4 6h1v1h-1z" fill="#c8d0e2"/> | ||
| 47 | <path d="M3 7h1v1h-1z" fill="#29457a"/> | ||
| 48 | <path d="M4 7h1v1h-1z" fill="#95a2be"/> | ||
| 49 | <path d="M3 8h1v1h-1z" fill="#576d90"/> | ||
| 50 | <path d="M4 8h1v1h-1z" fill="#465d8d"/> | ||
| 51 | <path d="M3 9h1v1h-1z" fill="#8193a4"/> | ||
| 52 | <path d="M4 9h1v1h-1z" fill="#264178"/> | ||
| 53 | <path d="M5 9h1v1h-1z" fill="#bfcad1"/> | ||
| 54 | <path d="M4 10h1v1h-1z" fill="#f2f4f9"/> | ||
| 7 | </svg> | 55 | </svg> |
crates/canvas/assets/tags/mark-flag-green.svg+56-5| ... | @@ -1,7 +1,58 @@ | ... | @@ -1,7 +1,58 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h1v1h-1z" fill="#6c7aa0"/> |
| 3 | <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="5.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7fe07a"/><stop offset="1" stop-color="#2a9a25"/></linearGradient> | 3 | <path d="M1 0h1v1h-1z" fill="#5f9e68"/> |
| 4 | </defs> | 4 | <path d="M2 0h1v1h-1z" fill="#44a344" fill-opacity="0.11"/> |
| 5 | <path d="M1.4 1.2L5.4 11.6" fill="none" stroke="#2a3a6a" stroke-width="1"/> | 5 | <path d="M3 0h1v1h-1z" fill="#2cb156" fill-opacity="0.23"/> |
| 6 | <path d="M1.5 1.4C3.5 0.4 5.2 2.2 8.8 1.2C8 2.8 8.4 3.6 9.6 4.6C6.4 5.6 4.9 4.1 2.8 5Z" fill="url(#g0)"/> | 6 | <path d="M4 0h1v1h-1z" fill="#4ea15a"/> |
| 7 | <path d="M5 0h1v1h-1z" fill="#419d4e"/> | ||
| 8 | <path d="M6 0h1v1h-1z" fill="#30c452" fill-opacity="0.27"/> | ||
| 9 | <path d="M0 1h1v1h-1z" fill="#465786"/> | ||
| 10 | <path d="M1 1h1v1h-1z" fill="#7a9774"/> | ||
| 11 | <path d="M2 1h1v1h-1z" fill="#469f53"/> | ||
| 12 | <path d="M3 1h1v1h-1z" fill="#389c46"/> | ||
| 13 | <path d="M4 1h1v1h-1z" fill="#2ea33f"/> | ||
| 14 | <path d="M5 1h1v1h-1z" fill="#45d64f"/> | ||
| 15 | <path d="M6 1h1v1h-1z" fill="#2d9440"/> | ||
| 16 | <path d="M7 1h1v1h-1z" fill="#208533"/> | ||
| 17 | <path d="M8 1h1v1h-1z" fill="#177a25"/> | ||
| 18 | <path d="M9 1h1v1h-1z" fill="#177420"/> | ||
| 19 | <path d="M0 2h1v1h-1z" fill="#1d68a9" fill-opacity="0.46"/> | ||
| 20 | <path d="M1 2h1v1h-1z" fill="#495d90"/> | ||
| 21 | <path d="M2 2h1v1h-1zM6 2h1v1h-1z" fill="#48d952"/> | ||
| 22 | <path d="M3 2h1v1h-1z" fill="#4cdd56"/> | ||
| 23 | <path d="M4 2h1v1h-1z" fill="#228f32"/> | ||
| 24 | <path d="M5 2h1v1h-1z" fill="#2ac735"/> | ||
| 25 | <path d="M7 2h1v1h-1z" fill="#36a440"/> | ||
| 26 | <path d="M8 2h1v1h-1zM7 3h1v1h-1z" fill="#125e0f"/> | ||
| 27 | <path d="M9 2h1v1h-1z" fill="#24a13e" fill-opacity="0.43"/> | ||
| 28 | <path d="M1 3h1v1h-1zM2 5h1v1h-1z" fill="#425485"/> | ||
| 29 | <path d="M2 3h1v1h-1z" fill="#45a043"/> | ||
| 30 | <path d="M3 3h1v1h-1z" fill="#29d039"/> | ||
| 31 | <path d="M4 3h1v1h-1z" fill="#30a941"/> | ||
| 32 | <path d="M5 3h1v1h-1z" fill="#26b530"/> | ||
| 33 | <path d="M6 3h1v1h-1z" fill="#25af32"/> | ||
| 34 | <path d="M8 3h1v1h-1z" fill="#75958c"/> | ||
| 35 | <path d="M1 4h1v1h-1z" fill="#084c94" fill-opacity="0.61"/> | ||
| 36 | <path d="M2 4h1v1h-1z" fill="#3b4e80"/> | ||
| 37 | <path d="M3 4h1v1h-1z" fill="#24d33d"/> | ||
| 38 | <path d="M4 4h1v1h-1z" fill="#35a73c"/> | ||
| 39 | <path d="M5 4h1v1h-1z" fill="#126714"/> | ||
| 40 | <path d="M6 4h1v1h-1z" fill="#186916"/> | ||
| 41 | <path d="M7 4h1v1h-1z" fill="#cfdfd2"/> | ||
| 42 | <path d="M1 5h1v1h-1z" fill="#5e5ec4" fill-opacity="0.02"/> | ||
| 43 | <path d="M3 5h1v1h-1z" fill="#1e771c"/> | ||
| 44 | <path d="M4 5h1v1h-1z" fill="#186314"/> | ||
| 45 | <path d="M5 5h1v1h-1z" fill="#93b49c"/> | ||
| 46 | <path d="M2 6h1v1h-1z" fill="#8c94b0"/> | ||
| 47 | <path d="M3 6h1v1h-1z" fill="#3d4c7a"/> | ||
| 48 | <path d="M4 6h1v1h-1z" fill="#cbded0"/> | ||
| 49 | <path d="M2 7h1v1h-1z" fill="#49679f" fill-opacity="0.12"/> | ||
| 50 | <path d="M3 7h1v1h-1z" fill="#29457a"/> | ||
| 51 | <path d="M4 7h1v1h-1z" fill="#95a2be"/> | ||
| 52 | <path d="M3 8h1v1h-1z" fill="#576d90"/> | ||
| 53 | <path d="M4 8h1v1h-1z" fill="#465d8d"/> | ||
| 54 | <path d="M3 9h1v1h-1z" fill="#8193a4"/> | ||
| 55 | <path d="M4 9h1v1h-1z" fill="#264178"/> | ||
| 56 | <path d="M5 9h1v1h-1z" fill="#bfcad1"/> | ||
| 57 | <path d="M4 10h1v1h-1z" fill="#f2f4f7"/> | ||
| 7 | </svg> | 58 | </svg> |
crates/canvas/assets/tags/mark-flag-yellow.svg+55-5| ... | @@ -1,7 +1,57 @@ | ... | @@ -1,7 +1,57 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h1v1h-1z" fill="#6c7aa0"/> |
| 3 | <linearGradient id="g0" x1="0" y1="0.5" x2="0" y2="5.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffd27a"/><stop offset="1" stop-color="#e08a1a"/></linearGradient> | 3 | <path d="M1 0h1v1h-1z" fill="#a88d55"/> |
| 4 | </defs> | 4 | <path d="M2 0h1v1h-1z" fill="#dbad49" fill-opacity="0.12"/> |
| 5 | <path d="M1.4 1.2L5.4 11.6" fill="none" stroke="#2a3a6a" stroke-width="1"/> | 5 | <path d="M3 0h1v1h-1z" fill="#c1a907" fill-opacity="0.24"/> |
| 6 | <path d="M1.5 1.4C3.5 0.4 5.2 2.2 8.8 1.2C8 2.8 8.4 3.6 9.6 4.6C6.4 5.6 4.9 4.1 2.8 5Z" fill="url(#g0)"/> | 6 | <path d="M4 0h1v1h-1z" fill="#ae8b40"/> |
| 7 | <path d="M5 0h1v1h-1z" fill="#ab8532"/> | ||
| 8 | <path d="M6 0h1v1h-1z" fill="#d2aa06" fill-opacity="0.27"/> | ||
| 9 | <path d="M0 1h1v1h-1z" fill="#465786"/> | ||
| 10 | <path d="M1 1h1v1h-1z" fill="#9d816e"/> | ||
| 11 | <path d="M2 1h1v1h-1z" fill="#ad8839"/> | ||
| 12 | <path d="M3 1h1v1h-1z" fill="#ac8228"/> | ||
| 13 | <path d="M4 1h1v1h-1z" fill="#b6851b"/> | ||
| 14 | <path d="M5 1h1v1h-1z" fill="#eea82d"/> | ||
| 15 | <path d="M6 1h1v1h-1z" fill="#a5801c"/> | ||
| 16 | <path d="M7 1h1v1h-1z" fill="#96720f"/> | ||
| 17 | <path d="M8 1h1v1h-1z" fill="#8a6107"/> | ||
| 18 | <path d="M9 1h1v1h-1z" fill="#845607"/> | ||
| 19 | <path d="M0 2h1v1h-1z" fill="#1d68a9" fill-opacity="0.46"/> | ||
| 20 | <path d="M1 2h1v1h-1z" fill="#495d90"/> | ||
| 21 | <path d="M2 2h1v1h-1zM6 2h1v1h-1z" fill="#f1ab30"/> | ||
| 22 | <path d="M3 2h1v1h-1z" fill="#f5af34"/> | ||
| 23 | <path d="M4 2h1v1h-1z" fill="#a07311"/> | ||
| 24 | <path d="M5 2h1v1h-1z" fill="#e19510"/> | ||
| 25 | <path d="M7 2h1v1h-1zM4 4h1v1h-1z" fill="#b78123"/> | ||
| 26 | <path d="M8 2h1v1h-1zM7 3h1v1h-1z" fill="#6b3802"/> | ||
| 27 | <path d="M9 2h1v1h-1z" fill="#b39225" fill-opacity="0.45"/> | ||
| 28 | <path d="M1 3h1v1h-1zM2 5h1v1h-1z" fill="#425485"/> | ||
| 29 | <path d="M2 3h1v1h-1z" fill="#b07433"/> | ||
| 30 | <path d="M3 3h1v1h-1z" fill="#eb9a0e"/> | ||
| 31 | <path d="M4 3h1v1h-1z" fill="#bd8a1c"/> | ||
| 32 | <path d="M5 3h1v1h-1z" fill="#cc870f"/> | ||
| 33 | <path d="M6 3h1v1h-1z" fill="#c5830f"/> | ||
| 34 | <path d="M8 3h1v1h-1z" fill="#8b8e87"/> | ||
| 35 | <path d="M1 4h1v1h-1z" fill="#084c94" fill-opacity="0.61"/> | ||
| 36 | <path d="M2 4h1v1h-1z" fill="#564c70"/> | ||
| 37 | <path d="M3 4h1v1h-1z" fill="#efa608"/> | ||
| 38 | <path d="M5 4h1v1h-1z" fill="#754404"/> | ||
| 39 | <path d="M6 4h1v1h-1z" fill="#77410a"/> | ||
| 40 | <path d="M7 4h1v1h-1z" fill="#e1dacf"/> | ||
| 41 | <path d="M1 5h1v1h-1z" fill="#5e5ec4" fill-opacity="0.02"/> | ||
| 42 | <path d="M3 5h1v1h-1z" fill="#864c0d"/> | ||
| 43 | <path d="M4 5h1v1h-1z" fill="#703d07"/> | ||
| 44 | <path d="M5 5h1v1h-1z" fill="#b2a997"/> | ||
| 45 | <path d="M2 6h1v1h-1z" fill="#8c94b0"/> | ||
| 46 | <path d="M3 6h1v1h-1z" fill="#3d4c7a"/> | ||
| 47 | <path d="M4 6h1v1h-1z" fill="#e0d9ca"/> | ||
| 48 | <path d="M2 7h1v1h-1z" fill="#49679f" fill-opacity="0.12"/> | ||
| 49 | <path d="M3 7h1v1h-1z" fill="#29457a"/> | ||
| 50 | <path d="M4 7h1v1h-1z" fill="#95a2be"/> | ||
| 51 | <path d="M3 8h1v1h-1z" fill="#576d90"/> | ||
| 52 | <path d="M4 8h1v1h-1z" fill="#465d8d"/> | ||
| 53 | <path d="M3 9h1v1h-1z" fill="#8193a4"/> | ||
| 54 | <path d="M4 9h1v1h-1z" fill="#264178"/> | ||
| 55 | <path d="M5 9h1v1h-1z" fill="#bfcad1"/> | ||
| 56 | <path d="M4 10h1v1h-1z" fill="#f2f4f7"/> | ||
| 7 | </svg> | 57 | </svg> |
crates/canvas/assets/tags/mark-one-blue.svg+4-5| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#6888b9"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5a8ad8"/><stop offset="1" stop-color="#1e4494"/></linearGradient> | 3 | <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#2e5898"/> |
| 4 | </defs> | 4 | <path d="M1 1h6v1h-6zM1 2h3v1h-3zM5 2h2v6h-2zM1 3h2v5h-2zM3 4h1v4h-1zM4 7h1v1h-1z" fill="#4974ba"/> |
| 5 | <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/> | 5 | <path d="M4 2h1v5h-1zM3 3h1v1h-1z" fill="#ffffff"/> |
| 6 | <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/> | ||
| 7 | </svg> | 6 | </svg> |
crates/canvas/assets/tags/mark-one-green.svg+4-5| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#1e8d26"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62b556"/><stop offset="1" stop-color="#237a1c"/></linearGradient> | 3 | <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#006100"/> |
| 4 | </defs> | 4 | <path d="M1 1h6v1h-6zM1 2h3v1h-3zM5 2h2v6h-2zM1 3h2v5h-2zM3 4h1v4h-1zM4 7h1v1h-1z" fill="#23a92d"/> |
| 5 | <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/> | 5 | <path d="M4 2h1v5h-1zM3 3h1v1h-1z" fill="#ffffff"/> |
| 6 | <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/> | ||
| 7 | </svg> | 6 | </svg> |
crates/canvas/assets/tags/mark-one-yellow.svg+4-5| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#c19b00"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8b84a"/><stop offset="1" stop-color="#a8781a"/></linearGradient> | 3 | <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#a88405"/> |
| 4 | </defs> | 4 | <path d="M1 1h6v1h-6zM1 2h3v1h-3zM5 2h2v6h-2zM1 3h2v5h-2zM3 4h1v4h-1zM4 7h1v1h-1z" fill="#caa100"/> |
| 5 | <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/> | 5 | <path d="M4 2h1v5h-1zM3 3h1v1h-1z" fill="#ffffff"/> |
| 6 | <path d="M3.25 2.1L4.5 1.6V7.25H3.25V3.3L2.3 3.7V2.6Z" fill="#ffffff"/> | ||
| 7 | </svg> | 6 | </svg> |
crates/canvas/assets/tags/mark-person-blue.svg+62-7| ... | @@ -1,9 +1,64 @@ | ... | @@ -1,9 +1,64 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h1v1h-1z" fill="#7b7b7b" fill-opacity="0.14"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa2ae"/><stop offset="1" stop-color="#4a525e"/></linearGradient> | 3 | <path d="M1 0h1v1h-1z" fill="#6f6f6f" fill-opacity="0.62"/> |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6f9ae0"/><stop offset="1" stop-color="#1e4494"/></linearGradient> | 4 | <path d="M2 0h1v1h-1zM4 1h1v1h-1z" fill="#8d8d8d"/> |
| 5 | </defs> | 5 | <path d="M3 0h1v1h-1z" fill="#888888"/> |
| 6 | <path d="M4 0.5A2.5 2.5 0 1 1 4 5.5A2.5 2.5 0 1 1 4 0.5Z" fill="url(#layer0)"/> | 6 | <path d="M4 0h1v1h-1zM6 2h1v1h-1zM5 4h1v1h-1zM4 5h1v1h-1z" fill="#858585"/> |
| 7 | <path d="M4 5.5C6.4 5.5 7.75 6.5 7.75 8.5V10H0.25V8.5C0.25 6.5 1.6 5.5 4 5.5Z" fill="url(#layer1)"/> | 7 | <path d="M5 0h1v1h-1z" fill="#565656" fill-opacity="0.6"/> |
| 8 | <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/> | 8 | <path d="M6 0h1v1h-1z" fill="#686868" fill-opacity="0.13"/> |
| 9 | <path d="M0 1h1v1h-1z" fill="#666666" fill-opacity="0.51"/> | ||
| 10 | <path d="M1 1h1v1h-1zM3 2h1v1h-1z" fill="#6c6c6c"/> | ||
| 11 | <path d="M2 1h1v1h-1z" fill="#828282"/> | ||
| 12 | <path d="M3 1h1v1h-1zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v1h-1z" fill="#a2a2a2"/> | ||
| 13 | <path d="M5 1h1v1h-1zM0 3h1v1h-1zM5 5h1v1h-1zM3 6h1v1h-1z" fill="#555555"/> | ||
| 14 | <path d="M6 1h1v1h-1z" fill="#5b5b5b" fill-opacity="0.65"/> | ||
| 15 | <path d="M0 2h1v1h-1z" fill="#7e7e7e"/> | ||
| 16 | <path d="M1 2h1v1h-1zM6 4h1v1h-1zM4 6h1v1h-1z" fill="#636363"/> | ||
| 17 | <path d="M2 2h1v1h-1zM2 6h1v1h-1z" fill="#5d5d5d"/> | ||
| 18 | <path d="M4 2h1v1h-1z" fill="#585858"/> | ||
| 19 | <path d="M5 2h1v1h-1zM4 3h1v1h-1zM3 4h1v1h-1zM2 5h1v1h-1z" fill="#c1c1c1"/> | ||
| 20 | <path d="M1 3h1v1h-1z" fill="#494949"/> | ||
| 21 | <path d="M2 3h1v1h-1z" fill="#b4b4b4"/> | ||
| 22 | <path d="M3 3h1v1h-1zM2 4h1v1h-1z" fill="#d5d5d5"/> | ||
| 23 | <path d="M6 3h1v1h-1z" fill="#757575"/> | ||
| 24 | <path d="M0 4h1v1h-1z" fill="#383838"/> | ||
| 25 | <path d="M1 4h1v1h-1z" fill="#2c2c2c"/> | ||
| 26 | <path d="M0 5h1v1h-1z" fill="#252525" fill-opacity="0.45"/> | ||
| 27 | <path d="M1 5h1v1h-1z" fill="#000000"/> | ||
| 28 | <path d="M6 5h1v1h-1z" fill="#7f8284"/> | ||
| 29 | <path d="M0 6h1v1h-1z" fill="#636363" fill-opacity="0.07"/> | ||
| 30 | <path d="M1 6h1v1h-1z" fill="#b9bbc6"/> | ||
| 31 | <path d="M5 6h1v1h-1z" fill="#3246ac"/> | ||
| 32 | <path d="M6 6h1v1h-1z" fill="#b4b8cb"/> | ||
| 33 | <path d="M13 6h1v8h-1zM5 13h8v1h-8z" fill="#ccd5d9"/> | ||
| 34 | <path d="M0 7h1v1h-1z" fill="#6b6b6b" fill-opacity="0.4"/> | ||
| 35 | <path d="M1 7h1v1h-1z" fill="#6c7bc9"/> | ||
| 36 | <path d="M2 7h1v1h-1z" fill="#596bc7"/> | ||
| 37 | <path d="M3 7h1v1h-1z" fill="#f3f5f2"/> | ||
| 38 | <path d="M4 7h1v1h-1z" fill="#f8f8f8"/> | ||
| 39 | <path d="M5 7h1v2h-1zM4 9h1v1h-1z" fill="#485bbb"/> | ||
| 40 | <path d="M6 7h1v1h-1z" fill="#3548ad"/> | ||
| 41 | <path d="M7 7h1v1h-1z" fill="#e9e9e9"/> | ||
| 42 | <path d="M0 8h1v2h-1z" fill="#6d7dd4"/> | ||
| 43 | <path d="M1 8h1v1h-1z" fill="#a3afea"/> | ||
| 44 | <path d="M2 8h1v3h-1zM3 9h1v2h-1z" fill="#889af8"/> | ||
| 45 | <path d="M3 8h1v1h-1z" fill="#8191e4"/> | ||
| 46 | <path d="M4 8h1v1h-1z" fill="#afbaee"/> | ||
| 47 | <path d="M6 8h1v1h-1zM5 10h1v1h-1z" fill="#3a56e7"/> | ||
| 48 | <path d="M7 8h1v1h-1z" fill="#2236a4"/> | ||
| 49 | <path d="M1 9h1v1h-1z" fill="#8797f3"/> | ||
| 50 | <path d="M5 9h1v1h-1z" fill="#3855ea"/> | ||
| 51 | <path d="M6 9h1v1h-1z" fill="#324fdb"/> | ||
| 52 | <path d="M7 9h1v2h-1z" fill="#102490"/> | ||
| 53 | <path d="M0 10h1v1h-1z" fill="#6a7bd3"/> | ||
| 54 | <path d="M1 10h1v1h-1z" fill="#7b8deb"/> | ||
| 55 | <path d="M4 10h1v1h-1zM3 11h1v1h-1z" fill="#4354b4"/> | ||
| 56 | <path d="M6 10h1v1h-1z" fill="#253fbf"/> | ||
| 57 | <path d="M0 11h1v1h-1z" fill="#8a8a8a" fill-opacity="0.39"/> | ||
| 58 | <path d="M1 11h1v1h-1z" fill="#6374c5"/> | ||
| 59 | <path d="M2 11h1v1h-1z" fill="#4f62be"/> | ||
| 60 | <path d="M4 11h1v1h-1z" fill="#2f40a6"/> | ||
| 61 | <path d="M5 11h1v1h-1z" fill="#1c2f9d"/> | ||
| 62 | <path d="M6 11h1v1h-1z" fill="#1d2f8d"/> | ||
| 63 | <path d="M7 11h1v1h-1z" fill="#c3c8d3"/> | ||
| 9 | </svg> | 64 | </svg> |
crates/canvas/assets/tags/mark-person-green.svg+67-7| ... | @@ -1,9 +1,69 @@ | ... | @@ -1,9 +1,69 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h1v1h-1z" fill="#7b7b7b" fill-opacity="0.14"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa2ae"/><stop offset="1" stop-color="#4a525e"/></linearGradient> | 3 | <path d="M1 0h1v1h-1z" fill="#6f6f6f" fill-opacity="0.62"/> |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#86d070"/><stop offset="1" stop-color="#2e7a1e"/></linearGradient> | 4 | <path d="M2 0h1v1h-1zM4 1h1v1h-1z" fill="#8d8d8d"/> |
| 5 | </defs> | 5 | <path d="M3 0h1v1h-1z" fill="#888888"/> |
| 6 | <path d="M4 0.5A2.5 2.5 0 1 1 4 5.5A2.5 2.5 0 1 1 4 0.5Z" fill="url(#layer0)"/> | 6 | <path d="M4 0h1v1h-1zM6 2h1v1h-1zM5 4h1v1h-1zM4 5h1v1h-1z" fill="#858585"/> |
| 7 | <path d="M4 5.5C6.4 5.5 7.75 6.5 7.75 8.5V10H0.25V8.5C0.25 6.5 1.6 5.5 4 5.5Z" fill="url(#layer1)"/> | 7 | <path d="M5 0h1v1h-1z" fill="#565656" fill-opacity="0.6"/> |
| 8 | <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/> | 8 | <path d="M6 0h1v1h-1z" fill="#686868" fill-opacity="0.13"/> |
| 9 | <path d="M0 1h1v1h-1z" fill="#666666" fill-opacity="0.51"/> | ||
| 10 | <path d="M1 1h1v1h-1zM3 2h1v1h-1z" fill="#6c6c6c"/> | ||
| 11 | <path d="M2 1h1v1h-1z" fill="#828282"/> | ||
| 12 | <path d="M3 1h1v1h-1zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v1h-1z" fill="#a2a2a2"/> | ||
| 13 | <path d="M5 1h1v1h-1zM0 3h1v1h-1zM5 5h1v1h-1zM3 6h1v1h-1z" fill="#555555"/> | ||
| 14 | <path d="M6 1h1v1h-1z" fill="#5b5b5b" fill-opacity="0.65"/> | ||
| 15 | <path d="M0 2h1v1h-1z" fill="#7e7e7e"/> | ||
| 16 | <path d="M1 2h1v1h-1zM6 4h1v1h-1zM4 6h1v1h-1z" fill="#636363"/> | ||
| 17 | <path d="M2 2h1v1h-1z" fill="#5d5d5d"/> | ||
| 18 | <path d="M4 2h1v1h-1z" fill="#585858"/> | ||
| 19 | <path d="M5 2h1v1h-1zM4 3h1v1h-1zM3 4h1v1h-1zM2 5h1v1h-1z" fill="#c1c1c1"/> | ||
| 20 | <path d="M1 3h1v1h-1z" fill="#494949"/> | ||
| 21 | <path d="M2 3h1v1h-1z" fill="#b4b4b4"/> | ||
| 22 | <path d="M3 3h1v1h-1zM2 4h1v1h-1z" fill="#d5d5d5"/> | ||
| 23 | <path d="M6 3h1v1h-1z" fill="#757575"/> | ||
| 24 | <path d="M0 4h1v1h-1z" fill="#383838"/> | ||
| 25 | <path d="M1 4h1v1h-1z" fill="#2c2c2c"/> | ||
| 26 | <path d="M0 5h1v1h-1z" fill="#252525" fill-opacity="0.45"/> | ||
| 27 | <path d="M1 5h1v1h-1z" fill="#000000"/> | ||
| 28 | <path d="M6 5h1v1h-1z" fill="#868a8c"/> | ||
| 29 | <path d="M0 6h1v1h-1z" fill="#343434" fill-opacity="0.09"/> | ||
| 30 | <path d="M1 6h1v1h-1z" fill="#d3d0cd"/> | ||
| 31 | <path d="M2 6h1v1h-1z" fill="#6c6661"/> | ||
| 32 | <path d="M5 6h1v1h-1z" fill="#209417"/> | ||
| 33 | <path d="M6 6h1v1h-1z" fill="#bcc7bb"/> | ||
| 34 | <path d="M13 6h1v8h-1zM5 13h8v1h-8z" fill="#ccd5d9"/> | ||
| 35 | <path d="M0 7h1v1h-1z" fill="#2f8a24" fill-opacity="0.53"/> | ||
| 36 | <path d="M1 7h1v1h-1z" fill="#2f9b23"/> | ||
| 37 | <path d="M2 7h1v1h-1z" fill="#33a32a"/> | ||
| 38 | <path d="M3 7h1v1h-1z" fill="#c9d6cc"/> | ||
| 39 | <path d="M4 7h1v1h-1z" fill="#f4f1f8"/> | ||
| 40 | <path d="M5 7h1v2h-1z" fill="#26a518"/> | ||
| 41 | <path d="M6 7h1v1h-1zM4 11h1v1h-1z" fill="#1c7f14"/> | ||
| 42 | <path d="M7 7h1v1h-1z" fill="#d1d6d0"/> | ||
| 43 | <path d="M0 8h1v1h-1z" fill="#35ab28"/> | ||
| 44 | <path d="M1 8h1v1h-1z" fill="#90d88b"/> | ||
| 45 | <path d="M2 8h1v1h-1z" fill="#77d66d"/> | ||
| 46 | <path d="M3 8h1v1h-1z" fill="#1c8c13"/> | ||
| 47 | <path d="M4 8h1v1h-1zM6 8h1v1h-1z" fill="#3dcb2e"/> | ||
| 48 | <path d="M7 8h1v1h-1z" fill="#2c9421"/> | ||
| 49 | <path d="M0 9h1v2h-1z" fill="#30a524"/> | ||
| 50 | <path d="M1 9h1v1h-1z" fill="#7eda75"/> | ||
| 51 | <path d="M2 9h1v1h-1z" fill="#6bd760"/> | ||
| 52 | <path d="M3 9h1v1h-1z" fill="#69d65d"/> | ||
| 53 | <path d="M4 9h1v2h-1z" fill="#1b8813"/> | ||
| 54 | <path d="M5 9h1v2h-1z" fill="#38c82d"/> | ||
| 55 | <path d="M6 9h1v1h-1z" fill="#30b326"/> | ||
| 56 | <path d="M7 9h1v1h-1z" fill="#1a7310"/> | ||
| 57 | <path d="M1 10h1v1h-1z" fill="#51c348"/> | ||
| 58 | <path d="M2 10h1v1h-1z" fill="#64d259"/> | ||
| 59 | <path d="M3 10h1v1h-1z" fill="#5ccc53"/> | ||
| 60 | <path d="M6 10h1v1h-1z" fill="#27951c"/> | ||
| 61 | <path d="M7 10h1v1h-1z" fill="#297024"/> | ||
| 62 | <path d="M0 11h1v1h-1z" fill="#54854b" fill-opacity="0.39"/> | ||
| 63 | <path d="M1 11h1v1h-1z" fill="#329e2a"/> | ||
| 64 | <path d="M2 11h1v1h-1z" fill="#229a15"/> | ||
| 65 | <path d="M3 11h1v1h-1z" fill="#1f8d14"/> | ||
| 66 | <path d="M5 11h1v1h-1z" fill="#19780f"/> | ||
| 67 | <path d="M6 11h1v1h-1z" fill="#146e0d"/> | ||
| 68 | <path d="M7 11h1v1h-1z" fill="#c8d1ce"/> | ||
| 9 | </svg> | 69 | </svg> |
crates/canvas/assets/tags/mark-person-yellow.svg+69-7| ... | @@ -1,9 +1,71 @@ | ... | @@ -1,9 +1,71 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h1v1h-1z" fill="#7b7b7b" fill-opacity="0.14"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9aa2ae"/><stop offset="1" stop-color="#4a525e"/></linearGradient> | 3 | <path d="M1 0h1v1h-1z" fill="#6f6f6f" fill-opacity="0.62"/> |
| 4 | <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f5d45c"/><stop offset="1" stop-color="#b8901e"/></linearGradient> | 4 | <path d="M2 0h1v1h-1zM4 1h1v1h-1z" fill="#8d8d8d"/> |
| 5 | </defs> | 5 | <path d="M3 0h1v1h-1z" fill="#888888"/> |
| 6 | <path d="M4 0.5A2.5 2.5 0 1 1 4 5.5A2.5 2.5 0 1 1 4 0.5Z" fill="url(#layer0)"/> | 6 | <path d="M4 0h1v1h-1zM6 2h1v1h-1zM5 4h1v1h-1zM4 5h1v1h-1z" fill="#858585"/> |
| 7 | <path d="M4 5.5C6.4 5.5 7.75 6.5 7.75 8.5V10H0.25V8.5C0.25 6.5 1.6 5.5 4 5.5Z" fill="url(#layer1)"/> | 7 | <path d="M5 0h1v1h-1z" fill="#565656" fill-opacity="0.6"/> |
| 8 | <path d="M3.4 5.75H4.6L4 7.25Z" fill="#ffffff"/> | 8 | <path d="M6 0h1v1h-1z" fill="#686868" fill-opacity="0.13"/> |
| 9 | <path d="M0 1h1v1h-1z" fill="#666666" fill-opacity="0.51"/> | ||
| 10 | <path d="M1 1h1v1h-1zM3 2h1v1h-1z" fill="#6c6c6c"/> | ||
| 11 | <path d="M2 1h1v1h-1z" fill="#828282"/> | ||
| 12 | <path d="M3 1h1v1h-1zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v1h-1z" fill="#a2a2a2"/> | ||
| 13 | <path d="M5 1h1v1h-1zM0 3h1v1h-1zM5 5h1v1h-1zM3 6h1v1h-1z" fill="#555555"/> | ||
| 14 | <path d="M6 1h1v1h-1z" fill="#5b5b5b" fill-opacity="0.65"/> | ||
| 15 | <path d="M0 2h1v1h-1z" fill="#7e7e7e"/> | ||
| 16 | <path d="M1 2h1v1h-1zM6 4h1v1h-1zM4 6h1v1h-1z" fill="#636363"/> | ||
| 17 | <path d="M2 2h1v1h-1z" fill="#5d5d5d"/> | ||
| 18 | <path d="M4 2h1v1h-1z" fill="#585858"/> | ||
| 19 | <path d="M5 2h1v1h-1zM4 3h1v1h-1zM3 4h1v1h-1zM2 5h1v1h-1z" fill="#c1c1c1"/> | ||
| 20 | <path d="M1 3h1v1h-1z" fill="#494949"/> | ||
| 21 | <path d="M2 3h1v1h-1z" fill="#b4b4b4"/> | ||
| 22 | <path d="M3 3h1v1h-1zM2 4h1v1h-1z" fill="#d5d5d5"/> | ||
| 23 | <path d="M6 3h1v1h-1z" fill="#757575"/> | ||
| 24 | <path d="M0 4h1v1h-1z" fill="#383838"/> | ||
| 25 | <path d="M1 4h1v1h-1z" fill="#2c2c2c"/> | ||
| 26 | <path d="M0 5h1v1h-1z" fill="#252525" fill-opacity="0.45"/> | ||
| 27 | <path d="M1 5h1v1h-1z" fill="#000000"/> | ||
| 28 | <path d="M6 5h1v1h-1z" fill="#868a8c"/> | ||
| 29 | <path d="M0 6h1v1h-1z" fill="#343434" fill-opacity="0.09"/> | ||
| 30 | <path d="M1 6h1v1h-1z" fill="#d3d0cd"/> | ||
| 31 | <path d="M2 6h1v1h-1z" fill="#6c6661"/> | ||
| 32 | <path d="M5 6h1v1h-1z" fill="#a59806"/> | ||
| 33 | <path d="M6 6h1v1h-1z" fill="#c9c7b9"/> | ||
| 34 | <path d="M13 6h1v8h-1zM5 13h8v1h-8z" fill="#ccd5d9"/> | ||
| 35 | <path d="M0 7h1v1h-1z" fill="#968d0c" fill-opacity="0.54"/> | ||
| 36 | <path d="M1 7h1v1h-1z" fill="#ab9813"/> | ||
| 37 | <path d="M2 7h1v1h-1z" fill="#b3a71a"/> | ||
| 38 | <path d="M3 7h1v1h-1z" fill="#d4d8c7"/> | ||
| 39 | <path d="M4 7h1v1h-1z" fill="#f0f6f9"/> | ||
| 40 | <path d="M5 7h1v2h-1z" fill="#b8a105"/> | ||
| 41 | <path d="M6 7h1v1h-1z" fill="#8d8206"/> | ||
| 42 | <path d="M7 7h1v1h-1z" fill="#d7d6cf"/> | ||
| 43 | <path d="M0 8h1v1h-1z" fill="#bbae18"/> | ||
| 44 | <path d="M1 8h1v1h-1z" fill="#e3db80"/> | ||
| 45 | <path d="M2 8h1v1h-1z" fill="#e4d35f"/> | ||
| 46 | <path d="M3 8h1v1h-1z" fill="#9c9003"/> | ||
| 47 | <path d="M4 8h1v1h-1z" fill="#e1c718"/> | ||
| 48 | <path d="M6 8h1v1h-1z" fill="#dec519"/> | ||
| 49 | <path d="M7 8h1v1h-1z" fill="#a39012"/> | ||
| 50 | <path d="M0 9h1v2h-1z" fill="#b6a113"/> | ||
| 51 | <path d="M1 9h1v1h-1z" fill="#e8d867"/> | ||
| 52 | <path d="M2 9h1v1h-1z" fill="#e7d450"/> | ||
| 53 | <path d="M3 9h1v1h-1z" fill="#e6d24d"/> | ||
| 54 | <path d="M4 9h1v2h-1z" fill="#988c03"/> | ||
| 55 | <path d="M5 9h1v2h-1z" fill="#ddcd18"/> | ||
| 56 | <path d="M6 9h1v1h-1z" fill="#c6b713"/> | ||
| 57 | <path d="M7 9h1v1h-1z" fill="#807003"/> | ||
| 58 | <path d="M1 10h1v1h-1z" fill="#d4c737"/> | ||
| 59 | <path d="M2 10h1v1h-1z" fill="#e3cf48"/> | ||
| 60 | <path d="M3 10h1v1h-1z" fill="#ddd042"/> | ||
| 61 | <path d="M6 10h1v1h-1z" fill="#a6920b"/> | ||
| 62 | <path d="M7 10h1v1h-1z" fill="#7a721a"/> | ||
| 63 | <path d="M0 11h1v1h-1z" fill="#9a9269" fill-opacity="0.43"/> | ||
| 64 | <path d="M1 11h1v1h-1z" fill="#aea21a"/> | ||
| 65 | <path d="M2 11h1v1h-1z" fill="#ac9603"/> | ||
| 66 | <path d="M3 11h1v1h-1z" fill="#9e8a03"/> | ||
| 67 | <path d="M4 11h1v1h-1z" fill="#8e7c03"/> | ||
| 68 | <path d="M5 11h1v1h-1z" fill="#867501"/> | ||
| 69 | <path d="M6 11h1v1h-1z" fill="#7c7100"/> | ||
| 70 | <path d="M7 11h1v1h-1z" fill="#cfd1cd"/> | ||
| 9 | </svg> | 71 | </svg> |
crates/canvas/assets/tags/mark-star-blue.svg+57-5| ... | @@ -1,7 +1,59 @@ | ... | @@ -1,7 +1,59 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M4 0h2v1h-2z" fill="#6693c3" fill-opacity="0.37"/> |
| 3 | <linearGradient id="g0" x1="0" y1="0" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d3e1fa"/><stop offset="1" stop-color="#8eaee9"/></linearGradient> | 3 | <path d="M4 1h1v1h-1z" fill="#738eb9"/> |
| 4 | </defs> | 4 | <path d="M5 1h1v1h-1z" fill="#6d8bbc"/> |
| 5 | <path d="M4.4 0.1L5.72 2.78L8.68 3.21L6.54 5.3L7.05 8.24L4.4 6.85L1.75 8.24L2.26 5.3L0.12 3.21L3.08 2.78Z" fill="#3d62ad"/> | 5 | <path d="M3 2h1v1h-1z" fill="#0475b5" fill-opacity="0.39"/> |
| 6 | <path d="M4.4 1.2L5.43 3.28L7.73 3.62L6.06 5.24L6.46 7.53L4.4 6.45L2.34 7.53L2.74 5.24L1.07 3.62L3.37 3.28Z" fill="url(#g0)"/> | 6 | <path d="M4 2h1v1h-1z" fill="#6888b9"/> |
| 7 | <path d="M5 2h1v1h-1z" fill="#5d7eb2"/> | ||
| 8 | <path d="M6 2h1v1h-1zM1 6h1v1h-1z" fill="#1d79b5" fill-opacity="0.36"/> | ||
| 9 | <path d="M0 3h1v1h-1z" fill="#338ac1" fill-opacity="0.15"/> | ||
| 10 | <path d="M1 3h1v1h-1zM2 10h1v1h-1z" fill="#2f7db9" fill-opacity="0.33"/> | ||
| 11 | <path d="M2 3h1v1h-1z" fill="#1974b8" fill-opacity="0.52"/> | ||
| 12 | <path d="M3 3h1v1h-1z" fill="#486b9b"/> | ||
| 13 | <path d="M4 3h2v1h-2z" fill="#88a5d5"/> | ||
| 14 | <path d="M6 3h1v1h-1z" fill="#4f709d"/> | ||
| 15 | <path d="M7 3h1v1h-1z" fill="#7188a4"/> | ||
| 16 | <path d="M8 3h1v1h-1z" fill="#7c91a7"/> | ||
| 17 | <path d="M9 3h1v1h-1z" fill="#8799aa"/> | ||
| 18 | <path d="M0 4h1v1h-1z" fill="#456ba8"/> | ||
| 19 | <path d="M1 4h1v1h-1z" fill="#4c72b0"/> | ||
| 20 | <path d="M2 4h1v1h-1z" fill="#5a7ebc"/> | ||
| 21 | <path d="M3 4h1v1h-1z" fill="#7496d1"/> | ||
| 22 | <path d="M4 4h1v1h-1z" fill="#99b6ef"/> | ||
| 23 | <path d="M5 4h1v1h-1z" fill="#96b4ed"/> | ||
| 24 | <path d="M6 4h1v1h-1z" fill="#6b8dc9"/> | ||
| 25 | <path d="M7 4h1v1h-1z" fill="#5d81be"/> | ||
| 26 | <path d="M8 4h1v1h-1z" fill="#4970ae"/> | ||
| 27 | <path d="M9 4h1v1h-1zM7 8h1v1h-1z" fill="#466dab"/> | ||
| 28 | <path d="M0 5h1v1h-1zM1 9h1v1h-1z" fill="#1d76bb" fill-opacity="0.46"/> | ||
| 29 | <path d="M1 5h1v1h-1z" fill="#2e5898"/> | ||
| 30 | <path d="M2 5h1v1h-1z" fill="#6a91d9"/> | ||
| 31 | <path d="M3 5h4v1h-4z" fill="#7ca2ec"/> | ||
| 32 | <path d="M7 5h1v1h-1z" fill="#668ed5"/> | ||
| 33 | <path d="M8 5h1v1h-1zM7 6h1v1h-1z" fill="#3760a1"/> | ||
| 34 | <path d="M9 5h1v1h-1z" fill="#a6b7c9"/> | ||
| 35 | <path d="M2 6h1v1h-1z" fill="#3f68ab"/> | ||
| 36 | <path d="M3 6h1v1h-1zM6 6h1v1h-1z" fill="#6d93dc"/> | ||
| 37 | <path d="M4 6h2v1h-2z" fill="#7297e1"/> | ||
| 38 | <path d="M8 6h1v1h-1z" fill="#dee5ef"/> | ||
| 39 | <path d="M1 7h1v1h-1z" fill="#63a4cf" fill-opacity="0.07"/> | ||
| 40 | <path d="M2 7h1v1h-1z" fill="#6987b4"/> | ||
| 41 | <path d="M3 7h3v1h-3z" fill="#8aa8e3"/> | ||
| 42 | <path d="M6 7h1v1h-1z" fill="#84a3de"/> | ||
| 43 | <path d="M7 7h1v1h-1z" fill="#6c89b5"/> | ||
| 44 | <path d="M1 8h1v1h-1z" fill="#067bba" fill-opacity="0.27"/> | ||
| 45 | <path d="M2 8h1v1h-1zM4 8h2v1h-2z" fill="#5377b0"/> | ||
| 46 | <path d="M3 8h1v1h-1z" fill="#7b9acd"/> | ||
| 47 | <path d="M6 8h1v1h-1z" fill="#7594c8"/> | ||
| 48 | <path d="M8 8h1v1h-1z" fill="#eaeff6"/> | ||
| 49 | <path d="M2 9h1v1h-1z" fill="#496ea7"/> | ||
| 50 | <path d="M3 9h1v1h-1z" fill="#56749e"/> | ||
| 51 | <path d="M4 9h1v1h-1z" fill="#d2dbe8"/> | ||
| 52 | <path d="M5 9h1v1h-1z" fill="#b0bfcd"/> | ||
| 53 | <path d="M6 9h1v1h-1z" fill="#8099c0"/> | ||
| 54 | <path d="M7 9h1v1h-1z" fill="#3c649f"/> | ||
| 55 | <path d="M8 9h1v1h-1z" fill="#d8e1ef"/> | ||
| 56 | <path d="M1 10h1v1h-1z" fill="#497dba" fill-opacity="0.12"/> | ||
| 57 | <path d="M7 10h1v1h-1z" fill="#d4deec"/> | ||
| 58 | <path d="M8 10h1v1h-1z" fill="#f4f8fe"/> | ||
| 7 | </svg> | 59 | </svg> |
crates/canvas/assets/tags/mark-star-green.svg+57-5| ... | @@ -1,7 +1,59 @@ | ... | @@ -1,7 +1,59 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M4 0h2v1h-2z" fill="#7fb479" fill-opacity="0.37"/> |
| 3 | <linearGradient id="g0" x1="0" y1="0" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#dcf2d0"/><stop offset="1" stop-color="#a4da8c"/></linearGradient> | 3 | <path d="M4 1h1v1h-1z" fill="#82ab7e"/> |
| 4 | </defs> | 4 | <path d="M5 1h1v1h-1z" fill="#7dad79"/> |
| 5 | <path d="M4.4 0.1L5.72 2.78L8.68 3.21L6.54 5.3L7.05 8.24L4.4 6.85L1.75 8.24L2.26 5.3L0.12 3.21L3.08 2.78Z" fill="#3f8a2c"/> | 5 | <path d="M3 2h1v1h-1z" fill="#5ca34b" fill-opacity="0.39"/> |
| 6 | <path d="M4.4 1.2L5.43 3.28L7.73 3.62L6.06 5.24L6.46 7.53L4.4 6.45L2.34 7.53L2.74 5.24L1.07 3.62L3.37 3.28Z" fill="url(#g0)"/> | 6 | <path d="M4 2h1v1h-1z" fill="#7aaa75"/> |
| 7 | <path d="M5 2h1v1h-1z" fill="#70a26b"/> | ||
| 8 | <path d="M6 2h1v1h-1z" fill="#5da54b" fill-opacity="0.36"/> | ||
| 9 | <path d="M0 3h1v1h-1z" fill="#50a650" fill-opacity="0.14"/> | ||
| 10 | <path d="M1 3h1v1h-1z" fill="#54a449" fill-opacity="0.32"/> | ||
| 11 | <path d="M2 3h1v1h-1z" fill="#55a443" fill-opacity="0.52"/> | ||
| 12 | <path d="M3 3h1v1h-1z" fill="#598a5d"/> | ||
| 13 | <path d="M4 3h2v1h-2z" fill="#99c794"/> | ||
| 14 | <path d="M6 3h1v1h-1z" fill="#5e8c64"/> | ||
| 15 | <path d="M7 3h1v1h-1z" fill="#789688"/> | ||
| 16 | <path d="M8 3h1v1h-1z" fill="#809995"/> | ||
| 17 | <path d="M9 3h1v1h-1z" fill="#899da2"/> | ||
| 18 | <path d="M0 4h1v1h-1z" fill="#5a9554"/> | ||
| 19 | <path d="M1 4h1v1h-1z" fill="#639e5d"/> | ||
| 20 | <path d="M2 4h1v1h-1zM7 4h1v1h-1z" fill="#6faa6b"/> | ||
| 21 | <path d="M3 4h1v1h-1z" fill="#87c083"/> | ||
| 22 | <path d="M4 4h2v1h-2z" fill="#a9dfa6"/> | ||
| 23 | <path d="M6 4h1v1h-1z" fill="#7fb87b"/> | ||
| 24 | <path d="M8 4h1v1h-1z" fill="#609b5a"/> | ||
| 25 | <path d="M9 4h1v1h-1zM7 8h1v1h-1zM2 9h1v1h-1z" fill="#5d9857"/> | ||
| 26 | <path d="M0 5h1v1h-1z" fill="#3ea124" fill-opacity="0.43"/> | ||
| 27 | <path d="M1 5h1v1h-1z" fill="#468540"/> | ||
| 28 | <path d="M2 5h1v1h-1z" fill="#7fc57c"/> | ||
| 29 | <path d="M3 5h4v1h-4z" fill="#92d88f"/> | ||
| 30 | <path d="M7 5h1v1h-1z" fill="#7dc178"/> | ||
| 31 | <path d="M8 5h1v1h-1zM7 6h1v1h-1z" fill="#4f8e49"/> | ||
| 32 | <path d="M9 5h1v1h-1z" fill="#acc1b4"/> | ||
| 33 | <path d="M1 6h1v1h-1z" fill="#52a23b" fill-opacity="0.35"/> | ||
| 34 | <path d="M2 6h1v1h-1z" fill="#589851"/> | ||
| 35 | <path d="M3 6h1v1h-1zM6 6h1v1h-1z" fill="#83c87f"/> | ||
| 36 | <path d="M4 6h2v1h-2z" fill="#88cd84"/> | ||
| 37 | <path d="M8 6h1v1h-1z" fill="#e2ece1"/> | ||
| 38 | <path d="M1 7h1v1h-1z" fill="#62ac62" fill-opacity="0.06"/> | ||
| 39 | <path d="M2 7h1v1h-1z" fill="#7aa675"/> | ||
| 40 | <path d="M3 7h3v1h-3z" fill="#9bd398"/> | ||
| 41 | <path d="M6 7h1v1h-1z" fill="#96ce93"/> | ||
| 42 | <path d="M7 7h1v1h-1z" fill="#7da778"/> | ||
| 43 | <path d="M1 8h1v1h-1z" fill="#5eaa52" fill-opacity="0.27"/> | ||
| 44 | <path d="M2 8h1v1h-1zM4 8h2v1h-2z" fill="#689f62"/> | ||
| 45 | <path d="M3 8h1v1h-1z" fill="#8ebe89"/> | ||
| 46 | <path d="M6 8h1v1h-1z" fill="#86b982"/> | ||
| 47 | <path d="M8 8h1v1h-1z" fill="#ecf3ef"/> | ||
| 48 | <path d="M1 9h1v1h-1z" fill="#55a746" fill-opacity="0.45"/> | ||
| 49 | <path d="M3 9h1v1h-1z" fill="#638e6b"/> | ||
| 50 | <path d="M4 9h1v1h-1z" fill="#d7e3d8"/> | ||
| 51 | <path d="M5 9h1v1h-1z" fill="#b4c6be"/> | ||
| 52 | <path d="M6 9h1v1h-1z" fill="#8eb38b"/> | ||
| 53 | <path d="M7 9h1v1h-1z" fill="#548d4c"/> | ||
| 54 | <path d="M8 9h1v1h-1z" fill="#dce8e1"/> | ||
| 55 | <path d="M1 10h1v1h-1z" fill="#67ad67" fill-opacity="0.12"/> | ||
| 56 | <path d="M2 10h1v1h-1z" fill="#60a857" fill-opacity="0.33"/> | ||
| 57 | <path d="M7 10h1v1h-1z" fill="#d8e5dc"/> | ||
| 58 | <path d="M8 10h1v1h-1z" fill="#f4f9fd"/> | ||
| 7 | </svg> | 59 | </svg> |
crates/canvas/assets/tags/mark-star-yellow.svg+55-5| ... | @@ -1,7 +1,57 @@ | ... | @@ -1,7 +1,57 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M4 0h2v1h-2z" fill="#bdad5d" fill-opacity="0.36"/> |
| 3 | <linearGradient id="g0" x1="0" y1="0" x2="0" y2="9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcf0c0"/><stop offset="1" stop-color="#f7d66e"/></linearGradient> | 3 | <path d="M4 1h1v1h-1z" fill="#b7a672"/> |
| 4 | </defs> | 4 | <path d="M5 1h1v1h-1z" fill="#b9a56b"/> |
| 5 | <path d="M4.4 0.1L5.72 2.78L8.68 3.21L6.54 5.3L7.05 8.24L4.4 6.85L1.75 8.24L2.26 5.3L0.12 3.21L3.08 2.78Z" fill="#b0801a"/> | 5 | <path d="M3 2h1v1h-1z" fill="#b29b04" fill-opacity="0.39"/> |
| 6 | <path d="M4.4 1.2L5.43 3.28L7.73 3.62L6.06 5.24L6.46 7.53L4.4 6.45L2.34 7.53L2.74 5.24L1.07 3.62L3.37 3.28Z" fill="url(#g0)"/> | 6 | <path d="M4 2h1v1h-1z" fill="#b8a467"/> |
| 7 | <path d="M5 2h1v1h-1z" fill="#b19c5c"/> | ||
| 8 | <path d="M6 2h1v1h-1zM1 6h1v1h-1z" fill="#b59c1d" fill-opacity="0.36"/> | ||
| 9 | <path d="M0 3h1v1h-1z" fill="#b8a333" fill-opacity="0.15"/> | ||
| 10 | <path d="M1 3h1v1h-1zM2 10h1v1h-1z" fill="#b5a32f" fill-opacity="0.33"/> | ||
| 11 | <path d="M2 3h1v1h-1z" fill="#b39b24" fill-opacity="0.53"/> | ||
| 12 | <path d="M3 3h1v1h-1z" fill="#918450"/> | ||
| 13 | <path d="M4 3h2v1h-2z" fill="#d4c187"/> | ||
| 14 | <path d="M6 3h1v1h-1z" fill="#918759"/> | ||
| 15 | <path d="M7 3h1v1h-1z" fill="#919382"/> | ||
| 16 | <path d="M8 3h1v1h-1z" fill="#909891"/> | ||
| 17 | <path d="M9 3h1v1h-1z" fill="#909da1"/> | ||
| 18 | <path d="M0 4h1v1h-1z" fill="#a78e45"/> | ||
| 19 | <path d="M1 4h1v1h-1z" fill="#af974c"/> | ||
| 20 | <path d="M2 4h1v1h-1zM7 4h1v1h-1z" fill="#bba35a"/> | ||
| 21 | <path d="M3 4h1v1h-1z" fill="#d0b973"/> | ||
| 22 | <path d="M4 4h2v1h-2z" fill="#eedb97"/> | ||
| 23 | <path d="M6 4h1v1h-1z" fill="#c8b16b"/> | ||
| 24 | <path d="M8 4h1v1h-1z" fill="#ad9448"/> | ||
| 25 | <path d="M9 4h1v1h-1zM7 8h1v1h-1z" fill="#a99146"/> | ||
| 26 | <path d="M0 5h1v1h-1z" fill="#b29c1d" fill-opacity="0.46"/> | ||
| 27 | <path d="M1 5h1v1h-1z" fill="#977d2e"/> | ||
| 28 | <path d="M2 5h1v1h-1z" fill="#d8bf69"/> | ||
| 29 | <path d="M3 5h4v1h-4z" fill="#ebd27c"/> | ||
| 30 | <path d="M7 5h1v1h-1z" fill="#d4b965"/> | ||
| 31 | <path d="M8 5h1v1h-1zM7 6h1v1h-1z" fill="#a08637"/> | ||
| 32 | <path d="M9 5h1v1h-1z" fill="#bebfb0"/> | ||
| 33 | <path d="M2 6h1v1h-1z" fill="#aa903f"/> | ||
| 34 | <path d="M3 6h1v1h-1zM6 6h1v1h-1z" fill="#dbc26c"/> | ||
| 35 | <path d="M4 6h2v1h-2z" fill="#e0c771"/> | ||
| 36 | <path d="M8 6h1v1h-1z" fill="#eeeade"/> | ||
| 37 | <path d="M1 7h1v1h-1z" fill="#cfbb63" fill-opacity="0.07"/> | ||
| 38 | <path d="M2 7h1v1h-1zM7 7h1v1h-1z" fill="#b2a069"/> | ||
| 39 | <path d="M3 7h3v1h-3z" fill="#e2ce89"/> | ||
| 40 | <path d="M6 7h1v1h-1z" fill="#ddc984"/> | ||
| 41 | <path d="M1 8h1v1h-1z" fill="#b59e06" fill-opacity="0.27"/> | ||
| 42 | <path d="M2 8h1v1h-1zM4 8h2v1h-2z" fill="#af9852"/> | ||
| 43 | <path d="M3 8h1v1h-1z" fill="#ccb87b"/> | ||
| 44 | <path d="M6 8h1v1h-1z" fill="#c7b274"/> | ||
| 45 | <path d="M8 8h1v1h-1z" fill="#f2f2ed"/> | ||
| 46 | <path d="M1 9h1v1h-1z" fill="#b49f33" fill-opacity="0.47"/> | ||
| 47 | <path d="M2 9h1v1h-1z" fill="#a68f49"/> | ||
| 48 | <path d="M3 9h1v1h-1z" fill="#918961"/> | ||
| 49 | <path d="M4 9h1v1h-1z" fill="#e5e1d5"/> | ||
| 50 | <path d="M5 9h1v1h-1z" fill="#c2c5bb"/> | ||
| 51 | <path d="M6 9h1v1h-1z" fill="#beaf80"/> | ||
| 52 | <path d="M7 9h1v1h-1z" fill="#9e813b"/> | ||
| 53 | <path d="M8 9h1v1h-1z" fill="#e8e7de"/> | ||
| 54 | <path d="M1 10h1v1h-1z" fill="#baad49" fill-opacity="0.12"/> | ||
| 55 | <path d="M7 10h1v1h-1z" fill="#e6e4d9"/> | ||
| 56 | <path d="M8 10h1v1h-1z" fill="#f5f8fc"/> | ||
| 7 | </svg> | 57 | </svg> |
crates/canvas/assets/tags/mark-three-blue.svg+4-5| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#6888b9"/> |
| 3 | <linearGradient id="g0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5a8ad8"/><stop offset="1" stop-color="#1e4494"/></linearGradient> | 3 | <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#2e5898"/> |
| 4 | </defs> | 4 | <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v2h-2zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#4974ba"/> |
| 5 | <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#g0)"/> | 5 | <path d="M2 2h4v1h-4zM5 3h1v4h-1zM4 4h1v1h-1zM2 6h3v1h-3z" fill="#ffffff"/> |
| 6 | <path d="M2.3 2.4C2.6 1.8 3.1 1.5 3.8 1.5C4.7 1.5 5.3 2 5.3 2.7C5.3 3.5 4.6 4 3.7 4C4.8 4 5.5 4.6 5.5 5.5C5.5 6.4 4.8 7.1 3.8 7.1C3.1 7.1 2.5 6.8 2.1 6.2" fill="none" stroke="#ffffff" stroke-width="1.1"/> | ||
| 7 | </svg> | 6 | </svg> |
crates/canvas/assets/tags/mark-three-green.svg+4-5| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#1e8d26"/> |
| 3 | <linearGradient id="g0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62b556"/><stop offset="1" stop-color="#237a1c"/></linearGradient> | 3 | <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#006100"/> |
| 4 | </defs> | 4 | <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v2h-2zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#23a92d"/> |
| 5 | <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#g0)"/> | 5 | <path d="M2 2h4v1h-4zM5 3h1v4h-1zM4 4h1v1h-1zM2 6h3v1h-3z" fill="#ffffff"/> |
| 6 | <path d="M2.3 2.4C2.6 1.8 3.1 1.5 3.8 1.5C4.7 1.5 5.3 2 5.3 2.7C5.3 3.5 4.6 4 3.7 4C4.8 4 5.5 4.6 5.5 5.5C5.5 6.4 4.8 7.1 3.8 7.1C3.1 7.1 2.5 6.8 2.1 6.2" fill="none" stroke="#ffffff" stroke-width="1.1"/> | ||
| 7 | </svg> | 6 | </svg> |
crates/canvas/assets/tags/mark-three-yellow.svg+4-5| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#c19b00"/> |
| 3 | <linearGradient id="g0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8b84a"/><stop offset="1" stop-color="#a8781a"/></linearGradient> | 3 | <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#a88405"/> |
| 4 | </defs> | 4 | <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v2h-2zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#caa100"/> |
| 5 | <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#g0)"/> | 5 | <path d="M2 2h4v1h-4zM5 3h1v4h-1zM4 4h1v1h-1zM2 6h3v1h-3z" fill="#ffffff"/> |
| 6 | <path d="M2.3 2.4C2.6 1.8 3.1 1.5 3.8 1.5C4.7 1.5 5.3 2 5.3 2.7C5.3 3.5 4.6 4 3.7 4C4.8 4 5.5 4.6 5.5 5.5C5.5 6.4 4.8 7.1 3.8 7.1C3.1 7.1 2.5 6.8 2.1 6.2" fill="none" stroke="#ffffff" stroke-width="1.1"/> | ||
| 7 | </svg> | 6 | </svg> |
crates/canvas/assets/tags/mark-two-blue.svg+4-5| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#6888b9"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5a8ad8"/><stop offset="1" stop-color="#1e4494"/></linearGradient> | 3 | <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#2e5898"/> |
| 4 | </defs> | 4 | <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v1h-2zM5 4h1v2h-1zM2 5h1v1h-1zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#4974ba"/> |
| 5 | <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/> | 5 | <path d="M2 2h4v1h-4zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v2h-1zM2 6h1v1h-1zM4 6h2v1h-2z" fill="#ffffff"/> |
| 6 | <path d="M2 2.9C2.1 1.9 2.8 1.5 3.75 1.5C4.9 1.5 5.5 2.1 5.5 3C5.5 3.9 4.9 4.5 3.6 5.7L3.3 6H5.6V7.25H1.9V6.2L3.3 4.9C4.1 4.1 4.25 3.7 4.25 3.2C4.25 2.8 4 2.6 3.7 2.6C3.35 2.6 3.2 2.85 3.15 3.2Z" fill="#ffffff"/> | ||
| 7 | </svg> | 6 | </svg> |
crates/canvas/assets/tags/mark-two-green.svg+4-5| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#1e8d26"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62b556"/><stop offset="1" stop-color="#237a1c"/></linearGradient> | 3 | <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#006100"/> |
| 4 | </defs> | 4 | <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v1h-2zM5 4h1v2h-1zM2 5h1v1h-1zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#23a92d"/> |
| 5 | <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/> | 5 | <path d="M2 2h4v1h-4zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v2h-1zM2 6h1v1h-1zM4 6h2v1h-2z" fill="#ffffff"/> |
| 6 | <path d="M2 2.9C2.1 1.9 2.8 1.5 3.75 1.5C4.9 1.5 5.5 2.1 5.5 3C5.5 3.9 4.9 4.5 3.6 5.7L3.3 6H5.6V7.25H1.9V6.2L3.3 4.9C4.1 4.1 4.25 3.7 4.25 3.2C4.25 2.8 4 2.6 3.7 2.6C3.35 2.6 3.2 2.85 3.15 3.2Z" fill="#ffffff"/> | ||
| 7 | </svg> | 6 | </svg> |
crates/canvas/assets/tags/mark-two-yellow.svg+4-5| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> |
| 2 | <defs> | 2 | <path d="M0 0h7v1h-7zM0 1h1v8h-1z" fill="#c19b00"/> |
| 3 | <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8b84a"/><stop offset="1" stop-color="#a8781a"/></linearGradient> | 3 | <path d="M7 0h1v9h-1zM1 8h6v1h-6z" fill="#a88405"/> |
| 4 | </defs> | 4 | <path d="M1 1h6v1h-6zM1 2h1v6h-1zM6 2h1v6h-1zM2 3h3v1h-3zM2 4h2v1h-2zM5 4h1v2h-1zM2 5h1v1h-1zM4 5h1v1h-1zM2 7h4v1h-4z" fill="#caa100"/> |
| 5 | <path d="M0.5 0.5H7V8.5H0.5Z" fill="url(#layer0)"/> | 5 | <path d="M2 2h4v1h-4zM5 3h1v1h-1zM4 4h1v1h-1zM3 5h1v2h-1zM2 6h1v1h-1zM4 6h2v1h-2z" fill="#ffffff"/> |
| 6 | <path d="M2 2.9C2.1 1.9 2.8 1.5 3.75 1.5C4.9 1.5 5.5 2.1 5.5 3C5.5 3.9 4.9 4.5 3.6 5.7L3.3 6H5.6V7.25H1.9V6.2L3.3 4.9C4.1 4.1 4.25 3.7 4.25 3.2C4.25 2.8 4 2.6 3.7 2.6C3.35 2.6 3.2 2.85 3.15 3.2Z" fill="#ffffff"/> | ||
| 7 | </svg> | 6 | </svg> |
crates/canvas/assets/tags/star8-green.svg created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | ||
| 2 | <path d="M8 1.2V14.8M1.2 8H14.8M3.2 3.2L12.8 12.8M12.8 3.2L3.2 12.8" fill="none" stroke="#5aa83c" stroke-width="1"/> | ||
| 3 | <path d="M6.6 8A1.4 1.4 0 1 1 9.4 8A1.4 1.4 0 1 1 6.6 8Z" fill="#3f8a2c"/> | ||
| 4 | </svg> | ||
crates/canvas/src/document.rs+2| ... | @@ -1578,6 +1578,8 @@ mod tests { | ... | @@ -1578,6 +1578,8 @@ mod tests { |
| 1578 | nodes[1].tags.push(onestore::document::Tag { | 1578 | nodes[1].tags.push(onestore::document::Tag { |
| 1579 | definition: Some(new_id().unwrap()), | 1579 | definition: Some(new_id().unwrap()), |
| 1580 | action_type: Some(0), | 1580 | action_type: Some(0), |
| 1581 | shape: None, | ||
| 1582 | property_status: None, | ||
| 1581 | status: 1, | 1583 | status: 1, |
| 1582 | created: Some(123), | 1584 | created: Some(123), |
| 1583 | completed: None, | 1585 | completed: None, |
crates/canvas/src/editor/evidence.rs+2| ... | @@ -97,6 +97,8 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor { | ... | @@ -97,6 +97,8 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor { |
| 97 | node.text_mut().unwrap().tags.push(Tag { | 97 | node.text_mut().unwrap().tags.push(Tag { |
| 98 | definition: Some(definition), | 98 | definition: Some(definition), |
| 99 | action_type: None, | 99 | action_type: None, |
| 100 | shape: None, | ||
| 101 | property_status: None, | ||
| 100 | status: u16::from(completed), | 102 | status: u16::from(completed), |
| 101 | created: Some(1), | 103 | created: Some(1), |
| 102 | completed: Some(u32::from(completed)), | 104 | completed: Some(u32::from(completed)), |
crates/canvas/src/editor/format.rs+5-1| ... | @@ -769,6 +769,8 @@ impl CanvasEditor { | ... | @@ -769,6 +769,8 @@ impl CanvasEditor { |
| 769 | Tag { | 769 | Tag { |
| 770 | definition: Some(id), | 770 | definition: Some(id), |
| 771 | action_type: None, | 771 | action_type: None, |
| 772 | shape: None, | ||
| 773 | property_status: None, | ||
| 772 | status: u16::from(!checkable), | 774 | status: u16::from(!checkable), |
| 773 | created, | 775 | created, |
| 774 | completed: if checkable { Some(0) } else { created }, | 776 | completed: if checkable { Some(0) } else { created }, |
| ... | @@ -1809,6 +1811,8 @@ mod tests { | ... | @@ -1809,6 +1811,8 @@ mod tests { |
| 1809 | nodes[2].tags.push(Tag { | 1811 | nodes[2].tags.push(Tag { |
| 1810 | definition: Some(music), | 1812 | definition: Some(music), |
| 1811 | action_type: None, | 1813 | action_type: None, |
| 1814 | shape: None, | ||
| 1815 | property_status: None, | ||
| 1812 | status: 1, | 1816 | status: 1, |
| 1813 | created: Some(1), | 1817 | created: Some(1), |
| 1814 | completed: Some(1), | 1818 | completed: Some(1), |
| ... | @@ -1938,7 +1942,7 @@ mod tests { | ... | @@ -1938,7 +1942,7 @@ mod tests { |
| 1938 | .iter() | 1942 | .iter() |
| 1939 | .map(|tag| match tag.icon { | 1943 | .map(|tag| match tag.icon { |
| 1940 | crate::outline::TagIcon::Symbol { shape, .. } => shape, | 1944 | crate::outline::TagIcon::Symbol { shape, .. } => shape, |
| 1941 | crate::outline::TagIcon::Task => unreachable!(), | 1945 | crate::outline::TagIcon::Task { .. } => unreachable!(), |
| 1942 | }) | 1946 | }) |
| 1943 | .collect::<Vec<_>>(); | 1947 | .collect::<Vec<_>>(); |
| 1944 | assert_eq!(shapes.len(), defaults.len() - 2); | 1948 | assert_eq!(shapes.len(), defaults.len() - 2); |
crates/canvas/src/gpu/mod.rs+83-7| ... | @@ -141,7 +141,6 @@ pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout { | ... | @@ -141,7 +141,6 @@ pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout { |
| 141 | } | 141 | } |
| 142 | 142 | ||
| 143 | const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg"); | 143 | const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg"); |
| 144 | const FLAG: &str = include_str!("../../assets/tags/flag.svg"); | ||
| 145 | const BOX_SMALL: &str = include_str!("../../assets/tags/box-small.svg"); | 144 | const BOX_SMALL: &str = include_str!("../../assets/tags/box-small.svg"); |
| 146 | const CHECK_SMALL: &str = include_str!("../../assets/tags/check-small.svg"); | 145 | const CHECK_SMALL: &str = include_str!("../../assets/tags/check-small.svg"); |
| 147 | const TAG: &str = include_str!("../../assets/tags/tag.svg"); | 146 | const TAG: &str = include_str!("../../assets/tags/tag.svg"); |
| ... | @@ -155,8 +154,7 @@ enum Art { | ... | @@ -155,8 +154,7 @@ enum Art { |
| 155 | } | 154 | } |
| 156 | 155 | ||
| 157 | /// The artwork of symbol `shape` of MS-ONE's NoteTagShape, as OneNote 2010 draws it on the | 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 | 157 | /// page. A shape MS-ONE does not list shows a plain tag. |
| 159 | /// plain tag. | ||
| 160 | fn art(shape: u16) -> Art { | 158 | fn art(shape: u16) -> Art { |
| 161 | use Art::{Badged, Box, Plain}; | 159 | use Art::{Badged, Box, Plain}; |
| 162 | macro_rules! tag { | 160 | macro_rules! tag { |
| ... | @@ -218,7 +216,7 @@ fn art(shape: u16) -> Art { | ... | @@ -218,7 +216,7 @@ fn art(shape: u16) -> Art { |
| 218 | 51 => Plain(tag!("circle-2-green")), | 216 | 51 => Plain(tag!("circle-2-green")), |
| 219 | 52 => Badged(tag!("mark-three-green")), | 217 | 52 => Badged(tag!("mark-three-green")), |
| 220 | 53 => Plain(tag!("circle-3-green")), | 218 | 53 => Plain(tag!("circle-3-green")), |
| 221 | 54 => Plain(tag!("star8-blue")), | 219 | 54 => Plain(tag!("star8-green")), |
| 222 | 55 => Plain(tag!("tick-green")), | 220 | 55 => Plain(tag!("tick-green")), |
| 223 | 56 => Plain(tag!("circle-green")), | 221 | 56 => Plain(tag!("circle-green")), |
| 224 | 57 => Plain(tag!("arrow-down-green")), | 222 | 57 => Plain(tag!("arrow-down-green")), |
| ... | @@ -318,8 +316,9 @@ const SYMBOLS: u16 = 143; | ... | @@ -318,8 +316,9 @@ const SYMBOLS: u16 = 143; |
| 318 | /// The tag's artwork, drawn in order. | 316 | /// The tag's artwork, drawn in order. |
| 319 | pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { | 317 | pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { |
| 320 | static SOURCES: std::sync::OnceLock<Vec<[Vec<&'static str>; 2]>> = std::sync::OnceLock::new(); | 318 | static SOURCES: std::sync::OnceLock<Vec<[Vec<&'static str>; 2]>> = std::sync::OnceLock::new(); |
| 321 | let TagIcon::Symbol { shape, checked } = icon else { | 319 | let (shape, checked) = match icon { |
| 322 | return &[FLAG]; | 320 | TagIcon::Symbol { shape, checked } => (shape, checked), |
| 321 | TagIcon::Task { shape } => (shape, false), | ||
| 323 | }; | 322 | }; |
| 324 | let sources = SOURCES.get_or_init(|| { | 323 | let sources = SOURCES.get_or_init(|| { |
| 325 | (0..=SYMBOLS) | 324 | (0..=SYMBOLS) |
| ... | @@ -350,6 +349,83 @@ mod tests { | ... | @@ -350,6 +349,83 @@ mod tests { |
| 350 | use onestore::page::text::Paragraph; | 349 | use onestore::page::text::Paragraph; |
| 351 | use std::time::Duration; | 350 | use std::time::Duration; |
| 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 | /// A page colour paints as OneNote paints it on white paper, and on dark paper as a | 429 | /// A page colour paints as OneNote paints it on white paper, and on dark paper as a |
| 354 | /// dark paper of its hue; no colour leaves the paper as it was. | 430 | /// dark paper of its hue; no colour leaves the paper as it was. |
| 355 | #[test] | 431 | #[test] |
| ... | @@ -465,7 +541,7 @@ mod tests { | ... | @@ -465,7 +541,7 @@ mod tests { |
| 465 | ] | 541 | ] |
| 466 | .into_iter() | 542 | .into_iter() |
| 467 | .map(|(shape, checked)| TagIcon::of(shape, checked).unwrap()) | 543 | .map(|(shape, checked)| TagIcon::of(shape, checked).unwrap()) |
| 468 | .chain([TagIcon::Task]) | 544 | .chain([TagIcon::Task { shape: 91 }]) |
| 469 | .collect(); | 545 | .collect(); |
| 470 | let text = TextEngine::default() | 546 | let text = TextEngine::default() |
| 471 | .layout( | 547 | .layout( |
crates/canvas/src/gpu/page.rs+2| ... | @@ -1777,6 +1777,8 @@ mod tests { | ... | @@ -1777,6 +1777,8 @@ mod tests { |
| 1777 | definition: Some(tag), | 1777 | definition: Some(tag), |
| 1778 | status: 1, | 1778 | status: 1, |
| 1779 | action_type: None, | 1779 | action_type: None, |
| 1780 | shape: None, | ||
| 1781 | property_status: None, | ||
| 1780 | created: None, | 1782 | created: None, |
| 1781 | completed: None, | 1783 | completed: None, |
| 1782 | start: None, | 1784 | start: None, |
crates/canvas/src/interaction/accessibility.rs+3-1| ... | @@ -598,7 +598,7 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32> | ... | @@ -598,7 +598,7 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32> |
| 598 | let label = match tag.icon { | 598 | let label = match tag.icon { |
| 599 | _ if !tag.label.is_empty() => &tag.label, | 599 | _ if !tag.label.is_empty() => &tag.label, |
| 600 | TagIcon::Symbol { shape, .. } => symbol_name(shape).unwrap_or("Tag"), | 600 | TagIcon::Symbol { shape, .. } => symbol_name(shape).unwrap_or("Tag"), |
| 601 | TagIcon::Task => "Outlook task", | 601 | TagIcon::Task { .. } => "Outlook task", |
| 602 | }; | 602 | }; |
| 603 | let state = match tag.icon { | 603 | let state = match tag.icon { |
| 604 | TagIcon::Symbol { checked: true, .. } => ", completed", | 604 | TagIcon::Symbol { checked: true, .. } => ", completed", |
| ... | @@ -946,6 +946,8 @@ pub(super) mod tests { | ... | @@ -946,6 +946,8 @@ pub(super) mod tests { |
| 946 | paragraphs[index].text_mut().unwrap().tags.push(Tag { | 946 | paragraphs[index].text_mut().unwrap().tags.push(Tag { |
| 947 | definition: Some(id), | 947 | definition: Some(id), |
| 948 | action_type: None, | 948 | action_type: None, |
| 949 | shape: None, | ||
| 950 | property_status: None, | ||
| 949 | status, | 951 | status, |
| 950 | created: None, | 952 | created: None, |
| 951 | completed: None, | 953 | completed: None, |
crates/canvas/src/interaction/mod.rs+31| ... | @@ -504,6 +504,37 @@ impl PageView { | ... | @@ -504,6 +504,37 @@ impl PageView { |
| 504 | self.hit_test(self.viewport.document_point(position)) | 504 | self.hit_test(self.viewport.document_point(position)) |
| 505 | } | 505 | } |
| 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 | fn set_object_focus(&mut self, focus: Option<ObjectFocus>) { | 538 | fn set_object_focus(&mut self, focus: Option<ObjectFocus>) { |
| 508 | if self.object_focus != focus { | 539 | if self.object_focus != focus { |
| 509 | self.editor.finish_composition(); | 540 | self.editor.finish_composition(); |
crates/canvas/src/outline.rs+22-7| ... | @@ -227,8 +227,8 @@ pub struct ParagraphLayout { | ... | @@ -227,8 +227,8 @@ pub struct ParagraphLayout { |
| 227 | pub enum TagIcon { | 227 | pub enum TagIcon { |
| 228 | /// Symbol `shape` of MS-ONE's NoteTagShape, `checked` only where it is a check box. | 228 | /// Symbol `shape` of MS-ONE's NoteTagShape, `checked` only where it is a check box. |
| 229 | Symbol { shape: u16, checked: bool }, | 229 | Symbol { shape: u16, checked: bool }, |
| 230 | /// An Outlook task, which OneNote 2010 flags. | 230 | /// An Outlook task, drawn with its stored follow-up flag and never checked from the page. |
| 231 | Task, | 231 | Task { shape: u16 }, |
| 232 | } | 232 | } |
| 233 | 233 | ||
| 234 | impl TagIcon { | 234 | impl TagIcon { |
| ... | @@ -887,8 +887,8 @@ impl ParagraphLayout { | ... | @@ -887,8 +887,8 @@ impl ParagraphLayout { |
| 887 | return Err(LayoutError::InvalidSpacing); | 887 | return Err(LayoutError::InvalidSpacing); |
| 888 | } | 888 | } |
| 889 | // Stored newest first; OneNote lists and paints tags oldest first. | 889 | // Stored newest first; OneNote lists and paints tags oldest first. |
| 890 | // An Outlook task tag carries no definition; a tag whose definition is missing shows | 890 | // An Outlook task tag carries no definition but its own icon; a tag whose definition |
| 891 | // nothing, as OneNote draws no icon for it. | 891 | // is missing shows nothing, as OneNote draws no icon for it. |
| 892 | let mut tag_definitions: Vec<_> = paragraph | 892 | let mut tag_definitions: Vec<_> = paragraph |
| 893 | .tags | 893 | .tags |
| 894 | .iter() | 894 | .iter() |
| ... | @@ -1065,7 +1065,9 @@ impl ParagraphLayout { | ... | @@ -1065,7 +1065,9 @@ impl ParagraphLayout { |
| 1065 | for tag in &tag_definitions { | 1065 | for tag in &tag_definitions { |
| 1066 | let (tag, shape, label) = (tag.0, tag.1, tag.2); | 1066 | let (tag, shape, label) = (tag.0, tag.1, tag.2); |
| 1067 | let icon = match shape { | 1067 | let icon = match shape { |
| 1068 | None => TagIcon::Task, | 1068 | None => TagIcon::Task { |
| 1069 | shape: tag.shape.unwrap_or(0), | ||
| 1070 | }, | ||
| 1069 | Some(shape) => match TagIcon::of(shape.unwrap_or(0), tag.status & 1 != 0) { | 1071 | Some(shape) => match TagIcon::of(shape.unwrap_or(0), tag.status & 1 != 0) { |
| 1070 | Some(icon) => icon, | 1072 | Some(icon) => icon, |
| 1071 | None => continue, | 1073 | None => continue, |
| ... | @@ -2241,6 +2243,8 @@ mod tests { | ... | @@ -2241,6 +2243,8 @@ mod tests { |
| 2241 | definition: Some(id), | 2243 | definition: Some(id), |
| 2242 | status: 3, | 2244 | status: 3, |
| 2243 | action_type: None, | 2245 | action_type: None, |
| 2246 | shape: None, | ||
| 2247 | property_status: None, | ||
| 2244 | created: None, | 2248 | created: None, |
| 2245 | completed: None, | 2249 | completed: None, |
| 2246 | start: None, | 2250 | start: None, |
| ... | @@ -2356,8 +2360,13 @@ mod tests { | ... | @@ -2356,8 +2360,13 @@ mod tests { |
| 2356 | } | 2360 | } |
| 2357 | ); | 2361 | ); |
| 2358 | let mut task = node.clone(); | 2362 | let mut task = node.clone(); |
| 2359 | task.text_mut().unwrap().tags[0].status |= 4; | 2363 | let stored = &mut task.text_mut().unwrap().tags[0]; |
| 2360 | assert_eq!(shaped(&task, &definitions).tags[0].icon, TagIcon::Task); | 2364 | stored.status |= 4; |
| 2365 | stored.shape = Some(89); | ||
| 2366 | assert_eq!( | ||
| 2367 | shaped(&task, &definitions).tags[0].icon, | ||
| 2368 | TagIcon::Task { shape: 89 } | ||
| 2369 | ); | ||
| 2361 | let mut untagged = definitions.clone(); | 2370 | let mut untagged = definitions.clone(); |
| 2362 | untagged.remove(&id); | 2371 | untagged.remove(&id); |
| 2363 | assert!(shaped(&node, &untagged).tags.is_empty()); | 2372 | assert!(shaped(&node, &untagged).tags.is_empty()); |
| ... | @@ -2403,6 +2412,8 @@ mod tests { | ... | @@ -2403,6 +2412,8 @@ mod tests { |
| 2403 | let tag = Tag { | 2412 | let tag = Tag { |
| 2404 | definition: Some(id(902)), | 2413 | definition: Some(id(902)), |
| 2405 | action_type: None, | 2414 | action_type: None, |
| 2415 | shape: None, | ||
| 2416 | property_status: None, | ||
| 2406 | status: 0, | 2417 | status: 0, |
| 2407 | created: None, | 2418 | created: None, |
| 2408 | completed: None, | 2419 | completed: None, |
| ... | @@ -2499,6 +2510,8 @@ mod tests { | ... | @@ -2499,6 +2510,8 @@ mod tests { |
| 2499 | let tag = Tag { | 2510 | let tag = Tag { |
| 2500 | definition: Some(id(902)), | 2511 | definition: Some(id(902)), |
| 2501 | action_type: None, | 2512 | action_type: None, |
| 2513 | shape: None, | ||
| 2514 | property_status: None, | ||
| 2502 | status: 0, | 2515 | status: 0, |
| 2503 | created: None, | 2516 | created: None, |
| 2504 | completed: None, | 2517 | completed: None, |
| ... | @@ -2665,6 +2678,8 @@ mod tests { | ... | @@ -2665,6 +2678,8 @@ mod tests { |
| 2665 | definition: Some(id), | 2678 | definition: Some(id), |
| 2666 | status: 1, | 2679 | status: 1, |
| 2667 | action_type: None, | 2680 | action_type: None, |
| 2681 | shape: None, | ||
| 2682 | property_status: None, | ||
| 2668 | created: None, | 2683 | created: None, |
| 2669 | completed: None, | 2684 | completed: None, |
| 2670 | start: None, | 2685 | start: None, |
crates/canvas/tests/task_tags.rs created+145| ... | @@ -0,0 +1,145 @@ | ||
| 1 | //! Outlook task tags stay read-only through the editor: Remove Tag drops one, removing a | ||
| 2 | //! normal tag beside one keeps it, and deleting a page and undoing restores them as OneNote | ||
| 3 | //! 2010 stored them (`corpus/task-tags`). | ||
| 4 | |||
| 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,7 +224,6 @@ enum AtlasKey { |
| 224 | Icon { | 224 | Icon { |
| 225 | sources: &'static [&'static str], | 225 | sources: &'static [&'static str], |
| 226 | size: u32, | 226 | size: u32, |
| 227 | phase: [u8; 2], | ||
| 228 | /// The bits of the colour `currentColor` paints. | 227 | /// The bits of the colour `currentColor` paints. |
| 229 | ink: [u32; 3], | 228 | ink: [u32; 3], |
| 230 | palette: [Option<[u32; 3]>; 3], | 229 | palette: [Option<[u32; 3]>; 3], |
| ... | @@ -1104,27 +1103,24 @@ impl Renderer { | ... | @@ -1104,27 +1103,24 @@ impl Renderer { |
| 1104 | tint: [f32; 4], | 1103 | tint: [f32; 4], |
| 1105 | palette: &Palette, | 1104 | palette: &Palette, |
| 1106 | ) -> Result<(), RenderError> { | 1105 | ) -> Result<(), RenderError> { |
| 1107 | let size = size * space.scale; | 1106 | // Whole device pixels keep a sprite's pixel-art edges crisp at every scale. |
| 1108 | let [x, y] = space.origin.map(|v| (v * 4.0).round() * 0.25); | 1107 | let size = (size * space.scale).round().max(1.0); |
| 1108 | let [x, y] = space.origin.map(f32::round); | ||
| 1109 | if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) { | 1109 | if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) { |
| 1110 | return Err(RenderError::InvalidPrimitive); | 1110 | return Err(RenderError::InvalidPrimitive); |
| 1111 | } | 1111 | } |
| 1112 | if x + size + 1.0 < 0.0 | 1112 | if x + size < 0.0 || y + size < 0.0 || x > space.size[0] as f32 || y > space.size[1] as f32 |
| 1113 | || y + size + 1.0 < 0.0 | ||
| 1114 | || x - 1.0 > space.size[0] as f32 | ||
| 1115 | || y - 1.0 > space.size[1] as f32 | ||
| 1116 | { | 1113 | { |
| 1117 | return Ok(()); | 1114 | return Ok(()); |
| 1118 | } | 1115 | } |
| 1119 | if size + 3.0 > self.atlas_side() as f32 { | 1116 | if size + 2.0 > self.atlas_side() as f32 { |
| 1120 | return Err(RenderError::AtlasFull); | 1117 | return Err(RenderError::AtlasFull); |
| 1121 | } | 1118 | } |
| 1122 | let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; | 1119 | let size = size as u32; |
| 1123 | let ink = [tint[0], tint[1], tint[2]]; | 1120 | let ink = [tint[0], tint[1], tint[2]]; |
| 1124 | let key = AtlasKey::Icon { | 1121 | let key = AtlasKey::Icon { |
| 1125 | sources, | 1122 | sources, |
| 1126 | size: size.to_bits(), | 1123 | size, |
| 1127 | phase, | ||
| 1128 | ink: ink.map(f32::to_bits), | 1124 | ink: ink.map(f32::to_bits), |
| 1129 | palette: palette.bits(), | 1125 | palette: palette.bits(), |
| 1130 | }; | 1126 | }; |
| ... | @@ -1134,20 +1130,13 @@ impl Renderer { | ... | @@ -1134,20 +1130,13 @@ impl Renderer { |
| 1134 | if self.glyphs.len() >= self.glyph_limit() { | 1130 | if self.glyphs.len() >= self.glyph_limit() { |
| 1135 | return Err(RenderError::AtlasFull); | 1131 | return Err(RenderError::AtlasFull); |
| 1136 | } | 1132 | } |
| 1137 | let glyph = self.upload(icon::rasterize(sources, size, phase, ink, palette))?; | 1133 | let glyph = self.upload(icon::rasterize(sources, size, ink, palette))?; |
| 1138 | self.glyphs.insert(key, Some(glyph)); | 1134 | self.glyphs.insert(key, Some(glyph)); |
| 1139 | glyph | 1135 | glyph |
| 1140 | }; | 1136 | }; |
| 1141 | let left = x.floor(); | ||
| 1142 | let top = y.floor(); | ||
| 1143 | self.quad( | 1137 | self.quad( |
| 1144 | space, | 1138 | space, |
| 1145 | [ | 1139 | [x, y, x + glyph.width as f32, y + glyph.height as f32], |
| 1146 | left, | ||
| 1147 | top, | ||
| 1148 | left + glyph.width as f32, | ||
| 1149 | top + glyph.height as f32, | ||
| 1150 | ], | ||
| 1151 | self.uv(glyph), | 1140 | self.uv(glyph), |
| 1152 | [1.0, 1.0, 1.0, tint[3]], | 1141 | [1.0, 1.0, 1.0, tint[3]], |
| 1153 | ) | 1142 | ) |
crates/draw/src/render/icon.rs+32-51| ... | @@ -1,9 +1,6 @@ | ... | @@ -1,9 +1,6 @@ |
| 1 | use swash::{ | 1 | use swash::{ |
| 2 | scale::image::{Content, Image}, | 2 | scale::image::{Content, Image}, |
| 3 | zeno::{ | 3 | zeno::{self, Cap, Fill, Join, Mask, PathBuilder, Placement, Point, Stroke, Style, Transform}, |
| 4 | self, Cap, Fill, Join, Mask, PathBuilder, Placement, Point, Stroke, Style, Transform, | ||
| 5 | Vector, | ||
| 6 | }, | ||
| 7 | }; | 4 | }; |
| 8 | 5 | ||
| 9 | /// Colours for an icon's slots, linear RGB: paths of `class="accent"`, `"highlight"` or | 6 | /// Colours for an icon's slots, linear RGB: paths of `class="accent"`, `"highlight"` or |
| ... | @@ -32,24 +29,17 @@ impl Palette { | ... | @@ -32,24 +29,17 @@ impl Palette { |
| 32 | } | 29 | } |
| 33 | } | 30 | } |
| 34 | 31 | ||
| 35 | /// Rasterizes 16×16 SVG sources over each other at `size` device pixels, offset by | 32 | /// Rasterizes 16×16 SVG sources over each other at a whole `size` of device pixels. Paths are filled and stroked with `#rrggbb`, `currentColor`, |
| 36 | /// `phase` quarter pixels. Paths are filled and stroked with `#rrggbb`, `currentColor`, | ||
| 37 | /// which paints the linear `ink`, or two-stop `linearGradient`s with SVG's defaults | 33 | /// which paints the linear `ink`, or two-stop `linearGradient`s with SVG's defaults |
| 38 | /// (`objectBoundingBox` unless `userSpaceOnUse`, `x1`..`y2`, stop offsets, pad spread; no | 34 | /// (`objectBoundingBox` unless `userSpaceOnUse`, `x1`..`y2`, stop offsets, pad spread; no |
| 39 | /// `gradientTransform`). `fill-opacity` and `stroke-opacity` apply, and strokes have round | 35 | /// `gradientTransform`). `fill-opacity` and `stroke-opacity` apply, and strokes have round |
| 40 | /// caps and joins. A slot's paths keep the shading among their colours: the hue turn, | 36 | /// caps and joins. A slot's paths keep the shading among their colours: the hue turn, |
| 41 | /// saturation scale and lightness shift that carry the mean of the slot's colours in a | 37 | /// saturation scale and lightness shift that carry the mean of the slot's colours in a |
| 42 | /// source onto the palette's colour apply to each of them. | 38 | /// source onto the palette's colour apply to each of them. |
| 43 | pub(crate) fn rasterize( | 39 | pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Palette) -> Image { |
| 44 | sources: &[&str], | 40 | let side = size; |
| 45 | size: f32, | 41 | let size = size as f32; |
| 46 | phase: [u8; 2], | ||
| 47 | ink: [f32; 3], | ||
| 48 | palette: &Palette, | ||
| 49 | ) -> Image { | ||
| 50 | let side = (size + 1.0).ceil() as u32; | ||
| 51 | let mut pixels = vec![[0.0_f32; 4]; (side * side) as usize]; | 42 | let mut pixels = vec![[0.0_f32; 4]; (side * side) as usize]; |
| 52 | let offset = phase.map(|quarters| f32::from(quarters) * 0.25); | ||
| 53 | for source in sources { | 43 | for source in sources { |
| 54 | let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid"); | 44 | let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid"); |
| 55 | assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16")); | 45 | assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16")); |
| ... | @@ -135,7 +125,6 @@ pub(crate) fn rasterize( | ... | @@ -135,7 +125,6 @@ pub(crate) fn rasterize( |
| 135 | let (mask, _) = Mask::new(data) | 125 | let (mask, _) = Mask::new(data) |
| 136 | .style(style) | 126 | .style(style) |
| 137 | .transform(Some(Transform::scale(size / 16.0, size / 16.0))) | 127 | .transform(Some(Transform::scale(size / 16.0, size / 16.0))) |
| 138 | .render_offset(Vector::new(offset[0], offset[1])) | ||
| 139 | .size(side, side) | 128 | .size(side, side) |
| 140 | .render(); | 129 | .render(); |
| 141 | for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { | 130 | for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { |
| ... | @@ -147,7 +136,7 @@ pub(crate) fn rasterize( | ... | @@ -147,7 +136,7 @@ pub(crate) fn rasterize( |
| 147 | let [x, y] = [index % side as usize, index / side as usize] | 136 | let [x, y] = [index % side as usize, index / side as usize] |
| 148 | .map(|device| device as f32 + 0.5) | 137 | .map(|device| device as f32 + 0.5) |
| 149 | .map(|device| device * 16.0 / size); | 138 | .map(|device| device * 16.0 / size); |
| 150 | let color = color([x - offset[0] * 16.0 / size, y - offset[1] * 16.0 / size]); | 139 | let color = color([x, y]); |
| 151 | for channel in 0..3 { | 140 | for channel in 0..3 { |
| 152 | pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha); | 141 | pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha); |
| 153 | } | 142 | } |
| ... | @@ -468,7 +457,7 @@ mod tests { | ... | @@ -468,7 +457,7 @@ mod tests { |
| 468 | /// The grey painted at a pixel of `source` rasterized at 16 pixels, and the grey a | 457 | /// The grey painted at a pixel of `source` rasterized at 16 pixels, and the grey a |
| 469 | /// black-to-white ramp paints at fraction `t`. | 458 | /// black-to-white ramp paints at fraction `t`. |
| 470 | fn grey(source: &str, x: usize, y: usize) -> u8 { | 459 | fn grey(source: &str, x: usize, y: usize) -> u8 { |
| 471 | rasterize(&[source], 16.0, [0, 0], [1.0; 3], &Palette::default()).data[(y * 17 + x) * 4] | 460 | rasterize(&[source], 16, [1.0; 3], &Palette::default()).data[(y * 16 + x) * 4] |
| 472 | } | 461 | } |
| 473 | 462 | ||
| 474 | fn ramp(t: f32) -> u8 { | 463 | fn ramp(t: f32) -> u8 { |
| ... | @@ -516,8 +505,8 @@ mod tests { | ... | @@ -516,8 +505,8 @@ mod tests { |
| 516 | // Two blues of one hue in the accent slot, a red outside it. | 505 | // Two blues of one hue in the accent slot, a red outside it. |
| 517 | const ART: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path class="accent" d="M0 0H16V4H0Z" fill="#80a0ff"/><path class="accent" d="M0 4H16V8H0Z" fill="#0040e0"/><path d="M0 8H16V12H0Z" fill="#ff0000"/><path class="accent" d="M0 12H16V16H0Z" fill="currentColor"/></svg>"##; | 506 | const ART: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path class="accent" d="M0 0H16V4H0Z" fill="#80a0ff"/><path class="accent" d="M0 4H16V8H0Z" fill="#0040e0"/><path d="M0 8H16V12H0Z" fill="#ff0000"/><path class="accent" d="M0 12H16V16H0Z" fill="currentColor"/></svg>"##; |
| 518 | let pixel = |palette: &Palette, y: usize| { | 507 | let pixel = |palette: &Palette, y: usize| { |
| 519 | let image = rasterize(&[ART], 16.0, [0, 0], [0.0; 3], palette); | 508 | let image = rasterize(&[ART], 16, [0.0; 3], palette); |
| 520 | let at = (y * 17 + 8) * 4; | 509 | let at = (y * 16 + 8) * 4; |
| 521 | let [r, g, b] = [0, 1, 2].map(|channel| f32::from(image.data[at + channel]) / 255.0); | 510 | let [r, g, b] = [0, 1, 2].map(|channel| f32::from(image.data[at + channel]) / 255.0); |
| 522 | hsl([r, g, b]) | 511 | hsl([r, g, b]) |
| 523 | }; | 512 | }; |
| ... | @@ -542,14 +531,8 @@ mod tests { | ... | @@ -542,14 +531,8 @@ mod tests { |
| 542 | fn current_color_paints_the_ink_and_fixed_colours_keep_theirs() { | 531 | fn current_color_paints_the_ink_and_fixed_colours_keep_theirs() { |
| 543 | const LINE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 8H14" fill="none" stroke="currentColor" stroke-width="2"/><path d="M2 12H14" fill="none" stroke="#ff0000" stroke-width="2"/></svg>"##; | 532 | const LINE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 8H14" fill="none" stroke="currentColor" stroke-width="2"/><path d="M2 12H14" fill="none" stroke="#ff0000" stroke-width="2"/></svg>"##; |
| 544 | let ink = super::super::srgb(0x20, 0x60, 0xa0); | 533 | let ink = super::super::srgb(0x20, 0x60, 0xa0); |
| 545 | let image = rasterize( | 534 | let image = rasterize(&[LINE], 16, [ink[0], ink[1], ink[2]], &Palette::default()); |
| 546 | &[LINE], | 535 | let pixel = |x: usize, y: usize| &image.data[(y * 16 + x) * 4..][..4]; |
| 547 | 16.0, | ||
| 548 | [0, 0], | ||
| 549 | [ink[0], ink[1], ink[2]], | ||
| 550 | &Palette::default(), | ||
| 551 | ); | ||
| 552 | let pixel = |x: usize, y: usize| &image.data[(y * 17 + x) * 4..][..4]; | ||
| 553 | assert_eq!(pixel(8, 7), [0x20, 0x60, 0xa0, 255]); | 536 | assert_eq!(pixel(8, 7), [0x20, 0x60, 0xa0, 255]); |
| 554 | assert_eq!(pixel(8, 11), [255, 0, 0, 255]); | 537 | assert_eq!(pixel(8, 11), [255, 0, 0, 255]); |
| 555 | assert_eq!(pixel(8, 2)[3], 0); | 538 | assert_eq!(pixel(8, 2)[3], 0); |
| ... | @@ -560,8 +543,8 @@ mod tests { | ... | @@ -560,8 +543,8 @@ mod tests { |
| 560 | #[test] | 543 | #[test] |
| 561 | fn opacity_scales_each_paint_on_its_own() { | 544 | fn opacity_scales_each_paint_on_its_own() { |
| 562 | const HALF: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 2H14V14H2Z" fill="#ffffff" fill-opacity="0.5" stroke="#000000" stroke-width="2" stroke-opacity="0.25"/></svg>"##; | 545 | const HALF: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 2H14V14H2Z" fill="#ffffff" fill-opacity="0.5" stroke="#000000" stroke-width="2" stroke-opacity="0.25"/></svg>"##; |
| 563 | let image = rasterize(&[HALF], 16.0, [0, 0], [1.0; 3], &Palette::default()); | 546 | let image = rasterize(&[HALF], 16, [1.0; 3], &Palette::default()); |
| 564 | let alpha = |x: usize, y: usize| image.data[(y * 17 + x) * 4 + 3]; | 547 | let alpha = |x: usize, y: usize| image.data[(y * 16 + x) * 4 + 3]; |
| 565 | assert_eq!(alpha(8, 8), 128); | 548 | assert_eq!(alpha(8, 8), 128); |
| 566 | // The stroke's outer half covers only what the fill leaves. | 549 | // The stroke's outer half covers only what the fill leaves. |
| 567 | assert_eq!(alpha(1, 8), 64); | 550 | assert_eq!(alpha(1, 8), 64); |
| ... | @@ -569,28 +552,26 @@ mod tests { | ... | @@ -569,28 +552,26 @@ mod tests { |
| 569 | 552 | ||
| 570 | #[test] | 553 | #[test] |
| 571 | fn icons_keep_transparency_and_overlays_across_scales() { | 554 | fn icons_keep_transparency_and_overlays_across_scales() { |
| 572 | for size in [8.0, 16.0, 32.0, 57.5] { | 555 | for size in [8, 16, 32, 57] { |
| 573 | for phase in [[0, 0], [1, 2], [3, 3]] { | 556 | let plain = rasterize(&[BOX], size, [1.0; 3], &Palette::default()); |
| 574 | let plain = rasterize(&[BOX], size, phase, [1.0; 3], &Palette::default()); | 557 | let marked = rasterize(&[BOX, MARK], size, [1.0; 3], &Palette::default()); |
| 575 | let marked = rasterize(&[BOX, MARK], size, phase, [1.0; 3], &Palette::default()); | 558 | for image in [&plain, &marked] { |
| 576 | for image in [&plain, &marked] { | 559 | assert_eq!( |
| 577 | assert_eq!( | 560 | image.data.len(), |
| 578 | image.data.len(), | 561 | (image.placement.width * image.placement.height * 4) as usize |
| 579 | (image.placement.width * image.placement.height * 4) as usize | 562 | ); |
| 580 | ); | 563 | assert!(image.data.chunks_exact(4).any(|p| p[3] > 0)); |
| 581 | assert!(image.data.chunks_exact(4).any(|p| p[3] > 0)); | 564 | assert!(image.data.chunks_exact(4).any(|p| p[3] < 255)); |
| 582 | assert!(image.data.chunks_exact(4).any(|p| p[3] < 255)); | 565 | assert!( |
| 583 | assert!( | 566 | image |
| 584 | image | 567 | .data |
| 585 | .data | 568 | .chunks_exact(4) |
| 586 | .chunks_exact(4) | 569 | .filter(|p| p[3] == 0) |
| 587 | .filter(|p| p[3] == 0) | 570 | .all(|p| p[..3] == [0; 3]) |
| 588 | .all(|p| p[..3] == [0; 3]) | 571 | ); |
| 589 | ); | ||
| 590 | } | ||
| 591 | assert_ne!(plain.data, marked.data); | ||
| 592 | assert!(marked.data.chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0)); | ||
| 593 | } | 572 | } |
| 573 | assert_ne!(plain.data, marked.data); | ||
| 574 | assert!(marked.data.chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0)); | ||
| 594 | } | 575 | } |
| 595 | } | 576 | } |
| 596 | } | 577 | } |
crates/mobile/src/library.rs+5-11| ... | @@ -33,8 +33,6 @@ const TIMEOUT: Duration = Duration::from_secs(10); | ... | @@ -33,8 +33,6 @@ const TIMEOUT: Duration = Duration::from_secs(10); |
| 33 | const LIMIT: usize = 256 * 1024 * 1024; | 33 | const LIMIT: usize = 256 * 1024 * 1024; |
| 34 | /// The tab colour OneNote gives a section that stores none, as the desktop shows it. | 34 | /// The tab colour OneNote gives a section that stores none, as the desktop shows it. |
| 35 | const SECTION_COLOR: u32 = 0x00e4_a88a; | 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 | /// Called on a background thread when a notebook's sync status may have changed. | 37 | /// Called on a background thread when a notebook's sync status may have changed. |
| 40 | static SYNC_WAKE: OnceLock<extern "C" fn()> = OnceLock::new(); | 38 | static SYNC_WAKE: OnceLock<extern "C" fn()> = OnceLock::new(); |
| ... | @@ -239,7 +237,7 @@ fn stem(path: &str) -> String { | ... | @@ -239,7 +237,7 @@ fn stem(path: &str) -> String { |
| 239 | fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { | 237 | fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { |
| 240 | for section in &folder.sections { | 238 | for section in &folder.sections { |
| 241 | let (name, color, readable) = match &section.state { | 239 | let (name, color, readable) = match &section.state { |
| 242 | SectionState::Readable { name, color } => (name.clone(), *color, true), | 240 | SectionState::Readable { name, color, .. } => (name.clone(), *color, true), |
| 243 | SectionState::Locked | SectionState::Unreadable(_) => (None, None, false), | 241 | SectionState::Locked | SectionState::Unreadable(_) => (None, None, false), |
| 244 | }; | 242 | }; |
| 245 | tabs.push(Tab { | 243 | tabs.push(Tab { |
| ... | @@ -272,8 +270,10 @@ impl Library { | ... | @@ -272,8 +270,10 @@ impl Library { |
| 272 | (None, Place::File(path.to_owned()), None) | 270 | (None, Place::File(path.to_owned()), None) |
| 273 | } else { | 271 | } else { |
| 274 | let notebook = Notebook::open(path, cache)?; | 272 | let notebook = Notebook::open(path, cache)?; |
| 273 | // Nothing here tells a file provider's folder on this device from one elsewhere. | ||
| 275 | let background = notebook.background_with( | 274 | let background = notebook.background_with( |
| 276 | BACKGROUND, | 275 | Background::UNWATCHED, |
| 276 | false, | ||
| 277 | |file| Coordinated(file.to_owned()), | 277 | |file| Coordinated(file.to_owned()), |
| 278 | sync_woken, | 278 | sync_woken, |
| 279 | )?; | 279 | )?; |
| ... | @@ -300,13 +300,7 @@ impl Library { | ... | @@ -300,13 +300,7 @@ impl Library { |
| 300 | pub(crate) fn server(server: Server, root: &str, cache: &Path) -> Result<Self> { | 300 | pub(crate) fn server(server: Server, root: &str, cache: &Path) -> Result<Self> { |
| 301 | let server = Arc::new(server); | 301 | let server = Arc::new(server); |
| 302 | let connect = Arc::clone(&server); | 302 | let connect = Arc::clone(&server); |
| 303 | let background = Background::smb( | 303 | let background = Background::smb(root, LIMIT, move || connect.connect(), sync_woken)?; |
| 304 | root, | ||
| 305 | LIMIT, | ||
| 306 | BACKGROUND, | ||
| 307 | move || connect.connect(), | ||
| 308 | sync_woken, | ||
| 309 | )?; | ||
| 310 | let library = Self { | 304 | let library = Self { |
| 311 | notebook: Mutex::default(), | 305 | notebook: Mutex::default(), |
| 312 | place: Place::Share { | 306 | place: Place::Share { |
crates/notebook/README.md+21-8| ... | @@ -65,14 +65,21 @@ and how many edits wait for it. `set_offline(true)` works offline as OneNote doe | ... | @@ -65,14 +65,21 @@ and how many edits wait for it. `set_offline(true)` works offline as OneNote doe |
| 65 | worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offline(false)`. | 65 | worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offline(false)`. |
| 66 | 66 | ||
| 67 | `session::Background` keeps the sections no session holds in sync, as OneNote 2010 keeps | 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 | 68 | every section of an open notebook: `Background::smb(root, limit, connect, notify)` on a |
| 69 | notebook, `Background::smb(root, limit, interval, connect, notify)` on a share, then | 69 | share, `Notebook::background(interval, copies, notify)` for a mounted notebook, then |
| 70 | `watch(notebook.replicas())`. Each round reads every watched file's stamp; a section whose | 70 | `watch(notebook.replicas())`. On a share, one CHANGE_NOTIFY on the notebook's folder |
| 71 | replica has edits waiting, or whose file moved past the replica's base, has the replica | 71 | (`smb::Client::watch`) reports what changed; a host watching a mounted folder passes the |
| 72 | opened for the synchronization steps that publish or rebase it and closed again. A replica | 72 | changed paths to `touched`. A reported section is checked a second later, a failing one |
| 73 | a session holds is skipped (`Error::busy`), so opening a section may wait out one step. | 73 | every 31 seconds, and any other every `interval` (`Background::BACKSTOP` while a watch |
| 74 | `status()` gives each section's `SyncStatus`, `changed()` the sections another client | 74 | reports). Newly watched sections, and every section after a reconnect, are checked one at a |
| 75 | changed, and `wake` and `set_offline` follow Sync Now and Work Offline. | 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 | `Notebook::replica_path(path)` names a section's replica for either kind of notebook. | 83 | `Notebook::replica_path(path)` names a section's replica for either kind of notebook. |
| 77 | 84 | ||
| 78 | `Section::resume(file, replica, notify)` starts from an owned `Replica` without | 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,6 +380,12 @@ are rejected with `ResourceBusy`. Notebook identities come from the files, not |
| 373 | directory names or sizes. Missing paths, denied access and non-directory paths | 380 | directory names or sizes. Missing paths, denied access and non-directory paths |
| 374 | have distinct I/O error kinds. | 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 | `Client::read_asset(path, byte_limit)` reads an external payload under a read-only | 389 | `Client::read_asset(path, byte_limit)` reads an external payload under a read-only |
| 377 | share handle that excludes writes and deletion. Empty files succeed; limits, | 390 | share handle that excludes writes and deletion. Empty files succeed; limits, |
| 378 | interrupted reads and failed close never return partial bytes. This payload read | 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,19 +11,45 @@ use onestore::Stamp; |
| 11 | use std::{ | 11 | use std::{ |
| 12 | io, | 12 | io, |
| 13 | path::{Path, PathBuf}, | 13 | path::{Path, PathBuf}, |
| 14 | sync::{Arc, Mutex, atomic::Ordering}, | 14 | sync::{ |
| 15 | Arc, Mutex, Weak, | ||
| 16 | atomic::{AtomicBool, Ordering}, | ||
| 17 | }, | ||
| 15 | thread, | 18 | thread, |
| 16 | time::Duration, | 19 | time::{Duration, Instant}, |
| 17 | }; | 20 | }; |
| 18 | 21 | ||
| 19 | /// Polls each watched section file's stamp every interval. A section whose replica has | 22 | /// How long a section that could not be reached waits to be tried again; OneNote 2010 |
| 20 | /// edits waiting, or whose file moved past the replica's base, has its replica opened for | 23 | /// retries a failing section about every 31 seconds. |
| 21 | /// the synchronization steps that publish or rebase it, then closed again; any other costs | 24 | const RETRY: Duration = Duration::from_secs(31); |
| 22 | /// one stamp read. A replica a session holds is left to that session's worker. Dropping | 25 | /// Between one section's first check and the next's, so opening a notebook reads its files |
| 23 | /// requests cancellation without waiting for the step in flight. | 26 | /// one after another rather than in a burst. |
| 24 | pub struct Background { | 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 | signal: Arc<Signal>, | 43 | signal: Arc<Signal>, |
| 26 | watched: Arc<Mutex<Watched>>, | 44 | watched: Mutex<Watched>, |
| 45 | /// Asked for by `discard`, once the thread stops. | ||
| 46 | discard: AtomicBool, | ||
| 47 | } | ||
| 48 | |||
| 49 | /// A connection's report of its notebook folder's changes, from a watch armed on connecting. | ||
| 50 | pub(crate) struct Reports { | ||
| 51 | shared: Weak<Shared>, | ||
| 52 | connection: u64, | ||
| 27 | } | 53 | } |
| 28 | 54 | ||
| 29 | #[derive(Default)] | 55 | #[derive(Default)] |
| ... | @@ -31,6 +57,10 @@ struct Watched { | ... | @@ -31,6 +57,10 @@ struct Watched { |
| 31 | sections: Vec<Watch>, | 57 | sections: Vec<Watch>, |
| 32 | /// Sections whose file changed since `changed` was last asked. | 58 | /// Sections whose file changed since `changed` was last asked. |
| 33 | changed: Vec<String>, | 59 | changed: Vec<String>, |
| 60 | /// Counts connections, so a watch that ended with an earlier one is not this one's. | ||
| 61 | connection: u64, | ||
| 62 | /// The current connection's watch ended: changes since went unreported. | ||
| 63 | lost: bool, | ||
| 34 | } | 64 | } |
| 35 | 65 | ||
| 36 | struct Watch { | 66 | struct Watch { |
| ... | @@ -39,12 +69,24 @@ struct Watch { | ... | @@ -39,12 +69,24 @@ struct Watch { |
| 39 | /// The file's stamp when last reached. | 69 | /// The file's stamp when last reached. |
| 40 | stamp: Option<Stamp>, | 70 | stamp: Option<Stamp>, |
| 41 | status: SyncStatus, | 71 | status: SyncStatus, |
| 72 | /// When the section is next checked. | ||
| 73 | due: Instant, | ||
| 42 | } | 74 | } |
| 43 | 75 | ||
| 44 | impl Background { | 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 | pub(crate) fn start<R, B>( | 87 | pub(crate) fn start<R, B>( |
| 46 | interval: Duration, | 88 | copies: bool, |
| 47 | mut connect: impl FnMut() -> io::Result<B> + Send + 'static, | 89 | mut connect: impl FnMut(Reports) -> io::Result<(B, Duration)> + Send + 'static, |
| 48 | notify: impl Fn() + Send + 'static, | 90 | notify: impl Fn() + Send + 'static, |
| 49 | ) -> Result<Self> | 91 | ) -> Result<Self> |
| 50 | where | 92 | where |
| ... | @@ -52,165 +94,201 @@ impl Background { | ... | @@ -52,165 +94,201 @@ impl Background { |
| 52 | B: FnMut(&str) -> R, | 94 | B: FnMut(&str) -> R, |
| 53 | { | 95 | { |
| 54 | let (signal, receiver) = Signal::new(); | 96 | let (signal, receiver) = Signal::new(); |
| 55 | let watched = Arc::new(Mutex::new(Watched::default())); | 97 | let shared = Arc::new(Shared { |
| 56 | let (shared, sections) = (Arc::clone(&signal), Arc::clone(&watched)); | 98 | signal, |
| 99 | watched: Mutex::default(), | ||
| 100 | discard: AtomicBool::new(false), | ||
| 101 | }); | ||
| 102 | let weak = Arc::downgrade(&shared); | ||
| 103 | let owner = Arc::clone(&shared); | ||
| 57 | thread::Builder::new() | 104 | thread::Builder::new() |
| 58 | .name("onestore-background".into()) | 105 | .name("onestore-background".into()) |
| 59 | .spawn(move || { | 106 | .spawn(move || { |
| 60 | let mut bound: Option<B> = None; | 107 | let signal = &owner.signal; |
| 61 | while !shared.stopped.load(Ordering::Acquire) { | 108 | let mut bound: Option<(B, Duration)> = None; |
| 62 | if shared.offline.load(Ordering::Acquire) | 109 | let mut news = false; |
| 63 | && !shared.requested.load(Ordering::Acquire) | 110 | while !signal.stopped.load(Ordering::Acquire) { |
| 111 | if signal.offline.load(Ordering::Acquire) | ||
| 112 | && !signal.requested.load(Ordering::Acquire) | ||
| 64 | { | 113 | { |
| 65 | bound = None; | 114 | bound = None; |
| 66 | let _ = receiver.recv(); | 115 | let _ = receiver.recv(); |
| 67 | continue; | 116 | continue; |
| 68 | } | 117 | } |
| 69 | shared.requested.store(false, Ordering::Release); | 118 | let now = Instant::now(); |
| 70 | let round: Vec<_> = match sections.lock() { | 119 | let (path, replica, seen, connection) = { |
| 71 | Ok(watched) => watched | 120 | let Ok(mut watched) = owner.watched.lock() else { |
| 72 | .sections | ||
| 73 | .iter() | ||
| 74 | .map(|watch| { | ||
| 75 | ( | ||
| 76 | watch.path.clone(), | ||
| 77 | watch.replica.clone(), | ||
| 78 | watch.stamp.clone(), | ||
| 79 | ) | ||
| 80 | }) | ||
| 81 | .collect(), | ||
| 82 | Err(_) => return, | ||
| 83 | }; | ||
| 84 | let mut news = false; | ||
| 85 | for (path, replica, seen) in round { | ||
| 86 | if shared.stopped.load(Ordering::Acquire) { | ||
| 87 | return; | 121 | return; |
| 88 | } | ||
| 89 | let bind = match &mut bound { | ||
| 90 | Some(bind) => bind, | ||
| 91 | None => match connect() { | ||
| 92 | Ok(bind) => bound.insert(bind), | ||
| 93 | Err(error) => { | ||
| 94 | // Nothing on the share is reachable this round. | ||
| 95 | let Ok(mut watched) = sections.lock() else { | ||
| 96 | return; | ||
| 97 | }; | ||
| 98 | for watch in &mut watched.sections { | ||
| 99 | news |= watch.fail( | ||
| 100 | &Error::RemoteIo(io::Error::new( | ||
| 101 | error.kind(), | ||
| 102 | error.to_string(), | ||
| 103 | )), | ||
| 104 | None, | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | break; | ||
| 108 | } | ||
| 109 | }, | ||
| 110 | }; | 122 | }; |
| 111 | let (queued, outcome) = | 123 | if std::mem::take(&mut watched.lost) { |
| 112 | step(&mut bind(&path), replica.as_deref(), seen.as_ref()); | ||
| 113 | if matches!(outcome, Err(Error::RemoteIo(_) | Error::Remote(_))) { | ||
| 114 | bound = None; | 124 | bound = None; |
| 125 | watched.rescan(now); | ||
| 115 | } | 126 | } |
| 116 | let Ok(mut watched) = sections.lock() else { | 127 | let due = watched.next().map(|index| &watched.sections[index]); |
| 117 | return; | 128 | match due.filter(|watch| watch.due <= now) { |
| 118 | }; | 129 | Some(watch) => ( |
| 119 | let Watched { sections, changed } = &mut *watched; | 130 | watch.path.clone(), |
| 120 | let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) | 131 | watch.replica.clone(), |
| 121 | else { | 132 | watch.stamp.clone(), |
| 122 | continue; | 133 | watched.connection, |
| 123 | }; | 134 | ), |
| 124 | news |= match outcome { | 135 | None => { |
| 125 | Ok((stamp, moved)) => { | 136 | let wait = due.map(|watch| watch.due - now); |
| 126 | watch.stamp = Some(stamp); | 137 | drop(watched); |
| 127 | if moved { | 138 | signal.requested.store(false, Ordering::Release); |
| 128 | changed.push(path); | 139 | if std::mem::take(&mut news) { |
| 140 | notify(); | ||
| 129 | } | 141 | } |
| 130 | let before = summary(&watch.status); | 142 | let _ = match wait { |
| 131 | if let Some(queued) = queued { | 143 | Some(wait) => receiver.recv_timeout(wait).ok(), |
| 132 | watch.status = SyncStatus { | 144 | None => receiver.recv().ok(), |
| 133 | synced: Some(crate::now()), | 145 | }; |
| 134 | error: None, | 146 | continue; |
| 135 | queued, | 147 | } |
| 136 | }; | 148 | } |
| 149 | }; | ||
| 150 | let (bind, interval) = match &mut bound { | ||
| 151 | Some(bound) => bound, | ||
| 152 | None => { | ||
| 153 | let reports = Reports { | ||
| 154 | shared: weak.clone(), | ||
| 155 | connection: connection + 1, | ||
| 156 | }; | ||
| 157 | let connected = connect(reports); | ||
| 158 | let Ok(mut watched) = owner.watched.lock() else { | ||
| 159 | return; | ||
| 160 | }; | ||
| 161 | // Whatever ended before now ended with the last connection. | ||
| 162 | watched.connection += 1; | ||
| 163 | watched.lost = false; | ||
| 164 | match connected { | ||
| 165 | // Changes while unwatched went unreported. | ||
| 166 | Ok(connected) => { | ||
| 167 | watched.rescan(now); | ||
| 168 | bound = Some(connected); | ||
| 169 | } | ||
| 170 | Err(error) => { | ||
| 171 | let error = Error::RemoteIo(error); | ||
| 172 | for watch in &mut watched.sections { | ||
| 173 | news |= watch.fail(&error, None); | ||
| 174 | watch.due = now + RETRY; | ||
| 175 | } | ||
| 137 | } | 176 | } |
| 138 | moved || before != summary(&watch.status) | ||
| 139 | } | 177 | } |
| 140 | Err(error) => watch.fail(&error, queued), | 178 | continue; |
| 141 | }; | 179 | } |
| 180 | }; | ||
| 181 | let (queued, outcome) = | ||
| 182 | step(&mut bind(&path), replica.as_deref(), seen.as_ref(), copies); | ||
| 183 | let interval = *interval; | ||
| 184 | if outcome.as_ref().is_err_and(disconnected) { | ||
| 185 | bound = None; | ||
| 142 | } | 186 | } |
| 143 | if news { | 187 | let Ok(mut watched) = owner.watched.lock() else { |
| 144 | notify(); | 188 | return; |
| 189 | }; | ||
| 190 | let Watched { | ||
| 191 | sections, changed, .. | ||
| 192 | } = &mut *watched; | ||
| 193 | let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) else { | ||
| 194 | continue; | ||
| 195 | }; | ||
| 196 | let now = Instant::now(); | ||
| 197 | news |= match outcome { | ||
| 198 | Ok((stamp, moved)) => { | ||
| 199 | watch.stamp = Some(stamp); | ||
| 200 | watch.due = now + interval; | ||
| 201 | if moved { | ||
| 202 | changed.push(path); | ||
| 203 | } | ||
| 204 | let before = summary(&watch.status); | ||
| 205 | if let Some(queued) = queued { | ||
| 206 | watch.status = SyncStatus { | ||
| 207 | synced: Some(crate::now()), | ||
| 208 | error: None, | ||
| 209 | queued, | ||
| 210 | }; | ||
| 211 | } | ||
| 212 | moved || before != summary(&watch.status) | ||
| 213 | } | ||
| 214 | Err(error) => { | ||
| 215 | watch.due = now + RETRY; | ||
| 216 | watch.fail(&error, queued) | ||
| 217 | } | ||
| 218 | }; | ||
| 219 | } | ||
| 220 | if owner.discard.load(Ordering::Acquire) | ||
| 221 | && let Ok(watched) = owner.watched.lock() | ||
| 222 | { | ||
| 223 | for replica in watched | ||
| 224 | .sections | ||
| 225 | .iter() | ||
| 226 | .filter_map(|watch| watch.replica.as_ref()) | ||
| 227 | { | ||
| 228 | discard(replica); | ||
| 145 | } | 229 | } |
| 146 | let _ = receiver.recv_timeout(interval); | ||
| 147 | } | 230 | } |
| 148 | })?; | 231 | })?; |
| 149 | Ok(Self { signal, watched }) | 232 | Ok(Self(shared)) |
| 150 | } | 233 | } |
| 151 | 234 | ||
| 152 | /// Keeps the sections of a notebook on a share in sync while they are not open; | 235 | /// Keeps the sections of a notebook on a share in sync while they are not open, with an |
| 153 | /// `connect` runs again after a transport failure. Paths are relative to `root`. | 236 | /// offline copy of each, and one watch on the notebook's folder reporting what changed, |
| 237 | /// as OneNote 2010 watches it; `connect` runs again after a transport failure. Paths are | ||
| 238 | /// relative to `root`. | ||
| 154 | #[cfg(feature = "smb")] | 239 | #[cfg(feature = "smb")] |
| 155 | pub fn smb( | 240 | pub fn smb( |
| 156 | root: &str, | 241 | root: &str, |
| 157 | limit: usize, | 242 | limit: usize, |
| 158 | interval: Duration, | ||
| 159 | mut connect: impl FnMut() -> io::Result<crate::smb::Client> + Send + 'static, | 243 | mut connect: impl FnMut() -> io::Result<crate::smb::Client> + Send + 'static, |
| 160 | notify: impl Fn() + Send + 'static, | 244 | notify: impl Fn() + Send + 'static, |
| 161 | ) -> Result<Self> { | 245 | ) -> Result<Self> { |
| 162 | let root = root.replace('\\', "/"); | 246 | let root = root.replace('\\', "/"); |
| 163 | Self::start( | 247 | Self::start( |
| 164 | interval, | 248 | true, |
| 165 | move || { | 249 | move |reports| { |
| 166 | let client = Arc::new(connect()?); | 250 | let client = Arc::new(connect()?); |
| 251 | let interval = match client.watch(&root, move |changed| match changed { | ||
| 252 | Ok(paths) => reports.touched(&paths), | ||
| 253 | Err(_) => reports.lost(), | ||
| 254 | }) { | ||
| 255 | Ok(()) => Self::BACKSTOP, | ||
| 256 | Err(error) if error.kind() == io::ErrorKind::Unsupported => Self::UNWATCHED, | ||
| 257 | Err(error) => return Err(error), | ||
| 258 | }; | ||
| 167 | let root = root.clone(); | 259 | let root = root.clone(); |
| 168 | Ok(move |path: &str| { | 260 | let bind = move |path: &str| { |
| 169 | let file = match root.as_str() { | 261 | let file = match root.as_str() { |
| 170 | "" => path.to_owned(), | 262 | "" => path.to_owned(), |
| 171 | root => format!("{root}/{path}"), | 263 | root => format!("{root}/{path}"), |
| 172 | }; | 264 | }; |
| 173 | crate::SmbRemote::new(Arc::clone(&client), file, limit) | 265 | crate::SmbRemote::new(Arc::clone(&client), file, limit) |
| 174 | }) | 266 | }; |
| 267 | Ok((bind, interval)) | ||
| 175 | }, | 268 | }, |
| 176 | notify, | 269 | notify, |
| 177 | ) | 270 | ) |
| 178 | } | 271 | } |
| 179 | 272 | ||
| 180 | /// Watches these sections, by catalog path and replica (`Notebook::replicas`), from | 273 | /// Watches these sections, by catalog path and replica (`Notebook::replicas`); a section |
| 181 | /// the next round, which starts now. | 274 | /// not watched before is first checked soon after, the next one a little later. |
| 182 | pub fn watch(&self, sections: Vec<(String, Option<PathBuf>)>) { | 275 | pub fn watch(&self, sections: Vec<(String, Option<PathBuf>)>) { |
| 183 | if let Ok(mut watched) = self.watched.lock() { | 276 | if let Ok(mut watched) = self.0.watched.lock() { |
| 184 | let mut previous = std::mem::take(&mut watched.sections); | 277 | watched.watch(sections, Instant::now()); |
| 185 | watched.sections = sections | ||
| 186 | .into_iter() | ||
| 187 | .map( | ||
| 188 | |(path, replica)| match previous.iter().position(|watch| watch.path == path) { | ||
| 189 | Some(index) => Watch { | ||
| 190 | replica, | ||
| 191 | ..previous.swap_remove(index) | ||
| 192 | }, | ||
| 193 | None => Watch { | ||
| 194 | path, | ||
| 195 | replica, | ||
| 196 | stamp: None, | ||
| 197 | status: SyncStatus { | ||
| 198 | synced: None, | ||
| 199 | error: None, | ||
| 200 | queued: 0, | ||
| 201 | }, | ||
| 202 | }, | ||
| 203 | }, | ||
| 204 | ) | ||
| 205 | .collect(); | ||
| 206 | } | 278 | } |
| 207 | self.signal.wake(); | 279 | self.0.signal.wake(); |
| 208 | } | 280 | } |
| 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 | /// session holds keeps the status it had before. | 289 | /// session holds keeps the status it had before. |
| 212 | pub fn status(&self) -> Vec<(String, SyncStatus)> { | 290 | pub fn status(&self) -> Vec<(String, SyncStatus)> { |
| 213 | self.watched.lock().map_or_else( | 291 | self.0.watched.lock().map_or_else( |
| 214 | |_| Vec::new(), | 292 | |_| Vec::new(), |
| 215 | |watched| { | 293 | |watched| { |
| 216 | watched | 294 | watched |
| ... | @@ -237,32 +315,165 @@ impl Background { | ... | @@ -237,32 +315,165 @@ impl Background { |
| 237 | 315 | ||
| 238 | /// Sections whose file changed since the last call, by catalog path. | 316 | /// Sections whose file changed since the last call, by catalog path. |
| 239 | pub fn changed(&self) -> Vec<String> { | 317 | pub fn changed(&self) -> Vec<String> { |
| 240 | self.watched | 318 | self.0 |
| 319 | .watched | ||
| 241 | .lock() | 320 | .lock() |
| 242 | .map(|mut watched| std::mem::take(&mut watched.changed)) | 321 | .map(|mut watched| std::mem::take(&mut watched.changed)) |
| 243 | .unwrap_or_default() | 322 | .unwrap_or_default() |
| 244 | } | 323 | } |
| 245 | 324 | ||
| 246 | /// Runs a round now, working offline included (Sync Now). | 325 | /// Checks every section now, working offline included (Sync Now). |
| 247 | pub fn wake(&self) { | 326 | pub fn wake(&self) { |
| 248 | self.signal.requested.store(true, Ordering::Release); | 327 | if let Ok(mut watched) = self.0.watched.lock() { |
| 249 | self.signal.wake(); | 328 | let now = Instant::now(); |
| 329 | for watch in &mut watched.sections { | ||
| 330 | watch.due = now; | ||
| 331 | } | ||
| 332 | } | ||
| 333 | self.0.signal.requested.store(true, Ordering::Release); | ||
| 334 | self.0.signal.wake(); | ||
| 250 | } | 335 | } |
| 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 | pub fn set_offline(&self, offline: bool) { | 347 | pub fn set_offline(&self, offline: bool) { |
| 254 | self.signal.offline.store(offline, Ordering::Release); | 348 | self.0.signal.offline.store(offline, Ordering::Release); |
| 255 | self.signal.wake(); | 349 | self.0.signal.wake(); |
| 256 | } | 350 | } |
| 257 | } | 351 | } |
| 258 | 352 | ||
| 259 | impl Drop for Background { | 353 | impl Drop for Background { |
| 260 | fn drop(&mut self) { | 354 | fn drop(&mut self) { |
| 261 | self.signal.stopped.store(true, Ordering::Release); | 355 | self.0.signal.stopped.store(true, Ordering::Release); |
| 356 | self.0.signal.wake(); | ||
| 357 | } | ||
| 358 | } | ||
| 359 | |||
| 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 | self.signal.wake(); | 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 | impl Watch { | 477 | impl Watch { |
| 267 | /// Records a failed step and what it found waiting, answering whether the status shown | 478 | /// Records a failed step and what it found waiting, answering whether the status shown |
| 268 | /// changes. | 479 | /// changes. |
| ... | @@ -284,20 +495,43 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) { | ... | @@ -284,20 +495,43 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) { |
| 284 | } | 495 | } |
| 285 | 496 | ||
| 286 | /// One section's step: how many of its edits wait (`None` while unknown, as while a session | 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 | fn step<R: Remote>( | 500 | fn step<R: Remote>( |
| 289 | remote: &mut R, | 501 | remote: &mut R, |
| 290 | replica: Option<&Path>, | 502 | replica: Option<&Path>, |
| 291 | seen: Option<&Stamp>, | 503 | seen: Option<&Stamp>, |
| 504 | copies: bool, | ||
| 292 | ) -> (Option<u64>, Result<(Stamp, bool)>) { | 505 | ) -> (Option<u64>, Result<(Stamp, bool)>) { |
| 293 | let stamp = match remote.stamp() { | 506 | let stamp = match remote.stamp() { |
| 294 | Ok(stamp) => stamp, | 507 | Ok(stamp) => stamp, |
| 295 | Err(error) => return (None, Err(Error::RemoteIo(error))), | 508 | Err(error) => return (None, Err(Error::RemoteIo(error))), |
| 296 | }; | 509 | }; |
| 297 | let moved = seen.is_some_and(|seen| *seen != stamp); | 510 | let moved = seen.is_some_and(|seen| *seen != stamp); |
| 298 | let Some(replica) = replica.filter(|replica| replica.exists()) else { | 511 | let Some(replica) = replica else { |
| 299 | return (Some(0), Ok((stamp, moved))); | 512 | return (Some(0), Ok((stamp, moved))); |
| 300 | }; | 513 | }; |
| 514 | if !replica.exists() { | ||
| 515 | if !copies { | ||
| 516 | return (Some(0), Ok((stamp, moved))); | ||
| 517 | } | ||
| 518 | let copied = (|| { | ||
| 519 | let image = remote.read().map_err(Error::RemoteIo)?; | ||
| 520 | if let Some(folder) = replica.parent() { | ||
| 521 | std::fs::create_dir_all(folder)?; | ||
| 522 | } | ||
| 523 | Replica::seed(replica, &image)?; | ||
| 524 | Ok(Stamp::of(&image)?) | ||
| 525 | })(); | ||
| 526 | return match copied { | ||
| 527 | Ok(stamp) => (Some(0), Ok((stamp, moved))), | ||
| 528 | // A session made it first. | ||
| 529 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => { | ||
| 530 | (None, Ok((stamp, false))) | ||
| 531 | } | ||
| 532 | Err(error) => (None, Err(error)), | ||
| 533 | }; | ||
| 534 | } | ||
| 301 | match crate::peek(replica) { | 535 | match crate::peek(replica) { |
| 302 | Ok((base, 0)) if base == stamp => return (Some(0), Ok((stamp, moved))), | 536 | Ok((base, 0)) if base == stamp => return (Some(0), Ok((stamp, moved))), |
| 303 | Err(error) if error.busy() => return (None, Ok((stamp, false))), | 537 | Err(error) if error.busy() => return (None, Ok((stamp, false))), |
| ... | @@ -324,3 +558,176 @@ fn step<R: Remote>( | ... | @@ -324,3 +558,176 @@ fn step<R: Remote>( |
| 324 | .map(|summary| summary.queued_edits); | 558 | .map(|summary| summary.queued_edits); |
| 325 | (queued, synced) | 559 | (queued, synced) |
| 326 | } | 560 | } |
| 561 | |||
| 562 | #[cfg(test)] | ||
| 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,6 +30,9 @@ pub enum SectionState { |
| 30 | name: Option<String>, | 30 | name: Option<String>, |
| 31 | /// The tab colour as a COLORREF; OneNote assigns one when absent. | 31 | /// The tab colour as a COLORREF; OneNote assigns one when absent. |
| 32 | color: Option<u32>, | 32 | color: Option<u32>, |
| 33 | /// The root object space's GUID, the document identity that names the section's | ||
| 34 | /// replica in a mounted notebook. | ||
| 35 | document: [u8; 16], | ||
| 33 | }, | 36 | }, |
| 34 | Locked, | 37 | Locked, |
| 35 | Unreadable(onestore::Error), | 38 | Unreadable(onestore::Error), |
| ... | @@ -310,6 +313,7 @@ fn scan( | ... | @@ -310,6 +313,7 @@ fn scan( |
| 310 | state: SectionState::Readable { | 313 | state: SectionState::Readable { |
| 311 | name, | 314 | name, |
| 312 | color: color.filter(|color| *color != 0xffff_ffff), | 315 | color: color.filter(|color| *color != 0xffff_ffff), |
| 316 | document: index.root.guid, | ||
| 313 | }, | 317 | }, |
| 314 | }); | 318 | }); |
| 315 | } else { | 319 | } else { |
crates/notebook/src/lib.rs+16-11| ... | @@ -105,23 +105,28 @@ impl Replica { | ... | @@ -105,23 +105,28 @@ impl Replica { |
| 105 | /// Seeds a new cache from a validated section image, refusing any existing path. | 105 | /// Seeds a new cache from a validated section image, refusing any existing path. |
| 106 | /// An initialization error preserves the created file for inspection. | 106 | /// An initialization error preserves the created file for inspection. |
| 107 | pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> { | 107 | pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> { |
| 108 | Self::seed(path.as_ref(), source)?; | ||
| 109 | Self::start(cache_connection(path.as_ref())?) | ||
| 110 | } | ||
| 111 | |||
| 112 | /// `create` without opening the cache it made, as for an offline copy. | ||
| 113 | pub(crate) fn seed(path: &Path, source: &[u8]) -> Result<()> { | ||
| 108 | validate(source)?; | 114 | validate(source)?; |
| 109 | let path = path.as_ref(); | 115 | // Built under a name of its own and linked into place whole, so that a cache that |
| 110 | // Built under another name and linked into place whole, so that a cache that exists | 116 | // exists is complete, and two threads making the same one never share a build. |
| 111 | // is complete: a background step or a failed creation never leaves one half made. | 117 | static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); |
| 118 | let build = BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); | ||
| 112 | let mut building = path.as_os_str().to_owned(); | 119 | let mut building = path.as_os_str().to_owned(); |
| 113 | building.push(".creating"); | 120 | building.push(format!(".creating-{}-{build}", std::process::id())); |
| 114 | let building = PathBuf::from(building); | 121 | let building = PathBuf::from(building); |
| 115 | for stale in ["", "-wal", "-shm"] { | 122 | let built = Self::build(&building, source); |
| 123 | let linked = built.and_then(|()| Ok(std::fs::hard_link(&building, path)?)); | ||
| 124 | for suffix in ["", "-wal", "-shm"] { | ||
| 116 | let mut file = building.as_os_str().to_owned(); | 125 | let mut file = building.as_os_str().to_owned(); |
| 117 | file.push(stale); | 126 | file.push(suffix); |
| 118 | let _ = std::fs::remove_file(file); | 127 | let _ = std::fs::remove_file(file); |
| 119 | } | 128 | } |
| 120 | let built = Self::build(&building, source); | 129 | linked |
| 121 | let linked = built.and_then(|()| Ok(std::fs::hard_link(&building, path)?)); | ||
| 122 | let _ = std::fs::remove_file(&building); | ||
| 123 | linked?; | ||
| 124 | Self::start(cache_connection(path)?) | ||
| 125 | } | 130 | } |
| 126 | 131 | ||
| 127 | fn build(path: &Path, source: &[u8]) -> Result<()> { | 132 | fn build(path: &Path, source: &[u8]) -> Result<()> { |
crates/notebook/src/merge.rs+2| ... | @@ -164,6 +164,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> { | ... | @@ -164,6 +164,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> { |
| 164 | PageOp::Level { paragraph, .. } | 164 | PageOp::Level { paragraph, .. } |
| 165 | | PageOp::Paragraph { paragraph, .. } | 165 | | PageOp::Paragraph { paragraph, .. } |
| 166 | | PageOp::Style { paragraph, .. } | 166 | | PageOp::Style { paragraph, .. } |
| 167 | | PageOp::Media { paragraph, .. } | ||
| 167 | | PageOp::List { paragraph, .. } => vec![*paragraph], | 168 | | PageOp::List { paragraph, .. } => vec![*paragraph], |
| 168 | PageOp::Tags { target, .. } => vec![*target], | 169 | PageOp::Tags { target, .. } => vec![*target], |
| 169 | PageOp::Add { object, .. } => vec![object.id()], | 170 | PageOp::Add { object, .. } => vec![object.id()], |
| ... | @@ -736,6 +737,7 @@ impl Diff { | ... | @@ -736,6 +737,7 @@ impl Diff { |
| 736 | } | 737 | } |
| 737 | PageOp::Paragraph { paragraph, .. } | 738 | PageOp::Paragraph { paragraph, .. } |
| 738 | | PageOp::Style { paragraph, .. } | 739 | | PageOp::Style { paragraph, .. } |
| 740 | | PageOp::Media { paragraph, .. } | ||
| 739 | | PageOp::List { paragraph, .. } => { | 741 | | PageOp::List { paragraph, .. } => { |
| 740 | require(self.kept(*paragraph) && self.new_has(*paragraph))? | 742 | require(self.kept(*paragraph) && self.new_has(*paragraph))? |
| 741 | } | 743 | } |
crates/notebook/src/session.rs+15-8| ... | @@ -887,13 +887,16 @@ impl Notebook { | ... | @@ -887,13 +887,16 @@ impl Notebook { |
| 887 | /// `smb` on a share. It exists once the section has been opened. | 887 | /// `smb` on a share. It exists once the section has been opened. |
| 888 | pub fn replica_path(&self, path: &str) -> Result<PathBuf> { | 888 | pub fn replica_path(&self, path: &str) -> Result<PathBuf> { |
| 889 | let section = self.section_path(path)?; | 889 | let section = self.section_path(path)?; |
| 890 | Ok(match &self.root { | 890 | Ok(match (&self.root, &section.state) { |
| 891 | Some(_) => { | 891 | (Some(_), discover::SectionState::Readable { document, .. }) => { |
| 892 | replica_file(&self.cache, document) | ||
| 893 | } | ||
| 894 | (Some(_), _) => { | ||
| 892 | let image = self.storage.read(&section.path)?; | 895 | let image = self.storage.read(&section.path)?; |
| 893 | let root = RevisionIndex::parse(&Store::parse(&image)?)?.root; | 896 | let root = RevisionIndex::parse(&Store::parse(&image)?)?.root; |
| 894 | replica_file(&self.cache, &root.guid) | 897 | replica_file(&self.cache, &root.guid) |
| 895 | } | 898 | } |
| 896 | None => replica_file(&self.cache.join("smb"), &section.file_id), | 899 | (None, _) => replica_file(&self.cache.join("smb"), &section.file_id), |
| 897 | }) | 900 | }) |
| 898 | } | 901 | } |
| 899 | 902 | ||
| ... | @@ -914,13 +917,16 @@ impl Notebook { | ... | @@ -914,13 +917,16 @@ impl Notebook { |
| 914 | } | 917 | } |
| 915 | 918 | ||
| 916 | /// Keeps the sections of a mounted notebook in sync while they are not open | 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 | pub fn background( | 923 | pub fn background( |
| 919 | &self, | 924 | &self, |
| 920 | interval: Duration, | 925 | interval: Duration, |
| 926 | copies: bool, | ||
| 921 | notify: impl Fn() + Send + 'static, | 927 | notify: impl Fn() + Send + 'static, |
| 922 | ) -> Result<Background> { | 928 | ) -> Result<Background> { |
| 923 | self.background_with(interval, |file| FileRemote(file.to_owned()), notify) | 929 | self.background_with(interval, copies, |file| FileRemote(file.to_owned()), notify) |
| 924 | } | 930 | } |
| 925 | 931 | ||
| 926 | /// `background`, reaching each section file through the remote `remote` makes for it | 932 | /// `background`, reaching each section file through the remote `remote` makes for it |
| ... | @@ -928,6 +934,7 @@ impl Notebook { | ... | @@ -928,6 +934,7 @@ impl Notebook { |
| 928 | pub fn background_with<R: Remote + 'static>( | 934 | pub fn background_with<R: Remote + 'static>( |
| 929 | &self, | 935 | &self, |
| 930 | interval: Duration, | 936 | interval: Duration, |
| 937 | copies: bool, | ||
| 931 | remote: impl Fn(&Path) -> R + Clone + Send + 'static, | 938 | remote: impl Fn(&Path) -> R + Clone + Send + 'static, |
| 932 | notify: impl Fn() + Send + 'static, | 939 | notify: impl Fn() + Send + 'static, |
| 933 | ) -> Result<Background> { | 940 | ) -> Result<Background> { |
| ... | @@ -939,10 +946,10 @@ impl Notebook { | ... | @@ -939,10 +946,10 @@ impl Notebook { |
| 939 | .into()); | 946 | .into()); |
| 940 | }; | 947 | }; |
| 941 | Background::start( | 948 | Background::start( |
| 942 | interval, | 949 | copies, |
| 943 | move || { | 950 | move |_| { |
| 944 | let (root, remote) = (root.clone(), remote.clone()); | 951 | let (root, remote) = (root.clone(), remote.clone()); |
| 945 | Ok(move |path: &str| remote(&root.join(path))) | 952 | Ok((move |path: &str| remote(&root.join(path)), interval)) |
| 946 | }, | 953 | }, |
| 947 | notify, | 954 | notify, |
| 948 | ) | 955 | ) |
crates/notebook/src/smb/directory.rs+67| ... | @@ -96,6 +96,73 @@ impl Client { | ... | @@ -96,6 +96,73 @@ impl Client { |
| 96 | }) | 96 | }) |
| 97 | .collect()) | 97 | .collect()) |
| 98 | } | 98 | } |
| 99 | |||
| 100 | /// Watches the directory tree at `path` as OneNote 2010 watches a notebook's folder: one | ||
| 101 | /// CHANGE_NOTIFY with WATCH_TREE, kept armed on the server, so an idle watch sends | ||
| 102 | /// nothing. `changed` runs on the client's runtime with each batch of changed paths, | ||
| 103 | /// relative to `path` and `/`-separated, or `""` when the server lost count of them, then | ||
| 104 | /// `Err(_)` once the watch ends with its connection or the client. | ||
| 105 | pub fn watch( | ||
| 106 | &self, | ||
| 107 | path: &str, | ||
| 108 | changed: impl FnMut(io::Result<Vec<String>>) + Send + 'static, | ||
| 109 | ) -> io::Result<()> { | ||
| 110 | if Handle::try_current().is_ok() { | ||
| 111 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 112 | } | ||
| 113 | let mut connection = self | ||
| 114 | .connection | ||
| 115 | .lock() | ||
| 116 | .map_err(|_| io::ErrorKind::Other)? | ||
| 117 | .clone() | ||
| 118 | .ok_or(io::ErrorKind::NotConnected)?; | ||
| 119 | // Probing a connection whose only request is the watch would end OneNote's silence. | ||
| 120 | connection.set_keepalive(None); | ||
| 121 | let runtime = self.runtime.lock().map_err(|_| io::ErrorKind::Other)?; | ||
| 122 | let runtime = runtime.as_ref().ok_or(io::ErrorKind::NotConnected)?; | ||
| 123 | let path = path.replace('\\', "/"); | ||
| 124 | let mut watcher = runtime | ||
| 125 | .block_on(async { | ||
| 126 | tokio::time::timeout(self.timeout, self.tree.watch(&mut connection, &path, true)) | ||
| 127 | .await | ||
| 128 | }) | ||
| 129 | .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))? | ||
| 130 | .map_err(|error| match &error { | ||
| 131 | smb2::Error::Protocol { status, .. } if status.0 == 0xc0000034 => { | ||
| 132 | io::Error::new(io::ErrorKind::NotFound, error) | ||
| 133 | } | ||
| 134 | // STATUS_NOT_SUPPORTED, STATUS_INVALID_DEVICE_REQUEST | ||
| 135 | smb2::Error::Protocol { status, .. } | ||
| 136 | if matches!(status.0, 0xc00000bb | 0xc0000010) => | ||
| 137 | { | ||
| 138 | io::Error::new(io::ErrorKind::Unsupported, error) | ||
| 139 | } | ||
| 140 | _ => io::Error::other(error), | ||
| 141 | })?; | ||
| 142 | // However the task ends, the connection failing or the client retiring its runtime, | ||
| 143 | // `changed` hears that the watch did. | ||
| 144 | struct Ends<F: FnMut(io::Result<Vec<String>>)>(F); | ||
| 145 | impl<F: FnMut(io::Result<Vec<String>>)> Drop for Ends<F> { | ||
| 146 | fn drop(&mut self) { | ||
| 147 | (self.0)(Err(io::ErrorKind::NotConnected.into())); | ||
| 148 | } | ||
| 149 | } | ||
| 150 | let mut ends = Ends(changed); | ||
| 151 | runtime.spawn(async move { | ||
| 152 | loop { | ||
| 153 | match watcher.next_events().await { | ||
| 154 | Ok(events) => { | ||
| 155 | (ends.0)(Ok(events.into_iter().map(|event| event.filename).collect())) | ||
| 156 | } | ||
| 157 | Err(smb2::Error::Protocol { status, .. }) if status.0 == 0x0000010c => { | ||
| 158 | (ends.0)(Ok(vec![String::new()])) | ||
| 159 | } | ||
| 160 | Err(_) => return, | ||
| 161 | } | ||
| 162 | } | ||
| 163 | }); | ||
| 164 | Ok(()) | ||
| 165 | } | ||
| 99 | } | 166 | } |
| 100 | 167 | ||
| 101 | fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> { | 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,6 +421,44 @@ fn live_stamp_and_transaction() { |
| 421 | poller.delete(&path).unwrap(); | 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 | #[test] | 462 | #[test] |
| 425 | #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] | 463 | #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] |
| 426 | fn live_storage_inspection() { | 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,7 +1029,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() { |
| 1029 | let notebook = two_sections(directory.path()); | 1029 | let notebook = two_sections(directory.path()); |
| 1030 | let file = directory.path().join("Shared/Second.one"); | 1030 | let file = directory.path().join("Shared/Second.one"); |
| 1031 | let background = notebook | 1031 | let background = notebook |
| 1032 | .background(Duration::from_millis(20), || {}) | 1032 | .background(Duration::from_millis(20), false, || {}) |
| 1033 | .unwrap(); | 1033 | .unwrap(); |
| 1034 | background.set_offline(true); | 1034 | background.set_offline(true); |
| 1035 | background.watch(notebook.replicas()); | 1035 | background.watch(notebook.replicas()); |
| ... | @@ -1083,7 +1083,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { | ... | @@ -1083,7 +1083,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { |
| 1083 | let notified = Arc::new(AtomicUsize::new(0)); | 1083 | let notified = Arc::new(AtomicUsize::new(0)); |
| 1084 | let counter = Arc::clone(&notified); | 1084 | let counter = Arc::clone(&notified); |
| 1085 | let background = notebook | 1085 | let background = notebook |
| 1086 | .background(Duration::from_millis(20), move || { | 1086 | .background(Duration::from_millis(20), false, move || { |
| 1087 | counter.fetch_add(1, Ordering::SeqCst); | 1087 | counter.fetch_add(1, Ordering::SeqCst); |
| 1088 | }) | 1088 | }) |
| 1089 | .unwrap(); | 1089 | .unwrap(); |
| ... | @@ -1125,3 +1125,77 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { | ... | @@ -1125,3 +1125,77 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { |
| 1125 | }); | 1125 | }); |
| 1126 | assert_same(opened.unwrap().page(*space).unwrap(), native); | 1126 | assert_same(opened.unwrap().page(*space).unwrap(), native); |
| 1127 | } | 1127 | } |
| 1128 | |||
| 1129 | #[test] | ||
| 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,9 +405,12 @@ impl Revision<'_> { |
| 405 | 405 | ||
| 406 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | 406 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 407 | /// Tag dates count seconds since 1980-01-01 UTC; status retains the ActionItemStatus bits. | 407 | /// Tag dates count seconds since 1980-01-01 UTC; status retains the ActionItemStatus bits. |
| 408 | /// A task tag (status bit 4) has no definition and carries its own icon and property status. | ||
| 408 | pub struct Tag { | 409 | pub struct Tag { |
| 409 | pub definition: Option<ExGuid>, | 410 | pub definition: Option<ExGuid>, |
| 410 | pub action_type: Option<u16>, | 411 | pub action_type: Option<u16>, |
| 412 | pub shape: Option<u16>, | ||
| 413 | pub property_status: Option<u32>, | ||
| 411 | pub status: u16, | 414 | pub status: u16, |
| 412 | pub created: Option<u32>, | 415 | pub created: Option<u32>, |
| 413 | pub completed: Option<u32>, | 416 | pub completed: Option<u32>, |
| ... | @@ -446,6 +449,8 @@ pub enum Kind<'a> { | ... | @@ -446,6 +449,8 @@ pub enum Kind<'a> { |
| 446 | margin_origin_x: Option<f32>, | 449 | margin_origin_x: Option<f32>, |
| 447 | margin_origin_y: Option<f32>, | 450 | margin_origin_y: Option<f32>, |
| 448 | rtl: Option<bool>, | 451 | rtl: Option<bool>, |
| 452 | /// Every audio and video recording on the page (AudioRecordingGuids). | ||
| 453 | recordings: Vec<[u8; 16]>, | ||
| 449 | }, | 454 | }, |
| 450 | Metadata { | 455 | Metadata { |
| 451 | title: Option<String>, | 456 | title: Option<String>, |
| ... | @@ -475,11 +480,16 @@ pub enum Kind<'a> { | ... | @@ -475,11 +480,16 @@ pub enum Kind<'a> { |
| 475 | }, | 480 | }, |
| 476 | OutlineGroup, | 481 | OutlineGroup, |
| 477 | /// An ink drawing or handwriting container: strokes hang off `data`, nested containers off | 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 | Ink { | 486 | Ink { |
| 480 | data: Option<ExGuid>, | 487 | data: Option<ExGuid>, |
| 481 | scale_x: Option<f32>, | 488 | scale_x: Option<f32>, |
| 482 | scale_y: Option<f32>, | 489 | scale_y: Option<f32>, |
| 490 | shape_kind: Option<u8>, | ||
| 491 | line: Option<Vec<u8>>, | ||
| 492 | anchors: Option<Vec<u8>>, | ||
| 483 | }, | 493 | }, |
| 484 | InkData { | 494 | InkData { |
| 485 | strokes: Vec<ExGuid>, | 495 | strokes: Vec<ExGuid>, |
| ... | @@ -555,6 +565,8 @@ pub enum Kind<'a> { | ... | @@ -555,6 +565,8 @@ pub enum Kind<'a> { |
| 555 | source_path: Option<String>, | 565 | source_path: Option<String>, |
| 556 | recording_id: Option<[u8; 16]>, | 566 | recording_id: Option<[u8; 16]>, |
| 557 | recording_type: Option<u32>, | 567 | recording_type: Option<u32>, |
| 568 | /// AudioRecordingDuration, in milliseconds. | ||
| 569 | recording_duration: Option<u32>, | ||
| 558 | /// Displayed icon width and height in points. | 570 | /// Displayed icon width and height in points. |
| 559 | icon_width: Option<f32>, | 571 | icon_width: Option<f32>, |
| 560 | icon_height: Option<f32>, | 572 | icon_height: Option<f32>, |
| ... | @@ -675,6 +687,16 @@ impl<'a, 'o> Fields<'a, 'o> { | ... | @@ -675,6 +687,16 @@ impl<'a, 'o> Fields<'a, 'o> { |
| 675 | fn u32(&mut self, id: u32) -> Result<Option<u32>> { | 687 | fn u32(&mut self, id: u32) -> Result<Option<u32>> { |
| 676 | Ok(self.fixed(id)?.map(u32::from_le_bytes)) | 688 | Ok(self.fixed(id)?.map(u32::from_le_bytes)) |
| 677 | } | 689 | } |
| 690 | fn guids(&mut self, id: u32) -> Result<Vec<[u8; 16]>> { | ||
| 691 | let bytes = self.bytes(id)?.unwrap_or_default(); | ||
| 692 | if !bytes.len().is_multiple_of(16) { | ||
| 693 | return Err(invalid("Media identifiers have an invalid length")); | ||
| 694 | } | ||
| 695 | Ok(bytes | ||
| 696 | .chunks_exact(16) | ||
| 697 | .map(|id| id.try_into().unwrap()) | ||
| 698 | .collect()) | ||
| 699 | } | ||
| 678 | fn u16(&mut self, id: u32) -> Result<Option<u16>> { | 700 | fn u16(&mut self, id: u32) -> Result<Option<u16>> { |
| 679 | Ok(self.fixed(id)?.map(u16::from_le_bytes)) | 701 | Ok(self.fixed(id)?.map(u16::from_le_bytes)) |
| 680 | } | 702 | } |
| ... | @@ -1058,6 +1080,8 @@ impl<'a> Element<'a> { | ... | @@ -1058,6 +1080,8 @@ impl<'a> Element<'a> { |
| 1058 | tags.push(Tag { | 1080 | tags.push(Tag { |
| 1059 | definition: tag.one(0x20003488)?, | 1081 | definition: tag.one(0x20003488)?, |
| 1060 | action_type: tag.u16(0x10003463)?, | 1082 | action_type: tag.u16(0x10003463)?, |
| 1083 | shape: tag.u16(0x10003464)?, | ||
| 1084 | property_status: tag.u32(0x14003467)?, | ||
| 1061 | status: tag | 1085 | status: tag |
| 1062 | .u16(0x10003470)? | 1086 | .u16(0x10003470)? |
| 1063 | .ok_or_else(|| invalid("Note tag has no status"))?, | 1087 | .ok_or_else(|| invalid("Note tag has no status"))?, |
| ... | @@ -1114,6 +1138,7 @@ impl<'a> Element<'a> { | ... | @@ -1114,6 +1138,7 @@ impl<'a> Element<'a> { |
| 1114 | margin_origin_x: f.float(0x14001d0f, 36.0)?, | 1138 | margin_origin_x: f.float(0x14001d0f, 36.0)?, |
| 1115 | margin_origin_y: f.float(0x14001d10, 36.0)?, | 1139 | margin_origin_y: f.float(0x14001d10, 36.0)?, |
| 1116 | rtl: f.boolean(0x08001c92)?, | 1140 | rtl: f.boolean(0x08001c92)?, |
| 1141 | recordings: f.guids(0x1c001ca3)?, | ||
| 1117 | }, | 1142 | }, |
| 1118 | 0x20030 => Kind::Metadata { | 1143 | 0x20030 => Kind::Metadata { |
| 1119 | title: f.text(0x1c001cf3)?, | 1144 | title: f.text(0x1c001cf3)?, |
| ... | @@ -1128,6 +1153,9 @@ impl<'a> Element<'a> { | ... | @@ -1128,6 +1153,9 @@ impl<'a> Element<'a> { |
| 1128 | data: f.one(0x20003415)?, | 1153 | data: f.one(0x20003415)?, |
| 1129 | scale_x: f.float(0x14001c46, 1.0)?, | 1154 | scale_x: f.float(0x14001c46, 1.0)?, |
| 1130 | scale_y: f.float(0x14001c47, 1.0)?, | 1155 | scale_y: f.float(0x14001c47, 1.0)?, |
| 1156 | shape_kind: f.bytes(0x0c001d4f)?.and_then(|b| b.first().copied()), | ||
| 1157 | line: f.bytes(0x1c001dac)?.map(<[u8]>::to_vec), | ||
| 1158 | anchors: f.bytes(0x1c001daa)?.map(<[u8]>::to_vec), | ||
| 1131 | }, | 1159 | }, |
| 1132 | 0x2003b => Kind::InkData { | 1160 | 0x2003b => Kind::InkData { |
| 1133 | strokes: f.refs(0x24003416, IdStream::Objects)?, | 1161 | strokes: f.refs(0x24003416, IdStream::Objects)?, |
| ... | @@ -1327,6 +1355,7 @@ impl<'a> Element<'a> { | ... | @@ -1327,6 +1355,7 @@ impl<'a> Element<'a> { |
| 1327 | source_path: f.text(0x1c001d9d)?, | 1355 | source_path: f.text(0x1c001d9d)?, |
| 1328 | recording_id: f.fixed(0x1c001c97)?, | 1356 | recording_id: f.fixed(0x1c001c97)?, |
| 1329 | recording_type: f.u32(0x14001d24)?, | 1357 | recording_type: f.u32(0x14001d24)?, |
| 1358 | recording_duration: f.u32(0x14001cfd)?, | ||
| 1330 | icon_width: f.float(0x140034cd, 36.0)?, | 1359 | icon_width: f.float(0x140034cd, 36.0)?, |
| 1331 | icon_height: f.float(0x140034ce, 36.0)?, | 1360 | icon_height: f.float(0x140034ce, 36.0)?, |
| 1332 | }, | 1361 | }, |
| ... | @@ -1349,14 +1378,7 @@ impl<'a> Element<'a> { | ... | @@ -1349,14 +1378,7 @@ impl<'a> Element<'a> { |
| 1349 | }, | 1378 | }, |
| 1350 | _ => Kind::Unknown, | 1379 | _ => Kind::Unknown, |
| 1351 | }; | 1380 | }; |
| 1352 | let media_ids = f.bytes(0x1c001c98)?.unwrap_or_default(); | 1381 | let media_ids = f.guids(0x1c001c98)?; |
| 1353 | if !media_ids.len().is_multiple_of(16) { | ||
| 1354 | return Err(invalid("Media identifiers have an invalid length")); | ||
| 1355 | } | ||
| 1356 | let media_ids = media_ids | ||
| 1357 | .chunks_exact(16) | ||
| 1358 | .map(|id| id.try_into().unwrap()) | ||
| 1359 | .collect(); | ||
| 1360 | Ok(Self { | 1382 | Ok(Self { |
| 1361 | jcid: object.jcid, | 1383 | jcid: object.jcid, |
| 1362 | children, | 1384 | children, |
crates/onestore/src/op/apply.rs+82-17| ... | @@ -63,9 +63,16 @@ impl<'a> Section<'a> { | ... | @@ -63,9 +63,16 @@ impl<'a> Section<'a> { |
| 63 | .collect(); | 63 | .collect(); |
| 64 | for space in spaces { | 64 | for space in spaces { |
| 65 | // A page the edit went on to delete has no cells left to check. | 65 | // A page the edit went on to delete has no cells left to check. |
| 66 | let Ok(page) = section.active(space) else { | 66 | if section.active(space).is_err() { |
| 67 | continue; | 67 | continue; |
| 68 | }; | 68 | } |
| 69 | Writer { | ||
| 70 | section: &mut *section, | ||
| 71 | space, | ||
| 72 | author, | ||
| 73 | } | ||
| 74 | .playlist()?; | ||
| 75 | let page = section.active(space)?; | ||
| 69 | if page.view.nodes.values().any(|node| { | 76 | if page.view.nodes.values().any(|node| { |
| 70 | matches!(node.kind, Kind::Cell { .. }) && node.children.is_empty() | 77 | matches!(node.kind, Kind::Cell { .. }) && node.children.is_empty() |
| 71 | }) { | 78 | }) { |
| ... | @@ -474,6 +481,41 @@ impl<'a> Writer<'_, 'a> { | ... | @@ -474,6 +481,41 @@ impl<'a> Writer<'_, 'a> { |
| 474 | Ok(()) | 481 | Ok(()) |
| 475 | } | 482 | } |
| 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 | /// Requires `id` reachable on the page. | 519 | /// Requires `id` reachable on the page. |
| 478 | fn target(&mut self, id: ExGuid) -> Result<(), Failure> { | 520 | fn target(&mut self, id: ExGuid) -> Result<(), Failure> { |
| 479 | let page = self.page()?; | 521 | let page = self.page()?; |
| ... | @@ -746,6 +788,33 @@ impl<'a> Writer<'_, 'a> { | ... | @@ -746,6 +788,33 @@ impl<'a> Writer<'_, 'a> { |
| 746 | let text = self.text_of(*paragraph)?; | 788 | let text = self.text_of(*paragraph)?; |
| 747 | self.write(|page| properties::style_changes(page, text, *style, Some(definition))) | 789 | self.write(|page| properties::style_changes(page, text, *style, Some(definition))) |
| 748 | } | 790 | } |
| 791 | PageOp::Media { paragraph, media } => { | ||
| 792 | self.target(*paragraph)?; | ||
| 793 | let page = self.page()?; | ||
| 794 | let content = match page.view.nodes[paragraph].content.as_slice() { | ||
| 795 | [content] | ||
| 796 | if matches!( | ||
| 797 | page.view.nodes[content].kind, | ||
| 798 | Kind::RichText { .. } | Kind::Attachment { .. } | ||
| 799 | ) => | ||
| 800 | { | ||
| 801 | *content | ||
| 802 | } | ||
| 803 | _ => { | ||
| 804 | return Err(OpError::Unsupported( | ||
| 805 | "Link a paragraph of text or a file to a recording", | ||
| 806 | ) | ||
| 807 | .into()); | ||
| 808 | } | ||
| 809 | }; | ||
| 810 | if media.recordings.is_empty() != media.time_ms.is_none() { | ||
| 811 | return Err(OpError::Unsupported( | ||
| 812 | "A recording link names recordings and a moment in them", | ||
| 813 | ) | ||
| 814 | .into()); | ||
| 815 | } | ||
| 816 | self.write(|page| properties::media_changes(page, content, media)) | ||
| 817 | } | ||
| 749 | PageOp::List { paragraph, lists } => { | 818 | PageOp::List { paragraph, lists } => { |
| 750 | self.target(*paragraph)?; | 819 | self.target(*paragraph)?; |
| 751 | let page = self.page()?; | 820 | let page = self.page()?; |
| ... | @@ -772,17 +841,16 @@ impl<'a> Writer<'_, 'a> { | ... | @@ -772,17 +841,16 @@ impl<'a> Writer<'_, 'a> { |
| 772 | definitions, | 841 | definitions, |
| 773 | } => { | 842 | } => { |
| 774 | self.target(*target)?; | 843 | self.target(*target)?; |
| 775 | let mut entries = Vec::new(); | 844 | let entries: Vec<_> = tags |
| 776 | for tag in tags { | 845 | .iter() |
| 777 | let definition = tag | 846 | .map(|tag| { |
| 778 | .definition | 847 | let model = definitions |
| 779 | .ok_or(OpError::Unsupported("A note tag names its definition"))?; | 848 | .iter() |
| 780 | let model = definitions | 849 | .find(|(id, _)| Some(*id) == tag.definition) |
| 781 | .iter() | 850 | .map(|(_, definition)| definition); |
| 782 | .find(|(id, _)| *id == definition) | 851 | (tag, model) |
| 783 | .map(|(_, definition)| definition); | 852 | }) |
| 784 | entries.push((definition, tag, model)); | 853 | .collect(); |
| 785 | } | ||
| 786 | self.write(|page| properties::tag_changes(page, *target, &entries)) | 854 | self.write(|page| properties::tag_changes(page, *target, &entries)) |
| 787 | } | 855 | } |
| 788 | PageOp::Add { object, before } => self.add(object, *before), | 856 | PageOp::Add { object, before } => self.add(object, *before), |
| ... | @@ -1449,7 +1517,7 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> { | ... | @@ -1449,7 +1517,7 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> { |
| 1449 | } | 1517 | } |
| 1450 | if paragraph.media != Default::default() { | 1518 | if paragraph.media != Default::default() { |
| 1451 | return Err(OpError::Unsupported( | 1519 | return Err(OpError::Unsupported( |
| 1452 | "Recording annotations are made by OneNote while it records", | 1520 | "Link a paragraph to a recording with its own op", |
| 1453 | )); | 1521 | )); |
| 1454 | } | 1522 | } |
| 1455 | match &paragraph.content { | 1523 | match &paragraph.content { |
| ... | @@ -1470,9 +1538,6 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> { | ... | @@ -1470,9 +1538,6 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> { |
| 1470 | } | 1538 | } |
| 1471 | Ok(()) | 1539 | Ok(()) |
| 1472 | } | 1540 | } |
| 1473 | ParagraphContent::Attachment(attachment) if attachment.recording.is_some() => Err( | ||
| 1474 | OpError::Unsupported("Recordings are captured by OneNote, not inserted"), | ||
| 1475 | ), | ||
| 1476 | _ => Ok(()), | 1541 | _ => Ok(()), |
| 1477 | } | 1542 | } |
| 1478 | } | 1543 | } |
crates/onestore/src/op/content.rs+124-40| ... | @@ -6,7 +6,7 @@ use crate::{ | ... | @@ -6,7 +6,7 @@ use crate::{ |
| 6 | Error, ExGuid, | 6 | Error, ExGuid, |
| 7 | active::{ActivePage, Changes}, | 7 | active::{ActivePage, Changes}, |
| 8 | document::{Format, Layout}, | 8 | document::{Format, Layout}, |
| 9 | page::{Attachment, Image, Ink, InkStroke, Paragraph}, | 9 | page::{Attachment, Image, Ink, InkStroke, Paragraph, Recording}, |
| 10 | write::PropertyObject, | 10 | write::PropertyObject, |
| 11 | }; | 11 | }; |
| 12 | use std::collections::BTreeMap; | 12 | use std::collections::BTreeMap; |
| ... | @@ -319,8 +319,10 @@ pub(crate) fn attachment_changes( | ... | @@ -319,8 +319,10 @@ pub(crate) fn attachment_changes( |
| 319 | "An attachment needs a file name without path separators", | 319 | "An attachment needs a file name without path separators", |
| 320 | )); | 320 | )); |
| 321 | } | 321 | } |
| 322 | if attachment.recording.is_some() { | 322 | if Recording::kind_of(name).is_some() != attachment.recording.is_some() { |
| 323 | return Err(invalid("Recordings are captured by OneNote, not inserted")); | 323 | return Err(invalid( |
| 324 | "Audio and video files are recordings, and only they are", | ||
| 325 | )); | ||
| 324 | } | 326 | } |
| 325 | if attachment.bytes.is_none() { | 327 | if attachment.bytes.is_none() { |
| 326 | return Err(invalid("A new attachment needs its payload")); | 328 | return Err(invalid("A new attachment needs its payload")); |
| ... | @@ -374,6 +376,19 @@ pub(crate) fn attachment_changes( | ... | @@ -374,6 +376,19 @@ pub(crate) fn attachment_changes( |
| 374 | if let Some(path) = &attachment.source_path { | 376 | if let Some(path) = &attachment.source_path { |
| 375 | values.push((0x1c001d9d, crate::create::string(path))); | 377 | values.push((0x1c001d9d, crate::create::string(path))); |
| 376 | } | 378 | } |
| 379 | if let Some(recording) = attachment.recording { | ||
| 380 | if recording.id == [0; 16] || !matches!(recording.kind, 1 | 2) { | ||
| 381 | return Err(invalid( | ||
| 382 | "A recording needs an identity and is audio or video", | ||
| 383 | )); | ||
| 384 | } | ||
| 385 | // AudioRecordingGuid, IRecordMedia and AudioRecordingDuration. | ||
| 386 | values.push((0x1c001c97, recording.id.to_vec())); | ||
| 387 | values.push((0x14001d24, recording.kind.to_le_bytes().to_vec())); | ||
| 388 | if let Some(duration) = recording.duration_ms { | ||
| 389 | values.push((0x14001cfd, duration.to_le_bytes().to_vec())); | ||
| 390 | } | ||
| 391 | } | ||
| 377 | values.extend(layout_values(&Default::default(), &attachment.layout)?.0); | 392 | values.extend(layout_values(&Default::default(), &attachment.layout)?.0); |
| 378 | let mut changed = BTreeMap::new(); | 393 | let mut changed = BTreeMap::new(); |
| 379 | let mut file = PropertyObject::file(ids.file, &payload_reference(ids.payload), extension)?; | 394 | let mut file = PropertyObject::file(ids.file, &payload_reference(ids.payload), extension)?; |
| ... | @@ -400,6 +415,21 @@ pub(crate) fn attachment_changes( | ... | @@ -400,6 +415,21 @@ pub(crate) fn attachment_changes( |
| 400 | Ok(changed) | 415 | Ok(changed) |
| 401 | } | 416 | } |
| 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 | /// A stored attachment keeps its payload and preview; its shown name, recorded source | 433 | /// A stored attachment keeps its payload and preview; its shown name, recorded source |
| 404 | /// path and icon size change in place, as OneNote's rename does. | 434 | /// path and icon size change in place, as OneNote's rename does. |
| 405 | pub(crate) fn attachment_edit_changes( | 435 | pub(crate) fn attachment_edit_changes( |
| ... | @@ -415,7 +445,7 @@ pub(crate) fn attachment_edit_changes( | ... | @@ -415,7 +445,7 @@ pub(crate) fn attachment_edit_changes( |
| 415 | .to_vec(), | 445 | .to_vec(), |
| 416 | )]; | 446 | )]; |
| 417 | if attachment.recording != stored.recording { | 447 | if attachment.recording != stored.recording { |
| 418 | return Err(invalid("A recording stays the recording OneNote captured")); | 448 | return Err(invalid("A file keeps its recording")); |
| 419 | } | 449 | } |
| 420 | let name = attachment.filename.as_str(); | 450 | let name = attachment.filename.as_str(); |
| 421 | if name.is_empty() || name.contains(['\0', '/', '\\']) { | 451 | if name.is_empty() || name.contains(['\0', '/', '\\']) { |
| ... | @@ -425,6 +455,11 @@ pub(crate) fn attachment_edit_changes( | ... | @@ -425,6 +455,11 @@ pub(crate) fn attachment_edit_changes( |
| 425 | } | 455 | } |
| 426 | let mut removed = Vec::new(); | 456 | let mut removed = Vec::new(); |
| 427 | if attachment.filename != stored.filename { | 457 | if attachment.filename != stored.filename { |
| 458 | if Recording::kind_of(name).is_some() != attachment.recording.is_some() { | ||
| 459 | return Err(invalid( | ||
| 460 | "Audio and video files are recordings, and only they are", | ||
| 461 | )); | ||
| 462 | } | ||
| 428 | let name = crate::create::string(&attachment.filename); | 463 | let name = crate::create::string(&attachment.filename); |
| 429 | values.push((0x1c001c22, name.clone())); | 464 | values.push((0x1c001c22, name.clone())); |
| 430 | values.push((0x1c001d9c, name)); | 465 | values.push((0x1c001d9c, name)); |
| ... | @@ -457,19 +492,31 @@ pub(crate) fn attachment_edit_changes( | ... | @@ -457,19 +492,31 @@ pub(crate) fn attachment_edit_changes( |
| 457 | Ok(BTreeMap::from([(object, node)])) | 492 | Ok(BTreeMap::from([(object, node)])) |
| 458 | } | 493 | } |
| 459 | 494 | ||
| 460 | /// Creates stroke objects (numbered from `first`) and one drawing-attribute object per | 495 | /// Creates stroke objects (numbered on from the page's last stroke) and one |
| 461 | /// distinct pen; returns the compact references for the data node's stroke list. | 496 | /// drawing-attribute object per distinct pen; returns the compact references for the data |
| 497 | /// node's stroke list. A shape's strokes carry only their path and pen, as OneNote's do. | ||
| 462 | fn write_strokes( | 498 | fn write_strokes( |
| 499 | active: &ActivePage<'_>, | ||
| 463 | changed: &mut Changes, | 500 | changed: &mut Changes, |
| 464 | data_object: &mut PropertyObject, | 501 | data_object: &mut PropertyObject, |
| 465 | strokes: &[(ExGuid, &InkStroke)], | 502 | strokes: &[(ExGuid, &InkStroke)], |
| 466 | first: usize, | 503 | shape: bool, |
| 467 | filetime: u64, | 504 | filetime: u64, |
| 468 | ) -> Result<Vec<u8>, Error> { | 505 | ) -> Result<Vec<u8>, Error> { |
| 506 | let first = active | ||
| 507 | .view | ||
| 508 | .nodes | ||
| 509 | .values() | ||
| 510 | .filter_map(|node| match node.kind { | ||
| 511 | crate::document::Kind::InkStroke { index, .. } => index, | ||
| 512 | _ => None, | ||
| 513 | }) | ||
| 514 | .max() | ||
| 515 | .unwrap_or(0); | ||
| 469 | let mut styles: Vec<(InkPen, ExGuid)> = Vec::new(); | 516 | let mut styles: Vec<(InkPen, ExGuid)> = Vec::new(); |
| 470 | let mut references = Vec::new(); | 517 | let mut references = Vec::new(); |
| 471 | for (offset, (stroke_id, stroke)) in strokes.iter().enumerate() { | 518 | for (offset, (stroke_id, stroke)) in strokes.iter().enumerate() { |
| 472 | let pen = InkPen::of(stroke); | 519 | let pen = InkPen::of(stroke, shape); |
| 473 | let style = match styles.iter().find(|(known, _)| *known == pen) { | 520 | let style = match styles.iter().find(|(known, _)| *known == pen) { |
| 474 | Some((_, id)) => *id, | 521 | Some((_, id)) => *id, |
| 475 | None => { | 522 | None => { |
| ... | @@ -488,13 +535,14 @@ fn write_strokes( | ... | @@ -488,13 +535,14 @@ fn write_strokes( |
| 488 | id | 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 | let mut object = PropertyObject { | 543 | let mut object = PropertyObject { |
| 492 | jcid: 0x20047, | 544 | jcid: 0x20047, |
| 493 | bytes: crate::create::properties(&stroke_values( | 545 | bytes: crate::create::properties(&values)?, |
| 494 | stroke, | ||
| 495 | (first + offset) as u32 + 1, | ||
| 496 | filetime, | ||
| 497 | )?)?, | ||
| 498 | global_ids: std::sync::Arc::new(BTreeMap::from([(0, stroke_id.guid)])), | 546 | global_ids: std::sync::Arc::new(BTreeMap::from([(0, stroke_id.guid)])), |
| 499 | }; | 547 | }; |
| 500 | object.reference(*stroke_id)?; | 548 | object.reference(*stroke_id)?; |
| ... | @@ -506,7 +554,8 @@ fn write_strokes( | ... | @@ -506,7 +554,8 @@ fn write_strokes( |
| 506 | Ok(references) | 554 | Ok(references) |
| 507 | } | 555 | } |
| 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 | #[derive(PartialEq)] | 559 | #[derive(PartialEq)] |
| 511 | struct InkPen { | 560 | struct InkPen { |
| 512 | width: u32, | 561 | width: u32, |
| ... | @@ -514,22 +563,31 @@ struct InkPen { | ... | @@ -514,22 +563,31 @@ struct InkPen { |
| 514 | color: Option<u32>, | 563 | color: Option<u32>, |
| 515 | transparency: Option<u8>, | 564 | transparency: Option<u8>, |
| 516 | pen_tip: Option<u8>, | 565 | pen_tip: Option<u8>, |
| 566 | raster_operation: Option<u8>, | ||
| 567 | shape: bool, | ||
| 517 | } | 568 | } |
| 518 | 569 | ||
| 519 | impl InkPen { | 570 | impl InkPen { |
| 520 | fn of(stroke: &InkStroke) -> Self { | 571 | fn of(stroke: &InkStroke, shape: bool) -> Self { |
| 521 | Self { | 572 | Self { |
| 522 | width: (stroke.width * 2540.0 / 72.0).to_bits(), | 573 | width: (stroke.width * 2540.0 / 72.0).to_bits(), |
| 523 | height: (stroke.height * 2540.0 / 72.0).to_bits(), | 574 | height: (stroke.height * 2540.0 / 72.0).to_bits(), |
| 524 | color: stroke.color, | 575 | color: stroke.color, |
| 525 | transparency: stroke.transparency, | 576 | transparency: stroke.transparency, |
| 526 | pen_tip: stroke.pen_tip, | 577 | pen_tip: stroke.pen_tip, |
| 578 | raster_operation: stroke.raster_operation, | ||
| 579 | shape, | ||
| 527 | } | 580 | } |
| 528 | } | 581 | } |
| 529 | 582 | ||
| 530 | fn values(&self) -> Values { | 583 | fn values(&self) -> Values { |
| 584 | let dimensions = if self.shape { | ||
| 585 | crate::page::ink::SHAPE_DIMENSIONS | ||
| 586 | } else { | ||
| 587 | crate::page::ink::DIMENSIONS | ||
| 588 | }; | ||
| 531 | let mut values: Values = vec![ | 589 | let mut values: Values = vec![ |
| 532 | (0x1c00340a, crate::page::ink::DIMENSIONS.to_vec()), | 590 | (0x1c00340a, dimensions.to_vec()), |
| 533 | (0x1400340c, self.height.to_le_bytes().to_vec()), | 591 | (0x1400340c, self.height.to_le_bytes().to_vec()), |
| 534 | (0x1400340d, self.width.to_le_bytes().to_vec()), | 592 | (0x1400340d, self.width.to_le_bytes().to_vec()), |
| 535 | ]; | 593 | ]; |
| ... | @@ -542,11 +600,17 @@ impl InkPen { | ... | @@ -542,11 +600,17 @@ impl InkPen { |
| 542 | if let Some(tip) = self.pen_tip { | 600 | if let Some(tip) = self.pen_tip { |
| 543 | values.push((0x0c003412, vec![tip])); | 601 | values.push((0x0c003412, vec![tip])); |
| 544 | } | 602 | } |
| 603 | if let Some(operation) = self.raster_operation { | ||
| 604 | values.push((0x0c003413, vec![operation])); | ||
| 605 | } | ||
| 606 | if !self.shape { | ||
| 607 | values.push((0x88003411, Vec::new())); | ||
| 608 | } | ||
| 545 | values | 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 | if stroke.points.is_empty() { | 614 | if stroke.points.is_empty() { |
| 551 | return Err(invalid("A stroke needs at least one point")); | 615 | return Err(invalid("A stroke needs at least one point")); |
| 552 | } | 616 | } |
| ... | @@ -562,27 +626,32 @@ fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values | ... | @@ -562,27 +626,32 @@ fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values |
| 562 | "Stroke points and pen size must be finite and positive", | 626 | "Stroke points and pen size must be finite and positive", |
| 563 | )); | 627 | )); |
| 564 | } | 628 | } |
| 565 | let left = stroke | 629 | Ok(vec![(0x1c00340b, stroke.packet())]) |
| 566 | .points | 630 | } |
| 567 | .iter() | 631 | |
| 568 | .map(|p| p[0]) | 632 | /// A drawn stroke as OneNote stores one: its path, index on the page, language, identity, |
| 569 | .fold(f32::INFINITY, f32::min); | 633 | /// time and half-inch origin, and a bias of 2 for a highlighter. |
| 570 | let top = stroke | 634 | fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values, Error> { |
| 571 | .points | 635 | let mut values = stroke_path(stroke)?; |
| 572 | .iter() | 636 | let [left, top] = [0, 1].map(|axis| { |
| 573 | .map(|p| p[1]) | 637 | stroke |
| 574 | .fold(f32::INFINITY, f32::min); | 638 | .points |
| 639 | .iter() | ||
| 640 | .map(|p| p[axis]) | ||
| 641 | .fold(f32::INFINITY, f32::min) | ||
| 642 | }); | ||
| 575 | let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec(); | 643 | let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec(); |
| 576 | origin.extend_from_slice(&((top - stroke.height / 2.0) / 36.0).to_le_bytes()); | 644 | origin.extend_from_slice(&((top - stroke.height / 2.0) / 36.0).to_le_bytes()); |
| 577 | Ok(vec![ | 645 | let bias = if stroke.raster_operation == Some(9) { 2 } else { 0 }; |
| 578 | (0x1c00340b, stroke.packet()), | 646 | values.extend([ |
| 579 | (0x14003419, index.to_le_bytes().to_vec()), | 647 | (0x14003419, index.to_le_bytes().to_vec()), |
| 580 | (0x1000341b, 0x409_u16.to_le_bytes().to_vec()), | 648 | (0x1000341b, 0x409_u16.to_le_bytes().to_vec()), |
| 581 | (0x0c00341c, vec![0]), | 649 | (0x0c00341c, vec![bias]), |
| 582 | (0x1c00341a, crate::write::fresh_guid()?.to_vec()), | 650 | (0x1c00341a, crate::write::fresh_guid()?.to_vec()), |
| 583 | (0x1c00341d, filetime.to_le_bytes().to_vec()), | 651 | (0x1c00341d, filetime.to_le_bytes().to_vec()), |
| 584 | (0x1c00345b, origin), | 652 | (0x1c00345b, origin), |
| 585 | ]) | 653 | ]); |
| 654 | Ok(values) | ||
| 586 | } | 655 | } |
| 587 | 656 | ||
| 588 | /// New ink as OneNote 2010 stores a drawing: container `id` that the page lists as a child | 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,15 +683,27 @@ pub(crate) fn ink_changes( |
| 614 | global_ids: std::sync::Arc::new(BTreeMap::from([(0, data.guid)])), | 683 | global_ids: std::sync::Arc::new(BTreeMap::from([(0, data.guid)])), |
| 615 | }; | 684 | }; |
| 616 | data_object.reference(data)?; | 685 | data_object.reference(data)?; |
| 617 | let references = write_strokes(&mut changed, &mut data_object, strokes, 0, filetime)?; | 686 | let references = write_strokes( |
| 687 | active, | ||
| 688 | &mut changed, | ||
| 689 | &mut data_object, | ||
| 690 | strokes, | ||
| 691 | ink.shape.is_some(), | ||
| 692 | filetime, | ||
| 693 | )?; | ||
| 618 | data_object.set(&[(0x24003416, &references)])?; | 694 | data_object.set(&[(0x24003416, &references)])?; |
| 619 | changed.insert(data, data_object); | 695 | changed.insert(data, data_object); |
| 696 | // A shape keeps its kind and geometry beside the strokes, and counts as a second kind of | ||
| 697 | // drawing. | ||
| 698 | let mut values: Values = vec![(0x14001d7a, modified.to_vec())]; | ||
| 699 | if let Some(shape) = &ink.shape { | ||
| 700 | let (kind, property, bytes) = shape.stored(); | ||
| 701 | values.extend([(0x0c001d4f, vec![kind]), (property, bytes)]); | ||
| 702 | } | ||
| 703 | values.push((0x14001d4e, (1 + u32::from(ink.shape.is_some())).to_le_bytes().to_vec())); | ||
| 620 | let mut object = PropertyObject { | 704 | let mut object = PropertyObject { |
| 621 | jcid: 0x60014, | 705 | jcid: 0x60014, |
| 622 | bytes: crate::create::properties(&[ | 706 | bytes: crate::create::properties(&values)?, |
| 623 | (0x14001d7a, modified.to_vec()), | ||
| 624 | (0x14001d4e, 1u32.to_le_bytes().to_vec()), | ||
| 625 | ])?, | ||
| 626 | global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), | 707 | global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), |
| 627 | }; | 708 | }; |
| 628 | object.reference(id)?; | 709 | object.reference(id)?; |
| ... | @@ -641,10 +722,12 @@ pub(crate) fn strokes_changes( | ... | @@ -641,10 +722,12 @@ pub(crate) fn strokes_changes( |
| 641 | kept: &[ExGuid], | 722 | kept: &[ExGuid], |
| 642 | added: &[(ExGuid, &InkStroke)], | 723 | added: &[(ExGuid, &InkStroke)], |
| 643 | ) -> Result<Changes, Error> { | 724 | ) -> Result<Changes, Error> { |
| 644 | let data = match active.view.nodes.get(&container).map(|node| &node.kind) { | 725 | let (data, shape) = match active.view.nodes.get(&container).map(|node| &node.kind) { |
| 645 | Some(crate::document::Kind::Ink { | 726 | Some(crate::document::Kind::Ink { |
| 646 | data: Some(data), .. | 727 | data: Some(data), |
| 647 | }) => *data, | 728 | shape_kind, |
| 729 | .. | ||
| 730 | }) => (*data, shape_kind.is_some()), | ||
| 648 | _ => return Err(invalid("Stored ink has no stroke data to rewrite")), | 731 | _ => return Err(invalid("Stored ink has no stroke data to rewrite")), |
| 649 | }; | 732 | }; |
| 650 | let (modified, filetime) = crate::create::current_timestamps()?; | 733 | let (modified, filetime) = crate::create::current_timestamps()?; |
| ... | @@ -657,10 +740,11 @@ pub(crate) fn strokes_changes( | ... | @@ -657,10 +740,11 @@ pub(crate) fn strokes_changes( |
| 657 | references.extend(data_object.reference(*id)?); | 740 | references.extend(data_object.reference(*id)?); |
| 658 | } | 741 | } |
| 659 | references.extend(write_strokes( | 742 | references.extend(write_strokes( |
| 743 | active, | ||
| 660 | &mut changed, | 744 | &mut changed, |
| 661 | &mut data_object, | 745 | &mut data_object, |
| 662 | added, | 746 | added, |
| 663 | kept.len(), | 747 | shape, |
| 664 | filetime, | 748 | filetime, |
| 665 | )?); | 749 | )?); |
| 666 | data_object.set(&[(0x24003416, &references)])?; | 750 | data_object.set(&[(0x24003416, &references)])?; |
crates/onestore/src/op/lower.rs+27-13| ... | @@ -225,7 +225,7 @@ pub(crate) fn collect_containers(list: &[PageParagraph], out: &mut Vec<ExGuid>) | ... | @@ -225,7 +225,7 @@ pub(crate) fn collect_containers(list: &[PageParagraph], out: &mut Vec<ExGuid>) |
| 225 | 225 | ||
| 226 | /// What the writers keep as stored, checked before any op: titles, unsupported objects, | 226 | /// What the writers keep as stored, checked before any op: titles, unsupported objects, |
| 227 | /// definitions other than lists and tags, outline roles, paragraph styles and formats, | 227 | /// definitions other than lists and tags, outline roles, paragraph styles and formats, |
| 228 | /// recording links, fields and content types. | 228 | /// fields and content types. |
| 229 | pub(crate) fn validate( | 229 | pub(crate) fn validate( |
| 230 | before: &Page, | 230 | before: &Page, |
| 231 | after: &Page, | 231 | after: &Page, |
| ... | @@ -309,17 +309,13 @@ pub(crate) fn validate( | ... | @@ -309,17 +309,13 @@ pub(crate) fn validate( |
| 309 | let Some(previous) = old.paragraphs.get(id) else { | 309 | let Some(previous) = old.paragraphs.get(id) else { |
| 310 | continue; | 310 | continue; |
| 311 | }; | 311 | }; |
| 312 | // A join moves a text object, with its style and recording link, into an emptied | 312 | // A join moves a text object, with its style, into an emptied paragraph. |
| 313 | // paragraph; one new to the page starts as a copy without the link. | ||
| 314 | let owner = paragraph.text().map(|text| owners.get(&text.id)); | 313 | let owner = paragraph.text().map(|text| owners.get(&text.id)); |
| 315 | let (style, media) = match owner { | 314 | let style = match owner { |
| 316 | Some(Some(owner)) => (owner.style, owner.media.clone()), | 315 | Some(Some(owner)) => owner.style, |
| 317 | Some(None) => (paragraph.style, Default::default()), | 316 | Some(None) => paragraph.style, |
| 318 | None => (previous.style, previous.media.clone()), | 317 | None => previous.style, |
| 319 | }; | 318 | }; |
| 320 | if paragraph.media != media { | ||
| 321 | return Err(invalid("Recording annotations cannot be edited")); | ||
| 322 | } | ||
| 323 | if paragraph.style != style || paragraph.format != previous.format { | 319 | if paragraph.style != style || paragraph.format != previous.format { |
| 324 | return Err(invalid( | 320 | return Err(invalid( |
| 325 | "Paragraph styles and paragraph formatting cannot be edited", | 321 | "Paragraph styles and paragraph formatting cannot be edited", |
| ... | @@ -695,6 +691,8 @@ pub(crate) fn same_tags(a: &[Tag], b: &[Tag]) -> bool { | ... | @@ -695,6 +691,8 @@ pub(crate) fn same_tags(a: &[Tag], b: &[Tag]) -> bool { |
| 695 | ( | 691 | ( |
| 696 | t.definition, | 692 | t.definition, |
| 697 | t.action_type, | 693 | t.action_type, |
| 694 | t.shape, | ||
| 695 | t.property_status, | ||
| 698 | t.status, | 696 | t.status, |
| 699 | t.created, | 697 | t.created, |
| 700 | t.completed, | 698 | t.completed, |
| ... | @@ -745,6 +743,7 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph { | ... | @@ -745,6 +743,7 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph { |
| 745 | stripped.lists.clear(); | 743 | stripped.lists.clear(); |
| 746 | stripped.tags.clear(); | 744 | stripped.tags.clear(); |
| 747 | stripped.collapsed = false; | 745 | stripped.collapsed = false; |
| 746 | stripped.media = Default::default(); | ||
| 748 | match &mut stripped.content { | 747 | match &mut stripped.content { |
| 749 | ParagraphContent::Text(text) => text.tags.clear(), | 748 | ParagraphContent::Text(text) => text.tags.clear(), |
| 750 | ParagraphContent::Table(table) => { | 749 | ParagraphContent::Table(table) => { |
| ... | @@ -820,6 +819,7 @@ impl Lowering { | ... | @@ -820,6 +819,7 @@ impl Lowering { |
| 820 | self.styles(after, new)?; | 819 | self.styles(after, new)?; |
| 821 | self.lists(after, new)?; | 820 | self.lists(after, new)?; |
| 822 | self.tags(after, new)?; | 821 | self.tags(after, new)?; |
| 822 | self.media(new)?; | ||
| 823 | self.paragraph_formatting(new)?; | 823 | self.paragraph_formatting(new)?; |
| 824 | self.formatting(old, new)?; | 824 | self.formatting(old, new)?; |
| 825 | self.layout(old, new)?; | 825 | self.layout(old, new)?; |
| ... | @@ -1775,9 +1775,9 @@ impl Lowering { | ... | @@ -1775,9 +1775,9 @@ impl Lowering { |
| 1775 | } | 1775 | } |
| 1776 | let mut definitions = Vec::new(); | 1776 | let mut definitions = Vec::new(); |
| 1777 | for tag in tags { | 1777 | for tag in tags { |
| 1778 | let definition = tag | 1778 | let Some(definition) = tag.definition else { |
| 1779 | .definition | 1779 | continue; |
| 1780 | .ok_or_else(|| invalid("A note tag names its definition"))?; | 1780 | }; |
| 1781 | if let Some(model) = after.definitions.get(&definition) | 1781 | if let Some(model) = after.definitions.get(&definition) |
| 1782 | && !definitions.iter().any(|(id, _)| *id == definition) | 1782 | && !definitions.iter().any(|(id, _)| *id == definition) |
| 1783 | { | 1783 | { |
| ... | @@ -1794,6 +1794,20 @@ impl Lowering { | ... | @@ -1794,6 +1794,20 @@ impl Lowering { |
| 1794 | Ok(()) | 1794 | Ok(()) |
| 1795 | } | 1795 | } |
| 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 | fn paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { | 1811 | fn paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { |
| 1798 | for (id, paragraph) in &new.paragraphs { | 1812 | for (id, paragraph) in &new.paragraphs { |
| 1799 | let Some(text) = paragraph.text() else { | 1813 | let Some(text) = paragraph.text() else { |
crates/onestore/src/op/mod.rs+8-2| ... | @@ -6,8 +6,8 @@ use crate::{ | ... | @@ -6,8 +6,8 @@ use crate::{ |
| 6 | ExGuid, OutlineEdit, PageCreation, PageEdit, TextAttribute, | 6 | ExGuid, OutlineEdit, PageCreation, PageEdit, TextAttribute, |
| 7 | document::{Layout, Tag}, | 7 | document::{Layout, Tag}, |
| 8 | page::{ | 8 | page::{ |
| 9 | Definition, InkStroke, Page, PageObject, PageParagraph, Paragraph, TableCell, TableColumn, | 9 | Definition, InkStroke, MediaIndex, Page, PageObject, PageParagraph, Paragraph, TableCell, |
| 10 | TableRow, | 10 | TableColumn, TableRow, |
| 11 | }, | 11 | }, |
| 12 | }; | 12 | }; |
| 13 | use serde::{Deserialize, Serialize}; | 13 | use serde::{Deserialize, Serialize}; |
| ... | @@ -151,6 +151,12 @@ pub enum PageOp { | ... | @@ -151,6 +151,12 @@ pub enum PageOp { |
| 151 | style: ExGuid, | 151 | style: ExGuid, |
| 152 | definition: Definition, | 152 | definition: Definition, |
| 153 | }, | 153 | }, |
| 154 | /// Links a paragraph's text or file to a moment in recordings on the page, or with | ||
| 155 | /// empty `media` unlinks it. | ||
| 156 | Media { | ||
| 157 | paragraph: ExGuid, | ||
| 158 | media: MediaIndex, | ||
| 159 | }, | ||
| 154 | /// Replaces a paragraph's list nodes; a node another paragraph owns is copied. | 160 | /// Replaces a paragraph's list nodes; a node another paragraph owns is copied. |
| 155 | List { | 161 | List { |
| 156 | paragraph: ExGuid, | 162 | paragraph: ExGuid, |
crates/onestore/src/op/model.rs+16| ... | @@ -468,6 +468,7 @@ fn stroked(stroke: &crate::page::InkStroke) -> crate::page::InkStroke { | ... | @@ -468,6 +468,7 @@ fn stroked(stroke: &crate::page::InkStroke) -> crate::page::InkStroke { |
| 468 | fn inked(ink: &crate::page::Ink) -> crate::page::Ink { | 468 | fn inked(ink: &crate::page::Ink) -> crate::page::Ink { |
| 469 | crate::page::Ink { | 469 | crate::page::Ink { |
| 470 | strokes: ink.strokes.iter().map(stroked).collect(), | 470 | strokes: ink.strokes.iter().map(stroked).collect(), |
| 471 | shape: ink.shape.as_ref().map(crate::page::ink::InkShape::snapped), | ||
| 471 | ..ink.clone() | 472 | ..ink.clone() |
| 472 | } | 473 | } |
| 473 | } | 474 | } |
| ... | @@ -1042,6 +1043,21 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { | ... | @@ -1042,6 +1043,21 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { |
| 1042 | text.text = Paragraph::from_runs(runs); | 1043 | text.text = Paragraph::from_runs(runs); |
| 1043 | } | 1044 | } |
| 1044 | } | 1045 | } |
| 1046 | PageOp::Media { paragraph, media } => { | ||
| 1047 | let paragraph = paragraph_mut(page, *paragraph)?; | ||
| 1048 | if !matches!( | ||
| 1049 | paragraph.content, | ||
| 1050 | ParagraphContent::Text(_) | ParagraphContent::Attachment(_) | ||
| 1051 | ) { | ||
| 1052 | return Err(invalid("Link a paragraph of text or a file to a recording")); | ||
| 1053 | } | ||
| 1054 | if media.recordings.is_empty() != media.time_ms.is_none() { | ||
| 1055 | return Err(invalid( | ||
| 1056 | "A recording link names recordings and a moment in them", | ||
| 1057 | )); | ||
| 1058 | } | ||
| 1059 | paragraph.media = media.clone(); | ||
| 1060 | } | ||
| 1045 | PageOp::List { paragraph, lists } => { | 1061 | PageOp::List { paragraph, lists } => { |
| 1046 | paragraph_mut(page, *paragraph)?.lists = lists.iter().map(|(id, _)| *id).collect(); | 1062 | paragraph_mut(page, *paragraph)?.lists = lists.iter().map(|(id, _)| *id).collect(); |
| 1047 | for (id, definition) in lists { | 1063 | for (id, definition) in lists { |
crates/onestore/src/op/properties.rs+91-38| ... | @@ -5,7 +5,7 @@ use crate::{ | ... | @@ -5,7 +5,7 @@ use crate::{ |
| 5 | Error, ExGuid, PropertySets, Value, | 5 | Error, ExGuid, PropertySets, Value, |
| 6 | active::{ActivePage, Changes}, | 6 | active::{ActivePage, Changes}, |
| 7 | document::{Kind, Tag}, | 7 | document::{Kind, Tag}, |
| 8 | page::Definition, | 8 | page::{Definition, MediaIndex}, |
| 9 | write::PropertyObject, | 9 | write::PropertyObject, |
| 10 | }; | 10 | }; |
| 11 | use std::collections::{BTreeMap, BTreeSet}; | 11 | use std::collections::{BTreeMap, BTreeSet}; |
| ... | @@ -165,54 +165,78 @@ fn tag_definition_values(definition: &Definition) -> Result<Values, Error> { | ... | @@ -165,54 +165,78 @@ fn tag_definition_values(definition: &Definition) -> Result<Values, Error> { |
| 165 | Ok(values) | 165 | Ok(values) |
| 166 | } | 166 | } |
| 167 | 167 | ||
| 168 | /// Replaces the note tags of `object`. Each entry names the stored identity of its tag's | 168 | /// Replaces the note tags of `object`. A normal tag comes with its definition where the page |
| 169 | /// definition and, where the tag does not carry its action type or the page lacks the | 169 | /// lacks it; a task tag has none and stores its own icon, in the order OneNote writes it. |
| 170 | /// definition, the definition itself. | ||
| 171 | pub(crate) fn tag_changes( | 170 | pub(crate) fn tag_changes( |
| 172 | active: &ActivePage<'_>, | 171 | active: &ActivePage<'_>, |
| 173 | object: ExGuid, | 172 | object: ExGuid, |
| 174 | tags: &[(ExGuid, &Tag, Option<&Definition>)], | 173 | tags: &[(&Tag, Option<&Definition>)], |
| 175 | ) -> Result<Changes, Error> { | 174 | ) -> Result<Changes, Error> { |
| 176 | let raw = &active.live.revision; | 175 | let raw = &active.live.revision; |
| 177 | let mut definitions: Vec<(ExGuid, Option<Values>)> = Vec::new(); | 176 | let mut definitions: Vec<(ExGuid, Option<Values>)> = Vec::new(); |
| 178 | let mut sets: Vec<(usize, Values)> = Vec::new(); | 177 | let mut sets: Vec<(Option<ExGuid>, Values)> = Vec::new(); |
| 179 | let mut action_types = BTreeSet::new(); | 178 | let mut action_types = BTreeSet::new(); |
| 180 | for (written, tag, definition) in tags { | 179 | for (tag, definition) in tags { |
| 181 | let action_type = if tag.status & 4 != 0 { | 180 | let dates = [ |
| 182 | tag.action_type | 181 | (0x1400346e, tag.created), |
| 183 | } else { | 182 | (0x1400346f, tag.completed), |
| 184 | match definition.map(|d| &d.kind) { | 183 | (0x1400346a, tag.start), |
| 185 | Some(Kind::TagDefinition { action_type, .. }) => *action_type, | 184 | (0x1400346b, tag.due), |
| 186 | _ => return Err(invalid("A note tag must reference a tag definition")), | 185 | ]; |
| 186 | let mut fields: Values = Vec::new(); | ||
| 187 | if tag.status & 4 != 0 { | ||
| 188 | let (Some(action_type), Some(shape), Some(property_status)) = | ||
| 189 | (tag.action_type, tag.shape, tag.property_status) | ||
| 190 | else { | ||
| 191 | return Err(invalid( | ||
| 192 | "A task tag stores its action type, icon and property status", | ||
| 193 | )); | ||
| 194 | }; | ||
| 195 | if !action_types.insert(action_type) { | ||
| 196 | return Err(invalid("An element holds one note tag per action type")); | ||
| 187 | } | 197 | } |
| 198 | fields.extend([ | ||
| 199 | (0x0c003473, vec![0]), | ||
| 200 | (0x10003463, action_type.to_le_bytes().to_vec()), | ||
| 201 | (0x10003464, shape.to_le_bytes().to_vec()), | ||
| 202 | (0x14003467, property_status.to_le_bytes().to_vec()), | ||
| 203 | ]); | ||
| 204 | if let Some(task) = tag.task_id { | ||
| 205 | fields.push((0x1c003469, task.to_vec())); | ||
| 206 | } | ||
| 207 | for (id, value) in [dates[2], dates[3], dates[0], dates[1]] { | ||
| 208 | if let Some(value) = value { | ||
| 209 | fields.push((id, value.to_le_bytes().to_vec())); | ||
| 210 | } | ||
| 211 | } | ||
| 212 | fields.push((0x10003470, tag.status.to_le_bytes().to_vec())); | ||
| 213 | sets.push((None, fields)); | ||
| 214 | continue; | ||
| 215 | } | ||
| 216 | let written = tag | ||
| 217 | .definition | ||
| 218 | .ok_or_else(|| invalid("A note tag must reference a tag definition"))?; | ||
| 219 | let action_type = match definition.map(|d| &d.kind) { | ||
| 220 | Some(Kind::TagDefinition { action_type, .. }) => *action_type, | ||
| 221 | _ => return Err(invalid("A note tag must reference a tag definition")), | ||
| 188 | }; | 222 | }; |
| 189 | if !action_types.insert(action_type.unwrap_or(0)) { | 223 | if !action_types.insert(action_type.unwrap_or(0)) { |
| 190 | return Err(invalid("An element holds one note tag per action type")); | 224 | return Err(invalid("An element holds one note tag per action type")); |
| 191 | } | 225 | } |
| 192 | let index = match definitions.iter().position(|(id, _)| id == written) { | 226 | if !definitions.iter().any(|(id, _)| *id == written) { |
| 193 | Some(index) => index, | 227 | let values = if raw.objects.contains_key(&written) { |
| 194 | None => { | 228 | None |
| 195 | let values = if raw.objects.contains_key(written) { | 229 | } else { |
| 196 | None | 230 | let definition = definition |
| 197 | } else { | 231 | .ok_or_else(|| invalid("A note tag references a missing tag definition"))?; |
| 198 | let definition = definition | 232 | Some(tag_definition_values(definition)?) |
| 199 | .ok_or_else(|| invalid("A note tag references a missing tag definition"))?; | 233 | }; |
| 200 | Some(tag_definition_values(definition)?) | 234 | definitions.push((written, values)); |
| 201 | }; | 235 | } |
| 202 | definitions.push((*written, values)); | ||
| 203 | definitions.len() - 1 | ||
| 204 | } | ||
| 205 | }; | ||
| 206 | let mut fields: Values = Vec::new(); | ||
| 207 | if let Some(action_type) = tag.action_type { | 236 | if let Some(action_type) = tag.action_type { |
| 208 | fields.push((0x10003463, action_type.to_le_bytes().to_vec())); | 237 | fields.push((0x10003463, action_type.to_le_bytes().to_vec())); |
| 209 | } | 238 | } |
| 210 | for (id, value) in [ | 239 | for (id, value) in dates { |
| 211 | (0x1400346e, tag.created), | ||
| 212 | (0x1400346f, tag.completed), | ||
| 213 | (0x1400346a, tag.start), | ||
| 214 | (0x1400346b, tag.due), | ||
| 215 | ] { | ||
| 216 | if let Some(value) = value { | 240 | if let Some(value) = value { |
| 217 | fields.push((id, value.to_le_bytes().to_vec())); | 241 | fields.push((id, value.to_le_bytes().to_vec())); |
| 218 | } | 242 | } |
| ... | @@ -221,7 +245,7 @@ pub(crate) fn tag_changes( | ... | @@ -221,7 +245,7 @@ pub(crate) fn tag_changes( |
| 221 | if let Some(task) = tag.task_id { | 245 | if let Some(task) = tag.task_id { |
| 222 | fields.push((0x1c003469, task.to_vec())); | 246 | fields.push((0x1c003469, task.to_vec())); |
| 223 | } | 247 | } |
| 224 | sets.push((index, fields)); | 248 | sets.push((Some(written), fields)); |
| 225 | } | 249 | } |
| 226 | let parents = active.editable_parents(object)?; | 250 | let parents = active.editable_parents(object)?; |
| 227 | let modified = crate::create::current_timestamps()?.0.to_le_bytes(); | 251 | let modified = crate::create::current_timestamps()?.0.to_le_bytes(); |
| ... | @@ -240,9 +264,12 @@ pub(crate) fn tag_changes( | ... | @@ -240,9 +264,12 @@ pub(crate) fn tag_changes( |
| 240 | } | 264 | } |
| 241 | let mut target = PropertyObject::from_object(&raw.objects[&object])?; | 265 | let mut target = PropertyObject::from_object(&raw.objects[&object])?; |
| 242 | let mut encoded = Vec::new(); | 266 | let mut encoded = Vec::new(); |
| 243 | for (index, fields) in &sets { | 267 | for (definition, fields) in &sets { |
| 244 | let reference = target.reference(definitions[*index].0)?; | 268 | let mut set = Vec::new(); |
| 245 | let mut set = vec![(0x20003488, reference.to_vec())]; | 269 | if let Some(definition) = definition { |
| 270 | let reference = target.reference(*definition)?; | ||
| 271 | set.push((0x20003488, reference.to_vec())); | ||
| 272 | } | ||
| 246 | set.extend(fields.iter().cloned()); | 273 | set.extend(fields.iter().cloned()); |
| 247 | encoded.push(set); | 274 | encoded.push(set); |
| 248 | } | 275 | } |
| ... | @@ -380,3 +407,29 @@ pub(crate) fn paragraph_format_changes( | ... | @@ -380,3 +407,29 @@ pub(crate) fn paragraph_format_changes( |
| 380 | crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?; | 407 | crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?; |
| 381 | Ok(changed) | 408 | Ok(changed) |
| 382 | } | 409 | } |
| 410 | |||
| 411 | /// Links content `object` to a moment in recordings (MediaIndex), or unlinks it. | ||
| 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,6 +834,8 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { |
| 834 | text.tags.push(crate::document::Tag { | 834 | text.tags.push(crate::document::Tag { |
| 835 | definition: Some(definition), | 835 | definition: Some(definition), |
| 836 | action_type: None, | 836 | action_type: None, |
| 837 | shape: None, | ||
| 838 | property_status: None, | ||
| 837 | status, | 839 | status, |
| 838 | created: Some(1_262_401_445), | 840 | created: Some(1_262_401_445), |
| 839 | completed: (status == 1).then_some(1_262_402_000), | 841 | completed: (status == 1).then_some(1_262_402_000), |
| ... | @@ -1159,6 +1161,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { | ... | @@ -1159,6 +1161,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { |
| 1159 | layout: Layout::default(), | 1161 | layout: Layout::default(), |
| 1160 | strokes: vec![stroke(rng)], | 1162 | strokes: vec![stroke(rng)], |
| 1161 | groups: Vec::new(), | 1163 | groups: Vec::new(), |
| 1164 | shape: None, | ||
| 1162 | }) | 1165 | }) |
| 1163 | }; | 1166 | }; |
| 1164 | page.objects.insert(at, object); | 1167 | page.objects.insert(at, object); |
| ... | @@ -1251,6 +1254,8 @@ fn stroke(rng: &mut Rng) -> InkStroke { | ... | @@ -1251,6 +1254,8 @@ fn stroke(rng: &mut Rng) -> InkStroke { |
| 1251 | color: Some(0x0000ff), | 1254 | color: Some(0x0000ff), |
| 1252 | transparency: None, | 1255 | transparency: None, |
| 1253 | pen_tip: None, | 1256 | pen_tip: None, |
| 1257 | // Every other stroke a highlighter's. | ||
| 1258 | raster_operation: (x as u32).is_multiple_of(2).then_some(9), | ||
| 1254 | } | 1259 | } |
| 1255 | } | 1260 | } |
| 1256 | 1261 | ||
| ... | @@ -1595,6 +1600,8 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> { | ... | @@ -1595,6 +1600,8 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> { |
| 1595 | tags: vec![crate::document::Tag { | 1600 | tags: vec![crate::document::Tag { |
| 1596 | definition: Some(definition), | 1601 | definition: Some(definition), |
| 1597 | action_type: None, | 1602 | action_type: None, |
| 1603 | shape: None, | ||
| 1604 | property_status: None, | ||
| 1598 | status: 0, | 1605 | status: 0, |
| 1599 | created: Some(1_262_401_445), | 1606 | created: Some(1_262_401_445), |
| 1600 | completed: None, | 1607 | completed: None, |
| ... | @@ -1639,15 +1646,20 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> { | ... | @@ -1639,15 +1646,20 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> { |
| 1639 | }), | 1646 | }), |
| 1640 | before: None, | 1647 | before: None, |
| 1641 | }, | 1648 | }, |
| 1642 | _ => PageOp::Add { | 1649 | _ => { |
| 1643 | object: PageObject::Ink(Ink { | 1650 | let stroke = stroke(rng); |
| 1644 | id: id(), | 1651 | let ends = [stroke.points[0], stroke.points[stroke.points.len() - 1]]; |
| 1645 | layout: Layout::default(), | 1652 | PageOp::Add { |
| 1646 | strokes: vec![stroke(rng)], | 1653 | object: PageObject::Ink(Ink { |
| 1647 | groups: Vec::new(), | 1654 | id: id(), |
| 1648 | }), | 1655 | layout: Layout::default(), |
| 1649 | before: None, | 1656 | strokes: vec![stroke], |
| 1650 | }, | 1657 | groups: Vec::new(), |
| 1658 | shape: Some(crate::page::ink::InkShape::Line(ends)), | ||
| 1659 | }), | ||
| 1660 | before: None, | ||
| 1661 | } | ||
| 1662 | } | ||
| 1651 | }) | 1663 | }) |
| 1652 | } | 1664 | } |
| 1653 | 1665 |
crates/onestore/src/page/ink.rs+200| ... | @@ -32,6 +32,119 @@ pub struct Ink { | ... | @@ -32,6 +32,119 @@ pub struct Ink { |
| 32 | pub strokes: Vec<InkStroke>, | 32 | pub strokes: Vec<InkStroke>, |
| 33 | /// Nested ink containers, as newer OneNote versions group handwriting. | 33 | /// Nested ink containers, as newer OneNote versions group handwriting. |
| 34 | pub groups: Vec<Ink>, | 34 | pub groups: Vec<Ink>, |
| 35 | /// The shape drawn, for a drawing made from Draw's Insert Shapes. | ||
| 36 | #[serde(default)] | ||
| 37 | pub shape: Option<InkShape>, | ||
| 38 | } | ||
| 39 | |||
| 40 | /// The shapes of Draw's Insert Shapes that Snowbound draws. | ||
| 41 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
| 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 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | 150 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| ... | @@ -48,6 +161,10 @@ pub struct InkStroke { | ... | @@ -48,6 +161,10 @@ pub struct InkStroke { |
| 48 | pub transparency: Option<u8>, | 161 | pub transparency: Option<u8>, |
| 49 | /// 0 is a round (ball) tip, 1 a rectangle. | 162 | /// 0 is a round (ball) tip, 1 a rectangle. |
| 50 | pub pen_tip: Option<u8>, | 163 | pub pen_tip: Option<u8>, |
| 164 | /// The ISF raster operation: 9 (MaskPen) for a highlighter, whose colour multiplies | ||
| 165 | /// what lies beneath. | ||
| 166 | #[serde(default)] | ||
| 167 | pub raster_operation: Option<u8>, | ||
| 51 | } | 168 | } |
| 52 | 169 | ||
| 53 | impl Ink { | 170 | impl Ink { |
| ... | @@ -61,6 +178,9 @@ impl Ink { | ... | @@ -61,6 +178,9 @@ impl Ink { |
| 61 | data, | 178 | data, |
| 62 | scale_x, | 179 | scale_x, |
| 63 | scale_y, | 180 | scale_y, |
| 181 | shape_kind, | ||
| 182 | line, | ||
| 183 | anchors, | ||
| 64 | } = &node.kind | 184 | } = &node.kind |
| 65 | else { | 185 | else { |
| 66 | unreachable!() | 186 | unreachable!() |
| ... | @@ -98,6 +218,7 @@ impl Ink { | ... | @@ -98,6 +218,7 @@ impl Ink { |
| 98 | layout: node.layout.clone(), | 218 | layout: node.layout.clone(), |
| 99 | strokes, | 219 | strokes, |
| 100 | groups, | 220 | groups, |
| 221 | shape: InkShape::read(*shape_kind, line.as_deref(), anchors.as_deref()), | ||
| 101 | }) | 222 | }) |
| 102 | } | 223 | } |
| 103 | 224 | ||
| ... | @@ -151,6 +272,7 @@ impl InkStroke { | ... | @@ -151,6 +272,7 @@ impl InkStroke { |
| 151 | color, | 272 | color, |
| 152 | transparency, | 273 | transparency, |
| 153 | pen_tip, | 274 | pen_tip, |
| 275 | raster_operation, | ||
| 154 | .. | 276 | .. |
| 155 | } = &style.kind | 277 | } = &style.kind |
| 156 | else { | 278 | else { |
| ... | @@ -190,10 +312,78 @@ impl InkStroke { | ... | @@ -190,10 +312,78 @@ impl InkStroke { |
| 190 | color: *color, | 312 | color: *color, |
| 191 | transparency: *transparency, | 313 | transparency: *transparency, |
| 192 | pen_tip: *pen_tip, | 314 | pen_tip: *pen_tip, |
| 315 | raster_operation: *raster_operation, | ||
| 193 | }) | 316 | }) |
| 194 | } | 317 | } |
| 195 | } | 318 | } |
| 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 | /// ISF multi-byte decoding: a count, then that many 7-bit little-endian varints whose low bit | 387 | /// ISF multi-byte decoding: a count, then that many 7-bit little-endian varints whose low bit |
| 198 | /// is the sign. | 388 | /// is the sign. |
| 199 | fn multi_byte(bytes: &[u8]) -> Option<Vec<i64>> { | 389 | fn multi_byte(bytes: &[u8]) -> Option<Vec<i64>> { |
| ... | @@ -268,6 +458,15 @@ pub(crate) const DIMENSIONS: [u8; 64] = [ | ... | @@ -268,6 +458,15 @@ pub(crate) const DIMENSIONS: [u8; 64] = [ |
| 268 | 0x00, 0x00, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x8b, 0xc5, 0xe2, 0x41, | 458 | 0x00, 0x00, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x8b, 0xc5, 0xe2, 0x41, |
| 269 | ]; | 459 | ]; |
| 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 | #[cfg(test)] | 470 | #[cfg(test)] |
| 272 | mod tests { | 471 | mod tests { |
| 273 | use super::*; | 472 | use super::*; |
| ... | @@ -282,6 +481,7 @@ mod tests { | ... | @@ -282,6 +481,7 @@ mod tests { |
| 282 | color: None, | 481 | color: None, |
| 283 | transparency: None, | 482 | transparency: None, |
| 284 | pen_tip: None, | 483 | pen_tip: None, |
| 484 | raster_operation: None, | ||
| 285 | }; | 485 | }; |
| 286 | let values = multi_byte(&stroke.packet()).unwrap(); | 486 | let values = multi_byte(&stroke.packet()).unwrap(); |
| 287 | assert_eq!(values.len(), 6); | 487 | assert_eq!(values.len(), 6); |
crates/onestore/src/page/mod.rs+26-6| ... | @@ -282,21 +282,34 @@ pub struct Attachment { | ... | @@ -282,21 +282,34 @@ pub struct Attachment { |
| 282 | pub bytes: Option<Arc<[u8]>>, | 282 | pub bytes: Option<Arc<[u8]>>, |
| 283 | #[serde(with = "payload")] | 283 | #[serde(with = "payload")] |
| 284 | pub preview: Option<Arc<[u8]>>, | 284 | pub preview: Option<Arc<[u8]>>, |
| 285 | /// Set when the file is an audio or video recording OneNote captured; annotations on | 285 | /// Set when the file is audio or video OneNote plays; notes on the page refer to it by |
| 286 | /// the page refer to it by identity (`PageParagraph::media`). | 286 | /// identity (`PageParagraph::media`). |
| 287 | pub recording: Option<Recording>, | 287 | pub recording: Option<Recording>, |
| 288 | } | 288 | } |
| 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 | #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | 291 | #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| 292 | pub struct Recording { | 292 | pub struct Recording { |
| 293 | pub id: [u8; 16], | 293 | pub id: [u8; 16], |
| 294 | pub kind: u32, | 294 | pub kind: u32, |
| 295 | pub duration_ms: Option<u32>, | ||
| 296 | } | ||
| 297 | |||
| 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 | /// A paragraph's link to a moment in recordings on the page: OneNote plays from | 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 | 312 | /// `time_ms` when the paragraph is chosen, and links what is written while it records. |
| 299 | /// edits; OneNote alone creates them while recording. | ||
| 300 | #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | 313 | #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| 301 | pub struct MediaIndex { | 314 | pub struct MediaIndex { |
| 302 | pub recordings: Vec<[u8; 16]>, | 315 | pub recordings: Vec<[u8; 16]>, |
| ... | @@ -328,6 +341,7 @@ impl Attachment { | ... | @@ -328,6 +341,7 @@ impl Attachment { |
| 328 | source_path, | 341 | source_path, |
| 329 | recording_id, | 342 | recording_id, |
| 330 | recording_type, | 343 | recording_type, |
| 344 | recording_duration, | ||
| 331 | icon_width, | 345 | icon_width, |
| 332 | icon_height, | 346 | icon_height, |
| 333 | } = &node.kind | 347 | } = &node.kind |
| ... | @@ -357,7 +371,10 @@ impl Attachment { | ... | @@ -357,7 +371,10 @@ impl Attachment { |
| 357 | preview: payload(preview)?, | 371 | preview: payload(preview)?, |
| 358 | recording: recording_id.map(|id| Recording { | 372 | recording: recording_id.map(|id| Recording { |
| 359 | id, | 373 | id, |
| 360 | kind: recording_type.unwrap_or(0), | 374 | kind: recording_type |
| 375 | .or_else(|| Recording::kind_of(filename.as_deref().unwrap_or_default())) | ||
| 376 | .unwrap_or(1), | ||
| 377 | duration_ms: *recording_duration, | ||
| 361 | }), | 378 | }), |
| 362 | }) | 379 | }) |
| 363 | } | 380 | } |
| ... | @@ -1369,6 +1386,7 @@ mod tests { | ... | @@ -1369,6 +1386,7 @@ mod tests { |
| 1369 | margin_origin_x: Some(36.0), | 1386 | margin_origin_x: Some(36.0), |
| 1370 | margin_origin_y: Some(12.0), | 1387 | margin_origin_y: Some(12.0), |
| 1371 | rtl: None, | 1388 | rtl: None, |
| 1389 | recordings: Vec::new(), | ||
| 1372 | }); | 1390 | }); |
| 1373 | page.children.push(id(2)); | 1391 | page.children.push(id(2)); |
| 1374 | let mut outline = element(Kind::Outline { | 1392 | let mut outline = element(Kind::Outline { |
| ... | @@ -1621,6 +1639,8 @@ mod tests { | ... | @@ -1621,6 +1639,8 @@ mod tests { |
| 1621 | source.nodes.get_mut(&id(4)).unwrap().tags.push(Tag { | 1639 | source.nodes.get_mut(&id(4)).unwrap().tags.push(Tag { |
| 1622 | definition: Some(id(7)), | 1640 | definition: Some(id(7)), |
| 1623 | action_type: None, | 1641 | action_type: None, |
| 1642 | shape: None, | ||
| 1643 | property_status: None, | ||
| 1624 | status: 0, | 1644 | status: 0, |
| 1625 | created: Some(123), | 1645 | created: Some(123), |
| 1626 | completed: None, | 1646 | completed: None, |
crates/onestore/tests/page_ink.rs+371| ... | @@ -88,6 +88,7 @@ fn stroke(points: &[[f32; 2]], color: Option<u32>) -> InkStroke { | ... | @@ -88,6 +88,7 @@ fn stroke(points: &[[f32; 2]], color: Option<u32>) -> InkStroke { |
| 88 | color, | 88 | color, |
| 89 | transparency: None, | 89 | transparency: None, |
| 90 | pen_tip: None, | 90 | pen_tip: None, |
| 91 | raster_operation: None, | ||
| 91 | } | 92 | } |
| 92 | } | 93 | } |
| 93 | 94 | ||
| ... | @@ -109,6 +110,7 @@ fn drawing() -> Ink { | ... | @@ -109,6 +110,7 @@ fn drawing() -> Ink { |
| 109 | stroke(&[[300.0, 120.0], [360.0, 180.0]], Some(0x0000ff)), | 110 | stroke(&[[300.0, 120.0], [360.0, 180.0]], Some(0x0000ff)), |
| 110 | ], | 111 | ], |
| 111 | groups: Vec::new(), | 112 | groups: Vec::new(), |
| 113 | shape: None, | ||
| 112 | } | 114 | } |
| 113 | } | 115 | } |
| 114 | 116 | ||
| ... | @@ -232,3 +234,372 @@ fn stored_strokes_keep_their_paths() { | ... | @@ -232,3 +234,372 @@ fn stored_strokes_keep_their_paths() { |
| 232 | } | 234 | } |
| 233 | assert!(ops::saved(NATIVE, space, &moved).is_err()); | 235 | assert!(ops::saved(NATIVE, space, &moved).is_err()); |
| 234 | } | 236 | } |
| 237 | |||
| 238 | /// OneNote 2010's Draw tab used with the mouse (`corpus/ink-tools/native-ui`): every favourite | ||
| 239 | /// pen and highlighter, shapes, and ink erased, moved and deleted. | ||
| 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,18 +103,29 @@ fn editing_a_recorded_page_keeps_the_recording_and_its_annotation() { |
| 103 | assert_eq!(recording(&stored), recording(&before)); | 103 | assert_eq!(recording(&stored), recording(&before)); |
| 104 | assert_eq!(stored.objects.len(), before.objects.len()); | 104 | assert_eq!(stored.objects.len(), before.objects.len()); |
| 105 | 105 | ||
| 106 | let mut annotated = after.clone(); | 106 | // A note linked to the recording, then unlinked, as Snowbound links notes taken while |
| 107 | let outline = annotated | 107 | // it records. |
| 108 | .objects | 108 | let annotate = |page: &Page, media: MediaIndex| { |
| 109 | .iter_mut() | 109 | let mut page = page.clone(); |
| 110 | .find_map(|object| match object { | 110 | for object in &mut page.objects { |
| 111 | PageObject::Outline(outline) if !outline.title => Some(outline), | 111 | if let PageObject::Outline(outline) = object |
| 112 | _ => None, | 112 | && !outline.title |
| 113 | }) | 113 | { |
| 114 | .unwrap(); | 114 | outline.paragraphs.last_mut().unwrap().media = media.clone(); |
| 115 | let last = outline.paragraphs.last_mut().unwrap(); | 115 | } |
| 116 | last.media = recording(&before).1; | 116 | } |
| 117 | assert!(ops::saved(written.as_slice(), space, &annotated).is_err()); | 117 | page |
| 118 | }; | ||
| 119 | let linked = ops::saved( | ||
| 120 | written.as_slice(), | ||
| 121 | space, | ||
| 122 | &annotate(&stored, recording(&before).1), | ||
| 123 | ) | ||
| 124 | .unwrap(); | ||
| 125 | let (_, relinked) = page(linked.as_slice()); | ||
| 126 | assert_eq!(relinked, annotate(&stored, recording(&before).1)); | ||
| 127 | let unlinked = ops::saved(linked.as_slice(), space, &stored).unwrap(); | ||
| 128 | assert_eq!(page(unlinked.as_slice()).1, stored); | ||
| 118 | let mut recaptured = after.clone(); | 129 | let mut recaptured = after.clone(); |
| 119 | for object in &mut recaptured.objects { | 130 | for object in &mut recaptured.objects { |
| 120 | if let PageObject::Outline(outline) = object { | 131 | if let PageObject::Outline(outline) = object { |
crates/onestore/tests/page_tags.rs+48| ... | @@ -113,6 +113,8 @@ fn tag(definition: ExGuid, completed: bool) -> Tag { | ... | @@ -113,6 +113,8 @@ fn tag(definition: ExGuid, completed: bool) -> Tag { |
| 113 | Tag { | 113 | Tag { |
| 114 | definition: Some(definition), | 114 | definition: Some(definition), |
| 115 | action_type: None, | 115 | action_type: None, |
| 116 | shape: None, | ||
| 117 | property_status: None, | ||
| 116 | status: u16::from(completed), | 118 | status: u16::from(completed), |
| 117 | created: Some(1_262_401_445), | 119 | created: Some(1_262_401_445), |
| 118 | completed: completed.then_some(1_262_405_106), | 120 | completed: completed.then_some(1_262_405_106), |
| ... | @@ -305,3 +307,49 @@ fn new_definitions_and_tags_publish_on_a_fresh_page() { | ... | @@ -305,3 +307,49 @@ fn new_definitions_and_tags_publish_on_a_fresh_page() { |
| 305 | .unwrap(); | 307 | .unwrap(); |
| 306 | } | 308 | } |
| 307 | } | 309 | } |
| 310 | |||
| 311 | /// An Outlook task tag names no definition: rewritten beside a normal tag it keeps its icon, | ||
| 312 | /// property status and dates, and one missing its icon is refused. | ||
| 313 | #[test] | ||
| 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,6 +256,8 @@ fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) { |
| 256 | text.tags.push(onestore::document::Tag { | 256 | text.tags.push(onestore::document::Tag { |
| 257 | definition: Some(definition), | 257 | definition: Some(definition), |
| 258 | action_type: None, | 258 | action_type: None, |
| 259 | shape: None, | ||
| 260 | property_status: None, | ||
| 259 | status: u16::from(bytes.next().unwrap_or(0) % 2), | 261 | status: u16::from(bytes.next().unwrap_or(0) % 2), |
| 260 | created: Some(1_262_401_445), | 262 | created: Some(1_262_401_445), |
| 261 | completed: None, | 263 | completed: None, |
crates/snowbound/src/library.rs+52-16| ... | @@ -33,10 +33,6 @@ pub fn on_background(notify: impl Fn() + Send + Sync + 'static) { | ... | @@ -33,10 +33,6 @@ pub fn on_background(notify: impl Fn() + Send + Sync + 'static) { |
| 33 | let _ = NOTIFY.set(Box::new(notify)); | 33 | let _ = NOTIFY.set(Box::new(notify)); |
| 34 | } | 34 | } |
| 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 | fn notify_background() { | 36 | fn notify_background() { |
| 41 | if let Some(notify) = NOTIFY.get() { | 37 | if let Some(notify) = NOTIFY.get() { |
| 42 | notify(); | 38 | notify(); |
| ... | @@ -171,6 +167,8 @@ pub struct Library { | ... | @@ -171,6 +167,8 @@ pub struct Library { |
| 171 | /// Syncs the notebook's sections while no tab shows them, as OneNote syncs every | 167 | /// Syncs the notebook's sections while no tab shows them, as OneNote syncs every |
| 172 | /// section of an open notebook. | 168 | /// section of an open notebook. |
| 173 | pub background: Option<Arc<Background>>, | 169 | pub background: Option<Arc<Background>>, |
| 170 | /// Reports the changes to a folder on this computer to `background`. | ||
| 171 | watch: Option<Arc<crate::watch::Watch>>, | ||
| 174 | } | 172 | } |
| 175 | 173 | ||
| 176 | impl Library { | 174 | impl Library { |
| ... | @@ -192,10 +190,15 @@ impl Library { | ... | @@ -192,10 +190,15 @@ impl Library { |
| 192 | } | 190 | } |
| 193 | } | 191 | } |
| 194 | let notebook = Notebook::open(location, cache); | 192 | let notebook = Notebook::open(location, cache); |
| 193 | let (background, watch) = match &notebook { | ||
| 194 | Ok(notebook) => local_background(notebook, location), | ||
| 195 | Err(_) => (None, None), | ||
| 196 | }; | ||
| 195 | Self { | 197 | Self { |
| 196 | location: location.to_owned(), | 198 | location: location.to_owned(), |
| 197 | name: file_name(Path::new(location)), | 199 | name: file_name(Path::new(location)), |
| 198 | background: notebook.as_ref().ok().and_then(local_background), | 200 | background, |
| 201 | watch, | ||
| 199 | notebook: notebook.map(Some).map_err(|error| error.to_string()), | 202 | notebook: notebook.map(Some).map_err(|error| error.to_string()), |
| 200 | server: None, | 203 | server: None, |
| 201 | notice, | 204 | notice, |
| ... | @@ -219,7 +222,6 @@ impl Library { | ... | @@ -219,7 +222,6 @@ impl Library { |
| 219 | let background = Background::smb( | 222 | let background = Background::smb( |
| 220 | &server.mount.root, | 223 | &server.mount.root, |
| 221 | LIMIT, | 224 | LIMIT, |
| 222 | BACKGROUND, | ||
| 223 | move || connect.connect(), | 225 | move || connect.connect(), |
| 224 | notify_background, | 226 | notify_background, |
| 225 | ) | 227 | ) |
| ... | @@ -234,6 +236,7 @@ impl Library { | ... | @@ -234,6 +236,7 @@ impl Library { |
| 234 | notice: None, | 236 | notice: None, |
| 235 | cache: cache.to_owned(), | 237 | cache: cache.to_owned(), |
| 236 | background: Some(Arc::new(background)), | 238 | background: Some(Arc::new(background)), |
| 239 | watch: None, | ||
| 237 | }) | 240 | }) |
| 238 | } | 241 | } |
| 239 | 242 | ||
| ... | @@ -260,15 +263,18 @@ impl Library { | ... | @@ -260,15 +263,18 @@ impl Library { |
| 260 | notice: self.notice.clone(), | 263 | notice: self.notice.clone(), |
| 261 | cache: self.cache.clone(), | 264 | cache: self.cache.clone(), |
| 262 | background: self.background.clone(), | 265 | background: self.background.clone(), |
| 266 | watch: self.watch.clone(), | ||
| 263 | } | 267 | } |
| 264 | } | 268 | } |
| 265 | 269 | ||
| 266 | /// A notebook `Notebook::create` just made in the folder at `location`. | 270 | /// A notebook `Notebook::create` just made in the folder at `location`. |
| 267 | pub fn created(location: &str, notebook: Notebook, cache: &Path) -> Self { | 271 | pub fn created(location: &str, notebook: Notebook, cache: &Path) -> Self { |
| 272 | let (background, watch) = local_background(&notebook, location); | ||
| 268 | Self { | 273 | Self { |
| 269 | location: location.to_owned(), | 274 | location: location.to_owned(), |
| 270 | name: file_name(Path::new(location)), | 275 | name: file_name(Path::new(location)), |
| 271 | background: local_background(&notebook), | 276 | background, |
| 277 | watch, | ||
| 272 | notebook: Ok(Some(notebook)), | 278 | notebook: Ok(Some(notebook)), |
| 273 | server: None, | 279 | server: None, |
| 274 | notice: None, | 280 | notice: None, |
| ... | @@ -286,6 +292,7 @@ impl Library { | ... | @@ -286,6 +292,7 @@ impl Library { |
| 286 | notice: None, | 292 | notice: None, |
| 287 | cache: cache.to_owned(), | 293 | cache: cache.to_owned(), |
| 288 | background: None, | 294 | background: None, |
| 295 | watch: None, | ||
| 289 | } | 296 | } |
| 290 | } | 297 | } |
| 291 | 298 | ||
| ... | @@ -296,7 +303,8 @@ impl Library { | ... | @@ -296,7 +303,8 @@ impl Library { |
| 296 | path: &str, | 303 | path: &str, |
| 297 | notify: impl Fn() + Send + 'static, | 304 | notify: impl Fn() + Send + 'static, |
| 298 | ) -> Result<Section, Box<dyn Error>> { | 305 | ) -> Result<Section, Box<dyn Error>> { |
| 299 | // The background may hold the replica for a step, which takes a network round trip. | 306 | // The background may hold the replica for a step, which takes a network round trip, |
| 307 | // or be making it as an offline copy. | ||
| 300 | let deadline = Instant::now() + TIMEOUT * 3; | 308 | let deadline = Instant::now() + TIMEOUT * 3; |
| 301 | let notify = Arc::new(Mutex::new(notify)); | 309 | let notify = Arc::new(Mutex::new(notify)); |
| 302 | let notifier = || { | 310 | let notifier = || { |
| ... | @@ -335,7 +343,12 @@ impl Library { | ... | @@ -335,7 +343,12 @@ impl Library { |
| 335 | (Err(error), _) => return Err(error.clone().into()), | 343 | (Err(error), _) => return Err(error.clone().into()), |
| 336 | }; | 344 | }; |
| 337 | match opened { | 345 | match opened { |
| 338 | Err(error) if error.busy() && Instant::now() < deadline => { | 346 | Err(error) |
| 347 | if (error.busy() | ||
| 348 | || matches!(&error, notebook::Error::Io(error) | ||
| 349 | if error.kind() == io::ErrorKind::AlreadyExists)) | ||
| 350 | && Instant::now() < deadline => | ||
| 351 | { | ||
| 339 | std::thread::sleep(Duration::from_millis(50)); | 352 | std::thread::sleep(Duration::from_millis(50)); |
| 340 | } | 353 | } |
| 341 | opened => break opened?, | 354 | opened => break opened?, |
| ... | @@ -446,15 +459,37 @@ pub fn recycle_bin(path: &str) -> bool { | ... | @@ -446,15 +459,37 @@ pub fn recycle_bin(path: &str) -> bool { |
| 446 | path.rsplit('/').next() == Some("OneNote_RecycleBin") | 459 | path.rsplit('/').next() == Some("OneNote_RecycleBin") |
| 447 | } | 460 | } |
| 448 | 461 | ||
| 449 | /// A mounted notebook's background sync, following Work Offline. | 462 | /// A mounted notebook's background sync, following Work Offline, and the watch that reports |
| 450 | fn local_background(notebook: &Notebook) -> Option<Arc<Background>> { | 463 | /// the folder's changes to it. Only a folder on this computer reports every change; one on a |
| 451 | let background = notebook | 464 | /// network volume is checked more often and kept in offline copies, as on a share. |
| 452 | .background(BACKGROUND, notify_background) | 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 | .inspect_err(|error| eprintln!("Background sync did not start: {error}")) | 483 | .inspect_err(|error| eprintln!("Background sync did not start: {error}")) |
| 454 | .ok()?; | 484 | .ok() |
| 485 | else { | ||
| 486 | return (None, None); | ||
| 487 | }; | ||
| 455 | background.set_offline(offline()); | 488 | background.set_offline(offline()); |
| 456 | background.watch(notebook.replicas()); | 489 | background.watch(notebook.replicas()); |
| 457 | Some(Arc::new(background)) | 490 | let background = Arc::new(background); |
| 491 | let _ = reports.set(Arc::downgrade(&background)); | ||
| 492 | (Some(background), watch.map(Arc::new)) | ||
| 458 | } | 493 | } |
| 459 | 494 | ||
| 460 | pub fn file_name(path: &Path) -> String { | 495 | pub fn file_name(path: &Path) -> String { |
| ... | @@ -483,7 +518,7 @@ fn tabs(catalog: &Folder) -> Vec<Tab> { | ... | @@ -483,7 +518,7 @@ fn tabs(catalog: &Folder) -> Vec<Tab> { |
| 483 | .sections | 518 | .sections |
| 484 | .iter() | 519 | .iter() |
| 485 | .filter_map(|section| match &section.state { | 520 | .filter_map(|section| match &section.state { |
| 486 | SectionState::Readable { name, color } => Some(Tab { | 521 | SectionState::Readable { name, color, .. } => Some(Tab { |
| 487 | name: section_name(&section.path, name), | 522 | name: section_name(&section.path, name), |
| 488 | path: section.path.clone(), | 523 | path: section.path.clone(), |
| 489 | color: *color, | 524 | color: *color, |
| ... | @@ -668,6 +703,7 @@ mod tests { | ... | @@ -668,6 +703,7 @@ mod tests { |
| 668 | notice: None, | 703 | notice: None, |
| 669 | cache: PathBuf::new(), | 704 | cache: PathBuf::new(), |
| 670 | background: None, | 705 | background: None, |
| 706 | watch: None, | ||
| 671 | }; | 707 | }; |
| 672 | let shown = |root, file| library(root).local(Path::new(file)); | 708 | let shown = |root, file| library(root).local(Path::new(file)); |
| 673 | let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); | 709 | let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); |
crates/snowbound/src/linux.rs+2| ... | @@ -25,6 +25,8 @@ use winit::{ | ... | @@ -25,6 +25,8 @@ use winit::{ |
| 25 | const APP_ID: &str = "snowbound"; | 25 | const APP_ID: &str = "snowbound"; |
| 26 | /// The title bar's leading margin; the window controls sit at its trailing end. | 26 | /// The title bar's leading margin; the window controls sit at its trailing end. |
| 27 | pub const LEADING: f32 = 8.0; | 27 | pub const LEADING: f32 = 8.0; |
| 28 | /// The margin past the window controls. | ||
| 29 | pub const TRAILING: f32 = 8.0; | ||
| 28 | /// How far inside the window's edges a press resizes it, and how far along them a corner | 30 | /// How far inside the window's edges a press resizes it, and how far along them a corner |
| 29 | /// reaches, in logical pixels. | 31 | /// reaches, in logical pixels. |
| 30 | const EDGE: f32 = 5.0; | 32 | const EDGE: f32 = 5.0; |
crates/snowbound/src/macos.rs+2| ... | @@ -86,6 +86,8 @@ fn ns_window(window: &Window) -> Retained<AnyObject> { | ... | @@ -86,6 +86,8 @@ fn ns_window(window: &Window) -> Retained<AnyObject> { |
| 86 | 86 | ||
| 87 | /// Room the traffic lights take at the title bar's leading edge. | 87 | /// Room the traffic lights take at the title bar's leading edge. |
| 88 | pub const LEADING: f32 = 78.0; | 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 | /// How far AppKit rounds a window's corners. | 91 | /// How far AppKit rounds a window's corners. |
| 90 | pub const CORNER_RADIUS: f32 = 10.0; | 92 | pub const CORNER_RADIUS: f32 = 10.0; |
| 91 | 93 |
crates/snowbound/src/main.rs+36-1| ... | @@ -31,6 +31,7 @@ mod surface; | ... | @@ -31,6 +31,7 @@ mod surface; |
| 31 | mod sync; | 31 | mod sync; |
| 32 | mod tags; | 32 | mod tags; |
| 33 | mod templates; | 33 | mod templates; |
| 34 | mod watch; | ||
| 34 | 35 | ||
| 35 | use canvas::gpu::page::PageScene; | 36 | use canvas::gpu::page::PageScene; |
| 36 | use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility}; | 37 | use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility}; |
| ... | @@ -1200,6 +1201,13 @@ impl State { | ... | @@ -1200,6 +1201,13 @@ impl State { |
| 1200 | if self.session.is_some() { | 1201 | if self.session.is_some() { |
| 1201 | self.page_tools(&theme); | 1202 | self.page_tools(&theme); |
| 1202 | } | 1203 | } |
| 1204 | self.ui.leaf( | ||
| 1205 | "trail", | ||
| 1206 | Spec { | ||
| 1207 | size: [px(self.trailing() - FRAME), px(1.0)], | ||
| 1208 | ..Spec::default() | ||
| 1209 | }, | ||
| 1210 | ); | ||
| 1203 | self.ui.close(); | 1211 | self.ui.close(); |
| 1204 | 1212 | ||
| 1205 | self.ui.open_as( | 1213 | self.ui.open_as( |
| ... | @@ -1268,6 +1276,23 @@ impl State { | ... | @@ -1268,6 +1276,23 @@ impl State { |
| 1268 | } | 1276 | } |
| 1269 | } | 1277 | } |
| 1270 | self.ui.close(); | 1278 | self.ui.close(); |
| 1279 | if let Some(task) = self.view.task_under_pointer() { | ||
| 1280 | let [left, top, ..] = self.ui.rect(page()).unwrap_or_default(); | ||
| 1281 | let scale = self.ui.scale(); | ||
| 1282 | let [x0, y0, x1, y1] = task; | ||
| 1283 | let part = [ | ||
| 1284 | left + x0 / scale, | ||
| 1285 | top + y0 / scale, | ||
| 1286 | left + x1 / scale, | ||
| 1287 | top + y1 / scale, | ||
| 1288 | ]; | ||
| 1289 | ui::popup::tooltip_over( | ||
| 1290 | &mut self.ui, | ||
| 1291 | part, | ||
| 1292 | "Outlook task", | ||
| 1293 | Some("Edit it in OneNote with Outlook"), | ||
| 1294 | ); | ||
| 1295 | } | ||
| 1271 | let signal = self.ui.signal(page()); | 1296 | let signal = self.ui.signal(page()); |
| 1272 | let focused = self.ui.window_focused && self.ui.focused() == Some(page()); | 1297 | let focused = self.ui.window_focused && self.ui.focused() == Some(page()); |
| 1273 | if focused != self.page_focused { | 1298 | if focused != self.page_focused { |
| ... | @@ -1411,6 +1436,16 @@ impl State { | ... | @@ -1411,6 +1436,16 @@ impl State { |
| 1411 | ); | 1436 | ); |
| 1412 | } | 1437 | } |
| 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 | /// Whether the chrome's empty space drags the window: where it is the title bar, or | 1449 | /// Whether the chrome's empty space drags the window: where it is the title bar, or |
| 1415 | /// over the title's gradient. | 1450 | /// over the title's gradient. |
| 1416 | fn chrome_drags(&self) -> bool { | 1451 | fn chrome_drags(&self) -> bool { |
| ... | @@ -1468,7 +1503,7 @@ impl State { | ... | @@ -1468,7 +1503,7 @@ impl State { |
| 1468 | self.ui.leaf( | 1503 | self.ui.leaf( |
| 1469 | "trail", | 1504 | "trail", |
| 1470 | Spec { | 1505 | Spec { |
| 1471 | size: [px(FRAME - GAP), px(1.0)], | 1506 | size: [px(self.trailing() - GAP), px(1.0)], |
| 1472 | ..Spec::default() | 1507 | ..Spec::default() |
| 1473 | }, | 1508 | }, |
| 1474 | ); | 1509 | ); |
crates/snowbound/src/manage.rs+5-1| ... | @@ -189,9 +189,13 @@ impl State { | ... | @@ -189,9 +189,13 @@ impl State { |
| 189 | Ok(()) | 189 | Ok(()) |
| 190 | } | 190 | } |
| 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 | pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) { | 194 | pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) { |
| 194 | self.notebooks.retain(|open| !Arc::ptr_eq(open, library)); | 195 | self.notebooks.retain(|open| !Arc::ptr_eq(open, library)); |
| 196 | if let Some(background) = &library.background { | ||
| 197 | background.discard(); | ||
| 198 | } | ||
| 195 | let shown = |shown: &Arc<Library>| shown.location == library.location; | 199 | let shown = |shown: &Arc<Library>| shown.location == library.location; |
| 196 | if self.sectionless.as_ref().is_some_and(shown) | 200 | if self.sectionless.as_ref().is_some_and(shown) |
| 197 | || self | 201 | || self |
crates/snowbound/src/meeting.rs+2| ... | @@ -164,6 +164,8 @@ pub fn content(page: &Page) -> Result<Page, Box<dyn Error>> { | ... | @@ -164,6 +164,8 @@ pub fn content(page: &Page) -> Result<Page, Box<dyn Error>> { |
| 164 | vec![Tag { | 164 | vec![Tag { |
| 165 | definition: Some(to_do), | 165 | definition: Some(to_do), |
| 166 | action_type: None, | 166 | action_type: None, |
| 167 | shape: None, | ||
| 168 | property_status: None, | ||
| 167 | status: 0, | 169 | status: 0, |
| 168 | created: Some(created), | 170 | created: Some(created), |
| 169 | completed: Some(0), | 171 | completed: Some(0), |
crates/snowbound/src/sidebar.rs+1-1| ... | @@ -260,7 +260,7 @@ fn folder( | ... | @@ -260,7 +260,7 @@ fn folder( |
| 260 | }; | 260 | }; |
| 261 | for section in &folder.sections { | 261 | for section in &folder.sections { |
| 262 | let (name, color, readable) = match &section.state { | 262 | let (name, color, readable) = match &section.state { |
| 263 | SectionState::Readable { name, color } => ( | 263 | SectionState::Readable { name, color, .. } => ( |
| 264 | crate::library::section_name(&section.path, name), | 264 | crate::library::section_name(&section.path, name), |
| 265 | *color, | 265 | *color, |
| 266 | true, | 266 | true, |
crates/snowbound/src/tags.rs+8-4| ... | @@ -103,13 +103,13 @@ fn label(ui: &mut Ui, part: &str, text: &str, tag: &NoteTag) { | ... | @@ -103,13 +103,13 @@ fn label(ui: &mut Ui, part: &str, text: &str, tag: &NoteTag) { |
| 103 | ); | 103 | ); |
| 104 | } | 104 | } |
| 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 | ui.leaf( | 107 | ui.leaf( |
| 108 | part, | 108 | part, |
| 109 | Spec { | 109 | Spec { |
| 110 | size: [px(SIDE + 4.0), px(ROW)], | 110 | size: [px(SIDE + 4.0), px(ROW)], |
| 111 | icon: art, | 111 | icon: art, |
| 112 | color: Some([1.0; 4]), | 112 | color: Some(tint), |
| 113 | center: true, | 113 | center: true, |
| 114 | ..Spec::default() | 114 | ..Spec::default() |
| 115 | }, | 115 | }, |
| ... | @@ -324,7 +324,11 @@ impl State { | ... | @@ -324,7 +324,11 @@ impl State { |
| 324 | ..Spec::default() | 324 | ..Spec::default() |
| 325 | }, | 325 | }, |
| 326 | ); | 326 | ); |
| 327 | icon(ui, "icon", artwork(tag)); | 327 | // OneNote's list marks a tag without a symbol with its "None" symbol art. |
| 328 | match artwork(tag) { | ||
| 329 | Some(art) => icon(ui, "icon", Some(art), [1.0; 4]), | ||
| 330 | None => icon(ui, "icon", Some(crate::art::FONT_COLOR), theme.text), | ||
| 331 | } | ||
| 328 | let text = match commands::tag_chord(place) { | 332 | let text = match commands::tag_chord(place) { |
| 329 | Some(chord) => format!("{} ({})", tag.label, chord.label(Platform::CURRENT)), | 333 | Some(chord) => format!("{} ({})", tag.label, chord.label(Platform::CURRENT)), |
| 330 | None => tag.label.clone(), | 334 | None => tag.label.clone(), |
| ... | @@ -585,7 +589,7 @@ fn tag_editor( | ... | @@ -585,7 +589,7 @@ fn tag_editor( |
| 585 | ..Spec::default() | 589 | ..Spec::default() |
| 586 | }, | 590 | }, |
| 587 | ); | 591 | ); |
| 588 | icon(ui, "icon", artwork(tag)); | 592 | icon(ui, "icon", artwork(tag), [1.0; 4]); |
| 589 | label(ui, "label", &tag.label, tag); | 593 | label(ui, "label", &tag.label, tag); |
| 590 | ui.leaf( | 594 | ui.leaf( |
| 591 | "end", | 595 | "end", |
crates/snowbound/src/watch.rs created+441| ... | @@ -0,0 +1,441 @@ | ||
| 1 | //! Changes below a notebook folder on this computer, as the system reports them: FSEvents on | ||
| 2 | //! macOS, inotify on Linux. A notebook on a share learns of its changes from the server | ||
| 3 | //! instead (`notebook::session::Background::smb`). | ||
| 4 | |||
| 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,6 +138,22 @@ pub fn menu( |
| 138 | /// delay, or at once while another has just shown. A press or the wheel hides it until the | 138 | /// delay, or at once while another has just shown. A press or the wheel hides it until the |
| 139 | /// pointer leaves. | 139 | /// pointer leaves. |
| 140 | pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) { | 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 | let Some(&index) = ui.nodes[*ui.stack.last().unwrap()].children.last() else { | 157 | let Some(&index) = ui.nodes[*ui.stack.last().unwrap()].children.last() else { |
| 142 | return; | 158 | return; |
| 143 | }; | 159 | }; |
| ... | @@ -183,7 +199,7 @@ pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) | ... | @@ -183,7 +199,7 @@ pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) |
| 183 | } else { | 199 | } else { |
| 184 | format!("{title} ({keys})") | 200 | format!("{title} ({keys})") |
| 185 | }; | 201 | }; |
| 186 | let [left, top, right, bottom] = ui.rect(id).unwrap_or_default(); | 202 | let [left, top, right, bottom] = part.or(ui.rect(id)).unwrap_or_default(); |
| 187 | let theme = &ui.theme; | 203 | let theme = &ui.theme; |
| 188 | let spec = Spec { | 204 | let spec = Spec { |
| 189 | axis: Axis::Y, | 205 | axis: Axis::Y, |
tools/test_task_tags.py created+52| ... | @@ -0,0 +1,52 @@ | ||
| 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() | ||