diff --git a/corpus/paragraph-edit/README.md b/corpus/paragraph-edit/README.md index 162562834f34293f17959d97333e96bee9d54e71..a8ed082d25ee9a53dda45247767161dabf90562a 100644 --- a/corpus/paragraph-edit/README.md +++ b/corpus/paragraph-edit/README.md @@ -60,3 +60,11 @@ XML, preserve exact child/content identities across reopening, and compare the native-rendered result to the corresponding keyboard-generated control. `export_native_paragraph_joins` regenerates candidates in a new directory specified by `ONESTORE_PARAGRAPH_JOIN_OUTPUT`. + +`offline` retains reconciled split/join candidates and cold native captures. +The cache queues edits before observing independent remote `☂` prefixes, reopens +between acknowledgements, and confirms modeled lost replies without replaying +operations. Tests compare native text/styles to the original keyboard controls, +account for the remote prefixes, and retain exact graph identities after reopening. +The ignored offline test `export_native_offline_paragraphs` regenerates candidates +in a new directory specified by `ONESTORE_OFFLINE_PARAGRAPH_OUTPUT`. diff --git a/corpus/paragraph-edit/offline/inheritance/candidate/synthetic.one b/corpus/paragraph-edit/offline/inheritance/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..e64cafc0ec2e467c73f2709679fe0acfda707323 Binary files /dev/null and b/corpus/paragraph-edit/offline/inheritance/candidate/synthetic.one differ diff --git a/corpus/paragraph-edit/offline/inheritance/manifest.json b/corpus/paragraph-edit/offline/inheritance/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..8905aa0adf6878941da6efe2ffbf7aebf840af61 --- /dev/null +++ b/corpus/paragraph-edit/offline/inheritance/manifest.json @@ -0,0 +1,62 @@ +[ + { + "case": "Join inherited styles", + "id": 1, + "intent": { + "author": "Rust join author", + "left": "{10952FC5-A849-0DE5-03A7-5D9304627DA7},44", + "right": "{10952FC5-A849-0DE5-03A7-5D9304627DA7},50" + }, + "remote_prefix": true, + "revision": "{E109048E-057D-41DC-B2A8-B9F11839B9C9},1", + "space": "{D6CEB12D-D1B7-033B-20A2-E9B6FF501602},1" + }, + { + "case": "Join empty left tag", + "id": 2, + "intent": { + "author": "Rust join author", + "left": "{8BAFC64A-8457-0253-3906-57D27F8546B7},45", + "right": "{8BAFC64A-8457-0253-3906-57D27F8546B7},51" + }, + "remote_prefix": false, + "revision": "{19A52AE8-D897-467C-956F-26CA42CEDA1B},1", + "space": "{F6E7A6F0-805F-0858-2B5A-4B1260940CF9},1" + }, + { + "case": "Join right tag", + "id": 3, + "intent": { + "author": "Rust join author", + "left": "{2E9A20A9-1E0E-0F04-0FCE-8497CF032068},44", + "right": "{2E9A20A9-1E0E-0F04-0FCE-8497CF032068},49" + }, + "remote_prefix": true, + "revision": "{BDDFFFE4-439A-4466-9D85-20B88D5A5E61},1", + "space": "{04EE80E5-584B-04E0-1F05-07D86902DFA1},1" + }, + { + "case": "Join both tags", + "id": 4, + "intent": { + "author": "Rust join author", + "left": "{7A5195E3-F792-0C49-1F3C-EBBFCE774FE6},44", + "right": "{7A5195E3-F792-0C49-1F3C-EBBFCE774FE6},50" + }, + "remote_prefix": true, + "revision": "{694A9A93-E1AA-48DD-AD67-6BD2C0EF5D6F},1", + "space": "{631A0B54-DBB1-0988-22E1-96E8B1202B59},1" + }, + { + "case": "Join both children", + "id": 5, + "intent": { + "author": "Rust join author", + "left": "{0384F5F6-5F0B-0164-32C6-6E97A4F1B661},49", + "right": "{0384F5F6-5F0B-0164-32C6-6E97A4F1B661},54" + }, + "remote_prefix": true, + "revision": "{B3100F21-58B5-4417-9797-B397AC0F7BE3},1", + "space": "{D7D982D1-A3D3-0690-1181-A9703CF717E5},1" + } +] \ No newline at end of file diff --git a/corpus/paragraph-edit/offline/inheritance/native/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/offline/inheritance/native/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..8ecc576e8f13a07ffe261f2fdbe028a37cb11351 Binary files /dev/null and b/corpus/paragraph-edit/offline/inheritance/native/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/paragraph-edit/offline/inheritance/native/notebook/synthetic.one b/corpus/paragraph-edit/offline/inheritance/native/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..8b4bece77818ba8f070380e73523c35b9ab5b19a Binary files /dev/null and b/corpus/paragraph-edit/offline/inheritance/native/notebook/synthetic.one differ diff --git a/corpus/paragraph-edit/offline/inheritance/native/read/environment.json b/corpus/paragraph-edit/offline/inheritance/native/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..9ce6d758f071c4bb49737eb8104c0ee15ce86363 --- /dev/null +++ b/corpus/paragraph-edit/offline/inheritance/native/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-6061365E", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/paragraph-edit/offline/inheritance/native/read/hierarchy.xml b/corpus/paragraph-edit/offline/inheritance/native/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..92079e1969cd7a99c75f19db52f7e085a591b431 --- /dev/null +++ b/corpus/paragraph-edit/offline/inheritance/native/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-edit/offline/inheritance/native/read/page-000.xml b/corpus/paragraph-edit/offline/inheritance/native/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..7ec799793802bacdd3ab47100a46821273f490bd --- /dev/null +++ b/corpus/paragraph-edit/offline/inheritance/native/read/page-000.xml @@ -0,0 +1,5 @@ + +☂LeftBold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/inheritance/native/read/page-001.xml b/corpus/paragraph-edit/offline/inheritance/native/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..e7da941a9f1ab49daedf6b4599443ae39a0269b6 --- /dev/null +++ b/corpus/paragraph-edit/offline/inheritance/native/read/page-001.xml @@ -0,0 +1,5 @@ + +☂LeftBold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/inheritance/native/read/page-002.xml b/corpus/paragraph-edit/offline/inheritance/native/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..9f7ebac7baae2c38efceca18ccd5d33d6fa668e9 --- /dev/null +++ b/corpus/paragraph-edit/offline/inheritance/native/read/page-002.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/offline/inheritance/native/read/page-003.xml b/corpus/paragraph-edit/offline/inheritance/native/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..b4d1f59c82406908740081c4710a489002085ac2 --- /dev/null +++ b/corpus/paragraph-edit/offline/inheritance/native/read/page-003.xml @@ -0,0 +1,5 @@ + +☂LeftRight italic]]> diff --git a/corpus/paragraph-edit/offline/inheritance/native/read/page-004.xml b/corpus/paragraph-edit/offline/inheritance/native/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..2f7e50d49be4739acae06d0e4fb7c70663d60644 --- /dev/null +++ b/corpus/paragraph-edit/offline/inheritance/native/read/page-004.xml @@ -0,0 +1,5 @@ + +☂Left childBold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/inheritance/native/read/page-005.xml b/corpus/paragraph-edit/offline/inheritance/native/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..82c73e5a1daf3735d8e9a40de6251a3f839b6a89 --- /dev/null +++ b/corpus/paragraph-edit/offline/inheritance/native/read/page-005.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/inheritance/native/read/payloads.json b/corpus/paragraph-edit/offline/inheritance/native/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..787b4bae85038cda922f8befac6c8ed361fa8035 --- /dev/null +++ b/corpus/paragraph-edit/offline/inheritance/native/read/payloads.json @@ -0,0 +1 @@ +../../../../before/read/payloads.json \ No newline at end of file diff --git a/corpus/paragraph-edit/offline/inheritance/native/run.json b/corpus/paragraph-edit/offline/inheritance/native/run.json new file mode 100644 index 0000000000000000000000000000000000000000..2fbf78ac8b624e29cbc1ef5a140dc7bb0c388002 --- /dev/null +++ b/corpus/paragraph-edit/offline/inheritance/native/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/offline-paragraph-native-01/inheritance/candidate", + "expected_pages": 6, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/paragraph-edit/offline/inheritance/native/source.json b/corpus/paragraph-edit/offline/inheritance/native/source.json new file mode 100644 index 0000000000000000000000000000000000000000..02e8e0326bf665e52ba695484dafd587f263b7c0 --- /dev/null +++ b/corpus/paragraph-edit/offline/inheritance/native/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 59016, + "sha256": "828e330b0dfda41ecd5ade77ea65398cf5a070170757b3f650ca4600520d99bb", + "mtime_ns": 1788863184799153800 + } +] diff --git a/corpus/paragraph-edit/offline/inheritance/native/teardown.json b/corpus/paragraph-edit/offline/inheritance/native/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..47a1192a30ea7da90676d631bb9a2687d829b43e --- /dev/null +++ b/corpus/paragraph-edit/offline/inheritance/native/teardown.json @@ -0,0 +1 @@ +../../../joined/teardown.json \ No newline at end of file diff --git a/corpus/paragraph-edit/offline/joins/candidate/synthetic.one b/corpus/paragraph-edit/offline/joins/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..b3fefe96ad2e3c9de97a136f760b521bcb11bda4 Binary files /dev/null and b/corpus/paragraph-edit/offline/joins/candidate/synthetic.one differ diff --git a/corpus/paragraph-edit/offline/joins/manifest.json b/corpus/paragraph-edit/offline/joins/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..9931ce4a575276ec439d92ff937a49dfcf9b79f1 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/manifest.json @@ -0,0 +1,146 @@ +[ + { + "case": "Split middle", + "id": 1, + "intent": { + "author": "Rust join author", + "left": "{DFE950EF-6BEB-0F5B-2403-7B74F1AE5002},44", + "right": "{0BA342F5-0690-087C-24FE-8EE6D81C2041},12" + }, + "remote_prefix": true, + "revision": "{D3E40C30-D3F7-48FC-8163-10191C9874A2},1", + "space": "{FE224563-B517-023E-0D75-9904B1952A26},1" + }, + { + "case": "Split style boundary", + "id": 2, + "intent": { + "author": "Rust join author", + "left": "{5FB484E2-1E3E-0A9D-1989-271A3EBBEC42},44", + "right": "{051BC1DE-5AE6-0D04-0CEF-9D05F53C8EAE},12" + }, + "remote_prefix": true, + "revision": "{B4AD652D-1152-4BFB-9F7C-3F5A37D526CC},1", + "space": "{DED48A05-0DB1-0712-086D-09237C982341},1" + }, + { + "case": "Split start", + "id": 3, + "intent": { + "author": "Rust join author", + "left": "{012ED94B-43B3-079A-3050-B1CBE25C1C09},44", + "right": "{B230A510-9428-0CE7-1B89-7CA970F8F676},12" + }, + "remote_prefix": false, + "revision": "{14C83ACF-0D2F-4D6C-8A85-D4B883407F57},1", + "space": "{7919E933-0809-00D8-1754-A21684215860},1" + }, + { + "case": "Split end", + "id": 4, + "intent": { + "author": "Rust join author", + "left": "{DAF723A3-C2A3-0798-2EAB-34613166A794},44", + "right": "{601DAE0A-2A53-0C28-3077-1FE8FCB26FC2},12" + }, + "remote_prefix": true, + "revision": "{BAE72407-55A9-4703-922B-67E6B8ADE435},1", + "space": "{927F2809-173C-036E-37B6-60A6568F7723},1" + }, + { + "case": "Split empty", + "id": 5, + "intent": { + "author": "Rust join author", + "left": "{0B467F31-D287-08CD-09F4-A6FBB2F5418F},45", + "right": "{EACB4A2C-2444-0A1F-03D6-68187F2FB345},12" + }, + "remote_prefix": false, + "revision": "{28937B4C-67BC-4ED8-A531-E63A6FB99F38},1", + "space": "{E4D8C4CC-508B-04D2-36CE-C5B8C9D2FEC4},1" + }, + { + "case": "Split parent", + "id": 6, + "intent": { + "author": "Rust join author", + "left": "{3860EA13-D444-0598-35D6-CF96769C6A21},44", + "right": "{C84CA5DB-61A8-0F9B-3F91-D6E0C74536F7},12" + }, + "remote_prefix": true, + "revision": "{E154B8F7-C84E-4AED-B436-D610448E37F1},1", + "space": "{7A363D4C-73C2-0ABE-0931-E6F62A90B95E},1" + }, + { + "case": "Split child", + "id": 7, + "intent": { + "author": "Rust join author", + "left": "{B587CBC6-0BCC-0A23-16F6-E1B0B3C5D061},49", + "right": "{DC240C63-FC8E-0267-0AFD-A53062C25702},12" + }, + "remote_prefix": true, + "revision": "{01D54762-D53E-4DF2-AD44-7789FAE785CE},1", + "space": "{3CC2C4DC-651A-07C9-2F7B-375C6663F3CA},1" + }, + { + "case": "Split bullet", + "id": 8, + "intent": { + "author": "Rust join author", + "left": "{8C65E427-477C-02ED-1807-2F10E277019D},44", + "right": "{28F3F5EC-E687-02CA-2CFD-E73A184B875F},12" + }, + "remote_prefix": true, + "revision": "{BE53841B-A5D6-447A-B1F3-C1E2D11EBBCE},1", + "space": "{942EF5E4-FFD1-0FE4-23D2-7429899577BD},1" + }, + { + "case": "Split number", + "id": 9, + "intent": { + "author": "Rust join author", + "left": "{D4ABBE4F-2165-0122-1F9F-ACE3FE92C703},44", + "right": "{DD3FA0E7-15A6-01C4-1B77-F85F779BCBC8},12" + }, + "remote_prefix": true, + "revision": "{53CAB283-B0D5-4D3A-B325-9FD8B214FE21},1", + "space": "{A4AE40FF-DD80-0003-2856-17D8295C1B91},1" + }, + { + "case": "Split tag", + "id": 10, + "intent": { + "author": "Rust join author", + "left": "{37AA5877-2980-0227-169F-8B1E8EE0795A},44", + "right": "{1480427A-E9ED-04EC-0409-0653AE11FF78},12" + }, + "remote_prefix": true, + "revision": "{BC2C9E0D-BA56-48ED-8510-CFBF7FCBDEED},1", + "space": "{739DB38B-4239-0A7C-2652-4E525D219638},1" + }, + { + "case": "Split cell", + "id": 11, + "intent": { + "author": "Rust join author", + "left": "{F0832BC9-1578-0A96-08AF-99344644FFB8},50", + "right": "{1E353497-5C26-0459-3C0C-0E8D29ED8C5A},12" + }, + "remote_prefix": true, + "revision": "{392FD304-233C-4A9C-A90E-16EA8F916073},1", + "space": "{4432E3EC-5E5F-0A04-2108-E9A2C17BDBEC},1" + }, + { + "case": "Split soft break", + "id": 12, + "intent": { + "author": "Rust join author", + "left": "{88189BA1-2F19-0282-05ED-A0B83153F143},44", + "right": "{C1568E82-003F-016B-041A-CA304E1A5138},12" + }, + "remote_prefix": true, + "revision": "{D3D41323-E06E-4253-BAA5-B69A3AAA40E5},1", + "space": "{D8E15673-A45E-0914-291A-7767C3222F19},1" + } +] \ No newline at end of file diff --git a/corpus/paragraph-edit/offline/joins/native/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/offline/joins/native/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..dbc25757edc4679de12642bd8cb897bfff94488f Binary files /dev/null and b/corpus/paragraph-edit/offline/joins/native/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/paragraph-edit/offline/joins/native/notebook/synthetic.one b/corpus/paragraph-edit/offline/joins/native/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..58dbfc67d9a1df59104476713767fd5062c96b2f Binary files /dev/null and b/corpus/paragraph-edit/offline/joins/native/notebook/synthetic.one differ diff --git a/corpus/paragraph-edit/offline/joins/native/read/environment.json b/corpus/paragraph-edit/offline/joins/native/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..552ef9a6189225c2022dccd1e57702c7939ff4da --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-37FF299E", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/paragraph-edit/offline/joins/native/read/hierarchy.xml b/corpus/paragraph-edit/offline/joins/native/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..9a5218400d4c87069f2b31b2c580618a7d756a3d --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-000.xml b/corpus/paragraph-edit/offline/joins/native/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..9201082b94066f46a988d536c2ef7494840dfbf2 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-000.xml @@ -0,0 +1,5 @@ + +☂Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-001.xml b/corpus/paragraph-edit/offline/joins/native/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..62fe84b91cb277cec1462d1a02afd2446f6dee21 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-001.xml @@ -0,0 +1,3 @@ + + +soft break]]> diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-002.xml b/corpus/paragraph-edit/offline/joins/native/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..bf17bb70560f2566974524307321791177e331a1 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-003.xml b/corpus/paragraph-edit/offline/joins/native/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..adec3d788188349daf59a08d4d872658c04ef724 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-003.xml @@ -0,0 +1,5 @@ + +☂Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-004.xml b/corpus/paragraph-edit/offline/joins/native/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..fe7660933a9e562a29e298825fb22266375d80c8 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-004.xml @@ -0,0 +1,5 @@ + +☂Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-005.xml b/corpus/paragraph-edit/offline/joins/native/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..4227be5eb03a241bf6ce0dfe0fbb1ece9965dade --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-005.xml @@ -0,0 +1,6 @@ + +☂Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-006.xml b/corpus/paragraph-edit/offline/joins/native/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..167287870795f4b510eebf42241cafb673ad0940 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-006.xml @@ -0,0 +1,5 @@ + +☂Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-007.xml b/corpus/paragraph-edit/offline/joins/native/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..402252d7c06456b9a4f6d4461f03bcbd71640661 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-007.xml @@ -0,0 +1,5 @@ + +☂Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-008.xml b/corpus/paragraph-edit/offline/joins/native/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..552b927b47f79cfc1a3d1cd2e2b46c2c79adcff7 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-008.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-009.xml b/corpus/paragraph-edit/offline/joins/native/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..4301ba0550eaa621005b78bc9e7aeba3f76a2f6e --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-009.xml @@ -0,0 +1,5 @@ + +☂Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-010.xml b/corpus/paragraph-edit/offline/joins/native/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..dae5fdb3cbcd6aaa85cd91b2ab029238544058b3 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-010.xml @@ -0,0 +1,3 @@ + +Link label tail]]> diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-011.xml b/corpus/paragraph-edit/offline/joins/native/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..b31bc7ec4baddcf2e98da0146a56658a8ad390f8 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-011.xml @@ -0,0 +1,5 @@ + +☂Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-012.xml b/corpus/paragraph-edit/offline/joins/native/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..9fcb04f0bd3028c095bfaaab0c8cbf2add4e8c6c --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-012.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/joins/native/read/page-013.xml b/corpus/paragraph-edit/offline/joins/native/read/page-013.xml new file mode 100644 index 0000000000000000000000000000000000000000..6dd7d0276272223468796769d7447b6b7d25fc04 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/page-013.xml @@ -0,0 +1,5 @@ + +☂Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/joins/native/read/payloads.json b/corpus/paragraph-edit/offline/joins/native/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..787b4bae85038cda922f8befac6c8ed361fa8035 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/read/payloads.json @@ -0,0 +1 @@ +../../../../before/read/payloads.json \ No newline at end of file diff --git a/corpus/paragraph-edit/offline/joins/native/run.json b/corpus/paragraph-edit/offline/joins/native/run.json new file mode 100644 index 0000000000000000000000000000000000000000..d6d739820dca141e877c521937893a9db95b9010 --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/offline-paragraph-native-01/joins/candidate", + "expected_pages": 14, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/paragraph-edit/offline/joins/native/source.json b/corpus/paragraph-edit/offline/joins/native/source.json new file mode 100644 index 0000000000000000000000000000000000000000..699cc709a58444d4fb5d3cdaaf8e4edece01bf0c --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 150648, + "sha256": "2fd036cddb33de5e42f66cb6a6b7647cbfeac374a4055e9afc15bf3b0d6ec1b2", + "mtime_ns": 1788863184056850411 + } +] diff --git a/corpus/paragraph-edit/offline/joins/native/teardown.json b/corpus/paragraph-edit/offline/joins/native/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..47a1192a30ea7da90676d631bb9a2687d829b43e --- /dev/null +++ b/corpus/paragraph-edit/offline/joins/native/teardown.json @@ -0,0 +1 @@ +../../../joined/teardown.json \ No newline at end of file diff --git a/corpus/paragraph-edit/offline/splits/candidate/synthetic.one b/corpus/paragraph-edit/offline/splits/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..66148d4f93647580dd893d2c5fcdac50745a26bb Binary files /dev/null and b/corpus/paragraph-edit/offline/splits/candidate/synthetic.one differ diff --git a/corpus/paragraph-edit/offline/splits/manifest.json b/corpus/paragraph-edit/offline/splits/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..262dff2900ded1a50e82606439626bb8b6212703 --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/manifest.json @@ -0,0 +1,374 @@ +[ + { + "case": "Split middle", + "id": 1, + "intent": { + "author": "Rust split author", + "created": 1473324660, + "guid": [ + 228, + 60, + 65, + 246, + 194, + 244, + 184, + 76, + 170, + 5, + 19, + 130, + 32, + 122, + 148, + 241 + ], + "offset": 2, + "text": "{DFE950EF-6BEB-0F5B-2403-7B74F1AE5002},44" + }, + "remote_prefix": true, + "revision": "{BF0EE2EB-19D1-47B7-B28D-A9ABD19EBAAE},1", + "space": "{FE224563-B517-023E-0D75-9904B1952A26},1" + }, + { + "case": "Split style boundary", + "id": 2, + "intent": { + "author": "Rust split author", + "created": 1473324660, + "guid": [ + 163, + 91, + 197, + 73, + 195, + 225, + 53, + 68, + 140, + 152, + 83, + 168, + 172, + 124, + 226, + 213 + ], + "offset": 4, + "text": "{5FB484E2-1E3E-0A9D-1989-271A3EBBEC42},44" + }, + "remote_prefix": true, + "revision": "{167F0C99-70D5-4B0C-8339-4A5D4FA8D101},1", + "space": "{DED48A05-0DB1-0712-086D-09237C982341},1" + }, + { + "case": "Split start", + "id": 3, + "intent": { + "author": "Rust split author", + "created": 1473324660, + "guid": [ + 135, + 16, + 214, + 248, + 73, + 35, + 2, + 69, + 148, + 213, + 125, + 28, + 43, + 124, + 81, + 35 + ], + "offset": 0, + "text": "{012ED94B-43B3-079A-3050-B1CBE25C1C09},44" + }, + "remote_prefix": false, + "revision": "{F45BB0FF-3E33-4039-B5DE-C436C126C52E},1", + "space": "{7919E933-0809-00D8-1754-A21684215860},1" + }, + { + "case": "Split end", + "id": 4, + "intent": { + "author": "Rust split author", + "created": 1473324660, + "guid": [ + 37, + 220, + 149, + 138, + 85, + 3, + 106, + 69, + 168, + 172, + 49, + 82, + 247, + 99, + 160, + 139 + ], + "offset": 22, + "text": "{DAF723A3-C2A3-0798-2EAB-34613166A794},44" + }, + "remote_prefix": true, + "revision": "{B408F227-A7DE-4350-8068-D91005E0B8E5},1", + "space": "{927F2809-173C-036E-37B6-60A6568F7723},1" + }, + { + "case": "Split empty", + "id": 5, + "intent": { + "author": "Rust split author", + "created": 1473324660, + "guid": [ + 232, + 204, + 91, + 132, + 180, + 27, + 239, + 79, + 158, + 115, + 213, + 165, + 40, + 35, + 189, + 93 + ], + "offset": 0, + "text": "{0B467F31-D287-08CD-09F4-A6FBB2F5418F},45" + }, + "remote_prefix": false, + "revision": "{4F8EB252-1808-40AA-9B37-DB3DE1C5C3F2},1", + "space": "{E4D8C4CC-508B-04D2-36CE-C5B8C9D2FEC4},1" + }, + { + "case": "Split parent", + "id": 6, + "intent": { + "author": "Rust split author", + "created": 1473324660, + "guid": [ + 54, + 186, + 114, + 171, + 44, + 189, + 95, + 71, + 135, + 190, + 71, + 95, + 23, + 246, + 37, + 157 + ], + "offset": 2, + "text": "{3860EA13-D444-0598-35D6-CF96769C6A21},44" + }, + "remote_prefix": true, + "revision": "{A291D6A6-08A8-4E06-BF29-0479851E9E12},1", + "space": "{7A363D4C-73C2-0ABE-0931-E6F62A90B95E},1" + }, + { + "case": "Split child", + "id": 7, + "intent": { + "author": "Rust split author", + "created": 1473324660, + "guid": [ + 160, + 247, + 235, + 152, + 30, + 98, + 166, + 77, + 157, + 204, + 141, + 45, + 114, + 151, + 225, + 195 + ], + "offset": 2, + "text": "{B587CBC6-0BCC-0A23-16F6-E1B0B3C5D061},49" + }, + "remote_prefix": true, + "revision": "{FFE1F804-FA27-4EFD-B53C-9E1D07384DA6},1", + "space": "{3CC2C4DC-651A-07C9-2F7B-375C6663F3CA},1" + }, + { + "case": "Split bullet", + "id": 8, + "intent": { + "author": "Rust split author", + "created": 1473324660, + "guid": [ + 53, + 245, + 214, + 76, + 159, + 56, + 134, + 76, + 178, + 90, + 131, + 182, + 74, + 114, + 31, + 24 + ], + "offset": 2, + "text": "{8C65E427-477C-02ED-1807-2F10E277019D},44" + }, + "remote_prefix": true, + "revision": "{822FBEAD-035E-47B5-9ED6-CDAE96AA5837},1", + "space": "{942EF5E4-FFD1-0FE4-23D2-7429899577BD},1" + }, + { + "case": "Split number", + "id": 9, + "intent": { + "author": "Rust split author", + "created": 1473324661, + "guid": [ + 196, + 133, + 251, + 251, + 193, + 175, + 8, + 65, + 175, + 180, + 126, + 16, + 31, + 185, + 237, + 66 + ], + "offset": 2, + "text": "{D4ABBE4F-2165-0122-1F9F-ACE3FE92C703},44" + }, + "remote_prefix": true, + "revision": "{F5ABC207-F9C7-45A3-B213-AA675126A592},1", + "space": "{A4AE40FF-DD80-0003-2856-17D8295C1B91},1" + }, + { + "case": "Split tag", + "id": 10, + "intent": { + "author": "Rust split author", + "created": 1473324661, + "guid": [ + 40, + 122, + 110, + 77, + 185, + 255, + 122, + 69, + 128, + 15, + 14, + 166, + 54, + 156, + 154, + 91 + ], + "offset": 2, + "text": "{37AA5877-2980-0227-169F-8B1E8EE0795A},44" + }, + "remote_prefix": true, + "revision": "{8021DAB3-4163-49C4-A7A1-898ACCAB3981},1", + "space": "{739DB38B-4239-0A7C-2652-4E525D219638},1" + }, + { + "case": "Split cell", + "id": 11, + "intent": { + "author": "Rust split author", + "created": 1473324661, + "guid": [ + 150, + 222, + 89, + 134, + 80, + 167, + 167, + 67, + 179, + 23, + 93, + 176, + 27, + 184, + 119, + 185 + ], + "offset": 2, + "text": "{F0832BC9-1578-0A96-08AF-99344644FFB8},50" + }, + "remote_prefix": true, + "revision": "{0E8285DD-840A-4C8E-BFDB-CA3589436ECD},1", + "space": "{4432E3EC-5E5F-0A04-2108-E9A2C17BDBEC},1" + }, + { + "case": "Split soft break", + "id": 12, + "intent": { + "author": "Rust split author", + "created": 1473324661, + "guid": [ + 199, + 62, + 94, + 81, + 29, + 157, + 236, + 77, + 159, + 34, + 22, + 137, + 163, + 57, + 215, + 79 + ], + "offset": 2, + "text": "{88189BA1-2F19-0282-05ED-A0B83153F143},44" + }, + "remote_prefix": true, + "revision": "{89D96DD2-2D50-433E-B54B-50018E3C2108},1", + "space": "{D8E15673-A45E-0914-291A-7767C3222F19},1" + } +] \ No newline at end of file diff --git a/corpus/paragraph-edit/offline/splits/native/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/offline/splits/native/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..e6462b78708e6fcff91d8fb9b4059776e8c1e932 Binary files /dev/null and b/corpus/paragraph-edit/offline/splits/native/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/paragraph-edit/offline/splits/native/notebook/synthetic.one b/corpus/paragraph-edit/offline/splits/native/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..82323b80ecdf4626a8e3d11bda99eb0013358efd Binary files /dev/null and b/corpus/paragraph-edit/offline/splits/native/notebook/synthetic.one differ diff --git a/corpus/paragraph-edit/offline/splits/native/read/environment.json b/corpus/paragraph-edit/offline/splits/native/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..f25cbe4815b561c24a9a6bdd4809e070238ab820 --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-97095A74", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/paragraph-edit/offline/splits/native/read/hierarchy.xml b/corpus/paragraph-edit/offline/splits/native/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..569b27bb107f308aa2724d4e3d5a3f408803df0a --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-000.xml b/corpus/paragraph-edit/offline/splits/native/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..552bdb1a543eab002e577f984ca924481b2c0415 --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-000.xml @@ -0,0 +1,3 @@ + + +soft break]]> diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-001.xml b/corpus/paragraph-edit/offline/splits/native/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..d23d6f76d2f1ee76c8e096c59bacda45afb3626b --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-001.xml @@ -0,0 +1,5 @@ + +☂Bold]]> italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-002.xml b/corpus/paragraph-edit/offline/splits/native/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..302f68bcfd20c4f470d4442980f39ff29626f744 --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-002.xml @@ -0,0 +1,6 @@ + +☂Bo]]>ld italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-003.xml b/corpus/paragraph-edit/offline/splits/native/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..8a6966323f4370b6414fa6d324ed432fc6dee0fd --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-004.xml b/corpus/paragraph-edit/offline/splits/native/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..5f29ed91e8c01061ac3d14843d92c35a9d8a5024 --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-004.xml @@ -0,0 +1,5 @@ + +☂Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-005.xml b/corpus/paragraph-edit/offline/splits/native/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..47a49619f20d80f0cd3ec8a191e25c3ae776898d --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-005.xml @@ -0,0 +1,6 @@ + +☂Bo]]>ld italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-006.xml b/corpus/paragraph-edit/offline/splits/native/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..20a04fd89bdc80ba54ac6d41499b42fa651fa476 --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-006.xml @@ -0,0 +1,6 @@ + +☂Bo]]>ld italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-007.xml b/corpus/paragraph-edit/offline/splits/native/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..e8e34a6b4409d0ca62fd300775ae8ab05e8e187e --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-007.xml @@ -0,0 +1,6 @@ + +☂Bo]]>ld italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-008.xml b/corpus/paragraph-edit/offline/splits/native/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..c98920929247120cedf4f5d8821acde295b9ddd8 --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-008.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-009.xml b/corpus/paragraph-edit/offline/splits/native/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..239094503552c6db3c0ad713177654e64e916fe3 --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-009.xml @@ -0,0 +1,6 @@ + +☂Bo]]>ld italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-010.xml b/corpus/paragraph-edit/offline/splits/native/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..7589342d47d156ffa1b91da04b0d26bbb452e12a --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-010.xml @@ -0,0 +1,6 @@ + +☂Bo]]>ld italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-011.xml b/corpus/paragraph-edit/offline/splits/native/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..44401c90c4c34eed1906e8886f606578698472d7 --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-011.xml @@ -0,0 +1,3 @@ + +Link label tail]]> diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-012.xml b/corpus/paragraph-edit/offline/splits/native/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..94b7c097ba40e651efb699b7b83e8f31997e3fbc --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-012.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/offline/splits/native/read/page-013.xml b/corpus/paragraph-edit/offline/splits/native/read/page-013.xml new file mode 100644 index 0000000000000000000000000000000000000000..b45f6367cbe094cff50050a691df1a253377e0df --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/page-013.xml @@ -0,0 +1,6 @@ + +☂Bo]]>ld italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/splits/native/read/payloads.json b/corpus/paragraph-edit/offline/splits/native/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..787b4bae85038cda922f8befac6c8ed361fa8035 --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/read/payloads.json @@ -0,0 +1 @@ +../../../../before/read/payloads.json \ No newline at end of file diff --git a/corpus/paragraph-edit/offline/splits/native/run.json b/corpus/paragraph-edit/offline/splits/native/run.json new file mode 100644 index 0000000000000000000000000000000000000000..b59c126e1d874ea3b45cc19732aef5e66ac457ba --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/offline-paragraph-native-01/splits/candidate", + "expected_pages": 14, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/paragraph-edit/offline/splits/native/source.json b/corpus/paragraph-edit/offline/splits/native/source.json new file mode 100644 index 0000000000000000000000000000000000000000..efa07a806149800f245ae2315d9a27dcb421ff6e --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 119736, + "sha256": "ea8264bf8df579b69755c47918ab45f77a7fdd4a4cc7564ab84813220def9322", + "mtime_ns": 1788863181033354746 + } +] diff --git a/corpus/paragraph-edit/offline/splits/native/teardown.json b/corpus/paragraph-edit/offline/splits/native/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..47a1192a30ea7da90676d631bb9a2687d829b43e --- /dev/null +++ b/corpus/paragraph-edit/offline/splits/native/teardown.json @@ -0,0 +1 @@ +../../../joined/teardown.json \ No newline at end of file diff --git a/corpus/paragraph-edit/offline/tags/candidate/synthetic.one b/corpus/paragraph-edit/offline/tags/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..f14e719947b2b79e1a76d8c9e320f66d883af1c9 Binary files /dev/null and b/corpus/paragraph-edit/offline/tags/candidate/synthetic.one differ diff --git a/corpus/paragraph-edit/offline/tags/manifest.json b/corpus/paragraph-edit/offline/tags/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..91e171dadf3b1b404da916620fe17ee5242e4838 --- /dev/null +++ b/corpus/paragraph-edit/offline/tags/manifest.json @@ -0,0 +1,38 @@ +[ + { + "case": "Join empty left right tag", + "id": 1, + "intent": { + "author": "Rust join author", + "left": "{0D11242E-6A38-0F74-261A-A6FC444FA262},45", + "right": "{0D11242E-6A38-0F74-261A-A6FC444FA262},50" + }, + "remote_prefix": false, + "revision": "{E906B028-AA9F-4772-877F-CCBED45A8645},1", + "space": "{FC64EF0A-5CCB-0557-20E0-6C847015B691},1" + }, + { + "case": "Join empty left two tags", + "id": 2, + "intent": { + "author": "Rust join author", + "left": "{84061027-77E3-03F1-3E0D-A8F744B19C18},45", + "right": "{84061027-77E3-03F1-3E0D-A8F744B19C18},51" + }, + "remote_prefix": false, + "revision": "{98BB5C37-4F68-408F-AE2F-4D9EFD75022F},1", + "space": "{A05CB25D-C95D-043F-3B9A-CE333AD171AD},1" + }, + { + "case": "Join different tags", + "id": 3, + "intent": { + "author": "Rust join author", + "left": "{4FBF7D4A-6F4D-048E-0913-5BD9A184C25B},44", + "right": "{4FBF7D4A-6F4D-048E-0913-5BD9A184C25B},50" + }, + "remote_prefix": true, + "revision": "{AA6105EC-5A3B-4038-887A-DDADB2911713},1", + "space": "{6F0F6556-17D3-03AF-20EF-C465A856A153},1" + } +] \ No newline at end of file diff --git a/corpus/paragraph-edit/offline/tags/native/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/offline/tags/native/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..0dda8d5a7eefbb1b6b34b03bd871912e528d9d9a Binary files /dev/null and b/corpus/paragraph-edit/offline/tags/native/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/paragraph-edit/offline/tags/native/notebook/synthetic.one b/corpus/paragraph-edit/offline/tags/native/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..abb666b9c89afffb0acebc1f74a94ffd641936e5 Binary files /dev/null and b/corpus/paragraph-edit/offline/tags/native/notebook/synthetic.one differ diff --git a/corpus/paragraph-edit/offline/tags/native/read/environment.json b/corpus/paragraph-edit/offline/tags/native/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..b348dd06a6fd4897cb12c8e784c1cf7817fe7da6 --- /dev/null +++ b/corpus/paragraph-edit/offline/tags/native/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-C920A6E7", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/paragraph-edit/offline/tags/native/read/hierarchy.xml b/corpus/paragraph-edit/offline/tags/native/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..aae999471c108dab6b93ccdf7922138bcc4e42f4 --- /dev/null +++ b/corpus/paragraph-edit/offline/tags/native/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-edit/offline/tags/native/read/page-000.xml b/corpus/paragraph-edit/offline/tags/native/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..9b852a0b5177c4b307766f1da26fd363570f79f2 --- /dev/null +++ b/corpus/paragraph-edit/offline/tags/native/read/page-000.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/offline/tags/native/read/page-001.xml b/corpus/paragraph-edit/offline/tags/native/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..a518cfa62728716735c00eba3f549cd8ceed68ed --- /dev/null +++ b/corpus/paragraph-edit/offline/tags/native/read/page-001.xml @@ -0,0 +1,5 @@ + +☂LeftBold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/tags/native/read/page-002.xml b/corpus/paragraph-edit/offline/tags/native/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..6c9bad30a1ce9ab4890efd523db2c4d8686fa73d --- /dev/null +++ b/corpus/paragraph-edit/offline/tags/native/read/page-002.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/tags/native/read/page-003.xml b/corpus/paragraph-edit/offline/tags/native/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..f8a75604a6d26a6ae036923d879551df7dbf6ee9 --- /dev/null +++ b/corpus/paragraph-edit/offline/tags/native/read/page-003.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/offline/tags/native/read/payloads.json b/corpus/paragraph-edit/offline/tags/native/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..787b4bae85038cda922f8befac6c8ed361fa8035 --- /dev/null +++ b/corpus/paragraph-edit/offline/tags/native/read/payloads.json @@ -0,0 +1 @@ +../../../../before/read/payloads.json \ No newline at end of file diff --git a/corpus/paragraph-edit/offline/tags/native/run.json b/corpus/paragraph-edit/offline/tags/native/run.json new file mode 100644 index 0000000000000000000000000000000000000000..5a64074979038e3aff0716e3caf13ae9c03b43f3 --- /dev/null +++ b/corpus/paragraph-edit/offline/tags/native/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/offline-paragraph-native-01/tags/candidate", + "expected_pages": 4, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/paragraph-edit/offline/tags/native/source.json b/corpus/paragraph-edit/offline/tags/native/source.json new file mode 100644 index 0000000000000000000000000000000000000000..5253d2f66a6d0335f7eac687493ecba83043724c --- /dev/null +++ b/corpus/paragraph-edit/offline/tags/native/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 42688, + "sha256": "4d6efc70b84628a281d1a64dbb3cf96d7c682d558ed7fbef708b493c51b01794", + "mtime_ns": 1788863185189921064 + } +] diff --git a/corpus/paragraph-edit/offline/tags/native/teardown.json b/corpus/paragraph-edit/offline/tags/native/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..47a1192a30ea7da90676d631bb9a2687d829b43e --- /dev/null +++ b/corpus/paragraph-edit/offline/tags/native/teardown.json @@ -0,0 +1 @@ +../../../joined/teardown.json \ No newline at end of file diff --git a/crates/onestore-offline/README.md b/crates/onestore-offline/README.md index bae1c8bdb444b6cdb1156697916e728d9da5077f..cc8593c7be7f02433d59f2c70d3d78e73d59ef0d 100644 --- a/crates/onestore-offline/README.md +++ b/crates/onestore-offline/README.md @@ -1,7 +1,7 @@ # onestore-offline Durable local editing for a OneNote file, in an optional sibling crate. The current -foundation stores a complete working image and typed text, insertion and formatting intents in a local +foundation stores a complete working image and typed editing intents in a local SQLite database. `sync_once` provides a reconciliation step and `start_sync` owns automatic polling and reconnects. Local success does not acknowledge publication to a shared notebook. @@ -29,12 +29,30 @@ assert_eq!(pending.last().map(|edit| edit.id), local_id); intent identities. `Insertion::with_formatting` queues text and its styles as one intent and one publication. Keep that value across retries; its `text_object()` identifies the new text for subsequent offline edits. Pending entries expose -`Operation::Text(TextEdit)`, `Operation::Insert(Insertion)` or -`Operation::Format(FormatEdit)` through their +`Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`, +`Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)` and +`Operation::Join(JoinEdit)` through their `operation` field. Synchronization applies these in queue order, so an inserted outline can precede its paragraphs and their later edits. Missing anchors or existing insertion identities preserve a conflict and the complete local image. +`split` and `join` accept the core `ParagraphSplit` and `ParagraphJoin` intents. +Splits retain allocated identities when the original UTF-16 boundary rebases; +dependent edits can address `ParagraphSplit::text_object()` immediately after +local acknowledgement. Both operations retain observed parent paths, children, +indentation, paragraph/list state and tags. Changed structure produces +`StructureChanged`. Remote text changes must leave unambiguous boundary mappings; +current remote character styles are preserved. Reconciliation does not silently +discard a newly added right tag or move an unobserved child. + +`rebase_conflict` accepts a zero-length reviewed range for a split, preserving its +allocated identities. `rebase_join_conflict(id, local, remote)` reviews the original +join against current images. It rejects a change in which text identity survives, +because dependent edits still address that identity. As with other conflict +reviews, stale images, pending operations and uncertain attempts cannot be rebased. +An uncertain structural operation requires its original attempted revision for +confirmation; matching text or structure does not establish a receipt. + `rebase_paragraph_conflict(id, local, remote, parent, before)` and `rebase_outline_conflict(id, local, remote, page, x, y)` accept reviewed replacement placements for the oldest insertion conflict. They preserve creation time, text, @@ -70,8 +88,9 @@ requested value is accepted. Enabling superscript or subscript also checks the opposite attribute that the operation clears. If the remote image already satisfies the whole operation, guarded confirmation still precedes a durable receipt. -Recognized version-one through version-three caches migrate transactionally to the typed -queue. The migration retains images, local IDs, publication attempts, conflicts, +Recognized earlier caches migrate transactionally to version six, which adds +paragraph split/join intents and structural conflicts. The migration retains +images, local IDs, publication attempts, conflicts, receipts and the autoincrement sequence; it does not reuse acknowledged IDs when the pending queue is empty. diff --git a/crates/onestore-offline/examples/cache_probe.rs b/crates/onestore-offline/examples/cache_probe.rs index abd7ff2cf78c670c46635acf394fdd001aba39ff..8f016e89f1cee8b67dc9f82b971ec26e1cce240b 100644 --- a/crates/onestore-offline/examples/cache_probe.rs +++ b/crates/onestore-offline/examples/cache_probe.rs @@ -138,6 +138,9 @@ fn main() -> Result<(), Box> { font_size = Some(*value); *value as u64 - 6 } + onestore_offline::Operation::Split(_) | onestore_offline::Operation::Join(_) => { + panic!("This fixture does not generate paragraph boundary edits") + } }; assert!(operations.last().is_none_or(|last| *last < operation)); assert!(ids.last().is_none_or(|last| *last < pending.id)); diff --git a/crates/onestore-offline/src/lib.rs b/crates/onestore-offline/src/lib.rs index 31c7c9505ecf1fbc406f82fe2ac3df17ef9c0407..47dc8da3c60b5fcf2cba95c2ea33775402a11ea0 100644 --- a/crates/onestore-offline/src/lib.rs +++ b/crates/onestore-offline/src/lib.rs @@ -10,10 +10,12 @@ use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Durati mod assets; mod formatting; +mod paragraph; mod rebase; mod recovery; mod schema; pub use formatting::FormatEdit; +pub use paragraph::{JoinEdit, SplitEdit}; pub use recovery::{Recovery, RecoverySummary}; mod sync; pub use sync::{ConflictKind, EditStatus, Remote}; @@ -45,7 +47,7 @@ pub enum Error { type Result = std::result::Result; const APPLICATION_ID: u32 = 0x4f4e454f; -const SCHEMA_VERSION: u32 = 5; +const SCHEMA_VERSION: u32 = 6; /// Text and its observed precondition, retained across cache reopen and rebasing. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -63,6 +65,8 @@ pub enum Operation { Text(TextEdit), Insert(Insertion), Format(FormatEdit), + Split(SplitEdit), + Join(JoinEdit), } /// A locally acknowledged intent; its ID remains stable across cache reopen. diff --git a/crates/onestore-offline/src/paragraph.rs b/crates/onestore-offline/src/paragraph.rs new file mode 100644 index 0000000000000000000000000000000000000000..4454bc7c110c4dba5a7c196ad982a0318886ab5a --- /dev/null +++ b/crates/onestore-offline/src/paragraph.rs @@ -0,0 +1,241 @@ +use super::*; +use onestore::{ParagraphJoin, ParagraphSplit}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Observed { + text: String, + structure: Value, +} + +/// A stable split intent and the paragraph state observed before local publication. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SplitEdit { + pub intent: ParagraphSplit, + observed: Observed, +} + +/// A join intent and both paragraphs' observed text, placement and tag ownership. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JoinEdit { + pub intent: ParagraphJoin, + observed: [Observed; 2], +} + +fn observe(source: &[u8], space: ExGuid, texts: &[ExGuid]) -> Result>> { + let store = Store::parse(source)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + let Some(section) = document.spaces.get(&space) else { + return Ok(None); + }; + let Some(view) = section + .contexts + .get(&ExGuid::default()) + .and_then(|id| section.revisions.get(id)) + else { + return Ok(None); + }; + let mut pending: Vec<_> = document + .pages()? + .into_iter() + .filter_map(|(sid, page)| (sid == space).then_some(page)) + .collect(); + let pages = pending.clone(); + let mut seen = BTreeSet::new(); + let mut parents = BTreeMap::<_, Vec<_>>::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + let node = &view.nodes[&id]; + for child in node + .children + .iter() + .chain(&node.content) + .chain(&node.structure) + { + parents.entry(*child).or_default().push(id); + pending.push(*child); + } + } + let mut observed = Vec::new(); + for text in texts { + let Some(node) = view.nodes.get(text).filter(|_| seen.contains(text)) else { + return Ok(None); + }; + let Kind::RichText { text: content, .. } = &node.kind else { + return Ok(None); + }; + let mut path = Vec::new(); + let mut at = *text; + while !pages.contains(&at) { + let Some([parent]) = parents.get(&at).map(Vec::as_slice) else { + return Ok(None); + }; + if path.len() >= view.nodes.len() { + return Ok(None); + } + path.push(*parent); + at = *parent; + } + let Some(paragraph) = path.first().and_then(|id| view.nodes.get(id)) else { + return Ok(None); + }; + let Kind::Paragraph { lists, .. } = ¶graph.kind else { + return Ok(None); + }; + if paragraph.content != [*text] { + return Ok(None); + } + let lists: Vec<_> = lists.iter().map(|id| (*id, &view.nodes[id].kind)).collect(); + observed.push(Observed { + text: content.clone(), + structure: json!({ + "path": path, + "children": paragraph.children, + "child_level": paragraph.child_level, + "paragraph": paragraph.kind, + "lists": lists, + "tags": node.tags, + }), + }); + } + Ok(Some(observed)) +} + +impl Replica { + /// Durably queues a split with stable new identities and observed structural preconditions. + pub fn split( + &self, + source: &[u8], + space: ExGuid, + intent: &ParagraphSplit, + ) -> Result> { + let (operation, prepared) = SplitEdit::capture(source, space, intent)?; + self.record(source, space, Operation::Split(operation), &prepared) + } + + /// Durably queues a join; changed placement, children or tags require conflict review. + pub fn join( + &self, + source: &[u8], + space: ExGuid, + intent: &ParagraphJoin, + ) -> Result> { + let (operation, prepared) = JoinEdit::capture(source, space, intent)?; + self.record(source, space, Operation::Join(operation), &prepared) + } +} + +impl SplitEdit { + pub(crate) fn capture<'a>( + source: &'a [u8], + space: ExGuid, + intent: &ParagraphSplit, + ) -> Result<(Self, PreparedEdit<'a>)> { + let prepared = PreparedEdit::split(source, space, intent)?; + let Some(mut observed) = observe(source, space, &[intent.position().0])? else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Select text in an active paragraph", + ) + .into()); + }; + Ok(( + Self { + intent: intent.clone(), + observed: observed.remove(0), + }, + prepared, + )) + } + + pub(crate) fn prepare<'a>( + &self, + source: &'a [u8], + space: ExGuid, + ) -> Result, ConflictKind>> { + let (text, offset) = self.intent.position(); + let Some(observed) = observe(source, space, &[text])? else { + return Ok(Err(ConflictKind::TargetUnavailable)); + }; + if observed[0].structure != self.observed.structure { + return Ok(Err(ConflictKind::StructureChanged)); + } + let Some(range) = rebase::rebase(&self.observed.text, &observed[0].text, offset..offset) + else { + return Ok(Err(ConflictKind::TextChanged)); + }; + Ok( + PreparedEdit::split(source, space, &self.intent.reposition(range.start)) + .map_err(|_| ConflictKind::UnsupportedEdit), + ) + } +} + +impl JoinEdit { + pub(crate) fn review(&self, source: &[u8], space: ExGuid) -> Result { + let (reviewed, _) = Self::capture(source, space, &self.intent)?; + if reviewed.observed[0].text.is_empty() != self.observed[0].text.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "The join would change its surviving text identity", + ) + .into()); + } + Ok(reviewed) + } + + pub(crate) fn capture<'a>( + source: &'a [u8], + space: ExGuid, + intent: &ParagraphJoin, + ) -> Result<(Self, PreparedEdit<'a>)> { + let prepared = PreparedEdit::join(source, space, intent)?; + let Some(observed) = observe(source, space, &intent.texts())? else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Select text in two active paragraphs", + ) + .into()); + }; + Ok(( + Self { + intent: intent.clone(), + observed: observed.try_into().unwrap(), + }, + prepared, + )) + } + + pub(crate) fn prepare<'a>( + &self, + source: &'a [u8], + space: ExGuid, + ) -> Result, ConflictKind>> { + let Some(observed) = observe(source, space, &self.intent.texts())? else { + return Ok(Err(ConflictKind::TargetUnavailable)); + }; + for (i, (old, new)) in self.observed.iter().zip(&observed).enumerate() { + if old.structure != new.structure { + return Ok(Err(ConflictKind::StructureChanged)); + } + let at = if i == 0 { + u32::try_from(old.text.encode_utf16().count()).map_err(io::Error::other)? + } else { + 0 + }; + if rebase::rebase(&old.text, &new.text, at..at).is_none() { + return Ok(Err(ConflictKind::TextChanged)); + } + } + Ok(PreparedEdit::join(source, space, &self.intent) + .map_err(|_| ConflictKind::UnsupportedEdit)) + } +} diff --git a/crates/onestore-offline/src/schema.rs b/crates/onestore-offline/src/schema.rs index 32b8f454dc8f969a687d9412dd5cd01f74238b56..766da91f0e860c4ea378fe250a4c57771368d34b 100644 --- a/crates/onestore-offline/src/schema.rs +++ b/crates/onestore-offline/src/schema.rs @@ -3,7 +3,7 @@ use rusqlite::{OptionalExtension, Transaction}; const CONFLICTS: &str = "CREATE TABLE conflicts ( edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, - kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 3) + kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 4) ) STRICT;"; const ASSETS: &str = "CREATE TABLE assets ( @@ -35,17 +35,15 @@ pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> { } pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()> { - if version == 4 { - transaction.execute_batch(ASSETS)?; - return Ok(()); - } - if version == 3 { + if version >= 3 { transaction.execute_batch("ALTER TABLE conflicts RENAME TO old_conflicts;")?; transaction.execute_batch(CONFLICTS)?; transaction.execute_batch( "INSERT INTO conflicts SELECT * FROM old_conflicts; DROP TABLE old_conflicts;", )?; - transaction.execute_batch(ASSETS)?; + if version < 5 { + transaction.execute_batch(ASSETS)?; + } return Ok(()); } diff --git a/crates/onestore-offline/src/sync.rs b/crates/onestore-offline/src/sync.rs index 286fca19a5c4bb7456eafbdf1eba69cd789ef114..9f18010cb7bddce1cddcbbf0e442f6692dd5f4fc 100644 --- a/crates/onestore-offline/src/sync.rs +++ b/crates/onestore-offline/src/sync.rs @@ -18,6 +18,7 @@ pub enum ConflictKind { TargetUnavailable = 1, UnsupportedEdit = 2, FormattingChanged = 3, + StructureChanged = 4, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -131,6 +132,8 @@ impl Replica { } let candidate = match &intent.operation { Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?, + Operation::Split(edit) => edit.prepare(&snapshot, intent.space)?, + Operation::Join(edit) => edit.prepare(&snapshot, intent.space)?, Operation::Insert(insertion) => { PreparedEdit::insert(&snapshot, intent.space, insertion) .map_err(|_| ConflictKind::UnsupportedEdit) @@ -222,7 +225,7 @@ impl Replica { Ok(Some((intent.id, EditStatus::Published { revision }))) } - /// Places the oldest text or formatting conflict at a reviewed remote UTF-16 range. + /// Places the oldest text, formatting or split conflict at a reviewed remote UTF-16 range. /// The requested replacement/attributes, local image, intent ID and later edits are preserved. /// Both supplied images must match `snapshot` and `remote_snapshot`; stale review /// returns `Io(ResourceBusy)`. Uncertain publication attempts cannot be rebased. @@ -270,6 +273,30 @@ impl Replica { )? .0, ), + Operation::Split(edit) => { + if !range.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Choose a single split boundary", + ) + .into()); + } + Operation::Split( + crate::paragraph::SplitEdit::capture( + remote, + intent.space, + &edit.intent.reposition(range.start), + )? + .0, + ) + } + Operation::Join(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Review the paragraph join using rebase_join_conflict", + ) + .into()); + } Operation::Insert(_) => { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -281,6 +308,21 @@ impl Replica { }) } + /// Reviews a join against both current cache images, retaining its original text identities. + /// The surviving text identity must remain the same for dependent edits. + pub fn rebase_join_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> { + self.resolve_conflict(id, local, remote, |intent| { + let Operation::Join(edit) = intent.operation else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Select a paragraph join conflict", + ) + .into()); + }; + Ok(Operation::Join(edit.review(remote, intent.space)?)) + }) + } + /// Repositions the oldest paragraph insertion conflict against reviewed cache images. /// Object identities and dependent edits are retained; uncertain attempts cannot be moved. pub fn rebase_paragraph_conflict( @@ -449,6 +491,7 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result ConflictKind::TargetUnavailable, 2 => ConflictKind::UnsupportedEdit, 3 => ConflictKind::FormattingChanged, + 4 => ConflictKind::StructureChanged, _ => { return Err(io::Error::new( io::ErrorKind::InvalidData, diff --git a/crates/onestore-offline/tests/cache.rs b/crates/onestore-offline/tests/cache.rs index a0f9025d8e4b3f02e6fc53ef6d45e8b9fe0aad2b..08a28a89bfd8eff88a926e1620a61d8a76bd394f 100644 --- a/crates/onestore-offline/tests/cache.rs +++ b/crates/onestore-offline/tests/cache.rs @@ -710,12 +710,19 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { #[test] fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { - for operation in ["Text", "Insert", "Format"] { + for operation in ["Text", "Insert", "Format", "Split", "Join"] { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("unknown.sqlite"); - let source = onestore::create_section("unknown.one", "Original", "Author").unwrap(); - let cache = Replica::create(&path, &source).unwrap(); + let mut source = onestore::create_section("unknown.one", "Original", "Author").unwrap(); let (sid, oid, _) = target(&source); + let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap(); + if operation == "Join" { + source = onestore::PreparedEdit::split(&source, sid, &split) + .unwrap() + .as_bytes() + .to_vec(); + } + let cache = Replica::create(&path, &source).unwrap(); if operation == "Insert" { let store = Store::parse(&source).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); @@ -726,6 +733,16 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { cache.insert(&source, sid, &insertion).unwrap(); } else if operation == "Text" { cache.edit_text(&source, sid, oid, 0..0, "New ").unwrap(); + } else if operation == "Split" { + cache.split(&source, sid, &split).unwrap(); + } else if operation == "Join" { + cache + .join( + &source, + sid, + &onestore::ParagraphJoin::new(oid, split.text_object(), "Author").unwrap(), + ) + .unwrap(); } else { cache .format( @@ -746,7 +763,7 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { value[operation]["future_option"] = true.into(); db.execute("UPDATE edits SET operation=?1", [value.to_string()]) .unwrap(); - if operation != "Format" { + if matches!(operation, "Text" | "Insert") { db.execute_batch("DROP TABLE assets; DROP TABLE conflicts; CREATE TABLE conflicts (edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 2)) STRICT; PRAGMA user_version=3;").unwrap(); } drop(db); @@ -757,3 +774,101 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { assert_eq!(fs::read(&path).unwrap(), before); } } + +#[test] +fn version_five_migration_retains_queue_evidence_and_enables_structural_conflicts() { + use onestore_offline::{ConflictKind, EditStatus, Recovery}; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("version-five.sqlite"); + let source = onestore::create_section("migration.one", "Original", "Author").unwrap(); + let (sid, oid, _) = target(&source); + let cache = Replica::create(&path, &source).unwrap(); + let first = cache + .edit_text(&source, sid, oid, 0..0, "New ") + .unwrap() + .unwrap(); + let second = cache + .format( + &cache.snapshot().unwrap(), + sid, + oid, + 0..3, + &[onestore::TextAttribute::Bold(true)], + ) + .unwrap() + .unwrap(); + let queue = cache.pending().unwrap(); + let local = cache.snapshot().unwrap(); + let store = Store::parse(&local).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; + drop(cache); + let db = rusqlite::Connection::open(&path).unwrap(); + db.execute_batch( + "DROP TABLE conflicts; CREATE TABLE conflicts ( + edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, + kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 3)) STRICT; + PRAGMA user_version=5; + UPDATE sqlite_sequence SET seq=1000 WHERE name='edits';", + ) + .unwrap(); + db.execute( + "INSERT INTO conflicts VALUES (?1,3)", + [i64::try_from(second).unwrap()], + ) + .unwrap(); + db.execute( + "INSERT INTO attempt VALUES (1,?1,?2)", + rusqlite::params![i64::try_from(first).unwrap(), revision.to_string()], + ) + .unwrap(); + db.execute( + "INSERT INTO receipts VALUES (999,?1)", + [revision.to_string()], + ) + .unwrap(); + drop(db); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.pending().unwrap(), queue); + assert!(cache.snapshot().unwrap() == local); + assert!(cache.remote_snapshot().unwrap() == source); + assert_eq!( + cache.status(first).unwrap(), + Some(EditStatus::AwaitingConfirmation { revision }) + ); + assert_eq!( + cache.status(second).unwrap(), + Some(EditStatus::Conflict(ConflictKind::FormattingChanged)) + ); + assert_eq!( + cache.status(999).unwrap(), + Some(EditStatus::Published { revision }) + ); + let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap(); + assert_eq!(cache.split(&local, sid, &split).unwrap(), Some(1001)); + let pending = cache.pending().unwrap(); + let archive = directory.path().join("recovery.sqlite"); + cache.export_recovery(&archive).unwrap(); + let recovery = Recovery::open(&archive).unwrap(); + assert_eq!(recovery.pending().unwrap(), pending); + assert_eq!( + recovery.status(first).unwrap(), + cache.status(first).unwrap() + ); + assert_eq!(recovery.receipts().unwrap().get(&999), Some(&revision)); + drop(cache); + let db = rusqlite::Connection::open(&path).unwrap(); + assert_eq!( + db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) + .unwrap(), + 6 + ); + db.execute("INSERT INTO conflicts VALUES (1001,4)", []) + .unwrap(); + drop(db); + let cache = Replica::open(&path).unwrap(); + assert_eq!( + cache.status(1001).unwrap(), + Some(EditStatus::Conflict(ConflictKind::StructureChanged)) + ); +} diff --git a/crates/onestore-offline/tests/sync.rs b/crates/onestore-offline/tests/sync.rs index 52d1f6b8a2115edf0fcc2d43fc41408c7c0ff349..3b991310b001555db1d28de3590002f3c01b078a 100644 --- a/crates/onestore-offline/tests/sync.rs +++ b/crates/onestore-offline/tests/sync.rs @@ -5,6 +5,528 @@ use onestore::{ use onestore_offline::{ConflictKind, EditStatus, Error, Remote, Replica}; use std::io; +mod paragraph { + use super::*; + use onestore::{Insertion, ParagraphJoin, ParagraphSplit, TextAttribute}; + + fn fixture() -> (Vec, ExGuid, ExGuid, ExGuid) { + let source = onestore::create_section("paragraph.one", "ab🦀cd", "Fixture").unwrap(); + let (sid, left, _) = text(&source); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let doc = Document::parse(&index).unwrap(); + let space = &doc.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let parent = *view + .nodes + .iter() + .find(|(_, node)| node.content == [left]) + .unwrap() + .0; + (source, sid, left, parent) + } + + #[test] + fn split_join_dependencies_reopen_and_rebase_with_remote_text_and_styles() { + let (source, sid, left, parent) = fixture(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("paragraph.sqlite"); + let mut cache = Replica::create(&path, &source).unwrap(); + let split = ParagraphSplit::new(left, 2, "Local").unwrap(); + let child = Insertion::paragraph(split.object(), None, "Child", "Local").unwrap(); + let join = ParagraphJoin::new(left, split.text_object(), "Local").unwrap(); + let mut ids = Vec::new(); + for step in 0..6 { + let current = cache.snapshot().unwrap(); + let id = match step { + 0 => cache.split(¤t, sid, &split), + 1 => cache.edit_text(¤t, sid, split.text_object(), 0..2, "🦋"), + 2 => cache.format( + ¤t, + sid, + split.text_object(), + 2..3, + &[TextAttribute::Bold(true)], + ), + 3 => cache.insert(¤t, sid, &child), + 4 => cache.join(¤t, sid, &join), + 5 => cache.edit_text(¤t, sid, left, 5..6, "D"), + _ => unreachable!(), + } + .unwrap() + .unwrap(); + ids.push(id); + let saved = cache.snapshot().unwrap(); + let pending = cache.pending().unwrap(); + drop(cache); + cache = Replica::open(&path).unwrap(); + assert_eq!(cache.snapshot().unwrap(), saved); + assert_eq!(cache.pending().unwrap(), pending); + } + let remote = onestore::replace_text(&source, sid, left, 0..0, "Z").unwrap(); + let remote = + PreparedEdit::format(&remote, sid, left, 1..2, &[TextAttribute::Italic(true)]).unwrap(); + let mut server = Server::new(remote.as_bytes()); + let local = cache.snapshot().unwrap(); + for (i, id) in ids.iter().enumerate() { + let (published, state) = cache.sync_once(&mut server).unwrap().unwrap(); + assert_eq!(published, *id); + assert!(matches!(state, EditStatus::Published { .. }), "{state:?}"); + drop(cache); + cache = Replica::open(&path).unwrap(); + assert_eq!(cache.status(*id).unwrap(), Some(state)); + if i != ids.len() - 1 { + assert_eq!(cache.snapshot().unwrap(), local); + } + } + assert_eq!(server.publications, ids.len()); + assert_eq!(server.visible, server.durable); + assert_eq!(cache.snapshot().unwrap(), server.durable); + let store = Store::parse(&server.durable).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + let doc = Document::parse(&index).unwrap(); + let space = &doc.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + assert!( + matches!(&view.nodes[&left].kind, Kind::RichText { text, .. } if text == "Zab🦋cD") + ); + assert_eq!(view.nodes[&parent].children, [child.object()]); + assert_eq!(view.nodes[&parent].content, [left]); + let runs = view.text_runs(left).unwrap(); + let chars: Vec<_> = runs + .iter() + .flat_map(|run| { + run.text + .chars() + .map(|c| (c, run.format.bold, run.format.italic)) + }) + .collect(); + assert_eq!(chars.iter().find(|v| v.0 == 'a').unwrap().2, Some(true)); + assert_eq!(chars.iter().find(|v| v.0 == 'c').unwrap().1, Some(true)); + } + + #[test] + fn changed_split_children_require_fresh_review_without_reallocating_dependents() { + let (source, sid, left, parent) = fixture(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("paragraph.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let split = ParagraphSplit::new(left, 2, "Local").unwrap(); + let id = cache.split(&source, sid, &split).unwrap().unwrap(); + let dependent = cache + .edit_text( + &cache.snapshot().unwrap(), + sid, + split.text_object(), + 0..2, + "🦋", + ) + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let child = Insertion::paragraph(parent, None, "Remote child", "Remote").unwrap(); + let remote = PreparedEdit::insert(&source, sid, &child).unwrap(); + let mut server = Server::new(remote.as_bytes()); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) + ); + assert_eq!(server.publications, 0); + assert_eq!(cache.snapshot().unwrap(), local); + assert!( + cache + .rebase_conflict(id, &source, &server.visible, 2..2) + .is_err() + ); + assert!( + cache + .rebase_conflict(id, &local, &server.visible, 2..3) + .is_err() + ); + cache + .rebase_conflict(id, &local, &server.visible, 2..2) + .unwrap(); + let pending = cache.pending().unwrap(); + let onestore_offline::Operation::Split(edit) = &pending[0].operation else { + panic!() + }; + assert_eq!(edit.intent, split); + assert_eq!(pending[1].id, dependent); + drop(cache); + let cache = Replica::open(&path).unwrap(); + for expected in [id, dependent] { + let (actual, state) = cache.sync_once(&mut server).unwrap().unwrap(); + assert_eq!(actual, expected); + assert!(matches!(state, EditStatus::Published { .. })); + } + let store = Store::parse(&server.durable).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let doc = Document::parse(&index).unwrap(); + let space = &doc.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + assert_eq!(view.nodes[&split.object()].children, [child.object()]); + assert!( + matches!(&view.nodes[&split.text_object()].kind, Kind::RichText { text, .. } if text == "🦋cd") + ); + } + + #[test] + fn uncertain_paragraph_operations_keep_the_original_attempt_across_reopen() { + for join in [false, true] { + for fault in [ + Fault::Before, + Fault::UnknownBefore, + Fault::UnknownAfter, + Fault::Committed, + ] { + let (source, sid, left, _) = fixture(); + let split = ParagraphSplit::new(left, 2, "Local").unwrap(); + let source = if join { + PreparedEdit::split(&source, sid, &split) + .unwrap() + .as_bytes() + .to_vec() + } else { + source + }; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("paragraph.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let id = if join { + cache.join( + &source, + sid, + &ParagraphJoin::new(left, split.text_object(), "Local").unwrap(), + ) + } else { + cache.split(&source, sid, &split) + } + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let intent = cache.pending().unwrap(); + let mut server = Server::new(&source); + server.fault = fault; + assert!(cache.sync_once(&mut server).is_err()); + let state = cache.status(id).unwrap().unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.status(id).unwrap(), Some(state)); + if matches!(fault, Fault::UnknownBefore | Fault::UnknownAfter) { + assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); + assert_eq!(cache.pending().unwrap(), intent); + assert_eq!(cache.snapshot().unwrap(), local); + if matches!(fault, Fault::UnknownBefore) { + assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, state))); + assert_eq!(server.publications, 1); + continue; + } + server.fault = Fault::Confirm; + assert!(cache.sync_once(&mut server).is_err()); + assert_eq!(cache.status(id).unwrap(), Some(state)); + let (_, state) = cache.sync_once(&mut server).unwrap().unwrap(); + assert!(matches!(state, EditStatus::Published { .. })); + assert_eq!(server.publications, 1); + } else if matches!(fault, Fault::Before) { + assert_eq!(state, EditStatus::Pending); + assert!(matches!( + cache.sync_once(&mut server).unwrap().unwrap().1, + EditStatus::Published { .. } + )); + assert_eq!(server.publications, 2); + } else { + assert!(matches!(state, EditStatus::Published { .. })); + assert_eq!(server.publications, 1); + } + assert!(cache.pending().unwrap().is_empty()); + let saved = cache.snapshot().unwrap(); + if matches!(fault, Fault::UnknownAfter) { + // Confirmation refreshes version metadata after reading the acknowledged snapshot. + assert!(saved[..212] == server.durable[..212]); + assert!(saved[252..] == server.durable[252..]); + assert_eq!( + Store::parse(&server.durable).unwrap().header.generation, + Store::parse(&saved).unwrap().header.generation + 1 + ); + } else { + assert!(saved == server.durable); + } + assert_eq!(cache.sync_once(&mut server).unwrap(), None); + assert!(cache.snapshot().unwrap() == server.durable); + } + } + } + + #[test] + fn join_reviews_preserve_survivors_and_require_current_child_placement() { + for empty in [false, true] { + let (source, sid, left, _) = fixture(); + let split = ParagraphSplit::new(left, if empty { 0 } else { 2 }, "Local").unwrap(); + let source = PreparedEdit::split(&source, sid, &split) + .unwrap() + .as_bytes() + .to_vec(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("join.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let intent = ParagraphJoin::new(left, split.text_object(), "Local").unwrap(); + let id = cache.join(&source, sid, &intent).unwrap().unwrap(); + let survivor = if empty { split.text_object() } else { left }; + let dependent = cache + .edit_text(&cache.snapshot().unwrap(), sid, survivor, 0..0, "Local ") + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + let child = + Insertion::paragraph(split.object(), None, "Remote child", "Remote").unwrap(); + let remote = PreparedEdit::insert(&source, sid, &child) + .unwrap() + .as_bytes() + .to_vec(); + let mut server = Server::new(&remote); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) + ); + assert!(cache.rebase_join_conflict(id, &source, &remote).is_err()); + assert_eq!(server.publications, 0); + cache.rebase_join_conflict(id, &local, &remote).unwrap(); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + ); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent) + ); + let store = Store::parse(&server.durable).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let doc = Document::parse(&index).unwrap(); + let space = &doc.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let parent = view + .nodes + .values() + .find(|node| node.content == [survivor]) + .unwrap(); + assert_eq!(parent.children, [child.object()]); + assert!( + matches!(&view.nodes[&survivor].kind, Kind::RichText { text, .. } if text == "Local ab🦀cd") + ); + } + + let (source, sid, left, _) = fixture(); + let split = ParagraphSplit::new(left, 0, "Local").unwrap(); + let source = PreparedEdit::split(&source, sid, &split) + .unwrap() + .as_bytes() + .to_vec(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("identity.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let intent = ParagraphJoin::new(left, split.text_object(), "Local").unwrap(); + let id = cache.join(&source, sid, &intent).unwrap().unwrap(); + let local = cache.snapshot().unwrap(); + let remote = onestore::replace_text(&source, sid, left, 0..0, "Remote").unwrap(); + let mut server = Server::new(&remote); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) + ); + assert!(cache.rebase_join_conflict(id, &local, &remote).is_err()); + assert!(cache.snapshot().unwrap() == local); + assert_eq!(server.publications, 0); + assert_eq!(cache.pending().unwrap().len(), 1); + } + + #[test] + fn overlapping_clients_retain_each_local_branch_without_replaying_removed_targets() { + let (source, sid, left, _) = fixture(); + let split = ParagraphSplit::new(left, 2, "Seed").unwrap(); + let source = PreparedEdit::split(&source, sid, &split) + .unwrap() + .as_bytes() + .to_vec(); + let directory = tempfile::tempdir().unwrap(); + let first = Replica::create(directory.path().join("first.sqlite"), &source).unwrap(); + let second_path = directory.path().join("second.sqlite"); + let second = Replica::create(&second_path, &source).unwrap(); + let join = ParagraphJoin::new(left, split.text_object(), "First").unwrap(); + first.join(&source, sid, &join).unwrap(); + let right_split = ParagraphSplit::new(split.text_object(), 2, "Second").unwrap(); + let id = second.split(&source, sid, &right_split).unwrap().unwrap(); + second + .edit_text( + &second.snapshot().unwrap(), + sid, + right_split.text_object(), + 0..0, + "Second ", + ) + .unwrap(); + let local = second.snapshot().unwrap(); + let pending = second.pending().unwrap(); + let mut server = Server::new(&source); + assert!(matches!( + first.sync_once(&mut server).unwrap().unwrap().1, + EditStatus::Published { .. } + )); + assert_eq!( + second.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::TargetUnavailable))) + ); + drop(second); + let second = Replica::open(&second_path).unwrap(); + assert_eq!(second.pending().unwrap(), pending); + assert!(second.snapshot().unwrap() == local); + assert_eq!(server.publications, 1); + assert_eq!( + second.status(id).unwrap(), + Some(EditStatus::Conflict(ConflictKind::TargetUnavailable)) + ); + } + + #[test] + #[ignore = "exports reconciled paragraph edits for independent native validation"] + fn export_native_offline_paragraphs() { + use std::{fs, path::PathBuf}; + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/paragraph-edit"); + let output = PathBuf::from(std::env::var_os("ONESTORE_OFFLINE_PARAGRAPH_OUTPUT").unwrap()); + assert!(output.is_absolute()); + fs::create_dir(&output).unwrap(); + for (name, source, manifest, split) in [ + ( + "splits", + "before/notebook/synthetic.one", + "rust-split/manifest.json", + true, + ), + ( + "joins", + "split/notebook/synthetic.one", + "rust-join/split/manifest.json", + false, + ), + ( + "inheritance", + "join-edges/before/notebook/synthetic.one", + "rust-join/inheritance/manifest.json", + false, + ), + ( + "tags", + "join-tags/before/notebook/synthetic.one", + "rust-join/tags/manifest.json", + false, + ), + ] { + let source = fs::read(root.join(source)).unwrap(); + let folder = output.join(name); + fs::create_dir(&folder).unwrap(); + let path = folder.join("cache.sqlite"); + let mut cache = Replica::create(&path, &source).unwrap(); + let mut server = Server::new(&source); + let manifest: serde_json::Value = + serde_json::from_slice(&fs::read(root.join(manifest)).unwrap()).unwrap(); + let cases = if split { &manifest["cases"] } else { &manifest }; + let mut recorded = Vec::new(); + for case in cases + .as_array() + .unwrap() + .iter() + .filter(|case| case.get("intent").is_some()) + { + let local = cache.snapshot().unwrap(); + let (text, offset) = if split { + serde_json::from_value::(case["intent"].clone()) + .unwrap() + .position() + } else { + ( + serde_json::from_value::(case["intent"].clone()) + .unwrap() + .texts()[0], + 1, + ) + }; + let store = Store::parse(&local).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let doc = Document::parse(&index).unwrap(); + let (sid, space) = doc + .spaces + .iter() + .find(|(_, space)| { + space.revisions[&space.contexts[&ExGuid::default()]] + .nodes + .contains_key(&text) + }) + .unwrap(); + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let Kind::RichText { text: content, .. } = &view.nodes[&text].kind else { + panic!() + }; + let remote_prefix = offset > 0 && !content.is_empty(); + assert!(!content.contains('☂')); + if remote_prefix { + server.visible = + onestore::replace_text(&server.visible, *sid, text, 0..0, "☂").unwrap(); + server.durable.clone_from(&server.visible); + } + let id = if split { + cache.split( + &local, + *sid, + &serde_json::from_value(case["intent"].clone()).unwrap(), + ) + } else { + cache.join( + &local, + *sid, + &serde_json::from_value(case["intent"].clone()).unwrap(), + ) + } + .unwrap() + .unwrap(); + recorded.push(serde_json::json!({"case": case["case"], "id": id, + "space": sid, "intent": case["intent"], "remote_prefix": remote_prefix})); + drop(cache); + cache = Replica::open(&path).unwrap(); + } + for case in &mut recorded { + let id = case["id"].as_u64().unwrap(); + if id % 2 == 0 { + server.fault = Fault::UnknownAfter; + assert!(cache.sync_once(&mut server).is_err()); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::AwaitingConfirmation { .. }) + )); + drop(cache); + cache = Replica::open(&path).unwrap(); + } + let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); + assert_eq!(actual, id); + let EditStatus::Published { revision } = status else { + panic!("{name}: {status:?}") + }; + case["revision"] = serde_json::to_value(revision).unwrap(); + } + assert_eq!(server.publications, recorded.len()); + assert_eq!(cache.sync_once(&mut server).unwrap(), None); + assert!(cache.snapshot().unwrap() == server.durable); + cache + .export_recovery(folder.join("recovery.sqlite")) + .unwrap(); + fs::create_dir(folder.join("candidate")).unwrap(); + fs::write(folder.join("candidate/synthetic.one"), &server.durable).unwrap(); + fs::write( + folder.join("manifest.json"), + serde_json::to_vec_pretty(&recorded).unwrap(), + ) + .unwrap(); + } + } +} + #[derive(Clone, Copy, Default)] enum Fault { #[default] @@ -739,7 +1261,7 @@ fn version_one_cache_migration_preserves_images_intents_and_local_ids() { assert_eq!( db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) .unwrap(), - 5 + 6 ); } diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 3bc5ce49fa5859bd51d2f536a316032009bcddf4..ba65490e12ddb14398b00f73bc25e4c9851c179d 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -95,8 +95,9 @@ Ambiguous ancestry, unsupported indentation transitions and unknown implicit font/language inheritance reject before I/O. This is a logical join, so keyboard actions that only change list or indentation state remain separate operations. Generated fields, protected targets and unsupported run-data boundary changes are -rejected before publication. Local caches expose text, insertion and formatting edits; -the [document-writer acceptance](../../evidence/MILESTONE9.md#document-writer-and-offline-acceptance) +rejected before publication. The [offline crate](../onestore-offline/README.md) +documents durable local operations and reconciliation. The +[document-writer acceptance](../../evidence/MILESTONE9.md#document-writer-and-offline-acceptance) includes twelve mixed native/Rust clients, outages, lost replies and native revision retirement. External `.onebin` references identify payloads for the caller to obtain. Cloud diff --git a/crates/onestore/src/paragraph.rs b/crates/onestore/src/paragraph.rs index d122deeebd33fee4774f7559c2d732aa6293a0f7..88b96ec981b077a4aef5eef3358577b812d17805 100644 --- a/crates/onestore/src/paragraph.rs +++ b/crates/onestore/src/paragraph.rs @@ -61,6 +61,19 @@ impl ParagraphSplit { } } + /// Original text identity and UTF-16 split boundary. + pub fn position(&self) -> (ExGuid, u32) { + (self.text, self.offset) + } + + /// Changes the split boundary while retaining allocated identities and creation time. + /// Preparing the edit validates the offset against the supplied text. + pub fn reposition(&self, offset: u32) -> Self { + let mut split = self.clone(); + split.offset = offset; + split + } + pub(crate) fn apply(&self, source: &[u8], space: ExGuid) -> Result, Error> { if self.guid == [0; 16] || self.text.guid == [0; 16] || self.author.contains('\0') { return Err(invalid( @@ -418,6 +431,11 @@ pub struct ParagraphJoin { } impl ParagraphJoin { + /// Ordered left and right text identities. + pub fn texts(&self) -> [ExGuid; 2] { + [self.left, self.right] + } + /// Select the preceding leaf paragraph's text and the following paragraph's text. pub fn new(left: ExGuid, right: ExGuid, author: &str) -> Result { if left == right || left.guid == [0; 16] || right.guid == [0; 16] || author.contains('\0') { diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index a2e4232b1ca6977f055ff4674e78bbf2830dfce9..4761c1d148069c58d00c94f6cffd3d36b55fe192 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -51,6 +51,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "cbc" version = "0.2.1" @@ -138,12 +144,46 @@ dependencies = [ "zeroize", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "getrandom" version = "0.4.3" @@ -155,6 +195,33 @@ dependencies = [ "r-efi", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "hybrid-array" version = "0.4.14" @@ -191,6 +258,16 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.189" @@ -207,6 +284,23 @@ dependencies = [ "cc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "md5" version = "0.8.1" @@ -231,6 +325,12 @@ dependencies = [ "libc", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "onestore" version = "0.1.0" @@ -255,9 +355,40 @@ dependencies = [ "libfuzzer-sys", "md5", "onestore", + "onestore-offline", "serde_json", + "tempfile", ] +[[package]] +name = "onestore-notebook" +version = "0.1.0" +dependencies = [ + "onestore", + "serde", + "thiserror", +] + +[[package]] +name = "onestore-offline" +version = "0.1.0" +dependencies = [ + "onestore", + "onestore-notebook", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -291,6 +422,50 @@ dependencies = [ "memchr", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "serde" version = "1.0.229" @@ -345,12 +520,41 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "subtle" version = "2.6.1" @@ -368,6 +572,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typenum" version = "1.20.1" @@ -380,6 +617,72 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "zeroize" version = "1.9.0" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 3c0a6bb007fa7d2349e20ebf0db8b3539a536c65..8030aaced5f9bd27b8999e7e3a26402a8d929126 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -10,6 +10,8 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" onestore = { path = "../crates/onestore", features = ["protected"] } +onestore-offline = { path = "../crates/onestore-offline" } +tempfile = "3" md5 = "0.8.1" serde_json = "1" @@ -91,3 +93,10 @@ path = "fuzz_targets/paragraph.rs" test = false doc = false bench = false + +[[bin]] +name = "offline_paragraph" +path = "fuzz_targets/offline_paragraph.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/offline_paragraph.rs b/fuzz/fuzz_targets/offline_paragraph.rs new file mode 100644 index 0000000000000000000000000000000000000000..b826400660dbcc2ca393a2c005adfef6887fa229 --- /dev/null +++ b/fuzz/fuzz_targets/offline_paragraph.rs @@ -0,0 +1,165 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use onestore::{ + CommitError, ExGuid, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex, Store, + document::{Document, Kind}, +}; +use onestore_offline::{EditStatus, Remote, Replica}; +use std::{io, sync::LazyLock}; + +#[path = "../../crates/onestore/tests/support/disk.rs"] +mod disk; + +const TEXT: &str = "a🦀 e\u{301} 東京\rEnd"; + +static SOURCE: LazyLock<(Vec, ExGuid, ExGuid)> = LazyLock::new(|| { + let source = onestore::create_section("offline-paragraph.one", TEXT, "Fixture").unwrap(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let doc = Document::parse(&index).unwrap(); + let (sid, page) = doc.pages().unwrap()[0]; + let space = &doc.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let outline = *view.nodes[&page] + .children + .iter() + .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) + .unwrap(); + (source, sid, outline) +}); + +struct Server(disk::Disk); + +impl Remote for Server { + fn read(&mut self) -> io::Result> { + Ok(self.0.visible.clone()) + } + fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + edit.commit(&mut self.0) + } + fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + onestore::confirm_snapshot(&mut self.0, snapshot) + } +} + +fn paragraphs(source: &[u8], sid: ExGuid, outline: ExGuid) -> Vec<(ExGuid, String)> { + let store = Store::parse(source).unwrap(); + assert!(store.checksum_mismatches.is_empty()); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + let doc = Document::parse(&index).unwrap(); + let space = &doc.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let mut output = Vec::new(); + for paragraph in &view.nodes[&outline].children { + let node = &view.nodes[paragraph]; + assert!(node.children.is_empty()); + let [text] = node.content.as_slice() else { + panic!() + }; + let Kind::RichText { text: content, .. } = &view.nodes[text].kind else { + panic!() + }; + output.push((*text, content.clone())); + } + assert_eq!( + output + .iter() + .map(|(_, text)| text.as_str()) + .collect::(), + TEXT + ); + output +} + +fuzz_target!(|input: &[u8]| { + if input.len() < 8 { + return; + } + let (source, sid, outline) = &*SOURCE; + let directory = tempfile::tempdir().unwrap(); + let mut replicas: [Option; 12] = std::array::from_fn(|_| None); + let mut server = Server(disk::Disk { + visible: source.clone(), + durable: source.clone(), + operation: 0, + fail_at: None, + write_limit: 4096, + random: 1, + }); + for step in input.chunks_exact(8).take(24) { + let actor = usize::from(step[0]) % replicas.len(); + let path = directory.path().join(format!("{actor}.sqlite")); + let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap()); + let snapshot = cache.snapshot().unwrap(); + let pending = cache.pending().unwrap(); + let rows = paragraphs(&snapshot, *sid, *outline); + match step[1] % 5 { + 0 => { + let (text, content) = &rows[usize::from(step[2]) % rows.len()]; + let offsets: Vec = std::iter::once(0) + .chain(content.chars().scan(0, |at, c| { + *at += c.len_utf16() as u32; + Some(*at) + })) + .collect(); + let offset = offsets[usize::from(step[3]) % offsets.len()]; + let intent = ParagraphSplit::new(*text, offset, "Fuzz").unwrap(); + let id = cache.split(&snapshot, *sid, &intent).unwrap().unwrap(); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + let next = cache.pending().unwrap(); + assert_eq!(next[..pending.len()], pending); + assert_eq!(next.len(), pending.len() + 1); + } + 1 if rows.len() > 1 => { + let pair = usize::from(step[2]) % (rows.len() - 1); + let intent = ParagraphJoin::new(rows[pair].0, rows[pair + 1].0, "Fuzz").unwrap(); + let id = cache.join(&snapshot, *sid, &intent).unwrap().unwrap(); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + let next = cache.pending().unwrap(); + assert_eq!(next[..pending.len()], pending); + assert_eq!(next.len(), pending.len() + 1); + } + 2 | 3 => { + server.0.operation = 0; + server.0.fail_at = + (step[4] != 0).then_some(usize::from(u16::from_le_bytes([step[4], step[5]]))); + server.0.write_limit = if step[6] & 1 == 0 { 17 } else { 4096 }; + server.0.random = u64::from(step[7]) + 1; + let _ = cache.sync_once(&mut server); + let after = cache.pending().unwrap(); + assert!(after.len() == pending.len() || after.len() + 1 == pending.len()); + if after.len() == pending.len() { + assert_eq!(after, pending); + } else { + assert_eq!(after, pending[1..]); + let Some(EditStatus::Published { revision }) = + cache.status(pending[0].id).unwrap() + else { + panic!() + }; + let store = Store::parse(&server.0.durable).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + assert!(index.spaces[sid].revisions.contains_key(&revision)); + } + if !after.is_empty() { + assert!(cache.snapshot().unwrap() == snapshot); + } + server.0.visible.clone_from(&server.0.durable); + } + _ => { + drop(replicas[actor].take()); + let reopened = Replica::open(&path).unwrap(); + assert!(reopened.snapshot().unwrap() == snapshot); + assert_eq!(reopened.pending().unwrap(), pending); + replicas[actor] = Some(reopened); + } + } + paragraphs(&server.0.durable, *sid, *outline); + paragraphs( + &replicas[actor].as_ref().unwrap().snapshot().unwrap(), + *sid, + *outline, + ); + } +}); diff --git a/tools/test_paragraph_edit.py b/tools/test_paragraph_edit.py index dd58f808b585e7a2b4e84e921ab4decab224f1bd..62bfe9cd08b5214d27602dd37f5b6e0f5a2cf497 100644 --- a/tools/test_paragraph_edit.py +++ b/tools/test_paragraph_edit.py @@ -17,31 +17,64 @@ compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] class ParagraphEditTest(unittest.TestCase): + def assert_cold_graph(self, fixture): + with TemporaryDirectory() as temporary: + models = [] + for source in ('candidate', 'native/notebook'): + folder = Path(temporary) / source.replace('/', '-') + shutil.copytree(fixture / 'native/read', folder / 'read') + compare(fixture / source, folder / 'read') + subprocess.run([EXPORTER, fixture / source / 'synthetic.one', folder / 'model'], check=True) + model = json.loads((folder / 'model/document.json').read_text()) + models.append({r['nodes'][r['roots']['2']]['kind']['title']: (r, page) + for _, _, r, page in ordered_pages(model)}) + self.assertEqual(models[0].keys(), models[1].keys()) + for title, (old, page) in models[0].items(): + saved, saved_page = models[1][title] + self.assertEqual(saved_page, page) + for outline in old['nodes'][page]['children']: + if old['nodes'][outline]['kind']['type'] != 'Outline': continue + for oid, node in walk(old, outline): + self.assertEqual(saved['nodes'][oid]['children'], node['children']) + self.assertEqual(saved['nodes'][oid]['content'], node['content']) + + def test_offline_boundaries_retain_remote_edits_and_match_native_controls(self): + groups = [('splits', 'cold-split/read', 12), ('joins', 'joined/read', 12), + ('inheritance', 'join-edges/joined/read', 5), ('tags', 'join-tags/joined/read', 3)] + for name, control, count in groups: + fixture = FIXTURE / 'offline' / name + manifest = json.loads((fixture / 'manifest.json').read_text()) + self.assertEqual(len(manifest), count) + with self.subTest(group=name): + self.assert_cold_graph(fixture) + captures = [] + for folder in (FIXTURE / control, fixture / 'native/read'): + captures.append({page.get('name'): native_characters(page, page.findall('one:Outline', ns)) + for path in folder.glob('page-*.xml') for page in [ET.parse(path).getroot()]}) + for case in manifest: + self.assertTrue(case['revision']) + original = captures[0][case['case']] + current = captures[1][case['case']] + markers = 0 + for old, new in zip(original, current, strict=True): + if new and new[0][0] == '☂': + markers += 1 + new = new[1:] + for (a, left), (b, right) in zip(old, new, strict=True): + self.assertEqual(a, b) + for key in left.keys() | right.keys(): + default = 'automatic' if key in ('color', 'highlight') else False + self.assertEqual(left.get(key, default), right.get(key, default)) + self.assertEqual(markers, int(case['remote_prefix'])) + def test_rust_joins_match_native_controls_and_retain_cold_graph_identities(self): for name, control in [('split', FIXTURE / 'joined/read'), ('inheritance', FIXTURE / 'join-edges/joined/read'), ('tags', FIXTURE / 'join-tags/joined/read')]: fixture = FIXTURE / 'rust-join' / name manifest = json.loads((fixture / 'manifest.json').read_text()) - with self.subTest(fixture=name), TemporaryDirectory() as temporary: - models = [] - for source in ('candidate', 'native/notebook'): - folder = Path(temporary) / source.replace('/', '-') - shutil.copytree(fixture / 'native/read', folder / 'read') - compare(fixture / source, folder / 'read') - subprocess.run([EXPORTER, fixture / source / 'synthetic.one', folder / 'model'], check=True) - document = json.loads((folder / 'model/document.json').read_text()) - models.append({r['nodes'][r['roots']['2']]['kind']['title']: (r, page) - for _, _, r, page in ordered_pages(document)}) - self.assertEqual(models[0].keys(), models[1].keys()) - for title, (old, page) in models[0].items(): - saved, saved_page = models[1][title] - self.assertEqual(page, saved_page) - for outline in old['nodes'][page]['children']: - if old['nodes'][outline]['kind']['type'] != 'Outline': continue - for oid, node in walk(old, outline): - self.assertEqual(saved['nodes'][oid]['children'], node['children']) - self.assertEqual(saved['nodes'][oid]['content'], node['content']) + with self.subTest(fixture=name): + self.assert_cold_graph(fixture) captures = [] for folder in (control, fixture / 'native/read'): captures.append({page.get('name'): native_characters(page, page.findall('one:Outline', ns))