authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-08 12:03:31-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-08 12:03:31-07:00
logd1ad62573d835ddc1f3273975bfe9c4b1a554510
tree23930309b6d7250426a8bdf76a142d89b365b72d
parent423f7adf1aef0ed15f708ddac37865616105bbf3
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: reconcile durable offline outline layout edits

Retain layout intentions with their selected property values and ancestry. Merge independent changes, preserve competing changes for exact-image review, and retain uncertain attempts without acknowledging coincidentally converged values. Migrate prior caches to schema 7 while preserving images, pending edits, attempts, receipts, media and sequence IDs. Share active-path traversal with paragraph reconciliation. Native controls verify five automatic merges, five reviewed merges and four retained deletions, plus dependent text and cold reopening. Tests cover twelve writers with 144 intentions, migration, restart, uncertainty and extended SQLite fuzzing. Assisted-by: gpt-6-astra

42 files changed, 1456 insertions(+), 143 deletions(-)

corpus/outline-edit/README.md+16
...@@ -60,3 +60,19 @@ all 304 active graph objects, ten tags and 8,457 explicit formatting comparisons...@@ -60,3 +60,19 @@ all 304 active graph objects, ten tags and 8,457 explicit formatting comparisons
60To regenerate, run `resizing_a_native_reserved_width_preserves_content` with60To regenerate, run `resizing_a_native_reserved_width_preserves_content` with
61`ONESTORE_RESERVED_WIDTH_OUTPUT` set to a new absolute directory, then cold-open61`ONESTORE_RESERVED_WIDTH_OUTPUT` set to a new absolute directory, then cold-open
62it with the same runner arguments.62it with the same runner arguments.
63
64`offline` reconciles local layout/default intents and later text edits against all
65fourteen native changes. Five merge automatically, five require explicit review,
66and four deleted targets retain their local branches and dependent queues. The ten
67successful cases produce eighteen publications and twenty durable receipts; the
68already-converged collapse defaults require confirmation without another append.
69The final cold capture preserves all 304 active graph objects, ten tags, and 8,937
70explicit formatting comparisons. The `cases.json` records stable targets and the
71retained/dependent outcomes used by the public test.
72
73Regenerate by running the offline sync test
74`native_moves_deletions_and_layout_changes_merge_or_retain_explicit_conflicts`
75with `ONESTORE_OFFLINE_OUTLINE_OUTPUT` set to a new absolute directory, then use
76the same cold runner arguments. The test also retains a JSON record next to that
77directory. Cache migration, uncertain revision retention, stale review, and twelve
78offline writers are exercised in `crates/onestore-offline/tests`.
corpus/outline-edit/offline/candidate/synthetic.one created
Binary files /dev/null and b/corpus/outline-edit/offline/candidate/synthetic.one differ
corpus/outline-edit/offline/cases.json created+154
...@@ -0,0 +1,154 @@
1[
2 {
3 "change": {
4 "Collapsed": true
5 },
6 "dependent_text": "{033CC036-70D6-02D3-2869-6264552F7B9E},64",
7 "name": "Move leaf down",
8 "object": "{033CC036-70D6-02D3-2869-6264552F7B9E},45",
9 "retained": false,
10 "space": "{B7267338-BF83-0893-27A7-37F606DD20C8},1"
11 },
12 {
13 "change": {
14 "Collapsed": true
15 },
16 "dependent_text": "{E79A0442-BFCF-04C6-1E04-364B68877B88},75",
17 "name": "Move subtree down",
18 "object": "{E79A0442-BFCF-04C6-1E04-364B68877B88},45",
19 "retained": false,
20 "space": "{9B80D7CA-18B4-02B0-1E85-6E8AA0DAD827},1"
21 },
22 {
23 "change": {
24 "Collapsed": true
25 },
26 "dependent_text": "{3F47069A-F0C5-053E-1DEC-13B552EEE2A0},75",
27 "name": "Move subtree up",
28 "object": "{3F47069A-F0C5-053E-1DEC-13B552EEE2A0},45",
29 "retained": false,
30 "space": "{7538095C-389B-07D8-2991-B2BC46EBD52A},1"
31 },
32 {
33 "change": {
34 "Collapsed": true
35 },
36 "dependent_text": "{5A89330E-0803-08CF-2BF5-26621F998039},75",
37 "name": "Indent subtree",
38 "object": "{5A89330E-0803-08CF-2BF5-26621F998039},45",
39 "retained": false,
40 "space": "{651463E3-C768-0C93-363D-FCE642B07408},1"
41 },
42 {
43 "change": {
44 "Collapsed": true
45 },
46 "dependent_text": "{1A8D8A0B-E96E-01B0-3DA2-EF182AA19042},75",
47 "name": "Outdent subtree",
48 "object": "{1A8D8A0B-E96E-01B0-3DA2-EF182AA19042},45",
49 "retained": false,
50 "space": "{8384083C-60ED-0F7C-2F15-8B20B878B3EB},1"
51 },
52 {
53 "change": {
54 "Collapsed": true
55 },
56 "dependent_text": "{7375C8D1-28F0-0B05-00E5-093957D7F5F1},75",
57 "name": "Collapse subtree",
58 "object": "{7375C8D1-28F0-0B05-00E5-093957D7F5F1},45",
59 "retained": false,
60 "space": "{FF88B513-029C-00E2-0082-9E3DCE1D7796},1"
61 },
62 {
63 "change": {
64 "Collapsed": false
65 },
66 "dependent_text": "{B11DD73E-4234-04EF-0F4D-1F67E8F92C22},75",
67 "name": "Expand subtree",
68 "object": "{B11DD73E-4234-04EF-0F4D-1F67E8F92C22},45",
69 "retained": false,
70 "space": "{850B34E4-D62A-042B-138A-B2A905CEE28E},1"
71 },
72 {
73 "change": {
74 "Collapsed": true
75 },
76 "dependent_text": "{DC976FEC-CD30-0C41-20AD-BB462A203464},64",
77 "name": "Delete leaf",
78 "object": "{DC976FEC-CD30-0C41-20AD-BB462A203464},45",
79 "retained": true,
80 "space": "{363FE055-44E1-0B27-14A8-46278FD681FF},1"
81 },
82 {
83 "change": {
84 "Collapsed": true
85 },
86 "dependent_text": "{73987DA5-C014-0EEA-00F8-5C1E149736E4},75",
87 "name": "Delete subtree",
88 "object": "{73987DA5-C014-0EEA-00F8-5C1E149736E4},45",
89 "retained": true,
90 "space": "{8BBAD88F-D248-0959-00B3-2438A7065330},1"
91 },
92 {
93 "change": {
94 "Collapsed": true
95 },
96 "dependent_text": "{C868DD34-6E8C-07E9-116E-4C68D2C45DFC},54",
97 "name": "Delete only paragraph",
98 "object": "{C868DD34-6E8C-07E9-116E-4C68D2C45DFC},40",
99 "retained": true,
100 "space": "{A4CB3BA2-767F-0AE8-0916-AF142D995822},1"
101 },
102 {
103 "change": {
104 "Width": {
105 "points": 252.0,
106 "user_set": true
107 }
108 },
109 "dependent_text": "{78796D8B-FB39-02D8-2502-22530F00073C},75",
110 "name": "Delete outline",
111 "object": "{78796D8B-FB39-02D8-2502-22530F00073C},39",
112 "retained": true,
113 "space": "{80E80543-87A1-0C3A-0816-22B190BDFC87},1"
114 },
115 {
116 "change": {
117 "Position": {
118 "x": 252.0,
119 "y": 288.0
120 }
121 },
122 "dependent_text": "{462B969B-FCFA-0201-073A-0B4C3C23ABF5},75",
123 "name": "Move outline",
124 "object": "{462B969B-FCFA-0201-073A-0B4C3C23ABF5},39",
125 "retained": false,
126 "space": "{16207F40-69C9-0CA1-09B5-DC5712DB2BBE},1"
127 },
128 {
129 "change": {
130 "Width": {
131 "points": 252.0,
132 "user_set": true
133 }
134 },
135 "dependent_text": "{048364F9-D353-05E3-319E-2633D33434CE},75",
136 "name": "Resize outline",
137 "object": "{048364F9-D353-05E3-319E-2633D33434CE},39",
138 "retained": false,
139 "space": "{CD420421-73F7-0F11-02EC-35FCA348998C},1"
140 },
141 {
142 "change": {
143 "Width": {
144 "points": 252.0,
145 "user_set": true
146 }
147 },
148 "dependent_text": "{CE365E1B-9B76-067E-2EC7-220FB6AC76E9},75",
149 "name": "Automatic outline size",
150 "object": "{CE365E1B-9B76-067E-2EC7-220FB6AC76E9},39",
151 "retained": false,
152 "space": "{F383096F-2121-0225-1E62-AB37C9B15B33},1"
153 }
154]
\ No newline at end of file
corpus/outline-edit/offline/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-B0D8A07E -ExpectedPages 15", "exit": 0, "stdout": "Read 1 sections and 15 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/outline-edit/offline/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-b0d8a07e", "hostname": "ONE-M6-B0D8A07E"}
corpus/outline-edit/offline/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/outline-edit/offline/cold/notebook/Open Notebook.onetoc2 differ
corpus/outline-edit/offline/cold/notebook/synthetic.one created
Binary files /dev/null and b/corpus/outline-edit/offline/cold/notebook/synthetic.one differ
corpus/outline-edit/offline/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-B0D8A07E",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/outline-edit/offline/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{E3AC475E-8150-4056-9A49-9AA12DEF596A}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-08T19:02:11.000Z" color="#B49EDE"><one:Section name="synthetic" ID="{2B0D25D4-B33D-0BFF-0C1A-10A0030165EF}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-08T19:02:11.000Z" color="#8AA8E4"><one:Page ID="{0744D652-3AF6-48EE-8B7D-B831FF3979F0}{1}{B0}" name="Bold 🦀 italic é color 東京" dateTime="2026-09-05T05:06:54.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1"/><one:Page ID="{D47A9B9E-CB6C-4CC3-91E1-0738EE31D224}{1}{B0}" name="Move leaf down" dateTime="2026-09-08T18:00:47.000Z" lastModifiedTime="2026-09-08T18:02:45.000Z" pageLevel="1"/><one:Page ID="{F8DC3F6C-6C5B-46E0-A8C3-5E4448362ACB}{1}{B0}" name="Move subtree down" dateTime="2026-09-08T18:00:49.000Z" lastModifiedTime="2026-09-08T18:02:47.000Z" pageLevel="1"/><one:Page ID="{1664E1FA-4C74-4388-9FD7-8272AE0727C6}{1}{B0}" name="Move subtree up" dateTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:49.000Z" pageLevel="1"/><one:Page ID="{06488B45-B387-48C3-807B-CC28AA5C86E4}{1}{B0}" name="Indent subtree" dateTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:50.000Z" pageLevel="1"/><one:Page ID="{E0D8E09A-1402-4B2C-9953-BBEE50944107}{1}{B0}" name="Outdent subtree" dateTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:52.000Z" pageLevel="1"/><one:Page ID="{9CD45DB5-7673-44B2-B6C4-AEF326F1857A}{1}{B0}" name="Collapse subtree" dateTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1"/><one:Page ID="{E657DC42-A2C5-407B-A5CC-8267ED221062}{1}{B0}" name="Expand subtree" dateTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1"/><one:Page ID="{556308F3-300E-4F77-A2EE-76E9673A7313}{1}{B0}" name="Delete leaf" dateTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:03:01.000Z" pageLevel="1"/><one:Page ID="{E8E63029-A6A7-4D09-B6F5-14F64FEAA1DC}{1}{B0}" name="Delete subtree" dateTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:03.000Z" pageLevel="1"/><one:Page ID="{C797D304-0290-4EB8-BF50-9FDAC575AACE}{1}{B0}" name="Delete only paragraph" dateTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:05.000Z" pageLevel="1"/><one:Page ID="{E3B4EDE5-F34E-486A-BE50-127F78510E6B}{1}{B0}" name="Delete outline" dateTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1"/><one:Page ID="{757C97E6-1D26-48F1-BFF3-EC99FA37D952}{1}{B0}" name="Move outline" dateTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1"/><one:Page ID="{AE1EEC87-0718-4B41-B4AA-05324BA46B60}{1}{B0}" name="Resize outline" dateTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1"/><one:Page ID="{90DFE1C9-55CE-4675-A824-9BF9215DA9DF}{1}{B0}" name="Automatic outline size" dateTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/outline-edit/offline/cold/read/page-000.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{06488B45-B387-48C3-807B-CC28AA5C86E4}{1}{B0}" name="Indent subtree" dateTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:50.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Preserved task"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:00:50.000Z" objectID="{39D5DBA8-7CEC-4C9F-9DB3-16ACF77572D5}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Indent subtree]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:59:56.000Z" objectID="{39D5DBA8-7CEC-4C9F-9DB3-16ACF77572D5}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="156.6621246337891" height="40.28315734863281"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:59:56.000Z" objectID="{39D5DBA8-7CEC-4C9F-9DB3-16ACF77572D5}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Anchor]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:59:56.000Z" collapsed="1" objectID="{39D5DBA8-7CEC-4C9F-9DB3-16ACF77572D5}{45}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[<span
3style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a
4href="https://example.invalid/outline"><span lang=x-none>Link label</span></a>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:50.000Z" objectID="{39D5DBA8-7CEC-4C9F-9DB3-16ACF77572D5}{54}{B0}" alignment="left" quickStyleIndex="0"><one:T style="font-family:Calibri;font-size:11.0pt"><![CDATA[<span
5style='font-style:italic'>Child</span>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:50.000Z" objectID="{39D5DBA8-7CEC-4C9F-9DB3-16ACF77572D5}{60}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Grandchild]]></one:T></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:50.000Z" objectID="{39D5DBA8-7CEC-4C9F-9DB3-16ACF77572D5}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Trailing sibling]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:59:56.000Z" objectID="{39D5DBA8-7CEC-4C9F-9DB3-16ACF77572D5}{70}{B0}"><one:Position x="480.0" y="108.0" z="1"/><one:Size width="125.2148513793945" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:59:56.000Z" objectID="{39D5DBA8-7CEC-4C9F-9DB3-16ACF77572D5}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Local Other outline]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-001.xml created+20
...@@ -0,0 +1,20 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{0744D652-3AF6-48EE-8B7D-B831FF3979F0}{1}{B0}" name="Bold 🦀 italic é color 東京" dateTime="2026-09-05T05:06:54.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Rich author" lastModifiedBy="Rich author" lastModifiedTime="2026-09-08T04:47:24.000Z" objectID="{60887EEE-7183-4B58-848F-34EA00A59C91}{10}{B0}"><one:Position x="36.0" y="14.40000057220459" z="0"/><one:Size width="127.5136947631836" height="42.90299224853515"/><one:OEChildren><one:OE creationTime="2026-09-08T04:47:24.000Z" lastModifiedTime="2026-09-08T04:47:24.000Z" objectID="{67EDD619-82FE-0052-34DB-F7E0CBCEEE67}{1}{B0}" alignment="left"><one:T><![CDATA[<span style='font-weight:bold;
3text-decoration:underline;font-family:Calibri;font-size:11.0pt'>Bold</span><span
4style='font-family:Calibri;font-size:11.0pt'> </span><span style='font-family:
5Arial;font-size:13.5pt'>&#129408;</span><span style='font-family:Calibri;
6font-size:11.0pt'> </span><span style='font-style:italic;text-decoration:line-through;
7font-family:Calibri;font-size:11.0pt'>italic</span><span style='font-family:
8Calibri;font-size:11.0pt'> </span><span style='vertical-align:super;font-family:
9Calibri;font-size:11.0pt'>é</span><span style='font-family:Calibri;font-size:
1011.0pt'> </span><span style='font-family:Calibri;font-size:11.0pt;color:#0C2238;
11background:yellow;mso-highlight:yellow'>color</span><span style='font-family:
12Calibri;font-size:11.0pt'> </span><span style='vertical-align:sub;font-family:
13Calibri;font-size:11.0pt'>東京</span><span style='font-family:Calibri;font-size:
1411.0pt'><br>
15End</span>]]></one:T></one:OE><one:OE author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" creationTime="2026-09-05T05:06:55.000Z" lastModifiedTime="2026-09-05T05:06:56.000Z" objectID="{60887EEE-7183-4B58-848F-34EA00A59C91}{11}{B0}" alignment="left" quickStyleIndex="0" style="font-size:11.0pt;color:#1F4E79"><one:T><![CDATA[<span
16style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span
17style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span
18style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span
19style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed'
20lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-002.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{1664E1FA-4C74-4388-9FD7-8272AE0727C6}{1}{B0}" name="Move subtree up" dateTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:49.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Preserved task"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:00:50.000Z" objectID="{5C1BEE3C-842A-416E-ABAA-237BBA02104C}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Move subtree up]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:59:55.000Z" objectID="{5C1BEE3C-842A-416E-ABAA-237BBA02104C}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="129.6621246337891" height="40.28315734863281"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:59:55.000Z" collapsed="1" objectID="{5C1BEE3C-842A-416E-ABAA-237BBA02104C}{45}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[<span
3style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a
4href="https://example.invalid/outline"><span lang=x-none>Link label</span></a>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:48.000Z" objectID="{5C1BEE3C-842A-416E-ABAA-237BBA02104C}{54}{B0}" alignment="left" quickStyleIndex="0"><one:T style="font-family:Calibri;font-size:11.0pt"><![CDATA[<span
5style='font-style:italic'>Child</span>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:48.000Z" objectID="{5C1BEE3C-842A-416E-ABAA-237BBA02104C}{60}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Grandchild]]></one:T></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:48.000Z" objectID="{5C1BEE3C-842A-416E-ABAA-237BBA02104C}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Anchor]]></one:T></one:OE><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:48.000Z" objectID="{5C1BEE3C-842A-416E-ABAA-237BBA02104C}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Trailing sibling]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:59:56.000Z" objectID="{5C1BEE3C-842A-416E-ABAA-237BBA02104C}{70}{B0}"><one:Position x="480.0" y="108.0" z="1"/><one:Size width="125.2148513793945" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:59:56.000Z" objectID="{5C1BEE3C-842A-416E-ABAA-237BBA02104C}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Local Other outline]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-003.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{556308F3-300E-4F77-A2EE-76E9673A7313}{1}{B0}" name="Delete leaf" dateTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:03:01.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:00.000Z" objectID="{BFCB874A-B9DF-4811-96EB-8B88C2CCC688}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete leaf]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:03:01.000Z" objectID="{BFCB874A-B9DF-4811-96EB-8B88C2CCC688}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="103.8432159423828" height="26.85543441772461"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:00.000Z" objectID="{BFCB874A-B9DF-4811-96EB-8B88C2CCC688}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Anchor]]></one:T></one:OE><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:00.000Z" objectID="{BFCB874A-B9DF-4811-96EB-8B88C2CCC688}{54}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Trailing sibling]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:03:00.000Z" objectID="{BFCB874A-B9DF-4811-96EB-8B88C2CCC688}{59}{B0}"><one:Position x="480.0" y="108.0" z="1"/><one:Size width="99.85792541503906" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:00.000Z" objectID="{BFCB874A-B9DF-4811-96EB-8B88C2CCC688}{60}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Other outline]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-004.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{757C97E6-1D26-48F1-BFF3-EC99FA37D952}{1}{B0}" name="Move outline" dateTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Preserved task"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:00:51.000Z" objectID="{25777E3D-8815-4651-B17C-3B82D4CF5919}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Move outline]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T19:00:00.000Z" objectID="{25777E3D-8815-4651-B17C-3B82D4CF5919}{70}{B0}"><one:Position x="480.0" y="108.0" z="1"/><one:Size width="125.2148513793945" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T19:00:00.000Z" objectID="{25777E3D-8815-4651-B17C-3B82D4CF5919}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Local Other outline]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T19:00:00.000Z" objectID="{25777E3D-8815-4651-B17C-3B82D4CF5919}{39}{B0}"><one:Position x="252.0" y="288.0" z="0"/><one:Size width="142.6107788085937" height="67.13858032226562"/><one:OEChildren><one:OE creationTime="2026-09-08T18:03:10.000Z" lastModifiedTime="2026-09-08T18:03:10.000Z" objectID="{25777E3D-8815-4651-B17C-3B82D4CF5919}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Anchor]]></one:T></one:OE><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:10.000Z" objectID="{25777E3D-8815-4651-B17C-3B82D4CF5919}{45}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[<span
3style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a
4href="https://example.invalid/outline"><span lang=x-none>Link label</span></a>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:03:10.000Z" lastModifiedTime="2026-09-08T18:03:10.000Z" objectID="{25777E3D-8815-4651-B17C-3B82D4CF5919}{54}{B0}" alignment="left" quickStyleIndex="0"><one:T style="font-family:Calibri;font-size:11.0pt"><![CDATA[<span
5style='font-style:italic'>Child</span>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:03:10.000Z" lastModifiedTime="2026-09-08T18:03:10.000Z" objectID="{25777E3D-8815-4651-B17C-3B82D4CF5919}{60}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Grandchild]]></one:T></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE><one:OE creationTime="2026-09-08T18:03:10.000Z" lastModifiedTime="2026-09-08T18:03:10.000Z" objectID="{25777E3D-8815-4651-B17C-3B82D4CF5919}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Trailing sibling]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-005.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{90DFE1C9-55CE-4675-A824-9BF9215DA9DF}{1}{B0}" name="Automatic outline size" dateTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Preserved task"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:00:51.000Z" objectID="{AD6AB6BD-EF99-422E-9881-12C15E408405}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Automatic outline size]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T19:00:01.000Z" objectID="{AD6AB6BD-EF99-422E-9881-12C15E408405}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="252.0" height="67.13858032226562" isSetByUser="true"/><one:OEChildren><one:OE creationTime="2026-09-08T18:03:15.000Z" lastModifiedTime="2026-09-08T18:03:15.000Z" objectID="{AD6AB6BD-EF99-422E-9881-12C15E408405}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Anchor]]></one:T></one:OE><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:15.000Z" objectID="{AD6AB6BD-EF99-422E-9881-12C15E408405}{45}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[<span
3style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a
4href="https://example.invalid/outline"><span lang=x-none>Link label</span></a>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:03:15.000Z" lastModifiedTime="2026-09-08T18:03:15.000Z" objectID="{AD6AB6BD-EF99-422E-9881-12C15E408405}{54}{B0}" alignment="left" quickStyleIndex="0"><one:T style="font-family:Calibri;font-size:11.0pt"><![CDATA[<span
5style='font-style:italic'>Child</span>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:03:15.000Z" lastModifiedTime="2026-09-08T18:03:15.000Z" objectID="{AD6AB6BD-EF99-422E-9881-12C15E408405}{60}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Grandchild]]></one:T></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE><one:OE creationTime="2026-09-08T18:03:15.000Z" lastModifiedTime="2026-09-08T18:03:15.000Z" objectID="{AD6AB6BD-EF99-422E-9881-12C15E408405}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Trailing sibling]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T19:00:01.000Z" objectID="{AD6AB6BD-EF99-422E-9881-12C15E408405}{70}{B0}"><one:Position x="480.0" y="108.0" z="1"/><one:Size width="125.2148513793945" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T19:00:01.000Z" objectID="{AD6AB6BD-EF99-422E-9881-12C15E408405}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Local Other outline]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-006.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9CD45DB5-7673-44B2-B6C4-AEF326F1857A}{1}{B0}" name="Collapse subtree" dateTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Preserved task"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:00:50.000Z" objectID="{10292077-5C1F-4F55-B6A3-39F7BF3B071D}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Collapse subtree]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:02:56.000Z" objectID="{10292077-5C1F-4F55-B6A3-39F7BF3B071D}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="129.6621246337891" height="40.28315734863281"/><one:OEChildren><one:OE creationTime="2026-09-08T18:02:56.000Z" lastModifiedTime="2026-09-08T18:02:56.000Z" objectID="{10292077-5C1F-4F55-B6A3-39F7BF3B071D}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Anchor]]></one:T></one:OE><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:56.000Z" collapsed="1" objectID="{10292077-5C1F-4F55-B6A3-39F7BF3B071D}{45}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[<span
3style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a
4href="https://example.invalid/outline"><span lang=x-none>Link label</span></a>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:02:56.000Z" lastModifiedTime="2026-09-08T18:02:56.000Z" objectID="{10292077-5C1F-4F55-B6A3-39F7BF3B071D}{54}{B0}" alignment="left" quickStyleIndex="0"><one:T style="font-family:Calibri;font-size:11.0pt"><![CDATA[<span
5style='font-style:italic'>Child</span>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:02:56.000Z" lastModifiedTime="2026-09-08T18:02:56.000Z" objectID="{10292077-5C1F-4F55-B6A3-39F7BF3B071D}{60}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Grandchild]]></one:T></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE><one:OE creationTime="2026-09-08T18:02:56.000Z" lastModifiedTime="2026-09-08T18:02:56.000Z" objectID="{10292077-5C1F-4F55-B6A3-39F7BF3B071D}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Trailing sibling]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:59:57.000Z" objectID="{10292077-5C1F-4F55-B6A3-39F7BF3B071D}{70}{B0}"><one:Position x="480.0" y="108.0" z="1"/><one:Size width="125.2148513793945" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:59:57.000Z" objectID="{10292077-5C1F-4F55-B6A3-39F7BF3B071D}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Local Other outline]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-007.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{AE1EEC87-0718-4B41-B4AA-05324BA46B60}{1}{B0}" name="Resize outline" dateTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Preserved task"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:00:51.000Z" objectID="{67DF8C5F-A7BC-41B3-87D8-16FD3BD8C622}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Resize outline]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T19:00:01.000Z" objectID="{67DF8C5F-A7BC-41B3-87D8-16FD3BD8C622}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="252.0" height="67.13858032226562" isSetByUser="true"/><one:OEChildren><one:OE creationTime="2026-09-08T18:03:12.000Z" lastModifiedTime="2026-09-08T18:03:12.000Z" objectID="{67DF8C5F-A7BC-41B3-87D8-16FD3BD8C622}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Anchor]]></one:T></one:OE><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:12.000Z" objectID="{67DF8C5F-A7BC-41B3-87D8-16FD3BD8C622}{45}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[<span
3style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a
4href="https://example.invalid/outline"><span lang=x-none>Link label</span></a>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:03:12.000Z" lastModifiedTime="2026-09-08T18:03:12.000Z" objectID="{67DF8C5F-A7BC-41B3-87D8-16FD3BD8C622}{54}{B0}" alignment="left" quickStyleIndex="0"><one:T style="font-family:Calibri;font-size:11.0pt"><![CDATA[<span
5style='font-style:italic'>Child</span>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:03:12.000Z" lastModifiedTime="2026-09-08T18:03:12.000Z" objectID="{67DF8C5F-A7BC-41B3-87D8-16FD3BD8C622}{60}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Grandchild]]></one:T></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE><one:OE creationTime="2026-09-08T18:03:12.000Z" lastModifiedTime="2026-09-08T18:03:12.000Z" objectID="{67DF8C5F-A7BC-41B3-87D8-16FD3BD8C622}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Trailing sibling]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T19:00:01.000Z" objectID="{67DF8C5F-A7BC-41B3-87D8-16FD3BD8C622}{70}{B0}"><one:Position x="480.0" y="108.0" z="1"/><one:Size width="125.2148513793945" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T19:00:01.000Z" objectID="{67DF8C5F-A7BC-41B3-87D8-16FD3BD8C622}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Local Other outline]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-008.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{C797D304-0290-4EB8-BF50-9FDAC575AACE}{1}{B0}" name="Delete only paragraph" dateTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:05.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:04.000Z" objectID="{AB343592-1A63-43B9-A728-7CA63A28AF10}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete only paragraph]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:03:04.000Z" objectID="{AB343592-1A63-43B9-A728-7CA63A28AF10}{49}{B0}"><one:Position x="480.0" y="108.0" z="0"/><one:Size width="99.85792541503906" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:04.000Z" objectID="{AB343592-1A63-43B9-A728-7CA63A28AF10}{50}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Other outline]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-009.xml created+4
...@@ -0,0 +1,4 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D47A9B9E-CB6C-4CC3-91E1-0738EE31D224}{1}{B0}" name="Move leaf down" dateTime="2026-09-08T18:00:47.000Z" lastModifiedTime="2026-09-08T18:02:45.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Preserved task"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:49.000Z" lastModifiedTime="2026-09-08T18:00:49.000Z" objectID="{60602890-0439-4683-9E2F-52AABDC38972}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Move leaf down]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:59:54.000Z" objectID="{60602890-0439-4683-9E2F-52AABDC38972}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="129.6621246337891" height="40.28315734863281"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:49.000Z" lastModifiedTime="2026-09-08T18:00:49.000Z" objectID="{60602890-0439-4683-9E2F-52AABDC38972}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Anchor]]></one:T></one:OE><one:OE creationTime="2026-09-08T18:00:49.000Z" lastModifiedTime="2026-09-08T18:00:49.000Z" objectID="{60602890-0439-4683-9E2F-52AABDC38972}{54}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Trailing sibling]]></one:T></one:OE><one:OE creationTime="2026-09-08T18:00:48.000Z" lastModifiedTime="2026-09-08T18:59:54.000Z" objectID="{60602890-0439-4683-9E2F-52AABDC38972}{45}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[<span
3style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a
4href="https://example.invalid/outline"><span lang=x-none>Link label</span></a>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:59:54.000Z" objectID="{60602890-0439-4683-9E2F-52AABDC38972}{59}{B0}"><one:Position x="480.0" y="108.0" z="1"/><one:Size width="125.2148513793945" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:49.000Z" lastModifiedTime="2026-09-08T18:59:54.000Z" objectID="{60602890-0439-4683-9E2F-52AABDC38972}{60}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Local Other outline]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-010.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E0D8E09A-1402-4B2C-9953-BBEE50944107}{1}{B0}" name="Outdent subtree" dateTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:52.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Preserved task"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:00:50.000Z" objectID="{79D162AD-9D81-45E0-8BE4-DFD6C24D62AE}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Outdent subtree]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:59:57.000Z" objectID="{79D162AD-9D81-45E0-8BE4-DFD6C24D62AE}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="129.6621246337891" height="40.28315734863281"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:52.000Z" objectID="{79D162AD-9D81-45E0-8BE4-DFD6C24D62AE}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Anchor]]></one:T></one:OE><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:59:57.000Z" collapsed="1" objectID="{79D162AD-9D81-45E0-8BE4-DFD6C24D62AE}{45}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[<span
3style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a
4href="https://example.invalid/outline"><span lang=x-none>Link label</span></a>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:52.000Z" objectID="{79D162AD-9D81-45E0-8BE4-DFD6C24D62AE}{54}{B0}" alignment="left" quickStyleIndex="0"><one:T style="font-family:Calibri;font-size:11.0pt"><![CDATA[<span
5style='font-style:italic'>Child</span>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:52.000Z" objectID="{79D162AD-9D81-45E0-8BE4-DFD6C24D62AE}{60}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Grandchild]]></one:T></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:52.000Z" objectID="{79D162AD-9D81-45E0-8BE4-DFD6C24D62AE}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Trailing sibling]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:59:57.000Z" objectID="{79D162AD-9D81-45E0-8BE4-DFD6C24D62AE}{70}{B0}"><one:Position x="480.0" y="108.0" z="1"/><one:Size width="125.2148513793945" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:59:57.000Z" objectID="{79D162AD-9D81-45E0-8BE4-DFD6C24D62AE}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Local Other outline]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-011.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E3B4EDE5-F34E-486A-BE50-127F78510E6B}{1}{B0}" name="Delete outline" dateTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:00:51.000Z" objectID="{1B25852D-8FD6-4688-9344-129DE7ECF5D0}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete outline]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:00:51.000Z" objectID="{1B25852D-8FD6-4688-9344-129DE7ECF5D0}{70}{B0}"><one:Position x="480.0" y="108.0" z="0"/><one:Size width="99.85792541503906" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:00:51.000Z" objectID="{1B25852D-8FD6-4688-9344-129DE7ECF5D0}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Other outline]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-012.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E657DC42-A2C5-407B-A5CC-8267ED221062}{1}{B0}" name="Expand subtree" dateTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T19:02:11.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Preserved task"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:00:50.000Z" objectID="{D2413F98-36DB-40BF-B90B-2FA90015DECE}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Expand subtree]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:02:59.000Z" objectID="{D2413F98-36DB-40BF-B90B-2FA90015DECE}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="129.6621246337891" height="67.13858032226562"/><one:OEChildren><one:OE creationTime="2026-09-08T18:02:59.000Z" lastModifiedTime="2026-09-08T18:02:59.000Z" objectID="{D2413F98-36DB-40BF-B90B-2FA90015DECE}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Anchor]]></one:T></one:OE><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:59.000Z" objectID="{D2413F98-36DB-40BF-B90B-2FA90015DECE}{45}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[<span
3style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a
4href="https://example.invalid/outline"><span lang=x-none>Link label</span></a>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:02:59.000Z" lastModifiedTime="2026-09-08T18:02:59.000Z" objectID="{D2413F98-36DB-40BF-B90B-2FA90015DECE}{54}{B0}" alignment="left" quickStyleIndex="0"><one:T style="font-family:Calibri;font-size:11.0pt"><![CDATA[<span
5style='font-style:italic'>Child</span>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:02:59.000Z" lastModifiedTime="2026-09-08T18:02:59.000Z" objectID="{D2413F98-36DB-40BF-B90B-2FA90015DECE}{60}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Grandchild]]></one:T></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE><one:OE creationTime="2026-09-08T18:02:59.000Z" lastModifiedTime="2026-09-08T18:02:59.000Z" objectID="{D2413F98-36DB-40BF-B90B-2FA90015DECE}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Trailing sibling]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:59:58.000Z" objectID="{D2413F98-36DB-40BF-B90B-2FA90015DECE}{70}{B0}"><one:Position x="480.0" y="108.0" z="1"/><one:Size width="125.2148513793945" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:59:58.000Z" objectID="{D2413F98-36DB-40BF-B90B-2FA90015DECE}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Local Other outline]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-013.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E8E63029-A6A7-4D09-B6F5-14F64FEAA1DC}{1}{B0}" name="Delete subtree" dateTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:03.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:00:51.000Z" objectID="{10C49503-B4FB-4ABA-B6BE-6CD0FC7BC408}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete subtree]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:03:03.000Z" objectID="{10C49503-B4FB-4ABA-B6BE-6CD0FC7BC408}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="103.8432159423828" height="26.85543441772461"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:02.000Z" objectID="{10C49503-B4FB-4ABA-B6BE-6CD0FC7BC408}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Anchor]]></one:T></one:OE><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:02.000Z" objectID="{10C49503-B4FB-4ABA-B6BE-6CD0FC7BC408}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Trailing sibling]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:03:02.000Z" objectID="{10C49503-B4FB-4ABA-B6BE-6CD0FC7BC408}{70}{B0}"><one:Position x="480.0" y="108.0" z="1"/><one:Size width="99.85792541503906" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:51.000Z" lastModifiedTime="2026-09-08T18:03:02.000Z" objectID="{10C49503-B4FB-4ABA-B6BE-6CD0FC7BC408}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Other outline]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/page-014.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{F8DC3F6C-6C5B-46E0-A8C3-5E4448362ACB}{1}{B0}" name="Move subtree down" dateTime="2026-09-08T18:00:49.000Z" lastModifiedTime="2026-09-08T18:02:47.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Preserved task"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:00:50.000Z" objectID="{84C6ECE4-CB20-4096-A842-0685806B8964}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Move subtree down]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:59:55.000Z" objectID="{84C6ECE4-CB20-4096-A842-0685806B8964}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="129.6621246337891" height="40.28315734863281"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:49.000Z" lastModifiedTime="2026-09-08T18:02:46.000Z" objectID="{84C6ECE4-CB20-4096-A842-0685806B8964}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Anchor]]></one:T></one:OE><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:46.000Z" objectID="{84C6ECE4-CB20-4096-A842-0685806B8964}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Trailing sibling]]></one:T></one:OE><one:OE creationTime="2026-09-08T18:00:49.000Z" lastModifiedTime="2026-09-08T18:59:55.000Z" collapsed="1" objectID="{84C6ECE4-CB20-4096-A842-0685806B8964}{45}{B0}" alignment="left" quickStyleIndex="0"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[<span
3style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a
4href="https://example.invalid/outline"><span lang=x-none>Link label</span></a>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:46.000Z" objectID="{84C6ECE4-CB20-4096-A842-0685806B8964}{54}{B0}" alignment="left" quickStyleIndex="0"><one:T style="font-family:Calibri;font-size:11.0pt"><![CDATA[<span
5style='font-style:italic'>Child</span>]]></one:T><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:02:46.000Z" objectID="{84C6ECE4-CB20-4096-A842-0685806B8964}{60}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Grandchild]]></one:T></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE></one:OEChildren></one:Outline><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T18:59:55.000Z" objectID="{84C6ECE4-CB20-4096-A842-0685806B8964}{70}{B0}"><one:Position x="480.0" y="108.0" z="1"/><one:Size width="125.2148513793945" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-08T18:00:50.000Z" lastModifiedTime="2026-09-08T18:59:55.000Z" objectID="{84C6ECE4-CB20-4096-A842-0685806B8964}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Local Other outline]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/outline-edit/offline/cold/read/payloads.json created+1
...@@ -0,0 +1 @@
1../../../../paragraph-edit/before/read/payloads.json
\ No newline at end of file
corpus/outline-edit/offline/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/Users/clo/dev/one/evidence/m10/offline-outline-candidate-01",
3 "expected_pages": 15,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/outline-edit/offline/cold/scripts/cold.ps1 created+1
...@@ -0,0 +1 @@
1../../../../paragraph-edit/reconciliation/keyboard/scripts/cold.ps1
\ No newline at end of file
corpus/outline-edit/offline/cold/scripts/read.ps1 created+1
...@@ -0,0 +1 @@
1../../../../paragraph-edit/reconciliation/keyboard/scripts/read.ps1
\ No newline at end of file
corpus/outline-edit/offline/cold/source.json created+8
...@@ -0,0 +1,8 @@
1[
2 {
3 "path": "synthetic.one",
4 "bytes": 149936,
5 "sha256": "608532b4ef9be3cbeac3c0ec9b7e624a96aa4ce0ce6c0f86837933703e61fd0c",
6 "mtime_ns": 1788894002037909008
7 }
8]
corpus/outline-edit/offline/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
crates/onestore-offline/README.md+15-6
...@@ -30,8 +30,8 @@ intent identities. `Insertion::with_formatting` queues text and...@@ -30,8 +30,8 @@ intent identities. `Insertion::with_formatting` queues text and
30its styles as one intent and one publication. Keep that value across retries; its `text_object()` identifies30its styles as one intent and one publication. Keep that value across retries; its `text_object()` identifies
31the new text for subsequent offline edits. Pending entries expose31the new text for subsequent offline edits. Pending entries expose
32`Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`,32`Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`,
33`Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)` and33`Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)`,
34`Operation::Join(JoinEdit)` through their34`Operation::Join(JoinEdit)` and `Operation::Outline(OutlineEdit)` through their
35`operation` field. Synchronization applies these in queue order, so an inserted35`operation` field. Synchronization applies these in queue order, so an inserted
36outline can precede its paragraphs and their later edits. Missing anchors or36outline can precede its paragraphs and their later edits. Missing anchors or
37existing insertion identities preserve a conflict and the complete local image.37existing insertion identities preserve a conflict and the complete local image.
...@@ -88,8 +88,18 @@ requested value is accepted. Enabling superscript or subscript also checks the...@@ -88,8 +88,18 @@ requested value is accepted. Enabling superscript or subscript also checks the
88opposite attribute that the operation clears. If the remote image already satisfies88opposite attribute that the operation clears. If the remote image already satisfies
89the whole operation, guarded confirmation still precedes a durable receipt.89the whole operation, guarded confirmation still precedes a durable receipt.
9090
91Recognized earlier caches migrate transactionally to version six, which adds91`outline` accepts a target object and the core `onestore::OutlineEdit`: outline
92paragraph split/join intents and structural conflicts. The migration retains92position/width or a paragraph's saved collapse default. It retains the target's
93ancestry and the properties the operation changes. Independent content, formatting
94and layout properties merge; competing selected values produce `LayoutChanged`,
95and changed ancestry produces `StructureChanged`. Width changes also check the
96reserved wrapping width that they clear. Missing targets preserve the local branch.
97`rebase_layout_conflict(id, local, remote)` explicitly reviews the original change
98against both current images, preserving dependent intents. An uncertain layout
99attempt requires its original revision; converged values cannot establish its receipt.
100
101Recognized earlier caches migrate transactionally to version seven, which adds
102layout intents and layout conflicts. The migration retains
93images, local IDs, publication attempts, conflicts,103images, local IDs, publication attempts, conflicts,
94receipts and the autoincrement sequence; it does not reuse acknowledged IDs when104receipts and the autoincrement sequence; it does not reuse acknowledged IDs when
95the pending queue is empty.105the pending queue is empty.
...@@ -258,5 +268,4 @@ identity return `AssetChanged` and preserve the previous download....@@ -258,5 +268,4 @@ identity return `AssetChanged` and preserve the previous download.
258268
259Downloads do not change pending edits, publication attempts or receipts. Recovery269Downloads do not change pending edits, publication attempts or receipts. Recovery
260archives include cached media and expose the same bounded `cached_asset` lookup.270archives include cached media and expose the same bounded `cached_asset` lookup.
261Opening older live caches migrates them transactionally to schema 5; original271Schema-4 archives remain readable without migration and contain no media cache.
262schema-4 archives remain readable without migration and contain no media cache.
crates/onestore-offline/examples/cache_probe.rs+4-2
...@@ -138,8 +138,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -138,8 +138,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
138 font_size = Some(*value);138 font_size = Some(*value);
139 *value as u64 - 6139 *value as u64 - 6
140 }140 }
141 onestore_offline::Operation::Split(_) | onestore_offline::Operation::Join(_) => {141 onestore_offline::Operation::Split(_)
142 panic!("This fixture does not generate paragraph boundary edits")142 | onestore_offline::Operation::Join(_)
143 | onestore_offline::Operation::Outline(_) => {
144 panic!("Unexpected operation for this fixture")
143 }145 }
144 };146 };
145 assert!(operations.last().is_none_or(|last| *last < operation));147 assert!(operations.last().is_none_or(|last| *last < operation));
crates/onestore-offline/src/lib.rs+51-1
...@@ -10,11 +10,13 @@ use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Durati...@@ -10,11 +10,13 @@ use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Durati
1010
11mod assets;11mod assets;
12mod formatting;12mod formatting;
13mod outline;
13mod paragraph;14mod paragraph;
14mod rebase;15mod rebase;
15mod recovery;16mod recovery;
16mod schema;17mod schema;
17pub use formatting::FormatEdit;18pub use formatting::FormatEdit;
19pub use outline::OutlineEdit;
18pub use paragraph::{JoinEdit, SplitEdit};20pub use paragraph::{JoinEdit, SplitEdit};
19pub use recovery::{Recovery, RecoverySummary};21pub use recovery::{Recovery, RecoverySummary};
20mod sync;22mod sync;
...@@ -47,7 +49,7 @@ pub enum Error {...@@ -47,7 +49,7 @@ pub enum Error {
47type Result<T> = std::result::Result<T, Error>;49type Result<T> = std::result::Result<T, Error>;
4850
49const APPLICATION_ID: u32 = 0x4f4e454f;51const APPLICATION_ID: u32 = 0x4f4e454f;
50const SCHEMA_VERSION: u32 = 6;52const SCHEMA_VERSION: u32 = 7;
5153
52/// Text and its observed precondition, retained across cache reopen and rebasing.54/// Text and its observed precondition, retained across cache reopen and rebasing.
53#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]55#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
...@@ -67,6 +69,7 @@ pub enum Operation {...@@ -67,6 +69,7 @@ pub enum Operation {
67 Format(FormatEdit),69 Format(FormatEdit),
68 Split(SplitEdit),70 Split(SplitEdit),
69 Join(JoinEdit),71 Join(JoinEdit),
72 Outline(OutlineEdit),
70}73}
7174
72/// A locally acknowledged intent; its ID remains stable across cache reopen.75/// A locally acknowledged intent; its ID remains stable across cache reopen.
...@@ -279,6 +282,53 @@ impl Replica {...@@ -279,6 +282,53 @@ impl Replica {
279 }282 }
280}283}
281284
285fn active_paths(
286 view: &onestore::document::Revision<'_>,
287 pages: &[ExGuid],
288 objects: &[ExGuid],
289) -> Option<Vec<Vec<ExGuid>>> {
290 use std::collections::{BTreeMap, BTreeSet};
291 let mut pending = pages.to_vec();
292 let mut seen = BTreeSet::new();
293 let mut parents = BTreeMap::<_, Vec<_>>::new();
294 while let Some(id) = pending.pop() {
295 if !seen.insert(id) {
296 continue;
297 }
298 let node = view.nodes.get(&id)?;
299 for child in node
300 .children
301 .iter()
302 .chain(&node.content)
303 .chain(&node.structure)
304 {
305 parents.entry(*child).or_default().push(id);
306 pending.push(*child);
307 }
308 }
309 objects
310 .iter()
311 .map(|object| {
312 if !seen.contains(object) {
313 return None;
314 }
315 let mut path = Vec::new();
316 let mut at = *object;
317 while !pages.contains(&at) {
318 let [parent] = parents.get(&at)?.as_slice() else {
319 return None;
320 };
321 if path.len() >= view.nodes.len() {
322 return None;
323 }
324 path.push(*parent);
325 at = *parent;
326 }
327 Some(path)
328 })
329 .collect()
330}
331
282fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result<Option<String>> {332fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result<Option<String>> {
283 let store = Store::parse(source)?;333 let store = Store::parse(source)?;
284 let index = RevisionIndex::parse(&store)?;334 let index = RevisionIndex::parse(&store)?;
crates/onestore-offline/src/outline.rs created+149
...@@ -0,0 +1,149 @@
1use super::*;
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6#[serde(deny_unknown_fields)]
7struct Observed {
8 path: Vec<ExGuid>,
9 values: Vec<Value>,
10}
11
12/// A layout or saved expansion intent with its original ancestry and property values.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct OutlineEdit {
16 pub object: ExGuid,
17 pub change: onestore::OutlineEdit,
18 observed: Observed,
19}
20
21fn observe(
22 source: &[u8],
23 space: ExGuid,
24 object: ExGuid,
25 change: onestore::OutlineEdit,
26) -> Result<Option<Observed>> {
27 let store = Store::parse(source)?;
28 let index = RevisionIndex::parse(&store)?;
29 let document = Document::parse(&index)?;
30 let Some(space_view) = document.spaces.get(&space) else {
31 return Ok(None);
32 };
33 let Some(view) = space_view
34 .contexts
35 .get(&ExGuid::default())
36 .and_then(|rid| space_view.revisions.get(rid))
37 else {
38 return Ok(None);
39 };
40 let pages: Vec<_> = document
41 .pages()?
42 .into_iter()
43 .filter_map(|(sid, page)| (sid == space).then_some(page))
44 .collect();
45 if pages.len() != 1 {
46 return Ok(None);
47 }
48 let Some(mut paths) = active_paths(view, &pages, &[object]) else {
49 return Ok(None);
50 };
51 let path = paths.pop().unwrap();
52 let node = &view.nodes[&object];
53 let values = match (change, &node.kind) {
54 (onestore::OutlineEdit::Position { .. }, Kind::Outline { .. }) => {
55 vec![json!(node.layout.x), json!(node.layout.y)]
56 }
57 (onestore::OutlineEdit::Width { .. }, Kind::Outline { .. }) => {
58 vec![
59 json!(node.layout.max_width),
60 json!(node.layout.width_set_by_user.unwrap_or(false)),
61 json!(node.extra[0].iter().find(|field| field.id == 0x14001cdb)),
62 ]
63 }
64 (onestore::OutlineEdit::Collapsed(_), Kind::Paragraph { collapse_state, .. }) => {
65 vec![json!(collapse_state.unwrap_or(0))]
66 }
67 _ => return Ok(None),
68 };
69 Ok(Some(Observed { path, values }))
70}
71
72impl Replica {
73 /// Durably records layout or saved expansion changes, preserving independent remote edits.
74 pub fn outline(
75 &self,
76 source: &[u8],
77 space: ExGuid,
78 object: ExGuid,
79 change: onestore::OutlineEdit,
80 ) -> Result<Option<u64>> {
81 let (edit, prepared) = OutlineEdit::capture(source, space, object, change)?;
82 self.record(source, space, Operation::Outline(edit), &prepared)
83 }
84}
85
86impl OutlineEdit {
87 pub(crate) fn capture<'a>(
88 source: &'a [u8],
89 space: ExGuid,
90 object: ExGuid,
91 change: onestore::OutlineEdit,
92 ) -> Result<(Self, PreparedEdit<'a>)> {
93 let prepared = PreparedEdit::outline(source, space, object, change)?;
94 let observed = observe(source, space, object, change)?.ok_or_else(|| {
95 io::Error::new(
96 io::ErrorKind::InvalidData,
97 "Prepared outline edit has no active target",
98 )
99 })?;
100 Ok((
101 Self {
102 object,
103 change,
104 observed,
105 },
106 prepared,
107 ))
108 }
109
110 pub(crate) fn prepare<'a>(
111 &self,
112 snapshot: &'a [u8],
113 space: ExGuid,
114 ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> {
115 let Some(current) = observe(snapshot, space, self.object, self.change)? else {
116 return Ok(Err(ConflictKind::TargetUnavailable));
117 };
118 if current.path != self.observed.path {
119 return Ok(Err(ConflictKind::StructureChanged));
120 }
121 let prepared = match PreparedEdit::outline(snapshot, space, self.object, self.change) {
122 Ok(prepared) => prepared,
123 Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)),
124 };
125 let wanted =
126 observe(prepared.as_bytes(), space, self.object, self.change)?.ok_or_else(|| {
127 io::Error::new(
128 io::ErrorKind::InvalidData,
129 "Prepared outline edit lost its target",
130 )
131 })?;
132 if self.observed.values.len() != current.values.len()
133 || wanted.values.len() != current.values.len()
134 {
135 return Ok(Err(ConflictKind::UnsupportedEdit));
136 }
137 if self
138 .observed
139 .values
140 .iter()
141 .zip(&current.values)
142 .zip(&wanted.values)
143 .any(|((before, current), wanted)| current != before && current != wanted)
144 {
145 return Ok(Err(ConflictKind::LayoutChanged));
146 }
147 Ok(Ok(prepared))
148 }
149}
crates/onestore-offline/src/paragraph.rs+6-36
...@@ -2,7 +2,6 @@ use super::*;...@@ -2,7 +2,6 @@ use super::*;
2use onestore::{ParagraphJoin, ParagraphSplit};2use onestore::{ParagraphJoin, ParagraphSplit};
3use serde::{Deserialize, Serialize};3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};4use serde_json::{Value, json};
5use std::collections::{BTreeMap, BTreeSet};
65
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8#[serde(deny_unknown_fields)]7#[serde(deny_unknown_fields)]
...@@ -41,49 +40,20 @@ fn observe(source: &[u8], space: ExGuid, texts: &[ExGuid]) -> Result<Option<Vec<...@@ -41,49 +40,20 @@ fn observe(source: &[u8], space: ExGuid, texts: &[ExGuid]) -> Result<Option<Vec<
41 else {40 else {
42 return Ok(None);41 return Ok(None);
43 };42 };
44 let mut pending: Vec<_> = document43 let pages: Vec<_> = document
45 .pages()?44 .pages()?
46 .into_iter()45 .into_iter()
47 .filter_map(|(sid, page)| (sid == space).then_some(page))46 .filter_map(|(sid, page)| (sid == space).then_some(page))
48 .collect();47 .collect();
49 let pages = pending.clone();48 let Some(paths) = active_paths(view, &pages, texts) else {
50 let mut seen = BTreeSet::new();49 return Ok(None);
51 let mut parents = BTreeMap::<_, Vec<_>>::new();50 };
52 while let Some(id) = pending.pop() {
53 if !seen.insert(id) {
54 continue;
55 }
56 let node = &view.nodes[&id];
57 for child in node
58 .children
59 .iter()
60 .chain(&node.content)
61 .chain(&node.structure)
62 {
63 parents.entry(*child).or_default().push(id);
64 pending.push(*child);
65 }
66 }
67 let mut observed = Vec::new();51 let mut observed = Vec::new();
68 for text in texts {52 for (text, path) in texts.iter().zip(paths) {
69 let Some(node) = view.nodes.get(text).filter(|_| seen.contains(text)) else {53 let node = &view.nodes[text];
70 return Ok(None);
71 };
72 let Kind::RichText { text: content, .. } = &node.kind else {54 let Kind::RichText { text: content, .. } = &node.kind else {
73 return Ok(None);55 return Ok(None);
74 };56 };
75 let mut path = Vec::new();
76 let mut at = *text;
77 while !pages.contains(&at) {
78 let Some([parent]) = parents.get(&at).map(Vec::as_slice) else {
79 return Ok(None);
80 };
81 if path.len() >= view.nodes.len() {
82 return Ok(None);
83 }
84 path.push(*parent);
85 at = *parent;
86 }
87 let Some(paragraph) = path.first().and_then(|id| view.nodes.get(id)) else {57 let Some(paragraph) = path.first().and_then(|id| view.nodes.get(id)) else {
88 return Ok(None);58 return Ok(None);
89 };59 };
crates/onestore-offline/src/schema.rs+1-1
...@@ -3,7 +3,7 @@ use rusqlite::{OptionalExtension, Transaction};...@@ -3,7 +3,7 @@ use rusqlite::{OptionalExtension, Transaction};
33
4const CONFLICTS: &str = "CREATE TABLE conflicts (4const CONFLICTS: &str = "CREATE TABLE conflicts (
5 edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE,5 edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE,
6 kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 4)6 kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 5)
7) STRICT;";7) STRICT;";
88
9const ASSETS: &str = "CREATE TABLE assets (9const ASSETS: &str = "CREATE TABLE assets (
crates/onestore-offline/src/sync.rs+27
...@@ -19,6 +19,7 @@ pub enum ConflictKind {...@@ -19,6 +19,7 @@ pub enum ConflictKind {
19 UnsupportedEdit = 2,19 UnsupportedEdit = 2,
20 FormattingChanged = 3,20 FormattingChanged = 3,
21 StructureChanged = 4,21 StructureChanged = 4,
22 LayoutChanged = 5,
22}23}
2324
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
...@@ -134,6 +135,7 @@ impl Replica {...@@ -134,6 +135,7 @@ impl Replica {
134 Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?,135 Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?,
135 Operation::Split(edit) => edit.prepare(&snapshot, intent.space)?,136 Operation::Split(edit) => edit.prepare(&snapshot, intent.space)?,
136 Operation::Join(edit) => edit.prepare(&snapshot, intent.space)?,137 Operation::Join(edit) => edit.prepare(&snapshot, intent.space)?,
138 Operation::Outline(edit) => edit.prepare(&snapshot, intent.space)?,
137 Operation::Insert(insertion) => {139 Operation::Insert(insertion) => {
138 PreparedEdit::insert(&snapshot, intent.space, insertion)140 PreparedEdit::insert(&snapshot, intent.space, insertion)
139 .map_err(|_| ConflictKind::UnsupportedEdit)141 .map_err(|_| ConflictKind::UnsupportedEdit)
...@@ -297,6 +299,13 @@ impl Replica {...@@ -297,6 +299,13 @@ impl Replica {
297 )299 )
298 .into());300 .into());
299 }301 }
302 Operation::Outline(_) => {
303 return Err(io::Error::new(
304 io::ErrorKind::InvalidInput,
305 "Review layout changes using rebase_layout_conflict",
306 )
307 .into());
308 }
300 Operation::Insert(_) => {309 Operation::Insert(_) => {
301 return Err(io::Error::new(310 return Err(io::Error::new(
302 io::ErrorKind::InvalidInput,311 io::ErrorKind::InvalidInput,
...@@ -372,6 +381,23 @@ impl Replica {...@@ -372,6 +381,23 @@ impl Replica {
372 })381 })
373 }382 }
374383
384 /// Reviews the original layout intent against both current cache images.
385 /// Competing property values are replaced only after this explicit review.
386 pub fn rebase_layout_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> {
387 self.resolve_conflict(id, local, remote, |intent| {
388 let Operation::Outline(edit) = intent.operation else {
389 return Err(io::Error::new(
390 io::ErrorKind::InvalidInput,
391 "Select an outline layout conflict",
392 )
393 .into());
394 };
395 Ok(Operation::Outline(
396 OutlineEdit::capture(remote, intent.space, edit.object, edit.change)?.0,
397 ))
398 })
399 }
400
375 fn resolve_conflict(401 fn resolve_conflict(
376 &self,402 &self,
377 id: u64,403 id: u64,
...@@ -492,6 +518,7 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat...@@ -492,6 +518,7 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat
492 2 => ConflictKind::UnsupportedEdit,518 2 => ConflictKind::UnsupportedEdit,
493 3 => ConflictKind::FormattingChanged,519 3 => ConflictKind::FormattingChanged,
494 4 => ConflictKind::StructureChanged,520 4 => ConflictKind::StructureChanged,
521 5 => ConflictKind::LayoutChanged,
495 _ => {522 _ => {
496 return Err(io::Error::new(523 return Err(io::Error::new(
497 io::ErrorKind::InvalidData,524 io::ErrorKind::InvalidData,
crates/onestore-offline/tests/cache.rs+133-92
...@@ -710,7 +710,7 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {...@@ -710,7 +710,7 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {
710710
711#[test]711#[test]
712fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {712fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {
713 for operation in ["Text", "Insert", "Format", "Split", "Join"] {713 for operation in ["Text", "Insert", "Format", "Split", "Join", "Outline"] {
714 let directory = tempfile::tempdir().unwrap();714 let directory = tempfile::tempdir().unwrap();
715 let path = directory.path().join("unknown.sqlite");715 let path = directory.path().join("unknown.sqlite");
716 let mut source = onestore::create_section("unknown.one", "Original", "Author").unwrap();716 let mut source = onestore::create_section("unknown.one", "Original", "Author").unwrap();
...@@ -731,6 +731,26 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {...@@ -731,6 +731,26 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {
731 let insertion =731 let insertion =
732 onestore::Insertion::outline(page, 144.0, 144.0, "Inserted", "Author").unwrap();732 onestore::Insertion::outline(page, 144.0, 144.0, "Inserted", "Author").unwrap();
733 cache.insert(&source, sid, &insertion).unwrap();733 cache.insert(&source, sid, &insertion).unwrap();
734 } else if operation == "Outline" {
735 let store = Store::parse(&source).unwrap();
736 let index = RevisionIndex::parse(&store).unwrap();
737 let doc = Document::parse(&index).unwrap();
738 let space = &doc.spaces[&sid];
739 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
740 let paragraph = *view
741 .nodes
742 .iter()
743 .find(|(_, node)| node.content == [oid])
744 .unwrap()
745 .0;
746 cache
747 .outline(
748 &source,
749 sid,
750 paragraph,
751 onestore::OutlineEdit::Collapsed(true),
752 )
753 .unwrap();
734 } else if operation == "Text" {754 } else if operation == "Text" {
735 cache.edit_text(&source, sid, oid, 0..0, "New ").unwrap();755 cache.edit_text(&source, sid, oid, 0..0, "New ").unwrap();
736 } else if operation == "Split" {756 } else if operation == "Split" {
...@@ -776,99 +796,120 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {...@@ -776,99 +796,120 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {
776}796}
777797
778#[test]798#[test]
779fn version_five_migration_retains_queue_evidence_and_enables_structural_conflicts() {799fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_layout_conflicts() {
780 use onestore_offline::{ConflictKind, EditStatus, Recovery};800 for (version, ceiling) in [(5, 3), (6, 4)] {
781 let directory = tempfile::tempdir().unwrap();801 use onestore_offline::{ConflictKind, EditStatus, Recovery};
782 let path = directory.path().join("version-five.sqlite");802 use sha2::{Digest, Sha256};
783 let source = onestore::create_section("migration.one", "Original", "Author").unwrap();803 let directory = tempfile::tempdir().unwrap();
784 let (sid, oid, _) = target(&source);804 let path = directory.path().join("previous.sqlite");
785 let cache = Replica::create(&path, &source).unwrap();805 let source = onestore::create_section("migration.one", "Original", "Author").unwrap();
786 let first = cache806 let (sid, oid, _) = target(&source);
787 .edit_text(&source, sid, oid, 0..0, "New ")807 let cache = Replica::create(&path, &source).unwrap();
788 .unwrap()808 let first = cache
809 .edit_text(&source, sid, oid, 0..0, "New ")
810 .unwrap()
811 .unwrap();
812 let second = cache
813 .format(
814 &cache.snapshot().unwrap(),
815 sid,
816 oid,
817 0..3,
818 &[onestore::TextAttribute::Bold(true)],
819 )
820 .unwrap()
821 .unwrap();
822 let queue = cache.pending().unwrap();
823 let local = cache.snapshot().unwrap();
824 let store = Store::parse(&local).unwrap();
825 let index = RevisionIndex::parse(&store).unwrap();
826 let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)];
827 drop(cache);
828 let db = rusqlite::Connection::open(&path).unwrap();
829 let current_version: u32 = db
830 .pragma_query_value(None, "user_version", |row| row.get(0))
831 .unwrap();
832 let asset = "00112233-4455-6677-8899-aabbccddeeff.onebin";
833 let data = b"retained media";
834 db.execute(
835 "INSERT INTO assets VALUES (?1,?2,?3)",
836 rusqlite::params![asset, data.as_slice(), Sha256::digest(data).as_slice()],
837 )
838 .unwrap();
839 db.execute_batch(&format!(
840 "DROP TABLE conflicts; CREATE TABLE conflicts (
841 edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE,
842 kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND {ceiling})) STRICT;
843 PRAGMA user_version={version};
844 UPDATE sqlite_sequence SET seq=1000 WHERE name='edits';"
845 ))
789 .unwrap();846 .unwrap();
790 let second = cache847 db.execute(
791 .format(848 "INSERT INTO conflicts VALUES (?1,3)",
792 &cache.snapshot().unwrap(),849 [i64::try_from(second).unwrap()],
793 sid,
794 oid,
795 0..3,
796 &[onestore::TextAttribute::Bold(true)],
797 )850 )
798 .unwrap()
799 .unwrap();851 .unwrap();
800 let queue = cache.pending().unwrap();852 db.execute(
801 let local = cache.snapshot().unwrap();853 "INSERT INTO attempt VALUES (1,?1,?2)",
802 let store = Store::parse(&local).unwrap();854 rusqlite::params![i64::try_from(first).unwrap(), revision.to_string()],
803 let index = RevisionIndex::parse(&store).unwrap();855 )
804 let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)];
805 drop(cache);
806 let db = rusqlite::Connection::open(&path).unwrap();
807 db.execute_batch(
808 "DROP TABLE conflicts; CREATE TABLE conflicts (
809 edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE,
810 kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 3)) STRICT;
811 PRAGMA user_version=5;
812 UPDATE sqlite_sequence SET seq=1000 WHERE name='edits';",
813 )
814 .unwrap();
815 db.execute(
816 "INSERT INTO conflicts VALUES (?1,3)",
817 [i64::try_from(second).unwrap()],
818 )
819 .unwrap();
820 db.execute(
821 "INSERT INTO attempt VALUES (1,?1,?2)",
822 rusqlite::params![i64::try_from(first).unwrap(), revision.to_string()],
823 )
824 .unwrap();
825 db.execute(
826 "INSERT INTO receipts VALUES (999,?1)",
827 [revision.to_string()],
828 )
829 .unwrap();
830 drop(db);
831 let cache = Replica::open(&path).unwrap();
832 assert_eq!(cache.pending().unwrap(), queue);
833 assert!(cache.snapshot().unwrap() == local);
834 assert!(cache.remote_snapshot().unwrap() == source);
835 assert_eq!(
836 cache.status(first).unwrap(),
837 Some(EditStatus::AwaitingConfirmation { revision })
838 );
839 assert_eq!(
840 cache.status(second).unwrap(),
841 Some(EditStatus::Conflict(ConflictKind::FormattingChanged))
842 );
843 assert_eq!(
844 cache.status(999).unwrap(),
845 Some(EditStatus::Published { revision })
846 );
847 let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap();
848 assert_eq!(cache.split(&local, sid, &split).unwrap(), Some(1001));
849 let pending = cache.pending().unwrap();
850 let archive = directory.path().join("recovery.sqlite");
851 cache.export_recovery(&archive).unwrap();
852 let recovery = Recovery::open(&archive).unwrap();
853 assert_eq!(recovery.pending().unwrap(), pending);
854 assert_eq!(
855 recovery.status(first).unwrap(),
856 cache.status(first).unwrap()
857 );
858 assert_eq!(recovery.receipts().unwrap().get(&999), Some(&revision));
859 drop(cache);
860 let db = rusqlite::Connection::open(&path).unwrap();
861 assert_eq!(
862 db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))
863 .unwrap(),
864 6
865 );
866 db.execute("INSERT INTO conflicts VALUES (1001,4)", [])
867 .unwrap();856 .unwrap();
868 drop(db);857 db.execute(
869 let cache = Replica::open(&path).unwrap();858 "INSERT INTO receipts VALUES (999,?1)",
870 assert_eq!(859 [revision.to_string()],
871 cache.status(1001).unwrap(),860 )
872 Some(EditStatus::Conflict(ConflictKind::StructureChanged))861 .unwrap();
873 );862 drop(db);
863 let cache = Replica::open(&path).unwrap();
864 assert_eq!(cache.pending().unwrap(), queue);
865 assert_eq!(
866 cache.cached_asset(asset, 1024).unwrap(),
867 Some(data.to_vec())
868 );
869 assert!(cache.snapshot().unwrap() == local);
870 assert!(cache.remote_snapshot().unwrap() == source);
871 assert_eq!(
872 cache.status(first).unwrap(),
873 Some(EditStatus::AwaitingConfirmation { revision })
874 );
875 assert_eq!(
876 cache.status(second).unwrap(),
877 Some(EditStatus::Conflict(ConflictKind::FormattingChanged))
878 );
879 assert_eq!(
880 cache.status(999).unwrap(),
881 Some(EditStatus::Published { revision })
882 );
883 let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap();
884 assert_eq!(cache.split(&local, sid, &split).unwrap(), Some(1001));
885 let pending = cache.pending().unwrap();
886 let archive = directory.path().join("recovery.sqlite");
887 cache.export_recovery(&archive).unwrap();
888 let recovery = Recovery::open(&archive).unwrap();
889 assert_eq!(recovery.pending().unwrap(), pending);
890 assert_eq!(
891 recovery.cached_asset(asset, 1024).unwrap(),
892 Some(data.to_vec())
893 );
894 assert_eq!(
895 recovery.status(first).unwrap(),
896 cache.status(first).unwrap()
897 );
898 assert_eq!(recovery.receipts().unwrap().get(&999), Some(&revision));
899 drop(cache);
900 let db = rusqlite::Connection::open(&path).unwrap();
901 assert_eq!(
902 db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))
903 .unwrap(),
904 current_version
905 );
906 db.execute("INSERT INTO conflicts VALUES (1001,5)", [])
907 .unwrap();
908 drop(db);
909 let cache = Replica::open(&path).unwrap();
910 assert_eq!(
911 cache.status(1001).unwrap(),
912 Some(EditStatus::Conflict(ConflictKind::LayoutChanged))
913 );
914 }
874}915}
crates/onestore-offline/tests/sync.rs+7-1
...@@ -5,6 +5,9 @@ use onestore::{...@@ -5,6 +5,9 @@ use onestore::{
5use onestore_offline::{ConflictKind, EditStatus, Error, Remote, Replica};5use onestore_offline::{ConflictKind, EditStatus, Error, Remote, Replica};
6use std::io;6use std::io;
77
8#[path = "sync/outline.rs"]
9mod outline;
10
8mod paragraph {11mod paragraph {
9 use super::*;12 use super::*;
10 use onestore::{Insertion, ParagraphJoin, ParagraphSplit, TextAttribute};13 use onestore::{Insertion, ParagraphJoin, ParagraphSplit, TextAttribute};
...@@ -1657,6 +1660,9 @@ fn version_one_cache_migration_preserves_images_intents_and_local_ids() {...@@ -1657,6 +1660,9 @@ fn version_one_cache_migration_preserves_images_intents_and_local_ids() {
1657 let pending = cache.pending().unwrap();1660 let pending = cache.pending().unwrap();
1658 drop(cache);1661 drop(cache);
1659 let db = rusqlite::Connection::open(&path).unwrap();1662 let db = rusqlite::Connection::open(&path).unwrap();
1663 let current_version: u32 = db
1664 .pragma_query_value(None, "user_version", |row| row.get(0))
1665 .unwrap();
1660 db.execute_batch(1666 db.execute_batch(
1661 "DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts; DROP TABLE edits; DROP TABLE assets;1667 "DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts; DROP TABLE edits; DROP TABLE assets;
1662 CREATE TABLE edits (1668 CREATE TABLE edits (
...@@ -1683,7 +1689,7 @@ fn version_one_cache_migration_preserves_images_intents_and_local_ids() {...@@ -1683,7 +1689,7 @@ fn version_one_cache_migration_preserves_images_intents_and_local_ids() {
1683 assert_eq!(1689 assert_eq!(
1684 db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))1690 db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))
1685 .unwrap(),1691 .unwrap(),
1686 61692 current_version
1687 );1693 );
1688}1694}
16891695
crates/onestore-offline/tests/sync/outline.rs created+656
...@@ -0,0 +1,656 @@
1use super::*;
2use onestore::OutlineEdit as Change;
3
4fn fixture() -> (Vec<u8>, ExGuid, ExGuid, ExGuid, ExGuid) {
5 let source = onestore::create_section("layout.one", "Original 🦀 é", "Author").unwrap();
6 let (sid, text, _) = text(&source);
7 let store = Store::parse(&source).unwrap();
8 let index = RevisionIndex::parse(&store).unwrap();
9 let document = Document::parse(&index).unwrap();
10 let space = &document.spaces[&sid];
11 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
12 let paragraph = *view
13 .nodes
14 .iter()
15 .find(|(_, node)| node.content == [text])
16 .unwrap()
17 .0;
18 let outline = *view
19 .nodes
20 .iter()
21 .find(|(_, node)| node.children.contains(&paragraph))
22 .unwrap()
23 .0;
24 (source, sid, outline, paragraph, text)
25}
26
27fn node(source: &[u8], sid: ExGuid, object: ExGuid) -> serde_json::Value {
28 let store = Store::parse(source).unwrap();
29 let index = RevisionIndex::parse(&store).unwrap();
30 index.validate_current().unwrap();
31 let document = Document::parse(&index).unwrap();
32 let space = &document.spaces[&sid];
33 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
34 serde_json::to_value(&view.nodes[&object]).unwrap()
35}
36
37#[test]
38fn layout_and_dependent_text_survive_reopen_and_independent_remote_formatting() {
39 let (source, sid, outline, paragraph, text_id) = fixture();
40 for (object, change) in [
41 (outline, Change::Position { x: 180.0, y: 216.0 }),
42 (
43 outline,
44 Change::Width {
45 points: 144.0,
46 user_set: true,
47 },
48 ),
49 (paragraph, Change::Collapsed(true)),
50 ] {
51 let directory = tempfile::tempdir().unwrap();
52 let path = directory.path().join("cache.sqlite");
53 let cache = Replica::create(&path, &source).unwrap();
54 let id = cache
55 .outline(&source, sid, object, change)
56 .unwrap()
57 .unwrap();
58 let local = cache.snapshot().unwrap();
59 let expected = node(&local, sid, object);
60 let dependent = cache
61 .edit_text(&local, sid, text_id, 0..0, "Local ")
62 .unwrap()
63 .unwrap();
64 let pending = cache.pending().unwrap();
65 let local = cache.snapshot().unwrap();
66 drop(cache);
67 let cache = Replica::open(&path).unwrap();
68 assert_eq!(cache.pending().unwrap(), pending);
69 assert!(cache.snapshot().unwrap() == local);
70 let remote = PreparedEdit::format(
71 &source,
72 sid,
73 text_id,
74 0..8,
75 &[onestore::TextAttribute::Bold(true)],
76 )
77 .unwrap();
78 let mut server = Server::new(remote.as_bytes());
79 assert!(
80 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id)
81 );
82 let actual = node(&server.durable, sid, object);
83 assert_eq!(actual["layout"], expected["layout"]);
84 assert_eq!(actual["kind"], expected["kind"]);
85 assert_eq!(cache.pending().unwrap().len(), 1);
86 drop(cache);
87 let cache = Replica::open(&path).unwrap();
88 assert!(
89 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==dependent)
90 );
91 assert_eq!(text(&server.durable).2, "Local Original 🦀 é");
92 assert!(cache.pending().unwrap().is_empty());
93 let store = Store::parse(&server.durable).unwrap();
94 let index = RevisionIndex::parse(&store).unwrap();
95 let document = Document::parse(&index).unwrap();
96 let space = &document.spaces[&sid];
97 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
98 assert!(view.text_runs(text_id).unwrap()[0].format.bold.unwrap());
99 assert_eq!(server.publications, 2);
100 drop(cache);
101 let cache = Replica::open(&path).unwrap();
102 for id in [id, dependent] {
103 assert!(matches!(
104 cache.status(id).unwrap(),
105 Some(EditStatus::Published { .. })
106 ));
107 }
108 }
109}
110
111#[test]
112fn competing_layout_requires_current_review_and_preserves_dependent_edits() {
113 let (source, sid, outline, _, text_id) = fixture();
114 for (local_change, remote_change) in [
115 (
116 Change::Position { x: 180.0, y: 216.0 },
117 Change::Position { x: 288.0, y: 216.0 },
118 ),
119 (
120 Change::Width {
121 points: 144.0,
122 user_set: true,
123 },
124 Change::Width {
125 points: 216.0,
126 user_set: false,
127 },
128 ),
129 ] {
130 let directory = tempfile::tempdir().unwrap();
131 let path = directory.path().join("cache.sqlite");
132 let cache = Replica::create(&path, &source).unwrap();
133 let id = cache
134 .outline(&source, sid, outline, local_change)
135 .unwrap()
136 .unwrap();
137 let dependent = cache
138 .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ")
139 .unwrap()
140 .unwrap();
141 let local = cache.snapshot().unwrap();
142 let queue = cache.pending().unwrap();
143 let remote = PreparedEdit::outline(&source, sid, outline, remote_change).unwrap();
144 let mut server = Server::new(remote.as_bytes());
145 assert_eq!(
146 cache.sync_once(&mut server).unwrap(),
147 Some((id, EditStatus::Conflict(ConflictKind::LayoutChanged)))
148 );
149 assert_eq!(server.publications, 0);
150 assert_eq!(cache.pending().unwrap(), queue);
151 assert!(cache.snapshot().unwrap() == local);
152 assert!(
153 cache
154 .rebase_layout_conflict(id, &source, &server.visible)
155 .is_err()
156 );
157 assert!(cache.rebase_layout_conflict(id, &local, &source).is_err());
158 assert!(
159 cache
160 .rebase_conflict(id, &local, &server.visible, 0..0)
161 .is_err()
162 );
163 drop(cache);
164 let cache = Replica::open(&path).unwrap();
165 assert_eq!(
166 cache.status(id).unwrap(),
167 Some(EditStatus::Conflict(ConflictKind::LayoutChanged))
168 );
169 cache
170 .rebase_layout_conflict(id, &local, &server.visible)
171 .unwrap();
172 let reviewed = cache.pending().unwrap();
173 assert_eq!(reviewed[1], queue[1]);
174 assert!(cache.snapshot().unwrap() == local);
175 drop(cache);
176 let cache = Replica::open(&path).unwrap();
177 assert_eq!(cache.pending().unwrap(), reviewed);
178 for id in [id, dependent] {
179 assert!(
180 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
181 );
182 }
183 assert_eq!(
184 node(&server.durable, sid, outline)["layout"],
185 node(&local, sid, outline)["layout"]
186 );
187 assert_eq!(text(&server.durable).2, "Local Original 🦀 é");
188 }
189}
190
191#[test]
192fn independent_axes_converge_without_overwriting_competing_values() {
193 let (source, sid, outline, _, _) = fixture();
194 let directory = tempfile::tempdir().unwrap();
195 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
196 let original = node(&source, sid, outline);
197 let local = Change::Position { x: 180.0, y: 216.0 };
198 let id = cache
199 .outline(&source, sid, outline, local)
200 .unwrap()
201 .unwrap();
202 let remote = PreparedEdit::outline(
203 &source,
204 sid,
205 outline,
206 Change::Position {
207 x: original["layout"]["x"].as_f64().unwrap() as f32,
208 y: 216.0,
209 },
210 )
211 .unwrap();
212 let mut server = Server::new(remote.as_bytes());
213 assert!(
214 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id)
215 );
216 let actual = node(&server.durable, sid, outline);
217 assert_eq!(actual["layout"]["x"], 180.0);
218 assert_eq!(actual["layout"]["y"], 216.0);
219}
220
221#[test]
222fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restarts() {
223 let (source, sid, outline, paragraph, _) = fixture();
224 let directory = tempfile::tempdir().unwrap();
225 std::thread::scope(|scope| {
226 let mut writers = Vec::new();
227 for actor in 0..12 {
228 let source = &source;
229 let path = directory.path().join(format!("{actor}.sqlite"));
230 writers.push(scope.spawn(move || {
231 let cache = Replica::create(&path, source).unwrap();
232 for round in 0..4 {
233 for (object, change) in [
234 (
235 outline,
236 Change::Position {
237 x: (actor + 2) as f32 * 36.0,
238 y: (round + 2) as f32 * 36.0,
239 },
240 ),
241 (
242 outline,
243 Change::Width {
244 points: (actor + round + 2) as f32 * 36.0,
245 user_set: true,
246 },
247 ),
248 (paragraph, Change::Collapsed((actor + round) % 2 != 0)),
249 ] {
250 assert!(
251 cache
252 .outline(&cache.snapshot().unwrap(), sid, object, change)
253 .unwrap()
254 .is_some()
255 );
256 }
257 }
258 assert_eq!(cache.pending().unwrap().len(), 12);
259 }));
260 }
261 for writer in writers {
262 writer.join().unwrap();
263 }
264 });
265 let mut server = Server::new(&source);
266 let mut expected_layout = node(&source, sid, outline)["layout"].clone();
267 let mut expected_collapse = None;
268 let mut reviewed = 0;
269 for round in 0..12 {
270 for actor in 0..12 {
271 let path = directory.path().join(format!("{actor}.sqlite"));
272 let cache = Replica::open(&path).unwrap();
273 let first = cache.pending().unwrap()[0].clone();
274 let onestore_offline::Operation::Outline(edit) = first.operation else {
275 panic!()
276 };
277 let local = cache.snapshot().unwrap();
278 let result = cache.sync_once(&mut server).unwrap();
279 if result == Some((first.id, EditStatus::Conflict(ConflictKind::LayoutChanged))) {
280 let remote = cache.remote_snapshot().unwrap();
281 cache
282 .rebase_layout_conflict(first.id, &local, &remote)
283 .unwrap();
284 drop(cache);
285 let cache = Replica::open(&path).unwrap();
286 assert!(
287 matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == first.id)
288 );
289 reviewed += 1;
290 } else {
291 assert!(
292 matches!(result, Some((id, EditStatus::Published { .. })) if id == first.id)
293 );
294 }
295 match edit.change {
296 Change::Position { x, y } => {
297 expected_layout["x"] = x.into();
298 expected_layout["y"] = y.into();
299 }
300 Change::Width { points, user_set } => {
301 expected_layout["max_width"] = points.into();
302 expected_layout["width_set_by_user"] = user_set.into();
303 }
304 Change::Collapsed(value) => expected_collapse = Some(u8::from(value)),
305 }
306 assert_eq!(
307 node(&server.durable, sid, outline)["layout"],
308 expected_layout
309 );
310 assert_eq!(
311 node(&server.durable, sid, paragraph)["kind"]["collapse_state"],
312 serde_json::json!(expected_collapse)
313 );
314 assert_eq!(text(&server.durable).2, "Original 🦀 é");
315 }
316 for actor in 0..12 {
317 let cache = Replica::open(directory.path().join(format!("{actor}.sqlite"))).unwrap();
318 assert_eq!(cache.pending().unwrap().len(), 11 - round);
319 }
320 }
321 assert!(reviewed >= 12);
322 for actor in 0..12 {
323 let cache = Replica::open(directory.path().join(format!("{actor}.sqlite"))).unwrap();
324 assert!(cache.pending().unwrap().is_empty());
325 for id in 1..=12 {
326 assert!(matches!(
327 cache.status(id).unwrap(),
328 Some(EditStatus::Published { .. })
329 ));
330 }
331 }
332}
333
334#[test]
335fn uncertain_layout_is_confirmed_by_revision_and_never_by_converged_values() {
336 let (source, sid, outline, _, text_id) = fixture();
337 for fault in [Fault::UnknownBefore, Fault::UnknownAfter] {
338 let before = matches!(fault, Fault::UnknownBefore);
339 let directory = tempfile::tempdir().unwrap();
340 let path = directory.path().join("cache.sqlite");
341 let cache = Replica::create(&path, &source).unwrap();
342 let change = Change::Width {
343 points: 144.0,
344 user_set: true,
345 };
346 let id = cache
347 .outline(&source, sid, outline, change)
348 .unwrap()
349 .unwrap();
350 cache
351 .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ")
352 .unwrap();
353 let local = cache.snapshot().unwrap();
354 let mut server = Server::new(&source);
355 server.fault = fault;
356 assert!(cache.sync_once(&mut server).is_err());
357 let status = cache.status(id).unwrap().unwrap();
358 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));
359 drop(cache);
360 let cache = Replica::open(&path).unwrap();
361 if before {
362 let converged = PreparedEdit::outline(&source, sid, outline, change).unwrap();
363 server.visible = converged.as_bytes().to_vec();
364 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status)));
365 assert!(
366 cache
367 .rebase_layout_conflict(id, &local, &server.visible)
368 .is_err()
369 );
370 assert_eq!(cache.pending().unwrap().len(), 2);
371 assert!(cache.snapshot().unwrap() == local);
372 let archive = directory.path().join("recovery.sqlite");
373 cache.export_recovery(&archive).unwrap();
374 let recovery = onestore_offline::Recovery::open(&archive).unwrap();
375 assert_eq!(recovery.pending().unwrap(), cache.pending().unwrap());
376 assert_eq!(recovery.status(id).unwrap(), Some(status));
377 assert_eq!(server.confirmations, 0);
378 } else {
379 assert!(
380 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id)
381 );
382 assert_eq!(
383 node(&server.durable, sid, outline)["layout"]["max_width"],
384 144.0
385 );
386 assert_eq!(server.confirmations, 1);
387 }
388 assert_eq!(server.publications, 1);
389 }
390}
391
392#[test]
393fn native_moves_deletions_and_layout_changes_merge_or_retain_explicit_conflicts() {
394 let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one");
395 let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one");
396 let store = Store::parse(source).unwrap();
397 let index = RevisionIndex::parse(&store).unwrap();
398 let document = Document::parse(&index).unwrap();
399 let mut server = Server::new(native);
400 let mut counts = [0; 3];
401 let mut records = Vec::new();
402 for (sid, page) in document.pages().unwrap() {
403 let space = &document.spaces[&sid];
404 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
405 let Kind::Metadata {
406 title: Some(name), ..
407 } = &view.nodes[&view.roots[&2]].kind
408 else {
409 continue;
410 };
411 if name == "Unicode rich text" {
412 continue;
413 }
414 let outlines: Vec<_> = view.nodes[&page]
415 .children
416 .iter()
417 .filter(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
418 .copied()
419 .collect();
420 if outlines.len() != 2 {
421 continue;
422 }
423 let outline = outlines[0];
424 let mut pending = vec![outline];
425 let mut target = None;
426 while let Some(id) = pending.pop() {
427 let node = &view.nodes[&id];
428 pending.extend(&node.children);
429 if node.content.first().is_some_and(|text| matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text.starts_with("Target "))) {
430 target = Some(id);
431 }
432 }
433 let target = target.unwrap();
434 let text_id = view.nodes[&view.nodes[&outlines[1]].children[0]].content[0];
435 let (object, change, conflict) = match name.as_str() {
436 "Move outline" => (
437 outline,
438 Change::Position { x: 252.0, y: 288.0 },
439 Some(ConflictKind::LayoutChanged),
440 ),
441 "Resize outline" | "Automatic outline size" => (
442 outline,
443 Change::Width {
444 points: 252.0,
445 user_set: true,
446 },
447 Some(ConflictKind::LayoutChanged),
448 ),
449 "Delete outline" => (
450 outline,
451 Change::Width {
452 points: 252.0,
453 user_set: true,
454 },
455 Some(ConflictKind::TargetUnavailable),
456 ),
457 "Delete leaf" | "Delete subtree" | "Delete only paragraph" => (
458 target,
459 Change::Collapsed(true),
460 Some(ConflictKind::TargetUnavailable),
461 ),
462 "Indent subtree" | "Outdent subtree" => (
463 target,
464 Change::Collapsed(true),
465 Some(ConflictKind::StructureChanged),
466 ),
467 "Expand subtree" => (target, Change::Collapsed(false), None),
468 "Collapse subtree" | "Move leaf down" | "Move subtree down" | "Move subtree up" => {
469 (target, Change::Collapsed(true), None)
470 }
471 _ => panic!("Unexpected native case {name}"),
472 };
473 let expected_layout = if conflict == Some(ConflictKind::TargetUnavailable) {
474 None
475 } else {
476 let mut layout = node(&server.visible, sid, object)["layout"].clone();
477 match change {
478 Change::Position { x, y } => {
479 layout["x"] = x.into();
480 layout["y"] = y.into();
481 }
482 Change::Width { points, user_set } => {
483 layout["max_width"] = points.into();
484 layout["width_set_by_user"] = user_set.into();
485 }
486 Change::Collapsed(_) => {}
487 }
488 Some(layout)
489 };
490 let directory = tempfile::tempdir().unwrap();
491 let path = directory.path().join("cache.sqlite");
492 let cache = Replica::create(&path, source).unwrap();
493 let id = cache.outline(source, sid, object, change).unwrap().unwrap();
494 let dependent = cache
495 .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ")
496 .unwrap()
497 .unwrap();
498 let local = cache.snapshot().unwrap();
499 let queue = cache.pending().unwrap();
500 let result = cache.sync_once(&mut server).unwrap().unwrap();
501 let mut retained = false;
502 if let Some(kind) = conflict {
503 assert_eq!(result, (id, EditStatus::Conflict(kind)), "{name}");
504 assert_eq!(cache.pending().unwrap(), queue);
505 assert!(cache.snapshot().unwrap() == local);
506 drop(cache);
507 let cache = Replica::open(&path).unwrap();
508 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Conflict(kind)));
509 if kind == ConflictKind::TargetUnavailable {
510 assert!(
511 cache
512 .rebase_layout_conflict(id, &local, &server.visible)
513 .is_err()
514 );
515 counts[2] += 1;
516 retained = true;
517 } else {
518 cache
519 .rebase_layout_conflict(id, &local, &server.visible)
520 .unwrap();
521 drop(cache);
522 let cache = Replica::open(&path).unwrap();
523 assert!(
524 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
525 );
526 counts[1] += 1;
527 }
528 } else {
529 assert!(
530 matches!(result, (n, EditStatus::Published { .. }) if n == id),
531 "{name}"
532 );
533 counts[0] += 1;
534 drop(cache);
535 }
536 let cache = Replica::open(&path).unwrap();
537 if retained {
538 assert_eq!(cache.pending().unwrap(), queue);
539 assert!(cache.snapshot().unwrap() == local);
540 } else {
541 assert!(
542 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == dependent)
543 );
544 assert_eq!(
545 node(&server.durable, sid, object)["layout"],
546 expected_layout.unwrap(),
547 "{name}"
548 );
549 if matches!(change, Change::Collapsed(_)) {
550 assert_eq!(
551 node(&server.durable, sid, object)["kind"]["collapse_state"],
552 node(&local, sid, object)["kind"]["collapse_state"]
553 );
554 }
555 assert!(
556 node(&server.durable, sid, text_id)["kind"]["text"]
557 .as_str()
558 .unwrap()
559 .starts_with("Local ")
560 );
561 drop(cache);
562 let cache = Replica::open(&path).unwrap();
563 for id in [id, dependent] {
564 assert!(matches!(
565 cache.status(id).unwrap(),
566 Some(EditStatus::Published { .. })
567 ));
568 }
569 }
570 records.push(serde_json::json!({"name": name, "space": sid, "object": object, "change": change, "retained": retained, "dependent_text": text_id}));
571 }
572 assert_eq!(counts, [5, 5, 4]);
573 assert_eq!(server.publications, 18);
574 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_OUTLINE_OUTPUT") {
575 let output = std::path::PathBuf::from(output);
576 assert!(output.is_absolute());
577 std::fs::create_dir(&output).unwrap();
578 std::fs::write(output.join("synthetic.one"), &server.durable).unwrap();
579 std::fs::write(
580 output.with_extension("json"),
581 serde_json::to_vec_pretty(&records).unwrap(),
582 )
583 .unwrap();
584 }
585}
586
587#[test]
588fn a_new_native_wrap_reservation_requires_review_before_width_replacement() {
589 let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one");
590 let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one");
591 let store = Store::parse(source).unwrap();
592 let index = RevisionIndex::parse(&store).unwrap();
593 let document = Document::parse(&index).unwrap();
594 let (sid, page) = document.pages().unwrap().into_iter().find(|(sid, _)| {
595 let space = &document.spaces[sid];
596 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
597 matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title: Some(name), .. } if name=="Move outline")
598 }).unwrap();
599 let space = &document.spaces[&sid];
600 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
601 let outline = *view.nodes[&page]
602 .children
603 .iter()
604 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
605 .unwrap();
606 let old = node(source, sid, outline);
607 let remote = node(native, sid, outline);
608 assert_eq!(old["layout"]["max_width"], remote["layout"]["max_width"]);
609 assert_eq!(
610 old["layout"]["width_set_by_user"],
611 remote["layout"]["width_set_by_user"]
612 );
613 assert!(
614 remote["extra"][0]
615 .as_array()
616 .unwrap()
617 .iter()
618 .any(|field| field["id"] == 0x14001cdb_u32)
619 );
620 let directory = tempfile::tempdir().unwrap();
621 let cache = Replica::create(directory.path().join("cache.sqlite"), source).unwrap();
622 let id = cache
623 .outline(
624 source,
625 sid,
626 outline,
627 Change::Width {
628 points: 144.0,
629 user_set: true,
630 },
631 )
632 .unwrap()
633 .unwrap();
634 let mut server = Server::new(native);
635 assert_eq!(
636 cache.sync_once(&mut server).unwrap(),
637 Some((id, EditStatus::Conflict(ConflictKind::LayoutChanged)))
638 );
639 cache
640 .rebase_layout_conflict(id, &cache.snapshot().unwrap(), &server.visible)
641 .unwrap();
642 assert!(
643 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id)
644 );
645 let after = node(&server.durable, sid, outline);
646 assert_eq!(after["layout"]["x"], remote["layout"]["x"]);
647 assert_eq!(after["layout"]["y"], remote["layout"]["y"]);
648 assert_eq!(after["layout"]["max_width"], 144.0);
649 assert!(
650 !after["extra"][0]
651 .as_array()
652 .unwrap()
653 .iter()
654 .any(|field| field["id"] == 0x14001cdb_u32)
655 );
656}
fuzz/fuzz_targets/offline_paragraph.rs+73-3
...@@ -1,10 +1,11 @@...@@ -1,10 +1,11 @@
1#![no_main]1#![no_main]
2use libfuzzer_sys::fuzz_target;2use libfuzzer_sys::fuzz_target;
3use onestore::{3use onestore::{
4 CommitError, ExGuid, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex, Store,4 CommitError, ExGuid, OutlineEdit, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex,
5 Store,
5 document::{Document, Kind},6 document::{Document, Kind},
6};7};
7use onestore_offline::{EditStatus, Remote, Replica};8use onestore_offline::{EditStatus, Operation, Remote, Replica};
8use std::{io, sync::LazyLock};9use std::{io, sync::LazyLock};
910
10#[path = "../../crates/onestore/tests/support/disk.rs"]11#[path = "../../crates/onestore/tests/support/disk.rs"]
...@@ -94,7 +95,7 @@ fuzz_target!(|input: &[u8]| {...@@ -94,7 +95,7 @@ fuzz_target!(|input: &[u8]| {
94 let snapshot = cache.snapshot().unwrap();95 let snapshot = cache.snapshot().unwrap();
95 let pending = cache.pending().unwrap();96 let pending = cache.pending().unwrap();
96 let rows = paragraphs(&snapshot, *sid, *outline);97 let rows = paragraphs(&snapshot, *sid, *outline);
97 match step[1] % 5 {98 match step[1] % 8 {
98 0 => {99 0 => {
99 let (text, content) = &rows[usize::from(step[2]) % rows.len()];100 let (text, content) = &rows[usize::from(step[2]) % rows.len()];
100 let offsets: Vec<u32> = std::iter::once(0)101 let offsets: Vec<u32> = std::iter::once(0)
...@@ -147,7 +148,76 @@ fuzz_target!(|input: &[u8]| {...@@ -147,7 +148,76 @@ fuzz_target!(|input: &[u8]| {
147 }148 }
148 server.0.visible.clone_from(&server.0.durable);149 server.0.visible.clone_from(&server.0.durable);
149 }150 }
151 5..=7 => {
152 let store = Store::parse(&snapshot).unwrap();
153 let index = RevisionIndex::parse(&store).unwrap();
154 let document = Document::parse(&index).unwrap();
155 let space = &document.spaces[sid];
156 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
157 let (object, change) = match step[1] % 8 {
158 5 => (
159 *outline,
160 OutlineEdit::Position {
161 x: f32::from(step[2]) * 18.0,
162 y: f32::from(step[3]) * 18.0,
163 },
164 ),
165 6 => (
166 *outline,
167 OutlineEdit::Width {
168 points: (2.0 + f32::from(step[2])) * 18.0,
169 user_set: step[3] & 1 != 0,
170 },
171 ),
172 _ => (
173 view.nodes[outline].children[usize::from(step[2]) % rows.len()],
174 OutlineEdit::Collapsed(step[3] & 1 != 0),
175 ),
176 };
177 let mut expected = serde_json::to_value(&view.nodes[&object]).unwrap();
178 match change {
179 OutlineEdit::Position { x, y } => {
180 expected["layout"]["x"] = x.into();
181 expected["layout"]["y"] = y.into();
182 }
183 OutlineEdit::Width { points, user_set } => {
184 expected["layout"]["max_width"] = points.into();
185 expected["layout"]["width_set_by_user"] = user_set.into();
186 }
187 OutlineEdit::Collapsed(value) => {
188 expected["kind"]["collapse_state"] = u8::from(value).into()
189 }
190 }
191 let id = cache.outline(&snapshot, *sid, object, change).unwrap();
192 let next = cache.pending().unwrap();
193 assert_eq!(next[..pending.len()], pending);
194 assert_eq!(next.len(), pending.len() + usize::from(id.is_some()));
195 if let Some(id) = id {
196 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
197 }
198 let bytes = cache.snapshot().unwrap();
199 let store = Store::parse(&bytes).unwrap();
200 let index = RevisionIndex::parse(&store).unwrap();
201 let document = Document::parse(&index).unwrap();
202 let space = &document.spaces[sid];
203 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
204 let actual = serde_json::to_value(&view.nodes[&object]).unwrap();
205 for key in ["kind", "layout", "children", "content"] {
206 assert_eq!(actual[key], expected[key]);
207 }
208 }
150 _ => {209 _ => {
210 if let Some(edit) = pending.first()
211 && matches!(edit.operation, Operation::Outline(_))
212 && matches!(
213 cache.status(edit.id).unwrap(),
214 Some(EditStatus::Conflict(_))
215 )
216 {
217 let remote = cache.remote_snapshot().unwrap();
218 let _ = cache.rebase_layout_conflict(edit.id, &snapshot, &remote);
219 }
220 let pending = cache.pending().unwrap();
151 drop(replicas[actor].take());221 drop(replicas[actor].take());
152 let reopened = Replica::open(&path).unwrap();222 let reopened = Replica::open(&path).unwrap();
153 assert!(reopened.snapshot().unwrap() == snapshot);223 assert!(reopened.snapshot().unwrap() == snapshot);
tools/test_outline_edit.py+44-1
...@@ -37,7 +37,7 @@ class OutlineEditTest(unittest.TestCase):...@@ -37,7 +37,7 @@ class OutlineEditTest(unittest.TestCase):
37 self.assertEqual(page, saved_page)37 self.assertEqual(page, saved_page)
38 for oid, node in walk(old, page):38 for oid, node in walk(old, page):
39 actual = new['nodes'][oid]39 actual = new['nodes'][oid]
40 for key in ('children', 'content', 'structure', 'child_level'):40 for key in ('children', 'content', 'structure', 'child_level', 'layout'):
41 self.assertEqual(actual[key], node[key], (name, oid, key))41 self.assertEqual(actual[key], node[key], (name, oid, key))
42 self.assertEqual(actual['kind'].get('collapse_state'), node['kind'].get('collapse_state'))42 self.assertEqual(actual['kind'].get('collapse_state'), node['kind'].get('collapse_state'))
43 if node['kind']['type'] == 'RichText':43 if node['kind']['type'] == 'RichText':
...@@ -86,6 +86,49 @@ class OutlineEditTest(unittest.TestCase):...@@ -86,6 +86,49 @@ class OutlineEditTest(unittest.TestCase):
86 self.assertEqual(size.get('isSetByUser'), 'true')86 self.assertEqual(size.get('isSetByUser'), 'true')
87 self.assertAlmostEqual(float(size.get('width')), 144, places=3)87 self.assertAlmostEqual(float(size.get('width')), 144, places=3)
8888
89 def test_offline_native_reconciliation_preserves_graph_and_dependent_edits(self):
90 models, _ = self.cold_layout(FIXTURE / 'offline')
91 records = json.loads((FIXTURE / 'offline/cases.json').read_text())
92 self.assertEqual(len(records), 14)
93 self.assertEqual(sum(row['retained'] for row in records), 4)
94 records = {row['name']: row for row in records}
95 with TemporaryDirectory() as temporary:
96 folder = Path(temporary)
97 subprocess.run([EXPORTER, FIXTURE / 'after/notebook/synthetic.one', folder / 'model'], check=True)
98 model = json.loads((folder / 'model/document.json').read_text())
99 remote = {r['nodes'][r['roots']['2']]['kind']['title']: (r, page)
100 for _, _, r, page in ordered_pages(model)}
101 changed = texts = 0
102 for name, (candidate, page) in models['candidate'].items():
103 original, remote_page = remote[name]
104 self.assertEqual(page, remote_page)
105 record = records.get(name)
106 for oid, node in walk(candidate, page):
107 before = original['nodes'][oid]
108 for key in ('children', 'content', 'structure', 'child_level'):
109 self.assertEqual(node[key], before[key], (name, oid, key))
110 layout = dict(before['layout'])
111 if record and not record['retained'] and oid == record['object']:
112 change = record['change']
113 if 'Position' in change:
114 layout.update(change['Position'])
115 elif 'Width' in change:
116 layout['max_width'] = change['Width']['points']
117 layout['width_set_by_user'] = change['Width']['user_set']
118 else:
119 self.assertEqual(node['kind']['collapse_state'], int(change['Collapsed']))
120 changed += 1
121 else:
122 self.assertEqual(node['kind'].get('collapse_state'), before['kind'].get('collapse_state'))
123 self.assertEqual(node['layout'], layout)
124 if node['kind']['type'] == 'RichText':
125 expected = before['kind']['text']
126 if record and not record['retained'] and oid == record['dependent_text']:
127 expected = 'Local ' + expected
128 texts += 1
129 self.assertEqual(node['kind']['text'], expected)
130 self.assertEqual((changed, texts), (10, 10))
131
89 def preserved_node(self, old, new):132 def preserved_node(self, old, new):
90 expected = dict(old)133 expected = dict(old)
91 if old['modified'] != new['modified']:134 if old['modified'] != new['modified']: