diff --git a/corpus/tag-edit/README.md b/corpus/tag-edit/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..7f32604fb23240e27c6ee877292d57fdaf9a8784
--- /dev/null
+++ b/corpus/tag-edit/README.md
@@ -0,0 +1,19 @@
+# Rust note tag authoring
+
+`candidate/` is the page writer's output for
+`new_definitions_and_tags_publish_on_a_fresh_page` in
+`crates/onestore/tests/page_tags.rs`: on a section created in Rust, two tag
+definitions are added ("Rust task", type 0, symbol 3; "Important", type 1,
+symbol 13), the first paragraph gains an open task, a second paragraph a
+completed task with creation and completion dates, a third paragraph both
+tags, and a fourth paragraph none. Tags are property-set arrays on the text
+object referencing definition objects (jcid 0x120043) that carry the model's
+identities after squash; an element holds one tag per action type, as OneNote
+requires.
+
+`cold/` is a fresh OneNote 2010 read: both `TagDef` entries appear with their
+type, symbol and name, the tags read back with their completion state and
+dates, and the untagged paragraph has none. `tools/test_tag_edit.py` checks
+this without a VM. Regenerate with `ONESTORE_TAG_EXPORT` set to a new absolute
+directory while running the test, then cold-open it with
+`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook`.
diff --git a/corpus/tag-edit/candidate/Open Notebook.onetoc2 b/corpus/tag-edit/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..e8389f6e1db021270da0b9edd70425d940dbf64e
Binary files /dev/null and b/corpus/tag-edit/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/tag-edit/candidate/tags.one b/corpus/tag-edit/candidate/tags.one
new file mode 100644
index 0000000000000000000000000000000000000000..197cc2ccf1965bfa156fa3d4acdb6ec7ca9362f1
Binary files /dev/null and b/corpus/tag-edit/candidate/tags.one differ
diff --git a/corpus/tag-edit/cold/commands.jsonl b/corpus/tag-edit/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..0b40bbdbcf63b5876f4029d6235462575970eb6f
--- /dev/null
+++ b/corpus/tag-edit/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"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}
+{"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}
+{"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}
diff --git a/corpus/tag-edit/cold/machine.json b/corpus/tag-edit/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..789e3c21c336c77a53b96bbcb7c2a35cc90ae9ea
--- /dev/null
+++ b/corpus/tag-edit/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-cfd607c8", "hostname": "ONE-M6-CFD607C8"}
diff --git a/corpus/tag-edit/cold/notebook/Open Notebook.onetoc2 b/corpus/tag-edit/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..24da7710436174fcca0465b4fb65084df42d28a6
Binary files /dev/null and b/corpus/tag-edit/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/tag-edit/cold/notebook/tags.one b/corpus/tag-edit/cold/notebook/tags.one
new file mode 100644
index 0000000000000000000000000000000000000000..d05139e43a9a9b70061e7b44d1feeabdc4c3d978
Binary files /dev/null and b/corpus/tag-edit/cold/notebook/tags.one differ
diff --git a/corpus/tag-edit/cold/read/environment.json b/corpus/tag-edit/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..594f24f943243c04b579251adfdf3fe8478f9a37
--- /dev/null
+++ b/corpus/tag-edit/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-CFD607C8",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/tag-edit/cold/read/hierarchy.xml b/corpus/tag-edit/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..841314eff1eb2271d34eaff44d07b1c362e7cf4b
--- /dev/null
+++ b/corpus/tag-edit/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/tag-edit/cold/read/page-000.xml b/corpus/tag-edit/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..85c699c5520bbfcefe08a894fbf2573a613f1b62
--- /dev/null
+++ b/corpus/tag-edit/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/tag-edit/cold/read/payloads.json b/corpus/tag-edit/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/tag-edit/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/tag-edit/cold/run.json b/corpus/tag-edit/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..a4e62281a0d3ffd44fd254377aa87f88fcd773ad
--- /dev/null
+++ b/corpus/tag-edit/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/m4/tags",
+ "expected_pages": 1,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/tag-edit/cold/scripts/cold.ps1 b/corpus/tag-edit/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/tag-edit/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/tag-edit/cold/scripts/read.ps1 b/corpus/tag-edit/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/tag-edit/cold/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/tag-edit/cold/source.json b/corpus/tag-edit/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..bf8d669db188f0769bb053778a7153c3516f39e4
--- /dev/null
+++ b/corpus/tag-edit/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1596,
+ "sha256": "f3ce1472c9be35b1fb52683c5214e62850787e8e13bbd1918fed81fd19a48a65",
+ "mtime_ns": 1789205536390985808
+ },
+ {
+ "path": "tags.one",
+ "bytes": 8352,
+ "sha256": "601a8998b691bab66830196b3b139be6ea58d6f29d5bc69b3efa9892e2fc1550",
+ "mtime_ns": 1789205536390698932
+ }
+]
diff --git a/corpus/tag-edit/cold/teardown.json b/corpus/tag-edit/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/tag-edit/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/crates/notebook/README.md b/crates/notebook/README.md
index afca8b056f56a9cb369ad581bb8a5045f0c952b4..ac5345e728727a4afc3dc882aca1a9d99c620ed0 100644
--- a/crates/notebook/README.md
+++ b/crates/notebook/README.md
@@ -14,8 +14,8 @@ diffs the supplied model against the page stored in the supplied local snapshot,
writes the difference into the working image and queues one
`Operation::Page(PageIntent { before, after, author })`; `before` is the page the
edit started from and is the precondition reconciliation checks. Text, styles,
-paragraph formatting, bullets and numbering, paragraph structure, outline
-layout, insertions and deletions are all differences between `before` and
+paragraph formatting, bullets, numbering and note tags, paragraph structure,
+outline layout, insertions and deletions are all differences between `before` and
`after`; the library never sees editor operations.
```no_run
diff --git a/crates/onestore/README.md b/crates/onestore/README.md
index e796392b892eec81dbcab9c91ba8897dc2b25912..3dfe7becde89b878f9f43de021d4f6e3df138805 100644
--- a/crates/onestore/README.md
+++ b/crates/onestore/README.md
@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`.
| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |
-| `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 |
+| `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 |
| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
| `read_file` | Read a snapshot under whole-file exclusion |
diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs
index a49864f4b3fdf788ad2c86621a22ef06549ea9c2..2963b0ca719940645b3e343780ae32d2114513a6 100644
--- a/crates/onestore/src/page/write.rs
+++ b/crates/onestore/src/page/write.rs
@@ -5,7 +5,7 @@ use super::{Outline, Page, PageObject, PageParagraph, ParagraphContent};
use crate::{
Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets,
RevisionIndex, Store, TextAttribute, TreeEdit, Value,
- document::{Document, Format, Kind},
+ document::{Document, Format, Kind, Tag},
write::{PropertyObject, RevisionEdit, write_revisions},
};
use std::{
@@ -13,6 +13,9 @@ use std::{
ops::Range,
};
+/// Property identifiers with their encoded values.
+type Values = Vec<(u32, Vec)>;
+
fn invalid(message: &'static str) -> Error {
Error { offset: 0, message }
}
@@ -174,9 +177,12 @@ impl Lowering<'_> {
}
for (id, definition) in &after.definitions {
if before.definitions.get(id) != Some(definition)
- && !matches!(definition.kind, Kind::List { .. })
+ && !matches!(
+ definition.kind,
+ Kind::List { .. } | Kind::TagDefinition { .. }
+ )
{
- return Err(invalid("Tag and style definitions cannot be edited"));
+ return Err(invalid("Style definitions cannot be edited"));
}
}
let old = View::new(before)?;
@@ -209,6 +215,7 @@ impl Lowering<'_> {
self.delete(&old, &new, &consumed)?;
self.edit_text(&new)?;
self.edit_lists(after, &new)?;
+ self.edit_tags(after, &new)?;
self.edit_paragraph_formatting(&new)?;
self.edit_formatting(&new)?;
self.edit_layout(&old, &new)?;
@@ -275,18 +282,16 @@ impl Lowering<'_> {
let Some(previous) = old.paragraphs.get(id) else {
continue;
};
- let same = paragraph.tags == previous.tags
- && paragraph.style == previous.style
- && paragraph.format == previous.format;
+ let same = paragraph.style == previous.style && paragraph.format == previous.format;
if !same {
return Err(invalid(
- "Paragraph tags, styles and paragraph formatting cannot be edited",
+ "Paragraph styles and paragraph formatting cannot be edited",
));
}
match (¶graph.content, &previous.content) {
(ParagraphContent::Text(text), ParagraphContent::Text(previous)) => {
- if text.date_field != previous.date_field || text.tags != previous.tags {
- return Err(invalid("Text fields and tags cannot be edited"));
+ if text.date_field != previous.date_field {
+ return Err(invalid("Text fields cannot be edited"));
}
}
(ParagraphContent::Table(table), ParagraphContent::Table(previous)) => {
@@ -558,12 +563,10 @@ impl Lowering<'_> {
let paragraph = new.paragraphs[id];
let text = paragraph
.text()
- .filter(|text| text.date_field.is_none() && text.tags.is_empty())
- .filter(|_| paragraph.tags.is_empty() && paragraph.style.is_none())
+ .filter(|text| text.date_field.is_none())
+ .filter(|_| paragraph.style.is_none())
.ok_or_else(|| {
- invalid(
- "New paragraphs contain plain text without tags, fields or styles",
- )
+ invalid("New paragraphs contain plain text without fields or styles")
})?;
let insertion = Insertion::paragraph(
self.id(container),
@@ -725,7 +728,7 @@ impl Lowering<'_> {
if bullet.is_some() && (format.is_none() || font.is_none()) {
return Err(invalid("A bullet definition names its glyph and font"));
}
- let mut values: Vec<(u32, Vec)> = Vec::new();
+ let mut values: Values = Vec::new();
if let Some(format) = format {
let units: Vec = format.encode_utf16().collect();
let count = u16::try_from(units.len())
@@ -838,6 +841,186 @@ impl Lowering<'_> {
Ok(())
}
+ /// Rewrites the note tags of paragraphs and text objects whose model tags differ from
+ /// the stored ones. A tag definition new to the section becomes a definition object
+ /// carrying the model's identity after squash.
+ fn edit_tags(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> {
+ fn same(a: &[Tag], b: &[Tag]) -> bool {
+ let key = |t: &Tag| {
+ (
+ t.definition,
+ t.action_type,
+ t.status,
+ t.created,
+ t.completed,
+ t.start,
+ t.due,
+ t.task_id,
+ )
+ };
+ a.len() == b.len() && a.iter().zip(b).all(|(x, y)| key(x) == key(y))
+ }
+ let current = self.current()?;
+ let stored = View::new(¤t)?;
+ for (id, paragraph) in &new.paragraphs {
+ let image_id = self.id(*id);
+ let previous = stored
+ .paragraphs
+ .get(&image_id)
+ .ok_or_else(|| invalid("A paragraph is missing after text edits"))?;
+ let mut targets = vec![(image_id, ¶graph.tags, &previous.tags)];
+ if let (Some(text), Some(before)) = (paragraph.text(), previous.text()) {
+ targets.push((self.id(text.id), &text.tags, &before.tags));
+ }
+ for (object, tags, stored_tags) in targets {
+ if same(tags, stored_tags) {
+ continue;
+ }
+ let mut definitions: Vec<(ExGuid, Option)> = Vec::new();
+ let mut sets: Vec<(usize, Values)> = Vec::new();
+ let mut action_types = BTreeSet::new();
+ for tag in tags {
+ let definition = tag
+ .definition
+ .ok_or_else(|| invalid("A note tag names its definition"))?;
+ let action_type = if tag.status & 4 != 0 {
+ tag.action_type
+ } else {
+ match after.definitions.get(&definition).map(|d| &d.kind) {
+ Some(Kind::TagDefinition { action_type, .. }) => *action_type,
+ _ => return Err(invalid("A note tag must reference a tag definition")),
+ }
+ };
+ if !action_types.insert(action_type.unwrap_or(0)) {
+ return Err(invalid("An element holds one note tag per action type"));
+ }
+ let known = current.definitions.contains_key(&definition)
+ || self.alias.contains_key(&definition);
+ let written = if known {
+ self.id(definition)
+ } else {
+ let allocated = ExGuid {
+ guid: crate::write::fresh_guid()?,
+ n: 1,
+ };
+ self.alias.insert(definition, allocated);
+ allocated
+ };
+ let index = match definitions.iter().position(|(id, _)| *id == written) {
+ Some(index) => index,
+ None => {
+ let values = if known {
+ None
+ } else {
+ let Some(model) = after.definitions.get(&definition) else {
+ return Err(invalid(
+ "A note tag references a missing tag definition",
+ ));
+ };
+ let Kind::TagDefinition {
+ label,
+ action_type,
+ shape,
+ color,
+ highlight,
+ } = &model.kind
+ else {
+ return Err(invalid(
+ "A note tag must reference a tag definition",
+ ));
+ };
+ let mut values: Values = vec![
+ (0x0c003473, vec![0]),
+ (0x10003463, action_type.unwrap_or(0).to_le_bytes().to_vec()),
+ (0x10003464, shape.unwrap_or(0).to_le_bytes().to_vec()),
+ (0x14003467, 0u32.to_le_bytes().to_vec()),
+ ];
+ if let Some(label) = label {
+ values.push((0x1c003468, crate::create::string(label)));
+ }
+ if let Some(color) = color {
+ values.push((0x14003466, color.to_le_bytes().to_vec()));
+ }
+ if let Some(highlight) = highlight {
+ values.push((0x14003465, highlight.to_le_bytes().to_vec()));
+ }
+ Some(values)
+ };
+ definitions.push((written, values));
+ definitions.len() - 1
+ }
+ };
+ let mut fields: Values = Vec::new();
+ if let Some(action_type) = tag.action_type {
+ fields.push((0x10003463, action_type.to_le_bytes().to_vec()));
+ }
+ for (id, value) in [
+ (0x1400346e, tag.created),
+ (0x1400346f, tag.completed),
+ (0x1400346a, tag.start),
+ (0x1400346b, tag.due),
+ ] {
+ if let Some(value) = value {
+ fields.push((id, value.to_le_bytes().to_vec()));
+ }
+ }
+ fields.push((0x10003470, tag.status.to_le_bytes().to_vec()));
+ if let Some(task) = tag.task_id {
+ fields.push((0x1c003469, task.to_vec()));
+ }
+ sets.push((index, fields));
+ }
+ let space = self.space;
+ self.apply(|image| {
+ let store = Store::parse(image)?;
+ let index = RevisionIndex::parse(&store)?;
+ let document = Document::parse(&index)?;
+ let parents = crate::edit::editable_parents(
+ document.active(space)?,
+ &document.pages_in(space)?,
+ object,
+ )?;
+ let modified = crate::create::current_timestamps()?.0.to_le_bytes();
+ crate::write::write_revision(image, space, |raw| {
+ let mut changed = BTreeMap::new();
+ for (id, values) in &definitions {
+ let Some(values) = values else {
+ continue;
+ };
+ let mut node = PropertyObject {
+ jcid: 0x120043,
+ bytes: crate::create::properties(values)?,
+ global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])),
+ };
+ node.reference(*id)?;
+ changed.insert(*id, node);
+ }
+ let mut target = PropertyObject::from_object(&raw.objects[&object])?;
+ let mut encoded = Vec::new();
+ for (index, fields) in &sets {
+ let reference = target.reference(definitions[*index].0)?;
+ let mut set = vec![(0x20003488, reference.to_vec())];
+ set.extend(fields.iter().cloned());
+ encoded.push(set);
+ }
+ target.set_sets(0x40003489, 0x44000811, &encoded)?;
+ target.set(&[(0x14001d7a, &modified)])?;
+ changed.insert(object, target);
+ crate::formatting::touch_ancestors(
+ raw,
+ &parents,
+ object,
+ &modified,
+ &mut changed,
+ )?;
+ Ok(changed)
+ })
+ })?;
+ }
+ }
+ Ok(())
+ }
+
fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> {
let current = self.current()?;
let current = View::new(¤t)?;
diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs
index e0e48f8134b444cf99fe6c9aac8c8e1e5eda3946..72662adc7232ab16d41f1de295fe9b1fd0f49727 100644
--- a/crates/onestore/src/write.rs
+++ b/crates/onestore/src/write.rs
@@ -133,6 +133,7 @@ fn patch_properties(
blob: &[u8],
updates: &[(u32, &[u8])],
inserts: &[(u32, &[u8])],
+ nested_references: &[u8],
) -> Result> {
let properties = PropertySets::parse(blob)?;
let root = &properties.sets[0];
@@ -167,6 +168,8 @@ fn patch_properties(
7 => value.len() < 0x40000000,
8 => value.len() == 4,
9 => value.len().is_multiple_of(4) && value.len() / 4 <= 0xffffff,
+ // An encoded property-set array is inserted whole; its references follow.
+ 16 => i >= updates.len() && value.len() >= 4,
_ => {
return Err(Error {
offset: 0,
@@ -198,13 +201,13 @@ fn patch_properties(
9 => encoded.extend_from_slice(&(value.len() as u32 / 4).to_le_bytes()),
_ => {}
}
- if (3..=7).contains(&kind) {
+ if (3..=7).contains(&kind) || kind == 16 {
encoded.extend_from_slice(value);
}
if adding {
added_ids.extend_from_slice(&property.to_le_bytes());
added_fields.extend_from_slice(&encoded);
- if kind >= 8 {
+ if (8..=9).contains(&kind) {
object_count += (value.len() / 4) as i64;
added_references.extend_from_slice(value);
}
@@ -232,6 +235,7 @@ fn patch_properties(
}
}
}
+ object_count += (nested_references.len() / 4) as i64;
if !(0..=0xffffff).contains(&object_count) {
return Err(Error {
offset: 0,
@@ -242,6 +246,7 @@ fn patch_properties(
if header != object_header {
patches.push((0, 4, 0, header.to_le_bytes().to_vec()));
}
+ added_references.extend_from_slice(nested_references);
if !added_references.is_empty() {
let end = 4 + (object_header as usize & 0xffffff) * 4;
patches.push((end, end, usize::MAX, added_references));
@@ -339,7 +344,7 @@ pub(crate) fn replace_objects(
edit.object,
PropertyObject {
jcid: object.jcid,
- bytes: patch_properties(blob, edit.updates, edit.inserts)?,
+ bytes: patch_properties(blob, edit.updates, edit.inserts, &[])?,
global_ids: Arc::clone(&object.global_ids),
},
);
@@ -384,7 +389,56 @@ impl PropertyObject {
.iter()
.any(|p| p.id & 0x7fffffff == id & 0x7fffffff)
});
- self.bytes = patch_properties(&self.bytes, &updates, &inserts)?;
+ self.bytes = patch_properties(&self.bytes, &updates, &inserts, &[])?;
+ Ok(())
+ }
+
+ /// Replaces a property-set array such as note tags: each set lists scalar values
+ /// inline, while an object reference (a kind-8 identity) is supplied as the compact
+ /// identity `reference` produced and joins the object's reference stream in order.
+ pub fn set_sets(&mut self, id: u32, element: u32, sets: &[Vec<(u32, Vec)>]) -> Result<()> {
+ if (id >> 26) & 31 != 16 || (element >> 26) & 31 != 17 {
+ return Err(Error {
+ offset: 0,
+ message: "Property-set arrays need array and element identifiers",
+ });
+ }
+ self.remove(&[id])?;
+ if sets.is_empty() {
+ return Ok(());
+ }
+ let mut encoded = (sets.len() as u32).to_le_bytes().to_vec();
+ encoded.extend_from_slice(&element.to_le_bytes());
+ let mut references = Vec::new();
+ for set in sets {
+ encoded.extend_from_slice(&u16::try_from(set.len()).unwrap().to_le_bytes());
+ for (field, _) in set {
+ encoded.extend_from_slice(&field.to_le_bytes());
+ }
+ for (field, value) in set {
+ match (field >> 26) & 31 {
+ 3..=6 => {
+ assert_eq!(value.len(), 1 << (((field >> 26) & 31) - 3));
+ encoded.extend_from_slice(value);
+ }
+ 7 => {
+ encoded.extend_from_slice(&(value.len() as u32).to_le_bytes());
+ encoded.extend_from_slice(value);
+ }
+ 8 => {
+ assert_eq!(value.len(), 4);
+ references.extend_from_slice(value);
+ }
+ _ => {
+ return Err(Error {
+ offset: 0,
+ message: "Property-set arrays hold scalars and single references",
+ });
+ }
+ }
+ }
+ }
+ self.bytes = patch_properties(&self.bytes, &[], &[(id, &encoded)], &references)?;
Ok(())
}
diff --git a/crates/onestore/src/write/tests.rs b/crates/onestore/src/write/tests.rs
index 02a27edb8c68f6c4c47e85818a5c1540e48d69c5..49027ec00f268929d70e6e342dab9f6867f76f84 100644
--- a/crates/onestore/src/write/tests.rs
+++ b/crates/onestore/src/write/tests.rs
@@ -221,6 +221,7 @@ fn add_paragraph(source: &[u8], number: u32) -> Vec {
blob,
&[(0x24001c20, &children), (0x14001d7a, &modified)],
&[],
+ &[],
)?,
global_ids: Arc::clone(&table),
},
@@ -302,7 +303,7 @@ fn restyle(source: &[u8]) -> Vec {
style,
PropertyObject {
jcid: previous.jcid,
- bytes: patch_properties(blob, &[], &[(0x88001c04, &[])])?,
+ bytes: patch_properties(blob, &[], &[(0x88001c04, &[])], &[])?,
global_ids: Arc::new(table),
},
)]);
@@ -316,7 +317,12 @@ fn restyle(source: &[u8]) -> Vec {
*id,
PropertyObject {
jcid: text.jcid,
- bytes: patch_properties(blob, &[(0x24001e13, &compact(style, &table)?)], &[])?,
+ bytes: patch_properties(
+ blob,
+ &[(0x24001e13, &compact(style, &table)?)],
+ &[],
+ &[],
+ )?,
global_ids: Arc::new(table),
},
);
@@ -461,7 +467,8 @@ fn changed_graphs_reject_cycles_dangling_and_unreachable_additions() {
bytes: patch_properties(
blob,
&[(0x24001c20, &compact(target, &outline.global_ids)?)],
- &[]
+ &[],
+ &[],
)?,
global_ids: Arc::clone(&outline.global_ids),
}
@@ -559,7 +566,7 @@ fn property_splices_match_independently_encoded_flat_sets() {
.map(|(id, value)| (*id, value.as_slice()))
.collect();
let original = properties(&original).unwrap();
- let actual = patch_properties(&original, &updates, &inserts).unwrap();
+ let actual = patch_properties(&original, &updates, &inserts, &[]).unwrap();
assert_eq!(actual, properties(&expected).unwrap(), "case {case}");
}
}
@@ -599,6 +606,7 @@ fn nested_fields_and_other_reference_streams_remain_byte_exact() {
(0x24000001, &[6, 0, 0, 0, 7, 0, 0, 0]),
],
&[(0x24000005, &[8, 0, 0, 0]), (0x88000006, &[])],
+ &[],
)
.unwrap();
let parsed = PropertySets::parse(&changed).unwrap();
@@ -717,7 +725,7 @@ fn deep_property_splices_do_not_use_the_call_stack() {
bytes.extend_from_slice(&0x44000001_u32.to_le_bytes());
}
bytes.extend_from_slice(&0_u16.to_le_bytes());
- let changed = patch_properties(&bytes, &[], &[(0x88000002, &[])]).unwrap();
+ let changed = patch_properties(&bytes, &[], &[(0x88000002, &[])], &[]).unwrap();
let parsed = PropertySets::parse(&changed).unwrap();
assert_eq!(parsed.sets.len(), 100_001);
assert_eq!(parsed.sets[0].len(), 2);
@@ -758,7 +766,7 @@ fn invalid_property_splices_are_rejected() {
(vec![], vec![(0x88000001, &[][..])]),
(vec![], vec![(0x20000003, &[][..])]),
] {
- assert!(patch_properties(&bytes, &updates, &inserts).is_err());
+ assert!(patch_properties(&bytes, &updates, &inserts, &[]).is_err());
}
}
diff --git a/crates/onestore/tests/page_tags.rs b/crates/onestore/tests/page_tags.rs
new file mode 100644
index 0000000000000000000000000000000000000000..9a31b1503e6c6cf64dc7763f07e0f45d7d42ec1b
--- /dev/null
+++ b/crates/onestore/tests/page_tags.rs
@@ -0,0 +1,305 @@
+use onestore::{
+ ExGuid, PreparedEdit, RevisionIndex, Store,
+ document::{Document, Format, Kind, Tag},
+ page::{
+ Definition, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id,
+ },
+};
+
+const TREES: &[u8] =
+ include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one");
+const AUTHOR: &str = "Tag author";
+
+fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) {
+ let store = Store::parse(bytes).unwrap();
+ assert!(store.checksum_mismatches.is_empty());
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ document
+ .pages()
+ .unwrap()
+ .into_iter()
+ .find_map(|(space, _)| {
+ let page = Page::from_space(&document, space).unwrap();
+ (page.title == title).then_some((space, page))
+ })
+ .unwrap()
+}
+
+fn page_in(bytes: &[u8], space: ExGuid) -> Page {
+ let store = Store::parse(bytes).unwrap();
+ assert!(store.checksum_mismatches.is_empty());
+ let index = RevisionIndex::parse(&store).unwrap();
+ Page::from_space(&Document::parse(&index).unwrap(), space).unwrap()
+}
+
+fn body_paragraphs(page: &mut Page) -> &mut Vec {
+ page.objects
+ .iter_mut()
+ .find_map(|object| match object {
+ PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs),
+ _ => None,
+ })
+ .unwrap()
+}
+
+/// Tag positions in the element's extra arena are assigned on read; equality ignores them.
+fn normalized(mut page: Page) -> Page {
+ for object in &mut page.objects {
+ if let PageObject::Outline(outline) = object {
+ for paragraph in &mut outline.paragraphs {
+ for tag in &mut paragraph.tags {
+ tag.extra_set = 0;
+ }
+ if let Some(text) = paragraph.text_mut() {
+ for tag in &mut text.tags {
+ tag.extra_set = 0;
+ }
+ }
+ }
+ }
+ }
+ page
+}
+
+fn assert_same(written: &[u8], space: ExGuid, expected: &Page) -> Page {
+ let stored = page_in(written, space);
+ let mut expected = expected.clone();
+ expected.title = stored.title.clone();
+ assert_eq!(normalized(stored.clone()), normalized(expected));
+ stored
+}
+
+fn tagged(page: &Page) -> usize {
+ page.objects
+ .iter()
+ .find_map(|object| match object {
+ PageObject::Outline(outline) if !outline.title => Some(
+ outline
+ .paragraphs
+ .iter()
+ .position(|p| p.text().is_some_and(|t| !t.tags.is_empty()))
+ .unwrap(),
+ ),
+ _ => None,
+ })
+ .unwrap()
+}
+
+fn raw_object(bytes: &[u8], space: ExGuid, id: ExGuid) -> String {
+ let store = Store::parse(bytes).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ format!(
+ "{:?}",
+ index.resolve_active(space).unwrap().objects[&id].data
+ )
+}
+
+fn definition(label: &str, shape: u16) -> Definition {
+ Definition {
+ kind: Kind::TagDefinition {
+ label: Some(label.into()),
+ action_type: Some(0),
+ shape: Some(shape),
+ color: None,
+ highlight: None,
+ },
+ format: Format::default(),
+ }
+}
+
+fn tag(definition: ExGuid, completed: bool) -> Tag {
+ Tag {
+ definition: Some(definition),
+ action_type: None,
+ status: u16::from(completed),
+ created: Some(1_262_401_445),
+ completed: completed.then_some(1_262_405_106),
+ start: None,
+ due: None,
+ task_id: None,
+ extra_set: 0,
+ }
+}
+
+fn plain_paragraph(template: &PageParagraph, text: &str) -> PageParagraph {
+ let mut paragraph = template.clone();
+ paragraph.id = new_id().unwrap();
+ paragraph.parent = None;
+ paragraph.level = 1;
+ paragraph.lists.clear();
+ paragraph.tags.clear();
+ paragraph.style = None;
+ paragraph.collapsed = false;
+ paragraph.content = ParagraphContent::Text(TextObject {
+ id: new_id().unwrap(),
+ date_field: None,
+ text: onestore::page::Paragraph::new(
+ text.into(),
+ Format {
+ font: Some("Calibri".into()),
+ font_size: Some(11.0),
+ language: Some(1033),
+ ..Default::default()
+ },
+ ),
+ tags: Vec::new(),
+ });
+ paragraph
+}
+
+#[test]
+fn removing_a_tag_leaves_its_definition_and_the_other_paragraphs_untouched() {
+ let (space, before) = page_by_title(TREES, "Indent first subtree");
+ let at = tagged(&before);
+ let mut after = before.clone();
+ let definition = body_paragraphs(&mut after)[at].text().unwrap().tags[0]
+ .definition
+ .unwrap();
+ body_paragraphs(&mut after)[at]
+ .text_mut()
+ .unwrap()
+ .tags
+ .clear();
+ let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap();
+ let mut expected = after.clone();
+ expected.definitions.remove(&definition);
+ let stored = assert_same(written.as_bytes(), space, &expected);
+ assert!(!stored.definitions.contains_key(&definition));
+ assert_eq!(
+ raw_object(written.as_bytes(), space, definition),
+ raw_object(TREES, space, definition)
+ );
+ let count = body_paragraphs(&mut after).len();
+ let other = body_paragraphs(&mut after)[(at + 1) % count]
+ .text()
+ .unwrap()
+ .id;
+ assert_eq!(
+ raw_object(written.as_bytes(), space, other),
+ raw_object(TREES, space, other)
+ );
+}
+
+#[test]
+fn an_existing_definition_tags_another_paragraph_and_a_task_completes_in_place() {
+ let (space, before) = page_by_title(TREES, "Indent first subtree");
+ let at = tagged(&before);
+ let mut after = before.clone();
+ let existing = body_paragraphs(&mut after)[at].text().unwrap().tags[0].clone();
+ let definition = existing.definition.unwrap();
+ let count = body_paragraphs(&mut after).len();
+ let other = (at + 1) % count;
+ body_paragraphs(&mut after)[other]
+ .text_mut()
+ .unwrap()
+ .tags
+ .push(tag(definition, false));
+ let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap();
+ let stored = assert_same(written.as_bytes(), space, &after);
+ let mut completed = stored.clone();
+ let text = body_paragraphs(&mut completed)[other].text_mut().unwrap();
+ text.tags[0].status = 1;
+ text.tags[0].completed = Some(1_262_405_106);
+ let again = PreparedEdit::page(written.as_bytes(), space, &completed, AUTHOR).unwrap();
+ assert_same(again.as_bytes(), space, &completed);
+ assert_eq!(
+ PreparedEdit::page(
+ again.as_bytes(),
+ space,
+ &page_in(again.as_bytes(), space),
+ AUTHOR
+ )
+ .unwrap()
+ .as_bytes(),
+ again.as_bytes()
+ );
+}
+
+#[test]
+fn tags_without_a_known_definition_are_refused() {
+ let (space, before) = page_by_title(TREES, "Indent first subtree");
+ let mut missing = before.clone();
+ body_paragraphs(&mut missing)[0]
+ .text_mut()
+ .unwrap()
+ .tags
+ .push(tag(new_id().unwrap(), false));
+ assert!(PreparedEdit::page(TREES, space, &missing, AUTHOR).is_err());
+ let mut repeated = before.clone();
+ let at = tagged(&repeated);
+ let existing = body_paragraphs(&mut repeated)[at].text().unwrap().tags[0].clone();
+ body_paragraphs(&mut repeated)[at]
+ .text_mut()
+ .unwrap()
+ .tags
+ .push(tag(existing.definition.unwrap(), false));
+ assert!(PreparedEdit::page(TREES, space, &repeated, AUTHOR).is_err());
+ let mut wrong = before.clone();
+ let list = wrong
+ .definitions
+ .iter()
+ .find(|(_, d)| matches!(d.kind, Kind::List { .. }))
+ .map(|(id, _)| *id);
+ if let Some(list) = list {
+ body_paragraphs(&mut wrong)[0]
+ .text_mut()
+ .unwrap()
+ .tags
+ .push(tag(list, false));
+ assert!(PreparedEdit::page(TREES, space, &wrong, AUTHOR).is_err());
+ }
+}
+
+/// A fresh page gains two tag definitions and three tagged paragraphs, one completed;
+/// `ONESTORE_TAG_EXPORT` names a new directory receiving the candidate for a cold reopen.
+#[test]
+fn new_definitions_and_tags_publish_on_a_fresh_page() {
+ let source = onestore::create_section("tags.one", "Open task", "Author").unwrap();
+ let store = Store::parse(&source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let (space, _) = document.pages().unwrap()[0];
+ let before = Page::from_space(&document, space).unwrap();
+ let mut after = before.clone();
+ let template = body_paragraphs(&mut after)[0].clone();
+ let task = new_id().unwrap();
+ let important = new_id().unwrap();
+ after.definitions.insert(task, definition("Rust task", 3));
+ let mut important_definition = definition("Important", 13);
+ let Kind::TagDefinition { action_type, .. } = &mut important_definition.kind else {
+ panic!()
+ };
+ *action_type = Some(1);
+ after.definitions.insert(important, important_definition);
+ body_paragraphs(&mut after)[0]
+ .text_mut()
+ .unwrap()
+ .tags
+ .push(tag(task, false));
+ let mut done = plain_paragraph(&template, "Completed task");
+ done.text_mut().unwrap().tags.push(tag(task, true));
+ body_paragraphs(&mut after).push(done);
+ let mut both = plain_paragraph(&template, "Important open task");
+ both.text_mut().unwrap().tags.push(tag(important, false));
+ both.text_mut().unwrap().tags.push(tag(task, false));
+ body_paragraphs(&mut after).push(both);
+ body_paragraphs(&mut after).push(plain_paragraph(&template, "Untagged"));
+ let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap();
+ assert_same(written.as_bytes(), space, &after);
+ if let Some(directory) = std::env::var_os("ONESTORE_TAG_EXPORT") {
+ let directory = std::path::PathBuf::from(directory);
+ std::fs::create_dir(&directory).unwrap();
+ std::fs::write(directory.join("tags.one"), written.as_bytes()).unwrap();
+ let written_store = Store::parse(written.as_bytes()).unwrap();
+ std::fs::write(
+ directory.join("Open Notebook.onetoc2"),
+ onestore::create_table_of_contents(
+ "Open Notebook.onetoc2",
+ &[("tags.one", written_store.header.file_id)],
+ )
+ .unwrap(),
+ )
+ .unwrap();
+ }
+}
diff --git a/crates/onestore/tests/support/page_edits.rs b/crates/onestore/tests/support/page_edits.rs
index 0418b9b3d32bb3fe8ceb65d0e7b0d40619838e53..f9d7ef96669d76957ae403c9bba307f0e6a9da78 100644
--- a/crates/onestore/tests/support/page_edits.rs
+++ b/crates/onestore/tests/support/page_edits.rs
@@ -199,7 +199,63 @@ fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) {
.filter(|(_, o)| matches!(o, PageObject::Outline(_)))
.map(|(i, _)| i)
.collect();
- match kind % 10 {
+ match kind % 11 {
+ 10 => {
+ let Some(outline) = outlines.first().map(|i| match &mut page.objects[*i] {
+ PageObject::Outline(outline) => outline,
+ _ => unreachable!(),
+ }) else {
+ continue;
+ };
+ let at = usize::from(bytes.next().unwrap_or(0)) % outline.paragraphs.len().max(1);
+ let Some(text) = outline.paragraphs.get_mut(at).and_then(|p| p.text_mut()) else {
+ continue;
+ };
+ if text.tags.is_empty() {
+ let definition = page
+ .definitions
+ .iter()
+ .find(|(_, d)| {
+ matches!(&d.kind, onestore::document::Kind::TagDefinition { label: Some(label), .. } if label == "Fuzz task")
+ })
+ .map(|(id, _)| *id)
+ .unwrap_or_else(|| {
+ let id = new_id().unwrap();
+ page.definitions.insert(
+ id,
+ onestore::page::Definition {
+ kind: onestore::document::Kind::TagDefinition {
+ label: Some("Fuzz task".into()),
+ action_type: Some(0),
+ shape: Some(3),
+ color: None,
+ highlight: None,
+ },
+ format: Default::default(),
+ },
+ );
+ id
+ });
+ let outline = outlines.first().map(|i| match &mut page.objects[*i] {
+ PageObject::Outline(outline) => outline,
+ _ => unreachable!(),
+ });
+ let text = outline.unwrap().paragraphs[at].text_mut().unwrap();
+ text.tags.push(onestore::document::Tag {
+ definition: Some(definition),
+ action_type: None,
+ status: u16::from(bytes.next().unwrap_or(0) % 2),
+ created: Some(1_262_401_445),
+ completed: None,
+ start: None,
+ due: None,
+ task_id: None,
+ extra_set: 0,
+ });
+ } else {
+ text.tags.clear();
+ }
+ }
8 => {
let Some(outline) = outlines.first().map(|i| match &mut page.objects[*i] {
PageObject::Outline(outline) => outline,
diff --git a/tools/test_tag_edit.py b/tools/test_tag_edit.py
new file mode 100644
index 0000000000000000000000000000000000000000..9645227074f0d5e6f8a95906d2981cfa853ab83b
--- /dev/null
+++ b/tools/test_tag_edit.py
@@ -0,0 +1,37 @@
+from pathlib import Path
+import re
+import runpy
+import shutil
+from tempfile import TemporaryDirectory
+import unittest
+import xml.etree.ElementTree as ET
+
+from native_xml import ns
+
+ROOT = Path(__file__).resolve().parent.parent
+FIXTURE = ROOT / 'corpus/tag-edit'
+compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
+
+
+class TagEditTest(unittest.TestCase):
+ def test_rust_tag_definitions_and_task_states_render_natively(self):
+ with TemporaryDirectory() as temporary:
+ read = Path(temporary) / 'read'
+ shutil.copytree(FIXTURE / 'cold/read', read)
+ compare(FIXTURE / 'candidate', read)
+ page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml')))
+ definitions = [(d.get('index'), d.get('type'), d.get('symbol'), d.get('name')) for d in page.findall('one:TagDef', ns)]
+ self.assertEqual(definitions, [('0', '0', '3', 'Rust task'), ('1', '1', '13', 'Important')])
+ tagged = [([(t.get('index'), t.get('completed'), t.get('completionDate') is not None) for t in oe.findall('one:Tag', ns)],
+ re.sub(r'<[^>]*>', '', oe.find('one:T', ns).text or ''))
+ for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None]
+ self.assertEqual(tagged, [
+ ([('0', 'false', False)], 'Open task'),
+ ([('0', 'true', True)], 'Completed task'),
+ ([('0', 'false', False), ('1', 'false', False)], 'Important open task'),
+ ([], 'Untagged'),
+ ])
+
+
+if __name__ == '__main__':
+ unittest.main()