authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 02:36:25-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 17:00:32-07:00
log190fb2d10c46ced562a7f0d96f63aa803f4b47d4
treeea89604d82729c5c97cfc818fc01ddae111363b1
parent8a86e3ac0f817d8d27104a0a58e865b17e94eb35
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: write note tags and tag definitions through the page model

Note tags on paragraphs and text objects, and the tag definitions the page model carries, are differences the page writer now lowers. A property-set array writer joins the property patcher: an encoded array of sets is inserted whole with its nested object references appended to the reference stream in traversal order, which is where a rewritten tag array sits. The writer rewrites an element's tag array from the model (definition reference, action type, status, creation, completion, start and due dates and task id), creates definition objects for definitions new to the section under writer identities that squash renames to the model's, and refuses a second tag with the same action type on one element as OneNote does. The model mutation driver toggles tags. corpus/tag-edit is the native gate: a Rust-authored page with two definitions and open, completed, doubly tagged and untagged paragraphs, cold-read by OneNote 2010 with the definitions, completion states and dates intact; tools/test_tag_edit.py checks it without a VM. Assisted-by: claude-fable-5.1

24 files changed, 911 insertions(+), 29 deletions(-)

corpus/tag-edit/README.md created+19
...@@ -0,0 +1,19 @@
1# Rust note tag authoring
2
3`candidate/` is the page writer's output for
4`new_definitions_and_tags_publish_on_a_fresh_page` in
5`crates/onestore/tests/page_tags.rs`: on a section created in Rust, two tag
6definitions are added ("Rust task", type 0, symbol 3; "Important", type 1,
7symbol 13), the first paragraph gains an open task, a second paragraph a
8completed task with creation and completion dates, a third paragraph both
9tags, and a fourth paragraph none. Tags are property-set arrays on the text
10object referencing definition objects (jcid 0x120043) that carry the model's
11identities after squash; an element holds one tag per action type, as OneNote
12requires.
13
14`cold/` is a fresh OneNote 2010 read: both `TagDef` entries appear with their
15type, symbol and name, the tags read back with their completion state and
16dates, and the untagged paragraph has none. `tools/test_tag_edit.py` checks
17this without a VM. Regenerate with `ONESTORE_TAG_EXPORT` set to a new absolute
18directory while running the test, then cold-open it with
19`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook`.
corpus/tag-edit/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/tag-edit/candidate/Open Notebook.onetoc2 differ
corpus/tag-edit/candidate/tags.one created
Binary files /dev/null and b/corpus/tag-edit/candidate/tags.one differ
corpus/tag-edit/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-CFD607C8 -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/tag-edit/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-cfd607c8", "hostname": "ONE-M6-CFD607C8"}
corpus/tag-edit/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/tag-edit/cold/notebook/Open Notebook.onetoc2 differ
corpus/tag-edit/cold/notebook/tags.one created
Binary files /dev/null and b/corpus/tag-edit/cold/notebook/tags.one differ
corpus/tag-edit/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-CFD607C8",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/tag-edit/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="{776770BA-9325-43FF-BB17-ADC70305D68D}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T09:33:49.000Z" color="#B49EDE"><one:Section name="tags" ID="{124F54B0-18DB-05C6-2862-7599822E55B2}{1}{B0}" path="C:\one-tests\runs\capture\notebook\tags.one" lastModifiedTime="2026-09-12T09:33:49.000Z" color="#8AA8E4"><one:Page ID="{8B9A6BBA-7DEE-0679-2C42-81467412EBEF}{1}{B0}" name="Open task" dateTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:33:49.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/tag-edit/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="{8B9A6BBA-7DEE-0679-2C42-81467412EBEF}{1}{B0}" name="Open task" dateTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:33:49.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Rust task"/><one:TagDef index="1" type="1" symbol="13" fontColor="automatic" highlightColor="none" name="Important"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{124F54B0-18DB-05C6-2862-7599822E55B2}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="158.2036895751953" height="53.71086883544922"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{124F54B0-18DB-05C6-2862-7599822E55B2}{24}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Open task]]></one:T></one:OE><one:OE author="Tag author" lastModifiedBy="Tag author" creationTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{13111015-B879-0D3E-10E3-0C769EF30D4A}{0}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[Completed task]]></one:T></one:OE><one:OE author="Tag author" lastModifiedBy="Tag author" creationTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{F2B0539D-F9DE-062B-07BC-8EFA0C693E00}{0}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:Tag index="1" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Important open task]]></one:T></one:OE><one:OE author="Tag author" lastModifiedBy="Tag author" creationTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{8F65AAD5-5CD7-0F4E-24EF-4FACE80A618B}{0}{B0}" alignment="left"><one:T><![CDATA[Untagged]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/tag-edit/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/tag-edit/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/m4/tags",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/tag-edit/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/tag-edit/cold/scripts/read.ps1 created+142
...@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/tag-edit/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1596,
5 "sha256": "f3ce1472c9be35b1fb52683c5214e62850787e8e13bbd1918fed81fd19a48a65",
6 "mtime_ns": 1789205536390985808
7 },
8 {
9 "path": "tags.one",
10 "bytes": 8352,
11 "sha256": "601a8998b691bab66830196b3b139be6ea58d6f29d5bc69b3efa9892e2fc1550",
12 "mtime_ns": 1789205536390698932
13 }
14]
corpus/tag-edit/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
crates/notebook/README.md+2-2
...@@ -14,8 +14,8 @@ diffs the supplied model against the page stored in the supplied local snapshot,...@@ -14,8 +14,8 @@ diffs the supplied model against the page stored in the supplied local snapshot,
14writes the difference into the working image and queues one14writes the difference into the working image and queues one
15`Operation::Page(PageIntent { before, after, author })`; `before` is the page the15`Operation::Page(PageIntent { before, after, author })`; `before` is the page the
16edit started from and is the precondition reconciliation checks. Text, styles,16edit started from and is the precondition reconciliation checks. Text, styles,
17paragraph formatting, bullets and numbering, paragraph structure, outline17paragraph formatting, bullets, numbering and note tags, paragraph structure,
18layout, insertions and deletions are all differences between `before` and18outline layout, insertions and deletions are all differences between `before` and
19`after`; the library never sees editor operations.19`after`; the library never sees editor operations.
2020
21```no_run21```no_run
crates/onestore/README.md+1-1
...@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`....@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`.
57| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |57| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
58| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |58| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
59| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |59| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |
60| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, bullets and numbering, paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities |60| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, bullets and numbering, note tags, paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities |
61| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |61| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
62| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |62| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
63| `read_file` | Read a snapshot under whole-file exclusion |63| `read_file` | Read a snapshot under whole-file exclusion |
crates/onestore/src/page/write.rs+198-15
...@@ -5,7 +5,7 @@ use super::{Outline, Page, PageObject, PageParagraph, ParagraphContent};...@@ -5,7 +5,7 @@ use super::{Outline, Page, PageObject, PageParagraph, ParagraphContent};
5use crate::{5use crate::{
6 Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets,6 Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets,
7 RevisionIndex, Store, TextAttribute, TreeEdit, Value,7 RevisionIndex, Store, TextAttribute, TreeEdit, Value,
8 document::{Document, Format, Kind},8 document::{Document, Format, Kind, Tag},
9 write::{PropertyObject, RevisionEdit, write_revisions},9 write::{PropertyObject, RevisionEdit, write_revisions},
10};10};
11use std::{11use std::{
...@@ -13,6 +13,9 @@ use std::{...@@ -13,6 +13,9 @@ use std::{
13 ops::Range,13 ops::Range,
14};14};
1515
16/// Property identifiers with their encoded values.
17type Values = Vec<(u32, Vec<u8>)>;
18
16fn invalid(message: &'static str) -> Error {19fn invalid(message: &'static str) -> Error {
17 Error { offset: 0, message }20 Error { offset: 0, message }
18}21}
...@@ -174,9 +177,12 @@ impl Lowering<'_> {...@@ -174,9 +177,12 @@ impl Lowering<'_> {
174 }177 }
175 for (id, definition) in &after.definitions {178 for (id, definition) in &after.definitions {
176 if before.definitions.get(id) != Some(definition)179 if before.definitions.get(id) != Some(definition)
177 && !matches!(definition.kind, Kind::List { .. })180 && !matches!(
181 definition.kind,
182 Kind::List { .. } | Kind::TagDefinition { .. }
183 )
178 {184 {
179 return Err(invalid("Tag and style definitions cannot be edited"));185 return Err(invalid("Style definitions cannot be edited"));
180 }186 }
181 }187 }
182 let old = View::new(before)?;188 let old = View::new(before)?;
...@@ -209,6 +215,7 @@ impl Lowering<'_> {...@@ -209,6 +215,7 @@ impl Lowering<'_> {
209 self.delete(&old, &new, &consumed)?;215 self.delete(&old, &new, &consumed)?;
210 self.edit_text(&new)?;216 self.edit_text(&new)?;
211 self.edit_lists(after, &new)?;217 self.edit_lists(after, &new)?;
218 self.edit_tags(after, &new)?;
212 self.edit_paragraph_formatting(&new)?;219 self.edit_paragraph_formatting(&new)?;
213 self.edit_formatting(&new)?;220 self.edit_formatting(&new)?;
214 self.edit_layout(&old, &new)?;221 self.edit_layout(&old, &new)?;
...@@ -275,18 +282,16 @@ impl Lowering<'_> {...@@ -275,18 +282,16 @@ impl Lowering<'_> {
275 let Some(previous) = old.paragraphs.get(id) else {282 let Some(previous) = old.paragraphs.get(id) else {
276 continue;283 continue;
277 };284 };
278 let same = paragraph.tags == previous.tags285 let same = paragraph.style == previous.style && paragraph.format == previous.format;
279 && paragraph.style == previous.style
280 && paragraph.format == previous.format;
281 if !same {286 if !same {
282 return Err(invalid(287 return Err(invalid(
283 "Paragraph tags, styles and paragraph formatting cannot be edited",288 "Paragraph styles and paragraph formatting cannot be edited",
284 ));289 ));
285 }290 }
286 match (&paragraph.content, &previous.content) {291 match (&paragraph.content, &previous.content) {
287 (ParagraphContent::Text(text), ParagraphContent::Text(previous)) => {292 (ParagraphContent::Text(text), ParagraphContent::Text(previous)) => {
288 if text.date_field != previous.date_field || text.tags != previous.tags {293 if text.date_field != previous.date_field {
289 return Err(invalid("Text fields and tags cannot be edited"));294 return Err(invalid("Text fields cannot be edited"));
290 }295 }
291 }296 }
292 (ParagraphContent::Table(table), ParagraphContent::Table(previous)) => {297 (ParagraphContent::Table(table), ParagraphContent::Table(previous)) => {
...@@ -558,12 +563,10 @@ impl Lowering<'_> {...@@ -558,12 +563,10 @@ impl Lowering<'_> {
558 let paragraph = new.paragraphs[id];563 let paragraph = new.paragraphs[id];
559 let text = paragraph564 let text = paragraph
560 .text()565 .text()
561 .filter(|text| text.date_field.is_none() && text.tags.is_empty())566 .filter(|text| text.date_field.is_none())
562 .filter(|_| paragraph.tags.is_empty() && paragraph.style.is_none())567 .filter(|_| paragraph.style.is_none())
563 .ok_or_else(|| {568 .ok_or_else(|| {
564 invalid(569 invalid("New paragraphs contain plain text without fields or styles")
565 "New paragraphs contain plain text without tags, fields or styles",
566 )
567 })?;570 })?;
568 let insertion = Insertion::paragraph(571 let insertion = Insertion::paragraph(
569 self.id(container),572 self.id(container),
...@@ -725,7 +728,7 @@ impl Lowering<'_> {...@@ -725,7 +728,7 @@ impl Lowering<'_> {
725 if bullet.is_some() && (format.is_none() || font.is_none()) {728 if bullet.is_some() && (format.is_none() || font.is_none()) {
726 return Err(invalid("A bullet definition names its glyph and font"));729 return Err(invalid("A bullet definition names its glyph and font"));
727 }730 }
728 let mut values: Vec<(u32, Vec<u8>)> = Vec::new();731 let mut values: Values = Vec::new();
729 if let Some(format) = format {732 if let Some(format) = format {
730 let units: Vec<u16> = format.encode_utf16().collect();733 let units: Vec<u16> = format.encode_utf16().collect();
731 let count = u16::try_from(units.len())734 let count = u16::try_from(units.len())
...@@ -838,6 +841,186 @@ impl Lowering<'_> {...@@ -838,6 +841,186 @@ impl Lowering<'_> {
838 Ok(())841 Ok(())
839 }842 }
840843
844 /// Rewrites the note tags of paragraphs and text objects whose model tags differ from
845 /// the stored ones. A tag definition new to the section becomes a definition object
846 /// carrying the model's identity after squash.
847 fn edit_tags(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> {
848 fn same(a: &[Tag], b: &[Tag]) -> bool {
849 let key = |t: &Tag| {
850 (
851 t.definition,
852 t.action_type,
853 t.status,
854 t.created,
855 t.completed,
856 t.start,
857 t.due,
858 t.task_id,
859 )
860 };
861 a.len() == b.len() && a.iter().zip(b).all(|(x, y)| key(x) == key(y))
862 }
863 let current = self.current()?;
864 let stored = View::new(&current)?;
865 for (id, paragraph) in &new.paragraphs {
866 let image_id = self.id(*id);
867 let previous = stored
868 .paragraphs
869 .get(&image_id)
870 .ok_or_else(|| invalid("A paragraph is missing after text edits"))?;
871 let mut targets = vec![(image_id, &paragraph.tags, &previous.tags)];
872 if let (Some(text), Some(before)) = (paragraph.text(), previous.text()) {
873 targets.push((self.id(text.id), &text.tags, &before.tags));
874 }
875 for (object, tags, stored_tags) in targets {
876 if same(tags, stored_tags) {
877 continue;
878 }
879 let mut definitions: Vec<(ExGuid, Option<Values>)> = Vec::new();
880 let mut sets: Vec<(usize, Values)> = Vec::new();
881 let mut action_types = BTreeSet::new();
882 for tag in tags {
883 let definition = tag
884 .definition
885 .ok_or_else(|| invalid("A note tag names its definition"))?;
886 let action_type = if tag.status & 4 != 0 {
887 tag.action_type
888 } else {
889 match after.definitions.get(&definition).map(|d| &d.kind) {
890 Some(Kind::TagDefinition { action_type, .. }) => *action_type,
891 _ => return Err(invalid("A note tag must reference a tag definition")),
892 }
893 };
894 if !action_types.insert(action_type.unwrap_or(0)) {
895 return Err(invalid("An element holds one note tag per action type"));
896 }
897 let known = current.definitions.contains_key(&definition)
898 || self.alias.contains_key(&definition);
899 let written = if known {
900 self.id(definition)
901 } else {
902 let allocated = ExGuid {
903 guid: crate::write::fresh_guid()?,
904 n: 1,
905 };
906 self.alias.insert(definition, allocated);
907 allocated
908 };
909 let index = match definitions.iter().position(|(id, _)| *id == written) {
910 Some(index) => index,
911 None => {
912 let values = if known {
913 None
914 } else {
915 let Some(model) = after.definitions.get(&definition) else {
916 return Err(invalid(
917 "A note tag references a missing tag definition",
918 ));
919 };
920 let Kind::TagDefinition {
921 label,
922 action_type,
923 shape,
924 color,
925 highlight,
926 } = &model.kind
927 else {
928 return Err(invalid(
929 "A note tag must reference a tag definition",
930 ));
931 };
932 let mut values: Values = vec![
933 (0x0c003473, vec![0]),
934 (0x10003463, action_type.unwrap_or(0).to_le_bytes().to_vec()),
935 (0x10003464, shape.unwrap_or(0).to_le_bytes().to_vec()),
936 (0x14003467, 0u32.to_le_bytes().to_vec()),
937 ];
938 if let Some(label) = label {
939 values.push((0x1c003468, crate::create::string(label)));
940 }
941 if let Some(color) = color {
942 values.push((0x14003466, color.to_le_bytes().to_vec()));
943 }
944 if let Some(highlight) = highlight {
945 values.push((0x14003465, highlight.to_le_bytes().to_vec()));
946 }
947 Some(values)
948 };
949 definitions.push((written, values));
950 definitions.len() - 1
951 }
952 };
953 let mut fields: Values = Vec::new();
954 if let Some(action_type) = tag.action_type {
955 fields.push((0x10003463, action_type.to_le_bytes().to_vec()));
956 }
957 for (id, value) in [
958 (0x1400346e, tag.created),
959 (0x1400346f, tag.completed),
960 (0x1400346a, tag.start),
961 (0x1400346b, tag.due),
962 ] {
963 if let Some(value) = value {
964 fields.push((id, value.to_le_bytes().to_vec()));
965 }
966 }
967 fields.push((0x10003470, tag.status.to_le_bytes().to_vec()));
968 if let Some(task) = tag.task_id {
969 fields.push((0x1c003469, task.to_vec()));
970 }
971 sets.push((index, fields));
972 }
973 let space = self.space;
974 self.apply(|image| {
975 let store = Store::parse(image)?;
976 let index = RevisionIndex::parse(&store)?;
977 let document = Document::parse(&index)?;
978 let parents = crate::edit::editable_parents(
979 document.active(space)?,
980 &document.pages_in(space)?,
981 object,
982 )?;
983 let modified = crate::create::current_timestamps()?.0.to_le_bytes();
984 crate::write::write_revision(image, space, |raw| {
985 let mut changed = BTreeMap::new();
986 for (id, values) in &definitions {
987 let Some(values) = values else {
988 continue;
989 };
990 let mut node = PropertyObject {
991 jcid: 0x120043,
992 bytes: crate::create::properties(values)?,
993 global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])),
994 };
995 node.reference(*id)?;
996 changed.insert(*id, node);
997 }
998 let mut target = PropertyObject::from_object(&raw.objects[&object])?;
999 let mut encoded = Vec::new();
1000 for (index, fields) in &sets {
1001 let reference = target.reference(definitions[*index].0)?;
1002 let mut set = vec![(0x20003488, reference.to_vec())];
1003 set.extend(fields.iter().cloned());
1004 encoded.push(set);
1005 }
1006 target.set_sets(0x40003489, 0x44000811, &encoded)?;
1007 target.set(&[(0x14001d7a, &modified)])?;
1008 changed.insert(object, target);
1009 crate::formatting::touch_ancestors(
1010 raw,
1011 &parents,
1012 object,
1013 &modified,
1014 &mut changed,
1015 )?;
1016 Ok(changed)
1017 })
1018 })?;
1019 }
1020 }
1021 Ok(())
1022 }
1023
841 fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> {1024 fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> {
842 let current = self.current()?;1025 let current = self.current()?;
843 let current = View::new(&current)?;1026 let current = View::new(&current)?;
crates/onestore/src/write.rs+58-4
...@@ -133,6 +133,7 @@ fn patch_properties(...@@ -133,6 +133,7 @@ fn patch_properties(
133 blob: &[u8],133 blob: &[u8],
134 updates: &[(u32, &[u8])],134 updates: &[(u32, &[u8])],
135 inserts: &[(u32, &[u8])],135 inserts: &[(u32, &[u8])],
136 nested_references: &[u8],
136) -> Result<Vec<u8>> {137) -> Result<Vec<u8>> {
137 let properties = PropertySets::parse(blob)?;138 let properties = PropertySets::parse(blob)?;
138 let root = &properties.sets[0];139 let root = &properties.sets[0];
...@@ -167,6 +168,8 @@ fn patch_properties(...@@ -167,6 +168,8 @@ fn patch_properties(
167 7 => value.len() < 0x40000000,168 7 => value.len() < 0x40000000,
168 8 => value.len() == 4,169 8 => value.len() == 4,
169 9 => value.len().is_multiple_of(4) && value.len() / 4 <= 0xffffff,170 9 => value.len().is_multiple_of(4) && value.len() / 4 <= 0xffffff,
171 // An encoded property-set array is inserted whole; its references follow.
172 16 => i >= updates.len() && value.len() >= 4,
170 _ => {173 _ => {
171 return Err(Error {174 return Err(Error {
172 offset: 0,175 offset: 0,
...@@ -198,13 +201,13 @@ fn patch_properties(...@@ -198,13 +201,13 @@ fn patch_properties(
198 9 => encoded.extend_from_slice(&(value.len() as u32 / 4).to_le_bytes()),201 9 => encoded.extend_from_slice(&(value.len() as u32 / 4).to_le_bytes()),
199 _ => {}202 _ => {}
200 }203 }
201 if (3..=7).contains(&kind) {204 if (3..=7).contains(&kind) || kind == 16 {
202 encoded.extend_from_slice(value);205 encoded.extend_from_slice(value);
203 }206 }
204 if adding {207 if adding {
205 added_ids.extend_from_slice(&property.to_le_bytes());208 added_ids.extend_from_slice(&property.to_le_bytes());
206 added_fields.extend_from_slice(&encoded);209 added_fields.extend_from_slice(&encoded);
207 if kind >= 8 {210 if (8..=9).contains(&kind) {
208 object_count += (value.len() / 4) as i64;211 object_count += (value.len() / 4) as i64;
209 added_references.extend_from_slice(value);212 added_references.extend_from_slice(value);
210 }213 }
...@@ -232,6 +235,7 @@ fn patch_properties(...@@ -232,6 +235,7 @@ fn patch_properties(
232 }235 }
233 }236 }
234 }237 }
238 object_count += (nested_references.len() / 4) as i64;
235 if !(0..=0xffffff).contains(&object_count) {239 if !(0..=0xffffff).contains(&object_count) {
236 return Err(Error {240 return Err(Error {
237 offset: 0,241 offset: 0,
...@@ -242,6 +246,7 @@ fn patch_properties(...@@ -242,6 +246,7 @@ fn patch_properties(
242 if header != object_header {246 if header != object_header {
243 patches.push((0, 4, 0, header.to_le_bytes().to_vec()));247 patches.push((0, 4, 0, header.to_le_bytes().to_vec()));
244 }248 }
249 added_references.extend_from_slice(nested_references);
245 if !added_references.is_empty() {250 if !added_references.is_empty() {
246 let end = 4 + (object_header as usize & 0xffffff) * 4;251 let end = 4 + (object_header as usize & 0xffffff) * 4;
247 patches.push((end, end, usize::MAX, added_references));252 patches.push((end, end, usize::MAX, added_references));
...@@ -339,7 +344,7 @@ pub(crate) fn replace_objects(...@@ -339,7 +344,7 @@ pub(crate) fn replace_objects(
339 edit.object,344 edit.object,
340 PropertyObject {345 PropertyObject {
341 jcid: object.jcid,346 jcid: object.jcid,
342 bytes: patch_properties(blob, edit.updates, edit.inserts)?,347 bytes: patch_properties(blob, edit.updates, edit.inserts, &[])?,
343 global_ids: Arc::clone(&object.global_ids),348 global_ids: Arc::clone(&object.global_ids),
344 },349 },
345 );350 );
...@@ -384,7 +389,56 @@ impl PropertyObject {...@@ -384,7 +389,56 @@ impl PropertyObject {
384 .iter()389 .iter()
385 .any(|p| p.id & 0x7fffffff == id & 0x7fffffff)390 .any(|p| p.id & 0x7fffffff == id & 0x7fffffff)
386 });391 });
387 self.bytes = patch_properties(&self.bytes, &updates, &inserts)?;392 self.bytes = patch_properties(&self.bytes, &updates, &inserts, &[])?;
393 Ok(())
394 }
395
396 /// Replaces a property-set array such as note tags: each set lists scalar values
397 /// inline, while an object reference (a kind-8 identity) is supplied as the compact
398 /// identity `reference` produced and joins the object's reference stream in order.
399 pub fn set_sets(&mut self, id: u32, element: u32, sets: &[Vec<(u32, Vec<u8>)>]) -> Result<()> {
400 if (id >> 26) & 31 != 16 || (element >> 26) & 31 != 17 {
401 return Err(Error {
402 offset: 0,
403 message: "Property-set arrays need array and element identifiers",
404 });
405 }
406 self.remove(&[id])?;
407 if sets.is_empty() {
408 return Ok(());
409 }
410 let mut encoded = (sets.len() as u32).to_le_bytes().to_vec();
411 encoded.extend_from_slice(&element.to_le_bytes());
412 let mut references = Vec::new();
413 for set in sets {
414 encoded.extend_from_slice(&u16::try_from(set.len()).unwrap().to_le_bytes());
415 for (field, _) in set {
416 encoded.extend_from_slice(&field.to_le_bytes());
417 }
418 for (field, value) in set {
419 match (field >> 26) & 31 {
420 3..=6 => {
421 assert_eq!(value.len(), 1 << (((field >> 26) & 31) - 3));
422 encoded.extend_from_slice(value);
423 }
424 7 => {
425 encoded.extend_from_slice(&(value.len() as u32).to_le_bytes());
426 encoded.extend_from_slice(value);
427 }
428 8 => {
429 assert_eq!(value.len(), 4);
430 references.extend_from_slice(value);
431 }
432 _ => {
433 return Err(Error {
434 offset: 0,
435 message: "Property-set arrays hold scalars and single references",
436 });
437 }
438 }
439 }
440 }
441 self.bytes = patch_properties(&self.bytes, &[], &[(id, &encoded)], &references)?;
388 Ok(())442 Ok(())
389 }443 }
390444
crates/onestore/src/write/tests.rs+14-6
...@@ -221,6 +221,7 @@ fn add_paragraph(source: &[u8], number: u32) -> Vec<u8> {...@@ -221,6 +221,7 @@ fn add_paragraph(source: &[u8], number: u32) -> Vec<u8> {
221 blob,221 blob,
222 &[(0x24001c20, &children), (0x14001d7a, &modified)],222 &[(0x24001c20, &children), (0x14001d7a, &modified)],
223 &[],223 &[],
224 &[],
224 )?,225 )?,
225 global_ids: Arc::clone(&table),226 global_ids: Arc::clone(&table),
226 },227 },
...@@ -302,7 +303,7 @@ fn restyle(source: &[u8]) -> Vec<u8> {...@@ -302,7 +303,7 @@ fn restyle(source: &[u8]) -> Vec<u8> {
302 style,303 style,
303 PropertyObject {304 PropertyObject {
304 jcid: previous.jcid,305 jcid: previous.jcid,
305 bytes: patch_properties(blob, &[], &[(0x88001c04, &[])])?,306 bytes: patch_properties(blob, &[], &[(0x88001c04, &[])], &[])?,
306 global_ids: Arc::new(table),307 global_ids: Arc::new(table),
307 },308 },
308 )]);309 )]);
...@@ -316,7 +317,12 @@ fn restyle(source: &[u8]) -> Vec<u8> {...@@ -316,7 +317,12 @@ fn restyle(source: &[u8]) -> Vec<u8> {
316 *id,317 *id,
317 PropertyObject {318 PropertyObject {
318 jcid: text.jcid,319 jcid: text.jcid,
319 bytes: patch_properties(blob, &[(0x24001e13, &compact(style, &table)?)], &[])?,320 bytes: patch_properties(
321 blob,
322 &[(0x24001e13, &compact(style, &table)?)],
323 &[],
324 &[],
325 )?,
320 global_ids: Arc::new(table),326 global_ids: Arc::new(table),
321 },327 },
322 );328 );
...@@ -461,7 +467,8 @@ fn changed_graphs_reject_cycles_dangling_and_unreachable_additions() {...@@ -461,7 +467,8 @@ fn changed_graphs_reject_cycles_dangling_and_unreachable_additions() {
461 bytes: patch_properties(467 bytes: patch_properties(
462 blob,468 blob,
463 &[(0x24001c20, &compact(target, &outline.global_ids)?)],469 &[(0x24001c20, &compact(target, &outline.global_ids)?)],
464 &[]470 &[],
471 &[],
465 )?,472 )?,
466 global_ids: Arc::clone(&outline.global_ids),473 global_ids: Arc::clone(&outline.global_ids),
467 }474 }
...@@ -559,7 +566,7 @@ fn property_splices_match_independently_encoded_flat_sets() {...@@ -559,7 +566,7 @@ fn property_splices_match_independently_encoded_flat_sets() {
559 .map(|(id, value)| (*id, value.as_slice()))566 .map(|(id, value)| (*id, value.as_slice()))
560 .collect();567 .collect();
561 let original = properties(&original).unwrap();568 let original = properties(&original).unwrap();
562 let actual = patch_properties(&original, &updates, &inserts).unwrap();569 let actual = patch_properties(&original, &updates, &inserts, &[]).unwrap();
563 assert_eq!(actual, properties(&expected).unwrap(), "case {case}");570 assert_eq!(actual, properties(&expected).unwrap(), "case {case}");
564 }571 }
565}572}
...@@ -599,6 +606,7 @@ fn nested_fields_and_other_reference_streams_remain_byte_exact() {...@@ -599,6 +606,7 @@ fn nested_fields_and_other_reference_streams_remain_byte_exact() {
599 (0x24000001, &[6, 0, 0, 0, 7, 0, 0, 0]),606 (0x24000001, &[6, 0, 0, 0, 7, 0, 0, 0]),
600 ],607 ],
601 &[(0x24000005, &[8, 0, 0, 0]), (0x88000006, &[])],608 &[(0x24000005, &[8, 0, 0, 0]), (0x88000006, &[])],
609 &[],
602 )610 )
603 .unwrap();611 .unwrap();
604 let parsed = PropertySets::parse(&changed).unwrap();612 let parsed = PropertySets::parse(&changed).unwrap();
...@@ -717,7 +725,7 @@ fn deep_property_splices_do_not_use_the_call_stack() {...@@ -717,7 +725,7 @@ fn deep_property_splices_do_not_use_the_call_stack() {
717 bytes.extend_from_slice(&0x44000001_u32.to_le_bytes());725 bytes.extend_from_slice(&0x44000001_u32.to_le_bytes());
718 }726 }
719 bytes.extend_from_slice(&0_u16.to_le_bytes());727 bytes.extend_from_slice(&0_u16.to_le_bytes());
720 let changed = patch_properties(&bytes, &[], &[(0x88000002, &[])]).unwrap();728 let changed = patch_properties(&bytes, &[], &[(0x88000002, &[])], &[]).unwrap();
721 let parsed = PropertySets::parse(&changed).unwrap();729 let parsed = PropertySets::parse(&changed).unwrap();
722 assert_eq!(parsed.sets.len(), 100_001);730 assert_eq!(parsed.sets.len(), 100_001);
723 assert_eq!(parsed.sets[0].len(), 2);731 assert_eq!(parsed.sets[0].len(), 2);
...@@ -758,7 +766,7 @@ fn invalid_property_splices_are_rejected() {...@@ -758,7 +766,7 @@ fn invalid_property_splices_are_rejected() {
758 (vec![], vec![(0x88000001, &[][..])]),766 (vec![], vec![(0x88000001, &[][..])]),
759 (vec![], vec![(0x20000003, &[][..])]),767 (vec![], vec![(0x20000003, &[][..])]),
760 ] {768 ] {
761 assert!(patch_properties(&bytes, &updates, &inserts).is_err());769 assert!(patch_properties(&bytes, &updates, &inserts, &[]).is_err());
762 }770 }
763}771}
764772
crates/onestore/tests/page_tags.rs created+305
...@@ -0,0 +1,305 @@
1use onestore::{
2 ExGuid, PreparedEdit, RevisionIndex, Store,
3 document::{Document, Format, Kind, Tag},
4 page::{
5 Definition, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id,
6 },
7};
8
9const TREES: &[u8] =
10 include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one");
11const AUTHOR: &str = "Tag author";
12
13fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) {
14 let store = Store::parse(bytes).unwrap();
15 assert!(store.checksum_mismatches.is_empty());
16 let index = RevisionIndex::parse(&store).unwrap();
17 let document = Document::parse(&index).unwrap();
18 document
19 .pages()
20 .unwrap()
21 .into_iter()
22 .find_map(|(space, _)| {
23 let page = Page::from_space(&document, space).unwrap();
24 (page.title == title).then_some((space, page))
25 })
26 .unwrap()
27}
28
29fn page_in(bytes: &[u8], space: ExGuid) -> Page {
30 let store = Store::parse(bytes).unwrap();
31 assert!(store.checksum_mismatches.is_empty());
32 let index = RevisionIndex::parse(&store).unwrap();
33 Page::from_space(&Document::parse(&index).unwrap(), space).unwrap()
34}
35
36fn body_paragraphs(page: &mut Page) -> &mut Vec<PageParagraph> {
37 page.objects
38 .iter_mut()
39 .find_map(|object| match object {
40 PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs),
41 _ => None,
42 })
43 .unwrap()
44}
45
46/// Tag positions in the element's extra arena are assigned on read; equality ignores them.
47fn normalized(mut page: Page) -> Page {
48 for object in &mut page.objects {
49 if let PageObject::Outline(outline) = object {
50 for paragraph in &mut outline.paragraphs {
51 for tag in &mut paragraph.tags {
52 tag.extra_set = 0;
53 }
54 if let Some(text) = paragraph.text_mut() {
55 for tag in &mut text.tags {
56 tag.extra_set = 0;
57 }
58 }
59 }
60 }
61 }
62 page
63}
64
65fn assert_same(written: &[u8], space: ExGuid, expected: &Page) -> Page {
66 let stored = page_in(written, space);
67 let mut expected = expected.clone();
68 expected.title = stored.title.clone();
69 assert_eq!(normalized(stored.clone()), normalized(expected));
70 stored
71}
72
73fn tagged(page: &Page) -> usize {
74 page.objects
75 .iter()
76 .find_map(|object| match object {
77 PageObject::Outline(outline) if !outline.title => Some(
78 outline
79 .paragraphs
80 .iter()
81 .position(|p| p.text().is_some_and(|t| !t.tags.is_empty()))
82 .unwrap(),
83 ),
84 _ => None,
85 })
86 .unwrap()
87}
88
89fn raw_object(bytes: &[u8], space: ExGuid, id: ExGuid) -> String {
90 let store = Store::parse(bytes).unwrap();
91 let index = RevisionIndex::parse(&store).unwrap();
92 format!(
93 "{:?}",
94 index.resolve_active(space).unwrap().objects[&id].data
95 )
96}
97
98fn definition(label: &str, shape: u16) -> Definition {
99 Definition {
100 kind: Kind::TagDefinition {
101 label: Some(label.into()),
102 action_type: Some(0),
103 shape: Some(shape),
104 color: None,
105 highlight: None,
106 },
107 format: Format::default(),
108 }
109}
110
111fn tag(definition: ExGuid, completed: bool) -> Tag {
112 Tag {
113 definition: Some(definition),
114 action_type: None,
115 status: u16::from(completed),
116 created: Some(1_262_401_445),
117 completed: completed.then_some(1_262_405_106),
118 start: None,
119 due: None,
120 task_id: None,
121 extra_set: 0,
122 }
123}
124
125fn plain_paragraph(template: &PageParagraph, text: &str) -> PageParagraph {
126 let mut paragraph = template.clone();
127 paragraph.id = new_id().unwrap();
128 paragraph.parent = None;
129 paragraph.level = 1;
130 paragraph.lists.clear();
131 paragraph.tags.clear();
132 paragraph.style = None;
133 paragraph.collapsed = false;
134 paragraph.content = ParagraphContent::Text(TextObject {
135 id: new_id().unwrap(),
136 date_field: None,
137 text: onestore::page::Paragraph::new(
138 text.into(),
139 Format {
140 font: Some("Calibri".into()),
141 font_size: Some(11.0),
142 language: Some(1033),
143 ..Default::default()
144 },
145 ),
146 tags: Vec::new(),
147 });
148 paragraph
149}
150
151#[test]
152fn removing_a_tag_leaves_its_definition_and_the_other_paragraphs_untouched() {
153 let (space, before) = page_by_title(TREES, "Indent first subtree");
154 let at = tagged(&before);
155 let mut after = before.clone();
156 let definition = body_paragraphs(&mut after)[at].text().unwrap().tags[0]
157 .definition
158 .unwrap();
159 body_paragraphs(&mut after)[at]
160 .text_mut()
161 .unwrap()
162 .tags
163 .clear();
164 let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap();
165 let mut expected = after.clone();
166 expected.definitions.remove(&definition);
167 let stored = assert_same(written.as_bytes(), space, &expected);
168 assert!(!stored.definitions.contains_key(&definition));
169 assert_eq!(
170 raw_object(written.as_bytes(), space, definition),
171 raw_object(TREES, space, definition)
172 );
173 let count = body_paragraphs(&mut after).len();
174 let other = body_paragraphs(&mut after)[(at + 1) % count]
175 .text()
176 .unwrap()
177 .id;
178 assert_eq!(
179 raw_object(written.as_bytes(), space, other),
180 raw_object(TREES, space, other)
181 );
182}
183
184#[test]
185fn an_existing_definition_tags_another_paragraph_and_a_task_completes_in_place() {
186 let (space, before) = page_by_title(TREES, "Indent first subtree");
187 let at = tagged(&before);
188 let mut after = before.clone();
189 let existing = body_paragraphs(&mut after)[at].text().unwrap().tags[0].clone();
190 let definition = existing.definition.unwrap();
191 let count = body_paragraphs(&mut after).len();
192 let other = (at + 1) % count;
193 body_paragraphs(&mut after)[other]
194 .text_mut()
195 .unwrap()
196 .tags
197 .push(tag(definition, false));
198 let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap();
199 let stored = assert_same(written.as_bytes(), space, &after);
200 let mut completed = stored.clone();
201 let text = body_paragraphs(&mut completed)[other].text_mut().unwrap();
202 text.tags[0].status = 1;
203 text.tags[0].completed = Some(1_262_405_106);
204 let again = PreparedEdit::page(written.as_bytes(), space, &completed, AUTHOR).unwrap();
205 assert_same(again.as_bytes(), space, &completed);
206 assert_eq!(
207 PreparedEdit::page(
208 again.as_bytes(),
209 space,
210 &page_in(again.as_bytes(), space),
211 AUTHOR
212 )
213 .unwrap()
214 .as_bytes(),
215 again.as_bytes()
216 );
217}
218
219#[test]
220fn tags_without_a_known_definition_are_refused() {
221 let (space, before) = page_by_title(TREES, "Indent first subtree");
222 let mut missing = before.clone();
223 body_paragraphs(&mut missing)[0]
224 .text_mut()
225 .unwrap()
226 .tags
227 .push(tag(new_id().unwrap(), false));
228 assert!(PreparedEdit::page(TREES, space, &missing, AUTHOR).is_err());
229 let mut repeated = before.clone();
230 let at = tagged(&repeated);
231 let existing = body_paragraphs(&mut repeated)[at].text().unwrap().tags[0].clone();
232 body_paragraphs(&mut repeated)[at]
233 .text_mut()
234 .unwrap()
235 .tags
236 .push(tag(existing.definition.unwrap(), false));
237 assert!(PreparedEdit::page(TREES, space, &repeated, AUTHOR).is_err());
238 let mut wrong = before.clone();
239 let list = wrong
240 .definitions
241 .iter()
242 .find(|(_, d)| matches!(d.kind, Kind::List { .. }))
243 .map(|(id, _)| *id);
244 if let Some(list) = list {
245 body_paragraphs(&mut wrong)[0]
246 .text_mut()
247 .unwrap()
248 .tags
249 .push(tag(list, false));
250 assert!(PreparedEdit::page(TREES, space, &wrong, AUTHOR).is_err());
251 }
252}
253
254/// A fresh page gains two tag definitions and three tagged paragraphs, one completed;
255/// `ONESTORE_TAG_EXPORT` names a new directory receiving the candidate for a cold reopen.
256#[test]
257fn new_definitions_and_tags_publish_on_a_fresh_page() {
258 let source = onestore::create_section("tags.one", "Open task", "Author").unwrap();
259 let store = Store::parse(&source).unwrap();
260 let index = RevisionIndex::parse(&store).unwrap();
261 let document = Document::parse(&index).unwrap();
262 let (space, _) = document.pages().unwrap()[0];
263 let before = Page::from_space(&document, space).unwrap();
264 let mut after = before.clone();
265 let template = body_paragraphs(&mut after)[0].clone();
266 let task = new_id().unwrap();
267 let important = new_id().unwrap();
268 after.definitions.insert(task, definition("Rust task", 3));
269 let mut important_definition = definition("Important", 13);
270 let Kind::TagDefinition { action_type, .. } = &mut important_definition.kind else {
271 panic!()
272 };
273 *action_type = Some(1);
274 after.definitions.insert(important, important_definition);
275 body_paragraphs(&mut after)[0]
276 .text_mut()
277 .unwrap()
278 .tags
279 .push(tag(task, false));
280 let mut done = plain_paragraph(&template, "Completed task");
281 done.text_mut().unwrap().tags.push(tag(task, true));
282 body_paragraphs(&mut after).push(done);
283 let mut both = plain_paragraph(&template, "Important open task");
284 both.text_mut().unwrap().tags.push(tag(important, false));
285 both.text_mut().unwrap().tags.push(tag(task, false));
286 body_paragraphs(&mut after).push(both);
287 body_paragraphs(&mut after).push(plain_paragraph(&template, "Untagged"));
288 let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap();
289 assert_same(written.as_bytes(), space, &after);
290 if let Some(directory) = std::env::var_os("ONESTORE_TAG_EXPORT") {
291 let directory = std::path::PathBuf::from(directory);
292 std::fs::create_dir(&directory).unwrap();
293 std::fs::write(directory.join("tags.one"), written.as_bytes()).unwrap();
294 let written_store = Store::parse(written.as_bytes()).unwrap();
295 std::fs::write(
296 directory.join("Open Notebook.onetoc2"),
297 onestore::create_table_of_contents(
298 "Open Notebook.onetoc2",
299 &[("tags.one", written_store.header.file_id)],
300 )
301 .unwrap(),
302 )
303 .unwrap();
304 }
305}
crates/onestore/tests/support/page_edits.rs+57-1
...@@ -199,7 +199,63 @@ fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) {...@@ -199,7 +199,63 @@ fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) {
199 .filter(|(_, o)| matches!(o, PageObject::Outline(_)))199 .filter(|(_, o)| matches!(o, PageObject::Outline(_)))
200 .map(|(i, _)| i)200 .map(|(i, _)| i)
201 .collect();201 .collect();
202 match kind % 10 {202 match kind % 11 {
203 10 => {
204 let Some(outline) = outlines.first().map(|i| match &mut page.objects[*i] {
205 PageObject::Outline(outline) => outline,
206 _ => unreachable!(),
207 }) else {
208 continue;
209 };
210 let at = usize::from(bytes.next().unwrap_or(0)) % outline.paragraphs.len().max(1);
211 let Some(text) = outline.paragraphs.get_mut(at).and_then(|p| p.text_mut()) else {
212 continue;
213 };
214 if text.tags.is_empty() {
215 let definition = page
216 .definitions
217 .iter()
218 .find(|(_, d)| {
219 matches!(&d.kind, onestore::document::Kind::TagDefinition { label: Some(label), .. } if label == "Fuzz task")
220 })
221 .map(|(id, _)| *id)
222 .unwrap_or_else(|| {
223 let id = new_id().unwrap();
224 page.definitions.insert(
225 id,
226 onestore::page::Definition {
227 kind: onestore::document::Kind::TagDefinition {
228 label: Some("Fuzz task".into()),
229 action_type: Some(0),
230 shape: Some(3),
231 color: None,
232 highlight: None,
233 },
234 format: Default::default(),
235 },
236 );
237 id
238 });
239 let outline = outlines.first().map(|i| match &mut page.objects[*i] {
240 PageObject::Outline(outline) => outline,
241 _ => unreachable!(),
242 });
243 let text = outline.unwrap().paragraphs[at].text_mut().unwrap();
244 text.tags.push(onestore::document::Tag {
245 definition: Some(definition),
246 action_type: None,
247 status: u16::from(bytes.next().unwrap_or(0) % 2),
248 created: Some(1_262_401_445),
249 completed: None,
250 start: None,
251 due: None,
252 task_id: None,
253 extra_set: 0,
254 });
255 } else {
256 text.tags.clear();
257 }
258 }
203 8 => {259 8 => {
204 let Some(outline) = outlines.first().map(|i| match &mut page.objects[*i] {260 let Some(outline) = outlines.first().map(|i| match &mut page.objects[*i] {
205 PageObject::Outline(outline) => outline,261 PageObject::Outline(outline) => outline,
tools/test_tag_edit.py created+37
...@@ -0,0 +1,37 @@
1from pathlib import Path
2import re
3import runpy
4import shutil
5from tempfile import TemporaryDirectory
6import unittest
7import xml.etree.ElementTree as ET
8
9from native_xml import ns
10
11ROOT = Path(__file__).resolve().parent.parent
12FIXTURE = ROOT / 'corpus/tag-edit'
13compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
14
15
16class TagEditTest(unittest.TestCase):
17 def test_rust_tag_definitions_and_task_states_render_natively(self):
18 with TemporaryDirectory() as temporary:
19 read = Path(temporary) / 'read'
20 shutil.copytree(FIXTURE / 'cold/read', read)
21 compare(FIXTURE / 'candidate', read)
22 page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml')))
23 definitions = [(d.get('index'), d.get('type'), d.get('symbol'), d.get('name')) for d in page.findall('one:TagDef', ns)]
24 self.assertEqual(definitions, [('0', '0', '3', 'Rust task'), ('1', '1', '13', 'Important')])
25 tagged = [([(t.get('index'), t.get('completed'), t.get('completionDate') is not None) for t in oe.findall('one:Tag', ns)],
26 re.sub(r'<[^>]*>', '', oe.find('one:T', ns).text or ''))
27 for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None]
28 self.assertEqual(tagged, [
29 ([('0', 'false', False)], 'Open task'),
30 ([('0', 'true', True)], 'Completed task'),
31 ([('0', 'false', False), ('1', 'false', False)], 'Important open task'),
32 ([], 'Untagged'),
33 ])
34
35
36if __name__ == '__main__':
37 unittest.main()