diff --git a/corpus/paragraph-edit/README.md b/corpus/paragraph-edit/README.md index bd0c08d60565a9d42b08016c4b3067b6c9966feb..162562834f34293f17959d97333e96bee9d54e71 100644 --- a/corpus/paragraph-edit/README.md +++ b/corpus/paragraph-edit/README.md @@ -34,8 +34,7 @@ directory comes from `ONESTORE_PARAGRAPH_OUTPUT`. Native character/style compari use both the keyboard-generated controls and the Rust-written notebooks. Empty typing checks extend the preexisting empty run in an independent expected model. -`tools/test_paragraph_edit.py` verifies these controls without a VM. The native -join captures establish behavior for subsequent join implementation. +`tools/test_paragraph_edit.py` verifies these controls without a VM. Identical captured files link to one canonical copy within this corpus. `join-edges`, authored with `tools/native/paragraph-joins.ps1`, adds five native @@ -48,3 +47,16 @@ The untouched parent's text gains a timestamp and `0x880034dd`, as in the earlie native controls; its content and other properties stay unchanged. The test fixes these observed graph, identity and metadata effects alongside independent native character-style comparisons. + +`join-tags`, authored with `tools/native/paragraph-tag-joins.ps1`, confirms the +left-tag rule for an empty untagged left paragraph, an empty tagged left paragraph +with a different right tag, and two nonempty paragraphs with different tags. +Right-side tags disappear in all three cases; only the left tag survives. + +`rust-join` retains twenty Rust joins across the original splits, inheritance +edges and additional tag controls. Each group has a cold OneNote capture and +saved notebook. Public tests compare the Rust and native-saved images to native +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`. diff --git a/corpus/paragraph-edit/join-tags/before/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/join-tags/before/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..064330348dfb7dd96b98ad3f0271099cba301e34 Binary files /dev/null and b/corpus/paragraph-edit/join-tags/before/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/paragraph-edit/join-tags/before/notebook/synthetic.one b/corpus/paragraph-edit/join-tags/before/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..fd8f46b91324c8903165bf061b93f60d8381f329 Binary files /dev/null and b/corpus/paragraph-edit/join-tags/before/notebook/synthetic.one differ diff --git a/corpus/paragraph-edit/join-tags/before/read/environment.json b/corpus/paragraph-edit/join-tags/before/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..071b20d3dd9ce19fa79bfc9f7e55dbf524246b93 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/before/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-57E1FB2A", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/paragraph-edit/join-tags/before/read/hierarchy.xml b/corpus/paragraph-edit/join-tags/before/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..593493e32f7c5cb4c5abcb2505a132c6ec2c9cc5 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/before/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-edit/join-tags/before/read/page-000.xml b/corpus/paragraph-edit/join-tags/before/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..1d7a403358436a0e4b3eb3b42b4d9a1d9521bb57 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/before/read/page-000.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/join-tags/before/read/page-001.xml b/corpus/paragraph-edit/join-tags/before/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..fc636e01a16d90ec85fb0d7e9f9ecb723582ca97 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/before/read/page-001.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/join-tags/before/read/page-002.xml b/corpus/paragraph-edit/join-tags/before/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..6d727eb5dd1a3515795c1ff680c1ef288e37519b --- /dev/null +++ b/corpus/paragraph-edit/join-tags/before/read/page-002.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/join-tags/before/read/page-003.xml b/corpus/paragraph-edit/join-tags/before/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..d4206f1d03b81dac0ce27b398d4192217c46cb27 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/before/read/page-003.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/join-tags/before/read/payloads.json b/corpus/paragraph-edit/join-tags/before/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..3cf45dc1acb611d7f764e0340d73e87192f81407 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/before/read/payloads.json @@ -0,0 +1 @@ +../../../before/read/payloads.json \ No newline at end of file diff --git a/corpus/paragraph-edit/join-tags/cases.json b/corpus/paragraph-edit/join-tags/cases.json new file mode 100644 index 0000000000000000000000000000000000000000..56d9b3d53dbe3a1c2ba1a84b037700a289ecec77 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/cases.json @@ -0,0 +1,17 @@ +[ + { + "body": "\u003cone:OE\u003e\u003cone:T\u003e\u003c![CDATA[]]\u003e\u003c/one:T\u003e\u003c/one:OE\u003e\u003cone:OE\u003e\u003cone:Tag index=\"1\" completed=\"true\" disabled=\"false\" creationDate=\"2020-01-02T03:04:05.000Z\" completionDate=\"2020-01-02T04:05:06.000Z\"/\u003e\u003cone:T\u003e\u003c![CDATA[\u003cb\u003eBold\u003c/b\u003e \u003ci\u003eitalic\u003c/i\u003e 🦀 é \u003cspan style=\"color:#123456\"\u003etail\u003c/span\u003e]]\u003e\u003c/one:T\u003e\u003c/one:OE\u003e", + "name": "Join empty left right tag", + "page": "{110D64A5-F4AB-4699-B620-CF39E7595E3D}{1}{B0}" + }, + { + "body": "\u003cone:OE\u003e\u003cone:Tag index=\"0\" completed=\"true\" disabled=\"false\" creationDate=\"2020-01-02T03:04:05.000Z\" completionDate=\"2020-01-02T04:05:06.000Z\"/\u003e\u003cone:T\u003e\u003c![CDATA[]]\u003e\u003c/one:T\u003e\u003c/one:OE\u003e\u003cone:OE\u003e\u003cone:Tag index=\"1\" completed=\"true\" disabled=\"false\" creationDate=\"2020-01-02T03:04:05.000Z\" completionDate=\"2020-01-02T04:05:06.000Z\"/\u003e\u003cone:T\u003e\u003c![CDATA[\u003cb\u003eBold\u003c/b\u003e \u003ci\u003eitalic\u003c/i\u003e 🦀 é \u003cspan style=\"color:#123456\"\u003etail\u003c/span\u003e]]\u003e\u003c/one:T\u003e\u003c/one:OE\u003e", + "name": "Join empty left two tags", + "page": "{4D3539F2-613D-47F1-AD5A-6D8EAD9D9901}{1}{B0}" + }, + { + "body": "\u003cone:OE\u003e\u003cone:Tag index=\"0\" completed=\"true\" disabled=\"false\" creationDate=\"2020-01-02T03:04:05.000Z\" completionDate=\"2020-01-02T04:05:06.000Z\"/\u003e\u003cone:T\u003eLeft\u003c/one:T\u003e\u003c/one:OE\u003e\u003cone:OE\u003e\u003cone:Tag index=\"1\" completed=\"true\" disabled=\"false\" creationDate=\"2020-01-02T03:04:05.000Z\" completionDate=\"2020-01-02T04:05:06.000Z\"/\u003e\u003cone:T\u003e\u003c![CDATA[\u003cb\u003eBold\u003c/b\u003e \u003ci\u003eitalic\u003c/i\u003e 🦀 é \u003cspan style=\"color:#123456\"\u003etail\u003c/span\u003e]]\u003e\u003c/one:T\u003e\u003c/one:OE\u003e", + "name": "Join different tags", + "page": "{8266EEF9-BFB3-4061-B62F-67D83F1A49FF}{1}{B0}" + } +] diff --git a/corpus/paragraph-edit/join-tags/joined/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/join-tags/joined/notebook/Open Notebook.onetoc2 new file mode 120000 index 0000000000000000000000000000000000000000..a6c7d197861f52dda5ecaf4da2ab7e471f409518 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/notebook/Open Notebook.onetoc2 @@ -0,0 +1 @@ +../../before/notebook/Open Notebook.onetoc2 \ No newline at end of file diff --git a/corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one b/corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..858448c79a0ad1bc5f50a95f163873f9e62040a9 Binary files /dev/null and b/corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one differ diff --git a/corpus/paragraph-edit/join-tags/joined/read/environment.json b/corpus/paragraph-edit/join-tags/joined/read/environment.json new file mode 120000 index 0000000000000000000000000000000000000000..032ba3b41dd4114f7b416f0781d9c5fb61796d7f --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/read/environment.json @@ -0,0 +1 @@ +../../before/read/environment.json \ No newline at end of file diff --git a/corpus/paragraph-edit/join-tags/joined/read/hierarchy.xml b/corpus/paragraph-edit/join-tags/joined/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..ec9545937600adfba928ffcdf4c17f7d22497c18 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-edit/join-tags/joined/read/page-000.xml b/corpus/paragraph-edit/join-tags/joined/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..84e778b834e7190545c998832644e5d83c98b8c0 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/read/page-000.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/join-tags/joined/read/page-001.xml b/corpus/paragraph-edit/join-tags/joined/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..0fdc79afba55934ca76360ee627a657709889a9e --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/read/page-001.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/join-tags/joined/read/page-002.xml b/corpus/paragraph-edit/join-tags/joined/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..291c17183d5b531f4dad9c71f2ed65cf64b91a11 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/read/page-002.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/join-tags/joined/read/page-003.xml b/corpus/paragraph-edit/join-tags/joined/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..98134b3e8ee9aa22f1ddb6b4e695d04af80c1a40 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/read/page-003.xml @@ -0,0 +1,5 @@ + +LeftBold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/join-tags/joined/read/payloads.json b/corpus/paragraph-edit/join-tags/joined/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..3cf45dc1acb611d7f764e0340d73e87192f81407 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/read/payloads.json @@ -0,0 +1 @@ +../../../before/read/payloads.json \ No newline at end of file diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.ahk b/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.ahk new file mode 100644 index 0000000000000000000000000000000000000000..4cd99f2353e5997f4bb7efb5d1f1927705e40cdd --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.ahk @@ -0,0 +1,22 @@ +#Requires AutoHotkey v2.0 +OnError((exception, mode) => (FileAppend(exception.Message, "**"), ExitApp(1))) +dm := Buffer(220, 0) +NumPut("UShort", 220, dm, 68) +if !DllCall("EnumDisplaySettingsW", "Ptr", 0, "UInt", 0xFFFFFFFF, "Ptr", dm) + throw Error("Cannot inspect display mode") +NumPut("UInt", NumGet(dm, 72, "UInt") | 0x180000, dm, 72) +NumPut("UInt", 1280, dm, 172) +NumPut("UInt", 720, dm, 176) +if DllCall("ChangeDisplaySettingsW", "Ptr", dm, "UInt", 0, "Int") != 0 + throw Error("Display mode rejected") +app := ComObject("OneNote.Application") +app.NavigateTo("{B93EF35F-621E-4F9A-90D4-B55A0CF7E4AA}{1}{B0}", "{998EEB43-1A80-48BB-B928-2AE6052587A2}{46}{B0}", false) +hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 10) +if !hwnd + throw Error("OneNote window did not appear") +WinMaximize(hwnd) +WinActivate(hwnd) +if !WinWaitActive(hwnd,, 10) + throw Error("OneNote window did not become active") +Sleep 300 +Send "{Left}{Backspace}" diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.json b/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.json new file mode 100644 index 0000000000000000000000000000000000000000..a7131b561a3dc5e5afc8eae4b4100e6b42e02e20 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 1280, + "h": 720, + "error": null, + "win": { + "title": "Join different tags - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.png b/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.png new file mode 100644 index 0000000000000000000000000000000000000000..7afde0130f593b6762527cc1f73a16739e2aa2eb Binary files /dev/null and b/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.png differ diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.ahk b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.ahk new file mode 100644 index 0000000000000000000000000000000000000000..49844f898faf78fab6c63bbda071c3c6e0d6eed8 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.ahk @@ -0,0 +1,22 @@ +#Requires AutoHotkey v2.0 +OnError((exception, mode) => (FileAppend(exception.Message, "**"), ExitApp(1))) +dm := Buffer(220, 0) +NumPut("UShort", 220, dm, 68) +if !DllCall("EnumDisplaySettingsW", "Ptr", 0, "UInt", 0xFFFFFFFF, "Ptr", dm) + throw Error("Cannot inspect display mode") +NumPut("UInt", NumGet(dm, 72, "UInt") | 0x180000, dm, 72) +NumPut("UInt", 1280, dm, 172) +NumPut("UInt", 720, dm, 176) +if DllCall("ChangeDisplaySettingsW", "Ptr", dm, "UInt", 0, "Int") != 0 + throw Error("Display mode rejected") +app := ComObject("OneNote.Application") +app.NavigateTo("{2A557903-2906-4962-90DB-1DBBD4B4F368}{1}{B0}", "{DB20B227-1FF5-4341-9621-D7C3E0EEE79B}{46}{B0}", false) +hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 10) +if !hwnd + throw Error("OneNote window did not appear") +WinMaximize(hwnd) +WinActivate(hwnd) +if !WinWaitActive(hwnd,, 10) + throw Error("OneNote window did not become active") +Sleep 300 +Send "{Left}{Backspace}" diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.json b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.json new file mode 100644 index 0000000000000000000000000000000000000000..1b7b1e5e4f0dbdca9c6dff38dbf3dd087b547a96 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 1280, + "h": 720, + "error": null, + "win": { + "title": "Join empty left right tag - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.png b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.png new file mode 100644 index 0000000000000000000000000000000000000000..7def31a3e394687523edf16b4d9537af80862ff4 Binary files /dev/null and b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.png differ diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.ahk b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.ahk new file mode 100644 index 0000000000000000000000000000000000000000..c851fdf2c0e3272bb51e512cb3269632ffe07273 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.ahk @@ -0,0 +1,22 @@ +#Requires AutoHotkey v2.0 +OnError((exception, mode) => (FileAppend(exception.Message, "**"), ExitApp(1))) +dm := Buffer(220, 0) +NumPut("UShort", 220, dm, 68) +if !DllCall("EnumDisplaySettingsW", "Ptr", 0, "UInt", 0xFFFFFFFF, "Ptr", dm) + throw Error("Cannot inspect display mode") +NumPut("UInt", NumGet(dm, 72, "UInt") | 0x180000, dm, 72) +NumPut("UInt", 1280, dm, 172) +NumPut("UInt", 720, dm, 176) +if DllCall("ChangeDisplaySettingsW", "Ptr", dm, "UInt", 0, "Int") != 0 + throw Error("Display mode rejected") +app := ComObject("OneNote.Application") +app.NavigateTo("{766D2454-BC90-480A-8BA1-BF0C9E703454}{1}{B0}", "{5237862E-022E-4FC4-8E36-D9C8E010D9E1}{47}{B0}", false) +hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 10) +if !hwnd + throw Error("OneNote window did not appear") +WinMaximize(hwnd) +WinActivate(hwnd) +if !WinWaitActive(hwnd,, 10) + throw Error("OneNote window did not become active") +Sleep 300 +Send "{Left}{Backspace}" diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.json b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.json new file mode 100644 index 0000000000000000000000000000000000000000..1faac657daf930348836578a92c8d73bf39fa538 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 1280, + "h": 720, + "error": null, + "win": { + "title": "Join empty left two tags - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.png b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.png new file mode 100644 index 0000000000000000000000000000000000000000..07207d8ae0b7612aea91a6c1717b3e79b0700281 Binary files /dev/null and b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.png differ diff --git a/corpus/paragraph-edit/join-tags/joined/ui/selections.json b/corpus/paragraph-edit/join-tags/joined/ui/selections.json new file mode 100644 index 0000000000000000000000000000000000000000..0ef76b57c78091049b0d48d0920bef1a3c9a4eb1 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/joined/ui/selections.json @@ -0,0 +1,20 @@ +[ + { + "case": "Join empty left right tag", + "page": "{2A557903-2906-4962-90DB-1DBBD4B4F368}{1}{B0}", + "object": "{DB20B227-1FF5-4341-9621-D7C3E0EEE79B}{46}{B0}", + "keys": "{Left}{Backspace}" + }, + { + "case": "Join empty left two tags", + "page": "{766D2454-BC90-480A-8BA1-BF0C9E703454}{1}{B0}", + "object": "{5237862E-022E-4FC4-8E36-D9C8E010D9E1}{47}{B0}", + "keys": "{Left}{Backspace}" + }, + { + "case": "Join different tags", + "page": "{B93EF35F-621E-4F9A-90D4-B55A0CF7E4AA}{1}{B0}", + "object": "{998EEB43-1A80-48BB-B928-2AE6052587A2}{46}{B0}", + "keys": "{Left}{Backspace}" + } +] \ No newline at end of file diff --git a/corpus/paragraph-edit/join-tags/run.json b/corpus/paragraph-edit/join-tags/run.json new file mode 100644 index 0000000000000000000000000000000000000000..6ba9afe3f14a07d8fb9b73fcae81069719dbd9c4 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/run.json @@ -0,0 +1,19 @@ +{ + "notebook": "/Users/clo/dev/one/corpus/formatted-insertion/native-paragraph/candidate", + "expected_pages": 4, + "author": "evidence/m10/native-paragraph-tag-joins.ps1", + "author_timeout_seconds": 600, + "inspect": true, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "author.ps1": "f975577d6abdb21e6cc8e2a6f605ab239047108e6da2e2f804ab8197991d86e7", + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/paragraph-edit/join-tags/source.json b/corpus/paragraph-edit/join-tags/source.json new file mode 120000 index 0000000000000000000000000000000000000000..4a9bb6bad713be61d465b5795764dd2e121941a8 --- /dev/null +++ b/corpus/paragraph-edit/join-tags/source.json @@ -0,0 +1 @@ +../join-edges/source.json \ No newline at end of file diff --git a/corpus/paragraph-edit/join-tags/teardown.json b/corpus/paragraph-edit/join-tags/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..e7edcabb62ab061e4e24fd304ce5b814f51c282e --- /dev/null +++ b/corpus/paragraph-edit/join-tags/teardown.json @@ -0,0 +1 @@ +../join-edges/teardown.json \ No newline at end of file diff --git a/corpus/paragraph-edit/rust-join/inheritance/candidate/synthetic.one b/corpus/paragraph-edit/rust-join/inheritance/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..1711afce11399174743d602c7c9a66789f663343 Binary files /dev/null and b/corpus/paragraph-edit/rust-join/inheritance/candidate/synthetic.one differ diff --git a/corpus/paragraph-edit/rust-join/inheritance/manifest.json b/corpus/paragraph-edit/rust-join/inheritance/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..5a7e1cd0111c4dea215677704946a71105260cbd --- /dev/null +++ b/corpus/paragraph-edit/rust-join/inheritance/manifest.json @@ -0,0 +1,47 @@ +[ + { + "case": "Join inherited styles", + "intent": { + "author": "Rust join author", + "left": "{10952FC5-A849-0DE5-03A7-5D9304627DA7},44", + "right": "{10952FC5-A849-0DE5-03A7-5D9304627DA7},50" + }, + "space": "{D6CEB12D-D1B7-033B-20A2-E9B6FF501602},1" + }, + { + "case": "Join empty left tag", + "intent": { + "author": "Rust join author", + "left": "{8BAFC64A-8457-0253-3906-57D27F8546B7},45", + "right": "{8BAFC64A-8457-0253-3906-57D27F8546B7},51" + }, + "space": "{F6E7A6F0-805F-0858-2B5A-4B1260940CF9},1" + }, + { + "case": "Join right tag", + "intent": { + "author": "Rust join author", + "left": "{2E9A20A9-1E0E-0F04-0FCE-8497CF032068},44", + "right": "{2E9A20A9-1E0E-0F04-0FCE-8497CF032068},49" + }, + "space": "{04EE80E5-584B-04E0-1F05-07D86902DFA1},1" + }, + { + "case": "Join both tags", + "intent": { + "author": "Rust join author", + "left": "{7A5195E3-F792-0C49-1F3C-EBBFCE774FE6},44", + "right": "{7A5195E3-F792-0C49-1F3C-EBBFCE774FE6},50" + }, + "space": "{631A0B54-DBB1-0988-22E1-96E8B1202B59},1" + }, + { + "case": "Join both children", + "intent": { + "author": "Rust join author", + "left": "{0384F5F6-5F0B-0164-32C6-6E97A4F1B661},49", + "right": "{0384F5F6-5F0B-0164-32C6-6E97A4F1B661},54" + }, + "space": "{D7D982D1-A3D3-0690-1181-A9703CF717E5},1" + } +] \ No newline at end of file diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/rust-join/inheritance/native/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..b0893a34c508a3537f3d2854a0f0e040034a97e8 Binary files /dev/null and b/corpus/paragraph-edit/rust-join/inheritance/native/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/notebook/synthetic.one b/corpus/paragraph-edit/rust-join/inheritance/native/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..9df4b97872e8ecc4b5ca09e2924edf639639e2ba Binary files /dev/null and b/corpus/paragraph-edit/rust-join/inheritance/native/notebook/synthetic.one differ diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/environment.json b/corpus/paragraph-edit/rust-join/inheritance/native/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..a131c4590186f953625489f6ee0491aa2bbb4782 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-36919857", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/hierarchy.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..43d800750395b12aa75a91372e4b9c71a0bef23c --- /dev/null +++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/page-000.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..190bc6162af54e692b4f3f5ff984bb7ddcb2b694 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-000.xml @@ -0,0 +1,5 @@ + +LeftBold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/page-001.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..b9f4e7f4664373221d9be021e23c8037e981272a --- /dev/null +++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-001.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/page-002.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..bead0314c4f44865b27916f59f3571c91c851c5f --- /dev/null +++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-002.xml @@ -0,0 +1,5 @@ + +LeftBold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/page-003.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..d14c76a42aed7d91c2a05b10de9214c850b82690 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-003.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/page-004.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..8a02d17b989865a26a76c6c2b19fc04e5288172a --- /dev/null +++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-004.xml @@ -0,0 +1,5 @@ + +Left childBold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/page-005.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..3a1c39eba71067e803c41b44105816ffde404fc4 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-005.xml @@ -0,0 +1,5 @@ + +LeftRight italic]]> diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/payloads.json b/corpus/paragraph-edit/rust-join/inheritance/native/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..787b4bae85038cda922f8befac6c8ed361fa8035 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/payloads.json @@ -0,0 +1 @@ +../../../../before/read/payloads.json \ No newline at end of file diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/run.json b/corpus/paragraph-edit/rust-join/inheritance/native/run.json new file mode 100644 index 0000000000000000000000000000000000000000..c2f772a0b8978fa99392d3d82049a03eeb6848ba --- /dev/null +++ b/corpus/paragraph-edit/rust-join/inheritance/native/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/paragraph-rust-join-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/rust-join/inheritance/native/source.json b/corpus/paragraph-edit/rust-join/inheritance/native/source.json new file mode 100644 index 0000000000000000000000000000000000000000..4ff673f31d5c63ccbca4e09520e1f20459168b94 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/inheritance/native/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 56344, + "sha256": "50318b4b3d4f4850dd578518eea4cc7649d29cc0c84b0d6a9b41811f91e6e2e1", + "mtime_ns": 1788860404399203298 + } +] diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/teardown.json b/corpus/paragraph-edit/rust-join/inheritance/native/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..4a260ae81ad209364b9d4ace600eec3f48f27aba --- /dev/null +++ b/corpus/paragraph-edit/rust-join/inheritance/native/teardown.json @@ -0,0 +1 @@ +../../../join-edges/teardown.json \ No newline at end of file diff --git a/corpus/paragraph-edit/rust-join/split/candidate/synthetic.one b/corpus/paragraph-edit/rust-join/split/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..f6e260945fd148d5bf3002a9fb3605c51de2e865 Binary files /dev/null and b/corpus/paragraph-edit/rust-join/split/candidate/synthetic.one differ diff --git a/corpus/paragraph-edit/rust-join/split/manifest.json b/corpus/paragraph-edit/rust-join/split/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..3575fb6149837a356d4981dcf981cfa31ae3d2d1 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/manifest.json @@ -0,0 +1,110 @@ +[ + { + "case": "Split middle", + "intent": { + "author": "Rust join author", + "left": "{DFE950EF-6BEB-0F5B-2403-7B74F1AE5002},44", + "right": "{0BA342F5-0690-087C-24FE-8EE6D81C2041},12" + }, + "space": "{FE224563-B517-023E-0D75-9904B1952A26},1" + }, + { + "case": "Split style boundary", + "intent": { + "author": "Rust join author", + "left": "{5FB484E2-1E3E-0A9D-1989-271A3EBBEC42},44", + "right": "{051BC1DE-5AE6-0D04-0CEF-9D05F53C8EAE},12" + }, + "space": "{DED48A05-0DB1-0712-086D-09237C982341},1" + }, + { + "case": "Split start", + "intent": { + "author": "Rust join author", + "left": "{012ED94B-43B3-079A-3050-B1CBE25C1C09},44", + "right": "{B230A510-9428-0CE7-1B89-7CA970F8F676},12" + }, + "space": "{7919E933-0809-00D8-1754-A21684215860},1" + }, + { + "case": "Split end", + "intent": { + "author": "Rust join author", + "left": "{DAF723A3-C2A3-0798-2EAB-34613166A794},44", + "right": "{601DAE0A-2A53-0C28-3077-1FE8FCB26FC2},12" + }, + "space": "{927F2809-173C-036E-37B6-60A6568F7723},1" + }, + { + "case": "Split empty", + "intent": { + "author": "Rust join author", + "left": "{0B467F31-D287-08CD-09F4-A6FBB2F5418F},45", + "right": "{EACB4A2C-2444-0A1F-03D6-68187F2FB345},12" + }, + "space": "{E4D8C4CC-508B-04D2-36CE-C5B8C9D2FEC4},1" + }, + { + "case": "Split parent", + "intent": { + "author": "Rust join author", + "left": "{3860EA13-D444-0598-35D6-CF96769C6A21},44", + "right": "{C84CA5DB-61A8-0F9B-3F91-D6E0C74536F7},12" + }, + "space": "{7A363D4C-73C2-0ABE-0931-E6F62A90B95E},1" + }, + { + "case": "Split child", + "intent": { + "author": "Rust join author", + "left": "{B587CBC6-0BCC-0A23-16F6-E1B0B3C5D061},49", + "right": "{DC240C63-FC8E-0267-0AFD-A53062C25702},12" + }, + "space": "{3CC2C4DC-651A-07C9-2F7B-375C6663F3CA},1" + }, + { + "case": "Split bullet", + "intent": { + "author": "Rust join author", + "left": "{8C65E427-477C-02ED-1807-2F10E277019D},44", + "right": "{28F3F5EC-E687-02CA-2CFD-E73A184B875F},12" + }, + "space": "{942EF5E4-FFD1-0FE4-23D2-7429899577BD},1" + }, + { + "case": "Split number", + "intent": { + "author": "Rust join author", + "left": "{D4ABBE4F-2165-0122-1F9F-ACE3FE92C703},44", + "right": "{DD3FA0E7-15A6-01C4-1B77-F85F779BCBC8},12" + }, + "space": "{A4AE40FF-DD80-0003-2856-17D8295C1B91},1" + }, + { + "case": "Split tag", + "intent": { + "author": "Rust join author", + "left": "{37AA5877-2980-0227-169F-8B1E8EE0795A},44", + "right": "{1480427A-E9ED-04EC-0409-0653AE11FF78},12" + }, + "space": "{739DB38B-4239-0A7C-2652-4E525D219638},1" + }, + { + "case": "Split cell", + "intent": { + "author": "Rust join author", + "left": "{F0832BC9-1578-0A96-08AF-99344644FFB8},50", + "right": "{1E353497-5C26-0459-3C0C-0E8D29ED8C5A},12" + }, + "space": "{4432E3EC-5E5F-0A04-2108-E9A2C17BDBEC},1" + }, + { + "case": "Split soft break", + "intent": { + "author": "Rust join author", + "left": "{88189BA1-2F19-0282-05ED-A0B83153F143},44", + "right": "{C1568E82-003F-016B-041A-CA304E1A5138},12" + }, + "space": "{D8E15673-A45E-0914-291A-7767C3222F19},1" + } +] \ No newline at end of file diff --git a/corpus/paragraph-edit/rust-join/split/native/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/rust-join/split/native/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..6b7942568a0471d110b51e14c98a37ff157ddc0a Binary files /dev/null and b/corpus/paragraph-edit/rust-join/split/native/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/paragraph-edit/rust-join/split/native/notebook/synthetic.one b/corpus/paragraph-edit/rust-join/split/native/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..c628f10eb6e05aa86b8b8303798be23bb3fd6bb9 Binary files /dev/null and b/corpus/paragraph-edit/rust-join/split/native/notebook/synthetic.one differ diff --git a/corpus/paragraph-edit/rust-join/split/native/read/environment.json b/corpus/paragraph-edit/rust-join/split/native/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..6e1172469add7fcc821a372c9d64f7211dded5ba --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-6A0771AD", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/paragraph-edit/rust-join/split/native/read/hierarchy.xml b/corpus/paragraph-edit/rust-join/split/native/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..e34c036c05b3bf2d925bccd0a8564c9d1e9e0588 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-000.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..9c5b5e5620baf4d2875b475c2e32ab7eb3228f15 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-000.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-001.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..ffd2fc371f4b506d2b9c50ee8b3f513074920f82 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-001.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-002.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..42ce5dc2d35a421f95c5ff57d1656fd8bc15facf --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-002.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-003.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..f44f5a693145d9cd3e7f13915dd75976865eda0d --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-003.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-004.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..d3e9383b0cd0c25408c0fe4287b01935d20e2d6a --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-004.xml @@ -0,0 +1,3 @@ + +Link label tail]]> diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-005.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..c2ea1f1da0663e0195e7cd66a40115d4c0b801cf --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-005.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-006.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..a978087ea853f5223c0747101fc5becef4c63c79 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-006.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-007.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..d2c17cb3894672743c763c7ed7496409498cba06 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-007.xml @@ -0,0 +1,3 @@ + + +soft break]]> diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-008.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..f3257b00d3350ab51c1309c37c19d2b683261b0b --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-008.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-009.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..22eed272f83abbc4a9d75c3f456c051dce48c9ea --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-009.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-010.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..6cacd112a07fcceeea14d9e3bf2d55cc453e8bda --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-010.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-011.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..c120a78c84de6cc5ddd62e849aef3c184149054c --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-011.xml @@ -0,0 +1,6 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-012.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..c008ca62bde4526513ce51503a8bda59e9c7bf5f --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-012.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-013.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-013.xml new file mode 100644 index 0000000000000000000000000000000000000000..37af0f5ceb319734cf8300e9ccaf64274a5ca054 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/page-013.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/split/native/read/payloads.json b/corpus/paragraph-edit/rust-join/split/native/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..787b4bae85038cda922f8befac6c8ed361fa8035 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/read/payloads.json @@ -0,0 +1 @@ +../../../../before/read/payloads.json \ No newline at end of file diff --git a/corpus/paragraph-edit/rust-join/split/native/run.json b/corpus/paragraph-edit/rust-join/split/native/run.json new file mode 100644 index 0000000000000000000000000000000000000000..e75fc623f38ae54ac646344148893cc773e4c830 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/paragraph-rust-join-01/split/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/rust-join/split/native/source.json b/corpus/paragraph-edit/rust-join/split/native/source.json new file mode 100644 index 0000000000000000000000000000000000000000..bfe81953672ef7d652c7c15c9b6a293083899bc6 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 139752, + "sha256": "6677af274e17de1ac194b2b18bc96d3eb33c04968881c272383f7cc47b10c5de", + "mtime_ns": 1788860404345967341 + } +] diff --git a/corpus/paragraph-edit/rust-join/split/native/teardown.json b/corpus/paragraph-edit/rust-join/split/native/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..4a260ae81ad209364b9d4ace600eec3f48f27aba --- /dev/null +++ b/corpus/paragraph-edit/rust-join/split/native/teardown.json @@ -0,0 +1 @@ +../../../join-edges/teardown.json \ No newline at end of file diff --git a/corpus/paragraph-edit/rust-join/tags/candidate/synthetic.one b/corpus/paragraph-edit/rust-join/tags/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..71430622d67679d3cf2ba34cb5c46ce06b6f0842 Binary files /dev/null and b/corpus/paragraph-edit/rust-join/tags/candidate/synthetic.one differ diff --git a/corpus/paragraph-edit/rust-join/tags/manifest.json b/corpus/paragraph-edit/rust-join/tags/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..fe168eaa1035cd51ca7af2ed8b9956b5618dd042 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/tags/manifest.json @@ -0,0 +1,29 @@ +[ + { + "case": "Join empty left right tag", + "intent": { + "author": "Rust join author", + "left": "{0D11242E-6A38-0F74-261A-A6FC444FA262},45", + "right": "{0D11242E-6A38-0F74-261A-A6FC444FA262},50" + }, + "space": "{FC64EF0A-5CCB-0557-20E0-6C847015B691},1" + }, + { + "case": "Join empty left two tags", + "intent": { + "author": "Rust join author", + "left": "{84061027-77E3-03F1-3E0D-A8F744B19C18},45", + "right": "{84061027-77E3-03F1-3E0D-A8F744B19C18},51" + }, + "space": "{A05CB25D-C95D-043F-3B9A-CE333AD171AD},1" + }, + { + "case": "Join different tags", + "intent": { + "author": "Rust join author", + "left": "{4FBF7D4A-6F4D-048E-0913-5BD9A184C25B},44", + "right": "{4FBF7D4A-6F4D-048E-0913-5BD9A184C25B},50" + }, + "space": "{6F0F6556-17D3-03AF-20EF-C465A856A153},1" + } +] \ No newline at end of file diff --git a/corpus/paragraph-edit/rust-join/tags/native/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/rust-join/tags/native/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..28ca4e63834c7b4a24dae185a1adf3a2a6fddec1 Binary files /dev/null and b/corpus/paragraph-edit/rust-join/tags/native/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/paragraph-edit/rust-join/tags/native/notebook/synthetic.one b/corpus/paragraph-edit/rust-join/tags/native/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..f805fa4ceb33ef378162f71bf3f4d7a12592b423 Binary files /dev/null and b/corpus/paragraph-edit/rust-join/tags/native/notebook/synthetic.one differ diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/environment.json b/corpus/paragraph-edit/rust-join/tags/native/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..cf321e2d9f6da6877ffb684320da7595a3e2470b --- /dev/null +++ b/corpus/paragraph-edit/rust-join/tags/native/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-5FECAC90", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/hierarchy.xml b/corpus/paragraph-edit/rust-join/tags/native/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..0bb2568bd3f2ec5818ecb748cfd4ca7aec9c0bf2 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/tags/native/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/page-000.xml b/corpus/paragraph-edit/rust-join/tags/native/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..5cdd251abfb245b33b76c801b4011c91dcb61958 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/tags/native/read/page-000.xml @@ -0,0 +1,20 @@ + +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/page-001.xml b/corpus/paragraph-edit/rust-join/tags/native/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..6a6d722dd3bd9b3e18c7ee18ace84160023ce944 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/tags/native/read/page-001.xml @@ -0,0 +1,5 @@ + +LeftBold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/page-002.xml b/corpus/paragraph-edit/rust-join/tags/native/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..6979a01a5cbe465260ce7c593d333a93a325509b --- /dev/null +++ b/corpus/paragraph-edit/rust-join/tags/native/read/page-002.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/page-003.xml b/corpus/paragraph-edit/rust-join/tags/native/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..7013b491ac10291da4085a978aa404d6f0476818 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/tags/native/read/page-003.xml @@ -0,0 +1,5 @@ + +Bold italic 🦀 é tail]]> diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/payloads.json b/corpus/paragraph-edit/rust-join/tags/native/read/payloads.json new file mode 120000 index 0000000000000000000000000000000000000000..787b4bae85038cda922f8befac6c8ed361fa8035 --- /dev/null +++ b/corpus/paragraph-edit/rust-join/tags/native/read/payloads.json @@ -0,0 +1 @@ +../../../../before/read/payloads.json \ No newline at end of file diff --git a/corpus/paragraph-edit/rust-join/tags/native/run.json b/corpus/paragraph-edit/rust-join/tags/native/run.json new file mode 100644 index 0000000000000000000000000000000000000000..74bec79c775e24e11e08a5162d26b1176db2860b --- /dev/null +++ b/corpus/paragraph-edit/rust-join/tags/native/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/evidence/m10/paragraph-rust-join-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/rust-join/tags/native/source.json b/corpus/paragraph-edit/rust-join/tags/native/source.json new file mode 100644 index 0000000000000000000000000000000000000000..e9dd9e3f2cadc7bbf354bb48d1389f7ef70b4dce --- /dev/null +++ b/corpus/paragraph-edit/rust-join/tags/native/source.json @@ -0,0 +1,8 @@ +[ + { + "path": "synthetic.one", + "bytes": 41808, + "sha256": "04f5dd05df411fdb228ff7cb80e02ca24744d40b6f0bd485b688aa8c0d6e8ac3", + "mtime_ns": 1788860404422342545 + } +] diff --git a/corpus/paragraph-edit/rust-join/tags/native/teardown.json b/corpus/paragraph-edit/rust-join/tags/native/teardown.json new file mode 120000 index 0000000000000000000000000000000000000000..4a260ae81ad209364b9d4ace600eec3f48f27aba --- /dev/null +++ b/corpus/paragraph-edit/rust-join/tags/native/teardown.json @@ -0,0 +1 @@ +../../../join-edges/teardown.json \ No newline at end of file diff --git a/crates/onestore/README.md b/crates/onestore/README.md index e029c43cadc0257e54ea518ef6d1e30f6632f27c..3bc5ce49fa5859bd51d2f536a316032009bcddf4 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -50,6 +50,7 @@ harness also accepts `--client-profile release`. | `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range across ordinary text runs; publish text, run boundaries and modification time together | | `Insertion`, `PreparedEdit::insert` | Insert paragraphs into editable containers or positioned outlines into a page, retaining intent identities across rebases | | `ParagraphSplit`, `PreparedEdit::split` | Split ordinary text at a UTF-16 scalar boundary, retaining the original left identities and moving children to the right | +| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | | `read_file` | Read a snapshot under whole-file exclusion | @@ -85,6 +86,14 @@ the complete child graph and title metadata; repeating an existing identity requ reconciliation. Title containers, generated fields, recording-linked text and associated run metadata are rejected before I/O. Native split controls and subsequent typing checks reside in [the paragraph corpus](../../corpus/paragraph-edit/README.md). +Joins retain the left paragraph. Nonempty left text keeps its identity; empty left +text adopts the right text identity. **The left tags win: right-side tags are removed +from active text even when the left text is empty.** History retains the original +objects. Select the preceding leaf text; where that leaf is deeper than the right +paragraph, right children move to its ancestor at the right paragraph's level. +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) diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index dbc621e4e78b394a7cf6df0fd6cf7a56099b682c..6bd5ed03294cfd1ec4434876863b98cd958cfce9 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -183,6 +183,18 @@ pub struct PreparedEdit<'a> { } impl<'a> PreparedEdit<'a> { + /// Joins adjacent ordinary paragraphs with native left-tag and text-identity semantics. + pub fn join( + source: &'a [u8], + space: ExGuid, + join: &crate::ParagraphJoin, + ) -> Result { + Ok(Self { + source, + written: join.apply(source, space)?, + }) + } + /// Splits a paragraph and updates its children, lists, tags and title metadata atomically. /// Fields and associated run metadata are rejected before I/O. pub fn split( diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index 9eac9aa570e7f35e36f497f9f2d791d773d38af8..379b01a43e63d64f37c057beb4eaf16c02cb894c 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -32,7 +32,7 @@ pub use files::FileDataReference; pub use formatting::TextAttribute; pub use insertion::Insertion; pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision}; -pub use paragraph::ParagraphSplit; +pub use paragraph::{ParagraphJoin, ParagraphSplit}; pub use properties::{IdStream, Property, PropertySets, Value}; pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex}; pub use snapshot::{read_snapshot, read_storage_snapshot}; diff --git a/crates/onestore/src/paragraph.rs b/crates/onestore/src/paragraph.rs index 0d96074ac3ecbddf7b3f14376fb6d2c37914d625..d122deeebd33fee4774f7559c2d732aa6293a0f7 100644 --- a/crates/onestore/src/paragraph.rs +++ b/crates/onestore/src/paragraph.rs @@ -76,7 +76,7 @@ impl ParagraphSplit { .into_iter() .filter_map(|(sid, page)| (sid == space).then_some(page)) .collect(); - let [page] = pages.as_slice() else { + let [_] = pages.as_slice() else { return Err(invalid("Splitting requires a single active page")); }; let semantic = document @@ -84,7 +84,7 @@ impl ParagraphSplit { .remove(&space) .ok_or_else(|| invalid("The active page is unavailable"))?; let rid = semantic.contexts[&ExGuid::default()]; - let mut view = semantic + let view = semantic .revisions .into_iter() .find_map(|(id, view)| (id == rid).then_some(view)) @@ -134,48 +134,9 @@ impl ParagraphSplit { } pending.extend(parents.get(&id).into_iter().flatten().copied()); } + let raw = index.resolve(space, rid)?; + let (text, runs) = ordinary_text(&view, &raw, self.text)?; let node = &view.nodes[&self.text]; - let Kind::RichText { - text, - runs, - boilerplate, - .. - } = &node.kind - else { - return Err(invalid("Select ordinary paragraph text")); - }; - if *boilerplate || !node.media_ids.is_empty() || node.media_time_ms.is_some() { - return Err(invalid( - "Generated or recording-linked text cannot be split", - )); - } - for run in view.text_runs(self.text)? { - if [ - run.format.hidden, - run.format.hyperlink, - run.format.math, - run.format.embedded_object, - ] - .contains(&Some(true)) - || run.text.contains(['\u{fffc}', '\u{fddf}']) - { - return Err(invalid( - "This paragraph contains a field or embedded object that cannot be split", - )); - } - } - let raw = index.resolve(space, rid)?; - let ObjectData::Properties(blob) = raw.objects[&self.text].data else { - unreachable!() - }; - if PropertySets::parse(blob)?.sets[0] - .iter() - .any(|p| matches!(p.id, 0x40003499 | 0x24003458)) - { - return Err(invalid( - "This paragraph contains run metadata that cannot be split", - )); - } let length = u32::try_from(text.encode_utf16().count()) .map_err(|_| invalid("Paragraph exceeds the UTF-16 offset range"))?; if self.offset > length || lists.len() > 251 { @@ -286,39 +247,7 @@ impl ParagraphSplit { }; object.set(&[(0x14001d7a, &modified)])?; } - for (id, object) in &changed { - view.nodes.insert( - *id, - Element::parse( - &Object { - jcid: object.jcid, - reference_count: 0, - data: ObjectData::Properties(&object.bytes), - global_ids: Arc::clone(&object.global_ids), - }, - &store, - )?, - ); - } - let title = page_title(&view, &pages, None)?; - drop(view); - if let Some((_, automatic, title)) = title { - let metadata = raw - .roots - .get(&2) - .ok_or_else(|| invalid("Page title metadata is unavailable"))?; - if raw.objects[metadata].jcid != 0x20030 { - return Err(invalid("Page title metadata is unavailable")); - } - let title = string(&title); - let mut object = PropertyObject::from_object(&raw.objects[metadata])?; - object.set(&[(0x1c001cf3, &title)])?; - changed.insert(*metadata, object); - changed - .get_mut(page) - .unwrap() - .set(&[(0x1c001d3c, if automatic { &title } else { &[0, 0] })])?; - } + update_title(&store, &raw, view, &pages, &mut changed)?; write_revision(source, space, |_| Ok(changed)) } } @@ -381,3 +310,473 @@ fn fragment( ])?; Ok(object) } + +fn ordinary_text<'a>( + view: &'a crate::document::Revision<'_>, + raw: &crate::ResolvedRevision<'_>, + id: ExGuid, +) -> Result<(&'a str, &'a [crate::document::TextRun]), Error> { + let node = &view.nodes[&id]; + let Kind::RichText { + text, + runs, + boilerplate, + .. + } = &node.kind + else { + return Err(invalid("Select ordinary paragraph text")); + }; + if *boilerplate || !node.media_ids.is_empty() || node.media_time_ms.is_some() { + return Err(invalid( + "Generated or recording-linked text cannot be split or joined", + )); + } + for run in view.text_runs(id)? { + if [ + run.format.hidden, + run.format.hyperlink, + run.format.math, + run.format.embedded_object, + ] + .contains(&Some(true)) + || run.text.contains(['\u{fffc}', '\u{fddf}']) + { + return Err(invalid( + "This paragraph contains a field or embedded object that cannot be split or joined", + )); + } + } + let ObjectData::Properties(blob) = raw.objects[&id].data else { + unreachable!() + }; + if PropertySets::parse(blob)?.sets[0] + .iter() + .any(|p| matches!(p.id, 0x40003499 | 0x24003458)) + { + return Err(invalid( + "This paragraph contains run metadata that cannot be split or joined", + )); + } + Ok((text, runs)) +} + +fn update_title( + store: &Store<'_>, + raw: &crate::ResolvedRevision<'_>, + view: crate::document::Revision<'_>, + pages: &[ExGuid], + changed: &mut BTreeMap, +) -> Result<(), Error> { + // Shorten the moved view's lifetime to the changed property buffers. + let mut view = view; + for (id, object) in changed.iter() { + view.nodes.insert( + *id, + Element::parse( + &Object { + jcid: object.jcid, + reference_count: 0, + data: ObjectData::Properties(&object.bytes), + global_ids: Arc::clone(&object.global_ids), + }, + store, + )?, + ); + } + let title = page_title(&view, pages, None)?; + drop(view); + if let Some((_, automatic, title)) = title { + let metadata = raw + .roots + .get(&2) + .ok_or_else(|| invalid("Page title metadata is unavailable"))?; + if raw.objects[metadata].jcid != 0x20030 { + return Err(invalid("Page title metadata is unavailable")); + } + let title = string(&title); + let mut object = PropertyObject::from_object(&raw.objects[metadata])?; + object.set(&[(0x1c001cf3, &title)])?; + changed.insert(*metadata, object); + changed + .get_mut(&pages[0]) + .unwrap() + .set(&[(0x1c001d3c, if automatic { &title } else { &[0, 0] })])?; + } + Ok(()) +} + +/// Joins adjacent ordinary text in one outline or table cell. +/// The left paragraph survives. Empty left text adopts the right text identity; +/// otherwise the left text survives. The left paragraph's tags are retained. +/// Right-side tags are removed from active text, including when the left text is empty. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ParagraphJoin { + left: ExGuid, + right: ExGuid, + author: String, +} + +impl ParagraphJoin { + /// 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') { + return Err(invalid( + "Choose two distinct text objects and an author name without NUL", + )); + } + Ok(Self { + left, + right, + author: author.to_owned(), + }) + } + + pub(crate) fn apply(&self, source: &[u8], space: ExGuid) -> Result, Error> { + if self.left == self.right + || self.left.guid == [0; 16] + || self.right.guid == [0; 16] + || self.author.contains('\0') + { + return Err(invalid( + "Choose two distinct text objects and an author name without NUL", + )); + } + let store = Store::parse(source)?; + let index = RevisionIndex::parse(&store)?; + index.validate_current()?; + let mut document = Document::parse(&index)?; + let pages: Vec<_> = document + .pages()? + .into_iter() + .filter_map(|(sid, page)| (sid == space).then_some(page)) + .collect(); + if pages.len() != 1 { + return Err(invalid("Joining requires a single active page")); + } + let semantic = document + .spaces + .remove(&space) + .ok_or_else(|| invalid("The active page is unavailable"))?; + let rid = semantic.contexts[&ExGuid::default()]; + let view = semantic + .revisions + .into_iter() + .find_map(|(id, view)| (id == rid).then_some(view)) + .unwrap(); + let parents = editable_parents(&view, &pages, self.left)?; + editable_parents(&view, &pages, self.right)?; + let parent = |id| -> Result { + let [parent] = parents.get(&id).map(Vec::as_slice).unwrap_or_default() else { + return Err(invalid("Select text with a unique editable parent")); + }; + Ok(*parent) + }; + let left = parent(self.left)?; + let right = parent(self.right)?; + for (paragraph, text) in [(left, self.left), (right, self.right)] { + if !matches!(view.nodes[¶graph].kind, Kind::Paragraph { .. }) + || view.nodes[¶graph].content != [text] + { + return Err(invalid("Select ordinary paragraph text")); + } + let mut at = paragraph; + while at != pages[0] { + if matches!(view.nodes[&at].kind, Kind::Title) { + return Err(invalid( + "Title containers cannot be joined with ordinary paragraphs", + )); + } + at = parent(at)?; + } + } + if !view.nodes[&left].children.is_empty() { + return Err(invalid("Select the preceding paragraph's last descendant")); + } + let right_parent = parent(right)?; + if !matches!( + view.nodes[&right_parent].kind, + Kind::Outline { .. } | Kind::OutlineGroup | Kind::Paragraph { .. } | Kind::Cell { .. } + ) { + return Err(invalid("Select paragraphs in one outline or table cell")); + } + let mut stem = left; + while parent(stem)? != right_parent { + let previous = stem; + stem = parent(stem)?; + if !matches!( + view.nodes[&stem].kind, + Kind::Paragraph { .. } | Kind::OutlineGroup + ) || view.nodes[&stem].children.last() != Some(&previous) + { + return Err(invalid( + "Select adjacent text within one outline or table cell", + )); + } + } + let siblings = &view.nodes[&right_parent].children; + if !siblings.windows(2).any(|pair| pair == [stem, right]) { + return Err(invalid("Select adjacent paragraph text in document order")); + } + let children = &view.nodes[&right].children; + if !children.is_empty() + && !view.nodes[&stem].children.is_empty() + && view.nodes[&stem].child_level != view.nodes[&right].child_level + { + return Err(invalid( + "Joining these child indentation levels requires a hierarchy edit", + )); + } + let raw = index.resolve(space, rid)?; + let (a, a_runs) = ordinary_text(&view, &raw, self.left)?; + let (b, b_runs) = ordinary_text(&view, &raw, self.right)?; + let length = u32::try_from(a.encode_utf16().count()) + .map_err(|_| invalid("Paragraph exceeds the UTF-16 offset range"))?; + let right_length = u32::try_from(b.encode_utf16().count()) + .map_err(|_| invalid("Paragraph exceeds the UTF-16 offset range"))?; + length + .checked_add(right_length) + .ok_or_else(|| invalid("Joined text exceeds the UTF-16 offset range"))?; + let modified = current_timestamps()?.0.to_le_bytes(); + let mut changed = BTreeMap::new(); + let (survivor, mut text) = if a.is_empty() { + let mut target = PropertyObject::from_object(&raw.objects[&self.right])?; + target.copy_property( + &PropertyObject::from_object(&raw.objects[&self.left])?, + 0x40003489, + )?; + (self.right, target) + } else { + let mut target = PropertyObject::from_object(&raw.objects[&self.left])?; + let mut styles = BTreeMap::new(); + let mut ends = Vec::new(); + let mut references = Vec::new(); + let mut empty_style = None; + for run in a_runs.iter().filter(|run| run.start < run.end) { + let id = if let Some(id) = run.format { + id + } else if let Some(id) = empty_style { + id + } else { + let id = ExGuid { + guid: fresh_guid()?, + n: 1, + }; + changed.insert( + id, + PropertyObject { + jcid: 0x12004d, + bytes: properties(&[])?, + global_ids: Arc::new(BTreeMap::from([(0, id.guid)])), + }, + ); + empty_style = Some(id); + id + }; + references.extend_from_slice(&target.reference(id)?); + ends.extend_from_slice(&run.end.to_le_bytes()); + } + let left_base = character_properties(&view, &raw, self.left)?; + let right_base = character_properties(&view, &raw, self.right)?; + for (i, run) in b_runs.iter().enumerate() { + if run.start == run.end && i != b_runs.len() - 1 { + continue; + } + let id = if let Some(id) = styles.get(&run.format) { + *id + } else { + let mut style = if let Some(id) = run.format { + PropertyObject::from_object(&raw.objects[&id])? + } else { + PropertyObject { + jcid: 0x12004d, + bytes: properties(&[])?, + global_ids: Arc::new(BTreeMap::new()), + } + }; + if left_base != right_base { + let mut values = right_base.clone(); + for property in &PropertySets::parse(&style.bytes)?.sets[0] { + let key = property.id & 0x7fffffff; + if is_character_property(key) { + let value = match property.value { + crate::Value::NoData => &[][..], + crate::Value::Bytes(bytes) => bytes, + _ => unreachable!(), + }; + values.insert(key, (property.id, value.to_vec())); + } + } + for key in left_base.keys() { + if values.contains_key(key) { + continue; + } + let value = match key >> 26 & 31 { + 2 => Vec::new(), + _ if matches!(*key, 0x14001c0c | 0x14001c0d) => { + 0xff000000_u32.to_le_bytes().to_vec() + } + _ => { + return Err(invalid( + "The right paragraph's implicit font or language cannot be preserved under the left style", + )); + } + }; + values.insert(*key, (*key, value)); + } + style.set( + &values + .values() + .map(|(id, value)| (*id, value.as_slice())) + .collect::>(), + )?; + } + let id = if let Some(id) = run + .format + .filter(|id| raw.objects[id].data == ObjectData::Properties(&style.bytes)) + { + id + } else { + let id = ExGuid { + guid: fresh_guid()?, + n: 1, + }; + style.reference(id)?; + changed.insert(id, style); + id + }; + styles.insert(run.format, id); + id + }; + references.extend_from_slice(&target.reference(id)?); + ends.extend_from_slice(&(length + run.end).to_le_bytes()); + } + ends.truncate(ends.len() - 4); + if ends + .chunks_exact(4) + .map(|bytes| u32::from_le_bytes(bytes.try_into().unwrap())) + .collect::>() + .windows(2) + .any(|pair| pair[0] >= pair[1]) + { + return Err(invalid("Text-run boundaries must be strictly increasing")); + } + target.set(&[ + (0x1c001c22, &string(&format!("{a}{b}"))), + (0x1c001e12, &ends), + (0x24001e13, &references), + ])?; + (self.left, target) + }; + text.set(&[(0x14001d7a, &modified)])?; + changed.insert(survivor, text); + let author = ExGuid { + guid: fresh_guid()?, + n: 1, + }; + changed.insert( + author, + PropertyObject { + jcid: 0x120001, + bytes: properties(&[(0x1c001d75, string(&self.author))])?, + global_ids: Arc::new(BTreeMap::from([(0, author.guid)])), + }, + ); + let mut left_object = PropertyObject::from_object(&raw.objects[&left])?; + let content = left_object.reference(survivor)?; + let author = left_object.reference(author)?; + left_object.set(&[(0x24001c1f, &content), (0x20001d79, &author)])?; + changed.insert(left, left_object); + if !children.is_empty() { + let target = match changed.entry(stem) { + std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(PropertyObject::from_object(&raw.objects[&stem])?) + } + }; + let mut references = Vec::new(); + for id in view.nodes[&stem].children.iter().chain(children) { + references.extend_from_slice(&target.reference(*id)?); + } + target.set(&[(0x24001c20, &references)])?; + target.copy_property( + &PropertyObject::from_object(&raw.objects[&right])?, + 0x0c001c03, + )?; + } + let mut container = PropertyObject::from_object(&raw.objects[&right_parent])?; + let mut references = Vec::new(); + for id in siblings.iter().filter(|id| **id != right) { + references.extend_from_slice(&container.reference(*id)?); + } + container.set(&[(0x24001c20, &references)])?; + changed.insert(right_parent, container); + let mut pending = vec![left, right_parent]; + let mut ancestors = BTreeSet::new(); + while let Some(id) = pending.pop() { + if !ancestors.insert(id) { + continue; + } + let object = match changed.entry(id) { + std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(PropertyObject::from_object(&raw.objects[&id])?) + } + }; + object.set(&[(0x14001d7a, &modified)])?; + pending.extend(parents.get(&id).into_iter().flatten().copied()); + } + update_title(&store, &raw, view, &pages, &mut changed)?; + write_revision(source, space, |_| Ok(changed)) + } +} + +fn is_character_property(id: u32) -> bool { + matches!( + id, + 0x08001c04 + ..=0x08001c09 + | 0x08001e16 + | 0x08001e14 + | 0x08001e19 + | 0x08003401 + | 0x08001e22 + | 0x08003476 + | 0x1c001c0a + | 0x10001c0b + | 0x14001c0c + | 0x14001c0d + | 0x14001c3b + ) +} + +fn character_properties( + view: &crate::document::Revision<'_>, + raw: &crate::ResolvedRevision<'_>, + text: ExGuid, +) -> Result)>, Error> { + let Kind::RichText { + paragraph_style, .. + } = view.nodes[&text].kind + else { + unreachable!() + }; + let mut values = BTreeMap::new(); + for id in paragraph_style.into_iter().chain([text]) { + let ObjectData::Properties(bytes) = raw.objects[&id].data else { + unreachable!() + }; + for property in &PropertySets::parse(bytes)?.sets[0] { + let key = property.id & 0x7fffffff; + if is_character_property(key) { + let value = match property.value { + crate::Value::NoData => &[][..], + crate::Value::Bytes(bytes) => bytes, + _ => unreachable!(), + }; + values.insert(key, (property.id, value.to_vec())); + } + } + } + Ok(values) +} diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs index 581a72460722c325de61b9e9bbef0b5e00bcf1a3..fb7dcd9f2162f4860f7ff3884b9989901eb9ff63 100644 --- a/crates/onestore/src/write.rs +++ b/crates/onestore/src/write.rs @@ -472,6 +472,114 @@ impl PropertyObject { } compact(id, &self.global_ids) } + + pub fn copy_property(&mut self, source: &Self, id: u32) -> Result<()> { + let properties = PropertySets::parse(&source.bytes)?; + let mut target = Self { + jcid: self.jcid, + bytes: self.bytes.clone(), + global_ids: Arc::clone(&self.global_ids), + }; + target.remove(&[id])?; + let lengths = property_set_lengths(&properties); + let mut offset = properties.root_ids.as_ptr().addr() - source.bytes.as_ptr().addr() + + properties.root_ids.len(); + let mut selected = None; + for property in &properties.sets[0] { + let end = offset + field_length(property, &lengths); + if property.id & 0x7fffffff == id & 0x7fffffff { + selected = Some((property, &source.bytes[offset..end])); + break; + } + offset = end; + } + if let Some((property, field)) = selected { + let mut added: [Vec; 3] = std::array::from_fn(|_| Vec::new()); + let mut pending = vec![property]; + while let Some(property) = pending.pop() { + match &property.value { + Value::References { + stream, + compact_ids, + } => { + let index = match stream { + crate::IdStream::Objects => 0, + crate::IdStream::ObjectSpaces => 1, + crate::IdStream::Contexts => 2, + }; + let mut cursor = crate::bytes::Cursor { + bytes: compact_ids, + offset: 0, + }; + while cursor.offset < cursor.bytes.len() { + let id = cursor.compact(&source.global_ids)?; + added[index].extend_from_slice(&target.reference(id)?); + } + } + Value::Sets(children) => { + for child in children.clone().rev() { + pending.extend(properties.sets[child].iter().rev()); + } + } + _ => {} + } + } + let old = PropertySets::parse(&target.bytes)?; + let count = u16::try_from(old.sets[0].len() + 1).map_err(|_| Error { + offset: 0, + message: "Root property count exceeds the format limit", + })?; + let streams = crate::properties::reference_streams(&mut crate::bytes::Cursor { + bytes: &target.bytes, + offset: 0, + })?; + let stream_count = (0..3) + .rev() + .find(|i| streams[*i].offset != 0 || !added[*i].is_empty()) + .unwrap() + + 1; + let mut bytes = Vec::new(); + for i in 0..stream_count { + let stream = &streams[i]; + let count = u32::try_from((stream.bytes.len() + added[i].len()) / 4) + .ok() + .filter(|n| *n <= 0xffffff) + .ok_or(Error { + offset: 0, + message: "Reference stream exceeds the format limit", + })?; + let reserved = if stream.offset == 0 { + 0 + } else { + u32::from_le_bytes( + target.bytes[stream.offset - 4..stream.offset] + .try_into() + .unwrap(), + ) & 0x3f000000 + }; + let flags = match (i, stream_count) { + (0, 1) => 0x80000000, + (0 | 1, 3) => 0x40000000, + _ => 0, + }; + bytes.extend_from_slice(&(count | reserved | flags).to_le_bytes()); + bytes.extend_from_slice(stream.bytes); + bytes.extend_from_slice(&added[i]); + } + bytes.extend_from_slice(&count.to_le_bytes()); + bytes.extend_from_slice(old.root_ids); + bytes.extend_from_slice(&property.id.to_le_bytes()); + let start = + old.root_ids.as_ptr().addr() - target.bytes.as_ptr().addr() + old.root_ids.len(); + bytes.extend_from_slice(&target.bytes[start..target.bytes.len() - old.padding.len()]); + bytes.extend_from_slice(field); + bytes.resize(bytes.len().next_multiple_of(8), 0); + PropertySets::parse(&bytes)?; + target.bytes = bytes; + } + *self = target; + Ok(()) + } } pub(crate) fn write_revision( diff --git a/crates/onestore/src/write/tests.rs b/crates/onestore/src/write/tests.rs index 5e996875596247be4c62f01f55f8f8c6c619c30a..ae67fe251d1d4d1eaac1938f39cdb402e32fe6b6 100644 --- a/crates/onestore/src/write/tests.rs +++ b/crates/onestore/src/write/tests.rs @@ -29,8 +29,11 @@ fn document_insertions_and_formatting_respect_readonly_ancestors() { )) }) .unwrap(); + let right = Insertion::paragraph(outline, None, "Right", "Author").unwrap(); + let expanded = PreparedEdit::insert(&source, sid, &right).unwrap(); + let source = expanded.as_bytes(); for blocked in [page, outline, paragraph] { - let protected = write_revision(&source, sid, |raw| { + let protected = write_revision(source, sid, |raw| { let mut object = PropertyObject::from_object(&raw.objects[&blocked])?; object.set(&[(0x88001cde, &[])])?; Ok(BTreeMap::from([(blocked, object)])) @@ -48,6 +51,8 @@ fn document_insertions_and_formatting_respect_readonly_ancestors() { .unwrap(); let split = crate::ParagraphSplit::new(text, 1, "Author").unwrap(); assert!(PreparedEdit::split(&protected, sid, &split).is_err()); + let join = crate::ParagraphJoin::new(text, right.text_object(), "Author").unwrap(); + assert!(PreparedEdit::join(&protected, sid, &join).is_err()); assert!( PreparedEdit::format( &protected, @@ -63,6 +68,18 @@ fn document_insertions_and_formatting_respect_readonly_ancestors() { assert!(PreparedEdit::insert(&protected, sid, &outline).is_err()); } } + let protected = write_revision(source, sid, |raw| { + let mut object = PropertyObject::from_object(&raw.objects[&right.object()])?; + object.set(&[(0x88001cde, &[])])?; + Ok(BTreeMap::from([(right.object(), object)])) + }) + .unwrap(); + let document = crate::document::Document::parse(&index).unwrap(); + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let text = view.nodes[¶graph].content[0]; + let join = crate::ParagraphJoin::new(text, right.text_object(), "Author").unwrap(); + assert!(PreparedEdit::join(&protected, sid, &join).is_err()); } fn add_paragraph(source: &[u8], number: u32) -> Vec { @@ -572,6 +589,56 @@ fn nested_fields_and_other_reference_streams_remain_byte_exact() { object.remove(&ids).unwrap(); assert_eq!(object.bytes, before); } + let source = super::PropertyObject { + jcid: 0x6000e, + bytes: original.clone(), + global_ids: std::sync::Arc::new(std::collections::BTreeMap::from([(0, [1; 16])])), + }; + let mut target = super::PropertyObject { + jcid: 0x6000e, + bytes: properties(&[(0x20000001, vec![6, 0, 0, 0])]).unwrap(), + global_ids: std::sync::Arc::new(std::collections::BTreeMap::from([(0, [9; 16])])), + }; + target.copy_property(&source, 0x40000002).unwrap(); + let copied = PropertySets::parse(&target.bytes).unwrap(); + assert_eq!(target.global_ids[&0], [9; 16]); + assert_eq!(target.global_ids[&1], [1; 16]); + assert_eq!( + copied.sets[0][0].value, + Value::References { + stream: crate::IdStream::Objects, + compact_ids: &[6, 0, 0, 0], + } + ); + for (i, n) in [2, 4, 5].into_iter().enumerate() { + let Value::References { compact_ids, .. } = copied.sets[1][i].value else { + panic!() + }; + assert_eq!(compact_ids, &[n, 1, 0, 0]); + } + assert_eq!(copied.sets[1][3], previous.sets[1][3]); + assert_eq!( + target + .bytes + .windows(nested.len()) + .filter(|b| *b == nested) + .count(), + 1 + ); + let before = target.bytes.clone(); + target.copy_property(&source, 0x40000002).unwrap(); + assert_eq!(target.bytes, before); + target.copy_property(&source, 0x20000001).unwrap(); + assert_eq!(PropertySets::parse(&target.bytes).unwrap().sets[0].len(), 1); + let before = target.bytes.clone(); + let ids = std::sync::Arc::clone(&target.global_ids); + let invalid = super::PropertyObject { + global_ids: Default::default(), + ..source + }; + assert!(target.copy_property(&invalid, 0x40000002).is_err()); + assert_eq!(target.bytes, before); + assert_eq!(target.global_ids, ids); } #[test] @@ -599,6 +666,13 @@ fn deep_property_splices_do_not_use_the_call_stack() { let parsed = PropertySets::parse(&object.bytes).unwrap(); assert_eq!(parsed.sets.len(), 100_001); assert_eq!(&object.bytes[..bytes.len()], &bytes); + let mut copied = super::PropertyObject { + jcid: object.jcid, + bytes: properties(&[]).unwrap(), + global_ids: Default::default(), + }; + copied.copy_property(&object, 0x44000001).unwrap(); + assert_eq!(copied.bytes, object.bytes); object.remove(&[0x44000001]).unwrap(); let parsed = PropertySets::parse(&object.bytes).unwrap(); assert_eq!(parsed.sets.len(), 1); diff --git a/crates/onestore/tests/paragraph.rs b/crates/onestore/tests/paragraph.rs index 4767022d053c4b677a6cd49151b564757c906bd9..e2f754a9d7ea6eab32f97f177ea7c930be7a673c 100644 --- a/crates/onestore/tests/paragraph.rs +++ b/crates/onestore/tests/paragraph.rs @@ -1,5 +1,5 @@ use onestore::{ - ExGuid, ParagraphSplit, PreparedEdit, RevisionIndex, Store, + ExGuid, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex, Store, document::{Document, Kind, Revision}, }; use serde_json::Value; @@ -25,6 +25,270 @@ fn characters(view: &Revision<'_>, id: ExGuid) -> Vec<(char, Value)> { .collect() } +const JOIN_FIXTURES: [(&[u8], &[u8], bool); 3] = [ + ( + include_bytes!("../../../corpus/paragraph-edit/split/notebook/synthetic.one").as_slice(), + include_bytes!("../../../corpus/paragraph-edit/joined/notebook/synthetic.one").as_slice(), + true, + ), + ( + include_bytes!("../../../corpus/paragraph-edit/join-edges/before/notebook/synthetic.one") + .as_slice(), + include_bytes!("../../../corpus/paragraph-edit/join-edges/joined/notebook/synthetic.one") + .as_slice(), + false, + ), + ( + include_bytes!("../../../corpus/paragraph-edit/join-tags/before/notebook/synthetic.one") + .as_slice(), + include_bytes!("../../../corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one") + .as_slice(), + false, + ), +]; + +fn join_targets(document: &Document<'_>, split_cases: bool) -> Vec<(String, ExGuid, ExGuid)> { + let mut cases = Vec::new(); + if split_cases { + let manifest: Value = + serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")) + .unwrap(); + for case in manifest["cases"].as_array().unwrap() { + cases.push(( + case["case"].as_str().unwrap().to_owned(), + serde_json::from_value::(case["original_text"].clone()).unwrap(), + serde_json::from_value::(case["new_text"].clone()).unwrap(), + )); + } + } else { + for (sid, page) in document.pages().unwrap() { + let s = &document.spaces[&sid]; + let view = &s.revisions[&s.contexts[&ExGuid::default()]]; + let Kind::Metadata { + title: Some(name), .. + } = &view.nodes[&view.roots[&2]].kind + else { + panic!() + }; + if !name.starts_with("Join ") { + continue; + } + let outline = view.nodes[&page] + .children + .iter() + .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) + .unwrap(); + let mut left = view.nodes[outline].children[0]; + while let Some(child) = view.nodes[&left].children.last() { + left = *child; + } + let right = view.nodes[outline].children[1]; + cases.push(( + name.clone(), + view.nodes[&left].content[0], + view.nodes[&right].content[0], + )); + } + } + assert_eq!(cases.len(), document.pages().unwrap().len() - 1); + cases +} + +#[test] +fn joins_match_native_graphs_tags_and_inherited_character_styles() { + for (source, native, split_cases) in JOIN_FIXTURES { + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let native_store = Store::parse(native).unwrap(); + let native_index = RevisionIndex::parse(&native_store).unwrap(); + let native_document = Document::parse(&native_index).unwrap(); + let cases = join_targets(&document, split_cases); + for (name, left, right) in cases { + let (sid, space) = document + .spaces + .iter() + .find(|(_, space)| { + space.revisions[&space.contexts[&ExGuid::default()]] + .nodes + .contains_key(&left) + }) + .unwrap(); + let intent = ParagraphJoin::new(left, right, "Join author").unwrap(); + let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); + assert_eq!(intent, restored); + let edited = PreparedEdit::join(source, *sid, &restored); + if name == "Split before hyperlink" { + assert!(edited.is_err()); + continue; + } + let edited = edited.unwrap_or_else(|error| panic!("{name}: {error}")); + let current_store = Store::parse(edited.as_bytes()).unwrap(); + assert_eq!( + current_store.header.transaction_count, + store.header.transaction_count + 1 + ); + let current_index = RevisionIndex::parse(¤t_store).unwrap(); + current_index.validate_current().unwrap(); + let current_document = Document::parse(¤t_index).unwrap(); + let current = ¤t_document.spaces[sid]; + let current = ¤t.revisions[¤t.contexts[&ExGuid::default()]]; + let expected = &native_document.spaces[sid]; + let expected = &expected.revisions[&expected.contexts[&ExGuid::default()]]; + let (_, page) = document + .pages() + .unwrap() + .into_iter() + .find(|(id, _)| id == sid) + .unwrap(); + let before = &space.revisions[&space.contexts[&ExGuid::default()]]; + let left_paragraph = *before + .nodes + .iter() + .find(|(_, node)| node.content == [left]) + .unwrap() + .0; + let right_paragraph = *before + .nodes + .iter() + .find(|(_, node)| node.content == [right]) + .unwrap() + .0; + let right_parent = *before + .nodes + .iter() + .find(|(_, node)| node.children.contains(&right_paragraph)) + .unwrap() + .0; + let survivor = if characters(before, left).is_empty() { + right + } else { + left + }; + let mut changed = std::collections::BTreeSet::from([survivor, before.roots[&2]]); + let mut ancestors = vec![left_paragraph, right_parent]; + while let Some(id) = ancestors.pop() { + if !changed.insert(id) || id == page { + continue; + } + ancestors.extend(before.nodes.iter().filter_map(|(parent, node)| { + node.children + .iter() + .chain(&node.content) + .chain(&node.structure) + .any(|child| *child == id) + .then_some(*parent) + })); + } + let old_raw = index + .resolve(*sid, space.contexts[&ExGuid::default()]) + .unwrap(); + let current_rid = current_document.spaces[sid].contexts[&ExGuid::default()]; + let new_raw = current_index.resolve(*sid, current_rid).unwrap(); + for (id, object) in old_raw.objects { + if !changed.contains(&id) { + assert_eq!( + object.data, new_raw.objects[&id].data, + "{name} untouched {id}" + ); + } + } + let mut pending: Vec<_> = expected.nodes[&page] + .children + .iter() + .filter(|id| matches!(expected.nodes[id].kind, Kind::Outline { .. })) + .copied() + .collect(); + while let Some(id) = pending.pop() { + let node = &expected.nodes[&id]; + assert_eq!( + current.nodes[&id].children, node.children, + "{name} children {id}" + ); + assert_eq!( + current.nodes[&id].content, node.content, + "{name} content {id}" + ); + assert_eq!( + current.nodes[&id].child_level, node.child_level, + "{name} indentation {id}" + ); + pending.extend( + node.children + .iter() + .chain(&node.content) + .chain(&node.structure) + .copied(), + ); + if matches!(node.kind, Kind::RichText { .. }) { + assert_eq!( + serde_json::to_value(¤t.nodes[&id].tags).unwrap(), + serde_json::to_value(&node.tags).unwrap(), + "{name} tags {id}" + ); + let normalize = |view, id| { + characters(view, id) + .into_iter() + .map(|(c, mut style)| { + let fields = style.as_object_mut().unwrap(); + for key in + ["alignment", "space_before", "space_after", "line_spacing"] + { + fields.remove(key); + } + for key in [ + "bold", + "italic", + "underline", + "strike", + "superscript", + "subscript", + "hidden", + "hyperlink", + "hyperlink_label", + "math", + "embedded_object", + "rtl", + ] { + if fields[key].is_null() { + fields.insert(key.into(), false.into()); + } + } + for key in ["color", "highlight"] { + if fields[key].is_null() { + fields.insert(key.into(), 0xff000000_u32.into()); + } + } + (c, style) + }) + .collect::>() + }; + assert_eq!( + normalize(current, id), + normalize(expected, id), + "{name} styles {id}" + ); + } + } + for (sid, old) in &index.spaces { + for revision in old.revisions.keys() { + let old = index.resolve(*sid, *revision).unwrap(); + let retained = current_index.resolve(*sid, *revision).unwrap(); + assert_eq!(old.roots, retained.roots); + for (id, object) in old.objects { + assert_eq!(object.data, retained.objects[&id].data); + } + } + } + assert!(PreparedEdit::join(edited.as_bytes(), *sid, &intent).is_err()); + assert_eq!( + space.contexts.len(), + current_document.spaces[sid].contexts.len() + ); + } + } +} + #[test] fn splits_partition_native_paragraphs_at_every_scalar_boundary() { let manifest: Value = @@ -341,3 +605,179 @@ fn interrupted_splits_publish_a_complete_graph_or_retain_the_original() { } } } + +#[test] +#[ignore = "exports paragraph joins for independent native validation"] +fn export_native_paragraph_joins() { + use std::{fs, path::PathBuf}; + let output = PathBuf::from(std::env::var_os("ONESTORE_PARAGRAPH_JOIN_OUTPUT").unwrap()); + assert!(output.is_absolute()); + fs::create_dir(&output).unwrap(); + for (number, (source, _, split_cases)) in JOIN_FIXTURES.into_iter().enumerate() { + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let cases = join_targets(&document, split_cases); + let mut source = source.to_vec(); + let mut manifest = Vec::new(); + for (name, left, right) in cases { + if name == "Split before hyperlink" { + continue; + } + let sid = *document + .spaces + .iter() + .find(|(_, space)| { + space.revisions[&space.contexts[&ExGuid::default()]] + .nodes + .contains_key(&left) + }) + .unwrap() + .0; + let intent = ParagraphJoin::new(left, right, "Rust join author").unwrap(); + let edit = PreparedEdit::join(&source, sid, &intent).unwrap(); + source = edit.as_bytes().to_vec(); + manifest.push(serde_json::json!({"case":name,"space":sid,"intent":intent})); + } + let folder = output.join(["split", "inheritance", "tags"][number]); + fs::create_dir_all(folder.join("candidate")).unwrap(); + fs::write(folder.join("candidate/synthetic.one"), source).unwrap(); + fs::write( + folder.join("manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + } +} + +#[test] +fn interrupted_joins_preserve_complete_graphs_and_empty_text_adoption() { + let source = onestore::create_section("join.one", "Left", "Author").unwrap(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (sid, page) = document.pages().unwrap()[0]; + let space = &document.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(); + let left = view.nodes[&view.nodes[&outline].children[0]].content[0]; + let right = onestore::Insertion::paragraph(outline, None, "Right 🦀", "Author") + .unwrap() + .with_formatting(0..5, &[onestore::TextAttribute::Bold(true)]) + .unwrap(); + let inserted = PreparedEdit::insert(&source, sid, &right).unwrap(); + let child = + onestore::Insertion::paragraph(right.object(), None, "Retained child", "Author").unwrap(); + let original = PreparedEdit::insert(inserted.as_bytes(), sid, &child).unwrap(); + let original = original.as_bytes(); + let empty = onestore::replace_text(original, sid, left, 0..4, "").unwrap(); + let checkpoint = checkpoint::pending(original, sid, left, 0x14001d7a); + for source in [original, &empty, &checkpoint] { + let intent = ParagraphJoin::new(left, right.text_object(), "Join author").unwrap(); + let edit = PreparedEdit::join(source, sid, &intent).unwrap(); + let before = current::current(source); + let after = current::current(edit.as_bytes()); + for write_limit in [17, 4096] { + let disk = |fail_at| disk::Disk { + visible: source.to_vec(), + durable: source.to_vec(), + operation: 0, + fail_at, + write_limit, + random: 917, + }; + let mut successful = disk(None); + edit.commit(&mut successful).unwrap(); + assert_eq!(successful.durable, edit.as_bytes()); + for at in std::iter::once(1).chain(source.len().div_ceil(193)..=successful.operation) { + let mut interrupted = disk(Some(at)); + let error = edit.commit(&mut interrupted).unwrap_err(); + let recovered = current::current(&interrupted.durable); + assert!( + recovered == before || recovered == after, + "interruption {at}" + ); + match error.state { + onestore::CommitState::NotCommitted => assert_eq!(recovered, before), + onestore::CommitState::Committed => assert_eq!(recovered, after), + onestore::CommitState::Unknown => {} + } + } + } + } +} + +#[test] +fn joins_reject_invalid_identities_wrong_order_and_unrelated_pages() { + let (source, _, _) = JOIN_FIXTURES[0]; + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let cases = join_targets(&document, true); + let (_, left, right) = &cases[0]; + let (sid, space) = document + .spaces + .iter() + .find(|(_, space)| { + space.revisions[&space.contexts[&ExGuid::default()]] + .nodes + .contains_key(left) + }) + .unwrap(); + assert!(ParagraphJoin::new(*left, *left, "Author").is_err()); + assert!(ParagraphJoin::new(ExGuid::default(), *right, "Author").is_err()); + assert!(ParagraphJoin::new(*left, *right, "a\0b").is_err()); + let intent = ParagraphJoin::new(*left, *right, "Author").unwrap(); + for (field, value) in [ + ("left", serde_json::to_value(ExGuid::default()).unwrap()), + ("right", serde_json::to_value(left).unwrap()), + ("author", serde_json::json!("a\0b")), + ] { + let mut encoded = serde_json::to_value(&intent).unwrap(); + encoded[field] = value; + let invalid = serde_json::from_value(encoded).unwrap(); + assert!(PreparedEdit::join(source, *sid, &invalid).is_err()); + } + for intent in [ + ParagraphJoin::new(*right, *left, "Author").unwrap(), + ParagraphJoin::new(*left, cases[1].2, "Author").unwrap(), + ] { + assert!(PreparedEdit::join(source, *sid, &intent).is_err()); + } + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let paragraph = *view + .nodes + .iter() + .find(|(_, node)| node.content == [*left]) + .unwrap() + .0; + let parent = view + .nodes + .values() + .find(|node| node.children.contains(¶graph)) + .unwrap(); + let last = view.nodes[parent.children.last().unwrap()].content[0]; + let nonadjacent = ParagraphJoin::new(*left, last, "Author").unwrap(); + assert!(PreparedEdit::join(source, *sid, &nonadjacent).is_err()); + let title = view + .nodes + .iter() + .find_map(|(_, node)| matches!(node.kind, Kind::Title).then_some(node)) + .unwrap(); + let mut pending = title.children.clone(); + let mut rejected = 0; + while let Some(id) = pending.pop() { + let node = &view.nodes[&id]; + pending.extend(node.children.iter().chain(&node.content).copied()); + if matches!(node.kind, Kind::RichText { .. }) { + let intent = ParagraphJoin::new(id, *right, "Author").unwrap(); + assert!(PreparedEdit::join(source, *sid, &intent).is_err()); + rejected += 1; + } + } + assert!(rejected > 0); +} diff --git a/fuzz/fuzz_targets/paragraph.rs b/fuzz/fuzz_targets/paragraph.rs index fcc5b46163d65f926a61b277abe432623a0410df..a7600901c0d5118d31963e56b19ea365bced0b32 100644 --- a/fuzz/fuzz_targets/paragraph.rs +++ b/fuzz/fuzz_targets/paragraph.rs @@ -1,8 +1,8 @@ #![no_main] use libfuzzer_sys::fuzz_target; use onestore::{ - CommitState, ExGuid, Insertion, ParagraphSplit, PreparedEdit, RevisionIndex, Store, - TextAttribute as A, + CommitState, ExGuid, Insertion, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex, + Store, TextAttribute as A, document::{Document, Kind}, }; use std::sync::LazyLock; @@ -43,6 +43,11 @@ fuzz_target!(|input: &[u8]| { { current::current(edit.as_bytes()); } + if let Ok(intent) = serde_json::from_slice::(input) + && let Ok(edit) = PreparedEdit::join(source, *sid, &intent) + { + current::current(edit.as_bytes()); + } let mut persisted = source.clone(); let mut caches = std::array::from_fn::<_, 12, _>(|_| source.clone()); for step in input.chunks_exact(8).take(20) { @@ -65,57 +70,91 @@ fuzz_target!(|input: &[u8]| { paragraphs.push(id); } } - let paragraph = paragraphs[usize::from(step[2]) % paragraphs.len()]; - let text = view.nodes[¶graph].content[0]; - let characters: Vec<_> = view - .text_runs(text) - .unwrap() - .into_iter() - .flat_map(|run| { - let style = serde_json::to_value(run.format).unwrap(); - run.text.chars().map(move |c| (c, style.clone())) + let characters = |view: &onestore::document::Revision<'_>, id| { + view.text_runs(id) + .unwrap() + .into_iter() + .flat_map(|run| { + let style = serde_json::to_value(run.format).unwrap(); + run.text.chars().map(move |c| (c, style.clone())) + }) + .collect::>() + }; + let pairs: Vec<_> = view + .nodes + .iter() + .flat_map(|(parent, node)| { + node.children.windows(2).filter_map(|pair| { + (paragraphs.contains(&pair[0]) + && paragraphs.contains(&pair[1]) + && view.nodes[&pair[0]].children.is_empty()) + .then_some((*parent, pair[0], pair[1])) + }) }) .collect(); - let offsets: Vec = std::iter::once(0) - .chain(characters.iter().scan(0, |n, (c, _)| { - *n += u32::try_from(c.len_utf16()).unwrap(); - Some(*n) - })) - .collect(); - let offset = u32::from(step[3]) % (offsets.last().unwrap() + 2); - let intent = ParagraphSplit::new(text, offset, "Paragraph fuzz").unwrap(); - let intent = serde_json::from_value(serde_json::to_value(intent).unwrap()).unwrap(); - let edit = PreparedEdit::split(source, *sid, &intent); - let Some(position) = offsets.iter().position(|n| *n == offset) else { - assert!(edit.is_err()); - continue; + let mut expected_text = Vec::new(); + let mut expected_graph = Vec::new(); + let edit = if step[1] & 8 != 0 && !pairs.is_empty() { + let (parent, left, right) = pairs[usize::from(step[2]) % pairs.len()]; + let a = view.nodes[&left].content[0]; + let b = view.nodes[&right].content[0]; + let mut expected = characters(view, a); + let survivor = if expected.is_empty() { b } else { a }; + expected.extend(characters(view, b)); + expected_text.push((survivor, expected)); + let children: Vec<_> = view.nodes[&parent] + .children + .iter() + .filter(|id| **id != right) + .copied() + .collect(); + expected_graph.push((parent, children, view.nodes[&parent].content.clone())); + expected_graph.push((left, view.nodes[&right].children.clone(), vec![survivor])); + let intent = ParagraphJoin::new(a, b, "Paragraph fuzz").unwrap(); + let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); + assert_eq!(intent, restored); + PreparedEdit::join(source, *sid, &restored).unwrap() + } else { + let paragraph = paragraphs[usize::from(step[2]) % paragraphs.len()]; + let text = view.nodes[¶graph].content[0]; + let before = characters(view, text); + let offsets: Vec = std::iter::once(0) + .chain(before.iter().scan(0, |n, (c, _)| { + *n += u32::try_from(c.len_utf16()).unwrap(); + Some(*n) + })) + .collect(); + let offset = u32::from(step[3]) % (offsets.last().unwrap() + 2); + let intent = ParagraphSplit::new(text, offset, "Paragraph fuzz").unwrap(); + let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); + assert_eq!(intent, restored); + let edit = PreparedEdit::split(source, *sid, &restored); + let Some(position) = offsets.iter().position(|n| *n == offset) else { + assert!(edit.is_err()); + continue; + }; + expected_text.push((text, before[..position].to_vec())); + expected_text.push((intent.text_object(), before[position..].to_vec())); + expected_graph.push((paragraph, vec![], vec![text])); + expected_graph.push(( + intent.object(), + view.nodes[¶graph].children.clone(), + vec![intent.text_object()], + )); + edit.unwrap() }; - let edit = edit.unwrap(); let after_store = Store::parse(edit.as_bytes()).unwrap(); let after_index = RevisionIndex::parse(&after_store).unwrap(); let after_document = Document::parse(&after_index).unwrap(); let space = &after_document.spaces[sid]; let after_view = &space.revisions[&space.contexts[&ExGuid::default()]]; - for (id, expected) in [ - (text, &characters[..position]), - (intent.text_object(), &characters[position..]), - ] { - let actual: Vec<_> = after_view - .text_runs(id) - .unwrap() - .into_iter() - .flat_map(|run| { - let style = serde_json::to_value(run.format).unwrap(); - run.text.chars().map(move |c| (c, style.clone())) - }) - .collect(); - assert_eq!(actual, expected); + for (id, expected) in expected_text { + assert_eq!(characters(after_view, id), expected); + } + for (id, children, content) in expected_graph { + assert_eq!(after_view.nodes[&id].children, children); + assert_eq!(after_view.nodes[&id].content, content); } - assert!(after_view.nodes[¶graph].children.is_empty()); - assert_eq!( - after_view.nodes[&intent.object()].children, - view.nodes[¶graph].children - ); let before = current::current(&persisted); let after = current::current(edit.as_bytes()); let mut disk = disk::Disk { diff --git a/tools/native/paragraph-tag-joins.ps1 b/tools/native/paragraph-tag-joins.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..023be19ffda21fb41d2fd9655a659df98be2c82b --- /dev/null +++ b/tools/native/paragraph-tag-joins.ps1 @@ -0,0 +1,39 @@ +param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost +$app = New-Object -ComObject OneNote.Application +$notebook = '' +try { + $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebook, 0) + $section = '' + $app.OpenHierarchy('synthetic.one', $notebook, [ref]$section, 0) + $mixed = 'Bold italic ' + [char]::ConvertFromUtf32(0x1F980) + ' e' + [char]0x0301 + ' tail' + $text = '' + $child = 'Preserved child' + $tag = '' + $rightTag = $tag.Replace('index="0"', 'index="1"') + $cases = @( + @{ name = 'Join empty left right tag'; body = '' + $rightTag + $text + '' }, + @{ name = 'Join empty left two tags'; body = '' + $tag + '' + $rightTag + $text + '' }, + @{ name = 'Join different tags'; body = '' + $tag + 'Left' + $rightTag + $text + '' } + ) + $inputs = Join-Path $Root 'inputs' + New-Item -ItemType Directory -Path $inputs | Out-Null + foreach ($case in $cases) { + $page = '' + $app.CreateNewPage($section, [ref]$page, 0) + $xml = '' + $case.name + '' + $case.body + 'Preserved sibling' + [IO.File]::WriteAllText((Join-Path $inputs ($case.name + '.xml')), $xml, [Text.Encoding]::UTF8) + $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $true) + $case.page = $page + } + $cases | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $Root 'cases.json') -Encoding UTF8 + $app.SyncHierarchy($notebook) +} finally { + if ($notebook) { $app.CloseNotebook($notebook, $false) } + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() +} diff --git a/tools/test_paragraph_edit.py b/tools/test_paragraph_edit.py index 4813b927f421f18a8c24b9859ec77d372412985b..dd58f808b585e7a2b4e84e921ab4decab224f1bd 100644 --- a/tools/test_paragraph_edit.py +++ b/tools/test_paragraph_edit.py @@ -17,60 +17,100 @@ compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] class ParagraphEditTest(unittest.TestCase): + 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']) + captures = [] + for folder in (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()]}) + self.assertEqual(len(manifest), {'split': 12, 'inheritance': 5, 'tags': 3}[name]) + for case in manifest: + for a, b in zip(captures[0][case['case']], captures[1][case['case']], strict=True): + for (x, old), (y, new) in zip(a, b, strict=True): + self.assertEqual(x, y) + for key in old.keys() | new.keys(): + default = 'automatic' if key in ('color', 'highlight') else False + self.assertEqual(old.get(key, default), new.get(key, default)) + def test_native_joins_preserve_inherited_styles_and_follow_tag_and_child_rules(self): - fixture = FIXTURE / 'join-edges' - models, captures = {}, {} - with TemporaryDirectory() as temporary: - for phase in ('before', 'joined'): - folder = Path(temporary) / phase - shutil.copytree(fixture / phase / 'read', folder / 'read') - compare(fixture / phase / 'notebook', folder / 'read') - subprocess.run([EXPORTER, fixture / phase / 'notebook/synthetic.one', folder / 'model'], check=True) - model = json.loads((folder / 'model/document.json').read_text()) - models[phase] = {r['nodes'][r['roots']['2']]['kind']['title']: (r, page) - for _, _, r, page in ordered_pages(model)} - captures[phase] = {} - for path in (folder / 'read').glob('page-*.xml'): - page = ET.parse(path).getroot() - captures[phase][page.get('name')] = native_characters(page, page.findall('one:Outline', ns)) - self.assertEqual(len(models[phase]), 6) - cases = json.loads((fixture / 'cases.json').read_text(encoding='utf-8-sig')) - self.assertEqual(len(cases), 5) - for case in cases: - name = case['name'] - with self.subTest(case=name): - old, page = models['before'][name] - new, new_page = models['joined'][name] - self.assertEqual(page, new_page) - outline, = [oid for oid in old['nodes'][page]['children'] if old['nodes'][oid]['kind']['type'] == 'Outline'] - left, right, sibling = old['nodes'][outline]['children'] - self.assertEqual(new['nodes'][outline]['children'], [left, sibling]) - target = old['nodes'][left]['children'][-1] if name == 'Join both children' else left - a, = old['nodes'][target]['content'] - b, = old['nodes'][right]['content'] - empty = old['nodes'][a]['kind']['text'] == '' - survivor = b if empty else a - self.assertEqual(new['nodes'][target]['content'], [survivor]) - self.assertEqual(new['nodes'][survivor]['kind']['text'], old['nodes'][a]['kind']['text'] + old['nodes'][b]['kind']['text']) - self.assertEqual(new['nodes'][survivor]['tags'], old['nodes'][a]['tags']) - children = old['nodes'][left]['children'] + old['nodes'][right]['children'] - self.assertEqual(new['nodes'][left]['children'], children) - self.assertEqual(new['nodes'][sibling], old['nodes'][sibling]) - if name == 'Join both children': - parent_text, = old['nodes'][left]['content'] - original = old['nodes'][parent_text] - current = new['nodes'][parent_text] - self.assertGreater(current['modified'], original['modified']) - expected = {**original, 'modified': current['modified'], - 'extra': [original['extra'][0] + [{'id': 0x880034dd, 'value': 'NoData'}]]} - self.assertEqual(current, expected) - before = [v for paragraph in captures['before'][name] for v in paragraph] - after = [v for paragraph in captures['joined'][name] for v in paragraph] - for (a, old_style), (b, new_style) in zip(before, after, strict=True): - self.assertEqual(a, b) - for key in old_style.keys() | new_style.keys(): - default = 'automatic' if key in ('color', 'highlight') else False - self.assertEqual(old_style.get(key, default), new_style.get(key, default)) + for fixture_name, expected_cases in [('join-edges', 5), ('join-tags', 3)]: + fixture = FIXTURE / fixture_name + models, captures = {}, {} + with TemporaryDirectory() as temporary: + for phase in ('before', 'joined'): + folder = Path(temporary) / phase + shutil.copytree(fixture / phase / 'read', folder / 'read') + compare(fixture / phase / 'notebook', folder / 'read') + subprocess.run([EXPORTER, fixture / phase / 'notebook/synthetic.one', folder / 'model'], check=True) + model = json.loads((folder / 'model/document.json').read_text()) + models[phase] = {r['nodes'][r['roots']['2']]['kind']['title']: (r, page) + for _, _, r, page in ordered_pages(model)} + captures[phase] = {} + for path in (folder / 'read').glob('page-*.xml'): + page = ET.parse(path).getroot() + captures[phase][page.get('name')] = native_characters(page, page.findall('one:Outline', ns)) + self.assertEqual(len(models[phase]), expected_cases + 1) + cases = json.loads((fixture / 'cases.json').read_text(encoding='utf-8-sig')) + self.assertEqual(len(cases), expected_cases) + for case in cases: + name = case['name'] + with self.subTest(case=name): + old, page = models['before'][name] + new, new_page = models['joined'][name] + self.assertEqual(page, new_page) + outline, = [oid for oid in old['nodes'][page]['children'] if old['nodes'][oid]['kind']['type'] == 'Outline'] + left, right, sibling = old['nodes'][outline]['children'] + self.assertEqual(new['nodes'][outline]['children'], [left, sibling]) + target = old['nodes'][left]['children'][-1] if name == 'Join both children' else left + a, = old['nodes'][target]['content'] + b, = old['nodes'][right]['content'] + empty = old['nodes'][a]['kind']['text'] == '' + survivor = b if empty else a + self.assertEqual(new['nodes'][target]['content'], [survivor]) + self.assertEqual(new['nodes'][survivor]['kind']['text'], old['nodes'][a]['kind']['text'] + old['nodes'][b]['kind']['text']) + self.assertEqual(new['nodes'][survivor]['tags'], old['nodes'][a]['tags']) + children = old['nodes'][left]['children'] + old['nodes'][right]['children'] + self.assertEqual(new['nodes'][left]['children'], children) + self.assertEqual(new['nodes'][sibling], old['nodes'][sibling]) + if name == 'Join both children': + parent_text, = old['nodes'][left]['content'] + original = old['nodes'][parent_text] + current = new['nodes'][parent_text] + self.assertGreater(current['modified'], original['modified']) + expected = {**original, 'modified': current['modified'], + 'extra': [original['extra'][0] + [{'id': 0x880034dd, 'value': 'NoData'}]]} + self.assertEqual(current, expected) + before = [v for paragraph in captures['before'][name] for v in paragraph] + after = [v for paragraph in captures['joined'][name] for v in paragraph] + for (a, old_style), (b, new_style) in zip(before, after, strict=True): + self.assertEqual(a, b) + for key in old_style.keys() | new_style.keys(): + default = 'automatic' if key in ('color', 'highlight') else False + self.assertEqual(old_style.get(key, default), new_style.get(key, default)) + def test_rust_splits_match_native_controls_and_preserve_empty_typing_styles(self): fixture = FIXTURE / 'rust-split'