| author | |
| committer | |
| log | d1ad62573d835ddc1f3273975bfe9c4b1a554510 |
| tree | 23930309b6d7250426a8bdf76a142d89b365b72d |
| parent | 423f7adf1aef0ed15f708ddac37865616105bbf3 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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-astra42 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 |
| 60 | To regenerate, run `resizing_a_native_reserved_width_preserves_content` with | 60 | To regenerate, run `resizing_a_native_reserved_width_preserves_content` with |
| 61 | `ONESTORE_RESERVED_WIDTH_OUTPUT` set to a new absolute directory, then cold-open | 61 | `ONESTORE_RESERVED_WIDTH_OUTPUT` set to a new absolute directory, then cold-open |
| 62 | it with the same runner arguments. | 62 | it with the same runner arguments. |
| 63 | |||
| 64 | `offline` reconciles local layout/default intents and later text edits against all | ||
| 65 | fourteen native changes. Five merge automatically, five require explicit review, | ||
| 66 | and four deleted targets retain their local branches and dependent queues. The ten | ||
| 67 | successful cases produce eighteen publications and twenty durable receipts; the | ||
| 68 | already-converged collapse defaults require confirmation without another append. | ||
| 69 | The final cold capture preserves all 304 active graph objects, ten tags, and 8,937 | ||
| 70 | explicit formatting comparisons. The `cases.json` records stable targets and the | ||
| 71 | retained/dependent outcomes used by the public test. | ||
| 72 | |||
| 73 | Regenerate by running the offline sync test | ||
| 74 | `native_moves_deletions_and_layout_changes_merge_or_retain_explicit_conflicts` | ||
| 75 | with `ONESTORE_OFFLINE_OUTLINE_OUTPUT` set to a new absolute directory, then use | ||
| 76 | the same cold runner arguments. The test also retains a JSON record next to that | ||
| 77 | directory. Cache migration, uncertain revision retention, stale review, and twelve | ||
| 78 | offline 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 | ||
| 3 | style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a | ||
| 4 | href="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 | ||
| 5 | style='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; | ||
| 3 | text-decoration:underline;font-family:Calibri;font-size:11.0pt'>Bold</span><span | ||
| 4 | style='font-family:Calibri;font-size:11.0pt'> </span><span style='font-family: | ||
| 5 | Arial;font-size:13.5pt'>&#129408;</span><span style='font-family:Calibri; | ||
| 6 | font-size:11.0pt'> </span><span style='font-style:italic;text-decoration:line-through; | ||
| 7 | font-family:Calibri;font-size:11.0pt'>italic</span><span style='font-family: | ||
| 8 | Calibri;font-size:11.0pt'> </span><span style='vertical-align:super;font-family: | ||
| 9 | Calibri;font-size:11.0pt'>é</span><span style='font-family:Calibri;font-size: | ||
| 10 | 11.0pt'> </span><span style='font-family:Calibri;font-size:11.0pt;color:#0C2238; | ||
| 11 | background:yellow;mso-highlight:yellow'>color</span><span style='font-family: | ||
| 12 | Calibri;font-size:11.0pt'> </span><span style='vertical-align:sub;font-family: | ||
| 13 | Calibri;font-size:11.0pt'>東京</span><span style='font-family:Calibri;font-size: | ||
| 14 | 11.0pt'><br> | ||
| 15 | End</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 | ||
| 16 | style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span | ||
| 17 | style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span | ||
| 18 | style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span | ||
| 19 | style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed' | ||
| 20 | lang=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 | ||
| 3 | style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a | ||
| 4 | href="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 | ||
| 5 | style='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 | ||
| 3 | style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a | ||
| 4 | href="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 | ||
| 5 | style='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 | ||
| 3 | style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a | ||
| 4 | href="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 | ||
| 5 | style='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 | ||
| 3 | style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a | ||
| 4 | href="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 | ||
| 5 | style='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 | ||
| 3 | style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a | ||
| 4 | href="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 | ||
| 5 | style='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 | ||
| 3 | style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a | ||
| 4 | href="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 | ||
| 3 | style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a | ||
| 4 | href="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 | ||
| 5 | style='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 | ||
| 3 | style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a | ||
| 4 | href="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 | ||
| 5 | style='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 | ||
| 3 | style='font-weight:bold' lang=en-US>Target &#129408; é</span><span lang=en-US> </span><a | ||
| 4 | href="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 | ||
| 5 | style='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 |
| 30 | its styles as one intent and one publication. Keep that value across retries; its `text_object()` identifies | 30 | its styles as one intent and one publication. Keep that value across retries; its `text_object()` identifies |
| 31 | the new text for subsequent offline edits. Pending entries expose | 31 | the 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)` and | 33 | `Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)`, |
| 34 | `Operation::Join(JoinEdit)` through their | 34 | `Operation::Join(JoinEdit)` and `Operation::Outline(OutlineEdit)` through their |
| 35 | `operation` field. Synchronization applies these in queue order, so an inserted | 35 | `operation` field. Synchronization applies these in queue order, so an inserted |
| 36 | outline can precede its paragraphs and their later edits. Missing anchors or | 36 | outline can precede its paragraphs and their later edits. Missing anchors or |
| 37 | existing insertion identities preserve a conflict and the complete local image. | 37 | existing 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 |
| 88 | opposite attribute that the operation clears. If the remote image already satisfies | 88 | opposite attribute that the operation clears. If the remote image already satisfies |
| 89 | the whole operation, guarded confirmation still precedes a durable receipt. | 89 | the whole operation, guarded confirmation still precedes a durable receipt. |
| 90 | 90 | ||
| 91 | Recognized earlier caches migrate transactionally to version six, which adds | 91 | `outline` accepts a target object and the core `onestore::OutlineEdit`: outline |
| 92 | paragraph split/join intents and structural conflicts. The migration retains | 92 | position/width or a paragraph's saved collapse default. It retains the target's |
| 93 | ancestry and the properties the operation changes. Independent content, formatting | ||
| 94 | and layout properties merge; competing selected values produce `LayoutChanged`, | ||
| 95 | and changed ancestry produces `StructureChanged`. Width changes also check the | ||
| 96 | reserved wrapping width that they clear. Missing targets preserve the local branch. | ||
| 97 | `rebase_layout_conflict(id, local, remote)` explicitly reviews the original change | ||
| 98 | against both current images, preserving dependent intents. An uncertain layout | ||
| 99 | attempt requires its original revision; converged values cannot establish its receipt. | ||
| 100 | |||
| 101 | Recognized earlier caches migrate transactionally to version seven, which adds | ||
| 102 | layout intents and layout conflicts. The migration retains | ||
| 93 | images, local IDs, publication attempts, conflicts, | 103 | images, local IDs, publication attempts, conflicts, |
| 94 | receipts and the autoincrement sequence; it does not reuse acknowledged IDs when | 104 | receipts and the autoincrement sequence; it does not reuse acknowledged IDs when |
| 95 | the pending queue is empty. | 105 | the 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. |
| 258 | 268 | ||
| 259 | Downloads do not change pending edits, publication attempts or receipts. Recovery | 269 | Downloads do not change pending edits, publication attempts or receipts. Recovery |
| 260 | archives include cached media and expose the same bounded `cached_asset` lookup. | 270 | archives include cached media and expose the same bounded `cached_asset` lookup. |
| 261 | Opening older live caches migrates them transactionally to schema 5; original | 271 | Schema-4 archives remain readable without migration and contain no media cache. |
| 262 | schema-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 - 6 | 139 | *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 |
| 10 | 10 | ||
| 11 | mod assets; | 11 | mod assets; |
| 12 | mod formatting; | 12 | mod formatting; |
| 13 | mod outline; | ||
| 13 | mod paragraph; | 14 | mod paragraph; |
| 14 | mod rebase; | 15 | mod rebase; |
| 15 | mod recovery; | 16 | mod recovery; |
| 16 | mod schema; | 17 | mod schema; |
| 17 | pub use formatting::FormatEdit; | 18 | pub use formatting::FormatEdit; |
| 19 | pub use outline::OutlineEdit; | ||
| 18 | pub use paragraph::{JoinEdit, SplitEdit}; | 20 | pub use paragraph::{JoinEdit, SplitEdit}; |
| 19 | pub use recovery::{Recovery, RecoverySummary}; | 21 | pub use recovery::{Recovery, RecoverySummary}; |
| 20 | mod sync; | 22 | mod sync; |
| ... | @@ -47,7 +49,7 @@ pub enum Error { | ... | @@ -47,7 +49,7 @@ pub enum Error { |
| 47 | type Result<T> = std::result::Result<T, Error>; | 49 | type Result<T> = std::result::Result<T, Error>; |
| 48 | 50 | ||
| 49 | const APPLICATION_ID: u32 = 0x4f4e454f; | 51 | const APPLICATION_ID: u32 = 0x4f4e454f; |
| 50 | const SCHEMA_VERSION: u32 = 6; | 52 | const SCHEMA_VERSION: u32 = 7; |
| 51 | 53 | ||
| 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 | } |
| 71 | 74 | ||
| 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 | } |
| 281 | 284 | ||
| 285 | fn 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 | |||
| 282 | fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result<Option<String>> { | 332 | fn 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 @@ | ||
| 1 | use super::*; | ||
| 2 | use serde::{Deserialize, Serialize}; | ||
| 3 | use serde_json::{Value, json}; | ||
| 4 | |||
| 5 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 6 | #[serde(deny_unknown_fields)] | ||
| 7 | struct 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)] | ||
| 15 | pub struct OutlineEdit { | ||
| 16 | pub object: ExGuid, | ||
| 17 | pub change: onestore::OutlineEdit, | ||
| 18 | observed: Observed, | ||
| 19 | } | ||
| 20 | |||
| 21 | fn 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 | |||
| 72 | impl 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 | |||
| 86 | impl 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::*; |
| 2 | use onestore::{ParagraphJoin, ParagraphSplit}; | 2 | use onestore::{ParagraphJoin, ParagraphSplit}; |
| 3 | use serde::{Deserialize, Serialize}; | 3 | use serde::{Deserialize, Serialize}; |
| 4 | use serde_json::{Value, json}; | 4 | use serde_json::{Value, json}; |
| 5 | use std::collections::{BTreeMap, BTreeSet}; | ||
| 6 | 5 | ||
| 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<_> = document | 43 | 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}; |
| 3 | 3 | ||
| 4 | const CONFLICTS: &str = "CREATE TABLE conflicts ( | 4 | const 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;"; |
| 8 | 8 | ||
| 9 | const ASSETS: &str = "CREATE TABLE assets ( | 9 | const 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 | } |
| 23 | 24 | ||
| 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 | } |
| 374 | 383 | ||
| 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() { |
| 710 | 710 | ||
| 711 | #[test] | 711 | #[test] |
| 712 | fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { | 712 | fn 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 | } |
| 777 | 797 | ||
| 778 | #[test] | 798 | #[test] |
| 779 | fn version_five_migration_retains_queue_evidence_and_enables_structural_conflicts() { | 799 | fn 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 = cache | 806 | 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 = cache | 847 | 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::{ |
| 5 | use onestore_offline::{ConflictKind, EditStatus, Error, Remote, Replica}; | 5 | use onestore_offline::{ConflictKind, EditStatus, Error, Remote, Replica}; |
| 6 | use std::io; | 6 | use std::io; |
| 7 | 7 | ||
| 8 | #[path = "sync/outline.rs"] | ||
| 9 | mod outline; | ||
| 10 | |||
| 8 | mod paragraph { | 11 | mod 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 | 6 | 1692 | current_version |
| 1687 | ); | 1693 | ); |
| 1688 | } | 1694 | } |
| 1689 | 1695 |
crates/onestore-offline/tests/sync/outline.rs created+656| ... | @@ -0,0 +1,656 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::OutlineEdit as Change; | ||
| 3 | |||
| 4 | fn 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 | |||
| 27 | fn 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] | ||
| 38 | fn 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] | ||
| 112 | fn 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] | ||
| 192 | fn 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] | ||
| 222 | fn 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] | ||
| 335 | fn 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] | ||
| 393 | fn 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] | ||
| 588 | fn 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] |
| 2 | use libfuzzer_sys::fuzz_target; | 2 | use libfuzzer_sys::fuzz_target; |
| 3 | use onestore::{ | 3 | use 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 | }; |
| 7 | use onestore_offline::{EditStatus, Remote, Replica}; | 8 | use onestore_offline::{EditStatus, Operation, Remote, Replica}; |
| 8 | use std::{io, sync::LazyLock}; | 9 | use std::{io, sync::LazyLock}; |
| 9 | 10 | ||
| 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) |
| 88 | 88 | ||
| 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']: |