diff --git a/corpus/link-edit/README.md b/corpus/link-edit/README.md index bb18bc40a59103567d2afa9ae5cfd08f99e1af51..5a98537fde0c8d2a86dc4c31192c057827fd2f22 100644 --- a/corpus/link-edit/README.md +++ b/corpus/link-edit/README.md @@ -37,3 +37,42 @@ second page, whose first page links to that page and to its first paragraph with URLs built by `onestore::page::link::internal_link`. `internal/cold` is its cold read with both links. Regenerate with `ONESTORE_INTERNAL_LINK_EXPORT` and cold-open with `--expected-pages 2`. + +## Typed links + +`native-typed/` is OneNote 2010 typing on the Rust-authored page above, driven by +`tools/native_links.py` (`links.ahk` the AutoHotkey session, `links.png` the +desktop afterwards). Each URL typed and ended by a space or Enter became a link +of its own text, a hyperlink run with no field code and no label flag: `http`, +`https`, `ftp`, `file`, `mailto`, `news` and `onenote` schemes, `www.` and +`\\server\share` paths. Trailing punctuation and an unmatched closing bracket +stay outside; `me@example.com` and `example.com` stay text. The Link dialog +(Ctrl+K) on the word at the caret, on a selection and on nothing stored the +address as typed (`example.net/x`, which the COM read shows as +`http://example.net/x`) in a hidden field code before a label flagged as one, +the address itself when the text was empty; text typed after a label is plain. +Remove Link (Shift+F10, R) left the URL as plain text, its runs' link flag +false. An interactive session on a copy showed the rest: a click on a link opens +it at once, typing straight after a link of its own text extends it and its +address, and Copy Link to Page puts +`onenote:///
#§ion-id={…}&page-id={…}&end` on the +clipboard (`&object-id={…}&n` in place of `&end` for Copy Link to Paragraph), +which pastes as a labelled link. `crates/canvas/src/editor/link.rs` replays +the session through the editor and compares every paragraph's link runs. + +## Links and equations from the editor + +`editor/candidate` is the canvas editor's output for +`crates/canvas/tests/links_equations.rs` on the internal-link section above: +URLs typed and ended by a space or Enter, the Link dialog on a selection, on the +word at the caret and on nothing, links to the other page and to a paragraph, +Remove Link, and equations typed after Alt+= (one switched to Linear, one after +text in its paragraph), all saved as the ops the editor recorded. +`editor/cold` is its cold OneNote 2010 read: `tools/test_link_edit.py` +compares it with the candidate and checks the links and MathML in +`editor/expected.json`. OneNote's read opens an address without a scheme over +http and links URL text again when it opens a page; its own section does the +same after Remove Link (`native-typed/cold`, its cold read, shows `Gone +ftp://h.example/f` linked again). Regenerate with +`CANVAS_LINKS_EQUATIONS_EXPORT` set to a new directory, move `expected.json` +beside the candidate, and cold-open with `--expected-pages 2 --screenshots`. diff --git a/corpus/link-edit/editor/candidate/Open Notebook.onetoc2 b/corpus/link-edit/editor/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..45ba18c99f5934accde1640689d5e7de327b3a46 Binary files /dev/null and b/corpus/link-edit/editor/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/link-edit/editor/candidate/links.one b/corpus/link-edit/editor/candidate/links.one new file mode 100644 index 0000000000000000000000000000000000000000..92a925b856fdfecea4f4a8e350f68d24af26b73d Binary files /dev/null and b/corpus/link-edit/editor/candidate/links.one differ diff --git a/corpus/link-edit/editor/cold/commands.jsonl b/corpus/link-edit/editor/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..f16dcbeb76812443ac916a89912a76c225fa11a8 --- /dev/null +++ b/corpus/link-edit/editor/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-8C62D976 -ExpectedPages 2 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/link-edit/editor/cold/machine.json b/corpus/link-edit/editor/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..d0d1a968d6cec9abb0ace4d3a106473a91563b1b --- /dev/null +++ b/corpus/link-edit/editor/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-8c62d976", "hostname": "ONE-M6-8C62D976"} diff --git a/corpus/link-edit/editor/cold/notebook/Open Notebook.onetoc2 b/corpus/link-edit/editor/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..cf5ba8ff0e74e8b316e9c9a8b3b1d8a08b1b80c6 Binary files /dev/null and b/corpus/link-edit/editor/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/link-edit/editor/cold/notebook/links.one b/corpus/link-edit/editor/cold/notebook/links.one new file mode 100644 index 0000000000000000000000000000000000000000..4e583cff08fba8547a415e7d379182c4405f6e6f Binary files /dev/null and b/corpus/link-edit/editor/cold/notebook/links.one differ diff --git a/corpus/link-edit/editor/cold/read/environment.json b/corpus/link-edit/editor/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..f0b2dee4a1fd985bb211f54f6cbfc916591f6f76 --- /dev/null +++ b/corpus/link-edit/editor/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-8C62D976", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/link-edit/editor/cold/read/hierarchy.xml b/corpus/link-edit/editor/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..7e4d4570edcdcec4dee9d9542a5453d234b1e74f --- /dev/null +++ b/corpus/link-edit/editor/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{73BFB197-D55A-45AA-B33D-13AE4D508815}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-28T07:41:49.000Z" color="#B49EDE"><one:Section name="links" ID="{E018864F-A1D8-099B-1541-A508D3F390DC}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-28T07:41:49.000Z" color="#8AA8E4"><one:Page ID="{2075D4FE-ABBA-0064-39D5-AE81ADA51B22}{1}{B0}" name="Linking page Link target" dateTime="2026-09-26T06:32:48.000Z" lastModifiedTime="2026-09-28T07:41:49.000Z" pageLevel="1"/><one:Page ID="{237B9A56-69AE-037B-0E57-A9B13FBCE71B}{1}{B0}" name="Link target" dateTime="2026-09-26T06:32:48.000Z" lastModifiedTime="2026-09-28T07:41:49.000Z" pageLevel="1"/></one:Section></one:Notebook> diff --git a/corpus/link-edit/editor/cold/read/page-000.navigation.json b/corpus/link-edit/editor/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..72c384aa3e0aac7e316e880cbb4a9c2575e673b4 --- /dev/null +++ b/corpus/link-edit/editor/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Linking page Link target - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/link-edit/editor/cold/read/page-000.png b/corpus/link-edit/editor/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..5077ba4c4983359087dd391cd7985403b27bb920 Binary files /dev/null and b/corpus/link-edit/editor/cold/read/page-000.png differ diff --git a/corpus/link-edit/editor/cold/read/page-000.xml b/corpus/link-edit/editor/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..919bf82bcbaec71f137b319407d9870ce9232a81 --- /dev/null +++ b/corpus/link-edit/editor/cold/read/page-000.xml @@ -0,0 +1,16 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{2075D4FE-ABBA-0064-39D5-AE81ADA51B22}{1}{B0}" name="Linking page Link target" dateTime="2026-09-26T06:32:48.000Z" lastModifiedTime="2026-09-28T07:41:49.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-26T06:32:48.000Z" objectID="{E018864F-A1D8-099B-1541-A508D3F390DC}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="468.0" height="387.6758728027344"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-26T06:32:48.000Z" lastModifiedTime="2026-09-26T06:32:48.000Z" objectID="{E018864F-A1D8-099B-1541-A508D3F390DC}{24}{B0}" alignment="left"><one:T><![CDATA[Linking page <a +href="onenote:#Link%20target&section-id={912D18F5-8E97-4C22-B11D-AA61E6E7D4B1}&page-id={54104D3B-F876-47C6-8009-D90D3DEC5217}&end&base-path=C:\one-tests\runs\capture\notebook\links.one">Link target</a>]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{7}{B0}" alignment="left"><one:T><![CDATA[Visit <a +href="http://www.example.com">www.example.com</a> or (<a +href="https://example.org/a?b=1">https://example.org/a?b=1</a>). ]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{11}{B0}" alignment="left"><one:T><![CDATA[Ends with <a +href="http://example.net/end">http://example.net/end</a>]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{15}{B0}" alignment="left"><one:T><![CDATA[Read the <a +href="https://doc.rust-lang.org/book/">Rust book</a> today]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{19}{B0}" alignment="left"><one:T><![CDATA[A <a +href="http://example.net/x">word</a> here]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{23}{B0}" alignment="left"><one:T><![CDATA[<a +href="https://example.com/only">https://example.com/only</a> tail]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{25}{B0}" alignment="left"><one:T><![CDATA[See <a +href="onenote:#Link%20target&section-id={912D18F5-8E97-4C22-B11D-AA61E6E7D4B1}&page-id={54104D3B-F876-47C6-8009-D90D3DEC5217}&end&base-path=C:\one-tests\runs\capture\notebook\links.one">the target page</a>]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{27}{B0}" alignment="left"><one:T><![CDATA[<a +href="onenote:#Linking%20page&section-id={912D18F5-8E97-4C22-B11D-AA61E6E7D4B1}&page-id={3225B89C-1ACE-4B05-A5C8-FF6E178A21D7}&object-id={97735122-3000-4D26-9B1F-D5B4D1A325D0}&24&base-path=C:\one-tests\runs\capture\notebook\links.one">Back to the top</a>]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{29}{B0}" alignment="left"><one:T><![CDATA[Gone <a +href="ftp://h.example/f">ftp://h.example/f</a> z]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{31}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msub><mml:mi>a</mml:mi><mml:mn>1</mml:mn></mml:msub><mml:mo>+</mml:mo><mml:msub><mml:mi>b</mml:mi><mml:mn>2</mml:mn></mml:msub></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{33}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msubsup><mml:mo stretchy="false">∫</mml:mo><mml:mn>0</mml:mn><mml:mn>1</mml:mn></mml:msubsup><mml:mrow><mml:mi>x</mml:mi><mml:mo> </mml:mo><mml:mi>d</mml:mi><mml:mi>x</mml:mi><mml:mo> </mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{35}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:mi>c</mml:mi><mml:mo>+</mml:mo><mml:mi>d</mml:mi></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{37}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msqrt><mml:mi>x</mml:mi></mml:msqrt><mml:mo>+</mml:mo><mml:mn>1</mml:mn><mml:mo> </mml:mo></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{39}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munderover><mml:mo stretchy="false">∑</mml:mo><mml:mrow><mml:mi>i</mml:mi><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mrow><mml:mi>n</mml:mi></mml:munderover><mml:mrow><mml:mi>i</mml:mi><mml:mo> </mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{41}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:mn>1</mml:mn></mml:mtd><mml:mtd><mml:mn>2</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:mn>3</mml:mn></mml:mtd><mml:mtd><mml:mn>4</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{43}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mi>e</mml:mi><mml:mo>^</mml:mo><mml:mo fence="false">(</mml:mo><mml:mi>x</mml:mi><mml:mo>+</mml:mo><mml:mn>1</mml:mn><mml:mo fence="false">)</mml:mo></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{45}{B0}" alignment="left" style="font-size:11.0pt"><one:T><![CDATA[<span +style='font-family:Calibri' lang=en-US>Area </span><!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mi>𝜋</mml:mi><mml:msup><mml:mi>r</mml:mi><mml:mn>2</mml:mn></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{47}{B0}" alignment="left"><one:T><![CDATA[Split ]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{49}{B0}" alignment="left" style="font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>y</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo></mml:math><![endif]--><span +style='font-family:Calibri' lang=en-US><br> +</span><!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mn>1</mml:mn><mml:mo> </mml:mo></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{51}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>c</mml:mi></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mo>+</mml:mo><mml:mi>d</mml:mi></mml:mtd></mml:mtr></mml:mtable></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{53}{B0}" alignment="left"><one:T><![CDATA[Moved ]]></one:T></one:OE><one:OE author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{F3330A81-6E1B-07A5-343B-138D77A42F82}{55}{B0}" alignment="left"><one:T><![CDATA[<a +href="https://example.com/moved">down</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/link-edit/editor/cold/read/page-001.navigation.json b/corpus/link-edit/editor/cold/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..679951c672daa4da7250052c61ac1f2e2d199efe --- /dev/null +++ b/corpus/link-edit/editor/cold/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Link target - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/link-edit/editor/cold/read/page-001.png b/corpus/link-edit/editor/cold/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..484d544e50003fc0a4f73fd7ed50c2f96c027c84 Binary files /dev/null and b/corpus/link-edit/editor/cold/read/page-001.png differ diff --git a/corpus/link-edit/editor/cold/read/page-001.xml b/corpus/link-edit/editor/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..aabcec4412d45a6dd140027577fc699871b831eb --- /dev/null +++ b/corpus/link-edit/editor/cold/read/page-001.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{237B9A56-69AE-037B-0E57-A9B13FBCE71B}{1}{B0}" name="Link target" dateTime="2026-09-26T06:32:48.000Z" lastModifiedTime="2026-09-28T07:41:49.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" lastModifiedBy="Author" creationTime="2026-09-26T06:32:48.000Z" lastModifiedTime="2026-09-26T06:32:48.000Z" objectID="{237B9A56-69AE-037B-0E57-A9B13FBCE71B}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Link target]]></one:T></one:OE></one:Title></one:Page> diff --git a/corpus/link-edit/editor/cold/read/payloads.json b/corpus/link-edit/editor/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/link-edit/editor/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/link-edit/editor/cold/run.json b/corpus/link-edit/editor/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..a1a590a83e62f3ef3b5f080ce7573ad6effeb7fa --- /dev/null +++ b/corpus/link-edit/editor/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/cands/le3nb", + "expected_pages": 2, + "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/link-edit/editor/cold/scripts/cold.ps1 b/corpus/link-edit/editor/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/link-edit/editor/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/link-edit/editor/cold/scripts/read.ps1 b/corpus/link-edit/editor/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/link-edit/editor/cold/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/link-edit/editor/cold/source.json b/corpus/link-edit/editor/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..b0b637282e3e7059dd438cd78c5e7bdc762add2b --- /dev/null +++ b/corpus/link-edit/editor/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "7cf4bd0ba068eaccb8e2d3306472ac133a08966eb9dc42cc2b4f7c3b0f44b03f", + "mtime_ns": 1790581219409357669 + }, + { + "path": "links.one", + "bytes": 24856, + "sha256": "0111a366e9e1b11029856360d54a7fb6665d47184a6622321e5639b945051811", + "mtime_ns": 1790581219407781429 + } +] diff --git a/corpus/link-edit/editor/cold/teardown.json b/corpus/link-edit/editor/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/link-edit/editor/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/link-edit/editor/expected.json b/corpus/link-edit/editor/expected.json new file mode 100644 index 0000000000000000000000000000000000000000..934eb707ce2aa96d42c06e9664c6943bcef8a6fb --- /dev/null +++ b/corpus/link-edit/editor/expected.json @@ -0,0 +1,28 @@ +{ + "hrefs": [ + "onenote:#Link%20target§ion-id={912D18F5-8E97-4C22-B11D-AA61E6E7D4B1}&page-id={54104D3B-F876-47C6-8009-D90D3DEC5217}&end&base-path=C:\\one-tests\\runs\\capture\\notebook\\links.one", + "http://www.example.com", + "https://example.org/a?b=1", + "http://example.net/end", + "https://doc.rust-lang.org/book/", + "http://example.net/x", + "https://example.com/only", + "onenote:#Link%20target§ion-id={912D18F5-8E97-4C22-B11D-AA61E6E7D4B1}&page-id={54104D3B-F876-47C6-8009-D90D3DEC5217}&end&base-path=C:\\one-tests\\runs\\capture\\notebook\\links.one", + "onenote:#Linking%20page§ion-id={912D18F5-8E97-4C22-B11D-AA61E6E7D4B1}&page-id={3225B89C-1ACE-4B05-A5C8-FF6E178A21D7}&object-id={97735122-3000-4D26-9B1F-D5B4D1A325D0}&24&base-path=C:\\one-tests\\runs\\capture\\notebook\\links.one", + "ftp://h.example/f", + "https://example.com/moved" + ], + "mathml": [ + "<mml:msub><mml:mi>a</mml:mi><mml:mn>1</mml:mn></mml:msub><mml:mo>+</mml:mo><mml:msub><mml:mi>b</mml:mi><mml:mn>2</mml:mn></mml:msub>", + "<mml:msubsup><mml:mo stretchy=\"false\">∫</mml:mo><mml:mn>0</mml:mn><mml:mn>1</mml:mn></mml:msubsup><mml:mrow><mml:mi>x</mml:mi><mml:mo> </mml:mo><mml:mi>d</mml:mi><mml:mi>x</mml:mi><mml:mo> </mml:mo></mml:mrow>", + "<mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:mi>c</mml:mi><mml:mo>+</mml:mo><mml:mi>d</mml:mi></mml:mrow></mml:mfrac>", + "<mml:msqrt><mml:mi>x</mml:mi></mml:msqrt><mml:mo>+</mml:mo><mml:mn>1</mml:mn><mml:mo> </mml:mo>", + "<mml:munderover><mml:mo stretchy=\"false\">∑</mml:mo><mml:mrow><mml:mi>i</mml:mi><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mrow><mml:mi>n</mml:mi></mml:munderover><mml:mrow><mml:mi>i</mml:mi><mml:mo> </mml:mo></mml:mrow>", + "<mml:mtable><mml:mtr><mml:mtd><mml:mn>1</mml:mn></mml:mtd><mml:mtd><mml:mn>2</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:mn>3</mml:mn></mml:mtd><mml:mtd><mml:mn>4</mml:mn></mml:mtd></mml:mtr></mml:mtable>", + "<mml:mi>e</mml:mi><mml:mo>^</mml:mo><mml:mo fence=\"false\">(</mml:mo><mml:mi>x</mml:mi><mml:mo>+</mml:mo><mml:mn>1</mml:mn><mml:mo fence=\"false\">)</mml:mo>", + "<mml:mi>𝜋</mml:mi><mml:msup><mml:mi>r</mml:mi><mml:mn>2</mml:mn></mml:msup>", + "<mml:msup><mml:mi>y</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo>", + "<mml:mn>1</mml:mn><mml:mo> </mml:mo>", + "<mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>c</mml:mi></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mo>+</mml:mo><mml:mi>d</mml:mi></mml:mtd></mml:mtr></mml:mtable></mml:mrow></mml:mfrac>" + ] +} \ No newline at end of file diff --git a/corpus/link-edit/native-typed/before-read/environment.json b/corpus/link-edit/native-typed/before-read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..bad04f6e2526daeb6b15c5ccf3953a1e69118362 --- /dev/null +++ b/corpus/link-edit/native-typed/before-read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-AFF74949", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/link-edit/native-typed/before-read/hierarchy.xml b/corpus/link-edit/native-typed/before-read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..f0d9da2fcfbca1896c88de38e74d10040004a425 --- /dev/null +++ b/corpus/link-edit/native-typed/before-read/hierarchy.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{41F2DADE-F5F0-433F-8ECC-52B84008DDE6}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-28T01:31:26.000Z" color="#B49EDE"><one:Section name="links" ID="{6A1E8E3D-937E-0251-09BA-580AD5F39CC6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-28T01:31:26.000Z" color="#8AA8E4"><one:Page ID="{CB1348B4-FB69-0D6C-1F11-786A2D10153D}{1}{B0}" name="Read about Rust ﷟HYPERLINK "https://example.invalid/rust"the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:31:15.000Z" pageLevel="1"/></one:Section></one:Notebook> diff --git a/corpus/link-edit/native-typed/before-read/page-000.xml b/corpus/link-edit/native-typed/before-read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..3a2027772dc0051572a15692cb7d83c12f5cb981 --- /dev/null +++ b/corpus/link-edit/native-typed/before-read/page-000.xml @@ -0,0 +1,3 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{CB1348B4-FB69-0D6C-1F11-786A2D10153D}{1}{B0}" name="Read about Rust ﷟HYPERLINK "https://example.invalid/rust"the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:31:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-12T09:49:16.000Z" objectID="{6A1E8E3D-937E-0251-09BA-580AD5F39CC6}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-12T09:49:16.000Z" objectID="{6A1E8E3D-937E-0251-09BA-580AD5F39CC6}{24}{B0}" alignment="left"><one:T><![CDATA[Read about Rust <a +href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/link-edit/native-typed/before-read/payloads.json b/corpus/link-edit/native-typed/before-read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/link-edit/native-typed/before-read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/link-edit/native-typed/cold/commands.jsonl b/corpus/link-edit/native-typed/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..caeb2843f9eee84c9b1094e0902820e03f67616b --- /dev/null +++ b/corpus/link-edit/native-typed/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-367C90E8 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\read -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/link-edit/native-typed/cold/machine.json b/corpus/link-edit/native-typed/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..2e3d94f966c5d01b4546a66a5bd864f36d0a9d46 --- /dev/null +++ b/corpus/link-edit/native-typed/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-367c90e8", "hostname": "ONE-M6-367C90E8"} diff --git a/corpus/link-edit/native-typed/cold/read/environment.json b/corpus/link-edit/native-typed/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..70cb710c44783bbe367da13856605905a5b6dad9 --- /dev/null +++ b/corpus/link-edit/native-typed/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-367C90E8", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/link-edit/native-typed/cold/read/hierarchy.xml b/corpus/link-edit/native-typed/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..aa1170809c7db70919e584a0492c458a52330b09 --- /dev/null +++ b/corpus/link-edit/native-typed/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{0F4C9CD6-B03C-4483-B9D6-7B666F385EBA}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-28T05:41:37.000Z" color="#B49EDE"><one:Section name="links" ID="{183267C0-E406-07DC-0B96-9F1B5960351B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-28T05:41:37.000Z" color="#8AA8E4"><one:Page ID="{B93FA149-8C11-08E1-1D3D-BF7BA183BCE0}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:32:56.000Z" pageLevel="1"/></one:Section></one:Notebook> diff --git a/corpus/link-edit/native-typed/cold/read/page-000.navigation.json b/corpus/link-edit/native-typed/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..83fb4df3362bb2ac7e9fa657fe6db76614b54e31 --- /dev/null +++ b/corpus/link-edit/native-typed/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Read about Rust the Rust site - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/link-edit/native-typed/cold/read/page-000.png b/corpus/link-edit/native-typed/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..ccb1b64554490f6b2bdc5f78f2496138c2210599 Binary files /dev/null and b/corpus/link-edit/native-typed/cold/read/page-000.png differ diff --git a/corpus/link-edit/native-typed/cold/read/page-000.xml b/corpus/link-edit/native-typed/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..18df2dc394aecf1e9ce7950078d82a88f5ba8f57 --- /dev/null +++ b/corpus/link-edit/native-typed/cold/read/page-000.xml @@ -0,0 +1,21 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B93FA149-8C11-08E1-1D3D-BF7BA183BCE0}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:32:56.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-28T01:32:56.000Z" objectID="{183267C0-E406-07DC-0B96-9F1B5960351B}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="247.9598236083984" height="281.9819946289062"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:32:38.000Z" objectID="{183267C0-E406-07DC-0B96-9F1B5960351B}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Read about Rust <a +href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:43.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{12}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="http://www.example.com">www.example.com</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:43.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{17}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="http://example.org/a?b=1">http://example.org/a?b=1</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:43.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{19}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="https://a.example/p">https://a.example/p</a>. Z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:44.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{21}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T (<a +href="http://b.example/q">http://b.example/q</a>) z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:44.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{23}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="ftp://c.example/f">ftp://c.example/f</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:44.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{25}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="mailto:me@example.com">mailto:me@example.com</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:45.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{27}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T me@example.com z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:45.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{29}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T example.com z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:45.000Z" lastModifiedTime="2026-09-28T01:32:45.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{31}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="onenote:#Page&section-id={x}">onenote:#Page&section-id={x}</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:46.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{33}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="file:///c:\windows">file://c:/windows</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:46.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{35}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="file:///\\server\share\f">\\server\share\f</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:47.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{37}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="news:comp.lang">news:comp.lang</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:47.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{39}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="http://www.d.example">www.d.example</a>, z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:47.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{41}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="HTTP://E.EXAMPLE/UP">HTTP://E.EXAMPLE/UP</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:48.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{43}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="https://f.example/a_b-c~d%20e#frag">https://f.example/a_b-c~d%20e#frag</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:48.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{45}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[See <a +href="http://g.example/end">http://g.example/end</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:49.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{47}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Zplain words <a +href="http://example.net/x">here</a>Q]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:51.000Z" lastModifiedTime="2026-09-28T01:32:51.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Select <a +href="https://example.com/selected">me please</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:54.000Z" lastModifiedTime="2026-09-28T01:32:55.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{53}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a +href="https://example.com/only">https://example.com/only</a> tail]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:56.000Z" lastModifiedTime="2026-09-28T01:32:56.000Z" objectID="{F739B035-CE59-4E3D-A5F7-400AA3776C53}{55}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Gone <a +href="ftp://h.example/f">ftp://h.example/f</a> z]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/link-edit/native-typed/cold/read/payloads.json b/corpus/link-edit/native-typed/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/link-edit/native-typed/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/link-edit/native-typed/cold/run.json b/corpus/link-edit/native-typed/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..6fe39a7226f1a312f5f9c7c8ba31b9692d4c1aa7 --- /dev/null +++ b/corpus/link-edit/native-typed/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/cands/native-typed-copy", + "expected_pages": 1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": false, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/link-edit/native-typed/cold/scripts/cold.ps1 b/corpus/link-edit/native-typed/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/link-edit/native-typed/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/link-edit/native-typed/cold/scripts/read.ps1 b/corpus/link-edit/native-typed/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/link-edit/native-typed/cold/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/link-edit/native-typed/cold/source.json b/corpus/link-edit/native-typed/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..86bb5a20d081d8d41f023982bdc1396f5e0fb2ca --- /dev/null +++ b/corpus/link-edit/native-typed/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 3764, + "sha256": "71fdfa91de2db76fdb2ecd3c05a4c5688f9c97c10bfb077bc0945d5e01c8efa2", + "mtime_ns": 1790573993933440881 + }, + { + "path": "links.one", + "bytes": 18320, + "sha256": "d716882cf7be8938b6506293d1b1542b8f458f8cd7c4ee74b1545848bc8c28c4", + "mtime_ns": 1790573993934334422 + } +] diff --git a/corpus/link-edit/native-typed/cold/teardown.json b/corpus/link-edit/native-typed/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/link-edit/native-typed/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/link-edit/native-typed/commands.jsonl b/corpus/link-edit/native-typed/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..d8b020daf0739d5a02c9530e40f1611931fd616c --- /dev/null +++ b/corpus/link-edit/native-typed/commands.jsonl @@ -0,0 +1,6 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-AFF74949 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-AFF74949 -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/link-edit/native-typed/links.ahk b/corpus/link-edit/native-typed/links.ahk new file mode 100644 index 0000000000000000000000000000000000000000..81a387126dd94b94f4031b7f9e23f8ddaf109c5c --- /dev/null +++ b/corpus/link-edit/native-typed/links.ahk @@ -0,0 +1,133 @@ +ComObject("OneNote.Application").NavigateTo("{CB1348B4-FB69-0D6C-1F11-786A2D10153D}{1}{B0}", "", false) +WinWait("ahk_exe ONENOTE.EXE", , 30) +WinActivate("ahk_exe ONENOTE.EXE") +WinWaitActive("ahk_exe ONENOTE.EXE", , 30) +WinMaximize("ahk_exe ONENOTE.EXE") +Sleep(3000) +CoordMode("Mouse", "Screen") +Click(200, 140) +Sleep(500) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(500) +SendText("t www.example.com") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t http://example.org/a?b=1") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t https://a.example/p.") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t (http://b.example/q)") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t ftp://c.example/f") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t mailto:me@example.com") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t me@example.com") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t example.com") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t onenote:#Page§ion-id={x}") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t file://c:/windows") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t \\server\share\f") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t news:comp.lang") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t www.d.example,") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t HTTP://E.EXAMPLE/UP") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("t https://f.example/a_b-c~d%20e#frag") +Send(" ") +SendText("z") +Send("{Enter}") +Sleep(300) +SendText("see http://g.example/end") +Send("{Enter}") +Send("{Up}{End}") +SendText("Z") +Send("{Down}{End}") +Sleep(300) +SendText("plain words here") +Send("^{Left}") +Send("^k") +Sleep(1500) +SendText("example.net/x") +Send("{Enter}") +Sleep(800) +Send("{End}") +SendText("Q") +Send("{Enter}") +Sleep(300) +SendText("select me please") +Send("+^{Left}+^{Left}") +Send("^k") +Sleep(1500) +SendText("https://example.com/selected") +Send("{Enter}") +Sleep(800) +Send("{End}{Enter}") +Sleep(300) +Send("^k") +Sleep(1500) +Send("!e") +Sleep(300) +SendText("https://example.com/only") +Send("{Enter}") +Sleep(800) +SendText(" tail") +Send("{Enter}") +Sleep(300) +SendText("gone ftp://h.example/f") +Send(" ") +SendText("z") +Send("{Home}^{Right}^{Right}") +Send("+{F10}") +Sleep(900) +Send("r") +Sleep(800) +Sleep(2000) diff --git a/corpus/link-edit/native-typed/links.json b/corpus/link-edit/native-typed/links.json new file mode 100644 index 0000000000000000000000000000000000000000..83fb4df3362bb2ac7e9fa657fe6db76614b54e31 --- /dev/null +++ b/corpus/link-edit/native-typed/links.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Read about Rust the Rust site - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/link-edit/native-typed/links.png b/corpus/link-edit/native-typed/links.png new file mode 100644 index 0000000000000000000000000000000000000000..6a99d7fa216a4cf58517f5808193a5358af00e18 Binary files /dev/null and b/corpus/link-edit/native-typed/links.png differ diff --git a/corpus/link-edit/native-typed/machine.json b/corpus/link-edit/native-typed/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..374c23e77f28846e1eca4d07db998370bf49c3ab --- /dev/null +++ b/corpus/link-edit/native-typed/machine.json @@ -0,0 +1 @@ +{"name": "m6-aff74949", "hostname": "ONE-M6-AFF74949"} diff --git a/corpus/link-edit/native-typed/notebook/Open Notebook.onetoc2 b/corpus/link-edit/native-typed/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..505a77cc124567f1d968a49646186a35f09993d2 Binary files /dev/null and b/corpus/link-edit/native-typed/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/link-edit/native-typed/notebook/links.one b/corpus/link-edit/native-typed/notebook/links.one new file mode 100644 index 0000000000000000000000000000000000000000..8bc4c2d3f3e75871cbcc6e31a87174d53b4f7d75 Binary files /dev/null and b/corpus/link-edit/native-typed/notebook/links.one differ diff --git a/corpus/link-edit/native-typed/read/environment.json b/corpus/link-edit/native-typed/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..a9db74f025215bedb45f05034c6839bcb12d28df --- /dev/null +++ b/corpus/link-edit/native-typed/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-AFF74949", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/link-edit/native-typed/read/hierarchy.xml b/corpus/link-edit/native-typed/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..f575dba8f952fa258319ed9992f485dd8eb56586 --- /dev/null +++ b/corpus/link-edit/native-typed/read/hierarchy.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{41F2DADE-F5F0-433F-8ECC-52B84008DDE6}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-28T01:32:56.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="links" ID="{6A1E8E3D-937E-0251-09BA-580AD5F39CC6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-28T01:32:56.000Z" color="#8AA8E4" isCurrentlyViewed="true"><one:Page ID="{CB1348B4-FB69-0D6C-1F11-786A2D10153D}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:32:56.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section></one:Notebook> diff --git a/corpus/link-edit/native-typed/read/page-000.xml b/corpus/link-edit/native-typed/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..906f626c31835d02784f86b71b8d0c4fdb5ea3ac --- /dev/null +++ b/corpus/link-edit/native-typed/read/page-000.xml @@ -0,0 +1,21 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{CB1348B4-FB69-0D6C-1F11-786A2D10153D}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:32:56.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-28T01:32:56.000Z" objectID="{6A1E8E3D-937E-0251-09BA-580AD5F39CC6}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="247.9598236083984" height="281.9819946289062"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:32:38.000Z" objectID="{6A1E8E3D-937E-0251-09BA-580AD5F39CC6}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Read about Rust <a +href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:43.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{12}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="http://www.example.com">www.example.com</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:43.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{17}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="http://example.org/a?b=1">http://example.org/a?b=1</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:43.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{19}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="https://a.example/p">https://a.example/p</a>. Z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:44.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{21}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T (<a +href="http://b.example/q">http://b.example/q</a>) z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:44.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{23}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="ftp://c.example/f">ftp://c.example/f</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:44.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{25}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="mailto:me@example.com">mailto:me@example.com</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:45.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{27}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T me@example.com z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:45.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{29}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T example.com z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:45.000Z" lastModifiedTime="2026-09-28T01:32:45.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{31}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="onenote:#Page&section-id={x}">onenote:#Page&section-id={x}</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:46.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{33}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="file:///c:\windows">file://c:/windows</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:46.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{35}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="file:///\\server\share\f">\\server\share\f</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:47.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{37}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="news:comp.lang">news:comp.lang</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:47.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{39}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="http://www.d.example">www.d.example</a>, z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:47.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{41}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="HTTP://E.EXAMPLE/UP">HTTP://E.EXAMPLE/UP</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:48.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{43}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[T <a +href="https://f.example/a_b-c~d%20e#frag">https://f.example/a_b-c~d%20e#frag</a> z]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:48.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{45}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[See <a +href="http://g.example/end">http://g.example/end</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:49.000Z" lastModifiedTime="2026-09-28T01:32:49.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{47}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Zplain words <a +href="http://example.net/x">here</a>Q]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:51.000Z" lastModifiedTime="2026-09-28T01:32:51.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Select <a +href="https://example.com/selected">me please</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:54.000Z" lastModifiedTime="2026-09-28T01:32:55.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{53}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a +href="https://example.com/only">https://example.com/only</a> tail]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:32:56.000Z" lastModifiedTime="2026-09-28T01:32:56.000Z" objectID="{851559C8-B921-4BB0-A7DB-871B2FE4C58E}{55}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Gone <a +href="ftp://h.example/f">ftp://h.example/f</a> z]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/link-edit/native-typed/read/payloads.json b/corpus/link-edit/native-typed/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/link-edit/native-typed/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/link-edit/native-typed/run.json b/corpus/link-edit/native-typed/run.json new file mode 100644 index 0000000000000000000000000000000000000000..9e670c7143179c57e2107d7c15c06d0cb8b81a64 --- /dev/null +++ b/corpus/link-edit/native-typed/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/corpus/link-edit/candidate", + "expected_pages": 1, + "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/link-edit/native-typed/scripts/cold.ps1 b/corpus/link-edit/native-typed/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/link-edit/native-typed/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/link-edit/native-typed/scripts/read.ps1 b/corpus/link-edit/native-typed/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/link-edit/native-typed/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/link-edit/native-typed/source.json b/corpus/link-edit/native-typed/source.json new file mode 100644 index 0000000000000000000000000000000000000000..14786084a474f1cc0a6f522bd49c8104ad3627f0 --- /dev/null +++ b/corpus/link-edit/native-typed/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "6731c4214437fe1cb6aa347b0004542b7f7e4260f47773bf9ec07c4e894de47c", + "mtime_ns": 1789206821144296891 + }, + { + "path": "links.one", + "bytes": 6128, + "sha256": "0cfb356ed374fe919f73fb73fdbe5d66e0baa484d4119f70074d9f869d6db78a", + "mtime_ns": 1789206821116200533 + } +] diff --git a/corpus/link-edit/native-typed/teardown.json b/corpus/link-edit/native-typed/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/link-edit/native-typed/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/math-edit/README.md b/corpus/math-edit/README.md index 76b3c8eee75a54973364e4a431c410aa5fdc837a..2909cd9d330468d6fbea9b503b5df1805978b36d 100644 --- a/corpus/math-edit/README.md +++ b/corpus/math-edit/README.md @@ -53,3 +53,41 @@ corpus/link-edit/candidate OUTPUT` and the written one with `ONESTORE_MATH_EXPORT` set while running the test, then `tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook --screenshots`. + +`native-editor-4/` is a fourth `tools/native_math.py` session repeating most +of the forms above and adding `e^(x+1)`, `\alpha+\beta` (which stays linear +text: one run between objects, Greek in mathematical italic), +`(a+b)/(c+d)`, `x^2+y^2=z^2`, `lim_(n\to\infty) a_n` (a function-apply +object, 17 with U+2061, around a limit; not built by the linear reading +here) and `{a+b}`; `x_i^2` kept the editor's “Type equation here.” +placeholder (11 with `⬚`), as the editor was still opening. + +`native-linear/` is the linear form OneNote 2010 shows and stores after +Equation Tools, Design, Linear, applied to each equation of a session like the +fourth one plus `x^2+1` and `(a+b)/(c+d)`. It was authored interactively in a +lab clone through its desktop agent (clicking each equation, then Linear), as +the key tips and the equation's context menu would not switch it from a +script; `notebook/links.one` is the section OneNote saved on closing. Each +equation becomes one run of math between objects: letters in mathematical +italic, arguments of more than one factor in parentheses, an n-ary body after +`▒` inside `〖〗`, `√(n&x)` for a root's degree and `∛` for a cube root, +`■(1&2@3&4)` and `█(x&=1@y&=2)` for arrays, `𝑥┴2`, `𝑥┬2`, `□𝑥`, `▭𝑥`, `¯𝑥`, +and an accent after a no-break space. + +`crates/onestore/tests/page_math.rs` builds every typed expression with +`Math::from_linear` and compares it with what the editor stored, run by run, +writes each tree back as the linear text OneNote showed, and rebuilds every +native equation from its linear form; `crates/canvas/src/editor/equation.rs` +types the same keys into the canvas editor, building up at each space. + +`native-enter/` is OneNote 2010 pressing Enter around equations and a link, +driven by `tools/native_enter.py`: Enter at an equation's start moves the +equation to a paragraph of its own; inside the equation's own row it stores a +line break (`\r`, a plain run) in the paragraph, the equation's two sides +becoming two equations (two MathML blocks in the read); inside a fraction's +denominator it makes that argument an equation array (15, `█`) of the two +parts; at a link's start the whole link, field code and label, moves down. An +interactive session showed that Enter inside a link opens it instead. +`crates/canvas/tests/links_equations.rs` presses the same keys in the editor and +compares every paragraph's runs, and `page_math.rs` compares each equation's +MathML with the read. diff --git a/corpus/math-edit/native-editor-4/before-read/environment.json b/corpus/math-edit/native-editor-4/before-read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..da4c9badef86d2f8e906fe793c02f0e9288b1d86 --- /dev/null +++ b/corpus/math-edit/native-editor-4/before-read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-649AC1D2", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/math-edit/native-editor-4/before-read/hierarchy.xml b/corpus/math-edit/native-editor-4/before-read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..e85a882381b973737cf421735d1fedb3e2a63e10 --- /dev/null +++ b/corpus/math-edit/native-editor-4/before-read/hierarchy.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{7C806B9F-5A4D-4B11-825E-82AA1CE0C357}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-28T01:48:20.000Z" color="#B49EDE"><one:Section name="links" ID="{B9C7BD81-9DC3-0446-2D55-5B561FA3FB12}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-28T01:48:20.000Z" color="#8AA8E4"><one:Page ID="{18CA7B08-F5D4-0B7B-3BFE-7B36E74072E9}{1}{B0}" name="Read about Rust ﷟HYPERLINK "https://example.invalid/rust"the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:48:11.000Z" pageLevel="1"/></one:Section></one:Notebook> diff --git a/corpus/math-edit/native-editor-4/before-read/page-000.xml b/corpus/math-edit/native-editor-4/before-read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..ef1446317098fbb055a9c41d9eb2077141607450 --- /dev/null +++ b/corpus/math-edit/native-editor-4/before-read/page-000.xml @@ -0,0 +1,3 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{18CA7B08-F5D4-0B7B-3BFE-7B36E74072E9}{1}{B0}" name="Read about Rust ﷟HYPERLINK "https://example.invalid/rust"the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:48:11.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-12T09:49:16.000Z" objectID="{B9C7BD81-9DC3-0446-2D55-5B561FA3FB12}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-12T09:49:16.000Z" objectID="{B9C7BD81-9DC3-0446-2D55-5B561FA3FB12}{24}{B0}" alignment="left"><one:T><![CDATA[Read about Rust <a +href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/math-edit/native-editor-4/before-read/payloads.json b/corpus/math-edit/native-editor-4/before-read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/math-edit/native-editor-4/before-read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/math-edit/native-editor-4/commands.jsonl b/corpus/math-edit/native-editor-4/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..d4621a44e3848ddc2b8482fc777c6c7c572151d3 --- /dev/null +++ b/corpus/math-edit/native-editor-4/commands.jsonl @@ -0,0 +1,6 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-649AC1D2 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-649AC1D2 -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/math-edit/native-editor-4/equations.ahk b/corpus/math-edit/native-editor-4/equations.ahk new file mode 100644 index 0000000000000000000000000000000000000000..b50fa879755aba0f60c398bd0aff8e965f8e1cc2 --- /dev/null +++ b/corpus/math-edit/native-editor-4/equations.ahk @@ -0,0 +1,248 @@ +ComObject("OneNote.Application").NavigateTo("{18CA7B08-F5D4-0B7B-3BFE-7B36E74072E9}{1}{B0}", "", false) +WinWait("ahk_exe ONENOTE.EXE", , 30) +WinActivate("ahk_exe ONENOTE.EXE") +WinWaitActive("ahk_exe ONENOTE.EXE", , 30) +WinMaximize("ahk_exe ONENOTE.EXE") +Sleep(3000) +CoordMode("Mouse", "Screen") +Click(300, 140) +Sleep(500) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(500) +Send("!=") +Sleep(600) +SendText("a_1+b_2") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("x_i^2") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("\sum_(i=1)^n i") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("\int_0^1 x dx") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("\sqrt x+1") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("\cbrt(x)") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("\sqrt(n&x)") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("a/b") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("\prod_(k=1)^n k") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("[a+b]") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("\overline(x)") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("x\hat") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("\matrix(1&2@3&4)") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("\eqarray(x&=1@y&=2)") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("x\above 2") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("x\below 2") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("\box(x)") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("\rect(x)") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("f(x)/(x^2+1)") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("\sum^n x") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("e^(x+1)") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("\alpha+\beta") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("(a+b)/(c+d)") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("x^2+y^2=z^2") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("lim_(n\to\infty) a_n") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("!=") +Sleep(600) +SendText("{a+b}") +Send(" ") +Sleep(600) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Sleep(2000) diff --git a/corpus/math-edit/native-editor-4/equations.json b/corpus/math-edit/native-editor-4/equations.json new file mode 100644 index 0000000000000000000000000000000000000000..83fb4df3362bb2ac7e9fa657fe6db76614b54e31 --- /dev/null +++ b/corpus/math-edit/native-editor-4/equations.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Read about Rust the Rust site - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/math-edit/native-editor-4/equations.png b/corpus/math-edit/native-editor-4/equations.png new file mode 100644 index 0000000000000000000000000000000000000000..493db5a1a27a11eab3b86cea405eaec02dd36483 Binary files /dev/null and b/corpus/math-edit/native-editor-4/equations.png differ diff --git a/corpus/math-edit/native-editor-4/machine.json b/corpus/math-edit/native-editor-4/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..cd75bd2356f619d45802ac6e1d1816bbfd6d1d80 --- /dev/null +++ b/corpus/math-edit/native-editor-4/machine.json @@ -0,0 +1 @@ +{"name": "m6-649ac1d2", "hostname": "ONE-M6-649AC1D2"} diff --git a/corpus/math-edit/native-editor-4/notebook/Open Notebook.onetoc2 b/corpus/math-edit/native-editor-4/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..a3c5f9fabd3120da1394202b571af31112c3ded2 Binary files /dev/null and b/corpus/math-edit/native-editor-4/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/math-edit/native-editor-4/notebook/links.one b/corpus/math-edit/native-editor-4/notebook/links.one new file mode 100644 index 0000000000000000000000000000000000000000..d786a69b18c3e61d51c6751cf56ca98e0a634802 Binary files /dev/null and b/corpus/math-edit/native-editor-4/notebook/links.one differ diff --git a/corpus/math-edit/native-editor-4/read/environment.json b/corpus/math-edit/native-editor-4/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..066af406fc8d0e076ed1e5476c709fb4fd87b83c --- /dev/null +++ b/corpus/math-edit/native-editor-4/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-649AC1D2", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/math-edit/native-editor-4/read/hierarchy.xml b/corpus/math-edit/native-editor-4/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..547ae90b579e4c8049a19bf51daeea2bdd38dfab --- /dev/null +++ b/corpus/math-edit/native-editor-4/read/hierarchy.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{7C806B9F-5A4D-4B11-825E-82AA1CE0C357}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-28T01:50:43.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="links" ID="{B9C7BD81-9DC3-0446-2D55-5B561FA3FB12}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-28T01:50:43.000Z" color="#8AA8E4" isCurrentlyViewed="true"><one:Page ID="{18CA7B08-F5D4-0B7B-3BFE-7B36E74072E9}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:50:43.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section></one:Notebook> diff --git a/corpus/math-edit/native-editor-4/read/page-000.xml b/corpus/math-edit/native-editor-4/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..04a83854ab3bd842db4289d98d3fea2e96c51fa5 --- /dev/null +++ b/corpus/math-edit/native-editor-4/read/page-000.xml @@ -0,0 +1,3 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{18CA7B08-F5D4-0B7B-3BFE-7B36E74072E9}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:50:44.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-28T01:50:44.000Z" objectID="{B9C7BD81-9DC3-0446-2D55-5B561FA3FB12}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="518.656005859375"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:49:44.000Z" objectID="{B9C7BD81-9DC3-0446-2D55-5B561FA3FB12}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Read about Rust <a +href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:49.000Z" lastModifiedTime="2026-09-28T01:49:50.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{12}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msub><mml:mi>a</mml:mi><mml:mn>1</mml:mn></mml:msub><mml:mo>+</mml:mo><mml:msub><mml:mi>b</mml:mi><mml:mn>2</mml:mn></mml:msub></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:51.000Z" lastModifiedTime="2026-09-28T01:49:52.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{18}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mpadded><mml:mrow><mml:mtext>Type equation here.</mml:mtext></mml:mrow></mml:mpadded></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:54.000Z" lastModifiedTime="2026-09-28T01:49:55.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{22}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munderover><mml:mo stretchy="false">∑</mml:mo><mml:mrow><mml:mi>i</mml:mi><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mrow><mml:mi>n</mml:mi></mml:munderover><mml:mrow><mml:mi>i</mml:mi><mml:mo> </mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:56.000Z" lastModifiedTime="2026-09-28T01:49:57.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{25}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msubsup><mml:mo stretchy="false">∫</mml:mo><mml:mn>0</mml:mn><mml:mn>1</mml:mn></mml:msubsup><mml:mrow><mml:mi>x</mml:mi><mml:mo> </mml:mo><mml:mi>d</mml:mi><mml:mi>x</mml:mi><mml:mo> </mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:58.000Z" lastModifiedTime="2026-09-28T01:49:59.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{28}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msqrt><mml:mi>x</mml:mi></mml:msqrt><mml:mo>+</mml:mo><mml:mn>1</mml:mn><mml:mo> </mml:mo></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:00.000Z" lastModifiedTime="2026-09-28T01:50:01.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{31}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mroot><mml:mi>x</mml:mi><mml:mn>3</mml:mn></mml:mroot></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:02.000Z" lastModifiedTime="2026-09-28T01:50:03.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{34}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mroot><mml:mi>x</mml:mi><mml:mi>n</mml:mi></mml:mroot></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:04.000Z" lastModifiedTime="2026-09-28T01:50:05.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{37}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mi>a</mml:mi><mml:mi>b</mml:mi></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:06.000Z" lastModifiedTime="2026-09-28T01:50:07.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{40}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munderover><mml:mo stretchy="false">∏</mml:mo><mml:mrow><mml:mi>k</mml:mi><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mrow><mml:mi>n</mml:mi></mml:munderover><mml:mrow><mml:mi>k</mml:mi><mml:mo> </mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:08.000Z" lastModifiedTime="2026-09-28T01:50:09.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{43}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="[" close="]"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:11.000Z" lastModifiedTime="2026-09-28T01:50:11.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{46}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="false"><mml:mi>x</mml:mi><mml:mo stretchy="true">¯</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:12.000Z" lastModifiedTime="2026-09-28T01:50:14.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{49}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="true"><mml:mi>x</mml:mi><mml:mo>^</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:15.000Z" lastModifiedTime="2026-09-28T01:50:16.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{52}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:mn>1</mml:mn></mml:mtd><mml:mtd><mml:mn>2</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:mn>3</mml:mn></mml:mtd><mml:mtd><mml:mn>4</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:17.000Z" lastModifiedTime="2026-09-28T01:50:18.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{55}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>x</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>y</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>2</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:18.000Z" lastModifiedTime="2026-09-28T01:50:20.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{58}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:21.000Z" lastModifiedTime="2026-09-28T01:50:22.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{61}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munder><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:munder></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:23.000Z" lastModifiedTime="2026-09-28T01:50:24.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{64}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mpadded><mml:mi>x</mml:mi></mml:mpadded></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:26.000Z" lastModifiedTime="2026-09-28T01:50:26.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{67}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:menclose notation="box"><mml:mi>x</mml:mi></mml:menclose></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:27.000Z" lastModifiedTime="2026-09-28T01:50:29.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{70}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>f</mml:mi><mml:mfenced><mml:mi>x</mml:mi></mml:mfenced></mml:mrow><mml:mrow><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:30.000Z" lastModifiedTime="2026-09-28T01:50:31.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{73}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mo stretchy="false">∑</mml:mo><mml:mi>n</mml:mi></mml:mover><mml:mrow><mml:mi>x</mml:mi><mml:mo> </mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:31.000Z" lastModifiedTime="2026-09-28T01:50:33.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{76}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>e</mml:mi><mml:mrow><mml:mi>x</mml:mi><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:34.000Z" lastModifiedTime="2026-09-28T01:50:35.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{79}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mi>𝛼</mml:mi><mml:mo>+</mml:mo><mml:mi>𝛽</mml:mi></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:35.000Z" lastModifiedTime="2026-09-28T01:50:37.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{82}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:mi>c</mml:mi><mml:mo>+</mml:mo><mml:mi>d</mml:mi></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:38.000Z" lastModifiedTime="2026-09-28T01:50:39.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{85}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:msup><mml:mi>y</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>=</mml:mo><mml:msup><mml:mi>z</mml:mi><mml:mn>2</mml:mn></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:40.000Z" lastModifiedTime="2026-09-28T01:50:42.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{88}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mrow><mml:mrow><mml:munder><mml:mrow><mml:mi>lim</mml:mi></mml:mrow><mml:mrow><mml:mi>n</mml:mi><mml:mo>→</mml:mo><mml:mo>∞</mml:mo></mml:mrow></mml:munder></mml:mrow><mml:mo>⁡</mml:mo><mml:mrow><mml:msub><mml:mi>a</mml:mi><mml:mi>n</mml:mi></mml:msub></mml:mrow></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:42.000Z" lastModifiedTime="2026-09-28T01:50:43.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{92}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="{" close="}"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:43.000Z" lastModifiedTime="2026-09-28T01:50:44.000Z" objectID="{8C825D84-DE0B-42AA-96B4-41E992DE9554}{95}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/math-edit/native-editor-4/read/payloads.json b/corpus/math-edit/native-editor-4/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/math-edit/native-editor-4/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/math-edit/native-editor-4/run.json b/corpus/math-edit/native-editor-4/run.json new file mode 100644 index 0000000000000000000000000000000000000000..9e670c7143179c57e2107d7c15c06d0cb8b81a64 --- /dev/null +++ b/corpus/math-edit/native-editor-4/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/corpus/link-edit/candidate", + "expected_pages": 1, + "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/math-edit/native-editor-4/scripts/cold.ps1 b/corpus/math-edit/native-editor-4/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/math-edit/native-editor-4/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/math-edit/native-editor-4/scripts/read.ps1 b/corpus/math-edit/native-editor-4/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/math-edit/native-editor-4/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/math-edit/native-editor-4/source.json b/corpus/math-edit/native-editor-4/source.json new file mode 100644 index 0000000000000000000000000000000000000000..14786084a474f1cc0a6f522bd49c8104ad3627f0 --- /dev/null +++ b/corpus/math-edit/native-editor-4/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "6731c4214437fe1cb6aa347b0004542b7f7e4260f47773bf9ec07c4e894de47c", + "mtime_ns": 1789206821144296891 + }, + { + "path": "links.one", + "bytes": 6128, + "sha256": "0cfb356ed374fe919f73fb73fdbe5d66e0baa484d4119f70074d9f869d6db78a", + "mtime_ns": 1789206821116200533 + } +] diff --git a/corpus/math-edit/native-editor-4/teardown.json b/corpus/math-edit/native-editor-4/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/math-edit/native-editor-4/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/math-edit/native-enter/before-read/environment.json b/corpus/math-edit/native-enter/before-read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..46d43976b6380b50f98b39fb2ea369042c800daf --- /dev/null +++ b/corpus/math-edit/native-enter/before-read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-37FE88DC", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/math-edit/native-enter/before-read/hierarchy.xml b/corpus/math-edit/native-enter/before-read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..da3bf3fed8ee044270e832aa1c6ea39cf3698606 --- /dev/null +++ b/corpus/math-edit/native-enter/before-read/hierarchy.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{D317B2FA-1D06-4CE7-AFA4-6641021828C4}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-28T06:48:36.000Z" color="#B49EDE"><one:Section name="links" ID="{4B703ABE-5CB3-09D4-04ED-D01FC2641497}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-28T06:48:36.000Z" color="#8AA8E4"><one:Page ID="{EA7DFC37-34A4-06E9-1246-F07F3A879D6C}{1}{B0}" name="Read about Rust ﷟HYPERLINK "https://example.invalid/rust"the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T06:48:36.000Z" pageLevel="1"/></one:Section></one:Notebook> diff --git a/corpus/math-edit/native-enter/before-read/page-000.xml b/corpus/math-edit/native-enter/before-read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..5de51e298859ce50397aa7d528f09aa91d5424cb --- /dev/null +++ b/corpus/math-edit/native-enter/before-read/page-000.xml @@ -0,0 +1,3 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{EA7DFC37-34A4-06E9-1246-F07F3A879D6C}{1}{B0}" name="Read about Rust ﷟HYPERLINK "https://example.invalid/rust"the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T06:48:36.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-12T09:49:16.000Z" objectID="{4B703ABE-5CB3-09D4-04ED-D01FC2641497}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-12T09:49:16.000Z" objectID="{4B703ABE-5CB3-09D4-04ED-D01FC2641497}{24}{B0}" alignment="left"><one:T><![CDATA[Read about Rust <a +href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/math-edit/native-enter/before-read/payloads.json b/corpus/math-edit/native-enter/before-read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/math-edit/native-enter/before-read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/math-edit/native-enter/commands.jsonl b/corpus/math-edit/native-enter/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..a94555fa2bbf177e583a323cf0225886048ecc26 --- /dev/null +++ b/corpus/math-edit/native-enter/commands.jsonl @@ -0,0 +1,6 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-37FE88DC -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-37FE88DC -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/math-edit/native-enter/enter.ahk b/corpus/math-edit/native-enter/enter.ahk new file mode 100644 index 0000000000000000000000000000000000000000..9a597652b2748a373f2a8962e217c9f55d91ff20 --- /dev/null +++ b/corpus/math-edit/native-enter/enter.ahk @@ -0,0 +1,53 @@ +ComObject("OneNote.Application").NavigateTo("{EA7DFC37-34A4-06E9-1246-F07F3A879D6C}{1}{B0}", "", false) +WinWait("ahk_exe ONENOTE.EXE", , 30) +WinActivate("ahk_exe ONENOTE.EXE") +WinWaitActive("ahk_exe ONENOTE.EXE", , 30) +WinMaximize("ahk_exe ONENOTE.EXE") +Sleep(3000) +CoordMode("Mouse", "Screen") +Click(200, 140) +Sleep(500) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(500) +SendText("Before ") +Send("!=") +Sleep(600) +SendText("y^2+1") +Send(" ") +Sleep(700) +Send("{Home}") +Send("{Right 7}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("{End}") +Send("{Left 2}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("{End}{Enter}") +Send("!=") +Sleep(600) +SendText("(a+b)/(c+d)") +Send(" ") +Sleep(700) +Send("{Left 3}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Send("^{End}{Enter}") +SendText("Select me please") +Send("+^{Left}+^{Left}") +Send("^k") +Sleep(1500) +SendText("https://example.com/selected") +Send("{Enter}") +Sleep(800) +Send("{End}") +Send("{Left 9}") +Sleep(300) +Send("{Enter}") +Sleep(600) +Sleep(2000) diff --git a/corpus/math-edit/native-enter/enter.json b/corpus/math-edit/native-enter/enter.json new file mode 100644 index 0000000000000000000000000000000000000000..83fb4df3362bb2ac7e9fa657fe6db76614b54e31 --- /dev/null +++ b/corpus/math-edit/native-enter/enter.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Read about Rust the Rust site - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/math-edit/native-enter/enter.png b/corpus/math-edit/native-enter/enter.png new file mode 100644 index 0000000000000000000000000000000000000000..c3da0589d5ebfabd24f8724eb51086807a3abacb Binary files /dev/null and b/corpus/math-edit/native-enter/enter.png differ diff --git a/corpus/math-edit/native-enter/machine.json b/corpus/math-edit/native-enter/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..5b0fc22050d679f170aba1d60453ff658d80992b --- /dev/null +++ b/corpus/math-edit/native-enter/machine.json @@ -0,0 +1 @@ +{"name": "m6-37fe88dc", "hostname": "ONE-M6-37FE88DC"} diff --git a/corpus/math-edit/native-enter/notebook/Open Notebook.onetoc2 b/corpus/math-edit/native-enter/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..f2645198bf195c85407e9eedf9f8c09f4c555da1 Binary files /dev/null and b/corpus/math-edit/native-enter/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/math-edit/native-enter/notebook/links.one b/corpus/math-edit/native-enter/notebook/links.one new file mode 100644 index 0000000000000000000000000000000000000000..fd7c7c30555858cae2a8202f6126be2c813371f3 Binary files /dev/null and b/corpus/math-edit/native-enter/notebook/links.one differ diff --git a/corpus/math-edit/native-enter/read/environment.json b/corpus/math-edit/native-enter/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..06561c0f775e450566cda396f240b3cbaab77b97 --- /dev/null +++ b/corpus/math-edit/native-enter/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-37FE88DC", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/math-edit/native-enter/read/hierarchy.xml b/corpus/math-edit/native-enter/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..5c76a8197d67a018904259322a6b08ad74150b65 --- /dev/null +++ b/corpus/math-edit/native-enter/read/hierarchy.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{D317B2FA-1D06-4CE7-AFA4-6641021828C4}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-28T06:50:24.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="links" ID="{4B703ABE-5CB3-09D4-04ED-D01FC2641497}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-28T06:50:24.000Z" color="#8AA8E4" isCurrentlyViewed="true"><one:Page ID="{EA7DFC37-34A4-06E9-1246-F07F3A879D6C}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T06:50:24.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section></one:Notebook> diff --git a/corpus/math-edit/native-enter/read/page-000.xml b/corpus/math-edit/native-enter/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..3bfe8415ee6806d3d3287f16b869472e3dc4b770 --- /dev/null +++ b/corpus/math-edit/native-enter/read/page-000.xml @@ -0,0 +1,6 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{EA7DFC37-34A4-06E9-1246-F07F3A879D6C}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T06:50:24.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-28T06:50:24.000Z" objectID="{4B703ABE-5CB3-09D4-04ED-D01FC2641497}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="352.7999572753906" height="114.4321136474609"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T06:50:13.000Z" objectID="{4B703ABE-5CB3-09D4-04ED-D01FC2641497}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Read about Rust <a +href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T06:50:17.000Z" lastModifiedTime="2026-09-28T06:50:18.000Z" objectID="{0C653CD9-749D-4D90-8840-58225BF9B002}{12}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before ]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T06:50:18.000Z" lastModifiedTime="2026-09-28T06:50:19.000Z" objectID="{0C653CD9-749D-4D90-8840-58225BF9B002}{18}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>y</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo></mml:math><![endif]--><span +style='font-family:Calibri' lang=en-US><br> +</span><!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mn>1</mml:mn><mml:mo> </mml:mo></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T06:50:19.000Z" lastModifiedTime="2026-09-28T06:50:22.000Z" objectID="{0C653CD9-749D-4D90-8840-58225BF9B002}{20}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>c</mml:mi></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mo>+</mml:mo><mml:mi>d</mml:mi></mml:mtd></mml:mtr></mml:mtable></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T06:50:23.000Z" lastModifiedTime="2026-09-28T06:50:24.000Z" objectID="{0C653CD9-749D-4D90-8840-58225BF9B002}{23}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Select ]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T06:50:24.000Z" lastModifiedTime="2026-09-28T06:50:24.000Z" objectID="{0C653CD9-749D-4D90-8840-58225BF9B002}{27}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a +href="https://example.com/selected">me please</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/math-edit/native-enter/read/payloads.json b/corpus/math-edit/native-enter/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/math-edit/native-enter/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/math-edit/native-enter/run.json b/corpus/math-edit/native-enter/run.json new file mode 100644 index 0000000000000000000000000000000000000000..9e670c7143179c57e2107d7c15c06d0cb8b81a64 --- /dev/null +++ b/corpus/math-edit/native-enter/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/corpus/link-edit/candidate", + "expected_pages": 1, + "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/math-edit/native-enter/scripts/cold.ps1 b/corpus/math-edit/native-enter/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/math-edit/native-enter/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/math-edit/native-enter/scripts/read.ps1 b/corpus/math-edit/native-enter/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/math-edit/native-enter/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/math-edit/native-enter/source.json b/corpus/math-edit/native-enter/source.json new file mode 100644 index 0000000000000000000000000000000000000000..14786084a474f1cc0a6f522bd49c8104ad3627f0 --- /dev/null +++ b/corpus/math-edit/native-enter/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "6731c4214437fe1cb6aa347b0004542b7f7e4260f47773bf9ec07c4e894de47c", + "mtime_ns": 1789206821144296891 + }, + { + "path": "links.one", + "bytes": 6128, + "sha256": "0cfb356ed374fe919f73fb73fdbe5d66e0baa484d4119f70074d9f869d6db78a", + "mtime_ns": 1789206821116200533 + } +] diff --git a/corpus/math-edit/native-enter/teardown.json b/corpus/math-edit/native-enter/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/math-edit/native-enter/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/math-edit/native-linear/notebook/links.one b/corpus/math-edit/native-linear/notebook/links.one new file mode 100644 index 0000000000000000000000000000000000000000..62b5ebdc701702ab8580ab7570b4b7f96b547c51 Binary files /dev/null and b/corpus/math-edit/native-linear/notebook/links.one differ diff --git a/corpus/page-versions/README.md b/corpus/page-versions/README.md new file mode 100644 index 0000000000000000000000000000000000000000..9d1a9c5a0e4df0b77ab43560fa2b7b44cb70b2e4 --- /dev/null +++ b/corpus/page-versions/README.md @@ -0,0 +1,65 @@ +# Page versions + +What OneNote 2010 stores for a page's earlier versions (Share, Page Versions), what its +Restore Version, Delete Version, Copy Page To and Delete All Versions write, and that it +reads and deletes the versions Rust writes. `tools/test_page_versions.py` checks the rows +without a VM; `crates/onestore/tests/versions.rs` compares Rust's writes with OneNote's. + +## Storage + +A version is an earlier revision of the page's own object space, kept current under a +context of its own (MS-ONE 2.1.17, 2.1.18); nothing is copied. The page's *version history* +is another revision of the same space, current under context +`{7111497F-1B6B-4209-9491-C98B04CF4C5A},1`: root `jcidVersionHistoryContent` (`0x6003c`) +whose `ElementChildNodesOfVersionHistory` (`0x24001c20`) lists a `jcidVersionProxy` +(`0x6003d`) per version, newest first. A proxy holds `VersionContextNodes` (`0x3400347b`, +the version's context), `LastModifiedTimeStamp` and `AuthorMostRecent` of the version (from +its revision metadata, root role 4) and a `CreationTimeStamp` of when the version was made. +The page manifest names the history context in `0x3400347b` from the page's creation, and +the page's metadata and the section's copy of it carry `HasVersionPages` (`0x88003462`) +while any version is listed. A version's context is its revision identity XOR +`9D52FE7C-8FBC-4495-9300-129B5797A9EA`, its proxy's identity the context XOR +`88440BCA-118C-0E81-32BF-83DB8CB8B6B5`. + +- **Making one.** When a different author edits the page, OneNote labels the page's last + revision as a version (a `0x5d` RevisionRoleAndContextDeclaration in the space's list) + and appends a history revision listing its proxy. One author's edits make none. +- **Restore Version** (`step-03` to `step-04`): the page's next revision depends on the + version's revision (the page's chain forks there), its revision metadata naming who + restored it; the page as it stood is labelled as the newest version and the history lists + it first. The version restored stays listed. +- **Delete Version** (`step-04` to `step-05`): only the history changes, dropping the proxy. + The version's label and revisions stay in the file until Optimize drops them. Deleting + the last one (`step-07` to `step-08`) also clears `HasVersionPages`. +- **Delete All Versions in Section** (`step-05` to `step-06`) asks first ("Do you want to + delete all page versions in the section "History"?") and writes the same as deleting each. +- **Copy Page To** (`step-02` to `step-03`) copies the version as a new page without + versions; the copy's manifest still names the history context, which no revision + carries. Readers must accept that. +- **Optimize** (`step-10`, two years on) rewrites each kept version and the history as + checkpoints; versions are not expired by age here. + +## Interface + +A version is listed under its page as "M/D/YYYY author" (`shots/06-shown-again.png`) once +the page's Show Page Versions (page menu, `shots/22-page-menu.png`) or Share, Page +Versions is chosen. An open version is read-only under a yellow bar (RGB 255, 238, 194): +"This is an earlier version of the page. It will be deleted over time. Click here to restore +or delete this version." What the version changed since the one before it is banded green +(RGB 214, 255, 214). The bar's menu (`shots/02-version-menu.png`): Restore Version, Delete +Version, Copy Page To…; Delete All Versions in Section, Section Group, Notebook; Disable +History for This Notebook; Hide Page Versions. Share also has Hide Authors, Recent Edits and +Find by Author (`shots/14`–`16`). + +## Rows + +- `native`: one OneNote client driven by hand, authors switched through the Office user + name. `step-NN/notebook` is the section after each step above, `shots/` the screens. +- `native-scripted`: the same steps, one to nine, by `tools/native_versions.py observe`. +- `candidate-restore`, `candidate-delete-all`: `restoring_a_version_stores_what_onenote_stores` + (Restore Version of `step-03`'s version) and `deleting_versions_stores_what_onenote_stores` + (Delete Version of `step-05`'s last one), `ONESTORE_VERSIONS_EXPORT`. +- `cold-restore`: OneNote's fresh read of `candidate-restore` (`native_versions.py cold + --delete`): both versions listed, the newest one Rust labelled opened read-only under the + bar and deleted through its menu (`notebook` is what OneNote left). `cold-delete-all`: its + fresh read of `candidate-delete-all`, the page without versions. diff --git a/corpus/page-versions/candidate-delete-all/History.one b/corpus/page-versions/candidate-delete-all/History.one new file mode 100644 index 0000000000000000000000000000000000000000..fbb0ed2d57f454ca7b8e3c8af8f4db258b5b2cc2 Binary files /dev/null and b/corpus/page-versions/candidate-delete-all/History.one differ diff --git a/corpus/page-versions/candidate-delete-all/Open Notebook.onetoc2 b/corpus/page-versions/candidate-delete-all/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/candidate-delete-all/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/candidate-restore/History.one b/corpus/page-versions/candidate-restore/History.one new file mode 100644 index 0000000000000000000000000000000000000000..4aba791c316dc9dca9932ef1a2a1745075b6a4b0 Binary files /dev/null and b/corpus/page-versions/candidate-restore/History.one differ diff --git a/corpus/page-versions/candidate-restore/Open Notebook.onetoc2 b/corpus/page-versions/candidate-restore/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/candidate-restore/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/cold-delete-all/before-read/environment.json b/corpus/page-versions/cold-delete-all/before-read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..f8fc11b83139ebbc4b108511063a254faf9faf54 --- /dev/null +++ b/corpus/page-versions/cold-delete-all/before-read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-9A6697E1", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/page-versions/cold-delete-all/before-read/hierarchy.xml b/corpus/page-versions/cold-delete-all/before-read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..23aa8fb9658de5d54c49abc078092517a21ceefd --- /dev/null +++ b/corpus/page-versions/cold-delete-all/before-read/hierarchy.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{8E516E85-6F39-4FC7-9D9E-14BE1F339DC1}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-28T03:50:41.000Z" color="#EE9597"><one:Section name="History" ID="{CC235E69-ECFE-02B0-0E8C-F3802399792B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\History.one" lastModifiedTime="2026-09-28T03:50:41.000Z" color="#B49EDE"><one:Page ID="{9193CD4B-2F8C-055F-3BDB-4EFF291B5152}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2026-09-28T00:47:43.000Z" pageLevel="1"/><one:Page ID="{D9B5FD1A-04B4-0CF5-0EA8-B062ED89939C}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" pageLevel="1"/></one:Section></one:Notebook> diff --git a/corpus/page-versions/cold-delete-all/before-read/page-000.xml b/corpus/page-versions/cold-delete-all/before-read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..b5dffeecd26d25c964b90aa06a82272ed3029389 --- /dev/null +++ b/corpus/page-versions/cold-delete-all/before-read/page-000.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9193CD4B-2F8C-055F-3BDB-4EFF291B5152}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2026-09-28T00:47:43.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T00:42:44.000Z" lastModifiedTime="2026-09-28T00:43:03.000Z" objectID="{41C4BEEF-F582-06A0-154D-9894867A7B00}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Versioned]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-28T00:43:34.000Z" objectID="{41C4BEEF-F582-06A0-154D-9894867A7B00}{39}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="163.7793731689453" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-28T00:43:34.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" objectID="{41C4BEEF-F582-06A0-154D-9894867A7B00}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[First state. Typed by virtual.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/page-versions/cold-delete-all/before-read/page-001.xml b/corpus/page-versions/cold-delete-all/before-read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..bb70997de84fbf15b96c744cd4d7b481182ebd1f --- /dev/null +++ b/corpus/page-versions/cold-delete-all/before-read/page-001.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D9B5FD1A-04B4-0CF5-0EA8-B062ED89939C}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T00:42:44.000Z" lastModifiedTime="2026-09-28T00:43:03.000Z" objectID="{29C73CCC-F3F6-0810-2004-F2FC1C59B929}{22}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Versioned]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-28T00:47:16.000Z" objectID="{29C73CCC-F3F6-0810-2004-F2FC1C59B929}{15}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="163.7793731689453" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-28T00:43:34.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" objectID="{29C73CCC-F3F6-0810-2004-F2FC1C59B929}{16}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[First state. Typed by virtual.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/page-versions/cold-delete-all/before-read/payloads.json b/corpus/page-versions/cold-delete-all/before-read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/page-versions/cold-delete-all/before-read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/page-versions/cold-delete-all/commands.jsonl b/corpus/page-versions/cold-delete-all/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..2116ca3e4b1c0b837a4bbb38557b93473884aae6 --- /dev/null +++ b/corpus/page-versions/cold-delete-all/commands.jsonl @@ -0,0 +1,6 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-9A6697E1 -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-9A6697E1 -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/page-versions/cold-delete-all/machine.json b/corpus/page-versions/cold-delete-all/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..4cd19491b5bb1c13b820aab5e2758b77bb77413a --- /dev/null +++ b/corpus/page-versions/cold-delete-all/machine.json @@ -0,0 +1 @@ +{"name": "m6-9a6697e1", "hostname": "ONE-M6-9A6697E1"} diff --git a/corpus/page-versions/cold-delete-all/notebook/History.one b/corpus/page-versions/cold-delete-all/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..fbb0ed2d57f454ca7b8e3c8af8f4db258b5b2cc2 Binary files /dev/null and b/corpus/page-versions/cold-delete-all/notebook/History.one differ diff --git a/corpus/page-versions/cold-delete-all/notebook/Open Notebook.onetoc2 b/corpus/page-versions/cold-delete-all/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/cold-delete-all/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/cold-delete-all/read/environment.json b/corpus/page-versions/cold-delete-all/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..c4168110a7f78368cbf00f7ccb3f3a2811394eaa --- /dev/null +++ b/corpus/page-versions/cold-delete-all/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-9A6697E1", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/page-versions/cold-delete-all/read/hierarchy.xml b/corpus/page-versions/cold-delete-all/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..7fd31036fd7a8223307ed6228ac7cafe015c968e --- /dev/null +++ b/corpus/page-versions/cold-delete-all/read/hierarchy.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{8E516E85-6F39-4FC7-9D9E-14BE1F339DC1}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-28T03:50:41.000Z" color="#EE9597" isCurrentlyViewed="true"><one:Section name="History" ID="{CC235E69-ECFE-02B0-0E8C-F3802399792B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\History.one" lastModifiedTime="2026-09-28T03:50:41.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Page ID="{9193CD4B-2F8C-055F-3BDB-4EFF291B5152}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2026-09-28T00:47:43.000Z" pageLevel="1"/><one:Page ID="{D9B5FD1A-04B4-0CF5-0EA8-B062ED89939C}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section></one:Notebook> diff --git a/corpus/page-versions/cold-delete-all/read/page-000.xml b/corpus/page-versions/cold-delete-all/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..7cdbf8b0e370bb944a87b8a5c1cf4c6ef123f6dc --- /dev/null +++ b/corpus/page-versions/cold-delete-all/read/page-000.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9193CD4B-2F8C-055F-3BDB-4EFF291B5152}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2026-09-28T03:52:19.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T00:42:44.000Z" lastModifiedTime="2026-09-28T00:43:03.000Z" objectID="{41C4BEEF-F582-06A0-154D-9894867A7B00}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Versioned]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-28T00:43:34.000Z" objectID="{41C4BEEF-F582-06A0-154D-9894867A7B00}{39}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="163.7793731689453" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-28T00:43:34.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" objectID="{41C4BEEF-F582-06A0-154D-9894867A7B00}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[First state. Typed by virtual.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/page-versions/cold-delete-all/read/page-001.xml b/corpus/page-versions/cold-delete-all/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..17bdd2072543b823f4f44ab33ed159c3399cc652 --- /dev/null +++ b/corpus/page-versions/cold-delete-all/read/page-001.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D9B5FD1A-04B4-0CF5-0EA8-B062ED89939C}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2026-09-28T03:52:31.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T00:42:44.000Z" lastModifiedTime="2026-09-28T00:43:03.000Z" objectID="{29C73CCC-F3F6-0810-2004-F2FC1C59B929}{22}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Versioned]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-28T00:47:16.000Z" objectID="{29C73CCC-F3F6-0810-2004-F2FC1C59B929}{15}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="163.7793731689453" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-28T00:43:34.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" objectID="{29C73CCC-F3F6-0810-2004-F2FC1C59B929}{16}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[First state. Typed by virtual.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/page-versions/cold-delete-all/read/payloads.json b/corpus/page-versions/cold-delete-all/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/page-versions/cold-delete-all/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/page-versions/cold-delete-all/run.json b/corpus/page-versions/cold-delete-all/run.json new file mode 100644 index 0000000000000000000000000000000000000000..e72c73999f2f304947f58e053987e3b0752e2136 --- /dev/null +++ b/corpus/page-versions/cold-delete-all/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/corpus/page-versions/candidate-delete-all", + "expected_pages": -1, + "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/page-versions/cold-delete-all/scripts/cold.ps1 b/corpus/page-versions/cold-delete-all/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/page-versions/cold-delete-all/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/page-versions/cold-delete-all/scripts/read.ps1 b/corpus/page-versions/cold-delete-all/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/page-versions/cold-delete-all/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/page-versions/cold-delete-all/shots/cold-closed.ahk b/corpus/page-versions/cold-delete-all/shots/cold-closed.ahk new file mode 100644 index 0000000000000000000000000000000000000000..502ccec355d2ea0f80921b601338c9265afdde57 --- /dev/null +++ b/corpus/page-versions/cold-delete-all/shots/cold-closed.ahk @@ -0,0 +1,4 @@ +WinActivate("ahk_exe ONENOTE.EXE") +CoordMode("Mouse", "Screen") +Send("{Esc}") +Sleep(800) diff --git a/corpus/page-versions/cold-delete-all/shots/cold-closed.json b/corpus/page-versions/cold-delete-all/shots/cold-closed.json new file mode 100644 index 0000000000000000000000000000000000000000..f43854a7a42d5bb99c0b443513bc0c0993c2716c --- /dev/null +++ b/corpus/page-versions/cold-delete-all/shots/cold-closed.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "", + "class": "Net UI Tool Window", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/cold-delete-all/shots/cold-closed.png b/corpus/page-versions/cold-delete-all/shots/cold-closed.png new file mode 100644 index 0000000000000000000000000000000000000000..8cb1e3b9ebed9fe8a906c62be0dfb95bb5fd5614 Binary files /dev/null and b/corpus/page-versions/cold-delete-all/shots/cold-closed.png differ diff --git a/corpus/page-versions/cold-delete-all/shots/cold-main.ahk b/corpus/page-versions/cold-delete-all/shots/cold-main.ahk new file mode 100644 index 0000000000000000000000000000000000000000..325ae4869487a19763ac34bfbf994a8e3755f68e --- /dev/null +++ b/corpus/page-versions/cold-delete-all/shots/cold-main.ahk @@ -0,0 +1,14 @@ +OnError((exception, mode) => (FileAppend(exception.Message, "**"), ExitApp(1))) +DetectHiddenWindows false +app := ComObject("OneNote.Application") +app.NavigateTo("{9193CD4B-2F8C-055F-3BDB-4EFF291B5152}{1}{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") +app.NavigateTo("{9193CD4B-2F8C-055F-3BDB-4EFF291B5152}{1}{B0}", "", false) +Sleep 300 +Sleep(2000) diff --git a/corpus/page-versions/cold-delete-all/shots/cold-main.json b/corpus/page-versions/cold-delete-all/shots/cold-main.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/cold-delete-all/shots/cold-main.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/cold-delete-all/shots/cold-main.png b/corpus/page-versions/cold-delete-all/shots/cold-main.png new file mode 100644 index 0000000000000000000000000000000000000000..47f252ca42f33eeb5887c975063f8192fae18199 Binary files /dev/null and b/corpus/page-versions/cold-delete-all/shots/cold-main.png differ diff --git a/corpus/page-versions/cold-delete-all/shots/cold-menu.ahk b/corpus/page-versions/cold-delete-all/shots/cold-menu.ahk new file mode 100644 index 0000000000000000000000000000000000000000..2d1fe2b86050641494bae74fbf2ea5df632a47a8 --- /dev/null +++ b/corpus/page-versions/cold-delete-all/shots/cold-menu.ahk @@ -0,0 +1,4 @@ +WinActivate("ahk_exe ONENOTE.EXE") +CoordMode("Mouse", "Screen") +Click(320, 93) +Sleep(1000) diff --git a/corpus/page-versions/cold-delete-all/shots/cold-menu.json b/corpus/page-versions/cold-delete-all/shots/cold-menu.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/cold-delete-all/shots/cold-menu.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/cold-delete-all/shots/cold-menu.png b/corpus/page-versions/cold-delete-all/shots/cold-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..2f7d5355293f0a27d379e12ec149e9101850d3ea Binary files /dev/null and b/corpus/page-versions/cold-delete-all/shots/cold-menu.png differ diff --git a/corpus/page-versions/cold-delete-all/shots/cold-shown.ahk b/corpus/page-versions/cold-delete-all/shots/cold-shown.ahk new file mode 100644 index 0000000000000000000000000000000000000000..57021288e33046724124084b1e09a39f5f2c8120 --- /dev/null +++ b/corpus/page-versions/cold-delete-all/shots/cold-shown.ahk @@ -0,0 +1,6 @@ +WinActivate("ahk_exe ONENOTE.EXE") +CoordMode("Mouse", "Screen") +Click(202, 36) +Sleep(600) +Click(443, 80) +Sleep(1500) diff --git a/corpus/page-versions/cold-delete-all/shots/cold-shown.json b/corpus/page-versions/cold-delete-all/shots/cold-shown.json new file mode 100644 index 0000000000000000000000000000000000000000..f43854a7a42d5bb99c0b443513bc0c0993c2716c --- /dev/null +++ b/corpus/page-versions/cold-delete-all/shots/cold-shown.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "", + "class": "Net UI Tool Window", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/cold-delete-all/shots/cold-shown.png b/corpus/page-versions/cold-delete-all/shots/cold-shown.png new file mode 100644 index 0000000000000000000000000000000000000000..53cac2e0332b3d4d2ef1c610500160d09f7e19d5 Binary files /dev/null and b/corpus/page-versions/cold-delete-all/shots/cold-shown.png differ diff --git a/corpus/page-versions/cold-delete-all/shots/cold-version.ahk b/corpus/page-versions/cold-delete-all/shots/cold-version.ahk new file mode 100644 index 0000000000000000000000000000000000000000..397667f9ab27c602c2e271940f77f3f0cdd6a319 --- /dev/null +++ b/corpus/page-versions/cold-delete-all/shots/cold-version.ahk @@ -0,0 +1,4 @@ +WinActivate("ahk_exe ONENOTE.EXE") +CoordMode("Mouse", "Screen") +Click(710, 143) +Sleep(1500) diff --git a/corpus/page-versions/cold-delete-all/shots/cold-version.json b/corpus/page-versions/cold-delete-all/shots/cold-version.json new file mode 100644 index 0000000000000000000000000000000000000000..f43854a7a42d5bb99c0b443513bc0c0993c2716c --- /dev/null +++ b/corpus/page-versions/cold-delete-all/shots/cold-version.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "", + "class": "Net UI Tool Window", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/cold-delete-all/shots/cold-version.png b/corpus/page-versions/cold-delete-all/shots/cold-version.png new file mode 100644 index 0000000000000000000000000000000000000000..8cb1e3b9ebed9fe8a906c62be0dfb95bb5fd5614 Binary files /dev/null and b/corpus/page-versions/cold-delete-all/shots/cold-version.png differ diff --git a/corpus/page-versions/cold-delete-all/source.json b/corpus/page-versions/cold-delete-all/source.json new file mode 100644 index 0000000000000000000000000000000000000000..c9634129833c59822d760e0fe4660cb7993ccd26 --- /dev/null +++ b/corpus/page-versions/cold-delete-all/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "History.one", + "bytes": 23048, + "sha256": "ae4b6cc0de2cc2384c8bd67f55a2d468ce100698c5c7fc0585d30008d77257cb", + "mtime_ns": 1790567288571022815 + }, + { + "path": "Open Notebook.onetoc2", + "bytes": 4808, + "sha256": "21f33fee6a5f2946e7d9394036a37cc1bf062856449eed5a0eceaf682dde76fc", + "mtime_ns": 1790567288570444733 + } +] diff --git a/corpus/page-versions/cold-delete-all/teardown.json b/corpus/page-versions/cold-delete-all/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/page-versions/cold-delete-all/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/page-versions/cold-restore/before-read/environment.json b/corpus/page-versions/cold-restore/before-read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..d72e5f4fbc37245a0ae3544d282216c8b8da28bf --- /dev/null +++ b/corpus/page-versions/cold-restore/before-read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-7319BB2D", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/page-versions/cold-restore/before-read/hierarchy.xml b/corpus/page-versions/cold-restore/before-read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..accdff923eea60b4004f9628a08f2c3fea56dcc1 --- /dev/null +++ b/corpus/page-versions/cold-restore/before-read/hierarchy.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{5B778416-7313-4D11-87CC-FA8573783DF1}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-28T03:50:30.000Z" color="#EE9597"><one:Section name="History" ID="{E9212367-F870-0A56-22F4-5CE45E73E5B8}{1}{B0}" path="C:\one-tests\runs\capture\notebook\History.one" lastModifiedTime="2026-09-28T03:50:30.000Z" color="#B49EDE"><one:Page ID="{B491B045-3B02-0DB9-17A3-E19B54F1CDC1}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2025-10-03T21:20:00.000Z" pageLevel="1"/><one:Page ID="{FCB78014-103A-0413-22D0-1F0690630F0F}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" pageLevel="1"/></one:Section></one:Notebook> diff --git a/corpus/page-versions/cold-restore/before-read/page-000.xml b/corpus/page-versions/cold-restore/before-read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..25606f9d4d7d0b461b8887b3b95eda2c3ea7f23f --- /dev/null +++ b/corpus/page-versions/cold-restore/before-read/page-000.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B491B045-3B02-0DB9-17A3-E19B54F1CDC1}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2025-10-03T21:20:00.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T00:42:44.000Z" lastModifiedTime="2026-09-28T00:43:03.000Z" objectID="{64C6C3E1-E10C-0E46-3935-37F0FB90E793}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Versioned]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-28T00:43:34.000Z" objectID="{64C6C3E1-E10C-0E46-3935-37F0FB90E793}{39}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="163.7793731689453" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-28T00:43:34.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" objectID="{64C6C3E1-E10C-0E46-3935-37F0FB90E793}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[First state. Typed by virtual.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/page-versions/cold-restore/before-read/page-001.xml b/corpus/page-versions/cold-restore/before-read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..79b665ba7dca02c1d0906df416869f54e87a39f0 --- /dev/null +++ b/corpus/page-versions/cold-restore/before-read/page-001.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{FCB78014-103A-0413-22D0-1F0690630F0F}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T00:42:44.000Z" lastModifiedTime="2026-09-28T00:43:03.000Z" objectID="{0CC541C2-E778-00F6-0C7C-5D9861B325BA}{22}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Versioned]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-28T00:47:16.000Z" objectID="{0CC541C2-E778-00F6-0C7C-5D9861B325BA}{15}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="163.7793731689453" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-28T00:43:34.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" objectID="{0CC541C2-E778-00F6-0C7C-5D9861B325BA}{16}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[First state. Typed by virtual.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/page-versions/cold-restore/before-read/payloads.json b/corpus/page-versions/cold-restore/before-read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/page-versions/cold-restore/before-read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/page-versions/cold-restore/commands.jsonl b/corpus/page-versions/cold-restore/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..32ccade8280d6f623fd98b3fbf5b165eef9abbad --- /dev/null +++ b/corpus/page-versions/cold-restore/commands.jsonl @@ -0,0 +1,6 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-7319BB2D -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-7319BB2D -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/page-versions/cold-restore/machine.json b/corpus/page-versions/cold-restore/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..f4621da0ed360f0ee6ce5d2c9ba50606286f16a9 --- /dev/null +++ b/corpus/page-versions/cold-restore/machine.json @@ -0,0 +1 @@ +{"name": "m6-7319bb2d", "hostname": "ONE-M6-7319BB2D"} diff --git a/corpus/page-versions/cold-restore/notebook/History.one b/corpus/page-versions/cold-restore/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..310d68e320662de861ccbde469e1936210c7d29c Binary files /dev/null and b/corpus/page-versions/cold-restore/notebook/History.one differ diff --git a/corpus/page-versions/cold-restore/notebook/Open Notebook.onetoc2 b/corpus/page-versions/cold-restore/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/cold-restore/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/cold-restore/read/environment.json b/corpus/page-versions/cold-restore/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..c2b79f78da82459581b0f026aaf195cec5349587 --- /dev/null +++ b/corpus/page-versions/cold-restore/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-7319BB2D", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/page-versions/cold-restore/read/hierarchy.xml b/corpus/page-versions/cold-restore/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..0b39f133e5dcfdebaf66c59fc044911edfe22f80 --- /dev/null +++ b/corpus/page-versions/cold-restore/read/hierarchy.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{5B778416-7313-4D11-87CC-FA8573783DF1}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-28T03:50:30.000Z" color="#EE9597" isCurrentlyViewed="true"><one:Section name="History" ID="{E9212367-F870-0A56-22F4-5CE45E73E5B8}{1}{B0}" path="C:\one-tests\runs\capture\notebook\History.one" lastModifiedTime="2026-09-28T03:50:30.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Page ID="{B491B045-3B02-0DB9-17A3-E19B54F1CDC1}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2025-10-03T21:20:00.000Z" pageLevel="1" isCurrentlyViewed="true"/><one:Page ID="{FCB78014-103A-0413-22D0-1F0690630F0F}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" pageLevel="1"/></one:Section></one:Notebook> diff --git a/corpus/page-versions/cold-restore/read/page-000.xml b/corpus/page-versions/cold-restore/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..80699f91020e1092773100f20d21f6dcf5dfc122 --- /dev/null +++ b/corpus/page-versions/cold-restore/read/page-000.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B491B045-3B02-0DB9-17A3-E19B54F1CDC1}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2026-09-28T03:51:38.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T00:42:44.000Z" lastModifiedTime="2026-09-28T00:43:03.000Z" objectID="{64C6C3E1-E10C-0E46-3935-37F0FB90E793}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Versioned]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-28T00:43:34.000Z" objectID="{64C6C3E1-E10C-0E46-3935-37F0FB90E793}{39}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="163.7793731689453" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-28T00:43:34.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" objectID="{64C6C3E1-E10C-0E46-3935-37F0FB90E793}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[First state. Typed by virtual.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/page-versions/cold-restore/read/page-001.xml b/corpus/page-versions/cold-restore/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..79b665ba7dca02c1d0906df416869f54e87a39f0 --- /dev/null +++ b/corpus/page-versions/cold-restore/read/page-001.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{FCB78014-103A-0413-22D0-1F0690630F0F}{1}{B0}" name="Versioned" dateTime="2026-09-28T00:42:43.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T00:42:44.000Z" lastModifiedTime="2026-09-28T00:43:03.000Z" objectID="{0CC541C2-E778-00F6-0C7C-5D9861B325BA}{22}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Versioned]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-28T00:47:16.000Z" objectID="{0CC541C2-E778-00F6-0C7C-5D9861B325BA}{15}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="163.7793731689453" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-28T00:43:34.000Z" lastModifiedTime="2026-09-28T00:43:34.000Z" objectID="{0CC541C2-E778-00F6-0C7C-5D9861B325BA}{16}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[First state. Typed by virtual.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> diff --git a/corpus/page-versions/cold-restore/read/payloads.json b/corpus/page-versions/cold-restore/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/page-versions/cold-restore/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/page-versions/cold-restore/run.json b/corpus/page-versions/cold-restore/run.json new file mode 100644 index 0000000000000000000000000000000000000000..2a5320a701bf285f1b20560dca8974ee6c1ce59e --- /dev/null +++ b/corpus/page-versions/cold-restore/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/corpus/page-versions/candidate-restore", + "expected_pages": -1, + "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/page-versions/cold-restore/scripts/cold.ps1 b/corpus/page-versions/cold-restore/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/page-versions/cold-restore/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/page-versions/cold-restore/scripts/read.ps1 b/corpus/page-versions/cold-restore/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/page-versions/cold-restore/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/page-versions/cold-restore/shots/cold-delete.ahk b/corpus/page-versions/cold-restore/shots/cold-delete.ahk new file mode 100644 index 0000000000000000000000000000000000000000..7a2bc4d4a2abd4ea6567a7e3a3db837025d4236e --- /dev/null +++ b/corpus/page-versions/cold-restore/shots/cold-delete.ahk @@ -0,0 +1,7 @@ +WinActivate("ahk_exe ONENOTE.EXE") +CoordMode("Mouse", "Screen") +Click(393, 128) +Sleep(2500) +if WinExist("ahk_class #32770 ahk_exe ONENOTE.EXE") { + FileAppend("dialog: " WinGetTitle() " | " WinGetText() "`n", "*", "UTF-8") +} diff --git a/corpus/page-versions/cold-restore/shots/cold-delete.json b/corpus/page-versions/cold-restore/shots/cold-delete.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/cold-restore/shots/cold-delete.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/cold-restore/shots/cold-delete.png b/corpus/page-versions/cold-restore/shots/cold-delete.png new file mode 100644 index 0000000000000000000000000000000000000000..22286bc6d9b84454d22985934ab5172b71126942 Binary files /dev/null and b/corpus/page-versions/cold-restore/shots/cold-delete.png differ diff --git a/corpus/page-versions/cold-restore/shots/cold-main.ahk b/corpus/page-versions/cold-restore/shots/cold-main.ahk new file mode 100644 index 0000000000000000000000000000000000000000..01fa8ba60b48e2944d7ca36014876036bcf4777a --- /dev/null +++ b/corpus/page-versions/cold-restore/shots/cold-main.ahk @@ -0,0 +1,14 @@ +OnError((exception, mode) => (FileAppend(exception.Message, "**"), ExitApp(1))) +DetectHiddenWindows false +app := ComObject("OneNote.Application") +app.NavigateTo("{B491B045-3B02-0DB9-17A3-E19B54F1CDC1}{1}{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") +app.NavigateTo("{B491B045-3B02-0DB9-17A3-E19B54F1CDC1}{1}{B0}", "", false) +Sleep 300 +Sleep(2000) diff --git a/corpus/page-versions/cold-restore/shots/cold-main.json b/corpus/page-versions/cold-restore/shots/cold-main.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/cold-restore/shots/cold-main.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/cold-restore/shots/cold-main.png b/corpus/page-versions/cold-restore/shots/cold-main.png new file mode 100644 index 0000000000000000000000000000000000000000..a4e7d0a858e88acf9b00c7d5208f514210beb657 Binary files /dev/null and b/corpus/page-versions/cold-restore/shots/cold-main.png differ diff --git a/corpus/page-versions/cold-restore/shots/cold-menu.ahk b/corpus/page-versions/cold-restore/shots/cold-menu.ahk new file mode 100644 index 0000000000000000000000000000000000000000..2d1fe2b86050641494bae74fbf2ea5df632a47a8 --- /dev/null +++ b/corpus/page-versions/cold-restore/shots/cold-menu.ahk @@ -0,0 +1,4 @@ +WinActivate("ahk_exe ONENOTE.EXE") +CoordMode("Mouse", "Screen") +Click(320, 93) +Sleep(1000) diff --git a/corpus/page-versions/cold-restore/shots/cold-menu.json b/corpus/page-versions/cold-restore/shots/cold-menu.json new file mode 100644 index 0000000000000000000000000000000000000000..f43854a7a42d5bb99c0b443513bc0c0993c2716c --- /dev/null +++ b/corpus/page-versions/cold-restore/shots/cold-menu.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "", + "class": "Net UI Tool Window", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/cold-restore/shots/cold-menu.png b/corpus/page-versions/cold-restore/shots/cold-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..9f3f2520613d1ae245ec6fe1e1b5f0826dc4507d Binary files /dev/null and b/corpus/page-versions/cold-restore/shots/cold-menu.png differ diff --git a/corpus/page-versions/cold-restore/shots/cold-shown.ahk b/corpus/page-versions/cold-restore/shots/cold-shown.ahk new file mode 100644 index 0000000000000000000000000000000000000000..57021288e33046724124084b1e09a39f5f2c8120 --- /dev/null +++ b/corpus/page-versions/cold-restore/shots/cold-shown.ahk @@ -0,0 +1,6 @@ +WinActivate("ahk_exe ONENOTE.EXE") +CoordMode("Mouse", "Screen") +Click(202, 36) +Sleep(600) +Click(443, 80) +Sleep(1500) diff --git a/corpus/page-versions/cold-restore/shots/cold-shown.json b/corpus/page-versions/cold-restore/shots/cold-shown.json new file mode 100644 index 0000000000000000000000000000000000000000..f43854a7a42d5bb99c0b443513bc0c0993c2716c --- /dev/null +++ b/corpus/page-versions/cold-restore/shots/cold-shown.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "", + "class": "Net UI Tool Window", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/cold-restore/shots/cold-shown.png b/corpus/page-versions/cold-restore/shots/cold-shown.png new file mode 100644 index 0000000000000000000000000000000000000000..0621102a273d6abbd232e68118c23faf87f36e3b Binary files /dev/null and b/corpus/page-versions/cold-restore/shots/cold-shown.png differ diff --git a/corpus/page-versions/cold-restore/shots/cold-version.ahk b/corpus/page-versions/cold-restore/shots/cold-version.ahk new file mode 100644 index 0000000000000000000000000000000000000000..397667f9ab27c602c2e271940f77f3f0cdd6a319 --- /dev/null +++ b/corpus/page-versions/cold-restore/shots/cold-version.ahk @@ -0,0 +1,4 @@ +WinActivate("ahk_exe ONENOTE.EXE") +CoordMode("Mouse", "Screen") +Click(710, 143) +Sleep(1500) diff --git a/corpus/page-versions/cold-restore/shots/cold-version.json b/corpus/page-versions/cold-restore/shots/cold-version.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/cold-restore/shots/cold-version.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/cold-restore/shots/cold-version.png b/corpus/page-versions/cold-restore/shots/cold-version.png new file mode 100644 index 0000000000000000000000000000000000000000..344ce4d80b36be33da668e4c61b6a5580243709c Binary files /dev/null and b/corpus/page-versions/cold-restore/shots/cold-version.png differ diff --git a/corpus/page-versions/cold-restore/source.json b/corpus/page-versions/cold-restore/source.json new file mode 100644 index 0000000000000000000000000000000000000000..ed6032d01903ffec8daa28ab8d74a71f48bdece4 --- /dev/null +++ b/corpus/page-versions/cold-restore/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "History.one", + "bytes": 21128, + "sha256": "51694895069efb861d51e420ff463fbe9ce1207091a27aa7e98de0ebd6e469f7", + "mtime_ns": 1790567288549426487 + }, + { + "path": "Open Notebook.onetoc2", + "bytes": 4808, + "sha256": "21f33fee6a5f2946e7d9394036a37cc1bf062856449eed5a0eceaf682dde76fc", + "mtime_ns": 1790567288548592364 + } +] diff --git a/corpus/page-versions/cold-restore/teardown.json b/corpus/page-versions/cold-restore/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/page-versions/cold-restore/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/page-versions/native-scripted/commands.jsonl b/corpus/page-versions/native-scripted/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..1759450b3fbed7dfdbdd45ab42c4a6be65b600d8 --- /dev/null +++ b/corpus/page-versions/native-scripted/commands.jsonl @@ -0,0 +1,12 @@ +{"command": "mkdir C:\\one-tests\\versions", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\versions\\setup.ps1", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"$f = 'C:\\one-tests\\versions/Versions/History.one'; $last = ''; $still = 0; for ($i = 0; $i -lt 120 -and $still -lt 10; $i++) { Start-Sleep -Milliseconds 500; $item = Get-Item $f; $now = '' + $item.Length + $item.LastWriteTimeUtc.Ticks; if ($now -eq $last) { $still++ } else { $still = 0; $last = $now } }\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"$f = 'C:\\one-tests\\versions/Versions/History.one'; $last = ''; $still = 0; for ($i = 0; $i -lt 120 -and $still -lt 10; $i++) { Start-Sleep -Milliseconds 500; $item = Get-Item $f; $now = '' + $item.Length + $item.LastWriteTimeUtc.Ticks; if ($now -eq $last) { $still++ } else { $still = 0; $last = $now } }\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"$f = 'C:\\one-tests\\versions/Versions/History.one'; $last = ''; $still = 0; for ($i = 0; $i -lt 120 -and $still -lt 10; $i++) { Start-Sleep -Milliseconds 500; $item = Get-Item $f; $now = '' + $item.Length + $item.LastWriteTimeUtc.Ticks; if ($now -eq $last) { $still++ } else { $still = 0; $last = $now } }\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"$f = 'C:\\one-tests\\versions/Versions/History.one'; $last = ''; $still = 0; for ($i = 0; $i -lt 120 -and $still -lt 10; $i++) { Start-Sleep -Milliseconds 500; $item = Get-Item $f; $now = '' + $item.Length + $item.LastWriteTimeUtc.Ticks; if ($now -eq $last) { $still++ } else { $still = 0; $last = $now } }\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"$f = 'C:\\one-tests\\versions/Versions/History.one'; $last = ''; $still = 0; for ($i = 0; $i -lt 120 -and $still -lt 10; $i++) { Start-Sleep -Milliseconds 500; $item = Get-Item $f; $now = '' + $item.Length + $item.LastWriteTimeUtc.Ticks; if ($now -eq $last) { $still++ } else { $still = 0; $last = $now } }\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"$f = 'C:\\one-tests\\versions/Versions/History.one'; $last = ''; $still = 0; for ($i = 0; $i -lt 120 -and $still -lt 10; $i++) { Start-Sleep -Milliseconds 500; $item = Get-Item $f; $now = '' + $item.Length + $item.LastWriteTimeUtc.Ticks; if ($now -eq $last) { $still++ } else { $still = 0; $last = $now } }\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"$f = 'C:\\one-tests\\versions/Versions/History.one'; $last = ''; $still = 0; for ($i = 0; $i -lt 120 -and $still -lt 10; $i++) { Start-Sleep -Milliseconds 500; $item = Get-Item $f; $now = '' + $item.Length + $item.LastWriteTimeUtc.Ticks; if ($now -eq $last) { $still++ } else { $still = 0; $last = $now } }\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"$f = 'C:\\one-tests\\versions/Versions/History.one'; $last = ''; $still = 0; for ($i = 0; $i -lt 120 -and $still -lt 10; $i++) { Start-Sleep -Milliseconds 500; $item = Get-Item $f; $now = '' + $item.Length + $item.LastWriteTimeUtc.Ticks; if ($now -eq $last) { $still++ } else { $still = 0; $last = $now } }\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"$f = 'C:\\one-tests\\versions/Versions/History.one'; $last = ''; $still = 0; for ($i = 0; $i -lt 120 -and $still -lt 10; $i++) { Start-Sleep -Milliseconds 500; $item = Get-Item $f; $now = '' + $item.Length + $item.LastWriteTimeUtc.Ticks; if ($now -eq $last) { $still++ } else { $still = 0; $last = $now } }\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 1, "stdout": "", "stderr": "Compress-Archive : The path 'C:\\one-tests\\runs\\capture\\*' either does not \r\nexist or is not a valid file system path.\r\nAt line:1 char:1\r\n+ Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -Destinatio ...\r\n+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r\n + CategoryInfo : InvalidArgument: (C:\\one-tests\\runs\\capture\\*:St \r\n ring) [Compress-Archive], InvalidOperationException\r\n + FullyQualifiedErrorId : ArchiveCmdletPathNotFound,Compress-Archive\r\n \r\n", "error": null} diff --git a/corpus/page-versions/native-scripted/machine.json b/corpus/page-versions/native-scripted/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..32345a021bb3c2d14e95a33a3583e5ca1daf1955 --- /dev/null +++ b/corpus/page-versions/native-scripted/machine.json @@ -0,0 +1 @@ +{"name": "m6-5ffe803a", "hostname": "ONE-M6-5FFE803A"} diff --git a/corpus/page-versions/native-scripted/setup.ps1 b/corpus/page-versions/native-scripted/setup.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..1cb4cddb1caa8ea63dafade3dc7db0ce7da54060 --- /dev/null +++ b/corpus/page-versions/native-scripted/setup.ps1 @@ -0,0 +1,11 @@ +$ErrorActionPreference = 'Stop' +$app = New-Object -ComObject OneNote.Application +$notebook = '' +$app.OpenHierarchy('C:\one-tests\versions\Versions', '', [ref]$notebook, 1) +$section = '' +$app.OpenHierarchy('History.one', $notebook, [ref]$section, 3) +$page = '' +$app.CreateNewPage($section, [ref]$page, 0) +$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' +$app.UpdatePageContent('<one:Page xmlns:one="' + $namespace + '" ID="' + $page + '"><one:Title><one:OE><one:T>Versioned</one:T></one:OE></one:Title><one:Outline><one:Position x="36" y="86"/><one:OEChildren><one:OE><one:T>First state.</one:T></one:OE></one:OEChildren></one:Outline></one:Page>') +$page | Out-File 'C:\one-tests\versions\page.txt' -Encoding UTF8 diff --git a/corpus/page-versions/native-scripted/shots/01-typed.json b/corpus/page-versions/native-scripted/shots/01-typed.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/01-typed.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/01-typed.png b/corpus/page-versions/native-scripted/shots/01-typed.png new file mode 100644 index 0000000000000000000000000000000000000000..1be30e778c4c5a539b251133b989b27587d8cf4f Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/01-typed.png differ diff --git a/corpus/page-versions/native-scripted/shots/02-menu.json b/corpus/page-versions/native-scripted/shots/02-menu.json new file mode 100644 index 0000000000000000000000000000000000000000..f43854a7a42d5bb99c0b443513bc0c0993c2716c --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/02-menu.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "", + "class": "Net UI Tool Window", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/02-menu.png b/corpus/page-versions/native-scripted/shots/02-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..14e2e099a362a24955cb0e0d85b25f6cf470a3ca Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/02-menu.png differ diff --git a/corpus/page-versions/native-scripted/shots/02-second-author.json b/corpus/page-versions/native-scripted/shots/02-second-author.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/02-second-author.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/02-second-author.png b/corpus/page-versions/native-scripted/shots/02-second-author.png new file mode 100644 index 0000000000000000000000000000000000000000..da4d97c068d6488d982af09e07dfab5d5c1b6c08 Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/02-second-author.png differ diff --git a/corpus/page-versions/native-scripted/shots/02-shown.json b/corpus/page-versions/native-scripted/shots/02-shown.json new file mode 100644 index 0000000000000000000000000000000000000000..f43854a7a42d5bb99c0b443513bc0c0993c2716c --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/02-shown.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "", + "class": "Net UI Tool Window", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/02-shown.png b/corpus/page-versions/native-scripted/shots/02-shown.png new file mode 100644 index 0000000000000000000000000000000000000000..eeb73f56f8d1b2a7b0563668f09cd35c13ec2f07 Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/02-shown.png differ diff --git a/corpus/page-versions/native-scripted/shots/02-version-menu.json b/corpus/page-versions/native-scripted/shots/02-version-menu.json new file mode 100644 index 0000000000000000000000000000000000000000..f43854a7a42d5bb99c0b443513bc0c0993c2716c --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/02-version-menu.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "", + "class": "Net UI Tool Window", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/02-version-menu.png b/corpus/page-versions/native-scripted/shots/02-version-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..8a5774f2180d7484994e0487e872b795e6a36d53 Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/02-version-menu.png differ diff --git a/corpus/page-versions/native-scripted/shots/02-version.json b/corpus/page-versions/native-scripted/shots/02-version.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/02-version.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/02-version.png b/corpus/page-versions/native-scripted/shots/02-version.png new file mode 100644 index 0000000000000000000000000000000000000000..bf53d0ab8a033ca8d9e32986647a1dca23299cae Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/02-version.png differ diff --git a/corpus/page-versions/native-scripted/shots/03-copied.json b/corpus/page-versions/native-scripted/shots/03-copied.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/03-copied.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/03-copied.png b/corpus/page-versions/native-scripted/shots/03-copied.png new file mode 100644 index 0000000000000000000000000000000000000000..bbe29f38ec528f0b430e1567fe1cc64eecc425cc Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/03-copied.png differ diff --git a/corpus/page-versions/native-scripted/shots/03-copy-dialog.json b/corpus/page-versions/native-scripted/shots/03-copy-dialog.json new file mode 100644 index 0000000000000000000000000000000000000000..139e7fbc61183c206f5948a58b75c055cab87a81 --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/03-copy-dialog.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Move or Copy Pages", + "class": "NUIDialog", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/03-copy-dialog.png b/corpus/page-versions/native-scripted/shots/03-copy-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..6140b87f4a27cacf0077a8f148ee87735d5d64b6 Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/03-copy-dialog.png differ diff --git a/corpus/page-versions/native-scripted/shots/04-restored.json b/corpus/page-versions/native-scripted/shots/04-restored.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/04-restored.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/04-restored.png b/corpus/page-versions/native-scripted/shots/04-restored.png new file mode 100644 index 0000000000000000000000000000000000000000..3c4e3a73261f34fed6eb40f94be037d44372b664 Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/04-restored.png differ diff --git a/corpus/page-versions/native-scripted/shots/04-shown.json b/corpus/page-versions/native-scripted/shots/04-shown.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/04-shown.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/04-shown.png b/corpus/page-versions/native-scripted/shots/04-shown.png new file mode 100644 index 0000000000000000000000000000000000000000..1996432e6ee199c87688011550f36a3b271d0b00 Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/04-shown.png differ diff --git a/corpus/page-versions/native-scripted/shots/05-deleted.json b/corpus/page-versions/native-scripted/shots/05-deleted.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/05-deleted.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/05-deleted.png b/corpus/page-versions/native-scripted/shots/05-deleted.png new file mode 100644 index 0000000000000000000000000000000000000000..d3288e4063a78fe0fa4167a391b08560cb645413 Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/05-deleted.png differ diff --git a/corpus/page-versions/native-scripted/shots/05-newest.json b/corpus/page-versions/native-scripted/shots/05-newest.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/05-newest.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/05-newest.png b/corpus/page-versions/native-scripted/shots/05-newest.png new file mode 100644 index 0000000000000000000000000000000000000000..31de5a5e2617fec67e2dd21a5d9759e5f7a45630 Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/05-newest.png differ diff --git a/corpus/page-versions/native-scripted/shots/06-confirm.json b/corpus/page-versions/native-scripted/shots/06-confirm.json new file mode 100644 index 0000000000000000000000000000000000000000..e1acaa25f783e3e325304deb3c64eed36b7b11cd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/06-confirm.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "dialog: Microsoft OneNote | &Yes\r\n&No\r\nDo you want to delete all page versions in the section \"History\"?\r\n\n", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Microsoft OneNote", + "class": "#32770", + "dialog": true + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/06-confirm.png b/corpus/page-versions/native-scripted/shots/06-confirm.png new file mode 100644 index 0000000000000000000000000000000000000000..c9965dc0030294d18362e1e95e46f218eb675b42 Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/06-confirm.png differ diff --git a/corpus/page-versions/native-scripted/shots/06-deleted-all.json b/corpus/page-versions/native-scripted/shots/06-deleted-all.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/06-deleted-all.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/06-deleted-all.png b/corpus/page-versions/native-scripted/shots/06-deleted-all.png new file mode 100644 index 0000000000000000000000000000000000000000..8af3a3084117dc063c5ab1bb72bcf99fa4b7372e Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/06-deleted-all.png differ diff --git a/corpus/page-versions/native-scripted/shots/07-third-author-edit.json b/corpus/page-versions/native-scripted/shots/07-third-author-edit.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/07-third-author-edit.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/07-third-author-edit.png b/corpus/page-versions/native-scripted/shots/07-third-author-edit.png new file mode 100644 index 0000000000000000000000000000000000000000..2c8a0d89ade71fa351be87507fda15766f3a5b9b Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/07-third-author-edit.png differ diff --git a/corpus/page-versions/native-scripted/shots/08-deleted-only.json b/corpus/page-versions/native-scripted/shots/08-deleted-only.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/08-deleted-only.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/08-deleted-only.png b/corpus/page-versions/native-scripted/shots/08-deleted-only.png new file mode 100644 index 0000000000000000000000000000000000000000..c41fd40e168eac2e8bc656945e4239a512cddde0 Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/08-deleted-only.png differ diff --git a/corpus/page-versions/native-scripted/shots/09-authors.json b/corpus/page-versions/native-scripted/shots/09-authors.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/09-authors.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/09-authors.png b/corpus/page-versions/native-scripted/shots/09-authors.png new file mode 100644 index 0000000000000000000000000000000000000000..a9d12da9c3603c79b8ebc70b9ae4e2efa0b82355 Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/09-authors.png differ diff --git a/corpus/page-versions/native-scripted/shots/09-find-by-author.json b/corpus/page-versions/native-scripted/shots/09-find-by-author.json new file mode 100644 index 0000000000000000000000000000000000000000..f22e85e63040b639b3cae82791406ace67630fdd --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/09-find-by-author.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Versioned - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/09-find-by-author.png b/corpus/page-versions/native-scripted/shots/09-find-by-author.png new file mode 100644 index 0000000000000000000000000000000000000000..6f0e3c3144161e4d0abae632991ebf4a0ae7e77d Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/09-find-by-author.png differ diff --git a/corpus/page-versions/native-scripted/shots/09-recent-edits.json b/corpus/page-versions/native-scripted/shots/09-recent-edits.json new file mode 100644 index 0000000000000000000000000000000000000000..f43854a7a42d5bb99c0b443513bc0c0993c2716c --- /dev/null +++ b/corpus/page-versions/native-scripted/shots/09-recent-edits.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "", + "class": "Net UI Tool Window", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-versions/native-scripted/shots/09-recent-edits.png b/corpus/page-versions/native-scripted/shots/09-recent-edits.png new file mode 100644 index 0000000000000000000000000000000000000000..a15d9315e880f718ae5ef2c600379435909f75da Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/09-recent-edits.png differ diff --git a/corpus/page-versions/native-scripted/step-01/notebook/History.one b/corpus/page-versions/native-scripted/step-01/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..b7da17933e88a96c3cab96a8d61a7d80d635fcbc Binary files /dev/null and b/corpus/page-versions/native-scripted/step-01/notebook/History.one differ diff --git a/corpus/page-versions/native-scripted/step-01/notebook/Open Notebook.onetoc2 b/corpus/page-versions/native-scripted/step-01/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..3c88c0332c0f66096345593e9f7b6846c31f9e71 Binary files /dev/null and b/corpus/page-versions/native-scripted/step-01/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/native-scripted/step-02/notebook/History.one b/corpus/page-versions/native-scripted/step-02/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..a832f9a1ae0af9bad6eeb69955f699e679712fc5 Binary files /dev/null and b/corpus/page-versions/native-scripted/step-02/notebook/History.one differ diff --git a/corpus/page-versions/native-scripted/step-03/notebook/History.one b/corpus/page-versions/native-scripted/step-03/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..a4fd97d68ef70829b2afbdec9d5012916b504f60 Binary files /dev/null and b/corpus/page-versions/native-scripted/step-03/notebook/History.one differ diff --git a/corpus/page-versions/native-scripted/step-04/notebook/History.one b/corpus/page-versions/native-scripted/step-04/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..a408683aca1ad7069a30b2db45a2054891a1e9bf Binary files /dev/null and b/corpus/page-versions/native-scripted/step-04/notebook/History.one differ diff --git a/corpus/page-versions/native-scripted/step-05/notebook/History.one b/corpus/page-versions/native-scripted/step-05/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..b68d0a3ec2b137ded357743129cb2c986bb0dd87 Binary files /dev/null and b/corpus/page-versions/native-scripted/step-05/notebook/History.one differ diff --git a/corpus/page-versions/native-scripted/step-06/notebook/History.one b/corpus/page-versions/native-scripted/step-06/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..106ff74cb7814c95f27c4a9985e7b75f9d808822 Binary files /dev/null and b/corpus/page-versions/native-scripted/step-06/notebook/History.one differ diff --git a/corpus/page-versions/native-scripted/step-07/notebook/History.one b/corpus/page-versions/native-scripted/step-07/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..78823d5f0a3b1247b0e08c68a1fbe56f397ed410 Binary files /dev/null and b/corpus/page-versions/native-scripted/step-07/notebook/History.one differ diff --git a/corpus/page-versions/native-scripted/step-08/notebook/History.one b/corpus/page-versions/native-scripted/step-08/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..3467a083ed2f73d3a72bab74b04159145eb2ada8 Binary files /dev/null and b/corpus/page-versions/native-scripted/step-08/notebook/History.one differ diff --git a/corpus/page-versions/native-scripted/step-09/notebook/History.one b/corpus/page-versions/native-scripted/step-09/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..73cc0538f59711c3b5f11d7e93decdec8e0b16e3 Binary files /dev/null and b/corpus/page-versions/native-scripted/step-09/notebook/History.one differ diff --git a/corpus/page-versions/native/shots/01-version-open.png b/corpus/page-versions/native/shots/01-version-open.png new file mode 100644 index 0000000000000000000000000000000000000000..c19166293e442c35e50b6a3d28c7fd0c781c3a3d Binary files /dev/null and b/corpus/page-versions/native/shots/01-version-open.png differ diff --git a/corpus/page-versions/native/shots/02-version-menu.png b/corpus/page-versions/native/shots/02-version-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..fca1ca11f2a3e298443c1746175ca1ec290c06bf Binary files /dev/null and b/corpus/page-versions/native/shots/02-version-menu.png differ diff --git a/corpus/page-versions/native/shots/03-closed.png b/corpus/page-versions/native/shots/03-closed.png new file mode 100644 index 0000000000000000000000000000000000000000..b237fa7a9ccfa1e47e54a04ad5327809a01e0e94 Binary files /dev/null and b/corpus/page-versions/native/shots/03-closed.png differ diff --git a/corpus/page-versions/native/shots/04-copy-dialog.png b/corpus/page-versions/native/shots/04-copy-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..c79f721db8e1d83a0d0d5b6c0d318aa64b565bbb Binary files /dev/null and b/corpus/page-versions/native/shots/04-copy-dialog.png differ diff --git a/corpus/page-versions/native/shots/05-copied.png b/corpus/page-versions/native/shots/05-copied.png new file mode 100644 index 0000000000000000000000000000000000000000..7129a4013ee0fac9b40dcf9ad149e6e83cd79890 Binary files /dev/null and b/corpus/page-versions/native/shots/05-copied.png differ diff --git a/corpus/page-versions/native/shots/06-shown-again.png b/corpus/page-versions/native/shots/06-shown-again.png new file mode 100644 index 0000000000000000000000000000000000000000..d726df189d0b6fdcaf88fb82d71a12b574615893 Binary files /dev/null and b/corpus/page-versions/native/shots/06-shown-again.png differ diff --git a/corpus/page-versions/native/shots/07-restored.png b/corpus/page-versions/native/shots/07-restored.png new file mode 100644 index 0000000000000000000000000000000000000000..3b16db4487905e311da21ab8ae2705f4dbe02049 Binary files /dev/null and b/corpus/page-versions/native/shots/07-restored.png differ diff --git a/corpus/page-versions/native/shots/08-other-version.png b/corpus/page-versions/native/shots/08-other-version.png new file mode 100644 index 0000000000000000000000000000000000000000..2f0ab786422be27798d06ea9f3a31b4c586ffcc9 Binary files /dev/null and b/corpus/page-versions/native/shots/08-other-version.png differ diff --git a/corpus/page-versions/native/shots/09-deleted.png b/corpus/page-versions/native/shots/09-deleted.png new file mode 100644 index 0000000000000000000000000000000000000000..5eb6a7b8ad9528daaaee9d0e24d2e9b4e2560e3d Binary files /dev/null and b/corpus/page-versions/native/shots/09-deleted.png differ diff --git a/corpus/page-versions/native/shots/10-delete-all-dialog.png b/corpus/page-versions/native/shots/10-delete-all-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..92cca1547705aa2fdc30aeb34e67e27e9362ea3e Binary files /dev/null and b/corpus/page-versions/native/shots/10-delete-all-dialog.png differ diff --git a/corpus/page-versions/native/shots/11-deleted-all.png b/corpus/page-versions/native/shots/11-deleted-all.png new file mode 100644 index 0000000000000000000000000000000000000000..cc754c0e3eedc8abb9d54aac183f7fd44e62f9dc Binary files /dev/null and b/corpus/page-versions/native/shots/11-deleted-all.png differ diff --git a/corpus/page-versions/native/shots/14-authors-shown.png b/corpus/page-versions/native/shots/14-authors-shown.png new file mode 100644 index 0000000000000000000000000000000000000000..ecb9b772dfc8f2e576c569a05c909d872d1b46ce Binary files /dev/null and b/corpus/page-versions/native/shots/14-authors-shown.png differ diff --git a/corpus/page-versions/native/shots/15-recent-edits.png b/corpus/page-versions/native/shots/15-recent-edits.png new file mode 100644 index 0000000000000000000000000000000000000000..48f5f69628cfad872dedae455510b0ec703ba77e Binary files /dev/null and b/corpus/page-versions/native/shots/15-recent-edits.png differ diff --git a/corpus/page-versions/native/shots/16-find-by-author.png b/corpus/page-versions/native/shots/16-find-by-author.png new file mode 100644 index 0000000000000000000000000000000000000000..4ff010aac31ed1e2e4c56960ebc11d61631fb865 Binary files /dev/null and b/corpus/page-versions/native/shots/16-find-by-author.png differ diff --git a/corpus/page-versions/native/shots/17-future.png b/corpus/page-versions/native/shots/17-future.png new file mode 100644 index 0000000000000000000000000000000000000000..98642e11993bfc383f5dbe406b37bfaefeecbb9c Binary files /dev/null and b/corpus/page-versions/native/shots/17-future.png differ diff --git a/corpus/page-versions/native/shots/18-options.png b/corpus/page-versions/native/shots/18-options.png new file mode 100644 index 0000000000000000000000000000000000000000..0788625e16e76359d85dcdd765c19b6d56f09126 Binary files /dev/null and b/corpus/page-versions/native/shots/18-options.png differ diff --git a/corpus/page-versions/native/shots/19-save-backup.png b/corpus/page-versions/native/shots/19-save-backup.png new file mode 100644 index 0000000000000000000000000000000000000000..32551646aa24acd2c4ec6ae686724e27d97614d5 Binary files /dev/null and b/corpus/page-versions/native/shots/19-save-backup.png differ diff --git a/corpus/page-versions/native/shots/20-optimized.png b/corpus/page-versions/native/shots/20-optimized.png new file mode 100644 index 0000000000000000000000000000000000000000..015db1502dd360fea66f82e1e1f5235c58bb781d Binary files /dev/null and b/corpus/page-versions/native/shots/20-optimized.png differ diff --git a/corpus/page-versions/native/shots/21-after-optimize.png b/corpus/page-versions/native/shots/21-after-optimize.png new file mode 100644 index 0000000000000000000000000000000000000000..e766f9abd1771fd91419e86771910438ecc5fc16 Binary files /dev/null and b/corpus/page-versions/native/shots/21-after-optimize.png differ diff --git a/corpus/page-versions/native/shots/22-page-menu.png b/corpus/page-versions/native/shots/22-page-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..3ab0227f5fc1478f04dac047e618fef714e751da Binary files /dev/null and b/corpus/page-versions/native/shots/22-page-menu.png differ diff --git a/corpus/page-versions/native/shots/23-page-menu-shown.png b/corpus/page-versions/native/shots/23-page-menu-shown.png new file mode 100644 index 0000000000000000000000000000000000000000..ed80fad5c39ba2cfec788955f89165566dd0fe6f Binary files /dev/null and b/corpus/page-versions/native/shots/23-page-menu-shown.png differ diff --git a/corpus/page-versions/native/shots/24-tooltip.png b/corpus/page-versions/native/shots/24-tooltip.png new file mode 100644 index 0000000000000000000000000000000000000000..fa54350e0905ec5f9e0d91d035ec2df3fc7183ad Binary files /dev/null and b/corpus/page-versions/native/shots/24-tooltip.png differ diff --git a/corpus/page-versions/native/step-01/notebook/History.one b/corpus/page-versions/native/step-01/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..3761b7ebf6a3f8a249b10e0f79dbf295c11c8c89 Binary files /dev/null and b/corpus/page-versions/native/step-01/notebook/History.one differ diff --git a/corpus/page-versions/native/step-01/notebook/Open Notebook.onetoc2 b/corpus/page-versions/native/step-01/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/native/step-01/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/native/step-02/notebook/History.one b/corpus/page-versions/native/step-02/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..d623ebf504dd44e097549e7b52c1e4ea17cc1540 Binary files /dev/null and b/corpus/page-versions/native/step-02/notebook/History.one differ diff --git a/corpus/page-versions/native/step-02/notebook/Open Notebook.onetoc2 b/corpus/page-versions/native/step-02/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/native/step-02/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/native/step-03/notebook/History.one b/corpus/page-versions/native/step-03/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..11307051af80b923394716dc8e132044d5c354c4 Binary files /dev/null and b/corpus/page-versions/native/step-03/notebook/History.one differ diff --git a/corpus/page-versions/native/step-03/notebook/Open Notebook.onetoc2 b/corpus/page-versions/native/step-03/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/native/step-03/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/native/step-04/notebook/History.one b/corpus/page-versions/native/step-04/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..2e44f993638894db4af255621e82a8f3c1ba026a Binary files /dev/null and b/corpus/page-versions/native/step-04/notebook/History.one differ diff --git a/corpus/page-versions/native/step-04/notebook/Open Notebook.onetoc2 b/corpus/page-versions/native/step-04/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/native/step-04/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/native/step-05/notebook/History.one b/corpus/page-versions/native/step-05/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..329778028447ed138498091fca382b3c5ad48dcd Binary files /dev/null and b/corpus/page-versions/native/step-05/notebook/History.one differ diff --git a/corpus/page-versions/native/step-05/notebook/Open Notebook.onetoc2 b/corpus/page-versions/native/step-05/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/native/step-05/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/native/step-06/notebook/History.one b/corpus/page-versions/native/step-06/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..f05e5df6b47c34cc6dd5fe9de8bcb8c3bb1bae18 Binary files /dev/null and b/corpus/page-versions/native/step-06/notebook/History.one differ diff --git a/corpus/page-versions/native/step-06/notebook/Open Notebook.onetoc2 b/corpus/page-versions/native/step-06/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/native/step-06/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/native/step-07/notebook/History.one b/corpus/page-versions/native/step-07/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..ab7041f4329c84d2651401c8274848d0c1e724c5 Binary files /dev/null and b/corpus/page-versions/native/step-07/notebook/History.one differ diff --git a/corpus/page-versions/native/step-07/notebook/Open Notebook.onetoc2 b/corpus/page-versions/native/step-07/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/native/step-07/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/native/step-08/notebook/History.one b/corpus/page-versions/native/step-08/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..ef5c1ace02caf1d45ddf56a1a0bf49021642a793 Binary files /dev/null and b/corpus/page-versions/native/step-08/notebook/History.one differ diff --git a/corpus/page-versions/native/step-08/notebook/Open Notebook.onetoc2 b/corpus/page-versions/native/step-08/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/native/step-08/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/native/step-09/notebook/History.one b/corpus/page-versions/native/step-09/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..52a9d03c606322b33813495eaad943b04f978425 Binary files /dev/null and b/corpus/page-versions/native/step-09/notebook/History.one differ diff --git a/corpus/page-versions/native/step-09/notebook/Open Notebook.onetoc2 b/corpus/page-versions/native/step-09/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/native/step-09/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-versions/native/step-10/notebook/History.one b/corpus/page-versions/native/step-10/notebook/History.one new file mode 100644 index 0000000000000000000000000000000000000000..8d4eb044c2a5bb4523568bbe5bd7a1aa5d0a83f8 Binary files /dev/null and b/corpus/page-versions/native/step-10/notebook/History.one differ diff --git a/corpus/page-versions/native/step-10/notebook/Open Notebook.onetoc2 b/corpus/page-versions/native/step-10/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..85b3dc0b31493b0b145cb139a98dbb7a5f3f27cf Binary files /dev/null and b/corpus/page-versions/native/step-10/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/recycle-bin-repair/README.md b/corpus/recycle-bin-repair/README.md new file mode 100644 index 0000000000000000000000000000000000000000..4956ef6d3d3887c1b0bb357383665c4fcb9e0d47 --- /dev/null +++ b/corpus/recycle-bin-repair/README.md @@ -0,0 +1,19 @@ +# Recycle bin repair + +`candidate` is the notebook +`a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadable_one` in +`crates/notebook/tests/structure.rs` exports through `NOTEBOOK_RECYCLE_EXPORT`: a page +deleted into a new recycle bin whose name the root TOC still listed for a bin gone from the +folder (the stale entry goes, the new bin is listed), then deleted again into a bin whose +TOC OneNote might name `OneNote Table Of Contents.onetoc2` and which did not list +`OneNote_DeletedPages.one` (the bin's TOC is kept, the deleted pages placed under it and +listed). + +`cold` is its fresh OneNote 2010 read: the recycle bin (`isRecycleBin`) holds "Deleted +Pages" (`isDeletedPages`) with both deleted pages, and OneNote left every file byte for +byte as written, so it repaired nothing. `tools/test_recycle_bin_repair.py` checks this +without a VM. + +Regenerate with `NOTEBOOK_RECYCLE_EXPORT` set to a new directory while running the test, +then `tools/native_runner.py OUTPUT COLD --expected-pages -1 --collect-notebook +--screenshots`. diff --git a/corpus/recycle-bin-repair/candidate/New Section 1.one b/corpus/recycle-bin-repair/candidate/New Section 1.one new file mode 100644 index 0000000000000000000000000000000000000000..3f689157f41b4381735bb0efef9ef5522699eef2 Binary files /dev/null and b/corpus/recycle-bin-repair/candidate/New Section 1.one differ diff --git a/corpus/recycle-bin-repair/candidate/OneNote_RecycleBin/OneNote Table Of Contents.onetoc2 b/corpus/recycle-bin-repair/candidate/OneNote_RecycleBin/OneNote Table Of Contents.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..820c42b88693678f5a1921d5b9ae65dce9ae4453 Binary files /dev/null and b/corpus/recycle-bin-repair/candidate/OneNote_RecycleBin/OneNote Table Of Contents.onetoc2 differ diff --git a/corpus/recycle-bin-repair/candidate/OneNote_RecycleBin/OneNote_DeletedPages.one b/corpus/recycle-bin-repair/candidate/OneNote_RecycleBin/OneNote_DeletedPages.one new file mode 100644 index 0000000000000000000000000000000000000000..ddea766e44fc80772524966f0e50c5ce24a1ecb3 Binary files /dev/null and b/corpus/recycle-bin-repair/candidate/OneNote_RecycleBin/OneNote_DeletedPages.one differ diff --git a/corpus/recycle-bin-repair/candidate/Open Notebook.onetoc2 b/corpus/recycle-bin-repair/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..6dbf7c52c864cdbc827ee6134ccaa72b63934154 Binary files /dev/null and b/corpus/recycle-bin-repair/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/recycle-bin-repair/cold/commands.jsonl b/corpus/recycle-bin-repair/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..d985a5362bd20b5930c28b9cac6ea13b1af31cdf --- /dev/null +++ b/corpus/recycle-bin-repair/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-18ABF18A -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 2 sections and 3 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/recycle-bin-repair/cold/machine.json b/corpus/recycle-bin-repair/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..016f7130bd1d0e9e648c45d5bebb3833005103bd --- /dev/null +++ b/corpus/recycle-bin-repair/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-18abf18a", "hostname": "ONE-M6-18ABF18A"} diff --git a/corpus/recycle-bin-repair/cold/notebook/New Section 1.one b/corpus/recycle-bin-repair/cold/notebook/New Section 1.one new file mode 100644 index 0000000000000000000000000000000000000000..3f689157f41b4381735bb0efef9ef5522699eef2 Binary files /dev/null and b/corpus/recycle-bin-repair/cold/notebook/New Section 1.one differ diff --git a/corpus/recycle-bin-repair/cold/notebook/OneNote_RecycleBin/OneNote Table Of Contents.onetoc2 b/corpus/recycle-bin-repair/cold/notebook/OneNote_RecycleBin/OneNote Table Of Contents.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..820c42b88693678f5a1921d5b9ae65dce9ae4453 Binary files /dev/null and b/corpus/recycle-bin-repair/cold/notebook/OneNote_RecycleBin/OneNote Table Of Contents.onetoc2 differ diff --git a/corpus/recycle-bin-repair/cold/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one b/corpus/recycle-bin-repair/cold/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one new file mode 100644 index 0000000000000000000000000000000000000000..ddea766e44fc80772524966f0e50c5ce24a1ecb3 Binary files /dev/null and b/corpus/recycle-bin-repair/cold/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one differ diff --git a/corpus/recycle-bin-repair/cold/notebook/Open Notebook.onetoc2 b/corpus/recycle-bin-repair/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..6dbf7c52c864cdbc827ee6134ccaa72b63934154 Binary files /dev/null and b/corpus/recycle-bin-repair/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/recycle-bin-repair/cold/read/environment.json b/corpus/recycle-bin-repair/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..250f18100a086d30e48b62d1d42c988da3efa9fc --- /dev/null +++ b/corpus/recycle-bin-repair/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-18ABF18A", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/recycle-bin-repair/cold/read/hierarchy.xml b/corpus/recycle-bin-repair/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..029623ae2ad214a2ecb58592d16e98731e94738c --- /dev/null +++ b/corpus/recycle-bin-repair/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{4A7F185F-1A48-43E7-BD06-5CC5B53BDBAF}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-28T08:06:34.000Z" color="#FFD700"><one:Section name="New Section 1" ID="{73ACD765-279A-0D4E-0BCC-965A2F853DE6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\New Section 1.one" lastModifiedTime="2026-09-28T08:06:34.000Z" color="#8AA8E4"><one:Page ID="{1906471E-0D0A-0E51-0A33-6158092860A4}{1}{B0}" name="Kept" dateTime="2026-09-28T08:04:50.000Z" lastModifiedTime="2026-09-28T08:06:34.000Z" pageLevel="1"/></one:Section><one:SectionGroup name="OneNote_RecycleBin" ID="{E3457349-0965-4F8B-A86B-E5ED5EFA53F0}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin" lastModifiedTime="2026-09-28T08:06:34.000Z" isRecycleBin="true"><one:Section name="Deleted Pages" ID="{CA06A3F3-B5E8-03CC-20A3-828848B29E61}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin\OneNote_DeletedPages.one" lastModifiedTime="2026-09-28T08:06:34.000Z" color="#E1E1E1" isInRecycleBin="true" isDeletedPages="true"><one:Page ID="{AFFE36A9-90C2-0C90-25EC-A4F7606E5EB1}{1}{B0}" name="Kept" dateTime="2026-09-28T08:04:50.000Z" lastModifiedTime="2026-09-28T08:06:34.000Z" pageLevel="1" isInRecycleBin="true"/><one:Page ID="{C9906D9C-6FE2-085A-2DB6-0405237AB185}{1}{B0}" name="Kept" dateTime="2026-09-28T08:04:50.000Z" lastModifiedTime="2026-09-28T08:06:34.000Z" pageLevel="1" isInRecycleBin="true"/></one:Section></one:SectionGroup></one:Notebook> diff --git a/corpus/recycle-bin-repair/cold/read/page-000.navigation.json b/corpus/recycle-bin-repair/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..786f5d77169a6a0cc6a55ecc374de91d8d836905 --- /dev/null +++ b/corpus/recycle-bin-repair/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Kept - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-repair/cold/read/page-000.png b/corpus/recycle-bin-repair/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..5b9ad9c19c1823add8aa3eff426c2060c2735c7d Binary files /dev/null and b/corpus/recycle-bin-repair/cold/read/page-000.png differ diff --git a/corpus/recycle-bin-repair/cold/read/page-000.xml b/corpus/recycle-bin-repair/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..1b5c93854ce8aa5788dcfad8f2f5923a6b5adf75 --- /dev/null +++ b/corpus/recycle-bin-repair/cold/read/page-000.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{1906471E-0D0A-0E51-0A33-6158092860A4}{1}{B0}" name="Kept" dateTime="2026-09-28T08:04:50.000Z" lastModifiedTime="2026-09-28T08:06:34.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-09-28T08:04:50.000Z" lastModifiedTime="2026-09-28T08:04:50.000Z" objectID="{1906471E-0D0A-0E51-0A33-6158092860A4}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Kept]]></one:T></one:OE></one:Title></one:Page> diff --git a/corpus/recycle-bin-repair/cold/read/page-001.navigation.json b/corpus/recycle-bin-repair/cold/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..bc5677d217a10b292f8414ec8d35fa319785cdef --- /dev/null +++ b/corpus/recycle-bin-repair/cold/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Kept (Read-Only - Recycle Bin) - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-repair/cold/read/page-001.png b/corpus/recycle-bin-repair/cold/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..859be1d0c81ce3c781cbb572088785d94aac1b12 Binary files /dev/null and b/corpus/recycle-bin-repair/cold/read/page-001.png differ diff --git a/corpus/recycle-bin-repair/cold/read/page-001.xml b/corpus/recycle-bin-repair/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..febef77d432f50a4013902bb88c7527d4a915f0a --- /dev/null +++ b/corpus/recycle-bin-repair/cold/read/page-001.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{AFFE36A9-90C2-0C90-25EC-A4F7606E5EB1}{1}{B0}" name="Kept" dateTime="2026-09-28T08:04:50.000Z" lastModifiedTime="2026-09-28T08:06:34.000Z" pageLevel="1" isInRecycleBin="true"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-09-28T08:04:50.000Z" lastModifiedTime="2026-09-28T08:04:50.000Z" objectID="{AFFE36A9-90C2-0C90-25EC-A4F7606E5EB1}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Kept]]></one:T></one:OE></one:Title></one:Page> diff --git a/corpus/recycle-bin-repair/cold/read/page-002.navigation.json b/corpus/recycle-bin-repair/cold/read/page-002.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..bc5677d217a10b292f8414ec8d35fa319785cdef --- /dev/null +++ b/corpus/recycle-bin-repair/cold/read/page-002.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Kept (Read-Only - Recycle Bin) - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-repair/cold/read/page-002.png b/corpus/recycle-bin-repair/cold/read/page-002.png new file mode 100644 index 0000000000000000000000000000000000000000..05c3f960e7beae67f00c62b619a424c85da573bd Binary files /dev/null and b/corpus/recycle-bin-repair/cold/read/page-002.png differ diff --git a/corpus/recycle-bin-repair/cold/read/page-002.xml b/corpus/recycle-bin-repair/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..b19f00e1f974098e9723bac227646441bccdfa31 --- /dev/null +++ b/corpus/recycle-bin-repair/cold/read/page-002.xml @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{C9906D9C-6FE2-085A-2DB6-0405237AB185}{1}{B0}" name="Kept" dateTime="2026-09-28T08:04:50.000Z" lastModifiedTime="2026-09-28T08:06:34.000Z" pageLevel="1" isInRecycleBin="true"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-09-28T08:04:50.000Z" lastModifiedTime="2026-09-28T08:04:50.000Z" objectID="{C9906D9C-6FE2-085A-2DB6-0405237AB185}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Kept]]></one:T></one:OE></one:Title></one:Page> diff --git a/corpus/recycle-bin-repair/cold/read/payloads.json b/corpus/recycle-bin-repair/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/recycle-bin-repair/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/recycle-bin-repair/cold/run.json b/corpus/recycle-bin-repair/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..58ade1d8dc9caddccaeb04004380c2a9d7385885 --- /dev/null +++ b/corpus/recycle-bin-repair/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/recycle-gate/candidate", + "expected_pages": -1, + "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/recycle-bin-repair/cold/scripts/cold.ps1 b/corpus/recycle-bin-repair/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/recycle-bin-repair/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/recycle-bin-repair/cold/scripts/read.ps1 b/corpus/recycle-bin-repair/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/recycle-bin-repair/cold/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/recycle-bin-repair/cold/source.json b/corpus/recycle-bin-repair/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..b2969534441cf94c813941858b71dc3f06bfee34 --- /dev/null +++ b/corpus/recycle-bin-repair/cold/source.json @@ -0,0 +1,26 @@ +[ + { + "path": "New Section 1.one", + "bytes": 6024, + "sha256": "b1444e361cef2e5f0d716ae021ad42b051129ec9ac32c36b54f4d4c7ddfc3f84", + "mtime_ns": 1790582690747351251 + }, + { + "path": "OneNote_RecycleBin/OneNote Table Of Contents.onetoc2", + "bytes": 3848, + "sha256": "b0e0f0928773e1fe41fac3e0377162201083a86e789a193ba0977a03cf043808", + "mtime_ns": 1790582691261467141 + }, + { + "path": "OneNote_RecycleBin/OneNote_DeletedPages.one", + "bytes": 8352, + "sha256": "05b75f0780567db72cc70dc8277fe8a947d6edc5f6bb51bea76f488669d6cdd0", + "mtime_ns": 1790582691300920689 + }, + { + "path": "Open Notebook.onetoc2", + "bytes": 5456, + "sha256": "f282959ea3de40d2460fe941ade4b7ab14f22e569530056bf7690f041b0118c6", + "mtime_ns": 1790582691234269352 + } +] diff --git a/corpus/recycle-bin-repair/cold/teardown.json b/corpus/recycle-bin-repair/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/recycle-bin-repair/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 76681b41152cdfc807b5cbe3a8587ac9e9e3190c..49d75c26c3f7cbed4de98cff537b0790f7e08fe5 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -226,7 +226,10 @@ Reserved `_onefiles` directories are excluded from section-group traversal; OneNote's `OneNote_RecycleBin` is listed as the section group OneNote shows. Encrypted sections and valid storage with an unreadable document graph retain their identity as `Locked` or `Unreadable`, without being presented as empty pages. -Malformed storage, failed reads and ambiguous identities reject the discovery. +A child folder or section file that is denied or gone while listing (a folder +another client holds delete-pending reads as denied) is kept as `unavailable` and +tried again on the next discovery; the root itself, malformed storage, other failed +reads and ambiguous identities reject the discovery. Each file read must be a consistent, bounded snapshot. The result is an observation across multiple files, not an atomic notebook transaction or @@ -252,7 +255,12 @@ section moves into `OneNote_RecycleBin`, a group with its own TOC, and a deleted sections move there too before its folders go. `recycle_pages` keeps copies of pages a section is about to delete in `OneNote_RecycleBin/OneNote_DeletedPages.one`, with their identities, titles, dates and creation times, as OneNote does -(`corpus/notebook-management`). Every created, renamed or moved file is placed with +(`corpus/notebook-management`). A bin or deleted-pages file its TOC does not list is +placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an +unavailable bin refuses the delete (`corpus/recycle-bin-repair`). An entry a TOC still +lists for a file gone from its folder gives way to the file an edit gives its name. +Edits keep entries in the order their ordering numbers give; a rename or colour keeps +the numbers. Every created, renamed or moved file is placed with `onestore::place_file`, which sets the header's ancestor to the parent TOC's identity and the name CRC OneNote checks on open; a file without them is re-identified and listed anew. They run over `Storage`: a mounted directory diff --git a/crates/notebook/examples/migrate_probe.rs b/crates/notebook/examples/migrate_probe.rs index fa449976cf8984da3df03528e0f9076106b1bc73..de3e868e7a9354777fc31d571a12d5b40fc7c4e0 100644 --- a/crates/notebook/examples/migrate_probe.rs +++ b/crates/notebook/examples/migrate_probe.rs @@ -95,8 +95,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { // The remote the queue was made on, as a recovery archive records it. let archive = copy.with_extension("probe-recovery"); replica.export_recovery(&archive)?; - let mut remote = - Memory(notebook::Recovery::open(&archive)?.remote_snapshot()?); + let mut remote = Memory(notebook::Recovery::open(&archive)?.remote_snapshot()?); std::fs::remove_file(&archive)?; let synced = replica.sync_once(&mut remote)?; let published = { diff --git a/crates/notebook/examples/queue_scale.rs b/crates/notebook/examples/queue_scale.rs index 28833b139bc2dbd89b4429e5a73f9aa2ce4f2d10..5f34160e71567e95a0b06292539eb2af97bd74f6 100644 --- a/crates/notebook/examples/queue_scale.rs +++ b/crates/notebook/examples/queue_scale.rs @@ -36,9 +36,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { let arena = Arena::default(); let mut section = Section::open(&arena, source)?; let ops = vec![Op::Section(SectionOp::Create(creation))]; - section.apply("Fixture", &Edit { at: 133_000_000_000_000_000, ops })?; + section.apply( + "Fixture", + &Edit { + at: 133_000_000_000_000_000, + ops, + }, + )?; section.seal()?; - let pages: Vec<ExGuid> = section.pages()?.into_iter().map(|(space, ..)| space).collect(); + let pages: Vec<ExGuid> = section + .pages()? + .into_iter() + .map(|(space, ..)| space) + .collect(); (section.image(), pages) }; assert_eq!(pages.len(), 2); diff --git a/crates/notebook/examples/smb_offline_client.rs b/crates/notebook/examples/smb_offline_client.rs index dbf7ceeae4fe935f5bb7c558ffbca12d6b1de756..f3e4248b3d95c14b2ca369093c0721117330615c 100644 --- a/crates/notebook/examples/smb_offline_client.rs +++ b/crates/notebook/examples/smb_offline_client.rs @@ -404,10 +404,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { let at = u32::try_from(text.encode_utf16().count())?; let token = format!(" [{}:{}]", args[2], generated); let started = now(); - match cache.apply( - "Offline document writer", - append(space, object, at, &token), - ) { + match cache.apply("Offline document writer", append(space, object, at, &token)) { Ok(id) => { println!( "{}", @@ -432,7 +429,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { .collect(); let mut at = u32::try_from(text.encode_utf16().count())?; for token in &missing { - ids.push(cache.apply("Offline document writer", append(space, object, at, token))?); + ids.push( + cache.apply("Offline document writer", append(space, object, at, token))?, + ); at += u32::try_from(token.encode_utf16().count())?; } if !missing.is_empty() { diff --git a/crates/notebook/examples/support/view.rs b/crates/notebook/examples/support/view.rs index 96977f14d8ccb470793b81099e4be8aeff8c4785..089613898926fb4f86049d053f8182dee21aa4aa 100644 --- a/crates/notebook/examples/support/view.rs +++ b/crates/notebook/examples/support/view.rs @@ -76,7 +76,14 @@ fn in_page(page: &onestore::page::Page) -> Option<(ExGuid, String)> { return Some((text.id, text.text.text().to_owned())); } if let onestore::page::ParagraphContent::Table(table) = ¶graph.content { - pending.extend(table.rows.iter().flat_map(|row| &row.cells).flat_map(|cell| &cell.paragraphs).rev()); + pending.extend( + table + .rows + .iter() + .flat_map(|row| &row.cells) + .flat_map(|cell| &cell.paragraphs) + .rev(), + ); } } None @@ -84,7 +91,9 @@ fn in_page(page: &onestore::page::Page) -> Option<(ExGuid, String)> { /// The concurrent-edit paragraph as the queued edits leave it: its page, text object and /// text. -pub fn cached(cache: ¬ebook::Replica) -> Result<(ExGuid, ExGuid, String), Box<dyn std::error::Error>> { +pub fn cached( + cache: ¬ebook::Replica, +) -> Result<(ExGuid, ExGuid, String), Box<dyn std::error::Error>> { let mut found = Vec::new(); for (space, ..) in cache.pages()? { if let Some((object, text)) = in_page(&cache.page(space)?) { diff --git a/crates/notebook/src/discover.rs b/crates/notebook/src/discover.rs index 7ce65d12fa5f25355f2c6bbacaa6136a35bba402..60a4e136f50d9c3ad2abc4dcc3e23a5ab8449756 100644 --- a/crates/notebook/src/discover.rs +++ b/crates/notebook/src/discover.rs @@ -41,6 +41,16 @@ pub struct Folder { pub toc: Option<Toc>, pub sections: Vec<Section>, pub groups: Vec<Folder>, + /// Child section files and groups that could not be read. + pub unavailable: Vec<Unavailable>, +} + +/// A section file or group folder denied or gone while listing; each discovery tries it again. +#[derive(Debug, Serialize)] +pub struct Unavailable { + pub path: String, + pub group: bool, + pub error: String, } #[derive(Debug, Serialize)] @@ -196,6 +206,7 @@ fn scan( toc: None, sections: Vec::new(), groups: Vec::new(), + unavailable: Vec::new(), }; for entry in &listing { let child = join(path, &entry.name); @@ -203,14 +214,18 @@ fn scan( if entry.name.to_ascii_lowercase().ends_with("_onefiles") { continue; } - result.groups.push(scan( - source, - &child, - limits, - depth + 1, - remaining, - identities, - )?); + // A group whose own listing or TOC cannot be read is unavailable, not fatal. + match scan(source, &child, limits, depth + 1, remaining, identities) { + Ok(group) => result.groups.push(group), + Err(error @ Error::Io { .. }) if unavailable(&error) => { + result.unavailable.push(Unavailable { + path: child, + group: true, + error: error.to_string(), + }); + } + Err(error) => return Err(error), + } continue; } let lower = entry.name.to_ascii_lowercase(); @@ -226,12 +241,24 @@ fn scan( { return Err(Error::Entry { path: child }); } - let bytes = source - .read(&child, limits.bytes_per_file) - .map_err(|error| Error::Io { - path: child.clone(), - error, - })?; + let bytes = match source.read(&child, limits.bytes_per_file) { + Ok(bytes) => bytes, + Err(error) => { + let error = Error::Io { + path: child.clone(), + error, + }; + if expected == FileType::Section && unavailable(&error) { + result.unavailable.push(Unavailable { + path: child, + group: false, + error: error.to_string(), + }); + continue; + } + return Err(error); + } + }; if bytes.len() > limits.bytes_per_file { return Err(Error::Limit { path: child }); } @@ -404,6 +431,14 @@ fn scan( Ok(result) } +/// Access denied or a file gone mid-listing; a lost connection stays fatal. +fn unavailable(error: &Error) -> bool { + matches!(error, Error::Io { error, .. } if matches!( + error.kind(), + io::ErrorKind::PermissionDenied | io::ErrorKind::NotFound + )) +} + fn component(name: &str) -> bool { !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) } diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index 4456baadbf222a8b4ae28e3c6e6907bf5ec88a94..fe6db93334d5e794ee2dea17df25dd97b65d941e 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -243,6 +243,20 @@ impl Replica { self.ask(|reply| working::Request::Conflicts { reply }) } + /// The versions of each page that has them (`onestore::Section::versions`). + pub fn versions(&self) -> Result<Vec<(ExGuid, Vec<onestore::PageVersion>)>> { + self.ask(|reply| working::Request::Versions { reply }) + } + + /// A page as one of its versions holds it; O(section). + pub fn version(&self, space: ExGuid, version: ExGuid) -> Result<Page> { + self.ask(|reply| working::Request::Version { + space, + version, + reply, + }) + } + /// The section image the queued edits leave, the unsealed ones sealed as one more /// revision whose identities differ per call: O(section). pub(crate) fn snapshot(&self) -> Result<Vec<u8>> { diff --git a/crates/notebook/src/merge.rs b/crates/notebook/src/merge.rs index 038402e7b264f2ebe92912cb17b499cba47b858b..c817e8945c7ecef4b8f809d6c58960cfd9ec6782 100644 --- a/crates/notebook/src/merge.rs +++ b/crates/notebook/src/merge.rs @@ -156,7 +156,9 @@ fn named(op: &PageOp) -> Vec<ExGuid> { PageOp::Color(_) => Vec::new(), PageOp::Join { left, right } => vec![*left, *right], PageOp::Insert { container, .. } => vec![*container], - PageOp::Move { object, .. } | PageOp::Delete { object } | PageOp::Outline { object, .. } => { + PageOp::Move { object, .. } + | PageOp::Delete { object } + | PageOp::Outline { object, .. } => { vec![*object] } PageOp::Level { paragraph, .. } @@ -165,7 +167,9 @@ fn named(op: &PageOp) -> Vec<ExGuid> { | PageOp::List { paragraph, .. } => vec![*paragraph], PageOp::Tags { target, .. } => vec![*target], PageOp::Add { object, .. } => vec![object.id()], - PageOp::Picture { picture: object, .. } + PageOp::Picture { + picture: object, .. + } | PageOp::Attachment { attachment: object, .. } @@ -196,7 +200,8 @@ pub(crate) fn conflict_page( at: u64, ) -> Result<Edit> { let mut page = page.clone(); - page.objects.retain(|object| !matches!(object, PageObject::Unsupported(_))); + page.objects + .retain(|object| !matches!(object, PageObject::Unsupported(_))); let listed = order(new)?; let ops = if listed.iter().any(|(listed, _)| *listed == space) { let index = Index::of(&page); @@ -224,9 +229,7 @@ pub(crate) fn conflict_page( .map_or(&[][..], |at| &queued[at + 1..]) .iter() .map(|(next, _)| *next) - .find(|next| { - !moved.contains(next) && listed.iter().any(|(listed, _)| listed == next) - }); + .find(|next| !moved.contains(next) && listed.iter().any(|(listed, _)| listed == next)); let creation = PageCreation::new(before, Some(&page.title), author)?; let mut ops = vec![Op::Section(SectionOp::Import { creation: creation.clone(), @@ -260,7 +263,8 @@ fn moved(old: &mut Section<'_>, new: &mut Section<'_>) -> Result<BTreeSet<ExGuid .iter() .filter_map(|&(space, level)| { let (at, before) = base.get(&space)?; - (*before == level && series.0.get(&space) == series.1.get(&space)).then_some((space, *at)) + (*before == level && series.0.get(&space) == series.1.get(&space)) + .then_some((space, *at)) }) .collect(); // A longest subsequence of `kept` in base order (patience sorting). @@ -313,7 +317,10 @@ fn advance(local: &mut Order, op: &SectionOp) { } } SectionOp::Delete(spaces) => local.retain(|(space, _)| !spaces.contains(space)), - SectionOp::Conflict { .. } | SectionOp::Color(_) => {} + SectionOp::Conflict { .. } + | SectionOp::Color(_) + | SectionOp::RestoreVersion { .. } + | SectionOp::DeleteVersions { .. } => {} } } @@ -411,10 +418,18 @@ impl Replay { /// from `before` on, in the queue's order, that the remote lists where the base had it, /// or last, as OneNote 2010 places a page series only one side changed. fn anchor(&self, listed: &Order, page: ExGuid, before: Option<ExGuid>) -> Option<ExGuid> { - let from = self.local.iter().position(|(space, _)| Some(*space) == before)?; - self.local[from..].iter().map(|(space, _)| *space).find(|space| { - *space != page && !self.moved.contains(space) && listed.iter().any(|(listed, _)| listed == space) - }) + let from = self + .local + .iter() + .position(|(space, _)| Some(*space) == before)?; + self.local[from..] + .iter() + .map(|(space, _)| *space) + .find(|space| { + *space != page + && !self.moved.contains(space) + && listed.iter().any(|(listed, _)| listed == space) + }) } /// A section op as it replays on the remote section, or `None`: a page placed by @@ -467,6 +482,23 @@ impl Replay { } (!kept.is_empty()).then_some(SectionOp::Pages(kept)) } + // A version the remote deleted is gone; restoring one keeps the remote's page as + // the newest version. + SectionOp::RestoreVersion { page, version, .. } => (self.listed(*page) + && listed_versions(new, *page)?.contains(version)) + .then(|| op.clone()), + SectionOp::DeleteVersions { page, versions } => { + let listed = listed_versions(new, *page)?; + let versions: Vec<ExGuid> = versions + .iter() + .filter(|version| listed.contains(version)) + .copied() + .collect(); + (!versions.is_empty()).then_some(SectionOp::DeleteVersions { + page: *page, + versions, + }) + } SectionOp::Delete(deleted) => { let deleted: Vec<ExGuid> = deleted .iter() @@ -482,6 +514,16 @@ impl Replay { } } +/// The versions `section` lists for `page`. +fn listed_versions(section: &mut Section<'_>, page: ExGuid) -> Result<Vec<ExGuid>> { + Ok(section + .versions()? + .into_iter() + .filter(|(listed, _)| *listed == page) + .flat_map(|(_, versions)| versions.into_iter().map(|version| version.context)) + .collect()) +} + /// One page as the base stored it and as the remote does, with the remote's text changes /// in the coordinates of the replayed edits. struct Diff { diff --git a/crates/notebook/src/migrate.rs b/crates/notebook/src/migrate.rs index 6b139418ac872ac7f2b224a24230a8a13ac09fc3..023a6dd8deeb773f3debd54d840cb5ca92465aa8 100644 --- a/crates/notebook/src/migrate.rs +++ b/crates/notebook/src/migrate.rs @@ -116,9 +116,12 @@ fn conflict(space: ExGuid, remote: &Page, local: &Page, author: &str) -> Result< for object in &page.objects { match object { onestore::page::PageObject::Outline(outline) => pending.extend(&outline.paragraphs), - onestore::page::PageObject::Title(title) => { - pending.extend(title.outlines.iter().flat_map(|outline| &outline.paragraphs)) - } + onestore::page::PageObject::Title(title) => pending.extend( + title + .outlines + .iter() + .flat_map(|outline| &outline.paragraphs), + ), _ => {} } } @@ -128,7 +131,11 @@ fn conflict(space: ExGuid, remote: &Page, local: &Page, author: &str) -> Result< texts.insert(text.id, format!("{:?}", text.text)); } onestore::page::ParagraphContent::Table(table) => pending.extend( - table.rows.iter().flat_map(|row| &row.cells).flat_map(|cell| &cell.paragraphs), + table + .rows + .iter() + .flat_map(|row| &row.cells) + .flat_map(|cell| &cell.paragraphs), ), _ => {} } diff --git a/crates/notebook/src/queue.rs b/crates/notebook/src/queue.rs index c4c9c89b5528b285f766a886c53a50780d3b996b..6fbc98dbfe04b3875876b212145e127089135617 100644 --- a/crates/notebook/src/queue.rs +++ b/crates/notebook/src/queue.rs @@ -240,6 +240,8 @@ pub(crate) fn spaces(edit: &Edit, root: onestore::ExGuid) -> Vec<onestore::ExGui } SectionOp::Pages(edits) => spaces.extend(edits.iter().map(|edit| edit.space())), SectionOp::Delete(pages) => spaces.extend(pages), + SectionOp::RestoreVersion { page, .. } + | SectionOp::DeleteVersions { page, .. } => spaces.push(*page), SectionOp::Color(_) => {} } } diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index cb6e11014221b7f621f9c1997f113305b993c132..1676e109cf9d78e8dd2f60168555bd97e5778ecf 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -2,8 +2,7 @@ //! publishes their edits to the section file in the background. use crate::{ - EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, - SyncWorker, discover, + EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, }; use onestore::{ CommitError, ExGuid, PageCreation, RevisionIndex, Stamp, Store, Transaction, @@ -388,9 +387,37 @@ impl Notebook { self.commit_toc(&toc, edits) } + /// Commits `edits` to the TOC at `toc`. A name it still lists for a file gone from its + /// folder passes to the file an edit gives that name; the stale entry goes. fn commit_toc(&self, toc: &str, edits: &[onestore::TocEdit]) -> Result<()> { + let unresolved = self + .folder(split(toc).0) + .ok() + .and_then(|folder| folder.toc.as_ref()) + .map_or(&[][..], |toc| &toc.unresolved[..]); + let mut superseded = Vec::new(); + for edit in edits { + if let onestore::TocEdit::Add { filename, .. } + | onestore::TocEdit::Rename { filename, .. } = edit + { + superseded.extend( + unresolved + .iter() + .filter(|entry| { + entry + .filename + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(filename)) + }) + .map(|entry| onestore::TocEdit::Remove { + identity: entry.file, + }), + ); + } + superseded.push(edit.clone()); + } let source = self.storage.read(toc)?; - match onestore::edit_table_of_contents(&source, edits)? { + match onestore::edit_table_of_contents(&source, &superseded)? { Some(transaction) => self.storage.commit(toc, &transaction), None => Ok(()), } @@ -399,7 +426,12 @@ impl Notebook { /// Creates `name.one` in `folder` holding the page `page` creates, in the next of /// OneNote's section colours, and lists it last in the folder's TOC, as OneNote creates /// a section. Returns the new catalog path. - pub fn create_section(&mut self, folder: &str, name: &str, page: &PageCreation) -> Result<String> { + pub fn create_section( + &mut self, + folder: &str, + name: &str, + page: &PageCreation, + ) -> Result<String> { let filename = format!("{name}.one"); let color = next_color(self.folder(folder)?); let (_, ancestor) = self.toc(folder)?; @@ -541,13 +573,24 @@ impl Notebook { let mut folders = Vec::new(); let mut pending = vec![self.folder(path)?]; while let Some(group) = pending.pop() { + // Its folder could not be emptied, so nothing moves. + if let Some(entry) = group.unavailable.first() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + entry.error.clone(), + ) + .into()); + } sections.extend( group .sections .iter() .map(|section| (section.path.clone(), section.file_id)), ); - folders.push((group.path.clone(), group.toc.as_ref().map(|toc| toc.filename.clone()))); + folders.push(( + group.path.clone(), + group.toc.as_ref().map(|toc| toc.filename.clone()), + )); pending.extend(&group.groups); } for (section, identity) in sections { @@ -566,28 +609,50 @@ impl Notebook { } /// The recycle bin's TOC path and file identity, creating the bin as OneNote does: a - /// section group the root TOC lists. + /// section group the root TOC lists. A bin the root TOC misses is listed again. fn bin(&self) -> Result<(String, [u8; 16])> { - let bin_toc = catalog_path(RECYCLE_BIN, TOC); - if !self.storage.exists(&bin_toc) { - if !self.storage.exists(RECYCLE_BIN) { - self.storage.create_directory(RECYCLE_BIN)?; + if let Some(entry) = self + .catalog + .unavailable + .iter() + .find(|entry| entry.path == RECYCLE_BIN) + { + return Err( + io::Error::new(io::ErrorKind::PermissionDenied, entry.error.clone()).into(), + ); + } + // A bin OneNote made may name its TOC otherwise; a second TOC would hide the folder. + let bin_toc = match self.folder(RECYCLE_BIN) { + Ok(discover::Folder { toc: Some(toc), .. }) => catalog_path(RECYCLE_BIN, &toc.filename), + _ => { + let bin_toc = catalog_path(RECYCLE_BIN, TOC); + if !self.storage.exists(&bin_toc) { + if !self.storage.exists(RECYCLE_BIN) { + self.storage.create_directory(RECYCLE_BIN)?; + } + let bytes = onestore::create_table_of_contents(TOC, &[])?; + self.storage.create(&bin_toc, &bytes)?; + } + bin_toc } - let bytes = onestore::create_table_of_contents(TOC, &[])?; - self.storage.create(&bin_toc, &bytes)?; - self.storage.place(&bin_toc, self.toc("")?.1, RECYCLE_BIN)?; - let bin_identity = onestore::Store::parse(&bytes)?.header.file_id; - self.edit_toc( - "", + }; + let identity = onestore::Store::parse(&self.storage.read(&bin_toc)?)? + .header + .file_id; + let (root, root_identity) = self.toc("")?; + // OneNote takes a TOC placed under another parent for a new one. + if !lists(&self.storage.read(&root)?, identity)? { + self.storage.place(&bin_toc, root_identity, RECYCLE_BIN)?; + self.commit_toc( + &root, &[onestore::TocEdit::Add { filename: RECYCLE_BIN.into(), - identity: bin_identity, + identity, group: true, }], )?; } - let source = self.storage.read(&bin_toc)?; - Ok((bin_toc, onestore::Store::parse(&source)?.header.file_id)) + Ok((bin_toc, identity)) } /// Moves the section file at `path` into the recycle bin, under a name no binned @@ -626,23 +691,35 @@ impl Notebook { // OneNote's "Deleted Pages" section is grey. let bytes = onestore::create_empty_section(DELETED, Some(0x00e1e1e1))?; self.storage.create(&path, &bytes)?; + } + let mut bytes = self.storage.read(&path)?; + // Listed and placed too when an earlier delete made the file but not its entry. + let identity = onestore::Store::parse(&bytes)?.header.file_id; + if !lists(&self.storage.read(&bin_toc)?, identity)? { self.storage.place(&path, bin_identity, DELETED)?; self.commit_toc( &bin_toc, &[onestore::TocEdit::Add { filename: DELETED.into(), - identity: onestore::Store::parse(&bytes)?.header.file_id, + identity, group: false, }], )?; + bytes = self.storage.read(&path)?; } let arena = onestore::Arena::default(); - let mut section = onestore::Section::open(&arena, self.storage.read(&path)?)?; + let mut section = onestore::Section::open(&arena, bytes)?; let mut ops = Vec::new(); for page in pages { ops.push(moved(page, author)?); } - section.apply(author, &Edit { at: crate::now(), ops })?; + section.apply( + author, + &Edit { + at: crate::now(), + ops, + }, + )?; if let Some(transaction) = section.seal()? { self.storage.commit(&path, &transaction)?; } @@ -866,9 +943,8 @@ pub fn stored_pages(image: &[u8]) -> Result<Vec<StoredPage>> { /// order it gives them (`corpus/notebook-management/native/section-colors`: sixteen sections /// made with New Section in a new notebook). const SECTION_COLORS: [u32; 16] = [ - 0x00e4a88a, 0x0078b0f6, 0x00bba4d5, 0x00d2bb9b, 0x00b79cab, 0x0099d1e8, 0x006ff9f5, - 0x0092e7ad, 0x00cabc4d, 0x007575ba, 0x00aa9595, 0x00e4a88a, 0x0069d8ff, 0x0097c9b7, - 0x009795ee, 0x00de9eb4, + 0x00e4a88a, 0x0078b0f6, 0x00bba4d5, 0x00d2bb9b, 0x00b79cab, 0x0099d1e8, 0x006ff9f5, 0x0092e7ad, + 0x00cabc4d, 0x007575ba, 0x00aa9595, 0x00e4a88a, 0x0069d8ff, 0x0097c9b7, 0x009795ee, 0x00de9eb4, ]; /// The colour OneNote gives a new section in `folder`, by how many sections it holds. @@ -897,6 +973,28 @@ fn component(name: &str) -> bool { !name.is_empty() && !name.contains(['/', '\\', '\0']) && name != "." && name != ".." } +/// Whether the TOC `image` holds has an entry for the file identity `file`. +fn lists(image: &[u8], file: [u8; 16]) -> Result<bool> { + let store = Store::parse(image)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + let revision = document.active(document.root)?; + let entries = revision + .roots + .get(&1) + .and_then(|id| revision.nodes.get(id)) + .map_or(&[][..], |node| match &node.kind { + onestore::document::Kind::Toc { entries, .. } => &entries[..], + _ => &[], + }); + Ok(entries.iter().any(|id| { + matches!( + revision.nodes.get(id).map(|node| &node.kind), + Some(onestore::document::Kind::Toc { identity: Some(identity), .. }) if *identity == file + ) + })) +} + fn split(path: &str) -> (&str, &str) { match path.rsplit_once('/') { Some((folder, name)) => (folder, name), @@ -1167,6 +1265,48 @@ impl Section { self.replica.conflicts() } + /// The versions of each page that has them, newest first, as the local edits leave them + /// (`onestore::Section::versions`); `version` reads one. + pub fn versions(&self) -> Result<Vec<(ExGuid, Vec<onestore::PageVersion>)>> { + self.replica.versions() + } + + /// Page `space` as its version `version` holds it; O(section). + pub fn version(&self, space: ExGuid, version: ExGuid) -> Result<Page> { + self.replica.version(space, version) + } + + /// Makes a page's version its current state, the page as it stood becoming the newest + /// version, queued like the user's own edits. + pub fn restore_version(&self, space: ExGuid, version: ExGuid, author: &str) -> Result<u64> { + self.replica.apply( + author, + Edit { + at: crate::now(), + ops: vec![Op::Section(SectionOp::restore(space, version)?)], + }, + ) + } + + /// Deletes versions of pages, queued like the user's own edits. + pub fn delete_versions(&self, versions: &[(ExGuid, Vec<ExGuid>)]) -> Result<u64> { + self.replica.apply( + "", + Edit { + at: crate::now(), + ops: versions + .iter() + .map(|(page, versions)| { + Op::Section(SectionOp::DeleteVersions { + page: *page, + versions: versions.clone(), + }) + }) + .collect(), + }, + ) + } + /// Retires an uncertain attempt after review, exporting the queue to `archive` first: /// `Mine` publishes the local edits again, `Theirs` abandons them. Neither claims the /// attempt was acknowledged. diff --git a/crates/notebook/src/smb/mod.rs b/crates/notebook/src/smb/mod.rs index 051352e80331ccf33c6ac38f4967b4937fc6f773..e36caca0c418a46e03619c97372fd56e3b218c86 100644 --- a/crates/notebook/src/smb/mod.rs +++ b/crates/notebook/src/smb/mod.rs @@ -206,7 +206,8 @@ impl Client { 0xc0000043 | 0xc0000054 | 0xc0000055 => io::ErrorKind::WouldBlock, 0xc0000011 => io::ErrorKind::UnexpectedEof, 0xc0000034 | 0xc000003a => io::ErrorKind::NotFound, - 0xc0000022 => io::ErrorKind::PermissionDenied, + // Windows reports a delete-pending file as access denied too. + 0xc0000022 | 0xc0000056 => io::ErrorKind::PermissionDenied, 0xc0000103 => io::ErrorKind::NotADirectory, _ => io::ErrorKind::Other, }; diff --git a/crates/notebook/src/smb/tests.rs b/crates/notebook/src/smb/tests.rs index af3c34d20f8ccbfbe66741337843cd11284932df..74340bd1f7b7fa7a5ce4b7de2c78e69834b3b12d 100644 --- a/crates/notebook/src/smb/tests.rs +++ b/crates/notebook/src/smb/tests.rs @@ -609,13 +609,23 @@ fn live_structure() { let mut notebook = Notebook::open_smb(std::sync::Arc::clone(&client), &root, cache.path()).unwrap(); assert_eq!( - notebook.create_section("", "Second", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()).unwrap(), + notebook + .create_section( + "", + "Second", + &onestore::PageCreation::new(None, Some(""), "Author").unwrap() + ) + .unwrap(), "Second.one" ); assert_eq!(notebook.create_group("", "Archive").unwrap(), "Archive"); assert_eq!( notebook - .create_section("Archive", "Inner", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()) + .create_section( + "Archive", + "Inner", + &onestore::PageCreation::new(None, Some(""), "Author").unwrap() + ) .unwrap(), "Archive/Inner.one" ); diff --git a/crates/notebook/src/sync.rs b/crates/notebook/src/sync.rs index e23cca5ce9edd84e1b1f37fabd4cb8ab1a192088..0f1b9f42e3e39d30d0f41500640ae93e3124508f 100644 --- a/crates/notebook/src/sync.rs +++ b/crates/notebook/src/sync.rs @@ -341,10 +341,19 @@ impl Replica { if let Some(sealed) = sealed { local.replay(sealed)?; } - let remote = onestore::Section::open(&remote, image.to_vec())?; + let mut remote = onestore::Section::open(&remote, image.to_vec())?; + // A page's versions change without its page changing. + let versions = |section: &mut onestore::Section<'_>| { + section.versions().map(|versions| { + versions + .into_iter() + .filter(|(page, _)| revisions.contains_key(page)) + .collect::<Vec<_>>() + }) + }; Ok(revisions.keys().all( |space| matches!((local.page(*space), remote.page(*space)), (Ok(a), Ok(b)) if a == b), - )) + ) && versions(&mut local)? == versions(&mut remote)?) } /// Asks the section thread to replay the queue on `image`, returning the pages the diff --git a/crates/notebook/src/working.rs b/crates/notebook/src/working.rs index bdefba27e3d473001d2e2b802c64b97c69925b38..a8e76dc2837e3650a0196619649e2b3fa9e38726 100644 --- a/crates/notebook/src/working.rs +++ b/crates/notebook/src/working.rs @@ -3,9 +3,7 @@ //! here and are written in one SQLite transaction per burst; the sync thread asks it to seal //! and, when the remote changed, to rebase the queue. -use crate::{ - Result, base, lock, merge, queue, worker::Signal, -}; +use crate::{Result, base, lock, merge, queue, worker::Signal}; use onestore::{ Arena, ExGuid, Section, Transaction, op::{Edit, OpError}, @@ -37,6 +35,14 @@ pub(crate) enum Request { Conflicts { reply: Reply<Vec<(ExGuid, Vec<onestore::ConflictPage>)>>, }, + Versions { + reply: Reply<Vec<(ExGuid, Vec<onestore::PageVersion>)>>, + }, + Version { + space: ExGuid, + version: ExGuid, + reply: Reply<Page>, + }, /// Answers once the edits before it are written. Flush { reply: Reply<()>, @@ -79,6 +85,8 @@ impl Request { Self::Page { reply, .. } => reply(Err(error())), Self::Pages { reply } => reply(Err(error())), Self::Conflicts { reply } => reply(Err(error())), + Self::Versions { reply } => reply(Err(error())), + Self::Version { reply, .. } => reply(Err(error())), Self::Flush { reply } => reply(Err(error())), Self::Seal { reply } => reply(Err(error())), Self::Rebase { reply, .. } => reply(Err(error())), @@ -321,6 +329,14 @@ impl<'a> Working<'a> { Request::Conflicts { reply } => { reply(self.section.conflicts().map_err(Into::into)) } + Request::Versions { reply } => { + reply(self.section.versions().map_err(Into::into)) + } + Request::Version { + space, + version, + reply, + } => reply(self.section.version(space, version).map_err(Into::into)), Request::Handover(to) => { let mut waiting = held.take().unwrap_or_default(); waiting.extend(burst.drain(..).chain(later.drain(..))); @@ -355,6 +371,18 @@ impl<'a> Working<'a> { reply(self.section.conflicts().map_err(Into::into)); false } + Request::Versions { reply } => { + reply(self.section.versions().map_err(Into::into)); + false + } + Request::Version { + space, + version, + reply, + } => { + reply(self.section.version(space, version).map_err(Into::into)); + false + } request @ (Request::Flush { .. } | Request::Seal { .. }) if !burst.is_empty() => { @@ -390,7 +418,9 @@ impl<'a> Working<'a> { .then(VecDeque::new); false } else { - reply(Err(io::Error::other("The queue could not be written").into())); + reply(Err( + io::Error::other("The queue could not be written").into() + )); true } } @@ -546,7 +576,7 @@ impl<'a> Working<'a> { let root = self.section.root(); let revisions: BTreeMap<ExGuid, ExGuid> = self .section - .revisions() + .newest() .filter(|(space, _)| { self.touched.contains(space) || (self.touched.is_empty() && *space == root) }) @@ -677,7 +707,12 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec< .iter() .filter(|(space, rid)| after.get(space) != Some(rid)) .map(|(space, _)| *space) - .chain(after.keys().filter(|space| !before.contains_key(space)).copied()) + .chain( + after + .keys() + .filter(|space| !before.contains_key(space)) + .copied(), + ) .collect(); changed.extend(converged); Ok(changed.into_iter().collect()) diff --git a/crates/notebook/tests/assets.rs b/crates/notebook/tests/assets.rs index 2c2571d3e7fedbed427957ecd1ed5a4e066c159c..e7d1434f89cfcb0505f625e5056b5defbe18aa53 100644 --- a/crates/notebook/tests/assets.rs +++ b/crates/notebook/tests/assets.rs @@ -118,10 +118,7 @@ fn downloaded_media_survives_reopen_and_recovery_with_the_queue_intact() { (summary.cached_assets, summary.cached_asset_bytes), (2, 1024) ); - assert_eq!( - server::pages(&snapshot(&cache)), - server::pages(&working) - ); + assert_eq!(server::pages(&snapshot(&cache)), server::pages(&working)); assert_eq!(remote_snapshot(&cache), remote); assert_eq!(cache.pending().unwrap(), pending); assert_eq!(cache.status(1).unwrap(), uncertain); diff --git a/crates/notebook/tests/cache.rs b/crates/notebook/tests/cache.rs index c4235dcb0c691298944ae6581fe27905d4af4d99..545ba39de0784f7fe857485181ad01d1b0f5539e 100644 --- a/crates/notebook/tests/cache.rs +++ b/crates/notebook/tests/cache.rs @@ -110,10 +110,7 @@ fn cache_reopen_preserves_the_base_and_queued_edits() { .apply(model_ops::AUTHOR, typed(sid, oid, "Recovered ")) .unwrap(); assert!(second > first); - assert_eq!( - target(&snapshot(&replica)).2, - "Recovered café 🐈 日本語" - ); + assert_eq!(target(&snapshot(&replica)).2, "Recovered café 🐈 日本語"); assert_eq!(replica.pending().unwrap().len(), 2); } diff --git a/crates/notebook/tests/changes.rs b/crates/notebook/tests/changes.rs index 4d86c90b6653093506a1e59c47208b0bc498f8c3..28abf563fb6270cd2b441e43aa3cab4bb5722f38 100644 --- a/crates/notebook/tests/changes.rs +++ b/crates/notebook/tests/changes.rs @@ -24,9 +24,21 @@ fn external_structure_changes_are_reported_by_identity() { let mut other = Notebook::open(&root, temporary.path().join("other")).unwrap(); assert_eq!(watcher.refresh().unwrap(), []); - other.create_section("", "Second", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()).unwrap(); + other + .create_section( + "", + "Second", + &onestore::PageCreation::new(None, Some(""), "Author").unwrap(), + ) + .unwrap(); other.create_group("", "Group").unwrap(); - other.create_section("Group", "Inner", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()).unwrap(); + other + .create_section( + "Group", + "Inner", + &onestore::PageCreation::new(None, Some(""), "Author").unwrap(), + ) + .unwrap(); assert_eq!( watcher.refresh().unwrap(), [ diff --git a/crates/notebook/tests/discovery.rs b/crates/notebook/tests/discovery.rs index f61babffc362c846cda2e541593ec5a9ae584352..bd4d59c3bdf01aca62f291ac8e2bf76c02670fef 100644 --- a/crates/notebook/tests/discovery.rs +++ b/crates/notebook/tests/discovery.rs @@ -278,3 +278,82 @@ fn local_access_stays_inside_the_selected_root() { ); } } + +/// Restores permissions a test took away, so the temporary directory can be removed. +struct Restore(Vec<(std::path::PathBuf, u32)>); + +impl Drop for Restore { + fn drop(&mut self) { + use std::os::unix::fs::PermissionsExt; + for (path, mode) in &self.0 { + let _ = fs::set_permissions(path, fs::Permissions::from_mode(*mode)); + } + } +} + +#[test] +fn unreadable_children_are_listed_as_unavailable_and_retried() { + use std::os::unix::fs::PermissionsExt; + // Permission bits do not bind the superuser. + if unsafe { libc::geteuid() } == 0 { + return; + } + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("notebook"); + fs::create_dir(&root).unwrap(); + fixture(&root); + fs::create_dir(root.join("Art")).unwrap(); + fs::create_dir(root.join("Group")).unwrap(); + for name in ["kept.one", "denied.one"] { + fs::write( + root.join("Group").join(name), + onestore::create_section(name, "Text", "Fixture").unwrap(), + ) + .unwrap(); + } + fs::write( + root.join("Group/Open Notebook.onetoc2"), + onestore::create_table_of_contents("Open Notebook.onetoc2", &[]).unwrap(), + ) + .unwrap(); + let _restore = Restore(vec![ + (root.join("Art"), 0o755), + (root.join("Group/denied.one"), 0o644), + ]); + for path in [root.join("Art"), root.join("Group/denied.one")] { + fs::set_permissions(path, fs::Permissions::from_mode(0o000)).unwrap(); + } + + let mut notebook = + notebook::session::Notebook::open(&root, directory.path().join("cache")).unwrap(); + let catalog = notebook.catalog(); + assert_eq!(catalog.sections.len(), 1); + assert_eq!(catalog.groups.len(), 1); + assert_eq!(catalog.unavailable.len(), 1); + assert_eq!(catalog.unavailable[0].path, "Art"); + assert!(catalog.unavailable[0].group); + assert!(catalog.unavailable[0].error.contains("Permission denied")); + let group = &catalog.groups[0]; + assert_eq!(group.sections.len(), 1); + assert_eq!(group.sections[0].path, "Group/kept.one"); + assert_eq!(group.unavailable[0].path, "Group/denied.one"); + assert!(!group.unavailable[0].group); + + // An unavailable entry is not the catalog's to change, nor a group holding one. + assert!(notebook.delete("Art").is_err()); + assert!(notebook.delete("Group").is_err()); + assert!(root.join("Group/kept.one").exists()); + assert!(notebook.rename("Art", "Other").is_err()); + assert!(notebook.create_group("", "Art").is_err()); + + fs::set_permissions(root.join("Art"), fs::Permissions::from_mode(0o755)).unwrap(); + notebook.refresh().unwrap(); + assert!(notebook.catalog().unavailable.is_empty()); + assert_eq!(notebook.catalog().groups.len(), 2); + + // The notebook's own folder is still the notebook. + fs::set_permissions(&root, fs::Permissions::from_mode(0o000)).unwrap(); + let denied = notebook.refresh(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(denied.is_err()); +} diff --git a/crates/notebook/tests/migrate.rs b/crates/notebook/tests/migrate.rs index 83af0972f9e8d3fa1b5cd6ca70b2dbc7dec78064..c0979a6f434a7c41897e70e6aa64effa36d932f7 100644 --- a/crates/notebook/tests/migrate.rs +++ b/crates/notebook/tests/migrate.rs @@ -191,9 +191,7 @@ fn a_schema_14_queue_converts_to_ops_that_reach_its_working_pages_and_publish() Queued::Page( a, appended( - ops::saved(SOURCE, a, &first) - .unwrap() - .as_slice(), + ops::saved(SOURCE, a, &first).unwrap().as_slice(), a, a_text, " two", @@ -306,10 +304,7 @@ fn an_uncertain_attempt_keeps_its_state_and_a_conflict_becomes_a_conflict_page() .as_slice() .to_vec(); let local = appended(SOURCE, a, a_text, " local"); - let working = ops::saved(SOURCE, a, &local) - .unwrap() - .as_slice() - .to_vec(); + let working = ops::saved(SOURCE, a, &local).unwrap().as_slice().to_vec(); { // Schema 14 stored the remote as the base and kept the local working image. v14(&path, &remote, &[], Some(&working)); @@ -346,7 +341,10 @@ fn an_uncertain_attempt_keeps_its_state_and_a_conflict_becomes_a_conflict_page() conflicts(&server.durable), [( a, - vec![("Author".to_owned(), page_texts(&model_ops::page_of(&working, a)))] + vec![( + "Author".to_owned(), + page_texts(&model_ops::page_of(&working, a)) + )] )] ); } @@ -447,7 +445,13 @@ fn a_schema_15_cache_upgrades_in_place_and_its_conflict_becomes_a_conflict_page( assert_eq!(version, 16); assert_eq!(columns, ["id", "sealed", "revisions", "attempted"]); drop(connection); - assert_eq!(notebook::Recovery::open(&archive).unwrap().pending().unwrap(), pending); + assert_eq!( + notebook::Recovery::open(&archive) + .unwrap() + .pending() + .unwrap(), + pending + ); let cache = Replica::open(&path).unwrap(); let remote = typed(SOURCE, space, text, 0..0, "Remote "); let mut server = Server::new(&remote); diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index 640cc42a21e7dd103aa3250de1c4365b59716a72..cc4533b87b20d334718003189505e36eadc01eaa 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -43,7 +43,13 @@ fn edited(page: &Page, text: &str) -> Page { /// Replaces `range` of the page's first body text as the editor does: the edit's id once /// it is durable. -fn typed(section: &Section, space: ExGuid, page: &Page, range: std::ops::Range<u32>, text: &str) -> u64 { +fn typed( + section: &Section, + space: ExGuid, + page: &Page, + range: std::ops::Range<u32>, + text: &str, +) -> u64 { let op = PageOp::Text { text: first_text(page), range, @@ -240,7 +246,10 @@ fn an_external_change_reloads_the_page_and_later_edits_apply_to_it() { } } section.wake(); - wait(§ion, |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space))); + wait( + §ion, + |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space)), + ); assert_same(section.page(space).unwrap(), &native); // An edit made on the page shown before the change applies to the page as it is now. let id = typed(§ion, space, &before, 0..0, "Local "); @@ -312,7 +321,10 @@ fn edits_apply_in_order_without_waiting_and_finish_before_close() { } } section.wake(); - wait(§ion, |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space))); + wait( + §ion, + |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space)), + ); apply("Local "); section.close().unwrap(); let (section, _) = open(&file, &cache); @@ -715,9 +727,10 @@ fn a_conflicting_save_keeps_the_native_page_and_a_conflict_page_the_session_dele } } section.wake(); - wait(§ion, |event| { - matches!(event, Event::Attempt { id: n, status: EditStatus::Published { .. } } if *n > id) - }); + wait( + §ion, + |event| matches!(event, Event::Attempt { id: n, status: EditStatus::Published { .. } } if *n > id), + ); assert!(matches!( section.status(id).unwrap(), Some(EditStatus::Published { .. }) @@ -846,3 +859,61 @@ fn space_of(source: &[u8]) -> ExGuid { .unwrap()[0] .0 } + +/// Restoring and deleting page versions go through the queue and reach the file as OneNote +/// 2010 stores them (`corpus/page-versions`); a publication that changes only a page's +/// versions confirms by the history revision it wrote. +#[test] +fn page_versions_restore_and_delete_through_the_session() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("History.one"); + let cache = directory.path().join("cache"); + std::fs::copy( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../corpus/page-versions/native/step-02/notebook/History.one"), + &file, + ) + .unwrap(); + let (section, _) = open(&file, &cache); + let listed = section.versions().unwrap(); + let (space, versions) = listed[0].clone(); + assert_eq!(versions.len(), 1); + let standing = section.page(space).unwrap(); + let old = section.version(space, versions[0].context).unwrap(); + let restored = section + .restore_version(space, versions[0].context, "Editor") + .unwrap(); + published(§ion, restored); + assert_eq!(section.page(space).unwrap().objects, old.objects); + let stored = |file: &Path| { + let arena = onestore::Arena::default(); + let mut stored = + onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap(); + let versions = stored.versions().unwrap(); + (stored.page(space).unwrap(), versions) + }; + let (page, after) = stored(&file); + assert_eq!(page.objects, old.objects); + assert_eq!(after[0].1.len(), 2); + assert_eq!(after[0].1[0].author.as_deref(), Some("Other Person")); + assert_eq!( + section.version(space, after[0].1[0].context).unwrap(), + standing + ); + section.close().unwrap(); + + // After a relaunch, deleting every version publishes the history alone. + let (section, _) = open(&file, &cache); + let all: Vec<ExGuid> = section.versions().unwrap()[0] + .1 + .iter() + .map(|version| version.context) + .collect(); + let deleted = section.delete_versions(&[(space, all)]).unwrap(); + published(§ion, deleted); + assert!(section.versions().unwrap().is_empty()); + let (page, after) = stored(&file); + assert!(after.is_empty()); + assert_eq!(page.objects, old.objects); + section.close().unwrap(); +} diff --git a/crates/notebook/tests/smb_queue.rs b/crates/notebook/tests/smb_queue.rs index 4282840cdde547b398df649e169b3f53cb5ea192..38b02a72a263582fd82452818c2b36050b5c12bb 100644 --- a/crates/notebook/tests/smb_queue.rs +++ b/crates/notebook/tests/smb_queue.rs @@ -142,10 +142,7 @@ fn live_keystrokes_publish_reading_only_the_header_and_a_native_change_merges() } assert_eq!(remote.reads, 0, "publications read only the header"); assert_eq!(remote.stamps, 20); - assert_eq!( - lab.read(&path, 1 << 24).unwrap(), - snapshot(&replica) - ); + assert_eq!(lab.read(&path, 1 << 24).unwrap(), snapshot(&replica)); for _ in 0..5 { assert_eq!(replica.sync_once(&mut remote).unwrap().edit, None); } @@ -175,10 +172,7 @@ fn live_keystrokes_publish_reading_only_the_header_and_a_native_change_merges() let merged = text_of(&replica, space, text); assert!(merged.starts_with("Native Body"), "{merged}"); assert!(merged.ends_with(" local"), "{merged}"); - assert_eq!( - lab.read(&path, 1 << 24).unwrap(), - snapshot(&replica) - ); + assert_eq!(lab.read(&path, 1 << 24).unwrap(), snapshot(&replica)); lab.delete(&path).unwrap(); } diff --git a/crates/notebook/tests/structure.rs b/crates/notebook/tests/structure.rs index 79d684d23c9c67749c548746b0fc927000947265..36c5b95670685c49fb69983c1abcc14c37af5e3b 100644 --- a/crates/notebook/tests/structure.rs +++ b/crates/notebook/tests/structure.rs @@ -53,13 +53,23 @@ fn sections_and_groups_are_created_renamed_coloured_ordered_and_deleted() { onestore::place_file(root.join("First.one"), toc_id, "First.one").unwrap(); let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap(); assert_eq!( - notebook.create_section("", "Second", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()).unwrap(), + notebook + .create_section( + "", + "Second", + &onestore::PageCreation::new(None, Some(""), "Author").unwrap() + ) + .unwrap(), "Second.one" ); assert_eq!(notebook.create_group("", "Archive").unwrap(), "Archive"); assert_eq!( notebook - .create_section("Archive", "Inner", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()) + .create_section( + "Archive", + "Inner", + &onestore::PageCreation::new(None, Some(""), "Author").unwrap() + ) .unwrap(), "Archive/Inner.one" ); @@ -171,3 +181,156 @@ fn copy_dir(from: &std::path::Path, to: &std::path::Path) { } } } + +#[test] +fn a_rename_keeps_its_place_in_the_notebook_and_in_a_group() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("notebook"); + let page = onestore::PageCreation::new(None, Some(""), "Author").unwrap(); + let mut notebook = + Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap(); + notebook.create_group("", "Group").unwrap(); + for folder in ["", "Group"] { + for name in ["Video", "Song", "Album"] { + notebook.create_section(folder, name, &page).unwrap(); + } + } + let path = |folder: &str, name: &str| { + if folder.is_empty() { + format!("{name}.one") + } else { + format!("{folder}/{name}.one") + } + }; + notebook + .reorder( + "", + &["Album.one", "Video.one", "New Section 1.one", "Song.one"], + ) + .unwrap(); + notebook + .reorder("Group", &["Group/Album.one", "Group/Video.one"]) + .unwrap(); + notebook.rename("Video.one", "Kitchen").unwrap(); + notebook.rename("Group/Video.one", "Kitchen").unwrap(); + notebook.rename("Group", "Renamed").unwrap(); + assert_eq!( + names(notebook.catalog()), + ( + ["Album", "Kitchen", "New Section 1", "Song"] + .map(|name| path("", name)) + .to_vec(), + vec!["Renamed".to_owned()] + ) + ); + assert_eq!( + names(¬ebook.catalog().groups[0]).0, + ["Album", "Kitchen", "Song"].map(|name| path("Renamed", name)) + ); +} + +/// Each entry a TOC file lists: its filename and file identity. +fn listed(path: &std::path::Path) -> Vec<(String, [u8; 16])> { + let image = onestore::read_file(path).unwrap(); + let store = onestore::Store::parse(&image).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + let document = onestore::document::Document::parse(&index).unwrap(); + let revision = document.active(document.root).unwrap(); + let onestore::document::Kind::Toc { entries, .. } = &revision.nodes[&revision.roots[&1]].kind + else { + panic!() + }; + entries + .iter() + .map(|id| match &revision.nodes[id].kind { + onestore::document::Kind::Toc { + filename, identity, .. + } => (filename.clone().unwrap_or_default(), identity.unwrap()), + _ => panic!(), + }) + .collect() +} + +fn file_id(path: &std::path::Path) -> [u8; 16] { + onestore::Store::parse(&onestore::read_file(path).unwrap()) + .unwrap() + .header + .file_id +} + +#[test] +fn a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadable_one() { + use std::os::unix::fs::PermissionsExt; + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("notebook"); + let bin = root.join("OneNote_RecycleBin"); + let page = onestore::PageCreation::new(None, Some("Kept"), "Author").unwrap(); + let mut notebook = + Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap(); + let recycle = |notebook: &mut Notebook| { + let image = notebook.read_section("New Section 1.one").unwrap(); + let pages = notebook::session::stored_pages(&image).unwrap(); + notebook.recycle_pages(&[pages[0].page.clone()], "Author") + }; + let bins = |root: &std::path::Path| -> Vec<[u8; 16]> { + listed(&root.join("Open Notebook.onetoc2")) + .into_iter() + .filter(|(name, _)| name == "OneNote_RecycleBin") + .map(|(_, identity)| identity) + .collect() + }; + recycle(&mut notebook).unwrap(); + + // Gone from the folder, the bin's entry is stale; the next bin takes its name. + std::fs::remove_dir_all(&bin).unwrap(); + notebook.refresh().unwrap(); + recycle(&mut notebook).unwrap(); + let toc = bin.join("Open Notebook.onetoc2"); + assert_eq!(bins(&root), [file_id(&toc)]); + let deleted = file_id(&bin.join("OneNote_DeletedPages.one")); + assert_eq!(listed(&toc), [("OneNote_DeletedPages.one".into(), deleted)]); + + // A bin whose TOC has another name and misses the deleted pages is kept and completed. + std::fs::remove_file(&toc).unwrap(); + let other = bin.join("OneNote Table Of Contents.onetoc2"); + std::fs::write( + &other, + onestore::create_table_of_contents("OneNote Table Of Contents.onetoc2", &[]).unwrap(), + ) + .unwrap(); + let parent = file_id(&root.join("Open Notebook.onetoc2")); + onestore::place_file(&other, parent, "OneNote_RecycleBin").unwrap(); + notebook.refresh().unwrap(); + recycle(&mut notebook).unwrap(); + assert!(!toc.exists()); + assert_eq!(bins(&root), [file_id(&other)]); + assert_eq!( + listed(&other), + [("OneNote_DeletedPages.one".into(), deleted)] + ); + let ancestor = + onestore::Store::parse(&onestore::read_file(bin.join("OneNote_DeletedPages.one")).unwrap()) + .unwrap() + .header + .ancestor; + assert_eq!(ancestor, file_id(&other)); + // `NOTEBOOK_RECYCLE_EXPORT` names a new directory receiving it for a cold reopen. + if let Some(directory) = std::env::var_os("NOTEBOOK_RECYCLE_EXPORT") { + copy_dir(&root, std::path::Path::new(&directory)); + } + // Permission bits do not bind the superuser. + if unsafe { libc::geteuid() } == 0 { + return; + } + + // A bin that cannot be read is neither replaced nor added to. + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o000)).unwrap(); + let denied = notebook.refresh().and_then(|_| recycle(&mut notebook)); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(denied.is_err()); + let names: Vec<_> = std::fs::read_dir(&bin) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + assert_eq!(names.len(), 2, "{names:?}"); +} diff --git a/crates/notebook/tests/support/model_schedule.rs b/crates/notebook/tests/support/model_schedule.rs index e019ef53a86ed6ceff186058d1d520819453e56c..79827a5906f2e012e6900c8744fc5e415a2b0246 100644 --- a/crates/notebook/tests/support/model_schedule.rs +++ b/crates/notebook/tests/support/model_schedule.rs @@ -3,6 +3,7 @@ //! pages the merges leave. use crate::model_ops::{self, AUTHOR}; +use crate::ops; use crate::server::{Fault, Server, remote_snapshot, snapshot}; use notebook::{EditStatus, Remote, Replica}; use onestore::{ @@ -12,9 +13,6 @@ use onestore::{ }; use std::{io, sync::LazyLock}; -#[path = "../../../onestore/tests/support/ops.rs"] -mod ops; - static SOURCE: LazyLock<(Vec<u8>, ExGuid)> = LazyLock::new(|| { let source = onestore::create_section("model-schedule.one", "Original 🦀 é 0", "Author").unwrap(); @@ -282,13 +280,16 @@ pub fn run(input: &[u8]) { let next = cache.pending().unwrap(); // Edits leave the queue oldest first, each with a durable receipt; a rebase // queues the conflict pages it makes after them. - let queued = |edit: ¬ebook::PendingEdit| pending.iter().any(|kept| kept.id == edit.id); + let queued = + |edit: ¬ebook::PendingEdit| pending.iter().any(|kept| kept.id == edit.id); let left = pending.len() - next.iter().filter(|edit| queued(edit)).count(); assert!(next.iter().all(|edit| { pending[left..].iter().any(|kept| kept.id == edit.id) || matches!( &edit.edit.ops[..], - [onestore::op::Op::Section(onestore::op::SectionOp::Conflict { .. })] + [onestore::op::Op::Section( + onestore::op::SectionOp::Conflict { .. } + )] ) })); for edit in &pending[..left] { @@ -301,24 +302,18 @@ pub fn run(input: &[u8]) { if next.is_empty() && result.is_ok() { assert_eq!( local(cache), - shape(&model_ops::page_of( - &remote_snapshot(cache), - *space - )) + shape(&model_ops::page_of(&remote_snapshot(cache), *space)) ); } } 5 => { let remote = rows(&server.visible); let row = remote[usize::from(step[2]) % remote.len()].clone(); - if !row.2.is_empty() { + let letter = char::from(b'a' + step[3] % 26); + // Retyping the letter already there changes nothing, so stores no revision. + if row.2.chars().next().is_some_and(|first| first != letter) { let mut page = model_ops::page_of(&server.visible, *space); - model_ops::replace_text( - &mut page, - row.1, - 0..1, - &char::from(b'a' + step[3] % 26).to_string(), - ); + model_ops::replace_text(&mut page, row.1, 0..1, &letter.to_string()); let visible = server.visible.clone(); ops::save(&visible, *space, &page) .unwrap() @@ -334,9 +329,8 @@ pub fn run(input: &[u8]) { .find(|(page, _)| page == space) .map(|(_, pages)| pages[usize::from(step[2]) % pages.len()].space) { - let delete = onestore::op::Op::Section(onestore::op::SectionOp::Delete(vec![ - conflict, - ])); + let delete = + onestore::op::Op::Section(onestore::op::SectionOp::Delete(vec![conflict])); cache .apply( AUTHOR, diff --git a/crates/notebook/tests/support/page_schedule.rs b/crates/notebook/tests/support/page_schedule.rs index b01d6af2d69bb66f20d70eaa47627df58cb3851b..3fef6572686f99b94b5e8e0769fd47ddb5b8effe 100644 --- a/crates/notebook/tests/support/page_schedule.rs +++ b/crates/notebook/tests/support/page_schedule.rs @@ -1,5 +1,5 @@ use crate::{ - disk, model_ops, + disk, model_ops, ops, server::{remote_snapshot, snapshot}, }; use notebook::{Remote, Replica}; @@ -14,9 +14,6 @@ use onestore::{ }; use std::{io, sync::LazyLock}; -#[path = "../../../onestore/tests/support/ops.rs"] -mod ops; - const BODY: &str = "Body 🦋 é"; #[path = "../../../onestore/tests/support/current.rs"] @@ -242,10 +239,9 @@ pub fn run(input: &[u8]) { .collect(); let expected = ops::section_op(&image, SectionOp::Pages(edits.to_vec())); match section_op(cache, onestore::op::SectionOp::Pages(edits)) { - Ok(_) => assert_eq!( - pages(&snapshot(cache)), - pages(expected.unwrap().as_bytes()) - ), + Ok(_) => { + assert_eq!(pages(&snapshot(cache)), pages(expected.unwrap().as_bytes())) + } Err(_) => { assert!(expected.is_err()); assert_eq!(pages(&snapshot(cache)), listed); diff --git a/crates/notebook/tests/sync.rs b/crates/notebook/tests/sync.rs index 2f1bf4fcd2d8b43b91badad1b77d7a7c10140e2f..0a9ef69555ed098a3727324f6701e9592a4a96cb 100644 --- a/crates/notebook/tests/sync.rs +++ b/crates/notebook/tests/sync.rs @@ -57,8 +57,7 @@ fn an_unchanged_stamp_publishes_and_settles_without_reading_the_remote() { assert_eq!(cache.sync_once(&mut remote).unwrap().edit, None); assert_eq!(remote.reads, 0); // Another writer's commit moves the header, so the next step reads the file. - let native = - typed(&remote.server.visible, sid, object, 0..0, "Native "); + let native = typed(&remote.server.visible, sid, object, 0..0, "Native "); remote.server.visible.clone_from(&native); assert_eq!(cache.sync_once(&mut remote).unwrap().edit, None); assert_eq!(remote.reads, 1); @@ -252,7 +251,10 @@ fn overlapping_changes_keep_both_versions_and_survive_restart() { assert_eq!(content(&server.durable, oid), "aRc"); assert_eq!( conflicts(&server.durable), - [(sid, vec![(model_ops::AUTHOR.to_owned(), vec!["aLc".to_owned()])])] + [( + sid, + vec![(model_ops::AUTHOR.to_owned(), vec!["aLc".to_owned()])] + )] ); assert_eq!(snapshot(&cache), server.durable); assert!(cache.pending().unwrap().is_empty()); @@ -635,7 +637,10 @@ fn edits_after_a_conflict_apply_to_the_remote_version_and_survive_reopen() { assert_eq!(content(&server.durable, oid), expected); assert_eq!( conflicts(&server.durable), - [(sid, vec![(model_ops::AUTHOR.to_owned(), vec!["aLc".to_owned()])])] + [( + sid, + vec![(model_ops::AUTHOR.to_owned(), vec!["aLc".to_owned()])] + )] ); assert!(cache.pending().unwrap().is_empty()); } @@ -714,7 +719,11 @@ fn seeded_conflicts_keep_the_remote_text_and_later_edits_publish_once() { ), "case {case}, seed {seed}" ); - assert_eq!(content(&server.durable, oid), remote_text, "case {case}, seed {seed}"); + assert_eq!( + content(&server.durable, oid), + remote_text, + "case {case}, seed {seed}" + ); assert_eq!( conflicts(&server.durable), [(sid, vec![(model_ops::AUTHOR.to_owned(), vec![local])])], @@ -729,7 +738,11 @@ fn seeded_conflicts_keep_the_remote_text_and_later_edits_publish_once() { let mut expected = original.clone(); expected.splice(at..at + 1, "λ🐕μ".chars()); let expected = prefix + &expected.iter().collect::<String>() + &suffix; - assert_eq!(content(&server.durable, oid), expected, "case {case}, seed {seed}"); + assert_eq!( + content(&server.durable, oid), + expected, + "case {case}, seed {seed}" + ); assert_eq!(server.publications, 2); assert!(cache.pending().unwrap().is_empty()); } @@ -919,7 +932,10 @@ fn a_remote_restore_keeps_local_work_on_a_conflict_page() { conflicts(&server.durable), [( space, - vec![(model_ops::AUTHOR.to_owned(), vec!["Revised Original".to_owned()])] + vec![( + model_ops::AUTHOR.to_owned(), + vec!["Revised Original".to_owned()] + )] )] ); drop(cache); diff --git a/crates/notebook/tests/sync_formatting.rs b/crates/notebook/tests/sync_formatting.rs index 4550f8275dc49c9d28fbdf1e4e8536646a39c65e..beb24e3b0e114501e19f047cdf0767ed85088822 100644 --- a/crates/notebook/tests/sync_formatting.rs +++ b/crates/notebook/tests/sync_formatting.rs @@ -83,11 +83,7 @@ fn formats(page: &Page, text: ExGuid) -> Vec<Format> { fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server { let mut page = page_of(OUTLINES, space); change(&mut page); - Server::new( - ops::saved(OUTLINES, space, &page) - .unwrap() - .as_slice(), - ) + Server::new(ops::saved(OUTLINES, space, &page).unwrap().as_slice()) } fn cache(directory: &tempfile::TempDir) -> Replica { @@ -157,7 +153,11 @@ fn offline_formatting_conflicts_only_with_remote_changes_it_overlaps() { assert!(matches!(outcome, Some((_, EditStatus::Published { .. })))); assert!(conflicted(&server.durable, space)); let remote = page_of(&server.durable, space); - assert!(formats(&remote, ids[0]).iter().all(|format| format.bold != Some(true))); + assert!( + formats(&remote, ids[0]) + .iter() + .all(|format| format.bold != Some(true)) + ); assert_eq!(pages(&snapshot(&cache)), pages(&server.durable)); assert_ne!(pages(&local), pages(&server.durable)); } @@ -302,7 +302,6 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone }); let remote_page = page_of(&server.durable, space); let outcome = cache.sync_once(&mut server).unwrap().edit.unwrap(); - assert_eq!(outcome.0, id, "seed {seed}"); // Bullets the remote typed before the target's text, and whether it underlined a // character the target already had. let mut bullets = vec![0_u32; ids.len()]; @@ -316,13 +315,20 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone } if underlined[target] && start == 0 { conflicts += 1; - assert!(matches!(outcome.1, EditStatus::Published { .. }), "seed {seed}"); + // The step names its batch by the newest edit: the conflict page the rebase queued. + assert!(outcome.0 > id, "seed {seed}"); + assert!( + matches!(outcome.1, EditStatus::Published { .. }), + "seed {seed}" + ); + assert_eq!(cache.status(id).unwrap(), Some(outcome.1), "seed {seed}"); assert!(conflicted(&server.durable, space), "seed {seed}"); assert_eq!(page_of(&server.durable, space), remote_page, "seed {seed}"); assert_ne!(pages(&snapshot(&cache)), pages(&local), "seed {seed}"); continue; } published += 1; + assert_eq!(outcome.0, id, "seed {seed}"); assert!( matches!(outcome.1, EditStatus::Published { .. }), "seed {seed}: {outcome:?}" diff --git a/crates/notebook/tests/sync_outline.rs b/crates/notebook/tests/sync_outline.rs index 829b756ca62f14089a6fdc9532694559c43113f8..37da748666c860d770661363035f23462f681159 100644 --- a/crates/notebook/tests/sync_outline.rs +++ b/crates/notebook/tests/sync_outline.rs @@ -301,7 +301,10 @@ fn competing_layout_keeps_the_local_one_on_a_conflict_page() { let mut section = onestore::Section::open(&arena, server.durable.clone()).unwrap(); let conflict = section.conflicts().unwrap()[0].1[0].space; let kept = section.page(conflict).unwrap(); - assert_eq!(body_outlines(&kept)[0].layout, outline_of(&local, outline).layout); + assert_eq!( + body_outlines(&kept)[0].layout, + outline_of(&local, outline).layout + ); let dependent = save(&cache, text_id, |page| { replace_text(page, text_id, 0..0, "Local ") }) diff --git a/crates/notebook/tests/sync_page.rs b/crates/notebook/tests/sync_page.rs index 16a1c74c24c0713aa7c9490b5d43997b8c8d3a49..d116d22d1993cf164f8632f63147bea87c8d3b57 100644 --- a/crates/notebook/tests/sync_page.rs +++ b/crates/notebook/tests/sync_page.rs @@ -280,7 +280,10 @@ fn a_moved_page_anchor_places_the_page_without_regenerating_dependent_identities .pages() .unwrap(); // The remote made `child` and `grandchild` subpages; `Renamed` stayed. - let at = listed.iter().position(|listed| listed.0 == pages[6].0).unwrap(); + let at = listed + .iter() + .position(|listed| listed.0 == pages[6].0) + .unwrap(); assert_eq!(listed[at - 1].0, page.space()); let store = Store::parse(&server.durable).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); diff --git a/crates/notebook/tests/sync_pages.rs b/crates/notebook/tests/sync_pages.rs index 009f731609957044662bd133a4ef454a2d213550..3f819fc2110f393c6adda36b2d8c706f5e0f7574 100644 --- a/crates/notebook/tests/sync_pages.rs +++ b/crates/notebook/tests/sync_pages.rs @@ -143,10 +143,7 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() { drop(cache); let cache = Replica::open(&path).unwrap(); assert_eq!(cache.pending().unwrap(), queue); - assert_eq!( - server::pages(&snapshot(&cache)), - server::pages(&local) - ); + assert_eq!(server::pages(&snapshot(&cache)), server::pages(&local)); let mut server = Server::new(SOURCE); assert!(matches!( cache.sync_once(&mut server).unwrap().edit, @@ -178,7 +175,10 @@ fn indenting_a_page_the_remote_moved_keeps_the_remote_placement() { SectionOp::Pages([PageEdit::set_level(sid, 3).unwrap()].to_vec()), ); let mut server = Server::new(SOURCE); - ops::section_op(SOURCE, SectionOp::Pages([PageEdit::move_to(sid, Some(before[7].0), 2).unwrap()].to_vec())) + ops::section_op( + SOURCE, + SectionOp::Pages([PageEdit::move_to(sid, Some(before[7].0), 2).unwrap()].to_vec()), + ) .unwrap() .commit(&mut server) .unwrap(); @@ -196,9 +196,15 @@ fn competing_page_moves_keep_the_remote_placement() { let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); let pages = order(SOURCE); let sid = pages[6].0; - let id = section_op(&cache, SectionOp::Pages(vec![PageEdit::move_to(sid, None, 1).unwrap()])); + let id = section_op( + &cache, + SectionOp::Pages(vec![PageEdit::move_to(sid, None, 1).unwrap()]), + ); let mut server = Server::new(SOURCE); - ops::section_op(SOURCE, SectionOp::Pages([PageEdit::move_to(sid, Some(pages[0].0), 1).unwrap()].to_vec())) + ops::section_op( + SOURCE, + SectionOp::Pages([PageEdit::move_to(sid, Some(pages[0].0), 1).unwrap()].to_vec()), + ) .unwrap() .commit(&mut server) .unwrap(); @@ -219,12 +225,18 @@ fn a_competing_level_keeps_the_remote_level_and_the_rest_of_the_batch() { ]; let id = section_op(&cache, SectionOp::Pages(edits.clone())); let mut server = Server::new(SOURCE); - ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(pages[5].0, 1).unwrap()].to_vec())) - .unwrap() - .commit(&mut server) - .unwrap(); - let expected = - ops::section_op(&server.durable.clone(), SectionOp::Pages(edits[..1].to_vec())).unwrap(); + ops::section_op( + SOURCE, + SectionOp::Pages([PageEdit::set_level(pages[5].0, 1).unwrap()].to_vec()), + ) + .unwrap() + .commit(&mut server) + .unwrap(); + let expected = ops::section_op( + &server.durable.clone(), + SectionOp::Pages(edits[..1].to_vec()), + ) + .unwrap(); assert!(matches!(cache.sync_once(&mut server).unwrap().edit, Some((published, EditStatus::Published { .. })) if published == id)); assert_eq!(order(&server.durable), order(expected.as_bytes())); @@ -367,7 +379,10 @@ fn an_explicit_anchor_yields_to_a_remote_move() { ]), ); let mut server = Server::new(SOURCE); - ops::section_op(SOURCE, SectionOp::Pages([PageEdit::move_to(pages[6].0, Some(pages[0].0), 1).unwrap()].to_vec())) + ops::section_op( + SOURCE, + SectionOp::Pages([PageEdit::move_to(pages[6].0, Some(pages[0].0), 1).unwrap()].to_vec()), + ) .unwrap() .commit(&mut server) .unwrap(); @@ -391,10 +406,13 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() { SectionOp::Pages([PageEdit::set_level(sid, 1).unwrap()].to_vec()), ); let mut server = Server::new(SOURCE); - ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(sid, 1).unwrap()].to_vec())) - .unwrap() - .commit(&mut server) - .unwrap(); + ops::section_op( + SOURCE, + SectionOp::Pages([PageEdit::set_level(sid, 1).unwrap()].to_vec()), + ) + .unwrap() + .commit(&mut server) + .unwrap(); server.fault = Fault::Confirm; assert!(cache.sync_once(&mut server).is_err()); assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); @@ -451,12 +469,21 @@ fn native_page_changes_reconcile_with_atomic_offline_batches() { .collect(); let placement = |image: &[u8]| -> BTreeMap<ExGuid, (ExGuid, Option<ExGuid>, u32)> { let arena = onestore::Arena::default(); - let series = onestore::Section::open(&arena, image.to_vec()).unwrap().series().unwrap(); - let shared: Vec<_> = order(image).into_iter().filter(|(sid, _)| both.contains(sid)).collect(); + let series = onestore::Section::open(&arena, image.to_vec()) + .unwrap() + .series() + .unwrap(); + let shared: Vec<_> = order(image) + .into_iter() + .filter(|(sid, _)| both.contains(sid)) + .collect(); (0..shared.len()) .map(|at| { let (sid, level) = shared[at]; - (sid, (series[&sid], at.checked_sub(1).map(|b| shared[b].0), level)) + ( + sid, + (series[&sid], at.checked_sub(1).map(|b| shared[b].0), level), + ) }) .collect() }; @@ -607,14 +634,21 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { /// A, offline, `moves` pages (each last, or before another) and edits `Target`'s body while /// client B publishes `b-published`. The pages the merge lists after the first, and /// `Target`'s space and texts. -fn native_page_merge(capture: &str, moves: &[(&str, Option<&str>)]) -> (Vec<String>, ExGuid, Vec<String>) { +fn native_page_merge( + capture: &str, + moves: &[(&str, Option<&str>)], +) -> (Vec<String>, ExGuid, Vec<String>) { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../corpus/conflict-page") .join(capture); - let read = |phase: &str| std::fs::read(root.join(phase).join("notebook/synthetic.one")).unwrap(); + let read = + |phase: &str| std::fs::read(root.join(phase).join("notebook/synthetic.one")).unwrap(); let pages = |image: &[u8]| { let arena = onestore::Arena::default(); - onestore::Section::open(&arena, image.to_vec()).unwrap().pages().unwrap() + onestore::Section::open(&arena, image.to_vec()) + .unwrap() + .pages() + .unwrap() }; let initial = read("initial"); let listed = pages(&initial); @@ -624,7 +658,9 @@ fn native_page_merge(capture: &str, moves: &[(&str, Option<&str>)]) -> (Vec<Stri for (page, before) in moves { section_op( &cache, - SectionOp::Pages(vec![PageEdit::move_to(space(page), before.map(space), 1).unwrap()]), + SectionOp::Pages(vec![ + PageEdit::move_to(space(page), before.map(space), 1).unwrap(), + ]), ); } let body = texts(&initial) @@ -650,7 +686,11 @@ fn native_page_merge(capture: &str, moves: &[(&str, Option<&str>)]) -> (Vec<Stri .filter(|((page, _), _)| *page == target) .map(|(_, text)| text) .collect(); - (merged[1..].iter().map(|page| page.1.clone()).collect(), target, texts) + ( + merged[1..].iter().map(|page| page.1.clone()).collect(), + target, + texts, + ) } /// Each side keeps the pages it gave a series of their own, the remote's where both did; @@ -659,20 +699,47 @@ fn native_page_merge(capture: &str, moves: &[(&str, Option<&str>)]) -> (Vec<Stri #[test] fn page_moves_and_a_removed_page_merge_as_onenote_merges_them() { for (capture, moves) in [ - ("native-pages", &[("One", Some("Target")), ("Three", Some("Target")), ("Two", None)][..]), - ("native-restore", &[("One", None), ("Two", None), ("Target", None), ("Three", None)][..]), + ( + "native-pages", + &[ + ("One", Some("Target")), + ("Three", Some("Target")), + ("Two", None), + ][..], + ), + ( + "native-restore", + &[ + ("One", None), + ("Two", None), + ("Target", None), + ("Three", None), + ][..], + ), ("native-restore", &[("Four", Some("One"))][..]), ] { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/conflict-page"); + let root = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/conflict-page"); let native: serde_json::Value = - serde_json::from_slice(&std::fs::read(root.join(capture).join("merged.json")).unwrap()).unwrap(); + serde_json::from_slice(&std::fs::read(root.join(capture).join("merged.json")).unwrap()) + .unwrap(); let (order, target, texts) = native_page_merge(capture, moves); let native: Vec<String> = serde_json::from_value(native["order"].clone()).unwrap(); assert_eq!(order, native[1..], "{capture}"); - assert!(texts.contains(&"Body Target edited offline.".to_owned()), "{capture}"); + assert!( + texts.contains(&"Body Target edited offline.".to_owned()), + "{capture}" + ); let arena = onestore::Arena::default(); - let initial = std::fs::read(root.join(capture).join("initial/notebook/synthetic.one")).unwrap(); - let listed = onestore::Section::open(&arena, initial).unwrap().pages().unwrap(); - assert!(listed.iter().all(|page| page.0 != target), "{capture}: a new page"); + let initial = + std::fs::read(root.join(capture).join("initial/notebook/synthetic.one")).unwrap(); + let listed = onestore::Section::open(&arena, initial) + .unwrap() + .pages() + .unwrap(); + assert!( + listed.iter().all(|page| page.0 != target), + "{capture}: a new page" + ); } } diff --git a/crates/notebook/tests/sync_paragraph.rs b/crates/notebook/tests/sync_paragraph.rs index aa54b56ff2c2db8b1f699f2636c475467a7034c4..b418cd0cd0bde1468ef93531fda24e58e1300a26 100644 --- a/crates/notebook/tests/sync_paragraph.rs +++ b/crates/notebook/tests/sync_paragraph.rs @@ -392,7 +392,10 @@ fn a_native_text_object_replacement_keeps_a_conflict_page_for_every_offline_spli "{name}" ); assert!( - matches!(cache.status(id).unwrap(), Some(EditStatus::Published { .. })), + matches!( + cache.status(id).unwrap(), + Some(EditStatus::Published { .. }) + ), "{name}" ); assert_eq!(page_of(&server.visible, space), remote, "{name}"); @@ -483,8 +486,7 @@ fn offline_paragraphs(output: Option<&Path>) { }; let prefixed = offset > 0 && !content(&page, left).text().is_empty(); if prefixed { - server.visible = - typed(&server.visible, space, left, 0..0, "\u{2602}"); + server.visible = typed(&server.visible, space, left, 0..0, "\u{2602}"); server.durable.clone_from(&server.visible); } let Ok(queued) = save_page(&cache, left, |page| replay(page, intent, split)) else { @@ -591,11 +593,7 @@ fn control(title: &str) -> (ExGuid, Page) { fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server { let mut page = page_of(BEFORE, space); change(&mut page); - Server::new( - ops::saved(BEFORE, space, &page) - .unwrap() - .as_slice(), - ) + Server::new(ops::saved(BEFORE, space, &page).unwrap().as_slice()) } #[test] diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 614961a997ddb5c0413aed85187e861fbc0feca0..219631190dc9b188078a9a8fa80c21c075b06cfd 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -61,6 +61,7 @@ harness also accepts `--client-profile release`. | `Arena`, `Section` | Keep a section parsed across edits: `open` validates an image once; `seal` appends one revision per changed space as a `Transaction` on `stamp`, checking only what it appends; `replay` applies a queued transaction; `image` and `page` read the result | | `op::{Edit, Op, PageOp, TableEdit, SectionOp}`, `Section::apply` | Object-level edits (text, formatting, links, equations, paragraph insertion/split/join/move/deletion/levels, outlines, lists, tags, styles, paragraph formatting, tables, pictures, attachments, ink, page creation/import/moves/removal, conflict pages) applied whole or not to a kept-open section with emitter-chosen identities, UTF-16 ranges and the edit's time; refusals name the target, identity or structure at fault | | `SectionOp::Conflict`, `Section::conflicts`, `ConflictPage` | Keep the version of a page a merge could not take as OneNote 2010 does: a read-only conflict page (`jcidConflictPageMetaData`, `IsConflictPage`, conflict objects marked) under the page's manifest, which says it has conflict pages; list each page's, newest first, and delete one (`SectionOp::Delete`) as OneNote's Delete Conflict Page does | +| `Section::versions`, `Section::version`, `PageVersion`, `SectionOp::RestoreVersion`, `SectionOp::DeleteVersions` | A page's versions as OneNote 2010 keeps them: earlier revisions of the page's own space, each current under its own context and listed by the page's version history, newest first with its author and time; read one as a page (O(section)); restore one as Restore Version does (the page's next revision builds on it and the page as it stood becomes the newest version) or unlist some as Delete Version does, their revisions staying stored ([corpus](../../corpus/page-versions/README.md)) | | `op::lower`, `op::lower_page` | The ops turning a range of paragraphs, or a whole page model, into another, computed from the models alone, for editors and imports | | `op::predict` | The page an op leaves, as the section stores it and reading it back shows it | | `read_file` | Read a snapshot under whole-file exclusion | diff --git a/crates/onestore/examples/edit_property.rs b/crates/onestore/examples/edit_property.rs index 2a720da5cb0f5f34f7065899856eebbbfbeb8ded..2e9b45113011fd66a39d4428e98269b2f13f5954 100644 --- a/crates/onestore/examples/edit_property.rs +++ b/crates/onestore/examples/edit_property.rs @@ -84,7 +84,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { } (0x20001, 0x14001cbe) => { if index.resolve_active(space)?.roots.get(&1) != Some(&object) { - return Err("Only the notebook's colour, on the TOC root, changes".into()); + return Err("Only the notebook's color, on the TOC root, changes".into()); } let color = u32::from_le_bytes(value.as_slice().try_into()?); onestore::edit_table_of_contents(&bytes, &[TocEdit::Color(color)])? @@ -108,7 +108,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { write(&args[1], &patched)?; return Ok(()); } - _ => return Err("Only text and TOC colours change as edits; other values are patched into a copy of equal length".into()), + _ => return Err("Only text and TOC colors change as edits; other values are patched into a copy of equal length".into()), }; let transaction = transaction.ok_or("The value is already stored")?; if args[1] == "--in-place" { diff --git a/crates/onestore/examples/insert.rs b/crates/onestore/examples/insert.rs index 41a08d32b447ea4e672e334da186c0db5ba45386..02643caf06af7f55996ad3013fc323427955f8c3 100644 --- a/crates/onestore/examples/insert.rs +++ b/crates/onestore/examples/insert.rs @@ -6,7 +6,9 @@ mod typing; use onestore::{ document::{Format, Layout}, op::{Edit, Op, PageOp}, - page::{Outline, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, text::new_id}, + page::{ + Outline, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, text::new_id, + }, }; use std::{fs, io::Write}; @@ -87,7 +89,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { op, }], }; - let transaction = typing::sealed(&source, author, &edit)?.ok_or("The insertion stores nothing")?; + let transaction = + typing::sealed(&source, author, &edit)?.ok_or("The insertion stores nothing")?; let mut record = serde_json::json!({"edit": edit, "object": object, "text_object": text}); if args[2] == "--in-place" { if let Err(error) = transaction.commit_file(&args[1]) { diff --git a/crates/onestore/examples/support/concurrent.rs b/crates/onestore/examples/support/concurrent.rs index 8b8984b9b660e473761c5ec94ba38e1c68e46953..a90e900619329213595593286da23ea12e7716a1 100644 --- a/crates/onestore/examples/support/concurrent.rs +++ b/crates/onestore/examples/support/concurrent.rs @@ -322,7 +322,10 @@ mod tests { TextAttribute, document::{Format, Layout}, op::{Edit, Op, PageOp}, - page::{Outline, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, text::new_id}, + page::{ + Outline, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, + text::new_id, + }, }; fn paragraph(text: &str, level: u32) -> PageParagraph { @@ -335,12 +338,15 @@ mod tests { content: ParagraphContent::Text(TextObject { id: new_id().unwrap(), date_field: None, - text: Paragraph::new(text.into(), Format { - font: Some("Calibri".into()), - font_size: Some(11.0), - language: Some(0x409), - ..Format::default() - }), + text: Paragraph::new( + text.into(), + Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + language: Some(0x409), + ..Format::default() + }, + ), tags: Vec::new(), }), lists: Vec::new(), @@ -354,7 +360,15 @@ mod tests { let arena = onestore::Arena::default(); let mut section = onestore::Section::open(&arena, image.to_vec()).unwrap(); let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); - section.apply("Author", &Edit { at: 134_000_000_000_000_000, ops }).unwrap(); + section + .apply( + "Author", + &Edit { + at: 134_000_000_000_000_000, + ops, + }, + ) + .unwrap(); section.seal().unwrap(); section.image() } diff --git a/crates/onestore/src/active.rs b/crates/onestore/src/active.rs index 3d4f3ca32192e37431d4515be3c60c9326c67ff3..ccbce21a1520e6287d455d74c35eb3a2fc51d207 100644 --- a/crates/onestore/src/active.rs +++ b/crates/onestore/src/active.rs @@ -58,7 +58,12 @@ impl<'a> ActivePage<'a> { let rid = index.active(space)?; let live = LiveRevision::new(index.resolve(space, rid)?, chain_depth(index, space, rid))?; let store: &'a Store<'a> = index.store; - Self::viewed(std::rc::Rc::new(|guid| store.file_data(guid)), pages, live, view) + Self::viewed( + std::rc::Rc::new(|guid| store.file_data(guid)), + pages, + live, + view, + ) } /// The page space `live` holds, whose stored payloads `files` reads. @@ -404,21 +409,20 @@ pub(crate) mod tests { choice => choice, }; match choice { - 0..=3 => Insertion::paragraph(container, anchor, word, "Author").and_then( - |insertion| { + 0..=3 => { + Insertion::paragraph(container, anchor, word, "Author").and_then(|insertion| { let paragraph = new_id()?; insertion.changes_as(active, paragraph, paragraph, new_id()?) - }, - ), + }) + } 4 => TreeEdit::move_to(paragraph, container, anchor, "Author") .and_then(|edit| edit.changes(active)), 5 => TreeEdit::delete(paragraph, "Author").and_then(|edit| edit.changes(active)), 6 => { let at = self.pick(3) as u32; let lists = (0..8).map(|_| new_id()).collect::<Result<Vec<_>>>()?; - ParagraphSplit::new(text, at, "Author").and_then(|split| { - split.changes_as(active, new_id()?, new_id()?, &lists) - }) + ParagraphSplit::new(text, at, "Author") + .and_then(|split| split.changes_as(active, new_id()?, new_id()?, &lists)) } _ => { let end = self.pick(2) as u32; diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index d372d746452d028b4474f254f402695c3f1e6d55..90bc2f3be0f141117bbf04c915f0d1a76edcfaa5 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -360,7 +360,11 @@ impl Transaction { /// last, covers bytes 0..1024. pub fn writes(&self) -> impl Iterator<Item = (u64, &[u8])> { std::iter::once((self.base.length, self.append.as_slice())) - .chain(self.patches.iter().map(|(offset, bytes)| (*offset, bytes.as_slice()))) + .chain( + self.patches + .iter() + .map(|(offset, bytes)| (*offset, bytes.as_slice())), + ) .chain(std::iter::once((0, self.header.as_slice()))) } diff --git a/crates/onestore/src/create.rs b/crates/onestore/src/create.rs index 6122092b22c26a42f24409571cbf19d859a53f2e..82ef2506ebdf24894c74acb4557fcfaa3a7de74f 100644 --- a/crates/onestore/src/create.rs +++ b/crates/onestore/src/create.rs @@ -29,7 +29,10 @@ pub(crate) fn initials(author: &str) -> String { /// An author object's properties as OneNote 2010 writes them: initials, then the name. pub(crate) fn author_properties(name: &str) -> Vec<(u32, Vec<u8>)> { - vec![(0x1c001df8, string(&initials(name))), (0x1c001d75, string(name))] + vec![ + (0x1c001df8, string(&initials(name))), + (0x1c001d75, string(name)), + ] } pub(crate) fn default_text_style() -> Vec<(u32, Vec<u8>)> { @@ -160,10 +163,12 @@ pub(crate) fn at<T>(filetime: u64, f: impl FnOnce() -> T) -> T { /// Seconds since 1980 as OneNote's Time32, and FILETIME, of the writers' clock. pub(crate) fn current_timestamps() -> Result<(u32, u64)> { let now = match CLOCK.get() { - Some(filetime) => (filetime / 10_000_000).checked_sub(11644473600).ok_or(Error { - offset: 0, - message: "An edit time precedes the Unix epoch", - })?, + Some(filetime) => (filetime / 10_000_000) + .checked_sub(11644473600) + .ok_or(Error { + offset: 0, + message: "An edit time precedes the Unix epoch", + })?, None => SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| Error { @@ -200,7 +205,7 @@ pub fn create_section(file_name: &str, text: &str, author: &str) -> Result<Vec<u let page_guid = fresh_guid()?; let metadata = vec![ (0x1c001c30, page_guid.to_vec()), - (0x1c001cf3, string(crate::edit::automatic_title(text))), + (0x1c001cf3, string(&crate::edit::automatic_title(text))), (0x14001d82, 40_u32.to_le_bytes().to_vec()), (0x1400348b, 40_u32.to_le_bytes().to_vec()), (0x14001dff, 1_u32.to_le_bytes().to_vec()), @@ -265,7 +270,7 @@ pub fn create_section(file_name: &str, text: &str, author: &str) -> Result<Vec<u (0x24001c20, id(23)), (0x1c001d75, string(author)), (0x1c001df8, string(&initials(author))), - (0x1c001d3c, string(crate::edit::automatic_title(text))), + (0x1c001d3c, string(&crate::edit::automatic_title(text))), ], page_margins(), ] diff --git a/crates/onestore/src/document.rs b/crates/onestore/src/document.rs index 0db2f37b41d753949efd94180a0dde807e873074..4556ed6da34ba0aa5beabd308c6c5a06d34bae60 100644 --- a/crates/onestore/src/document.rs +++ b/crates/onestore/src/document.rs @@ -897,11 +897,16 @@ impl<'a> Document<'a> { if space.contexts.contains_key(&context) { continue; } - let rid = *index + let rid = index .spaces .get(&id) - .and_then(|s| s.labels.get(&(context, 1))) - .ok_or_else(|| invalid("Document context has no revision"))?; + .and_then(|s| s.labels.get(&(context, 1))); + let rid = match rid { + Some(rid) => *rid, + // OneNote's copy of a page version names a history it never wrote. + None if context == crate::section::HISTORY => continue, + None => return Err(invalid("Document context has no revision")), + }; space.contexts.insert(context, rid); if space.revisions.contains_key(&rid) { continue; @@ -924,8 +929,10 @@ impl<'a> Document<'a> { Kind::VersionProxy { context, .. } => (*context, false), _ => continue, }; - let rid = space.contexts[&context]; - let revision = &space.revisions[&rid]; + let Some(rid) = space.contexts.get(&context) else { + continue; + }; + let revision = &space.revisions[rid]; let root = revision.roots.get(&1).and_then(|id| revision.nodes.get(id)); if history { if !matches!(root.map(|n| &n.kind), Some(Kind::VersionHistory)) { @@ -934,7 +941,7 @@ impl<'a> Document<'a> { )); } } else if !matches!(root.map(|n| &n.kind), Some(Kind::Manifest { .. })) - || space.contexts.get(&ExGuid::default()) == Some(&rid) + || space.contexts.get(&ExGuid::default()) == Some(rid) { return Err(invalid( "Version context does not resolve to a historical page", diff --git a/crates/onestore/src/edit.rs b/crates/onestore/src/edit.rs index 7caa327195eb331e17f8910c85a4dbd62671596f..09b531de6d26e3f2d87479222dc3b2a4a56f7344 100644 --- a/crates/onestore/src/edit.rs +++ b/crates/onestore/src/edit.rs @@ -11,16 +11,39 @@ fn title_line(text: &str) -> &str { text.trim_start().split('\r').next().unwrap() } -pub(crate) fn automatic_title(text: &str) -> &str { - let line = title_line(text).trim_end(); +/// `text` as it shows: hyperlink field instructions, which the text holds hidden, left out. +pub(crate) fn without_fields(text: &str) -> String { + let mut line = String::new(); + let mut rest = text; + while let Some(start) = rest.find('\u{fddf}') { + line.push_str(&rest[..start]); + rest = &rest[start..]; + let Some(label) = rest + .strip_prefix("\u{fddf}HYPERLINK \"") + .and_then(|target| target.split_once('"')) + .map(|(_, label)| label) + else { + break; + }; + rest = label; + } + line.push_str(rest); + line +} + +/// The first line of `text` as OneNote titles a page after it: without hyperlink field +/// instructions, which the text holds hidden, and at most 255 UTF-16 units long. +pub(crate) fn automatic_title(text: &str) -> String { + let line = without_fields(title_line(text)); + let line = line.trim(); let mut units = 0; for (byte, character) in line.char_indices() { if units >= 255 { - return line[..byte].trim_end(); + return line[..byte].trim_end().to_owned(); } units += character.len_utf16(); } - line + line.to_owned() } /// Replaces UTF-16 character positions across ordinary text runs. @@ -446,7 +469,7 @@ pub(crate) fn title_of<'n>( let Kind::Page { rtl, .. } = &node(*page).unwrap().kind else { unreachable!() }; - let mut title_text = title_line(title_text); + let mut title_text = without_fields(title_line(title_text)); if automatic { let mut roots = node(*page).unwrap().children.clone(); roots.sort_by(|a, b| { @@ -495,7 +518,7 @@ pub(crate) fn title_of<'n>( pending.extend(element.structure.iter().rev().copied()); } } - Ok(Some((*page, automatic, title_text.to_owned()))) + Ok(Some((*page, automatic, title_text))) } /// Updates the page title for `changed` objects made on `view`, a copy of the page's view. diff --git a/crates/onestore/src/insertion.rs b/crates/onestore/src/insertion.rs index dda6301876eff8cc7d772db236a5b0c6d15517a3..1e7299e19e3eb6317e9b5243b3cd96ee861c76d1 100644 --- a/crates/onestore/src/insertion.rs +++ b/crates/onestore/src/insertion.rs @@ -47,7 +47,13 @@ impl Insertion { } /// Adds an outline to an editable page at coordinates measured in points. - pub(crate) fn outline(page: ExGuid, x: f32, y: f32, text: &str, author: &str) -> Result<Self, Error> { + pub(crate) fn outline( + page: ExGuid, + x: f32, + y: f32, + text: &str, + author: &str, + ) -> Result<Self, Error> { Self::new(page, Placement::Outline { x, y }, text, author) } @@ -186,7 +192,11 @@ impl Insertion { (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()), ], ), - (author, 0x120001, crate::create::author_properties(&self.author)), + ( + author, + 0x120001, + crate::create::author_properties(&self.author), + ), (default, 0x12004d, default_text_style()), ] { new.insert( diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index f366c891e9cf9c2f0fb92dd8dd84e1642aadf764..d0a66ab0c5d0ea0d17dc754bfc1494f6858eeca5 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -44,7 +44,7 @@ pub use pages::{ConflictPage, PageCreation, PageEdit, PagePosition}; pub(crate) use paragraph::{ParagraphJoin, ParagraphSplit}; pub use properties::{IdStream, Property, PropertySets, Value}; pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex}; -pub use section::{Arena, Section}; +pub use section::{Arena, PageVersion, Section}; pub use snapshot::{read_snapshot, read_storage_snapshot}; pub use store::{Chunk, Error, FileType, Header, Node, NodeList, Reference, Store}; pub use toc::{TocEdit, edit_table_of_contents}; diff --git a/crates/onestore/src/objects.rs b/crates/onestore/src/objects.rs index 5556312ca104bb117fd0d8f0bb1618e003ea589b..67f4d47fc5e9ed525d42998e30642101eb15ee4c 100644 --- a/crates/onestore/src/objects.rs +++ b/crates/onestore/src/objects.rs @@ -209,7 +209,10 @@ impl<'a> RevisionIndex<'a> { targets.insert((target, rid)); } for context in references.contexts { - if !space.labels.contains_key(&(context, 1)) { + // OneNote's copy of a page version names a history it never wrote. + if !space.labels.contains_key(&(context, 1)) + && context != crate::section::HISTORY + { return Err(Error { offset: 0, message: "Context reference has no current revision", diff --git a/crates/onestore/src/op/apply.rs b/crates/onestore/src/op/apply.rs index b9b9c292ac294d898bf012fb16235541b124fdbd..a5292c09b170ee1efdd2601e506d2f3d07ceb789 100644 --- a/crates/onestore/src/op/apply.rs +++ b/crates/onestore/src/op/apply.rs @@ -15,8 +15,8 @@ use crate::{ active::{ActivePage, Changes}, document::Kind, page::{ - Attachment, Image, Ink, PageObject, PageParagraph, Paragraph, ParagraphContent, - TableCell, TableColumn, + Attachment, Image, Ink, PageObject, PageParagraph, Paragraph, ParagraphContent, TableCell, + TableColumn, }, }; use std::{ @@ -24,6 +24,10 @@ use std::{ ops::Range, }; +/// `HasConflictPages` and `HasVersionPages` (MS-ONE 2.3.71, 2.3.84), true when present. +const HAS_CONFLICT_PAGES: u32 = 0x88001d97; +const HAS_VERSION_PAGES: u32 = 0x88003462; + fn invalid(message: &'static str) -> Error { Error { offset: 0, message } } @@ -87,7 +91,9 @@ impl<'a> Section<'a> { space: ExGuid, after: &crate::page::Page, ) -> Result<Vec<PageOp>, OpError> { - let before = self.page(space).map_err(|error| self.classify(Failure::Rejected(error)))?; + let before = self + .page(space) + .map_err(|error| self.classify(Failure::Rejected(error)))?; let ops = super::lower_page(&before, after) .map_err(|error| OpError::Unsupported(error.message))?; let edit = Edit { @@ -152,7 +158,10 @@ impl<'a> Section<'a> { let conflicts = self.conflicts()?; for space in pages { let of = conflicts.iter().find_map(|(of, conflicts)| { - conflicts.iter().any(|page| page.space == *space).then_some(*of) + conflicts + .iter() + .any(|page| page.space == *space) + .then_some(*of) }); match of { Some(of) => self.unlink_conflict(of, *space)?, @@ -165,6 +174,19 @@ impl<'a> Section<'a> { } } } + SectionOp::RestoreVersion { + page, + version, + guid, + } => { + self.restore(author, *page, *version, *guid)?; + self.flag_metadata(*page, HAS_VERSION_PAGES, true)?; + } + SectionOp::DeleteVersions { page, versions } => { + if self.unlist(*page, versions)? { + self.flag_metadata(*page, HAS_VERSION_PAGES, false)?; + } + } SectionOp::Color(color) => { let root = self.root(); let page = self.active(root)?; @@ -173,7 +195,7 @@ impl<'a> Section<'a> { .roots .get(&2) .filter(|id| page.live.revision.objects.contains_key(id)) - .ok_or_else(|| invalid("The section has no metadata to colour"))?; + .ok_or_else(|| invalid("The section has no metadata to color"))?; let mut object = crate::write::PropertyObject::from_object( &page.live.revision.objects[&metadata], )?; @@ -185,10 +207,17 @@ impl<'a> Section<'a> { } /// Writes `page`'s content onto the page just created in `space`, whose title stays. - fn fill(&mut self, author: &str, space: ExGuid, page: &crate::page::Page) -> Result<(), Failure> { + fn fill( + &mut self, + author: &str, + space: ExGuid, + page: &crate::page::Page, + ) -> Result<(), Failure> { let before = self.page(space)?; let mut after = page.clone(); - after.objects.retain(|object| !matches!(object, PageObject::Title(_))); + after + .objects + .retain(|object| !matches!(object, PageObject::Title(_))); for object in &mut after.objects { // Stroke points are page coordinates; new ink takes its position from them. if let PageObject::Ink(ink) = object { @@ -298,7 +327,7 @@ impl<'a> Section<'a> { &[], BTreeMap::from([(manifest_id, manifest), (copy, metadata)]), )?; - self.has_conflicts(of, true) + self.flag_metadata(of, HAS_CONFLICT_PAGES, true) } /// Deletes conflict page `space` of page `of` as OneNote 2010 does: the manifest stops @@ -322,21 +351,21 @@ impl<'a> Section<'a> { } self.apply_changes(of, &[], BTreeMap::from([(manifest_id, manifest)]))?; if spaces.is_empty() { - self.has_conflicts(of, false)?; + self.flag_metadata(of, HAS_CONFLICT_PAGES, false)?; } Ok(()) } - /// Sets or clears `HasConflictPages` on page `of`'s metadata and on the section's copy + /// Sets or clears a Boolean `property` on page `of`'s metadata and on the section's copy /// of it, where its series keeps one. - fn has_conflicts(&mut self, of: ExGuid, value: bool) -> Result<(), Failure> { + fn flag_metadata(&mut self, of: ExGuid, property: u32, value: bool) -> Result<(), Failure> { use crate::write::PropertyObject; let flag = |object: &crate::Object<'_>| -> Result<PropertyObject, Error> { let mut object = PropertyObject::from_object(object)?; if value { - object.set(&[(0x88001d97, &[])])?; + object.set(&[(property, &[])])?; } else { - object.remove(&[0x88001d97])?; + object.remove(&[property])?; } Ok(object) }; @@ -399,7 +428,7 @@ fn stores_more_than_once(op: &Op) -> bool { } /// Why an op was not applied: a precondition it names, or what a writer refused. -enum Failure { +pub(crate) enum Failure { Refused(OpError), Rejected(Error), } @@ -524,7 +553,11 @@ impl<'a> Writer<'_, 'a> { (*child, target) }) .unzip(); - if current.iter().map(|(_, depth)| *depth).eq(depths.iter().copied()) { + if current + .iter() + .map(|(_, depth)| *depth) + .eq(depths.iter().copied()) + { return Ok(()); } let (level, runs) = levels::runs(&children, &depths, is_cell)?; @@ -592,13 +625,15 @@ impl<'a> Writer<'_, 'a> { } PageOp::Color(color) => self.write(|page| { let [node] = crate::active::manifest_pages(&page.view)[..] else { - return Err(invalid("The page has no page node to colour")); + return Err(invalid("The page has no page node to color")); }; let mut object = crate::write::PropertyObject::from_object(&page.live.revision.objects[&node])?; // OneNote's "No color" removes the property. match color { - Some(color) => object.set(&[(crate::page::PAGE_COLOR, &color.to_le_bytes())])?, + Some(color) => { + object.set(&[(crate::page::PAGE_COLOR, &color.to_le_bytes())])? + } None => object.remove(&[crate::page::PAGE_COLOR])?, } Ok(BTreeMap::from([(node, object)])) @@ -648,12 +683,9 @@ impl<'a> Writer<'_, 'a> { self.target(*paragraph)?; let container = self.container(*paragraph)?; let base = self.level(container)?; - let depth = level - .checked_sub(base) - .filter(|depth| *depth > 0) - .ok_or(OpError::Unsupported( - "A paragraph lies deeper than its parent", - ))?; + let depth = level.checked_sub(base).filter(|depth| *depth > 0).ok_or( + OpError::Unsupported("A paragraph lies deeper than its parent"), + )?; self.regroup(container, &[(*paragraph, depth)]) } PageOp::Outline { object, edit } => { @@ -703,7 +735,9 @@ impl<'a> Writer<'_, 'a> { .get(id) .is_some_and(|owners| owners.iter().any(|owner| owner != paragraph)) { - return Err(OpError::Unsupported("A list node belongs to one paragraph").into()); + return Err( + OpError::Unsupported("A list node belongs to one paragraph").into() + ); } } let nodes = lists @@ -833,7 +867,9 @@ impl<'a> Writer<'_, 'a> { let mut at = parent; loop { if at == object { - return Err(OpError::StructureChanged("A subtree cannot move inside itself").into()); + return Err( + OpError::StructureChanged("A subtree cannot move inside itself").into(), + ); } match self.page()?.parents.get(&at).map(Vec::as_slice) { Some([up]) => at = *up, @@ -844,9 +880,10 @@ impl<'a> Writer<'_, 'a> { Some(before) => { self.target(before)?; if self.container(before)? != parent { - return Err( - OpError::StructureChanged("The anchor is no child of the container").into(), - ); + return Err(OpError::StructureChanged( + "The anchor is no child of the container", + ) + .into()); } self.parent(before)? } @@ -877,9 +914,10 @@ impl<'a> Writer<'_, 'a> { Some(before) => { self.target(before)?; if self.container(before)? != container { - return Err( - OpError::StructureChanged("The anchor is no child of the container").into(), - ); + return Err(OpError::StructureChanged( + "The anchor is no child of the container", + ) + .into()); } self.parent(before)? } @@ -920,8 +958,13 @@ impl<'a> Writer<'_, 'a> { .level .checked_sub(parent_level) .filter(|depth| *depth > 0) - .ok_or(OpError::Unsupported("A paragraph lies deeper than its parent"))?; - targets.entry(parent).or_default().push((paragraph.id, depth)); + .ok_or(OpError::Unsupported( + "A paragraph lies deeper than its parent", + ))?; + targets + .entry(parent) + .or_default() + .push((paragraph.id, depth)); self.paragraph(holder, anchor, paragraph, None)?; } for (parent, targets) in targets { @@ -966,13 +1009,16 @@ impl<'a> Writer<'_, 'a> { match ¶graph.content { ParagraphContent::Text(target) => { if equation { - return self.write(|page| content::equation_changes(page, text_id, &target.text)); + return self + .write(|page| content::equation_changes(page, text_id, &target.text)); } let page = self.page()?; let stored = crate::page::text_of(&page.view, &page.parents, text_id)?; let values = paragraph_fields(&stored, &target.text)?.values()?; if !values.is_empty() { - self.write(|page| properties::paragraph_format_changes(page, text_id, &values))?; + self.write(|page| { + properties::paragraph_format_changes(page, text_id, &values) + })?; } let page = self.page()?; let stored = crate::page::text_of(&page.view, &page.parents, text_id)?; @@ -996,12 +1042,19 @@ impl<'a> Writer<'_, 'a> { .rows .iter() .flat_map(|row| &row.cells) - .map(|cell| (cell.id, (table::cell_indents(&cell.indents, None), cell.shading))) + .map(|cell| { + ( + cell.id, + (table::cell_indents(&cell.indents, None), cell.shading), + ) + }) .collect(), columns: &table.columns, borders: table.borders, }; - self.write(|page| table::table_changes(page, table.id, Some(paragraph.id), &structure))?; + self.write(|page| { + table::table_changes(page, table.id, Some(paragraph.id), &structure) + })?; for cell in table.rows.iter().flat_map(|row| &row.cells) { self.cell(cell)?; } @@ -1009,7 +1062,10 @@ impl<'a> Writer<'_, 'a> { } ParagraphContent::Image(image) => { if image.layout.x.is_some() || image.layout.y.is_some() { - return Err(OpError::Unsupported("A paragraph picture has no position of its own").into()); + return Err(OpError::Unsupported( + "A paragraph picture has no position of its own", + ) + .into()); } self.picture(image, Some(paragraph.id)) } @@ -1065,7 +1121,11 @@ impl<'a> Writer<'_, 'a> { self.free(stroke.id)?; } let data = fresh()?; - let strokes: Vec<_> = ink.strokes.iter().map(|stroke| (stroke.id, stroke)).collect(); + let strokes: Vec<_> = ink + .strokes + .iter() + .map(|stroke| (stroke.id, stroke)) + .collect(); self.write(|page| content::ink_changes(page, ink, ink.id, data, &strokes, holder)) } @@ -1104,7 +1164,9 @@ impl<'a> Writer<'_, 'a> { .level .checked_sub(parent_level) .filter(|depth| *depth > 0) - .ok_or(OpError::Unsupported("A paragraph lies deeper than its parent"))?; + .ok_or(OpError::Unsupported( + "A paragraph lies deeper than its parent", + ))?; self.paragraph(parent, None, paragraph, None)?; if parent == outline.id { targets.push((paragraph.id, depth)); @@ -1128,8 +1190,9 @@ impl<'a> Writer<'_, 'a> { let indents = content::measurement_bytes(&outline.indents, 4)?; let id = outline.id; self.write(|page| { - let mut node = - crate::write::PropertyObject::from_object(&page.live.revision.objects[&id])?; + let mut node = crate::write::PropertyObject::from_object( + &page.live.revision.objects[&id], + )?; node.set(&[(0x1c001c12, &indents)])?; Ok(BTreeMap::from([(id, node)])) })?; @@ -1137,13 +1200,17 @@ impl<'a> Writer<'_, 'a> { } PageObject::Image(image) => { if image.layout.x.is_none() || image.layout.y.is_none() { - return Err(OpError::Unsupported("A new page-level picture needs a position").into()); + return Err( + OpError::Unsupported("A new page-level picture needs a position").into(), + ); } self.picture(image, None)?; } PageObject::Ink(ink) => self.ink(ink, None)?, PageObject::Title(_) | PageObject::Unsupported(_) => { - return Err(OpError::Unsupported("Titles and unsupported objects cannot be added").into()); + return Err( + OpError::Unsupported("Titles and unsupported objects cannot be added").into(), + ); } } if before.is_some() { @@ -1178,13 +1245,13 @@ impl<'a> Writer<'_, 'a> { .map(|row| (*row, page.view.nodes[row].children.clone())) .collect(); let mut borders = *borders; - let template = rows - .first() - .and_then(|(_, cells)| cells.first()) - .and_then(|cell| match &page.view.nodes[cell].kind { - Kind::Cell { indents, .. } => Some(indents.clone()), - _ => None, - }); + let template = + rows.first() + .and_then(|(_, cells)| cells.first()) + .and_then(|cell| match &page.view.nodes[cell].kind { + Kind::Cell { indents, .. } => Some(indents.clone()), + _ => None, + }); let mut new_rows = BTreeSet::new(); let mut new_cells: BTreeMap<ExGuid, (Vec<f32>, Option<u32>)> = BTreeMap::new(); let mut filled: Vec<&TableCell> = Vec::new(); @@ -1195,17 +1262,22 @@ impl<'a> Writer<'_, 'a> { ) }; match edit { - TableEdit::Rows { before, rows: added } => { + TableEdit::Rows { + before, + rows: added, + } => { let at = match before { - Some(before) => rows - .iter() - .position(|(row, _)| row == before) - .ok_or(OpError::StructureChanged("The anchor row is not in the table"))?, + Some(before) => rows.iter().position(|(row, _)| row == before).ok_or( + OpError::StructureChanged("The anchor row is not in the table"), + )?, None => rows.len(), }; for row in added { if row.cells.len() != columns.len() { - return Err(OpError::Unsupported("Every table row has one cell per column").into()); + return Err(OpError::Unsupported( + "Every table row has one cell per column", + ) + .into()); } new_rows.insert(row.id); for cell in &row.cells { @@ -1245,13 +1317,18 @@ impl<'a> Writer<'_, 'a> { return Err(OpError::TargetUnavailable(*row).into()); } if rows.is_empty() { - return Err(OpError::Unsupported("A table keeps a row; delete the table instead").into()); + return Err(OpError::Unsupported( + "A table keeps a row; delete the table instead", + ) + .into()); } } TableEdit::DeleteColumn(at) => { let at = *at as usize; if at >= columns.len() || columns.len() == 1 { - return Err(OpError::Unsupported("Choose a column of a table with several").into()); + return Err( + OpError::Unsupported("Choose a column of a table with several").into(), + ); } columns.remove(at); for (_, cells) in &mut rows { @@ -1294,7 +1371,10 @@ impl<'a> Writer<'_, 'a> { for row in &new_rows { self.free(*row)?; } - if columns.iter().any(|c| !c.width.is_finite() || c.width < 36.0) { + if columns + .iter() + .any(|c| !c.width.is_finite() || c.width < 36.0) + { return Err(OpError::Unsupported("Table columns are at least 36 points wide").into()); } let structure = Structure { @@ -1348,7 +1428,11 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> { if !table.tags.is_empty() { return Err(OpError::Unsupported("Set a table's tags with their own op")); } - for paragraph in table.rows.iter().flat_map(|row| &row.cells).flat_map(|cell| &cell.paragraphs) + for paragraph in table + .rows + .iter() + .flat_map(|row| &row.cells) + .flat_map(|cell| &cell.paragraphs) { bare(paragraph)?; } @@ -1364,7 +1448,9 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> { /// The field code OneNote stores before a hyperlink's label. fn field_code(target: &str) -> Result<String, Error> { if target.is_empty() || target.contains(['"', '\0', '\r', '\n', '\u{fddf}']) { - return Err(invalid("Choose a link target without quotes or line breaks")); + return Err(invalid( + "Choose a link target without quotes or line breaks", + )); } Ok(format!("\u{fddf}HYPERLINK \"{target}\"")) } @@ -1449,6 +1535,8 @@ pub(crate) fn link_ops( clear: vec![TextProperty::Hyperlink, TextProperty::HyperlinkLabel], }), } - ops.retain(|op| !matches!(op, PageOp::Format { set, clear, .. } if set.is_empty() && clear.is_empty())); + ops.retain( + |op| !matches!(op, PageOp::Format { set, clear, .. } if set.is_empty() && clear.is_empty()), + ); Ok(ops) } diff --git a/crates/onestore/src/op/content.rs b/crates/onestore/src/op/content.rs index d4ff55ca870cfebf3f26828fee50e71387bc66fa..7cc5eac85331ef7f49ba0f50ba0dffd36742b002 100644 --- a/crates/onestore/src/op/content.rs +++ b/crates/onestore/src/op/content.rs @@ -184,7 +184,11 @@ fn layout_values(stored: &Layout, layout: &Layout) -> Result<(Values, Vec<u32>), Ok((values, removed)) } -fn icon_size(values: &mut Values, size: Option<[f32; 2]>, message: &'static str) -> Result<(), Error> { +fn icon_size( + values: &mut Values, + size: Option<[f32; 2]>, + message: &'static str, +) -> Result<(), Error> { if let Some([width, height]) = size { if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { return Err(invalid(message)); @@ -332,7 +336,11 @@ pub(crate) fn attachment_changes( .unwrap_or(""); let modified = crate::create::current_timestamps()?.0.to_le_bytes(); let mut values: Values = vec![(0x14001d7a, modified.to_vec())]; - icon_size(&mut values, attachment.size, "Attachment icon size must be positive")?; + icon_size( + &mut values, + attachment.size, + "Attachment icon size must be positive", + )?; values.push((0x14001c3b, 0x409_u32.to_le_bytes().to_vec())); values.push((0x10001cfe, 0x409_u16.to_le_bytes().to_vec())); values.push((0x1c001dcf, vec![0; 32])); @@ -412,7 +420,11 @@ pub(crate) fn attachment_edit_changes( } if attachment.size != stored.size { match attachment.size { - Some(_) => icon_size(&mut values, attachment.size, "Attachment icon size must be positive")?, + Some(_) => icon_size( + &mut values, + attachment.size, + "Attachment icon size must be positive", + )?, None => removed.extend([0x140034cd, 0x140034ce]), } } @@ -612,12 +624,7 @@ pub(crate) fn strokes_changes( kept: &[ExGuid], added: &[(ExGuid, &InkStroke)], ) -> Result<Changes, Error> { - let data = match active - .view - .nodes - .get(&container) - .map(|node| &node.kind) - { + let data = match active.view.nodes.get(&container).map(|node| &node.kind) { Some(crate::document::Kind::Ink { data: Some(data), .. }) => *data, diff --git a/crates/onestore/src/op/levels.rs b/crates/onestore/src/op/levels.rs index b015fe417e7d64573bc74674e63c2ed85ab51336..cf0300285b0b5e0a55459fef1ffb0d8935c6d695 100644 --- a/crates/onestore/src/op/levels.rs +++ b/crates/onestore/src/op/levels.rs @@ -37,7 +37,11 @@ pub(crate) type Runs = Vec<(u32, Vec<ExGuid>)>; /// The child level of a container whose children lie `depths` deeper than it, and its /// children in runs. -pub(crate) fn runs(children: &[ExGuid], depths: &[u32], is_cell: bool) -> Result<(u32, Runs), Error> { +pub(crate) fn runs( + children: &[ExGuid], + depths: &[u32], + is_cell: bool, +) -> Result<(u32, Runs), Error> { let level = *depths .iter() .min() diff --git a/crates/onestore/src/op/lower.rs b/crates/onestore/src/op/lower.rs index 995f4fafdb1bbf293c56264f3c604bf2a0b575f7..befc6740028540750b1624f9b54ecf54819332b0 100644 --- a/crates/onestore/src/op/lower.rs +++ b/crates/onestore/src/op/lower.rs @@ -577,7 +577,9 @@ fn attributes( } let color = |value: u32| (value != 0xff000000).then(|| value.to_le_bytes()[..3].try_into().unwrap()); - value!(font, Font, |font: &String| TextAttribute::Font(font.clone())); + value!(font, Font, |font: &String| TextAttribute::Font( + font.clone() + )); value!(font_size, FontSize, |size: &f32| TextAttribute::FontSize( *size )); @@ -690,9 +692,11 @@ pub(crate) fn same_tags(a: &[Tag], b: &[Tag]) -> bool { fn ordinary(text: &TextObject) -> bool { text.date_field.is_none() && !text.text.text().contains('\u{fffc}') - && text.text.spans().iter().all(|span| { - span.format.math != Some(true) && span.format.embedded_object != Some(true) - }) + && text + .text + .spans() + .iter() + .all(|span| span.format.math != Some(true) && span.format.embedded_object != Some(true)) } /// Whether a split at `offset` would divide a hyperlink, which the writer refuses. @@ -936,7 +940,13 @@ impl Lowering { continue; } let adopts_right = previous.text.text().is_empty(); - if after.id != if adopts_right { removed.id } else { previous.id } { + if after.id + != if adopts_right { + removed.id + } else { + previous.id + } + { continue; } self.emit(PageOp::Join { @@ -1025,7 +1035,11 @@ impl Lowering { } let object = match object { PageObject::Outline(outline) => { - if outline.paragraphs.iter().any(|p| old.paragraphs.contains_key(&p.id)) { + if outline + .paragraphs + .iter() + .any(|p| old.paragraphs.contains_key(&p.id)) + { return Err(invalid( "A new outline holds new paragraphs; move stored ones after", )); @@ -1233,11 +1247,21 @@ impl Lowering { let mut deleted: Option<Vec<u32>> = None; let mut added: Option<Vec<u32>> = None; for row in &kept_rows { - let old_cells: Vec<ExGuid> = before.rows.iter().find(|r| r.id == *row).unwrap().cells + let old_cells: Vec<ExGuid> = before + .rows + .iter() + .find(|r| r.id == *row) + .unwrap() + .cells .iter() .map(|cell| cell.id) .collect(); - let new_cells: Vec<ExGuid> = after.rows.iter().find(|r| r.id == *row).unwrap().cells + let new_cells: Vec<ExGuid> = after + .rows + .iter() + .find(|r| r.id == *row) + .unwrap() + .cells .iter() .map(|cell| cell.id) .collect(); @@ -1382,9 +1406,10 @@ impl Lowering { (ParagraphContent::Ink(stored), ParagraphContent::Ink(ink)) if stored != ink => { self.strokes(stored, ink)?; } - (ParagraphContent::Attachment(stored), ParagraphContent::Attachment(attachment)) - if stored != attachment => - { + ( + ParagraphContent::Attachment(stored), + ParagraphContent::Attachment(attachment), + ) if stored != attachment => { self.emit(PageOp::Attachment { attachment: attachment.id, filename: attachment.filename.clone(), @@ -1520,8 +1545,8 @@ impl Lowering { { continue; } - let stored = - stored.ok_or_else(|| invalid("An equation paragraph is missing after placement"))?; + let stored = stored + .ok_or_else(|| invalid("An equation paragraph is missing after placement"))?; if stored.text != text.text { self.emit(PageOp::Equation { text: stored.id, @@ -1611,10 +1636,14 @@ impl Lowering { let current = model::paragraph(&self.current, *id) .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; let same = paragraph.lists.len() == current.lists.len() - && paragraph.lists.iter().zip(¤t.lists).all(|(model, node)| { - model == node - && after.definitions.get(model) == self.current.definitions.get(node) - }); + && paragraph + .lists + .iter() + .zip(¤t.lists) + .all(|(model, node)| { + model == node + && after.definitions.get(model) == self.current.definitions.get(node) + }); if same { continue; } diff --git a/crates/onestore/src/op/mod.rs b/crates/onestore/src/op/mod.rs index cdda21dc027bd590d687e5bd132a187f58f71acf..14d00eb55180e548a797a1b34a2a5e7baf9b58ff 100644 --- a/crates/onestore/src/op/mod.rs +++ b/crates/onestore/src/op/mod.rs @@ -5,13 +5,16 @@ use crate::{ ExGuid, OutlineEdit, PageCreation, PageEdit, TextAttribute, document::{Layout, Tag}, - page::{Definition, InkStroke, Page, PageObject, PageParagraph, Paragraph, TableCell, - TableColumn, TableRow}, + page::{ + Definition, InkStroke, Page, PageObject, PageParagraph, Paragraph, TableCell, TableColumn, + TableRow, + }, }; use serde::{Deserialize, Serialize}; use std::ops::Range; mod apply; +pub(crate) use apply::Failure; pub(crate) mod content; pub(crate) mod levels; pub(crate) mod lower; @@ -73,7 +76,10 @@ pub enum PageOp { target: Option<String>, }, /// Rewrites an equation's linear text and spans whole. - Equation { text: ExGuid, math: Paragraph }, + Equation { + text: ExGuid, + math: Paragraph, + }, /// The page's date: its creation time, FILETIME, and the text each of the title's date /// and time fields shows for it, as OneNote 2010 writes both when the date changes. Date { @@ -101,7 +107,10 @@ pub enum PageOp { }, /// Appends the right text to the left one and removes the right paragraph; an empty /// left text is replaced by the right text object. - Join { left: ExGuid, right: ExGuid }, + Join { + left: ExGuid, + right: ExGuid, + }, /// Moves a subtree before a direct child of `parent`, or last; `None` names the page, /// whose children are outlines, pictures and ink. A paragraph keeps its level where it /// lies deeper than its new parent. @@ -111,11 +120,19 @@ pub enum PageOp { before: Option<ExGuid>, }, /// Removes a subtree. - Delete { object: ExGuid }, + Delete { + object: ExGuid, + }, /// Sets a paragraph's absolute outline level, regrouping its container. - Level { paragraph: ExGuid, level: u32 }, + Level { + paragraph: ExGuid, + level: u32, + }, /// Outline position and width, or a paragraph's saved expansion state. - Outline { object: ExGuid, edit: OutlineEdit }, + Outline { + object: ExGuid, + edit: OutlineEdit, + }, /// Paragraph formatting stored on a paragraph's text; `None` leaves a value as it is. Paragraph { paragraph: ExGuid, @@ -169,7 +186,10 @@ pub enum PageOp { add: Vec<InkStroke>, remove: Vec<ExGuid>, }, - Table { table: ExGuid, edit: TableEdit }, + Table { + table: ExGuid, + edit: TableEdit, + }, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -205,7 +225,10 @@ pub enum SectionOp { Create(PageCreation), /// A page created with `creation` holding `page`'s content under the identities it /// carries (`Page::copy` gives fresh ones); the created title stays. - Import { creation: PageCreation, page: Page }, + Import { + creation: PageCreation, + page: Page, + }, /// A read-only conflict page of the listed page `of`, as OneNote 2010 keeps the version of /// a page a merge could not take: created with `creation` (not placed, its author the user /// whose version it is, a title only where `page` has one) and holding `page` as `Import` @@ -223,6 +246,31 @@ pub enum SectionOp { /// The section's colour as COLORREF, kept in its own metadata; `None` is OneNote's /// "no colour". Color(Option<u32>), + /// Makes a version of a page (`Section::versions`) its current state, as OneNote 2010's + /// Restore Version does; the page as it stood becomes the newest version. New objects take + /// identities from `guid`. + RestoreVersion { + page: ExGuid, + version: ExGuid, + guid: [u8; 16], + }, + /// Deletes versions of a page, as OneNote 2010's Delete Version does; their revisions stay + /// stored. + DeleteVersions { + page: ExGuid, + versions: Vec<ExGuid>, + }, +} + +impl SectionOp { + /// `RestoreVersion` of `page`'s `version`, its new objects under a fresh identity. + pub fn restore(page: ExGuid, version: ExGuid) -> Result<Self, crate::Error> { + Ok(Self::RestoreVersion { + page, + version, + guid: crate::write::fresh_guid()?, + }) + } } /// A character property that `PageOp::Format` can clear so the text inherits it. diff --git a/crates/onestore/src/op/model.rs b/crates/onestore/src/op/model.rs index 09a4a5de195223048b05ea7c5a57ef4cbf6bb058..130e957b0fe48b37c374193a76e54549954227ec 100644 --- a/crates/onestore/src/op/model.rs +++ b/crates/onestore/src/op/model.rs @@ -53,7 +53,12 @@ pub(crate) fn lists(page: &Page) -> Vec<(Path, ExGuid, &Vec<PageParagraph>)> { for (object, value) in page.objects.iter().enumerate() { let outlines: Vec<(Option<usize>, &Outline)> = match value { PageObject::Outline(outline) => vec![(None, outline)], - PageObject::Title(title) => title.outlines.iter().enumerate().map(|(i, o)| (Some(i), o)).collect(), + PageObject::Title(title) => title + .outlines + .iter() + .enumerate() + .map(|(i, o)| (Some(i), o)) + .collect(), _ => Vec::new(), }; for (title_outline, outline) in outlines { @@ -88,24 +93,24 @@ pub(crate) fn list_at<'p>(page: &'p mut Page, path: &Path) -> &'p mut Vec<PagePa /// The list holding paragraph `id`, and its index there. fn find(page: &Page, id: ExGuid) -> Option<(Path, usize)> { lists(page).into_iter().find_map(|(path, _, list)| { - list.iter().position(|p| p.id == id).map(|index| (path, index)) + list.iter() + .position(|p| p.id == id) + .map(|index| (path, index)) }) } /// The list a container's children lie in: its own for an outline or cell, the one /// holding it for a paragraph. fn container(page: &Page, id: ExGuid) -> Option<(Path, Option<usize>)> { - lists(page) - .into_iter() - .find_map(|(path, owner, list)| { - if owner == id { - Some((path, None)) - } else { - list.iter() - .position(|p| p.id == id) - .map(|index| (path, Some(index))) - } - }) + lists(page).into_iter().find_map(|(path, owner, list)| { + if owner == id { + Some((path, None)) + } else { + list.iter() + .position(|p| p.id == id) + .map(|index| (path, Some(index))) + } + }) } /// The entries of `list` in the subtree of the paragraph at `index`. @@ -227,7 +232,8 @@ pub(crate) fn paragraph_text(page: &Page, text: ExGuid) -> Option<&TextObject> { } fn text_mut(page: &mut Page, text: ExGuid) -> Result<&mut TextObject, Error> { - let (path, index) = text_holder(page, text).ok_or_else(|| invalid("Text is not on the page"))?; + let (path, index) = + text_holder(page, text).ok_or_else(|| invalid("Text is not on the page"))?; Ok(list_at(page, &path)[index].text_mut().unwrap()) } @@ -269,7 +275,10 @@ pub(crate) fn replace_text( let format = format_in(text, range.start)?.clone(); let removed = range.end - range.start; if length - removed == 0 && with.is_empty() { - return Ok(Paragraph::new(String::new(), text.spans().last().unwrap().format.clone())); + return Ok(Paragraph::new( + String::new(), + text.spans().last().unwrap().format.clone(), + )); } let mut result = text.clone(); result.apply(crate::page::text::Edit { @@ -373,7 +382,10 @@ pub(crate) fn format_text( format }; if text.text().is_empty() { - return Ok(Paragraph::new(String::new(), apply(&text.spans()[0].format))); + return Ok(Paragraph::new( + String::new(), + apply(&text.spans()[0].format), + )); } let (start, end) = (text.byte_offset(range.start)?, text.byte_offset(range.end)?); let mut runs = Vec::new(); @@ -381,11 +393,19 @@ pub(crate) fn format_text( for span in text.spans() { for (from, to, selected) in [ (previous, start.clamp(previous, span.end), false), - (start.clamp(previous, span.end), end.clamp(previous, span.end), true), + ( + start.clamp(previous, span.end), + end.clamp(previous, span.end), + true, + ), (end.clamp(previous, span.end), span.end, false), ] { if from < to { - let format = if selected { apply(&span.format) } else { span.format.clone() }; + let format = if selected { + apply(&span.format) + } else { + span.format.clone() + }; runs.push((text.text()[from..to].to_owned(), format)); } } @@ -401,7 +421,9 @@ fn inserted(text: &Paragraph) -> Paragraph { macro_rules! shared { ($field:ident, $type:ty, $store:expr) => {{ let first = spans[0].format.$field.unwrap_or_default(); - (spans.iter().all(|s| s.format.$field.unwrap_or_default() == first) + (spans + .iter() + .all(|s| s.format.$field.unwrap_or_default() == first) && first != <$type>::default()) .then(|| $store(first)) }}; @@ -485,8 +507,8 @@ fn insert( before: Option<ExGuid>, paragraphs: Vec<PageParagraph>, ) -> Result<(), Error> { - let (path, owner) = - self::container(page, container).ok_or_else(|| invalid("The container is not on the page"))?; + let (path, owner) = self::container(page, container) + .ok_or_else(|| invalid("The container is not on the page"))?; let list = list_at(page, &path); let at = match before { Some(before) => list @@ -519,7 +541,9 @@ fn relevel(list: &mut [PageParagraph], index: usize, level: u32) { /// The title a page's writers store after changing it: the title text's first line when /// it has one, which only an edit of that text changes, else the first line of body text. fn retitle(page: &mut Page, edited: Option<ExGuid>) { - let line = |text: &str| text.trim_start().split('\r').next().unwrap_or_default().to_owned(); + let line = |text: &str| { + crate::edit::without_fields(text.trim_start().split('\r').next().unwrap_or_default()) + }; match find_title_text(page) { Some(title) => { let text = text_of(page, title); @@ -545,7 +569,7 @@ fn automatic(page: &mut Page) { .total_cmp(&b.y.unwrap_or(0.0)) .then_with(|| a.x.unwrap_or(0.0).total_cmp(&b.x.unwrap_or(0.0))) }); - fn first(list: &[PageParagraph]) -> Option<&str> { + fn first(list: &[PageParagraph]) -> Option<String> { list.iter().find_map(|paragraph| match ¶graph.content { ParagraphContent::Text(text) => { Some(crate::edit::automatic_title(text.text.text())).filter(|t| !t.is_empty()) @@ -564,8 +588,7 @@ fn automatic(page: &mut Page) { PageObject::Outline(outline) => first(&outline.paragraphs), _ => None, }) - .unwrap_or_default() - .to_owned(); + .unwrap_or_default(); page.title = title; } @@ -650,7 +673,10 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { let object = paragraph.text_mut().unwrap(); let previous = object.text.spans()[0].format.clone(); let math = Paragraph::from_runs(math.spans().iter().scan(0, |start, span| { - let run = (math.text()[*start..span.end].to_owned(), span.format.inherit(&inherited)); + let run = ( + math.text()[*start..span.end].to_owned(), + span.format.inherit(&inherited), + ); *start = span.end; Some(run) })); @@ -671,7 +697,8 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { right, lists, } => { - let (path, index) = text_holder(page, *text).ok_or_else(|| invalid("Text is not on the page"))?; + let (path, index) = + text_holder(page, *text).ok_or_else(|| invalid("Text is not on the page"))?; let definitions: Vec<_> = { let list = list_at(page, &path); list[index].lists.clone() @@ -722,7 +749,8 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { retitle(page, None); } PageOp::Join { left, right } => { - let (path, index) = text_holder(page, *right).ok_or_else(|| invalid("Text is not on the page"))?; + let (path, index) = + text_holder(page, *right).ok_or_else(|| invalid("Text is not on the page"))?; let (left_path, left_index) = text_holder(page, *left).ok_or_else(|| invalid("Text is not on the page"))?; let list = list_at(page, &path); @@ -778,7 +806,10 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { } } } - let run = (right_text.text.text()[previous..span.end].to_owned(), format); + let run = ( + right_text.text.text()[previous..span.end].to_owned(), + format, + ); previous = span.end; run })); @@ -849,7 +880,8 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { retitle(page, None); return Ok(()); }; - let (path, index) = find(page, *object).ok_or_else(|| invalid("The paragraph is not on the page"))?; + let (path, index) = + find(page, *object).ok_or_else(|| invalid("The paragraph is not on the page"))?; let list = list_at(page, &path); let mut moved: Vec<PageParagraph> = list.drain(subtree(list, index)).collect(); let into_cell = table_cells(page).contains(parent); @@ -884,7 +916,8 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { retitle(page, None); } PageOp::Level { paragraph, level } => { - let (path, index) = find(page, *paragraph).ok_or_else(|| invalid("The paragraph is not on the page"))?; + let (path, index) = find(page, *paragraph) + .ok_or_else(|| invalid("The paragraph is not on the page"))?; relevel(list_at(page, &path), index, *level); } PageOp::Outline { object, edit } => match edit { @@ -967,10 +1000,31 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { } )*}; } - restyle!(bold, italic, underline, strike, superscript, subscript, hidden, - hyperlink, hyperlink_label, math, embedded_object, font, font_size, - color, highlight, language, alignment, rtl, space_before, space_after, - line_spacing, list_spacing, math_object); + restyle!( + bold, + italic, + underline, + strike, + superscript, + subscript, + hidden, + hyperlink, + hyperlink_label, + math, + embedded_object, + font, + font_size, + color, + highlight, + language, + alignment, + rtl, + space_before, + space_after, + line_spacing, + list_spacing, + math_object + ); let run = (text.text.text()[previous..span.end].to_owned(), format); previous = span.end; run @@ -1052,7 +1106,9 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { y: layout.y.map(stored), max_width: layout.max_width.map(stored), max_height: layout.max_height.map(stored), - width_set_by_user: layout.max_width.map(|_| layout.width_set_by_user == Some(true)), + width_set_by_user: layout + .max_width + .map(|_| layout.width_set_by_user == Some(true)), reserved_width: image.layout.reserved_width, }; image.alt = alt.clone(); @@ -1071,7 +1127,8 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { .map(|index| (path, index)) }) .ok_or_else(|| invalid("The attachment is not on the page"))?; - let ParagraphContent::Attachment(stored_attachment) = &mut list_at(page, &path)[index].content + let ParagraphContent::Attachment(stored_attachment) = + &mut list_at(page, &path)[index].content else { unreachable!() }; @@ -1170,8 +1227,9 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { /// Removes outlines a tree edit left without paragraphs, as the tree writer does. fn drop_emptied(page: &mut Page) { - page.objects - .retain(|object| !matches!(object, PageObject::Outline(outline) if outline.paragraphs.is_empty())); + page.objects.retain( + |object| !matches!(object, PageObject::Outline(outline) if outline.paragraphs.is_empty()), + ); } /// The identity a read-only definition is stored under: an equal one the page holds, as @@ -1283,8 +1341,11 @@ fn text_of(page: &Page, text: ExGuid) -> String { lists(page) .into_iter() .find_map(|(_, _, list)| { - list.iter() - .find_map(|p| p.text().filter(|t| t.id == text).map(|t| t.text.text().to_owned())) + list.iter().find_map(|p| { + p.text() + .filter(|t| t.id == text) + .map(|t| t.text.text().to_owned()) + }) }) .unwrap_or_default() } diff --git a/crates/onestore/src/op/properties.rs b/crates/onestore/src/op/properties.rs index b33a621ba2813791bd8ea194a187ac9824f46ef3..ec5d3f0bab088be8d9c84b70b21fb23679b41731 100644 --- a/crates/onestore/src/op/properties.rs +++ b/crates/onestore/src/op/properties.rs @@ -258,8 +258,8 @@ pub(crate) fn style_changes( let raw = &active.live.revision; let mut changed = BTreeMap::new(); if !raw.objects.contains_key(&style) { - let definition = - definition.ok_or_else(|| invalid("A paragraph references a missing style definition"))?; + let definition = definition + .ok_or_else(|| invalid("A paragraph references a missing style definition"))?; let Kind::Style { name } = &definition.kind else { return Err(invalid("A paragraph style must be a style definition")); }; diff --git a/crates/onestore/src/op/tests.rs b/crates/onestore/src/op/tests.rs index 64eb03eb53306186e05e15aee2e5e05ca125a20d..398576c7095b0fd4b2b489396836bac1f33da432 100644 --- a/crates/onestore/src/op/tests.rs +++ b/crates/onestore/src/op/tests.rs @@ -15,21 +15,66 @@ use crate::{ use std::collections::{BTreeMap, BTreeSet}; const SOURCES: &[(&str, &[u8])] = &[ - ("outline-edit", include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one")), - ("paragraph-edit", include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one")), - ("outline-tree", include_bytes!("../../../../corpus/outline-edit/tree/before/notebook/synthetic.one")), - ("features", include_bytes!("../../../../corpus/m6/native-features-01/notebook/Features.one")), - ("tables", include_bytes!("../../../../corpus/table-edit/nested/cold/notebook/synthetic.one")), - ("table-controls", include_bytes!("../../../../corpus/m6/native-table-controls-01/notebook/synthetic.one")), - ("lists", include_bytes!("../../../../corpus/list-edit/cold/notebook/lists.one")), - ("tags", include_bytes!("../../../../corpus/tag-edit/cold/notebook/tags.one")), - ("pictures", include_bytes!("../../../../corpus/picture-edit/native-page-level/notebook/pictures.one")), - ("attachments", include_bytes!("../../../../corpus/attachment-edit/plain/cold/notebook/files.one")), - ("ink", include_bytes!("../../../../corpus/ink-edit/drawing/cold/notebook/ink.one")), - ("math", include_bytes!("../../../../corpus/math-edit/native-editor/notebook/links.one")), - ("links", include_bytes!("../../../../corpus/link-edit/native-links/notebook/links.one")), - ("paragraph-format", include_bytes!("../../../../corpus/paragraph-format/cold/notebook/synthetic.one")), - ("enter-probe", include_bytes!("../../../../corpus/structural-probe/probe.one")), + ( + "outline-edit", + include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"), + ), + ( + "paragraph-edit", + include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one"), + ), + ( + "outline-tree", + include_bytes!("../../../../corpus/outline-edit/tree/before/notebook/synthetic.one"), + ), + ( + "features", + include_bytes!("../../../../corpus/m6/native-features-01/notebook/Features.one"), + ), + ( + "tables", + include_bytes!("../../../../corpus/table-edit/nested/cold/notebook/synthetic.one"), + ), + ( + "table-controls", + include_bytes!("../../../../corpus/m6/native-table-controls-01/notebook/synthetic.one"), + ), + ( + "lists", + include_bytes!("../../../../corpus/list-edit/cold/notebook/lists.one"), + ), + ( + "tags", + include_bytes!("../../../../corpus/tag-edit/cold/notebook/tags.one"), + ), + ( + "pictures", + include_bytes!("../../../../corpus/picture-edit/native-page-level/notebook/pictures.one"), + ), + ( + "attachments", + include_bytes!("../../../../corpus/attachment-edit/plain/cold/notebook/files.one"), + ), + ( + "ink", + include_bytes!("../../../../corpus/ink-edit/drawing/cold/notebook/ink.one"), + ), + ( + "math", + include_bytes!("../../../../corpus/math-edit/native-editor/notebook/links.one"), + ), + ( + "links", + include_bytes!("../../../../corpus/link-edit/native-links/notebook/links.one"), + ), + ( + "paragraph-format", + include_bytes!("../../../../corpus/paragraph-format/cold/notebook/synthetic.one"), + ), + ( + "enter-probe", + include_bytes!("../../../../corpus/structural-probe/probe.one"), + ), ]; /// FILETIME both writers take as the edit's time. @@ -55,7 +100,15 @@ impl Rng { } fn text(&mut self) -> String { - let words = ["", "a", "Two words", "東京 🦀", "é\u{301}", "longer text here", " "]; + let words = [ + "", + "a", + "Two words", + "東京 🦀", + "é\u{301}", + "longer text here", + " ", + ]; words[self.next() as usize % words.len()].to_owned() } } @@ -92,7 +145,10 @@ pub(super) fn normalize(page: &Page) -> Page { let mut page = page.clone(); let mut lists = BTreeMap::new(); let mut canonical = BTreeMap::new(); - let mut paths: Vec<_> = model::lists(&page).into_iter().map(|(path, _, _)| path).collect(); + let mut paths: Vec<_> = model::lists(&page) + .into_iter() + .map(|(path, _, _)| path) + .collect(); paths.sort_by_key(|path| format!("{path:?}")); let definitions = page.definitions.clone(); for path in &paths { @@ -102,7 +158,9 @@ pub(super) fn normalize(page: &Page) -> Page { guid: [0xee; 16], n: 1 + canonical.len() as u32, }; - let content = definitions.get(list).map_or(format!("{list:?}"), |d| format!("{d:?}")); + let content = definitions + .get(list) + .map_or(format!("{list:?}"), |d| format!("{d:?}")); let id = *canonical.entry(content).or_insert(next); *list = *lists.entry(*list).or_insert(id); } @@ -185,26 +243,33 @@ fn as_written(after: &Page, before: &Page, stored: &Page) -> Page { fn adopt(after: &mut Value, stored: &Value, old: &BTreeSet<String>, new: bool) { match (after, stored) { (Value::Object(after), Value::Object(stored)) => { - let new = new - || matches!(after.get("id"), Some(Value::String(id)) if !old.contains(id)); + let new = + new || matches!(after.get("id"), Some(Value::String(id)) if !old.contains(id)); for (key, value) in after.iter_mut() { let Some(written) = stored.get(key) else { continue; }; // Ink coordinates are stored at a coarser resolution. let near = |a: &Value, b: &Value| { - a.as_f64().zip(b.as_f64()).is_some_and(|(a, b)| (a - b).abs() < 0.05) + a.as_f64() + .zip(b.as_f64()) + .is_some_and(|(a, b)| (a - b).abs() < 0.05) }; let close_points = key == "points" - && value.as_array().zip(written.as_array()).is_some_and(|(a, b)| { - a.len() == b.len() - && a.iter().zip(b).all(|(a, b)| { - a.as_array().zip(b.as_array()).is_some_and(|(a, b)| { - a.len() == b.len() && a.iter().zip(b).all(|(a, b)| near(a, b)) + && value + .as_array() + .zip(written.as_array()) + .is_some_and(|(a, b)| { + a.len() == b.len() + && a.iter().zip(b).all(|(a, b)| { + a.as_array().zip(b.as_array()).is_some_and(|(a, b)| { + a.len() == b.len() + && a.iter().zip(b).all(|(a, b)| near(a, b)) + }) }) - }) - }); - let unset = new && (value.is_null() || value.as_array().is_some_and(Vec::is_empty)); + }); + let unset = + new && (value.is_null() || value.as_array().is_some_and(Vec::is_empty)); if close_points || unset { *value = written.clone(); } else { @@ -223,7 +288,12 @@ fn as_written(after: &Page, before: &Page, stored: &Page) -> Page { let mut old = BTreeSet::new(); ids(&serde_json::to_value(before).unwrap(), &mut old); let mut value = serde_json::to_value(after).unwrap(); - adopt(&mut value, &serde_json::to_value(stored).unwrap(), &old, false); + adopt( + &mut value, + &serde_json::to_value(stored).unwrap(), + &old, + false, + ); let mut page: Page = serde_json::from_value(value).unwrap(); page.title = stored.title.clone(); unused_dropped(&mut page); @@ -323,11 +393,11 @@ pub(super) const FAMILIES: [Family; 33] = [ ]; const PNG: &[u8] = &[ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, - 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, - 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, - 0xcf, 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, - 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, + 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, + 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]; fn id() -> ExGuid { @@ -370,7 +440,9 @@ fn editable(page: &Page) -> Vec<model::Path> { .objects .iter() .filter_map(|o| match o { - PageObject::Title(title) => Some(title.outlines.iter().map(|o| o.id).collect::<Vec<_>>()), + PageObject::Title(title) => { + Some(title.outlines.iter().map(|o| o.id).collect::<Vec<_>>()) + } _ => None, }) .flatten() @@ -493,7 +565,13 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { 0 => format.bold = Some(!format.bold.unwrap_or(false)), 1 => format.italic = Some(!format.italic.unwrap_or(false)), 2 => format.underline = Some(!format.underline.unwrap_or(false)), - 3 => format.font_size = Some(if format.font_size == Some(14.0) { 11.0 } else { 14.0 }), + 3 => { + format.font_size = Some(if format.font_size == Some(14.0) { + 11.0 + } else { + 14.0 + }) + } 4 => format.color = Some(0x00ff), _ => format.font = Some("Consolas".into()), } @@ -510,7 +588,10 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { } _ => { if range.is_empty() - || text.spans().iter().any(|s| s.format.hyperlink == Some(true)) + || text + .spans() + .iter() + .any(|s| s.format.hyperlink == Some(true)) { return false; } @@ -579,7 +660,11 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { .filter(|i| list.get(*i).is_none_or(|p| p.parent.is_none())) .collect(); let at = boundaries[rng.pick(boundaries.len()).unwrap()]; - let count = if family == Family::Paste { 2 + rng.next() as usize % 3 } else { 1 }; + let count = if family == Family::Paste { + 2 + rng.next() as usize % 3 + } else { + 1 + }; let mut inserted = Vec::new(); for i in 0..count { let mut paragraph = text_paragraph(&rng.text(), template.clone()); @@ -612,7 +697,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { return false; }; let list = model::list_at(page, &path); - let top: Vec<usize> = (0..list.len()).filter(|i| list[*i].parent.is_none()).collect(); + let top: Vec<usize> = (0..list.len()) + .filter(|i| list[*i].parent.is_none()) + .collect(); match family { Family::Delete => { if top.len() < 2 { @@ -645,7 +732,8 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { } } Family::Position | Family::Width => { - let mut outlines: Vec<&mut Outline> = outlines(page).into_iter().filter(|o| !o.title).collect(); + let mut outlines: Vec<&mut Outline> = + outlines(page).into_iter().filter(|o| !o.title).collect(); let Some(at) = rng.pick(outlines.len()) else { return false; }; @@ -784,7 +872,11 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { let paragraph = &list[index]; let text = ¶graph.text().unwrap().text; let end = text.utf16_offset(text.text().len()).unwrap(); - if text.spans().iter().any(|s| s.format.hyperlink == Some(true)) { + if text + .spans() + .iter() + .any(|s| s.format.hyperlink == Some(true)) + { return false; } let offsets: Vec<u32> = text @@ -822,14 +914,18 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { let upper = &list[index]; if lower.parent != upper.parent || lower.level != upper.level - || list.iter().any(|p| p.parent == Some(upper.id) || p.parent == Some(lower.id)) + || list + .iter() + .any(|p| p.parent == Some(upper.id) || p.parent == Some(lower.id)) { return false; } let Some(lower_text) = lower.text().cloned() else { return false; }; - if lower_text.text.text().is_empty() || crate::page::Math::is_equation(&lower_text.text) { + if lower_text.text.text().is_empty() + || crate::page::Math::is_equation(&lower_text.text) + { return false; } let upper = list[index].text_mut().unwrap(); @@ -859,7 +955,12 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { if family == Family::Indent { // Tab: the paragraph becomes the last child of its previous sibling. let parent = list[index].parent; - let Some(sibling) = list[..index].iter().rev().find(|p| p.parent == parent).map(|p| (p.id, p.level)) else { + let Some(sibling) = list[..index] + .iter() + .rev() + .find(|p| p.parent == parent) + .map(|p| (p.id, p.level)) + else { return false; }; list[index].parent = Some(sibling.0); @@ -912,7 +1013,13 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { } Family::TableColumn => { let at = rng.pick(table.columns.len() + 1).unwrap(); - table.columns.insert(at, TableColumn { width: 72.0, locked: false }); + table.columns.insert( + at, + TableColumn { + width: 72.0, + locked: false, + }, + ); for row in &mut table.rows { row.cells.insert(at, new_cell(&indents)); } @@ -940,7 +1047,11 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { let cells: Vec<_> = table.rows.iter_mut().flat_map(|r| &mut r.cells).collect(); let len = cells.len(); let cell = cells.into_iter().nth(rng.pick(len).unwrap()).unwrap(); - cell.shading = if cell.shading.is_some() { None } else { Some(0x00ccff) }; + cell.shading = if cell.shading.is_some() { + None + } else { + Some(0x00ccff) + }; } } true @@ -948,7 +1059,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { Family::NewTable | Family::ParagraphPicture | Family::Attachment | Family::Equation => { let equation = page.objects.iter().find_map(|o| match o { PageObject::Outline(outline) => outline.paragraphs.iter().find_map(|p| { - p.text().filter(|t| crate::page::Math::is_equation(&t.text)).map(|t| t.text.clone()) + p.text() + .filter(|t| crate::page::Math::is_equation(&t.text)) + .map(|t| t.text.clone()) }), _ => None, }); @@ -959,7 +1072,13 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { paragraph.content = match family { Family::NewTable => ParagraphContent::Table(Table { id: id(), - columns: vec![TableColumn { width: 72.0, locked: false }; 2], + columns: vec![ + TableColumn { + width: 72.0, + locked: false + }; + 2 + ], rows: (0..2) .map(|_| TableRow { id: id(), @@ -1000,7 +1119,12 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { } }; let boundaries: Vec<usize> = (0..=outline.paragraphs.len()) - .filter(|i| outline.paragraphs.get(*i).is_none_or(|p| p.parent.is_none())) + .filter(|i| { + outline + .paragraphs + .get(*i) + .is_none_or(|p| p.parent.is_none()) + }) .collect(); let at = boundaries[rng.pick(boundaries.len()).unwrap()]; outline.paragraphs.insert(at, paragraph); @@ -1139,7 +1263,13 @@ pub(super) fn stored(source: &[u8], space: ExGuid, after: &Page, seed: u64) -> O }; let edit = Edit { at: AT, - ops: ops.iter().map(|op| Op::Page { space, op: op.clone() }).collect(), + ops: ops + .iter() + .map(|op| Op::Page { + space, + op: op.clone(), + }) + .collect(), }; if seeded(seed, || section.apply("Author", &edit)).is_err() { if section.page(space).unwrap() != before { @@ -1159,7 +1289,9 @@ pub(super) fn stored(source: &[u8], space: ExGuid, after: &Page, seed: u64) -> O )); } let Some(_) = seeded(seed, || section.seal().unwrap()) else { - return if live == before { Outcome::Unchanged } else { + return if live == before { + Outcome::Unchanged + } else { Outcome::Mismatch("the page changed and the seal stored nothing".into()) }; }; @@ -1171,12 +1303,18 @@ pub(super) fn stored(source: &[u8], space: ExGuid, after: &Page, seed: u64) -> O } let reread = read(&image, space); if reread != live { - return Outcome::Mismatch(format!("reads back otherwise: {}", first_difference(&live, &reread))); + return Outcome::Mismatch(format!( + "reads back otherwise: {}", + first_difference(&live, &reread) + )); } let written = as_written(after, &before, &reread); let (stored, wanted) = (normalize(&reread), normalize(&written)); if stored != wanted { - return Outcome::Mismatch(format!("stores another page: {}", first_difference(&wanted, &stored))); + return Outcome::Mismatch(format!( + "stores another page: {}", + first_difference(&wanted, &stored) + )); } Outcome::Stored } @@ -1187,7 +1325,16 @@ fn spans(text: &Paragraph) -> String { let f = &span.format; out.push_str(&format!( " [{} b{:?} h{:?} l{:?}/{:?} c{:?} a{:?} s{:?} lang{:?} f{:?}]", - span.end, f.bold, f.hidden, f.hyperlink, f.hyperlink_label, f.color, f.alignment, f.space_before, f.language, f.font + span.end, + f.bold, + f.hidden, + f.hyperlink, + f.hyperlink_label, + f.color, + f.alignment, + f.space_before, + f.language, + f.font )); } out @@ -1243,7 +1390,8 @@ fn lowered_edits_store_the_edited_page() { }; *tally.entry((family, key)).or_default() += 1; if let Outcome::Mismatch(detail) = outcome { - mismatches.push(format!("{name} page {p} {family:?} seed {seed}: {detail}")); + mismatches + .push(format!("{name} page {p} {family:?} seed {seed}: {detail}")); } } } @@ -1279,7 +1427,25 @@ fn debug_case() { model::apply(&mut page, op).unwrap(); } for object in &page.objects { - println!("{:?} {:?} {:?}", object.id(), object.layout(), match object { PageObject::Outline(o) => o.paragraphs.iter().filter_map(|p| p.text().map(|t| t.text.text().to_owned())).collect::<Vec<_>>(), PageObject::Title(t) => t.outlines.iter().flat_map(|o| &o.paragraphs).filter_map(|p| p.text().map(|t| format!("title {:?}", t.text.text()))).collect(), _ => vec![] }); + println!( + "{:?} {:?} {:?}", + object.id(), + object.layout(), + match object { + PageObject::Outline(o) => o + .paragraphs + .iter() + .filter_map(|p| p.text().map(|t| t.text.text().to_owned())) + .collect::<Vec<_>>(), + PageObject::Title(t) => t + .outlines + .iter() + .flat_map(|o| &o.paragraphs) + .filter_map(|p| p.text().map(|t| format!("title {:?}", t.text.text()))) + .collect(), + _ => vec![], + } + ); } println!("{:?} -> {:?}", before.title, page.title); } @@ -1334,7 +1500,11 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> { text, range, set: vec![TextAttribute::Bold(rng.coin())], - clear: if rng.next().is_multiple_of(3) { vec![TextProperty::Italic] } else { Vec::new() }, + clear: if rng.next().is_multiple_of(3) { + vec![TextProperty::Italic] + } else { + Vec::new() + }, }, 3 => PageOp::Split { text, @@ -1357,7 +1527,9 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> { 6 => PageOp::Move { object: paragraph, parent: Some(container), - before: siblings.get(rng.pick(siblings.len().max(1)).unwrap_or(0)).copied(), + before: siblings + .get(rng.pick(siblings.len().max(1)).unwrap_or(0)) + .copied(), }, 7 => PageOp::Level { paragraph, @@ -1377,7 +1549,10 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> { language: None, }, 10 => { - let right = texts.iter().skip_while(|(p, _, _)| *p != paragraph).nth(1)?; + let right = texts + .iter() + .skip_while(|(p, _, _)| *p != paragraph) + .nth(1)?; PageOp::Join { left: text, right: right.1, @@ -1505,7 +1680,10 @@ fn random_object_op(page: &Page, rng: &mut Rng) -> Option<PageOp> { }; let edit = match rng.next() % 7 { 0 => TableEdit::Rows { - before: table.rows.get(rng.pick(table.rows.len() + 1)?).map(|r| r.id), + before: table + .rows + .get(rng.pick(table.rows.len() + 1)?) + .map(|r| r.id), rows: vec![TableRow { id: id(), cells: table.columns.iter().map(|_| cell()).collect(), @@ -1639,7 +1817,10 @@ fn random_ops_read_back_as_the_model_predicts() { if ops.is_empty() { continue; } - let edit = Edit { at: AT + step as u64, ops }; + let edit = Edit { + at: AT + step as u64, + ops, + }; // Text whose emptied final run keeps an insertion style the model cannot show. let hidden: Vec<ExGuid> = section .active(space) @@ -1658,7 +1839,9 @@ fn random_ops_read_back_as_the_model_predicts() { applied += 1; let mut predicted = before.clone(); for op in &edit.ops { - let Op::Page { op, .. } = op else { unreachable!() }; + let Op::Page { op, .. } = op else { + unreachable!() + }; model::apply(&mut predicted, op).unwrap(); } let stored = section.page(space).unwrap(); @@ -1671,7 +1854,9 @@ fn random_ops_read_back_as_the_model_predicts() { matches!(op, Op::Page { op: PageOp::Style { paragraph, .. }, .. } if model::paragraph(&before, *paragraph).is_some_and(|p| p.style.is_some())) }); - if (touches_hidden || restyles) && normalize(&predicted) != normalize(&stored) { + if (touches_hidden || restyles) + && normalize(&predicted) != normalize(&stored) + { unseen += 1; continue; } @@ -1680,7 +1865,11 @@ fn random_ops_read_back_as_the_model_predicts() { .ops .iter() .map(|op| match op { - Op::Page { op, .. } => format!("{op:?}").split([' ', '{']).next().unwrap().to_owned(), + Op::Page { op, .. } => format!("{op:?}") + .split([' ', '{']) + .next() + .unwrap() + .to_owned(), Op::Section(_) => "Section".into(), }) .collect(); @@ -1693,7 +1882,12 @@ fn random_ops_read_back_as_the_model_predicts() { "{}: {}", owner.n, list.iter() - .map(|p| format!("{}<{:?}>@{}", p.id.n, p.parent.map(|p| p.n), p.level)) + .map(|p| format!( + "{}<{:?}>@{}", + p.id.n, + p.parent.map(|p| p.n), + p.level + )) .collect::<Vec<_>>() .join(" ") ) @@ -1701,7 +1895,13 @@ fn random_ops_read_back_as_the_model_predicts() { .collect::<Vec<_>>() .join("\n") }; - println!("ops {:?}\nbefore\n{}\nstored\n{}\npredicted\n{}", edit.ops, tree(&before), tree(&stored), tree(&predicted)); + println!( + "ops {:?}\nbefore\n{}\nstored\n{}\npredicted\n{}", + edit.ops, + tree(&before), + tree(&stored), + tree(&predicted) + ); } if mismatches.len() < 6 { println!( @@ -1715,7 +1915,10 @@ fn random_ops_read_back_as_the_model_predicts() { Err(OpError::Failed(error)) => panic!("{name} step {step}: {error:?}"), Err(_) => { refused += 1; - assert!(section.page(space).unwrap() == before, "{name} step {step}: a refused edit changed the page"); + assert!( + section.page(space).unwrap() == before, + "{name} step {step}: a refused edit changed the page" + ); } } if step % 7 == 6 || step + 1 == steps { @@ -1723,13 +1926,18 @@ fn random_ops_read_back_as_the_model_predicts() { let image = section.image(); let reopened = Section::open(&arena, image).unwrap(); for space in &spaces { - assert!(reopened.page(*space).unwrap() == section.page(*space).unwrap(), "{name} step {step}: reopening changed a page"); + assert!( + reopened.page(*space).unwrap() == section.page(*space).unwrap(), + "{name} step {step}: reopening changed a page" + ); } } } } } - println!("{applied} edits applied, {refused} refused, {unseen} restyled or typed into a hidden insertion style"); + println!( + "{applied} edits applied, {refused} refused, {unseen} restyled or typed into a hidden insertion style" + ); let mut kinds = BTreeMap::new(); for kind in &mismatches { *kinds.entry(kind.clone()).or_insert(0) += 1; @@ -1745,7 +1953,8 @@ fn random_ops_read_back_as_the_model_predicts() { fn section_ops_leave_the_page_list_they_describe() { let sources: Vec<Vec<u8>> = vec![ crate::create_section("pages.one", "First", "Author").unwrap(), - include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one").to_vec(), + include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one") + .to_vec(), SOURCES[3].1.to_vec(), ]; let mut applied = 0; @@ -1756,16 +1965,31 @@ fn section_ops_leave_the_page_list_they_describe() { for step in 0..12u64 { let listed = section.pages().unwrap(); let spaces: Vec<ExGuid> = listed.iter().map(|(space, ..)| *space).collect(); - let anchor = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied().filter(|_| rng.coin()); + let anchor = spaces + .get(rng.pick(spaces.len()).unwrap_or(0)) + .copied() + .filter(|_| rng.coin()); let op = match step % 4 { - 0 => SectionOp::Create(crate::PageCreation::new(anchor, Some("Created"), "Author").unwrap()), + 0 => SectionOp::Create( + crate::PageCreation::new(anchor, Some("Created"), "Author").unwrap(), + ), 1 => { - let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied() else { continue }; - let level = if spaces[0] == space { 1 } else { 1 + (rng.next() % 2) as u32 }; + let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied() + else { + continue; + }; + let level = if spaces[0] == space { + 1 + } else { + 1 + (rng.next() % 2) as u32 + }; SectionOp::Pages(vec![crate::PageEdit::set_level(space, level).unwrap()]) } 2 => { - let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied() else { continue }; + let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied() + else { + continue; + }; SectionOp::Pages(vec![crate::PageEdit::move_to(space, None, 1).unwrap()]) } _ => { @@ -1775,7 +1999,10 @@ fn section_ops_leave_the_page_list_they_describe() { SectionOp::Delete(vec![spaces[rng.pick(spaces.len()).unwrap()]]) } }; - let edit = Edit { at: AT, ops: vec![Op::Section(op.clone())] }; + let edit = Edit { + at: AT, + ops: vec![Op::Section(op.clone())], + }; if section.apply("Author", &edit).is_err() { assert_eq!(section.pages().unwrap(), listed, "{n} step {step} {op:?}"); continue; @@ -1784,11 +2011,17 @@ fn section_ops_leave_the_page_list_they_describe() { applied += 1; let after = section.pages().unwrap(); let order = |list: &[(ExGuid, String, u32)], without: ExGuid| -> Vec<ExGuid> { - list.iter().map(|(space, ..)| *space).filter(|space| *space != without).collect() + list.iter() + .map(|(space, ..)| *space) + .filter(|space| *space != without) + .collect() }; match &op { SectionOp::Create(creation) => { - let at = after.iter().position(|(space, ..)| *space == creation.space()).unwrap(); + let at = after + .iter() + .position(|(space, ..)| *space == creation.space()) + .unwrap(); assert_eq!(after[at].1, "Created"); assert_eq!(order(&after, creation.space()), spaces, "{n} step {step}"); match anchor { @@ -1798,7 +2031,10 @@ fn section_ops_leave_the_page_list_they_describe() { } SectionOp::Pages(edits) => { let space = edits[0].space(); - let at = after.iter().position(|(listed, ..)| *listed == space).unwrap(); + let at = after + .iter() + .position(|(listed, ..)| *listed == space) + .unwrap(); assert_eq!(after[at].2, edits[0].level(), "{n} step {step} {op:?}"); if edits[0].position() == crate::PagePosition::Keep { assert_eq!(order(&after, space), order(&listed, space)); @@ -1808,11 +2044,16 @@ fn section_ops_leave_the_page_list_they_describe() { } } SectionOp::Delete(deleted) => { - assert_eq!(after.iter().map(|(s, ..)| *s).collect::<Vec<_>>(), order(&listed, deleted[0])); - } - SectionOp::Import { .. } | SectionOp::Conflict { .. } | SectionOp::Color(_) => { - unreachable!() + assert_eq!( + after.iter().map(|(s, ..)| *s).collect::<Vec<_>>(), + order(&listed, deleted[0]) + ); } + SectionOp::Import { .. } + | SectionOp::Conflict { .. } + | SectionOp::Color(_) + | SectionOp::RestoreVersion { .. } + | SectionOp::DeleteVersions { .. } => unreachable!(), } let image = section.image(); let store = Store::parse(&image).unwrap(); @@ -1830,7 +2071,11 @@ fn section_ops_leave_the_page_list_they_describe() { .collect(); assert_eq!(reread, after, "{n} step {step}"); for (space, ..) in &after { - assert_eq!(read(&image, *space), section.page(*space).unwrap(), "{n} step {step}"); + assert_eq!( + read(&image, *space), + section.page(*space).unwrap(), + "{n} step {step}" + ); } } } @@ -1851,21 +2096,40 @@ fn an_imported_page_reads_back_as_its_copy() { let arena = Arena::default(); let mut section = Section::open(&arena, target.clone()).unwrap(); let import = |section: &mut Section<'_>, op: SectionOp| { - section.apply("Author", &Edit { at: AT, ops: vec![Op::Section(op)] }) + section.apply( + "Author", + &Edit { + at: AT, + ops: vec![Op::Section(op)], + }, + ) }; import(&mut section, SectionOp::Create(creation.clone())).unwrap(); let created = section.page(creation.space()).unwrap(); let mut expected = created.clone(); let arena = Arena::default(); let mut section = Section::open(&arena, target.clone()).unwrap(); - if import(&mut section, SectionOp::Import { creation: creation.clone(), page: copy.clone() }).is_err() { + if import( + &mut section, + SectionOp::Import { + creation: creation.clone(), + page: copy.clone(), + }, + ) + .is_err() + { continue; } section.seal().unwrap(); let image = section.image(); let store = Store::parse(&image).unwrap(); - RevisionIndex::parse(&store).unwrap().validate_current().unwrap(); - expected.objects.retain(|object| matches!(object, PageObject::Title(_))); + RevisionIndex::parse(&store) + .unwrap() + .validate_current() + .unwrap(); + expected + .objects + .retain(|object| matches!(object, PageObject::Title(_))); expected.objects.extend( copy.objects .iter() @@ -1876,16 +2140,24 @@ fn an_imported_page_reads_back_as_its_copy() { let stored = read(&image, creation.space()); // New page children stack as the writers add them. let order: Vec<ExGuid> = stored.objects.iter().map(PageObject::id).collect(); - expected.objects.sort_by_key(|object| order.iter().position(|id| *id == object.id())); + expected + .objects + .sort_by_key(|object| order.iter().position(|id| *id == object.id())); let mut expected = as_written(&expected, &created, &stored); // An outline's height follows its content; no op carries it. for (object, stored) in expected.objects.iter_mut().zip(&stored.objects) { - if let (PageObject::Outline(outline), PageObject::Outline(stored)) = (object, stored) { + if let (PageObject::Outline(outline), PageObject::Outline(stored)) = + (object, stored) + { outline.layout.max_height = stored.layout.max_height; } } let (ours, theirs) = (normalize(&stored), normalize(&expected)); - assert!(ours == theirs, "{name}: {}", first_difference(&theirs, &ours)); + assert!( + ours == theirs, + "{name}: {}", + first_difference(&theirs, &ours) + ); imported += 1; } } @@ -1902,7 +2174,15 @@ fn lower_range(before: &Page, after: &Page) -> Option<Result<Vec<PageOp>, crate: match (a, b) { (PageObject::Outline(a), PageObject::Outline(b)) if a.id == b.id => { if a.paragraphs != b.paragraphs { - if changed.is_some() || (Outline { paragraphs: Vec::new(), ..a.clone() }) != (Outline { paragraphs: Vec::new(), ..b.clone() }) { + if changed.is_some() + || (Outline { + paragraphs: Vec::new(), + ..a.clone() + }) != (Outline { + paragraphs: Vec::new(), + ..b.clone() + }) + { return None; } changed = Some((a, b)); @@ -1924,7 +2204,11 @@ fn lower_range(before: &Page, after: &Page) -> Option<Result<Vec<PageOp>, crate: let (mut end_a, mut end_b) = (a.len() - suffix, b.len() - suffix); // A range holds the children of what it changes. while end_a < a.len() { - let ids: Vec<ExGuid> = a[prefix..end_a].iter().chain(&b[prefix..end_b]).map(|p| p.id).collect(); + let ids: Vec<ExGuid> = a[prefix..end_a] + .iter() + .chain(&b[prefix..end_b]) + .map(|p| p.id) + .collect(); if a[end_a].parent.is_some_and(|parent| ids.contains(&parent)) { end_a += 1; end_b += 1; @@ -1932,23 +2216,49 @@ fn lower_range(before: &Page, after: &Page) -> Option<Result<Vec<PageOp>, crate: break; } } - let outline = match before.objects.iter().find(|o| matches!(o, PageObject::Outline(o) if o.paragraphs == *a)) { + let outline = match before + .objects + .iter() + .find(|o| matches!(o, PageObject::Outline(o) if o.paragraphs == *a)) + { Some(PageObject::Outline(outline)) => outline.id, _ => return None, }; let mut definitions = before.definitions.clone(); definitions.extend(after.definitions.clone()); - Some(lower(outline, &a[prefix..end_a], &b[prefix..end_b], b.get(end_b), &definitions)) + Some(lower( + outline, + &a[prefix..end_a], + &b[prefix..end_b], + b.get(end_b), + &definitions, + )) } /// Lowering the changed range of an outline stores what lowering the whole page does. #[test] fn a_range_lowers_as_its_page_does() { let families = [ - Family::Text, Family::Format, Family::Insert, Family::Paste, Family::Delete, Family::Move, - Family::Collapse, Family::List, Family::Tag, Family::Split, Family::Join, Family::Indent, - Family::Outdent, Family::Alignment, Family::Style, Family::Link, Family::NewTable, - Family::ParagraphPicture, Family::Attachment, Family::Equation, + Family::Text, + Family::Format, + Family::Insert, + Family::Paste, + Family::Delete, + Family::Move, + Family::Collapse, + Family::List, + Family::Tag, + Family::Split, + Family::Join, + Family::Indent, + Family::Outdent, + Family::Alignment, + Family::Style, + Family::Link, + Family::NewTable, + Family::ParagraphPicture, + Family::Attachment, + Family::Equation, ]; let (mut same, mut total, mut smaller) = (0, 0, 0); for (name, source) in SOURCES { @@ -1974,18 +2284,32 @@ fn a_range_lowers_as_its_page_does() { let mut section = Section::open(&arena, source.to_vec()).unwrap(); let edit = Edit { at: AT, - ops: ops.iter().map(|op| Op::Page { space, op: op.clone() }).collect(), + ops: ops + .iter() + .map(|op| Op::Page { + space, + op: op.clone(), + }) + .collect(), }; - section.apply("Author", &edit).map(|()| normalize(§ion.page(space).unwrap())) + section + .apply("Author", &edit) + .map(|()| normalize(§ion.page(space).unwrap())) }; let (ours, theirs) = (write(&ranged), write(&whole)); match (ours, theirs) { (Ok(ours), Ok(theirs)) => { - assert!(ours == theirs, "{name} page {p} {family:?} seed {seed}: {}", first_difference(&theirs, &ours)); + assert!( + ours == theirs, + "{name} page {p} {family:?} seed {seed}: {}", + first_difference(&theirs, &ours) + ); same += 1; } (Err(_), Err(_)) => same += 1, - (ours, theirs) => panic!("{name} page {p} {family:?} seed {seed}: {ours:?} vs {theirs:?}"), + (ours, theirs) => { + panic!("{name} page {p} {family:?} seed {seed}: {ours:?} vs {theirs:?}") + } } let length = |ops: &[PageOp]| format!("{ops:?}").len(); smaller += usize::from(length(&ranged) <= length(&whole)); @@ -2012,7 +2336,9 @@ fn keystroke_ops() { let image = std::fs::read(&path).unwrap(); let space = pages(&image) .into_iter() - .max_by_key(|space| read(&image, *space).objects.len() + format!("{:?}", read(&image, *space)).len()) + .max_by_key(|space| { + read(&image, *space).objects.len() + format!("{:?}", read(&image, *space)).len() + }) .unwrap(); let page = read(&image, space); let outline = page @@ -2027,7 +2353,11 @@ fn keystroke_ops() { let middle = outline.paragraphs.len() / 2; let paragraph = &outline.paragraphs[middle]; let text = paragraph.text().unwrap().id; - println!("{path}: {} bytes, {} paragraphs in the outline", image.len(), outline.paragraphs.len()); + println!( + "{path}: {} bytes, {} paragraphs in the outline", + image.len(), + outline.paragraphs.len() + ); // Emitting: a keystroke is one op; a generic range replacement lowers its paragraphs. let mut edited = paragraph.clone(); @@ -2087,7 +2417,11 @@ fn keystroke_ops() { seals.push(start.elapsed()); sizes.push(( transaction.append.len(), - transaction.patches.iter().map(|(_, bytes)| bytes.len()).sum::<usize>(), + transaction + .patches + .iter() + .map(|(_, bytes)| bytes.len()) + .sum::<usize>(), )); } sizes.sort(); @@ -2122,20 +2456,42 @@ fn op_gate_candidate() { at.get() }; let apply = |section: &mut Section<'_>, op: Op| { - let result = section.apply("Gate author", &Edit { at: next(), ops: vec![op] }); + let result = section.apply( + "Gate author", + &Edit { + at: next(), + ops: vec![op], + }, + ); section.seal().unwrap(); result }; let mut imported = Vec::new(); for (name, bytes) in SOURCES.iter().filter(|(name, _)| *name != "enter-probe") { - let Some(space) = pages(bytes).first().copied() else { continue }; + let Some(space) = pages(bytes).first().copied() else { + continue; + }; let page = read(bytes, space); - if page.objects.iter().any(|o| matches!(o, PageObject::Unsupported(_))) { + if page + .objects + .iter() + .any(|o| matches!(o, PageObject::Unsupported(_))) + { continue; } let Ok(copy) = page.copy() else { continue }; - let creation = crate::PageCreation::new(None, Some(&format!("Imported {name}")), "Gate author").unwrap(); - if apply(&mut section, Op::Section(SectionOp::Import { creation: creation.clone(), page: copy })).is_ok() { + let creation = + crate::PageCreation::new(None, Some(&format!("Imported {name}")), "Gate author") + .unwrap(); + if apply( + &mut section, + Op::Section(SectionOp::Import { + creation: creation.clone(), + page: copy, + }), + ) + .is_ok() + { imported.push(creation.space()); } } @@ -2163,10 +2519,16 @@ fn op_gate_candidate() { .collect() }; let edited = fields(&before); - if fields(&after).iter().any(|(id, text)| edited.iter().any(|(e, old)| e == id && old != text)) { + if fields(&after) + .iter() + .any(|(id, text)| edited.iter().any(|(e, old)| e == id && old != text)) + { continue; } - if section.apply_page("Gate author", next(), *space, &after).is_ok() { + if section + .apply_page("Gate author", next(), *space, &after) + .is_ok() + { section.seal().unwrap(); edits += 1; } @@ -2174,12 +2536,20 @@ fn op_gate_candidate() { } // A created page: margins, an outline, and its first move adding the margin origin. let creation = crate::PageCreation::new(None, Some("Created by ops"), "Gate author").unwrap(); - apply(&mut section, Op::Section(SectionOp::Create(creation.clone()))).unwrap(); + apply( + &mut section, + Op::Section(SectionOp::Create(creation.clone())), + ) + .unwrap(); let outline = Outline { id: id(), title: false, min_width: None, - layout: Layout { x: Some(36.0), y: Some(86.4), ..Default::default() }, + layout: Layout { + x: Some(36.0), + y: Some(86.4), + ..Default::default() + }, indents: Vec::new(), paragraphs: vec![text_paragraph("Typed through ops", Format::default())], unsupported: Vec::new(), @@ -2188,26 +2558,87 @@ fn op_gate_candidate() { let text = outline.paragraphs[0].text().unwrap().id; let space = creation.space(); let page = |op| Op::Page { space, op }; - apply(&mut section, page(PageOp::Add { object: PageObject::Outline(outline), before: None })).unwrap(); - apply(&mut section, page(PageOp::Outline { object: outline_id, edit: crate::OutlineEdit::Position { x: 72.0, y: 108.0 } })).unwrap(); - apply(&mut section, page(PageOp::Text { text, range: 5..5, with: " and sealed".into() })).unwrap(); - apply(&mut section, page(PageOp::Link { text, range: 0..5, target: Some("https://example.invalid/ops".into()) })).unwrap(); - let spaces: Vec<ExGuid> = section.pages().unwrap().into_iter().map(|(s, _, _)| s).collect(); - apply(&mut section, Op::Section(SectionOp::Pages(vec![crate::PageEdit::move_to(space, Some(spaces[1]), 1).unwrap()]))).unwrap(); - apply(&mut section, Op::Section(SectionOp::Pages(vec![crate::PageEdit::set_level(spaces[2], 2).unwrap()]))).unwrap(); - apply(&mut section, Op::Section(SectionOp::Delete(vec![*spaces.last().unwrap()]))).unwrap(); + apply( + &mut section, + page(PageOp::Add { + object: PageObject::Outline(outline), + before: None, + }), + ) + .unwrap(); + apply( + &mut section, + page(PageOp::Outline { + object: outline_id, + edit: crate::OutlineEdit::Position { x: 72.0, y: 108.0 }, + }), + ) + .unwrap(); + apply( + &mut section, + page(PageOp::Text { + text, + range: 5..5, + with: " and sealed".into(), + }), + ) + .unwrap(); + apply( + &mut section, + page(PageOp::Link { + text, + range: 0..5, + target: Some("https://example.invalid/ops".into()), + }), + ) + .unwrap(); + let spaces: Vec<ExGuid> = section + .pages() + .unwrap() + .into_iter() + .map(|(s, _, _)| s) + .collect(); + apply( + &mut section, + Op::Section(SectionOp::Pages(vec![ + crate::PageEdit::move_to(space, Some(spaces[1]), 1).unwrap(), + ])), + ) + .unwrap(); + apply( + &mut section, + Op::Section(SectionOp::Pages(vec![ + crate::PageEdit::set_level(spaces[2], 2).unwrap(), + ])), + ) + .unwrap(); + apply( + &mut section, + Op::Section(SectionOp::Delete(vec![*spaces.last().unwrap()])), + ) + .unwrap(); let image = section.image(); let store = Store::parse(&image).unwrap(); - RevisionIndex::parse(&store).unwrap().validate_current().unwrap(); + RevisionIndex::parse(&store) + .unwrap() + .validate_current() + .unwrap(); let count = section.pages().unwrap().len(); std::fs::write(directory.join("ops.one"), &image).unwrap(); std::fs::write( directory.join("Open Notebook.onetoc2"), - crate::create_table_of_contents("Open Notebook.onetoc2", &[("ops.one", store.header.file_id)]).unwrap(), + crate::create_table_of_contents( + "Open Notebook.onetoc2", + &[("ops.one", store.header.file_id)], + ) + .unwrap(), ) .unwrap(); std::fs::write(directory.join("expected-count.txt"), count.to_string()).unwrap(); - println!("{} imported pages, {edits} lowered edits, {count} pages", imported.len()); + println!( + "{} imported pages, {edits} lowered edits, {count} pages", + imported.len() + ); } /// OneNote 2010's own date change (`corpus/page-date/native`, COM `UpdatePageContent` @@ -2229,9 +2660,9 @@ fn changed_jcids(image: &[u8], space: ExGuid) -> Vec<u32> { .objects .iter() .filter(|(id, object)| { - old.objects.get(id).is_none_or(|old| { - format!("{:?}", old.data) != format!("{:?}", object.data) - }) + old.objects + .get(id) + .is_none_or(|old| format!("{:?}", old.data) != format!("{:?}", object.data)) }) .map(|(_, object)| object.jcid) .collect(); @@ -2256,10 +2687,19 @@ const DATED_AT: u64 = 133_961_211_000_000_000; fn a_page_date_stores_what_onenote_stores() { let spaces = pages(DATED); let title = |space: &ExGuid| read(DATED, *space).title; - let kept = *spaces.iter().find(|space| title(space) == "Kept date").unwrap(); - let changed = *spaces.iter().find(|space| title(space) == "Changed date").unwrap(); + let kept = *spaces + .iter() + .find(|space| title(space) == "Kept date") + .unwrap(); + let changed = *spaces + .iter() + .find(|space| title(space) == "Changed date") + .unwrap(); let native = changed_jcids(DATED, changed); - assert_eq!(native, [0x20030, 0x6000c, 0x6000d, 0x6000d, 0x6000e, 0x6000e]); + assert_eq!( + native, + [0x20030, 0x6000c, 0x6000d, 0x6000d, 0x6000e, 0x6000e] + ); let arena = Arena::default(); let mut section = Section::open(&arena, DATED.to_vec()).unwrap(); @@ -2278,7 +2718,13 @@ fn a_page_date_stores_what_onenote_stores() { let mut predicted = before.clone(); model::apply(&mut predicted, &op).unwrap(); section - .apply("Author", &Edit { at: AT, ops: vec![Op::Page { space: kept, op }] }) + .apply( + "Author", + &Edit { + at: AT, + ops: vec![Op::Page { space: kept, op }], + }, + ) .unwrap(); section.seal().unwrap(); let image = section.image(); @@ -2288,20 +2734,36 @@ fn a_page_date_stores_what_onenote_stores() { assert_eq!(stored, predicted); assert_eq!(changed_jcids(&image, kept), native); let store = Store::parse(&image).unwrap(); - RevisionIndex::parse(&store).unwrap().validate_current().unwrap(); + RevisionIndex::parse(&store) + .unwrap() + .validate_current() + .unwrap(); // The whole-page lowering reaches the same page through the same op. let mut after = before.clone(); after.created = Some(DATED_AT); - model::apply(&mut after, &PageOp::Date { created: DATED_AT, fields }).unwrap(); - assert!(matches!(&lower_page(&before, &after).unwrap()[..], [PageOp::Date { .. }])); + model::apply( + &mut after, + &PageOp::Date { + created: DATED_AT, + fields, + }, + ) + .unwrap(); + assert!(matches!( + &lower_page(&before, &after).unwrap()[..], + [PageOp::Date { .. }] + )); if let Some(directory) = std::env::var_os("ONESTORE_PAGE_DATE_EXPORT") { let directory = std::path::Path::new(&directory); std::fs::create_dir_all(directory).unwrap(); std::fs::write(directory.join("pictures.one"), &image).unwrap(); std::fs::copy( - concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/page-date/native/notebook/Open Notebook.onetoc2"), + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../corpus/page-date/native/notebook/Open Notebook.onetoc2" + ), directory.join("Open Notebook.onetoc2"), ) .unwrap(); diff --git a/crates/onestore/src/page/linear.rs b/crates/onestore/src/page/linear.rs new file mode 100644 index 0000000000000000000000000000000000000000..e1b9b9f604aecaa16491820fe64826ae98a3b4a2 --- /dev/null +++ b/crates/onestore/src/page/linear.rs @@ -0,0 +1,741 @@ +//! Equations in the linear format of OneNote's equation editor (UnicodeMath): what Linear +//! shows and what typing builds up. `Math::from_linear` reads the typed or shown text into the +//! tree `Math::paragraph` stores; `Math::linear` writes a tree the way Linear shows it. Both +//! follow the equation editor as observed (`corpus/math-edit/native-linear`): a space that +//! ends a built object is consumed, other spaces stay, and an n-ary operator takes the rest +//! of its row as its body. + +use super::Math; +use super::math::{italic, plain}; +use super::text::Paragraph; +use crate::document::Format; + +/// N-ary operators, which take limits and a body when a script follows them. +const NARY: &[char] = &[ + '∑', '∏', '∐', '∫', '∬', '∭', '∮', '∯', '∰', '⋀', '⋁', '⋂', '⋃', '⨀', '⨁', '⨂', '⨄', '⨅', '⨆', +]; + +/// Function names the equation editor sets upright; those that take a limit below take it +/// from a subscript. +const FUNCTIONS: &[&str] = &[ + "lim", "max", "min", "sup", "inf", "det", "gcd", "sin", "cos", "tan", "sec", "csc", "cot", + "sinh", "cosh", "tanh", "log", "ln", "exp", "arg", +]; +const LIMITS: &[&str] = &["lim", "max", "min", "sup", "inf", "det", "gcd"]; + +/// The equation editor's control words: `\name` and the space after it become the symbol. +const CONTROL_WORDS: &[(&str, char)] = &[ + ("alpha", 'α'), + ("beta", 'β'), + ("gamma", 'γ'), + ("delta", 'δ'), + ("epsilon", 'ϵ'), + ("varepsilon", 'ε'), + ("zeta", 'ζ'), + ("eta", 'η'), + ("theta", 'θ'), + ("vartheta", 'ϑ'), + ("iota", 'ι'), + ("kappa", 'κ'), + ("lambda", 'λ'), + ("mu", 'μ'), + ("nu", 'ν'), + ("xi", 'ξ'), + ("pi", 'π'), + ("rho", 'ρ'), + ("sigma", 'σ'), + ("tau", 'τ'), + ("upsilon", 'υ'), + ("phi", 'ϕ'), + ("varphi", 'φ'), + ("chi", 'χ'), + ("psi", 'ψ'), + ("omega", 'ω'), + ("Gamma", 'Γ'), + ("Delta", 'Δ'), + ("Theta", 'Θ'), + ("Lambda", 'Λ'), + ("Xi", 'Ξ'), + ("Pi", 'Π'), + ("Sigma", 'Σ'), + ("Upsilon", 'Υ'), + ("Phi", 'Φ'), + ("Psi", 'Ψ'), + ("Omega", 'Ω'), + ("to", '→'), + ("rightarrow", '→'), + ("leftarrow", '←'), + ("times", '×'), + ("div", '÷'), + ("pm", '±'), + ("mp", '∓'), + ("infty", '∞'), + ("le", '≤'), + ("ge", '≥'), + ("ne", '≠'), + ("approx", '≈'), + ("equiv", '≡'), + ("sim", '∼'), + ("propto", '∝'), + ("cdot", '⋅'), + ("partial", '∂'), + ("nabla", '∇'), + ("in", '∈'), + ("notin", '∉'), + ("subset", '⊂'), + ("supset", '⊃'), + ("cup", '∪'), + ("cap", '∩'), + ("forall", '∀'), + ("exists", '∃'), + ("ll", '≪'), + ("gg", '≫'), + ("ldots", '…'), + ("cdots", '⋯'), + ("sum", '∑'), + ("prod", '∏'), + ("coprod", '∐'), + ("int", '∫'), + ("iint", '∬'), + ("iiint", '∭'), + ("oint", '∮'), + ("bigcap", '⋂'), + ("bigcup", '⋃'), + ("sqrt", '√'), + ("cbrt", '∛'), + ("qdrt", '∜'), + ("matrix", '■'), + ("eqarray", '█'), + ("box", '□'), + ("rect", '▭'), + ("overline", '¯'), + ("above", '┴'), + ("below", '┬'), + ("naryand", '▒'), + ("hat", '\u{302}'), + ("tilde", '\u{303}'), + ("bar", '\u{305}'), + ("dot", '\u{307}'), + ("ddot", '\u{308}'), + ("check", '\u{30c}'), + ("vec", '\u{20d7}'), +]; + +fn object(kind: u32, symbols: Vec<char>, columns: Option<u8>, arguments: Vec<Vec<Math>>) -> Math { + Math::Object { + kind, + symbols, + columns, + arguments, + } +} + +fn accent(c: char) -> bool { + matches!(u32::from(c), 0x300..=0x36f | 0x20d0..=0x20ff) +} + +fn closing(open: char) -> Option<char> { + Some(match open { + '(' => ')', + '[' => ']', + '{' => '}', + '⟨' => '⟩', + '〖' => '〗', + _ => return None, + }) +} + +struct Parser { + chars: Vec<char>, + at: usize, + /// Text still being typed: a construct whose last operand is missing stays as typed. + typing: bool, +} + +impl Parser { + fn peek(&self) -> Option<char> { + self.chars.get(self.at).copied() + } + + /// A space ending a built object triggered the build and is not kept. + fn built(&mut self, out: &mut Vec<Math>, node: Math) { + out.push(node); + if self.peek() == Some(' ') { + self.at += 1; + } + } + + fn sequence(&mut self, stops: &[char]) -> Vec<Math> { + let mut out = Vec::new(); + while let Some(c) = self.peek() { + if stops.contains(&c) { + break; + } + self.element(&mut out, stops); + } + out + } + + fn element(&mut self, out: &mut Vec<Math>, stops: &[char]) { + let c = self.peek().unwrap(); + let next = self.chars.get(self.at + 1).copied(); + match c { + '/' => { + self.at += 1; + let denominator = stripped(self.run(stops)); + if self.typing && denominator.is_empty() { + out.push(Math::Operator(c)); + return; + } + let start = out + .iter() + .rposition(|node| matches!(node, Math::Operator(_))) + .map_or(0, |at| at + 1); + let numerator = stripped(out.split_off(start)); + self.built( + out, + object(16, vec!['/'], None, vec![numerator, denominator]), + ); + } + '^' | '_' => self.script(out), + '┴' | '┬' => { + self.at += 1; + let argument = self.operand(); + if self.typing && argument.is_empty() { + out.push(Math::Operator(c)); + return; + } + let base = out.pop().into_iter().collect(); + let kind = if c == '┴' { 33 } else { 19 }; + self.built(out, object(kind, vec![c], None, vec![base, argument])); + } + '\u{a0}' if next.is_some_and(accent) => self.at += 1, + c if accent(c) => { + self.at += 1; + if matches!(out.last(), Some(Math::Operator(' ' | '\u{a0}'))) { + out.pop(); + } + let base = stripped(out.pop().into_iter().collect()); + self.built(out, object(10, vec![c], None, vec![base])); + } + c if NARY.contains(&c) && matches!(next, Some('_' | '^' | '▒')) => { + self.at += 1; + let limits = self.at; + let (mut lower, mut upper) = (None, None); + loop { + match self.peek() { + Some('_') if lower.is_none() => { + self.at += 1; + lower = Some(self.operand()); + } + Some('^') if upper.is_none() => { + self.at += 1; + upper = Some(self.operand()); + } + _ => break, + } + } + if self.typing && [&lower, &upper].contains(&&Some(Vec::new())) { + self.at = limits; + out.push(Math::Operator(c)); + return; + } + let body = match self.peek() { + Some('▒') => { + self.at += 1; + self.operand() + } + Some(' ') => { + self.at += 1; + self.sequence(stops) + } + _ => self.sequence(stops), + }; + out.push(object( + 21, + vec![c], + None, + vec![lower.unwrap_or_default(), upper.unwrap_or_default(), body], + )); + } + '〖' => { + self.at += 1; + let inner = self.sequence(&['〗']); + if self.peek() == Some('〗') { + self.at += 1; + } + out.extend(inner); + } + _ => { + let node = self.factor(); + if matches!(node, Math::Object { .. }) { + self.built(out, node); + } else { + out.push(node); + } + } + } + } + + fn script(&mut self, out: &mut Vec<Math>) { + let first = self.peek().unwrap(); + self.at += 1; + let script = self.operand(); + if self.typing && script.is_empty() { + out.push(Math::Operator(first)); + return; + } + let base: Vec<Math> = out.pop().into_iter().collect(); + let other = if first == '_' { '^' } else { '_' }; + let node = if self.peek() == Some(other) { + self.at += 1; + let second = self.operand(); + let (lower, upper) = if first == '_' { + (script, second) + } else { + (second, script) + }; + object(30, vec!['_'], None, vec![base, lower, upper]) + } else if first == '_' + && matches!(&base[..], [Math::Function(name)] if LIMITS.contains(&name.as_str())) + { + object(19, vec!['_'], None, vec![base, script]) + } else { + let kind = if first == '^' { 31 } else { 29 }; + object(kind, vec![first], None, vec![base, script]) + }; + self.built(out, node); + } + + /// One factor as an object's argument: parentheses and the invisible grouping brackets + /// only group it. + fn operand(&mut self) -> Vec<Math> { + match self.peek() { + Some(open @ ('(' | '〖')) => { + let start = self.at; + self.at += 1; + let close = closing(open).unwrap(); + let inner = self.sequence(&[close]); + if self.peek() == Some(close) { + self.at += 1; + } else if self.typing { + // A group still open is still being typed. + self.at = start; + return Vec::new(); + } + inner + } + // An argument not typed yet stays empty for the caret. + Some(' ') | None => Vec::new(), + Some(_) => vec![self.factor()], + } + } + + /// Juxtaposed factors with their scripts, as a fraction's denominator. + fn run(&mut self, stops: &[char]) -> Vec<Math> { + let mut out = Vec::new(); + while let Some(c) = self.peek() { + if stops.contains(&c) { + break; + } + let starts = c.is_alphanumeric() + || closing(c).is_some() + || matches!(c, '√' | '∛' | '∜' | '■' | '█' | '□' | '▭' | '¯'); + if matches!(c, '^' | '_') && !out.is_empty() { + self.script(&mut out); + } else if starts { + out.push(self.factor()); + } else { + break; + } + } + out + } + + fn factor(&mut self) -> Math { + let c = self.peek().unwrap(); + self.at += 1; + if c.is_ascii_digit() { + let mut number = c.to_string(); + while let Some(digit) = self.peek().filter(char::is_ascii_digit) { + number.push(digit); + self.at += 1; + } + return Math::Number(number); + } + if c.is_ascii_alphabetic() { + let word: String = self.chars[self.at - 1..] + .iter() + .take_while(|c| c.is_ascii_alphabetic()) + .collect(); + if FUNCTIONS.contains(&word.as_str()) { + self.at += word.len() - 1; + return Math::Function(word); + } + return Math::Identifier(c); + } + if c.is_alphabetic() { + return Math::Identifier(plain(c)); + } + if let Some(close) = closing(c) { + let start = self.at; + let inner = self.sequence(&[close]); + if self.peek() == Some(close) { + self.at += 1; + return object(13, vec![c, close], None, vec![inner]); + } + self.at = start; + return Math::Operator(c); + } + match c { + '√' | '∛' | '∜' => { + let degree = match c { + '∛' => vec![Math::Number("3".into())], + '∜' => vec![Math::Number("4".into())], + _ => Vec::new(), + }; + let (degree, radicand) = if c == '√' && self.peek() == Some('(') { + self.at += 1; + let first = self.sequence(&[')', '&']); + let parts = if self.peek() == Some('&') { + self.at += 1; + (first, self.sequence(&[')'])) + } else { + (degree, first) + }; + if self.peek() == Some(')') { + self.at += 1; + } + parts + } else { + (degree, self.operand()) + }; + if self.typing && radicand.is_empty() { + return Math::Operator(c); + } + object(25, vec!['√'], None, vec![degree, radicand]) + } + '■' | '█' if self.peek() == Some('(') => { + self.at += 1; + let matrix = c == '■'; + let mut rows = vec![Vec::new()]; + loop { + let stops: &[char] = if matrix { + &[')', '&', '@'] + } else { + &[')', '@'] + }; + let cell = self.sequence(stops); + rows.last_mut().unwrap().push(cell); + match self.peek() { + Some('&') => self.at += 1, + Some('@') => { + self.at += 1; + rows.push(Vec::new()); + } + Some(_) => { + self.at += 1; + break; + } + None => break, + } + } + if matrix { + let columns = rows.iter().map(Vec::len).max().unwrap_or(1); + let arguments = rows + .into_iter() + .flat_map(|mut row| { + row.resize(columns, Vec::new()); + row + }) + .collect(); + object(20, vec!['■'], u8::try_from(columns).ok(), arguments) + } else { + let arguments = rows.into_iter().flatten().collect(); + object(15, vec!['█'], Some(1), arguments) + } + } + '□' | '▭' | '¯' => { + let kind = match c { + '□' => 11, + '▭' => 12, + _ => 23, + }; + let inner = self.operand(); + if self.typing && inner.is_empty() { + return Math::Operator(c); + } + object(kind, vec![c], None, vec![inner]) + } + c => Math::Operator(c), + } + } +} + +/// A lone parenthesized group as an argument only groups it. +fn stripped(nodes: Vec<Math>) -> Vec<Math> { + match <[Math; 1]>::try_from(nodes) { + Ok( + [ + Math::Object { + kind: 13, + symbols, + mut arguments, + .. + }, + ], + ) if symbols == ['(', ')'] && arguments.len() == 1 => arguments.pop().unwrap(), + Ok([node]) => vec![node], + Err(nodes) => nodes, + } +} + +/// Replaces control words with their symbols; an unknown one stays as typed. +fn expand(text: &str) -> Vec<char> { + let chars: Vec<char> = text.chars().collect(); + let mut out = Vec::with_capacity(chars.len()); + let mut at = 0; + while at < chars.len() { + if chars[at] == '\\' { + let name: String = chars[at + 1..] + .iter() + .take_while(|c| c.is_ascii_alphabetic()) + .collect(); + if let Some((_, symbol)) = CONTROL_WORDS.iter().find(|(word, _)| *word == name) { + out.push(*symbol); + at += 1 + name.len(); + if chars.get(at) == Some(&' ') { + at += 1; + } + continue; + } + } + out.push(chars[at]); + at += 1; + } + out +} + +impl Math { + /// The equation typed or shown as `text` in the linear format, built up. + pub fn from_linear(text: &str) -> Vec<Math> { + Parser { + chars: expand(text), + at: 0, + typing: false, + } + .sequence(&[]) + } + + /// What the equation editor builds as `text` is typed: as [`Math::from_linear`], but a + /// construct still missing its last operand stays as typed, and an n-ary operator's + /// empty body waits for the caret. + pub fn typed(text: &str) -> Vec<Math> { + Parser { + chars: expand(text), + at: 0, + typing: true, + } + .sequence(&[]) + } + + /// The paragraph OneNote stores for an equation shown in the linear format: its linear + /// text in one run of math over `base`. + pub fn linear_paragraph(nodes: &[Math], base: &Format) -> Option<Paragraph> { + Some(Paragraph::new(Self::linear(nodes)?, Self::format(base))) + } + + /// The linear format Linear shows for `nodes`, letters in mathematical italic; `None` + /// where an object has no linear form here. + pub fn linear(nodes: &[Math]) -> Option<String> { + let mut out = String::new(); + write(nodes, &mut out)?; + Some(out) + } +} + +fn write(nodes: &[Math], out: &mut String) -> Option<()> { + let mut after_object = false; + for node in nodes { + match node { + Math::Identifier(c) => out.push(italic(*c)), + Math::Function(name) => out.push_str(name), + Math::Number(n) => out.push_str(n), + // Reading consumes one space after an object. + Math::Operator(' ') if after_object => out.push_str(" "), + Math::Operator(c) => out.push(*c), + Math::Object { + kind, + symbols, + arguments, + columns, + } => write_object(*kind, symbols, *columns, arguments, out)?, + } + after_object = matches!(node, Math::Object { .. }); + } + Some(()) +} + +/// An argument that reads back as one factor stands bare; others are parenthesized. +fn argument(nodes: &[Math], out: &mut String) -> Option<()> { + let bare = match nodes { + [Math::Identifier(_) | Math::Number(_) | Math::Function(_)] => true, + [Math::Object { kind, symbols, .. }] => { + matches!(kind, 11 | 12 | 15 | 20 | 23 | 25) || *kind == 13 && symbols[..] != ['(', ')'] + } + _ => false, + }; + if bare { + return write(nodes, out); + } + out.push('('); + write(nodes, out)?; + out.push(')'); + Some(()) +} + +/// A fraction's part stands bare when it reads back as one run of factors. +fn part(nodes: &[Math], out: &mut String) -> Option<()> { + let bare = !nodes.is_empty() + && !matches!(nodes, [Math::Object { kind: 13, symbols, .. }] if symbols[..] == ['(', ')']) + && nodes.iter().all(|node| match node { + Math::Operator(_) => false, + Math::Object { kind, .. } => !matches!(kind, 10 | 16 | 19 | 21 | 33), + _ => true, + }); + if bare { + return write(nodes, out); + } + out.push('('); + write(nodes, out)?; + out.push(')'); + Some(()) +} + +fn write_object( + kind: u32, + symbols: &[char], + columns: Option<u8>, + arguments: &[Vec<Math>], + out: &mut String, +) -> Option<()> { + match (kind, arguments) { + (31, [base, sup]) => { + argument(base, out)?; + out.push('^'); + argument(sup, out) + } + (29, [base, sub]) => { + argument(base, out)?; + out.push('_'); + argument(sub, out) + } + (30, [base, sub, sup]) => { + argument(base, out)?; + out.push('_'); + argument(sub, out)?; + out.push('^'); + argument(sup, out) + } + (16, [numerator, denominator]) => { + part(numerator, out)?; + out.push('/'); + part(denominator, out) + } + (13, [inner]) => { + out.push(symbols.first().copied().unwrap_or('(')); + write(inner, out)?; + out.push(symbols.get(1).copied().unwrap_or(')')); + Some(()) + } + (21, [lower, upper, body]) => { + out.push(symbols.first().copied().unwrap_or('∑')); + if !lower.is_empty() { + out.push('_'); + argument(lower, out)?; + } + if !upper.is_empty() { + out.push('^'); + argument(upper, out)?; + } + out.push('▒'); + match &body[..] { + [Math::Identifier(_) | Math::Number(_)] => write(body, out), + _ => { + out.push('〖'); + write(body, out)?; + out.push('〗'); + Some(()) + } + } + } + (25, [degree, radicand]) => { + match °ree[..] { + [] => out.push('√'), + [Math::Number(n)] if n == "3" => out.push('∛'), + [Math::Number(n)] if n == "4" => out.push('∜'), + _ => { + out.push_str("√("); + write(degree, out)?; + out.push('&'); + write(radicand, out)?; + out.push(')'); + return Some(()); + } + } + argument(radicand, out) + } + (19, [base, below]) => { + argument(base, out)?; + out.push(if symbols == ['_'] { '_' } else { '┬' }); + argument(below, out) + } + (33, [base, above]) => { + argument(base, out)?; + out.push('┴'); + argument(above, out) + } + (10, [base]) => { + argument(base, out)?; + out.push('\u{a0}'); + out.push(*symbols.first()?); + Some(()) + } + (11, [inner]) if symbols == ['□'] => { + out.push('□'); + argument(inner, out) + } + (12, [inner]) if symbols == ['▭'] => { + out.push('▭'); + argument(inner, out) + } + (23, [inner]) if symbols == ['¯'] => { + out.push('¯'); + argument(inner, out) + } + (20, cells) => { + let width = usize::from(columns?.max(1)); + out.push_str("■("); + for (index, cell) in cells.iter().enumerate() { + if index > 0 { + out.push(if index % width == 0 { '@' } else { '&' }); + } + write(cell, out)?; + } + out.push(')'); + Some(()) + } + (15, rows) => { + out.push_str("█("); + for (index, row) in rows.iter().enumerate() { + if index > 0 { + out.push('@'); + } + write(row, out)?; + } + out.push(')'); + Some(()) + } + _ => None, + } +} diff --git a/crates/onestore/src/page/link.rs b/crates/onestore/src/page/link.rs index e33e4b7b6057f0d3f71ca12d45048003d47530a4..6727b5a7408fbc9ffd91a39140fc4957ce958fca 100644 --- a/crates/onestore/src/page/link.rs +++ b/crates/onestore/src/page/link.rs @@ -71,7 +71,8 @@ pub struct InternalLink { pub object: Option<ExGuid>, } -/// The identities in an `onenote:#…` link, or `None` for any other URL. +/// The identities in an `onenote:` link, whatever path it names before the `#` (none as +/// stored, the section file's as Copy Link to Page gives it), or `None` for any other URL. pub fn parse_internal_link(url: &str) -> Option<InternalLink> { let guid = |value: &str| { format!("{value},0") @@ -79,7 +80,8 @@ pub fn parse_internal_link(url: &str) -> Option<InternalLink> { .ok() .map(|id| id.guid) }; - let parts: Vec<&str> = url.strip_prefix("onenote:#")?.split('&').collect(); + let (_, fragment) = url.strip_prefix("onenote:")?.split_once('#')?; + let parts: Vec<&str> = fragment.split('&').collect(); let value = |name: &str| { parts.iter().find_map(|part| { part.strip_prefix(name) diff --git a/crates/onestore/src/page/math.rs b/crates/onestore/src/page/math.rs index fe596d1829df6f894bd675fab62ecab097f7437c..b8f2483781a73319620b53652110d67d6ead36fc 100644 --- a/crates/onestore/src/page/math.rs +++ b/crates/onestore/src/page/math.rs @@ -149,6 +149,10 @@ impl Math { Math::Identifier(c) => tag(out, "mi", &c.to_string()), Math::Function(name) => tag(out, "mi", name), Math::Number(n) => tag(out, "mn", n), + // A parenthesis an object does not pair, as in linear text, is no fence. + Math::Operator(c @ ('(' | ')')) => { + out.push_str(&format!("<mml:mo fence=\"false\">{c}</mml:mo>")); + } Math::Operator(c) => tag(out, "mo", &c.to_string()), Math::Object { kind, @@ -346,7 +350,7 @@ fn tag(out: &mut String, element: &str, content: &str) { /// OneNote exports mathematical italic Latin letters as their plain letters and leaves every /// other alphabet (Greek, double-struck, …) as stored. -fn plain(c: char) -> char { +pub(crate) fn plain(c: char) -> char { match u32::from(c) { code @ 0x1d434..=0x1d44d => char::from_u32(code - 0x1d434 + u32::from('A')).unwrap(), code @ 0x1d44e..=0x1d467 => char::from_u32(code - 0x1d44e + u32::from('a')).unwrap(), @@ -358,27 +362,32 @@ fn plain(c: char) -> char { impl Math { /// The paragraph OneNote stores for `nodes`: linear text with object controls, every run /// formatted the way the equation editor formats it (Cambria Math, italic, the math flags - /// and the math language over `base`), and the run data each object carries. + /// and the math language over `base`; function names upright), and the run data each object + /// carries. + /// How the equation editor formats math typed over `base`, before it builds up: as a + /// run between objects. + pub fn format(base: &crate::document::Format) -> crate::document::Format { + let mut format = style(base); + format.math_object = Some(MathObject { + kind: PLAIN_RUN, + arguments: None, + columns: None, + symbols: Vec::new(), + }); + format + } + pub fn paragraph(nodes: &[Math], base: &crate::document::Format) -> Paragraph { - let mut style = base.clone(); - style.italic = Some(true); - style.hidden = Some(false); - style.hyperlink = Some(false); - style.math = Some(true); - style.embedded_object = Some(true); - style.font = Some("Cambria Math".into()); - style.language = Some(0x1007f); - let mut runs: Vec<(String, Option<MathObject>)> = Vec::new(); - write_sequence(nodes, &mut runs); - Paragraph::from_runs(runs.into_iter().map(|(text, object)| { + let style = style(base); + let mut runs = Vec::new(); + write_sequence(nodes, None, &mut runs); + Paragraph::from_runs(runs.into_iter().map(|run| { let mut format = style.clone(); - format.math_object = Some(object.unwrap_or(MathObject { - kind: PLAIN_RUN, - arguments: None, - columns: None, - symbols: Vec::new(), - })); - (text, format) + if run.upright { + format.italic = Some(false); + } + format.math_object = Some(run.object); + (run.text, format) })) } } @@ -386,12 +395,56 @@ impl Math { /// The object kind OneNote gives runs between objects. const PLAIN_RUN: u32 = 0x9000_0000; -fn write_sequence(nodes: &[Math], runs: &mut Vec<(String, Option<MathObject>)>) { +/// How the equation editor formats math over `base`. +fn style(base: &crate::document::Format) -> crate::document::Format { + let mut style = base.clone(); + style.italic = Some(true); + style.hidden = Some(false); + style.hyperlink = Some(false); + style.hyperlink_label = None; + style.math = Some(true); + style.embedded_object = Some(true); + style.font = Some("Cambria Math".into()); + style.language = Some(0x1007f); + style +} + +struct Run { + text: String, + object: MathObject, + upright: bool, + /// Text alone, which the control ending its argument may join. + leaf: bool, +} + +/// Runs for `nodes`, an argument of an object of kind `within` or the equation itself. Text +/// in an argument carries its object's kind, as the equation editor stores it. +fn write_sequence(nodes: &[Math], within: Option<u32>, runs: &mut Vec<Run>) { + let object = || MathObject { + kind: within.unwrap_or(PLAIN_RUN), + arguments: None, + columns: None, + symbols: Vec::new(), + }; let mut leaf = String::new(); + let flush = |leaf: &mut String, runs: &mut Vec<Run>, upright: bool| { + if !leaf.is_empty() { + runs.push(Run { + text: std::mem::take(leaf), + object: object(), + upright, + leaf: true, + }); + } + }; for node in nodes { match node { Math::Identifier(c) => leaf.push(italic(*c)), - Math::Function(name) => leaf.push_str(name), + Math::Function(name) => { + flush(&mut leaf, runs, false); + leaf.push_str(name); + flush(&mut leaf, runs, true); + } Math::Number(n) => leaf.push_str(n), Math::Operator(c) => leaf.push(*c), Math::Object { @@ -400,21 +453,22 @@ fn write_sequence(nodes: &[Math], runs: &mut Vec<(String, Option<MathObject>)>) columns, arguments, } => { - if !leaf.is_empty() { - runs.push((std::mem::take(&mut leaf), None)); - } - runs.push(( - OBJECT_START.to_string(), - Some(MathObject { + flush(&mut leaf, runs, false); + runs.push(Run { + text: OBJECT_START.to_string(), + object: MathObject { kind: *kind, arguments: Some(arguments.len() as u32), columns: *columns, symbols: symbols.clone(), - }), - )); + }, + upright: false, + leaf: false, + }); for (index, argument) in arguments.iter().enumerate() { let before = runs.len(); - write_sequence(argument, runs); + write_sequence(argument, Some(*kind), runs); + let extended = runs.len() > before; let control = if index + 1 == arguments.len() { OBJECT_END } else { @@ -426,29 +480,33 @@ fn write_sequence(nodes: &[Math], runs: &mut Vec<(String, Option<MathObject>)>) columns: None, symbols: Vec::new(), }; - let extended = runs.len() > before; match runs.last_mut() { - Some((text, slot)) if extended && slot.is_none() => { - text.push(control); - *slot = Some(object); + Some(run) if extended && run.leaf && !run.upright => { + run.text.push(control); + run.object = object; + run.leaf = false; } - _ => runs.push((control.to_string(), Some(object))), + _ => runs.push(Run { + text: control.to_string(), + object, + upright: false, + leaf: false, + }), } } } } } - if !leaf.is_empty() { - runs.push((leaf, None)); - } + flush(&mut leaf, runs, false); } -/// The equation editor stores Latin letters as mathematical italics. -fn italic(c: char) -> char { +/// The equation editor stores Latin letters and lowercase Greek as mathematical italics. +pub(crate) fn italic(c: char) -> char { match c { 'h' => '\u{210e}', 'A'..='Z' => char::from_u32(0x1d434 + u32::from(c) - u32::from('A')).unwrap(), 'a'..='z' => char::from_u32(0x1d44e + u32::from(c) - u32::from('a')).unwrap(), + 'α'..='ω' => char::from_u32(0x1d6fc + u32::from(c) - u32::from('α')).unwrap(), c => c, } } diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index 3c9c503e4be5fd3e1a795276399e69b0799f2756..593424ca89729c08126cd447b87459330b5c6dbd 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -11,6 +11,7 @@ use std::{ }; pub mod ink; +mod linear; pub mod link; pub mod math; pub mod text; @@ -378,7 +379,11 @@ impl Page { let mut selected = None; for (space, id) in document.pages()? { let revision = document.active(space)?; - if page_title(revision, id) == Some(title) { + if page_title(revision, id) + .map(crate::edit::without_fields) + .as_deref() + == Some(title) + { if selected.is_some() { return Err(Error { offset: 0, @@ -406,8 +411,8 @@ impl Page { /// `copy`, renaming `ids`, identities on this page, to theirs on the copy. pub fn copy_with(&self, ids: &mut [ExGuid]) -> Result<Self, Error> { let invalid = |message| Error { offset: 0, message }; - let mut value = serde_json::to_value((self, &*ids)) - .map_err(|_| invalid("Page is not serializable"))?; + let mut value = + serde_json::to_value((self, &*ids)).map_err(|_| invalid("Page is not serializable"))?; let mut fresh: BTreeMap<String, String> = BTreeMap::new(); fn walk( value: &mut serde_json::Value, @@ -495,7 +500,10 @@ impl Page { PageObject::Title(title) => Some(title), _ => None, })?; - let outline = title.outlines.iter().find(|outline| Some(outline.id) == title.date)?; + let outline = title + .outlines + .iter() + .find(|outline| Some(outline.id) == title.date)?; let [first, second] = &outline.paragraphs[..] else { return None; }; @@ -503,7 +511,9 @@ impl Page { let text = |object: &TextObject| object.text.text().to_owned(); // OneNote writes the date first; a field marked as the other role says otherwise. Some( - if first.date_field == Some(DateField::Time) || second.date_field == Some(DateField::Date) { + if first.date_field == Some(DateField::Time) + || second.date_field == Some(DateField::Date) + { [text(second), text(first)] } else { [text(first), text(second)] @@ -539,7 +549,7 @@ impl Page { _ => None, }); ( - page_title(revision, id).unwrap_or_default().to_owned(), + crate::edit::without_fields(page_title(revision, id).unwrap_or_default()), level.unwrap_or(1), ) } @@ -577,7 +587,7 @@ impl Page { .filter(|node| matches!(node.kind, Kind::Metadata { .. })) .and_then(|node| node.extra.first()); let mut page = Self { - title: page_title(revision, id).unwrap_or_default().to_owned(), + title: crate::edit::without_fields(page_title(revision, id).unwrap_or_default()), identity: Self::identity_of(revision), created: metadata .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65)) @@ -604,7 +614,7 @@ impl Page { FieldValue::Bytes(&[red, green, blue, alpha]) => { Ok(u32::from_le_bytes([red, green, blue, alpha])) } - _ => Err(invalid("The page colour is damaged")), + _ => Err(invalid("The page color is damaged")), }) .transpose()?, objects: Vec::new(), @@ -755,7 +765,11 @@ fn inherited_by( paragraph_style, .. } => { let style = match paragraph_style { - Some(id) => revision.nodes.get(id).map(|n| n.format.clone()).unwrap_or_default(), + Some(id) => revision + .nodes + .get(id) + .map(|n| n.format.clone()) + .unwrap_or_default(), None => Format::default(), }; node.format @@ -805,7 +819,11 @@ pub(crate) fn text_of( let runs = revision.text_runs(text)?; Ok(if runs.is_empty() { let style = match paragraph_style { - Some(id) => revision.nodes.get(id).map(|n| n.format.clone()).unwrap_or_default(), + Some(id) => revision + .nodes + .get(id) + .map(|n| n.format.clone()) + .unwrap_or_default(), None => Format::default(), }; Paragraph::new(String::new(), node.format.inherit(&style).inherit(&format)) diff --git a/crates/onestore/src/pages.rs b/crates/onestore/src/pages.rs index bb7ee6dda54e49426369dcabaa45017d9d6570e0..175bfce7c5211bdc72d5be4137288cb06febc67f 100644 --- a/crates/onestore/src/pages.rs +++ b/crates/onestore/src/pages.rs @@ -38,7 +38,11 @@ pub(crate) fn metadata_guid(metadata: &crate::document::Element<'_>) -> Result<[ .ok_or_else(|| invalid("Page metadata needs its page identifier")) } -pub(crate) fn set_references(object: &mut PropertyObject, property: u32, ids: &[ExGuid]) -> Result<(), Error> { +pub(crate) fn set_references( + object: &mut PropertyObject, + property: u32, + ids: &[ExGuid], +) -> Result<(), Error> { let mut references = Vec::new(); for id in ids { references.extend_from_slice(&object.reference(*id)?); @@ -184,19 +188,29 @@ pub(crate) fn section_changes( ) }; if element(&manifest)?.content.len() != 1 { - return Err(invalid("Each page space must contain one page with a valid level")); + return Err(invalid( + "Each page space must contain one page with a valid level", + )); } Ok(PageParts { metadata: element(&stored)?, - manifest: (manifest, PropertyObject::from_object(&revision.objects[&manifest])?), - stored_metadata: (stored, PropertyObject::from_object(&revision.objects[&stored])?), + manifest: ( + manifest, + PropertyObject::from_object(&revision.objects[&manifest])?, + ), + stored_metadata: ( + stored, + PropertyObject::from_object(&revision.objects[&stored])?, + ), }) }; - Ok(page_changes(root, &view, &raw, &pages, parts, edits, removals)? - .unwrap_or_default() - .into_iter() - .filter(|(_, changes)| !changes.is_empty()) - .collect()) + Ok( + page_changes(root, &view, &raw, &pages, parts, edits, removals)? + .unwrap_or_default() + .into_iter() + .filter(|(_, changes)| !changes.is_empty()) + .collect(), + ) } /// The objects page moves, indentation and removals change in each space; none when the @@ -253,7 +267,10 @@ fn page_changes<'a>( "Each page space must contain one page with a valid level", )); } - if page_guids.insert(metadata_guid(&page.metadata)?, *sid).is_some() { + if page_guids + .insert(metadata_guid(&page.metadata)?, *sid) + .is_some() + { return Err(invalid("Pages must have distinct metadata identifiers")); } order.push(*sid); @@ -305,9 +322,7 @@ fn page_changes<'a>( let sid = page_guids .get(&metadata_guid(&view.nodes[id])?) .filter(|sid| series.spaces.contains(sid)) - .ok_or_else(|| { - invalid("Metadata copy must identify a page in its series") - })?; + .ok_or_else(|| invalid("Metadata copy must identify a page in its series"))?; if copies.insert(*sid, *id).is_some() && !mismatched { return Err(invalid("Each page needs its own ordinary metadata copy")); } @@ -334,11 +349,10 @@ fn page_changes<'a>( order.remove(at); let position = match before { None => order.len(), - Some(before) => { - order.iter().position(|sid| *sid == before).ok_or_else(|| { - invalid("The movement anchor is no longer in the section") - })? - } + Some(before) => order + .iter() + .position(|sid| *sid == before) + .ok_or_else(|| invalid("The movement anchor is no longer in the section"))?, }; order.insert(position, edit.space); } @@ -351,7 +365,7 @@ fn page_changes<'a>( if order == original_order && levels == original_levels { return Ok(None); } - let mut groups: Vec<Vec<ExGuid>> = Vec::new(); + let mut groups: Vec<Vec<ExGuid>> = Vec::new(); for sid in order { if levels[&sid] == 1 { groups.push(Vec::new()); @@ -405,8 +419,8 @@ fn page_changes<'a>( ) }; children.push(id); - let membership_changed = old_id - .is_none_or(|old| view.nodes[&old].spaces != spaces || stale.contains(&old)); + let membership_changed = + old_id.is_none_or(|old| view.nodes[&old].spaces != spaces || stale.contains(&old)); if !membership_changed && !spaces.iter().any(|sid| { levels[sid] != original_levels[sid] @@ -611,10 +625,7 @@ impl PageCreation { } /// The root space's changes creating this page, and the new space's roots and objects. - pub(crate) fn changes( - &self, - section: &mut crate::Section<'_>, - ) -> Result<Creation, Error> { + pub(crate) fn changes(&self, section: &mut crate::Section<'_>) -> Result<Creation, Error> { self.validate()?; if section.revision(self.space()).is_ok() { return Err(invalid( @@ -636,7 +647,14 @@ impl PageCreation { section: &mut crate::Section<'_>, title: &str, level: u32, - ) -> Result<(BTreeMap<u32, ExGuid>, crate::active::Changes, PropertyObject), Error> { + ) -> Result< + ( + BTreeMap<u32, ExGuid>, + crate::active::Changes, + PropertyObject, + ), + Error, + > { if self.before.is_some() { return Err(invalid("A conflict page is not placed in the page list")); } @@ -683,7 +701,11 @@ impl PageCreation { }; let text = |value: &str, role: u32| { // OneNote keeps Latin-1 text as TextExtendedAscii, other text as Unicode. - let content = match value.chars().map(|c| u8::try_from(c).ok()).collect::<Option<Vec<u8>>>() { + let content = match value + .chars() + .map(|c| u8::try_from(c).ok()) + .collect::<Option<Vec<u8>>>() + { Some(latin) => (0x1c003498, latin), None => (0x1c001c22, string(value)), }; @@ -829,8 +851,11 @@ impl PageCreation { modified(), ( 0x24001c20, - [reference(14), self.date.iter().flat_map(|_| reference(20)).collect()] - .concat(), + [ + reference(14), + self.date.iter().flat_map(|_| reference(20)).collect(), + ] + .concat(), ), (0x14001c14, 0_f32.to_le_bytes().to_vec()), (0x14001c15, 0_f32.to_le_bytes().to_vec()), @@ -949,14 +974,24 @@ mod tests { }; /// Page moves and removals through a section, sealed: the image they leave. - fn edit_pages(source: &[u8], edits: &[PageEdit], removals: &[ExGuid]) -> Result<Vec<u8>, OpError> { + fn edit_pages( + source: &[u8], + edits: &[PageEdit], + removals: &[ExGuid], + ) -> Result<Vec<u8>, OpError> { let arena = crate::Arena::default(); let mut section = crate::Section::open(&arena, source.to_vec()).map_err(OpError::Failed)?; let mut ops = vec![Op::Section(SectionOp::Pages(edits.to_vec()))]; if !removals.is_empty() { ops.push(Op::Section(SectionOp::Delete(removals.to_vec()))); } - section.apply("Author", &Edit { at: 133_700_000_000_000_000, ops })?; + section.apply( + "Author", + &Edit { + at: 133_700_000_000_000_000, + ops, + }, + )?; section.seal().map_err(OpError::Failed)?; Ok(section.image()) } diff --git a/crates/onestore/src/protected/mod.rs b/crates/onestore/src/protected/mod.rs index a2907077ff91a737b696a52eeb8fad2bb49397ac..44dfeffcc498bca32680a5d745f5a43e24e0a627 100644 --- a/crates/onestore/src/protected/mod.rs +++ b/crates/onestore/src/protected/mod.rs @@ -416,7 +416,9 @@ impl UnlockedSection<'_> { )); } if edit.ops.iter().any(|op| matches!(op, Op::Section(_))) { - return Err(OpError::Unsupported("A protected section's page list is not edited")); + return Err(OpError::Unsupported( + "A protected section's page list is not edited", + )); } let twin = self.twin().map_err(failed)?; let arena = crate::Arena::default(); @@ -435,7 +437,10 @@ impl UnlockedSection<'_> { .map(|space| Ok((*space, applied_index.resolve_active(*space)?))) .collect::<std::result::Result<Vec<_>, crate::Error>>() .map_err(failed)?; - let edited: Vec<_> = edited.iter().map(|(space, after)| (*space, after)).collect(); + let edited: Vec<_> = edited + .iter() + .map(|(space, after)| (*space, after)) + .collect(); let payloads = crate::write::declared_payloads(&applied_store) .into_iter() .map(|guid| Ok((guid, applied_store.file_data(guid)?))) @@ -444,12 +449,15 @@ impl UnlockedSection<'_> { let transaction = crate::write::squash(self.index, &edited, &payloads, Some(self)) .map_err(failed)? .ok_or(OpError::Unsupported("The edit changes nothing"))?; - let written = crate::write::applied(self.index.store.data, Some(&transaction)) - .map_err(failed)?; + let written = + crate::write::applied(self.index.store.data, Some(&transaction)).map_err(failed)?; let store = crate::Store::parse(&written).map_err(failed)?; let index = RevisionIndex::parse(&store).map_err(failed)?; UnlockedSection::unlock(&index, Limits::default(), |metadata, _| { - self.keys.get(metadata).cloned().ok_or(Error::PasswordMismatch) + self.keys + .get(metadata) + .cloned() + .ok_or(Error::PasswordMismatch) }) .map_err(|error| match error { Error::Invalid(error) => failed(error), diff --git a/crates/onestore/src/section.rs b/crates/onestore/src/section.rs index f05b52b0125d056525dfc86679ce6fe8fa526144..179d7371cde1c8f6461d115c370c4173a2b0976e 100644 --- a/crates/onestore/src/section.rs +++ b/crates/onestore/src/section.rs @@ -9,13 +9,17 @@ use crate::{ page::Page, store::StoreState, write::{ - Appending, LiveRevision, Sealing, Written, chain_depth, check_transaction, declared, - replacement, unchanged, + Appending, Commit, LiveRevision, PropertyObject, Sealing, Written, chain_depth, + check_transaction, declared, label_payload, node, replacement, unchanged, }, }; use bumpalo::Bump; use std::collections::{BTreeMap, BTreeSet}; +mod versions; +pub(crate) use versions::HISTORY; +pub use versions::PageVersion; + type Result<T> = std::result::Result<T, Error>; /// Holds the bytes a `Section` reads: the image it opened and each transaction it sealed. @@ -58,6 +62,58 @@ struct Space<'a> { /// The active revision the file stores; none before a new space's first seal. rid: Option<ExGuid>, state: SpaceState<'a>, + /// The version a restore took the open revision from: the revision it depends on, which + /// the seal writes even unchanged. + restored: Option<ExGuid>, + /// Page states restores made versions of, which the seal writes before the open revision. + frozen: Vec<Frozen<'a>>, + history: Option<History<'a>>, + /// Revisions the seal labels as versions, with their contexts. + labels: Vec<(ExGuid, ExGuid)>, + /// The newest revision a seal appended to the space. + newest: Option<ExGuid>, +} + +impl<'a> Space<'a> { + fn new(rid: Option<ExGuid>, state: SpaceState<'a>, history: Option<History<'a>>) -> Self { + Self { + rid, + state, + restored: None, + frozen: Vec::new(), + history, + labels: Vec::new(), + newest: None, + } + } +} + +/// A page state a restore made a version of: a revision under the identity it chose. +#[derive(Clone)] +struct Frozen<'a> { + rid: ExGuid, + previous: Option<ExGuid>, + live: LiveRevision<'a>, + revised: Revised, +} + +/// A revision `changed` committed: what it declares, and of those the objects whose bytes it +/// stores and the objects new to the space. +#[derive(Clone)] +struct Revised { + commit: Commit, + replaced: BTreeSet<ExGuid>, + created: BTreeSet<ExGuid>, +} + +/// A page's version history (`versions::HISTORY`), with the objects edits since the last +/// seal replaced in it. +#[derive(Clone)] +struct History<'a> { + rid: ExGuid, + revision: ResolvedRevision<'a>, + depth: usize, + pending: BTreeMap<ExGuid, PropertyObject>, } #[derive(Clone)] @@ -105,50 +161,24 @@ impl<'a> Section<'a> { let Some(rid) = space.labels.get(&(ExGuid::default(), 1)).copied() else { continue; }; - let resolved = index.resolve(*id, rid)?; - let revision = ResolvedRevision { - roots: resolved.roots, - objects: resolved - .objects - .into_iter() - .map(|(id, object)| { - // The parse borrows `bytes` only as long as `store`; its slices lie - // in `bytes`, which the arena keeps. - let data = match object.data { - ObjectData::Properties(data) => { - ObjectData::Properties(within(bytes, data)) - } - ObjectData::Encrypted(data) => { - ObjectData::Encrypted(within(bytes, data)) - } - ObjectData::File { - reference, - extension, - } => ObjectData::File { - reference: within(bytes, reference), - extension: within(bytes, extension), - }, - }; - let object = crate::Object { - jcid: object.jcid, - reference_count: object.reference_count, - data, - global_ids: object.global_ids, - }; - (id, object) + let revision = bound(bytes, index.resolve(*id, rid)?); + let history = space + .labels + .get(&(versions::HISTORY, 1)) + .map(|history| -> Result<History<'a>> { + Ok(History { + rid: *history, + revision: bound(bytes, index.resolve(*id, *history)?), + depth: chain_depth(&index, *id, *history), + pending: BTreeMap::new(), }) - .collect(), + }) + .transpose()?; + let state = SpaceState::Stored { + revision, + depth: chain_depth(&index, *id, rid), }; - spaces.insert( - *id, - Space { - rid: Some(rid), - state: SpaceState::Stored { - revision, - depth: chain_depth(&index, *id, rid), - }, - }, - ); + spaces.insert(*id, Space::new(Some(rid), state, history)); } let mut files = Vec::new(); for node in store.lists.values().flat_map(|list| &list.nodes) { @@ -209,6 +239,15 @@ impl<'a> Section<'a> { .filter_map(|(space, stored)| Some((*space, stored.rid?))) } + /// Each object space with the revision this section last sealed in it, where one did, or + /// its active revision: a seal that changes only a page's versions leaves the page's + /// active revision as it was. + pub fn newest(&self) -> impl Iterator<Item = (ExGuid, ExGuid)> + '_ { + self.spaces + .iter() + .filter_map(|(space, stored)| Some((*space, stored.newest.or(stored.rid)?))) + } + /// The page an object space holds, with the changes applied since the last seal. pub fn page(&self, space: ExGuid) -> Result<Page> { let stored = self.spaces.get(&space).ok_or(Error { @@ -303,10 +342,14 @@ impl<'a> Section<'a> { /// own, so a page in another series than before was moved. pub fn series(&mut self) -> Result<BTreeMap<ExGuid, ExGuid>> { let view = &self.active(self.root)?.view; - let section = view.roots.get(&1).and_then(|id| view.nodes.get(id)).ok_or(Error { - offset: 0, - message: "Section root is unavailable", - })?; + let section = view + .roots + .get(&1) + .and_then(|id| view.nodes.get(id)) + .ok_or(Error { + offset: 0, + message: "Section root is unavailable", + })?; Ok(section .children .iter() @@ -375,7 +418,8 @@ impl<'a> Section<'a> { title = alternate_title; } } - pages.push((space, title.unwrap_or_default(), level)); + let title = crate::edit::without_fields(&title.unwrap_or_default()); + pages.push((space, title, level)); } Ok(pages) } @@ -447,6 +491,15 @@ impl<'a> Section<'a> { Ok(open) } + /// Keeps a space as it is for the running edit to return to, the first time it changes it. + pub(crate) fn remember(&mut self, space: ExGuid) { + if let Some(undo) = &mut self.undo + && !undo.spaces.contains_key(&space) + { + undo.spaces.insert(space, self.spaces.get(&space).cloned()); + } + } + /// Stores a writer's objects and payloads in a space until the next seal; false when /// it stores nothing. An error leaves the section to be reopened. pub(crate) fn apply_changes( @@ -456,11 +509,7 @@ impl<'a> Section<'a> { changes: Changes, ) -> Result<bool> { self.usable()?; - if let Some(undo) = &mut self.undo - && !undo.spaces.contains_key(&space) - { - undo.spaces.insert(space, self.spaces.get(&space).cloned()); - } + self.remember(space); let arena = &self.arena.0; self.broken = true; let open = self.editable(space)?; @@ -519,17 +568,12 @@ impl<'a> Section<'a> { let all: Vec<ExGuid> = work.revision.objects.keys().copied().collect(); work.settle(all)?; let page = ActivePage::open(space, work, FileType::Section, self.files())?; - self.spaces.insert( - space, - Space { - rid: None, - state: SpaceState::Open(Box::new(Open { - file, - page, - pending, - })), - }, - ); + let state = SpaceState::Open(Box::new(Open { + file, + page, + pending, + })); + self.spaces.insert(space, Space::new(None, state, None)); self.broken = false; Ok(()) } @@ -595,69 +639,124 @@ impl<'a> Section<'a> { let mut appending = Appending::new(self.state.clone()); let mut sealed = Vec::new(); for (id, space) in &mut self.spaces { - let SpaceState::Open(open) = &mut space.state else { - continue; - }; - if open.pending.is_empty() { - continue; + // A space's revisions and labels go in one fragment: page states restores made + // versions of, the open revision, the version history, then the labels. + let mut manifest = Vec::new(); + for frozen in &space.frozen { + appending.manifest( + &Sealing { + space: *id, + previous: frozen.previous, + new_space: false, + label: (ExGuid::default(), 1), + rid: Some(frozen.rid), + live: &frozen.live, + commit: &frozen.revised.commit, + replaced: &frozen.revised.replaced, + created: &frozen.revised.created, + }, + &self.segments, + None, + None, + &mut manifest, + )?; } - let (work, file) = (&open.page.live, &mut open.file); - let mut replacements = BTreeMap::new(); - for object_id in &open.pending { - if !work.is_reachable(*object_id) { - continue; - } - let object = &work.revision.objects[object_id]; - if let Some(stored) = file.revision.objects.get(object_id) - && unchanged(stored, object)? + let mut page = None; + if let SpaceState::Open(open) = &mut space.state + && (!open.pending.is_empty() || space.restored.is_some()) + { + let file = &mut open.file; + let restored = space.restored.is_some(); + let written = match changed(arena, &open.page.live, &open.pending, file, restored)? { - continue; - } - replacements.insert(*object_id, replacement(*object_id, object)?); - } - let replacements = file.prepare(replacements, true)?; - if replacements.is_empty() { - sealed.push((*id, None)); - continue; - } - let replaced: BTreeSet<ExGuid> = replacements.keys().copied().collect(); - let created = replaced - .iter() - .filter(|id| !file.revision.objects.contains_key(id)) - .copied() - .collect(); - let mut objects = Vec::new(); - for (object_id, change) in replacements { - // The page holds these bytes already unless preparing remapped them. - let bytes = match work.revision.objects.get(&object_id).map(|o| o.data) { - Some(ObjectData::Properties(bytes)) if bytes == change.bytes => bytes, - _ => arena.alloc_slice_copy(&change.bytes), + Some(Revised { + commit, + replaced, + created, + }) => { + let (rid, stored) = appending.manifest( + &Sealing { + space: *id, + previous: space.restored.or(space.rid), + new_space: space.rid.is_none(), + label: (ExGuid::default(), 1), + rid: None, + live: file, + commit: &commit, + replaced: &replaced, + created: &created, + }, + &self.segments, + None, + None, + &mut manifest, + )?; + if commit.checkpoint { + let all: Vec<ExGuid> = file.revision.objects.keys().copied().collect(); + file.settle(all)?; + } else { + file.settle(commit.changed.iter().copied())?; + } + Some((rid, stored, commit)) + } + None => None, }; - objects.push((object_id, declared(change.jcid, bytes, change.global_ids)?)); + page = Some(written); } - let commit = file.commit(objects)?; - let (rid, stored) = appending.revision( - &Sealing { - space: *id, - previous: space.rid, - new_space: space.rid.is_none(), - label: (ExGuid::default(), 1), - live: file, - commit: &commit, - replaced: &replaced, - created: &created, - }, - &self.segments, - None, - None, - )?; - if commit.checkpoint { - let all: Vec<ExGuid> = file.revision.objects.keys().copied().collect(); - file.settle(all)?; - } else { - file.settle(commit.changed.iter().copied())?; + let mut history = None; + if let Some(stored) = &space.history + && !stored.pending.is_empty() + { + let mut live = LiveRevision::new(stored.revision.clone(), stored.depth)?; + let replacements = live.prepare(stored.pending.clone(), true)?; + if !replacements.is_empty() { + let replaced: BTreeSet<ExGuid> = replacements.keys().copied().collect(); + let created = replaced + .iter() + .filter(|id| !live.revision.objects.contains_key(id)) + .copied() + .collect(); + let mut objects = Vec::new(); + for (object_id, change) in replacements { + let bytes = arena.alloc_slice_copy(&change.bytes); + objects.push((object_id, declared(change.jcid, bytes, change.global_ids)?)); + } + let commit = live.commit(objects)?; + let (rid, chunks) = appending.manifest( + &Sealing { + space: *id, + previous: Some(stored.rid), + new_space: false, + label: (versions::HISTORY, 1), + rid: None, + live: &live, + commit: &commit, + replaced: &replaced, + created: &created, + }, + &self.segments, + None, + None, + &mut manifest, + )?; + if commit.checkpoint { + let all: Vec<ExGuid> = live.revision.objects.keys().copied().collect(); + live.settle(all)?; + } else { + live.settle(commit.changed.iter().copied())?; + } + history = Some((rid, chunks, live, commit)); + } + } + for (rid, context) in &space.labels { + manifest.push(node(0x5d, None, &label_payload(*rid, *context))?); + } + if !manifest.is_empty() { + appending.close(*id, space.rid.is_none(), &manifest)?; + } + if page.is_some() || !manifest.is_empty() { + sealed.push((*id, page, history)); } - sealed.push((*id, Some((rid, stored, commit)))); } let payloads: Vec<_> = self .payloads @@ -674,20 +773,46 @@ impl<'a> Section<'a> { } if let Some((transaction, state)) = &transaction { let mut written = Vec::new(); - for (id, revision) in &sealed { - let (Some((rid, _, commit)), Some(space)) = (revision, self.spaces.get(id)) else { - continue; - }; - let SpaceState::Open(open) = &space.state else { - unreachable!() - }; - written.push(Written { - space: *id, - rid: *rid, - previous: space.rid, - live: &open.file, - commit, - }); + let mut labels = Vec::new(); + for (id, page, history) in &sealed { + let space = &self.spaces[id]; + for frozen in &space.frozen { + written.push(Written { + space: *id, + rid: frozen.rid, + previous: frozen.previous, + label: (ExGuid::default(), 1), + live: &frozen.live, + commit: &frozen.revised.commit, + }); + } + if let (Some(Some((rid, _, commit))), SpaceState::Open(open)) = (page, &space.state) + { + written.push(Written { + space: *id, + rid: *rid, + previous: space.restored.or(space.rid), + label: (ExGuid::default(), 1), + live: &open.file, + commit, + }); + } + if let (Some((rid, _, live, commit)), Some(stored)) = (history, &space.history) { + written.push(Written { + space: *id, + rid: *rid, + previous: Some(stored.rid), + label: (versions::HISTORY, 1), + live, + commit, + }); + } + labels.extend( + space + .labels + .iter() + .map(|(rid, context)| (*id, *rid, *context)), + ); } check_transaction( &self.state, @@ -695,6 +820,7 @@ impl<'a> Section<'a> { transaction, &self.segments, &written, + &labels, &payloads, )?; let base = transaction.base.length; @@ -703,23 +829,42 @@ impl<'a> Section<'a> { self.patches.extend(transaction.patches.iter().cloned()); self.state = state.clone(); self.declared.extend(payloads.iter().map(|(guid, _)| *guid)); - for (id, revision) in &sealed { - let Some((rid, stored, _)) = revision else { - continue; - }; - let space = self.spaces.get_mut(id).unwrap(); - space.rid = Some(*rid); - let SpaceState::Open(open) = &mut space.state else { - unreachable!() - }; + let bind = |revision: &mut ResolvedRevision<'a>, stored: &[(ExGuid, crate::Chunk)]| { for (object_id, chunk) in stored { let start = (chunk.offset - base) as usize; - open.file.revision.objects.get_mut(object_id).unwrap().data = + revision.objects.get_mut(object_id).unwrap().data = ObjectData::Properties(&segment[start..start + chunk.length as usize]); } + }; + for (id, page, history) in &mut sealed { + let space = self.spaces.get_mut(id).unwrap(); + space.frozen.clear(); + space.labels.clear(); + if let (Some(Some((rid, stored, _))), SpaceState::Open(open)) = + (&*page, &mut space.state) + { + space.rid = Some(*rid); + space.restored = None; + space.newest = Some(*rid); + bind(&mut open.file.revision, stored); + } + if let Some((rid, stored, live, _)) = history.take() { + let mut revision = live.revision; + bind(&mut revision, &stored); + space.history = Some(History { + rid, + revision, + depth: live.depth, + pending: BTreeMap::new(), + }); + space.newest = Some(rid); + } } } - for (id, revision) in sealed { + for (id, page, _) in sealed { + let Some(revision) = page else { + continue; + }; let SpaceState::Open(open) = &mut self.spaces.get_mut(&id).unwrap().state else { unreachable!() }; @@ -759,6 +904,87 @@ impl<'a> Section<'a> { } } +/// Commits to `file` the objects `pending` names that `page` changed, with what the revision +/// declares, replaces and creates; none when that changes nothing, unless `force`. +fn changed<'a>( + arena: &'a Bump, + page: &LiveRevision<'a>, + pending: &BTreeSet<ExGuid>, + file: &mut LiveRevision<'a>, + force: bool, +) -> Result<Option<Revised>> { + let mut replacements = BTreeMap::new(); + for object_id in pending { + if !page.is_reachable(*object_id) { + continue; + } + let object = &page.revision.objects[object_id]; + if let Some(stored) = file.revision.objects.get(object_id) + && unchanged(stored, object)? + { + continue; + } + replacements.insert(*object_id, replacement(*object_id, object)?); + } + let replacements = file.prepare(replacements, true)?; + if replacements.is_empty() && !force { + return Ok(None); + } + let replaced: BTreeSet<ExGuid> = replacements.keys().copied().collect(); + let created = replaced + .iter() + .filter(|id| !file.revision.objects.contains_key(id)) + .copied() + .collect(); + let mut objects = Vec::new(); + for (object_id, change) in replacements { + // The page holds these bytes already unless preparing remapped them. + let bytes = match page.revision.objects.get(&object_id).map(|o| o.data) { + Some(ObjectData::Properties(bytes)) if bytes == change.bytes => bytes, + _ => arena.alloc_slice_copy(&change.bytes), + }; + objects.push((object_id, declared(change.jcid, bytes, change.global_ids)?)); + } + Ok(Some(Revised { + commit: file.commit(objects)?, + replaced, + created, + })) +} + +/// `resolved`, parsed from `bytes`, borrowing them for their lifetime. +fn bound<'a>(bytes: &'a [u8], resolved: ResolvedRevision<'_>) -> ResolvedRevision<'a> { + ResolvedRevision { + roots: resolved.roots, + objects: resolved + .objects + .into_iter() + .map(|(id, object)| { + // The parse borrows `bytes` only as long as its store; its slices lie in + // `bytes`, which the arena keeps. + let data = match object.data { + ObjectData::Properties(data) => ObjectData::Properties(within(bytes, data)), + ObjectData::Encrypted(data) => ObjectData::Encrypted(within(bytes, data)), + ObjectData::File { + reference, + extension, + } => ObjectData::File { + reference: within(bytes, reference), + extension: within(bytes, extension), + }, + }; + let object = crate::Object { + jcid: object.jcid, + reference_count: object.reference_count, + data, + global_ids: object.global_ids, + }; + (id, object) + }) + .collect(), + } +} + /// `part`, a slice of `image`, with the image's lifetime. fn within<'a>(image: &'a [u8], part: &[u8]) -> &'a [u8] { let start = part.as_ptr().addr() - image.as_ptr().addr(); diff --git a/crates/onestore/src/section/versions.rs b/crates/onestore/src/section/versions.rs new file mode 100644 index 0000000000000000000000000000000000000000..bc61dc0962dea0a91cfdfd5474ee4a214208414b --- /dev/null +++ b/crates/onestore/src/section/versions.rs @@ -0,0 +1,492 @@ +//! Page versions, the earlier states of a page OneNote 2010 keeps (MS-ONE 2.1.17, 2.1.18). +//! A version is an earlier revision of the page's own object space, current under a context +//! of its own. The page's version history, the space's revision current under `HISTORY`, +//! holds a proxy for each (`jcidVersionProxy`), newest first. `corpus/page-versions` records +//! what OneNote 2010 writes. + +use super::{Frozen, Open, Section, SpaceState, changed, one_page}; +use crate::{ + Error, ExGuid, FileType, Object, ObjectData, ResolvedRevision, RevisionIndex, Store, + active::{ActivePage, manifest_pages}, + create::{author_properties, current_timestamps, properties}, + document::{Element, Kind, Revision}, + op::{Failure, OpError}, + page::Page, + pages::set_references, + write::{LiveRevision, PropertyObject, chain_depth, compact, declared}, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; + +type Result<T> = std::result::Result<T, Error>; + +/// The context of a page's version history, {7111497F-1B6B-4209-9491-C98B04CF4C5A},1. +pub(crate) const HISTORY: ExGuid = ExGuid { + guid: [ + 0x7f, 0x49, 0x11, 0x71, 0x6b, 0x1b, 0x09, 0x42, 0x94, 0x91, 0xc9, 0x8b, 0x04, 0xcf, 0x4c, + 0x5a, + ], + n: 1, +}; + +/// A version's context is its revision's identity with these bits flipped, and its proxy's +/// identity the context's with `PROXY` flipped, as OneNote 2010 derives both. +const CONTEXT: [u8; 16] = [ + 0x7c, 0xfe, 0x52, 0x9d, 0xbc, 0x8f, 0x95, 0x44, 0x93, 0x00, 0x12, 0x9b, 0x57, 0x97, 0xa9, 0xea, +]; +const PROXY: [u8; 16] = [ + 0xca, 0x0b, 0x44, 0x88, 0x8c, 0x11, 0x81, 0x0e, 0x32, 0xbf, 0x83, 0xdb, 0x8c, 0xb8, 0xb6, 0xb5, +]; + +fn flipped(mut id: ExGuid, salt: [u8; 16]) -> ExGuid { + for (byte, salt) in id.guid.iter_mut().zip(salt) { + *byte ^= salt; + } + id +} + +/// An earlier state of a page, as the page list names it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageVersion { + /// The context its revision is current under, which names it. + pub context: ExGuid, + /// FILETIME of its last change (`LastModifiedTimeStamp`). + pub modified: Option<u64>, + /// Who last changed it (`AuthorMostRecent`). + pub author: Option<String>, +} + +fn element<'o>(object: &Object<'o>) -> Result<Element<'o>> { + Element::parse_with(object, FileType::Section, &mut |_| { + Err(Error { + offset: 0, + message: "Version metadata holds no payload", + }) + }) +} + +/// When `revision`'s page was last changed and by whom, as its revision metadata says. +fn last_change(revision: &ResolvedRevision<'_>) -> Result<(Option<u64>, Option<ExGuid>)> { + let Some(metadata) = revision + .roots + .get(&4) + .and_then(|id| revision.objects.get(id)) + else { + return Ok((None, None)); + }; + let metadata = element(metadata)?; + let Kind::RevisionMetadata { modified_filetime } = metadata.kind else { + return Ok((None, None)); + }; + Ok((modified_filetime, metadata.latest_author)) +} + +impl<'a> Section<'a> { + /// The versions of each page that has them, in section order, each page's newest first as + /// OneNote 2010 lists them; read from the version histories alone. + pub fn versions(&mut self) -> Result<Vec<(ExGuid, Vec<PageVersion>)>> { + let mut listed = Vec::new(); + for (page, ..) in self.pages()? { + let versions: Vec<PageVersion> = self + .listed(page)? + .into_iter() + .map(|(_, version)| version) + .collect(); + if !versions.is_empty() { + listed.push((page, versions)); + } + } + Ok(listed) + } + + /// Page `space` as its version `context` holds it. Reads the sealed image: O(section). + pub fn version(&self, space: ExGuid, context: ExGuid) -> Result<Page> { + if !self + .listed(space)? + .iter() + .any(|(_, version)| version.context == context) + { + return Err(Error { + offset: 0, + message: "The page has no such version", + }); + } + let stored = &self.spaces[&space]; + if let Some(frozen) = stored + .frozen + .iter() + .find(|frozen| flipped(frozen.rid, CONTEXT) == context) + { + let files = self.files(); + let pending = &self.payloads; + let (view, _) = Revision::parse( + space, + &frozen.live.revision, + FileType::Section, + &mut |guid| match pending.iter().find(|(id, _)| *id == guid) { + Some((_, payload)) => Ok(*payload), + None => files(guid), + }, + )?; + return one_page(&view, &manifest_pages(&view)); + } + let image = self.image(); + let store = Store::parse(&image)?; + let index = RevisionIndex::parse(&store)?; + let rid = self.version_revision(&index, space, context)?; + let resolved = index.resolve(space, rid)?; + let (view, _) = Revision::parse(space, &resolved, FileType::Section, &mut |guid| { + store.file_data(guid) + })?; + one_page(&view, &manifest_pages(&view)) + } + + /// Page `space`'s versions, newest first, with their proxies. + fn listed(&self, space: ExGuid) -> Result<Vec<(ExGuid, PageVersion)>> { + let Some(history) = self + .spaces + .get(&space) + .and_then(|space| space.history.as_ref()) + else { + return Ok(Vec::new()); + }; + let lookup = |id: &ExGuid| -> Result<Option<Object<'_>>> { + match history.pending.get(id) { + Some(object) => { + declared(object.jcid, &object.bytes, Arc::clone(&object.global_ids)).map(Some) + } + None => Ok(history.revision.objects.get(id).cloned()), + } + }; + let Some(root) = history.revision.roots.get(&1) else { + return Ok(Vec::new()); + }; + let Some(root) = lookup(root)? else { + return Ok(Vec::new()); + }; + let mut versions = Vec::new(); + for proxy in element(&root)?.children { + let Some(object) = lookup(&proxy)? else { + return Err(Error { + offset: 0, + message: "A version proxy is unavailable", + }); + }; + let node = element(&object)?; + let Kind::VersionProxy { + context, + modified_filetime, + } = node.kind + else { + return Err(Error { + offset: 0, + message: "A version history lists another kind of object", + }); + }; + let author = match node + .latest_author + .as_ref() + .map(lookup) + .transpose()? + .flatten() + { + Some(author) => match element(&author)?.kind { + Kind::Author { name } => name, + _ => None, + }, + None => None, + }; + versions.push(( + proxy, + PageVersion { + context, + modified: modified_filetime, + author, + }, + )); + } + Ok(versions) + } + + /// The revision version `context` of `space` is, in `index` of the sealed image. + fn version_revision( + &self, + index: &RevisionIndex<'_>, + space: ExGuid, + context: ExGuid, + ) -> Result<ExGuid> { + let labelled = self.spaces[&space] + .labels + .iter() + .find(|(_, labelled)| *labelled == context) + .map(|(rid, _)| *rid); + labelled + .or_else(|| index.spaces.get(&space)?.labels.get(&(context, 1)).copied()) + .ok_or(Error { + offset: 0, + message: "A version's revision is not stored", + }) + } + + /// Version `context` of `space` as a revision of the section's own bytes, with its + /// identity and chain depth. Reads the sealed image: O(section). + fn historic( + &self, + space: ExGuid, + context: ExGuid, + ) -> Result<(ExGuid, ResolvedRevision<'a>, usize)> { + if self.spaces[&space] + .frozen + .iter() + .any(|frozen| flipped(frozen.rid, CONTEXT) == context) + { + return Err(Error { + offset: 0, + message: "This version is still saving. Restore it in a moment.", + }); + } + let image = self.image(); + let store = Store::parse(&image)?; + let index = RevisionIndex::parse(&store)?; + let rid = self.version_revision(&index, space, context)?; + let resolved = index.resolve(space, rid)?; + // Object bytes lie in the section's segments, which outlive this image. + let resident = |part: &[u8]| -> Result<&'a [u8]> { + let offset = (part.as_ptr().addr() - image.as_ptr().addr()) as u64; + self.segments + .iter() + .rfind(|(start, _)| *start <= offset) + .and_then(|(start, segment)| { + let at = (offset - start) as usize; + segment.get(at..at + part.len()) + }) + .filter(|bytes| *bytes == part) + .ok_or(Error { + offset: offset as usize, + message: "A version's bytes lie outside the section", + }) + }; + let mut objects = BTreeMap::new(); + for (id, object) in resolved.objects { + let data = match object.data { + ObjectData::Properties(data) => ObjectData::Properties(resident(data)?), + ObjectData::Encrypted(data) => ObjectData::Encrypted(resident(data)?), + ObjectData::File { + reference, + extension, + } => ObjectData::File { + reference: resident(reference)?, + extension: resident(extension)?, + }, + }; + objects.insert( + id, + Object { + jcid: object.jcid, + reference_count: object.reference_count, + data, + global_ids: object.global_ids, + }, + ); + } + let revision = ResolvedRevision { + roots: resolved.roots, + objects, + }; + Ok((rid, revision, chain_depth(&index, space, rid))) + } + + /// Restores version `context` of page `space` as OneNote 2010's Restore Version does: the + /// page as it stands becomes the newest version, and the page's next revision builds on + /// the version's, its revision metadata naming `author` now. New objects take identities + /// from `guid`. + pub(crate) fn restore( + &mut self, + author: &str, + space: ExGuid, + context: ExGuid, + guid: [u8; 16], + ) -> std::result::Result<(), Failure> { + if !self + .listed(space)? + .iter() + .any(|(_, version)| version.context == context) + { + return Err(OpError::TargetUnavailable(context).into()); + } + let (from, revision, depth) = self.historic(space, context)?; + let (now, filetime) = current_timestamps()?; + let named = |n| ExGuid { guid, n }; + self.remember(space); + let files = self.files(); + let arena = &self.arena.0; + self.editable(space)?; + let stored = self.spaces.get_mut(&space).unwrap(); + let SpaceState::Open(open) = &mut stored.state else { + unreachable!() + }; + // The page as it stands: the stored revision, or its unsaved state as one of its own. + let (current, modified, who) = if open.pending.is_empty() && stored.restored.is_none() { + let rid = stored.rid.ok_or(Error { + offset: 0, + message: "A page never saved has no versions", + })?; + let (modified, who) = last_change(&open.file.revision)?; + let who = who + .and_then(|id| open.file.revision.objects.get(&id)) + .cloned(); + (rid, modified, who) + } else { + let mut live = open.file.clone(); + let revised = changed(arena, &open.page.live, &open.pending, &mut live, true)? + .expect("a forced revision is written"); + let rid = named(1); + stored.frozen.push(Frozen { + rid, + previous: stored.restored.or(stored.rid), + live, + revised, + }); + (rid, None, None) + }; + let label = flipped(current, CONTEXT); + stored.labels.push((current, label)); + // Its proxy, newest in the history, names when and by whom it was last changed. + let proxy = flipped(label, PROXY); + let proxy_author = named(3); + let author_object = match &who { + Some(object) => PropertyObject::from_object(object)?, + None => PropertyObject { + jcid: 0x120001, + bytes: properties(&author_properties(author))?, + global_ids: Arc::new(BTreeMap::new()), + }, + }; + let author_object = PropertyObject { + global_ids: Arc::new(BTreeMap::from([(0, proxy_author.guid)])), + ..author_object + }; + let table = BTreeMap::from([(0, proxy.guid), (1, proxy_author.guid), (2, label.guid)]); + let proxy_object = PropertyObject { + jcid: 0x6003d, + bytes: properties(&[ + (0x14001d09, now.to_le_bytes().to_vec()), + ( + 0x18001d77, + modified.unwrap_or(filetime).to_le_bytes().to_vec(), + ), + (0x20001d79, compact(proxy_author, &table)?.to_vec()), + (0x3400347b, compact(label, &table)?.to_vec()), + ])?, + global_ids: Arc::new(table), + }; + let listed: Vec<ExGuid> = self + .listed(space)? + .into_iter() + .map(|(proxy, _)| proxy) + .collect(); + let stored = self.spaces.get_mut(&space).unwrap(); + let history = stored.history.as_mut().ok_or(Error { + offset: 0, + message: "The page has no version history", + })?; + let root = *history.revision.roots.get(&1).ok_or(Error { + offset: 0, + message: "The version history has no content", + })?; + let mut root_object = match history.pending.get(&root) { + Some(object) => object.clone(), + None => PropertyObject::from_object(&history.revision.objects[&root])?, + }; + let children: Vec<ExGuid> = std::iter::once(proxy).chain(listed).collect(); + set_references(&mut root_object, 0x24001c20, &children)?; + history.pending.extend([ + (root, root_object), + (proxy, proxy_object), + (proxy_author, author_object), + ]); + // The page's next revision builds on the version's. + let file = LiveRevision::new(revision, depth)?; + let page = ActivePage::open(space, file.clone(), FileType::Section, files)?; + stored.state = SpaceState::Open(Box::new(Open { + file, + page, + pending: BTreeSet::new(), + })); + stored.restored = Some(from); + // Its revision metadata names who restored it, and when. + let active = self.active(space)?; + let revision = &active.live.revision; + let mut changes = BTreeMap::new(); + if let Some(id) = revision.roots.get(&4) + && revision.objects[id].jcid == 0x20044 + { + let restorer = named(2); + let mut metadata = PropertyObject::from_object(&revision.objects[id])?; + let reference = metadata.reference(restorer)?; + metadata.set(&[ + (0x18001d77, &filetime.to_le_bytes()), + (0x20001d79, &reference), + ])?; + changes.insert(*id, metadata); + changes.insert( + restorer, + PropertyObject { + jcid: 0x120001, + bytes: properties(&author_properties(author))?, + global_ids: Arc::new(BTreeMap::from([(0, restorer.guid)])), + }, + ); + } + self.apply_changes(space, &[], changes)?; + Ok(()) + } + + /// Deletes versions `contexts` of page `space` as OneNote 2010's Delete Version does: its + /// history stops listing them; their revisions stay stored. True when none is left. + pub(crate) fn unlist( + &mut self, + space: ExGuid, + contexts: &[ExGuid], + ) -> std::result::Result<bool, Failure> { + let listed = self.listed(space)?; + if let Some(context) = contexts.iter().find(|context| { + !listed + .iter() + .any(|(_, version)| version.context == **context) + }) { + return Err(OpError::TargetUnavailable(*context).into()); + } + let kept: Vec<ExGuid> = listed + .iter() + .filter(|(_, version)| !contexts.contains(&version.context)) + .map(|(proxy, _)| *proxy) + .collect(); + self.remember(space); + let history = self + .spaces + .get_mut(&space) + .and_then(|space| space.history.as_mut()) + .ok_or(Error { + offset: 0, + message: "The page has no version history", + })?; + let root = *history.revision.roots.get(&1).ok_or(Error { + offset: 0, + message: "The version history has no content", + })?; + let mut object = match history.pending.get(&root) { + Some(object) => object.clone(), + None => PropertyObject::from_object(&history.revision.objects[&root])?, + }; + if kept.is_empty() { + object.remove(&[0x24001c20])?; + } else { + set_references(&mut object, 0x24001c20, &kept)?; + } + history.pending.insert(root, object); + Ok(kept.is_empty()) + } +} diff --git a/crates/onestore/src/toc.rs b/crates/onestore/src/toc.rs index 72c120ec39e40ddfeb012d6d0edb60d9cff1eb15..49fdd9854d36d96c8124f904f0509f367eadbe63 100644 --- a/crates/onestore/src/toc.rs +++ b/crates/onestore/src/toc.rs @@ -64,19 +64,27 @@ pub fn edit_table_of_contents( let Some(Kind::Toc { entries, .. }) = revision.nodes.get(&root).map(|node| &node.kind) else { return Err(invalid("Missing notebook TOC root")); }; - // Entries in stored order with their identities and filenames. - let mut listed: Vec<(ExGuid, [u8; 16], String)> = Vec::new(); + // Entries in display order (their ordering numbers, not their place in the root's list) + // with their identities and filenames. + let mut stored = Vec::new(); for id in entries { let Some(Kind::Toc { filename, identity: Some(identity), + order, .. }) = revision.nodes.get(id).map(|node| &node.kind) else { return Err(invalid("Incomplete notebook TOC reference")); }; - listed.push((*id, *identity, filename.clone().unwrap_or_default())); + stored.push(( + order.unwrap_or(u32::MAX), + (*id, *identity, filename.clone().unwrap_or_default()), + )); } + stored.sort_by_key(|(order, _)| *order); + let mut listed: Vec<(ExGuid, [u8; 16], String)> = + stored.into_iter().map(|(_, entry)| entry).collect(); let mut created: BTreeMap<ExGuid, PropertyObject> = BTreeMap::new(); let mut renamed = BTreeSet::new(); let mut color = None; @@ -173,6 +181,13 @@ pub fn edit_table_of_contents( } } let listed = listed; + // A rename or a colour keeps every number, gaps and ties included, as OneNote left them. + let resequence = edits.iter().any(|edit| { + matches!( + edit, + TocEdit::Add { .. } | TocEdit::Order(_) | TocEdit::Remove { .. } + ) + }); let transaction = build_on(&index, &[], None, |index| { let raw = index.resolve_active(space)?; let mut changed = BTreeMap::new(); @@ -190,9 +205,10 @@ pub fn edit_table_of_contents( Value::Bytes(b) => b.try_into().ok().map(u32::from_le_bytes), _ => None, }); + let order = order as u32 + 1; let mut updates: Vec<(u32, Vec<u8>)> = Vec::new(); - if stored_order != Some(order as u32 + 1) { - updates.push((0x14001cb9, (order as u32 + 1).to_le_bytes().to_vec())); + if resequence && stored_order != Some(order) { + updates.push((0x14001cb9, order.to_le_bytes().to_vec())); } if renamed.contains(id) { updates.push((0x1c001d6b, crate::create::string(filename))); @@ -219,3 +235,113 @@ pub fn edit_table_of_contents( check(&applied(source, transaction.as_ref())?, true)?; Ok(transaction) } + +#[cfg(test)] +mod tests { + use super::*; + + fn apply(source: &[u8], transaction: Option<Transaction>) -> Vec<u8> { + applied(source, transaction.as_ref()).unwrap() + } + + /// Each entry's filename and ordering number, as the root lists them. + fn entries(image: &[u8]) -> Vec<(ExGuid, String, u32)> { + let store = Store::parse(image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let revision = document.active(document.root).unwrap(); + let Kind::Toc { entries, .. } = &revision.nodes[&revision.roots[&1]].kind else { + panic!() + }; + entries + .iter() + .map(|id| match &revision.nodes[id].kind { + Kind::Toc { + filename, order, .. + } => (*id, filename.clone().unwrap(), order.unwrap()), + _ => panic!(), + }) + .collect() + } + + fn displayed(image: &[u8]) -> Vec<(String, u32)> { + let mut entries: Vec<_> = entries(image) + .into_iter() + .map(|(_, name, order)| (name, order)) + .collect(); + entries.sort_by_key(|(_, order)| *order); + entries + } + + #[test] + fn edits_follow_the_ordering_numbers_not_the_list() { + let [a, b, c] = [[1; 16], [2; 16], [3; 16]]; + let toc = crate::create_table_of_contents( + "Open Notebook.onetoc2", + &[("Video.one", a), ("Song.one", b), ("Album.one", c)], + ) + .unwrap(); + // Numbers out of the list's order, with a gap and a tie, as native TOCs carry them. + let dragged = { + let store = Store::parse(&toc).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let space = Document::parse(&index).unwrap().root; + let listed = entries(&toc); + let transaction = build_on(&index, &[], None, |index| { + let raw = index.resolve_active(space)?; + let mut changed = BTreeMap::new(); + for ((id, _, _), order) in listed.iter().zip([7u32, 2, 2]) { + let mut object = PropertyObject::from_object(&raw.objects[id])?; + object.set(&[(0x14001cb9, &order.to_le_bytes())])?; + changed.insert(*id, object); + } + Ok(BTreeMap::from([(space, RevisionEdit::Update(changed))])) + }) + .unwrap(); + apply(&toc, transaction) + }; + let renamed = apply( + &dragged, + edit_table_of_contents( + &dragged, + &[TocEdit::Rename { + identity: a, + filename: "Kitchen.one".into(), + }], + ) + .unwrap(), + ); + assert_eq!( + displayed(&renamed), + [ + ("Song.one".into(), 2), + ("Album.one".into(), 2), + ("Kitchen.one".into(), 7) + ] + ); + let added = apply( + &renamed, + edit_table_of_contents( + &renamed, + &[TocEdit::Add { + filename: "Lore.one".into(), + identity: [4; 16], + group: false, + }], + ) + .unwrap(), + ); + assert_eq!( + entries(&added) + .into_iter() + .map(|(_, name, order)| (name, order)) + .collect::<Vec<_>>(), + [ + ("Song.one".into(), 1), + ("Album.one".into(), 2), + ("Kitchen.one".into(), 3), + ("Lore.one".into(), 4) + ] + ); + } +} diff --git a/crates/onestore/src/tree.rs b/crates/onestore/src/tree.rs index 8b1d66d7c86f9008fecbb7e207c64894fda92e64..978dce428560ee28bf04ce078c79c03279a212d0 100644 --- a/crates/onestore/src/tree.rs +++ b/crates/onestore/src/tree.rs @@ -458,9 +458,8 @@ mod tests { .unwrap(); let paragraph = view.nodes[&outline].children[0]; let text = view.nodes[¶graph].content[0]; - let deleted = |bytes: &[u8]| { - edited(bytes, sid, vec![PageOp::Delete { object: paragraph }]).is_ok() - }; + let deleted = + |bytes: &[u8]| edited(bytes, sid, vec![PageOp::Delete { object: paragraph }]).is_ok(); for target in [page, outline, paragraph, text] { for property in [0x08001cde, 0x08001cb4, 0x08001cf9, 0x08001cb2] { for enabled in [false, true] { diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs index 57b74738c91a97209a7d53a5e4b71dbb05c45bcc..8a5d92cf12e606eb320006836f0f0f058a30c2da 100644 --- a/crates/onestore/src/write.rs +++ b/crates/onestore/src/write.rs @@ -76,6 +76,16 @@ pub(crate) fn node(id: u16, reference: Option<Reference>, payload: &[u8]) -> Res Ok(bytes) } +/// A `RevisionRoleAndContextDeclarationFND` payload: `rid` current under `context` in the +/// default role. +pub(crate) fn label_payload(rid: ExGuid, context: ExGuid) -> Vec<u8> { + let mut payload = Vec::new(); + rid.encode(&mut payload); + payload.extend_from_slice(&1_u32.to_le_bytes()); + context.encode(&mut payload); + payload +} + pub(crate) fn append(data: &mut Vec<u8>, bytes: &[u8]) -> Result<Chunk> { let length = u64::from(u32::try_from(bytes.len()).map_err(|_| Error { offset: 0, @@ -1213,6 +1223,7 @@ pub(crate) fn differing<'s, T: PartialEq>(before: &'s [T], after: &'s [T]) -> (& } /// A revision `LiveRevision::commit` stored. +#[derive(Clone)] pub(crate) struct Commit { /// Whether the revision declares every object, depending on none. pub checkpoint: bool, @@ -1329,7 +1340,10 @@ fn build( if validate { index.validate_current()?; } - applied(source, build_on(&index, payloads, protection, edit)?.as_ref()) + applied( + source, + build_on(&index, payloads, protection, edit)?.as_ref(), + ) } /// `write_revision` on a source the caller has parsed and validated. @@ -1484,6 +1498,7 @@ pub(crate) fn build_on( previous: rid, new_space, label, + rid: None, live: &live, commit: &commit, replaced: &replaced, @@ -1578,6 +1593,8 @@ pub(crate) struct Sealing<'r, 'a> { pub new_space: bool, /// The context and role it is current under. pub label: (ExGuid, u32), + /// Its identity, where the caller chose one; a fresh one otherwise. + pub rid: Option<ExGuid>, pub live: &'r LiveRevision<'a>, pub commit: &'r Commit, /// Objects whose bytes the revision stores rather than referencing stored bytes. @@ -1722,6 +1739,23 @@ impl Appending { segments: &[(u64, &[u8])], protection: Option<&dyn Protection>, key: Option<&crate::Node<'_>>, + ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> { + let mut manifest = Vec::new(); + let written = self.manifest(sealing, segments, protection, key, &mut manifest)?; + self.close(sealing.space, sealing.new_space, &manifest)?; + Ok(written) + } + + /// Appends a revision's object groups and new data, and its manifest's nodes to + /// `manifest` for `close`; returns its identity and where it stored each replaced + /// object's bytes. + pub(crate) fn manifest( + &mut self, + sealing: &Sealing<'_, '_>, + segments: &[(u64, &[u8])], + protection: Option<&dyn Protection>, + key: Option<&crate::Node<'_>>, + manifest: &mut Vec<Vec<u8>>, ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> { let is_section = self.state.file_type == FileType::Section; let Sealing { @@ -1782,9 +1816,12 @@ impl Appending { .or_default() .push((*id, object)); } - let new_rid = ExGuid { - guid: fresh_guid()?, - n: 1, + let new_rid = match sealing.rid { + Some(rid) => rid, + None => ExGuid { + guid: fresh_guid()?, + n: 1, + }, }; let mut start = Vec::new(); new_rid.encode(&mut start); @@ -1806,7 +1843,6 @@ impl Appending { if contextual { label.0.encode(&mut start); } - let mut manifest = Vec::new(); if sealing.new_space { let mut payload = Vec::new(); space.encode(&mut payload); @@ -1997,8 +2033,13 @@ impl Appending { } } manifest.push(node(0x1c, None, &[])?); - if sealing.new_space { - let (chunk, tail) = self.list(&manifest)?; + Ok((new_rid, stored)) + } + + /// Adds a space's manifest nodes to its revision list, creating the space when `new`. + pub(crate) fn close(&mut self, space: ExGuid, new: bool, manifest: &[Vec<u8>]) -> Result<()> { + if new { + let (chunk, tail) = self.list(manifest)?; let mut payload = Vec::new(); space.encode(&mut payload); let (chunk, _) = self.list(&[ @@ -2013,10 +2054,10 @@ impl Appending { offset: 0, message: "Object space is absent from the root list", })?; - self.extend(&mut tail, &manifest)?; + self.extend(&mut tail, manifest)?; self.state.spaces.insert(space, tail); } - Ok((new_rid, stored)) + Ok(()) } /// Embeds payloads as file-data store objects referenced from the root file node list @@ -2221,12 +2262,14 @@ pub(crate) struct Written<'r, 'a> { pub space: ExGuid, pub rid: ExGuid, pub previous: Option<ExGuid>, + pub label: (ExGuid, u32), pub live: &'r LiveRevision<'a>, pub commit: &'r Commit, } /// Reads back what `transaction` appends to the section `before` describes, whose bytes -/// `segments` hold, and checks it stores `revisions` and `payloads` as `after` expects: +/// `segments` hold, and checks it stores `revisions`, then each space's `labels` (space, +/// revision, context), and `payloads` as `after` expects: /// fragments link and decode to the logged counts under the logged checksum; each declared /// object parses, names identities its group's table holds and objects reachable after its /// revision, carries its incremental reference count and, if read-only, its MD5; roots stay @@ -2237,6 +2280,7 @@ pub(crate) fn check_transaction( transaction: &crate::Transaction, segments: &[(u64, &[u8])], revisions: &[Written<'_, '_>], + labels: &[(ExGuid, ExGuid, ExGuid)], payloads: &[([u8; 16], &[u8])], ) -> Result<()> { let wrong = |message| Error { offset: 0, message }; @@ -2332,14 +2376,18 @@ pub(crate) fn check_transaction( } read(after.last) }; - for written in revisions { - let Written { - space, - rid, - live, - commit, - .. - } = *written; + // Each space's list gains one fragment: its revisions in order, then its labels. + let mut spaces: Vec<ExGuid> = Vec::new(); + for space in revisions + .iter() + .map(|written| written.space) + .chain(labels.iter().map(|(space, ..)| *space)) + { + if !spaces.contains(&space) { + spaces.push(space); + } + } + for space in spaces { let old = before.spaces.get(&space); let new = after .spaces @@ -2359,166 +2407,190 @@ pub(crate) fn check_transaction( .next() .ok_or(wrong("A revision manifest is truncated")) }; - let mut node = next()?; - if old.is_none() { + let mut fresh = old.is_none(); + for written in revisions.iter().filter(|written| written.space == space) { + let Written { + rid, + live, + commit, + label, + .. + } = *written; + let mut node = next()?; + if fresh { + let mut c = Cursor { + bytes: node.payload, + offset: node.offset, + }; + if node.id != 0x14 || c.exguid()? != space { + return Err(wrong("A new space's revision list names another space")); + } + node = next()?; + fresh = false; + } let mut c = Cursor { bytes: node.payload, offset: node.offset, }; - if node.id != 0x14 || c.exguid()? != space { - return Err(wrong("A new space's revision list names another space")); + let dependency = if commit.checkpoint { + ExGuid::default() + } else { + written + .previous + .ok_or(wrong("A dependent revision follows none"))? + }; + let contextual = label.0 != ExGuid::default(); + if node.id != if contextual { 0x1f } else { 0x1e } + || c.exguid()? != rid + || c.exguid()? != dependency + || c.read::<4>()? != label.1.to_le_bytes() + || c.read::<2>()? != [0, 0] + || (contextual && c.exguid()? != label.0) + { + return Err(wrong( + "A revision starts with the wrong identity, dependency or label", + )); + } + let mut declared = BTreeSet::new(); + let mut roots = BTreeMap::new(); + loop { + let node = next()?; + match node.id { + 0xb0 => { + let Some(Reference::NodeList(at)) = node.reference else { + return Err(wrong("An object group lacks its list")); + }; + let bytes = read(at)?; + let id = Fragment::parse(&bytes, at.offset as usize)?.id; + let required = *counts + .get(&id) + .ok_or(wrong("An object group is not logged"))?; + let group = nodes(&bytes, at, id, 0, required)?; + let mut table = BTreeMap::new(); + let mut override_crc = u32::MAX; + for item in &group { + let mut c = Cursor { + bytes: item.payload, + offset: item.offset, + }; + match item.id { + 0xb4 | 0x22 | 0x28 | 0xb8 => {} + 0x24 => { + table.insert(u32::from_le_bytes(c.read()?), c.read::<16>()?); + } + 0xa5 | 0xc5 | 0x73 => { + let id = c.compact(&table)?; + let object = + live.revision.objects.get(&id).ok_or(wrong( + "A revision declares an unknown object", + ))?; + let jcid = u32::from_le_bytes(c.read()?); + let flags = if item.id == 0x73 { + None + } else { + Some(c.read::<1>()?[0]) + }; + let count = u32::from_le_bytes(c.read()?); + if !declared.insert(id) + || jcid != object.jcid + || count != object.reference_count + { + return Err(wrong( + "A declaration disagrees with its object or its count", + )); + } + override_crc = + crc(override_crc, &count.to_le_bytes(), FileType::Section); + if item.id == 0x73 { + continue; + } + let Some(Reference::Data(at)) = item.reference else { + return Err(wrong("An object declaration lacks its data")); + }; + let data = read(at)?; + let stored = crate::Object { + jcid, + reference_count: count, + data: ObjectData::Properties(&data), + global_ids: Arc::new(table.clone()), + }; + let references = stored.references()?; + let expected = u8::from(!references.objects.is_empty()) + | (u8::from( + !references.object_spaces.is_empty() + || !references.contexts.is_empty(), + ) << 1); + if object.data != ObjectData::Properties(&data) + || flags != Some(expected) + || (item.id == 0xc5) != (jcid & 0x100000 != 0) + || (item.id == 0xc5 + && c.read::<16>()? != md5::compute(&data).0) + { + return Err(wrong( + "An object's stored bytes differ from its revision", + )); + } + if live.is_reachable(id) + && !references + .objects + .iter() + .all(|target| live.is_reachable(*target)) + { + return Err(wrong( + "An object references one its revision cannot reach", + )); + } + } + _ => return Err(wrong("Unexpected node in an object group")), + } + } + let node = next()?; + let mut c = Cursor { + bytes: node.payload, + offset: node.offset, + }; + if node.id != 0x84 + || c.read::<8>()? != [0; 8] + || u32::from_le_bytes(c.read()?) != !override_crc + { + return Err(wrong("An object group's count overrides do not match")); + } + } + 0x5a => { + let mut c = Cursor { + bytes: node.payload, + offset: node.offset, + }; + let id = c.exguid()?; + roots.insert(u32::from_le_bytes(c.read()?), id); + } + 0x1c => break, + _ => return Err(wrong("Unexpected node in a revision manifest")), + } + } + let expected: BTreeSet<ExGuid> = if commit.checkpoint { + live.revision.objects.keys().copied().collect() + } else { + commit.changed.clone() + }; + if declared != expected + || (commit.checkpoint && roots != live.revision.roots) + || (!commit.checkpoint && !roots.is_empty()) + { + return Err(wrong( + "A revision declares other objects or roots than it changed", + )); } - node = next()?; - } - let mut c = Cursor { - bytes: node.payload, - offset: node.offset, - }; - let dependency = if commit.checkpoint { - ExGuid::default() - } else { - written - .previous - .ok_or(wrong("A dependent revision follows none"))? - }; - if node.id != 0x1e - || c.exguid()? != rid - || c.exguid()? != dependency - || c.read::<4>()? != 1_u32.to_le_bytes() - || c.read::<2>()? != [0, 0] - { - return Err(wrong( - "A revision starts with the wrong identity or dependency", - )); } - let mut declared = BTreeSet::new(); - let mut roots = BTreeMap::new(); - loop { + for (_, rid, context) in labels.iter().filter(|(labelled, ..)| *labelled == space) { let node = next()?; - match node.id { - 0xb0 => { - let Some(Reference::NodeList(at)) = node.reference else { - return Err(wrong("An object group lacks its list")); - }; - let bytes = read(at)?; - let id = Fragment::parse(&bytes, at.offset as usize)?.id; - let required = *counts - .get(&id) - .ok_or(wrong("An object group is not logged"))?; - let group = nodes(&bytes, at, id, 0, required)?; - let mut table = BTreeMap::new(); - let mut override_crc = u32::MAX; - for item in &group { - let mut c = Cursor { - bytes: item.payload, - offset: item.offset, - }; - match item.id { - 0xb4 | 0x22 | 0x28 | 0xb8 => {} - 0x24 => { - table.insert(u32::from_le_bytes(c.read()?), c.read::<16>()?); - } - 0xa5 | 0xc5 | 0x73 => { - let id = c.compact(&table)?; - let object = live - .revision - .objects - .get(&id) - .ok_or(wrong("A revision declares an unknown object"))?; - let jcid = u32::from_le_bytes(c.read()?); - let flags = if item.id == 0x73 { - None - } else { - Some(c.read::<1>()?[0]) - }; - let count = u32::from_le_bytes(c.read()?); - if !declared.insert(id) - || jcid != object.jcid - || count != object.reference_count - { - return Err(wrong( - "A declaration disagrees with its object or its count", - )); - } - override_crc = - crc(override_crc, &count.to_le_bytes(), FileType::Section); - if item.id == 0x73 { - continue; - } - let Some(Reference::Data(at)) = item.reference else { - return Err(wrong("An object declaration lacks its data")); - }; - let data = read(at)?; - let stored = crate::Object { - jcid, - reference_count: count, - data: ObjectData::Properties(&data), - global_ids: Arc::new(table.clone()), - }; - let references = stored.references()?; - let expected = u8::from(!references.objects.is_empty()) - | (u8::from( - !references.object_spaces.is_empty() - || !references.contexts.is_empty(), - ) << 1); - if object.data != ObjectData::Properties(&data) - || flags != Some(expected) - || (item.id == 0xc5) != (jcid & 0x100000 != 0) - || (item.id == 0xc5 && c.read::<16>()? != md5::compute(&data).0) - { - return Err(wrong( - "An object's stored bytes differ from its revision", - )); - } - if live.is_reachable(id) - && !references - .objects - .iter() - .all(|target| live.is_reachable(*target)) - { - return Err(wrong( - "An object references one its revision cannot reach", - )); - } - } - _ => return Err(wrong("Unexpected node in an object group")), - } - } - let node = next()?; - let mut c = Cursor { - bytes: node.payload, - offset: node.offset, - }; - if node.id != 0x84 - || c.read::<8>()? != [0; 8] - || u32::from_le_bytes(c.read()?) != !override_crc - { - return Err(wrong("An object group's count overrides do not match")); - } - } - 0x5a => { - let mut c = Cursor { - bytes: node.payload, - offset: node.offset, - }; - let id = c.exguid()?; - roots.insert(u32::from_le_bytes(c.read()?), id); - } - 0x1c => break, - _ => return Err(wrong("Unexpected node in a revision manifest")), + if node.id != 0x5d || node.payload != label_payload(*rid, *context) { + return Err(wrong("A revision label names another revision or context")); } } - let expected: BTreeSet<ExGuid> = if commit.checkpoint { - live.revision.objects.keys().copied().collect() - } else { - commit.changed.clone() - }; - if declared != expected - || (commit.checkpoint && roots != live.revision.roots) - || (!commit.checkpoint && !roots.is_empty()) - { + if next().is_ok() { return Err(wrong( - "A revision declares other objects or roots than it changed", + "A revision list gains nodes no revision or label declares", )); } } @@ -2534,8 +2606,10 @@ pub(crate) fn check_transaction( )?; let created = revisions .iter() - .filter(|written| !before.spaces.contains_key(&written.space)) - .count(); + .map(|written| written.space) + .filter(|space| !before.spaces.contains_key(space)) + .collect::<BTreeSet<_>>() + .len(); let spaces = added.iter().filter(|node| node.id == 8).count(); if spaces != created || added.iter().any(|node| !matches!(node.id, 8 | 0x90)) { return Err(wrong( diff --git a/crates/onestore/src/write/tests.rs b/crates/onestore/src/write/tests.rs index 7f9f138491c89743808db459b9c598f3615d9dfa..59160afadc2b1ed70459f5c101969ce35cb574f9 100644 --- a/crates/onestore/src/write/tests.rs +++ b/crates/onestore/src/write/tests.rs @@ -130,7 +130,10 @@ fn document_insertions_and_formatting_respect_readonly_ancestors() { }) .unwrap(); let refused = |op| edited(&protected, sid, vec![op]).is_err(); - assert!(refused(insert(paragraph, text_paragraph("Nested", Format::default())))); + assert!(refused(insert( + paragraph, + text_paragraph("Nested", Format::default()) + ))); assert!(refused(PageOp::Split { text, at: 1, diff --git a/crates/onestore/src/write/tests/multi.rs b/crates/onestore/src/write/tests/multi.rs index 1680b1e3f9c4140ea33d2c5eae62ffc445d196ac..053042c2f019a1a8101d3b9c442f945ed16c7ad8 100644 --- a/crates/onestore/src/write/tests/multi.rs +++ b/crates/onestore/src/write/tests/multi.rs @@ -1,7 +1,6 @@ use crate::{ - ExGuid, RevisionIndex, Store, + ExGuid, RevisionIndex, Store, Transaction, document::{Document, Kind}, - Transaction, write::{PropertyObject, RevisionEdit, applied, revisions, write_revisions}, }; use std::collections::BTreeMap; diff --git a/crates/onestore/tests/commit.rs b/crates/onestore/tests/commit.rs index ba35e0dc8934e66d8ee993e85a19368f6ea9d55d..98ac0060964791651156c5dd65bafc907cdfa5b1 100644 --- a/crates/onestore/tests/commit.rs +++ b/crates/onestore/tests/commit.rs @@ -45,7 +45,8 @@ fn text_of(image: &[u8], space: ExGuid, object: ExGuid) -> String { let index = RevisionIndex::parse(&store).unwrap(); index.validate_current().unwrap(); let document = Document::parse(&index).unwrap(); - let Kind::RichText { text, .. } = &document.spaces[&space].active().unwrap().nodes[&object].kind + let Kind::RichText { text, .. } = + &document.spaces[&space].active().unwrap().nodes[&object].kind else { panic!() }; @@ -76,7 +77,13 @@ fn repeated_appends_cross_log_fragments_and_expose_counter_tears() { }; length = value.encode_utf16().count() as u32; section - .apply("Author", &Edit { at: 133_700_000_000_000_000 + u64::from(count), ops: vec![Op::Page { space, op }] }) + .apply( + "Author", + &Edit { + at: 133_700_000_000_000_000 + u64::from(count), + ops: vec![Op::Page { space, op }], + }, + ) .unwrap(); let transaction = section.seal().unwrap().unwrap(); let mut written = source.clone(); diff --git a/crates/onestore/tests/conflict.rs b/crates/onestore/tests/conflict.rs index 9d42f3773a391a3f67b4ba647d5f516e8858718d..ab9dd576037c7c0c702fb5e52544b4d412505a0d 100644 --- a/crates/onestore/tests/conflict.rs +++ b/crates/onestore/tests/conflict.rs @@ -91,13 +91,15 @@ fn native_conflict_pages_are_listed_under_their_page_last_first() { assert_eq!(conflicts.len(), 1); let (page, listed) = &conflicts[0]; assert_eq!(*page, pages[0].0); - let [ConflictPage { - space, - title, - user, - created, - objects, - }] = listed.as_slice() + let [ + ConflictPage { + space, + title, + user, + created, + objects, + }, + ] = listed.as_slice() else { panic!("{listed:?}") }; @@ -282,7 +284,10 @@ fn a_conflict_page_is_stored_as_onenote_stores_one() { std::fs::create_dir_all(output).unwrap(); std::fs::write(output.join("synthetic.one"), &image).unwrap(); std::fs::copy( - format!("{}/../../{notebook}/Open Notebook.onetoc2", env!("CARGO_MANIFEST_DIR")), + format!( + "{}/../../{notebook}/Open Notebook.onetoc2", + env!("CARGO_MANIFEST_DIR") + ), output.join("Open Notebook.onetoc2"), ) .unwrap(); @@ -335,9 +340,20 @@ fn deleting_a_conflict_page_stores_what_onenote_stores() { }; let (manifest, metadata, flagged, space) = shapes(&image); let (native_manifest, native_metadata, native_flagged, native_space) = shapes(&native); - assert_eq!(manifest.1.iter().filter(|id| **id != 0x3400347b).collect::<Vec<_>>(), [&0x24001c1f, &0x24003442]); assert_eq!( - native_manifest.1.iter().filter(|id| **id != 0x3400347b).collect::<Vec<_>>(), + manifest + .1 + .iter() + .filter(|id| **id != 0x3400347b) + .collect::<Vec<_>>(), + [&0x24001c1f, &0x24003442] + ); + assert_eq!( + native_manifest + .1 + .iter() + .filter(|id| **id != 0x3400347b) + .collect::<Vec<_>>(), [&0x24001c1f, &0x24003442] ); assert!(!metadata.1.contains(&0x88001d97) && !native_metadata.1.contains(&0x88001d97)); @@ -349,7 +365,10 @@ fn deleting_a_conflict_page_stores_what_onenote_stores() { std::fs::create_dir_all(output).unwrap(); std::fs::write(output.join("synthetic.one"), &image).unwrap(); std::fs::copy( - format!("{}/../../{notebook}/Open Notebook.onetoc2", env!("CARGO_MANIFEST_DIR")), + format!( + "{}/../../{notebook}/Open Notebook.onetoc2", + env!("CARGO_MANIFEST_DIR") + ), output.join("Open Notebook.onetoc2"), ) .unwrap(); diff --git a/crates/onestore/tests/document.rs b/crates/onestore/tests/document.rs index ccc831b773d2d740ebe68d73d528750dbaf828cc..96fb415b3ff9dc868733016bef99190778fb9a99 100644 --- a/crates/onestore/tests/document.rs +++ b/crates/onestore/tests/document.rs @@ -270,7 +270,7 @@ fn native_paragraph_breaks_preserve_every_stored_character() { } #[test] -fn referenced_history_contexts_require_history_roots() { +fn referenced_history_contexts_require_history_roots_where_stored() { let bytes = fs::read("../../corpus/m6/native-features-01/notebook/Features.one").unwrap(); let store = Store::parse(&bytes).unwrap(); let mut index = RevisionIndex::parse(&store).unwrap(); @@ -306,16 +306,16 @@ fn referenced_history_contexts_require_history_roots() { Document::parse(&index).unwrap_err().message, "History context does not resolve to version history" ); + // OneNote's Copy Page To of a page version names a history it never writes + // (corpus/page-versions/native/step-03). index .spaces .get_mut(&sid) .unwrap() .labels .remove(&(context, 1)); - assert_eq!( - Document::parse(&index).unwrap_err().message, - "Document context has no revision" - ); + let document = Document::parse(&index).unwrap(); + assert!(!document.spaces[&sid].contexts.contains_key(&context)); } #[test] diff --git a/crates/onestore/tests/edit.rs b/crates/onestore/tests/edit.rs index 6d9f87595221dfafcc8f86ab14d1ca980fcc9625..9cb6637e4c0c8ec74d373dba7790edb66532e292 100644 --- a/crates/onestore/tests/edit.rs +++ b/crates/onestore/tests/edit.rs @@ -181,9 +181,7 @@ fn snapshot_confirmation_needs_no_surviving_edit_target() { write_limit: 17, random: 911, }; - assert!( - replace_text(SOURCE, ExGuid::default(), ExGuid::default(), 0..0, "").is_err() - ); + assert!(replace_text(SOURCE, ExGuid::default(), ExGuid::default(), 0..0, "").is_err()); onestore::confirm(&mut disk, &onestore::Stamp::of(SOURCE).unwrap()).unwrap(); assert_refreshed(SOURCE, &disk.durable); let flush = disk.operation; @@ -278,9 +276,7 @@ fn confirmation_rejects_a_changed_header_or_length_before_any_write() { bytes: source.clone(), events: Vec::new(), }; - let confirmed = |events: &[trace::Event]| { - matches!(events, [trace::Event::Flush, trace::Event::Write(212, version), trace::Event::Flush] if version.len() == 40) - }; + let confirmed = |events: &[trace::Event]| matches!(events, [trace::Event::Flush, trace::Event::Write(212, version), trace::Event::Flush] if version.len() == 40); onestore::confirm(&mut disk, &base).unwrap(); assert!(confirmed(&disk.events)); for changed in [96, 128, 212, 1023] { @@ -455,8 +451,7 @@ fn invalid_text_edits_never_touch_storage() { write_limit: 1024, random: 1, }; - let error = - commit_text(&mut disk, &source, sid, oid, range, replacement).unwrap_err(); + let error = commit_text(&mut disk, &source, sid, oid, range, replacement).unwrap_err(); assert_eq!(error.state, CommitState::NotCommitted); assert_eq!(disk.operation, 0); assert_eq!(disk.durable, source); @@ -497,8 +492,7 @@ fn cross_run_splices_match_a_character_model_and_preserve_history() { let mut expected = before.clone(); expected.splice(a..b, replacement.chars().map(|c| (c, before[a].1.clone()))); let edited = - replace_text(SOURCE, sid, oid, offsets[a]..offsets[b], replacement) - .unwrap(); + replace_text(SOURCE, sid, oid, offsets[a]..offsets[b], replacement).unwrap(); assert_eq!(characters(&edited), expected, "characters {a}..{b}"); let current_store = Store::parse(&edited).unwrap(); assert_eq!( @@ -519,8 +513,7 @@ fn cross_run_splices_match_a_character_model_and_preserve_history() { } } } - let cleared = - replace_text(SOURCE, sid, oid, 0..*offsets.last().unwrap(), "").unwrap(); + let cleared = replace_text(SOURCE, sid, oid, 0..*offsets.last().unwrap(), "").unwrap(); let typed = replace_text(&cleared, sid, oid, 0..0, "new").unwrap(); let format = &before.last().unwrap().1; assert_eq!( @@ -579,8 +572,7 @@ fn export_native_cross_run_edits() { }) .unwrap(); let range = range.start..range.end.min(length); - let mut edited = - replace_text(&source, sid, oid, range.clone(), replacement).unwrap(); + let mut edited = replace_text(&source, sid, oid, range.clone(), replacement).unwrap(); if retype { edited = replace_text(&edited, sid, oid, 0..0, "Retyped 🦀").unwrap(); } @@ -648,8 +640,7 @@ fn automatic_titles_follow_native_line_and_utf16_limits() { }; let (sid, oid) = check(&source, &expected); let cleared = - replace_text(&source, sid, oid, 0..text.encode_utf16().count() as u32, "") - .unwrap(); + replace_text(&source, sid, oid, 0..text.encode_utf16().count() as u32, "").unwrap(); check(&cleared, ""); let written = replace_text(&cleared, sid, oid, 0..0, " new 🦀 name ").unwrap(); check(&written, "new 🦀 name"); @@ -803,8 +794,7 @@ fn title_text_and_navigation_caches_publish_together() { for (source, write_limit) in [(source.as_slice(), 17), (checkpoint.as_slice(), 257)] { let before = state(source); for replacement in ["Renamed 🦀 日本語", ""] { - let edit = - text_transaction(source, sid, *oid, 0..end, replacement).unwrap(); + let edit = text_transaction(source, sid, *oid, 0..end, replacement).unwrap(); let disk = |fail_at| Disk { visible: source.to_vec(), durable: source.to_vec(), @@ -916,8 +906,7 @@ fn interrupted_text_commits_never_publish_mismatched_run_boundaries() { for seed in [1, 42] { let mut disk = make_disk(Some(at), seed); let error = - commit_text(&mut disk, source, sid, oid, 0..23, "🐈 mixed edit") - .unwrap_err(); + commit_text(&mut disk, source, sid, oid, 0..23, "🐈 mixed edit").unwrap_err(); let observed = text_runs(&disk.durable, sid, oid); match error.state { CommitState::NotCommitted => assert_eq!(observed, before), @@ -1018,8 +1007,7 @@ fn empty_and_legacy_native_text_gain_unicode_without_losing_existing_properties( for seed in [1, 42] { let mut interrupted = disk(Some(at), seed); let error = - commit_text(&mut interrupted, &source, sid, oid, 0..0, "🦀 café ") - .unwrap_err(); + commit_text(&mut interrupted, &source, sid, oid, 0..0, "🦀 café ").unwrap_err(); let actual = text_runs(&interrupted.durable, sid, oid); match error.state { CommitState::NotCommitted => assert_eq!(actual, before), diff --git a/crates/onestore/tests/formatting.rs b/crates/onestore/tests/formatting.rs index 9631bb8119fb36b0a9d86ceb842603f106b4f24c..89e508ecc279f7a001264e505ac2cc7f3332e0bb 100644 --- a/crates/onestore/tests/formatting.rs +++ b/crates/onestore/tests/formatting.rs @@ -1,9 +1,9 @@ -#[path = "support/ops.rs"] -mod ops; #[path = "support/current.rs"] mod current; #[path = "support/disk.rs"] mod disk; +#[path = "support/ops.rs"] +mod ops; #[path = "support/sweep.rs"] mod sweep; use onestore::{ @@ -90,7 +90,16 @@ fn overlapping_unicode_format_edits_match_an_independent_character_model() { json!(0x00ff00_u32), ), }; - let edit = ops::page_op(&source, sid, PageOp::Format { text: id, range: offsets[start]..offsets[end], set: std::slice::from_ref(&attribute).to_vec(), clear: Vec::new() }) + let edit = ops::page_op( + &source, + sid, + PageOp::Format { + text: id, + range: offsets[start]..offsets[end], + set: std::slice::from_ref(&attribute).to_vec(), + clear: Vec::new(), + }, + ) .unwrap(); for (_, style) in &mut expected[start..end] { style[key] = value.clone(); @@ -101,7 +110,16 @@ fn overlapping_unicode_format_edits_match_an_independent_character_model() { "seed {seed}, step {step}" ); assert_eq!( - ops::page_op(edit.as_bytes(), sid, PageOp::Format { text: id, range: offsets[start]..offsets[end], set: vec![attribute], clear: Vec::new() }) + ops::page_op( + edit.as_bytes(), + sid, + PageOp::Format { + text: id, + range: offsets[start]..offsets[end], + set: vec![attribute], + clear: Vec::new() + } + ) .unwrap() .as_bytes(), edit.as_bytes() @@ -123,9 +141,27 @@ fn overlapping_unicode_format_edits_match_an_independent_character_model() { fn script_positions_are_exclusive_and_explicit_false_overrides_true() { let source = onestore::create_section("script.one", "abc", "Author").unwrap(); let (sid, id) = target(&source); - let superscript = ops::page_op(&source, sid, PageOp::Format { text: id, range: 0..3, set: vec![A::Superscript(true), A::Bold(true)], clear: Vec::new() }) + let superscript = ops::page_op( + &source, + sid, + PageOp::Format { + text: id, + range: 0..3, + set: vec![A::Superscript(true), A::Bold(true)], + clear: Vec::new(), + }, + ) .unwrap(); - let subscript = ops::page_op(superscript.as_bytes(), sid, PageOp::Format { text: id, range: 1..2, set: vec![A::Subscript(true), A::Bold(false)], clear: Vec::new() }) + let subscript = ops::page_op( + superscript.as_bytes(), + sid, + PageOp::Format { + text: id, + range: 1..2, + set: vec![A::Subscript(true), A::Bold(false)], + clear: Vec::new(), + }, + ) .unwrap(); let chars = characters(subscript.as_bytes(), sid, id); assert_eq!(chars[0].1["superscript"], true); @@ -140,9 +176,27 @@ fn script_positions_are_exclusive_and_explicit_false_overrides_true() { fn empty_paragraph_style_is_used_by_later_text_edits() { let source = onestore::create_section("empty.one", "", "Author").unwrap(); let (sid, id) = target(&source); - let formatted = ops::page_op(&source, sid, PageOp::Format { text: id, range: 0..0, set: vec![A::Italic(true), A::FontSize(18.0)], clear: Vec::new() }) + let formatted = ops::page_op( + &source, + sid, + PageOp::Format { + text: id, + range: 0..0, + set: vec![A::Italic(true), A::FontSize(18.0)], + clear: Vec::new(), + }, + ) + .unwrap(); + let filled = ops::page_op( + formatted.as_bytes(), + sid, + PageOp::Text { + text: id, + range: 0..0, + with: "Added 🦀".into(), + }, + ) .unwrap(); - let filled = ops::page_op(formatted.as_bytes(), sid, PageOp::Text { text: id, range: 0..0, with: "Added 🦀".into() }).unwrap(); for (_, format) in characters(filled.as_bytes(), sid, id) { assert_eq!(format["italic"], true); assert_eq!(format["font_size"], 18.0); @@ -154,7 +208,19 @@ fn invalid_ranges_attributes_and_fields_are_rejected() { let (sid, id) = target(&source); for (start, end) in [(2, 3), (1, 2), (0, 8), (2, 2), (3, 1), (1, 1)] { let range = start..end; - assert!(ops::page_op(&source, sid, PageOp::Format { text: id, range, set: vec![A::Bold(true)], clear: Vec::new() }).is_err()); + assert!( + ops::page_op( + &source, + sid, + PageOp::Format { + text: id, + range, + set: vec![A::Bold(true)], + clear: Vec::new() + } + ) + .is_err() + ); } for attributes in [ vec![], @@ -163,18 +229,63 @@ fn invalid_ranges_attributes_and_fields_are_rejected() { vec![A::Font("".into())], vec![A::Font("a\0b".into())], ] { - assert!(ops::page_op(&source, sid, PageOp::Format { text: id, range: 0..4, set: attributes.to_vec(), clear: Vec::new() }).is_err()); + assert!( + ops::page_op( + &source, + sid, + PageOp::Format { + text: id, + range: 0..4, + set: attributes.to_vec(), + clear: Vec::new() + } + ) + .is_err() + ); } for size in [f32::NAN, f32::INFINITY, 0.0, 5.5, 130.5, 144.0, 144.5, 12.1] { - assert!(ops::page_op(&source, sid, PageOp::Format { text: id, range: 0..4, set: vec![A::FontSize(size)], clear: Vec::new() }).is_err()); + assert!( + ops::page_op( + &source, + sid, + PageOp::Format { + text: id, + range: 0..4, + set: vec![A::FontSize(size)], + clear: Vec::new() + } + ) + .is_err() + ); } - assert!(ops::page_op(&source, sid, PageOp::Format { text: ExGuid::default(), range: 0..4, set: vec![A::Bold(true)], clear: Vec::new() }).is_err()); + assert!( + ops::page_op( + &source, + sid, + PageOp::Format { + text: ExGuid::default(), + range: 0..4, + set: vec![A::Bold(true)], + clear: Vec::new() + } + ) + .is_err() + ); } #[test] fn formatting_publication_faults_preserve_complete_old_or_new_styles() { let source = onestore::create_section("atomic.one", "Before 🦀 after", "Author").unwrap(); let (sid, id) = target(&source); - let edit = ops::page_op(&source, sid, PageOp::Format { text: id, range: 2..10, set: vec![A::Bold(true), A::Color(Some([8, 64, 128]))], clear: Vec::new() }) + let edit = ops::page_op( + &source, + sid, + PageOp::Format { + text: id, + range: 2..10, + set: vec![A::Bold(true), A::Color(Some([8, 64, 128]))], + clear: Vec::new(), + }, + ) .unwrap(); let before = current::current(&source); let after = current::current(edit.as_bytes()); @@ -219,8 +330,17 @@ fn export_native_formatting_candidates() { let mut manifest = Vec::new(); let mut save = |name: &str, source: &[u8], sid, id, range: std::ops::Range<u32>, attributes: &[A]| { - let prepared = - ops::page_op(source, sid, PageOp::Format { text: id, range: range.clone(), set: attributes.to_vec(), clear: Vec::new() }).unwrap(); + let prepared = ops::page_op( + source, + sid, + PageOp::Format { + text: id, + range: range.clone(), + set: attributes.to_vec(), + clear: Vec::new(), + }, + ) + .unwrap(); fs::write(output.join(format!("{name}.one")), prepared.as_bytes()).unwrap(); manifest.push( json!({"name":name,"space":sid,"object":id,"range":range,"attributes":attributes}), @@ -298,9 +418,26 @@ fn export_native_formatting_candidates() { ); let empty = onestore::create_section("empty.one", "", "Author").unwrap(); let (empty_sid, empty_id) = target(&empty); - let formatted = ops::page_op(&empty, empty_sid, PageOp::Format { text: empty_id, range: 0..0, set: vec![A::FontSize(18.0), A::Italic(true)], clear: Vec::new() }) + let formatted = ops::page_op( + &empty, + empty_sid, + PageOp::Format { + text: empty_id, + range: 0..0, + set: vec![A::FontSize(18.0), A::Italic(true)], + clear: Vec::new(), + }, + ) .unwrap(); - let typed = ops::page_op(formatted.as_bytes(), empty_sid, PageOp::Text { text: empty_id, range: 0..0, with: "Typed café 🦀".into() }) + let typed = ops::page_op( + formatted.as_bytes(), + empty_sid, + PageOp::Text { + text: empty_id, + range: 0..0, + with: "Typed café 🦀".into(), + }, + ) .unwrap(); fs::write(output.join("empty-then-type.one"), typed.as_bytes()).unwrap(); let native = include_bytes!("../../../corpus/native-ink/20260905-ui/notebook/synthetic.one"); diff --git a/crates/onestore/tests/insertion.rs b/crates/onestore/tests/insertion.rs index b06fb730ed426c58a5b52cfed1b1c9a5221004a1..3850c394cf436496f19ad99e737374fecee65ae0 100644 --- a/crates/onestore/tests/insertion.rs +++ b/crates/onestore/tests/insertion.rs @@ -50,7 +50,13 @@ fn insertion( Place::Outline(x, y) => { let object = ops::outline(x, y, vec![paragraph]); let id = object.id(); - (PageOp::Add { object, before: None }, id) + ( + PageOp::Add { + object, + before: None, + }, + id, + ) } }; let mut result = vec![Op::Page { space, op }]; @@ -161,7 +167,10 @@ fn atomic_formatted_insertions_match_a_character_model_and_preserve_history() { Place::In(outline, None) }; let (ops, _, inserted) = insertion(sid, place, text, &formats); - let edit = Edit { at: ops::now(), ops }; + let edit = Edit { + at: ops::now(), + ops, + }; let restored: Edit = serde_json::from_slice(&serde_json::to_vec(&edit).unwrap()).unwrap(); assert_eq!(restored, edit); let edited = ops::edited(&source, restored.ops).unwrap(); @@ -308,7 +317,10 @@ fn export_native_formatted_insertions() { .into_iter() .map(|(label, attributes)| { let start = text[..text.find(label).unwrap()].encode_utf16().count() as u32; - (start..start + label.encode_utf16().count() as u32, attributes) + ( + start..start + label.encode_utf16().count() as u32, + attributes, + ) }) .collect() }; @@ -322,7 +334,10 @@ fn export_native_formatted_insertions() { }; ops.push(Op::Page { space: sid, op }); } - let edit = Edit { at: ops::now(), ops }; + let edit = Edit { + at: ops::now(), + ops, + }; let bytes = ops::edited(source, edit.ops.clone()).unwrap(); current::current(&bytes); let notebook = output.join(name); @@ -348,7 +363,9 @@ fn paragraph_and_outline_insertions_publish_metadata_and_references_together() { (Place::Outline(144.0, 18.0), page, Some(144.0), Some(18.0)), ] { let (ops, object, text) = insertion(sid, place, "First 🦀\rSecond", &[]); - let transaction = ops::transaction(&source, "New author", ops).unwrap().unwrap(); + let transaction = ops::transaction(&source, "New author", ops) + .unwrap() + .unwrap(); let mut written = source.clone(); transaction.apply(&mut written).unwrap(); let store = Store::parse(&written).unwrap(); @@ -405,7 +422,9 @@ fn repeated_insertions_and_formatting_share_immutable_objects() { let mut texts = Vec::new(); for _ in 0..12 { let (ops, _, text) = insertion(sid, Place::In(outline, None), "Repeated paragraph", &[]); - let transaction = ops::transaction(&source, "Same author", ops).unwrap().unwrap(); + let transaction = ops::transaction(&source, "Same author", ops) + .unwrap() + .unwrap(); transaction.apply(&mut source).unwrap(); let bold = PageOp::Format { text, @@ -413,9 +432,16 @@ fn repeated_insertions_and_formatting_share_immutable_objects() { set: vec![A::Bold(true), A::FontSize(20.0)], clear: Vec::new(), }; - let transaction = ops::transaction(&source, "Same author", vec![Op::Page { space: sid, op: bold }]) - .unwrap() - .unwrap(); + let transaction = ops::transaction( + &source, + "Same author", + vec![Op::Page { + space: sid, + op: bold, + }], + ) + .unwrap() + .unwrap(); transaction.apply(&mut source).unwrap(); texts.push(text); } @@ -459,8 +485,12 @@ fn serialized_insertions_rebase_with_the_same_objects_and_preserve_remote_edits( let source = onestore::create_section("rebase.one", "Original", "Author").unwrap(); let (sid, _, outline, paragraph, text) = targets(&source); let formats = [(0..3, vec![A::Bold(true), A::Color(Some([12, 34, 56]))])]; - let (ops, object, inserted) = - insertion(sid, Place::In(outline, Some(paragraph)), "Inserted", &formats); + let (ops, object, inserted) = insertion( + sid, + Place::In(outline, Some(paragraph)), + "Inserted", + &formats, + ); let encoded = serde_json::to_vec(&ops).unwrap(); let restored: Vec<Op> = serde_json::from_slice(&encoded).unwrap(); assert_eq!(ops, restored); @@ -540,8 +570,12 @@ fn anchors_targets_identities_and_text_are_validated_before_publication() { assert!(refused(&source, Place::In(outline, None), content)); } assert!( - ops::transaction(&source, "a\0b", insertion(sid, Place::In(outline, None), "Text", &[]).0) - .is_err() + ops::transaction( + &source, + "a\0b", + insertion(sid, Place::In(outline, None), "Text", &[]).0 + ) + .is_err() ); for place in [ Place::In(outline, Some(text)), @@ -610,13 +644,21 @@ fn insertion_publication_faults_expose_only_complete_graphs_and_title_caches() { &checkpoint, ] { let (sid, _, outline, paragraph, _) = targets(source); - for place in [Place::In(outline, Some(paragraph)), Place::Outline(0.0, 0.0)] { + for place in [ + Place::In(outline, Some(paragraph)), + Place::Outline(0.0, 0.0), + ] { let formats = [ (0..3, vec![A::Bold(true), A::FontSize(18.0)]), - (6..8, vec![A::Italic(true), A::Highlight(Some([255, 255, 0]))]), + ( + 6..8, + vec![A::Italic(true), A::Highlight(Some([255, 255, 0]))], + ), ]; let (ops, _, _) = insertion(sid, place, "First 🦀", &formats); - let edit = ops::transaction(source, "New author", ops).unwrap().unwrap(); + let edit = ops::transaction(source, "New author", ops) + .unwrap() + .unwrap(); let mut written = source.to_vec(); edit.apply(&mut written).unwrap(); let before = current::current(source); diff --git a/crates/onestore/tests/outline.rs b/crates/onestore/tests/outline.rs index 232e76eea2a21ec45da8ef058f09474b9ceef7ca..0465495fa25646184eae83ef951a62d33e35c4fc 100644 --- a/crates/onestore/tests/outline.rs +++ b/crates/onestore/tests/outline.rs @@ -67,7 +67,15 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() { for (name, sid, object, edit) in cases(&document) { let restored = serde_json::from_value(serde_json::to_value(edit).unwrap()).unwrap(); assert_eq!(edit, restored); - let prepared = ops::page_op(SOURCE, sid, PageOp::Outline { object, edit: restored }).unwrap(); + let prepared = ops::page_op( + SOURCE, + sid, + PageOp::Outline { + object, + edit: restored, + }, + ) + .unwrap(); let after_store = Store::parse(prepared.as_bytes()).unwrap(); assert!(after_store.checksum_mismatches.is_empty()); let after_index = RevisionIndex::parse(&after_store).unwrap(); @@ -144,7 +152,8 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() { assert_eq!(old_space.labels, after_index.spaces[space_id].labels); } } - let again = ops::page_op(prepared.as_bytes(), sid, PageOp::Outline { object, edit }).unwrap(); + let again = + ops::page_op(prepared.as_bytes(), sid, PageOp::Outline { object, edit }).unwrap(); assert_eq!(again.as_bytes(), prepared.as_bytes()); } } @@ -169,10 +178,17 @@ fn invalid_geometry_and_non_outline_targets_are_rejected() { } for points in [-1.0, 0.0, 35.999] { assert!( - ops::page_op(SOURCE, sid, PageOp::Outline { object, edit: Edit::Width { - points, - user_set: false - } }) + ops::page_op( + SOURCE, + sid, + PageOp::Outline { + object, + edit: Edit::Width { + points, + user_set: false + } + } + ) .is_err() ); } @@ -181,8 +197,15 @@ fn invalid_geometry_and_non_outline_targets_are_rejected() { for (id, node) in &view.nodes { if !matches!(node.kind, Kind::Outline { .. }) { assert!( - ops::page_op(SOURCE, sid, PageOp::Outline { object: *id, edit: Edit::Position { x: 72.0, y: 72.0 } }) - .is_err() + ops::page_op( + SOURCE, + sid, + PageOp::Outline { + object: *id, + edit: Edit::Position { x: 72.0, y: 72.0 } + } + ) + .is_err() ); } if matches!(node.kind, Kind::Title) { @@ -197,20 +220,55 @@ fn invalid_geometry_and_non_outline_targets_are_rejected() { }, Edit::Collapsed(true), ] { - assert!(ops::page_op(SOURCE, sid, PageOp::Outline { object: child, edit }).is_err()); + assert!( + ops::page_op( + SOURCE, + sid, + PageOp::Outline { + object: child, + edit + } + ) + .is_err() + ); } } } if !matches!(node.kind, Kind::Paragraph { .. }) { - assert!(ops::page_op(SOURCE, sid, PageOp::Outline { object: *id, edit: Edit::Collapsed(true) }).is_err()); + assert!( + ops::page_op( + SOURCE, + sid, + PageOp::Outline { + object: *id, + edit: Edit::Collapsed(true) + } + ) + .is_err() + ); } } assert!( - ops::page_op(SOURCE, ExGuid::default(), PageOp::Outline { object, edit: Edit::Collapsed(true) }) - .is_err() + ops::page_op( + SOURCE, + ExGuid::default(), + PageOp::Outline { + object, + edit: Edit::Collapsed(true) + } + ) + .is_err() ); assert!( - ops::page_op(SOURCE, sid, PageOp::Outline { object: ExGuid::default(), edit: Edit::Collapsed(true) }).is_err() + ops::page_op( + SOURCE, + sid, + PageOp::Outline { + object: ExGuid::default(), + edit: Edit::Collapsed(true) + } + ) + .is_err() ); } } @@ -228,10 +286,17 @@ fn resizing_a_native_reserved_width_preserves_content() { let space = &document.spaces[&sid]; let before = &space.revisions[&space.contexts[&ExGuid::default()]]; assert!(before.nodes[&object].layout.reserved_width.is_some()); - let edit = ops::page_op(source, sid, PageOp::Outline { object, edit: Edit::Width { - points: 144.0, - user_set: true, - } }) + let edit = ops::page_op( + source, + sid, + PageOp::Outline { + object, + edit: Edit::Width { + points: 144.0, + user_set: true, + }, + }, + ) .unwrap(); let after_store = Store::parse(edit.as_bytes()).unwrap(); let after_index = RevisionIndex::parse(&after_store).unwrap(); @@ -279,8 +344,15 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content (144.0, 36.0, "First"), (0.0, 144.0, "First"), ] { - let edit = ops::page_op(&source, sid, PageOp::Outline { object: insertion, edit: Edit::Position { x, y } }) - .unwrap(); + let edit = ops::page_op( + &source, + sid, + PageOp::Outline { + object: insertion, + edit: Edit::Position { x, y }, + }, + ) + .unwrap(); let saved = Store::parse(edit.as_bytes()).unwrap(); let saved_index = RevisionIndex::parse(&saved).unwrap(); saved_index.validate_current().unwrap(); @@ -311,7 +383,14 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content format!("{:?}", saved_index.resolve(sid, rid).unwrap()) ); assert_eq!( - ops::page_op(edit.as_bytes(), sid, PageOp::Outline { object: insertion, edit: Edit::Position { x, y } }) + ops::page_op( + edit.as_bytes(), + sid, + PageOp::Outline { + object: insertion, + edit: Edit::Position { x, y } + } + ) .unwrap() .as_bytes(), edit.as_bytes() @@ -352,10 +431,17 @@ fn native_automatic_and_explicit_titles_follow_outline_movement() { 72.0 }, }; - candidate = ops::page_op(&candidate, sid, PageOp::Outline { object: outline, edit: change }) - .unwrap() - .as_bytes() - .to_vec(); + candidate = ops::page_op( + &candidate, + sid, + PageOp::Outline { + object: outline, + edit: change, + }, + ) + .unwrap() + .as_bytes() + .to_vec(); let store = Store::parse(&candidate).unwrap(); let saved = RevisionIndex::parse(&store).unwrap(); saved.validate_current().unwrap(); @@ -417,7 +503,15 @@ fn outline_publication_interruptions_reopen_as_complete_old_or_new_layout() { ), (paragraph, Edit::Collapsed(true)), ] { - let edit = ops::page_op(&source, sid, PageOp::Outline { object, edit: operation }).unwrap(); + let edit = ops::page_op( + &source, + sid, + PageOp::Outline { + object, + edit: operation, + }, + ) + .unwrap(); let before = current::current(&source); let after = current::current(edit.as_bytes()); for write_limit in [17, 4096] { diff --git a/crates/onestore/tests/page.rs b/crates/onestore/tests/page.rs index 53164ab0b1351c26968f26d46afbf59145d4a686..8e3a363e06408a17b9b221daeb9250862c6e5350 100644 --- a/crates/onestore/tests/page.rs +++ b/crates/onestore/tests/page.rs @@ -117,7 +117,15 @@ fn created_pages_support_title_edits_and_body_insertion() { Some(title.filter(|s| !s.is_empty()).unwrap_or("Body 🦀 é")) ); if let Some(text) = intent.title_object() { - source = ops::page_op(&source, intent.space(), PageOp::Text { text, range: 0..title.unwrap().encode_utf16().count() as u32, with: "Renamed".into() }) + source = ops::page_op( + &source, + intent.space(), + PageOp::Text { + text, + range: 0..title.unwrap().encode_utf16().count() as u32, + with: "Renamed".into(), + }, + ) .unwrap() .as_bytes() .to_vec(); @@ -359,10 +367,18 @@ fn native_changes_on_created_pages_accept_rust_followups() { let mut source = initial.to_vec(); for (sid, title, body) in targets { for (object, prefix) in [(body, "Rust + "), (title, "Reviewed ")] { - source = ops::page_op(&source, sid, PageOp::Text { text: object, range: 0..0, with: prefix.into() }) - .unwrap() - .as_bytes() - .to_vec(); + source = ops::page_op( + &source, + sid, + PageOp::Text { + text: object, + range: 0..0, + with: prefix.into(), + }, + ) + .unwrap() + .as_bytes() + .to_vec(); } let store = Store::parse(&source).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); diff --git a/crates/onestore/tests/page_deletion.rs b/crates/onestore/tests/page_deletion.rs index 002580cc331cde6982c37db49122ec2a92a565c3..f42f92a4bd42f071ea020a4bed60202649777c71 100644 --- a/crates/onestore/tests/page_deletion.rs +++ b/crates/onestore/tests/page_deletion.rs @@ -171,8 +171,11 @@ fn removal_rejects_duplicate_missing_and_non_page_spaces_without_publication() { ); let written = ops::section_op(SOURCE, SectionOp::Delete([pages[0].0].to_vec())).unwrap(); assert!( - ops::section_op(written.as_bytes(), SectionOp::Delete([pages[1].0, pages[0].0].to_vec())) - .is_err() + ops::section_op( + written.as_bytes(), + SectionOp::Delete([pages[1].0, pages[0].0].to_vec()) + ) + .is_err() ); } @@ -427,7 +430,12 @@ fn external_payloads_and_their_historical_references_survive_file_removal() { assert_eq!(payloads.len(), 3); let path = root.join("synthetic.one"); std::fs::write(&path, &original).unwrap(); - prepared.transaction.as_ref().unwrap().commit_file(&path).unwrap(); + prepared + .transaction + .as_ref() + .unwrap() + .commit_file(&path) + .unwrap(); let written = onestore::read_file(&path).unwrap(); assert_eq!(written, prepared.as_bytes()); let current_store = Store::parse(&written).unwrap(); diff --git a/crates/onestore/tests/page_ink.rs b/crates/onestore/tests/page_ink.rs index c986b4d402e308f051bd5f42fffb3c693ca55e42..329e50b6662427da2472c06b69b0668b86b71b28 100644 --- a/crates/onestore/tests/page_ink.rs +++ b/crates/onestore/tests/page_ink.rs @@ -6,7 +6,6 @@ use onestore::{ page::{Ink, InkStroke, Page, PageObject, text::new_id}, }; - /// Two drawings made with the mouse in OneNote 2010 (`tools/native/ink.ahk`): a diamond and a /// diagonal line, whose native read reports their positions and sizes. const NATIVE: &[u8] = diff --git a/crates/onestore/tests/page_links.rs b/crates/onestore/tests/page_links.rs index f7472b47220a8ca5fbef6c482cc337100aec9fac..7f79c7d2cf355bcdc86023590a002c1a77039d62 100644 --- a/crates/onestore/tests/page_links.rs +++ b/crates/onestore/tests/page_links.rs @@ -162,6 +162,8 @@ fn a_link_is_added_to_a_fresh_page_and_reads_back() { ); let written = ops::saved(&source, space, &after).unwrap(); let stored = assert_same(written.as_slice(), space, &after); + // As OneNote titles `NATIVE_LINKS`'s page holding this paragraph: without the field code. + assert_eq!(stored.title, "Read about Rust the Rust site"); let mut stored = stored; let text = &body_paragraphs(&mut stored)[0].text().unwrap().text; let flags: Vec<(Option<bool>, Option<bool>, Option<bool>)> = text @@ -285,6 +287,24 @@ fn internal_links_match_what_onenote_stores() { ); assert_eq!(parse_internal_link(&stored[0]), None); assert_eq!(parse_internal_link("onenote:#x&page-id={0}"), None); + // The Link dialog's page and Copy Link to Page name the section file before the `#`. + let ids = "section-id={0F1EFC25-DF7C-45CB-87A4-BDD03F42057E}&page-id={A9CCA318-A43E-4912-A1C7-F19F1EAF30CA}"; + for url in [ + format!("onenote:links.one#Page&{ids}&base-path=//C:/notebook"), + format!("onenote:///C:\\notebook\\links.one#Page&{ids}&end"), + ] { + let link = parse_internal_link(&url).unwrap(); + assert_eq!( + ( + link.section[..4].to_vec(), + link.page.map(|page| page[..4].to_vec()) + ), + ( + vec![0x25, 0xfc, 0x1e, 0x0f], + Some(vec![0x18, 0xa3, 0xcc, 0xa9]) + ) + ); + } } /// `ONESTORE_INTERNAL_LINK_EXPORT` names a new directory receiving the candidate for a cold @@ -295,7 +315,9 @@ fn a_page_links_to_another_page_and_its_paragraph() { let creation = onestore::PageCreation::new(None, Some("Link target"), "Author").unwrap(); let with_target = ops::edited( &source, - vec![onestore::op::Op::Section(onestore::op::SectionOp::Create(creation))], + vec![onestore::op::Op::Section(onestore::op::SectionOp::Create( + creation, + ))], ) .unwrap(); let section = Store::parse(&with_target).unwrap().header.file_id; @@ -354,7 +376,13 @@ fn a_page_links_to_another_page_and_its_paragraph() { }) .unwrap(); let written = ops::saved(&with_target, space, &after).unwrap(); - assert_same(written.as_slice(), space, &after); + let stored = assert_same(written.as_slice(), space, &after); + // Titles show the link's label, not its field code, as OneNote stores them. + assert!( + stored.title.starts_with("Linking page Link target"), + "{}", + stored.title + ); if let Some(directory) = std::env::var_os("ONESTORE_INTERNAL_LINK_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir(&directory).unwrap(); @@ -367,3 +395,20 @@ fn a_page_links_to_another_page_and_its_paragraph() { .unwrap(); } } + +/// A title stored with a link's field code, as an earlier writer left it +/// (`corpus/link-edit/internal/candidate`), reads as the label the page shows. +#[test] +fn titles_read_without_link_field_codes() { + let bytes = include_bytes!("../../../corpus/link-edit/internal/candidate/links.one").to_vec(); + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, bytes.clone()).unwrap(); + let titles: Vec<String> = section + .pages() + .unwrap() + .into_iter() + .map(|(_, title, _)| title) + .collect(); + assert_eq!(titles, ["Linking page Link target", "Link target"]); + page_by_title(&bytes, "Linking page Link target"); +} diff --git a/crates/onestore/tests/page_math.rs b/crates/onestore/tests/page_math.rs index b7f707d5a90475972f35e4597a32fa6abb023daf..79eb8b9b9c45e4907c5c83158eccd7bf62873846 100644 --- a/crates/onestore/tests/page_math.rs +++ b/crates/onestore/tests/page_math.rs @@ -26,6 +26,10 @@ const EDITOR_3: &[u8] = include_bytes!("../../../corpus/math-edit/native-editor-3/notebook/links.one"); const EDITOR_3_READ: &str = include_str!("../../../corpus/math-edit/native-editor-3/read/page-000.xml"); +/// A fourth session repeating most of the others and adding an exponent, Greek letters, a +/// fraction of sums, a Pythagorean sum, a limit applied as a function and braces. +const EDITOR_4: &[u8] = + include_bytes!("../../../corpus/math-edit/native-editor-4/notebook/links.one"); fn native_mathml(read: &str) -> Vec<String> { read.split("<mml:math") @@ -70,22 +74,23 @@ fn rendered_equations(bytes: &[u8], title: &str) -> Vec<String> { if !Math::is_equation(&text.text) { continue; } - // Inline math follows ordinary text in the same paragraph; only the math runs - // form the equation. + // Inline math follows ordinary text in the same paragraph, and a line break + // divides an equation: each run of math is an equation of its own. let mut at = 0; - let math_runs: Vec<(String, onestore::document::Format)> = text - .text - .spans() - .iter() - .map(|span| { - let piece = text.text.text()[at..span.end].to_owned(); - at = span.end; - (piece, span.format.clone()) - }) - .filter(|(_, format)| format.math == Some(true)) - .collect(); - let equation = onestore::page::Paragraph::from_runs(math_runs); - rendered.push(Math::mathml(&Math::parse(&equation).unwrap())); + let mut zones: Vec<Vec<(String, onestore::document::Format)>> = vec![Vec::new()]; + for span in text.text.spans() { + let piece = text.text.text()[at..span.end].to_owned(); + at = span.end; + if span.format.math == Some(true) { + zones.last_mut().unwrap().push((piece, span.format.clone())); + } else if !zones.last().unwrap().is_empty() { + zones.push(Vec::new()); + } + } + for zone in zones.into_iter().filter(|zone| !zone.is_empty()) { + let equation = onestore::page::Paragraph::from_runs(zone); + rendered.push(Math::mathml(&Math::parse(&equation).unwrap())); + } } } rendered @@ -99,8 +104,17 @@ fn native_equations_render_to_the_mathml_onenote_exports() { ); } +/// Enter inside equations (`tools/native_enter.py`): a line break dividing one, and an +/// argument made an equation array. +const ENTER: &[u8] = include_bytes!("../../../corpus/math-edit/native-enter/notebook/links.one"); +const ENTER_READ: &str = include_str!("../../../corpus/math-edit/native-enter/read/page-000.xml"); + #[test] fn equation_editor_expressions_render_to_the_mathml_onenote_exports() { + assert_eq!( + rendered_equations(ENTER, "Read about Rust the Rust site"), + native_mathml(ENTER_READ) + ); assert_eq!( rendered_equations(EDITOR, "Read about Rust the Rust site"), native_mathml(EDITOR_READ) @@ -369,3 +383,241 @@ fn a_paragraph_emptied_of_its_inline_math_saves() { let document = Document::parse(&index).unwrap(); assert_eq!(Page::from_space(&document, space).unwrap(), after); } + +type MathRun = (String, Option<onestore::document::MathObject>, Option<bool>); + +/// The math runs of each equation paragraph on the page titled `title`: text, run data and +/// whether the run is italic, which is what the equation editor decides. +fn stored_equations(bytes: &[u8], title: &str) -> Vec<Vec<MathRun>> { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let page = document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| Page::from_space(&document, space).unwrap()) + .find(|page| page.title == title) + .unwrap(); + let mut equations = Vec::new(); + for object in &page.objects { + let PageObject::Outline(outline) = object else { + continue; + }; + for paragraph in &outline.paragraphs { + if let Some(text) = paragraph + .text() + .filter(|text| Math::is_equation(&text.text)) + { + equations.push(runs(&text.text)); + } + } + } + equations +} + +fn runs(paragraph: &onestore::page::Paragraph) -> Vec<MathRun> { + let mut at = 0; + paragraph + .spans() + .iter() + .filter_map(|span| { + let text = paragraph.text()[at..span.end].to_owned(); + at = span.end; + (span.format.math == Some(true)) + .then(|| (text, span.format.math_object.clone(), span.format.italic)) + }) + .collect() +} + +/// What OneNote 2010's equation editor stored for text typed into it, each followed by the +/// space that builds it up, in the order the equations appear; `None` skips an equation the +/// editor built in a way this reading does not follow. +const TYPED: &[(&[u8], &[Option<&str>])] = &[ + ( + EDITOR, + &[ + Some("a_1+b_2"), + Some("x_i^2"), + Some("(a+b)"), + Some("\\int_0^1 x dx"), + Some("\\sum_(i=1)^n i"), + ], + ), + ( + EDITOR_2, + &[ + Some("\\sqrt x+1"), + Some("\\cbrt(x)"), + Some("\\sqrt(n&x)"), + Some("a/b"), + Some("\\lim_(x\\to 0) f(x)"), + Some("\\prod_(k=1)^n k"), + Some("[a+b]"), + Some("\\overline(x)"), + Some("x\\hat"), + ], + ), + ( + EDITOR_3, + &[ + Some("\\matrix(1&2@3&4)"), + Some("\\eqarray(x&=1@y&=2)"), + Some("x\\above 2"), + Some("x\\below 2"), + Some("\\box(x)"), + Some("\\rect(x)"), + // `\underline` became an unrelated symbol. + None, + Some("\\iint x dx dy"), + Some("f(x)/(x^2+1)"), + Some("\\sum^n x"), + ], + ), + ( + EDITOR_4, + &[ + Some("a_1+b_2"), + // The editor was still starting this one and kept its placeholder. + None, + Some("\\sum_(i=1)^n i"), + Some("\\int_0^1 x dx"), + Some("\\sqrt x+1"), + Some("\\cbrt(x)"), + Some("\\sqrt(n&x)"), + Some("a/b"), + Some("\\prod_(k=1)^n k"), + Some("[a+b]"), + Some("\\overline(x)"), + Some("x\\hat"), + Some("\\matrix(1&2@3&4)"), + Some("\\eqarray(x&=1@y&=2)"), + Some("x\\above 2"), + Some("x\\below 2"), + Some("\\box(x)"), + Some("\\rect(x)"), + Some("f(x)/(x^2+1)"), + Some("\\sum^n x"), + Some("e^(x+1)"), + Some("\\alpha+\\beta"), + Some("(a+b)/(c+d)"), + Some("x^2+y^2=z^2"), + // A function applied to a limit, an object this reading does not build. + None, + Some("{a+b}"), + ], + ), +]; + +#[test] +fn typed_linear_text_builds_up_as_the_equation_editor_stores_it() { + for (bytes, typed) in TYPED { + let stored = stored_equations(bytes, "Read about Rust the Rust site"); + assert_eq!(stored.len(), typed.len()); + for (stored, typed) in stored.iter().zip(*typed) { + let Some(typed) = typed else { + continue; + }; + let built = Math::paragraph( + &Math::from_linear(&format!("{typed} ")), + &onestore::document::Format::default(), + ); + assert_eq!(&runs(&built), stored, "{typed}"); + } + } +} + +/// Every native equation reads back from the linear text written for it, except the two +/// the linear format here cannot write: a limit applied as a function and an empty +/// equation's placeholder (`EDITOR_4`). +#[test] +fn native_equations_survive_their_linear_form() { + let mut unwritten = 0; + for (bytes, title) in [ + (NATIVE, "Equation controls"), + (EDITOR, "Read about Rust the Rust site"), + (EDITOR_2, "Read about Rust the Rust site"), + (EDITOR_3, "Read about Rust the Rust site"), + (EDITOR_4, "Read about Rust the Rust site"), + ] { + for stored in stored_equations(bytes, title) { + let paragraph = onestore::page::Paragraph::from_runs(stored.iter().map( + |(text, object, italic)| { + let format = onestore::document::Format { + math: Some(true), + math_object: object.clone(), + italic: *italic, + ..Default::default() + }; + (text.clone(), format) + }, + )); + let Some(linear) = Math::linear(&Math::parse(¶graph).unwrap()) else { + unwritten += 1; + continue; + }; + let rebuilt = Math::paragraph(&Math::from_linear(&linear), &Default::default()); + // Linear text without objects stores no run data until it is built up. + if stored.iter().all(|(_, object, _)| object.is_none()) { + assert_eq!(rebuilt.text(), paragraph.text()); + continue; + } + assert_eq!(runs(&rebuilt), stored, "{linear}"); + } + } + assert_eq!(unwritten, 2); +} + +/// OneNote 2010's Linear on equations typed through its editor (`corpus/math-edit/native-linear`): +/// the tree built from the typed text writes as the linear text OneNote showed and stored, +/// and that text builds up to the same tree. +#[test] +fn equations_write_the_linear_text_onenote_shows() { + const LINEAR: &[u8] = + include_bytes!("../../../corpus/math-edit/native-linear/notebook/links.one"); + let typed = [ + "x^2+1", + "(a+b)/(c+d)", + "a_1+b_2", + "x_i^2", + "\\sum_(i=1)^n i", + "\\int_0^1 x dx", + "\\sqrt x+1", + "\\cbrt(x)", + "\\sqrt(n&x)", + "a/b", + "\\prod_(k=1)^n k", + "[a+b]", + "\\overline(x)", + "x\\hat", + "\\matrix(1&2@3&4)", + "\\eqarray(x&=1@y&=2)", + "x\\above 2", + "x\\below 2", + "\\box(x)", + "\\rect(x)", + "f(x)/(x^2+1)", + "\\sum^n x", + "e^(x+1)", + "\\alpha+\\beta", + ]; + let shown: Vec<String> = stored_equations(LINEAR, "Read about Rust the Rust siteR") + .into_iter() + .map(|runs| runs.into_iter().map(|(text, ..)| text).collect()) + .collect(); + assert_eq!(shown.len(), typed.len()); + for (shown, typed) in shown.iter().zip(typed) { + let built = Math::from_linear(&format!("{typed} ")); + assert_eq!( + Math::linear(&built).as_deref(), + Some(shown.as_str()), + "{typed}" + ); + let base = onestore::document::Format::default(); + assert_eq!( + Math::paragraph(&Math::from_linear(shown), &base), + Math::paragraph(&built, &base), + "{typed}" + ); + } +} diff --git a/crates/onestore/tests/page_movement.rs b/crates/onestore/tests/page_movement.rs index 6b4042e1a78b0497d2e9d5cd0825d5a1de849e32..d4723ae5bdd06f5957f88e5e4b0d362d27201d0b 100644 --- a/crates/onestore/tests/page_movement.rs +++ b/crates/onestore/tests/page_movement.rs @@ -212,23 +212,52 @@ fn invalid_and_empty_page_edits_do_not_publish() { } assert!(PageEdit::move_to(ids[0], Some(ids[0]), 1).is_err()); assert!(PageEdit::set_level(ExGuid::default(), 1).is_err()); - assert_eq!(ops::section_op(SOURCE, SectionOp::Pages([].to_vec())).unwrap().as_bytes(), SOURCE); assert_eq!( - ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(ids[0], 1).unwrap()].to_vec())) + ops::section_op(SOURCE, SectionOp::Pages([].to_vec())) .unwrap() .as_bytes(), SOURCE ); - assert!(ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(ids[0], 2).unwrap()].to_vec())).is_err()); + assert_eq!( + ops::section_op( + SOURCE, + SectionOp::Pages([PageEdit::set_level(ids[0], 1).unwrap()].to_vec()) + ) + .unwrap() + .as_bytes(), + SOURCE + ); + assert!( + ops::section_op( + SOURCE, + SectionOp::Pages([PageEdit::set_level(ids[0], 2).unwrap()].to_vec()) + ) + .is_err() + ); let duplicate = PageEdit::set_level(ids[3], 2).unwrap(); - assert!(ops::section_op(SOURCE, SectionOp::Pages([duplicate.clone(), duplicate].to_vec())).is_err()); + assert!( + ops::section_op( + SOURCE, + SectionOp::Pages([duplicate.clone(), duplicate].to_vec()) + ) + .is_err() + ); let missing = ExGuid { guid: [0x77; 16], n: 1, }; - assert!(ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(missing, 1).unwrap()].to_vec())).is_err()); assert!( - ops::section_op(SOURCE, SectionOp::Pages([PageEdit::move_to(ids[3], Some(missing), 1).unwrap()].to_vec())) + ops::section_op( + SOURCE, + SectionOp::Pages([PageEdit::set_level(missing, 1).unwrap()].to_vec()) + ) + .is_err() + ); + assert!( + ops::section_op( + SOURCE, + SectionOp::Pages([PageEdit::move_to(ids[3], Some(missing), 1).unwrap()].to_vec()) + ) .is_err() ); let mut value = serde_json::to_value(PageEdit::set_level(ids[3], 2).unwrap()).unwrap(); @@ -253,8 +282,11 @@ fn moving_pages_preserves_ink_file_data_and_native_feature_objects() { .unwrap() .as_bytes() .to_vec(); - let moved = - ops::section_op(&source, SectionOp::Pages([PageEdit::move_to(first, None, 1).unwrap()].to_vec())).unwrap(); + let moved = ops::section_op( + &source, + SectionOp::Pages([PageEdit::move_to(first, None, 1).unwrap()].to_vec()), + ) + .unwrap(); assert_eq!(pages(moved.as_bytes()).last(), Some(&first)); let stores = [&source, moved.as_bytes()].map(|bytes| Store::parse(bytes).unwrap()); let indexes = stores @@ -338,7 +370,11 @@ fn metadata_copy_order_does_not_change_page_identity() { "../../../corpus/page-lifecycle/page-edits/optional-cache/source-cold/notebook/Lifecycle.one" ); let sid = pages(source)[3]; - let edited = ops::section_op(source, SectionOp::Pages([PageEdit::set_level(sid, 2).unwrap()].to_vec())).unwrap(); + let edited = ops::section_op( + source, + SectionOp::Pages([PageEdit::set_level(sid, 2).unwrap()].to_vec()), + ) + .unwrap(); let expected = include_bytes!( "../../../corpus/page-lifecycle/page-edits/optional-cache/candidate/Lifecycle.one" ); @@ -374,8 +410,21 @@ fn native_edits_on_moved_pages_support_more_rust_changes() { let bodies: Vec<_> = view.nodes.iter().filter_map(|(id, node)| matches!(&node.kind, Kind::RichText { text, .. } if text == "Native body after a Rust page move.").then_some(*id)).collect(); let [body] = bodies.as_slice() else { panic!() }; - let moved = ops::section_op(source, SectionOp::Pages([PageEdit::set_level(sid, 2).unwrap()].to_vec())).unwrap(); - let edited = ops::page_op(moved.as_bytes(), sid, PageOp::Text { text: *body, range: 0..0, with: "Rust + ".into() }).unwrap(); + let moved = ops::section_op( + source, + SectionOp::Pages([PageEdit::set_level(sid, 2).unwrap()].to_vec()), + ) + .unwrap(); + let edited = ops::page_op( + moved.as_bytes(), + sid, + PageOp::Text { + text: *body, + range: 0..0, + with: "Rust + ".into(), + }, + ) + .unwrap(); let after_store = Store::parse(edited.as_bytes()).unwrap(); let after_index = RevisionIndex::parse(&after_store).unwrap(); let after = Document::parse(&after_index).unwrap(); @@ -533,9 +582,8 @@ fn repeated_nesting_retains_series_history_across_revision_checkpoints() { /// without them and gives the moved page one of its own. #[test] fn a_move_after_a_native_merge_drops_its_empty_series() { - let merged = include_bytes!( - "../../../corpus/conflict-page/native-pages/merged/notebook/synthetic.one" - ); + let merged = + include_bytes!("../../../corpus/conflict-page/native-pages/merged/notebook/synthetic.one"); let series = |bytes: &[u8]| { let store = Store::parse(bytes).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); @@ -561,7 +609,10 @@ fn a_move_after_a_native_merge_drops_its_empty_series() { let (id, spaces) = after.last().unwrap(); assert_eq!(spaces, &vec![ids[1]]); assert!(before.iter().all(|(old, _)| old != id)); - assert_eq!(pages(moved.as_bytes()), [&ids[..1], &ids[2..], &ids[1..2]].concat()); + assert_eq!( + pages(moved.as_bytes()), + [&ids[..1], &ids[2..], &ids[1..2]].concat() + ); if let Some(output) = std::env::var_os("ONESTORE_EMPTY_SERIES_EXPORT") { let output = std::path::Path::new(&output); std::fs::create_dir(output).unwrap(); diff --git a/crates/onestore/tests/page_writer.rs b/crates/onestore/tests/page_writer.rs index db9f45ce7ad0db0e4b478d8c12c71a314a9ce3da..0903175360eb0c8ec361012f840f569613869e9d 100644 --- a/crates/onestore/tests/page_writer.rs +++ b/crates/onestore/tests/page_writer.rs @@ -242,16 +242,34 @@ fn formatting_matches_format_text() { let (space, page) = page_by_title(PARAGRAPHS, "Split style boundary"); let (_, text, content) = text_ids(body(&page)[0])[0].clone(); let end = u32::try_from(content.encode_utf16().count()).unwrap(); - let reference = ops::page_op(PARAGRAPHS, space, PageOp::Format { text, range: 1..end.min(4), set: vec![ - TextAttribute::Bold(true), - TextAttribute::Color(Some([255, 0, 0])), - TextAttribute::FontSize(14.0), - ], clear: Vec::new() }) + let reference = ops::page_op( + PARAGRAPHS, + space, + PageOp::Format { + text, + range: 1..end.min(4), + set: vec![ + TextAttribute::Bold(true), + TextAttribute::Color(Some([255, 0, 0])), + TextAttribute::FontSize(14.0), + ], + clear: Vec::new(), + }, + ) .unwrap(); - let reference = ops::page_op(reference.as_bytes(), space, PageOp::Format { text, range: 0..1, set: vec![ - TextAttribute::Italic(true), - TextAttribute::Font("Consolas".into()), - ], clear: Vec::new() }) + let reference = ops::page_op( + reference.as_bytes(), + space, + PageOp::Format { + text, + range: 0..1, + set: vec![ + TextAttribute::Italic(true), + TextAttribute::Font("Consolas".into()), + ], + clear: Vec::new(), + }, + ) .unwrap(); oracle(PARAGRAPHS, space, reference.as_bytes()); } @@ -444,7 +462,11 @@ fn composed_edits_publish_one_transaction() { }], ); let insertion = insert(outline.id, Some(paragraphs[1].0), "Inserted", 1); - let PageOp::Insert { paragraphs: inserted, .. } = &insertion else { + let PageOp::Insert { + paragraphs: inserted, + .. + } = &insertion + else { unreachable!() }; let inserted = inserted[0].text().unwrap().id; diff --git a/crates/onestore/tests/paragraph.rs b/crates/onestore/tests/paragraph.rs index 4904916f1c240763bbe1e49f350406e8c768d53d..6675166893610afada9c2d7c8fbe71d4e8e401e8 100644 --- a/crates/onestore/tests/paragraph.rs +++ b/crates/onestore/tests/paragraph.rs @@ -44,7 +44,13 @@ fn split(source: &[u8], space: ExGuid, text: ExGuid, at: u32) -> (Op, ExGuid, Ex /// `source` after joining the paragraph of text `right` to the one of text `left`. fn join(source: &[u8], space: ExGuid, left: ExGuid, right: ExGuid) -> Result<Vec<u8>, OpError> { - ops::edited(source, vec![Op::Page { space, op: PageOp::Join { left, right } }]) + ops::edited( + source, + vec![Op::Page { + space, + op: PageOp::Join { left, right }, + }], + ) } const SOURCE: &[u8] = @@ -445,10 +451,7 @@ fn splits_partition_native_paragraphs_at_every_scalar_boundary() { assert_eq!(after.nodes[parent].children, children); assert_eq!(after.nodes[¶graph].content, [text]); assert!(after.nodes[¶graph].children.is_empty()); - assert_eq!( - after.nodes[&new_paragraph].content, - [new_text] - ); + assert_eq!(after.nodes[&new_paragraph].content, [new_text]); assert_eq!( after.nodes[&new_paragraph].children, before.nodes[¶graph].children @@ -516,7 +519,17 @@ fn invalid_split_identities_and_title_targets_are_rejected() { .0; let (op, ..) = split(SOURCE, sid, text, 1); assert!(ops::transaction(SOURCE, "a\0b", vec![op.clone()]).is_err()); - let Op::Page { op: PageOp::Split { paragraph, right, lists, .. }, .. } = op else { + let Op::Page { + op: + PageOp::Split { + paragraph, + right, + lists, + .. + }, + .. + } = op + else { unreachable!() }; for (text, at, paragraph) in [ @@ -525,7 +538,13 @@ fn invalid_split_identities_and_title_targets_are_rejected() { (text, 1, ExGuid::default()), (text, 1, text), ] { - let op = PageOp::Split { text, at, paragraph, right, lists: lists.clone() }; + let op = PageOp::Split { + text, + at, + paragraph, + right, + lists: lists.clone(), + }; assert!(ops::page_edited(SOURCE, sid, vec![op]).is_err()); } let mut titles = 0; @@ -638,7 +657,9 @@ fn interrupted_splits_publish_a_complete_graph_or_retain_the_original() { let checkpoint = checkpoint::pending(source, sid, text); for source in [source, &checkpoint] { let (op, ..) = split(source, sid, text, 1); - let edit = ops::transaction(source, "Author", vec![op]).unwrap().unwrap(); + let edit = ops::transaction(source, "Author", vec![op]) + .unwrap() + .unwrap(); let mut written = source.to_vec(); edit.apply(&mut written).unwrap(); let before = current::current(source); @@ -698,7 +719,10 @@ fn export_native_paragraph_joins() { }) .unwrap() .0; - let op = Op::Page { space: sid, op: PageOp::Join { left, right } }; + let op = Op::Page { + space: sid, + op: PageOp::Join { left, right }, + }; let transaction = ops::transaction(&source, "Rust join author", vec![op.clone()]) .unwrap() .unwrap(); @@ -767,8 +791,16 @@ fn interrupted_joins_preserve_complete_graphs_and_empty_text_adoption() { .unwrap(); let checkpoint = checkpoint::pending(original, sid, left); for source in [original, &empty, &checkpoint] { - let op = Op::Page { space: sid, op: PageOp::Join { left, right: right_text } }; - let edit = ops::transaction(source, "Join author", vec![op]).unwrap().unwrap(); + let op = Op::Page { + space: sid, + op: PageOp::Join { + left, + right: right_text, + }, + }; + let edit = ops::transaction(source, "Join author", vec![op]) + .unwrap() + .unwrap(); let mut written = source.to_vec(); edit.apply(&mut written).unwrap(); let before = current::current(source); @@ -828,7 +860,13 @@ fn joins_reject_invalid_identities_wrong_order_and_unrelated_pages() { ] { assert!(join(source, *sid, a, b).is_err()); } - let op = Op::Page { space: *sid, op: PageOp::Join { left: *left, right: *right } }; + let op = Op::Page { + space: *sid, + op: PageOp::Join { + left: *left, + right: *right, + }, + }; assert!(ops::transaction(source, "a\0b", vec![op]).is_err()); let view = &space.revisions[&space.contexts[&ExGuid::default()]]; let paragraph = *view diff --git a/crates/onestore/tests/protected.rs b/crates/onestore/tests/protected.rs index 3abf6caf9a2d75ad1b4f5f0a314ba73ead315bef..73e918906d5c247534598db1f26aa96efc616dea 100644 --- a/crates/onestore/tests/protected.rs +++ b/crates/onestore/tests/protected.rs @@ -188,7 +188,10 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { let ops = onestore::op::lower_page(&before, page).unwrap(); let edit = Edit { at: 134_000_000_000_000_000, - ops: ops.into_iter().map(|op| Op::Page { space: *space, op }).collect(), + ops: ops + .into_iter() + .map(|op| Op::Page { space: *space, op }) + .collect(), }; let store = Store::parse(&bytes).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); diff --git a/crates/onestore/tests/shared_snapshot.rs b/crates/onestore/tests/shared_snapshot.rs index 12cb6c3134a9c09d13806f6800dd2f56be4ec254..46ff76255f5883f0a583d6b1ddead97f625f25e1 100644 --- a/crates/onestore/tests/shared_snapshot.rs +++ b/crates/onestore/tests/shared_snapshot.rs @@ -198,8 +198,7 @@ fn published_snapshots_survive_interleaved_commit_io() { bytes: source.clone(), events: Vec::new(), }; - commit_text(&mut trace, &source, sid, oid, end..end, " [reader café 🦀]") - .unwrap(); + commit_text(&mut trace, &source, sid, oid, end..end, " [reader café 🦀]").unwrap(); let after = current(&trace.bytes); assert_ne!(before, after); let mut writes = Vec::new(); diff --git a/crates/onestore/tests/support/page_edits.rs b/crates/onestore/tests/support/page_edits.rs index d3b6ca0067f4275e3597ed812a96015e374b63eb..8051562a6c97d03477487eb64113a31217080ac1 100644 --- a/crates/onestore/tests/support/page_edits.rs +++ b/crates/onestore/tests/support/page_edits.rs @@ -5,8 +5,8 @@ use onestore::{ Arena, ExGuid, RevisionIndex, Section, Store, - op::{self, Op}, document::{Document, Format, Layout}, + op::{self, Op}, page::{ Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, text::{Edit, new_id}, @@ -576,10 +576,20 @@ pub fn run(input: &[u8]) { let mut section = Section::open(&arena, source.clone()).unwrap(); let edit = op::Edit { at: 134_000_000_000_000_000, - ops: ops.iter().map(|op| Op::Page { space, op: op.clone() }).collect(), + ops: ops + .iter() + .map(|op| Op::Page { + space, + op: op.clone(), + }) + .collect(), }; if section.apply("Fuzz author", &edit).is_err() { - assert_eq!(section.page(space).unwrap(), before, "a refused edit changes nothing"); + assert_eq!( + section.page(space).unwrap(), + before, + "a refused edit changes nothing" + ); return; } let mut predicted = before.clone(); diff --git a/crates/onestore/tests/support/page_schedule.rs b/crates/onestore/tests/support/page_schedule.rs index 71ee9c5729ad21e9665df7009ad01c097746e4d3..459cdd6ca3a6efa21f0c97f4890d91ba50611478 100644 --- a/crates/onestore/tests/support/page_schedule.rs +++ b/crates/onestore/tests/support/page_schedule.rs @@ -94,7 +94,8 @@ pub fn run(input: &[u8]) { let removed: Vec<_> = (0..count) .map(|i| pages[(usize::from(step[2]) + i) % pages.len()].0) .collect(); - let prepared = ops::section_op(source, SectionOp::Delete(removed.to_vec())).unwrap(); + let prepared = + ops::section_op(source, SectionOp::Delete(removed.to_vec())).unwrap(); pages.retain(|page| !removed.contains(&page.0)); for sid in removed { levels.remove(&sid); @@ -143,7 +144,15 @@ pub fn run(input: &[u8]) { unreachable!() }; Some(( - ops::page_op(source, sid, PageOp::Text { text: object, range: 0..u32::try_from(original.encode_utf16().count()).unwrap(), with: text.into() }) + ops::page_op( + source, + sid, + PageOp::Text { + text: object, + range: 0..u32::try_from(original.encode_utf16().count()).unwrap(), + with: text.into(), + }, + ) .unwrap(), Some((sid, object, text)), )) diff --git a/crates/onestore/tests/support/tree_model.rs b/crates/onestore/tests/support/tree_model.rs index 9b7680d9ceb309f688d059460db542982a087c68..ec39300852f5ed4de9a49caa50e74f6a23edc16f 100644 --- a/crates/onestore/tests/support/tree_model.rs +++ b/crates/onestore/tests/support/tree_model.rs @@ -4,10 +4,7 @@ use onestore::{ op::{Op, PageOp}, page::Page, }; -use std::{ - collections::BTreeMap, - sync::LazyLock, -}; +use std::{collections::BTreeMap, sync::LazyLock}; use crate::{current, disk, ops}; @@ -136,7 +133,12 @@ pub fn run(input: &[u8]) { let destination = destinations[usize::from(step[4]) % destinations.len()]; // Outline groups are the writers' business: anchors are the paragraphs in them. let mut children = Vec::new(); - let mut pending: Vec<ExGuid> = view.nodes[&destination].children.iter().rev().copied().collect(); + let mut pending: Vec<ExGuid> = view.nodes[&destination] + .children + .iter() + .rev() + .copied() + .collect(); while let Some(id) = pending.pop() { match view.nodes[&id].kind { Kind::OutlineGroup => pending.extend(view.nodes[&id].children.iter().rev()), @@ -155,7 +157,10 @@ pub fn run(input: &[u8]) { (op, subtree.contains_key(&destination)) }; let model = Page::from_space(&document, sid).unwrap(); - let ops = vec![Op::Page { space: sid, op: op.clone() }]; + let ops = vec![Op::Page { + space: sid, + op: op.clone(), + }]; let Ok(edit) = ops::apply(source, "Tree schedule", ops) else { continue; }; @@ -170,7 +175,13 @@ pub fn run(input: &[u8]) { if stored != predicted { let (a, b) = (format!("{stored:?}"), format!("{predicted:?}")); let at = a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count(); - panic!("{op:?}\n stored …{}\n predicted …{}", &a[a.floor_char_boundary(at.saturating_sub(300))..a.floor_char_boundary((at + 200).min(a.len()))], &b[b.floor_char_boundary(at.saturating_sub(300))..b.floor_char_boundary((at + 200).min(b.len()))]); + panic!( + "{op:?}\n stored …{}\n predicted …{}", + &a[a.floor_char_boundary(at.saturating_sub(300)) + ..a.floor_char_boundary((at + 200).min(a.len()))], + &b[b.floor_char_boundary(at.saturating_sub(300)) + ..b.floor_char_boundary((at + 200).min(b.len()))] + ); } let space = &after_document.spaces[&sid]; let after = &space.revisions[&space.contexts[&ExGuid::default()]]; diff --git a/crates/onestore/tests/table_growth.rs b/crates/onestore/tests/table_growth.rs index 6f61607ee69f62514c697bc1037e1a67acb56c03..f59aa339c5b51594c2452251ba305f0df0703328 100644 --- a/crates/onestore/tests/table_growth.rs +++ b/crates/onestore/tests/table_growth.rs @@ -75,9 +75,7 @@ fn a_text_edit_stores_the_table_entries_it_names() { sparse |= (entries as u32) < span; } assert_eq!( - ops::saved(&written, space, &page) - .unwrap() - .as_slice(), + ops::saved(&written, space, &page).unwrap().as_slice(), written ); bytes = written; diff --git a/crates/onestore/tests/tree.rs b/crates/onestore/tests/tree.rs index cfa6c40d95f3fbb4f95b12004a78368b5d54abf3..72e44ae7b1870a0cf63cf61dd07bea66bc512932 100644 --- a/crates/onestore/tests/tree.rs +++ b/crates/onestore/tests/tree.rs @@ -187,7 +187,10 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() { let Some((name, object, edit)) = operation(before, page) else { continue; }; - let edit: Vec<Op> = edit.into_iter().map(|op| Op::Page { space: sid, op }).collect(); + let edit: Vec<Op> = edit + .into_iter() + .map(|op| Op::Page { space: sid, op }) + .collect(); let prepared = ops::apply(source, "Tree author", edit.clone()) .unwrap_or_else(|error| panic!("{name}: {error}")); let after_store = Store::parse(prepared.as_bytes()).unwrap(); @@ -425,7 +428,10 @@ fn publication_interruptions_expose_complete_old_or_new_trees() { ) { continue; } - let intent = intent.into_iter().map(|op| Op::Page { space: sid, op }).collect(); + let intent = intent + .into_iter() + .map(|op| Op::Page { space: sid, op }) + .collect(); let edit = ops::apply(source, "Tree author", intent).unwrap(); let after = current::current(edit.as_bytes()); for write_limit in [17, 4096] { diff --git a/crates/onestore/tests/versions.rs b/crates/onestore/tests/versions.rs new file mode 100644 index 0000000000000000000000000000000000000000..dcd063ed27fa5466ca5c09adac18dead55911bbb --- /dev/null +++ b/crates/onestore/tests/versions.rs @@ -0,0 +1,506 @@ +//! Page versions against OneNote 2010's own (`corpus/page-versions/native`): listing them, +//! reading them, and Restore Version and Delete Version storing what OneNote stores. + +use onestore::{ + Arena, ExGuid, ObjectData, PageVersion, PropertySets, RevisionIndex, Section, Store, + document::Document, + op::{Edit, Op, PageOp, SectionOp}, + page::{Page, PageObject}, +}; +use std::collections::BTreeSet; + +const AT: u64 = 134_040_000_000_000_000; +const NATIVE: &str = "corpus/page-versions/native"; +/// The version history's context. +const HISTORY: &str = "{7111497F-1B6B-4209-9491-C98B04CF4C5A},1"; + +fn read(path: &str) -> Vec<u8> { + std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap() +} + +fn step(n: u32) -> Vec<u8> { + read(&format!("{NATIVE}/step-{n:02}/notebook/History.one")) +} + +fn id(text: &str) -> ExGuid { + text.parse().unwrap() +} + +/// The page every step holds, and the version OneNote made when a second author edited it. +fn page() -> ExGuid { + id("{55479BA2-FB3A-45EB-A43D-EDB93DA0593D},1") +} + +fn texts(page: &Page) -> Vec<String> { + page.objects + .iter() + .flat_map(|object| match object { + PageObject::Outline(outline) => outline.paragraphs.clone(), + _ => Vec::new(), + }) + .filter_map(|paragraph| paragraph.text().map(|text| text.text.text().to_owned())) + .collect() +} + +fn versions(image: &[u8]) -> Vec<PageVersion> { + let arena = Arena::default(); + let mut section = Section::open(&arena, image.to_vec()).unwrap(); + section + .versions() + .unwrap() + .into_iter() + .find(|(listed, _)| *listed == page()) + .map_or(Vec::new(), |(_, versions)| versions) +} + +/// What a revision of `space` changed: its declared objects' types, sorted, with whether +/// each was new. +fn changed(index: &RevisionIndex<'_>, space: ExGuid, rid: ExGuid) -> Vec<(u32, bool)> { + let revision = index.resolve(space, rid).unwrap(); + let base = index.spaces[&space].revisions[&rid] + .dependency + .map(|dependency| index.resolve(space, dependency).unwrap()); + let mut changed: Vec<(u32, bool)> = revision + .objects + .iter() + .filter_map(|(id, object)| { + let old = base.as_ref().and_then(|base| base.objects.get(id)); + let same = old.is_some_and(|old| { + old.reference_count == object.reference_count && old.data == object.data + }); + (!same).then_some((object.jcid, old.is_none())) + }) + .collect(); + changed.sort(); + changed +} + +/// Property identities of an object's root set. +fn properties(object: &onestore::Object<'_>) -> Vec<u32> { + let ObjectData::Properties(bytes) = object.data else { + panic!() + }; + PropertySets::parse(bytes).unwrap().sets[0] + .iter() + .map(|property| property.id) + .collect() +} + +/// How an edit left the page's revisions, from `before` to `after`: the page revision's +/// dependency (named by what it was before), the history's, which revisions became versions, +/// the declared object types of the page and history revisions, the history's children +/// count and the proxy's properties, and whether the page metadata and the section's copy +/// say the page has versions. +#[derive(Debug, PartialEq)] +struct Shape { + page_depends_on: &'static str, + history_depends_on_previous: bool, + labelled: Vec<&'static str>, + page_changed: Vec<(u32, bool)>, + history_changed: Vec<(u32, bool)>, + listed: usize, + proxy: Vec<u32>, + flagged: [bool; 2], +} + +fn shape(before: &[u8], after: &[u8]) -> Shape { + let (old_store, new_store) = (Store::parse(before).unwrap(), Store::parse(after).unwrap()); + let (old, new) = ( + RevisionIndex::parse(&old_store).unwrap(), + RevisionIndex::parse(&new_store).unwrap(), + ); + new.validate_current().unwrap(); + Document::parse(&new).unwrap(); + let space = page(); + let history = id(HISTORY); + let (old_labels, new_labels) = (&old.spaces[&space].labels, &new.spaces[&space].labels); + let old_page = old_labels[&(ExGuid::default(), 1)]; + let new_page = new_labels[&(ExGuid::default(), 1)]; + let name = |rid: ExGuid| -> &'static str { + if rid == old_page { + "the page" + } else if old_labels.get(&(history, 1)) == Some(&rid) { + "the history" + } else if old_labels.iter().any(|((context, role), labelled)| { + *role == 1 && *context != ExGuid::default() && *labelled == rid + }) { + "a version" + } else { + "another revision" + } + }; + let page_depends_on = if new_page == old_page { + "unchanged" + } else { + new.spaces[&space].revisions[&new_page] + .dependency + .map_or("none", name) + }; + let (old_history, new_history) = (old_labels[&(history, 1)], new_labels[&(history, 1)]); + let labelled = new_labels + .iter() + .filter(|((context, role), _)| { + *role == 1 + && *context != ExGuid::default() + && *context != history + && !old_labels.contains_key(&(*context, 1)) + }) + .map(|(_, rid)| name(*rid)) + .collect(); + let history_revision = new.resolve(space, new_history).unwrap(); + let root = &history_revision.objects[&history_revision.roots[&1]]; + let listed = root.references().unwrap().objects; + let proxy = listed.first().map_or(Vec::new(), |proxy| { + properties(&history_revision.objects[proxy]) + }); + let metadata = |index: &RevisionIndex<'_>| -> [bool; 2] { + let revision = index.resolve_active(space).unwrap(); + let own = properties(&revision.objects[&revision.roots[&2]]).contains(&0x88003462); + // The section's copy, named by the page's space (MS-ONE 2.2.81). + let section = index.resolve_active(index.root).unwrap(); + let copy = properties(§ion.objects[&id("{77EF5B93-CD3A-0705-1329-3A15E7846CD5},1")]) + .contains(&0x88003462); + [own, copy] + }; + Shape { + page_depends_on, + history_depends_on_previous: new_history != old_history + && new.spaces[&space].revisions[&new_history].dependency == Some(old_history), + labelled, + page_changed: if new_page == old_page { + Vec::new() + } else { + changed(&new, space, new_page) + }, + history_changed: if new_history == old_history { + Vec::new() + } else { + changed(&new, space, new_history) + }, + listed: listed.len(), + proxy, + flagged: metadata(&new), + } +} + +fn apply(image: &[u8], author: &str, ops: Vec<Op>) -> Vec<u8> { + let arena = Arena::default(); + let mut section = Section::open(&arena, image.to_vec()).unwrap(); + section.apply(author, &Edit { at: AT, ops }).unwrap(); + section.seal().unwrap().unwrap(); + section.image() +} + +#[test] +fn native_versions_are_listed_newest_first_with_their_authors() { + assert_eq!(versions(&step(1)), []); + let virtual_version = PageVersion { + context: id("{2F6B7C6D-DF95-0F56-085E-74357323A760},1"), + modified: Some(0x01dd4ee2641a3f00), + author: Some("virtual".into()), + }; + assert_eq!(versions(&step(2)), std::slice::from_ref(&virtual_version)); + // A Restore Version made the page as it stood the newest version. + let restored = versions(&step(4)); + assert_eq!( + restored, + [ + PageVersion { + context: id("{783B5BD2-65DB-082D-3AAC-47135BA38EA9},1"), + modified: Some(0x01dd4ee27e541d00), + author: Some("Other Person".into()), + }, + virtual_version.clone(), + ] + ); + assert_eq!(versions(&step(5)), [virtual_version]); + assert_eq!(versions(&step(6)), []); + // Optimizing keeps a version and the history as checkpoints. + assert_eq!(versions(&step(10)).len(), 1); +} + +#[test] +fn a_version_reads_as_the_page_it_was() { + let arena = Arena::default(); + let section = Section::open(&arena, step(1)).unwrap(); + let before = section.page(page()).unwrap(); + let arena = Arena::default(); + let mut section = Section::open(&arena, step(2)).unwrap(); + let context = section.versions().unwrap()[0].1[0].context; + let version = section.version(page(), context).unwrap(); + assert_eq!(version, before); + assert_eq!(texts(&version), ["First state. Typed by virtual."]); + assert_eq!( + texts(§ion.page(page()).unwrap()), + ["First state. Typed by virtual.", "Second author line."] + ); + assert!(section.version(page(), id(HISTORY)).is_err()); +} + +/// Restore Version stores what OneNote 2010's does (`step-03` to `step-04`): the page's next +/// revision builds on the version's, the page as it stood becomes the newest version under +/// the context its revision derives, the history lists it first, and the revision metadata +/// names who restored it. `ONESTORE_VERSIONS_EXPORT` names a directory for the notebooks, +/// for a cold read. +#[test] +fn restoring_a_version_stores_what_onenote_stores() { + let before = step(3); + let restored_version = versions(&before)[0].context; + let arena = Arena::default(); + let section = Section::open(&arena, before.clone()).unwrap(); + let standing = section.page(page()).unwrap(); + let version = section.version(page(), restored_version).unwrap(); + let ours = apply( + &before, + "Other Person", + vec![Op::Section(SectionOp::RestoreVersion { + page: page(), + version: restored_version, + guid: [7; 16], + })], + ); + let expected = shape(&before, &step(4)); + assert_eq!(expected.page_depends_on, "a version"); + assert_eq!(shape(&before, &ours), expected); + + let arena = Arena::default(); + let mut reopened = Section::open(&arena, ours.clone()).unwrap(); + assert_eq!(reopened.page(page()).unwrap().objects, version.objects); + let listed = reopened.versions().unwrap().remove(0).1; + assert_eq!(listed.len(), 2); + assert_eq!(listed[1].context, restored_version); + // As OneNote names it (`step-04`): the page's revision, its bits flipped by a salt. + let store = Store::parse(&before).unwrap(); + let standing_rid = RevisionIndex::parse(&store) + .unwrap() + .active(page()) + .unwrap(); + let salt = |a: ExGuid, b: ExGuid| -> Vec<u8> { + a.guid.iter().zip(b.guid).map(|(a, b)| a ^ b).collect() + }; + assert_eq!( + salt(listed[0].context, standing_rid), + salt( + id("{783B5BD2-65DB-082D-3AAC-47135BA38EA9},1"), + id("{E569A5AE-EA67-4CB8-A9AC-55880C342743},1") + ) + ); + assert_eq!(listed[0].author.as_deref(), Some("Other Person")); + assert_eq!( + reopened.version(page(), listed[0].context).unwrap(), + standing + ); + + if let Some(output) = std::env::var_os("ONESTORE_VERSIONS_EXPORT") { + export(&output, "restore", &ours); + } +} + +/// Delete Version stores what OneNote 2010's does: the history stops listing the version +/// (`step-04` to `step-05`), and once none is left the page's metadata and the section's copy +/// stop saying it has versions (`step-05` to `step-06`). +#[test] +fn deleting_versions_stores_what_onenote_stores() { + let before = step(4); + let newest = versions(&before)[0].context; + let delete = |image: &[u8], versions: Vec<ExGuid>| { + apply( + image, + "Other Person", + vec![Op::Section(SectionOp::DeleteVersions { + page: page(), + versions, + })], + ) + }; + let ours = delete(&before, vec![newest]); + assert_eq!(shape(&before, &ours), shape(&before, &step(5))); + let emptied = delete(&step(5), vec![versions(&step(5))[0].context]); + let expected = shape(&step(5), &step(6)); + assert_eq!(expected.flagged, [false, false]); + assert_eq!(shape(&step(5), &emptied), expected); + // Every version at once, as Delete All Versions in Section asks. + let all = versions(&before) + .into_iter() + .map(|version| version.context) + .collect(); + assert_eq!(versions(&delete(&before, all)), []); + // A version already gone refuses the edit. + let arena = Arena::default(); + let mut section = Section::open(&arena, step(5)).unwrap(); + let refused = section.apply( + "Other Person", + &Edit { + at: AT, + ops: vec![Op::Section(SectionOp::DeleteVersions { + page: page(), + versions: vec![newest], + })], + }, + ); + assert_eq!( + refused, + Err(onestore::op::OpError::TargetUnavailable(newest)) + ); + + if let Some(output) = std::env::var_os("ONESTORE_VERSIONS_EXPORT") { + export(&output, "delete", &ours); + export(&output, "delete-all", &emptied); + } +} + +/// Edits not yet sealed become a version of their own when a restore follows them in the +/// same batch, and restores stack: every state the page passed through stays listed. +#[test] +fn unsaved_edits_and_stacked_restores_become_versions() { + let before = step(2); + let arena = Arena::default(); + let mut section = Section::open(&arena, before.clone()).unwrap(); + let first = section.versions().unwrap()[0].1[0].context; + let text = section + .page(page()) + .unwrap() + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => outline.paragraphs.last()?.text().map(|text| text.id), + _ => None, + }); + let edit = |ops| Edit { at: AT, ops }; + section + .apply( + "Clover Snow", + &edit(vec![Op::Page { + space: page(), + op: PageOp::Text { + text: text.unwrap(), + range: 0..6, + with: "Unsaved".into(), + }, + }]), + ) + .unwrap(); + let edited = section.page(page()).unwrap(); + let restore = |version, guid| { + edit(vec![Op::Section(SectionOp::RestoreVersion { + page: page(), + version, + guid, + })]) + }; + section + .apply("Clover Snow", &restore(first, [1; 16])) + .unwrap(); + let listed = section.versions().unwrap()[0].1.clone(); + assert_eq!(listed.len(), 2); + assert_eq!(section.version(page(), listed[0].context).unwrap(), edited); + // The unsaved state is still being saved, so it cannot be restored yet; the stored one can. + assert!( + section + .apply("Clover Snow", &restore(listed[0].context, [2; 16])) + .is_err() + ); + section + .apply("Clover Snow", &restore(first, [3; 16])) + .unwrap(); + assert_eq!(section.versions().unwrap()[0].1.len(), 3); + section.seal().unwrap().unwrap(); + let image = section.image(); + + let arena = Arena::default(); + let mut reopened = Section::open(&arena, image.clone()).unwrap(); + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + Document::parse(&index).unwrap(); + let listed = reopened.versions().unwrap().remove(0).1; + assert_eq!(listed.len(), 3); + let pages: Vec<Page> = listed + .iter() + .map(|version| reopened.version(page(), version.context).unwrap()) + .collect(); + let version = reopened.version(page(), first).unwrap(); + assert_eq!(pages[1], edited); + assert_eq!(pages[0].objects, version.objects); + assert_eq!(reopened.page(page()).unwrap().objects, version.objects); + // The restored page edits on as any other. + let text = texts(&reopened.page(page()).unwrap()); + assert_eq!(text, ["First state. Typed by virtual."]); +} + +/// An edit that fails after a restore leaves the section as it was before the edit. +#[test] +fn a_failed_edit_undoes_its_restore() { + let before = step(2); + let arena = Arena::default(); + let mut section = Section::open(&arena, before.clone()).unwrap(); + let first = section.versions().unwrap()[0].1[0].context; + let page_before = section.page(page()).unwrap(); + let refused = section.apply( + "Clover Snow", + &Edit { + at: AT, + ops: vec![ + Op::Section(SectionOp::RestoreVersion { + page: page(), + version: first, + guid: [1; 16], + }), + Op::Section(SectionOp::DeleteVersions { + page: page(), + versions: vec![id(HISTORY)], + }), + ], + }, + ); + assert!(refused.is_err()); + assert_eq!(section.page(page()).unwrap(), page_before); + assert_eq!(section.versions().unwrap()[0].1.len(), 1); + assert!(section.seal().unwrap().is_none()); + assert!(section.image() == before); +} + +#[test] +fn private_notebook_versions_read() { + let path = format!( + "{}/../../corpus/private/current/Art/Album.one", + env!("CARGO_MANIFEST_DIR") + ); + let Ok(image) = std::fs::read(path) else { + return; + }; + let arena = Arena::default(); + let mut section = Section::open(&arena, image).unwrap(); + let listed = section.versions().unwrap(); + assert!( + listed + .iter() + .map(|(_, versions)| versions.len()) + .sum::<usize>() + >= 10 + ); + let contexts: BTreeSet<ExGuid> = listed + .iter() + .flat_map(|(_, versions)| versions.iter().map(|version| version.context)) + .collect(); + assert_eq!( + contexts.len(), + listed.iter().map(|(_, v)| v.len()).sum::<usize>() + ); + for (page, versions) in &listed { + for version in versions { + section.version(*page, version.context).unwrap(); + } + } +} + +fn export(output: &std::ffi::OsStr, name: &str, image: &[u8]) { + let directory = std::path::Path::new(output).join(name); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("History.one"), image).unwrap(); + std::fs::write( + directory.join("Open Notebook.onetoc2"), + read(&format!("{NATIVE}/step-01/notebook/Open Notebook.onetoc2")), + ) + .unwrap(); +} diff --git a/tools/TESTING.md b/tools/TESTING.md index b280d6d6788190e652c68f84e6fb6df92781afa9..3809ae2a5608d97812bc74de63858407d0b85531 100644 --- a/tools/TESTING.md +++ b/tools/TESTING.md @@ -2,10 +2,12 @@ ## Public fixtures -From a checkout with Rust and Python 3 plus Pillow installed: +From a checkout with Rust and [uv](https://docs.astral.sh/uv/), which provides +Python 3.12 with Pillow and pdfplumber without installing anything system-wide: ```sh -python3 tools/check_public.py /absolute/path/to/new-results +uv run --no-project --python 3.12 --with pillow --with pdfplumber \ + python tools/check_public.py /absolute/path/to/new-results ``` The command runs formatting, all workspace features/targets, doctests, Clippy, @@ -22,6 +24,11 @@ targets are versioned in this repository. Neither `corpus/private`, ignored `evidence`, existing `target` outputs, credentials nor running virtual machines are required. Cargo's normal dependency cache may be reused. +For the Python tests alone, end the same `uv run` with +`python -m unittest discover -s tools -p 'test_*.py'`. Without pdfplumber they +skip the PDF oracles (`test_pdf_format`, part of `test_document_oracle`) and +say so; `check_public.py` refuses to start without it. + Rust explicitly reports ignored lab tests and fixture generators. Those cases are **not** part of a successful public run. The Python suite tests native/lab harness logic using retained synthetic captures and mocks; it does not claim a @@ -40,8 +47,7 @@ debug assertions, then every `fuzz/` target for the time limit. A failing round leaves `soak/*.log` ending in the command that replays it; crash inputs stay in `fuzz/artifacts/<target>/`. On a Linux VM, install rustup's stable and nightly toolchains, `cargo install cargo-fuzz`, and `build-essential pkg-config -libfontconfig-dev`; copy the checkout together with the untracked `evidence/`, -which tests read, and run the script under `tmux`. +libfontconfig-dev`; copy the checkout and run the script under `tmux`. ## Private and native verification diff --git a/tools/canvas/README.md b/tools/canvas/README.md index 431a78d23750db4b533eb1d44957d19b64e0e25a..0b9fca2e836594f355c23af503d8173b4d4e8bce 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -48,7 +48,7 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a ## Desktop shell -`--notebook FOLDER` opens a notebook laid out as OneNote lays one out. The title bar shows the application's icon and the notebook's name; below it two rows of toolbar buttons follow the Home ribbon's Basic Text group, the first nine tags and zoom (only zoom acts yet). Readable top-level sections are tabs in the notebook's order, each leaning over the next at 45° with a faint highlight along its top, and the open one on top, casting a soft shadow and framing the page and the page tabs on the right in its colour, which shades slightly down the window; a tab that opens rises to meet the frame as its outline fades in. The frame's top corners and the page's corners are rounded concentric with the window's. The open page's tab is the page's colour and joins it with rounded inside corners; subpages are indented. The interface follows the system's light or dark appearance, and in the dark one the page's paper darkens and text left at OneNote's automatic colour turns light (except on a highlight), resolved when painting so nothing re-lays out; explicit colours, highlights, pictures and ink keep theirs, except that the background art of OneNote 2010's page templates, recognised by the picture's hash, paints from bundled vector recreations drawn for the paper. Sections take their stored colour's hue at a saturation and lightness chosen for each appearance. `--ui-font FONT_FILE_OR_FOLDER` sets the interface's typeface in place of the system's; fonts are not bundled. The section's colour is also the interface accent, easing to the next section's when another opens. The search box, New Page and the page-tab toggle sit above the page tabs. Command-F searches the page titles; Enter opens the first match and Escape clears it. A page that also changed on another computer shows Keep mine / Keep theirs above it. `--section FILE TITLE` opens one section the same way at that page. Section files must be regular files; discovery rejects symbolic links. +`--notebook FOLDER` opens a notebook laid out as OneNote lays one out. The title bar shows the application's icon and the notebook's name; below it two rows of toolbar buttons follow the Home ribbon's Basic Text group, the first nine tags and zoom (only zoom acts yet). Readable top-level sections are tabs in the notebook's order, each leaning over the next at 45° with a faint highlight along its top, and the open one on top, casting a soft shadow and framing the page and the page tabs on the right in its colour, which shades slightly down the window; a tab that opens rises to meet the frame as its outline fades in. The frame's top corners and the page's corners are rounded concentric with the window's. The open page's tab is the page's colour and joins it with rounded inside corners; subpages are indented. The interface follows the system's light or dark appearance, and in the dark one the page's paper darkens and text left at OneNote's automatic colour turns light (except on a highlight), resolved when painting so nothing re-lays out; explicit colours, highlights, pictures and ink keep theirs, except that the background art of OneNote 2010's page templates, recognised by the picture's hash, paints from bundled vector recreations drawn for the paper. Sections take their stored colour's hue at a saturation and lightness chosen for each appearance. The section's colour is also the interface accent, easing to the next section's when another opens. The search box, New Page and the page-tab toggle sit above the page tabs. Command-F searches the page titles; Enter opens the first match and Escape clears it. A page that also changed on another computer shows Keep mine / Keep theirs above it. `--section FILE TITLE` opens one section the same way at that page. Section files must be regular files; discovery rejects symbolic links. Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Existing. Settings (the open notebooks, the sidebar, recent fonts and the user name edits are stored under, which Settings… (⌘,; Options in the sidebar and Ctrl+, on Linux) changes and which starts as the account's full name) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. Open Existing takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`). diff --git a/tools/check_public.py b/tools/check_public.py index ad81b4d4a49ab2bb474795f1463f88516c9a3155..f4915d2e0a3402f70f993ff9d707db5d0d24f3db 100644 --- a/tools/check_public.py +++ b/tools/check_public.py @@ -22,7 +22,7 @@ def main(): environment['PYTHONPATH'] = str(root / 'tools') environment['CARGO_TARGET_DIR'] = str(root / 'target') commands = [ - ('python-dependencies', [sys.executable, '-c', 'import PIL']), + ('python-dependencies', [sys.executable, '-c', 'import PIL, pdfplumber']), ('format', ['cargo', 'fmt', '--all', '--check']), ('rust', ['cargo', 'test', '--locked', '--workspace', '--all-features', '--all-targets']), ('doctests', ['cargo', 'test', '--locked', '--workspace', '--all-features', '--doc']), diff --git a/tools/document_model.py b/tools/document_model.py index 843438411d85558ec843aca54b8397a2da092def..5d456f831ac6a20394e52bf155e00b90e805f00c 100644 --- a/tools/document_model.py +++ b/tools/document_model.py @@ -49,7 +49,8 @@ def ordered_pages(document): def version_pages(document, sid, revision): manifest = revision['nodes'][revision['roots']['1']]['kind'] context = manifest['history'] - if context is None: + # A page OneNote copied from a version names a history it never wrote. + if context not in document['spaces'][sid]['contexts']: return _, history = view(document, sid, context) root = history['nodes'][history['roots']['1']] diff --git a/tools/native_enter.py b/tools/native_enter.py new file mode 100644 index 0000000000000000000000000000000000000000..d51bfdf828578fc8f1a59dbd049cddd5ed5fb0a0 --- /dev/null +++ b/tools/native_enter.py @@ -0,0 +1,67 @@ +"""Press Enter at the edges of and inside equations and links in OneNote 2010 on a +Rust-written page and capture what it stores. The oracle for the canvas editor's Enter. + +Usage: native_enter.py NOTEBOOK_DIR OUTPUT_DIR +""" +import base64 +import json +import sys +from pathlib import Path +import xml.etree.ElementTree as ET + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import native_runner as runner + +windows = runner.windows + + +def text(value): + return 'SendText("%s")' % value.replace('"', '""') + + +def interaction(name, output): + lines = [ + # Enter at an equation's start moves it to a paragraph of its own. + text('Before '), 'Send("!=")', 'Sleep(600)', text('y^2+1'), 'Send(" ")', 'Sleep(700)', + 'Send("{Home}")', 'Send("{Right 7}")', 'Sleep(300)', 'Send("{Enter}")', 'Sleep(600)', + # Enter inside the equation's own row breaks its line. + 'Send("{End}")', 'Send("{Left 2}")', 'Sleep(300)', 'Send("{Enter}")', 'Sleep(600)', + # Enter inside a fraction's denominator makes it an equation array. + 'Send("{End}{Enter}")', 'Send("!=")', 'Sleep(600)', text('(a+b)/(c+d)'), 'Send(" ")', + 'Sleep(700)', 'Send("{Left 3}")', 'Sleep(300)', 'Send("{Enter}")', 'Sleep(600)', + # Enter at a link's start moves the link down whole (inside one, it follows the link). + 'Send("^{End}{Enter}")', text('Select me please'), 'Send("+^{Left}+^{Left}")', 'Send("^k")', + 'Sleep(1500)', text('https://example.com/selected'), 'Send("{Enter}")', 'Sleep(800)', + 'Send("{End}")', 'Send("{Left 9}")', 'Sleep(300)', 'Send("{Enter}")', 'Sleep(600)', + ] + page = ET.parse(sorted((output / 'read').glob('page-*.xml'))[0]).getroot().attrib['ID'] + script = '''ComObject("OneNote.Application").NavigateTo("PAGE_ID", "", false) +WinWait("ahk_exe ONENOTE.EXE", , 30) +WinActivate("ahk_exe ONENOTE.EXE") +WinWaitActive("ahk_exe ONENOTE.EXE", , 30) +WinMaximize("ahk_exe ONENOTE.EXE") +Sleep(3000) +CoordMode("Mouse", "Screen") +Click(200, 140) +Sleep(500) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(500) +%s +Sleep(2000) +''' % '\n'.join(lines) + script = script.replace('PAGE_ID', page) + (output / 'enter.ahk').write_text(script) + result = windows.do_exec(script, target=name, timeout_ms=240000, shot_delay_ms=1500) + screenshot = result.pop('png_b64', None) + if screenshot: + (output / 'enter.png').write_bytes(base64.b64decode(screenshot)) + (output / 'enter.json').write_text(json.dumps(result, indent=2)) + if result.get('error') or result.get('exit') != 0: + raise RuntimeError(str(result)) + + +if __name__ == '__main__': + notebook, output = Path(sys.argv[1]), Path(sys.argv[2]) + runner.capture(notebook, output, expected_pages=1, collect_notebook=True, interaction=interaction) diff --git a/tools/native_format.py b/tools/native_format.py index 57e19bd1e9f27aa757a76ccf2e8a3eccbbbd1b27..1fe34c220c6f355b7cc3ef97af4217047dc6b7f6 100644 --- a/tools/native_format.py +++ b/tools/native_format.py @@ -100,7 +100,8 @@ def compare_formats(space, nodes, native): # Hidden runs and equation runs (exported as MathML) have no visible native text. if not style.get('hidden') and not style.get('math'): actual.extend((char, style) for char in text) - if actual and actual[-1][0] == '\r': + # A trailing CR ends the stored text; one between equations is a line break. + if actual and actual[-1][0] == '\r' and kind['text'].endswith('\r'): actual.pop() actual = [(projected, style) for char, style in actual for projected in project_text(char)] if kind['text'] == '\u00a0' and not expected: diff --git a/tools/native_links.py b/tools/native_links.py new file mode 100644 index 0000000000000000000000000000000000000000..6577e4538a45644c03ba620b901dac70c0c99015 --- /dev/null +++ b/tools/native_links.py @@ -0,0 +1,94 @@ +"""Type hyperlinks into OneNote 2010 on a Rust-written page and capture what it stores: URLs +it links as they are typed, links made with the Link dialog (Ctrl+K), typing after a link and +Remove Link. The oracle for the canvas editor's link editing. + +Usage: native_links.py NOTEBOOK_DIR OUTPUT_DIR +""" +import base64 +import json +import sys +from pathlib import Path +import xml.etree.ElementTree as ET + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import native_runner as runner + +windows = runner.windows + +# Each typed URL sits between a word and a letter, one per paragraph; a space or Enter ends it. +TYPED = [ + 'www.example.com', + 'http://example.org/a?b=1', + 'https://a.example/p.', + '(http://b.example/q)', + 'ftp://c.example/f', + 'mailto:me@example.com', + 'me@example.com', + 'example.com', + 'onenote:#Page§ion-id={x}', + 'file://c:/windows', + '\\\\server\\share\\f', + 'news:comp.lang', + 'www.d.example,', + 'HTTP://E.EXAMPLE/UP', + 'https://f.example/a_b-c~d%20e#frag', +] + + +def text(value): + return 'SendText("%s")' % value.replace('"', '""') + + +def interaction(name, output): + lines = [] + for url in TYPED: + lines += [text('t ' + url), 'Send(" ")', text('z'), 'Send("{Enter}")', 'Sleep(300)'] + # A URL ended by Enter, then a letter typed straight after the link it made. + lines += [text('see http://g.example/end'), 'Send("{Enter}")', 'Send("{Up}{End}")', text('Z'), + 'Send("{Down}{End}")', 'Sleep(300)'] + # The Link dialog on the word at the caret: its text is the word, the address as typed. + lines += [text('plain words here'), 'Send("^{Left}")', 'Send("^k")', 'Sleep(1500)', + text('example.net/x'), 'Send("{Enter}")', 'Sleep(800)', 'Send("{End}")', text('Q'), + 'Send("{Enter}")', 'Sleep(300)'] + # The Link dialog on a selection. + lines += [text('select me please'), 'Send("+^{Left}+^{Left}")', 'Send("^k")', 'Sleep(1500)', + text('https://example.com/selected'), 'Send("{Enter}")', 'Sleep(800)', + 'Send("{End}{Enter}")', 'Sleep(300)'] + # The Link dialog with no text: the address becomes the text; typing after it is plain. + lines += ['Send("^k")', 'Sleep(1500)', 'Send("!e")', 'Sleep(300)', + text('https://example.com/only'), 'Send("{Enter}")', 'Sleep(800)', text(' tail'), + 'Send("{Enter}")', 'Sleep(300)'] + # Remove Link from the context menu (Shift+F10, R) with the caret inside a typed link. + lines += [text('gone ftp://h.example/f'), 'Send(" ")', text('z'), 'Send("{Home}^{Right}^{Right}")', + 'Send("+{F10}")', 'Sleep(900)', 'Send("r")', 'Sleep(800)'] + page = ET.parse(sorted((output / 'read').glob('page-*.xml'))[0]).getroot().attrib['ID'] + script = '''ComObject("OneNote.Application").NavigateTo("PAGE_ID", "", false) +WinWait("ahk_exe ONENOTE.EXE", , 30) +WinActivate("ahk_exe ONENOTE.EXE") +WinWaitActive("ahk_exe ONENOTE.EXE", , 30) +WinMaximize("ahk_exe ONENOTE.EXE") +Sleep(3000) +CoordMode("Mouse", "Screen") +Click(200, 140) +Sleep(500) +Send("{End}") +Sleep(300) +Send("{Enter}") +Sleep(500) +%s +Sleep(2000) +''' % '\n'.join(lines) + script = script.replace('PAGE_ID', page) + (output / 'links.ahk').write_text(script) + result = windows.do_exec(script, target=name, timeout_ms=240000, shot_delay_ms=1500) + screenshot = result.pop('png_b64', None) + if screenshot: + (output / 'links.png').write_bytes(base64.b64decode(screenshot)) + (output / 'links.json').write_text(json.dumps(result, indent=2)) + if result.get('error') or result.get('exit') != 0: + raise RuntimeError(str(result)) + + +if __name__ == '__main__': + notebook, output = Path(sys.argv[1]), Path(sys.argv[2]) + runner.capture(notebook, output, expected_pages=1, collect_notebook=True, interaction=interaction) diff --git a/tools/native_versions.py b/tools/native_versions.py new file mode 100644 index 0000000000000000000000000000000000000000..43414beb1771c060413301c6d8a881413d1b1093 --- /dev/null +++ b/tools/native_versions.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""OneNote 2010 page versions (Share, Page Versions): how they arise, what they store and +how OneNote shows, restores, copies and deletes them. + +`observe OUTPUT` runs one disposable OneNote client on a new notebook: `virtual` writes a +page, `Other Person` edits it (OneNote keeps the page as it was as a version), then OneNote's +Copy Page To, Restore Version, Delete Version and Delete All Versions in Section run on it; +two more authors' edits, a Delete Version of the only version and the author views (Hide +Authors, Recent Edits, Find by Author) follow. The section after each step is +`step-NN/notebook`, the screens `shots/`. + +`cold NOTEBOOK OUTPUT [--delete]` cold-opens a notebook whose page has versions in a fresh +clone, shows them and opens the newest, screenshots each view, and with `--delete` deletes +that version through its bar's menu and keeps the notebook OneNote leaves. +""" +import argparse +import base64 +import json +from pathlib import Path +import sys +import time +import xml.etree.ElementTree as ET + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent / 'w7')) +import native_runner as runner +from native_runner import windows + +ROOT = Path(r'C:\one-tests\versions') +SECTION = ROOT / 'Versions' / 'History.one' + +# Coordinates on OneNote 2010 maximized at 800x600 with its ribbon collapsed, from the +# capture in corpus/page-versions/native: the Share tab, its Page Versions button and the +# arrow under it, the information bar, the first body line, the first version listed under +# the page and the page itself in the list. +SHARE = (202, 36) +PAGE_VERSIONS = (443, 80) +PAGE_VERSIONS_MENU = (462, 108) +DELETE_ALL_IN_SECTION = (525, 155) +BAR = (320, 93) +BODY = (160, 208) +FIRST_VERSION = (710, 143) +THE_PAGE = (700, 121) +HIDE_AUTHORS = (383, 88) +RECENT_EDITS = (296, 108) +FIND_BY_AUTHOR = (338, 95) +# Items of the version bar's menu. +RESTORE, DELETE, COPY = (396, 106), (393, 128), (395, 150) + +# Reports any dialog OneNote raised, so a confirmation shows in the step's record. +DIALOG = ('if WinExist("ahk_class #32770 ahk_exe ONENOTE.EXE") {\n' + ' FileAppend("dialog: " WinGetTitle() " | " WinGetText() "`n", "*", "UTF-8")\n' + '}\n') +ACTIVATE = 'WinActivate("ahk_exe ONENOTE.EXE")\nCoordMode("Mouse", "Screen")\n' + + +def click(point, wait=1000): + return f'Click({point[0]}, {point[1]})\nSleep({wait})\n' + + +class Clone: + """A clone's desktop: `shot` runs an AutoHotkey step and keeps its screenshot.""" + + def __init__(self, name, output): + self.name, self.output = name, output + (output / 'shots').mkdir(parents=True, exist_ok=True) + + def shot(self, label, script, delay=1500): + (self.output / 'shots' / f'{label}.ahk').write_text(script) + result = windows.do_exec(script, target=self.name, timeout_ms=180000, shot_delay_ms=delay) + screenshot = result.pop('png_b64', None) + if screenshot: + (self.output / 'shots' / f'{label}.png').write_bytes(base64.b64decode(screenshot)) + (self.output / 'shots' / f'{label}.json').write_text(json.dumps(result, indent=2)) + if result.get('error') or result.get('exit') != 0: + raise RuntimeError(f'{label}: {result}') + return result + + def command(self, text, timeout_ms=120000): + runner.command(self.name, text, self.output, timeout_ms) + + def keep(self, step, files=('History.one',)): + """Keeps the section once OneNote has stopped writing it: its size and time unchanged + for five seconds.""" + self.command('powershell -NoProfile -Command "$f = \'%s\'; $last = \'\'; $still = 0; ' + 'for ($i = 0; $i -lt 120 -and $still -lt 10; $i++) { Start-Sleep -Milliseconds 500; ' + '$item = Get-Item $f; $now = \'\' + $item.Length + $item.LastWriteTimeUtc.Ticks; ' + 'if ($now -eq $last) { $still++ } else { $still = 0; $last = $now } }"' % SECTION) + directory = self.output / f'step-{step:02}' / 'notebook' + directory.mkdir(parents=True) + for name in files: + result = windows.do_get(str(SECTION.parent / name), directory / name, self.name) + if result.get('error'): + raise RuntimeError(result['error']) + + +def open_page(author): + """Closes OneNote, names the Office user `author` and opens the page.""" + name, initials = author + return ('if WinExist("ahk_exe ONENOTE.EXE") {\n WinClose("ahk_exe ONENOTE.EXE")\n Sleep(4000)\n}\n' + 'RunWait("cmd /c taskkill /IM ONENOTE.EXE /F", , "Hide")\nSleep(1000)\n' + f'RegWrite("{name}", "REG_SZ", "HKCU\\Software\\Microsoft\\Office\\Common\\UserInfo", "UserName")\n' + f'RegWrite("{initials}", "REG_SZ", "HKCU\\Software\\Microsoft\\Office\\Common\\UserInfo", "UserInitials")\n' + f'page := Trim(FileRead("{ROOT}\\page.txt", "UTF-8"), "`r`n ")\n' + 'app := ComObject("OneNote.Application")\napp.NavigateTo(page)\n' + 'WinWait("ahk_exe ONENOTE.EXE", , 20)\nWinMaximize("ahk_exe ONENOTE.EXE")\n' + + ACTIVATE + 'Sleep(3000)\n') + + +def type_line(text, new_line=True): + return (ACTIVATE + click(BODY, 300) + ('Send("^{End}{Enter}")\n' if new_line else 'Send("{End}")\n') + + f'SendText("{text}")\nSleep(3000)\n') + + +SETUP = r'''$ErrorActionPreference = 'Stop' +$app = New-Object -ComObject OneNote.Application +$notebook = '' +$app.OpenHierarchy('%(root)s\Versions', '', [ref]$notebook, 1) +$section = '' +$app.OpenHierarchy('History.one', $notebook, [ref]$section, 3) +$page = '' +$app.CreateNewPage($section, [ref]$page, 0) +$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' +$app.UpdatePageContent('<one:Page xmlns:one="' + $namespace + '" ID="' + $page + '"><one:Title><one:OE><one:T>Versioned</one:T></one:OE></one:Title><one:Outline><one:Position x="36" y="86"/><one:OEChildren><one:OE><one:T>First state.</one:T></one:OE></one:OEChildren></one:Outline></one:Page>') +$page | Out-File '%(root)s\page.txt' -Encoding UTF8 +''' + +VIRTUAL, OTHER = ('virtual', 'v'), ('Other Person', 'OP') + + +def observe(output): + output.mkdir(parents=True, exist_ok=False) + with runner.clone(output) as name: + clone = Clone(name, output) + setup = output / 'setup.ps1' + setup.write_text(SETUP % {'root': ROOT}) + clone.command(f'mkdir {ROOT}') + result = windows.do_put(setup, str(ROOT / 'setup.ps1'), name) + if result.get('error'): + raise RuntimeError(result['error']) + clone.command(f'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File {ROOT}\\setup.ps1') + # One author: no version. + clone.shot('01-typed', open_page(VIRTUAL) + type_line(' Typed by virtual.', new_line=False)) + clone.keep(1, ('History.one', 'Open Notebook.onetoc2')) + # A second author's edit keeps the page as it was as a version. + clone.shot('02-second-author', open_page(OTHER) + type_line('Second author line.')) + clone.keep(2) + clone.shot('02-menu', ACTIVATE + click(SHARE, 600) + click(PAGE_VERSIONS_MENU, 800)) + clone.shot('02-shown', ACTIVATE + click((486, 129), 1500)) + clone.shot('02-version', ACTIVATE + click(FIRST_VERSION, 1500)) + clone.shot('02-version-menu', ACTIVATE + click(BAR, 1000)) + # Copy Page To copies the version as a page at the section's end. + clone.shot('03-copy-dialog', ACTIVATE + click(COPY, 1500)) + clone.shot('03-copied', 'WinActivate("Move or Copy Pages")\n' + click((289, 200), 500) + + click((474, 494), 3000), 2000) + clone.keep(3) + # Restore Version: the page as it stood becomes the newest version. + clone.shot('04-shown', ACTIVATE + click(THE_PAGE, 1000) + click(SHARE, 600) + click(PAGE_VERSIONS, 1200)) + clone.shot('04-restored', ACTIVATE + click(FIRST_VERSION, 1200) + click(BAR, 800) + + click(RESTORE, 2500) + DIALOG, 2000) + clone.keep(4) + # Delete Version, without a confirmation. + clone.shot('05-newest', ACTIVATE + click(FIRST_VERSION, 1500)) + clone.shot('05-deleted', ACTIVATE + click(BAR, 800) + click(DELETE, 2500) + DIALOG, 2000) + clone.keep(5) + # Delete All Versions in Section asks first. + clone.shot('06-confirm', ACTIVATE + click(SHARE, 600) + click(PAGE_VERSIONS_MENU, 800) + + click(DELETE_ALL_IN_SECTION, 1500) + DIALOG) + clone.shot('06-deleted-all', 'WinActivate("Microsoft OneNote ahk_class #32770")\nSend("!y")\nSleep(2500)\n', 2500) + clone.keep(6) + # Deleting the only version clears the page's HasVersionPages. + clone.shot('07-third-author-edit', open_page(VIRTUAL) + type_line('Third line by virtual.') + + click(SHARE, 600) + click(PAGE_VERSIONS, 1200)) + clone.keep(7) + clone.shot('08-deleted-only', ACTIVATE + click(FIRST_VERSION, 1500) + click(BAR, 800) + + click(DELETE, 2500) + DIALOG, 2000) + clone.keep(8) + # Authors: Hide Authors off, Recent Edits, Find by Author. + clone.shot('09-authors', open_page(OTHER) + type_line('Fourth line by Other Person.') + + click(SHARE, 600) + click(HIDE_AUTHORS, 1500)) + clone.shot('09-recent-edits', ACTIVATE + click(SHARE, 600) + click(RECENT_EDITS, 1200), 1000) + clone.shot('09-find-by-author', ACTIVATE + 'Send("{Esc}")\nSleep(400)\n' + click(SHARE, 900) + + click(FIND_BY_AUTHOR, 3000), 2500) + clone.keep(9) + + +def cold(notebook, output, delete): + """Opens the page with versions, shows them, opens the newest and its menu; `delete` + then deletes it.""" + def interaction(name, output): + clone = Clone(name, output) + page = ET.parse(sorted((output / 'read').glob('page-*.xml'))[0]).getroot().attrib['ID'] + clone.shot('cold-main', runner.navigation_script(page) + 'Sleep(2000)\n') + clone.shot('cold-shown', ACTIVATE + click(SHARE, 600) + click(PAGE_VERSIONS, 1500)) + clone.shot('cold-version', ACTIVATE + click(FIRST_VERSION, 1500)) + clone.shot('cold-menu', ACTIVATE + click(BAR, 1000)) + if delete: + clone.shot('cold-delete', ACTIVATE + click(DELETE, 2500) + DIALOG, 2000) + # OneNote writes the deletion in the background; give it time before the re-read. + time.sleep(20) + else: + clone.shot('cold-closed', ACTIVATE + 'Send("{Esc}")\nSleep(800)\n') + runner.capture(notebook, output, collect_notebook=True, interaction=interaction) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + commands = parser.add_subparsers(dest='command', required=True) + observed = commands.add_parser('observe') + observed.add_argument('output', type=Path) + opened = commands.add_parser('cold') + opened.add_argument('notebook', type=Path) + opened.add_argument('output', type=Path) + opened.add_argument('--delete', action='store_true') + args = parser.parse_args() + if args.command == 'observe': + observe(args.output) + else: + cold(args.notebook, args.output, args.delete) diff --git a/tools/test_document_oracle.py b/tools/test_document_oracle.py index 4b0d2804a7822c346c3286c36472dedc46a243b1..2d1947536d66a1e0c4eb5e74cca20a6d23942f9c 100644 --- a/tools/test_document_oracle.py +++ b/tools/test_document_oracle.py @@ -1,3 +1,4 @@ +from importlib.util import find_spec from pathlib import Path import re import runpy @@ -9,9 +10,12 @@ import xml.etree.ElementTree as ET ROOT = Path(__file__).resolve().parent.parent compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] +requires_pdfplumber = unittest.skipUnless( + find_spec('pdfplumber'), 'pdfplumber is not installed; tools/TESTING.md sets up the Python lane') class DocumentOracleTest(unittest.TestCase): + @requires_pdfplumber def test_locked_table_width_requires_the_native_value(self): fixture = ROOT / 'corpus/m6/native-structure-01' with TemporaryDirectory() as temporary: @@ -27,6 +31,7 @@ class DocumentOracleTest(unittest.TestCase): with self.assertRaisesRegex(AssertionError, 'Locked table column width differs'): compare(fixture / 'notebook', native) + @requires_pdfplumber def test_native_2010_does_not_render_the_documented_cell_shading_control(self): import pdfplumber fixture = ROOT / 'corpus/m6/cell-shading-control-01/read/page-001' @@ -40,6 +45,7 @@ class DocumentOracleTest(unittest.TestCase): self.assertTrue(all(tuple(rect.get('non_stroking_color') or ()) != (1, 1, 0) for rect in table_page.rects)) + @requires_pdfplumber def test_rust_saved_expansion_is_honored_by_a_fresh_native_cache(self): import pdfplumber fixture = ROOT / 'corpus/m6/rust-collapse-control-01' @@ -53,6 +59,7 @@ class DocumentOracleTest(unittest.TestCase): self.assertIn('Hidden child remains stored', text) self.assertIn('Grandchild', text) + @requires_pdfplumber def test_native_expansion_changes_the_view_without_changing_notebook_bytes(self): import pdfplumber original = ROOT / 'corpus/m6/native-features-01/notebook/Features.one' @@ -96,6 +103,7 @@ class DocumentOracleTest(unittest.TestCase): captures.append([ET.tostring(n) for n in equations]) self.assertEqual(*captures) + @requires_pdfplumber def test_rtl_xml_order_matches_native_visual_column_order(self): import pdfplumber fixture = ROOT / 'corpus/m6/native-page-direction-03' diff --git a/tools/test_link_edit.py b/tools/test_link_edit.py index 1c4408d8d18d9201d6ae92db29f5156d9e1d1d7f..0515bfa9c346bab806347dbe348452ce9c051047 100644 --- a/tools/test_link_edit.py +++ b/tools/test_link_edit.py @@ -1,3 +1,4 @@ +import html import json import re from pathlib import Path @@ -49,6 +50,37 @@ class LinkEditTest(unittest.TestCase): text = ' '.join(' '.join(oe.find('one:T', ns).text.split()) for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None) self.assertRegex(text, r'^Linking page <a href="onenote:#Link%20target&section-id=\{[0-9A-F-]{36}\}&page-id=\{[0-9A-F-]{36}\}&end&base-path=C:\\one-tests\\runs\\capture\\notebook\\links.one">Link target</a>$') + def test_onenote_links_typed_urls_and_dialog_addresses(self): + """OneNote 2010's read of `native-typed`: a typed `www.` address opens over http, + and a Link dialog address typed without a scheme does too.""" + text = (FIXTURE / 'native-typed/read/page-000.xml').read_text(encoding='utf-8-sig') + hrefs = [html.unescape(href) for href in re.findall(r'href="([^"]*)"', text)] + self.assertEqual(hrefs[1], 'http://www.example.com') + self.assertEqual(hrefs[11], 'http://www.d.example') + self.assertEqual(hrefs[15], 'http://example.net/x') + self.assertNotIn('me@example.com', hrefs) + + def test_editor_links_and_equations_render_natively(self): + """What the canvas editor saved for typed URLs, the Link dialog, Remove Link and + equations typed after Alt+= reads back in a cold OneNote 2010 with the links and + MathML it meant (`crates/canvas/tests/links_equations.rs`).""" + row = FIXTURE / 'editor' + with TemporaryDirectory() as temporary: + read = Path(temporary) / 'read' + shutil.copytree(row / 'cold/read', read) + compare(row / 'candidate', read) + expected = json.loads((row / 'expected.json').read_text()) + path = next(path for path in sorted((row / 'cold/read').glob('page-*.xml')) + if ET.parse(path).getroot().get('name').startswith('Linking page')) + page = ET.parse(path).getroot() + text = ''.join(oe.find('one:T', ns).text for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None) + hrefs = [html.unescape(href) for href in re.findall(r'href="([^"]*)"', text)] + self.assertEqual(hrefs, expected['hrefs']) + raw = path.read_text(encoding='utf-8-sig') + mathml = [re.sub(r'&#(\d+);', lambda m: chr(int(m.group(1))), body) + for body in re.findall(r'<mml:math[^>]*>(.*?)</mml:math>', raw, re.S)] + self.assertEqual(mathml, expected['mathml']) + if __name__ == '__main__': unittest.main() diff --git a/tools/test_math_edit.py b/tools/test_math_edit.py index 63bd9370a4884f79ceb5adf5cdd1a38516c5fac2..0950be610925301695adaaf31d28b9508bf9bf89 100644 --- a/tools/test_math_edit.py +++ b/tools/test_math_edit.py @@ -24,6 +24,7 @@ class MathEditTest(unittest.TestCase): self.assertEqual(len(exported_mathml(FIXTURE / 'native-editor/read/page-000.xml')), 5) self.assertEqual(len(exported_mathml(FIXTURE / 'native-editor-2/read/page-000.xml')), 9) self.assertEqual(len(exported_mathml(FIXTURE / 'native-editor-3/read/page-000.xml')), 10) + self.assertEqual(len(exported_mathml(FIXTURE / 'native-editor-4/read/page-000.xml')), 26) def test_rust_written_equations_export_the_expected_mathml(self): with TemporaryDirectory() as temporary: diff --git a/tools/test_page_versions.py b/tools/test_page_versions.py new file mode 100644 index 0000000000000000000000000000000000000000..8fcfc4780e11af2a20835a128c749ca923a3a52f --- /dev/null +++ b/tools/test_page_versions.py @@ -0,0 +1,103 @@ +"""Page versions (corpus/page-versions): what OneNote 2010 stores for them and does to them, +and that it reads, lists and deletes the ones Rust writes.""" +import hashlib +import json +from pathlib import Path +import runpy +import shutil +import subprocess +from tempfile import TemporaryDirectory +import unittest + +from document_model import EXPORTER, ordered_pages, version_pages, walk + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/page-versions' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] +HISTORY = '{7111497F-1B6B-4209-9491-C98B04CF4C5A},1' +HAS_VERSION_PAGES = 0x88003462 + + +def model(notebook): + with TemporaryDirectory() as temporary: + output = Path(temporary) / 'model' + subprocess.run([EXPORTER, notebook / 'History.one', output], check=True, capture_output=True) + return json.loads((output / 'document.json').read_text()) + + +# The title's date and time fields, the same on every state of the page. +DATE = {'Sunday, September 27, 2026', '5:42 PM'} + + +def texts(space, root): + return [node['kind']['text'] for _, node in walk(space, root) + if node['kind']['type'] == 'RichText' and node['kind']['text'] and node['kind']['text'] not in DATE] + + +def versions(document): + """Each listed page's text and versions, newest first: each version's author and text, + and whether its metadata says it has versions.""" + listed = [] + for sid, _, page, oid in ordered_pages(document): + own = [] + history = document['spaces'][sid]['revisions'].get(document['spaces'][sid]['contexts'].get(HISTORY)) + for _, _, revision, version, proxy in version_pages(document, sid, page): + author = history['nodes'][proxy['latest_author']]['kind']['name'] + own.append((author, texts(revision, version))) + metadata = page['nodes'][page['roots']['2']] + flagged = any(field['id'] == HAS_VERSION_PAGES for fields in metadata['extra'] for field in fields) + listed.append((texts(page, oid), own, flagged)) + return listed + + +FIRST = 'First state. Typed by virtual.' +SECOND = 'Second author line.' + + +class PageVersionsTest(unittest.TestCase): + def test_onenote_keeps_a_version_when_another_author_edits(self): + native = FIXTURE / 'native' + self.assertEqual(versions(model(native / 'step-01/notebook')), + [(['Versioned', FIRST], [], False)]) + page = ['Versioned', FIRST, SECOND] + self.assertEqual(versions(model(native / 'step-02/notebook')), + [(page, [('virtual', ['Versioned', FIRST])], True)]) + # Copy Page To copies the version as a page of its own, without versions. + self.assertEqual(versions(model(native / 'step-03/notebook'))[1], + (['Versioned', FIRST], [], False)) + # Restore Version: the page as it stood becomes the newest version. + self.assertEqual(versions(model(native / 'step-04/notebook'))[0], + (['Versioned', FIRST], [('Other Person', page), ('virtual', ['Versioned', FIRST])], True)) + # Delete Version, then Delete All Versions in Section, which clears the flag. + self.assertEqual(versions(model(native / 'step-05/notebook'))[0][1], [('virtual', ['Versioned', FIRST])]) + self.assertEqual(versions(model(native / 'step-06/notebook'))[0], (['Versioned', FIRST], [], False)) + # Optimizing keeps versions. + self.assertEqual(len(versions(model(native / 'step-10/notebook'))[0][1]), 1) + + def test_the_scripted_capture_repeats_it(self): + scripted = FIXTURE / 'native-scripted' + counts = [len(versions(model(scripted / f'step-{n:02}/notebook'))[0][1]) for n in range(1, 10)] + self.assertEqual(counts, [0, 1, 1, 2, 1, 0, 1, 0, 1]) + + def test_onenote_reads_a_rust_restore_and_deletes_its_version(self): + for name in ('restore', 'delete-all'): + source = json.loads((FIXTURE / f'cold-{name}/source.json').read_text()) + written = hashlib.sha256((FIXTURE / f'candidate-{name}/History.one').read_bytes()).hexdigest() + self.assertIn(written, [item['sha256'] for item in source]) + with TemporaryDirectory() as temporary: + read = Path(temporary) / 'read' + shutil.copytree(FIXTURE / f'cold-{name}/before-read', read) + compare(FIXTURE / f'candidate-{name}', read) + restored = versions(model(FIXTURE / 'candidate-restore'))[0] + self.assertEqual(restored[0], ['Versioned', FIRST]) + self.assertEqual([author for author, _ in restored[1]], ['Other Person', 'virtual']) + self.assertEqual(versions(model(FIXTURE / 'candidate-delete-all'))[0], (['Versioned', FIRST], [], False)) + # OneNote listed the Rust versions under the page, opened the newest under its bar and + # deleted it through the bar's menu, leaving the older one. + record = lambda name: json.loads((FIXTURE / f'cold-restore/shots/{name}.json').read_text()) + self.assertEqual(record('cold-version')['win']['title'], 'Versioned - Microsoft OneNote') + after = versions(model(FIXTURE / 'cold-restore/notebook'))[0] + self.assertEqual([author for author, _ in after[1]], ['virtual']) + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_pdf_format.py b/tools/test_pdf_format.py index a96592a357cb79e59413c01df293c2a5eeca39b8..457a7014ae0db2ff003f1d5f7cf5c366afda6509 100644 --- a/tools/test_pdf_format.py +++ b/tools/test_pdf_format.py @@ -1,3 +1,4 @@ +from importlib.util import find_spec from pathlib import Path import runpy import unittest @@ -6,7 +7,11 @@ from unittest.mock import patch verify = runpy.run_path(str(Path(__file__).with_name('verify-document.py')))['verify_pdf_black'] +requires_pdfplumber = unittest.skipUnless( + find_spec('pdfplumber'), 'pdfplumber is not installed; tools/TESTING.md sets up the Python lane') + +@requires_pdfplumber class PdfFormatOracleTest(unittest.TestCase): def test_unmapped_text_cannot_hide_missing_text_or_incorrect_highlighting(self): chars = [{'text': c, 'x0': i * 2, 'x1': i * 2 + 1, 'top': 0, 'bottom': 1} diff --git a/tools/test_recycle_bin_repair.py b/tools/test_recycle_bin_repair.py new file mode 100644 index 0000000000000000000000000000000000000000..1f1783939e91c9b551b170c8e70908bc94736c59 --- /dev/null +++ b/tools/test_recycle_bin_repair.py @@ -0,0 +1,45 @@ +from pathlib import Path +import unittest +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/recycle-bin-repair' + + +def tree(node): + out = [] + for child in node: + tag = child.tag.rsplit('}', 1)[-1] + if tag in ('Section', 'SectionGroup', 'Page'): + flags = tuple(sorted(key for key in ('isRecycleBin', 'isDeletedPages', 'isInRecycleBin') + if child.get(key) == 'true')) + out.append((tag, child.get('name'), flags, tree(child))) + return out + + +class RecycleBinRepairTest(unittest.TestCase): + def test_onenote_repairs_nothing_in_the_bin_a_page_delete_repaired(self): + candidates = sorted(path.relative_to(FIXTURE / 'candidate') + for path in (FIXTURE / 'candidate').rglob('*') if path.is_file()) + self.assertEqual(candidates, sorted(path.relative_to(FIXTURE / 'cold/notebook') + for path in (FIXTURE / 'cold/notebook').rglob('*') + if path.is_file())) + for relative in candidates: + self.assertEqual((FIXTURE / 'cold/notebook' / relative).read_bytes(), + (FIXTURE / 'candidate' / relative).read_bytes(), str(relative)) + + def test_the_bin_holds_the_deleted_pages(self): + hierarchy = ET.parse(FIXTURE / 'cold/read/hierarchy.xml').getroot() + self.assertEqual(tree(hierarchy), [ + ('Section', 'New Section 1', (), [('Page', 'Kept', (), [])]), + ('SectionGroup', 'OneNote_RecycleBin', ('isRecycleBin',), [ + ('Section', 'Deleted Pages', ('isDeletedPages', 'isInRecycleBin'), [ + ('Page', 'Kept', ('isInRecycleBin',), []), + ('Page', 'Kept', ('isInRecycleBin',), []), + ]), + ]), + ]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/verify-document.py b/tools/verify-document.py index 567a87e88b2d5f867562a2b3f78b2a2fb4c9920b..fb7f9b0f63f8a5990cbccd78b6c0cdb1d03b9a87 100644 --- a/tools/verify-document.py +++ b/tools/verify-document.py @@ -82,7 +82,10 @@ def visible_text(node, space): text = ''.join(data[run['start'] * 2:run['end'] * 2].decode('utf-16-le') for run in kind['runs'] if not run['format'] or not (space['nodes'][run['format']]['format']['hidden'] or space['nodes'][run['format']]['format']['math'])) - return "" if text == "\u00a0" else project_text(text.removesuffix('\r')) + # A trailing CR ends the stored text; one inside it, between equations, is a line break. + if kind['text'].endswith('\r'): + text = text.removesuffix('\r') + return "" if text == "\u00a0" else project_text(text) ISF_X = bytes.fromhex('8f6a8a59c052a04b93afaf357411a561') @@ -247,6 +250,33 @@ def compare_objects(space, roots, page, native_roots, assets, native_payloads, a return count +def exported_links(runs): + """Characters and links as OneNote 2010 exports them: an address without a scheme opens + over http, and URL text it finds in a page's text is a link, a removed link's included + (`corpus/link-edit/native-typed`).""" + def href(link): + if link is None or ':' in link.split('/')[0] or link.startswith('\\\\'): + return link + return 'http://' + link + out = [(char, href(link)) for char, link in runs] + start = 0 + while start < len(out): + end = start + while end < len(out) and not out[end][0].isspace(): + end += 1 + word = ''.join(char for char, _ in out[start:end]) + # The scheme stays with the text it links; trailing punctuation does not. + for scheme in ('http://', 'https://', 'ftp://', 'mailto:', 'news:'): + at = word.lower().find(scheme) + if at >= 0 and all(link is None for _, link in out[start:end]): + url = word[at:].rstrip('.,;:!?\'")]}') + for index in range(start + at, start + at + len(url)): + out[index] = (out[index][0], url) + break + start = end + 1 + return out + + def compare(notebook, native, versions=None, password_file=None): notebook = notebook.resolve(strict=True) native = native.resolve(strict=True) @@ -396,12 +426,14 @@ def compare(notebook, native, versions=None, password_file=None): # Native XML exports equation runs as MathML, not text (tools/test_math_edit.py compares that). observed_runs = [(char, run['link']) for run in resolved_text[sid][rid][text_id] if not run['format']['hidden'] and not run['format']['math'] for char in run['text']] - if observed_runs and observed_runs[-1][0] == '\r': + stored = ''.join(run['text'] for run in resolved_text[sid][rid][text_id]) + if observed_runs and observed_runs[-1][0] == '\r' and stored.endswith('\r'): observed_runs.pop() observed_runs = [(projected, link) for char, link in observed_runs for projected in project_text(char)] expected_links = [(char, style.get('link')) for char, style in expected_runs] if observed_runs == [('\u00a0', None)] and not expected_links: observed_runs = [] + observed_runs = exported_links(observed_runs) assert observed_runs == expected_links, 'Resolved text or associated hyperlink differs' count, differences = compare_formats(space, text_nodes, native_runs) formatting += count