diff --git a/corpus/outline-edit/automatic-title/README.md b/corpus/outline-edit/automatic-title/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..d12b244ebdd2906f209576760c541ff2fe3ebf07
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/README.md
@@ -0,0 +1,29 @@
+# Automatic titles after outline movement
+
+Three native pages distinguish vertical ordering, horizontal ordering and an
+explicit title. Moving the second outline ahead of the first changes the two
+automatic titles to `Second 🦋 é`; the explicit title remains unchanged.
+
+`before` is the native source. `native` records the corresponding COM moves and
+an independent cold reopen. `candidate` is the Rust edit; `cold` is its independent
+OneNote reopen. Cold captures preserve exact active graphs. Rust movement retains
+all text identities, content, formatting and history. Identical artifacts link
+to one copy; `provenance.json` records the native runs and clone teardown.
+
+The Rust regression originally left both automatic-title fields unchanged.
+Position edits now update the existing page and metadata caches within the same
+revision as the coordinates. The offline regression additionally changes the
+remote title-producing text, loses the publication reply and reopens the cache
+before confirmation and a dependent edit.
+
+Run `tools.test_outline_edit.OutlineEditTest.test_automatic_titles_follow_native_and_rust_outline_movement`
+through Python unittest after building the workspace examples with all features.
+The Rust test `native_automatic_and_explicit_titles_follow_outline_movement` can
+export a new candidate directory through `ONESTORE_OUTLINE_TITLE_OUTPUT`.
+
+To regenerate the native controls, copy `fixture.json` and the synthetic section
+from `corpus/native-delete/deletion-05/before` into a new input notebook directory.
+Capture it with `tools/native_runner.py --author tools/native/pages.ps1
+--collect-notebook`. Capture that result's notebook with
+`--author tools/native/outline-title.ps1 --collect-notebook`, then independently
+cold-open both the native result and the Rust candidate without an author script.
diff --git a/corpus/outline-edit/automatic-title/before/notebook/TitleControl.one b/corpus/outline-edit/automatic-title/before/notebook/TitleControl.one
new file mode 100644
index 0000000000000000000000000000000000000000..b29efe83f94312361f3e77e6754f11adf0c9907d
Binary files /dev/null and b/corpus/outline-edit/automatic-title/before/notebook/TitleControl.one differ
diff --git a/corpus/outline-edit/automatic-title/before/read/hierarchy.xml b/corpus/outline-edit/automatic-title/before/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..fc93eb0778808de69cf3d5307bfb9dd450894e13
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/before/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/before/read/page-001.xml b/corpus/outline-edit/automatic-title/before/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..14027d9a74ab9ee14a9c32ab2f5454b3e88f4915
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/before/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/before/read/page-003.xml b/corpus/outline-edit/automatic-title/before/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..677b0132259a3cf8bed5426f06a2f598846f481c
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/before/read/page-003.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/before/read/page-004.xml b/corpus/outline-edit/automatic-title/before/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..87aaadbf74c8d772aeecff926d8ca33af9d47d26
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/before/read/page-004.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/candidate/TitleControl.one b/corpus/outline-edit/automatic-title/candidate/TitleControl.one
new file mode 100644
index 0000000000000000000000000000000000000000..884cec54059c0cbacd87a91f7be00f8941d18f1a
Binary files /dev/null and b/corpus/outline-edit/automatic-title/candidate/TitleControl.one differ
diff --git a/corpus/outline-edit/automatic-title/cold/notebook/TitleControl.one b/corpus/outline-edit/automatic-title/cold/notebook/TitleControl.one
new file mode 100644
index 0000000000000000000000000000000000000000..7c18e9b97196cc84c8623e8e1bcd0950c92cadb8
Binary files /dev/null and b/corpus/outline-edit/automatic-title/cold/notebook/TitleControl.one differ
diff --git a/corpus/outline-edit/automatic-title/cold/read/hierarchy.xml b/corpus/outline-edit/automatic-title/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4990dda9d774ae5de1c41a5ae72ef85ae37775ed
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/cold/read/page-000.xml b/corpus/outline-edit/automatic-title/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4b40442d60db1cf787076775f245b6cd52101fa7
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/cold/read/page-001.xml b/corpus/outline-edit/automatic-title/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..dd27ba5829a56dd160c7cb5d07b8f2dfb2f54cc0
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/cold/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/cold/read/page-002.xml b/corpus/outline-edit/automatic-title/cold/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..543bc61d4760baac2e20fa3d42ae46d6382c2502
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/cold/read/page-002.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/fixture.json b/corpus/outline-edit/automatic-title/fixture.json
new file mode 100644
index 0000000000000000000000000000000000000000..da384c090ad4c9543b224f1eeb40de7b7aed3583
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/fixture.json
@@ -0,0 +1 @@
+{"sections": ["TitleControl.one"], "pages": [{"section": "TitleControl.one", "xml": "Vertical firstSecond \ud83e\udd8b e\u0301"}, {"section": "TitleControl.one", "xml": "Horizontal firstSecond \ud83e\udd8b e\u0301"}, {"section": "TitleControl.one", "xml": "Explicit titleExplicit titleSecond \ud83e\udd8b e\u0301"}]}
\ No newline at end of file
diff --git a/corpus/outline-edit/automatic-title/native/cold/notebook/TitleControl.one b/corpus/outline-edit/automatic-title/native/cold/notebook/TitleControl.one
new file mode 120000
index 0000000000000000000000000000000000000000..c2ccdfd7698b3d0ff28d305952cd0b9aaaf7fcbb
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/native/cold/notebook/TitleControl.one
@@ -0,0 +1 @@
+../../notebook/TitleControl.one
\ No newline at end of file
diff --git a/corpus/outline-edit/automatic-title/native/cold/read/hierarchy.xml b/corpus/outline-edit/automatic-title/native/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..db0e819665e073c38d325b8cc121e9292e4f94d7
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/native/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/native/cold/read/page-002.xml b/corpus/outline-edit/automatic-title/native/cold/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8219b1b55f4a0250899b3b251476fe2b986e710f
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/native/cold/read/page-002.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/native/cold/read/page-003.xml b/corpus/outline-edit/automatic-title/native/cold/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d06176fd09b2314ce79c511ea036d80511d223cd
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/native/cold/read/page-003.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/native/cold/read/page-004.xml b/corpus/outline-edit/automatic-title/native/cold/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..eb9eb371e4ebb4b29ac03593ac2dfb7c53d067c7
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/native/cold/read/page-004.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/native/notebook/TitleControl.one b/corpus/outline-edit/automatic-title/native/notebook/TitleControl.one
new file mode 100644
index 0000000000000000000000000000000000000000..d7c68995a31f0bceeb86c4587d93dad51d811cd9
Binary files /dev/null and b/corpus/outline-edit/automatic-title/native/notebook/TitleControl.one differ
diff --git a/corpus/outline-edit/automatic-title/native/read/hierarchy.xml b/corpus/outline-edit/automatic-title/native/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c9c6498bc9b12465d27f2e6f0a846247b59f1833
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/native/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/native/read/page-000.xml b/corpus/outline-edit/automatic-title/native/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..48a2ab148379f8dd2b33312f7100cf84f6a5b639
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/native/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/native/read/page-001.xml b/corpus/outline-edit/automatic-title/native/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5334eb46b1e11826af3c1e04aa34c67fee59c7b4
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/native/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/native/read/page-003.xml b/corpus/outline-edit/automatic-title/native/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9d6adce81eaa9f312c05bbe4a9b99da177d4557a
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/native/read/page-003.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/automatic-title/provenance.json b/corpus/outline-edit/automatic-title/provenance.json
new file mode 100644
index 0000000000000000000000000000000000000000..ceecd4a3f008ddc0efbbf367ca9121dc367af0b2
--- /dev/null
+++ b/corpus/outline-edit/automatic-title/provenance.json
@@ -0,0 +1,116 @@
+{
+ "outline-title-native-before": {
+ "capture": "evidence/m10/outline-title-native-before",
+ "machine": {
+ "name": "m6-cfe84d87",
+ "hostname": "ONE-M6-CFE84D87"
+ },
+ "run": {
+ "notebook": "/Users/clo/dev/one/evidence/m10/outline-title-input",
+ "expected_pages": -1,
+ "author": "tools/native/pages.ps1",
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "author.ps1": "dfbf0e016aa4c8486ecea9f39768c633de05ac0db8214139052ca2e9c43bbd02",
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+ },
+ "teardown": {
+ "absent": true
+ }
+ },
+ "outline-title-native-after": {
+ "capture": "evidence/m10/outline-title-native-after",
+ "machine": {
+ "name": "m6-49c5ad30",
+ "hostname": "ONE-M6-49C5AD30"
+ },
+ "run": {
+ "notebook": "/Users/clo/dev/one/evidence/m10/outline-title-native-before/notebook",
+ "expected_pages": -1,
+ "author": "evidence/m10/outline-title-move.ps1",
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "author.ps1": "9d8779a521fb60ff3063d320babe742deeda1ca7e5cf89f54f5617473e28b998",
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+ },
+ "teardown": {
+ "absent": true
+ }
+ },
+ "outline-title-native-cold": {
+ "capture": "evidence/m10/outline-title-native-cold",
+ "machine": {
+ "name": "m6-3512bf26",
+ "hostname": "ONE-M6-3512BF26"
+ },
+ "run": {
+ "notebook": "/Users/clo/dev/one/evidence/m10/outline-title-native-after/notebook",
+ "expected_pages": 5,
+ "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"
+ }
+ },
+ "teardown": {
+ "absent": true
+ }
+ },
+ "outline-title-rust-cold": {
+ "capture": "evidence/m10/outline-title-rust-cold",
+ "machine": {
+ "name": "m6-61cabd16",
+ "hostname": "ONE-M6-61CABD16"
+ },
+ "run": {
+ "notebook": "/Users/clo/dev/one/evidence/m10/outline-title-rust-candidate",
+ "expected_pages": 3,
+ "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"
+ }
+ },
+ "teardown": {
+ "absent": true
+ }
+ }
+}
diff --git a/crates/onestore-offline/tests/sync/outline.rs b/crates/onestore-offline/tests/sync/outline.rs
index 87e6e1156c054bfc53acb7ad9b0d4ae8edc96ba5..bd021e0c0b77f555bf664bdee9fa603ddedbcee9 100644
--- a/crates/onestore-offline/tests/sync/outline.rs
+++ b/crates/onestore-offline/tests/sync/outline.rs
@@ -108,6 +108,81 @@ fn layout_and_dependent_text_survive_reopen_and_independent_remote_formatting()
}
}
+#[test]
+fn rebased_outline_movement_uses_remote_title_text_and_confirms_after_reopen() {
+ let (source, sid, outline, _, text_id) = fixture();
+ let store = Store::parse(&source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let (_, page) = document.pages().unwrap()[0];
+ let space = &document.spaces[&sid];
+ let metadata = space.revisions[&space.contexts[&ExGuid::default()]].roots[&2];
+ let second = onestore::Insertion::outline(page, 144.0, 36.0, "Second", "Author").unwrap();
+ let source = PreparedEdit::insert(&source, sid, &second)
+ .unwrap()
+ .as_bytes()
+ .to_vec();
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("titles.sqlite");
+ let cache = Replica::create(&path, &source).unwrap();
+ let movement = cache
+ .outline(
+ &source,
+ sid,
+ outline,
+ Change::Position { x: 216.0, y: 72.0 },
+ )
+ .unwrap()
+ .unwrap();
+ let local = cache.snapshot().unwrap();
+ assert_eq!(node(&local, sid, metadata)["kind"]["title"], "Second");
+ let dependent = cache
+ .edit_text(&local, sid, text_id, 0..0, "Local ")
+ .unwrap()
+ .unwrap();
+ drop(cache);
+ let remote =
+ PreparedEdit::text(&source, sid, second.text_object(), 0..6, "Remote second 🐈").unwrap();
+ let mut server = Server::new(remote.as_bytes());
+ server.fault = Fault::UnknownAfter;
+ let cache = Replica::open(&path).unwrap();
+ assert!(cache.sync_once(&mut server).is_err());
+ assert!(matches!(
+ cache.status(movement).unwrap(),
+ Some(EditStatus::AwaitingConfirmation { .. })
+ ));
+ assert_eq!(
+ node(&server.durable, sid, metadata)["kind"]["title"],
+ "Original 🦀 é"
+ );
+ assert_eq!(
+ node(&server.visible, sid, metadata)["kind"]["title"],
+ "Remote second 🐈"
+ );
+ drop(cache);
+ for id in [movement, dependent] {
+ let cache = Replica::open(&path).unwrap();
+ assert!(
+ matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
+ );
+ assert_eq!(
+ node(&server.durable, sid, metadata)["kind"]["title"],
+ "Remote second 🐈"
+ );
+ assert_eq!(
+ node(&server.durable, sid, page)["kind"]["alternate_title"],
+ "Remote second 🐈"
+ );
+ }
+ let cache = Replica::open(&path).unwrap();
+ assert!(cache.pending().unwrap().is_empty());
+ assert_eq!(
+ node(&cache.snapshot().unwrap(), sid, text_id)["kind"]["text"],
+ "Local Original 🦀 é"
+ );
+ assert_eq!(server.publications, 2);
+}
+
#[test]
fn competing_layout_requires_current_review_and_preserves_dependent_edits() {
let (source, sid, outline, _, text_id) = fixture();
diff --git a/crates/onestore/src/outline.rs b/crates/onestore/src/outline.rs
index 3c4f01f7cf20aed1e605190ac87eea4ace672711..c58fe5569c1d023b63b4943689972f39becbf974 100644
--- a/crates/onestore/src/outline.rs
+++ b/crates/onestore/src/outline.rs
@@ -2,7 +2,7 @@ use crate::{
Error, ExGuid, RevisionIndex, Store,
create::current_timestamps,
document::{Document, Kind},
- edit::editable_parents,
+ edit::{editable_parents, update_title},
write::{PropertyObject, write_revision},
};
use serde::{Deserialize, Serialize};
@@ -13,6 +13,7 @@ use std::collections::{BTreeMap, BTreeSet};
#[serde(deny_unknown_fields)]
pub enum OutlineEdit {
/// Moves an ordinary page outline to coordinates measured in points.
+ /// Refreshes automatic page titles when the leading outline changes.
Position { x: f32, y: f32 },
/// Changes an ordinary outline's maximum width in points, at least 36.
/// `user_set` distinguishes an explicit width from an automatic layout hint.
@@ -56,7 +57,7 @@ impl OutlineEdit {
let store = Store::parse(source)?;
let index = RevisionIndex::parse(&store)?;
index.validate_current()?;
- let document = Document::parse(&index)?;
+ let mut document = Document::parse(&index)?;
let pages: Vec<_> = document
.pages()?
.into_iter()
@@ -65,9 +66,12 @@ impl OutlineEdit {
let [page] = pages.as_slice() else {
return Err(invalid("Outline editing requires a single active page"));
};
- let semantic = &document.spaces[&space];
- let view = &semantic.revisions[&semantic.contexts[&ExGuid::default()]];
- let parents = editable_parents(view, &pages, object)?;
+ let mut semantic = document.spaces.remove(&space).unwrap();
+ let view = semantic
+ .revisions
+ .remove(&semantic.contexts[&ExGuid::default()])
+ .unwrap();
+ let parents = editable_parents(&view, &pages, object)?;
let node = &view.nodes[&object];
match self {
Self::Collapsed(_) => {
@@ -128,6 +132,9 @@ impl OutlineEdit {
changed.insert(id, ancestor);
}
}
+ if matches!(self, Self::Position { .. }) {
+ update_title(&store, raw, view, &pages, &mut changed)?;
+ }
Ok(changed)
})
}
diff --git a/crates/onestore/tests/outline.rs b/crates/onestore/tests/outline.rs
index 94be3a4363fbff3f9aa083cd98f53e77cb942bcb..d3f9e9238d2587eea35aa08220bf10bf92c02907 100644
--- a/crates/onestore/tests/outline.rs
+++ b/crates/onestore/tests/outline.rs
@@ -271,6 +271,146 @@ fn resizing_a_native_reserved_width_preserves_content() {
}
}
+#[test]
+fn outline_movement_updates_both_automatic_title_fields_without_changing_content() {
+ let source = onestore::create_section("titles.one", "First", "Author").unwrap();
+ let store = Store::parse(&source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let (sid, page) = document.pages().unwrap()[0];
+ let insertion =
+ onestore::Insertion::outline(page, 144.0, 144.0, "Second 🦋 é", "Author").unwrap();
+ let source = PreparedEdit::insert(&source, sid, &insertion)
+ .unwrap()
+ .as_bytes()
+ .to_vec();
+ let store = Store::parse(&source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let space = &document.spaces[&sid];
+ let rid = space.contexts[&ExGuid::default()];
+ let before = &space.revisions[&rid];
+ for (x, y, expected) in [
+ (144.0, 0.0, "Second 🦋 é"),
+ (0.0, 36.0, "Second 🦋 é"),
+ (144.0, 36.0, "First"),
+ (0.0, 144.0, "First"),
+ ] {
+ let edit = PreparedEdit::outline(&source, sid, insertion.object(), Edit::Position { x, y })
+ .unwrap();
+ let saved = Store::parse(edit.as_bytes()).unwrap();
+ let saved_index = RevisionIndex::parse(&saved).unwrap();
+ saved_index.validate_current().unwrap();
+ let saved_document = Document::parse(&saved_index).unwrap();
+ let saved_space = &saved_document.spaces[&sid];
+ let after = &saved_space.revisions[&saved_space.contexts[&ExGuid::default()]];
+ let metadata = before.roots[&2];
+ assert!(
+ matches!(&after.nodes[&metadata].kind, Kind::Metadata { title: Some(title), .. } if title == expected)
+ );
+ assert!(
+ matches!(&after.nodes[&page].kind, Kind::Page { alternate_title: Some(title), .. } if title == expected)
+ );
+ assert_eq!(
+ before.nodes.keys().collect::>(),
+ after.nodes.keys().collect::>()
+ );
+ for (oid, node) in &before.nodes {
+ if ![page, metadata, insertion.object()].contains(oid) {
+ assert_eq!(
+ serde_json::to_value(node).unwrap(),
+ serde_json::to_value(&after.nodes[oid]).unwrap()
+ );
+ }
+ }
+ assert_eq!(
+ format!("{:?}", index.resolve(sid, rid).unwrap()),
+ format!("{:?}", saved_index.resolve(sid, rid).unwrap())
+ );
+ assert_eq!(
+ PreparedEdit::outline(
+ edit.as_bytes(),
+ sid,
+ insertion.object(),
+ Edit::Position { x, y }
+ )
+ .unwrap()
+ .as_bytes(),
+ edit.as_bytes()
+ );
+ }
+}
+
+#[test]
+fn native_automatic_and_explicit_titles_follow_outline_movement() {
+ let source = include_bytes!(
+ "../../../corpus/outline-edit/automatic-title/before/notebook/TitleControl.one"
+ );
+ let store = Store::parse(source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let mut candidate = source.to_vec();
+ let pages = document.pages().unwrap();
+ assert_eq!(pages.len(), 3);
+ for (sid, page) in pages {
+ let space = &document.spaces[&sid];
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let Kind::Metadata {
+ title: Some(title), ..
+ } = &view.nodes[&view.roots[&2]].kind
+ else {
+ panic!()
+ };
+ let outline = *view.nodes[&page].children.iter().find(|oid| {
+ view.nodes[oid].children.iter().any(|paragraph| view.nodes[paragraph].content.iter().any(|text| {
+ matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text.starts_with("Second"))
+ }))
+ }).unwrap();
+ let change = Edit::Position {
+ x: 36.0,
+ y: if title == "Horizontal first" {
+ 108.0
+ } else {
+ 72.0
+ },
+ };
+ candidate = PreparedEdit::outline(&candidate, sid, outline, change)
+ .unwrap()
+ .as_bytes()
+ .to_vec();
+ let store = Store::parse(&candidate).unwrap();
+ let saved = RevisionIndex::parse(&store).unwrap();
+ saved.validate_current().unwrap();
+ let document = Document::parse(&saved).unwrap();
+ let space = &document.spaces[&sid];
+ let after = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let expected = if title == "Explicit title" {
+ "Explicit title"
+ } else {
+ "Second 🦋 é"
+ };
+ assert!(
+ matches!(&after.nodes[&after.roots[&2]].kind, Kind::Metadata { title: Some(title), .. } if title == expected)
+ );
+ for (oid, node) in &view.nodes {
+ assert_eq!(node.children, after.nodes[oid].children);
+ assert_eq!(node.content, after.nodes[oid].content);
+ if matches!(node.kind, Kind::RichText { .. }) {
+ assert_eq!(
+ serde_json::to_value(node).unwrap(),
+ serde_json::to_value(&after.nodes[oid]).unwrap()
+ );
+ }
+ }
+ }
+ if let Some(output) = std::env::var_os("ONESTORE_OUTLINE_TITLE_OUTPUT") {
+ let output = std::path::PathBuf::from(output);
+ assert!(output.is_absolute());
+ std::fs::create_dir(&output).unwrap();
+ std::fs::write(output.join("TitleControl.one"), candidate).unwrap();
+ }
+}
+
#[test]
fn outline_publication_interruptions_reopen_as_complete_old_or_new_layout() {
let source = onestore::create_section("layout.one", "Before 🦀 after", "Author").unwrap();
@@ -286,6 +426,11 @@ fn outline_publication_interruptions_reopen_as_complete_old_or_new_layout() {
.find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
.unwrap();
let paragraph = view.nodes[&outline].children[0];
+ let second = onestore::Insertion::outline(page, 144.0, 36.0, "Second", "Author").unwrap();
+ let source = PreparedEdit::insert(&source, sid, &second)
+ .unwrap()
+ .as_bytes()
+ .to_vec();
for (object, operation) in [
(outline, Edit::Position { x: 216.0, y: 72.0 }),
(
@@ -344,6 +489,11 @@ fn repeated_geometry_changes_and_expansion_match_an_independent_model() {
.unwrap();
let paragraph = view.nodes[&outline].children[0];
let text = view.nodes[¶graph].content[0];
+ let second = onestore::Insertion::outline(page, 144.0, 36.0, "Second", "Author").unwrap();
+ let original = PreparedEdit::insert(&original, sid, &second)
+ .unwrap()
+ .as_bytes()
+ .to_vec();
for seed in 1..=16_u64 {
let mut rng = seed;
let mut source = original.clone();
@@ -388,6 +538,19 @@ fn repeated_geometry_changes_and_expansion_match_an_independent_model() {
let document = Document::parse(&index).unwrap();
let space = &document.spaces[&sid];
let current = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let x = layout["x"].as_f64().unwrap();
+ let y = layout["y"].as_f64().unwrap();
+ let title = if y < 36.0 || (y == 36.0 && x <= 144.0) {
+ "Before 🦀 é"
+ } else {
+ "Second"
+ };
+ assert!(
+ matches!(¤t.nodes[¤t.roots[&2]].kind, Kind::Metadata { title: Some(actual), .. } if actual == title)
+ );
+ assert!(
+ matches!(¤t.nodes[&page].kind, Kind::Page { alternate_title: Some(actual), .. } if actual == title)
+ );
assert_eq!(
serde_json::to_value(¤t.nodes[&outline].layout).unwrap(),
layout
diff --git a/fuzz/fuzz_targets/paragraph.rs b/fuzz/fuzz_targets/paragraph.rs
index dbb663bf828dd4e32e13aee1b59fb24635a7db19..5d9d4d801966225ce38a15848bb0ddb24aab6ca3 100644
--- a/fuzz/fuzz_targets/paragraph.rs
+++ b/fuzz/fuzz_targets/paragraph.rs
@@ -33,6 +33,8 @@ static SOURCE: LazyLock<(Vec, ExGuid, ExGuid)> = LazyLock::new(|| {
.with_formatting(4..7, &[A::Italic(true), A::Color(Some([12, 34, 56]))])
.unwrap();
let edited = PreparedEdit::insert(&source, sid, &intent).unwrap();
+ let second = Insertion::outline(page, 144.0, 36.0, "Fixed second", "Author").unwrap();
+ let edited = PreparedEdit::insert(edited.as_bytes(), sid, &second).unwrap();
(edited.as_bytes().to_vec(), sid, outline)
});
@@ -187,6 +189,14 @@ fuzz_target!(|input: &[u8]| {
let after_document = Document::parse(&after_index).unwrap();
let space = &after_document.spaces[sid];
let after_view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let layout = &after_view.nodes[outline].layout;
+ if layout.y.unwrap() > 36.0 || (layout.y == Some(36.0) && layout.x.unwrap() > 144.0) {
+ let (_, page) = after_document.pages().unwrap()[0];
+ assert!(matches!(&after_view.nodes[&after_view.roots[&2]].kind,
+ Kind::Metadata { title: Some(title), .. } if title == "Fixed second"));
+ assert!(matches!(&after_view.nodes[&page].kind,
+ Kind::Page { alternate_title: Some(title), .. } if title == "Fixed second"));
+ }
if let Some(expected) = expected_layout {
assert_eq!(
serde_json::to_value(&after_view.nodes[outline].layout).unwrap(),
diff --git a/tools/native/outline-title.ps1 b/tools/native/outline-title.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..87299b07f00b9476ab4d2d0bb98d513314fbd2a0
--- /dev/null
+++ b/tools/native/outline-title.ps1
@@ -0,0 +1,48 @@
+param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
+$app = New-Object -ComObject OneNote.Application
+$notebook = ''
+try {
+ $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebook, 0)
+ $section = ''
+ $app.OpenHierarchy('TitleControl.one', $notebook, [ref]$section, 0)
+ $deadline = [DateTime]::UtcNow.AddSeconds(30)
+ do {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$tree = $hierarchy
+ $pages = @($tree.SelectNodes('//*[local-name()="Page"]'))
+ if ($pages.Count -eq 3) { break }
+ Start-Sleep -Milliseconds 200
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if ($pages.Count -ne 3) { throw 'Expected three outline title controls.' }
+ $inputs = Join-Path $Root 'inputs'
+ [void](New-Item -ItemType Directory -Path $inputs)
+ foreach ($page in $pages) {
+ $name = $page.GetAttribute('name')
+ if ($name -notin @('Vertical first', 'Horizontal first', 'Explicit title')) { throw 'Unexpected control title.' }
+ $content = ''
+ $app.GetPageContent($page.GetAttribute('ID'), [ref]$content, 1, 1)
+ [xml]$xml = $content
+ $outlines = @($xml.SelectNodes('/*/*[local-name()="Outline"]') | Where-Object { $_.InnerText -match 'Second' })
+ if ($outlines.Count -ne 1) { throw 'Expected one movable outline.' }
+ $outline = $outlines[0]
+ $position = $outline.SelectSingleNode('./*[local-name()="Position"]')
+ $position.SetAttribute('x', '36')
+ $position.SetAttribute('y', $(if ($name -eq 'Horizontal first') { '108' } else { '72' }))
+ foreach ($child in @($xml.DocumentElement.ChildNodes)) {
+ if ($child -ne $outline -and $child.LocalName -notin @('QuickStyleDef', 'TagDef')) {
+ [void]$xml.DocumentElement.RemoveChild($child)
+ }
+ }
+ [IO.File]::WriteAllText((Join-Path $inputs ($name + '.xml')), $xml.OuterXml, [Text.Encoding]::UTF8)
+ $app.UpdatePageContent($xml.OuterXml, [DateTime]::MinValue, 1, $false)
+ }
+ $app.SyncHierarchy($notebook)
+} finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+}
diff --git a/tools/test_outline_edit.py b/tools/test_outline_edit.py
index 4a02c4778c67229a5af116b6159993e0c25868ec..84ba019a8acb28898344219cb151c65c3e71a3d8 100644
--- a/tools/test_outline_edit.py
+++ b/tools/test_outline_edit.py
@@ -19,6 +19,39 @@ compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
class OutlineEditTest(unittest.TestCase):
+ def test_automatic_titles_follow_native_and_rust_outline_movement(self):
+ fixture = FIXTURE / 'automatic-title'
+ models = {}
+ with TemporaryDirectory() as temporary:
+ for phase, notebook, capture in [
+ ('before', fixture / 'before/notebook', fixture / 'before/read'),
+ ('native', fixture / 'native/notebook', fixture / 'native/read'),
+ ('native-cold', fixture / 'native/cold/notebook', fixture / 'native/cold/read'),
+ ('rust', fixture / 'candidate', fixture / 'cold/read'),
+ ('rust-cold', fixture / 'cold/notebook', fixture / 'cold/read'),
+ ]:
+ folder = Path(temporary) / phase
+ shutil.copytree(capture, folder / 'read')
+ compare(notebook, folder / 'read')
+ subprocess.run([EXPORTER, notebook / 'TitleControl.one', folder / 'model'], check=True)
+ document = json.loads((folder / 'model/document.json').read_text())
+ models[phase] = list(ordered_pages(document))
+ self.assertEqual(len(models[phase]), 3)
+ for phase, pages in models.items():
+ expected = ['Vertical first', 'Horizontal first', 'Explicit title'] if phase == 'before' else ['Second 🦋 é', 'Second 🦋 é', 'Explicit title']
+ self.assertEqual([r['nodes'][r['roots']['2']]['kind']['title'] for _, _, r, _ in pages], expected)
+ for before, after in [('native', 'native-cold'), ('rust', 'rust-cold')]:
+ for (sid, _, a, page), (saved_sid, _, b, saved_page) in zip(models[before], models[after], strict=True):
+ self.assertEqual((sid, page), (saved_sid, saved_page))
+ self.assertEqual(dict(walk(a, page)), dict(walk(b, page)))
+ for (sid, _, a, page), (saved_sid, _, b, saved_page) in zip(models['before'], models['rust'], strict=True):
+ self.assertEqual((sid, page), (saved_sid, saved_page))
+ for oid, node in walk(a, page):
+ self.assertEqual(node['children'], b['nodes'][oid]['children'])
+ self.assertEqual(node['content'], b['nodes'][oid]['content'])
+ if node['kind']['type'] == 'RichText':
+ self.assertEqual(node, b['nodes'][oid])
+
def test_rust_subtree_moves_and_deletions_survive_cold_native_reopen(self):
for name, pages in [
('rust-tree/ordinary', 15), ('rust-tree/groups-cells', 12),