diff --git a/corpus/list-edit/README.md b/corpus/list-edit/README.md new file mode 100644 index 0000000000000000000000000000000000000000..842c511a21959f8baf292ab367ec0f0003a3500b --- /dev/null +++ b/corpus/list-edit/README.md @@ -0,0 +1,23 @@ +# Rust list authoring + +`candidate/` is the page writer's output for +`bullets_numbering_nesting_and_restarts_publish_on_a_fresh_page` in +`crates/onestore/tests/page_lists.rs`: on a section created in Rust, the first +paragraph gains a bullet (glyph U+25CB in Courier New, bullet index 4, 11 pt), +then two numbered paragraphs, a nested numbered child, a numbered paragraph +restarting at three, a nested plain paragraph and a trailing plain paragraph. +The numbering definition is the upper-roman `##.` node copied from +`corpus/outline-edit/tree`. Every list node is written with the model's +identity after squash, so the reread model equals the saved one. + +`cold/` is a fresh OneNote 2010 read: the bullet renders (reported as bullet +index 3 at 11 pt), the numbered paragraphs read `I.` and `II.`, the nested +child restarts at `I.`, the restarted paragraph reads `III.`, and the plain +paragraphs carry no list. `tools/test_list_edit.py` checks this without a VM. + +A list node whose compact identity would be the null identity (sequence zero +at table index zero) fails OneNote's integrity check on open; the writer +allocates list nodes like other new objects and renames them to the model's +identities in squash. Regenerate with `ONESTORE_LIST_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/list-edit/candidate/Open Notebook.onetoc2 b/corpus/list-edit/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..e893988f00c2ccf9b7949df6d788ccba7a05b187 Binary files /dev/null and b/corpus/list-edit/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/list-edit/candidate/lists.one b/corpus/list-edit/candidate/lists.one new file mode 100644 index 0000000000000000000000000000000000000000..dc52b83ff3427b66e1becbc74478f066fc5e6b87 Binary files /dev/null and b/corpus/list-edit/candidate/lists.one differ diff --git a/corpus/list-edit/cold/commands.jsonl b/corpus/list-edit/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..8c378dee3e8ec4968f046d6958215d6418da7e32 --- /dev/null +++ b/corpus/list-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-17B0A046 -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/list-edit/cold/machine.json b/corpus/list-edit/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..9e20a9c0a32923eebfb5bee45c31bd457048af14 --- /dev/null +++ b/corpus/list-edit/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-17b0a046", "hostname": "ONE-M6-17B0A046"} diff --git a/corpus/list-edit/cold/notebook/Open Notebook.onetoc2 b/corpus/list-edit/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..ab1de4e83f613d517c06f3f412aa67871090671c Binary files /dev/null and b/corpus/list-edit/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/list-edit/cold/notebook/lists.one b/corpus/list-edit/cold/notebook/lists.one new file mode 100644 index 0000000000000000000000000000000000000000..b253fe7242c385affc1fb063461d5a2cccf05577 Binary files /dev/null and b/corpus/list-edit/cold/notebook/lists.one differ diff --git a/corpus/list-edit/cold/read/environment.json b/corpus/list-edit/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..473105c8c5428be2089b03e1018102b341234b29 --- /dev/null +++ b/corpus/list-edit/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-17B0A046", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/list-edit/cold/read/hierarchy.xml b/corpus/list-edit/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..357ed888a2478ca70d620e2e3068865c22d1c859 --- /dev/null +++ b/corpus/list-edit/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/list-edit/cold/read/page-000.xml b/corpus/list-edit/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..b3d70d1c0f12a58781a996ce5c0ea824123f88a9 --- /dev/null +++ b/corpus/list-edit/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/list-edit/cold/read/payloads.json b/corpus/list-edit/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/list-edit/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/list-edit/cold/run.json b/corpus/list-edit/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..b01c766d642cea229452fba5c07433d8c308c98c --- /dev/null +++ b/corpus/list-edit/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/m4/lists", + "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/list-edit/cold/scripts/cold.ps1 b/corpus/list-edit/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/list-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/list-edit/cold/scripts/read.ps1 b/corpus/list-edit/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/list-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/list-edit/cold/source.json b/corpus/list-edit/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..f431b9c2223759b278a6a42d5246847003cd5601 --- /dev/null +++ b/corpus/list-edit/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "25824736beb7e6c953922576b8d8309ec64568d08d1c850e71efa80efe2ee069", + "mtime_ns": 1789204406442521201 + }, + { + "path": "lists.one", + "bytes": 11328, + "sha256": "8ee832fe46e233c22995fd73ed9df9cb9ff345752570a250979aaa26c8f1ef15", + "mtime_ns": 1789204406442128119 + } +] diff --git a/corpus/list-edit/cold/teardown.json b/corpus/list-edit/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/list-edit/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 01cbfca2592e65efc3c102e37665459188671f5a..afca8b056f56a9cb369ad581bb8a5045f0c952b4 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -14,8 +14,9 @@ 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 structure, outline layout, insertions and deletions are all differences -between `before` and `after`; the library never sees editor operations. +paragraph formatting, bullets and numbering, paragraph structure, outline +layout, insertions and deletions are all differences between `before` and +`after`; the library never sees editor operations. ```no_run use onestore::{ExGuid, page::Page}; diff --git a/crates/notebook/src/merge.rs b/crates/notebook/src/merge.rs index 8a70862a15ab61e7e373ac2dd7d68f15a431aa02..136bba9d597c4e21637540a796ab15118c8e26a0 100644 --- a/crates/notebook/src/merge.rs +++ b/crates/notebook/src/merge.rs @@ -97,12 +97,29 @@ pub(crate) fn merge(base: &Page, ours: &Page, theirs: &Page) -> Option { } result = reorder(result, &our_order); } + // Definitions we added or changed travel with our paragraphs; theirs win elsewhere. + let mut definitions = theirs.definitions.clone(); + for (id, definition) in &ours.definitions { + match (base.definitions.get(id), theirs.definitions.get(id)) { + (Some(before), Some(after)) if before != definition => { + if after != before && after != definition { + return None; + } + definitions.insert(*id, definition.clone()); + } + (None, None) => { + definitions.insert(*id, definition.clone()); + } + (None, Some(after)) if after != definition => return None, + _ => {} + } + } Some(Page { title: theirs.title.clone(), created: theirs.created, margin_origin: theirs.margin_origin, objects: result, - definitions: theirs.definitions.clone(), + definitions, }) } diff --git a/crates/notebook/tests/sync_model.rs b/crates/notebook/tests/sync_model.rs index f830801de36ec2f510f0714c1cedcfc2a3ba01d2..b22176a9e37953ba0df47945b9557659951ac98d 100644 --- a/crates/notebook/tests/sync_model.rs +++ b/crates/notebook/tests/sync_model.rs @@ -532,3 +532,63 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() { Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) ); } + +#[test] +fn a_local_bullet_merges_with_a_remote_text_edit_and_publishes_its_definition() { + let (space, before) = page_titled(OUTLINES, "Move leaf down"); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); + let mut after = before.clone(); + let bullet = onestore::page::text::new_id().unwrap(); + after.definitions.insert( + bullet, + onestore::page::Definition { + kind: onestore::document::Kind::List { + font: Some("Courier New".into()), + format: Some("\u{25cb}".into()), + restart: None, + bullet: Some(4), + }, + format: onestore::document::Format { + font_size: Some(11.0), + color: Some(0xff000000), + ..Default::default() + }, + }, + ); + let outline = after + .objects + .iter_mut() + .find_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(outline), + _ => None, + }) + .unwrap(); + outline.paragraphs[0].lists = vec![bullet]; + let id = cache + .save(OUTLINES, space, &after, "Model author") + .unwrap() + .unwrap(); + let mut server = remote_with(space, |page| edit_paragraph(page, 1, 0..0, "Remote ")); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) + ); + let published = page_of(&server.durable, space); + let outline = published + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(outline), + _ => None, + }) + .unwrap(); + assert_eq!(outline.paragraphs[0].lists, vec![bullet]); + assert!(matches!( + published.definitions[&bullet].kind, + onestore::document::Kind::List { + bullet: Some(4), + .. + } + )); + assert!(texts(&published)[1].starts_with("Remote ")); +} diff --git a/crates/onestore/README.md b/crates/onestore/README.md index ca543ea889f24a1c7c5d763f3f8792775dbc9881..e796392b892eec81dbcab9c91ba8897dc2b25912 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 formatting, 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, 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 d4b5399efa9a1bde1bcae3c9e2c6993ac9ca3d66..a49864f4b3fdf788ad2c86621a22ef06549ea9c2 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}, + document::{Document, Format, Kind}, write::{PropertyObject, RevisionEdit, write_revisions}, }; use std::{ @@ -173,8 +173,10 @@ impl Lowering<'_> { return Err(invalid("Page creation time and margins cannot be edited")); } for (id, definition) in &after.definitions { - if before.definitions.get(id) != Some(definition) { - return Err(invalid("List, tag and style definitions cannot be edited")); + if before.definitions.get(id) != Some(definition) + && !matches!(definition.kind, Kind::List { .. }) + { + return Err(invalid("Tag and style definitions cannot be edited")); } } let old = View::new(before)?; @@ -206,6 +208,7 @@ impl Lowering<'_> { self.place(&old, &new, &placed, &page_order)?; self.delete(&old, &new, &consumed)?; self.edit_text(&new)?; + self.edit_lists(after, &new)?; self.edit_paragraph_formatting(&new)?; self.edit_formatting(&new)?; self.edit_layout(&old, &new)?; @@ -272,13 +275,12 @@ impl Lowering<'_> { let Some(previous) = old.paragraphs.get(id) else { continue; }; - let same = paragraph.lists == previous.lists - && paragraph.tags == previous.tags + let same = paragraph.tags == previous.tags && paragraph.style == previous.style && paragraph.format == previous.format; if !same { return Err(invalid( - "Paragraph lists, tags, styles and paragraph formatting cannot be edited", + "Paragraph tags, styles and paragraph formatting cannot be edited", )); } match (¶graph.content, &previous.content) { @@ -557,13 +559,11 @@ impl Lowering<'_> { let text = paragraph .text() .filter(|text| text.date_field.is_none() && text.tags.is_empty()) - .filter(|_| { - paragraph.lists.is_empty() - && paragraph.tags.is_empty() - && paragraph.style.is_none() - }) + .filter(|_| paragraph.tags.is_empty() && paragraph.style.is_none()) .ok_or_else(|| { - invalid("New paragraphs contain plain text without lists, tags, fields or styles") + invalid( + "New paragraphs contain plain text without tags, fields or styles", + ) })?; let insertion = Insertion::paragraph( self.id(container), @@ -659,6 +659,185 @@ impl Lowering<'_> { Ok(()) } + /// Gives each paragraph the list nodes its model references: a definition new to the + /// section becomes a list node carrying the model's identity, a changed one is rewritten + /// in place, a definition another paragraph already owns is copied because native list + /// nodes belong to one paragraph, and a dropped reference leaves the node unreferenced. + fn edit_lists(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> { + let current = self.current()?; + let stored = View::new(¤t)?; + let mut owners: BTreeMap = BTreeMap::new(); + for (id, paragraph) in &stored.paragraphs { + for list in ¶graph.lists { + owners.insert(*list, *id); + } + } + 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 same = paragraph.lists.len() == previous.lists.len() + && paragraph + .lists + .iter() + .zip(&previous.lists) + .all(|(model, node)| { + after.definitions.get(model) == current.definitions.get(node) + }); + if same { + continue; + } + let mut nodes = Vec::new(); + for list in ¶graph.lists { + // Writer-allocated identities keep a nonzero sequence number so the node's + // compact identity is never the null identity; squash renames them to the + // model's. A copy for a second owner keeps its allocated identity. + let node_id = match owners.get(list) { + Some(owner) if *owner == image_id => *list, + Some(_) => ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }, + None => { + let written = ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }; + self.alias.insert(*list, written); + written + } + }; + let definition = after + .definitions + .get(list) + .ok_or_else(|| invalid("A paragraph references a missing list definition"))?; + let Kind::List { + font, + format, + restart, + bullet, + } = &definition.kind + else { + return Err(invalid("A paragraph list must reference a list definition")); + }; + 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(); + if let Some(format) = format { + let units: Vec = format.encode_utf16().collect(); + let count = u16::try_from(units.len()) + .map_err(|_| invalid("List format exceeds the document range"))?; + let mut bytes = count.to_le_bytes().to_vec(); + bytes.extend(units.iter().flat_map(|unit| unit.to_le_bytes())); + values.push((0x1c001c1a, bytes)); + } + if let Some(font) = font { + values.push((0x1c001c52, crate::create::string(font))); + } + if let Some(restart) = restart { + values.push((0x14001cb7, restart.to_le_bytes().to_vec())); + } + if let Some(bullet) = bullet { + values.push((0x10001d0e, bullet.to_le_bytes().to_vec())); + // Every native bullet node carries this cleared flag alongside its index. + values.push((0x0c001cc0, vec![0])); + } + let style = &definition.format; + if let Some(font) = &style.font { + values.push((0x1c001c0a, crate::create::string(font))); + } + if let Some(size) = style.font_size { + let half = (size * 2.0).round(); + if !(0.0..=f32::from(u16::MAX)).contains(&half) { + return Err(invalid("List font size is outside the document range")); + } + values.push((0x10001c0b, (half as u16).to_le_bytes().to_vec())); + } + if let Some(color) = style.color { + values.push((0x14001c0c, color.to_le_bytes().to_vec())); + } + if let Some(language) = style.language { + values.push((0x14001c3b, language.to_le_bytes().to_vec())); + } + for (flag, id) in [(style.bold, 0x08001c04), (style.italic, 0x08001c05)] { + if let Some(flag) = flag { + values.push((id | (u32::from(flag) << 31), Vec::new())); + } + } + nodes.push((node_id, values)); + } + let (space, object) = (self.space, self.id(*id)); + 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(); + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + let mut references = Vec::new(); + for (list, values) in &nodes { + let mut node = match raw.objects.get(list) { + Some(existing) => { + if existing.jcid != 0x60012 { + return Err(invalid( + "A list definition identity belongs to another object", + )); + } + let mut node = PropertyObject::from_object(existing)?; + node.remove(&[ + 0x1c001c1a, 0x1c001c52, 0x14001cb7, 0x10001d0e, 0x0c001cc0, + 0x1c001c0a, 0x10001c0b, 0x14001c0c, 0x14001c3b, 0x08001c04, + 0x08001c05, + ])?; + node + } + None => PropertyObject { + jcid: 0x60012, + bytes: crate::create::properties(&[])?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, list.guid)])), + }, + }; + node.set( + &values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect::>(), + )?; + node.set(&[(0x14001d7a, &modified)])?; + node.reference(*list)?; + references.extend_from_slice(&target.reference(*list)?); + changed.insert(*list, node); + } + if references.is_empty() { + target.remove(&[0x24001c26])?; + } else { + target.set(&[(0x24001c26, &references)])?; + } + 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/tests/page_lists.rs b/crates/onestore/tests/page_lists.rs new file mode 100644 index 0000000000000000000000000000000000000000..af757f3d277fe932987552b273abdd2c83d08bcb --- /dev/null +++ b/crates/onestore/tests/page_lists.rs @@ -0,0 +1,322 @@ +use onestore::{ + ExGuid, PreparedEdit, RevisionIndex, Store, + document::{Document, Format, Kind}, + page::{ + Definition, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id, + }, +}; +use std::collections::BTreeMap; + +const TREES: &[u8] = + include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one"); +const AUTHOR: &str = "List author"; + +fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) { + let store = Store::parse(bytes).unwrap(); + 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(); + 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() +} + +fn list_ids(page: &Page) -> Vec> { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Outline(outline) if !outline.title => { + Some(outline.paragraphs.iter().map(|p| p.lists.clone()).collect()) + } + _ => None, + }) + .next() + .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 bullet_definition() -> Definition { + Definition { + kind: Kind::List { + font: Some("Courier New".into()), + format: Some("\u{25cb}".into()), + restart: None, + bullet: Some(4), + }, + format: Format { + font_size: Some(11.0), + color: Some(0xff000000), + ..Default::default() + }, + } +} + +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 a_numbered_paragraph_becomes_plain_while_its_neighbours_keep_their_nodes() { + let (space, before) = page_by_title(TREES, "Move numbered subtree down"); + let lists = list_ids(&before); + let numbered: Vec = (0..lists.len()).filter(|i| !lists[*i].is_empty()).collect(); + assert!(numbered.len() >= 2, "{lists:?}"); + let mut after = before.clone(); + body_paragraphs(&mut after)[numbered[0]].lists.clear(); + let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); + let stored = page_in(written.as_bytes(), space); + let mut expected = after.clone(); + expected.title = stored.title.clone(); + expected.definitions.remove(&lists[numbered[0]][0]); + assert_eq!(stored, expected); + for other in &numbered[1..] { + for node in &lists[*other] { + assert_eq!( + raw_object(written.as_bytes(), space, *node), + raw_object(TREES, space, *node) + ); + } + } + assert_eq!( + PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + .unwrap() + .as_bytes(), + written.as_bytes() + ); +} + +#[test] +fn bullets_and_numbering_write_from_definitions_and_read_back() { + let (space, before) = page_by_title(TREES, "Move numbered subtree down"); + let lists = list_ids(&before); + let numbered = (0..lists.len()).find(|i| !lists[*i].is_empty()).unwrap(); + let plain = (0..lists.len()).find(|i| lists[*i].is_empty()).unwrap(); + let mut after = before.clone(); + let bullet = new_id().unwrap(); + after.definitions.insert(bullet, bullet_definition()); + let template = body_paragraphs(&mut after)[plain].clone(); + body_paragraphs(&mut after)[plain].lists = vec![bullet]; + let number = new_id().unwrap(); + let mut numbering = after.definitions[&lists[numbered][0]].clone(); + let Kind::List { restart, .. } = &mut numbering.kind else { + panic!() + }; + *restart = Some(3); + after.definitions.insert(number, numbering); + let mut fresh = plain_paragraph(&template, "Numbered from three"); + fresh.lists = vec![number]; + body_paragraphs(&mut after).push(fresh); + let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); + let stored = page_in(written.as_bytes(), space); + let mut expected = after.clone(); + expected.title = stored.title.clone(); + assert_eq!(stored, expected); + assert_eq!( + PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + .unwrap() + .as_bytes(), + written.as_bytes() + ); +} + +#[test] +fn a_list_definition_changes_in_place() { + let (space, before) = page_by_title(TREES, "Move numbered subtree down"); + let lists = list_ids(&before); + let numbered = (0..lists.len()).find(|i| !lists[*i].is_empty()).unwrap(); + let node = lists[numbered][0]; + let mut after = before.clone(); + let definition = after.definitions.get_mut(&node).unwrap(); + let Kind::List { restart, .. } = &mut definition.kind else { + panic!() + }; + *restart = Some(7); + definition.format.bold = Some(true); + let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); + let stored = page_in(written.as_bytes(), space); + let mut expected = after.clone(); + expected.title = stored.title.clone(); + assert_eq!(stored, expected); + assert_eq!(list_ids(&stored)[numbered], vec![node]); +} + +#[test] +fn a_definition_shared_between_paragraphs_becomes_a_node_per_paragraph() { + let (space, before) = page_by_title(TREES, "Move numbered subtree down"); + let lists = list_ids(&before); + let numbered = (0..lists.len()).find(|i| !lists[*i].is_empty()).unwrap(); + let plain = (0..lists.len()).find(|i| lists[*i].is_empty()).unwrap(); + let mut shared = before.clone(); + body_paragraphs(&mut shared)[plain].lists = lists[numbered].clone(); + let written = PreparedEdit::page(TREES, space, &shared, AUTHOR).unwrap(); + let stored = page_in(written.as_bytes(), space); + let after = list_ids(&stored); + assert_eq!(after[numbered], lists[numbered]); + assert_eq!(after[plain].len(), 1); + assert_ne!(after[plain], lists[numbered]); + assert_eq!( + stored.definitions[&after[plain][0]], + before.definitions[&lists[numbered][0]] + ); +} + +#[test] +fn missing_and_foreign_list_definitions_are_refused() { + let (space, before) = page_by_title(TREES, "Move numbered subtree down"); + let lists = list_ids(&before); + let plain = (0..lists.len()).find(|i| lists[*i].is_empty()).unwrap(); + let mut missing = before.clone(); + body_paragraphs(&mut missing)[plain].lists = vec![new_id().unwrap()]; + assert!(PreparedEdit::page(TREES, space, &missing, AUTHOR).is_err()); + let mut foreign = before.clone(); + let paragraph = body_paragraphs(&mut foreign)[plain].id; + foreign.definitions.insert(paragraph, bullet_definition()); + body_paragraphs(&mut foreign)[plain].lists = vec![paragraph]; + assert!(PreparedEdit::page(TREES, space, &foreign, AUTHOR).is_err()); + let mut style: BTreeMap = before.definitions.clone(); + if let Some((id, definition)) = style + .iter_mut() + .find(|(_, d)| matches!(d.kind, Kind::Style { .. })) + { + definition.format.bold = Some(true); + let mut edited = before.clone(); + edited.definitions.insert(*id, definition.clone()); + assert!(PreparedEdit::page(TREES, space, &edited, AUTHOR).is_err()); + } +} + +/// A fresh page gains a bullet, two numbered paragraphs with a nested numbered child, a +/// numbering restart and a trailing plain paragraph; `ONESTORE_LIST_EXPORT` names a new +/// directory receiving the candidate for a cold native reopen. +#[test] +fn bullets_numbering_nesting_and_restarts_publish_on_a_fresh_page() { + let source = onestore::create_section("lists.one", "Bullet item", "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 (_, trees) = page_by_title(TREES, "Move numbered subtree down"); + let numbering = trees + .definitions + .values() + .find(|d| { + matches!( + d.kind, + Kind::List { + format: Some(_), + .. + } + ) + }) + .unwrap() + .clone(); + let scenario = std::env::var("ONESTORE_LIST_SCENARIO").unwrap_or_else(|_| "all".into()); + let wants = |name: &str| scenario == "all" || scenario == name; + let mut after = before.clone(); + let template = body_paragraphs(&mut after)[0].clone(); + if wants("bullet") { + let bullet = new_id().unwrap(); + after.definitions.insert(bullet, bullet_definition()); + body_paragraphs(&mut after)[0].lists = vec![bullet]; + } + for (text, level, restart, name) in [ + ("First numbered", 1, None, "numbered"), + ("Second numbered", 1, None, "numbered"), + ("Nested numbered", 2, None, "all"), + ("Restarted at three", 1, Some(3), "restart"), + ("Nested plain", 2, None, "nested"), + ] { + if !wants(name) { + continue; + } + let mut paragraph = plain_paragraph(&template, text); + if name != "nested" { + let id = new_id().unwrap(); + let mut definition = numbering.clone(); + let Kind::List { restart: value, .. } = &mut definition.kind else { + panic!() + }; + *value = restart; + after.definitions.insert(id, definition); + paragraph.lists = vec![id]; + } + paragraph.level = level; + if level == 2 { + paragraph.parent = Some(body_paragraphs(&mut after).last().unwrap().id); + } + body_paragraphs(&mut after).push(paragraph); + } + body_paragraphs(&mut after).push(plain_paragraph(&template, "Plain again")); + let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); + let stored = page_in(written.as_bytes(), space); + let mut expected = after.clone(); + expected.title = stored.title.clone(); + assert_eq!(stored, expected); + if let Some(directory) = std::env::var_os("ONESTORE_LIST_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("lists.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", + &[("lists.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 d7d7a88ed58ababcbdeb6302fb86f92cf17a8e9b..0418b9b3d32bb3fe8ceb65d0e7b0d40619838e53 100644 --- a/crates/onestore/tests/support/page_edits.rs +++ b/crates/onestore/tests/support/page_edits.rs @@ -91,11 +91,13 @@ fn projection(page: &Page) -> String { match ¶graph.content { ParagraphContent::Text(text) => { out.push_str(&format!(" text {} {:?}", text.id, text.text.text())); + // Runs that differ only in attributes the writer leaves as stored, + // such as the language tag, project as one span. + let mut runs: Vec<(usize, String)> = Vec::new(); for span in text.text.spans() { let f = &span.format; - out.push_str(&format!( - " [{} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}]", - span.end, + let attributes = format!( + "{:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}", f.bold.unwrap_or(false), f.italic.unwrap_or(false), f.underline.unwrap_or(false), @@ -106,7 +108,16 @@ fn projection(page: &Page) -> String { f.font_size, f.color, f.highlight - )); + ); + match runs.last_mut() { + Some((end, previous)) if *previous == attributes => { + *end = span.end + } + _ => runs.push((span.end, attributes)), + } + } + for (end, attributes) in runs { + out.push_str(&format!(" [{end} {attributes}]")); } } ParagraphContent::Table(table) => { @@ -188,7 +199,44 @@ fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) { .filter(|(_, o)| matches!(o, PageObject::Outline(_))) .map(|(i, _)| i) .collect(); - match kind % 9 { + match kind % 10 { + 8 => { + 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(paragraph) = outline.paragraphs.get_mut(at) else { + continue; + }; + if paragraph.text().is_none() { + continue; + } + if paragraph.lists.is_empty() { + let id = new_id().unwrap(); + page.definitions.insert( + id, + onestore::page::Definition { + kind: onestore::document::Kind::List { + font: Some("Courier New".into()), + format: Some("\u{25cb}".into()), + restart: None, + bullet: Some(4), + }, + format: onestore::document::Format { + font_size: Some(11.0), + color: Some(0xff000000), + ..Default::default() + }, + }, + ); + paragraph.lists = vec![id]; + } else { + paragraph.lists.clear(); + } + } 0 | 1 => { let Some(o) = bytes.pick(outlines.len()) else { continue; diff --git a/tools/test_list_edit.py b/tools/test_list_edit.py new file mode 100644 index 0000000000000000000000000000000000000000..660eb076c7ca02c0ba8adbd57bdf780d7f5cc0ac --- /dev/null +++ b/tools/test_list_edit.py @@ -0,0 +1,52 @@ +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/list-edit' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] + + +def items(read): + page, = (ET.parse(path).getroot() for path in sorted(read.glob('page-*.xml'))) + outline, = page.findall('one:Outline', ns) + + def walk(element, depth): + marker = element.find('one:List', ns) + kind = None + if marker is not None: + child, = list(marker) + kind = (child.tag.split('}')[1], child.get('text')) + text = re.sub(r'<[^>]*>', '', element.find('one:T', ns).text or '') + yield depth, kind, text + for nested in element.findall('one:OEChildren/one:OE', ns): + yield from walk(nested, depth + 1) + + return [item for element in outline.findall('one:OEChildren/one:OE', ns) for item in walk(element, 0)] + + +class ListEditTest(unittest.TestCase): + def test_rust_bullets_numbering_and_restarts_render_natively(self): + with TemporaryDirectory() as temporary: + read = Path(temporary) / 'read' + shutil.copytree(FIXTURE / 'cold/read', read) + compare(FIXTURE / 'candidate', read) + self.assertEqual(items(FIXTURE / 'cold/read'), [ + (0, ('Bullet', None), 'Bullet item'), + (0, ('Number', 'I.'), 'First numbered'), + (0, ('Number', 'II.'), 'Second numbered'), + (1, ('Number', 'I.'), 'Nested numbered'), + (0, ('Number', 'III.'), 'Restarted at three'), + (1, None, 'Nested plain'), + (0, None, 'Plain again'), + ]) + + +if __name__ == '__main__': + unittest.main()