authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 17:36:32-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-28 05:43:14-07:00
log71e4e308b60bf1a52ad839646150a0f9e324ecee
tree0afa25a027033419097876770d0258af46e37efc
parentb589fec9d51d404e16bcb68a176f226d5b5fa459
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat(data): page versions, editable links and math, TOC and discovery repairs

Page versions follow OneNote 2010: old revisions kept alive by revision labels and a version list, with restore, delete and copy written as OneNote writes them. Links and equations are editable end to end (UnicodeMath linear form, OneNote's Enter rules at equation and link boundaries), and titles no longer keep a link's field code. Discovery tolerates unreadable or delete-pending folders, table-of-contents edits keep OneNote's order values on rename, and recycle-bin placement repairs stale entries. New corpus rows: page-versions, link-edit and math-edit captures, recycle-bin-repair. Assisted-by: claude-opus-5.5

393 files changed, 9412 insertions(+), 1125 deletions(-)

corpus/link-edit/README.md+39
......@@ -37,3 +37,42 @@ second page, whose first page links to that page and to its first paragraph
3737with URLs built by `onestore::page::link::internal_link`. `internal/cold` is
3838its cold read with both links. Regenerate with
3939`ONESTORE_INTERNAL_LINK_EXPORT` and cold-open with `--expected-pages 2`.
40
41## Typed links
42
43`native-typed/` is OneNote 2010 typing on the Rust-authored page above, driven by
44`tools/native_links.py` (`links.ahk` the AutoHotkey session, `links.png` the
45desktop afterwards). Each URL typed and ended by a space or Enter became a link
46of its own text, a hyperlink run with no field code and no label flag: `http`,
47`https`, `ftp`, `file`, `mailto`, `news` and `onenote` schemes, `www.` and
48`\\server\share` paths. Trailing punctuation and an unmatched closing bracket
49stay outside; `me@example.com` and `example.com` stay text. The Link dialog
50(Ctrl+K) on the word at the caret, on a selection and on nothing stored the
51address as typed (`example.net/x`, which the COM read shows as
52`http://example.net/x`) in a hidden field code before a label flagged as one,
53the address itself when the text was empty; text typed after a label is plain.
54Remove Link (Shift+F10, R) left the URL as plain text, its runs' link flag
55false. An interactive session on a copy showed the rest: a click on a link opens
56it at once, typing straight after a link of its own text extends it and its
57address, and Copy Link to Page puts
58`onenote:///<section path>#<title>&section-id={…}&page-id={…}&end` on the
59clipboard (`&object-id={…}&n` in place of `&end` for Copy Link to Paragraph),
60which pastes as a labelled link. `crates/canvas/src/editor/link.rs` replays
61the session through the editor and compares every paragraph's link runs.
62
63## Links and equations from the editor
64
65`editor/candidate` is the canvas editor's output for
66`crates/canvas/tests/links_equations.rs` on the internal-link section above:
67URLs typed and ended by a space or Enter, the Link dialog on a selection, on the
68word at the caret and on nothing, links to the other page and to a paragraph,
69Remove Link, and equations typed after Alt+= (one switched to Linear, one after
70text in its paragraph), all saved as the ops the editor recorded.
71`editor/cold` is its cold OneNote 2010 read: `tools/test_link_edit.py`
72compares it with the candidate and checks the links and MathML in
73`editor/expected.json`. OneNote's read opens an address without a scheme over
74http and links URL text again when it opens a page; its own section does the
75same after Remove Link (`native-typed/cold`, its cold read, shows `Gone
76ftp://h.example/f` linked again). Regenerate with
77`CANVAS_LINKS_EQUATIONS_EXPORT` set to a new directory, move `expected.json`
78beside the candidate, and cold-open with `--expected-pages 2 --screenshots`.
corpus/link-edit/editor/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/link-edit/editor/candidate/Open Notebook.onetoc2 differ
corpus/link-edit/editor/candidate/links.one created
Binary files /dev/null and b/corpus/link-edit/editor/candidate/links.one differ
corpus/link-edit/editor/cold/commands.jsonl created+3
......@@ -0,0 +1,3 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-8C62D976 -ExpectedPages 2 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/link-edit/editor/cold/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-8c62d976", "hostname": "ONE-M6-8C62D976"}
corpus/link-edit/editor/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/link-edit/editor/cold/notebook/Open Notebook.onetoc2 differ
corpus/link-edit/editor/cold/notebook/links.one created
Binary files /dev/null and b/corpus/link-edit/editor/cold/notebook/links.one differ
corpus/link-edit/editor/cold/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-8C62D976",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/link-edit/editor/cold/read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{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>
corpus/link-edit/editor/cold/read/page-000.navigation.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Linking page Link target - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/link-edit/editor/cold/read/page-000.png created
Binary files /dev/null and b/corpus/link-edit/editor/cold/read/page-000.png differ
corpus/link-edit/editor/cold/read/page-000.xml created+16
......@@ -0,0 +1,16 @@
1<?xml version="1.0"?>
2<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
3href="onenote:#Link%20target&amp;section-id={912D18F5-8E97-4C22-B11D-AA61E6E7D4B1}&amp;page-id={54104D3B-F876-47C6-8009-D90D3DEC5217}&amp;end&amp;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
4href="http://www.example.com">www.example.com</a> or (<a
5href="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
6href="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
7href="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
8href="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
9href="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
10href="onenote:#Link%20target&amp;section-id={912D18F5-8E97-4C22-B11D-AA61E6E7D4B1}&amp;page-id={54104D3B-F876-47C6-8009-D90D3DEC5217}&amp;end&amp;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
11href="onenote:#Linking%20page&amp;section-id={912D18F5-8E97-4C22-B11D-AA61E6E7D4B1}&amp;page-id={3225B89C-1ACE-4B05-A5C8-FF6E178A21D7}&amp;object-id={97735122-3000-4D26-9B1F-D5B4D1A325D0}&amp;24&amp;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
12href="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>&nbsp;</mml:mo><mml:mi>d</mml:mi><mml:mi>x</mml:mi><mml:mo>&nbsp;</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>&nbsp;</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>&nbsp;</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
13style='font-family:Calibri' lang=en-US>Area </span><!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mi>&#120587;</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
14style='font-family:Calibri' lang=en-US><br>
15</span><!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mn>1</mml:mn><mml:mo>&nbsp;</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
16href="https://example.com/moved">down</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/link-edit/editor/cold/read/page-001.navigation.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Link target - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/link-edit/editor/cold/read/page-001.png created
Binary files /dev/null and b/corpus/link-edit/editor/cold/read/page-001.png differ
corpus/link-edit/editor/cold/read/page-001.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{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>
corpus/link-edit/editor/cold/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/link-edit/editor/cold/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/cands/le3nb",
3 "expected_pages": 2,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/link-edit/editor/cold/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/link-edit/editor/cold/scripts/read.ps1 created+142
......@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/link-edit/editor/cold/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "7cf4bd0ba068eaccb8e2d3306472ac133a08966eb9dc42cc2b4f7c3b0f44b03f",
6 "mtime_ns": 1790581219409357669
7 },
8 {
9 "path": "links.one",
10 "bytes": 24856,
11 "sha256": "0111a366e9e1b11029856360d54a7fb6665d47184a6622321e5639b945051811",
12 "mtime_ns": 1790581219407781429
13 }
14]
corpus/link-edit/editor/cold/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/link-edit/editor/expected.json created+28
......@@ -0,0 +1,28 @@
1{
2 "hrefs": [
3 "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",
4 "http://www.example.com",
5 "https://example.org/a?b=1",
6 "http://example.net/end",
7 "https://doc.rust-lang.org/book/",
8 "http://example.net/x",
9 "https://example.com/only",
10 "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",
11 "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",
12 "ftp://h.example/f",
13 "https://example.com/moved"
14 ],
15 "mathml": [
16 "<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>",
17 "<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>&nbsp;</mml:mo><mml:mi>d</mml:mi><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow>",
18 "<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>",
19 "<mml:msqrt><mml:mi>x</mml:mi></mml:msqrt><mml:mo>+</mml:mo><mml:mn>1</mml:mn><mml:mo>&nbsp;</mml:mo>",
20 "<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>&nbsp;</mml:mo></mml:mrow>",
21 "<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>",
22 "<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>",
23 "<mml:mi>𝜋</mml:mi><mml:msup><mml:mi>r</mml:mi><mml:mn>2</mml:mn></mml:msup>",
24 "<mml:msup><mml:mi>y</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo>",
25 "<mml:mn>1</mml:mn><mml:mo>&nbsp;</mml:mo>",
26 "<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>"
27 ]
28}
\ No newline at end of file
corpus/link-edit/native-typed/before-read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-AFF74949",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/link-edit/native-typed/before-read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{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 &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:31:15.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/link-edit/native-typed/before-read/page-000.xml created+3
......@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<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 &quot;https://example.invalid/rust&quot;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
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/link-edit/native-typed/before-read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/link-edit/native-typed/cold/commands.jsonl created+3
......@@ -0,0 +1,3 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-367C90E8 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
3{"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}
corpus/link-edit/native-typed/cold/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-367c90e8", "hostname": "ONE-M6-367C90E8"}
corpus/link-edit/native-typed/cold/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-367C90E8",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/link-edit/native-typed/cold/read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{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>
corpus/link-edit/native-typed/cold/read/page-000.navigation.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Read about Rust the Rust site - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/link-edit/native-typed/cold/read/page-000.png created
Binary files /dev/null and b/corpus/link-edit/native-typed/cold/read/page-000.png differ
corpus/link-edit/native-typed/cold/read/page-000.xml created+21
......@@ -0,0 +1,21 @@
1<?xml version="1.0"?>
2<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
3href="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
4href="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
5href="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
6href="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
7href="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
8href="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
9href="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
10href="onenote:#Page&amp;section-id={x}">onenote:#Page&amp;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
11href="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
12href="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
13href="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
14href="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
15href="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
16href="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
17href="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
18href="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
19href="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
20href="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
21href="ftp://h.example/f">ftp://h.example/f</a> z]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/link-edit/native-typed/cold/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/link-edit/native-typed/cold/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/cands/native-typed-copy",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": false,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/link-edit/native-typed/cold/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/link-edit/native-typed/cold/scripts/read.ps1 created+142
......@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/link-edit/native-typed/cold/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 3764,
5 "sha256": "71fdfa91de2db76fdb2ecd3c05a4c5688f9c97c10bfb077bc0945d5e01c8efa2",
6 "mtime_ns": 1790573993933440881
7 },
8 {
9 "path": "links.one",
10 "bytes": 18320,
11 "sha256": "d716882cf7be8938b6506293d1b1542b8f458f8cd7c4ee74b1545848bc8c28c4",
12 "mtime_ns": 1790573993934334422
13 }
14]
corpus/link-edit/native-typed/cold/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/link-edit/native-typed/commands.jsonl created+6
......@@ -0,0 +1,6 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-AFF74949 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
4{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
5{"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}
6{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/link-edit/native-typed/links.ahk created+133
......@@ -0,0 +1,133 @@
1ComObject("OneNote.Application").NavigateTo("{CB1348B4-FB69-0D6C-1F11-786A2D10153D}{1}{B0}", "", false)
2WinWait("ahk_exe ONENOTE.EXE", , 30)
3WinActivate("ahk_exe ONENOTE.EXE")
4WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
5WinMaximize("ahk_exe ONENOTE.EXE")
6Sleep(3000)
7CoordMode("Mouse", "Screen")
8Click(200, 140)
9Sleep(500)
10Send("{End}")
11Sleep(300)
12Send("{Enter}")
13Sleep(500)
14SendText("t www.example.com")
15Send(" ")
16SendText("z")
17Send("{Enter}")
18Sleep(300)
19SendText("t http://example.org/a?b=1")
20Send(" ")
21SendText("z")
22Send("{Enter}")
23Sleep(300)
24SendText("t https://a.example/p.")
25Send(" ")
26SendText("z")
27Send("{Enter}")
28Sleep(300)
29SendText("t (http://b.example/q)")
30Send(" ")
31SendText("z")
32Send("{Enter}")
33Sleep(300)
34SendText("t ftp://c.example/f")
35Send(" ")
36SendText("z")
37Send("{Enter}")
38Sleep(300)
39SendText("t mailto:me@example.com")
40Send(" ")
41SendText("z")
42Send("{Enter}")
43Sleep(300)
44SendText("t me@example.com")
45Send(" ")
46SendText("z")
47Send("{Enter}")
48Sleep(300)
49SendText("t example.com")
50Send(" ")
51SendText("z")
52Send("{Enter}")
53Sleep(300)
54SendText("t onenote:#Page&section-id={x}")
55Send(" ")
56SendText("z")
57Send("{Enter}")
58Sleep(300)
59SendText("t file://c:/windows")
60Send(" ")
61SendText("z")
62Send("{Enter}")
63Sleep(300)
64SendText("t \\server\share\f")
65Send(" ")
66SendText("z")
67Send("{Enter}")
68Sleep(300)
69SendText("t news:comp.lang")
70Send(" ")
71SendText("z")
72Send("{Enter}")
73Sleep(300)
74SendText("t www.d.example,")
75Send(" ")
76SendText("z")
77Send("{Enter}")
78Sleep(300)
79SendText("t HTTP://E.EXAMPLE/UP")
80Send(" ")
81SendText("z")
82Send("{Enter}")
83Sleep(300)
84SendText("t https://f.example/a_b-c~d%20e#frag")
85Send(" ")
86SendText("z")
87Send("{Enter}")
88Sleep(300)
89SendText("see http://g.example/end")
90Send("{Enter}")
91Send("{Up}{End}")
92SendText("Z")
93Send("{Down}{End}")
94Sleep(300)
95SendText("plain words here")
96Send("^{Left}")
97Send("^k")
98Sleep(1500)
99SendText("example.net/x")
100Send("{Enter}")
101Sleep(800)
102Send("{End}")
103SendText("Q")
104Send("{Enter}")
105Sleep(300)
106SendText("select me please")
107Send("+^{Left}+^{Left}")
108Send("^k")
109Sleep(1500)
110SendText("https://example.com/selected")
111Send("{Enter}")
112Sleep(800)
113Send("{End}{Enter}")
114Sleep(300)
115Send("^k")
116Sleep(1500)
117Send("!e")
118Sleep(300)
119SendText("https://example.com/only")
120Send("{Enter}")
121Sleep(800)
122SendText(" tail")
123Send("{Enter}")
124Sleep(300)
125SendText("gone ftp://h.example/f")
126Send(" ")
127SendText("z")
128Send("{Home}^{Right}^{Right}")
129Send("+{F10}")
130Sleep(900)
131Send("r")
132Sleep(800)
133Sleep(2000)
corpus/link-edit/native-typed/links.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Read about Rust the Rust site - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/link-edit/native-typed/links.png created
Binary files /dev/null and b/corpus/link-edit/native-typed/links.png differ
corpus/link-edit/native-typed/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-aff74949", "hostname": "ONE-M6-AFF74949"}
corpus/link-edit/native-typed/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/link-edit/native-typed/notebook/Open Notebook.onetoc2 differ
corpus/link-edit/native-typed/notebook/links.one created
Binary files /dev/null and b/corpus/link-edit/native-typed/notebook/links.one differ
corpus/link-edit/native-typed/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-AFF74949",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/link-edit/native-typed/read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{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>
corpus/link-edit/native-typed/read/page-000.xml created+21
......@@ -0,0 +1,21 @@
1<?xml version="1.0"?>
2<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
3href="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
4href="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
5href="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
6href="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
7href="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
8href="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
9href="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
10href="onenote:#Page&amp;section-id={x}">onenote:#Page&amp;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
11href="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
12href="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
13href="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
14href="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
15href="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
16href="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
17href="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
18href="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
19href="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
20href="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
21href="ftp://h.example/f">ftp://h.example/f</a> z]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/link-edit/native-typed/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/link-edit/native-typed/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/Users/clo/dev/one/corpus/link-edit/candidate",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/link-edit/native-typed/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/link-edit/native-typed/scripts/read.ps1 created+142
......@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/link-edit/native-typed/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "6731c4214437fe1cb6aa347b0004542b7f7e4260f47773bf9ec07c4e894de47c",
6 "mtime_ns": 1789206821144296891
7 },
8 {
9 "path": "links.one",
10 "bytes": 6128,
11 "sha256": "0cfb356ed374fe919f73fb73fdbe5d66e0baa484d4119f70074d9f869d6db78a",
12 "mtime_ns": 1789206821116200533
13 }
14]
corpus/link-edit/native-typed/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/math-edit/README.md+38
......@@ -53,3 +53,41 @@ corpus/link-edit/candidate OUTPUT` and the written one with
5353`ONESTORE_MATH_EXPORT` set while running the test, then
5454`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook
5555--screenshots`.
56
57`native-editor-4/` is a fourth `tools/native_math.py` session repeating most
58of the forms above and adding `e^(x+1)`, `\alpha+\beta` (which stays linear
59text: one run between objects, Greek in mathematical italic),
60`(a+b)/(c+d)`, `x^2+y^2=z^2`, `lim_(n\to\infty) a_n` (a function-apply
61object, 17 with U+2061, around a limit; not built by the linear reading
62here) and `{a+b}`; `x_i^2` kept the editor's “Type equation here.”
63placeholder (11 with `⬚`), as the editor was still opening.
64
65`native-linear/` is the linear form OneNote 2010 shows and stores after
66Equation Tools, Design, Linear, applied to each equation of a session like the
67fourth one plus `x^2+1` and `(a+b)/(c+d)`. It was authored interactively in a
68lab clone through its desktop agent (clicking each equation, then Linear), as
69the key tips and the equation's context menu would not switch it from a
70script; `notebook/links.one` is the section OneNote saved on closing. Each
71equation becomes one run of math between objects: letters in mathematical
72italic, arguments of more than one factor in parentheses, an n-ary body after
73`▒` inside `〖〗`, `√(n&x)` for a root's degree and `∛` for a cube root,
74`■(1&2@3&4)` and `█(x&=1@y&=2)` for arrays, `𝑥┴2`, `𝑥┬2`, `□𝑥`, `▭𝑥`, `¯𝑥`,
75and an accent after a no-break space.
76
77`crates/onestore/tests/page_math.rs` builds every typed expression with
78`Math::from_linear` and compares it with what the editor stored, run by run,
79writes each tree back as the linear text OneNote showed, and rebuilds every
80native equation from its linear form; `crates/canvas/src/editor/equation.rs`
81types the same keys into the canvas editor, building up at each space.
82
83`native-enter/` is OneNote 2010 pressing Enter around equations and a link,
84driven by `tools/native_enter.py`: Enter at an equation's start moves the
85equation to a paragraph of its own; inside the equation's own row it stores a
86line break (`\r`, a plain run) in the paragraph, the equation's two sides
87becoming two equations (two MathML blocks in the read); inside a fraction's
88denominator it makes that argument an equation array (15, `█`) of the two
89parts; at a link's start the whole link, field code and label, moves down. An
90interactive session showed that Enter inside a link opens it instead.
91`crates/canvas/tests/links_equations.rs` presses the same keys in the editor and
92compares every paragraph's runs, and `page_math.rs` compares each equation's
93MathML with the read.
corpus/math-edit/native-editor-4/before-read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-649AC1D2",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/math-edit/native-editor-4/before-read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{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 &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:48:11.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/math-edit/native-editor-4/before-read/page-000.xml created+3
......@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<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 &quot;https://example.invalid/rust&quot;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
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/math-edit/native-editor-4/before-read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/math-edit/native-editor-4/commands.jsonl created+6
......@@ -0,0 +1,6 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-649AC1D2 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
4{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
5{"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}
6{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/math-edit/native-editor-4/equations.ahk created+248
......@@ -0,0 +1,248 @@
1ComObject("OneNote.Application").NavigateTo("{18CA7B08-F5D4-0B7B-3BFE-7B36E74072E9}{1}{B0}", "", false)
2WinWait("ahk_exe ONENOTE.EXE", , 30)
3WinActivate("ahk_exe ONENOTE.EXE")
4WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
5WinMaximize("ahk_exe ONENOTE.EXE")
6Sleep(3000)
7CoordMode("Mouse", "Screen")
8Click(300, 140)
9Sleep(500)
10Send("{End}")
11Sleep(300)
12Send("{Enter}")
13Sleep(500)
14Send("!=")
15Sleep(600)
16SendText("a_1+b_2")
17Send(" ")
18Sleep(600)
19Send("{End}")
20Sleep(300)
21Send("{Enter}")
22Sleep(600)
23Send("!=")
24Sleep(600)
25SendText("x_i^2")
26Send(" ")
27Sleep(600)
28Send("{End}")
29Sleep(300)
30Send("{Enter}")
31Sleep(600)
32Send("!=")
33Sleep(600)
34SendText("\sum_(i=1)^n i")
35Send(" ")
36Sleep(600)
37Send("{End}")
38Sleep(300)
39Send("{Enter}")
40Sleep(600)
41Send("!=")
42Sleep(600)
43SendText("\int_0^1 x dx")
44Send(" ")
45Sleep(600)
46Send("{End}")
47Sleep(300)
48Send("{Enter}")
49Sleep(600)
50Send("!=")
51Sleep(600)
52SendText("\sqrt x+1")
53Send(" ")
54Sleep(600)
55Send("{End}")
56Sleep(300)
57Send("{Enter}")
58Sleep(600)
59Send("!=")
60Sleep(600)
61SendText("\cbrt(x)")
62Send(" ")
63Sleep(600)
64Send("{End}")
65Sleep(300)
66Send("{Enter}")
67Sleep(600)
68Send("!=")
69Sleep(600)
70SendText("\sqrt(n&x)")
71Send(" ")
72Sleep(600)
73Send("{End}")
74Sleep(300)
75Send("{Enter}")
76Sleep(600)
77Send("!=")
78Sleep(600)
79SendText("a/b")
80Send(" ")
81Sleep(600)
82Send("{End}")
83Sleep(300)
84Send("{Enter}")
85Sleep(600)
86Send("!=")
87Sleep(600)
88SendText("\prod_(k=1)^n k")
89Send(" ")
90Sleep(600)
91Send("{End}")
92Sleep(300)
93Send("{Enter}")
94Sleep(600)
95Send("!=")
96Sleep(600)
97SendText("[a+b]")
98Send(" ")
99Sleep(600)
100Send("{End}")
101Sleep(300)
102Send("{Enter}")
103Sleep(600)
104Send("!=")
105Sleep(600)
106SendText("\overline(x)")
107Send(" ")
108Sleep(600)
109Send("{End}")
110Sleep(300)
111Send("{Enter}")
112Sleep(600)
113Send("!=")
114Sleep(600)
115SendText("x\hat")
116Send(" ")
117Sleep(600)
118Send("{End}")
119Sleep(300)
120Send("{Enter}")
121Sleep(600)
122Send("!=")
123Sleep(600)
124SendText("\matrix(1&2@3&4)")
125Send(" ")
126Sleep(600)
127Send("{End}")
128Sleep(300)
129Send("{Enter}")
130Sleep(600)
131Send("!=")
132Sleep(600)
133SendText("\eqarray(x&=1@y&=2)")
134Send(" ")
135Sleep(600)
136Send("{End}")
137Sleep(300)
138Send("{Enter}")
139Sleep(600)
140Send("!=")
141Sleep(600)
142SendText("x\above 2")
143Send(" ")
144Sleep(600)
145Send("{End}")
146Sleep(300)
147Send("{Enter}")
148Sleep(600)
149Send("!=")
150Sleep(600)
151SendText("x\below 2")
152Send(" ")
153Sleep(600)
154Send("{End}")
155Sleep(300)
156Send("{Enter}")
157Sleep(600)
158Send("!=")
159Sleep(600)
160SendText("\box(x)")
161Send(" ")
162Sleep(600)
163Send("{End}")
164Sleep(300)
165Send("{Enter}")
166Sleep(600)
167Send("!=")
168Sleep(600)
169SendText("\rect(x)")
170Send(" ")
171Sleep(600)
172Send("{End}")
173Sleep(300)
174Send("{Enter}")
175Sleep(600)
176Send("!=")
177Sleep(600)
178SendText("f(x)/(x^2+1)")
179Send(" ")
180Sleep(600)
181Send("{End}")
182Sleep(300)
183Send("{Enter}")
184Sleep(600)
185Send("!=")
186Sleep(600)
187SendText("\sum^n x")
188Send(" ")
189Sleep(600)
190Send("{End}")
191Sleep(300)
192Send("{Enter}")
193Sleep(600)
194Send("!=")
195Sleep(600)
196SendText("e^(x+1)")
197Send(" ")
198Sleep(600)
199Send("{End}")
200Sleep(300)
201Send("{Enter}")
202Sleep(600)
203Send("!=")
204Sleep(600)
205SendText("\alpha+\beta")
206Send(" ")
207Sleep(600)
208Send("{End}")
209Sleep(300)
210Send("{Enter}")
211Sleep(600)
212Send("!=")
213Sleep(600)
214SendText("(a+b)/(c+d)")
215Send(" ")
216Sleep(600)
217Send("{End}")
218Sleep(300)
219Send("{Enter}")
220Sleep(600)
221Send("!=")
222Sleep(600)
223SendText("x^2+y^2=z^2")
224Send(" ")
225Sleep(600)
226Send("{End}")
227Sleep(300)
228Send("{Enter}")
229Sleep(600)
230Send("!=")
231Sleep(600)
232SendText("lim_(n\to\infty) a_n")
233Send(" ")
234Sleep(600)
235Send("{End}")
236Sleep(300)
237Send("{Enter}")
238Sleep(600)
239Send("!=")
240Sleep(600)
241SendText("{a+b}")
242Send(" ")
243Sleep(600)
244Send("{End}")
245Sleep(300)
246Send("{Enter}")
247Sleep(600)
248Sleep(2000)
corpus/math-edit/native-editor-4/equations.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Read about Rust the Rust site - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/math-edit/native-editor-4/equations.png created
Binary files /dev/null and b/corpus/math-edit/native-editor-4/equations.png differ
corpus/math-edit/native-editor-4/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-649ac1d2", "hostname": "ONE-M6-649AC1D2"}
corpus/math-edit/native-editor-4/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/math-edit/native-editor-4/notebook/Open Notebook.onetoc2 differ
corpus/math-edit/native-editor-4/notebook/links.one created
Binary files /dev/null and b/corpus/math-edit/native-editor-4/notebook/links.one differ
corpus/math-edit/native-editor-4/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-649AC1D2",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/math-edit/native-editor-4/read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{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>
corpus/math-edit/native-editor-4/read/page-000.xml created+3
......@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<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
3href="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&nbsp;equation&nbsp;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>&nbsp;</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>&nbsp;</mml:mo><mml:mi>d</mml:mi><mml:mi>x</mml:mi><mml:mo>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&#120572;</mml:mi><mml:mo>+</mml:mo><mml:mi>&#120573;</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>
corpus/math-edit/native-editor-4/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/math-edit/native-editor-4/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/Users/clo/dev/one/corpus/link-edit/candidate",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/math-edit/native-editor-4/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/math-edit/native-editor-4/scripts/read.ps1 created+142
......@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/math-edit/native-editor-4/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "6731c4214437fe1cb6aa347b0004542b7f7e4260f47773bf9ec07c4e894de47c",
6 "mtime_ns": 1789206821144296891
7 },
8 {
9 "path": "links.one",
10 "bytes": 6128,
11 "sha256": "0cfb356ed374fe919f73fb73fdbe5d66e0baa484d4119f70074d9f869d6db78a",
12 "mtime_ns": 1789206821116200533
13 }
14]
corpus/math-edit/native-editor-4/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/math-edit/native-enter/before-read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-37FE88DC",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/math-edit/native-enter/before-read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{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 &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T06:48:36.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/math-edit/native-enter/before-read/page-000.xml created+3
......@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<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 &quot;https://example.invalid/rust&quot;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
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/math-edit/native-enter/before-read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/math-edit/native-enter/commands.jsonl created+6
......@@ -0,0 +1,6 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-37FE88DC -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
4{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
5{"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}
6{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/math-edit/native-enter/enter.ahk created+53
......@@ -0,0 +1,53 @@
1ComObject("OneNote.Application").NavigateTo("{EA7DFC37-34A4-06E9-1246-F07F3A879D6C}{1}{B0}", "", false)
2WinWait("ahk_exe ONENOTE.EXE", , 30)
3WinActivate("ahk_exe ONENOTE.EXE")
4WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
5WinMaximize("ahk_exe ONENOTE.EXE")
6Sleep(3000)
7CoordMode("Mouse", "Screen")
8Click(200, 140)
9Sleep(500)
10Send("{End}")
11Sleep(300)
12Send("{Enter}")
13Sleep(500)
14SendText("Before ")
15Send("!=")
16Sleep(600)
17SendText("y^2+1")
18Send(" ")
19Sleep(700)
20Send("{Home}")
21Send("{Right 7}")
22Sleep(300)
23Send("{Enter}")
24Sleep(600)
25Send("{End}")
26Send("{Left 2}")
27Sleep(300)
28Send("{Enter}")
29Sleep(600)
30Send("{End}{Enter}")
31Send("!=")
32Sleep(600)
33SendText("(a+b)/(c+d)")
34Send(" ")
35Sleep(700)
36Send("{Left 3}")
37Sleep(300)
38Send("{Enter}")
39Sleep(600)
40Send("^{End}{Enter}")
41SendText("Select me please")
42Send("+^{Left}+^{Left}")
43Send("^k")
44Sleep(1500)
45SendText("https://example.com/selected")
46Send("{Enter}")
47Sleep(800)
48Send("{End}")
49Send("{Left 9}")
50Sleep(300)
51Send("{Enter}")
52Sleep(600)
53Sleep(2000)
corpus/math-edit/native-enter/enter.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Read about Rust the Rust site - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/math-edit/native-enter/enter.png created
Binary files /dev/null and b/corpus/math-edit/native-enter/enter.png differ
corpus/math-edit/native-enter/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-37fe88dc", "hostname": "ONE-M6-37FE88DC"}
corpus/math-edit/native-enter/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/math-edit/native-enter/notebook/Open Notebook.onetoc2 differ
corpus/math-edit/native-enter/notebook/links.one created
Binary files /dev/null and b/corpus/math-edit/native-enter/notebook/links.one differ
corpus/math-edit/native-enter/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-37FE88DC",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/math-edit/native-enter/read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{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>
corpus/math-edit/native-enter/read/page-000.xml created+6
......@@ -0,0 +1,6 @@
1<?xml version="1.0"?>
2<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
3href="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
4style='font-family:Calibri' lang=en-US><br>
5</span><!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mn>1</mml:mn><mml:mo>&nbsp;</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
6href="https://example.com/selected">me please</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/math-edit/native-enter/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/math-edit/native-enter/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/Users/clo/dev/one/corpus/link-edit/candidate",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/math-edit/native-enter/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/math-edit/native-enter/scripts/read.ps1 created+142
......@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/math-edit/native-enter/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "6731c4214437fe1cb6aa347b0004542b7f7e4260f47773bf9ec07c4e894de47c",
6 "mtime_ns": 1789206821144296891
7 },
8 {
9 "path": "links.one",
10 "bytes": 6128,
11 "sha256": "0cfb356ed374fe919f73fb73fdbe5d66e0baa484d4119f70074d9f869d6db78a",
12 "mtime_ns": 1789206821116200533
13 }
14]
corpus/math-edit/native-enter/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/math-edit/native-linear/notebook/links.one created
Binary files /dev/null and b/corpus/math-edit/native-linear/notebook/links.one differ
corpus/page-versions/README.md created+65
......@@ -0,0 +1,65 @@
1# Page versions
2
3What OneNote 2010 stores for a page's earlier versions (Share, Page Versions), what its
4Restore Version, Delete Version, Copy Page To and Delete All Versions write, and that it
5reads and deletes the versions Rust writes. `tools/test_page_versions.py` checks the rows
6without a VM; `crates/onestore/tests/versions.rs` compares Rust's writes with OneNote's.
7
8## Storage
9
10A version is an earlier revision of the page's own object space, kept current under a
11context of its own (MS-ONE 2.1.17, 2.1.18); nothing is copied. The page's *version history*
12is another revision of the same space, current under context
13`{7111497F-1B6B-4209-9491-C98B04CF4C5A},1`: root `jcidVersionHistoryContent` (`0x6003c`)
14whose `ElementChildNodesOfVersionHistory` (`0x24001c20`) lists a `jcidVersionProxy`
15(`0x6003d`) per version, newest first. A proxy holds `VersionContextNodes` (`0x3400347b`,
16the version's context), `LastModifiedTimeStamp` and `AuthorMostRecent` of the version (from
17its revision metadata, root role 4) and a `CreationTimeStamp` of when the version was made.
18The page manifest names the history context in `0x3400347b` from the page's creation, and
19the page's metadata and the section's copy of it carry `HasVersionPages` (`0x88003462`)
20while any version is listed. A version's context is its revision identity XOR
21`9D52FE7C-8FBC-4495-9300-129B5797A9EA`, its proxy's identity the context XOR
22`88440BCA-118C-0E81-32BF-83DB8CB8B6B5`.
23
24- **Making one.** When a different author edits the page, OneNote labels the page's last
25 revision as a version (a `0x5d` RevisionRoleAndContextDeclaration in the space's list)
26 and appends a history revision listing its proxy. One author's edits make none.
27- **Restore Version** (`step-03` to `step-04`): the page's next revision depends on the
28 version's revision (the page's chain forks there), its revision metadata naming who
29 restored it; the page as it stood is labelled as the newest version and the history lists
30 it first. The version restored stays listed.
31- **Delete Version** (`step-04` to `step-05`): only the history changes, dropping the proxy.
32 The version's label and revisions stay in the file until Optimize drops them. Deleting
33 the last one (`step-07` to `step-08`) also clears `HasVersionPages`.
34- **Delete All Versions in Section** (`step-05` to `step-06`) asks first ("Do you want to
35 delete all page versions in the section "History"?") and writes the same as deleting each.
36- **Copy Page To** (`step-02` to `step-03`) copies the version as a new page without
37 versions; the copy's manifest still names the history context, which no revision
38 carries. Readers must accept that.
39- **Optimize** (`step-10`, two years on) rewrites each kept version and the history as
40 checkpoints; versions are not expired by age here.
41
42## Interface
43
44A version is listed under its page as "M/D/YYYY author" (`shots/06-shown-again.png`) once
45the page's Show Page Versions (page menu, `shots/22-page-menu.png`) or Share, Page
46Versions is chosen. An open version is read-only under a yellow bar (RGB 255, 238, 194):
47"This is an earlier version of the page. It will be deleted over time. Click here to restore
48or delete this version." What the version changed since the one before it is banded green
49(RGB 214, 255, 214). The bar's menu (`shots/02-version-menu.png`): Restore Version, Delete
50Version, Copy Page To…; Delete All Versions in Section, Section Group, Notebook; Disable
51History for This Notebook; Hide Page Versions. Share also has Hide Authors, Recent Edits and
52Find by Author (`shots/14`–`16`).
53
54## Rows
55
56- `native`: one OneNote client driven by hand, authors switched through the Office user
57 name. `step-NN/notebook` is the section after each step above, `shots/` the screens.
58- `native-scripted`: the same steps, one to nine, by `tools/native_versions.py observe`.
59- `candidate-restore`, `candidate-delete-all`: `restoring_a_version_stores_what_onenote_stores`
60 (Restore Version of `step-03`'s version) and `deleting_versions_stores_what_onenote_stores`
61 (Delete Version of `step-05`'s last one), `ONESTORE_VERSIONS_EXPORT`.
62- `cold-restore`: OneNote's fresh read of `candidate-restore` (`native_versions.py cold
63 --delete`): both versions listed, the newest one Rust labelled opened read-only under the
64 bar and deleted through its menu (`notebook` is what OneNote left). `cold-delete-all`: its
65 fresh read of `candidate-delete-all`, the page without versions.
corpus/page-versions/candidate-delete-all/History.one created
Binary files /dev/null and b/corpus/page-versions/candidate-delete-all/History.one differ
corpus/page-versions/candidate-delete-all/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/candidate-delete-all/Open Notebook.onetoc2 differ
corpus/page-versions/candidate-restore/History.one created
Binary files /dev/null and b/corpus/page-versions/candidate-restore/History.one differ
corpus/page-versions/candidate-restore/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/candidate-restore/Open Notebook.onetoc2 differ
corpus/page-versions/cold-delete-all/before-read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-9A6697E1",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/page-versions/cold-delete-all/before-read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{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>
corpus/page-versions/cold-delete-all/before-read/page-000.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{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>
corpus/page-versions/cold-delete-all/before-read/page-001.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{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>
corpus/page-versions/cold-delete-all/before-read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/page-versions/cold-delete-all/commands.jsonl created+6
......@@ -0,0 +1,6 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-9A6697E1 -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
4{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
5{"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}
6{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/page-versions/cold-delete-all/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-9a6697e1", "hostname": "ONE-M6-9A6697E1"}
corpus/page-versions/cold-delete-all/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/cold-delete-all/notebook/History.one differ
corpus/page-versions/cold-delete-all/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/cold-delete-all/notebook/Open Notebook.onetoc2 differ
corpus/page-versions/cold-delete-all/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-9A6697E1",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/page-versions/cold-delete-all/read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{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>
corpus/page-versions/cold-delete-all/read/page-000.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{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>
corpus/page-versions/cold-delete-all/read/page-001.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{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>
corpus/page-versions/cold-delete-all/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/page-versions/cold-delete-all/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/Users/clo/dev/one/corpus/page-versions/candidate-delete-all",
3 "expected_pages": -1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/page-versions/cold-delete-all/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/page-versions/cold-delete-all/scripts/read.ps1 created+142
......@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/page-versions/cold-delete-all/shots/cold-closed.ahk created+4
......@@ -0,0 +1,4 @@
1WinActivate("ahk_exe ONENOTE.EXE")
2CoordMode("Mouse", "Screen")
3Send("{Esc}")
4Sleep(800)
corpus/page-versions/cold-delete-all/shots/cold-closed.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "",
10 "class": "Net UI Tool Window",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/cold-delete-all/shots/cold-closed.png created
Binary files /dev/null and b/corpus/page-versions/cold-delete-all/shots/cold-closed.png differ
corpus/page-versions/cold-delete-all/shots/cold-main.ahk created+14
......@@ -0,0 +1,14 @@
1OnError((exception, mode) => (FileAppend(exception.Message, "**"), ExitApp(1)))
2DetectHiddenWindows false
3app := ComObject("OneNote.Application")
4app.NavigateTo("{9193CD4B-2F8C-055F-3BDB-4EFF291B5152}{1}{B0}", "", false)
5hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 10)
6if !hwnd
7 throw Error("OneNote window did not appear")
8WinMaximize(hwnd)
9WinActivate(hwnd)
10if !WinWaitActive(hwnd,, 10)
11 throw Error("OneNote window did not become active")
12app.NavigateTo("{9193CD4B-2F8C-055F-3BDB-4EFF291B5152}{1}{B0}", "", false)
13Sleep 300
14Sleep(2000)
corpus/page-versions/cold-delete-all/shots/cold-main.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/cold-delete-all/shots/cold-main.png created
Binary files /dev/null and b/corpus/page-versions/cold-delete-all/shots/cold-main.png differ
corpus/page-versions/cold-delete-all/shots/cold-menu.ahk created+4
......@@ -0,0 +1,4 @@
1WinActivate("ahk_exe ONENOTE.EXE")
2CoordMode("Mouse", "Screen")
3Click(320, 93)
4Sleep(1000)
corpus/page-versions/cold-delete-all/shots/cold-menu.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/cold-delete-all/shots/cold-menu.png created
Binary files /dev/null and b/corpus/page-versions/cold-delete-all/shots/cold-menu.png differ
corpus/page-versions/cold-delete-all/shots/cold-shown.ahk created+6
......@@ -0,0 +1,6 @@
1WinActivate("ahk_exe ONENOTE.EXE")
2CoordMode("Mouse", "Screen")
3Click(202, 36)
4Sleep(600)
5Click(443, 80)
6Sleep(1500)
corpus/page-versions/cold-delete-all/shots/cold-shown.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "",
10 "class": "Net UI Tool Window",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/cold-delete-all/shots/cold-shown.png created
Binary files /dev/null and b/corpus/page-versions/cold-delete-all/shots/cold-shown.png differ
corpus/page-versions/cold-delete-all/shots/cold-version.ahk created+4
......@@ -0,0 +1,4 @@
1WinActivate("ahk_exe ONENOTE.EXE")
2CoordMode("Mouse", "Screen")
3Click(710, 143)
4Sleep(1500)
corpus/page-versions/cold-delete-all/shots/cold-version.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "",
10 "class": "Net UI Tool Window",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/cold-delete-all/shots/cold-version.png created
Binary files /dev/null and b/corpus/page-versions/cold-delete-all/shots/cold-version.png differ
corpus/page-versions/cold-delete-all/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "History.one",
4 "bytes": 23048,
5 "sha256": "ae4b6cc0de2cc2384c8bd67f55a2d468ce100698c5c7fc0585d30008d77257cb",
6 "mtime_ns": 1790567288571022815
7 },
8 {
9 "path": "Open Notebook.onetoc2",
10 "bytes": 4808,
11 "sha256": "21f33fee6a5f2946e7d9394036a37cc1bf062856449eed5a0eceaf682dde76fc",
12 "mtime_ns": 1790567288570444733
13 }
14]
corpus/page-versions/cold-delete-all/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/page-versions/cold-restore/before-read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-7319BB2D",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/page-versions/cold-restore/before-read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{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>
corpus/page-versions/cold-restore/before-read/page-000.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{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>
corpus/page-versions/cold-restore/before-read/page-001.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{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>
corpus/page-versions/cold-restore/before-read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/page-versions/cold-restore/commands.jsonl created+6
......@@ -0,0 +1,6 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-7319BB2D -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
4{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
5{"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}
6{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/page-versions/cold-restore/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-7319bb2d", "hostname": "ONE-M6-7319BB2D"}
corpus/page-versions/cold-restore/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/cold-restore/notebook/History.one differ
corpus/page-versions/cold-restore/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/cold-restore/notebook/Open Notebook.onetoc2 differ
corpus/page-versions/cold-restore/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-7319BB2D",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/page-versions/cold-restore/read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{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>
corpus/page-versions/cold-restore/read/page-000.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{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>
corpus/page-versions/cold-restore/read/page-001.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{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>
corpus/page-versions/cold-restore/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/page-versions/cold-restore/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/Users/clo/dev/one/corpus/page-versions/candidate-restore",
3 "expected_pages": -1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/page-versions/cold-restore/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/page-versions/cold-restore/scripts/read.ps1 created+142
......@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/page-versions/cold-restore/shots/cold-delete.ahk created+7
......@@ -0,0 +1,7 @@
1WinActivate("ahk_exe ONENOTE.EXE")
2CoordMode("Mouse", "Screen")
3Click(393, 128)
4Sleep(2500)
5if WinExist("ahk_class #32770 ahk_exe ONENOTE.EXE") {
6 FileAppend("dialog: " WinGetTitle() " | " WinGetText() "`n", "*", "UTF-8")
7}
corpus/page-versions/cold-restore/shots/cold-delete.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/cold-restore/shots/cold-delete.png created
Binary files /dev/null and b/corpus/page-versions/cold-restore/shots/cold-delete.png differ
corpus/page-versions/cold-restore/shots/cold-main.ahk created+14
......@@ -0,0 +1,14 @@
1OnError((exception, mode) => (FileAppend(exception.Message, "**"), ExitApp(1)))
2DetectHiddenWindows false
3app := ComObject("OneNote.Application")
4app.NavigateTo("{B491B045-3B02-0DB9-17A3-E19B54F1CDC1}{1}{B0}", "", false)
5hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 10)
6if !hwnd
7 throw Error("OneNote window did not appear")
8WinMaximize(hwnd)
9WinActivate(hwnd)
10if !WinWaitActive(hwnd,, 10)
11 throw Error("OneNote window did not become active")
12app.NavigateTo("{B491B045-3B02-0DB9-17A3-E19B54F1CDC1}{1}{B0}", "", false)
13Sleep 300
14Sleep(2000)
corpus/page-versions/cold-restore/shots/cold-main.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/cold-restore/shots/cold-main.png created
Binary files /dev/null and b/corpus/page-versions/cold-restore/shots/cold-main.png differ
corpus/page-versions/cold-restore/shots/cold-menu.ahk created+4
......@@ -0,0 +1,4 @@
1WinActivate("ahk_exe ONENOTE.EXE")
2CoordMode("Mouse", "Screen")
3Click(320, 93)
4Sleep(1000)
corpus/page-versions/cold-restore/shots/cold-menu.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "",
10 "class": "Net UI Tool Window",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/cold-restore/shots/cold-menu.png created
Binary files /dev/null and b/corpus/page-versions/cold-restore/shots/cold-menu.png differ
corpus/page-versions/cold-restore/shots/cold-shown.ahk created+6
......@@ -0,0 +1,6 @@
1WinActivate("ahk_exe ONENOTE.EXE")
2CoordMode("Mouse", "Screen")
3Click(202, 36)
4Sleep(600)
5Click(443, 80)
6Sleep(1500)
corpus/page-versions/cold-restore/shots/cold-shown.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "",
10 "class": "Net UI Tool Window",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/cold-restore/shots/cold-shown.png created
Binary files /dev/null and b/corpus/page-versions/cold-restore/shots/cold-shown.png differ
corpus/page-versions/cold-restore/shots/cold-version.ahk created+4
......@@ -0,0 +1,4 @@
1WinActivate("ahk_exe ONENOTE.EXE")
2CoordMode("Mouse", "Screen")
3Click(710, 143)
4Sleep(1500)
corpus/page-versions/cold-restore/shots/cold-version.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/cold-restore/shots/cold-version.png created
Binary files /dev/null and b/corpus/page-versions/cold-restore/shots/cold-version.png differ
corpus/page-versions/cold-restore/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "History.one",
4 "bytes": 21128,
5 "sha256": "51694895069efb861d51e420ff463fbe9ce1207091a27aa7e98de0ebd6e469f7",
6 "mtime_ns": 1790567288549426487
7 },
8 {
9 "path": "Open Notebook.onetoc2",
10 "bytes": 4808,
11 "sha256": "21f33fee6a5f2946e7d9394036a37cc1bf062856449eed5a0eceaf682dde76fc",
12 "mtime_ns": 1790567288548592364
13 }
14]
corpus/page-versions/cold-restore/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/page-versions/native-scripted/commands.jsonl created+12
......@@ -0,0 +1,12 @@
1{"command": "mkdir C:\\one-tests\\versions", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\versions\\setup.ps1", "exit": 0, "stdout": "", "stderr": "", "error": null}
3{"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}
4{"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}
5{"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}
6{"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}
7{"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}
8{"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}
9{"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}
10{"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}
11{"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}
12{"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}
corpus/page-versions/native-scripted/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-5ffe803a", "hostname": "ONE-M6-5FFE803A"}
corpus/page-versions/native-scripted/setup.ps1 created+11
......@@ -0,0 +1,11 @@
1$ErrorActionPreference = 'Stop'
2$app = New-Object -ComObject OneNote.Application
3$notebook = ''
4$app.OpenHierarchy('C:\one-tests\versions\Versions', '', [ref]$notebook, 1)
5$section = ''
6$app.OpenHierarchy('History.one', $notebook, [ref]$section, 3)
7$page = ''
8$app.CreateNewPage($section, [ref]$page, 0)
9$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
10$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>')
11$page | Out-File 'C:\one-tests\versions\page.txt' -Encoding UTF8
corpus/page-versions/native-scripted/shots/01-typed.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/01-typed.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/01-typed.png differ
corpus/page-versions/native-scripted/shots/02-menu.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "",
10 "class": "Net UI Tool Window",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/02-menu.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/02-menu.png differ
corpus/page-versions/native-scripted/shots/02-second-author.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/02-second-author.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/02-second-author.png differ
corpus/page-versions/native-scripted/shots/02-shown.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "",
10 "class": "Net UI Tool Window",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/02-shown.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/02-shown.png differ
corpus/page-versions/native-scripted/shots/02-version-menu.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "",
10 "class": "Net UI Tool Window",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/02-version-menu.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/02-version-menu.png differ
corpus/page-versions/native-scripted/shots/02-version.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/02-version.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/02-version.png differ
corpus/page-versions/native-scripted/shots/03-copied.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/03-copied.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/03-copied.png differ
corpus/page-versions/native-scripted/shots/03-copy-dialog.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Move or Copy Pages",
10 "class": "NUIDialog",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/03-copy-dialog.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/03-copy-dialog.png differ
corpus/page-versions/native-scripted/shots/04-restored.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/04-restored.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/04-restored.png differ
corpus/page-versions/native-scripted/shots/04-shown.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/04-shown.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/04-shown.png differ
corpus/page-versions/native-scripted/shots/05-deleted.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/05-deleted.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/05-deleted.png differ
corpus/page-versions/native-scripted/shots/05-newest.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/05-newest.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/05-newest.png differ
corpus/page-versions/native-scripted/shots/06-confirm.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "dialog: Microsoft OneNote | &Yes\r\n&No\r\nDo you want to delete all page versions in the section \"History\"?\r\n\n",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Microsoft OneNote",
10 "class": "#32770",
11 "dialog": true
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/06-confirm.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/06-confirm.png differ
corpus/page-versions/native-scripted/shots/06-deleted-all.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/06-deleted-all.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/06-deleted-all.png differ
corpus/page-versions/native-scripted/shots/07-third-author-edit.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/07-third-author-edit.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/07-third-author-edit.png differ
corpus/page-versions/native-scripted/shots/08-deleted-only.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/08-deleted-only.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/08-deleted-only.png differ
corpus/page-versions/native-scripted/shots/09-authors.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/09-authors.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/09-authors.png differ
corpus/page-versions/native-scripted/shots/09-find-by-author.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Versioned - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/09-find-by-author.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/09-find-by-author.png differ
corpus/page-versions/native-scripted/shots/09-recent-edits.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "",
10 "class": "Net UI Tool Window",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-versions/native-scripted/shots/09-recent-edits.png created
Binary files /dev/null and b/corpus/page-versions/native-scripted/shots/09-recent-edits.png differ
corpus/page-versions/native-scripted/step-01/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native-scripted/step-01/notebook/History.one differ
corpus/page-versions/native-scripted/step-01/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/native-scripted/step-01/notebook/Open Notebook.onetoc2 differ
corpus/page-versions/native-scripted/step-02/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native-scripted/step-02/notebook/History.one differ
corpus/page-versions/native-scripted/step-03/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native-scripted/step-03/notebook/History.one differ
corpus/page-versions/native-scripted/step-04/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native-scripted/step-04/notebook/History.one differ
corpus/page-versions/native-scripted/step-05/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native-scripted/step-05/notebook/History.one differ
corpus/page-versions/native-scripted/step-06/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native-scripted/step-06/notebook/History.one differ
corpus/page-versions/native-scripted/step-07/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native-scripted/step-07/notebook/History.one differ
corpus/page-versions/native-scripted/step-08/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native-scripted/step-08/notebook/History.one differ
corpus/page-versions/native-scripted/step-09/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native-scripted/step-09/notebook/History.one differ
corpus/page-versions/native/shots/01-version-open.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/01-version-open.png differ
corpus/page-versions/native/shots/02-version-menu.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/02-version-menu.png differ
corpus/page-versions/native/shots/03-closed.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/03-closed.png differ
corpus/page-versions/native/shots/04-copy-dialog.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/04-copy-dialog.png differ
corpus/page-versions/native/shots/05-copied.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/05-copied.png differ
corpus/page-versions/native/shots/06-shown-again.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/06-shown-again.png differ
corpus/page-versions/native/shots/07-restored.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/07-restored.png differ
corpus/page-versions/native/shots/08-other-version.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/08-other-version.png differ
corpus/page-versions/native/shots/09-deleted.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/09-deleted.png differ
corpus/page-versions/native/shots/10-delete-all-dialog.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/10-delete-all-dialog.png differ
corpus/page-versions/native/shots/11-deleted-all.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/11-deleted-all.png differ
corpus/page-versions/native/shots/14-authors-shown.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/14-authors-shown.png differ
corpus/page-versions/native/shots/15-recent-edits.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/15-recent-edits.png differ
corpus/page-versions/native/shots/16-find-by-author.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/16-find-by-author.png differ
corpus/page-versions/native/shots/17-future.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/17-future.png differ
corpus/page-versions/native/shots/18-options.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/18-options.png differ
corpus/page-versions/native/shots/19-save-backup.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/19-save-backup.png differ
corpus/page-versions/native/shots/20-optimized.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/20-optimized.png differ
corpus/page-versions/native/shots/21-after-optimize.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/21-after-optimize.png differ
corpus/page-versions/native/shots/22-page-menu.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/22-page-menu.png differ
corpus/page-versions/native/shots/23-page-menu-shown.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/23-page-menu-shown.png differ
corpus/page-versions/native/shots/24-tooltip.png created
Binary files /dev/null and b/corpus/page-versions/native/shots/24-tooltip.png differ
corpus/page-versions/native/step-01/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native/step-01/notebook/History.one differ
corpus/page-versions/native/step-01/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/native/step-01/notebook/Open Notebook.onetoc2 differ
corpus/page-versions/native/step-02/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native/step-02/notebook/History.one differ
corpus/page-versions/native/step-02/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/native/step-02/notebook/Open Notebook.onetoc2 differ
corpus/page-versions/native/step-03/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native/step-03/notebook/History.one differ
corpus/page-versions/native/step-03/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/native/step-03/notebook/Open Notebook.onetoc2 differ
corpus/page-versions/native/step-04/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native/step-04/notebook/History.one differ
corpus/page-versions/native/step-04/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/native/step-04/notebook/Open Notebook.onetoc2 differ
corpus/page-versions/native/step-05/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native/step-05/notebook/History.one differ
corpus/page-versions/native/step-05/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/native/step-05/notebook/Open Notebook.onetoc2 differ
corpus/page-versions/native/step-06/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native/step-06/notebook/History.one differ
corpus/page-versions/native/step-06/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/native/step-06/notebook/Open Notebook.onetoc2 differ
corpus/page-versions/native/step-07/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native/step-07/notebook/History.one differ
corpus/page-versions/native/step-07/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/native/step-07/notebook/Open Notebook.onetoc2 differ
corpus/page-versions/native/step-08/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native/step-08/notebook/History.one differ
corpus/page-versions/native/step-08/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/native/step-08/notebook/Open Notebook.onetoc2 differ
corpus/page-versions/native/step-09/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native/step-09/notebook/History.one differ
corpus/page-versions/native/step-09/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/native/step-09/notebook/Open Notebook.onetoc2 differ
corpus/page-versions/native/step-10/notebook/History.one created
Binary files /dev/null and b/corpus/page-versions/native/step-10/notebook/History.one differ
corpus/page-versions/native/step-10/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-versions/native/step-10/notebook/Open Notebook.onetoc2 differ
corpus/recycle-bin-repair/README.md created+19
......@@ -0,0 +1,19 @@
1# Recycle bin repair
2
3`candidate` is the notebook
4`a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadable_one` in
5`crates/notebook/tests/structure.rs` exports through `NOTEBOOK_RECYCLE_EXPORT`: a page
6deleted into a new recycle bin whose name the root TOC still listed for a bin gone from the
7folder (the stale entry goes, the new bin is listed), then deleted again into a bin whose
8TOC OneNote might name `OneNote Table Of Contents.onetoc2` and which did not list
9`OneNote_DeletedPages.one` (the bin's TOC is kept, the deleted pages placed under it and
10listed).
11
12`cold` is its fresh OneNote 2010 read: the recycle bin (`isRecycleBin`) holds "Deleted
13Pages" (`isDeletedPages`) with both deleted pages, and OneNote left every file byte for
14byte as written, so it repaired nothing. `tools/test_recycle_bin_repair.py` checks this
15without a VM.
16
17Regenerate with `NOTEBOOK_RECYCLE_EXPORT` set to a new directory while running the test,
18then `tools/native_runner.py OUTPUT COLD --expected-pages -1 --collect-notebook
19--screenshots`.
corpus/recycle-bin-repair/candidate/New Section 1.one created
Binary files /dev/null and b/corpus/recycle-bin-repair/candidate/New Section 1.one differ
corpus/recycle-bin-repair/candidate/OneNote_RecycleBin/OneNote Table Of Contents.onetoc2 created
Binary files /dev/null and b/corpus/recycle-bin-repair/candidate/OneNote_RecycleBin/OneNote Table Of Contents.onetoc2 differ
corpus/recycle-bin-repair/candidate/OneNote_RecycleBin/OneNote_DeletedPages.one created
Binary files /dev/null and b/corpus/recycle-bin-repair/candidate/OneNote_RecycleBin/OneNote_DeletedPages.one differ
corpus/recycle-bin-repair/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recycle-bin-repair/candidate/Open Notebook.onetoc2 differ
corpus/recycle-bin-repair/cold/commands.jsonl created+3
......@@ -0,0 +1,3 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-18ABF18A -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 2 sections and 3 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/recycle-bin-repair/cold/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-18abf18a", "hostname": "ONE-M6-18ABF18A"}
corpus/recycle-bin-repair/cold/notebook/New Section 1.one created
Binary files /dev/null and b/corpus/recycle-bin-repair/cold/notebook/New Section 1.one differ
corpus/recycle-bin-repair/cold/notebook/OneNote_RecycleBin/OneNote Table Of Contents.onetoc2 created
Binary files /dev/null and b/corpus/recycle-bin-repair/cold/notebook/OneNote_RecycleBin/OneNote Table Of Contents.onetoc2 differ
corpus/recycle-bin-repair/cold/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one created
Binary files /dev/null and b/corpus/recycle-bin-repair/cold/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one differ
corpus/recycle-bin-repair/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recycle-bin-repair/cold/notebook/Open Notebook.onetoc2 differ
corpus/recycle-bin-repair/cold/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-18ABF18A",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/recycle-bin-repair/cold/read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{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>
corpus/recycle-bin-repair/cold/read/page-000.navigation.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Kept - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/recycle-bin-repair/cold/read/page-000.png created
Binary files /dev/null and b/corpus/recycle-bin-repair/cold/read/page-000.png differ
corpus/recycle-bin-repair/cold/read/page-000.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{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>
corpus/recycle-bin-repair/cold/read/page-001.navigation.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Kept (Read-Only - Recycle Bin) - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/recycle-bin-repair/cold/read/page-001.png created
Binary files /dev/null and b/corpus/recycle-bin-repair/cold/read/page-001.png differ
corpus/recycle-bin-repair/cold/read/page-001.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{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>
corpus/recycle-bin-repair/cold/read/page-002.navigation.json created+13
......@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Kept (Read-Only - Recycle Bin) - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/recycle-bin-repair/cold/read/page-002.png created
Binary files /dev/null and b/corpus/recycle-bin-repair/cold/read/page-002.png differ
corpus/recycle-bin-repair/cold/read/page-002.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{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>
corpus/recycle-bin-repair/cold/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/recycle-bin-repair/cold/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/recycle-gate/candidate",
3 "expected_pages": -1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/recycle-bin-repair/cold/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/recycle-bin-repair/cold/scripts/read.ps1 created+142
......@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/recycle-bin-repair/cold/source.json created+26
......@@ -0,0 +1,26 @@
1[
2 {
3 "path": "New Section 1.one",
4 "bytes": 6024,
5 "sha256": "b1444e361cef2e5f0d716ae021ad42b051129ec9ac32c36b54f4d4c7ddfc3f84",
6 "mtime_ns": 1790582690747351251
7 },
8 {
9 "path": "OneNote_RecycleBin/OneNote Table Of Contents.onetoc2",
10 "bytes": 3848,
11 "sha256": "b0e0f0928773e1fe41fac3e0377162201083a86e789a193ba0977a03cf043808",
12 "mtime_ns": 1790582691261467141
13 },
14 {
15 "path": "OneNote_RecycleBin/OneNote_DeletedPages.one",
16 "bytes": 8352,
17 "sha256": "05b75f0780567db72cc70dc8277fe8a947d6edc5f6bb51bea76f488669d6cdd0",
18 "mtime_ns": 1790582691300920689
19 },
20 {
21 "path": "Open Notebook.onetoc2",
22 "bytes": 5456,
23 "sha256": "f282959ea3de40d2460fe941ade4b7ab14f22e569530056bf7690f041b0118c6",
24 "mtime_ns": 1790582691234269352
25 }
26]
corpus/recycle-bin-repair/cold/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
crates/notebook/README.md+10-2
......@@ -226,7 +226,10 @@ Reserved `_onefiles` directories are excluded from section-group traversal;
226226OneNote's `OneNote_RecycleBin` is listed as the section group OneNote shows.
227227Encrypted sections and valid storage with an unreadable document graph retain
228228their identity as `Locked` or `Unreadable`, without being presented as empty pages.
229Malformed storage, failed reads and ambiguous identities reject the discovery.
229A child folder or section file that is denied or gone while listing (a folder
230another client holds delete-pending reads as denied) is kept as `unavailable` and
231tried again on the next discovery; the root itself, malformed storage, other failed
232reads and ambiguous identities reject the discovery.
230233
231234Each file read must be a consistent, bounded snapshot. The result is an
232235observation 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
252255sections move there too before its folders go. `recycle_pages` keeps copies of pages a
253256section is about to delete in `OneNote_RecycleBin/OneNote_DeletedPages.one`, with their
254257identities, titles, dates and creation times, as OneNote does
255(`corpus/notebook-management`). Every created, renamed or moved file is placed with
258(`corpus/notebook-management`). A bin or deleted-pages file its TOC does not list is
259placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an
260unavailable bin refuses the delete (`corpus/recycle-bin-repair`). An entry a TOC still
261lists for a file gone from its folder gives way to the file an edit gives its name.
262Edits keep entries in the order their ordering numbers give; a rename or colour keeps
263the numbers. Every created, renamed or moved file is placed with
256264`onestore::place_file`, which sets the header's ancestor to the parent TOC's
257265identity and the name CRC OneNote checks on open; a file without them is
258266re-identified and listed anew. They run over `Storage`: a mounted directory
crates/notebook/examples/migrate_probe.rs+1-2
......@@ -95,8 +95,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
9595 // The remote the queue was made on, as a recovery archive records it.
9696 let archive = copy.with_extension("probe-recovery");
9797 replica.export_recovery(&archive)?;
98 let mut remote =
99 Memory(notebook::Recovery::open(&archive)?.remote_snapshot()?);
98 let mut remote = Memory(notebook::Recovery::open(&archive)?.remote_snapshot()?);
10099 std::fs::remove_file(&archive)?;
101100 let synced = replica.sync_once(&mut remote)?;
102101 let published = {
crates/notebook/examples/queue_scale.rs+12-2
......@@ -36,9 +36,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
3636 let arena = Arena::default();
3737 let mut section = Section::open(&arena, source)?;
3838 let ops = vec![Op::Section(SectionOp::Create(creation))];
39 section.apply("Fixture", &Edit { at: 133_000_000_000_000_000, ops })?;
39 section.apply(
40 "Fixture",
41 &Edit {
42 at: 133_000_000_000_000_000,
43 ops,
44 },
45 )?;
4046 section.seal()?;
41 let pages: Vec<ExGuid> = section.pages()?.into_iter().map(|(space, ..)| space).collect();
47 let pages: Vec<ExGuid> = section
48 .pages()?
49 .into_iter()
50 .map(|(space, ..)| space)
51 .collect();
4252 (section.image(), pages)
4353 };
4454 assert_eq!(pages.len(), 2);
crates/notebook/examples/smb_offline_client.rs+4-5
......@@ -404,10 +404,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
404404 let at = u32::try_from(text.encode_utf16().count())?;
405405 let token = format!(" [{}:{}]", args[2], generated);
406406 let started = now();
407 match cache.apply(
408 "Offline document writer",
409 append(space, object, at, &token),
410 ) {
407 match cache.apply("Offline document writer", append(space, object, at, &token)) {
411408 Ok(id) => {
412409 println!(
413410 "{}",
......@@ -432,7 +429,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
432429 .collect();
433430 let mut at = u32::try_from(text.encode_utf16().count())?;
434431 for token in &missing {
435 ids.push(cache.apply("Offline document writer", append(space, object, at, token))?);
432 ids.push(
433 cache.apply("Offline document writer", append(space, object, at, token))?,
434 );
436435 at += u32::try_from(token.encode_utf16().count())?;
437436 }
438437 if !missing.is_empty() {
crates/notebook/examples/support/view.rs+11-2
......@@ -76,7 +76,14 @@ fn in_page(page: &onestore::page::Page) -> Option<(ExGuid, String)> {
7676 return Some((text.id, text.text.text().to_owned()));
7777 }
7878 if let onestore::page::ParagraphContent::Table(table) = &paragraph.content {
79 pending.extend(table.rows.iter().flat_map(|row| &row.cells).flat_map(|cell| &cell.paragraphs).rev());
79 pending.extend(
80 table
81 .rows
82 .iter()
83 .flat_map(|row| &row.cells)
84 .flat_map(|cell| &cell.paragraphs)
85 .rev(),
86 );
8087 }
8188 }
8289 None
......@@ -84,7 +91,9 @@ fn in_page(page: &onestore::page::Page) -> Option<(ExGuid, String)> {
8491
8592/// The concurrent-edit paragraph as the queued edits leave it: its page, text object and
8693/// text.
87pub fn cached(cache: &notebook::Replica) -> Result<(ExGuid, ExGuid, String), Box<dyn std::error::Error>> {
94pub fn cached(
95 cache: &notebook::Replica,
96) -> Result<(ExGuid, ExGuid, String), Box<dyn std::error::Error>> {
8897 let mut found = Vec::new();
8998 for (space, ..) in cache.pages()? {
9099 if let Some((object, text)) = in_page(&cache.page(space)?) {
crates/notebook/src/discover.rs+49-14
......@@ -41,6 +41,16 @@ pub struct Folder {
4141 pub toc: Option<Toc>,
4242 pub sections: Vec<Section>,
4343 pub groups: Vec<Folder>,
44 /// Child section files and groups that could not be read.
45 pub unavailable: Vec<Unavailable>,
46}
47
48/// A section file or group folder denied or gone while listing; each discovery tries it again.
49#[derive(Debug, Serialize)]
50pub struct Unavailable {
51 pub path: String,
52 pub group: bool,
53 pub error: String,
4454}
4555
4656#[derive(Debug, Serialize)]
......@@ -196,6 +206,7 @@ fn scan(
196206 toc: None,
197207 sections: Vec::new(),
198208 groups: Vec::new(),
209 unavailable: Vec::new(),
199210 };
200211 for entry in &listing {
201212 let child = join(path, &entry.name);
......@@ -203,14 +214,18 @@ fn scan(
203214 if entry.name.to_ascii_lowercase().ends_with("_onefiles") {
204215 continue;
205216 }
206 result.groups.push(scan(
207 source,
208 &child,
209 limits,
210 depth + 1,
211 remaining,
212 identities,
213 )?);
217 // A group whose own listing or TOC cannot be read is unavailable, not fatal.
218 match scan(source, &child, limits, depth + 1, remaining, identities) {
219 Ok(group) => result.groups.push(group),
220 Err(error @ Error::Io { .. }) if unavailable(&error) => {
221 result.unavailable.push(Unavailable {
222 path: child,
223 group: true,
224 error: error.to_string(),
225 });
226 }
227 Err(error) => return Err(error),
228 }
214229 continue;
215230 }
216231 let lower = entry.name.to_ascii_lowercase();
......@@ -226,12 +241,24 @@ fn scan(
226241 {
227242 return Err(Error::Entry { path: child });
228243 }
229 let bytes = source
230 .read(&child, limits.bytes_per_file)
231 .map_err(|error| Error::Io {
232 path: child.clone(),
233 error,
234 })?;
244 let bytes = match source.read(&child, limits.bytes_per_file) {
245 Ok(bytes) => bytes,
246 Err(error) => {
247 let error = Error::Io {
248 path: child.clone(),
249 error,
250 };
251 if expected == FileType::Section && unavailable(&error) {
252 result.unavailable.push(Unavailable {
253 path: child,
254 group: false,
255 error: error.to_string(),
256 });
257 continue;
258 }
259 return Err(error);
260 }
261 };
235262 if bytes.len() > limits.bytes_per_file {
236263 return Err(Error::Limit { path: child });
237264 }
......@@ -404,6 +431,14 @@ fn scan(
404431 Ok(result)
405432}
406433
434/// Access denied or a file gone mid-listing; a lost connection stays fatal.
435fn unavailable(error: &Error) -> bool {
436 matches!(error, Error::Io { error, .. } if matches!(
437 error.kind(),
438 io::ErrorKind::PermissionDenied | io::ErrorKind::NotFound
439 ))
440}
441
407442fn component(name: &str) -> bool {
408443 !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
409444}
crates/notebook/src/lib.rs+14
......@@ -243,6 +243,20 @@ impl Replica {
243243 self.ask(|reply| working::Request::Conflicts { reply })
244244 }
245245
246 /// The versions of each page that has them (`onestore::Section::versions`).
247 pub fn versions(&self) -> Result<Vec<(ExGuid, Vec<onestore::PageVersion>)>> {
248 self.ask(|reply| working::Request::Versions { reply })
249 }
250
251 /// A page as one of its versions holds it; O(section).
252 pub fn version(&self, space: ExGuid, version: ExGuid) -> Result<Page> {
253 self.ask(|reply| working::Request::Version {
254 space,
255 version,
256 reply,
257 })
258 }
259
246260 /// The section image the queued edits leave, the unsealed ones sealed as one more
247261 /// revision whose identities differ per call: O(section).
248262 pub(crate) fn snapshot(&self) -> Result<Vec<u8>> {
crates/notebook/src/merge.rs+54-12
......@@ -156,7 +156,9 @@ fn named(op: &PageOp) -> Vec<ExGuid> {
156156 PageOp::Color(_) => Vec::new(),
157157 PageOp::Join { left, right } => vec![*left, *right],
158158 PageOp::Insert { container, .. } => vec![*container],
159 PageOp::Move { object, .. } | PageOp::Delete { object } | PageOp::Outline { object, .. } => {
159 PageOp::Move { object, .. }
160 | PageOp::Delete { object }
161 | PageOp::Outline { object, .. } => {
160162 vec![*object]
161163 }
162164 PageOp::Level { paragraph, .. }
......@@ -165,7 +167,9 @@ fn named(op: &PageOp) -> Vec<ExGuid> {
165167 | PageOp::List { paragraph, .. } => vec![*paragraph],
166168 PageOp::Tags { target, .. } => vec![*target],
167169 PageOp::Add { object, .. } => vec![object.id()],
168 PageOp::Picture { picture: object, .. }
170 PageOp::Picture {
171 picture: object, ..
172 }
169173 | PageOp::Attachment {
170174 attachment: object, ..
171175 }
......@@ -196,7 +200,8 @@ pub(crate) fn conflict_page(
196200 at: u64,
197201) -> Result<Edit> {
198202 let mut page = page.clone();
199 page.objects.retain(|object| !matches!(object, PageObject::Unsupported(_)));
203 page.objects
204 .retain(|object| !matches!(object, PageObject::Unsupported(_)));
200205 let listed = order(new)?;
201206 let ops = if listed.iter().any(|(listed, _)| *listed == space) {
202207 let index = Index::of(&page);
......@@ -224,9 +229,7 @@ pub(crate) fn conflict_page(
224229 .map_or(&[][..], |at| &queued[at + 1..])
225230 .iter()
226231 .map(|(next, _)| *next)
227 .find(|next| {
228 !moved.contains(next) && listed.iter().any(|(listed, _)| listed == next)
229 });
232 .find(|next| !moved.contains(next) && listed.iter().any(|(listed, _)| listed == next));
230233 let creation = PageCreation::new(before, Some(&page.title), author)?;
231234 let mut ops = vec![Op::Section(SectionOp::Import {
232235 creation: creation.clone(),
......@@ -260,7 +263,8 @@ fn moved(old: &mut Section<'_>, new: &mut Section<'_>) -> Result<BTreeSet<ExGuid
260263 .iter()
261264 .filter_map(|&(space, level)| {
262265 let (at, before) = base.get(&space)?;
263 (*before == level && series.0.get(&space) == series.1.get(&space)).then_some((space, *at))
266 (*before == level && series.0.get(&space) == series.1.get(&space))
267 .then_some((space, *at))
264268 })
265269 .collect();
266270 // A longest subsequence of `kept` in base order (patience sorting).
......@@ -313,7 +317,10 @@ fn advance(local: &mut Order, op: &SectionOp) {
313317 }
314318 }
315319 SectionOp::Delete(spaces) => local.retain(|(space, _)| !spaces.contains(space)),
316 SectionOp::Conflict { .. } | SectionOp::Color(_) => {}
320 SectionOp::Conflict { .. }
321 | SectionOp::Color(_)
322 | SectionOp::RestoreVersion { .. }
323 | SectionOp::DeleteVersions { .. } => {}
317324 }
318325}
319326
......@@ -411,10 +418,18 @@ impl Replay {
411418 /// from `before` on, in the queue's order, that the remote lists where the base had it,
412419 /// or last, as OneNote 2010 places a page series only one side changed.
413420 fn anchor(&self, listed: &Order, page: ExGuid, before: Option<ExGuid>) -> Option<ExGuid> {
414 let from = self.local.iter().position(|(space, _)| Some(*space) == before)?;
415 self.local[from..].iter().map(|(space, _)| *space).find(|space| {
416 *space != page && !self.moved.contains(space) && listed.iter().any(|(listed, _)| listed == space)
417 })
421 let from = self
422 .local
423 .iter()
424 .position(|(space, _)| Some(*space) == before)?;
425 self.local[from..]
426 .iter()
427 .map(|(space, _)| *space)
428 .find(|space| {
429 *space != page
430 && !self.moved.contains(space)
431 && listed.iter().any(|(listed, _)| listed == space)
432 })
418433 }
419434
420435 /// A section op as it replays on the remote section, or `None`: a page placed by
......@@ -467,6 +482,23 @@ impl Replay {
467482 }
468483 (!kept.is_empty()).then_some(SectionOp::Pages(kept))
469484 }
485 // A version the remote deleted is gone; restoring one keeps the remote's page as
486 // the newest version.
487 SectionOp::RestoreVersion { page, version, .. } => (self.listed(*page)
488 && listed_versions(new, *page)?.contains(version))
489 .then(|| op.clone()),
490 SectionOp::DeleteVersions { page, versions } => {
491 let listed = listed_versions(new, *page)?;
492 let versions: Vec<ExGuid> = versions
493 .iter()
494 .filter(|version| listed.contains(version))
495 .copied()
496 .collect();
497 (!versions.is_empty()).then_some(SectionOp::DeleteVersions {
498 page: *page,
499 versions,
500 })
501 }
470502 SectionOp::Delete(deleted) => {
471503 let deleted: Vec<ExGuid> = deleted
472504 .iter()
......@@ -482,6 +514,16 @@ impl Replay {
482514 }
483515}
484516
517/// The versions `section` lists for `page`.
518fn listed_versions(section: &mut Section<'_>, page: ExGuid) -> Result<Vec<ExGuid>> {
519 Ok(section
520 .versions()?
521 .into_iter()
522 .filter(|(listed, _)| *listed == page)
523 .flat_map(|(_, versions)| versions.into_iter().map(|version| version.context))
524 .collect())
525}
526
485527/// One page as the base stored it and as the remote does, with the remote's text changes
486528/// in the coordinates of the replayed edits.
487529struct Diff {
crates/notebook/src/migrate.rs+11-4
......@@ -116,9 +116,12 @@ fn conflict(space: ExGuid, remote: &Page, local: &Page, author: &str) -> Result<
116116 for object in &page.objects {
117117 match object {
118118 onestore::page::PageObject::Outline(outline) => pending.extend(&outline.paragraphs),
119 onestore::page::PageObject::Title(title) => {
120 pending.extend(title.outlines.iter().flat_map(|outline| &outline.paragraphs))
121 }
119 onestore::page::PageObject::Title(title) => pending.extend(
120 title
121 .outlines
122 .iter()
123 .flat_map(|outline| &outline.paragraphs),
124 ),
122125 _ => {}
123126 }
124127 }
......@@ -128,7 +131,11 @@ fn conflict(space: ExGuid, remote: &Page, local: &Page, author: &str) -> Result<
128131 texts.insert(text.id, format!("{:?}", text.text));
129132 }
130133 onestore::page::ParagraphContent::Table(table) => pending.extend(
131 table.rows.iter().flat_map(|row| &row.cells).flat_map(|cell| &cell.paragraphs),
134 table
135 .rows
136 .iter()
137 .flat_map(|row| &row.cells)
138 .flat_map(|cell| &cell.paragraphs),
132139 ),
133140 _ => {}
134141 }
crates/notebook/src/queue.rs+2
......@@ -240,6 +240,8 @@ pub(crate) fn spaces(edit: &Edit, root: onestore::ExGuid) -> Vec<onestore::ExGui
240240 }
241241 SectionOp::Pages(edits) => spaces.extend(edits.iter().map(|edit| edit.space())),
242242 SectionOp::Delete(pages) => spaces.extend(pages),
243 SectionOp::RestoreVersion { page, .. }
244 | SectionOp::DeleteVersions { page, .. } => spaces.push(*page),
243245 SectionOp::Color(_) => {}
244246 }
245247 }
crates/notebook/src/session.rs+165-25
......@@ -2,8 +2,7 @@
22//! publishes their edits to the section file in the background.
33
44use crate::{
5 EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result,
6 SyncWorker, discover,
5 EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover,
76};
87use onestore::{
98 CommitError, ExGuid, PageCreation, RevisionIndex, Stamp, Store, Transaction,
......@@ -388,9 +387,37 @@ impl Notebook {
388387 self.commit_toc(&toc, edits)
389388 }
390389
390 /// Commits `edits` to the TOC at `toc`. A name it still lists for a file gone from its
391 /// folder passes to the file an edit gives that name; the stale entry goes.
391392 fn commit_toc(&self, toc: &str, edits: &[onestore::TocEdit]) -> Result<()> {
393 let unresolved = self
394 .folder(split(toc).0)
395 .ok()
396 .and_then(|folder| folder.toc.as_ref())
397 .map_or(&[][..], |toc| &toc.unresolved[..]);
398 let mut superseded = Vec::new();
399 for edit in edits {
400 if let onestore::TocEdit::Add { filename, .. }
401 | onestore::TocEdit::Rename { filename, .. } = edit
402 {
403 superseded.extend(
404 unresolved
405 .iter()
406 .filter(|entry| {
407 entry
408 .filename
409 .as_deref()
410 .is_some_and(|name| name.eq_ignore_ascii_case(filename))
411 })
412 .map(|entry| onestore::TocEdit::Remove {
413 identity: entry.file,
414 }),
415 );
416 }
417 superseded.push(edit.clone());
418 }
392419 let source = self.storage.read(toc)?;
393 match onestore::edit_table_of_contents(&source, edits)? {
420 match onestore::edit_table_of_contents(&source, &superseded)? {
394421 Some(transaction) => self.storage.commit(toc, &transaction),
395422 None => Ok(()),
396423 }
......@@ -399,7 +426,12 @@ impl Notebook {
399426 /// Creates `name.one` in `folder` holding the page `page` creates, in the next of
400427 /// OneNote's section colours, and lists it last in the folder's TOC, as OneNote creates
401428 /// a section. Returns the new catalog path.
402 pub fn create_section(&mut self, folder: &str, name: &str, page: &PageCreation) -> Result<String> {
429 pub fn create_section(
430 &mut self,
431 folder: &str,
432 name: &str,
433 page: &PageCreation,
434 ) -> Result<String> {
403435 let filename = format!("{name}.one");
404436 let color = next_color(self.folder(folder)?);
405437 let (_, ancestor) = self.toc(folder)?;
......@@ -541,13 +573,24 @@ impl Notebook {
541573 let mut folders = Vec::new();
542574 let mut pending = vec![self.folder(path)?];
543575 while let Some(group) = pending.pop() {
576 // Its folder could not be emptied, so nothing moves.
577 if let Some(entry) = group.unavailable.first() {
578 return Err(io::Error::new(
579 io::ErrorKind::PermissionDenied,
580 entry.error.clone(),
581 )
582 .into());
583 }
544584 sections.extend(
545585 group
546586 .sections
547587 .iter()
548588 .map(|section| (section.path.clone(), section.file_id)),
549589 );
550 folders.push((group.path.clone(), group.toc.as_ref().map(|toc| toc.filename.clone())));
590 folders.push((
591 group.path.clone(),
592 group.toc.as_ref().map(|toc| toc.filename.clone()),
593 ));
551594 pending.extend(&group.groups);
552595 }
553596 for (section, identity) in sections {
......@@ -566,28 +609,50 @@ impl Notebook {
566609 }
567610
568611 /// The recycle bin's TOC path and file identity, creating the bin as OneNote does: a
569 /// section group the root TOC lists.
612 /// section group the root TOC lists. A bin the root TOC misses is listed again.
570613 fn bin(&self) -> Result<(String, [u8; 16])> {
571 let bin_toc = catalog_path(RECYCLE_BIN, TOC);
572 if !self.storage.exists(&bin_toc) {
573 if !self.storage.exists(RECYCLE_BIN) {
574 self.storage.create_directory(RECYCLE_BIN)?;
614 if let Some(entry) = self
615 .catalog
616 .unavailable
617 .iter()
618 .find(|entry| entry.path == RECYCLE_BIN)
619 {
620 return Err(
621 io::Error::new(io::ErrorKind::PermissionDenied, entry.error.clone()).into(),
622 );
623 }
624 // A bin OneNote made may name its TOC otherwise; a second TOC would hide the folder.
625 let bin_toc = match self.folder(RECYCLE_BIN) {
626 Ok(discover::Folder { toc: Some(toc), .. }) => catalog_path(RECYCLE_BIN, &toc.filename),
627 _ => {
628 let bin_toc = catalog_path(RECYCLE_BIN, TOC);
629 if !self.storage.exists(&bin_toc) {
630 if !self.storage.exists(RECYCLE_BIN) {
631 self.storage.create_directory(RECYCLE_BIN)?;
632 }
633 let bytes = onestore::create_table_of_contents(TOC, &[])?;
634 self.storage.create(&bin_toc, &bytes)?;
635 }
636 bin_toc
575637 }
576 let bytes = onestore::create_table_of_contents(TOC, &[])?;
577 self.storage.create(&bin_toc, &bytes)?;
578 self.storage.place(&bin_toc, self.toc("")?.1, RECYCLE_BIN)?;
579 let bin_identity = onestore::Store::parse(&bytes)?.header.file_id;
580 self.edit_toc(
581 "",
638 };
639 let identity = onestore::Store::parse(&self.storage.read(&bin_toc)?)?
640 .header
641 .file_id;
642 let (root, root_identity) = self.toc("")?;
643 // OneNote takes a TOC placed under another parent for a new one.
644 if !lists(&self.storage.read(&root)?, identity)? {
645 self.storage.place(&bin_toc, root_identity, RECYCLE_BIN)?;
646 self.commit_toc(
647 &root,
582648 &[onestore::TocEdit::Add {
583649 filename: RECYCLE_BIN.into(),
584 identity: bin_identity,
650 identity,
585651 group: true,
586652 }],
587653 )?;
588654 }
589 let source = self.storage.read(&bin_toc)?;
590 Ok((bin_toc, onestore::Store::parse(&source)?.header.file_id))
655 Ok((bin_toc, identity))
591656 }
592657
593658 /// Moves the section file at `path` into the recycle bin, under a name no binned
......@@ -626,23 +691,35 @@ impl Notebook {
626691 // OneNote's "Deleted Pages" section is grey.
627692 let bytes = onestore::create_empty_section(DELETED, Some(0x00e1e1e1))?;
628693 self.storage.create(&path, &bytes)?;
694 }
695 let mut bytes = self.storage.read(&path)?;
696 // Listed and placed too when an earlier delete made the file but not its entry.
697 let identity = onestore::Store::parse(&bytes)?.header.file_id;
698 if !lists(&self.storage.read(&bin_toc)?, identity)? {
629699 self.storage.place(&path, bin_identity, DELETED)?;
630700 self.commit_toc(
631701 &bin_toc,
632702 &[onestore::TocEdit::Add {
633703 filename: DELETED.into(),
634 identity: onestore::Store::parse(&bytes)?.header.file_id,
704 identity,
635705 group: false,
636706 }],
637707 )?;
708 bytes = self.storage.read(&path)?;
638709 }
639710 let arena = onestore::Arena::default();
640 let mut section = onestore::Section::open(&arena, self.storage.read(&path)?)?;
711 let mut section = onestore::Section::open(&arena, bytes)?;
641712 let mut ops = Vec::new();
642713 for page in pages {
643714 ops.push(moved(page, author)?);
644715 }
645 section.apply(author, &Edit { at: crate::now(), ops })?;
716 section.apply(
717 author,
718 &Edit {
719 at: crate::now(),
720 ops,
721 },
722 )?;
646723 if let Some(transaction) = section.seal()? {
647724 self.storage.commit(&path, &transaction)?;
648725 }
......@@ -866,9 +943,8 @@ pub fn stored_pages(image: &[u8]) -> Result<Vec<StoredPage>> {
866943/// order it gives them (`corpus/notebook-management/native/section-colors`: sixteen sections
867944/// made with New Section in a new notebook).
868945const SECTION_COLORS: [u32; 16] = [
869 0x00e4a88a, 0x0078b0f6, 0x00bba4d5, 0x00d2bb9b, 0x00b79cab, 0x0099d1e8, 0x006ff9f5,
870 0x0092e7ad, 0x00cabc4d, 0x007575ba, 0x00aa9595, 0x00e4a88a, 0x0069d8ff, 0x0097c9b7,
871 0x009795ee, 0x00de9eb4,
946 0x00e4a88a, 0x0078b0f6, 0x00bba4d5, 0x00d2bb9b, 0x00b79cab, 0x0099d1e8, 0x006ff9f5, 0x0092e7ad,
947 0x00cabc4d, 0x007575ba, 0x00aa9595, 0x00e4a88a, 0x0069d8ff, 0x0097c9b7, 0x009795ee, 0x00de9eb4,
872948];
873949
874950/// The colour OneNote gives a new section in `folder`, by how many sections it holds.
......@@ -897,6 +973,28 @@ fn component(name: &str) -> bool {
897973 !name.is_empty() && !name.contains(['/', '\\', '\0']) && name != "." && name != ".."
898974}
899975
976/// Whether the TOC `image` holds has an entry for the file identity `file`.
977fn lists(image: &[u8], file: [u8; 16]) -> Result<bool> {
978 let store = Store::parse(image)?;
979 let index = RevisionIndex::parse(&store)?;
980 let document = Document::parse(&index)?;
981 let revision = document.active(document.root)?;
982 let entries = revision
983 .roots
984 .get(&1)
985 .and_then(|id| revision.nodes.get(id))
986 .map_or(&[][..], |node| match &node.kind {
987 onestore::document::Kind::Toc { entries, .. } => &entries[..],
988 _ => &[],
989 });
990 Ok(entries.iter().any(|id| {
991 matches!(
992 revision.nodes.get(id).map(|node| &node.kind),
993 Some(onestore::document::Kind::Toc { identity: Some(identity), .. }) if *identity == file
994 )
995 }))
996}
997
900998fn split(path: &str) -> (&str, &str) {
901999 match path.rsplit_once('/') {
9021000 Some((folder, name)) => (folder, name),
......@@ -1167,6 +1265,48 @@ impl Section {
11671265 self.replica.conflicts()
11681266 }
11691267
1268 /// The versions of each page that has them, newest first, as the local edits leave them
1269 /// (`onestore::Section::versions`); `version` reads one.
1270 pub fn versions(&self) -> Result<Vec<(ExGuid, Vec<onestore::PageVersion>)>> {
1271 self.replica.versions()
1272 }
1273
1274 /// Page `space` as its version `version` holds it; O(section).
1275 pub fn version(&self, space: ExGuid, version: ExGuid) -> Result<Page> {
1276 self.replica.version(space, version)
1277 }
1278
1279 /// Makes a page's version its current state, the page as it stood becoming the newest
1280 /// version, queued like the user's own edits.
1281 pub fn restore_version(&self, space: ExGuid, version: ExGuid, author: &str) -> Result<u64> {
1282 self.replica.apply(
1283 author,
1284 Edit {
1285 at: crate::now(),
1286 ops: vec![Op::Section(SectionOp::restore(space, version)?)],
1287 },
1288 )
1289 }
1290
1291 /// Deletes versions of pages, queued like the user's own edits.
1292 pub fn delete_versions(&self, versions: &[(ExGuid, Vec<ExGuid>)]) -> Result<u64> {
1293 self.replica.apply(
1294 "",
1295 Edit {
1296 at: crate::now(),
1297 ops: versions
1298 .iter()
1299 .map(|(page, versions)| {
1300 Op::Section(SectionOp::DeleteVersions {
1301 page: *page,
1302 versions: versions.clone(),
1303 })
1304 })
1305 .collect(),
1306 },
1307 )
1308 }
1309
11701310 /// Retires an uncertain attempt after review, exporting the queue to `archive` first:
11711311 /// `Mine` publishes the local edits again, `Theirs` abandons them. Neither claims the
11721312 /// attempt was acknowledged.
crates/notebook/src/smb/mod.rs+2-1
......@@ -206,7 +206,8 @@ impl Client {
206206 0xc0000043 | 0xc0000054 | 0xc0000055 => io::ErrorKind::WouldBlock,
207207 0xc0000011 => io::ErrorKind::UnexpectedEof,
208208 0xc0000034 | 0xc000003a => io::ErrorKind::NotFound,
209 0xc0000022 => io::ErrorKind::PermissionDenied,
209 // Windows reports a delete-pending file as access denied too.
210 0xc0000022 | 0xc0000056 => io::ErrorKind::PermissionDenied,
210211 0xc0000103 => io::ErrorKind::NotADirectory,
211212 _ => io::ErrorKind::Other,
212213 };
crates/notebook/src/smb/tests.rs+12-2
......@@ -609,13 +609,23 @@ fn live_structure() {
609609 let mut notebook =
610610 Notebook::open_smb(std::sync::Arc::clone(&client), &root, cache.path()).unwrap();
611611 assert_eq!(
612 notebook.create_section("", "Second", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()).unwrap(),
612 notebook
613 .create_section(
614 "",
615 "Second",
616 &onestore::PageCreation::new(None, Some(""), "Author").unwrap()
617 )
618 .unwrap(),
613619 "Second.one"
614620 );
615621 assert_eq!(notebook.create_group("", "Archive").unwrap(), "Archive");
616622 assert_eq!(
617623 notebook
618 .create_section("Archive", "Inner", &onestore::PageCreation::new(None, Some(""), "Author").unwrap())
624 .create_section(
625 "Archive",
626 "Inner",
627 &onestore::PageCreation::new(None, Some(""), "Author").unwrap()
628 )
619629 .unwrap(),
620630 "Archive/Inner.one"
621631 );
crates/notebook/src/sync.rs+11-2
......@@ -341,10 +341,19 @@ impl Replica {
341341 if let Some(sealed) = sealed {
342342 local.replay(sealed)?;
343343 }
344 let remote = onestore::Section::open(&remote, image.to_vec())?;
344 let mut remote = onestore::Section::open(&remote, image.to_vec())?;
345 // A page's versions change without its page changing.
346 let versions = |section: &mut onestore::Section<'_>| {
347 section.versions().map(|versions| {
348 versions
349 .into_iter()
350 .filter(|(page, _)| revisions.contains_key(page))
351 .collect::<Vec<_>>()
352 })
353 };
345354 Ok(revisions.keys().all(
346355 |space| matches!((local.page(*space), remote.page(*space)), (Ok(a), Ok(b)) if a == b),
347 ))
356 ) && versions(&mut local)? == versions(&mut remote)?)
348357 }
349358
350359 /// Asks the section thread to replay the queue on `image`, returning the pages the
crates/notebook/src/working.rs+41-6
......@@ -3,9 +3,7 @@
33//! here and are written in one SQLite transaction per burst; the sync thread asks it to seal
44//! and, when the remote changed, to rebase the queue.
55
6use crate::{
7 Result, base, lock, merge, queue, worker::Signal,
8};
6use crate::{Result, base, lock, merge, queue, worker::Signal};
97use onestore::{
108 Arena, ExGuid, Section, Transaction,
119 op::{Edit, OpError},
......@@ -37,6 +35,14 @@ pub(crate) enum Request {
3735 Conflicts {
3836 reply: Reply<Vec<(ExGuid, Vec<onestore::ConflictPage>)>>,
3937 },
38 Versions {
39 reply: Reply<Vec<(ExGuid, Vec<onestore::PageVersion>)>>,
40 },
41 Version {
42 space: ExGuid,
43 version: ExGuid,
44 reply: Reply<Page>,
45 },
4046 /// Answers once the edits before it are written.
4147 Flush {
4248 reply: Reply<()>,
......@@ -79,6 +85,8 @@ impl Request {
7985 Self::Page { reply, .. } => reply(Err(error())),
8086 Self::Pages { reply } => reply(Err(error())),
8187 Self::Conflicts { reply } => reply(Err(error())),
88 Self::Versions { reply } => reply(Err(error())),
89 Self::Version { reply, .. } => reply(Err(error())),
8290 Self::Flush { reply } => reply(Err(error())),
8391 Self::Seal { reply } => reply(Err(error())),
8492 Self::Rebase { reply, .. } => reply(Err(error())),
......@@ -321,6 +329,14 @@ impl<'a> Working<'a> {
321329 Request::Conflicts { reply } => {
322330 reply(self.section.conflicts().map_err(Into::into))
323331 }
332 Request::Versions { reply } => {
333 reply(self.section.versions().map_err(Into::into))
334 }
335 Request::Version {
336 space,
337 version,
338 reply,
339 } => reply(self.section.version(space, version).map_err(Into::into)),
324340 Request::Handover(to) => {
325341 let mut waiting = held.take().unwrap_or_default();
326342 waiting.extend(burst.drain(..).chain(later.drain(..)));
......@@ -355,6 +371,18 @@ impl<'a> Working<'a> {
355371 reply(self.section.conflicts().map_err(Into::into));
356372 false
357373 }
374 Request::Versions { reply } => {
375 reply(self.section.versions().map_err(Into::into));
376 false
377 }
378 Request::Version {
379 space,
380 version,
381 reply,
382 } => {
383 reply(self.section.version(space, version).map_err(Into::into));
384 false
385 }
358386 request @ (Request::Flush { .. } | Request::Seal { .. })
359387 if !burst.is_empty() =>
360388 {
......@@ -390,7 +418,9 @@ impl<'a> Working<'a> {
390418 .then(VecDeque::new);
391419 false
392420 } else {
393 reply(Err(io::Error::other("The queue could not be written").into()));
421 reply(Err(
422 io::Error::other("The queue could not be written").into()
423 ));
394424 true
395425 }
396426 }
......@@ -546,7 +576,7 @@ impl<'a> Working<'a> {
546576 let root = self.section.root();
547577 let revisions: BTreeMap<ExGuid, ExGuid> = self
548578 .section
549 .revisions()
579 .newest()
550580 .filter(|(space, _)| {
551581 self.touched.contains(space) || (self.touched.is_empty() && *space == root)
552582 })
......@@ -677,7 +707,12 @@ fn rebase(connection: &Mutex<Connection>, image: Option<Vec<u8>>) -> Result<Vec<
677707 .iter()
678708 .filter(|(space, rid)| after.get(space) != Some(rid))
679709 .map(|(space, _)| *space)
680 .chain(after.keys().filter(|space| !before.contains_key(space)).copied())
710 .chain(
711 after
712 .keys()
713 .filter(|space| !before.contains_key(space))
714 .copied(),
715 )
681716 .collect();
682717 changed.extend(converged);
683718 Ok(changed.into_iter().collect())
crates/notebook/tests/assets.rs+1-4
......@@ -118,10 +118,7 @@ fn downloaded_media_survives_reopen_and_recovery_with_the_queue_intact() {
118118 (summary.cached_assets, summary.cached_asset_bytes),
119119 (2, 1024)
120120 );
121 assert_eq!(
122 server::pages(&snapshot(&cache)),
123 server::pages(&working)
124 );
121 assert_eq!(server::pages(&snapshot(&cache)), server::pages(&working));
125122 assert_eq!(remote_snapshot(&cache), remote);
126123 assert_eq!(cache.pending().unwrap(), pending);
127124 assert_eq!(cache.status(1).unwrap(), uncertain);
crates/notebook/tests/cache.rs+1-4
......@@ -110,10 +110,7 @@ fn cache_reopen_preserves_the_base_and_queued_edits() {
110110 .apply(model_ops::AUTHOR, typed(sid, oid, "Recovered "))
111111 .unwrap();
112112 assert!(second > first);
113 assert_eq!(
114 target(&snapshot(&replica)).2,
115 "Recovered café 🐈 日本語"
116 );
113 assert_eq!(target(&snapshot(&replica)).2, "Recovered café 🐈 日本語");
117114 assert_eq!(replica.pending().unwrap().len(), 2);
118115}
119116
crates/notebook/tests/changes.rs+14-2
......@@ -24,9 +24,21 @@ fn external_structure_changes_are_reported_by_identity() {
2424 let mut other = Notebook::open(&root, temporary.path().join("other")).unwrap();
2525 assert_eq!(watcher.refresh().unwrap(), []);
2626
27 other.create_section("", "Second", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()).unwrap();
27 other
28 .create_section(
29 "",
30 "Second",
31 &onestore::PageCreation::new(None, Some(""), "Author").unwrap(),
32 )
33 .unwrap();
2834 other.create_group("", "Group").unwrap();
29 other.create_section("Group", "Inner", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()).unwrap();
35 other
36 .create_section(
37 "Group",
38 "Inner",
39 &onestore::PageCreation::new(None, Some(""), "Author").unwrap(),
40 )
41 .unwrap();
3042 assert_eq!(
3143 watcher.refresh().unwrap(),
3244 [
crates/notebook/tests/discovery.rs+79
......@@ -278,3 +278,82 @@ fn local_access_stays_inside_the_selected_root() {
278278 );
279279 }
280280}
281
282/// Restores permissions a test took away, so the temporary directory can be removed.
283struct Restore(Vec<(std::path::PathBuf, u32)>);
284
285impl Drop for Restore {
286 fn drop(&mut self) {
287 use std::os::unix::fs::PermissionsExt;
288 for (path, mode) in &self.0 {
289 let _ = fs::set_permissions(path, fs::Permissions::from_mode(*mode));
290 }
291 }
292}
293
294#[test]
295fn unreadable_children_are_listed_as_unavailable_and_retried() {
296 use std::os::unix::fs::PermissionsExt;
297 // Permission bits do not bind the superuser.
298 if unsafe { libc::geteuid() } == 0 {
299 return;
300 }
301 let directory = tempfile::tempdir().unwrap();
302 let root = directory.path().join("notebook");
303 fs::create_dir(&root).unwrap();
304 fixture(&root);
305 fs::create_dir(root.join("Art")).unwrap();
306 fs::create_dir(root.join("Group")).unwrap();
307 for name in ["kept.one", "denied.one"] {
308 fs::write(
309 root.join("Group").join(name),
310 onestore::create_section(name, "Text", "Fixture").unwrap(),
311 )
312 .unwrap();
313 }
314 fs::write(
315 root.join("Group/Open Notebook.onetoc2"),
316 onestore::create_table_of_contents("Open Notebook.onetoc2", &[]).unwrap(),
317 )
318 .unwrap();
319 let _restore = Restore(vec![
320 (root.join("Art"), 0o755),
321 (root.join("Group/denied.one"), 0o644),
322 ]);
323 for path in [root.join("Art"), root.join("Group/denied.one")] {
324 fs::set_permissions(path, fs::Permissions::from_mode(0o000)).unwrap();
325 }
326
327 let mut notebook =
328 notebook::session::Notebook::open(&root, directory.path().join("cache")).unwrap();
329 let catalog = notebook.catalog();
330 assert_eq!(catalog.sections.len(), 1);
331 assert_eq!(catalog.groups.len(), 1);
332 assert_eq!(catalog.unavailable.len(), 1);
333 assert_eq!(catalog.unavailable[0].path, "Art");
334 assert!(catalog.unavailable[0].group);
335 assert!(catalog.unavailable[0].error.contains("Permission denied"));
336 let group = &catalog.groups[0];
337 assert_eq!(group.sections.len(), 1);
338 assert_eq!(group.sections[0].path, "Group/kept.one");
339 assert_eq!(group.unavailable[0].path, "Group/denied.one");
340 assert!(!group.unavailable[0].group);
341
342 // An unavailable entry is not the catalog's to change, nor a group holding one.
343 assert!(notebook.delete("Art").is_err());
344 assert!(notebook.delete("Group").is_err());
345 assert!(root.join("Group/kept.one").exists());
346 assert!(notebook.rename("Art", "Other").is_err());
347 assert!(notebook.create_group("", "Art").is_err());
348
349 fs::set_permissions(root.join("Art"), fs::Permissions::from_mode(0o755)).unwrap();
350 notebook.refresh().unwrap();
351 assert!(notebook.catalog().unavailable.is_empty());
352 assert_eq!(notebook.catalog().groups.len(), 2);
353
354 // The notebook's own folder is still the notebook.
355 fs::set_permissions(&root, fs::Permissions::from_mode(0o000)).unwrap();
356 let denied = notebook.refresh();
357 fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
358 assert!(denied.is_err());
359}
crates/notebook/tests/migrate.rs+13-9
......@@ -191,9 +191,7 @@ fn a_schema_14_queue_converts_to_ops_that_reach_its_working_pages_and_publish()
191191 Queued::Page(
192192 a,
193193 appended(
194 ops::saved(SOURCE, a, &first)
195 .unwrap()
196 .as_slice(),
194 ops::saved(SOURCE, a, &first).unwrap().as_slice(),
197195 a,
198196 a_text,
199197 " two",
......@@ -306,10 +304,7 @@ fn an_uncertain_attempt_keeps_its_state_and_a_conflict_becomes_a_conflict_page()
306304 .as_slice()
307305 .to_vec();
308306 let local = appended(SOURCE, a, a_text, " local");
309 let working = ops::saved(SOURCE, a, &local)
310 .unwrap()
311 .as_slice()
312 .to_vec();
307 let working = ops::saved(SOURCE, a, &local).unwrap().as_slice().to_vec();
313308 {
314309 // Schema 14 stored the remote as the base and kept the local working image.
315310 v14(&path, &remote, &[], Some(&working));
......@@ -346,7 +341,10 @@ fn an_uncertain_attempt_keeps_its_state_and_a_conflict_becomes_a_conflict_page()
346341 conflicts(&server.durable),
347342 [(
348343 a,
349 vec![("Author".to_owned(), page_texts(&model_ops::page_of(&working, a)))]
344 vec![(
345 "Author".to_owned(),
346 page_texts(&model_ops::page_of(&working, a))
347 )]
350348 )]
351349 );
352350}
......@@ -447,7 +445,13 @@ fn a_schema_15_cache_upgrades_in_place_and_its_conflict_becomes_a_conflict_page(
447445 assert_eq!(version, 16);
448446 assert_eq!(columns, ["id", "sealed", "revisions", "attempted"]);
449447 drop(connection);
450 assert_eq!(notebook::Recovery::open(&archive).unwrap().pending().unwrap(), pending);
448 assert_eq!(
449 notebook::Recovery::open(&archive)
450 .unwrap()
451 .pending()
452 .unwrap(),
453 pending
454 );
451455 let cache = Replica::open(&path).unwrap();
452456 let remote = typed(SOURCE, space, text, 0..0, "Remote ");
453457 let mut server = Server::new(&remote);
crates/notebook/tests/session.rs+77-6
......@@ -43,7 +43,13 @@ fn edited(page: &Page, text: &str) -> Page {
4343
4444/// Replaces `range` of the page's first body text as the editor does: the edit's id once
4545/// it is durable.
46fn typed(section: &Section, space: ExGuid, page: &Page, range: std::ops::Range<u32>, text: &str) -> u64 {
46fn typed(
47 section: &Section,
48 space: ExGuid,
49 page: &Page,
50 range: std::ops::Range<u32>,
51 text: &str,
52) -> u64 {
4753 let op = PageOp::Text {
4854 text: first_text(page),
4955 range,
......@@ -240,7 +246,10 @@ fn an_external_change_reloads_the_page_and_later_edits_apply_to_it() {
240246 }
241247 }
242248 section.wake();
243 wait(&section, |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space)));
249 wait(
250 &section,
251 |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space)),
252 );
244253 assert_same(section.page(space).unwrap(), &native);
245254 // An edit made on the page shown before the change applies to the page as it is now.
246255 let id = typed(&section, space, &before, 0..0, "Local ");
......@@ -312,7 +321,10 @@ fn edits_apply_in_order_without_waiting_and_finish_before_close() {
312321 }
313322 }
314323 section.wake();
315 wait(&section, |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space)));
324 wait(
325 &section,
326 |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space)),
327 );
316328 apply("Local ");
317329 section.close().unwrap();
318330 let (section, _) = open(&file, &cache);
......@@ -715,9 +727,10 @@ fn a_conflicting_save_keeps_the_native_page_and_a_conflict_page_the_session_dele
715727 }
716728 }
717729 section.wake();
718 wait(&section, |event| {
719 matches!(event, Event::Attempt { id: n, status: EditStatus::Published { .. } } if *n > id)
720 });
730 wait(
731 &section,
732 |event| matches!(event, Event::Attempt { id: n, status: EditStatus::Published { .. } } if *n > id),
733 );
721734 assert!(matches!(
722735 section.status(id).unwrap(),
723736 Some(EditStatus::Published { .. })
......@@ -846,3 +859,61 @@ fn space_of(source: &[u8]) -> ExGuid {
846859 .unwrap()[0]
847860 .0
848861}
862
863/// Restoring and deleting page versions go through the queue and reach the file as OneNote
864/// 2010 stores them (`corpus/page-versions`); a publication that changes only a page's
865/// versions confirms by the history revision it wrote.
866#[test]
867fn page_versions_restore_and_delete_through_the_session() {
868 let directory = tempfile::tempdir().unwrap();
869 let file = directory.path().join("History.one");
870 let cache = directory.path().join("cache");
871 std::fs::copy(
872 Path::new(env!("CARGO_MANIFEST_DIR"))
873 .join("../../corpus/page-versions/native/step-02/notebook/History.one"),
874 &file,
875 )
876 .unwrap();
877 let (section, _) = open(&file, &cache);
878 let listed = section.versions().unwrap();
879 let (space, versions) = listed[0].clone();
880 assert_eq!(versions.len(), 1);
881 let standing = section.page(space).unwrap();
882 let old = section.version(space, versions[0].context).unwrap();
883 let restored = section
884 .restore_version(space, versions[0].context, "Editor")
885 .unwrap();
886 published(&section, restored);
887 assert_eq!(section.page(space).unwrap().objects, old.objects);
888 let stored = |file: &Path| {
889 let arena = onestore::Arena::default();
890 let mut stored =
891 onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap();
892 let versions = stored.versions().unwrap();
893 (stored.page(space).unwrap(), versions)
894 };
895 let (page, after) = stored(&file);
896 assert_eq!(page.objects, old.objects);
897 assert_eq!(after[0].1.len(), 2);
898 assert_eq!(after[0].1[0].author.as_deref(), Some("Other Person"));
899 assert_eq!(
900 section.version(space, after[0].1[0].context).unwrap(),
901 standing
902 );
903 section.close().unwrap();
904
905 // After a relaunch, deleting every version publishes the history alone.
906 let (section, _) = open(&file, &cache);
907 let all: Vec<ExGuid> = section.versions().unwrap()[0]
908 .1
909 .iter()
910 .map(|version| version.context)
911 .collect();
912 let deleted = section.delete_versions(&[(space, all)]).unwrap();
913 published(&section, deleted);
914 assert!(section.versions().unwrap().is_empty());
915 let (page, after) = stored(&file);
916 assert!(after.is_empty());
917 assert_eq!(page.objects, old.objects);
918 section.close().unwrap();
919}
crates/notebook/tests/smb_queue.rs+2-8
......@@ -142,10 +142,7 @@ fn live_keystrokes_publish_reading_only_the_header_and_a_native_change_merges()
142142 }
143143 assert_eq!(remote.reads, 0, "publications read only the header");
144144 assert_eq!(remote.stamps, 20);
145 assert_eq!(
146 lab.read(&path, 1 << 24).unwrap(),
147 snapshot(&replica)
148 );
145 assert_eq!(lab.read(&path, 1 << 24).unwrap(), snapshot(&replica));
149146 for _ in 0..5 {
150147 assert_eq!(replica.sync_once(&mut remote).unwrap().edit, None);
151148 }
......@@ -175,10 +172,7 @@ fn live_keystrokes_publish_reading_only_the_header_and_a_native_change_merges()
175172 let merged = text_of(&replica, space, text);
176173 assert!(merged.starts_with("Native Body"), "{merged}");
177174 assert!(merged.ends_with(" local"), "{merged}");
178 assert_eq!(
179 lab.read(&path, 1 << 24).unwrap(),
180 snapshot(&replica)
181 );
175 assert_eq!(lab.read(&path, 1 << 24).unwrap(), snapshot(&replica));
182176 lab.delete(&path).unwrap();
183177}
184178
crates/notebook/tests/structure.rs+165-2
......@@ -53,13 +53,23 @@ fn sections_and_groups_are_created_renamed_coloured_ordered_and_deleted() {
5353 onestore::place_file(root.join("First.one"), toc_id, "First.one").unwrap();
5454 let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap();
5555 assert_eq!(
56 notebook.create_section("", "Second", &onestore::PageCreation::new(None, Some(""), "Author").unwrap()).unwrap(),
56 notebook
57 .create_section(
58 "",
59 "Second",
60 &onestore::PageCreation::new(None, Some(""), "Author").unwrap()
61 )
62 .unwrap(),
5763 "Second.one"
5864 );
5965 assert_eq!(notebook.create_group("", "Archive").unwrap(), "Archive");
6066 assert_eq!(
6167 notebook
62 .create_section("Archive", "Inner", &onestore::PageCreation::new(None, Some(""), "Author").unwrap())
68 .create_section(
69 "Archive",
70 "Inner",
71 &onestore::PageCreation::new(None, Some(""), "Author").unwrap()
72 )
6373 .unwrap(),
6474 "Archive/Inner.one"
6575 );
......@@ -171,3 +181,156 @@ fn copy_dir(from: &std::path::Path, to: &std::path::Path) {
171181 }
172182 }
173183}
184
185#[test]
186fn a_rename_keeps_its_place_in_the_notebook_and_in_a_group() {
187 let temporary = tempfile::tempdir().unwrap();
188 let root = temporary.path().join("notebook");
189 let page = onestore::PageCreation::new(None, Some(""), "Author").unwrap();
190 let mut notebook =
191 Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap();
192 notebook.create_group("", "Group").unwrap();
193 for folder in ["", "Group"] {
194 for name in ["Video", "Song", "Album"] {
195 notebook.create_section(folder, name, &page).unwrap();
196 }
197 }
198 let path = |folder: &str, name: &str| {
199 if folder.is_empty() {
200 format!("{name}.one")
201 } else {
202 format!("{folder}/{name}.one")
203 }
204 };
205 notebook
206 .reorder(
207 "",
208 &["Album.one", "Video.one", "New Section 1.one", "Song.one"],
209 )
210 .unwrap();
211 notebook
212 .reorder("Group", &["Group/Album.one", "Group/Video.one"])
213 .unwrap();
214 notebook.rename("Video.one", "Kitchen").unwrap();
215 notebook.rename("Group/Video.one", "Kitchen").unwrap();
216 notebook.rename("Group", "Renamed").unwrap();
217 assert_eq!(
218 names(notebook.catalog()),
219 (
220 ["Album", "Kitchen", "New Section 1", "Song"]
221 .map(|name| path("", name))
222 .to_vec(),
223 vec!["Renamed".to_owned()]
224 )
225 );
226 assert_eq!(
227 names(&notebook.catalog().groups[0]).0,
228 ["Album", "Kitchen", "Song"].map(|name| path("Renamed", name))
229 );
230}
231
232/// Each entry a TOC file lists: its filename and file identity.
233fn listed(path: &std::path::Path) -> Vec<(String, [u8; 16])> {
234 let image = onestore::read_file(path).unwrap();
235 let store = onestore::Store::parse(&image).unwrap();
236 let index = onestore::RevisionIndex::parse(&store).unwrap();
237 let document = onestore::document::Document::parse(&index).unwrap();
238 let revision = document.active(document.root).unwrap();
239 let onestore::document::Kind::Toc { entries, .. } = &revision.nodes[&revision.roots[&1]].kind
240 else {
241 panic!()
242 };
243 entries
244 .iter()
245 .map(|id| match &revision.nodes[id].kind {
246 onestore::document::Kind::Toc {
247 filename, identity, ..
248 } => (filename.clone().unwrap_or_default(), identity.unwrap()),
249 _ => panic!(),
250 })
251 .collect()
252}
253
254fn file_id(path: &std::path::Path) -> [u8; 16] {
255 onestore::Store::parse(&onestore::read_file(path).unwrap())
256 .unwrap()
257 .header
258 .file_id
259}
260
261#[test]
262fn a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadable_one() {
263 use std::os::unix::fs::PermissionsExt;
264 let temporary = tempfile::tempdir().unwrap();
265 let root = temporary.path().join("notebook");
266 let bin = root.join("OneNote_RecycleBin");
267 let page = onestore::PageCreation::new(None, Some("Kept"), "Author").unwrap();
268 let mut notebook =
269 Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap();
270 let recycle = |notebook: &mut Notebook| {
271 let image = notebook.read_section("New Section 1.one").unwrap();
272 let pages = notebook::session::stored_pages(&image).unwrap();
273 notebook.recycle_pages(&[pages[0].page.clone()], "Author")
274 };
275 let bins = |root: &std::path::Path| -> Vec<[u8; 16]> {
276 listed(&root.join("Open Notebook.onetoc2"))
277 .into_iter()
278 .filter(|(name, _)| name == "OneNote_RecycleBin")
279 .map(|(_, identity)| identity)
280 .collect()
281 };
282 recycle(&mut notebook).unwrap();
283
284 // Gone from the folder, the bin's entry is stale; the next bin takes its name.
285 std::fs::remove_dir_all(&bin).unwrap();
286 notebook.refresh().unwrap();
287 recycle(&mut notebook).unwrap();
288 let toc = bin.join("Open Notebook.onetoc2");
289 assert_eq!(bins(&root), [file_id(&toc)]);
290 let deleted = file_id(&bin.join("OneNote_DeletedPages.one"));
291 assert_eq!(listed(&toc), [("OneNote_DeletedPages.one".into(), deleted)]);
292
293 // A bin whose TOC has another name and misses the deleted pages is kept and completed.
294 std::fs::remove_file(&toc).unwrap();
295 let other = bin.join("OneNote Table Of Contents.onetoc2");
296 std::fs::write(
297 &other,
298 onestore::create_table_of_contents("OneNote Table Of Contents.onetoc2", &[]).unwrap(),
299 )
300 .unwrap();
301 let parent = file_id(&root.join("Open Notebook.onetoc2"));
302 onestore::place_file(&other, parent, "OneNote_RecycleBin").unwrap();
303 notebook.refresh().unwrap();
304 recycle(&mut notebook).unwrap();
305 assert!(!toc.exists());
306 assert_eq!(bins(&root), [file_id(&other)]);
307 assert_eq!(
308 listed(&other),
309 [("OneNote_DeletedPages.one".into(), deleted)]
310 );
311 let ancestor =
312 onestore::Store::parse(&onestore::read_file(bin.join("OneNote_DeletedPages.one")).unwrap())
313 .unwrap()
314 .header
315 .ancestor;
316 assert_eq!(ancestor, file_id(&other));
317 // `NOTEBOOK_RECYCLE_EXPORT` names a new directory receiving it for a cold reopen.
318 if let Some(directory) = std::env::var_os("NOTEBOOK_RECYCLE_EXPORT") {
319 copy_dir(&root, std::path::Path::new(&directory));
320 }
321 // Permission bits do not bind the superuser.
322 if unsafe { libc::geteuid() } == 0 {
323 return;
324 }
325
326 // A bin that cannot be read is neither replaced nor added to.
327 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o000)).unwrap();
328 let denied = notebook.refresh().and_then(|_| recycle(&mut notebook));
329 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
330 assert!(denied.is_err());
331 let names: Vec<_> = std::fs::read_dir(&bin)
332 .unwrap()
333 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
334 .collect();
335 assert_eq!(names.len(), 2, "{names:?}");
336}
crates/notebook/tests/support/model_schedule.rs+13-19
......@@ -3,6 +3,7 @@
33//! pages the merges leave.
44
55use crate::model_ops::{self, AUTHOR};
6use crate::ops;
67use crate::server::{Fault, Server, remote_snapshot, snapshot};
78use notebook::{EditStatus, Remote, Replica};
89use onestore::{
......@@ -12,9 +13,6 @@ use onestore::{
1213};
1314use std::{io, sync::LazyLock};
1415
15#[path = "../../../onestore/tests/support/ops.rs"]
16mod ops;
17
1816static SOURCE: LazyLock<(Vec<u8>, ExGuid)> = LazyLock::new(|| {
1917 let source =
2018 onestore::create_section("model-schedule.one", "Original 🦀 é 0", "Author").unwrap();
......@@ -282,13 +280,16 @@ pub fn run(input: &[u8]) {
282280 let next = cache.pending().unwrap();
283281 // Edits leave the queue oldest first, each with a durable receipt; a rebase
284282 // queues the conflict pages it makes after them.
285 let queued = |edit: &notebook::PendingEdit| pending.iter().any(|kept| kept.id == edit.id);
283 let queued =
284 |edit: &notebook::PendingEdit| pending.iter().any(|kept| kept.id == edit.id);
286285 let left = pending.len() - next.iter().filter(|edit| queued(edit)).count();
287286 assert!(next.iter().all(|edit| {
288287 pending[left..].iter().any(|kept| kept.id == edit.id)
289288 || matches!(
290289 &edit.edit.ops[..],
291 [onestore::op::Op::Section(onestore::op::SectionOp::Conflict { .. })]
290 [onestore::op::Op::Section(
291 onestore::op::SectionOp::Conflict { .. }
292 )]
292293 )
293294 }));
294295 for edit in &pending[..left] {
......@@ -301,24 +302,18 @@ pub fn run(input: &[u8]) {
301302 if next.is_empty() && result.is_ok() {
302303 assert_eq!(
303304 local(cache),
304 shape(&model_ops::page_of(
305 &remote_snapshot(cache),
306 *space
307 ))
305 shape(&model_ops::page_of(&remote_snapshot(cache), *space))
308306 );
309307 }
310308 }
311309 5 => {
312310 let remote = rows(&server.visible);
313311 let row = remote[usize::from(step[2]) % remote.len()].clone();
314 if !row.2.is_empty() {
312 let letter = char::from(b'a' + step[3] % 26);
313 // Retyping the letter already there changes nothing, so stores no revision.
314 if row.2.chars().next().is_some_and(|first| first != letter) {
315315 let mut page = model_ops::page_of(&server.visible, *space);
316 model_ops::replace_text(
317 &mut page,
318 row.1,
319 0..1,
320 &char::from(b'a' + step[3] % 26).to_string(),
321 );
316 model_ops::replace_text(&mut page, row.1, 0..1, &letter.to_string());
322317 let visible = server.visible.clone();
323318 ops::save(&visible, *space, &page)
324319 .unwrap()
......@@ -334,9 +329,8 @@ pub fn run(input: &[u8]) {
334329 .find(|(page, _)| page == space)
335330 .map(|(_, pages)| pages[usize::from(step[2]) % pages.len()].space)
336331 {
337 let delete = onestore::op::Op::Section(onestore::op::SectionOp::Delete(vec![
338 conflict,
339 ]));
332 let delete =
333 onestore::op::Op::Section(onestore::op::SectionOp::Delete(vec![conflict]));
340334 cache
341335 .apply(
342336 AUTHOR,
crates/notebook/tests/support/page_schedule.rs+4-8
......@@ -1,5 +1,5 @@
11use crate::{
2 disk, model_ops,
2 disk, model_ops, ops,
33 server::{remote_snapshot, snapshot},
44};
55use notebook::{Remote, Replica};
......@@ -14,9 +14,6 @@ use onestore::{
1414};
1515use std::{io, sync::LazyLock};
1616
17#[path = "../../../onestore/tests/support/ops.rs"]
18mod ops;
19
2017const BODY: &str = "Body 🦋 é";
2118
2219#[path = "../../../onestore/tests/support/current.rs"]
......@@ -242,10 +239,9 @@ pub fn run(input: &[u8]) {
242239 .collect();
243240 let expected = ops::section_op(&image, SectionOp::Pages(edits.to_vec()));
244241 match section_op(cache, onestore::op::SectionOp::Pages(edits)) {
245 Ok(_) => assert_eq!(
246 pages(&snapshot(cache)),
247 pages(expected.unwrap().as_bytes())
248 ),
242 Ok(_) => {
243 assert_eq!(pages(&snapshot(cache)), pages(expected.unwrap().as_bytes()))
244 }
249245 Err(_) => {
250246 assert!(expected.is_err());
251247 assert_eq!(pages(&snapshot(cache)), listed);
crates/notebook/tests/sync.rs+23-7
......@@ -57,8 +57,7 @@ fn an_unchanged_stamp_publishes_and_settles_without_reading_the_remote() {
5757 assert_eq!(cache.sync_once(&mut remote).unwrap().edit, None);
5858 assert_eq!(remote.reads, 0);
5959 // Another writer's commit moves the header, so the next step reads the file.
60 let native =
61 typed(&remote.server.visible, sid, object, 0..0, "Native ");
60 let native = typed(&remote.server.visible, sid, object, 0..0, "Native ");
6261 remote.server.visible.clone_from(&native);
6362 assert_eq!(cache.sync_once(&mut remote).unwrap().edit, None);
6463 assert_eq!(remote.reads, 1);
......@@ -252,7 +251,10 @@ fn overlapping_changes_keep_both_versions_and_survive_restart() {
252251 assert_eq!(content(&server.durable, oid), "aRc");
253252 assert_eq!(
254253 conflicts(&server.durable),
255 [(sid, vec![(model_ops::AUTHOR.to_owned(), vec!["aLc".to_owned()])])]
254 [(
255 sid,
256 vec![(model_ops::AUTHOR.to_owned(), vec!["aLc".to_owned()])]
257 )]
256258 );
257259 assert_eq!(snapshot(&cache), server.durable);
258260 assert!(cache.pending().unwrap().is_empty());
......@@ -635,7 +637,10 @@ fn edits_after_a_conflict_apply_to_the_remote_version_and_survive_reopen() {
635637 assert_eq!(content(&server.durable, oid), expected);
636638 assert_eq!(
637639 conflicts(&server.durable),
638 [(sid, vec![(model_ops::AUTHOR.to_owned(), vec!["aLc".to_owned()])])]
640 [(
641 sid,
642 vec![(model_ops::AUTHOR.to_owned(), vec!["aLc".to_owned()])]
643 )]
639644 );
640645 assert!(cache.pending().unwrap().is_empty());
641646}
......@@ -714,7 +719,11 @@ fn seeded_conflicts_keep_the_remote_text_and_later_edits_publish_once() {
714719 ),
715720 "case {case}, seed {seed}"
716721 );
717 assert_eq!(content(&server.durable, oid), remote_text, "case {case}, seed {seed}");
722 assert_eq!(
723 content(&server.durable, oid),
724 remote_text,
725 "case {case}, seed {seed}"
726 );
718727 assert_eq!(
719728 conflicts(&server.durable),
720729 [(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() {
729738 let mut expected = original.clone();
730739 expected.splice(at..at + 1, "λ🐕μ".chars());
731740 let expected = prefix + &expected.iter().collect::<String>() + &suffix;
732 assert_eq!(content(&server.durable, oid), expected, "case {case}, seed {seed}");
741 assert_eq!(
742 content(&server.durable, oid),
743 expected,
744 "case {case}, seed {seed}"
745 );
733746 assert_eq!(server.publications, 2);
734747 assert!(cache.pending().unwrap().is_empty());
735748 }
......@@ -919,7 +932,10 @@ fn a_remote_restore_keeps_local_work_on_a_conflict_page() {
919932 conflicts(&server.durable),
920933 [(
921934 space,
922 vec![(model_ops::AUTHOR.to_owned(), vec!["Revised Original".to_owned()])]
935 vec![(
936 model_ops::AUTHOR.to_owned(),
937 vec!["Revised Original".to_owned()]
938 )]
923939 )]
924940 );
925941 drop(cache);
crates/notebook/tests/sync_formatting.rs+14-8
......@@ -83,11 +83,7 @@ fn formats(page: &Page, text: ExGuid) -> Vec<Format> {
8383fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server {
8484 let mut page = page_of(OUTLINES, space);
8585 change(&mut page);
86 Server::new(
87 ops::saved(OUTLINES, space, &page)
88 .unwrap()
89 .as_slice(),
90 )
86 Server::new(ops::saved(OUTLINES, space, &page).unwrap().as_slice())
9187}
9288
9389fn cache(directory: &tempfile::TempDir) -> Replica {
......@@ -157,7 +153,11 @@ fn offline_formatting_conflicts_only_with_remote_changes_it_overlaps() {
157153 assert!(matches!(outcome, Some((_, EditStatus::Published { .. }))));
158154 assert!(conflicted(&server.durable, space));
159155 let remote = page_of(&server.durable, space);
160 assert!(formats(&remote, ids[0]).iter().all(|format| format.bold != Some(true)));
156 assert!(
157 formats(&remote, ids[0])
158 .iter()
159 .all(|format| format.bold != Some(true))
160 );
161161 assert_eq!(pages(&snapshot(&cache)), pages(&server.durable));
162162 assert_ne!(pages(&local), pages(&server.durable));
163163 }
......@@ -302,7 +302,6 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone
302302 });
303303 let remote_page = page_of(&server.durable, space);
304304 let outcome = cache.sync_once(&mut server).unwrap().edit.unwrap();
305 assert_eq!(outcome.0, id, "seed {seed}");
306305 // Bullets the remote typed before the target's text, and whether it underlined a
307306 // character the target already had.
308307 let mut bullets = vec![0_u32; ids.len()];
......@@ -316,13 +315,20 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone
316315 }
317316 if underlined[target] && start == 0 {
318317 conflicts += 1;
319 assert!(matches!(outcome.1, EditStatus::Published { .. }), "seed {seed}");
318 // The step names its batch by the newest edit: the conflict page the rebase queued.
319 assert!(outcome.0 > id, "seed {seed}");
320 assert!(
321 matches!(outcome.1, EditStatus::Published { .. }),
322 "seed {seed}"
323 );
324 assert_eq!(cache.status(id).unwrap(), Some(outcome.1), "seed {seed}");
320325 assert!(conflicted(&server.durable, space), "seed {seed}");
321326 assert_eq!(page_of(&server.durable, space), remote_page, "seed {seed}");
322327 assert_ne!(pages(&snapshot(&cache)), pages(&local), "seed {seed}");
323328 continue;
324329 }
325330 published += 1;
331 assert_eq!(outcome.0, id, "seed {seed}");
326332 assert!(
327333 matches!(outcome.1, EditStatus::Published { .. }),
328334 "seed {seed}: {outcome:?}"
crates/notebook/tests/sync_outline.rs+4-1
......@@ -301,7 +301,10 @@ fn competing_layout_keeps_the_local_one_on_a_conflict_page() {
301301 let mut section = onestore::Section::open(&arena, server.durable.clone()).unwrap();
302302 let conflict = section.conflicts().unwrap()[0].1[0].space;
303303 let kept = section.page(conflict).unwrap();
304 assert_eq!(body_outlines(&kept)[0].layout, outline_of(&local, outline).layout);
304 assert_eq!(
305 body_outlines(&kept)[0].layout,
306 outline_of(&local, outline).layout
307 );
305308 let dependent = save(&cache, text_id, |page| {
306309 replace_text(page, text_id, 0..0, "Local ")
307310 })
crates/notebook/tests/sync_page.rs+4-1
......@@ -280,7 +280,10 @@ fn a_moved_page_anchor_places_the_page_without_regenerating_dependent_identities
280280 .pages()
281281 .unwrap();
282282 // The remote made `child` and `grandchild` subpages; `Renamed` stayed.
283 let at = listed.iter().position(|listed| listed.0 == pages[6].0).unwrap();
283 let at = listed
284 .iter()
285 .position(|listed| listed.0 == pages[6].0)
286 .unwrap();
284287 assert_eq!(listed[at - 1].0, page.space());
285288 let store = Store::parse(&server.durable).unwrap();
286289 let index = RevisionIndex::parse(&store).unwrap();
crates/notebook/tests/sync_pages.rs+101-34
......@@ -143,10 +143,7 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() {
143143 drop(cache);
144144 let cache = Replica::open(&path).unwrap();
145145 assert_eq!(cache.pending().unwrap(), queue);
146 assert_eq!(
147 server::pages(&snapshot(&cache)),
148 server::pages(&local)
149 );
146 assert_eq!(server::pages(&snapshot(&cache)), server::pages(&local));
150147 let mut server = Server::new(SOURCE);
151148 assert!(matches!(
152149 cache.sync_once(&mut server).unwrap().edit,
......@@ -178,7 +175,10 @@ fn indenting_a_page_the_remote_moved_keeps_the_remote_placement() {
178175 SectionOp::Pages([PageEdit::set_level(sid, 3).unwrap()].to_vec()),
179176 );
180177 let mut server = Server::new(SOURCE);
181 ops::section_op(SOURCE, SectionOp::Pages([PageEdit::move_to(sid, Some(before[7].0), 2).unwrap()].to_vec()))
178 ops::section_op(
179 SOURCE,
180 SectionOp::Pages([PageEdit::move_to(sid, Some(before[7].0), 2).unwrap()].to_vec()),
181 )
182182 .unwrap()
183183 .commit(&mut server)
184184 .unwrap();
......@@ -196,9 +196,15 @@ fn competing_page_moves_keep_the_remote_placement() {
196196 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
197197 let pages = order(SOURCE);
198198 let sid = pages[6].0;
199 let id = section_op(&cache, SectionOp::Pages(vec![PageEdit::move_to(sid, None, 1).unwrap()]));
199 let id = section_op(
200 &cache,
201 SectionOp::Pages(vec![PageEdit::move_to(sid, None, 1).unwrap()]),
202 );
200203 let mut server = Server::new(SOURCE);
201 ops::section_op(SOURCE, SectionOp::Pages([PageEdit::move_to(sid, Some(pages[0].0), 1).unwrap()].to_vec()))
204 ops::section_op(
205 SOURCE,
206 SectionOp::Pages([PageEdit::move_to(sid, Some(pages[0].0), 1).unwrap()].to_vec()),
207 )
202208 .unwrap()
203209 .commit(&mut server)
204210 .unwrap();
......@@ -219,12 +225,18 @@ fn a_competing_level_keeps_the_remote_level_and_the_rest_of_the_batch() {
219225 ];
220226 let id = section_op(&cache, SectionOp::Pages(edits.clone()));
221227 let mut server = Server::new(SOURCE);
222 ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(pages[5].0, 1).unwrap()].to_vec()))
223 .unwrap()
224 .commit(&mut server)
225 .unwrap();
226 let expected =
227 ops::section_op(&server.durable.clone(), SectionOp::Pages(edits[..1].to_vec())).unwrap();
228 ops::section_op(
229 SOURCE,
230 SectionOp::Pages([PageEdit::set_level(pages[5].0, 1).unwrap()].to_vec()),
231 )
232 .unwrap()
233 .commit(&mut server)
234 .unwrap();
235 let expected = ops::section_op(
236 &server.durable.clone(),
237 SectionOp::Pages(edits[..1].to_vec()),
238 )
239 .unwrap();
228240 assert!(matches!(cache.sync_once(&mut server).unwrap().edit,
229241 Some((published, EditStatus::Published { .. })) if published == id));
230242 assert_eq!(order(&server.durable), order(expected.as_bytes()));
......@@ -367,7 +379,10 @@ fn an_explicit_anchor_yields_to_a_remote_move() {
367379 ]),
368380 );
369381 let mut server = Server::new(SOURCE);
370 ops::section_op(SOURCE, SectionOp::Pages([PageEdit::move_to(pages[6].0, Some(pages[0].0), 1).unwrap()].to_vec()))
382 ops::section_op(
383 SOURCE,
384 SectionOp::Pages([PageEdit::move_to(pages[6].0, Some(pages[0].0), 1).unwrap()].to_vec()),
385 )
371386 .unwrap()
372387 .commit(&mut server)
373388 .unwrap();
......@@ -391,10 +406,13 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() {
391406 SectionOp::Pages([PageEdit::set_level(sid, 1).unwrap()].to_vec()),
392407 );
393408 let mut server = Server::new(SOURCE);
394 ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(sid, 1).unwrap()].to_vec()))
395 .unwrap()
396 .commit(&mut server)
397 .unwrap();
409 ops::section_op(
410 SOURCE,
411 SectionOp::Pages([PageEdit::set_level(sid, 1).unwrap()].to_vec()),
412 )
413 .unwrap()
414 .commit(&mut server)
415 .unwrap();
398416 server.fault = Fault::Confirm;
399417 assert!(cache.sync_once(&mut server).is_err());
400418 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
......@@ -451,12 +469,21 @@ fn native_page_changes_reconcile_with_atomic_offline_batches() {
451469 .collect();
452470 let placement = |image: &[u8]| -> BTreeMap<ExGuid, (ExGuid, Option<ExGuid>, u32)> {
453471 let arena = onestore::Arena::default();
454 let series = onestore::Section::open(&arena, image.to_vec()).unwrap().series().unwrap();
455 let shared: Vec<_> = order(image).into_iter().filter(|(sid, _)| both.contains(sid)).collect();
472 let series = onestore::Section::open(&arena, image.to_vec())
473 .unwrap()
474 .series()
475 .unwrap();
476 let shared: Vec<_> = order(image)
477 .into_iter()
478 .filter(|(sid, _)| both.contains(sid))
479 .collect();
456480 (0..shared.len())
457481 .map(|at| {
458482 let (sid, level) = shared[at];
459 (sid, (series[&sid], at.checked_sub(1).map(|b| shared[b].0), level))
483 (
484 sid,
485 (series[&sid], at.checked_sub(1).map(|b| shared[b].0), level),
486 )
460487 })
461488 .collect()
462489 };
......@@ -607,14 +634,21 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() {
607634/// A, offline, `moves` pages (each last, or before another) and edits `Target`'s body while
608635/// client B publishes `b-published`. The pages the merge lists after the first, and
609636/// `Target`'s space and texts.
610fn native_page_merge(capture: &str, moves: &[(&str, Option<&str>)]) -> (Vec<String>, ExGuid, Vec<String>) {
637fn native_page_merge(
638 capture: &str,
639 moves: &[(&str, Option<&str>)],
640) -> (Vec<String>, ExGuid, Vec<String>) {
611641 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
612642 .join("../../corpus/conflict-page")
613643 .join(capture);
614 let read = |phase: &str| std::fs::read(root.join(phase).join("notebook/synthetic.one")).unwrap();
644 let read =
645 |phase: &str| std::fs::read(root.join(phase).join("notebook/synthetic.one")).unwrap();
615646 let pages = |image: &[u8]| {
616647 let arena = onestore::Arena::default();
617 onestore::Section::open(&arena, image.to_vec()).unwrap().pages().unwrap()
648 onestore::Section::open(&arena, image.to_vec())
649 .unwrap()
650 .pages()
651 .unwrap()
618652 };
619653 let initial = read("initial");
620654 let listed = pages(&initial);
......@@ -624,7 +658,9 @@ fn native_page_merge(capture: &str, moves: &[(&str, Option<&str>)]) -> (Vec<Stri
624658 for (page, before) in moves {
625659 section_op(
626660 &cache,
627 SectionOp::Pages(vec![PageEdit::move_to(space(page), before.map(space), 1).unwrap()]),
661 SectionOp::Pages(vec![
662 PageEdit::move_to(space(page), before.map(space), 1).unwrap(),
663 ]),
628664 );
629665 }
630666 let body = texts(&initial)
......@@ -650,7 +686,11 @@ fn native_page_merge(capture: &str, moves: &[(&str, Option<&str>)]) -> (Vec<Stri
650686 .filter(|((page, _), _)| *page == target)
651687 .map(|(_, text)| text)
652688 .collect();
653 (merged[1..].iter().map(|page| page.1.clone()).collect(), target, texts)
689 (
690 merged[1..].iter().map(|page| page.1.clone()).collect(),
691 target,
692 texts,
693 )
654694}
655695
656696/// 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
659699#[test]
660700fn page_moves_and_a_removed_page_merge_as_onenote_merges_them() {
661701 for (capture, moves) in [
662 ("native-pages", &[("One", Some("Target")), ("Three", Some("Target")), ("Two", None)][..]),
663 ("native-restore", &[("One", None), ("Two", None), ("Target", None), ("Three", None)][..]),
702 (
703 "native-pages",
704 &[
705 ("One", Some("Target")),
706 ("Three", Some("Target")),
707 ("Two", None),
708 ][..],
709 ),
710 (
711 "native-restore",
712 &[
713 ("One", None),
714 ("Two", None),
715 ("Target", None),
716 ("Three", None),
717 ][..],
718 ),
664719 ("native-restore", &[("Four", Some("One"))][..]),
665720 ] {
666 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/conflict-page");
721 let root =
722 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/conflict-page");
667723 let native: serde_json::Value =
668 serde_json::from_slice(&std::fs::read(root.join(capture).join("merged.json")).unwrap()).unwrap();
724 serde_json::from_slice(&std::fs::read(root.join(capture).join("merged.json")).unwrap())
725 .unwrap();
669726 let (order, target, texts) = native_page_merge(capture, moves);
670727 let native: Vec<String> = serde_json::from_value(native["order"].clone()).unwrap();
671728 assert_eq!(order, native[1..], "{capture}");
672 assert!(texts.contains(&"Body Target edited offline.".to_owned()), "{capture}");
729 assert!(
730 texts.contains(&"Body Target edited offline.".to_owned()),
731 "{capture}"
732 );
673733 let arena = onestore::Arena::default();
674 let initial = std::fs::read(root.join(capture).join("initial/notebook/synthetic.one")).unwrap();
675 let listed = onestore::Section::open(&arena, initial).unwrap().pages().unwrap();
676 assert!(listed.iter().all(|page| page.0 != target), "{capture}: a new page");
734 let initial =
735 std::fs::read(root.join(capture).join("initial/notebook/synthetic.one")).unwrap();
736 let listed = onestore::Section::open(&arena, initial)
737 .unwrap()
738 .pages()
739 .unwrap();
740 assert!(
741 listed.iter().all(|page| page.0 != target),
742 "{capture}: a new page"
743 );
677744 }
678745}
crates/notebook/tests/sync_paragraph.rs+6-8
......@@ -392,7 +392,10 @@ fn a_native_text_object_replacement_keeps_a_conflict_page_for_every_offline_spli
392392 "{name}"
393393 );
394394 assert!(
395 matches!(cache.status(id).unwrap(), Some(EditStatus::Published { .. })),
395 matches!(
396 cache.status(id).unwrap(),
397 Some(EditStatus::Published { .. })
398 ),
396399 "{name}"
397400 );
398401 assert_eq!(page_of(&server.visible, space), remote, "{name}");
......@@ -483,8 +486,7 @@ fn offline_paragraphs(output: Option<&Path>) {
483486 };
484487 let prefixed = offset > 0 && !content(&page, left).text().is_empty();
485488 if prefixed {
486 server.visible =
487 typed(&server.visible, space, left, 0..0, "\u{2602}");
489 server.visible = typed(&server.visible, space, left, 0..0, "\u{2602}");
488490 server.durable.clone_from(&server.visible);
489491 }
490492 let Ok(queued) = save_page(&cache, left, |page| replay(page, intent, split)) else {
......@@ -591,11 +593,7 @@ fn control(title: &str) -> (ExGuid, Page) {
591593fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server {
592594 let mut page = page_of(BEFORE, space);
593595 change(&mut page);
594 Server::new(
595 ops::saved(BEFORE, space, &page)
596 .unwrap()
597 .as_slice(),
598 )
596 Server::new(ops::saved(BEFORE, space, &page).unwrap().as_slice())
599597}
600598
601599#[test]
crates/onestore/README.md+1
......@@ -61,6 +61,7 @@ harness also accepts `--client-profile release`.
6161| `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 |
6262| `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 |
6363| `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 |
64| `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)) |
6465| `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 |
6566| `op::predict` | The page an op leaves, as the section stores it and reading it back shows it |
6667| `read_file` | Read a snapshot under whole-file exclusion |
crates/onestore/examples/edit_property.rs+2-2
......@@ -84,7 +84,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
8484 }
8585 (0x20001, 0x14001cbe) => {
8686 if index.resolve_active(space)?.roots.get(&1) != Some(&object) {
87 return Err("Only the notebook's colour, on the TOC root, changes".into());
87 return Err("Only the notebook's color, on the TOC root, changes".into());
8888 }
8989 let color = u32::from_le_bytes(value.as_slice().try_into()?);
9090 onestore::edit_table_of_contents(&bytes, &[TocEdit::Color(color)])?
......@@ -108,7 +108,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
108108 write(&args[1], &patched)?;
109109 return Ok(());
110110 }
111 _ => return Err("Only text and TOC colours change as edits; other values are patched into a copy of equal length".into()),
111 _ => return Err("Only text and TOC colors change as edits; other values are patched into a copy of equal length".into()),
112112 };
113113 let transaction = transaction.ok_or("The value is already stored")?;
114114 if args[1] == "--in-place" {
crates/onestore/examples/insert.rs+5-2
......@@ -6,7 +6,9 @@ mod typing;
66use onestore::{
77 document::{Format, Layout},
88 op::{Edit, Op, PageOp},
9 page::{Outline, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, text::new_id},
9 page::{
10 Outline, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, text::new_id,
11 },
1012};
1113use std::{fs, io::Write};
1214
......@@ -87,7 +89,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
8789 op,
8890 }],
8991 };
90 let transaction = typing::sealed(&source, author, &edit)?.ok_or("The insertion stores nothing")?;
92 let transaction =
93 typing::sealed(&source, author, &edit)?.ok_or("The insertion stores nothing")?;
9194 let mut record = serde_json::json!({"edit": edit, "object": object, "text_object": text});
9295 if args[2] == "--in-place" {
9396 if let Err(error) = transaction.commit_file(&args[1]) {
crates/onestore/examples/support/concurrent.rs+22-8
......@@ -322,7 +322,10 @@ mod tests {
322322 TextAttribute,
323323 document::{Format, Layout},
324324 op::{Edit, Op, PageOp},
325 page::{Outline, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, text::new_id},
325 page::{
326 Outline, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,
327 text::new_id,
328 },
326329 };
327330
328331 fn paragraph(text: &str, level: u32) -> PageParagraph {
......@@ -335,12 +338,15 @@ mod tests {
335338 content: ParagraphContent::Text(TextObject {
336339 id: new_id().unwrap(),
337340 date_field: None,
338 text: Paragraph::new(text.into(), Format {
339 font: Some("Calibri".into()),
340 font_size: Some(11.0),
341 language: Some(0x409),
342 ..Format::default()
343 }),
341 text: Paragraph::new(
342 text.into(),
343 Format {
344 font: Some("Calibri".into()),
345 font_size: Some(11.0),
346 language: Some(0x409),
347 ..Format::default()
348 },
349 ),
344350 tags: Vec::new(),
345351 }),
346352 lists: Vec::new(),
......@@ -354,7 +360,15 @@ mod tests {
354360 let arena = onestore::Arena::default();
355361 let mut section = onestore::Section::open(&arena, image.to_vec()).unwrap();
356362 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
357 section.apply("Author", &Edit { at: 134_000_000_000_000_000, ops }).unwrap();
363 section
364 .apply(
365 "Author",
366 &Edit {
367 at: 134_000_000_000_000_000,
368 ops,
369 },
370 )
371 .unwrap();
358372 section.seal().unwrap();
359373 section.image()
360374 }
crates/onestore/src/active.rs+12-8
......@@ -58,7 +58,12 @@ impl<'a> ActivePage<'a> {
5858 let rid = index.active(space)?;
5959 let live = LiveRevision::new(index.resolve(space, rid)?, chain_depth(index, space, rid))?;
6060 let store: &'a Store<'a> = index.store;
61 Self::viewed(std::rc::Rc::new(|guid| store.file_data(guid)), pages, live, view)
61 Self::viewed(
62 std::rc::Rc::new(|guid| store.file_data(guid)),
63 pages,
64 live,
65 view,
66 )
6267 }
6368
6469 /// The page space `live` holds, whose stored payloads `files` reads.
......@@ -404,21 +409,20 @@ pub(crate) mod tests {
404409 choice => choice,
405410 };
406411 match choice {
407 0..=3 => Insertion::paragraph(container, anchor, word, "Author").and_then(
408 |insertion| {
412 0..=3 => {
413 Insertion::paragraph(container, anchor, word, "Author").and_then(|insertion| {
409414 let paragraph = new_id()?;
410415 insertion.changes_as(active, paragraph, paragraph, new_id()?)
411 },
412 ),
416 })
417 }
413418 4 => TreeEdit::move_to(paragraph, container, anchor, "Author")
414419 .and_then(|edit| edit.changes(active)),
415420 5 => TreeEdit::delete(paragraph, "Author").and_then(|edit| edit.changes(active)),
416421 6 => {
417422 let at = self.pick(3) as u32;
418423 let lists = (0..8).map(|_| new_id()).collect::<Result<Vec<_>>>()?;
419 ParagraphSplit::new(text, at, "Author").and_then(|split| {
420 split.changes_as(active, new_id()?, new_id()?, &lists)
421 })
424 ParagraphSplit::new(text, at, "Author")
425 .and_then(|split| split.changes_as(active, new_id()?, new_id()?, &lists))
422426 }
423427 _ => {
424428 let end = self.pick(2) as u32;
crates/onestore/src/commit.rs+5-1
......@@ -360,7 +360,11 @@ impl Transaction {
360360 /// last, covers bytes 0..1024.
361361 pub fn writes(&self) -> impl Iterator<Item = (u64, &[u8])> {
362362 std::iter::once((self.base.length, self.append.as_slice()))
363 .chain(self.patches.iter().map(|(offset, bytes)| (*offset, bytes.as_slice())))
363 .chain(
364 self.patches
365 .iter()
366 .map(|(offset, bytes)| (*offset, bytes.as_slice())),
367 )
364368 .chain(std::iter::once((0, self.header.as_slice())))
365369 }
366370
crates/onestore/src/create.rs+12-7
......@@ -29,7 +29,10 @@ pub(crate) fn initials(author: &str) -> String {
2929
3030/// An author object's properties as OneNote 2010 writes them: initials, then the name.
3131pub(crate) fn author_properties(name: &str) -> Vec<(u32, Vec<u8>)> {
32 vec![(0x1c001df8, string(&initials(name))), (0x1c001d75, string(name))]
32 vec![
33 (0x1c001df8, string(&initials(name))),
34 (0x1c001d75, string(name)),
35 ]
3336}
3437
3538pub(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 {
160163/// Seconds since 1980 as OneNote's Time32, and FILETIME, of the writers' clock.
161164pub(crate) fn current_timestamps() -> Result<(u32, u64)> {
162165 let now = match CLOCK.get() {
163 Some(filetime) => (filetime / 10_000_000).checked_sub(11644473600).ok_or(Error {
164 offset: 0,
165 message: "An edit time precedes the Unix epoch",
166 })?,
166 Some(filetime) => (filetime / 10_000_000)
167 .checked_sub(11644473600)
168 .ok_or(Error {
169 offset: 0,
170 message: "An edit time precedes the Unix epoch",
171 })?,
167172 None => SystemTime::now()
168173 .duration_since(UNIX_EPOCH)
169174 .map_err(|_| Error {
......@@ -200,7 +205,7 @@ pub fn create_section(file_name: &str, text: &str, author: &str) -> Result<Vec<u
200205 let page_guid = fresh_guid()?;
201206 let metadata = vec![
202207 (0x1c001c30, page_guid.to_vec()),
203 (0x1c001cf3, string(crate::edit::automatic_title(text))),
208 (0x1c001cf3, string(&crate::edit::automatic_title(text))),
204209 (0x14001d82, 40_u32.to_le_bytes().to_vec()),
205210 (0x1400348b, 40_u32.to_le_bytes().to_vec()),
206211 (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
265270 (0x24001c20, id(23)),
266271 (0x1c001d75, string(author)),
267272 (0x1c001df8, string(&initials(author))),
268 (0x1c001d3c, string(crate::edit::automatic_title(text))),
273 (0x1c001d3c, string(&crate::edit::automatic_title(text))),
269274 ],
270275 page_margins(),
271276 ]
crates/onestore/src/document.rs+13-6
......@@ -897,11 +897,16 @@ impl<'a> Document<'a> {
897897 if space.contexts.contains_key(&context) {
898898 continue;
899899 }
900 let rid = *index
900 let rid = index
901901 .spaces
902902 .get(&id)
903 .and_then(|s| s.labels.get(&(context, 1)))
904 .ok_or_else(|| invalid("Document context has no revision"))?;
903 .and_then(|s| s.labels.get(&(context, 1)));
904 let rid = match rid {
905 Some(rid) => *rid,
906 // OneNote's copy of a page version names a history it never wrote.
907 None if context == crate::section::HISTORY => continue,
908 None => return Err(invalid("Document context has no revision")),
909 };
905910 space.contexts.insert(context, rid);
906911 if space.revisions.contains_key(&rid) {
907912 continue;
......@@ -924,8 +929,10 @@ impl<'a> Document<'a> {
924929 Kind::VersionProxy { context, .. } => (*context, false),
925930 _ => continue,
926931 };
927 let rid = space.contexts[&context];
928 let revision = &space.revisions[&rid];
932 let Some(rid) = space.contexts.get(&context) else {
933 continue;
934 };
935 let revision = &space.revisions[rid];
929936 let root = revision.roots.get(&1).and_then(|id| revision.nodes.get(id));
930937 if history {
931938 if !matches!(root.map(|n| &n.kind), Some(Kind::VersionHistory)) {
......@@ -934,7 +941,7 @@ impl<'a> Document<'a> {
934941 ));
935942 }
936943 } else if !matches!(root.map(|n| &n.kind), Some(Kind::Manifest { .. }))
937 || space.contexts.get(&ExGuid::default()) == Some(&rid)
944 || space.contexts.get(&ExGuid::default()) == Some(rid)
938945 {
939946 return Err(invalid(
940947 "Version context does not resolve to a historical page",
crates/onestore/src/edit.rs+29-6
......@@ -11,16 +11,39 @@ fn title_line(text: &str) -> &str {
1111 text.trim_start().split('\r').next().unwrap()
1212}
1313
14pub(crate) fn automatic_title(text: &str) -> &str {
15 let line = title_line(text).trim_end();
14/// `text` as it shows: hyperlink field instructions, which the text holds hidden, left out.
15pub(crate) fn without_fields(text: &str) -> String {
16 let mut line = String::new();
17 let mut rest = text;
18 while let Some(start) = rest.find('\u{fddf}') {
19 line.push_str(&rest[..start]);
20 rest = &rest[start..];
21 let Some(label) = rest
22 .strip_prefix("\u{fddf}HYPERLINK \"")
23 .and_then(|target| target.split_once('"'))
24 .map(|(_, label)| label)
25 else {
26 break;
27 };
28 rest = label;
29 }
30 line.push_str(rest);
31 line
32}
33
34/// The first line of `text` as OneNote titles a page after it: without hyperlink field
35/// instructions, which the text holds hidden, and at most 255 UTF-16 units long.
36pub(crate) fn automatic_title(text: &str) -> String {
37 let line = without_fields(title_line(text));
38 let line = line.trim();
1639 let mut units = 0;
1740 for (byte, character) in line.char_indices() {
1841 if units >= 255 {
19 return line[..byte].trim_end();
42 return line[..byte].trim_end().to_owned();
2043 }
2144 units += character.len_utf16();
2245 }
23 line
46 line.to_owned()
2447}
2548
2649/// Replaces UTF-16 character positions across ordinary text runs.
......@@ -446,7 +469,7 @@ pub(crate) fn title_of<'n>(
446469 let Kind::Page { rtl, .. } = &node(*page).unwrap().kind else {
447470 unreachable!()
448471 };
449 let mut title_text = title_line(title_text);
472 let mut title_text = without_fields(title_line(title_text));
450473 if automatic {
451474 let mut roots = node(*page).unwrap().children.clone();
452475 roots.sort_by(|a, b| {
......@@ -495,7 +518,7 @@ pub(crate) fn title_of<'n>(
495518 pending.extend(element.structure.iter().rev().copied());
496519 }
497520 }
498 Ok(Some((*page, automatic, title_text.to_owned())))
521 Ok(Some((*page, automatic, title_text)))
499522}
500523
501524/// Updates the page title for `changed` objects made on `view`, a copy of the page's view.
crates/onestore/src/insertion.rs+12-2
......@@ -47,7 +47,13 @@ impl Insertion {
4747 }
4848
4949 /// Adds an outline to an editable page at coordinates measured in points.
50 pub(crate) fn outline(page: ExGuid, x: f32, y: f32, text: &str, author: &str) -> Result<Self, Error> {
50 pub(crate) fn outline(
51 page: ExGuid,
52 x: f32,
53 y: f32,
54 text: &str,
55 author: &str,
56 ) -> Result<Self, Error> {
5157 Self::new(page, Placement::Outline { x, y }, text, author)
5258 }
5359
......@@ -186,7 +192,11 @@ impl Insertion {
186192 (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()),
187193 ],
188194 ),
189 (author, 0x120001, crate::create::author_properties(&self.author)),
195 (
196 author,
197 0x120001,
198 crate::create::author_properties(&self.author),
199 ),
190200 (default, 0x12004d, default_text_style()),
191201 ] {
192202 new.insert(
crates/onestore/src/lib.rs+1-1
......@@ -44,7 +44,7 @@ pub use pages::{ConflictPage, PageCreation, PageEdit, PagePosition};
4444pub(crate) use paragraph::{ParagraphJoin, ParagraphSplit};
4545pub use properties::{IdStream, Property, PropertySets, Value};
4646pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex};
47pub use section::{Arena, Section};
47pub use section::{Arena, PageVersion, Section};
4848pub use snapshot::{read_snapshot, read_storage_snapshot};
4949pub use store::{Chunk, Error, FileType, Header, Node, NodeList, Reference, Store};
5050pub use toc::{TocEdit, edit_table_of_contents};
crates/onestore/src/objects.rs+4-1
......@@ -209,7 +209,10 @@ impl<'a> RevisionIndex<'a> {
209209 targets.insert((target, rid));
210210 }
211211 for context in references.contexts {
212 if !space.labels.contains_key(&(context, 1)) {
212 // OneNote's copy of a page version names a history it never wrote.
213 if !space.labels.contains_key(&(context, 1))
214 && context != crate::section::HISTORY
215 {
213216 return Err(Error {
214217 offset: 0,
215218 message: "Context reference has no current revision",
crates/onestore/src/op/apply.rs+151-63
......@@ -15,8 +15,8 @@ use crate::{
1515 active::{ActivePage, Changes},
1616 document::Kind,
1717 page::{
18 Attachment, Image, Ink, PageObject, PageParagraph, Paragraph, ParagraphContent,
19 TableCell, TableColumn,
18 Attachment, Image, Ink, PageObject, PageParagraph, Paragraph, ParagraphContent, TableCell,
19 TableColumn,
2020 },
2121};
2222use std::{
......@@ -24,6 +24,10 @@ use std::{
2424 ops::Range,
2525};
2626
27/// `HasConflictPages` and `HasVersionPages` (MS-ONE 2.3.71, 2.3.84), true when present.
28const HAS_CONFLICT_PAGES: u32 = 0x88001d97;
29const HAS_VERSION_PAGES: u32 = 0x88003462;
30
2731fn invalid(message: &'static str) -> Error {
2832 Error { offset: 0, message }
2933}
......@@ -87,7 +91,9 @@ impl<'a> Section<'a> {
8791 space: ExGuid,
8892 after: &crate::page::Page,
8993 ) -> Result<Vec<PageOp>, OpError> {
90 let before = self.page(space).map_err(|error| self.classify(Failure::Rejected(error)))?;
94 let before = self
95 .page(space)
96 .map_err(|error| self.classify(Failure::Rejected(error)))?;
9197 let ops = super::lower_page(&before, after)
9298 .map_err(|error| OpError::Unsupported(error.message))?;
9399 let edit = Edit {
......@@ -152,7 +158,10 @@ impl<'a> Section<'a> {
152158 let conflicts = self.conflicts()?;
153159 for space in pages {
154160 let of = conflicts.iter().find_map(|(of, conflicts)| {
155 conflicts.iter().any(|page| page.space == *space).then_some(*of)
161 conflicts
162 .iter()
163 .any(|page| page.space == *space)
164 .then_some(*of)
156165 });
157166 match of {
158167 Some(of) => self.unlink_conflict(of, *space)?,
......@@ -165,6 +174,19 @@ impl<'a> Section<'a> {
165174 }
166175 }
167176 }
177 SectionOp::RestoreVersion {
178 page,
179 version,
180 guid,
181 } => {
182 self.restore(author, *page, *version, *guid)?;
183 self.flag_metadata(*page, HAS_VERSION_PAGES, true)?;
184 }
185 SectionOp::DeleteVersions { page, versions } => {
186 if self.unlist(*page, versions)? {
187 self.flag_metadata(*page, HAS_VERSION_PAGES, false)?;
188 }
189 }
168190 SectionOp::Color(color) => {
169191 let root = self.root();
170192 let page = self.active(root)?;
......@@ -173,7 +195,7 @@ impl<'a> Section<'a> {
173195 .roots
174196 .get(&2)
175197 .filter(|id| page.live.revision.objects.contains_key(id))
176 .ok_or_else(|| invalid("The section has no metadata to colour"))?;
198 .ok_or_else(|| invalid("The section has no metadata to color"))?;
177199 let mut object = crate::write::PropertyObject::from_object(
178200 &page.live.revision.objects[&metadata],
179201 )?;
......@@ -185,10 +207,17 @@ impl<'a> Section<'a> {
185207 }
186208
187209 /// Writes `page`'s content onto the page just created in `space`, whose title stays.
188 fn fill(&mut self, author: &str, space: ExGuid, page: &crate::page::Page) -> Result<(), Failure> {
210 fn fill(
211 &mut self,
212 author: &str,
213 space: ExGuid,
214 page: &crate::page::Page,
215 ) -> Result<(), Failure> {
189216 let before = self.page(space)?;
190217 let mut after = page.clone();
191 after.objects.retain(|object| !matches!(object, PageObject::Title(_)));
218 after
219 .objects
220 .retain(|object| !matches!(object, PageObject::Title(_)));
192221 for object in &mut after.objects {
193222 // Stroke points are page coordinates; new ink takes its position from them.
194223 if let PageObject::Ink(ink) = object {
......@@ -298,7 +327,7 @@ impl<'a> Section<'a> {
298327 &[],
299328 BTreeMap::from([(manifest_id, manifest), (copy, metadata)]),
300329 )?;
301 self.has_conflicts(of, true)
330 self.flag_metadata(of, HAS_CONFLICT_PAGES, true)
302331 }
303332
304333 /// Deletes conflict page `space` of page `of` as OneNote 2010 does: the manifest stops
......@@ -322,21 +351,21 @@ impl<'a> Section<'a> {
322351 }
323352 self.apply_changes(of, &[], BTreeMap::from([(manifest_id, manifest)]))?;
324353 if spaces.is_empty() {
325 self.has_conflicts(of, false)?;
354 self.flag_metadata(of, HAS_CONFLICT_PAGES, false)?;
326355 }
327356 Ok(())
328357 }
329358
330 /// Sets or clears `HasConflictPages` on page `of`'s metadata and on the section's copy
359 /// Sets or clears a Boolean `property` on page `of`'s metadata and on the section's copy
331360 /// of it, where its series keeps one.
332 fn has_conflicts(&mut self, of: ExGuid, value: bool) -> Result<(), Failure> {
361 fn flag_metadata(&mut self, of: ExGuid, property: u32, value: bool) -> Result<(), Failure> {
333362 use crate::write::PropertyObject;
334363 let flag = |object: &crate::Object<'_>| -> Result<PropertyObject, Error> {
335364 let mut object = PropertyObject::from_object(object)?;
336365 if value {
337 object.set(&[(0x88001d97, &[])])?;
366 object.set(&[(property, &[])])?;
338367 } else {
339 object.remove(&[0x88001d97])?;
368 object.remove(&[property])?;
340369 }
341370 Ok(object)
342371 };
......@@ -399,7 +428,7 @@ fn stores_more_than_once(op: &Op) -> bool {
399428}
400429
401430/// Why an op was not applied: a precondition it names, or what a writer refused.
402enum Failure {
431pub(crate) enum Failure {
403432 Refused(OpError),
404433 Rejected(Error),
405434}
......@@ -524,7 +553,11 @@ impl<'a> Writer<'_, 'a> {
524553 (*child, target)
525554 })
526555 .unzip();
527 if current.iter().map(|(_, depth)| *depth).eq(depths.iter().copied()) {
556 if current
557 .iter()
558 .map(|(_, depth)| *depth)
559 .eq(depths.iter().copied())
560 {
528561 return Ok(());
529562 }
530563 let (level, runs) = levels::runs(&children, &depths, is_cell)?;
......@@ -592,13 +625,15 @@ impl<'a> Writer<'_, 'a> {
592625 }
593626 PageOp::Color(color) => self.write(|page| {
594627 let [node] = crate::active::manifest_pages(&page.view)[..] else {
595 return Err(invalid("The page has no page node to colour"));
628 return Err(invalid("The page has no page node to color"));
596629 };
597630 let mut object =
598631 crate::write::PropertyObject::from_object(&page.live.revision.objects[&node])?;
599632 // OneNote's "No color" removes the property.
600633 match color {
601 Some(color) => object.set(&[(crate::page::PAGE_COLOR, &color.to_le_bytes())])?,
634 Some(color) => {
635 object.set(&[(crate::page::PAGE_COLOR, &color.to_le_bytes())])?
636 }
602637 None => object.remove(&[crate::page::PAGE_COLOR])?,
603638 }
604639 Ok(BTreeMap::from([(node, object)]))
......@@ -648,12 +683,9 @@ impl<'a> Writer<'_, 'a> {
648683 self.target(*paragraph)?;
649684 let container = self.container(*paragraph)?;
650685 let base = self.level(container)?;
651 let depth = level
652 .checked_sub(base)
653 .filter(|depth| *depth > 0)
654 .ok_or(OpError::Unsupported(
655 "A paragraph lies deeper than its parent",
656 ))?;
686 let depth = level.checked_sub(base).filter(|depth| *depth > 0).ok_or(
687 OpError::Unsupported("A paragraph lies deeper than its parent"),
688 )?;
657689 self.regroup(container, &[(*paragraph, depth)])
658690 }
659691 PageOp::Outline { object, edit } => {
......@@ -703,7 +735,9 @@ impl<'a> Writer<'_, 'a> {
703735 .get(id)
704736 .is_some_and(|owners| owners.iter().any(|owner| owner != paragraph))
705737 {
706 return Err(OpError::Unsupported("A list node belongs to one paragraph").into());
738 return Err(
739 OpError::Unsupported("A list node belongs to one paragraph").into()
740 );
707741 }
708742 }
709743 let nodes = lists
......@@ -833,7 +867,9 @@ impl<'a> Writer<'_, 'a> {
833867 let mut at = parent;
834868 loop {
835869 if at == object {
836 return Err(OpError::StructureChanged("A subtree cannot move inside itself").into());
870 return Err(
871 OpError::StructureChanged("A subtree cannot move inside itself").into(),
872 );
837873 }
838874 match self.page()?.parents.get(&at).map(Vec::as_slice) {
839875 Some([up]) => at = *up,
......@@ -844,9 +880,10 @@ impl<'a> Writer<'_, 'a> {
844880 Some(before) => {
845881 self.target(before)?;
846882 if self.container(before)? != parent {
847 return Err(
848 OpError::StructureChanged("The anchor is no child of the container").into(),
849 );
883 return Err(OpError::StructureChanged(
884 "The anchor is no child of the container",
885 )
886 .into());
850887 }
851888 self.parent(before)?
852889 }
......@@ -877,9 +914,10 @@ impl<'a> Writer<'_, 'a> {
877914 Some(before) => {
878915 self.target(before)?;
879916 if self.container(before)? != container {
880 return Err(
881 OpError::StructureChanged("The anchor is no child of the container").into(),
882 );
917 return Err(OpError::StructureChanged(
918 "The anchor is no child of the container",
919 )
920 .into());
883921 }
884922 self.parent(before)?
885923 }
......@@ -920,8 +958,13 @@ impl<'a> Writer<'_, 'a> {
920958 .level
921959 .checked_sub(parent_level)
922960 .filter(|depth| *depth > 0)
923 .ok_or(OpError::Unsupported("A paragraph lies deeper than its parent"))?;
924 targets.entry(parent).or_default().push((paragraph.id, depth));
961 .ok_or(OpError::Unsupported(
962 "A paragraph lies deeper than its parent",
963 ))?;
964 targets
965 .entry(parent)
966 .or_default()
967 .push((paragraph.id, depth));
925968 self.paragraph(holder, anchor, paragraph, None)?;
926969 }
927970 for (parent, targets) in targets {
......@@ -966,13 +1009,16 @@ impl<'a> Writer<'_, 'a> {
9661009 match &paragraph.content {
9671010 ParagraphContent::Text(target) => {
9681011 if equation {
969 return self.write(|page| content::equation_changes(page, text_id, &target.text));
1012 return self
1013 .write(|page| content::equation_changes(page, text_id, &target.text));
9701014 }
9711015 let page = self.page()?;
9721016 let stored = crate::page::text_of(&page.view, &page.parents, text_id)?;
9731017 let values = paragraph_fields(&stored, &target.text)?.values()?;
9741018 if !values.is_empty() {
975 self.write(|page| properties::paragraph_format_changes(page, text_id, &values))?;
1019 self.write(|page| {
1020 properties::paragraph_format_changes(page, text_id, &values)
1021 })?;
9761022 }
9771023 let page = self.page()?;
9781024 let stored = crate::page::text_of(&page.view, &page.parents, text_id)?;
......@@ -996,12 +1042,19 @@ impl<'a> Writer<'_, 'a> {
9961042 .rows
9971043 .iter()
9981044 .flat_map(|row| &row.cells)
999 .map(|cell| (cell.id, (table::cell_indents(&cell.indents, None), cell.shading)))
1045 .map(|cell| {
1046 (
1047 cell.id,
1048 (table::cell_indents(&cell.indents, None), cell.shading),
1049 )
1050 })
10001051 .collect(),
10011052 columns: &table.columns,
10021053 borders: table.borders,
10031054 };
1004 self.write(|page| table::table_changes(page, table.id, Some(paragraph.id), &structure))?;
1055 self.write(|page| {
1056 table::table_changes(page, table.id, Some(paragraph.id), &structure)
1057 })?;
10051058 for cell in table.rows.iter().flat_map(|row| &row.cells) {
10061059 self.cell(cell)?;
10071060 }
......@@ -1009,7 +1062,10 @@ impl<'a> Writer<'_, 'a> {
10091062 }
10101063 ParagraphContent::Image(image) => {
10111064 if image.layout.x.is_some() || image.layout.y.is_some() {
1012 return Err(OpError::Unsupported("A paragraph picture has no position of its own").into());
1065 return Err(OpError::Unsupported(
1066 "A paragraph picture has no position of its own",
1067 )
1068 .into());
10131069 }
10141070 self.picture(image, Some(paragraph.id))
10151071 }
......@@ -1065,7 +1121,11 @@ impl<'a> Writer<'_, 'a> {
10651121 self.free(stroke.id)?;
10661122 }
10671123 let data = fresh()?;
1068 let strokes: Vec<_> = ink.strokes.iter().map(|stroke| (stroke.id, stroke)).collect();
1124 let strokes: Vec<_> = ink
1125 .strokes
1126 .iter()
1127 .map(|stroke| (stroke.id, stroke))
1128 .collect();
10691129 self.write(|page| content::ink_changes(page, ink, ink.id, data, &strokes, holder))
10701130 }
10711131
......@@ -1104,7 +1164,9 @@ impl<'a> Writer<'_, 'a> {
11041164 .level
11051165 .checked_sub(parent_level)
11061166 .filter(|depth| *depth > 0)
1107 .ok_or(OpError::Unsupported("A paragraph lies deeper than its parent"))?;
1167 .ok_or(OpError::Unsupported(
1168 "A paragraph lies deeper than its parent",
1169 ))?;
11081170 self.paragraph(parent, None, paragraph, None)?;
11091171 if parent == outline.id {
11101172 targets.push((paragraph.id, depth));
......@@ -1128,8 +1190,9 @@ impl<'a> Writer<'_, 'a> {
11281190 let indents = content::measurement_bytes(&outline.indents, 4)?;
11291191 let id = outline.id;
11301192 self.write(|page| {
1131 let mut node =
1132 crate::write::PropertyObject::from_object(&page.live.revision.objects[&id])?;
1193 let mut node = crate::write::PropertyObject::from_object(
1194 &page.live.revision.objects[&id],
1195 )?;
11331196 node.set(&[(0x1c001c12, &indents)])?;
11341197 Ok(BTreeMap::from([(id, node)]))
11351198 })?;
......@@ -1137,13 +1200,17 @@ impl<'a> Writer<'_, 'a> {
11371200 }
11381201 PageObject::Image(image) => {
11391202 if image.layout.x.is_none() || image.layout.y.is_none() {
1140 return Err(OpError::Unsupported("A new page-level picture needs a position").into());
1203 return Err(
1204 OpError::Unsupported("A new page-level picture needs a position").into(),
1205 );
11411206 }
11421207 self.picture(image, None)?;
11431208 }
11441209 PageObject::Ink(ink) => self.ink(ink, None)?,
11451210 PageObject::Title(_) | PageObject::Unsupported(_) => {
1146 return Err(OpError::Unsupported("Titles and unsupported objects cannot be added").into());
1211 return Err(
1212 OpError::Unsupported("Titles and unsupported objects cannot be added").into(),
1213 );
11471214 }
11481215 }
11491216 if before.is_some() {
......@@ -1178,13 +1245,13 @@ impl<'a> Writer<'_, 'a> {
11781245 .map(|row| (*row, page.view.nodes[row].children.clone()))
11791246 .collect();
11801247 let mut borders = *borders;
1181 let template = rows
1182 .first()
1183 .and_then(|(_, cells)| cells.first())
1184 .and_then(|cell| match &page.view.nodes[cell].kind {
1185 Kind::Cell { indents, .. } => Some(indents.clone()),
1186 _ => None,
1187 });
1248 let template =
1249 rows.first()
1250 .and_then(|(_, cells)| cells.first())
1251 .and_then(|cell| match &page.view.nodes[cell].kind {
1252 Kind::Cell { indents, .. } => Some(indents.clone()),
1253 _ => None,
1254 });
11881255 let mut new_rows = BTreeSet::new();
11891256 let mut new_cells: BTreeMap<ExGuid, (Vec<f32>, Option<u32>)> = BTreeMap::new();
11901257 let mut filled: Vec<&TableCell> = Vec::new();
......@@ -1195,17 +1262,22 @@ impl<'a> Writer<'_, 'a> {
11951262 )
11961263 };
11971264 match edit {
1198 TableEdit::Rows { before, rows: added } => {
1265 TableEdit::Rows {
1266 before,
1267 rows: added,
1268 } => {
11991269 let at = match before {
1200 Some(before) => rows
1201 .iter()
1202 .position(|(row, _)| row == before)
1203 .ok_or(OpError::StructureChanged("The anchor row is not in the table"))?,
1270 Some(before) => rows.iter().position(|(row, _)| row == before).ok_or(
1271 OpError::StructureChanged("The anchor row is not in the table"),
1272 )?,
12041273 None => rows.len(),
12051274 };
12061275 for row in added {
12071276 if row.cells.len() != columns.len() {
1208 return Err(OpError::Unsupported("Every table row has one cell per column").into());
1277 return Err(OpError::Unsupported(
1278 "Every table row has one cell per column",
1279 )
1280 .into());
12091281 }
12101282 new_rows.insert(row.id);
12111283 for cell in &row.cells {
......@@ -1245,13 +1317,18 @@ impl<'a> Writer<'_, 'a> {
12451317 return Err(OpError::TargetUnavailable(*row).into());
12461318 }
12471319 if rows.is_empty() {
1248 return Err(OpError::Unsupported("A table keeps a row; delete the table instead").into());
1320 return Err(OpError::Unsupported(
1321 "A table keeps a row; delete the table instead",
1322 )
1323 .into());
12491324 }
12501325 }
12511326 TableEdit::DeleteColumn(at) => {
12521327 let at = *at as usize;
12531328 if at >= columns.len() || columns.len() == 1 {
1254 return Err(OpError::Unsupported("Choose a column of a table with several").into());
1329 return Err(
1330 OpError::Unsupported("Choose a column of a table with several").into(),
1331 );
12551332 }
12561333 columns.remove(at);
12571334 for (_, cells) in &mut rows {
......@@ -1294,7 +1371,10 @@ impl<'a> Writer<'_, 'a> {
12941371 for row in &new_rows {
12951372 self.free(*row)?;
12961373 }
1297 if columns.iter().any(|c| !c.width.is_finite() || c.width < 36.0) {
1374 if columns
1375 .iter()
1376 .any(|c| !c.width.is_finite() || c.width < 36.0)
1377 {
12981378 return Err(OpError::Unsupported("Table columns are at least 36 points wide").into());
12991379 }
13001380 let structure = Structure {
......@@ -1348,7 +1428,11 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> {
13481428 if !table.tags.is_empty() {
13491429 return Err(OpError::Unsupported("Set a table's tags with their own op"));
13501430 }
1351 for paragraph in table.rows.iter().flat_map(|row| &row.cells).flat_map(|cell| &cell.paragraphs)
1431 for paragraph in table
1432 .rows
1433 .iter()
1434 .flat_map(|row| &row.cells)
1435 .flat_map(|cell| &cell.paragraphs)
13521436 {
13531437 bare(paragraph)?;
13541438 }
......@@ -1364,7 +1448,9 @@ fn bare(paragraph: &PageParagraph) -> Result<(), OpError> {
13641448/// The field code OneNote stores before a hyperlink's label.
13651449fn field_code(target: &str) -> Result<String, Error> {
13661450 if target.is_empty() || target.contains(['"', '\0', '\r', '\n', '\u{fddf}']) {
1367 return Err(invalid("Choose a link target without quotes or line breaks"));
1451 return Err(invalid(
1452 "Choose a link target without quotes or line breaks",
1453 ));
13681454 }
13691455 Ok(format!("\u{fddf}HYPERLINK \"{target}\""))
13701456}
......@@ -1449,6 +1535,8 @@ pub(crate) fn link_ops(
14491535 clear: vec![TextProperty::Hyperlink, TextProperty::HyperlinkLabel],
14501536 }),
14511537 }
1452 ops.retain(|op| !matches!(op, PageOp::Format { set, clear, .. } if set.is_empty() && clear.is_empty()));
1538 ops.retain(
1539 |op| !matches!(op, PageOp::Format { set, clear, .. } if set.is_empty() && clear.is_empty()),
1540 );
14531541 Ok(ops)
14541542}
crates/onestore/src/op/content.rs+16-9
......@@ -184,7 +184,11 @@ fn layout_values(stored: &Layout, layout: &Layout) -> Result<(Values, Vec<u32>),
184184 Ok((values, removed))
185185}
186186
187fn icon_size(values: &mut Values, size: Option<[f32; 2]>, message: &'static str) -> Result<(), Error> {
187fn icon_size(
188 values: &mut Values,
189 size: Option<[f32; 2]>,
190 message: &'static str,
191) -> Result<(), Error> {
188192 if let Some([width, height]) = size {
189193 if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
190194 return Err(invalid(message));
......@@ -332,7 +336,11 @@ pub(crate) fn attachment_changes(
332336 .unwrap_or("");
333337 let modified = crate::create::current_timestamps()?.0.to_le_bytes();
334338 let mut values: Values = vec![(0x14001d7a, modified.to_vec())];
335 icon_size(&mut values, attachment.size, "Attachment icon size must be positive")?;
339 icon_size(
340 &mut values,
341 attachment.size,
342 "Attachment icon size must be positive",
343 )?;
336344 values.push((0x14001c3b, 0x409_u32.to_le_bytes().to_vec()));
337345 values.push((0x10001cfe, 0x409_u16.to_le_bytes().to_vec()));
338346 values.push((0x1c001dcf, vec![0; 32]));
......@@ -412,7 +420,11 @@ pub(crate) fn attachment_edit_changes(
412420 }
413421 if attachment.size != stored.size {
414422 match attachment.size {
415 Some(_) => icon_size(&mut values, attachment.size, "Attachment icon size must be positive")?,
423 Some(_) => icon_size(
424 &mut values,
425 attachment.size,
426 "Attachment icon size must be positive",
427 )?,
416428 None => removed.extend([0x140034cd, 0x140034ce]),
417429 }
418430 }
......@@ -612,12 +624,7 @@ pub(crate) fn strokes_changes(
612624 kept: &[ExGuid],
613625 added: &[(ExGuid, &InkStroke)],
614626) -> Result<Changes, Error> {
615 let data = match active
616 .view
617 .nodes
618 .get(&container)
619 .map(|node| &node.kind)
620 {
627 let data = match active.view.nodes.get(&container).map(|node| &node.kind) {
621628 Some(crate::document::Kind::Ink {
622629 data: Some(data), ..
623630 }) => *data,
crates/onestore/src/op/levels.rs+5-1
......@@ -37,7 +37,11 @@ pub(crate) type Runs = Vec<(u32, Vec<ExGuid>)>;
3737
3838/// The child level of a container whose children lie `depths` deeper than it, and its
3939/// children in runs.
40pub(crate) fn runs(children: &[ExGuid], depths: &[u32], is_cell: bool) -> Result<(u32, Runs), Error> {
40pub(crate) fn runs(
41 children: &[ExGuid],
42 depths: &[u32],
43 is_cell: bool,
44) -> Result<(u32, Runs), Error> {
4145 let level = *depths
4246 .iter()
4347 .min()
crates/onestore/src/op/lower.rs+46-17
......@@ -577,7 +577,9 @@ fn attributes(
577577 }
578578 let color =
579579 |value: u32| (value != 0xff000000).then(|| value.to_le_bytes()[..3].try_into().unwrap());
580 value!(font, Font, |font: &String| TextAttribute::Font(font.clone()));
580 value!(font, Font, |font: &String| TextAttribute::Font(
581 font.clone()
582 ));
581583 value!(font_size, FontSize, |size: &f32| TextAttribute::FontSize(
582584 *size
583585 ));
......@@ -690,9 +692,11 @@ pub(crate) fn same_tags(a: &[Tag], b: &[Tag]) -> bool {
690692fn ordinary(text: &TextObject) -> bool {
691693 text.date_field.is_none()
692694 && !text.text.text().contains('\u{fffc}')
693 && text.text.spans().iter().all(|span| {
694 span.format.math != Some(true) && span.format.embedded_object != Some(true)
695 })
695 && text
696 .text
697 .spans()
698 .iter()
699 .all(|span| span.format.math != Some(true) && span.format.embedded_object != Some(true))
696700}
697701
698702/// Whether a split at `offset` would divide a hyperlink, which the writer refuses.
......@@ -936,7 +940,13 @@ impl Lowering {
936940 continue;
937941 }
938942 let adopts_right = previous.text.text().is_empty();
939 if after.id != if adopts_right { removed.id } else { previous.id } {
943 if after.id
944 != if adopts_right {
945 removed.id
946 } else {
947 previous.id
948 }
949 {
940950 continue;
941951 }
942952 self.emit(PageOp::Join {
......@@ -1025,7 +1035,11 @@ impl Lowering {
10251035 }
10261036 let object = match object {
10271037 PageObject::Outline(outline) => {
1028 if outline.paragraphs.iter().any(|p| old.paragraphs.contains_key(&p.id)) {
1038 if outline
1039 .paragraphs
1040 .iter()
1041 .any(|p| old.paragraphs.contains_key(&p.id))
1042 {
10291043 return Err(invalid(
10301044 "A new outline holds new paragraphs; move stored ones after",
10311045 ));
......@@ -1233,11 +1247,21 @@ impl Lowering {
12331247 let mut deleted: Option<Vec<u32>> = None;
12341248 let mut added: Option<Vec<u32>> = None;
12351249 for row in &kept_rows {
1236 let old_cells: Vec<ExGuid> = before.rows.iter().find(|r| r.id == *row).unwrap().cells
1250 let old_cells: Vec<ExGuid> = before
1251 .rows
1252 .iter()
1253 .find(|r| r.id == *row)
1254 .unwrap()
1255 .cells
12371256 .iter()
12381257 .map(|cell| cell.id)
12391258 .collect();
1240 let new_cells: Vec<ExGuid> = after.rows.iter().find(|r| r.id == *row).unwrap().cells
1259 let new_cells: Vec<ExGuid> = after
1260 .rows
1261 .iter()
1262 .find(|r| r.id == *row)
1263 .unwrap()
1264 .cells
12411265 .iter()
12421266 .map(|cell| cell.id)
12431267 .collect();
......@@ -1382,9 +1406,10 @@ impl Lowering {
13821406 (ParagraphContent::Ink(stored), ParagraphContent::Ink(ink)) if stored != ink => {
13831407 self.strokes(stored, ink)?;
13841408 }
1385 (ParagraphContent::Attachment(stored), ParagraphContent::Attachment(attachment))
1386 if stored != attachment =>
1387 {
1409 (
1410 ParagraphContent::Attachment(stored),
1411 ParagraphContent::Attachment(attachment),
1412 ) if stored != attachment => {
13881413 self.emit(PageOp::Attachment {
13891414 attachment: attachment.id,
13901415 filename: attachment.filename.clone(),
......@@ -1520,8 +1545,8 @@ impl Lowering {
15201545 {
15211546 continue;
15221547 }
1523 let stored =
1524 stored.ok_or_else(|| invalid("An equation paragraph is missing after placement"))?;
1548 let stored = stored
1549 .ok_or_else(|| invalid("An equation paragraph is missing after placement"))?;
15251550 if stored.text != text.text {
15261551 self.emit(PageOp::Equation {
15271552 text: stored.id,
......@@ -1611,10 +1636,14 @@ impl Lowering {
16111636 let current = model::paragraph(&self.current, *id)
16121637 .ok_or_else(|| invalid("A paragraph is missing after text edits"))?;
16131638 let same = paragraph.lists.len() == current.lists.len()
1614 && paragraph.lists.iter().zip(&current.lists).all(|(model, node)| {
1615 model == node
1616 && after.definitions.get(model) == self.current.definitions.get(node)
1617 });
1639 && paragraph
1640 .lists
1641 .iter()
1642 .zip(&current.lists)
1643 .all(|(model, node)| {
1644 model == node
1645 && after.definitions.get(model) == self.current.definitions.get(node)
1646 });
16181647 if same {
16191648 continue;
16201649 }
crates/onestore/src/op/mod.rs+57-9
......@@ -5,13 +5,16 @@
55use crate::{
66 ExGuid, OutlineEdit, PageCreation, PageEdit, TextAttribute,
77 document::{Layout, Tag},
8 page::{Definition, InkStroke, Page, PageObject, PageParagraph, Paragraph, TableCell,
9 TableColumn, TableRow},
8 page::{
9 Definition, InkStroke, Page, PageObject, PageParagraph, Paragraph, TableCell, TableColumn,
10 TableRow,
11 },
1012};
1113use serde::{Deserialize, Serialize};
1214use std::ops::Range;
1315
1416mod apply;
17pub(crate) use apply::Failure;
1518pub(crate) mod content;
1619pub(crate) mod levels;
1720pub(crate) mod lower;
......@@ -73,7 +76,10 @@ pub enum PageOp {
7376 target: Option<String>,
7477 },
7578 /// Rewrites an equation's linear text and spans whole.
76 Equation { text: ExGuid, math: Paragraph },
79 Equation {
80 text: ExGuid,
81 math: Paragraph,
82 },
7783 /// The page's date: its creation time, FILETIME, and the text each of the title's date
7884 /// and time fields shows for it, as OneNote 2010 writes both when the date changes.
7985 Date {
......@@ -101,7 +107,10 @@ pub enum PageOp {
101107 },
102108 /// Appends the right text to the left one and removes the right paragraph; an empty
103109 /// left text is replaced by the right text object.
104 Join { left: ExGuid, right: ExGuid },
110 Join {
111 left: ExGuid,
112 right: ExGuid,
113 },
105114 /// Moves a subtree before a direct child of `parent`, or last; `None` names the page,
106115 /// whose children are outlines, pictures and ink. A paragraph keeps its level where it
107116 /// lies deeper than its new parent.
......@@ -111,11 +120,19 @@ pub enum PageOp {
111120 before: Option<ExGuid>,
112121 },
113122 /// Removes a subtree.
114 Delete { object: ExGuid },
123 Delete {
124 object: ExGuid,
125 },
115126 /// Sets a paragraph's absolute outline level, regrouping its container.
116 Level { paragraph: ExGuid, level: u32 },
127 Level {
128 paragraph: ExGuid,
129 level: u32,
130 },
117131 /// Outline position and width, or a paragraph's saved expansion state.
118 Outline { object: ExGuid, edit: OutlineEdit },
132 Outline {
133 object: ExGuid,
134 edit: OutlineEdit,
135 },
119136 /// Paragraph formatting stored on a paragraph's text; `None` leaves a value as it is.
120137 Paragraph {
121138 paragraph: ExGuid,
......@@ -169,7 +186,10 @@ pub enum PageOp {
169186 add: Vec<InkStroke>,
170187 remove: Vec<ExGuid>,
171188 },
172 Table { table: ExGuid, edit: TableEdit },
189 Table {
190 table: ExGuid,
191 edit: TableEdit,
192 },
173193}
174194
175195#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
......@@ -205,7 +225,10 @@ pub enum SectionOp {
205225 Create(PageCreation),
206226 /// A page created with `creation` holding `page`'s content under the identities it
207227 /// carries (`Page::copy` gives fresh ones); the created title stays.
208 Import { creation: PageCreation, page: Page },
228 Import {
229 creation: PageCreation,
230 page: Page,
231 },
209232 /// A read-only conflict page of the listed page `of`, as OneNote 2010 keeps the version of
210233 /// a page a merge could not take: created with `creation` (not placed, its author the user
211234 /// whose version it is, a title only where `page` has one) and holding `page` as `Import`
......@@ -223,6 +246,31 @@ pub enum SectionOp {
223246 /// The section's colour as COLORREF, kept in its own metadata; `None` is OneNote's
224247 /// "no colour".
225248 Color(Option<u32>),
249 /// Makes a version of a page (`Section::versions`) its current state, as OneNote 2010's
250 /// Restore Version does; the page as it stood becomes the newest version. New objects take
251 /// identities from `guid`.
252 RestoreVersion {
253 page: ExGuid,
254 version: ExGuid,
255 guid: [u8; 16],
256 },
257 /// Deletes versions of a page, as OneNote 2010's Delete Version does; their revisions stay
258 /// stored.
259 DeleteVersions {
260 page: ExGuid,
261 versions: Vec<ExGuid>,
262 },
263}
264
265impl SectionOp {
266 /// `RestoreVersion` of `page`'s `version`, its new objects under a fresh identity.
267 pub fn restore(page: ExGuid, version: ExGuid) -> Result<Self, crate::Error> {
268 Ok(Self::RestoreVersion {
269 page,
270 version,
271 guid: crate::write::fresh_guid()?,
272 })
273 }
226274}
227275
228276/// A character property that `PageOp::Format` can clear so the text inherits it.
crates/onestore/src/op/model.rs+102-41
......@@ -53,7 +53,12 @@ pub(crate) fn lists(page: &Page) -> Vec<(Path, ExGuid, &Vec<PageParagraph>)> {
5353 for (object, value) in page.objects.iter().enumerate() {
5454 let outlines: Vec<(Option<usize>, &Outline)> = match value {
5555 PageObject::Outline(outline) => vec![(None, outline)],
56 PageObject::Title(title) => title.outlines.iter().enumerate().map(|(i, o)| (Some(i), o)).collect(),
56 PageObject::Title(title) => title
57 .outlines
58 .iter()
59 .enumerate()
60 .map(|(i, o)| (Some(i), o))
61 .collect(),
5762 _ => Vec::new(),
5863 };
5964 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
8893/// The list holding paragraph `id`, and its index there.
8994fn find(page: &Page, id: ExGuid) -> Option<(Path, usize)> {
9095 lists(page).into_iter().find_map(|(path, _, list)| {
91 list.iter().position(|p| p.id == id).map(|index| (path, index))
96 list.iter()
97 .position(|p| p.id == id)
98 .map(|index| (path, index))
9299 })
93100}
94101
95102/// The list a container's children lie in: its own for an outline or cell, the one
96103/// holding it for a paragraph.
97104fn container(page: &Page, id: ExGuid) -> Option<(Path, Option<usize>)> {
98 lists(page)
99 .into_iter()
100 .find_map(|(path, owner, list)| {
101 if owner == id {
102 Some((path, None))
103 } else {
104 list.iter()
105 .position(|p| p.id == id)
106 .map(|index| (path, Some(index)))
107 }
108 })
105 lists(page).into_iter().find_map(|(path, owner, list)| {
106 if owner == id {
107 Some((path, None))
108 } else {
109 list.iter()
110 .position(|p| p.id == id)
111 .map(|index| (path, Some(index)))
112 }
113 })
109114}
110115
111116/// 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> {
227232}
228233
229234fn text_mut(page: &mut Page, text: ExGuid) -> Result<&mut TextObject, Error> {
230 let (path, index) = text_holder(page, text).ok_or_else(|| invalid("Text is not on the page"))?;
235 let (path, index) =
236 text_holder(page, text).ok_or_else(|| invalid("Text is not on the page"))?;
231237 Ok(list_at(page, &path)[index].text_mut().unwrap())
232238}
233239
......@@ -269,7 +275,10 @@ pub(crate) fn replace_text(
269275 let format = format_in(text, range.start)?.clone();
270276 let removed = range.end - range.start;
271277 if length - removed == 0 && with.is_empty() {
272 return Ok(Paragraph::new(String::new(), text.spans().last().unwrap().format.clone()));
278 return Ok(Paragraph::new(
279 String::new(),
280 text.spans().last().unwrap().format.clone(),
281 ));
273282 }
274283 let mut result = text.clone();
275284 result.apply(crate::page::text::Edit {
......@@ -373,7 +382,10 @@ pub(crate) fn format_text(
373382 format
374383 };
375384 if text.text().is_empty() {
376 return Ok(Paragraph::new(String::new(), apply(&text.spans()[0].format)));
385 return Ok(Paragraph::new(
386 String::new(),
387 apply(&text.spans()[0].format),
388 ));
377389 }
378390 let (start, end) = (text.byte_offset(range.start)?, text.byte_offset(range.end)?);
379391 let mut runs = Vec::new();
......@@ -381,11 +393,19 @@ pub(crate) fn format_text(
381393 for span in text.spans() {
382394 for (from, to, selected) in [
383395 (previous, start.clamp(previous, span.end), false),
384 (start.clamp(previous, span.end), end.clamp(previous, span.end), true),
396 (
397 start.clamp(previous, span.end),
398 end.clamp(previous, span.end),
399 true,
400 ),
385401 (end.clamp(previous, span.end), span.end, false),
386402 ] {
387403 if from < to {
388 let format = if selected { apply(&span.format) } else { span.format.clone() };
404 let format = if selected {
405 apply(&span.format)
406 } else {
407 span.format.clone()
408 };
389409 runs.push((text.text()[from..to].to_owned(), format));
390410 }
391411 }
......@@ -401,7 +421,9 @@ fn inserted(text: &Paragraph) -> Paragraph {
401421 macro_rules! shared {
402422 ($field:ident, $type:ty, $store:expr) => {{
403423 let first = spans[0].format.$field.unwrap_or_default();
404 (spans.iter().all(|s| s.format.$field.unwrap_or_default() == first)
424 (spans
425 .iter()
426 .all(|s| s.format.$field.unwrap_or_default() == first)
405427 && first != <$type>::default())
406428 .then(|| $store(first))
407429 }};
......@@ -485,8 +507,8 @@ fn insert(
485507 before: Option<ExGuid>,
486508 paragraphs: Vec<PageParagraph>,
487509) -> Result<(), Error> {
488 let (path, owner) =
489 self::container(page, container).ok_or_else(|| invalid("The container is not on the page"))?;
510 let (path, owner) = self::container(page, container)
511 .ok_or_else(|| invalid("The container is not on the page"))?;
490512 let list = list_at(page, &path);
491513 let at = match before {
492514 Some(before) => list
......@@ -519,7 +541,9 @@ fn relevel(list: &mut [PageParagraph], index: usize, level: u32) {
519541/// The title a page's writers store after changing it: the title text's first line when
520542/// it has one, which only an edit of that text changes, else the first line of body text.
521543fn retitle(page: &mut Page, edited: Option<ExGuid>) {
522 let line = |text: &str| text.trim_start().split('\r').next().unwrap_or_default().to_owned();
544 let line = |text: &str| {
545 crate::edit::without_fields(text.trim_start().split('\r').next().unwrap_or_default())
546 };
523547 match find_title_text(page) {
524548 Some(title) => {
525549 let text = text_of(page, title);
......@@ -545,7 +569,7 @@ fn automatic(page: &mut Page) {
545569 .total_cmp(&b.y.unwrap_or(0.0))
546570 .then_with(|| a.x.unwrap_or(0.0).total_cmp(&b.x.unwrap_or(0.0)))
547571 });
548 fn first(list: &[PageParagraph]) -> Option<&str> {
572 fn first(list: &[PageParagraph]) -> Option<String> {
549573 list.iter().find_map(|paragraph| match &paragraph.content {
550574 ParagraphContent::Text(text) => {
551575 Some(crate::edit::automatic_title(text.text.text())).filter(|t| !t.is_empty())
......@@ -564,8 +588,7 @@ fn automatic(page: &mut Page) {
564588 PageObject::Outline(outline) => first(&outline.paragraphs),
565589 _ => None,
566590 })
567 .unwrap_or_default()
568 .to_owned();
591 .unwrap_or_default();
569592 page.title = title;
570593}
571594
......@@ -650,7 +673,10 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
650673 let object = paragraph.text_mut().unwrap();
651674 let previous = object.text.spans()[0].format.clone();
652675 let math = Paragraph::from_runs(math.spans().iter().scan(0, |start, span| {
653 let run = (math.text()[*start..span.end].to_owned(), span.format.inherit(&inherited));
676 let run = (
677 math.text()[*start..span.end].to_owned(),
678 span.format.inherit(&inherited),
679 );
654680 *start = span.end;
655681 Some(run)
656682 }));
......@@ -671,7 +697,8 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
671697 right,
672698 lists,
673699 } => {
674 let (path, index) = text_holder(page, *text).ok_or_else(|| invalid("Text is not on the page"))?;
700 let (path, index) =
701 text_holder(page, *text).ok_or_else(|| invalid("Text is not on the page"))?;
675702 let definitions: Vec<_> = {
676703 let list = list_at(page, &path);
677704 list[index].lists.clone()
......@@ -722,7 +749,8 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
722749 retitle(page, None);
723750 }
724751 PageOp::Join { left, right } => {
725 let (path, index) = text_holder(page, *right).ok_or_else(|| invalid("Text is not on the page"))?;
752 let (path, index) =
753 text_holder(page, *right).ok_or_else(|| invalid("Text is not on the page"))?;
726754 let (left_path, left_index) =
727755 text_holder(page, *left).ok_or_else(|| invalid("Text is not on the page"))?;
728756 let list = list_at(page, &path);
......@@ -778,7 +806,10 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
778806 }
779807 }
780808 }
781 let run = (right_text.text.text()[previous..span.end].to_owned(), format);
809 let run = (
810 right_text.text.text()[previous..span.end].to_owned(),
811 format,
812 );
782813 previous = span.end;
783814 run
784815 }));
......@@ -849,7 +880,8 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
849880 retitle(page, None);
850881 return Ok(());
851882 };
852 let (path, index) = find(page, *object).ok_or_else(|| invalid("The paragraph is not on the page"))?;
883 let (path, index) =
884 find(page, *object).ok_or_else(|| invalid("The paragraph is not on the page"))?;
853885 let list = list_at(page, &path);
854886 let mut moved: Vec<PageParagraph> = list.drain(subtree(list, index)).collect();
855887 let into_cell = table_cells(page).contains(parent);
......@@ -884,7 +916,8 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
884916 retitle(page, None);
885917 }
886918 PageOp::Level { paragraph, level } => {
887 let (path, index) = find(page, *paragraph).ok_or_else(|| invalid("The paragraph is not on the page"))?;
919 let (path, index) = find(page, *paragraph)
920 .ok_or_else(|| invalid("The paragraph is not on the page"))?;
888921 relevel(list_at(page, &path), index, *level);
889922 }
890923 PageOp::Outline { object, edit } => match edit {
......@@ -967,10 +1000,31 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
9671000 }
9681001 )*};
9691002 }
970 restyle!(bold, italic, underline, strike, superscript, subscript, hidden,
971 hyperlink, hyperlink_label, math, embedded_object, font, font_size,
972 color, highlight, language, alignment, rtl, space_before, space_after,
973 line_spacing, list_spacing, math_object);
1003 restyle!(
1004 bold,
1005 italic,
1006 underline,
1007 strike,
1008 superscript,
1009 subscript,
1010 hidden,
1011 hyperlink,
1012 hyperlink_label,
1013 math,
1014 embedded_object,
1015 font,
1016 font_size,
1017 color,
1018 highlight,
1019 language,
1020 alignment,
1021 rtl,
1022 space_before,
1023 space_after,
1024 line_spacing,
1025 list_spacing,
1026 math_object
1027 );
9741028 let run = (text.text.text()[previous..span.end].to_owned(), format);
9751029 previous = span.end;
9761030 run
......@@ -1052,7 +1106,9 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
10521106 y: layout.y.map(stored),
10531107 max_width: layout.max_width.map(stored),
10541108 max_height: layout.max_height.map(stored),
1055 width_set_by_user: layout.max_width.map(|_| layout.width_set_by_user == Some(true)),
1109 width_set_by_user: layout
1110 .max_width
1111 .map(|_| layout.width_set_by_user == Some(true)),
10561112 reserved_width: image.layout.reserved_width,
10571113 };
10581114 image.alt = alt.clone();
......@@ -1071,7 +1127,8 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
10711127 .map(|index| (path, index))
10721128 })
10731129 .ok_or_else(|| invalid("The attachment is not on the page"))?;
1074 let ParagraphContent::Attachment(stored_attachment) = &mut list_at(page, &path)[index].content
1130 let ParagraphContent::Attachment(stored_attachment) =
1131 &mut list_at(page, &path)[index].content
10751132 else {
10761133 unreachable!()
10771134 };
......@@ -1170,8 +1227,9 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
11701227
11711228/// Removes outlines a tree edit left without paragraphs, as the tree writer does.
11721229fn drop_emptied(page: &mut Page) {
1173 page.objects
1174 .retain(|object| !matches!(object, PageObject::Outline(outline) if outline.paragraphs.is_empty()));
1230 page.objects.retain(
1231 |object| !matches!(object, PageObject::Outline(outline) if outline.paragraphs.is_empty()),
1232 );
11751233}
11761234
11771235/// 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 {
12831341 lists(page)
12841342 .into_iter()
12851343 .find_map(|(_, _, list)| {
1286 list.iter()
1287 .find_map(|p| p.text().filter(|t| t.id == text).map(|t| t.text.text().to_owned()))
1344 list.iter().find_map(|p| {
1345 p.text()
1346 .filter(|t| t.id == text)
1347 .map(|t| t.text.text().to_owned())
1348 })
12881349 })
12891350 .unwrap_or_default()
12901351}
crates/onestore/src/op/properties.rs+2-2
......@@ -258,8 +258,8 @@ pub(crate) fn style_changes(
258258 let raw = &active.live.revision;
259259 let mut changed = BTreeMap::new();
260260 if !raw.objects.contains_key(&style) {
261 let definition =
262 definition.ok_or_else(|| invalid("A paragraph references a missing style definition"))?;
261 let definition = definition
262 .ok_or_else(|| invalid("A paragraph references a missing style definition"))?;
263263 let Kind::Style { name } = &definition.kind else {
264264 return Err(invalid("A paragraph style must be a style definition"));
265265 };
crates/onestore/src/op/tests.rs+600-138
......@@ -15,21 +15,66 @@ use crate::{
1515use std::collections::{BTreeMap, BTreeSet};
1616
1717const SOURCES: &[(&str, &[u8])] = &[
18 ("outline-edit", include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one")),
19 ("paragraph-edit", include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one")),
20 ("outline-tree", include_bytes!("../../../../corpus/outline-edit/tree/before/notebook/synthetic.one")),
21 ("features", include_bytes!("../../../../corpus/m6/native-features-01/notebook/Features.one")),
22 ("tables", include_bytes!("../../../../corpus/table-edit/nested/cold/notebook/synthetic.one")),
23 ("table-controls", include_bytes!("../../../../corpus/m6/native-table-controls-01/notebook/synthetic.one")),
24 ("lists", include_bytes!("../../../../corpus/list-edit/cold/notebook/lists.one")),
25 ("tags", include_bytes!("../../../../corpus/tag-edit/cold/notebook/tags.one")),
26 ("pictures", include_bytes!("../../../../corpus/picture-edit/native-page-level/notebook/pictures.one")),
27 ("attachments", include_bytes!("../../../../corpus/attachment-edit/plain/cold/notebook/files.one")),
28 ("ink", include_bytes!("../../../../corpus/ink-edit/drawing/cold/notebook/ink.one")),
29 ("math", include_bytes!("../../../../corpus/math-edit/native-editor/notebook/links.one")),
30 ("links", include_bytes!("../../../../corpus/link-edit/native-links/notebook/links.one")),
31 ("paragraph-format", include_bytes!("../../../../corpus/paragraph-format/cold/notebook/synthetic.one")),
32 ("enter-probe", include_bytes!("../../../../corpus/structural-probe/probe.one")),
18 (
19 "outline-edit",
20 include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"),
21 ),
22 (
23 "paragraph-edit",
24 include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one"),
25 ),
26 (
27 "outline-tree",
28 include_bytes!("../../../../corpus/outline-edit/tree/before/notebook/synthetic.one"),
29 ),
30 (
31 "features",
32 include_bytes!("../../../../corpus/m6/native-features-01/notebook/Features.one"),
33 ),
34 (
35 "tables",
36 include_bytes!("../../../../corpus/table-edit/nested/cold/notebook/synthetic.one"),
37 ),
38 (
39 "table-controls",
40 include_bytes!("../../../../corpus/m6/native-table-controls-01/notebook/synthetic.one"),
41 ),
42 (
43 "lists",
44 include_bytes!("../../../../corpus/list-edit/cold/notebook/lists.one"),
45 ),
46 (
47 "tags",
48 include_bytes!("../../../../corpus/tag-edit/cold/notebook/tags.one"),
49 ),
50 (
51 "pictures",
52 include_bytes!("../../../../corpus/picture-edit/native-page-level/notebook/pictures.one"),
53 ),
54 (
55 "attachments",
56 include_bytes!("../../../../corpus/attachment-edit/plain/cold/notebook/files.one"),
57 ),
58 (
59 "ink",
60 include_bytes!("../../../../corpus/ink-edit/drawing/cold/notebook/ink.one"),
61 ),
62 (
63 "math",
64 include_bytes!("../../../../corpus/math-edit/native-editor/notebook/links.one"),
65 ),
66 (
67 "links",
68 include_bytes!("../../../../corpus/link-edit/native-links/notebook/links.one"),
69 ),
70 (
71 "paragraph-format",
72 include_bytes!("../../../../corpus/paragraph-format/cold/notebook/synthetic.one"),
73 ),
74 (
75 "enter-probe",
76 include_bytes!("../../../../corpus/structural-probe/probe.one"),
77 ),
3378];
3479
3580/// FILETIME both writers take as the edit's time.
......@@ -55,7 +100,15 @@ impl Rng {
55100 }
56101
57102 fn text(&mut self) -> String {
58 let words = ["", "a", "Two words", "東京 🦀", "é\u{301}", "longer text here", " "];
103 let words = [
104 "",
105 "a",
106 "Two words",
107 "東京 🦀",
108 "é\u{301}",
109 "longer text here",
110 " ",
111 ];
59112 words[self.next() as usize % words.len()].to_owned()
60113 }
61114}
......@@ -92,7 +145,10 @@ pub(super) fn normalize(page: &Page) -> Page {
92145 let mut page = page.clone();
93146 let mut lists = BTreeMap::new();
94147 let mut canonical = BTreeMap::new();
95 let mut paths: Vec<_> = model::lists(&page).into_iter().map(|(path, _, _)| path).collect();
148 let mut paths: Vec<_> = model::lists(&page)
149 .into_iter()
150 .map(|(path, _, _)| path)
151 .collect();
96152 paths.sort_by_key(|path| format!("{path:?}"));
97153 let definitions = page.definitions.clone();
98154 for path in &paths {
......@@ -102,7 +158,9 @@ pub(super) fn normalize(page: &Page) -> Page {
102158 guid: [0xee; 16],
103159 n: 1 + canonical.len() as u32,
104160 };
105 let content = definitions.get(list).map_or(format!("{list:?}"), |d| format!("{d:?}"));
161 let content = definitions
162 .get(list)
163 .map_or(format!("{list:?}"), |d| format!("{d:?}"));
106164 let id = *canonical.entry(content).or_insert(next);
107165 *list = *lists.entry(*list).or_insert(id);
108166 }
......@@ -185,26 +243,33 @@ fn as_written(after: &Page, before: &Page, stored: &Page) -> Page {
185243 fn adopt(after: &mut Value, stored: &Value, old: &BTreeSet<String>, new: bool) {
186244 match (after, stored) {
187245 (Value::Object(after), Value::Object(stored)) => {
188 let new = new
189 || matches!(after.get("id"), Some(Value::String(id)) if !old.contains(id));
246 let new =
247 new || matches!(after.get("id"), Some(Value::String(id)) if !old.contains(id));
190248 for (key, value) in after.iter_mut() {
191249 let Some(written) = stored.get(key) else {
192250 continue;
193251 };
194252 // Ink coordinates are stored at a coarser resolution.
195253 let near = |a: &Value, b: &Value| {
196 a.as_f64().zip(b.as_f64()).is_some_and(|(a, b)| (a - b).abs() < 0.05)
254 a.as_f64()
255 .zip(b.as_f64())
256 .is_some_and(|(a, b)| (a - b).abs() < 0.05)
197257 };
198258 let close_points = key == "points"
199 && value.as_array().zip(written.as_array()).is_some_and(|(a, b)| {
200 a.len() == b.len()
201 && a.iter().zip(b).all(|(a, b)| {
202 a.as_array().zip(b.as_array()).is_some_and(|(a, b)| {
203 a.len() == b.len() && a.iter().zip(b).all(|(a, b)| near(a, b))
259 && value
260 .as_array()
261 .zip(written.as_array())
262 .is_some_and(|(a, b)| {
263 a.len() == b.len()
264 && a.iter().zip(b).all(|(a, b)| {
265 a.as_array().zip(b.as_array()).is_some_and(|(a, b)| {
266 a.len() == b.len()
267 && a.iter().zip(b).all(|(a, b)| near(a, b))
268 })
204269 })
205 })
206 });
207 let unset = new && (value.is_null() || value.as_array().is_some_and(Vec::is_empty));
270 });
271 let unset =
272 new && (value.is_null() || value.as_array().is_some_and(Vec::is_empty));
208273 if close_points || unset {
209274 *value = written.clone();
210275 } else {
......@@ -223,7 +288,12 @@ fn as_written(after: &Page, before: &Page, stored: &Page) -> Page {
223288 let mut old = BTreeSet::new();
224289 ids(&serde_json::to_value(before).unwrap(), &mut old);
225290 let mut value = serde_json::to_value(after).unwrap();
226 adopt(&mut value, &serde_json::to_value(stored).unwrap(), &old, false);
291 adopt(
292 &mut value,
293 &serde_json::to_value(stored).unwrap(),
294 &old,
295 false,
296 );
227297 let mut page: Page = serde_json::from_value(value).unwrap();
228298 page.title = stored.title.clone();
229299 unused_dropped(&mut page);
......@@ -323,11 +393,11 @@ pub(super) const FAMILIES: [Family; 33] = [
323393];
324394
325395const PNG: &[u8] = &[
326 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
327 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f,
328 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8,
329 0xcf, 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00,
330 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
396 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
397 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
398 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
399 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
400 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
331401];
332402
333403fn id() -> ExGuid {
......@@ -370,7 +440,9 @@ fn editable(page: &Page) -> Vec<model::Path> {
370440 .objects
371441 .iter()
372442 .filter_map(|o| match o {
373 PageObject::Title(title) => Some(title.outlines.iter().map(|o| o.id).collect::<Vec<_>>()),
443 PageObject::Title(title) => {
444 Some(title.outlines.iter().map(|o| o.id).collect::<Vec<_>>())
445 }
374446 _ => None,
375447 })
376448 .flatten()
......@@ -493,7 +565,13 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
493565 0 => format.bold = Some(!format.bold.unwrap_or(false)),
494566 1 => format.italic = Some(!format.italic.unwrap_or(false)),
495567 2 => format.underline = Some(!format.underline.unwrap_or(false)),
496 3 => format.font_size = Some(if format.font_size == Some(14.0) { 11.0 } else { 14.0 }),
568 3 => {
569 format.font_size = Some(if format.font_size == Some(14.0) {
570 11.0
571 } else {
572 14.0
573 })
574 }
497575 4 => format.color = Some(0x00ff),
498576 _ => format.font = Some("Consolas".into()),
499577 }
......@@ -510,7 +588,10 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
510588 }
511589 _ => {
512590 if range.is_empty()
513 || text.spans().iter().any(|s| s.format.hyperlink == Some(true))
591 || text
592 .spans()
593 .iter()
594 .any(|s| s.format.hyperlink == Some(true))
514595 {
515596 return false;
516597 }
......@@ -579,7 +660,11 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
579660 .filter(|i| list.get(*i).is_none_or(|p| p.parent.is_none()))
580661 .collect();
581662 let at = boundaries[rng.pick(boundaries.len()).unwrap()];
582 let count = if family == Family::Paste { 2 + rng.next() as usize % 3 } else { 1 };
663 let count = if family == Family::Paste {
664 2 + rng.next() as usize % 3
665 } else {
666 1
667 };
583668 let mut inserted = Vec::new();
584669 for i in 0..count {
585670 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 {
612697 return false;
613698 };
614699 let list = model::list_at(page, &path);
615 let top: Vec<usize> = (0..list.len()).filter(|i| list[*i].parent.is_none()).collect();
700 let top: Vec<usize> = (0..list.len())
701 .filter(|i| list[*i].parent.is_none())
702 .collect();
616703 match family {
617704 Family::Delete => {
618705 if top.len() < 2 {
......@@ -645,7 +732,8 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
645732 }
646733 }
647734 Family::Position | Family::Width => {
648 let mut outlines: Vec<&mut Outline> = outlines(page).into_iter().filter(|o| !o.title).collect();
735 let mut outlines: Vec<&mut Outline> =
736 outlines(page).into_iter().filter(|o| !o.title).collect();
649737 let Some(at) = rng.pick(outlines.len()) else {
650738 return false;
651739 };
......@@ -784,7 +872,11 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
784872 let paragraph = &list[index];
785873 let text = &paragraph.text().unwrap().text;
786874 let end = text.utf16_offset(text.text().len()).unwrap();
787 if text.spans().iter().any(|s| s.format.hyperlink == Some(true)) {
875 if text
876 .spans()
877 .iter()
878 .any(|s| s.format.hyperlink == Some(true))
879 {
788880 return false;
789881 }
790882 let offsets: Vec<u32> = text
......@@ -822,14 +914,18 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
822914 let upper = &list[index];
823915 if lower.parent != upper.parent
824916 || lower.level != upper.level
825 || list.iter().any(|p| p.parent == Some(upper.id) || p.parent == Some(lower.id))
917 || list
918 .iter()
919 .any(|p| p.parent == Some(upper.id) || p.parent == Some(lower.id))
826920 {
827921 return false;
828922 }
829923 let Some(lower_text) = lower.text().cloned() else {
830924 return false;
831925 };
832 if lower_text.text.text().is_empty() || crate::page::Math::is_equation(&lower_text.text) {
926 if lower_text.text.text().is_empty()
927 || crate::page::Math::is_equation(&lower_text.text)
928 {
833929 return false;
834930 }
835931 let upper = list[index].text_mut().unwrap();
......@@ -859,7 +955,12 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
859955 if family == Family::Indent {
860956 // Tab: the paragraph becomes the last child of its previous sibling.
861957 let parent = list[index].parent;
862 let Some(sibling) = list[..index].iter().rev().find(|p| p.parent == parent).map(|p| (p.id, p.level)) else {
958 let Some(sibling) = list[..index]
959 .iter()
960 .rev()
961 .find(|p| p.parent == parent)
962 .map(|p| (p.id, p.level))
963 else {
863964 return false;
864965 };
865966 list[index].parent = Some(sibling.0);
......@@ -912,7 +1013,13 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
9121013 }
9131014 Family::TableColumn => {
9141015 let at = rng.pick(table.columns.len() + 1).unwrap();
915 table.columns.insert(at, TableColumn { width: 72.0, locked: false });
1016 table.columns.insert(
1017 at,
1018 TableColumn {
1019 width: 72.0,
1020 locked: false,
1021 },
1022 );
9161023 for row in &mut table.rows {
9171024 row.cells.insert(at, new_cell(&indents));
9181025 }
......@@ -940,7 +1047,11 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
9401047 let cells: Vec<_> = table.rows.iter_mut().flat_map(|r| &mut r.cells).collect();
9411048 let len = cells.len();
9421049 let cell = cells.into_iter().nth(rng.pick(len).unwrap()).unwrap();
943 cell.shading = if cell.shading.is_some() { None } else { Some(0x00ccff) };
1050 cell.shading = if cell.shading.is_some() {
1051 None
1052 } else {
1053 Some(0x00ccff)
1054 };
9441055 }
9451056 }
9461057 true
......@@ -948,7 +1059,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
9481059 Family::NewTable | Family::ParagraphPicture | Family::Attachment | Family::Equation => {
9491060 let equation = page.objects.iter().find_map(|o| match o {
9501061 PageObject::Outline(outline) => outline.paragraphs.iter().find_map(|p| {
951 p.text().filter(|t| crate::page::Math::is_equation(&t.text)).map(|t| t.text.clone())
1062 p.text()
1063 .filter(|t| crate::page::Math::is_equation(&t.text))
1064 .map(|t| t.text.clone())
9521065 }),
9531066 _ => None,
9541067 });
......@@ -959,7 +1072,13 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
9591072 paragraph.content = match family {
9601073 Family::NewTable => ParagraphContent::Table(Table {
9611074 id: id(),
962 columns: vec![TableColumn { width: 72.0, locked: false }; 2],
1075 columns: vec![
1076 TableColumn {
1077 width: 72.0,
1078 locked: false
1079 };
1080 2
1081 ],
9631082 rows: (0..2)
9641083 .map(|_| TableRow {
9651084 id: id(),
......@@ -1000,7 +1119,12 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
10001119 }
10011120 };
10021121 let boundaries: Vec<usize> = (0..=outline.paragraphs.len())
1003 .filter(|i| outline.paragraphs.get(*i).is_none_or(|p| p.parent.is_none()))
1122 .filter(|i| {
1123 outline
1124 .paragraphs
1125 .get(*i)
1126 .is_none_or(|p| p.parent.is_none())
1127 })
10041128 .collect();
10051129 let at = boundaries[rng.pick(boundaries.len()).unwrap()];
10061130 outline.paragraphs.insert(at, paragraph);
......@@ -1139,7 +1263,13 @@ pub(super) fn stored(source: &[u8], space: ExGuid, after: &Page, seed: u64) -> O
11391263 };
11401264 let edit = Edit {
11411265 at: AT,
1142 ops: ops.iter().map(|op| Op::Page { space, op: op.clone() }).collect(),
1266 ops: ops
1267 .iter()
1268 .map(|op| Op::Page {
1269 space,
1270 op: op.clone(),
1271 })
1272 .collect(),
11431273 };
11441274 if seeded(seed, || section.apply("Author", &edit)).is_err() {
11451275 if section.page(space).unwrap() != before {
......@@ -1159,7 +1289,9 @@ pub(super) fn stored(source: &[u8], space: ExGuid, after: &Page, seed: u64) -> O
11591289 ));
11601290 }
11611291 let Some(_) = seeded(seed, || section.seal().unwrap()) else {
1162 return if live == before { Outcome::Unchanged } else {
1292 return if live == before {
1293 Outcome::Unchanged
1294 } else {
11631295 Outcome::Mismatch("the page changed and the seal stored nothing".into())
11641296 };
11651297 };
......@@ -1171,12 +1303,18 @@ pub(super) fn stored(source: &[u8], space: ExGuid, after: &Page, seed: u64) -> O
11711303 }
11721304 let reread = read(&image, space);
11731305 if reread != live {
1174 return Outcome::Mismatch(format!("reads back otherwise: {}", first_difference(&live, &reread)));
1306 return Outcome::Mismatch(format!(
1307 "reads back otherwise: {}",
1308 first_difference(&live, &reread)
1309 ));
11751310 }
11761311 let written = as_written(after, &before, &reread);
11771312 let (stored, wanted) = (normalize(&reread), normalize(&written));
11781313 if stored != wanted {
1179 return Outcome::Mismatch(format!("stores another page: {}", first_difference(&wanted, &stored)));
1314 return Outcome::Mismatch(format!(
1315 "stores another page: {}",
1316 first_difference(&wanted, &stored)
1317 ));
11801318 }
11811319 Outcome::Stored
11821320}
......@@ -1187,7 +1325,16 @@ fn spans(text: &Paragraph) -> String {
11871325 let f = &span.format;
11881326 out.push_str(&format!(
11891327 " [{} b{:?} h{:?} l{:?}/{:?} c{:?} a{:?} s{:?} lang{:?} f{:?}]",
1190 span.end, f.bold, f.hidden, f.hyperlink, f.hyperlink_label, f.color, f.alignment, f.space_before, f.language, f.font
1328 span.end,
1329 f.bold,
1330 f.hidden,
1331 f.hyperlink,
1332 f.hyperlink_label,
1333 f.color,
1334 f.alignment,
1335 f.space_before,
1336 f.language,
1337 f.font
11911338 ));
11921339 }
11931340 out
......@@ -1243,7 +1390,8 @@ fn lowered_edits_store_the_edited_page() {
12431390 };
12441391 *tally.entry((family, key)).or_default() += 1;
12451392 if let Outcome::Mismatch(detail) = outcome {
1246 mismatches.push(format!("{name} page {p} {family:?} seed {seed}: {detail}"));
1393 mismatches
1394 .push(format!("{name} page {p} {family:?} seed {seed}: {detail}"));
12471395 }
12481396 }
12491397 }
......@@ -1279,7 +1427,25 @@ fn debug_case() {
12791427 model::apply(&mut page, op).unwrap();
12801428 }
12811429 for object in &page.objects {
1282 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![] });
1430 println!(
1431 "{:?} {:?} {:?}",
1432 object.id(),
1433 object.layout(),
1434 match object {
1435 PageObject::Outline(o) => o
1436 .paragraphs
1437 .iter()
1438 .filter_map(|p| p.text().map(|t| t.text.text().to_owned()))
1439 .collect::<Vec<_>>(),
1440 PageObject::Title(t) => t
1441 .outlines
1442 .iter()
1443 .flat_map(|o| &o.paragraphs)
1444 .filter_map(|p| p.text().map(|t| format!("title {:?}", t.text.text())))
1445 .collect(),
1446 _ => vec![],
1447 }
1448 );
12831449 }
12841450 println!("{:?} -> {:?}", before.title, page.title);
12851451}
......@@ -1334,7 +1500,11 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
13341500 text,
13351501 range,
13361502 set: vec![TextAttribute::Bold(rng.coin())],
1337 clear: if rng.next().is_multiple_of(3) { vec![TextProperty::Italic] } else { Vec::new() },
1503 clear: if rng.next().is_multiple_of(3) {
1504 vec![TextProperty::Italic]
1505 } else {
1506 Vec::new()
1507 },
13381508 },
13391509 3 => PageOp::Split {
13401510 text,
......@@ -1357,7 +1527,9 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
13571527 6 => PageOp::Move {
13581528 object: paragraph,
13591529 parent: Some(container),
1360 before: siblings.get(rng.pick(siblings.len().max(1)).unwrap_or(0)).copied(),
1530 before: siblings
1531 .get(rng.pick(siblings.len().max(1)).unwrap_or(0))
1532 .copied(),
13611533 },
13621534 7 => PageOp::Level {
13631535 paragraph,
......@@ -1377,7 +1549,10 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
13771549 language: None,
13781550 },
13791551 10 => {
1380 let right = texts.iter().skip_while(|(p, _, _)| *p != paragraph).nth(1)?;
1552 let right = texts
1553 .iter()
1554 .skip_while(|(p, _, _)| *p != paragraph)
1555 .nth(1)?;
13811556 PageOp::Join {
13821557 left: text,
13831558 right: right.1,
......@@ -1505,7 +1680,10 @@ fn random_object_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
15051680 };
15061681 let edit = match rng.next() % 7 {
15071682 0 => TableEdit::Rows {
1508 before: table.rows.get(rng.pick(table.rows.len() + 1)?).map(|r| r.id),
1683 before: table
1684 .rows
1685 .get(rng.pick(table.rows.len() + 1)?)
1686 .map(|r| r.id),
15091687 rows: vec![TableRow {
15101688 id: id(),
15111689 cells: table.columns.iter().map(|_| cell()).collect(),
......@@ -1639,7 +1817,10 @@ fn random_ops_read_back_as_the_model_predicts() {
16391817 if ops.is_empty() {
16401818 continue;
16411819 }
1642 let edit = Edit { at: AT + step as u64, ops };
1820 let edit = Edit {
1821 at: AT + step as u64,
1822 ops,
1823 };
16431824 // Text whose emptied final run keeps an insertion style the model cannot show.
16441825 let hidden: Vec<ExGuid> = section
16451826 .active(space)
......@@ -1658,7 +1839,9 @@ fn random_ops_read_back_as_the_model_predicts() {
16581839 applied += 1;
16591840 let mut predicted = before.clone();
16601841 for op in &edit.ops {
1661 let Op::Page { op, .. } = op else { unreachable!() };
1842 let Op::Page { op, .. } = op else {
1843 unreachable!()
1844 };
16621845 model::apply(&mut predicted, op).unwrap();
16631846 }
16641847 let stored = section.page(space).unwrap();
......@@ -1671,7 +1854,9 @@ fn random_ops_read_back_as_the_model_predicts() {
16711854 matches!(op, Op::Page { op: PageOp::Style { paragraph, .. }, .. }
16721855 if model::paragraph(&before, *paragraph).is_some_and(|p| p.style.is_some()))
16731856 });
1674 if (touches_hidden || restyles) && normalize(&predicted) != normalize(&stored) {
1857 if (touches_hidden || restyles)
1858 && normalize(&predicted) != normalize(&stored)
1859 {
16751860 unseen += 1;
16761861 continue;
16771862 }
......@@ -1680,7 +1865,11 @@ fn random_ops_read_back_as_the_model_predicts() {
16801865 .ops
16811866 .iter()
16821867 .map(|op| match op {
1683 Op::Page { op, .. } => format!("{op:?}").split([' ', '{']).next().unwrap().to_owned(),
1868 Op::Page { op, .. } => format!("{op:?}")
1869 .split([' ', '{'])
1870 .next()
1871 .unwrap()
1872 .to_owned(),
16841873 Op::Section(_) => "Section".into(),
16851874 })
16861875 .collect();
......@@ -1693,7 +1882,12 @@ fn random_ops_read_back_as_the_model_predicts() {
16931882 "{}: {}",
16941883 owner.n,
16951884 list.iter()
1696 .map(|p| format!("{}<{:?}>@{}", p.id.n, p.parent.map(|p| p.n), p.level))
1885 .map(|p| format!(
1886 "{}<{:?}>@{}",
1887 p.id.n,
1888 p.parent.map(|p| p.n),
1889 p.level
1890 ))
16971891 .collect::<Vec<_>>()
16981892 .join(" ")
16991893 )
......@@ -1701,7 +1895,13 @@ fn random_ops_read_back_as_the_model_predicts() {
17011895 .collect::<Vec<_>>()
17021896 .join("\n")
17031897 };
1704 println!("ops {:?}\nbefore\n{}\nstored\n{}\npredicted\n{}", edit.ops, tree(&before), tree(&stored), tree(&predicted));
1898 println!(
1899 "ops {:?}\nbefore\n{}\nstored\n{}\npredicted\n{}",
1900 edit.ops,
1901 tree(&before),
1902 tree(&stored),
1903 tree(&predicted)
1904 );
17051905 }
17061906 if mismatches.len() < 6 {
17071907 println!(
......@@ -1715,7 +1915,10 @@ fn random_ops_read_back_as_the_model_predicts() {
17151915 Err(OpError::Failed(error)) => panic!("{name} step {step}: {error:?}"),
17161916 Err(_) => {
17171917 refused += 1;
1718 assert!(section.page(space).unwrap() == before, "{name} step {step}: a refused edit changed the page");
1918 assert!(
1919 section.page(space).unwrap() == before,
1920 "{name} step {step}: a refused edit changed the page"
1921 );
17191922 }
17201923 }
17211924 if step % 7 == 6 || step + 1 == steps {
......@@ -1723,13 +1926,18 @@ fn random_ops_read_back_as_the_model_predicts() {
17231926 let image = section.image();
17241927 let reopened = Section::open(&arena, image).unwrap();
17251928 for space in &spaces {
1726 assert!(reopened.page(*space).unwrap() == section.page(*space).unwrap(), "{name} step {step}: reopening changed a page");
1929 assert!(
1930 reopened.page(*space).unwrap() == section.page(*space).unwrap(),
1931 "{name} step {step}: reopening changed a page"
1932 );
17271933 }
17281934 }
17291935 }
17301936 }
17311937 }
1732 println!("{applied} edits applied, {refused} refused, {unseen} restyled or typed into a hidden insertion style");
1938 println!(
1939 "{applied} edits applied, {refused} refused, {unseen} restyled or typed into a hidden insertion style"
1940 );
17331941 let mut kinds = BTreeMap::new();
17341942 for kind in &mismatches {
17351943 *kinds.entry(kind.clone()).or_insert(0) += 1;
......@@ -1745,7 +1953,8 @@ fn random_ops_read_back_as_the_model_predicts() {
17451953fn section_ops_leave_the_page_list_they_describe() {
17461954 let sources: Vec<Vec<u8>> = vec![
17471955 crate::create_section("pages.one", "First", "Author").unwrap(),
1748 include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one").to_vec(),
1956 include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one")
1957 .to_vec(),
17491958 SOURCES[3].1.to_vec(),
17501959 ];
17511960 let mut applied = 0;
......@@ -1756,16 +1965,31 @@ fn section_ops_leave_the_page_list_they_describe() {
17561965 for step in 0..12u64 {
17571966 let listed = section.pages().unwrap();
17581967 let spaces: Vec<ExGuid> = listed.iter().map(|(space, ..)| *space).collect();
1759 let anchor = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied().filter(|_| rng.coin());
1968 let anchor = spaces
1969 .get(rng.pick(spaces.len()).unwrap_or(0))
1970 .copied()
1971 .filter(|_| rng.coin());
17601972 let op = match step % 4 {
1761 0 => SectionOp::Create(crate::PageCreation::new(anchor, Some("Created"), "Author").unwrap()),
1973 0 => SectionOp::Create(
1974 crate::PageCreation::new(anchor, Some("Created"), "Author").unwrap(),
1975 ),
17621976 1 => {
1763 let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied() else { continue };
1764 let level = if spaces[0] == space { 1 } else { 1 + (rng.next() % 2) as u32 };
1977 let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied()
1978 else {
1979 continue;
1980 };
1981 let level = if spaces[0] == space {
1982 1
1983 } else {
1984 1 + (rng.next() % 2) as u32
1985 };
17651986 SectionOp::Pages(vec![crate::PageEdit::set_level(space, level).unwrap()])
17661987 }
17671988 2 => {
1768 let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied() else { continue };
1989 let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied()
1990 else {
1991 continue;
1992 };
17691993 SectionOp::Pages(vec![crate::PageEdit::move_to(space, None, 1).unwrap()])
17701994 }
17711995 _ => {
......@@ -1775,7 +1999,10 @@ fn section_ops_leave_the_page_list_they_describe() {
17751999 SectionOp::Delete(vec![spaces[rng.pick(spaces.len()).unwrap()]])
17762000 }
17772001 };
1778 let edit = Edit { at: AT, ops: vec![Op::Section(op.clone())] };
2002 let edit = Edit {
2003 at: AT,
2004 ops: vec![Op::Section(op.clone())],
2005 };
17792006 if section.apply("Author", &edit).is_err() {
17802007 assert_eq!(section.pages().unwrap(), listed, "{n} step {step} {op:?}");
17812008 continue;
......@@ -1784,11 +2011,17 @@ fn section_ops_leave_the_page_list_they_describe() {
17842011 applied += 1;
17852012 let after = section.pages().unwrap();
17862013 let order = |list: &[(ExGuid, String, u32)], without: ExGuid| -> Vec<ExGuid> {
1787 list.iter().map(|(space, ..)| *space).filter(|space| *space != without).collect()
2014 list.iter()
2015 .map(|(space, ..)| *space)
2016 .filter(|space| *space != without)
2017 .collect()
17882018 };
17892019 match &op {
17902020 SectionOp::Create(creation) => {
1791 let at = after.iter().position(|(space, ..)| *space == creation.space()).unwrap();
2021 let at = after
2022 .iter()
2023 .position(|(space, ..)| *space == creation.space())
2024 .unwrap();
17922025 assert_eq!(after[at].1, "Created");
17932026 assert_eq!(order(&after, creation.space()), spaces, "{n} step {step}");
17942027 match anchor {
......@@ -1798,7 +2031,10 @@ fn section_ops_leave_the_page_list_they_describe() {
17982031 }
17992032 SectionOp::Pages(edits) => {
18002033 let space = edits[0].space();
1801 let at = after.iter().position(|(listed, ..)| *listed == space).unwrap();
2034 let at = after
2035 .iter()
2036 .position(|(listed, ..)| *listed == space)
2037 .unwrap();
18022038 assert_eq!(after[at].2, edits[0].level(), "{n} step {step} {op:?}");
18032039 if edits[0].position() == crate::PagePosition::Keep {
18042040 assert_eq!(order(&after, space), order(&listed, space));
......@@ -1808,11 +2044,16 @@ fn section_ops_leave_the_page_list_they_describe() {
18082044 }
18092045 }
18102046 SectionOp::Delete(deleted) => {
1811 assert_eq!(after.iter().map(|(s, ..)| *s).collect::<Vec<_>>(), order(&listed, deleted[0]));
1812 }
1813 SectionOp::Import { .. } | SectionOp::Conflict { .. } | SectionOp::Color(_) => {
1814 unreachable!()
2047 assert_eq!(
2048 after.iter().map(|(s, ..)| *s).collect::<Vec<_>>(),
2049 order(&listed, deleted[0])
2050 );
18152051 }
2052 SectionOp::Import { .. }
2053 | SectionOp::Conflict { .. }
2054 | SectionOp::Color(_)
2055 | SectionOp::RestoreVersion { .. }
2056 | SectionOp::DeleteVersions { .. } => unreachable!(),
18162057 }
18172058 let image = section.image();
18182059 let store = Store::parse(&image).unwrap();
......@@ -1830,7 +2071,11 @@ fn section_ops_leave_the_page_list_they_describe() {
18302071 .collect();
18312072 assert_eq!(reread, after, "{n} step {step}");
18322073 for (space, ..) in &after {
1833 assert_eq!(read(&image, *space), section.page(*space).unwrap(), "{n} step {step}");
2074 assert_eq!(
2075 read(&image, *space),
2076 section.page(*space).unwrap(),
2077 "{n} step {step}"
2078 );
18342079 }
18352080 }
18362081 }
......@@ -1851,21 +2096,40 @@ fn an_imported_page_reads_back_as_its_copy() {
18512096 let arena = Arena::default();
18522097 let mut section = Section::open(&arena, target.clone()).unwrap();
18532098 let import = |section: &mut Section<'_>, op: SectionOp| {
1854 section.apply("Author", &Edit { at: AT, ops: vec![Op::Section(op)] })
2099 section.apply(
2100 "Author",
2101 &Edit {
2102 at: AT,
2103 ops: vec![Op::Section(op)],
2104 },
2105 )
18552106 };
18562107 import(&mut section, SectionOp::Create(creation.clone())).unwrap();
18572108 let created = section.page(creation.space()).unwrap();
18582109 let mut expected = created.clone();
18592110 let arena = Arena::default();
18602111 let mut section = Section::open(&arena, target.clone()).unwrap();
1861 if import(&mut section, SectionOp::Import { creation: creation.clone(), page: copy.clone() }).is_err() {
2112 if import(
2113 &mut section,
2114 SectionOp::Import {
2115 creation: creation.clone(),
2116 page: copy.clone(),
2117 },
2118 )
2119 .is_err()
2120 {
18622121 continue;
18632122 }
18642123 section.seal().unwrap();
18652124 let image = section.image();
18662125 let store = Store::parse(&image).unwrap();
1867 RevisionIndex::parse(&store).unwrap().validate_current().unwrap();
1868 expected.objects.retain(|object| matches!(object, PageObject::Title(_)));
2126 RevisionIndex::parse(&store)
2127 .unwrap()
2128 .validate_current()
2129 .unwrap();
2130 expected
2131 .objects
2132 .retain(|object| matches!(object, PageObject::Title(_)));
18692133 expected.objects.extend(
18702134 copy.objects
18712135 .iter()
......@@ -1876,16 +2140,24 @@ fn an_imported_page_reads_back_as_its_copy() {
18762140 let stored = read(&image, creation.space());
18772141 // New page children stack as the writers add them.
18782142 let order: Vec<ExGuid> = stored.objects.iter().map(PageObject::id).collect();
1879 expected.objects.sort_by_key(|object| order.iter().position(|id| *id == object.id()));
2143 expected
2144 .objects
2145 .sort_by_key(|object| order.iter().position(|id| *id == object.id()));
18802146 let mut expected = as_written(&expected, &created, &stored);
18812147 // An outline's height follows its content; no op carries it.
18822148 for (object, stored) in expected.objects.iter_mut().zip(&stored.objects) {
1883 if let (PageObject::Outline(outline), PageObject::Outline(stored)) = (object, stored) {
2149 if let (PageObject::Outline(outline), PageObject::Outline(stored)) =
2150 (object, stored)
2151 {
18842152 outline.layout.max_height = stored.layout.max_height;
18852153 }
18862154 }
18872155 let (ours, theirs) = (normalize(&stored), normalize(&expected));
1888 assert!(ours == theirs, "{name}: {}", first_difference(&theirs, &ours));
2156 assert!(
2157 ours == theirs,
2158 "{name}: {}",
2159 first_difference(&theirs, &ours)
2160 );
18892161 imported += 1;
18902162 }
18912163 }
......@@ -1902,7 +2174,15 @@ fn lower_range(before: &Page, after: &Page) -> Option<Result<Vec<PageOp>, crate:
19022174 match (a, b) {
19032175 (PageObject::Outline(a), PageObject::Outline(b)) if a.id == b.id => {
19042176 if a.paragraphs != b.paragraphs {
1905 if changed.is_some() || (Outline { paragraphs: Vec::new(), ..a.clone() }) != (Outline { paragraphs: Vec::new(), ..b.clone() }) {
2177 if changed.is_some()
2178 || (Outline {
2179 paragraphs: Vec::new(),
2180 ..a.clone()
2181 }) != (Outline {
2182 paragraphs: Vec::new(),
2183 ..b.clone()
2184 })
2185 {
19062186 return None;
19072187 }
19082188 changed = Some((a, b));
......@@ -1924,7 +2204,11 @@ fn lower_range(before: &Page, after: &Page) -> Option<Result<Vec<PageOp>, crate:
19242204 let (mut end_a, mut end_b) = (a.len() - suffix, b.len() - suffix);
19252205 // A range holds the children of what it changes.
19262206 while end_a < a.len() {
1927 let ids: Vec<ExGuid> = a[prefix..end_a].iter().chain(&b[prefix..end_b]).map(|p| p.id).collect();
2207 let ids: Vec<ExGuid> = a[prefix..end_a]
2208 .iter()
2209 .chain(&b[prefix..end_b])
2210 .map(|p| p.id)
2211 .collect();
19282212 if a[end_a].parent.is_some_and(|parent| ids.contains(&parent)) {
19292213 end_a += 1;
19302214 end_b += 1;
......@@ -1932,23 +2216,49 @@ fn lower_range(before: &Page, after: &Page) -> Option<Result<Vec<PageOp>, crate:
19322216 break;
19332217 }
19342218 }
1935 let outline = match before.objects.iter().find(|o| matches!(o, PageObject::Outline(o) if o.paragraphs == *a)) {
2219 let outline = match before
2220 .objects
2221 .iter()
2222 .find(|o| matches!(o, PageObject::Outline(o) if o.paragraphs == *a))
2223 {
19362224 Some(PageObject::Outline(outline)) => outline.id,
19372225 _ => return None,
19382226 };
19392227 let mut definitions = before.definitions.clone();
19402228 definitions.extend(after.definitions.clone());
1941 Some(lower(outline, &a[prefix..end_a], &b[prefix..end_b], b.get(end_b), &definitions))
2229 Some(lower(
2230 outline,
2231 &a[prefix..end_a],
2232 &b[prefix..end_b],
2233 b.get(end_b),
2234 &definitions,
2235 ))
19422236}
19432237
19442238/// Lowering the changed range of an outline stores what lowering the whole page does.
19452239#[test]
19462240fn a_range_lowers_as_its_page_does() {
19472241 let families = [
1948 Family::Text, Family::Format, Family::Insert, Family::Paste, Family::Delete, Family::Move,
1949 Family::Collapse, Family::List, Family::Tag, Family::Split, Family::Join, Family::Indent,
1950 Family::Outdent, Family::Alignment, Family::Style, Family::Link, Family::NewTable,
1951 Family::ParagraphPicture, Family::Attachment, Family::Equation,
2242 Family::Text,
2243 Family::Format,
2244 Family::Insert,
2245 Family::Paste,
2246 Family::Delete,
2247 Family::Move,
2248 Family::Collapse,
2249 Family::List,
2250 Family::Tag,
2251 Family::Split,
2252 Family::Join,
2253 Family::Indent,
2254 Family::Outdent,
2255 Family::Alignment,
2256 Family::Style,
2257 Family::Link,
2258 Family::NewTable,
2259 Family::ParagraphPicture,
2260 Family::Attachment,
2261 Family::Equation,
19522262 ];
19532263 let (mut same, mut total, mut smaller) = (0, 0, 0);
19542264 for (name, source) in SOURCES {
......@@ -1974,18 +2284,32 @@ fn a_range_lowers_as_its_page_does() {
19742284 let mut section = Section::open(&arena, source.to_vec()).unwrap();
19752285 let edit = Edit {
19762286 at: AT,
1977 ops: ops.iter().map(|op| Op::Page { space, op: op.clone() }).collect(),
2287 ops: ops
2288 .iter()
2289 .map(|op| Op::Page {
2290 space,
2291 op: op.clone(),
2292 })
2293 .collect(),
19782294 };
1979 section.apply("Author", &edit).map(|()| normalize(&section.page(space).unwrap()))
2295 section
2296 .apply("Author", &edit)
2297 .map(|()| normalize(&section.page(space).unwrap()))
19802298 };
19812299 let (ours, theirs) = (write(&ranged), write(&whole));
19822300 match (ours, theirs) {
19832301 (Ok(ours), Ok(theirs)) => {
1984 assert!(ours == theirs, "{name} page {p} {family:?} seed {seed}: {}", first_difference(&theirs, &ours));
2302 assert!(
2303 ours == theirs,
2304 "{name} page {p} {family:?} seed {seed}: {}",
2305 first_difference(&theirs, &ours)
2306 );
19852307 same += 1;
19862308 }
19872309 (Err(_), Err(_)) => same += 1,
1988 (ours, theirs) => panic!("{name} page {p} {family:?} seed {seed}: {ours:?} vs {theirs:?}"),
2310 (ours, theirs) => {
2311 panic!("{name} page {p} {family:?} seed {seed}: {ours:?} vs {theirs:?}")
2312 }
19892313 }
19902314 let length = |ops: &[PageOp]| format!("{ops:?}").len();
19912315 smaller += usize::from(length(&ranged) <= length(&whole));
......@@ -2012,7 +2336,9 @@ fn keystroke_ops() {
20122336 let image = std::fs::read(&path).unwrap();
20132337 let space = pages(&image)
20142338 .into_iter()
2015 .max_by_key(|space| read(&image, *space).objects.len() + format!("{:?}", read(&image, *space)).len())
2339 .max_by_key(|space| {
2340 read(&image, *space).objects.len() + format!("{:?}", read(&image, *space)).len()
2341 })
20162342 .unwrap();
20172343 let page = read(&image, space);
20182344 let outline = page
......@@ -2027,7 +2353,11 @@ fn keystroke_ops() {
20272353 let middle = outline.paragraphs.len() / 2;
20282354 let paragraph = &outline.paragraphs[middle];
20292355 let text = paragraph.text().unwrap().id;
2030 println!("{path}: {} bytes, {} paragraphs in the outline", image.len(), outline.paragraphs.len());
2356 println!(
2357 "{path}: {} bytes, {} paragraphs in the outline",
2358 image.len(),
2359 outline.paragraphs.len()
2360 );
20312361
20322362 // Emitting: a keystroke is one op; a generic range replacement lowers its paragraphs.
20332363 let mut edited = paragraph.clone();
......@@ -2087,7 +2417,11 @@ fn keystroke_ops() {
20872417 seals.push(start.elapsed());
20882418 sizes.push((
20892419 transaction.append.len(),
2090 transaction.patches.iter().map(|(_, bytes)| bytes.len()).sum::<usize>(),
2420 transaction
2421 .patches
2422 .iter()
2423 .map(|(_, bytes)| bytes.len())
2424 .sum::<usize>(),
20912425 ));
20922426 }
20932427 sizes.sort();
......@@ -2122,20 +2456,42 @@ fn op_gate_candidate() {
21222456 at.get()
21232457 };
21242458 let apply = |section: &mut Section<'_>, op: Op| {
2125 let result = section.apply("Gate author", &Edit { at: next(), ops: vec![op] });
2459 let result = section.apply(
2460 "Gate author",
2461 &Edit {
2462 at: next(),
2463 ops: vec![op],
2464 },
2465 );
21262466 section.seal().unwrap();
21272467 result
21282468 };
21292469 let mut imported = Vec::new();
21302470 for (name, bytes) in SOURCES.iter().filter(|(name, _)| *name != "enter-probe") {
2131 let Some(space) = pages(bytes).first().copied() else { continue };
2471 let Some(space) = pages(bytes).first().copied() else {
2472 continue;
2473 };
21322474 let page = read(bytes, space);
2133 if page.objects.iter().any(|o| matches!(o, PageObject::Unsupported(_))) {
2475 if page
2476 .objects
2477 .iter()
2478 .any(|o| matches!(o, PageObject::Unsupported(_)))
2479 {
21342480 continue;
21352481 }
21362482 let Ok(copy) = page.copy() else { continue };
2137 let creation = crate::PageCreation::new(None, Some(&format!("Imported {name}")), "Gate author").unwrap();
2138 if apply(&mut section, Op::Section(SectionOp::Import { creation: creation.clone(), page: copy })).is_ok() {
2483 let creation =
2484 crate::PageCreation::new(None, Some(&format!("Imported {name}")), "Gate author")
2485 .unwrap();
2486 if apply(
2487 &mut section,
2488 Op::Section(SectionOp::Import {
2489 creation: creation.clone(),
2490 page: copy,
2491 }),
2492 )
2493 .is_ok()
2494 {
21392495 imported.push(creation.space());
21402496 }
21412497 }
......@@ -2163,10 +2519,16 @@ fn op_gate_candidate() {
21632519 .collect()
21642520 };
21652521 let edited = fields(&before);
2166 if fields(&after).iter().any(|(id, text)| edited.iter().any(|(e, old)| e == id && old != text)) {
2522 if fields(&after)
2523 .iter()
2524 .any(|(id, text)| edited.iter().any(|(e, old)| e == id && old != text))
2525 {
21672526 continue;
21682527 }
2169 if section.apply_page("Gate author", next(), *space, &after).is_ok() {
2528 if section
2529 .apply_page("Gate author", next(), *space, &after)
2530 .is_ok()
2531 {
21702532 section.seal().unwrap();
21712533 edits += 1;
21722534 }
......@@ -2174,12 +2536,20 @@ fn op_gate_candidate() {
21742536 }
21752537 // A created page: margins, an outline, and its first move adding the margin origin.
21762538 let creation = crate::PageCreation::new(None, Some("Created by ops"), "Gate author").unwrap();
2177 apply(&mut section, Op::Section(SectionOp::Create(creation.clone()))).unwrap();
2539 apply(
2540 &mut section,
2541 Op::Section(SectionOp::Create(creation.clone())),
2542 )
2543 .unwrap();
21782544 let outline = Outline {
21792545 id: id(),
21802546 title: false,
21812547 min_width: None,
2182 layout: Layout { x: Some(36.0), y: Some(86.4), ..Default::default() },
2548 layout: Layout {
2549 x: Some(36.0),
2550 y: Some(86.4),
2551 ..Default::default()
2552 },
21832553 indents: Vec::new(),
21842554 paragraphs: vec![text_paragraph("Typed through ops", Format::default())],
21852555 unsupported: Vec::new(),
......@@ -2188,26 +2558,87 @@ fn op_gate_candidate() {
21882558 let text = outline.paragraphs[0].text().unwrap().id;
21892559 let space = creation.space();
21902560 let page = |op| Op::Page { space, op };
2191 apply(&mut section, page(PageOp::Add { object: PageObject::Outline(outline), before: None })).unwrap();
2192 apply(&mut section, page(PageOp::Outline { object: outline_id, edit: crate::OutlineEdit::Position { x: 72.0, y: 108.0 } })).unwrap();
2193 apply(&mut section, page(PageOp::Text { text, range: 5..5, with: " and sealed".into() })).unwrap();
2194 apply(&mut section, page(PageOp::Link { text, range: 0..5, target: Some("https://example.invalid/ops".into()) })).unwrap();
2195 let spaces: Vec<ExGuid> = section.pages().unwrap().into_iter().map(|(s, _, _)| s).collect();
2196 apply(&mut section, Op::Section(SectionOp::Pages(vec![crate::PageEdit::move_to(space, Some(spaces[1]), 1).unwrap()]))).unwrap();
2197 apply(&mut section, Op::Section(SectionOp::Pages(vec![crate::PageEdit::set_level(spaces[2], 2).unwrap()]))).unwrap();
2198 apply(&mut section, Op::Section(SectionOp::Delete(vec![*spaces.last().unwrap()]))).unwrap();
2561 apply(
2562 &mut section,
2563 page(PageOp::Add {
2564 object: PageObject::Outline(outline),
2565 before: None,
2566 }),
2567 )
2568 .unwrap();
2569 apply(
2570 &mut section,
2571 page(PageOp::Outline {
2572 object: outline_id,
2573 edit: crate::OutlineEdit::Position { x: 72.0, y: 108.0 },
2574 }),
2575 )
2576 .unwrap();
2577 apply(
2578 &mut section,
2579 page(PageOp::Text {
2580 text,
2581 range: 5..5,
2582 with: " and sealed".into(),
2583 }),
2584 )
2585 .unwrap();
2586 apply(
2587 &mut section,
2588 page(PageOp::Link {
2589 text,
2590 range: 0..5,
2591 target: Some("https://example.invalid/ops".into()),
2592 }),
2593 )
2594 .unwrap();
2595 let spaces: Vec<ExGuid> = section
2596 .pages()
2597 .unwrap()
2598 .into_iter()
2599 .map(|(s, _, _)| s)
2600 .collect();
2601 apply(
2602 &mut section,
2603 Op::Section(SectionOp::Pages(vec![
2604 crate::PageEdit::move_to(space, Some(spaces[1]), 1).unwrap(),
2605 ])),
2606 )
2607 .unwrap();
2608 apply(
2609 &mut section,
2610 Op::Section(SectionOp::Pages(vec![
2611 crate::PageEdit::set_level(spaces[2], 2).unwrap(),
2612 ])),
2613 )
2614 .unwrap();
2615 apply(
2616 &mut section,
2617 Op::Section(SectionOp::Delete(vec![*spaces.last().unwrap()])),
2618 )
2619 .unwrap();
21992620 let image = section.image();
22002621 let store = Store::parse(&image).unwrap();
2201 RevisionIndex::parse(&store).unwrap().validate_current().unwrap();
2622 RevisionIndex::parse(&store)
2623 .unwrap()
2624 .validate_current()
2625 .unwrap();
22022626 let count = section.pages().unwrap().len();
22032627 std::fs::write(directory.join("ops.one"), &image).unwrap();
22042628 std::fs::write(
22052629 directory.join("Open Notebook.onetoc2"),
2206 crate::create_table_of_contents("Open Notebook.onetoc2", &[("ops.one", store.header.file_id)]).unwrap(),
2630 crate::create_table_of_contents(
2631 "Open Notebook.onetoc2",
2632 &[("ops.one", store.header.file_id)],
2633 )
2634 .unwrap(),
22072635 )
22082636 .unwrap();
22092637 std::fs::write(directory.join("expected-count.txt"), count.to_string()).unwrap();
2210 println!("{} imported pages, {edits} lowered edits, {count} pages", imported.len());
2638 println!(
2639 "{} imported pages, {edits} lowered edits, {count} pages",
2640 imported.len()
2641 );
22112642}
22122643
22132644/// 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> {
22292660 .objects
22302661 .iter()
22312662 .filter(|(id, object)| {
2232 old.objects.get(id).is_none_or(|old| {
2233 format!("{:?}", old.data) != format!("{:?}", object.data)
2234 })
2663 old.objects
2664 .get(id)
2665 .is_none_or(|old| format!("{:?}", old.data) != format!("{:?}", object.data))
22352666 })
22362667 .map(|(_, object)| object.jcid)
22372668 .collect();
......@@ -2256,10 +2687,19 @@ const DATED_AT: u64 = 133_961_211_000_000_000;
22562687fn a_page_date_stores_what_onenote_stores() {
22572688 let spaces = pages(DATED);
22582689 let title = |space: &ExGuid| read(DATED, *space).title;
2259 let kept = *spaces.iter().find(|space| title(space) == "Kept date").unwrap();
2260 let changed = *spaces.iter().find(|space| title(space) == "Changed date").unwrap();
2690 let kept = *spaces
2691 .iter()
2692 .find(|space| title(space) == "Kept date")
2693 .unwrap();
2694 let changed = *spaces
2695 .iter()
2696 .find(|space| title(space) == "Changed date")
2697 .unwrap();
22612698 let native = changed_jcids(DATED, changed);
2262 assert_eq!(native, [0x20030, 0x6000c, 0x6000d, 0x6000d, 0x6000e, 0x6000e]);
2699 assert_eq!(
2700 native,
2701 [0x20030, 0x6000c, 0x6000d, 0x6000d, 0x6000e, 0x6000e]
2702 );
22632703
22642704 let arena = Arena::default();
22652705 let mut section = Section::open(&arena, DATED.to_vec()).unwrap();
......@@ -2278,7 +2718,13 @@ fn a_page_date_stores_what_onenote_stores() {
22782718 let mut predicted = before.clone();
22792719 model::apply(&mut predicted, &op).unwrap();
22802720 section
2281 .apply("Author", &Edit { at: AT, ops: vec![Op::Page { space: kept, op }] })
2721 .apply(
2722 "Author",
2723 &Edit {
2724 at: AT,
2725 ops: vec![Op::Page { space: kept, op }],
2726 },
2727 )
22822728 .unwrap();
22832729 section.seal().unwrap();
22842730 let image = section.image();
......@@ -2288,20 +2734,36 @@ fn a_page_date_stores_what_onenote_stores() {
22882734 assert_eq!(stored, predicted);
22892735 assert_eq!(changed_jcids(&image, kept), native);
22902736 let store = Store::parse(&image).unwrap();
2291 RevisionIndex::parse(&store).unwrap().validate_current().unwrap();
2737 RevisionIndex::parse(&store)
2738 .unwrap()
2739 .validate_current()
2740 .unwrap();
22922741
22932742 // The whole-page lowering reaches the same page through the same op.
22942743 let mut after = before.clone();
22952744 after.created = Some(DATED_AT);
2296 model::apply(&mut after, &PageOp::Date { created: DATED_AT, fields }).unwrap();
2297 assert!(matches!(&lower_page(&before, &after).unwrap()[..], [PageOp::Date { .. }]));
2745 model::apply(
2746 &mut after,
2747 &PageOp::Date {
2748 created: DATED_AT,
2749 fields,
2750 },
2751 )
2752 .unwrap();
2753 assert!(matches!(
2754 &lower_page(&before, &after).unwrap()[..],
2755 [PageOp::Date { .. }]
2756 ));
22982757
22992758 if let Some(directory) = std::env::var_os("ONESTORE_PAGE_DATE_EXPORT") {
23002759 let directory = std::path::Path::new(&directory);
23012760 std::fs::create_dir_all(directory).unwrap();
23022761 std::fs::write(directory.join("pictures.one"), &image).unwrap();
23032762 std::fs::copy(
2304 concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/page-date/native/notebook/Open Notebook.onetoc2"),
2763 concat!(
2764 env!("CARGO_MANIFEST_DIR"),
2765 "/../../corpus/page-date/native/notebook/Open Notebook.onetoc2"
2766 ),
23052767 directory.join("Open Notebook.onetoc2"),
23062768 )
23072769 .unwrap();
crates/onestore/src/page/linear.rs created+741
......@@ -0,0 +1,741 @@
1//! Equations in the linear format of OneNote's equation editor (UnicodeMath): what Linear
2//! shows and what typing builds up. `Math::from_linear` reads the typed or shown text into the
3//! tree `Math::paragraph` stores; `Math::linear` writes a tree the way Linear shows it. Both
4//! follow the equation editor as observed (`corpus/math-edit/native-linear`): a space that
5//! ends a built object is consumed, other spaces stay, and an n-ary operator takes the rest
6//! of its row as its body.
7
8use super::Math;
9use super::math::{italic, plain};
10use super::text::Paragraph;
11use crate::document::Format;
12
13/// N-ary operators, which take limits and a body when a script follows them.
14const NARY: &[char] = &[
15 '∑', '∏', '∐', '∫', '∬', '∭', '∮', '∯', '∰', '⋀', '⋁', '⋂', '⋃', '⨀', '⨁', '⨂', '⨄', '⨅', '⨆',
16];
17
18/// Function names the equation editor sets upright; those that take a limit below take it
19/// from a subscript.
20const FUNCTIONS: &[&str] = &[
21 "lim", "max", "min", "sup", "inf", "det", "gcd", "sin", "cos", "tan", "sec", "csc", "cot",
22 "sinh", "cosh", "tanh", "log", "ln", "exp", "arg",
23];
24const LIMITS: &[&str] = &["lim", "max", "min", "sup", "inf", "det", "gcd"];
25
26/// The equation editor's control words: `\name` and the space after it become the symbol.
27const CONTROL_WORDS: &[(&str, char)] = &[
28 ("alpha", 'α'),
29 ("beta", 'β'),
30 ("gamma", 'γ'),
31 ("delta", 'δ'),
32 ("epsilon", 'ϵ'),
33 ("varepsilon", 'ε'),
34 ("zeta", 'ζ'),
35 ("eta", 'η'),
36 ("theta", 'θ'),
37 ("vartheta", 'ϑ'),
38 ("iota", 'ι'),
39 ("kappa", 'κ'),
40 ("lambda", 'λ'),
41 ("mu", 'μ'),
42 ("nu", 'ν'),
43 ("xi", 'ξ'),
44 ("pi", 'π'),
45 ("rho", 'ρ'),
46 ("sigma", 'σ'),
47 ("tau", 'τ'),
48 ("upsilon", 'υ'),
49 ("phi", 'ϕ'),
50 ("varphi", 'φ'),
51 ("chi", 'χ'),
52 ("psi", 'ψ'),
53 ("omega", 'ω'),
54 ("Gamma", 'Γ'),
55 ("Delta", 'Δ'),
56 ("Theta", 'Θ'),
57 ("Lambda", 'Λ'),
58 ("Xi", 'Ξ'),
59 ("Pi", 'Π'),
60 ("Sigma", 'Σ'),
61 ("Upsilon", 'Υ'),
62 ("Phi", 'Φ'),
63 ("Psi", 'Ψ'),
64 ("Omega", 'Ω'),
65 ("to", '→'),
66 ("rightarrow", '→'),
67 ("leftarrow", '←'),
68 ("times", '×'),
69 ("div", '÷'),
70 ("pm", '±'),
71 ("mp", '∓'),
72 ("infty", '∞'),
73 ("le", '≤'),
74 ("ge", '≥'),
75 ("ne", '≠'),
76 ("approx", '≈'),
77 ("equiv", '≡'),
78 ("sim", '∼'),
79 ("propto", '∝'),
80 ("cdot", '⋅'),
81 ("partial", '∂'),
82 ("nabla", '∇'),
83 ("in", '∈'),
84 ("notin", '∉'),
85 ("subset", '⊂'),
86 ("supset", '⊃'),
87 ("cup", '∪'),
88 ("cap", '∩'),
89 ("forall", '∀'),
90 ("exists", '∃'),
91 ("ll", '≪'),
92 ("gg", '≫'),
93 ("ldots", '…'),
94 ("cdots", '⋯'),
95 ("sum", '∑'),
96 ("prod", '∏'),
97 ("coprod", '∐'),
98 ("int", '∫'),
99 ("iint", '∬'),
100 ("iiint", '∭'),
101 ("oint", '∮'),
102 ("bigcap", '⋂'),
103 ("bigcup", '⋃'),
104 ("sqrt", '√'),
105 ("cbrt", '∛'),
106 ("qdrt", '∜'),
107 ("matrix", '■'),
108 ("eqarray", '█'),
109 ("box", '□'),
110 ("rect", '▭'),
111 ("overline", '¯'),
112 ("above", '┴'),
113 ("below", '┬'),
114 ("naryand", '▒'),
115 ("hat", '\u{302}'),
116 ("tilde", '\u{303}'),
117 ("bar", '\u{305}'),
118 ("dot", '\u{307}'),
119 ("ddot", '\u{308}'),
120 ("check", '\u{30c}'),
121 ("vec", '\u{20d7}'),
122];
123
124fn object(kind: u32, symbols: Vec<char>, columns: Option<u8>, arguments: Vec<Vec<Math>>) -> Math {
125 Math::Object {
126 kind,
127 symbols,
128 columns,
129 arguments,
130 }
131}
132
133fn accent(c: char) -> bool {
134 matches!(u32::from(c), 0x300..=0x36f | 0x20d0..=0x20ff)
135}
136
137fn closing(open: char) -> Option<char> {
138 Some(match open {
139 '(' => ')',
140 '[' => ']',
141 '{' => '}',
142 '⟨' => '⟩',
143 '〖' => '〗',
144 _ => return None,
145 })
146}
147
148struct Parser {
149 chars: Vec<char>,
150 at: usize,
151 /// Text still being typed: a construct whose last operand is missing stays as typed.
152 typing: bool,
153}
154
155impl Parser {
156 fn peek(&self) -> Option<char> {
157 self.chars.get(self.at).copied()
158 }
159
160 /// A space ending a built object triggered the build and is not kept.
161 fn built(&mut self, out: &mut Vec<Math>, node: Math) {
162 out.push(node);
163 if self.peek() == Some(' ') {
164 self.at += 1;
165 }
166 }
167
168 fn sequence(&mut self, stops: &[char]) -> Vec<Math> {
169 let mut out = Vec::new();
170 while let Some(c) = self.peek() {
171 if stops.contains(&c) {
172 break;
173 }
174 self.element(&mut out, stops);
175 }
176 out
177 }
178
179 fn element(&mut self, out: &mut Vec<Math>, stops: &[char]) {
180 let c = self.peek().unwrap();
181 let next = self.chars.get(self.at + 1).copied();
182 match c {
183 '/' => {
184 self.at += 1;
185 let denominator = stripped(self.run(stops));
186 if self.typing && denominator.is_empty() {
187 out.push(Math::Operator(c));
188 return;
189 }
190 let start = out
191 .iter()
192 .rposition(|node| matches!(node, Math::Operator(_)))
193 .map_or(0, |at| at + 1);
194 let numerator = stripped(out.split_off(start));
195 self.built(
196 out,
197 object(16, vec!['/'], None, vec![numerator, denominator]),
198 );
199 }
200 '^' | '_' => self.script(out),
201 '┴' | '┬' => {
202 self.at += 1;
203 let argument = self.operand();
204 if self.typing && argument.is_empty() {
205 out.push(Math::Operator(c));
206 return;
207 }
208 let base = out.pop().into_iter().collect();
209 let kind = if c == '┴' { 33 } else { 19 };
210 self.built(out, object(kind, vec![c], None, vec![base, argument]));
211 }
212 '\u{a0}' if next.is_some_and(accent) => self.at += 1,
213 c if accent(c) => {
214 self.at += 1;
215 if matches!(out.last(), Some(Math::Operator(' ' | '\u{a0}'))) {
216 out.pop();
217 }
218 let base = stripped(out.pop().into_iter().collect());
219 self.built(out, object(10, vec![c], None, vec![base]));
220 }
221 c if NARY.contains(&c) && matches!(next, Some('_' | '^' | '▒')) => {
222 self.at += 1;
223 let limits = self.at;
224 let (mut lower, mut upper) = (None, None);
225 loop {
226 match self.peek() {
227 Some('_') if lower.is_none() => {
228 self.at += 1;
229 lower = Some(self.operand());
230 }
231 Some('^') if upper.is_none() => {
232 self.at += 1;
233 upper = Some(self.operand());
234 }
235 _ => break,
236 }
237 }
238 if self.typing && [&lower, &upper].contains(&&Some(Vec::new())) {
239 self.at = limits;
240 out.push(Math::Operator(c));
241 return;
242 }
243 let body = match self.peek() {
244 Some('▒') => {
245 self.at += 1;
246 self.operand()
247 }
248 Some(' ') => {
249 self.at += 1;
250 self.sequence(stops)
251 }
252 _ => self.sequence(stops),
253 };
254 out.push(object(
255 21,
256 vec![c],
257 None,
258 vec![lower.unwrap_or_default(), upper.unwrap_or_default(), body],
259 ));
260 }
261 '〖' => {
262 self.at += 1;
263 let inner = self.sequence(&['〗']);
264 if self.peek() == Some('〗') {
265 self.at += 1;
266 }
267 out.extend(inner);
268 }
269 _ => {
270 let node = self.factor();
271 if matches!(node, Math::Object { .. }) {
272 self.built(out, node);
273 } else {
274 out.push(node);
275 }
276 }
277 }
278 }
279
280 fn script(&mut self, out: &mut Vec<Math>) {
281 let first = self.peek().unwrap();
282 self.at += 1;
283 let script = self.operand();
284 if self.typing && script.is_empty() {
285 out.push(Math::Operator(first));
286 return;
287 }
288 let base: Vec<Math> = out.pop().into_iter().collect();
289 let other = if first == '_' { '^' } else { '_' };
290 let node = if self.peek() == Some(other) {
291 self.at += 1;
292 let second = self.operand();
293 let (lower, upper) = if first == '_' {
294 (script, second)
295 } else {
296 (second, script)
297 };
298 object(30, vec!['_'], None, vec![base, lower, upper])
299 } else if first == '_'
300 && matches!(&base[..], [Math::Function(name)] if LIMITS.contains(&name.as_str()))
301 {
302 object(19, vec!['_'], None, vec![base, script])
303 } else {
304 let kind = if first == '^' { 31 } else { 29 };
305 object(kind, vec![first], None, vec![base, script])
306 };
307 self.built(out, node);
308 }
309
310 /// One factor as an object's argument: parentheses and the invisible grouping brackets
311 /// only group it.
312 fn operand(&mut self) -> Vec<Math> {
313 match self.peek() {
314 Some(open @ ('(' | '〖')) => {
315 let start = self.at;
316 self.at += 1;
317 let close = closing(open).unwrap();
318 let inner = self.sequence(&[close]);
319 if self.peek() == Some(close) {
320 self.at += 1;
321 } else if self.typing {
322 // A group still open is still being typed.
323 self.at = start;
324 return Vec::new();
325 }
326 inner
327 }
328 // An argument not typed yet stays empty for the caret.
329 Some(' ') | None => Vec::new(),
330 Some(_) => vec![self.factor()],
331 }
332 }
333
334 /// Juxtaposed factors with their scripts, as a fraction's denominator.
335 fn run(&mut self, stops: &[char]) -> Vec<Math> {
336 let mut out = Vec::new();
337 while let Some(c) = self.peek() {
338 if stops.contains(&c) {
339 break;
340 }
341 let starts = c.is_alphanumeric()
342 || closing(c).is_some()
343 || matches!(c, '√' | '∛' | '∜' | '■' | '█' | '□' | '▭' | '¯');
344 if matches!(c, '^' | '_') && !out.is_empty() {
345 self.script(&mut out);
346 } else if starts {
347 out.push(self.factor());
348 } else {
349 break;
350 }
351 }
352 out
353 }
354
355 fn factor(&mut self) -> Math {
356 let c = self.peek().unwrap();
357 self.at += 1;
358 if c.is_ascii_digit() {
359 let mut number = c.to_string();
360 while let Some(digit) = self.peek().filter(char::is_ascii_digit) {
361 number.push(digit);
362 self.at += 1;
363 }
364 return Math::Number(number);
365 }
366 if c.is_ascii_alphabetic() {
367 let word: String = self.chars[self.at - 1..]
368 .iter()
369 .take_while(|c| c.is_ascii_alphabetic())
370 .collect();
371 if FUNCTIONS.contains(&word.as_str()) {
372 self.at += word.len() - 1;
373 return Math::Function(word);
374 }
375 return Math::Identifier(c);
376 }
377 if c.is_alphabetic() {
378 return Math::Identifier(plain(c));
379 }
380 if let Some(close) = closing(c) {
381 let start = self.at;
382 let inner = self.sequence(&[close]);
383 if self.peek() == Some(close) {
384 self.at += 1;
385 return object(13, vec![c, close], None, vec![inner]);
386 }
387 self.at = start;
388 return Math::Operator(c);
389 }
390 match c {
391 '√' | '∛' | '∜' => {
392 let degree = match c {
393 '∛' => vec![Math::Number("3".into())],
394 '∜' => vec![Math::Number("4".into())],
395 _ => Vec::new(),
396 };
397 let (degree, radicand) = if c == '√' && self.peek() == Some('(') {
398 self.at += 1;
399 let first = self.sequence(&[')', '&']);
400 let parts = if self.peek() == Some('&') {
401 self.at += 1;
402 (first, self.sequence(&[')']))
403 } else {
404 (degree, first)
405 };
406 if self.peek() == Some(')') {
407 self.at += 1;
408 }
409 parts
410 } else {
411 (degree, self.operand())
412 };
413 if self.typing && radicand.is_empty() {
414 return Math::Operator(c);
415 }
416 object(25, vec!['√'], None, vec![degree, radicand])
417 }
418 '■' | '█' if self.peek() == Some('(') => {
419 self.at += 1;
420 let matrix = c == '■';
421 let mut rows = vec![Vec::new()];
422 loop {
423 let stops: &[char] = if matrix {
424 &[')', '&', '@']
425 } else {
426 &[')', '@']
427 };
428 let cell = self.sequence(stops);
429 rows.last_mut().unwrap().push(cell);
430 match self.peek() {
431 Some('&') => self.at += 1,
432 Some('@') => {
433 self.at += 1;
434 rows.push(Vec::new());
435 }
436 Some(_) => {
437 self.at += 1;
438 break;
439 }
440 None => break,
441 }
442 }
443 if matrix {
444 let columns = rows.iter().map(Vec::len).max().unwrap_or(1);
445 let arguments = rows
446 .into_iter()
447 .flat_map(|mut row| {
448 row.resize(columns, Vec::new());
449 row
450 })
451 .collect();
452 object(20, vec!['■'], u8::try_from(columns).ok(), arguments)
453 } else {
454 let arguments = rows.into_iter().flatten().collect();
455 object(15, vec!['█'], Some(1), arguments)
456 }
457 }
458 '□' | '▭' | '¯' => {
459 let kind = match c {
460 '□' => 11,
461 '▭' => 12,
462 _ => 23,
463 };
464 let inner = self.operand();
465 if self.typing && inner.is_empty() {
466 return Math::Operator(c);
467 }
468 object(kind, vec![c], None, vec![inner])
469 }
470 c => Math::Operator(c),
471 }
472 }
473}
474
475/// A lone parenthesized group as an argument only groups it.
476fn stripped(nodes: Vec<Math>) -> Vec<Math> {
477 match <[Math; 1]>::try_from(nodes) {
478 Ok(
479 [
480 Math::Object {
481 kind: 13,
482 symbols,
483 mut arguments,
484 ..
485 },
486 ],
487 ) if symbols == ['(', ')'] && arguments.len() == 1 => arguments.pop().unwrap(),
488 Ok([node]) => vec![node],
489 Err(nodes) => nodes,
490 }
491}
492
493/// Replaces control words with their symbols; an unknown one stays as typed.
494fn expand(text: &str) -> Vec<char> {
495 let chars: Vec<char> = text.chars().collect();
496 let mut out = Vec::with_capacity(chars.len());
497 let mut at = 0;
498 while at < chars.len() {
499 if chars[at] == '\\' {
500 let name: String = chars[at + 1..]
501 .iter()
502 .take_while(|c| c.is_ascii_alphabetic())
503 .collect();
504 if let Some((_, symbol)) = CONTROL_WORDS.iter().find(|(word, _)| *word == name) {
505 out.push(*symbol);
506 at += 1 + name.len();
507 if chars.get(at) == Some(&' ') {
508 at += 1;
509 }
510 continue;
511 }
512 }
513 out.push(chars[at]);
514 at += 1;
515 }
516 out
517}
518
519impl Math {
520 /// The equation typed or shown as `text` in the linear format, built up.
521 pub fn from_linear(text: &str) -> Vec<Math> {
522 Parser {
523 chars: expand(text),
524 at: 0,
525 typing: false,
526 }
527 .sequence(&[])
528 }
529
530 /// What the equation editor builds as `text` is typed: as [`Math::from_linear`], but a
531 /// construct still missing its last operand stays as typed, and an n-ary operator's
532 /// empty body waits for the caret.
533 pub fn typed(text: &str) -> Vec<Math> {
534 Parser {
535 chars: expand(text),
536 at: 0,
537 typing: true,
538 }
539 .sequence(&[])
540 }
541
542 /// The paragraph OneNote stores for an equation shown in the linear format: its linear
543 /// text in one run of math over `base`.
544 pub fn linear_paragraph(nodes: &[Math], base: &Format) -> Option<Paragraph> {
545 Some(Paragraph::new(Self::linear(nodes)?, Self::format(base)))
546 }
547
548 /// The linear format Linear shows for `nodes`, letters in mathematical italic; `None`
549 /// where an object has no linear form here.
550 pub fn linear(nodes: &[Math]) -> Option<String> {
551 let mut out = String::new();
552 write(nodes, &mut out)?;
553 Some(out)
554 }
555}
556
557fn write(nodes: &[Math], out: &mut String) -> Option<()> {
558 let mut after_object = false;
559 for node in nodes {
560 match node {
561 Math::Identifier(c) => out.push(italic(*c)),
562 Math::Function(name) => out.push_str(name),
563 Math::Number(n) => out.push_str(n),
564 // Reading consumes one space after an object.
565 Math::Operator(' ') if after_object => out.push_str(" "),
566 Math::Operator(c) => out.push(*c),
567 Math::Object {
568 kind,
569 symbols,
570 arguments,
571 columns,
572 } => write_object(*kind, symbols, *columns, arguments, out)?,
573 }
574 after_object = matches!(node, Math::Object { .. });
575 }
576 Some(())
577}
578
579/// An argument that reads back as one factor stands bare; others are parenthesized.
580fn argument(nodes: &[Math], out: &mut String) -> Option<()> {
581 let bare = match nodes {
582 [Math::Identifier(_) | Math::Number(_) | Math::Function(_)] => true,
583 [Math::Object { kind, symbols, .. }] => {
584 matches!(kind, 11 | 12 | 15 | 20 | 23 | 25) || *kind == 13 && symbols[..] != ['(', ')']
585 }
586 _ => false,
587 };
588 if bare {
589 return write(nodes, out);
590 }
591 out.push('(');
592 write(nodes, out)?;
593 out.push(')');
594 Some(())
595}
596
597/// A fraction's part stands bare when it reads back as one run of factors.
598fn part(nodes: &[Math], out: &mut String) -> Option<()> {
599 let bare = !nodes.is_empty()
600 && !matches!(nodes, [Math::Object { kind: 13, symbols, .. }] if symbols[..] == ['(', ')'])
601 && nodes.iter().all(|node| match node {
602 Math::Operator(_) => false,
603 Math::Object { kind, .. } => !matches!(kind, 10 | 16 | 19 | 21 | 33),
604 _ => true,
605 });
606 if bare {
607 return write(nodes, out);
608 }
609 out.push('(');
610 write(nodes, out)?;
611 out.push(')');
612 Some(())
613}
614
615fn write_object(
616 kind: u32,
617 symbols: &[char],
618 columns: Option<u8>,
619 arguments: &[Vec<Math>],
620 out: &mut String,
621) -> Option<()> {
622 match (kind, arguments) {
623 (31, [base, sup]) => {
624 argument(base, out)?;
625 out.push('^');
626 argument(sup, out)
627 }
628 (29, [base, sub]) => {
629 argument(base, out)?;
630 out.push('_');
631 argument(sub, out)
632 }
633 (30, [base, sub, sup]) => {
634 argument(base, out)?;
635 out.push('_');
636 argument(sub, out)?;
637 out.push('^');
638 argument(sup, out)
639 }
640 (16, [numerator, denominator]) => {
641 part(numerator, out)?;
642 out.push('/');
643 part(denominator, out)
644 }
645 (13, [inner]) => {
646 out.push(symbols.first().copied().unwrap_or('('));
647 write(inner, out)?;
648 out.push(symbols.get(1).copied().unwrap_or(')'));
649 Some(())
650 }
651 (21, [lower, upper, body]) => {
652 out.push(symbols.first().copied().unwrap_or('∑'));
653 if !lower.is_empty() {
654 out.push('_');
655 argument(lower, out)?;
656 }
657 if !upper.is_empty() {
658 out.push('^');
659 argument(upper, out)?;
660 }
661 out.push('▒');
662 match &body[..] {
663 [Math::Identifier(_) | Math::Number(_)] => write(body, out),
664 _ => {
665 out.push('〖');
666 write(body, out)?;
667 out.push('〗');
668 Some(())
669 }
670 }
671 }
672 (25, [degree, radicand]) => {
673 match &degree[..] {
674 [] => out.push('√'),
675 [Math::Number(n)] if n == "3" => out.push('∛'),
676 [Math::Number(n)] if n == "4" => out.push('∜'),
677 _ => {
678 out.push_str("√(");
679 write(degree, out)?;
680 out.push('&');
681 write(radicand, out)?;
682 out.push(')');
683 return Some(());
684 }
685 }
686 argument(radicand, out)
687 }
688 (19, [base, below]) => {
689 argument(base, out)?;
690 out.push(if symbols == ['_'] { '_' } else { '┬' });
691 argument(below, out)
692 }
693 (33, [base, above]) => {
694 argument(base, out)?;
695 out.push('┴');
696 argument(above, out)
697 }
698 (10, [base]) => {
699 argument(base, out)?;
700 out.push('\u{a0}');
701 out.push(*symbols.first()?);
702 Some(())
703 }
704 (11, [inner]) if symbols == ['□'] => {
705 out.push('□');
706 argument(inner, out)
707 }
708 (12, [inner]) if symbols == ['▭'] => {
709 out.push('▭');
710 argument(inner, out)
711 }
712 (23, [inner]) if symbols == ['¯'] => {
713 out.push('¯');
714 argument(inner, out)
715 }
716 (20, cells) => {
717 let width = usize::from(columns?.max(1));
718 out.push_str("■(");
719 for (index, cell) in cells.iter().enumerate() {
720 if index > 0 {
721 out.push(if index % width == 0 { '@' } else { '&' });
722 }
723 write(cell, out)?;
724 }
725 out.push(')');
726 Some(())
727 }
728 (15, rows) => {
729 out.push_str("█(");
730 for (index, row) in rows.iter().enumerate() {
731 if index > 0 {
732 out.push('@');
733 }
734 write(row, out)?;
735 }
736 out.push(')');
737 Some(())
738 }
739 _ => None,
740 }
741}
crates/onestore/src/page/link.rs+4-2
......@@ -71,7 +71,8 @@ pub struct InternalLink {
7171 pub object: Option<ExGuid>,
7272}
7373
74/// The identities in an `onenote:#…` link, or `None` for any other URL.
74/// The identities in an `onenote:` link, whatever path it names before the `#` (none as
75/// stored, the section file's as Copy Link to Page gives it), or `None` for any other URL.
7576pub fn parse_internal_link(url: &str) -> Option<InternalLink> {
7677 let guid = |value: &str| {
7778 format!("{value},0")
......@@ -79,7 +80,8 @@ pub fn parse_internal_link(url: &str) -> Option<InternalLink> {
7980 .ok()
8081 .map(|id| id.guid)
8182 };
82 let parts: Vec<&str> = url.strip_prefix("onenote:#")?.split('&').collect();
83 let (_, fragment) = url.strip_prefix("onenote:")?.split_once('#')?;
84 let parts: Vec<&str> = fragment.split('&').collect();
8385 let value = |name: &str| {
8486 parts.iter().find_map(|part| {
8587 part.strip_prefix(name)
crates/onestore/src/page/math.rs+99-41
......@@ -149,6 +149,10 @@ impl Math {
149149 Math::Identifier(c) => tag(out, "mi", &c.to_string()),
150150 Math::Function(name) => tag(out, "mi", name),
151151 Math::Number(n) => tag(out, "mn", n),
152 // A parenthesis an object does not pair, as in linear text, is no fence.
153 Math::Operator(c @ ('(' | ')')) => {
154 out.push_str(&format!("<mml:mo fence=\"false\">{c}</mml:mo>"));
155 }
152156 Math::Operator(c) => tag(out, "mo", &c.to_string()),
153157 Math::Object {
154158 kind,
......@@ -346,7 +350,7 @@ fn tag(out: &mut String, element: &str, content: &str) {
346350
347351/// OneNote exports mathematical italic Latin letters as their plain letters and leaves every
348352/// other alphabet (Greek, double-struck, …) as stored.
349fn plain(c: char) -> char {
353pub(crate) fn plain(c: char) -> char {
350354 match u32::from(c) {
351355 code @ 0x1d434..=0x1d44d => char::from_u32(code - 0x1d434 + u32::from('A')).unwrap(),
352356 code @ 0x1d44e..=0x1d467 => char::from_u32(code - 0x1d44e + u32::from('a')).unwrap(),
......@@ -358,27 +362,32 @@ fn plain(c: char) -> char {
358362impl Math {
359363 /// The paragraph OneNote stores for `nodes`: linear text with object controls, every run
360364 /// formatted the way the equation editor formats it (Cambria Math, italic, the math flags
361 /// and the math language over `base`), and the run data each object carries.
365 /// and the math language over `base`; function names upright), and the run data each object
366 /// carries.
367 /// How the equation editor formats math typed over `base`, before it builds up: as a
368 /// run between objects.
369 pub fn format(base: &crate::document::Format) -> crate::document::Format {
370 let mut format = style(base);
371 format.math_object = Some(MathObject {
372 kind: PLAIN_RUN,
373 arguments: None,
374 columns: None,
375 symbols: Vec::new(),
376 });
377 format
378 }
379
362380 pub fn paragraph(nodes: &[Math], base: &crate::document::Format) -> Paragraph {
363 let mut style = base.clone();
364 style.italic = Some(true);
365 style.hidden = Some(false);
366 style.hyperlink = Some(false);
367 style.math = Some(true);
368 style.embedded_object = Some(true);
369 style.font = Some("Cambria Math".into());
370 style.language = Some(0x1007f);
371 let mut runs: Vec<(String, Option<MathObject>)> = Vec::new();
372 write_sequence(nodes, &mut runs);
373 Paragraph::from_runs(runs.into_iter().map(|(text, object)| {
381 let style = style(base);
382 let mut runs = Vec::new();
383 write_sequence(nodes, None, &mut runs);
384 Paragraph::from_runs(runs.into_iter().map(|run| {
374385 let mut format = style.clone();
375 format.math_object = Some(object.unwrap_or(MathObject {
376 kind: PLAIN_RUN,
377 arguments: None,
378 columns: None,
379 symbols: Vec::new(),
380 }));
381 (text, format)
386 if run.upright {
387 format.italic = Some(false);
388 }
389 format.math_object = Some(run.object);
390 (run.text, format)
382391 }))
383392 }
384393}
......@@ -386,12 +395,56 @@ impl Math {
386395/// The object kind OneNote gives runs between objects.
387396const PLAIN_RUN: u32 = 0x9000_0000;
388397
389fn write_sequence(nodes: &[Math], runs: &mut Vec<(String, Option<MathObject>)>) {
398/// How the equation editor formats math over `base`.
399fn style(base: &crate::document::Format) -> crate::document::Format {
400 let mut style = base.clone();
401 style.italic = Some(true);
402 style.hidden = Some(false);
403 style.hyperlink = Some(false);
404 style.hyperlink_label = None;
405 style.math = Some(true);
406 style.embedded_object = Some(true);
407 style.font = Some("Cambria Math".into());
408 style.language = Some(0x1007f);
409 style
410}
411
412struct Run {
413 text: String,
414 object: MathObject,
415 upright: bool,
416 /// Text alone, which the control ending its argument may join.
417 leaf: bool,
418}
419
420/// Runs for `nodes`, an argument of an object of kind `within` or the equation itself. Text
421/// in an argument carries its object's kind, as the equation editor stores it.
422fn write_sequence(nodes: &[Math], within: Option<u32>, runs: &mut Vec<Run>) {
423 let object = || MathObject {
424 kind: within.unwrap_or(PLAIN_RUN),
425 arguments: None,
426 columns: None,
427 symbols: Vec::new(),
428 };
390429 let mut leaf = String::new();
430 let flush = |leaf: &mut String, runs: &mut Vec<Run>, upright: bool| {
431 if !leaf.is_empty() {
432 runs.push(Run {
433 text: std::mem::take(leaf),
434 object: object(),
435 upright,
436 leaf: true,
437 });
438 }
439 };
391440 for node in nodes {
392441 match node {
393442 Math::Identifier(c) => leaf.push(italic(*c)),
394 Math::Function(name) => leaf.push_str(name),
443 Math::Function(name) => {
444 flush(&mut leaf, runs, false);
445 leaf.push_str(name);
446 flush(&mut leaf, runs, true);
447 }
395448 Math::Number(n) => leaf.push_str(n),
396449 Math::Operator(c) => leaf.push(*c),
397450 Math::Object {
......@@ -400,21 +453,22 @@ fn write_sequence(nodes: &[Math], runs: &mut Vec<(String, Option<MathObject>)>)
400453 columns,
401454 arguments,
402455 } => {
403 if !leaf.is_empty() {
404 runs.push((std::mem::take(&mut leaf), None));
405 }
406 runs.push((
407 OBJECT_START.to_string(),
408 Some(MathObject {
456 flush(&mut leaf, runs, false);
457 runs.push(Run {
458 text: OBJECT_START.to_string(),
459 object: MathObject {
409460 kind: *kind,
410461 arguments: Some(arguments.len() as u32),
411462 columns: *columns,
412463 symbols: symbols.clone(),
413 }),
414 ));
464 },
465 upright: false,
466 leaf: false,
467 });
415468 for (index, argument) in arguments.iter().enumerate() {
416469 let before = runs.len();
417 write_sequence(argument, runs);
470 write_sequence(argument, Some(*kind), runs);
471 let extended = runs.len() > before;
418472 let control = if index + 1 == arguments.len() {
419473 OBJECT_END
420474 } else {
......@@ -426,29 +480,33 @@ fn write_sequence(nodes: &[Math], runs: &mut Vec<(String, Option<MathObject>)>)
426480 columns: None,
427481 symbols: Vec::new(),
428482 };
429 let extended = runs.len() > before;
430483 match runs.last_mut() {
431 Some((text, slot)) if extended && slot.is_none() => {
432 text.push(control);
433 *slot = Some(object);
484 Some(run) if extended && run.leaf && !run.upright => {
485 run.text.push(control);
486 run.object = object;
487 run.leaf = false;
434488 }
435 _ => runs.push((control.to_string(), Some(object))),
489 _ => runs.push(Run {
490 text: control.to_string(),
491 object,
492 upright: false,
493 leaf: false,
494 }),
436495 }
437496 }
438497 }
439498 }
440499 }
441 if !leaf.is_empty() {
442 runs.push((leaf, None));
443 }
500 flush(&mut leaf, runs, false);
444501}
445502
446/// The equation editor stores Latin letters as mathematical italics.
447fn italic(c: char) -> char {
503/// The equation editor stores Latin letters and lowercase Greek as mathematical italics.
504pub(crate) fn italic(c: char) -> char {
448505 match c {
449506 'h' => '\u{210e}',
450507 'A'..='Z' => char::from_u32(0x1d434 + u32::from(c) - u32::from('A')).unwrap(),
451508 'a'..='z' => char::from_u32(0x1d44e + u32::from(c) - u32::from('a')).unwrap(),
509 'α'..='ω' => char::from_u32(0x1d6fc + u32::from(c) - u32::from('α')).unwrap(),
452510 c => c,
453511 }
454512}
crates/onestore/src/page/mod.rs+28-10
......@@ -11,6 +11,7 @@ use std::{
1111};
1212
1313pub mod ink;
14mod linear;
1415pub mod link;
1516pub mod math;
1617pub mod text;
......@@ -378,7 +379,11 @@ impl Page {
378379 let mut selected = None;
379380 for (space, id) in document.pages()? {
380381 let revision = document.active(space)?;
381 if page_title(revision, id) == Some(title) {
382 if page_title(revision, id)
383 .map(crate::edit::without_fields)
384 .as_deref()
385 == Some(title)
386 {
382387 if selected.is_some() {
383388 return Err(Error {
384389 offset: 0,
......@@ -406,8 +411,8 @@ impl Page {
406411 /// `copy`, renaming `ids`, identities on this page, to theirs on the copy.
407412 pub fn copy_with(&self, ids: &mut [ExGuid]) -> Result<Self, Error> {
408413 let invalid = |message| Error { offset: 0, message };
409 let mut value = serde_json::to_value((self, &*ids))
410 .map_err(|_| invalid("Page is not serializable"))?;
414 let mut value =
415 serde_json::to_value((self, &*ids)).map_err(|_| invalid("Page is not serializable"))?;
411416 let mut fresh: BTreeMap<String, String> = BTreeMap::new();
412417 fn walk(
413418 value: &mut serde_json::Value,
......@@ -495,7 +500,10 @@ impl Page {
495500 PageObject::Title(title) => Some(title),
496501 _ => None,
497502 })?;
498 let outline = title.outlines.iter().find(|outline| Some(outline.id) == title.date)?;
503 let outline = title
504 .outlines
505 .iter()
506 .find(|outline| Some(outline.id) == title.date)?;
499507 let [first, second] = &outline.paragraphs[..] else {
500508 return None;
501509 };
......@@ -503,7 +511,9 @@ impl Page {
503511 let text = |object: &TextObject| object.text.text().to_owned();
504512 // OneNote writes the date first; a field marked as the other role says otherwise.
505513 Some(
506 if first.date_field == Some(DateField::Time) || second.date_field == Some(DateField::Date) {
514 if first.date_field == Some(DateField::Time)
515 || second.date_field == Some(DateField::Date)
516 {
507517 [text(second), text(first)]
508518 } else {
509519 [text(first), text(second)]
......@@ -539,7 +549,7 @@ impl Page {
539549 _ => None,
540550 });
541551 (
542 page_title(revision, id).unwrap_or_default().to_owned(),
552 crate::edit::without_fields(page_title(revision, id).unwrap_or_default()),
543553 level.unwrap_or(1),
544554 )
545555 }
......@@ -577,7 +587,7 @@ impl Page {
577587 .filter(|node| matches!(node.kind, Kind::Metadata { .. }))
578588 .and_then(|node| node.extra.first());
579589 let mut page = Self {
580 title: page_title(revision, id).unwrap_or_default().to_owned(),
590 title: crate::edit::without_fields(page_title(revision, id).unwrap_or_default()),
581591 identity: Self::identity_of(revision),
582592 created: metadata
583593 .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65))
......@@ -604,7 +614,7 @@ impl Page {
604614 FieldValue::Bytes(&[red, green, blue, alpha]) => {
605615 Ok(u32::from_le_bytes([red, green, blue, alpha]))
606616 }
607 _ => Err(invalid("The page colour is damaged")),
617 _ => Err(invalid("The page color is damaged")),
608618 })
609619 .transpose()?,
610620 objects: Vec::new(),
......@@ -755,7 +765,11 @@ fn inherited_by(
755765 paragraph_style, ..
756766 } => {
757767 let style = match paragraph_style {
758 Some(id) => revision.nodes.get(id).map(|n| n.format.clone()).unwrap_or_default(),
768 Some(id) => revision
769 .nodes
770 .get(id)
771 .map(|n| n.format.clone())
772 .unwrap_or_default(),
759773 None => Format::default(),
760774 };
761775 node.format
......@@ -805,7 +819,11 @@ pub(crate) fn text_of(
805819 let runs = revision.text_runs(text)?;
806820 Ok(if runs.is_empty() {
807821 let style = match paragraph_style {
808 Some(id) => revision.nodes.get(id).map(|n| n.format.clone()).unwrap_or_default(),
822 Some(id) => revision
823 .nodes
824 .get(id)
825 .map(|n| n.format.clone())
826 .unwrap_or_default(),
809827 None => Format::default(),
810828 };
811829 Paragraph::new(String::new(), node.format.inherit(&style).inherit(&format))
crates/onestore/src/pages.rs+66-31
......@@ -38,7 +38,11 @@ pub(crate) fn metadata_guid(metadata: &crate::document::Element<'_>) -> Result<[
3838 .ok_or_else(|| invalid("Page metadata needs its page identifier"))
3939}
4040
41pub(crate) fn set_references(object: &mut PropertyObject, property: u32, ids: &[ExGuid]) -> Result<(), Error> {
41pub(crate) fn set_references(
42 object: &mut PropertyObject,
43 property: u32,
44 ids: &[ExGuid],
45) -> Result<(), Error> {
4246 let mut references = Vec::new();
4347 for id in ids {
4448 references.extend_from_slice(&object.reference(*id)?);
......@@ -184,19 +188,29 @@ pub(crate) fn section_changes(
184188 )
185189 };
186190 if element(&manifest)?.content.len() != 1 {
187 return Err(invalid("Each page space must contain one page with a valid level"));
191 return Err(invalid(
192 "Each page space must contain one page with a valid level",
193 ));
188194 }
189195 Ok(PageParts {
190196 metadata: element(&stored)?,
191 manifest: (manifest, PropertyObject::from_object(&revision.objects[&manifest])?),
192 stored_metadata: (stored, PropertyObject::from_object(&revision.objects[&stored])?),
197 manifest: (
198 manifest,
199 PropertyObject::from_object(&revision.objects[&manifest])?,
200 ),
201 stored_metadata: (
202 stored,
203 PropertyObject::from_object(&revision.objects[&stored])?,
204 ),
193205 })
194206 };
195 Ok(page_changes(root, &view, &raw, &pages, parts, edits, removals)?
196 .unwrap_or_default()
197 .into_iter()
198 .filter(|(_, changes)| !changes.is_empty())
199 .collect())
207 Ok(
208 page_changes(root, &view, &raw, &pages, parts, edits, removals)?
209 .unwrap_or_default()
210 .into_iter()
211 .filter(|(_, changes)| !changes.is_empty())
212 .collect(),
213 )
200214}
201215
202216/// The objects page moves, indentation and removals change in each space; none when the
......@@ -253,7 +267,10 @@ fn page_changes<'a>(
253267 "Each page space must contain one page with a valid level",
254268 ));
255269 }
256 if page_guids.insert(metadata_guid(&page.metadata)?, *sid).is_some() {
270 if page_guids
271 .insert(metadata_guid(&page.metadata)?, *sid)
272 .is_some()
273 {
257274 return Err(invalid("Pages must have distinct metadata identifiers"));
258275 }
259276 order.push(*sid);
......@@ -305,9 +322,7 @@ fn page_changes<'a>(
305322 let sid = page_guids
306323 .get(&metadata_guid(&view.nodes[id])?)
307324 .filter(|sid| series.spaces.contains(sid))
308 .ok_or_else(|| {
309 invalid("Metadata copy must identify a page in its series")
310 })?;
325 .ok_or_else(|| invalid("Metadata copy must identify a page in its series"))?;
311326 if copies.insert(*sid, *id).is_some() && !mismatched {
312327 return Err(invalid("Each page needs its own ordinary metadata copy"));
313328 }
......@@ -334,11 +349,10 @@ fn page_changes<'a>(
334349 order.remove(at);
335350 let position = match before {
336351 None => order.len(),
337 Some(before) => {
338 order.iter().position(|sid| *sid == before).ok_or_else(|| {
339 invalid("The movement anchor is no longer in the section")
340 })?
341 }
352 Some(before) => order
353 .iter()
354 .position(|sid| *sid == before)
355 .ok_or_else(|| invalid("The movement anchor is no longer in the section"))?,
342356 };
343357 order.insert(position, edit.space);
344358 }
......@@ -351,7 +365,7 @@ fn page_changes<'a>(
351365 if order == original_order && levels == original_levels {
352366 return Ok(None);
353367 }
354 let mut groups: Vec<Vec<ExGuid>> = Vec::new();
368 let mut groups: Vec<Vec<ExGuid>> = Vec::new();
355369 for sid in order {
356370 if levels[&sid] == 1 {
357371 groups.push(Vec::new());
......@@ -405,8 +419,8 @@ fn page_changes<'a>(
405419 )
406420 };
407421 children.push(id);
408 let membership_changed = old_id
409 .is_none_or(|old| view.nodes[&old].spaces != spaces || stale.contains(&old));
422 let membership_changed =
423 old_id.is_none_or(|old| view.nodes[&old].spaces != spaces || stale.contains(&old));
410424 if !membership_changed
411425 && !spaces.iter().any(|sid| {
412426 levels[sid] != original_levels[sid]
......@@ -611,10 +625,7 @@ impl PageCreation {
611625 }
612626
613627 /// The root space's changes creating this page, and the new space's roots and objects.
614 pub(crate) fn changes(
615 &self,
616 section: &mut crate::Section<'_>,
617 ) -> Result<Creation, Error> {
628 pub(crate) fn changes(&self, section: &mut crate::Section<'_>) -> Result<Creation, Error> {
618629 self.validate()?;
619630 if section.revision(self.space()).is_ok() {
620631 return Err(invalid(
......@@ -636,7 +647,14 @@ impl PageCreation {
636647 section: &mut crate::Section<'_>,
637648 title: &str,
638649 level: u32,
639 ) -> Result<(BTreeMap<u32, ExGuid>, crate::active::Changes, PropertyObject), Error> {
650 ) -> Result<
651 (
652 BTreeMap<u32, ExGuid>,
653 crate::active::Changes,
654 PropertyObject,
655 ),
656 Error,
657 > {
640658 if self.before.is_some() {
641659 return Err(invalid("A conflict page is not placed in the page list"));
642660 }
......@@ -683,7 +701,11 @@ impl PageCreation {
683701 };
684702 let text = |value: &str, role: u32| {
685703 // OneNote keeps Latin-1 text as TextExtendedAscii, other text as Unicode.
686 let content = match value.chars().map(|c| u8::try_from(c).ok()).collect::<Option<Vec<u8>>>() {
704 let content = match value
705 .chars()
706 .map(|c| u8::try_from(c).ok())
707 .collect::<Option<Vec<u8>>>()
708 {
687709 Some(latin) => (0x1c003498, latin),
688710 None => (0x1c001c22, string(value)),
689711 };
......@@ -829,8 +851,11 @@ impl PageCreation {
829851 modified(),
830852 (
831853 0x24001c20,
832 [reference(14), self.date.iter().flat_map(|_| reference(20)).collect()]
833 .concat(),
854 [
855 reference(14),
856 self.date.iter().flat_map(|_| reference(20)).collect(),
857 ]
858 .concat(),
834859 ),
835860 (0x14001c14, 0_f32.to_le_bytes().to_vec()),
836861 (0x14001c15, 0_f32.to_le_bytes().to_vec()),
......@@ -949,14 +974,24 @@ mod tests {
949974 };
950975
951976 /// Page moves and removals through a section, sealed: the image they leave.
952 fn edit_pages(source: &[u8], edits: &[PageEdit], removals: &[ExGuid]) -> Result<Vec<u8>, OpError> {
977 fn edit_pages(
978 source: &[u8],
979 edits: &[PageEdit],
980 removals: &[ExGuid],
981 ) -> Result<Vec<u8>, OpError> {
953982 let arena = crate::Arena::default();
954983 let mut section = crate::Section::open(&arena, source.to_vec()).map_err(OpError::Failed)?;
955984 let mut ops = vec![Op::Section(SectionOp::Pages(edits.to_vec()))];
956985 if !removals.is_empty() {
957986 ops.push(Op::Section(SectionOp::Delete(removals.to_vec())));
958987 }
959 section.apply("Author", &Edit { at: 133_700_000_000_000_000, ops })?;
988 section.apply(
989 "Author",
990 &Edit {
991 at: 133_700_000_000_000_000,
992 ops,
993 },
994 )?;
960995 section.seal().map_err(OpError::Failed)?;
961996 Ok(section.image())
962997 }
crates/onestore/src/protected/mod.rs+13-5
......@@ -416,7 +416,9 @@ impl UnlockedSection<'_> {
416416 ));
417417 }
418418 if edit.ops.iter().any(|op| matches!(op, Op::Section(_))) {
419 return Err(OpError::Unsupported("A protected section's page list is not edited"));
419 return Err(OpError::Unsupported(
420 "A protected section's page list is not edited",
421 ));
420422 }
421423 let twin = self.twin().map_err(failed)?;
422424 let arena = crate::Arena::default();
......@@ -435,7 +437,10 @@ impl UnlockedSection<'_> {
435437 .map(|space| Ok((*space, applied_index.resolve_active(*space)?)))
436438 .collect::<std::result::Result<Vec<_>, crate::Error>>()
437439 .map_err(failed)?;
438 let edited: Vec<_> = edited.iter().map(|(space, after)| (*space, after)).collect();
440 let edited: Vec<_> = edited
441 .iter()
442 .map(|(space, after)| (*space, after))
443 .collect();
439444 let payloads = crate::write::declared_payloads(&applied_store)
440445 .into_iter()
441446 .map(|guid| Ok((guid, applied_store.file_data(guid)?)))
......@@ -444,12 +449,15 @@ impl UnlockedSection<'_> {
444449 let transaction = crate::write::squash(self.index, &edited, &payloads, Some(self))
445450 .map_err(failed)?
446451 .ok_or(OpError::Unsupported("The edit changes nothing"))?;
447 let written = crate::write::applied(self.index.store.data, Some(&transaction))
448 .map_err(failed)?;
452 let written =
453 crate::write::applied(self.index.store.data, Some(&transaction)).map_err(failed)?;
449454 let store = crate::Store::parse(&written).map_err(failed)?;
450455 let index = RevisionIndex::parse(&store).map_err(failed)?;
451456 UnlockedSection::unlock(&index, Limits::default(), |metadata, _| {
452 self.keys.get(metadata).cloned().ok_or(Error::PasswordMismatch)
457 self.keys
458 .get(metadata)
459 .cloned()
460 .ok_or(Error::PasswordMismatch)
453461 })
454462 .map_err(|error| match error {
455463 Error::Invalid(error) => failed(error),
crates/onestore/src/section.rs+372-146
......@@ -9,13 +9,17 @@ use crate::{
99 page::Page,
1010 store::StoreState,
1111 write::{
12 Appending, LiveRevision, Sealing, Written, chain_depth, check_transaction, declared,
13 replacement, unchanged,
12 Appending, Commit, LiveRevision, PropertyObject, Sealing, Written, chain_depth,
13 check_transaction, declared, label_payload, node, replacement, unchanged,
1414 },
1515};
1616use bumpalo::Bump;
1717use std::collections::{BTreeMap, BTreeSet};
1818
19mod versions;
20pub(crate) use versions::HISTORY;
21pub use versions::PageVersion;
22
1923type Result<T> = std::result::Result<T, Error>;
2024
2125/// Holds the bytes a `Section` reads: the image it opened and each transaction it sealed.
......@@ -58,6 +62,58 @@ struct Space<'a> {
5862 /// The active revision the file stores; none before a new space's first seal.
5963 rid: Option<ExGuid>,
6064 state: SpaceState<'a>,
65 /// The version a restore took the open revision from: the revision it depends on, which
66 /// the seal writes even unchanged.
67 restored: Option<ExGuid>,
68 /// Page states restores made versions of, which the seal writes before the open revision.
69 frozen: Vec<Frozen<'a>>,
70 history: Option<History<'a>>,
71 /// Revisions the seal labels as versions, with their contexts.
72 labels: Vec<(ExGuid, ExGuid)>,
73 /// The newest revision a seal appended to the space.
74 newest: Option<ExGuid>,
75}
76
77impl<'a> Space<'a> {
78 fn new(rid: Option<ExGuid>, state: SpaceState<'a>, history: Option<History<'a>>) -> Self {
79 Self {
80 rid,
81 state,
82 restored: None,
83 frozen: Vec::new(),
84 history,
85 labels: Vec::new(),
86 newest: None,
87 }
88 }
89}
90
91/// A page state a restore made a version of: a revision under the identity it chose.
92#[derive(Clone)]
93struct Frozen<'a> {
94 rid: ExGuid,
95 previous: Option<ExGuid>,
96 live: LiveRevision<'a>,
97 revised: Revised,
98}
99
100/// A revision `changed` committed: what it declares, and of those the objects whose bytes it
101/// stores and the objects new to the space.
102#[derive(Clone)]
103struct Revised {
104 commit: Commit,
105 replaced: BTreeSet<ExGuid>,
106 created: BTreeSet<ExGuid>,
107}
108
109/// A page's version history (`versions::HISTORY`), with the objects edits since the last
110/// seal replaced in it.
111#[derive(Clone)]
112struct History<'a> {
113 rid: ExGuid,
114 revision: ResolvedRevision<'a>,
115 depth: usize,
116 pending: BTreeMap<ExGuid, PropertyObject>,
61117}
62118
63119#[derive(Clone)]
......@@ -105,50 +161,24 @@ impl<'a> Section<'a> {
105161 let Some(rid) = space.labels.get(&(ExGuid::default(), 1)).copied() else {
106162 continue;
107163 };
108 let resolved = index.resolve(*id, rid)?;
109 let revision = ResolvedRevision {
110 roots: resolved.roots,
111 objects: resolved
112 .objects
113 .into_iter()
114 .map(|(id, object)| {
115 // The parse borrows `bytes` only as long as `store`; its slices lie
116 // in `bytes`, which the arena keeps.
117 let data = match object.data {
118 ObjectData::Properties(data) => {
119 ObjectData::Properties(within(bytes, data))
120 }
121 ObjectData::Encrypted(data) => {
122 ObjectData::Encrypted(within(bytes, data))
123 }
124 ObjectData::File {
125 reference,
126 extension,
127 } => ObjectData::File {
128 reference: within(bytes, reference),
129 extension: within(bytes, extension),
130 },
131 };
132 let object = crate::Object {
133 jcid: object.jcid,
134 reference_count: object.reference_count,
135 data,
136 global_ids: object.global_ids,
137 };
138 (id, object)
164 let revision = bound(bytes, index.resolve(*id, rid)?);
165 let history = space
166 .labels
167 .get(&(versions::HISTORY, 1))
168 .map(|history| -> Result<History<'a>> {
169 Ok(History {
170 rid: *history,
171 revision: bound(bytes, index.resolve(*id, *history)?),
172 depth: chain_depth(&index, *id, *history),
173 pending: BTreeMap::new(),
139174 })
140 .collect(),
175 })
176 .transpose()?;
177 let state = SpaceState::Stored {
178 revision,
179 depth: chain_depth(&index, *id, rid),
141180 };
142 spaces.insert(
143 *id,
144 Space {
145 rid: Some(rid),
146 state: SpaceState::Stored {
147 revision,
148 depth: chain_depth(&index, *id, rid),
149 },
150 },
151 );
181 spaces.insert(*id, Space::new(Some(rid), state, history));
152182 }
153183 let mut files = Vec::new();
154184 for node in store.lists.values().flat_map(|list| &list.nodes) {
......@@ -209,6 +239,15 @@ impl<'a> Section<'a> {
209239 .filter_map(|(space, stored)| Some((*space, stored.rid?)))
210240 }
211241
242 /// Each object space with the revision this section last sealed in it, where one did, or
243 /// its active revision: a seal that changes only a page's versions leaves the page's
244 /// active revision as it was.
245 pub fn newest(&self) -> impl Iterator<Item = (ExGuid, ExGuid)> + '_ {
246 self.spaces
247 .iter()
248 .filter_map(|(space, stored)| Some((*space, stored.newest.or(stored.rid)?)))
249 }
250
212251 /// The page an object space holds, with the changes applied since the last seal.
213252 pub fn page(&self, space: ExGuid) -> Result<Page> {
214253 let stored = self.spaces.get(&space).ok_or(Error {
......@@ -303,10 +342,14 @@ impl<'a> Section<'a> {
303342 /// own, so a page in another series than before was moved.
304343 pub fn series(&mut self) -> Result<BTreeMap<ExGuid, ExGuid>> {
305344 let view = &self.active(self.root)?.view;
306 let section = view.roots.get(&1).and_then(|id| view.nodes.get(id)).ok_or(Error {
307 offset: 0,
308 message: "Section root is unavailable",
309 })?;
345 let section = view
346 .roots
347 .get(&1)
348 .and_then(|id| view.nodes.get(id))
349 .ok_or(Error {
350 offset: 0,
351 message: "Section root is unavailable",
352 })?;
310353 Ok(section
311354 .children
312355 .iter()
......@@ -375,7 +418,8 @@ impl<'a> Section<'a> {
375418 title = alternate_title;
376419 }
377420 }
378 pages.push((space, title.unwrap_or_default(), level));
421 let title = crate::edit::without_fields(&title.unwrap_or_default());
422 pages.push((space, title, level));
379423 }
380424 Ok(pages)
381425 }
......@@ -447,6 +491,15 @@ impl<'a> Section<'a> {
447491 Ok(open)
448492 }
449493
494 /// Keeps a space as it is for the running edit to return to, the first time it changes it.
495 pub(crate) fn remember(&mut self, space: ExGuid) {
496 if let Some(undo) = &mut self.undo
497 && !undo.spaces.contains_key(&space)
498 {
499 undo.spaces.insert(space, self.spaces.get(&space).cloned());
500 }
501 }
502
450503 /// Stores a writer's objects and payloads in a space until the next seal; false when
451504 /// it stores nothing. An error leaves the section to be reopened.
452505 pub(crate) fn apply_changes(
......@@ -456,11 +509,7 @@ impl<'a> Section<'a> {
456509 changes: Changes,
457510 ) -> Result<bool> {
458511 self.usable()?;
459 if let Some(undo) = &mut self.undo
460 && !undo.spaces.contains_key(&space)
461 {
462 undo.spaces.insert(space, self.spaces.get(&space).cloned());
463 }
512 self.remember(space);
464513 let arena = &self.arena.0;
465514 self.broken = true;
466515 let open = self.editable(space)?;
......@@ -519,17 +568,12 @@ impl<'a> Section<'a> {
519568 let all: Vec<ExGuid> = work.revision.objects.keys().copied().collect();
520569 work.settle(all)?;
521570 let page = ActivePage::open(space, work, FileType::Section, self.files())?;
522 self.spaces.insert(
523 space,
524 Space {
525 rid: None,
526 state: SpaceState::Open(Box::new(Open {
527 file,
528 page,
529 pending,
530 })),
531 },
532 );
571 let state = SpaceState::Open(Box::new(Open {
572 file,
573 page,
574 pending,
575 }));
576 self.spaces.insert(space, Space::new(None, state, None));
533577 self.broken = false;
534578 Ok(())
535579 }
......@@ -595,69 +639,124 @@ impl<'a> Section<'a> {
595639 let mut appending = Appending::new(self.state.clone());
596640 let mut sealed = Vec::new();
597641 for (id, space) in &mut self.spaces {
598 let SpaceState::Open(open) = &mut space.state else {
599 continue;
600 };
601 if open.pending.is_empty() {
602 continue;
642 // A space's revisions and labels go in one fragment: page states restores made
643 // versions of, the open revision, the version history, then the labels.
644 let mut manifest = Vec::new();
645 for frozen in &space.frozen {
646 appending.manifest(
647 &Sealing {
648 space: *id,
649 previous: frozen.previous,
650 new_space: false,
651 label: (ExGuid::default(), 1),
652 rid: Some(frozen.rid),
653 live: &frozen.live,
654 commit: &frozen.revised.commit,
655 replaced: &frozen.revised.replaced,
656 created: &frozen.revised.created,
657 },
658 &self.segments,
659 None,
660 None,
661 &mut manifest,
662 )?;
603663 }
604 let (work, file) = (&open.page.live, &mut open.file);
605 let mut replacements = BTreeMap::new();
606 for object_id in &open.pending {
607 if !work.is_reachable(*object_id) {
608 continue;
609 }
610 let object = &work.revision.objects[object_id];
611 if let Some(stored) = file.revision.objects.get(object_id)
612 && unchanged(stored, object)?
664 let mut page = None;
665 if let SpaceState::Open(open) = &mut space.state
666 && (!open.pending.is_empty() || space.restored.is_some())
667 {
668 let file = &mut open.file;
669 let restored = space.restored.is_some();
670 let written = match changed(arena, &open.page.live, &open.pending, file, restored)?
613671 {
614 continue;
672 Some(Revised {
673 commit,
674 replaced,
675 created,
676 }) => {
677 let (rid, stored) = appending.manifest(
678 &Sealing {
679 space: *id,
680 previous: space.restored.or(space.rid),
681 new_space: space.rid.is_none(),
682 label: (ExGuid::default(), 1),
683 rid: None,
684 live: file,
685 commit: &commit,
686 replaced: &replaced,
687 created: &created,
688 },
689 &self.segments,
690 None,
691 None,
692 &mut manifest,
693 )?;
694 if commit.checkpoint {
695 let all: Vec<ExGuid> = file.revision.objects.keys().copied().collect();
696 file.settle(all)?;
697 } else {
698 file.settle(commit.changed.iter().copied())?;
699 }
700 Some((rid, stored, commit))
701 }
702 None => None,
703 };
704 page = Some(written);
705 }
706 let mut history = None;
707 if let Some(stored) = &space.history
708 && !stored.pending.is_empty()
709 {
710 let mut live = LiveRevision::new(stored.revision.clone(), stored.depth)?;
711 let replacements = live.prepare(stored.pending.clone(), true)?;
712 if !replacements.is_empty() {
713 let replaced: BTreeSet<ExGuid> = replacements.keys().copied().collect();
714 let created = replaced
715 .iter()
716 .filter(|id| !live.revision.objects.contains_key(id))
717 .copied()
718 .collect();
719 let mut objects = Vec::new();
720 for (object_id, change) in replacements {
721 let bytes = arena.alloc_slice_copy(&change.bytes);
722 objects.push((object_id, declared(change.jcid, bytes, change.global_ids)?));
723 }
724 let commit = live.commit(objects)?;
725 let (rid, chunks) = appending.manifest(
726 &Sealing {
727 space: *id,
728 previous: Some(stored.rid),
729 new_space: false,
730 label: (versions::HISTORY, 1),
731 rid: None,
732 live: &live,
733 commit: &commit,
734 replaced: &replaced,
735 created: &created,
736 },
737 &self.segments,
738 None,
739 None,
740 &mut manifest,
741 )?;
742 if commit.checkpoint {
743 let all: Vec<ExGuid> = live.revision.objects.keys().copied().collect();
744 live.settle(all)?;
745 } else {
746 live.settle(commit.changed.iter().copied())?;
747 }
748 history = Some((rid, chunks, live, commit));
615749 }
616 replacements.insert(*object_id, replacement(*object_id, object)?);
617750 }
618 let replacements = file.prepare(replacements, true)?;
619 if replacements.is_empty() {
620 sealed.push((*id, None));
621 continue;
751 for (rid, context) in &space.labels {
752 manifest.push(node(0x5d, None, &label_payload(*rid, *context))?);
622753 }
623 let replaced: BTreeSet<ExGuid> = replacements.keys().copied().collect();
624 let created = replaced
625 .iter()
626 .filter(|id| !file.revision.objects.contains_key(id))
627 .copied()
628 .collect();
629 let mut objects = Vec::new();
630 for (object_id, change) in replacements {
631 // The page holds these bytes already unless preparing remapped them.
632 let bytes = match work.revision.objects.get(&object_id).map(|o| o.data) {
633 Some(ObjectData::Properties(bytes)) if bytes == change.bytes => bytes,
634 _ => arena.alloc_slice_copy(&change.bytes),
635 };
636 objects.push((object_id, declared(change.jcid, bytes, change.global_ids)?));
754 if !manifest.is_empty() {
755 appending.close(*id, space.rid.is_none(), &manifest)?;
637756 }
638 let commit = file.commit(objects)?;
639 let (rid, stored) = appending.revision(
640 &Sealing {
641 space: *id,
642 previous: space.rid,
643 new_space: space.rid.is_none(),
644 label: (ExGuid::default(), 1),
645 live: file,
646 commit: &commit,
647 replaced: &replaced,
648 created: &created,
649 },
650 &self.segments,
651 None,
652 None,
653 )?;
654 if commit.checkpoint {
655 let all: Vec<ExGuid> = file.revision.objects.keys().copied().collect();
656 file.settle(all)?;
657 } else {
658 file.settle(commit.changed.iter().copied())?;
757 if page.is_some() || !manifest.is_empty() {
758 sealed.push((*id, page, history));
659759 }
660 sealed.push((*id, Some((rid, stored, commit))));
661760 }
662761 let payloads: Vec<_> = self
663762 .payloads
......@@ -674,20 +773,46 @@ impl<'a> Section<'a> {
674773 }
675774 if let Some((transaction, state)) = &transaction {
676775 let mut written = Vec::new();
677 for (id, revision) in &sealed {
678 let (Some((rid, _, commit)), Some(space)) = (revision, self.spaces.get(id)) else {
679 continue;
680 };
681 let SpaceState::Open(open) = &space.state else {
682 unreachable!()
683 };
684 written.push(Written {
685 space: *id,
686 rid: *rid,
687 previous: space.rid,
688 live: &open.file,
689 commit,
690 });
776 let mut labels = Vec::new();
777 for (id, page, history) in &sealed {
778 let space = &self.spaces[id];
779 for frozen in &space.frozen {
780 written.push(Written {
781 space: *id,
782 rid: frozen.rid,
783 previous: frozen.previous,
784 label: (ExGuid::default(), 1),
785 live: &frozen.live,
786 commit: &frozen.revised.commit,
787 });
788 }
789 if let (Some(Some((rid, _, commit))), SpaceState::Open(open)) = (page, &space.state)
790 {
791 written.push(Written {
792 space: *id,
793 rid: *rid,
794 previous: space.restored.or(space.rid),
795 label: (ExGuid::default(), 1),
796 live: &open.file,
797 commit,
798 });
799 }
800 if let (Some((rid, _, live, commit)), Some(stored)) = (history, &space.history) {
801 written.push(Written {
802 space: *id,
803 rid: *rid,
804 previous: Some(stored.rid),
805 label: (versions::HISTORY, 1),
806 live,
807 commit,
808 });
809 }
810 labels.extend(
811 space
812 .labels
813 .iter()
814 .map(|(rid, context)| (*id, *rid, *context)),
815 );
691816 }
692817 check_transaction(
693818 &self.state,
......@@ -695,6 +820,7 @@ impl<'a> Section<'a> {
695820 transaction,
696821 &self.segments,
697822 &written,
823 &labels,
698824 &payloads,
699825 )?;
700826 let base = transaction.base.length;
......@@ -703,23 +829,42 @@ impl<'a> Section<'a> {
703829 self.patches.extend(transaction.patches.iter().cloned());
704830 self.state = state.clone();
705831 self.declared.extend(payloads.iter().map(|(guid, _)| *guid));
706 for (id, revision) in &sealed {
707 let Some((rid, stored, _)) = revision else {
708 continue;
709 };
710 let space = self.spaces.get_mut(id).unwrap();
711 space.rid = Some(*rid);
712 let SpaceState::Open(open) = &mut space.state else {
713 unreachable!()
714 };
832 let bind = |revision: &mut ResolvedRevision<'a>, stored: &[(ExGuid, crate::Chunk)]| {
715833 for (object_id, chunk) in stored {
716834 let start = (chunk.offset - base) as usize;
717 open.file.revision.objects.get_mut(object_id).unwrap().data =
835 revision.objects.get_mut(object_id).unwrap().data =
718836 ObjectData::Properties(&segment[start..start + chunk.length as usize]);
719837 }
838 };
839 for (id, page, history) in &mut sealed {
840 let space = self.spaces.get_mut(id).unwrap();
841 space.frozen.clear();
842 space.labels.clear();
843 if let (Some(Some((rid, stored, _))), SpaceState::Open(open)) =
844 (&*page, &mut space.state)
845 {
846 space.rid = Some(*rid);
847 space.restored = None;
848 space.newest = Some(*rid);
849 bind(&mut open.file.revision, stored);
850 }
851 if let Some((rid, stored, live, _)) = history.take() {
852 let mut revision = live.revision;
853 bind(&mut revision, &stored);
854 space.history = Some(History {
855 rid,
856 revision,
857 depth: live.depth,
858 pending: BTreeMap::new(),
859 });
860 space.newest = Some(rid);
861 }
720862 }
721863 }
722 for (id, revision) in sealed {
864 for (id, page, _) in sealed {
865 let Some(revision) = page else {
866 continue;
867 };
723868 let SpaceState::Open(open) = &mut self.spaces.get_mut(&id).unwrap().state else {
724869 unreachable!()
725870 };
......@@ -759,6 +904,87 @@ impl<'a> Section<'a> {
759904 }
760905}
761906
907/// Commits to `file` the objects `pending` names that `page` changed, with what the revision
908/// declares, replaces and creates; none when that changes nothing, unless `force`.
909fn changed<'a>(
910 arena: &'a Bump,
911 page: &LiveRevision<'a>,
912 pending: &BTreeSet<ExGuid>,
913 file: &mut LiveRevision<'a>,
914 force: bool,
915) -> Result<Option<Revised>> {
916 let mut replacements = BTreeMap::new();
917 for object_id in pending {
918 if !page.is_reachable(*object_id) {
919 continue;
920 }
921 let object = &page.revision.objects[object_id];
922 if let Some(stored) = file.revision.objects.get(object_id)
923 && unchanged(stored, object)?
924 {
925 continue;
926 }
927 replacements.insert(*object_id, replacement(*object_id, object)?);
928 }
929 let replacements = file.prepare(replacements, true)?;
930 if replacements.is_empty() && !force {
931 return Ok(None);
932 }
933 let replaced: BTreeSet<ExGuid> = replacements.keys().copied().collect();
934 let created = replaced
935 .iter()
936 .filter(|id| !file.revision.objects.contains_key(id))
937 .copied()
938 .collect();
939 let mut objects = Vec::new();
940 for (object_id, change) in replacements {
941 // The page holds these bytes already unless preparing remapped them.
942 let bytes = match page.revision.objects.get(&object_id).map(|o| o.data) {
943 Some(ObjectData::Properties(bytes)) if bytes == change.bytes => bytes,
944 _ => arena.alloc_slice_copy(&change.bytes),
945 };
946 objects.push((object_id, declared(change.jcid, bytes, change.global_ids)?));
947 }
948 Ok(Some(Revised {
949 commit: file.commit(objects)?,
950 replaced,
951 created,
952 }))
953}
954
955/// `resolved`, parsed from `bytes`, borrowing them for their lifetime.
956fn bound<'a>(bytes: &'a [u8], resolved: ResolvedRevision<'_>) -> ResolvedRevision<'a> {
957 ResolvedRevision {
958 roots: resolved.roots,
959 objects: resolved
960 .objects
961 .into_iter()
962 .map(|(id, object)| {
963 // The parse borrows `bytes` only as long as its store; its slices lie in
964 // `bytes`, which the arena keeps.
965 let data = match object.data {
966 ObjectData::Properties(data) => ObjectData::Properties(within(bytes, data)),
967 ObjectData::Encrypted(data) => ObjectData::Encrypted(within(bytes, data)),
968 ObjectData::File {
969 reference,
970 extension,
971 } => ObjectData::File {
972 reference: within(bytes, reference),
973 extension: within(bytes, extension),
974 },
975 };
976 let object = crate::Object {
977 jcid: object.jcid,
978 reference_count: object.reference_count,
979 data,
980 global_ids: object.global_ids,
981 };
982 (id, object)
983 })
984 .collect(),
985 }
986}
987
762988/// `part`, a slice of `image`, with the image's lifetime.
763989fn within<'a>(image: &'a [u8], part: &[u8]) -> &'a [u8] {
764990 let start = part.as_ptr().addr() - image.as_ptr().addr();
crates/onestore/src/section/versions.rs created+492
......@@ -0,0 +1,492 @@
1//! Page versions, the earlier states of a page OneNote 2010 keeps (MS-ONE 2.1.17, 2.1.18).
2//! A version is an earlier revision of the page's own object space, current under a context
3//! of its own. The page's version history, the space's revision current under `HISTORY`,
4//! holds a proxy for each (`jcidVersionProxy`), newest first. `corpus/page-versions` records
5//! what OneNote 2010 writes.
6
7use super::{Frozen, Open, Section, SpaceState, changed, one_page};
8use crate::{
9 Error, ExGuid, FileType, Object, ObjectData, ResolvedRevision, RevisionIndex, Store,
10 active::{ActivePage, manifest_pages},
11 create::{author_properties, current_timestamps, properties},
12 document::{Element, Kind, Revision},
13 op::{Failure, OpError},
14 page::Page,
15 pages::set_references,
16 write::{LiveRevision, PropertyObject, chain_depth, compact, declared},
17};
18use std::{
19 collections::{BTreeMap, BTreeSet},
20 sync::Arc,
21};
22
23type Result<T> = std::result::Result<T, Error>;
24
25/// The context of a page's version history, {7111497F-1B6B-4209-9491-C98B04CF4C5A},1.
26pub(crate) const HISTORY: ExGuid = ExGuid {
27 guid: [
28 0x7f, 0x49, 0x11, 0x71, 0x6b, 0x1b, 0x09, 0x42, 0x94, 0x91, 0xc9, 0x8b, 0x04, 0xcf, 0x4c,
29 0x5a,
30 ],
31 n: 1,
32};
33
34/// A version's context is its revision's identity with these bits flipped, and its proxy's
35/// identity the context's with `PROXY` flipped, as OneNote 2010 derives both.
36const CONTEXT: [u8; 16] = [
37 0x7c, 0xfe, 0x52, 0x9d, 0xbc, 0x8f, 0x95, 0x44, 0x93, 0x00, 0x12, 0x9b, 0x57, 0x97, 0xa9, 0xea,
38];
39const PROXY: [u8; 16] = [
40 0xca, 0x0b, 0x44, 0x88, 0x8c, 0x11, 0x81, 0x0e, 0x32, 0xbf, 0x83, 0xdb, 0x8c, 0xb8, 0xb6, 0xb5,
41];
42
43fn flipped(mut id: ExGuid, salt: [u8; 16]) -> ExGuid {
44 for (byte, salt) in id.guid.iter_mut().zip(salt) {
45 *byte ^= salt;
46 }
47 id
48}
49
50/// An earlier state of a page, as the page list names it.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct PageVersion {
53 /// The context its revision is current under, which names it.
54 pub context: ExGuid,
55 /// FILETIME of its last change (`LastModifiedTimeStamp`).
56 pub modified: Option<u64>,
57 /// Who last changed it (`AuthorMostRecent`).
58 pub author: Option<String>,
59}
60
61fn element<'o>(object: &Object<'o>) -> Result<Element<'o>> {
62 Element::parse_with(object, FileType::Section, &mut |_| {
63 Err(Error {
64 offset: 0,
65 message: "Version metadata holds no payload",
66 })
67 })
68}
69
70/// When `revision`'s page was last changed and by whom, as its revision metadata says.
71fn last_change(revision: &ResolvedRevision<'_>) -> Result<(Option<u64>, Option<ExGuid>)> {
72 let Some(metadata) = revision
73 .roots
74 .get(&4)
75 .and_then(|id| revision.objects.get(id))
76 else {
77 return Ok((None, None));
78 };
79 let metadata = element(metadata)?;
80 let Kind::RevisionMetadata { modified_filetime } = metadata.kind else {
81 return Ok((None, None));
82 };
83 Ok((modified_filetime, metadata.latest_author))
84}
85
86impl<'a> Section<'a> {
87 /// The versions of each page that has them, in section order, each page's newest first as
88 /// OneNote 2010 lists them; read from the version histories alone.
89 pub fn versions(&mut self) -> Result<Vec<(ExGuid, Vec<PageVersion>)>> {
90 let mut listed = Vec::new();
91 for (page, ..) in self.pages()? {
92 let versions: Vec<PageVersion> = self
93 .listed(page)?
94 .into_iter()
95 .map(|(_, version)| version)
96 .collect();
97 if !versions.is_empty() {
98 listed.push((page, versions));
99 }
100 }
101 Ok(listed)
102 }
103
104 /// Page `space` as its version `context` holds it. Reads the sealed image: O(section).
105 pub fn version(&self, space: ExGuid, context: ExGuid) -> Result<Page> {
106 if !self
107 .listed(space)?
108 .iter()
109 .any(|(_, version)| version.context == context)
110 {
111 return Err(Error {
112 offset: 0,
113 message: "The page has no such version",
114 });
115 }
116 let stored = &self.spaces[&space];
117 if let Some(frozen) = stored
118 .frozen
119 .iter()
120 .find(|frozen| flipped(frozen.rid, CONTEXT) == context)
121 {
122 let files = self.files();
123 let pending = &self.payloads;
124 let (view, _) = Revision::parse(
125 space,
126 &frozen.live.revision,
127 FileType::Section,
128 &mut |guid| match pending.iter().find(|(id, _)| *id == guid) {
129 Some((_, payload)) => Ok(*payload),
130 None => files(guid),
131 },
132 )?;
133 return one_page(&view, &manifest_pages(&view));
134 }
135 let image = self.image();
136 let store = Store::parse(&image)?;
137 let index = RevisionIndex::parse(&store)?;
138 let rid = self.version_revision(&index, space, context)?;
139 let resolved = index.resolve(space, rid)?;
140 let (view, _) = Revision::parse(space, &resolved, FileType::Section, &mut |guid| {
141 store.file_data(guid)
142 })?;
143 one_page(&view, &manifest_pages(&view))
144 }
145
146 /// Page `space`'s versions, newest first, with their proxies.
147 fn listed(&self, space: ExGuid) -> Result<Vec<(ExGuid, PageVersion)>> {
148 let Some(history) = self
149 .spaces
150 .get(&space)
151 .and_then(|space| space.history.as_ref())
152 else {
153 return Ok(Vec::new());
154 };
155 let lookup = |id: &ExGuid| -> Result<Option<Object<'_>>> {
156 match history.pending.get(id) {
157 Some(object) => {
158 declared(object.jcid, &object.bytes, Arc::clone(&object.global_ids)).map(Some)
159 }
160 None => Ok(history.revision.objects.get(id).cloned()),
161 }
162 };
163 let Some(root) = history.revision.roots.get(&1) else {
164 return Ok(Vec::new());
165 };
166 let Some(root) = lookup(root)? else {
167 return Ok(Vec::new());
168 };
169 let mut versions = Vec::new();
170 for proxy in element(&root)?.children {
171 let Some(object) = lookup(&proxy)? else {
172 return Err(Error {
173 offset: 0,
174 message: "A version proxy is unavailable",
175 });
176 };
177 let node = element(&object)?;
178 let Kind::VersionProxy {
179 context,
180 modified_filetime,
181 } = node.kind
182 else {
183 return Err(Error {
184 offset: 0,
185 message: "A version history lists another kind of object",
186 });
187 };
188 let author = match node
189 .latest_author
190 .as_ref()
191 .map(lookup)
192 .transpose()?
193 .flatten()
194 {
195 Some(author) => match element(&author)?.kind {
196 Kind::Author { name } => name,
197 _ => None,
198 },
199 None => None,
200 };
201 versions.push((
202 proxy,
203 PageVersion {
204 context,
205 modified: modified_filetime,
206 author,
207 },
208 ));
209 }
210 Ok(versions)
211 }
212
213 /// The revision version `context` of `space` is, in `index` of the sealed image.
214 fn version_revision(
215 &self,
216 index: &RevisionIndex<'_>,
217 space: ExGuid,
218 context: ExGuid,
219 ) -> Result<ExGuid> {
220 let labelled = self.spaces[&space]
221 .labels
222 .iter()
223 .find(|(_, labelled)| *labelled == context)
224 .map(|(rid, _)| *rid);
225 labelled
226 .or_else(|| index.spaces.get(&space)?.labels.get(&(context, 1)).copied())
227 .ok_or(Error {
228 offset: 0,
229 message: "A version's revision is not stored",
230 })
231 }
232
233 /// Version `context` of `space` as a revision of the section's own bytes, with its
234 /// identity and chain depth. Reads the sealed image: O(section).
235 fn historic(
236 &self,
237 space: ExGuid,
238 context: ExGuid,
239 ) -> Result<(ExGuid, ResolvedRevision<'a>, usize)> {
240 if self.spaces[&space]
241 .frozen
242 .iter()
243 .any(|frozen| flipped(frozen.rid, CONTEXT) == context)
244 {
245 return Err(Error {
246 offset: 0,
247 message: "This version is still saving. Restore it in a moment.",
248 });
249 }
250 let image = self.image();
251 let store = Store::parse(&image)?;
252 let index = RevisionIndex::parse(&store)?;
253 let rid = self.version_revision(&index, space, context)?;
254 let resolved = index.resolve(space, rid)?;
255 // Object bytes lie in the section's segments, which outlive this image.
256 let resident = |part: &[u8]| -> Result<&'a [u8]> {
257 let offset = (part.as_ptr().addr() - image.as_ptr().addr()) as u64;
258 self.segments
259 .iter()
260 .rfind(|(start, _)| *start <= offset)
261 .and_then(|(start, segment)| {
262 let at = (offset - start) as usize;
263 segment.get(at..at + part.len())
264 })
265 .filter(|bytes| *bytes == part)
266 .ok_or(Error {
267 offset: offset as usize,
268 message: "A version's bytes lie outside the section",
269 })
270 };
271 let mut objects = BTreeMap::new();
272 for (id, object) in resolved.objects {
273 let data = match object.data {
274 ObjectData::Properties(data) => ObjectData::Properties(resident(data)?),
275 ObjectData::Encrypted(data) => ObjectData::Encrypted(resident(data)?),
276 ObjectData::File {
277 reference,
278 extension,
279 } => ObjectData::File {
280 reference: resident(reference)?,
281 extension: resident(extension)?,
282 },
283 };
284 objects.insert(
285 id,
286 Object {
287 jcid: object.jcid,
288 reference_count: object.reference_count,
289 data,
290 global_ids: object.global_ids,
291 },
292 );
293 }
294 let revision = ResolvedRevision {
295 roots: resolved.roots,
296 objects,
297 };
298 Ok((rid, revision, chain_depth(&index, space, rid)))
299 }
300
301 /// Restores version `context` of page `space` as OneNote 2010's Restore Version does: the
302 /// page as it stands becomes the newest version, and the page's next revision builds on
303 /// the version's, its revision metadata naming `author` now. New objects take identities
304 /// from `guid`.
305 pub(crate) fn restore(
306 &mut self,
307 author: &str,
308 space: ExGuid,
309 context: ExGuid,
310 guid: [u8; 16],
311 ) -> std::result::Result<(), Failure> {
312 if !self
313 .listed(space)?
314 .iter()
315 .any(|(_, version)| version.context == context)
316 {
317 return Err(OpError::TargetUnavailable(context).into());
318 }
319 let (from, revision, depth) = self.historic(space, context)?;
320 let (now, filetime) = current_timestamps()?;
321 let named = |n| ExGuid { guid, n };
322 self.remember(space);
323 let files = self.files();
324 let arena = &self.arena.0;
325 self.editable(space)?;
326 let stored = self.spaces.get_mut(&space).unwrap();
327 let SpaceState::Open(open) = &mut stored.state else {
328 unreachable!()
329 };
330 // The page as it stands: the stored revision, or its unsaved state as one of its own.
331 let (current, modified, who) = if open.pending.is_empty() && stored.restored.is_none() {
332 let rid = stored.rid.ok_or(Error {
333 offset: 0,
334 message: "A page never saved has no versions",
335 })?;
336 let (modified, who) = last_change(&open.file.revision)?;
337 let who = who
338 .and_then(|id| open.file.revision.objects.get(&id))
339 .cloned();
340 (rid, modified, who)
341 } else {
342 let mut live = open.file.clone();
343 let revised = changed(arena, &open.page.live, &open.pending, &mut live, true)?
344 .expect("a forced revision is written");
345 let rid = named(1);
346 stored.frozen.push(Frozen {
347 rid,
348 previous: stored.restored.or(stored.rid),
349 live,
350 revised,
351 });
352 (rid, None, None)
353 };
354 let label = flipped(current, CONTEXT);
355 stored.labels.push((current, label));
356 // Its proxy, newest in the history, names when and by whom it was last changed.
357 let proxy = flipped(label, PROXY);
358 let proxy_author = named(3);
359 let author_object = match &who {
360 Some(object) => PropertyObject::from_object(object)?,
361 None => PropertyObject {
362 jcid: 0x120001,
363 bytes: properties(&author_properties(author))?,
364 global_ids: Arc::new(BTreeMap::new()),
365 },
366 };
367 let author_object = PropertyObject {
368 global_ids: Arc::new(BTreeMap::from([(0, proxy_author.guid)])),
369 ..author_object
370 };
371 let table = BTreeMap::from([(0, proxy.guid), (1, proxy_author.guid), (2, label.guid)]);
372 let proxy_object = PropertyObject {
373 jcid: 0x6003d,
374 bytes: properties(&[
375 (0x14001d09, now.to_le_bytes().to_vec()),
376 (
377 0x18001d77,
378 modified.unwrap_or(filetime).to_le_bytes().to_vec(),
379 ),
380 (0x20001d79, compact(proxy_author, &table)?.to_vec()),
381 (0x3400347b, compact(label, &table)?.to_vec()),
382 ])?,
383 global_ids: Arc::new(table),
384 };
385 let listed: Vec<ExGuid> = self
386 .listed(space)?
387 .into_iter()
388 .map(|(proxy, _)| proxy)
389 .collect();
390 let stored = self.spaces.get_mut(&space).unwrap();
391 let history = stored.history.as_mut().ok_or(Error {
392 offset: 0,
393 message: "The page has no version history",
394 })?;
395 let root = *history.revision.roots.get(&1).ok_or(Error {
396 offset: 0,
397 message: "The version history has no content",
398 })?;
399 let mut root_object = match history.pending.get(&root) {
400 Some(object) => object.clone(),
401 None => PropertyObject::from_object(&history.revision.objects[&root])?,
402 };
403 let children: Vec<ExGuid> = std::iter::once(proxy).chain(listed).collect();
404 set_references(&mut root_object, 0x24001c20, &children)?;
405 history.pending.extend([
406 (root, root_object),
407 (proxy, proxy_object),
408 (proxy_author, author_object),
409 ]);
410 // The page's next revision builds on the version's.
411 let file = LiveRevision::new(revision, depth)?;
412 let page = ActivePage::open(space, file.clone(), FileType::Section, files)?;
413 stored.state = SpaceState::Open(Box::new(Open {
414 file,
415 page,
416 pending: BTreeSet::new(),
417 }));
418 stored.restored = Some(from);
419 // Its revision metadata names who restored it, and when.
420 let active = self.active(space)?;
421 let revision = &active.live.revision;
422 let mut changes = BTreeMap::new();
423 if let Some(id) = revision.roots.get(&4)
424 && revision.objects[id].jcid == 0x20044
425 {
426 let restorer = named(2);
427 let mut metadata = PropertyObject::from_object(&revision.objects[id])?;
428 let reference = metadata.reference(restorer)?;
429 metadata.set(&[
430 (0x18001d77, &filetime.to_le_bytes()),
431 (0x20001d79, &reference),
432 ])?;
433 changes.insert(*id, metadata);
434 changes.insert(
435 restorer,
436 PropertyObject {
437 jcid: 0x120001,
438 bytes: properties(&author_properties(author))?,
439 global_ids: Arc::new(BTreeMap::from([(0, restorer.guid)])),
440 },
441 );
442 }
443 self.apply_changes(space, &[], changes)?;
444 Ok(())
445 }
446
447 /// Deletes versions `contexts` of page `space` as OneNote 2010's Delete Version does: its
448 /// history stops listing them; their revisions stay stored. True when none is left.
449 pub(crate) fn unlist(
450 &mut self,
451 space: ExGuid,
452 contexts: &[ExGuid],
453 ) -> std::result::Result<bool, Failure> {
454 let listed = self.listed(space)?;
455 if let Some(context) = contexts.iter().find(|context| {
456 !listed
457 .iter()
458 .any(|(_, version)| version.context == **context)
459 }) {
460 return Err(OpError::TargetUnavailable(*context).into());
461 }
462 let kept: Vec<ExGuid> = listed
463 .iter()
464 .filter(|(_, version)| !contexts.contains(&version.context))
465 .map(|(proxy, _)| *proxy)
466 .collect();
467 self.remember(space);
468 let history = self
469 .spaces
470 .get_mut(&space)
471 .and_then(|space| space.history.as_mut())
472 .ok_or(Error {
473 offset: 0,
474 message: "The page has no version history",
475 })?;
476 let root = *history.revision.roots.get(&1).ok_or(Error {
477 offset: 0,
478 message: "The version history has no content",
479 })?;
480 let mut object = match history.pending.get(&root) {
481 Some(object) => object.clone(),
482 None => PropertyObject::from_object(&history.revision.objects[&root])?,
483 };
484 if kept.is_empty() {
485 object.remove(&[0x24001c20])?;
486 } else {
487 set_references(&mut object, 0x24001c20, &kept)?;
488 }
489 history.pending.insert(root, object);
490 Ok(kept.is_empty())
491 }
492}
crates/onestore/src/toc.rs+131-5
......@@ -64,19 +64,27 @@ pub fn edit_table_of_contents(
6464 let Some(Kind::Toc { entries, .. }) = revision.nodes.get(&root).map(|node| &node.kind) else {
6565 return Err(invalid("Missing notebook TOC root"));
6666 };
67 // Entries in stored order with their identities and filenames.
68 let mut listed: Vec<(ExGuid, [u8; 16], String)> = Vec::new();
67 // Entries in display order (their ordering numbers, not their place in the root's list)
68 // with their identities and filenames.
69 let mut stored = Vec::new();
6970 for id in entries {
7071 let Some(Kind::Toc {
7172 filename,
7273 identity: Some(identity),
74 order,
7375 ..
7476 }) = revision.nodes.get(id).map(|node| &node.kind)
7577 else {
7678 return Err(invalid("Incomplete notebook TOC reference"));
7779 };
78 listed.push((*id, *identity, filename.clone().unwrap_or_default()));
80 stored.push((
81 order.unwrap_or(u32::MAX),
82 (*id, *identity, filename.clone().unwrap_or_default()),
83 ));
7984 }
85 stored.sort_by_key(|(order, _)| *order);
86 let mut listed: Vec<(ExGuid, [u8; 16], String)> =
87 stored.into_iter().map(|(_, entry)| entry).collect();
8088 let mut created: BTreeMap<ExGuid, PropertyObject> = BTreeMap::new();
8189 let mut renamed = BTreeSet::new();
8290 let mut color = None;
......@@ -173,6 +181,13 @@ pub fn edit_table_of_contents(
173181 }
174182 }
175183 let listed = listed;
184 // A rename or a colour keeps every number, gaps and ties included, as OneNote left them.
185 let resequence = edits.iter().any(|edit| {
186 matches!(
187 edit,
188 TocEdit::Add { .. } | TocEdit::Order(_) | TocEdit::Remove { .. }
189 )
190 });
176191 let transaction = build_on(&index, &[], None, |index| {
177192 let raw = index.resolve_active(space)?;
178193 let mut changed = BTreeMap::new();
......@@ -190,9 +205,10 @@ pub fn edit_table_of_contents(
190205 Value::Bytes(b) => b.try_into().ok().map(u32::from_le_bytes),
191206 _ => None,
192207 });
208 let order = order as u32 + 1;
193209 let mut updates: Vec<(u32, Vec<u8>)> = Vec::new();
194 if stored_order != Some(order as u32 + 1) {
195 updates.push((0x14001cb9, (order as u32 + 1).to_le_bytes().to_vec()));
210 if resequence && stored_order != Some(order) {
211 updates.push((0x14001cb9, order.to_le_bytes().to_vec()));
196212 }
197213 if renamed.contains(id) {
198214 updates.push((0x1c001d6b, crate::create::string(filename)));
......@@ -219,3 +235,113 @@ pub fn edit_table_of_contents(
219235 check(&applied(source, transaction.as_ref())?, true)?;
220236 Ok(transaction)
221237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 fn apply(source: &[u8], transaction: Option<Transaction>) -> Vec<u8> {
244 applied(source, transaction.as_ref()).unwrap()
245 }
246
247 /// Each entry's filename and ordering number, as the root lists them.
248 fn entries(image: &[u8]) -> Vec<(ExGuid, String, u32)> {
249 let store = Store::parse(image).unwrap();
250 let index = RevisionIndex::parse(&store).unwrap();
251 let document = Document::parse(&index).unwrap();
252 let revision = document.active(document.root).unwrap();
253 let Kind::Toc { entries, .. } = &revision.nodes[&revision.roots[&1]].kind else {
254 panic!()
255 };
256 entries
257 .iter()
258 .map(|id| match &revision.nodes[id].kind {
259 Kind::Toc {
260 filename, order, ..
261 } => (*id, filename.clone().unwrap(), order.unwrap()),
262 _ => panic!(),
263 })
264 .collect()
265 }
266
267 fn displayed(image: &[u8]) -> Vec<(String, u32)> {
268 let mut entries: Vec<_> = entries(image)
269 .into_iter()
270 .map(|(_, name, order)| (name, order))
271 .collect();
272 entries.sort_by_key(|(_, order)| *order);
273 entries
274 }
275
276 #[test]
277 fn edits_follow_the_ordering_numbers_not_the_list() {
278 let [a, b, c] = [[1; 16], [2; 16], [3; 16]];
279 let toc = crate::create_table_of_contents(
280 "Open Notebook.onetoc2",
281 &[("Video.one", a), ("Song.one", b), ("Album.one", c)],
282 )
283 .unwrap();
284 // Numbers out of the list's order, with a gap and a tie, as native TOCs carry them.
285 let dragged = {
286 let store = Store::parse(&toc).unwrap();
287 let index = RevisionIndex::parse(&store).unwrap();
288 let space = Document::parse(&index).unwrap().root;
289 let listed = entries(&toc);
290 let transaction = build_on(&index, &[], None, |index| {
291 let raw = index.resolve_active(space)?;
292 let mut changed = BTreeMap::new();
293 for ((id, _, _), order) in listed.iter().zip([7u32, 2, 2]) {
294 let mut object = PropertyObject::from_object(&raw.objects[id])?;
295 object.set(&[(0x14001cb9, &order.to_le_bytes())])?;
296 changed.insert(*id, object);
297 }
298 Ok(BTreeMap::from([(space, RevisionEdit::Update(changed))]))
299 })
300 .unwrap();
301 apply(&toc, transaction)
302 };
303 let renamed = apply(
304 &dragged,
305 edit_table_of_contents(
306 &dragged,
307 &[TocEdit::Rename {
308 identity: a,
309 filename: "Kitchen.one".into(),
310 }],
311 )
312 .unwrap(),
313 );
314 assert_eq!(
315 displayed(&renamed),
316 [
317 ("Song.one".into(), 2),
318 ("Album.one".into(), 2),
319 ("Kitchen.one".into(), 7)
320 ]
321 );
322 let added = apply(
323 &renamed,
324 edit_table_of_contents(
325 &renamed,
326 &[TocEdit::Add {
327 filename: "Lore.one".into(),
328 identity: [4; 16],
329 group: false,
330 }],
331 )
332 .unwrap(),
333 );
334 assert_eq!(
335 entries(&added)
336 .into_iter()
337 .map(|(_, name, order)| (name, order))
338 .collect::<Vec<_>>(),
339 [
340 ("Song.one".into(), 1),
341 ("Album.one".into(), 2),
342 ("Kitchen.one".into(), 3),
343 ("Lore.one".into(), 4)
344 ]
345 );
346 }
347}
crates/onestore/src/tree.rs+2-3
......@@ -458,9 +458,8 @@ mod tests {
458458 .unwrap();
459459 let paragraph = view.nodes[&outline].children[0];
460460 let text = view.nodes[&paragraph].content[0];
461 let deleted = |bytes: &[u8]| {
462 edited(bytes, sid, vec![PageOp::Delete { object: paragraph }]).is_ok()
463 };
461 let deleted =
462 |bytes: &[u8]| edited(bytes, sid, vec![PageOp::Delete { object: paragraph }]).is_ok();
464463 for target in [page, outline, paragraph, text] {
465464 for property in [0x08001cde, 0x08001cb4, 0x08001cf9, 0x08001cb2] {
466465 for enabled in [false, true] {
crates/onestore/src/write.rs+238-164
......@@ -76,6 +76,16 @@ pub(crate) fn node(id: u16, reference: Option<Reference>, payload: &[u8]) -> Res
7676 Ok(bytes)
7777}
7878
79/// A `RevisionRoleAndContextDeclarationFND` payload: `rid` current under `context` in the
80/// default role.
81pub(crate) fn label_payload(rid: ExGuid, context: ExGuid) -> Vec<u8> {
82 let mut payload = Vec::new();
83 rid.encode(&mut payload);
84 payload.extend_from_slice(&1_u32.to_le_bytes());
85 context.encode(&mut payload);
86 payload
87}
88
7989pub(crate) fn append(data: &mut Vec<u8>, bytes: &[u8]) -> Result<Chunk> {
8090 let length = u64::from(u32::try_from(bytes.len()).map_err(|_| Error {
8191 offset: 0,
......@@ -1213,6 +1223,7 @@ pub(crate) fn differing<'s, T: PartialEq>(before: &'s [T], after: &'s [T]) -> (&
12131223}
12141224
12151225/// A revision `LiveRevision::commit` stored.
1226#[derive(Clone)]
12161227pub(crate) struct Commit {
12171228 /// Whether the revision declares every object, depending on none.
12181229 pub checkpoint: bool,
......@@ -1329,7 +1340,10 @@ fn build(
13291340 if validate {
13301341 index.validate_current()?;
13311342 }
1332 applied(source, build_on(&index, payloads, protection, edit)?.as_ref())
1343 applied(
1344 source,
1345 build_on(&index, payloads, protection, edit)?.as_ref(),
1346 )
13331347}
13341348
13351349/// `write_revision` on a source the caller has parsed and validated.
......@@ -1484,6 +1498,7 @@ pub(crate) fn build_on(
14841498 previous: rid,
14851499 new_space,
14861500 label,
1501 rid: None,
14871502 live: &live,
14881503 commit: &commit,
14891504 replaced: &replaced,
......@@ -1578,6 +1593,8 @@ pub(crate) struct Sealing<'r, 'a> {
15781593 pub new_space: bool,
15791594 /// The context and role it is current under.
15801595 pub label: (ExGuid, u32),
1596 /// Its identity, where the caller chose one; a fresh one otherwise.
1597 pub rid: Option<ExGuid>,
15811598 pub live: &'r LiveRevision<'a>,
15821599 pub commit: &'r Commit,
15831600 /// Objects whose bytes the revision stores rather than referencing stored bytes.
......@@ -1722,6 +1739,23 @@ impl Appending {
17221739 segments: &[(u64, &[u8])],
17231740 protection: Option<&dyn Protection>,
17241741 key: Option<&crate::Node<'_>>,
1742 ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> {
1743 let mut manifest = Vec::new();
1744 let written = self.manifest(sealing, segments, protection, key, &mut manifest)?;
1745 self.close(sealing.space, sealing.new_space, &manifest)?;
1746 Ok(written)
1747 }
1748
1749 /// Appends a revision's object groups and new data, and its manifest's nodes to
1750 /// `manifest` for `close`; returns its identity and where it stored each replaced
1751 /// object's bytes.
1752 pub(crate) fn manifest(
1753 &mut self,
1754 sealing: &Sealing<'_, '_>,
1755 segments: &[(u64, &[u8])],
1756 protection: Option<&dyn Protection>,
1757 key: Option<&crate::Node<'_>>,
1758 manifest: &mut Vec<Vec<u8>>,
17251759 ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> {
17261760 let is_section = self.state.file_type == FileType::Section;
17271761 let Sealing {
......@@ -1782,9 +1816,12 @@ impl Appending {
17821816 .or_default()
17831817 .push((*id, object));
17841818 }
1785 let new_rid = ExGuid {
1786 guid: fresh_guid()?,
1787 n: 1,
1819 let new_rid = match sealing.rid {
1820 Some(rid) => rid,
1821 None => ExGuid {
1822 guid: fresh_guid()?,
1823 n: 1,
1824 },
17881825 };
17891826 let mut start = Vec::new();
17901827 new_rid.encode(&mut start);
......@@ -1806,7 +1843,6 @@ impl Appending {
18061843 if contextual {
18071844 label.0.encode(&mut start);
18081845 }
1809 let mut manifest = Vec::new();
18101846 if sealing.new_space {
18111847 let mut payload = Vec::new();
18121848 space.encode(&mut payload);
......@@ -1997,8 +2033,13 @@ impl Appending {
19972033 }
19982034 }
19992035 manifest.push(node(0x1c, None, &[])?);
2000 if sealing.new_space {
2001 let (chunk, tail) = self.list(&manifest)?;
2036 Ok((new_rid, stored))
2037 }
2038
2039 /// Adds a space's manifest nodes to its revision list, creating the space when `new`.
2040 pub(crate) fn close(&mut self, space: ExGuid, new: bool, manifest: &[Vec<u8>]) -> Result<()> {
2041 if new {
2042 let (chunk, tail) = self.list(manifest)?;
20022043 let mut payload = Vec::new();
20032044 space.encode(&mut payload);
20042045 let (chunk, _) = self.list(&[
......@@ -2013,10 +2054,10 @@ impl Appending {
20132054 offset: 0,
20142055 message: "Object space is absent from the root list",
20152056 })?;
2016 self.extend(&mut tail, &manifest)?;
2057 self.extend(&mut tail, manifest)?;
20172058 self.state.spaces.insert(space, tail);
20182059 }
2019 Ok((new_rid, stored))
2060 Ok(())
20202061 }
20212062
20222063 /// Embeds payloads as file-data store objects referenced from the root file node list
......@@ -2221,12 +2262,14 @@ pub(crate) struct Written<'r, 'a> {
22212262 pub space: ExGuid,
22222263 pub rid: ExGuid,
22232264 pub previous: Option<ExGuid>,
2265 pub label: (ExGuid, u32),
22242266 pub live: &'r LiveRevision<'a>,
22252267 pub commit: &'r Commit,
22262268}
22272269
22282270/// Reads back what `transaction` appends to the section `before` describes, whose bytes
2229/// `segments` hold, and checks it stores `revisions` and `payloads` as `after` expects:
2271/// `segments` hold, and checks it stores `revisions`, then each space's `labels` (space,
2272/// revision, context), and `payloads` as `after` expects:
22302273/// fragments link and decode to the logged counts under the logged checksum; each declared
22312274/// object parses, names identities its group's table holds and objects reachable after its
22322275/// revision, carries its incremental reference count and, if read-only, its MD5; roots stay
......@@ -2237,6 +2280,7 @@ pub(crate) fn check_transaction(
22372280 transaction: &crate::Transaction,
22382281 segments: &[(u64, &[u8])],
22392282 revisions: &[Written<'_, '_>],
2283 labels: &[(ExGuid, ExGuid, ExGuid)],
22402284 payloads: &[([u8; 16], &[u8])],
22412285) -> Result<()> {
22422286 let wrong = |message| Error { offset: 0, message };
......@@ -2332,14 +2376,18 @@ pub(crate) fn check_transaction(
23322376 }
23332377 read(after.last)
23342378 };
2335 for written in revisions {
2336 let Written {
2337 space,
2338 rid,
2339 live,
2340 commit,
2341 ..
2342 } = *written;
2379 // Each space's list gains one fragment: its revisions in order, then its labels.
2380 let mut spaces: Vec<ExGuid> = Vec::new();
2381 for space in revisions
2382 .iter()
2383 .map(|written| written.space)
2384 .chain(labels.iter().map(|(space, ..)| *space))
2385 {
2386 if !spaces.contains(&space) {
2387 spaces.push(space);
2388 }
2389 }
2390 for space in spaces {
23432391 let old = before.spaces.get(&space);
23442392 let new = after
23452393 .spaces
......@@ -2359,166 +2407,190 @@ pub(crate) fn check_transaction(
23592407 .next()
23602408 .ok_or(wrong("A revision manifest is truncated"))
23612409 };
2362 let mut node = next()?;
2363 if old.is_none() {
2410 let mut fresh = old.is_none();
2411 for written in revisions.iter().filter(|written| written.space == space) {
2412 let Written {
2413 rid,
2414 live,
2415 commit,
2416 label,
2417 ..
2418 } = *written;
2419 let mut node = next()?;
2420 if fresh {
2421 let mut c = Cursor {
2422 bytes: node.payload,
2423 offset: node.offset,
2424 };
2425 if node.id != 0x14 || c.exguid()? != space {
2426 return Err(wrong("A new space's revision list names another space"));
2427 }
2428 node = next()?;
2429 fresh = false;
2430 }
23642431 let mut c = Cursor {
23652432 bytes: node.payload,
23662433 offset: node.offset,
23672434 };
2368 if node.id != 0x14 || c.exguid()? != space {
2369 return Err(wrong("A new space's revision list names another space"));
2435 let dependency = if commit.checkpoint {
2436 ExGuid::default()
2437 } else {
2438 written
2439 .previous
2440 .ok_or(wrong("A dependent revision follows none"))?
2441 };
2442 let contextual = label.0 != ExGuid::default();
2443 if node.id != if contextual { 0x1f } else { 0x1e }
2444 || c.exguid()? != rid
2445 || c.exguid()? != dependency
2446 || c.read::<4>()? != label.1.to_le_bytes()
2447 || c.read::<2>()? != [0, 0]
2448 || (contextual && c.exguid()? != label.0)
2449 {
2450 return Err(wrong(
2451 "A revision starts with the wrong identity, dependency or label",
2452 ));
23702453 }
2371 node = next()?;
2372 }
2373 let mut c = Cursor {
2374 bytes: node.payload,
2375 offset: node.offset,
2376 };
2377 let dependency = if commit.checkpoint {
2378 ExGuid::default()
2379 } else {
2380 written
2381 .previous
2382 .ok_or(wrong("A dependent revision follows none"))?
2383 };
2384 if node.id != 0x1e
2385 || c.exguid()? != rid
2386 || c.exguid()? != dependency
2387 || c.read::<4>()? != 1_u32.to_le_bytes()
2388 || c.read::<2>()? != [0, 0]
2389 {
2390 return Err(wrong(
2391 "A revision starts with the wrong identity or dependency",
2392 ));
2393 }
2394 let mut declared = BTreeSet::new();
2395 let mut roots = BTreeMap::new();
2396 loop {
2397 let node = next()?;
2398 match node.id {
2399 0xb0 => {
2400 let Some(Reference::NodeList(at)) = node.reference else {
2401 return Err(wrong("An object group lacks its list"));
2402 };
2403 let bytes = read(at)?;
2404 let id = Fragment::parse(&bytes, at.offset as usize)?.id;
2405 let required = *counts
2406 .get(&id)
2407 .ok_or(wrong("An object group is not logged"))?;
2408 let group = nodes(&bytes, at, id, 0, required)?;
2409 let mut table = BTreeMap::new();
2410 let mut override_crc = u32::MAX;
2411 for item in &group {
2412 let mut c = Cursor {
2413 bytes: item.payload,
2414 offset: item.offset,
2454 let mut declared = BTreeSet::new();
2455 let mut roots = BTreeMap::new();
2456 loop {
2457 let node = next()?;
2458 match node.id {
2459 0xb0 => {
2460 let Some(Reference::NodeList(at)) = node.reference else {
2461 return Err(wrong("An object group lacks its list"));
24152462 };
2416 match item.id {
2417 0xb4 | 0x22 | 0x28 | 0xb8 => {}
2418 0x24 => {
2419 table.insert(u32::from_le_bytes(c.read()?), c.read::<16>()?);
2420 }
2421 0xa5 | 0xc5 | 0x73 => {
2422 let id = c.compact(&table)?;
2423 let object = live
2424 .revision
2425 .objects
2426 .get(&id)
2427 .ok_or(wrong("A revision declares an unknown object"))?;
2428 let jcid = u32::from_le_bytes(c.read()?);
2429 let flags = if item.id == 0x73 {
2430 None
2431 } else {
2432 Some(c.read::<1>()?[0])
2433 };
2434 let count = u32::from_le_bytes(c.read()?);
2435 if !declared.insert(id)
2436 || jcid != object.jcid
2437 || count != object.reference_count
2438 {
2439 return Err(wrong(
2440 "A declaration disagrees with its object or its count",
2441 ));
2442 }
2443 override_crc =
2444 crc(override_crc, &count.to_le_bytes(), FileType::Section);
2445 if item.id == 0x73 {
2446 continue;
2447 }
2448 let Some(Reference::Data(at)) = item.reference else {
2449 return Err(wrong("An object declaration lacks its data"));
2450 };
2451 let data = read(at)?;
2452 let stored = crate::Object {
2453 jcid,
2454 reference_count: count,
2455 data: ObjectData::Properties(&data),
2456 global_ids: Arc::new(table.clone()),
2457 };
2458 let references = stored.references()?;
2459 let expected = u8::from(!references.objects.is_empty())
2460 | (u8::from(
2461 !references.object_spaces.is_empty()
2462 || !references.contexts.is_empty(),
2463 ) << 1);
2464 if object.data != ObjectData::Properties(&data)
2465 || flags != Some(expected)
2466 || (item.id == 0xc5) != (jcid & 0x100000 != 0)
2467 || (item.id == 0xc5 && c.read::<16>()? != md5::compute(&data).0)
2468 {
2469 return Err(wrong(
2470 "An object's stored bytes differ from its revision",
2471 ));
2463 let bytes = read(at)?;
2464 let id = Fragment::parse(&bytes, at.offset as usize)?.id;
2465 let required = *counts
2466 .get(&id)
2467 .ok_or(wrong("An object group is not logged"))?;
2468 let group = nodes(&bytes, at, id, 0, required)?;
2469 let mut table = BTreeMap::new();
2470 let mut override_crc = u32::MAX;
2471 for item in &group {
2472 let mut c = Cursor {
2473 bytes: item.payload,
2474 offset: item.offset,
2475 };
2476 match item.id {
2477 0xb4 | 0x22 | 0x28 | 0xb8 => {}
2478 0x24 => {
2479 table.insert(u32::from_le_bytes(c.read()?), c.read::<16>()?);
24722480 }
2473 if live.is_reachable(id)
2474 && !references
2475 .objects
2476 .iter()
2477 .all(|target| live.is_reachable(*target))
2478 {
2479 return Err(wrong(
2480 "An object references one its revision cannot reach",
2481 ));
2481 0xa5 | 0xc5 | 0x73 => {
2482 let id = c.compact(&table)?;
2483 let object =
2484 live.revision.objects.get(&id).ok_or(wrong(
2485 "A revision declares an unknown object",
2486 ))?;
2487 let jcid = u32::from_le_bytes(c.read()?);
2488 let flags = if item.id == 0x73 {
2489 None
2490 } else {
2491 Some(c.read::<1>()?[0])
2492 };
2493 let count = u32::from_le_bytes(c.read()?);
2494 if !declared.insert(id)
2495 || jcid != object.jcid
2496 || count != object.reference_count
2497 {
2498 return Err(wrong(
2499 "A declaration disagrees with its object or its count",
2500 ));
2501 }
2502 override_crc =
2503 crc(override_crc, &count.to_le_bytes(), FileType::Section);
2504 if item.id == 0x73 {
2505 continue;
2506 }
2507 let Some(Reference::Data(at)) = item.reference else {
2508 return Err(wrong("An object declaration lacks its data"));
2509 };
2510 let data = read(at)?;
2511 let stored = crate::Object {
2512 jcid,
2513 reference_count: count,
2514 data: ObjectData::Properties(&data),
2515 global_ids: Arc::new(table.clone()),
2516 };
2517 let references = stored.references()?;
2518 let expected = u8::from(!references.objects.is_empty())
2519 | (u8::from(
2520 !references.object_spaces.is_empty()
2521 || !references.contexts.is_empty(),
2522 ) << 1);
2523 if object.data != ObjectData::Properties(&data)
2524 || flags != Some(expected)
2525 || (item.id == 0xc5) != (jcid & 0x100000 != 0)
2526 || (item.id == 0xc5
2527 && c.read::<16>()? != md5::compute(&data).0)
2528 {
2529 return Err(wrong(
2530 "An object's stored bytes differ from its revision",
2531 ));
2532 }
2533 if live.is_reachable(id)
2534 && !references
2535 .objects
2536 .iter()
2537 .all(|target| live.is_reachable(*target))
2538 {
2539 return Err(wrong(
2540 "An object references one its revision cannot reach",
2541 ));
2542 }
24822543 }
2544 _ => return Err(wrong("Unexpected node in an object group")),
24832545 }
2484 _ => return Err(wrong("Unexpected node in an object group")),
2546 }
2547 let node = next()?;
2548 let mut c = Cursor {
2549 bytes: node.payload,
2550 offset: node.offset,
2551 };
2552 if node.id != 0x84
2553 || c.read::<8>()? != [0; 8]
2554 || u32::from_le_bytes(c.read()?) != !override_crc
2555 {
2556 return Err(wrong("An object group's count overrides do not match"));
24852557 }
24862558 }
2487 let node = next()?;
2488 let mut c = Cursor {
2489 bytes: node.payload,
2490 offset: node.offset,
2491 };
2492 if node.id != 0x84
2493 || c.read::<8>()? != [0; 8]
2494 || u32::from_le_bytes(c.read()?) != !override_crc
2495 {
2496 return Err(wrong("An object group's count overrides do not match"));
2559 0x5a => {
2560 let mut c = Cursor {
2561 bytes: node.payload,
2562 offset: node.offset,
2563 };
2564 let id = c.exguid()?;
2565 roots.insert(u32::from_le_bytes(c.read()?), id);
24972566 }
2567 0x1c => break,
2568 _ => return Err(wrong("Unexpected node in a revision manifest")),
24982569 }
2499 0x5a => {
2500 let mut c = Cursor {
2501 bytes: node.payload,
2502 offset: node.offset,
2503 };
2504 let id = c.exguid()?;
2505 roots.insert(u32::from_le_bytes(c.read()?), id);
2506 }
2507 0x1c => break,
2508 _ => return Err(wrong("Unexpected node in a revision manifest")),
2570 }
2571 let expected: BTreeSet<ExGuid> = if commit.checkpoint {
2572 live.revision.objects.keys().copied().collect()
2573 } else {
2574 commit.changed.clone()
2575 };
2576 if declared != expected
2577 || (commit.checkpoint && roots != live.revision.roots)
2578 || (!commit.checkpoint && !roots.is_empty())
2579 {
2580 return Err(wrong(
2581 "A revision declares other objects or roots than it changed",
2582 ));
25092583 }
25102584 }
2511 let expected: BTreeSet<ExGuid> = if commit.checkpoint {
2512 live.revision.objects.keys().copied().collect()
2513 } else {
2514 commit.changed.clone()
2515 };
2516 if declared != expected
2517 || (commit.checkpoint && roots != live.revision.roots)
2518 || (!commit.checkpoint && !roots.is_empty())
2519 {
2585 for (_, rid, context) in labels.iter().filter(|(labelled, ..)| *labelled == space) {
2586 let node = next()?;
2587 if node.id != 0x5d || node.payload != label_payload(*rid, *context) {
2588 return Err(wrong("A revision label names another revision or context"));
2589 }
2590 }
2591 if next().is_ok() {
25202592 return Err(wrong(
2521 "A revision declares other objects or roots than it changed",
2593 "A revision list gains nodes no revision or label declares",
25222594 ));
25232595 }
25242596 }
......@@ -2534,8 +2606,10 @@ pub(crate) fn check_transaction(
25342606 )?;
25352607 let created = revisions
25362608 .iter()
2537 .filter(|written| !before.spaces.contains_key(&written.space))
2538 .count();
2609 .map(|written| written.space)
2610 .filter(|space| !before.spaces.contains_key(space))
2611 .collect::<BTreeSet<_>>()
2612 .len();
25392613 let spaces = added.iter().filter(|node| node.id == 8).count();
25402614 if spaces != created || added.iter().any(|node| !matches!(node.id, 8 | 0x90)) {
25412615 return Err(wrong(
crates/onestore/src/write/tests.rs+4-1
......@@ -130,7 +130,10 @@ fn document_insertions_and_formatting_respect_readonly_ancestors() {
130130 })
131131 .unwrap();
132132 let refused = |op| edited(&protected, sid, vec![op]).is_err();
133 assert!(refused(insert(paragraph, text_paragraph("Nested", Format::default()))));
133 assert!(refused(insert(
134 paragraph,
135 text_paragraph("Nested", Format::default())
136 )));
134137 assert!(refused(PageOp::Split {
135138 text,
136139 at: 1,
crates/onestore/src/write/tests/multi.rs+1-2
......@@ -1,7 +1,6 @@
11use crate::{
2 ExGuid, RevisionIndex, Store,
2 ExGuid, RevisionIndex, Store, Transaction,
33 document::{Document, Kind},
4 Transaction,
54 write::{PropertyObject, RevisionEdit, applied, revisions, write_revisions},
65};
76use std::collections::BTreeMap;
crates/onestore/tests/commit.rs+9-2
......@@ -45,7 +45,8 @@ fn text_of(image: &[u8], space: ExGuid, object: ExGuid) -> String {
4545 let index = RevisionIndex::parse(&store).unwrap();
4646 index.validate_current().unwrap();
4747 let document = Document::parse(&index).unwrap();
48 let Kind::RichText { text, .. } = &document.spaces[&space].active().unwrap().nodes[&object].kind
48 let Kind::RichText { text, .. } =
49 &document.spaces[&space].active().unwrap().nodes[&object].kind
4950 else {
5051 panic!()
5152 };
......@@ -76,7 +77,13 @@ fn repeated_appends_cross_log_fragments_and_expose_counter_tears() {
7677 };
7778 length = value.encode_utf16().count() as u32;
7879 section
79 .apply("Author", &Edit { at: 133_700_000_000_000_000 + u64::from(count), ops: vec![Op::Page { space, op }] })
80 .apply(
81 "Author",
82 &Edit {
83 at: 133_700_000_000_000_000 + u64::from(count),
84 ops: vec![Op::Page { space, op }],
85 },
86 )
8087 .unwrap();
8188 let transaction = section.seal().unwrap().unwrap();
8289 let mut written = source.clone();
crates/onestore/tests/conflict.rs+30-11
......@@ -91,13 +91,15 @@ fn native_conflict_pages_are_listed_under_their_page_last_first() {
9191 assert_eq!(conflicts.len(), 1);
9292 let (page, listed) = &conflicts[0];
9393 assert_eq!(*page, pages[0].0);
94 let [ConflictPage {
95 space,
96 title,
97 user,
98 created,
99 objects,
100 }] = listed.as_slice()
94 let [
95 ConflictPage {
96 space,
97 title,
98 user,
99 created,
100 objects,
101 },
102 ] = listed.as_slice()
101103 else {
102104 panic!("{listed:?}")
103105 };
......@@ -282,7 +284,10 @@ fn a_conflict_page_is_stored_as_onenote_stores_one() {
282284 std::fs::create_dir_all(output).unwrap();
283285 std::fs::write(output.join("synthetic.one"), &image).unwrap();
284286 std::fs::copy(
285 format!("{}/../../{notebook}/Open Notebook.onetoc2", env!("CARGO_MANIFEST_DIR")),
287 format!(
288 "{}/../../{notebook}/Open Notebook.onetoc2",
289 env!("CARGO_MANIFEST_DIR")
290 ),
286291 output.join("Open Notebook.onetoc2"),
287292 )
288293 .unwrap();
......@@ -335,9 +340,20 @@ fn deleting_a_conflict_page_stores_what_onenote_stores() {
335340 };
336341 let (manifest, metadata, flagged, space) = shapes(&image);
337342 let (native_manifest, native_metadata, native_flagged, native_space) = shapes(&native);
338 assert_eq!(manifest.1.iter().filter(|id| **id != 0x3400347b).collect::<Vec<_>>(), [&0x24001c1f, &0x24003442]);
339343 assert_eq!(
340 native_manifest.1.iter().filter(|id| **id != 0x3400347b).collect::<Vec<_>>(),
344 manifest
345 .1
346 .iter()
347 .filter(|id| **id != 0x3400347b)
348 .collect::<Vec<_>>(),
349 [&0x24001c1f, &0x24003442]
350 );
351 assert_eq!(
352 native_manifest
353 .1
354 .iter()
355 .filter(|id| **id != 0x3400347b)
356 .collect::<Vec<_>>(),
341357 [&0x24001c1f, &0x24003442]
342358 );
343359 assert!(!metadata.1.contains(&0x88001d97) && !native_metadata.1.contains(&0x88001d97));
......@@ -349,7 +365,10 @@ fn deleting_a_conflict_page_stores_what_onenote_stores() {
349365 std::fs::create_dir_all(output).unwrap();
350366 std::fs::write(output.join("synthetic.one"), &image).unwrap();
351367 std::fs::copy(
352 format!("{}/../../{notebook}/Open Notebook.onetoc2", env!("CARGO_MANIFEST_DIR")),
368 format!(
369 "{}/../../{notebook}/Open Notebook.onetoc2",
370 env!("CARGO_MANIFEST_DIR")
371 ),
353372 output.join("Open Notebook.onetoc2"),
354373 )
355374 .unwrap();
crates/onestore/tests/document.rs+5-5
......@@ -270,7 +270,7 @@ fn native_paragraph_breaks_preserve_every_stored_character() {
270270}
271271
272272#[test]
273fn referenced_history_contexts_require_history_roots() {
273fn referenced_history_contexts_require_history_roots_where_stored() {
274274 let bytes = fs::read("../../corpus/m6/native-features-01/notebook/Features.one").unwrap();
275275 let store = Store::parse(&bytes).unwrap();
276276 let mut index = RevisionIndex::parse(&store).unwrap();
......@@ -306,16 +306,16 @@ fn referenced_history_contexts_require_history_roots() {
306306 Document::parse(&index).unwrap_err().message,
307307 "History context does not resolve to version history"
308308 );
309 // OneNote's Copy Page To of a page version names a history it never writes
310 // (corpus/page-versions/native/step-03).
309311 index
310312 .spaces
311313 .get_mut(&sid)
312314 .unwrap()
313315 .labels
314316 .remove(&(context, 1));
315 assert_eq!(
316 Document::parse(&index).unwrap_err().message,
317 "Document context has no revision"
318 );
317 let document = Document::parse(&index).unwrap();
318 assert!(!document.spaces[&sid].contexts.contains_key(&context));
319319}
320320
321321#[test]
crates/onestore/tests/edit.rs+10-22
......@@ -181,9 +181,7 @@ fn snapshot_confirmation_needs_no_surviving_edit_target() {
181181 write_limit: 17,
182182 random: 911,
183183 };
184 assert!(
185 replace_text(SOURCE, ExGuid::default(), ExGuid::default(), 0..0, "").is_err()
186 );
184 assert!(replace_text(SOURCE, ExGuid::default(), ExGuid::default(), 0..0, "").is_err());
187185 onestore::confirm(&mut disk, &onestore::Stamp::of(SOURCE).unwrap()).unwrap();
188186 assert_refreshed(SOURCE, &disk.durable);
189187 let flush = disk.operation;
......@@ -278,9 +276,7 @@ fn confirmation_rejects_a_changed_header_or_length_before_any_write() {
278276 bytes: source.clone(),
279277 events: Vec::new(),
280278 };
281 let confirmed = |events: &[trace::Event]| {
282 matches!(events, [trace::Event::Flush, trace::Event::Write(212, version), trace::Event::Flush] if version.len() == 40)
283 };
279 let confirmed = |events: &[trace::Event]| matches!(events, [trace::Event::Flush, trace::Event::Write(212, version), trace::Event::Flush] if version.len() == 40);
284280 onestore::confirm(&mut disk, &base).unwrap();
285281 assert!(confirmed(&disk.events));
286282 for changed in [96, 128, 212, 1023] {
......@@ -455,8 +451,7 @@ fn invalid_text_edits_never_touch_storage() {
455451 write_limit: 1024,
456452 random: 1,
457453 };
458 let error =
459 commit_text(&mut disk, &source, sid, oid, range, replacement).unwrap_err();
454 let error = commit_text(&mut disk, &source, sid, oid, range, replacement).unwrap_err();
460455 assert_eq!(error.state, CommitState::NotCommitted);
461456 assert_eq!(disk.operation, 0);
462457 assert_eq!(disk.durable, source);
......@@ -497,8 +492,7 @@ fn cross_run_splices_match_a_character_model_and_preserve_history() {
497492 let mut expected = before.clone();
498493 expected.splice(a..b, replacement.chars().map(|c| (c, before[a].1.clone())));
499494 let edited =
500 replace_text(SOURCE, sid, oid, offsets[a]..offsets[b], replacement)
501 .unwrap();
495 replace_text(SOURCE, sid, oid, offsets[a]..offsets[b], replacement).unwrap();
502496 assert_eq!(characters(&edited), expected, "characters {a}..{b}");
503497 let current_store = Store::parse(&edited).unwrap();
504498 assert_eq!(
......@@ -519,8 +513,7 @@ fn cross_run_splices_match_a_character_model_and_preserve_history() {
519513 }
520514 }
521515 }
522 let cleared =
523 replace_text(SOURCE, sid, oid, 0..*offsets.last().unwrap(), "").unwrap();
516 let cleared = replace_text(SOURCE, sid, oid, 0..*offsets.last().unwrap(), "").unwrap();
524517 let typed = replace_text(&cleared, sid, oid, 0..0, "new").unwrap();
525518 let format = &before.last().unwrap().1;
526519 assert_eq!(
......@@ -579,8 +572,7 @@ fn export_native_cross_run_edits() {
579572 })
580573 .unwrap();
581574 let range = range.start..range.end.min(length);
582 let mut edited =
583 replace_text(&source, sid, oid, range.clone(), replacement).unwrap();
575 let mut edited = replace_text(&source, sid, oid, range.clone(), replacement).unwrap();
584576 if retype {
585577 edited = replace_text(&edited, sid, oid, 0..0, "Retyped 🦀").unwrap();
586578 }
......@@ -648,8 +640,7 @@ fn automatic_titles_follow_native_line_and_utf16_limits() {
648640 };
649641 let (sid, oid) = check(&source, &expected);
650642 let cleared =
651 replace_text(&source, sid, oid, 0..text.encode_utf16().count() as u32, "")
652 .unwrap();
643 replace_text(&source, sid, oid, 0..text.encode_utf16().count() as u32, "").unwrap();
653644 check(&cleared, "");
654645 let written = replace_text(&cleared, sid, oid, 0..0, " new 🦀 name ").unwrap();
655646 check(&written, "new 🦀 name");
......@@ -803,8 +794,7 @@ fn title_text_and_navigation_caches_publish_together() {
803794 for (source, write_limit) in [(source.as_slice(), 17), (checkpoint.as_slice(), 257)] {
804795 let before = state(source);
805796 for replacement in ["Renamed 🦀 日本語", ""] {
806 let edit =
807 text_transaction(source, sid, *oid, 0..end, replacement).unwrap();
797 let edit = text_transaction(source, sid, *oid, 0..end, replacement).unwrap();
808798 let disk = |fail_at| Disk {
809799 visible: source.to_vec(),
810800 durable: source.to_vec(),
......@@ -916,8 +906,7 @@ fn interrupted_text_commits_never_publish_mismatched_run_boundaries() {
916906 for seed in [1, 42] {
917907 let mut disk = make_disk(Some(at), seed);
918908 let error =
919 commit_text(&mut disk, source, sid, oid, 0..23, "🐈 mixed edit")
920 .unwrap_err();
909 commit_text(&mut disk, source, sid, oid, 0..23, "🐈 mixed edit").unwrap_err();
921910 let observed = text_runs(&disk.durable, sid, oid);
922911 match error.state {
923912 CommitState::NotCommitted => assert_eq!(observed, before),
......@@ -1018,8 +1007,7 @@ fn empty_and_legacy_native_text_gain_unicode_without_losing_existing_properties(
10181007 for seed in [1, 42] {
10191008 let mut interrupted = disk(Some(at), seed);
10201009 let error =
1021 commit_text(&mut interrupted, &source, sid, oid, 0..0, "🦀 café ")
1022 .unwrap_err();
1010 commit_text(&mut interrupted, &source, sid, oid, 0..0, "🦀 café ").unwrap_err();
10231011 let actual = text_runs(&interrupted.durable, sid, oid);
10241012 match error.state {
10251013 CommitState::NotCommitted => assert_eq!(actual, before),
crates/onestore/tests/formatting.rs+154-17
......@@ -1,9 +1,9 @@
1#[path = "support/ops.rs"]
2mod ops;
31#[path = "support/current.rs"]
42mod current;
53#[path = "support/disk.rs"]
64mod disk;
5#[path = "support/ops.rs"]
6mod ops;
77#[path = "support/sweep.rs"]
88mod sweep;
99use onestore::{
......@@ -90,7 +90,16 @@ fn overlapping_unicode_format_edits_match_an_independent_character_model() {
9090 json!(0x00ff00_u32),
9191 ),
9292 };
93 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() })
93 let edit = ops::page_op(
94 &source,
95 sid,
96 PageOp::Format {
97 text: id,
98 range: offsets[start]..offsets[end],
99 set: std::slice::from_ref(&attribute).to_vec(),
100 clear: Vec::new(),
101 },
102 )
94103 .unwrap();
95104 for (_, style) in &mut expected[start..end] {
96105 style[key] = value.clone();
......@@ -101,7 +110,16 @@ fn overlapping_unicode_format_edits_match_an_independent_character_model() {
101110 "seed {seed}, step {step}"
102111 );
103112 assert_eq!(
104 ops::page_op(edit.as_bytes(), sid, PageOp::Format { text: id, range: offsets[start]..offsets[end], set: vec![attribute], clear: Vec::new() })
113 ops::page_op(
114 edit.as_bytes(),
115 sid,
116 PageOp::Format {
117 text: id,
118 range: offsets[start]..offsets[end],
119 set: vec![attribute],
120 clear: Vec::new()
121 }
122 )
105123 .unwrap()
106124 .as_bytes(),
107125 edit.as_bytes()
......@@ -123,9 +141,27 @@ fn overlapping_unicode_format_edits_match_an_independent_character_model() {
123141fn script_positions_are_exclusive_and_explicit_false_overrides_true() {
124142 let source = onestore::create_section("script.one", "abc", "Author").unwrap();
125143 let (sid, id) = target(&source);
126 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() })
144 let superscript = ops::page_op(
145 &source,
146 sid,
147 PageOp::Format {
148 text: id,
149 range: 0..3,
150 set: vec![A::Superscript(true), A::Bold(true)],
151 clear: Vec::new(),
152 },
153 )
127154 .unwrap();
128 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() })
155 let subscript = ops::page_op(
156 superscript.as_bytes(),
157 sid,
158 PageOp::Format {
159 text: id,
160 range: 1..2,
161 set: vec![A::Subscript(true), A::Bold(false)],
162 clear: Vec::new(),
163 },
164 )
129165 .unwrap();
130166 let chars = characters(subscript.as_bytes(), sid, id);
131167 assert_eq!(chars[0].1["superscript"], true);
......@@ -140,9 +176,27 @@ fn script_positions_are_exclusive_and_explicit_false_overrides_true() {
140176fn empty_paragraph_style_is_used_by_later_text_edits() {
141177 let source = onestore::create_section("empty.one", "", "Author").unwrap();
142178 let (sid, id) = target(&source);
143 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() })
179 let formatted = ops::page_op(
180 &source,
181 sid,
182 PageOp::Format {
183 text: id,
184 range: 0..0,
185 set: vec![A::Italic(true), A::FontSize(18.0)],
186 clear: Vec::new(),
187 },
188 )
189 .unwrap();
190 let filled = ops::page_op(
191 formatted.as_bytes(),
192 sid,
193 PageOp::Text {
194 text: id,
195 range: 0..0,
196 with: "Added 🦀".into(),
197 },
198 )
144199 .unwrap();
145 let filled = ops::page_op(formatted.as_bytes(), sid, PageOp::Text { text: id, range: 0..0, with: "Added 🦀".into() }).unwrap();
146200 for (_, format) in characters(filled.as_bytes(), sid, id) {
147201 assert_eq!(format["italic"], true);
148202 assert_eq!(format["font_size"], 18.0);
......@@ -154,7 +208,19 @@ fn invalid_ranges_attributes_and_fields_are_rejected() {
154208 let (sid, id) = target(&source);
155209 for (start, end) in [(2, 3), (1, 2), (0, 8), (2, 2), (3, 1), (1, 1)] {
156210 let range = start..end;
157 assert!(ops::page_op(&source, sid, PageOp::Format { text: id, range, set: vec![A::Bold(true)], clear: Vec::new() }).is_err());
211 assert!(
212 ops::page_op(
213 &source,
214 sid,
215 PageOp::Format {
216 text: id,
217 range,
218 set: vec![A::Bold(true)],
219 clear: Vec::new()
220 }
221 )
222 .is_err()
223 );
158224 }
159225 for attributes in [
160226 vec![],
......@@ -163,18 +229,63 @@ fn invalid_ranges_attributes_and_fields_are_rejected() {
163229 vec![A::Font("".into())],
164230 vec![A::Font("a\0b".into())],
165231 ] {
166 assert!(ops::page_op(&source, sid, PageOp::Format { text: id, range: 0..4, set: attributes.to_vec(), clear: Vec::new() }).is_err());
232 assert!(
233 ops::page_op(
234 &source,
235 sid,
236 PageOp::Format {
237 text: id,
238 range: 0..4,
239 set: attributes.to_vec(),
240 clear: Vec::new()
241 }
242 )
243 .is_err()
244 );
167245 }
168246 for size in [f32::NAN, f32::INFINITY, 0.0, 5.5, 130.5, 144.0, 144.5, 12.1] {
169 assert!(ops::page_op(&source, sid, PageOp::Format { text: id, range: 0..4, set: vec![A::FontSize(size)], clear: Vec::new() }).is_err());
247 assert!(
248 ops::page_op(
249 &source,
250 sid,
251 PageOp::Format {
252 text: id,
253 range: 0..4,
254 set: vec![A::FontSize(size)],
255 clear: Vec::new()
256 }
257 )
258 .is_err()
259 );
170260 }
171 assert!(ops::page_op(&source, sid, PageOp::Format { text: ExGuid::default(), range: 0..4, set: vec![A::Bold(true)], clear: Vec::new() }).is_err());
261 assert!(
262 ops::page_op(
263 &source,
264 sid,
265 PageOp::Format {
266 text: ExGuid::default(),
267 range: 0..4,
268 set: vec![A::Bold(true)],
269 clear: Vec::new()
270 }
271 )
272 .is_err()
273 );
172274}
173275#[test]
174276fn formatting_publication_faults_preserve_complete_old_or_new_styles() {
175277 let source = onestore::create_section("atomic.one", "Before 🦀 after", "Author").unwrap();
176278 let (sid, id) = target(&source);
177 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() })
279 let edit = ops::page_op(
280 &source,
281 sid,
282 PageOp::Format {
283 text: id,
284 range: 2..10,
285 set: vec![A::Bold(true), A::Color(Some([8, 64, 128]))],
286 clear: Vec::new(),
287 },
288 )
178289 .unwrap();
179290 let before = current::current(&source);
180291 let after = current::current(edit.as_bytes());
......@@ -219,8 +330,17 @@ fn export_native_formatting_candidates() {
219330 let mut manifest = Vec::new();
220331 let mut save =
221332 |name: &str, source: &[u8], sid, id, range: std::ops::Range<u32>, attributes: &[A]| {
222 let prepared =
223 ops::page_op(source, sid, PageOp::Format { text: id, range: range.clone(), set: attributes.to_vec(), clear: Vec::new() }).unwrap();
333 let prepared = ops::page_op(
334 source,
335 sid,
336 PageOp::Format {
337 text: id,
338 range: range.clone(),
339 set: attributes.to_vec(),
340 clear: Vec::new(),
341 },
342 )
343 .unwrap();
224344 fs::write(output.join(format!("{name}.one")), prepared.as_bytes()).unwrap();
225345 manifest.push(
226346 json!({"name":name,"space":sid,"object":id,"range":range,"attributes":attributes}),
......@@ -298,9 +418,26 @@ fn export_native_formatting_candidates() {
298418 );
299419 let empty = onestore::create_section("empty.one", "", "Author").unwrap();
300420 let (empty_sid, empty_id) = target(&empty);
301 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() })
421 let formatted = ops::page_op(
422 &empty,
423 empty_sid,
424 PageOp::Format {
425 text: empty_id,
426 range: 0..0,
427 set: vec![A::FontSize(18.0), A::Italic(true)],
428 clear: Vec::new(),
429 },
430 )
302431 .unwrap();
303 let typed = ops::page_op(formatted.as_bytes(), empty_sid, PageOp::Text { text: empty_id, range: 0..0, with: "Typed café 🦀".into() })
432 let typed = ops::page_op(
433 formatted.as_bytes(),
434 empty_sid,
435 PageOp::Text {
436 text: empty_id,
437 range: 0..0,
438 with: "Typed café 🦀".into(),
439 },
440 )
304441 .unwrap();
305442 fs::write(output.join("empty-then-type.one"), typed.as_bytes()).unwrap();
306443 let native = include_bytes!("../../../corpus/native-ink/20260905-ui/notebook/synthetic.one");
crates/onestore/tests/insertion.rs+58-16
......@@ -50,7 +50,13 @@ fn insertion(
5050 Place::Outline(x, y) => {
5151 let object = ops::outline(x, y, vec![paragraph]);
5252 let id = object.id();
53 (PageOp::Add { object, before: None }, id)
53 (
54 PageOp::Add {
55 object,
56 before: None,
57 },
58 id,
59 )
5460 }
5561 };
5662 let mut result = vec![Op::Page { space, op }];
......@@ -161,7 +167,10 @@ fn atomic_formatted_insertions_match_a_character_model_and_preserve_history() {
161167 Place::In(outline, None)
162168 };
163169 let (ops, _, inserted) = insertion(sid, place, text, &formats);
164 let edit = Edit { at: ops::now(), ops };
170 let edit = Edit {
171 at: ops::now(),
172 ops,
173 };
165174 let restored: Edit = serde_json::from_slice(&serde_json::to_vec(&edit).unwrap()).unwrap();
166175 assert_eq!(restored, edit);
167176 let edited = ops::edited(&source, restored.ops).unwrap();
......@@ -308,7 +317,10 @@ fn export_native_formatted_insertions() {
308317 .into_iter()
309318 .map(|(label, attributes)| {
310319 let start = text[..text.find(label).unwrap()].encode_utf16().count() as u32;
311 (start..start + label.encode_utf16().count() as u32, attributes)
320 (
321 start..start + label.encode_utf16().count() as u32,
322 attributes,
323 )
312324 })
313325 .collect()
314326 };
......@@ -322,7 +334,10 @@ fn export_native_formatted_insertions() {
322334 };
323335 ops.push(Op::Page { space: sid, op });
324336 }
325 let edit = Edit { at: ops::now(), ops };
337 let edit = Edit {
338 at: ops::now(),
339 ops,
340 };
326341 let bytes = ops::edited(source, edit.ops.clone()).unwrap();
327342 current::current(&bytes);
328343 let notebook = output.join(name);
......@@ -348,7 +363,9 @@ fn paragraph_and_outline_insertions_publish_metadata_and_references_together() {
348363 (Place::Outline(144.0, 18.0), page, Some(144.0), Some(18.0)),
349364 ] {
350365 let (ops, object, text) = insertion(sid, place, "First 🦀\rSecond", &[]);
351 let transaction = ops::transaction(&source, "New author", ops).unwrap().unwrap();
366 let transaction = ops::transaction(&source, "New author", ops)
367 .unwrap()
368 .unwrap();
352369 let mut written = source.clone();
353370 transaction.apply(&mut written).unwrap();
354371 let store = Store::parse(&written).unwrap();
......@@ -405,7 +422,9 @@ fn repeated_insertions_and_formatting_share_immutable_objects() {
405422 let mut texts = Vec::new();
406423 for _ in 0..12 {
407424 let (ops, _, text) = insertion(sid, Place::In(outline, None), "Repeated paragraph", &[]);
408 let transaction = ops::transaction(&source, "Same author", ops).unwrap().unwrap();
425 let transaction = ops::transaction(&source, "Same author", ops)
426 .unwrap()
427 .unwrap();
409428 transaction.apply(&mut source).unwrap();
410429 let bold = PageOp::Format {
411430 text,
......@@ -413,9 +432,16 @@ fn repeated_insertions_and_formatting_share_immutable_objects() {
413432 set: vec![A::Bold(true), A::FontSize(20.0)],
414433 clear: Vec::new(),
415434 };
416 let transaction = ops::transaction(&source, "Same author", vec![Op::Page { space: sid, op: bold }])
417 .unwrap()
418 .unwrap();
435 let transaction = ops::transaction(
436 &source,
437 "Same author",
438 vec![Op::Page {
439 space: sid,
440 op: bold,
441 }],
442 )
443 .unwrap()
444 .unwrap();
419445 transaction.apply(&mut source).unwrap();
420446 texts.push(text);
421447 }
......@@ -459,8 +485,12 @@ fn serialized_insertions_rebase_with_the_same_objects_and_preserve_remote_edits(
459485 let source = onestore::create_section("rebase.one", "Original", "Author").unwrap();
460486 let (sid, _, outline, paragraph, text) = targets(&source);
461487 let formats = [(0..3, vec![A::Bold(true), A::Color(Some([12, 34, 56]))])];
462 let (ops, object, inserted) =
463 insertion(sid, Place::In(outline, Some(paragraph)), "Inserted", &formats);
488 let (ops, object, inserted) = insertion(
489 sid,
490 Place::In(outline, Some(paragraph)),
491 "Inserted",
492 &formats,
493 );
464494 let encoded = serde_json::to_vec(&ops).unwrap();
465495 let restored: Vec<Op> = serde_json::from_slice(&encoded).unwrap();
466496 assert_eq!(ops, restored);
......@@ -540,8 +570,12 @@ fn anchors_targets_identities_and_text_are_validated_before_publication() {
540570 assert!(refused(&source, Place::In(outline, None), content));
541571 }
542572 assert!(
543 ops::transaction(&source, "a\0b", insertion(sid, Place::In(outline, None), "Text", &[]).0)
544 .is_err()
573 ops::transaction(
574 &source,
575 "a\0b",
576 insertion(sid, Place::In(outline, None), "Text", &[]).0
577 )
578 .is_err()
545579 );
546580 for place in [
547581 Place::In(outline, Some(text)),
......@@ -610,13 +644,21 @@ fn insertion_publication_faults_expose_only_complete_graphs_and_title_caches() {
610644 &checkpoint,
611645 ] {
612646 let (sid, _, outline, paragraph, _) = targets(source);
613 for place in [Place::In(outline, Some(paragraph)), Place::Outline(0.0, 0.0)] {
647 for place in [
648 Place::In(outline, Some(paragraph)),
649 Place::Outline(0.0, 0.0),
650 ] {
614651 let formats = [
615652 (0..3, vec![A::Bold(true), A::FontSize(18.0)]),
616 (6..8, vec![A::Italic(true), A::Highlight(Some([255, 255, 0]))]),
653 (
654 6..8,
655 vec![A::Italic(true), A::Highlight(Some([255, 255, 0]))],
656 ),
617657 ];
618658 let (ops, _, _) = insertion(sid, place, "First 🦀", &formats);
619 let edit = ops::transaction(source, "New author", ops).unwrap().unwrap();
659 let edit = ops::transaction(source, "New author", ops)
660 .unwrap()
661 .unwrap();
620662 let mut written = source.to_vec();
621663 edit.apply(&mut written).unwrap();
622664 let before = current::current(source);
crates/onestore/tests/outline.rs+119-25
......@@ -67,7 +67,15 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() {
6767 for (name, sid, object, edit) in cases(&document) {
6868 let restored = serde_json::from_value(serde_json::to_value(edit).unwrap()).unwrap();
6969 assert_eq!(edit, restored);
70 let prepared = ops::page_op(SOURCE, sid, PageOp::Outline { object, edit: restored }).unwrap();
70 let prepared = ops::page_op(
71 SOURCE,
72 sid,
73 PageOp::Outline {
74 object,
75 edit: restored,
76 },
77 )
78 .unwrap();
7179 let after_store = Store::parse(prepared.as_bytes()).unwrap();
7280 assert!(after_store.checksum_mismatches.is_empty());
7381 let after_index = RevisionIndex::parse(&after_store).unwrap();
......@@ -144,7 +152,8 @@ fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() {
144152 assert_eq!(old_space.labels, after_index.spaces[space_id].labels);
145153 }
146154 }
147 let again = ops::page_op(prepared.as_bytes(), sid, PageOp::Outline { object, edit }).unwrap();
155 let again =
156 ops::page_op(prepared.as_bytes(), sid, PageOp::Outline { object, edit }).unwrap();
148157 assert_eq!(again.as_bytes(), prepared.as_bytes());
149158 }
150159}
......@@ -169,10 +178,17 @@ fn invalid_geometry_and_non_outline_targets_are_rejected() {
169178 }
170179 for points in [-1.0, 0.0, 35.999] {
171180 assert!(
172 ops::page_op(SOURCE, sid, PageOp::Outline { object, edit: Edit::Width {
173 points,
174 user_set: false
175 } })
181 ops::page_op(
182 SOURCE,
183 sid,
184 PageOp::Outline {
185 object,
186 edit: Edit::Width {
187 points,
188 user_set: false
189 }
190 }
191 )
176192 .is_err()
177193 );
178194 }
......@@ -181,8 +197,15 @@ fn invalid_geometry_and_non_outline_targets_are_rejected() {
181197 for (id, node) in &view.nodes {
182198 if !matches!(node.kind, Kind::Outline { .. }) {
183199 assert!(
184 ops::page_op(SOURCE, sid, PageOp::Outline { object: *id, edit: Edit::Position { x: 72.0, y: 72.0 } })
185 .is_err()
200 ops::page_op(
201 SOURCE,
202 sid,
203 PageOp::Outline {
204 object: *id,
205 edit: Edit::Position { x: 72.0, y: 72.0 }
206 }
207 )
208 .is_err()
186209 );
187210 }
188211 if matches!(node.kind, Kind::Title) {
......@@ -197,20 +220,55 @@ fn invalid_geometry_and_non_outline_targets_are_rejected() {
197220 },
198221 Edit::Collapsed(true),
199222 ] {
200 assert!(ops::page_op(SOURCE, sid, PageOp::Outline { object: child, edit }).is_err());
223 assert!(
224 ops::page_op(
225 SOURCE,
226 sid,
227 PageOp::Outline {
228 object: child,
229 edit
230 }
231 )
232 .is_err()
233 );
201234 }
202235 }
203236 }
204237 if !matches!(node.kind, Kind::Paragraph { .. }) {
205 assert!(ops::page_op(SOURCE, sid, PageOp::Outline { object: *id, edit: Edit::Collapsed(true) }).is_err());
238 assert!(
239 ops::page_op(
240 SOURCE,
241 sid,
242 PageOp::Outline {
243 object: *id,
244 edit: Edit::Collapsed(true)
245 }
246 )
247 .is_err()
248 );
206249 }
207250 }
208251 assert!(
209 ops::page_op(SOURCE, ExGuid::default(), PageOp::Outline { object, edit: Edit::Collapsed(true) })
210 .is_err()
252 ops::page_op(
253 SOURCE,
254 ExGuid::default(),
255 PageOp::Outline {
256 object,
257 edit: Edit::Collapsed(true)
258 }
259 )
260 .is_err()
211261 );
212262 assert!(
213 ops::page_op(SOURCE, sid, PageOp::Outline { object: ExGuid::default(), edit: Edit::Collapsed(true) }).is_err()
263 ops::page_op(
264 SOURCE,
265 sid,
266 PageOp::Outline {
267 object: ExGuid::default(),
268 edit: Edit::Collapsed(true)
269 }
270 )
271 .is_err()
214272 );
215273 }
216274}
......@@ -228,10 +286,17 @@ fn resizing_a_native_reserved_width_preserves_content() {
228286 let space = &document.spaces[&sid];
229287 let before = &space.revisions[&space.contexts[&ExGuid::default()]];
230288 assert!(before.nodes[&object].layout.reserved_width.is_some());
231 let edit = ops::page_op(source, sid, PageOp::Outline { object, edit: Edit::Width {
232 points: 144.0,
233 user_set: true,
234 } })
289 let edit = ops::page_op(
290 source,
291 sid,
292 PageOp::Outline {
293 object,
294 edit: Edit::Width {
295 points: 144.0,
296 user_set: true,
297 },
298 },
299 )
235300 .unwrap();
236301 let after_store = Store::parse(edit.as_bytes()).unwrap();
237302 let after_index = RevisionIndex::parse(&after_store).unwrap();
......@@ -279,8 +344,15 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content
279344 (144.0, 36.0, "First"),
280345 (0.0, 144.0, "First"),
281346 ] {
282 let edit = ops::page_op(&source, sid, PageOp::Outline { object: insertion, edit: Edit::Position { x, y } })
283 .unwrap();
347 let edit = ops::page_op(
348 &source,
349 sid,
350 PageOp::Outline {
351 object: insertion,
352 edit: Edit::Position { x, y },
353 },
354 )
355 .unwrap();
284356 let saved = Store::parse(edit.as_bytes()).unwrap();
285357 let saved_index = RevisionIndex::parse(&saved).unwrap();
286358 saved_index.validate_current().unwrap();
......@@ -311,7 +383,14 @@ fn outline_movement_updates_both_automatic_title_fields_without_changing_content
311383 format!("{:?}", saved_index.resolve(sid, rid).unwrap())
312384 );
313385 assert_eq!(
314 ops::page_op(edit.as_bytes(), sid, PageOp::Outline { object: insertion, edit: Edit::Position { x, y } })
386 ops::page_op(
387 edit.as_bytes(),
388 sid,
389 PageOp::Outline {
390 object: insertion,
391 edit: Edit::Position { x, y }
392 }
393 )
315394 .unwrap()
316395 .as_bytes(),
317396 edit.as_bytes()
......@@ -352,10 +431,17 @@ fn native_automatic_and_explicit_titles_follow_outline_movement() {
352431 72.0
353432 },
354433 };
355 candidate = ops::page_op(&candidate, sid, PageOp::Outline { object: outline, edit: change })
356 .unwrap()
357 .as_bytes()
358 .to_vec();
434 candidate = ops::page_op(
435 &candidate,
436 sid,
437 PageOp::Outline {
438 object: outline,
439 edit: change,
440 },
441 )
442 .unwrap()
443 .as_bytes()
444 .to_vec();
359445 let store = Store::parse(&candidate).unwrap();
360446 let saved = RevisionIndex::parse(&store).unwrap();
361447 saved.validate_current().unwrap();
......@@ -417,7 +503,15 @@ fn outline_publication_interruptions_reopen_as_complete_old_or_new_layout() {
417503 ),
418504 (paragraph, Edit::Collapsed(true)),
419505 ] {
420 let edit = ops::page_op(&source, sid, PageOp::Outline { object, edit: operation }).unwrap();
506 let edit = ops::page_op(
507 &source,
508 sid,
509 PageOp::Outline {
510 object,
511 edit: operation,
512 },
513 )
514 .unwrap();
421515 let before = current::current(&source);
422516 let after = current::current(edit.as_bytes());
423517 for write_limit in [17, 4096] {
crates/onestore/tests/page.rs+21-5
......@@ -117,7 +117,15 @@ fn created_pages_support_title_edits_and_body_insertion() {
117117 Some(title.filter(|s| !s.is_empty()).unwrap_or("Body 🦀 é"))
118118 );
119119 if let Some(text) = intent.title_object() {
120 source = ops::page_op(&source, intent.space(), PageOp::Text { text, range: 0..title.unwrap().encode_utf16().count() as u32, with: "Renamed".into() })
120 source = ops::page_op(
121 &source,
122 intent.space(),
123 PageOp::Text {
124 text,
125 range: 0..title.unwrap().encode_utf16().count() as u32,
126 with: "Renamed".into(),
127 },
128 )
121129 .unwrap()
122130 .as_bytes()
123131 .to_vec();
......@@ -359,10 +367,18 @@ fn native_changes_on_created_pages_accept_rust_followups() {
359367 let mut source = initial.to_vec();
360368 for (sid, title, body) in targets {
361369 for (object, prefix) in [(body, "Rust + "), (title, "Reviewed ")] {
362 source = ops::page_op(&source, sid, PageOp::Text { text: object, range: 0..0, with: prefix.into() })
363 .unwrap()
364 .as_bytes()
365 .to_vec();
370 source = ops::page_op(
371 &source,
372 sid,
373 PageOp::Text {
374 text: object,
375 range: 0..0,
376 with: prefix.into(),
377 },
378 )
379 .unwrap()
380 .as_bytes()
381 .to_vec();
366382 }
367383 let store = Store::parse(&source).unwrap();
368384 let index = RevisionIndex::parse(&store).unwrap();
crates/onestore/tests/page_deletion.rs+11-3
......@@ -171,8 +171,11 @@ fn removal_rejects_duplicate_missing_and_non_page_spaces_without_publication() {
171171 );
172172 let written = ops::section_op(SOURCE, SectionOp::Delete([pages[0].0].to_vec())).unwrap();
173173 assert!(
174 ops::section_op(written.as_bytes(), SectionOp::Delete([pages[1].0, pages[0].0].to_vec()))
175 .is_err()
174 ops::section_op(
175 written.as_bytes(),
176 SectionOp::Delete([pages[1].0, pages[0].0].to_vec())
177 )
178 .is_err()
176179 );
177180}
178181
......@@ -427,7 +430,12 @@ fn external_payloads_and_their_historical_references_survive_file_removal() {
427430 assert_eq!(payloads.len(), 3);
428431 let path = root.join("synthetic.one");
429432 std::fs::write(&path, &original).unwrap();
430 prepared.transaction.as_ref().unwrap().commit_file(&path).unwrap();
433 prepared
434 .transaction
435 .as_ref()
436 .unwrap()
437 .commit_file(&path)
438 .unwrap();
431439 let written = onestore::read_file(&path).unwrap();
432440 assert_eq!(written, prepared.as_bytes());
433441 let current_store = Store::parse(&written).unwrap();
crates/onestore/tests/page_ink.rs-1
......@@ -6,7 +6,6 @@ use onestore::{
66 page::{Ink, InkStroke, Page, PageObject, text::new_id},
77};
88
9
109/// Two drawings made with the mouse in OneNote 2010 (`tools/native/ink.ahk`): a diamond and a
1110/// diagonal line, whose native read reports their positions and sizes.
1211const NATIVE: &[u8] =
crates/onestore/tests/page_links.rs+47-2
......@@ -162,6 +162,8 @@ fn a_link_is_added_to_a_fresh_page_and_reads_back() {
162162 );
163163 let written = ops::saved(&source, space, &after).unwrap();
164164 let stored = assert_same(written.as_slice(), space, &after);
165 // As OneNote titles `NATIVE_LINKS`'s page holding this paragraph: without the field code.
166 assert_eq!(stored.title, "Read about Rust the Rust site");
165167 let mut stored = stored;
166168 let text = &body_paragraphs(&mut stored)[0].text().unwrap().text;
167169 let flags: Vec<(Option<bool>, Option<bool>, Option<bool>)> = text
......@@ -285,6 +287,24 @@ fn internal_links_match_what_onenote_stores() {
285287 );
286288 assert_eq!(parse_internal_link(&stored[0]), None);
287289 assert_eq!(parse_internal_link("onenote:#x&page-id={0}"), None);
290 // The Link dialog's page and Copy Link to Page name the section file before the `#`.
291 let ids = "section-id={0F1EFC25-DF7C-45CB-87A4-BDD03F42057E}&page-id={A9CCA318-A43E-4912-A1C7-F19F1EAF30CA}";
292 for url in [
293 format!("onenote:links.one#Page&{ids}&base-path=//C:/notebook"),
294 format!("onenote:///C:\\notebook\\links.one#Page&{ids}&end"),
295 ] {
296 let link = parse_internal_link(&url).unwrap();
297 assert_eq!(
298 (
299 link.section[..4].to_vec(),
300 link.page.map(|page| page[..4].to_vec())
301 ),
302 (
303 vec![0x25, 0xfc, 0x1e, 0x0f],
304 Some(vec![0x18, 0xa3, 0xcc, 0xa9])
305 )
306 );
307 }
288308}
289309
290310/// `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() {
295315 let creation = onestore::PageCreation::new(None, Some("Link target"), "Author").unwrap();
296316 let with_target = ops::edited(
297317 &source,
298 vec![onestore::op::Op::Section(onestore::op::SectionOp::Create(creation))],
318 vec![onestore::op::Op::Section(onestore::op::SectionOp::Create(
319 creation,
320 ))],
299321 )
300322 .unwrap();
301323 let section = Store::parse(&with_target).unwrap().header.file_id;
......@@ -354,7 +376,13 @@ fn a_page_links_to_another_page_and_its_paragraph() {
354376 })
355377 .unwrap();
356378 let written = ops::saved(&with_target, space, &after).unwrap();
357 assert_same(written.as_slice(), space, &after);
379 let stored = assert_same(written.as_slice(), space, &after);
380 // Titles show the link's label, not its field code, as OneNote stores them.
381 assert!(
382 stored.title.starts_with("Linking page Link target"),
383 "{}",
384 stored.title
385 );
358386 if let Some(directory) = std::env::var_os("ONESTORE_INTERNAL_LINK_EXPORT") {
359387 let directory = std::path::PathBuf::from(directory);
360388 std::fs::create_dir(&directory).unwrap();
......@@ -367,3 +395,20 @@ fn a_page_links_to_another_page_and_its_paragraph() {
367395 .unwrap();
368396 }
369397}
398
399/// A title stored with a link's field code, as an earlier writer left it
400/// (`corpus/link-edit/internal/candidate`), reads as the label the page shows.
401#[test]
402fn titles_read_without_link_field_codes() {
403 let bytes = include_bytes!("../../../corpus/link-edit/internal/candidate/links.one").to_vec();
404 let arena = onestore::Arena::default();
405 let mut section = onestore::Section::open(&arena, bytes.clone()).unwrap();
406 let titles: Vec<String> = section
407 .pages()
408 .unwrap()
409 .into_iter()
410 .map(|(_, title, _)| title)
411 .collect();
412 assert_eq!(titles, ["Linking page Link target", "Link target"]);
413 page_by_title(&bytes, "Linking page Link target");
414}
crates/onestore/tests/page_math.rs+267-15
......@@ -26,6 +26,10 @@ const EDITOR_3: &[u8] =
2626 include_bytes!("../../../corpus/math-edit/native-editor-3/notebook/links.one");
2727const EDITOR_3_READ: &str =
2828 include_str!("../../../corpus/math-edit/native-editor-3/read/page-000.xml");
29/// A fourth session repeating most of the others and adding an exponent, Greek letters, a
30/// fraction of sums, a Pythagorean sum, a limit applied as a function and braces.
31const EDITOR_4: &[u8] =
32 include_bytes!("../../../corpus/math-edit/native-editor-4/notebook/links.one");
2933
3034fn native_mathml(read: &str) -> Vec<String> {
3135 read.split("<mml:math")
......@@ -70,22 +74,23 @@ fn rendered_equations(bytes: &[u8], title: &str) -> Vec<String> {
7074 if !Math::is_equation(&text.text) {
7175 continue;
7276 }
73 // Inline math follows ordinary text in the same paragraph; only the math runs
74 // form the equation.
77 // Inline math follows ordinary text in the same paragraph, and a line break
78 // divides an equation: each run of math is an equation of its own.
7579 let mut at = 0;
76 let math_runs: Vec<(String, onestore::document::Format)> = text
77 .text
78 .spans()
79 .iter()
80 .map(|span| {
81 let piece = text.text.text()[at..span.end].to_owned();
82 at = span.end;
83 (piece, span.format.clone())
84 })
85 .filter(|(_, format)| format.math == Some(true))
86 .collect();
87 let equation = onestore::page::Paragraph::from_runs(math_runs);
88 rendered.push(Math::mathml(&Math::parse(&equation).unwrap()));
80 let mut zones: Vec<Vec<(String, onestore::document::Format)>> = vec![Vec::new()];
81 for span in text.text.spans() {
82 let piece = text.text.text()[at..span.end].to_owned();
83 at = span.end;
84 if span.format.math == Some(true) {
85 zones.last_mut().unwrap().push((piece, span.format.clone()));
86 } else if !zones.last().unwrap().is_empty() {
87 zones.push(Vec::new());
88 }
89 }
90 for zone in zones.into_iter().filter(|zone| !zone.is_empty()) {
91 let equation = onestore::page::Paragraph::from_runs(zone);
92 rendered.push(Math::mathml(&Math::parse(&equation).unwrap()));
93 }
8994 }
9095 }
9196 rendered
......@@ -99,8 +104,17 @@ fn native_equations_render_to_the_mathml_onenote_exports() {
99104 );
100105}
101106
107/// Enter inside equations (`tools/native_enter.py`): a line break dividing one, and an
108/// argument made an equation array.
109const ENTER: &[u8] = include_bytes!("../../../corpus/math-edit/native-enter/notebook/links.one");
110const ENTER_READ: &str = include_str!("../../../corpus/math-edit/native-enter/read/page-000.xml");
111
102112#[test]
103113fn equation_editor_expressions_render_to_the_mathml_onenote_exports() {
114 assert_eq!(
115 rendered_equations(ENTER, "Read about Rust the Rust site"),
116 native_mathml(ENTER_READ)
117 );
104118 assert_eq!(
105119 rendered_equations(EDITOR, "Read about Rust the Rust site"),
106120 native_mathml(EDITOR_READ)
......@@ -369,3 +383,241 @@ fn a_paragraph_emptied_of_its_inline_math_saves() {
369383 let document = Document::parse(&index).unwrap();
370384 assert_eq!(Page::from_space(&document, space).unwrap(), after);
371385}
386
387type MathRun = (String, Option<onestore::document::MathObject>, Option<bool>);
388
389/// The math runs of each equation paragraph on the page titled `title`: text, run data and
390/// whether the run is italic, which is what the equation editor decides.
391fn stored_equations(bytes: &[u8], title: &str) -> Vec<Vec<MathRun>> {
392 let store = Store::parse(bytes).unwrap();
393 let index = RevisionIndex::parse(&store).unwrap();
394 let document = Document::parse(&index).unwrap();
395 let page = document
396 .pages()
397 .unwrap()
398 .into_iter()
399 .map(|(space, _)| Page::from_space(&document, space).unwrap())
400 .find(|page| page.title == title)
401 .unwrap();
402 let mut equations = Vec::new();
403 for object in &page.objects {
404 let PageObject::Outline(outline) = object else {
405 continue;
406 };
407 for paragraph in &outline.paragraphs {
408 if let Some(text) = paragraph
409 .text()
410 .filter(|text| Math::is_equation(&text.text))
411 {
412 equations.push(runs(&text.text));
413 }
414 }
415 }
416 equations
417}
418
419fn runs(paragraph: &onestore::page::Paragraph) -> Vec<MathRun> {
420 let mut at = 0;
421 paragraph
422 .spans()
423 .iter()
424 .filter_map(|span| {
425 let text = paragraph.text()[at..span.end].to_owned();
426 at = span.end;
427 (span.format.math == Some(true))
428 .then(|| (text, span.format.math_object.clone(), span.format.italic))
429 })
430 .collect()
431}
432
433/// What OneNote 2010's equation editor stored for text typed into it, each followed by the
434/// space that builds it up, in the order the equations appear; `None` skips an equation the
435/// editor built in a way this reading does not follow.
436const TYPED: &[(&[u8], &[Option<&str>])] = &[
437 (
438 EDITOR,
439 &[
440 Some("a_1+b_2"),
441 Some("x_i^2"),
442 Some("(a+b)"),
443 Some("\\int_0^1 x dx"),
444 Some("\\sum_(i=1)^n i"),
445 ],
446 ),
447 (
448 EDITOR_2,
449 &[
450 Some("\\sqrt x+1"),
451 Some("\\cbrt(x)"),
452 Some("\\sqrt(n&x)"),
453 Some("a/b"),
454 Some("\\lim_(x\\to 0) f(x)"),
455 Some("\\prod_(k=1)^n k"),
456 Some("[a+b]"),
457 Some("\\overline(x)"),
458 Some("x\\hat"),
459 ],
460 ),
461 (
462 EDITOR_3,
463 &[
464 Some("\\matrix(1&2@3&4)"),
465 Some("\\eqarray(x&=1@y&=2)"),
466 Some("x\\above 2"),
467 Some("x\\below 2"),
468 Some("\\box(x)"),
469 Some("\\rect(x)"),
470 // `\underline` became an unrelated symbol.
471 None,
472 Some("\\iint x dx dy"),
473 Some("f(x)/(x^2+1)"),
474 Some("\\sum^n x"),
475 ],
476 ),
477 (
478 EDITOR_4,
479 &[
480 Some("a_1+b_2"),
481 // The editor was still starting this one and kept its placeholder.
482 None,
483 Some("\\sum_(i=1)^n i"),
484 Some("\\int_0^1 x dx"),
485 Some("\\sqrt x+1"),
486 Some("\\cbrt(x)"),
487 Some("\\sqrt(n&x)"),
488 Some("a/b"),
489 Some("\\prod_(k=1)^n k"),
490 Some("[a+b]"),
491 Some("\\overline(x)"),
492 Some("x\\hat"),
493 Some("\\matrix(1&2@3&4)"),
494 Some("\\eqarray(x&=1@y&=2)"),
495 Some("x\\above 2"),
496 Some("x\\below 2"),
497 Some("\\box(x)"),
498 Some("\\rect(x)"),
499 Some("f(x)/(x^2+1)"),
500 Some("\\sum^n x"),
501 Some("e^(x+1)"),
502 Some("\\alpha+\\beta"),
503 Some("(a+b)/(c+d)"),
504 Some("x^2+y^2=z^2"),
505 // A function applied to a limit, an object this reading does not build.
506 None,
507 Some("{a+b}"),
508 ],
509 ),
510];
511
512#[test]
513fn typed_linear_text_builds_up_as_the_equation_editor_stores_it() {
514 for (bytes, typed) in TYPED {
515 let stored = stored_equations(bytes, "Read about Rust the Rust site");
516 assert_eq!(stored.len(), typed.len());
517 for (stored, typed) in stored.iter().zip(*typed) {
518 let Some(typed) = typed else {
519 continue;
520 };
521 let built = Math::paragraph(
522 &Math::from_linear(&format!("{typed} ")),
523 &onestore::document::Format::default(),
524 );
525 assert_eq!(&runs(&built), stored, "{typed}");
526 }
527 }
528}
529
530/// Every native equation reads back from the linear text written for it, except the two
531/// the linear format here cannot write: a limit applied as a function and an empty
532/// equation's placeholder (`EDITOR_4`).
533#[test]
534fn native_equations_survive_their_linear_form() {
535 let mut unwritten = 0;
536 for (bytes, title) in [
537 (NATIVE, "Equation controls"),
538 (EDITOR, "Read about Rust the Rust site"),
539 (EDITOR_2, "Read about Rust the Rust site"),
540 (EDITOR_3, "Read about Rust the Rust site"),
541 (EDITOR_4, "Read about Rust the Rust site"),
542 ] {
543 for stored in stored_equations(bytes, title) {
544 let paragraph = onestore::page::Paragraph::from_runs(stored.iter().map(
545 |(text, object, italic)| {
546 let format = onestore::document::Format {
547 math: Some(true),
548 math_object: object.clone(),
549 italic: *italic,
550 ..Default::default()
551 };
552 (text.clone(), format)
553 },
554 ));
555 let Some(linear) = Math::linear(&Math::parse(&paragraph).unwrap()) else {
556 unwritten += 1;
557 continue;
558 };
559 let rebuilt = Math::paragraph(&Math::from_linear(&linear), &Default::default());
560 // Linear text without objects stores no run data until it is built up.
561 if stored.iter().all(|(_, object, _)| object.is_none()) {
562 assert_eq!(rebuilt.text(), paragraph.text());
563 continue;
564 }
565 assert_eq!(runs(&rebuilt), stored, "{linear}");
566 }
567 }
568 assert_eq!(unwritten, 2);
569}
570
571/// OneNote 2010's Linear on equations typed through its editor (`corpus/math-edit/native-linear`):
572/// the tree built from the typed text writes as the linear text OneNote showed and stored,
573/// and that text builds up to the same tree.
574#[test]
575fn equations_write_the_linear_text_onenote_shows() {
576 const LINEAR: &[u8] =
577 include_bytes!("../../../corpus/math-edit/native-linear/notebook/links.one");
578 let typed = [
579 "x^2+1",
580 "(a+b)/(c+d)",
581 "a_1+b_2",
582 "x_i^2",
583 "\\sum_(i=1)^n i",
584 "\\int_0^1 x dx",
585 "\\sqrt x+1",
586 "\\cbrt(x)",
587 "\\sqrt(n&x)",
588 "a/b",
589 "\\prod_(k=1)^n k",
590 "[a+b]",
591 "\\overline(x)",
592 "x\\hat",
593 "\\matrix(1&2@3&4)",
594 "\\eqarray(x&=1@y&=2)",
595 "x\\above 2",
596 "x\\below 2",
597 "\\box(x)",
598 "\\rect(x)",
599 "f(x)/(x^2+1)",
600 "\\sum^n x",
601 "e^(x+1)",
602 "\\alpha+\\beta",
603 ];
604 let shown: Vec<String> = stored_equations(LINEAR, "Read about Rust the Rust siteR")
605 .into_iter()
606 .map(|runs| runs.into_iter().map(|(text, ..)| text).collect())
607 .collect();
608 assert_eq!(shown.len(), typed.len());
609 for (shown, typed) in shown.iter().zip(typed) {
610 let built = Math::from_linear(&format!("{typed} "));
611 assert_eq!(
612 Math::linear(&built).as_deref(),
613 Some(shown.as_str()),
614 "{typed}"
615 );
616 let base = onestore::document::Format::default();
617 assert_eq!(
618 Math::paragraph(&Math::from_linear(shown), &base),
619 Math::paragraph(&built, &base),
620 "{typed}"
621 );
622 }
623}
crates/onestore/tests/page_movement.rs+66-15
......@@ -212,23 +212,52 @@ fn invalid_and_empty_page_edits_do_not_publish() {
212212 }
213213 assert!(PageEdit::move_to(ids[0], Some(ids[0]), 1).is_err());
214214 assert!(PageEdit::set_level(ExGuid::default(), 1).is_err());
215 assert_eq!(ops::section_op(SOURCE, SectionOp::Pages([].to_vec())).unwrap().as_bytes(), SOURCE);
216215 assert_eq!(
217 ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(ids[0], 1).unwrap()].to_vec()))
216 ops::section_op(SOURCE, SectionOp::Pages([].to_vec()))
218217 .unwrap()
219218 .as_bytes(),
220219 SOURCE
221220 );
222 assert!(ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(ids[0], 2).unwrap()].to_vec())).is_err());
221 assert_eq!(
222 ops::section_op(
223 SOURCE,
224 SectionOp::Pages([PageEdit::set_level(ids[0], 1).unwrap()].to_vec())
225 )
226 .unwrap()
227 .as_bytes(),
228 SOURCE
229 );
230 assert!(
231 ops::section_op(
232 SOURCE,
233 SectionOp::Pages([PageEdit::set_level(ids[0], 2).unwrap()].to_vec())
234 )
235 .is_err()
236 );
223237 let duplicate = PageEdit::set_level(ids[3], 2).unwrap();
224 assert!(ops::section_op(SOURCE, SectionOp::Pages([duplicate.clone(), duplicate].to_vec())).is_err());
238 assert!(
239 ops::section_op(
240 SOURCE,
241 SectionOp::Pages([duplicate.clone(), duplicate].to_vec())
242 )
243 .is_err()
244 );
225245 let missing = ExGuid {
226246 guid: [0x77; 16],
227247 n: 1,
228248 };
229 assert!(ops::section_op(SOURCE, SectionOp::Pages([PageEdit::set_level(missing, 1).unwrap()].to_vec())).is_err());
230249 assert!(
231 ops::section_op(SOURCE, SectionOp::Pages([PageEdit::move_to(ids[3], Some(missing), 1).unwrap()].to_vec()))
250 ops::section_op(
251 SOURCE,
252 SectionOp::Pages([PageEdit::set_level(missing, 1).unwrap()].to_vec())
253 )
254 .is_err()
255 );
256 assert!(
257 ops::section_op(
258 SOURCE,
259 SectionOp::Pages([PageEdit::move_to(ids[3], Some(missing), 1).unwrap()].to_vec())
260 )
232261 .is_err()
233262 );
234263 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() {
253282 .unwrap()
254283 .as_bytes()
255284 .to_vec();
256 let moved =
257 ops::section_op(&source, SectionOp::Pages([PageEdit::move_to(first, None, 1).unwrap()].to_vec())).unwrap();
285 let moved = ops::section_op(
286 &source,
287 SectionOp::Pages([PageEdit::move_to(first, None, 1).unwrap()].to_vec()),
288 )
289 .unwrap();
258290 assert_eq!(pages(moved.as_bytes()).last(), Some(&first));
259291 let stores = [&source, moved.as_bytes()].map(|bytes| Store::parse(bytes).unwrap());
260292 let indexes = stores
......@@ -338,7 +370,11 @@ fn metadata_copy_order_does_not_change_page_identity() {
338370 "../../../corpus/page-lifecycle/page-edits/optional-cache/source-cold/notebook/Lifecycle.one"
339371 );
340372 let sid = pages(source)[3];
341 let edited = ops::section_op(source, SectionOp::Pages([PageEdit::set_level(sid, 2).unwrap()].to_vec())).unwrap();
373 let edited = ops::section_op(
374 source,
375 SectionOp::Pages([PageEdit::set_level(sid, 2).unwrap()].to_vec()),
376 )
377 .unwrap();
342378 let expected = include_bytes!(
343379 "../../../corpus/page-lifecycle/page-edits/optional-cache/candidate/Lifecycle.one"
344380 );
......@@ -374,8 +410,21 @@ fn native_edits_on_moved_pages_support_more_rust_changes() {
374410 let bodies: Vec<_> = view.nodes.iter().filter_map(|(id, node)|
375411 matches!(&node.kind, Kind::RichText { text, .. } if text == "Native body after a Rust page move.").then_some(*id)).collect();
376412 let [body] = bodies.as_slice() else { panic!() };
377 let moved = ops::section_op(source, SectionOp::Pages([PageEdit::set_level(sid, 2).unwrap()].to_vec())).unwrap();
378 let edited = ops::page_op(moved.as_bytes(), sid, PageOp::Text { text: *body, range: 0..0, with: "Rust + ".into() }).unwrap();
413 let moved = ops::section_op(
414 source,
415 SectionOp::Pages([PageEdit::set_level(sid, 2).unwrap()].to_vec()),
416 )
417 .unwrap();
418 let edited = ops::page_op(
419 moved.as_bytes(),
420 sid,
421 PageOp::Text {
422 text: *body,
423 range: 0..0,
424 with: "Rust + ".into(),
425 },
426 )
427 .unwrap();
379428 let after_store = Store::parse(edited.as_bytes()).unwrap();
380429 let after_index = RevisionIndex::parse(&after_store).unwrap();
381430 let after = Document::parse(&after_index).unwrap();
......@@ -533,9 +582,8 @@ fn repeated_nesting_retains_series_history_across_revision_checkpoints() {
533582/// without them and gives the moved page one of its own.
534583#[test]
535584fn a_move_after_a_native_merge_drops_its_empty_series() {
536 let merged = include_bytes!(
537 "../../../corpus/conflict-page/native-pages/merged/notebook/synthetic.one"
538 );
585 let merged =
586 include_bytes!("../../../corpus/conflict-page/native-pages/merged/notebook/synthetic.one");
539587 let series = |bytes: &[u8]| {
540588 let store = Store::parse(bytes).unwrap();
541589 let index = RevisionIndex::parse(&store).unwrap();
......@@ -561,7 +609,10 @@ fn a_move_after_a_native_merge_drops_its_empty_series() {
561609 let (id, spaces) = after.last().unwrap();
562610 assert_eq!(spaces, &vec![ids[1]]);
563611 assert!(before.iter().all(|(old, _)| old != id));
564 assert_eq!(pages(moved.as_bytes()), [&ids[..1], &ids[2..], &ids[1..2]].concat());
612 assert_eq!(
613 pages(moved.as_bytes()),
614 [&ids[..1], &ids[2..], &ids[1..2]].concat()
615 );
565616 if let Some(output) = std::env::var_os("ONESTORE_EMPTY_SERIES_EXPORT") {
566617 let output = std::path::Path::new(&output);
567618 std::fs::create_dir(output).unwrap();
crates/onestore/tests/page_writer.rs+32-10
......@@ -242,16 +242,34 @@ fn formatting_matches_format_text() {
242242 let (space, page) = page_by_title(PARAGRAPHS, "Split style boundary");
243243 let (_, text, content) = text_ids(body(&page)[0])[0].clone();
244244 let end = u32::try_from(content.encode_utf16().count()).unwrap();
245 let reference = ops::page_op(PARAGRAPHS, space, PageOp::Format { text, range: 1..end.min(4), set: vec![
246 TextAttribute::Bold(true),
247 TextAttribute::Color(Some([255, 0, 0])),
248 TextAttribute::FontSize(14.0),
249 ], clear: Vec::new() })
245 let reference = ops::page_op(
246 PARAGRAPHS,
247 space,
248 PageOp::Format {
249 text,
250 range: 1..end.min(4),
251 set: vec![
252 TextAttribute::Bold(true),
253 TextAttribute::Color(Some([255, 0, 0])),
254 TextAttribute::FontSize(14.0),
255 ],
256 clear: Vec::new(),
257 },
258 )
250259 .unwrap();
251 let reference = ops::page_op(reference.as_bytes(), space, PageOp::Format { text, range: 0..1, set: vec![
252 TextAttribute::Italic(true),
253 TextAttribute::Font("Consolas".into()),
254 ], clear: Vec::new() })
260 let reference = ops::page_op(
261 reference.as_bytes(),
262 space,
263 PageOp::Format {
264 text,
265 range: 0..1,
266 set: vec![
267 TextAttribute::Italic(true),
268 TextAttribute::Font("Consolas".into()),
269 ],
270 clear: Vec::new(),
271 },
272 )
255273 .unwrap();
256274 oracle(PARAGRAPHS, space, reference.as_bytes());
257275}
......@@ -444,7 +462,11 @@ fn composed_edits_publish_one_transaction() {
444462 }],
445463 );
446464 let insertion = insert(outline.id, Some(paragraphs[1].0), "Inserted", 1);
447 let PageOp::Insert { paragraphs: inserted, .. } = &insertion else {
465 let PageOp::Insert {
466 paragraphs: inserted,
467 ..
468 } = &insertion
469 else {
448470 unreachable!()
449471 };
450472 let inserted = inserted[0].text().unwrap().id;
crates/onestore/tests/paragraph.rs+50-12
......@@ -44,7 +44,13 @@ fn split(source: &[u8], space: ExGuid, text: ExGuid, at: u32) -> (Op, ExGuid, Ex
4444
4545/// `source` after joining the paragraph of text `right` to the one of text `left`.
4646fn join(source: &[u8], space: ExGuid, left: ExGuid, right: ExGuid) -> Result<Vec<u8>, OpError> {
47 ops::edited(source, vec![Op::Page { space, op: PageOp::Join { left, right } }])
47 ops::edited(
48 source,
49 vec![Op::Page {
50 space,
51 op: PageOp::Join { left, right },
52 }],
53 )
4854}
4955
5056const SOURCE: &[u8] =
......@@ -445,10 +451,7 @@ fn splits_partition_native_paragraphs_at_every_scalar_boundary() {
445451 assert_eq!(after.nodes[parent].children, children);
446452 assert_eq!(after.nodes[&paragraph].content, [text]);
447453 assert!(after.nodes[&paragraph].children.is_empty());
448 assert_eq!(
449 after.nodes[&new_paragraph].content,
450 [new_text]
451 );
454 assert_eq!(after.nodes[&new_paragraph].content, [new_text]);
452455 assert_eq!(
453456 after.nodes[&new_paragraph].children,
454457 before.nodes[&paragraph].children
......@@ -516,7 +519,17 @@ fn invalid_split_identities_and_title_targets_are_rejected() {
516519 .0;
517520 let (op, ..) = split(SOURCE, sid, text, 1);
518521 assert!(ops::transaction(SOURCE, "a\0b", vec![op.clone()]).is_err());
519 let Op::Page { op: PageOp::Split { paragraph, right, lists, .. }, .. } = op else {
522 let Op::Page {
523 op:
524 PageOp::Split {
525 paragraph,
526 right,
527 lists,
528 ..
529 },
530 ..
531 } = op
532 else {
520533 unreachable!()
521534 };
522535 for (text, at, paragraph) in [
......@@ -525,7 +538,13 @@ fn invalid_split_identities_and_title_targets_are_rejected() {
525538 (text, 1, ExGuid::default()),
526539 (text, 1, text),
527540 ] {
528 let op = PageOp::Split { text, at, paragraph, right, lists: lists.clone() };
541 let op = PageOp::Split {
542 text,
543 at,
544 paragraph,
545 right,
546 lists: lists.clone(),
547 };
529548 assert!(ops::page_edited(SOURCE, sid, vec![op]).is_err());
530549 }
531550 let mut titles = 0;
......@@ -638,7 +657,9 @@ fn interrupted_splits_publish_a_complete_graph_or_retain_the_original() {
638657 let checkpoint = checkpoint::pending(source, sid, text);
639658 for source in [source, &checkpoint] {
640659 let (op, ..) = split(source, sid, text, 1);
641 let edit = ops::transaction(source, "Author", vec![op]).unwrap().unwrap();
660 let edit = ops::transaction(source, "Author", vec![op])
661 .unwrap()
662 .unwrap();
642663 let mut written = source.to_vec();
643664 edit.apply(&mut written).unwrap();
644665 let before = current::current(source);
......@@ -698,7 +719,10 @@ fn export_native_paragraph_joins() {
698719 })
699720 .unwrap()
700721 .0;
701 let op = Op::Page { space: sid, op: PageOp::Join { left, right } };
722 let op = Op::Page {
723 space: sid,
724 op: PageOp::Join { left, right },
725 };
702726 let transaction = ops::transaction(&source, "Rust join author", vec![op.clone()])
703727 .unwrap()
704728 .unwrap();
......@@ -767,8 +791,16 @@ fn interrupted_joins_preserve_complete_graphs_and_empty_text_adoption() {
767791 .unwrap();
768792 let checkpoint = checkpoint::pending(original, sid, left);
769793 for source in [original, &empty, &checkpoint] {
770 let op = Op::Page { space: sid, op: PageOp::Join { left, right: right_text } };
771 let edit = ops::transaction(source, "Join author", vec![op]).unwrap().unwrap();
794 let op = Op::Page {
795 space: sid,
796 op: PageOp::Join {
797 left,
798 right: right_text,
799 },
800 };
801 let edit = ops::transaction(source, "Join author", vec![op])
802 .unwrap()
803 .unwrap();
772804 let mut written = source.to_vec();
773805 edit.apply(&mut written).unwrap();
774806 let before = current::current(source);
......@@ -828,7 +860,13 @@ fn joins_reject_invalid_identities_wrong_order_and_unrelated_pages() {
828860 ] {
829861 assert!(join(source, *sid, a, b).is_err());
830862 }
831 let op = Op::Page { space: *sid, op: PageOp::Join { left: *left, right: *right } };
863 let op = Op::Page {
864 space: *sid,
865 op: PageOp::Join {
866 left: *left,
867 right: *right,
868 },
869 };
832870 assert!(ops::transaction(source, "a\0b", vec![op]).is_err());
833871 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
834872 let paragraph = *view
crates/onestore/tests/protected.rs+4-1
......@@ -188,7 +188,10 @@ fn a_protected_page_edit_is_stored_under_the_section_key() {
188188 let ops = onestore::op::lower_page(&before, page).unwrap();
189189 let edit = Edit {
190190 at: 134_000_000_000_000_000,
191 ops: ops.into_iter().map(|op| Op::Page { space: *space, op }).collect(),
191 ops: ops
192 .into_iter()
193 .map(|op| Op::Page { space: *space, op })
194 .collect(),
192195 };
193196 let store = Store::parse(&bytes).unwrap();
194197 let index = RevisionIndex::parse(&store).unwrap();
crates/onestore/tests/shared_snapshot.rs+1-2
......@@ -198,8 +198,7 @@ fn published_snapshots_survive_interleaved_commit_io() {
198198 bytes: source.clone(),
199199 events: Vec::new(),
200200 };
201 commit_text(&mut trace, &source, sid, oid, end..end, " [reader café 🦀]")
202 .unwrap();
201 commit_text(&mut trace, &source, sid, oid, end..end, " [reader café 🦀]").unwrap();
203202 let after = current(&trace.bytes);
204203 assert_ne!(before, after);
205204 let mut writes = Vec::new();
crates/onestore/tests/support/page_edits.rs+13-3
......@@ -5,8 +5,8 @@
55
66use onestore::{
77 Arena, ExGuid, RevisionIndex, Section, Store,
8 op::{self, Op},
98 document::{Document, Format, Layout},
9 op::{self, Op},
1010 page::{
1111 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent,
1212 text::{Edit, new_id},
......@@ -576,10 +576,20 @@ pub fn run(input: &[u8]) {
576576 let mut section = Section::open(&arena, source.clone()).unwrap();
577577 let edit = op::Edit {
578578 at: 134_000_000_000_000_000,
579 ops: ops.iter().map(|op| Op::Page { space, op: op.clone() }).collect(),
579 ops: ops
580 .iter()
581 .map(|op| Op::Page {
582 space,
583 op: op.clone(),
584 })
585 .collect(),
580586 };
581587 if section.apply("Fuzz author", &edit).is_err() {
582 assert_eq!(section.page(space).unwrap(), before, "a refused edit changes nothing");
588 assert_eq!(
589 section.page(space).unwrap(),
590 before,
591 "a refused edit changes nothing"
592 );
583593 return;
584594 }
585595 let mut predicted = before.clone();
crates/onestore/tests/support/page_schedule.rs+11-2
......@@ -94,7 +94,8 @@ pub fn run(input: &[u8]) {
9494 let removed: Vec<_> = (0..count)
9595 .map(|i| pages[(usize::from(step[2]) + i) % pages.len()].0)
9696 .collect();
97 let prepared = ops::section_op(source, SectionOp::Delete(removed.to_vec())).unwrap();
97 let prepared =
98 ops::section_op(source, SectionOp::Delete(removed.to_vec())).unwrap();
9899 pages.retain(|page| !removed.contains(&page.0));
99100 for sid in removed {
100101 levels.remove(&sid);
......@@ -143,7 +144,15 @@ pub fn run(input: &[u8]) {
143144 unreachable!()
144145 };
145146 Some((
146 ops::page_op(source, sid, PageOp::Text { text: object, range: 0..u32::try_from(original.encode_utf16().count()).unwrap(), with: text.into() })
147 ops::page_op(
148 source,
149 sid,
150 PageOp::Text {
151 text: object,
152 range: 0..u32::try_from(original.encode_utf16().count()).unwrap(),
153 with: text.into(),
154 },
155 )
147156 .unwrap(),
148157 Some((sid, object, text)),
149158 ))
crates/onestore/tests/support/tree_model.rs+18-7
......@@ -4,10 +4,7 @@ use onestore::{
44 op::{Op, PageOp},
55 page::Page,
66};
7use std::{
8 collections::BTreeMap,
9 sync::LazyLock,
10};
7use std::{collections::BTreeMap, sync::LazyLock};
118
129use crate::{current, disk, ops};
1310
......@@ -136,7 +133,12 @@ pub fn run(input: &[u8]) {
136133 let destination = destinations[usize::from(step[4]) % destinations.len()];
137134 // Outline groups are the writers' business: anchors are the paragraphs in them.
138135 let mut children = Vec::new();
139 let mut pending: Vec<ExGuid> = view.nodes[&destination].children.iter().rev().copied().collect();
136 let mut pending: Vec<ExGuid> = view.nodes[&destination]
137 .children
138 .iter()
139 .rev()
140 .copied()
141 .collect();
140142 while let Some(id) = pending.pop() {
141143 match view.nodes[&id].kind {
142144 Kind::OutlineGroup => pending.extend(view.nodes[&id].children.iter().rev()),
......@@ -155,7 +157,10 @@ pub fn run(input: &[u8]) {
155157 (op, subtree.contains_key(&destination))
156158 };
157159 let model = Page::from_space(&document, sid).unwrap();
158 let ops = vec![Op::Page { space: sid, op: op.clone() }];
160 let ops = vec![Op::Page {
161 space: sid,
162 op: op.clone(),
163 }];
159164 let Ok(edit) = ops::apply(source, "Tree schedule", ops) else {
160165 continue;
161166 };
......@@ -170,7 +175,13 @@ pub fn run(input: &[u8]) {
170175 if stored != predicted {
171176 let (a, b) = (format!("{stored:?}"), format!("{predicted:?}"));
172177 let at = a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count();
173 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()))]);
178 panic!(
179 "{op:?}\n stored …{}\n predicted …{}",
180 &a[a.floor_char_boundary(at.saturating_sub(300))
181 ..a.floor_char_boundary((at + 200).min(a.len()))],
182 &b[b.floor_char_boundary(at.saturating_sub(300))
183 ..b.floor_char_boundary((at + 200).min(b.len()))]
184 );
174185 }
175186 let space = &after_document.spaces[&sid];
176187 let after = &space.revisions[&space.contexts[&ExGuid::default()]];
crates/onestore/tests/table_growth.rs+1-3
......@@ -75,9 +75,7 @@ fn a_text_edit_stores_the_table_entries_it_names() {
7575 sparse |= (entries as u32) < span;
7676 }
7777 assert_eq!(
78 ops::saved(&written, space, &page)
79 .unwrap()
80 .as_slice(),
78 ops::saved(&written, space, &page).unwrap().as_slice(),
8179 written
8280 );
8381 bytes = written;
crates/onestore/tests/tree.rs+8-2
......@@ -187,7 +187,10 @@ fn native_subtree_controls_match_with_preserved_fields_and_history() {
187187 let Some((name, object, edit)) = operation(before, page) else {
188188 continue;
189189 };
190 let edit: Vec<Op> = edit.into_iter().map(|op| Op::Page { space: sid, op }).collect();
190 let edit: Vec<Op> = edit
191 .into_iter()
192 .map(|op| Op::Page { space: sid, op })
193 .collect();
191194 let prepared = ops::apply(source, "Tree author", edit.clone())
192195 .unwrap_or_else(|error| panic!("{name}: {error}"));
193196 let after_store = Store::parse(prepared.as_bytes()).unwrap();
......@@ -425,7 +428,10 @@ fn publication_interruptions_expose_complete_old_or_new_trees() {
425428 ) {
426429 continue;
427430 }
428 let intent = intent.into_iter().map(|op| Op::Page { space: sid, op }).collect();
431 let intent = intent
432 .into_iter()
433 .map(|op| Op::Page { space: sid, op })
434 .collect();
429435 let edit = ops::apply(source, "Tree author", intent).unwrap();
430436 let after = current::current(edit.as_bytes());
431437 for write_limit in [17, 4096] {
crates/onestore/tests/versions.rs created+506
......@@ -0,0 +1,506 @@
1//! Page versions against OneNote 2010's own (`corpus/page-versions/native`): listing them,
2//! reading them, and Restore Version and Delete Version storing what OneNote stores.
3
4use onestore::{
5 Arena, ExGuid, ObjectData, PageVersion, PropertySets, RevisionIndex, Section, Store,
6 document::Document,
7 op::{Edit, Op, PageOp, SectionOp},
8 page::{Page, PageObject},
9};
10use std::collections::BTreeSet;
11
12const AT: u64 = 134_040_000_000_000_000;
13const NATIVE: &str = "corpus/page-versions/native";
14/// The version history's context.
15const HISTORY: &str = "{7111497F-1B6B-4209-9491-C98B04CF4C5A},1";
16
17fn read(path: &str) -> Vec<u8> {
18 std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap()
19}
20
21fn step(n: u32) -> Vec<u8> {
22 read(&format!("{NATIVE}/step-{n:02}/notebook/History.one"))
23}
24
25fn id(text: &str) -> ExGuid {
26 text.parse().unwrap()
27}
28
29/// The page every step holds, and the version OneNote made when a second author edited it.
30fn page() -> ExGuid {
31 id("{55479BA2-FB3A-45EB-A43D-EDB93DA0593D},1")
32}
33
34fn texts(page: &Page) -> Vec<String> {
35 page.objects
36 .iter()
37 .flat_map(|object| match object {
38 PageObject::Outline(outline) => outline.paragraphs.clone(),
39 _ => Vec::new(),
40 })
41 .filter_map(|paragraph| paragraph.text().map(|text| text.text.text().to_owned()))
42 .collect()
43}
44
45fn versions(image: &[u8]) -> Vec<PageVersion> {
46 let arena = Arena::default();
47 let mut section = Section::open(&arena, image.to_vec()).unwrap();
48 section
49 .versions()
50 .unwrap()
51 .into_iter()
52 .find(|(listed, _)| *listed == page())
53 .map_or(Vec::new(), |(_, versions)| versions)
54}
55
56/// What a revision of `space` changed: its declared objects' types, sorted, with whether
57/// each was new.
58fn changed(index: &RevisionIndex<'_>, space: ExGuid, rid: ExGuid) -> Vec<(u32, bool)> {
59 let revision = index.resolve(space, rid).unwrap();
60 let base = index.spaces[&space].revisions[&rid]
61 .dependency
62 .map(|dependency| index.resolve(space, dependency).unwrap());
63 let mut changed: Vec<(u32, bool)> = revision
64 .objects
65 .iter()
66 .filter_map(|(id, object)| {
67 let old = base.as_ref().and_then(|base| base.objects.get(id));
68 let same = old.is_some_and(|old| {
69 old.reference_count == object.reference_count && old.data == object.data
70 });
71 (!same).then_some((object.jcid, old.is_none()))
72 })
73 .collect();
74 changed.sort();
75 changed
76}
77
78/// Property identities of an object's root set.
79fn properties(object: &onestore::Object<'_>) -> Vec<u32> {
80 let ObjectData::Properties(bytes) = object.data else {
81 panic!()
82 };
83 PropertySets::parse(bytes).unwrap().sets[0]
84 .iter()
85 .map(|property| property.id)
86 .collect()
87}
88
89/// How an edit left the page's revisions, from `before` to `after`: the page revision's
90/// dependency (named by what it was before), the history's, which revisions became versions,
91/// the declared object types of the page and history revisions, the history's children
92/// count and the proxy's properties, and whether the page metadata and the section's copy
93/// say the page has versions.
94#[derive(Debug, PartialEq)]
95struct Shape {
96 page_depends_on: &'static str,
97 history_depends_on_previous: bool,
98 labelled: Vec<&'static str>,
99 page_changed: Vec<(u32, bool)>,
100 history_changed: Vec<(u32, bool)>,
101 listed: usize,
102 proxy: Vec<u32>,
103 flagged: [bool; 2],
104}
105
106fn shape(before: &[u8], after: &[u8]) -> Shape {
107 let (old_store, new_store) = (Store::parse(before).unwrap(), Store::parse(after).unwrap());
108 let (old, new) = (
109 RevisionIndex::parse(&old_store).unwrap(),
110 RevisionIndex::parse(&new_store).unwrap(),
111 );
112 new.validate_current().unwrap();
113 Document::parse(&new).unwrap();
114 let space = page();
115 let history = id(HISTORY);
116 let (old_labels, new_labels) = (&old.spaces[&space].labels, &new.spaces[&space].labels);
117 let old_page = old_labels[&(ExGuid::default(), 1)];
118 let new_page = new_labels[&(ExGuid::default(), 1)];
119 let name = |rid: ExGuid| -> &'static str {
120 if rid == old_page {
121 "the page"
122 } else if old_labels.get(&(history, 1)) == Some(&rid) {
123 "the history"
124 } else if old_labels.iter().any(|((context, role), labelled)| {
125 *role == 1 && *context != ExGuid::default() && *labelled == rid
126 }) {
127 "a version"
128 } else {
129 "another revision"
130 }
131 };
132 let page_depends_on = if new_page == old_page {
133 "unchanged"
134 } else {
135 new.spaces[&space].revisions[&new_page]
136 .dependency
137 .map_or("none", name)
138 };
139 let (old_history, new_history) = (old_labels[&(history, 1)], new_labels[&(history, 1)]);
140 let labelled = new_labels
141 .iter()
142 .filter(|((context, role), _)| {
143 *role == 1
144 && *context != ExGuid::default()
145 && *context != history
146 && !old_labels.contains_key(&(*context, 1))
147 })
148 .map(|(_, rid)| name(*rid))
149 .collect();
150 let history_revision = new.resolve(space, new_history).unwrap();
151 let root = &history_revision.objects[&history_revision.roots[&1]];
152 let listed = root.references().unwrap().objects;
153 let proxy = listed.first().map_or(Vec::new(), |proxy| {
154 properties(&history_revision.objects[proxy])
155 });
156 let metadata = |index: &RevisionIndex<'_>| -> [bool; 2] {
157 let revision = index.resolve_active(space).unwrap();
158 let own = properties(&revision.objects[&revision.roots[&2]]).contains(&0x88003462);
159 // The section's copy, named by the page's space (MS-ONE 2.2.81).
160 let section = index.resolve_active(index.root).unwrap();
161 let copy = properties(&section.objects[&id("{77EF5B93-CD3A-0705-1329-3A15E7846CD5},1")])
162 .contains(&0x88003462);
163 [own, copy]
164 };
165 Shape {
166 page_depends_on,
167 history_depends_on_previous: new_history != old_history
168 && new.spaces[&space].revisions[&new_history].dependency == Some(old_history),
169 labelled,
170 page_changed: if new_page == old_page {
171 Vec::new()
172 } else {
173 changed(&new, space, new_page)
174 },
175 history_changed: if new_history == old_history {
176 Vec::new()
177 } else {
178 changed(&new, space, new_history)
179 },
180 listed: listed.len(),
181 proxy,
182 flagged: metadata(&new),
183 }
184}
185
186fn apply(image: &[u8], author: &str, ops: Vec<Op>) -> Vec<u8> {
187 let arena = Arena::default();
188 let mut section = Section::open(&arena, image.to_vec()).unwrap();
189 section.apply(author, &Edit { at: AT, ops }).unwrap();
190 section.seal().unwrap().unwrap();
191 section.image()
192}
193
194#[test]
195fn native_versions_are_listed_newest_first_with_their_authors() {
196 assert_eq!(versions(&step(1)), []);
197 let virtual_version = PageVersion {
198 context: id("{2F6B7C6D-DF95-0F56-085E-74357323A760},1"),
199 modified: Some(0x01dd4ee2641a3f00),
200 author: Some("virtual".into()),
201 };
202 assert_eq!(versions(&step(2)), std::slice::from_ref(&virtual_version));
203 // A Restore Version made the page as it stood the newest version.
204 let restored = versions(&step(4));
205 assert_eq!(
206 restored,
207 [
208 PageVersion {
209 context: id("{783B5BD2-65DB-082D-3AAC-47135BA38EA9},1"),
210 modified: Some(0x01dd4ee27e541d00),
211 author: Some("Other Person".into()),
212 },
213 virtual_version.clone(),
214 ]
215 );
216 assert_eq!(versions(&step(5)), [virtual_version]);
217 assert_eq!(versions(&step(6)), []);
218 // Optimizing keeps a version and the history as checkpoints.
219 assert_eq!(versions(&step(10)).len(), 1);
220}
221
222#[test]
223fn a_version_reads_as_the_page_it_was() {
224 let arena = Arena::default();
225 let section = Section::open(&arena, step(1)).unwrap();
226 let before = section.page(page()).unwrap();
227 let arena = Arena::default();
228 let mut section = Section::open(&arena, step(2)).unwrap();
229 let context = section.versions().unwrap()[0].1[0].context;
230 let version = section.version(page(), context).unwrap();
231 assert_eq!(version, before);
232 assert_eq!(texts(&version), ["First state. Typed by virtual."]);
233 assert_eq!(
234 texts(&section.page(page()).unwrap()),
235 ["First state. Typed by virtual.", "Second author line."]
236 );
237 assert!(section.version(page(), id(HISTORY)).is_err());
238}
239
240/// Restore Version stores what OneNote 2010's does (`step-03` to `step-04`): the page's next
241/// revision builds on the version's, the page as it stood becomes the newest version under
242/// the context its revision derives, the history lists it first, and the revision metadata
243/// names who restored it. `ONESTORE_VERSIONS_EXPORT` names a directory for the notebooks,
244/// for a cold read.
245#[test]
246fn restoring_a_version_stores_what_onenote_stores() {
247 let before = step(3);
248 let restored_version = versions(&before)[0].context;
249 let arena = Arena::default();
250 let section = Section::open(&arena, before.clone()).unwrap();
251 let standing = section.page(page()).unwrap();
252 let version = section.version(page(), restored_version).unwrap();
253 let ours = apply(
254 &before,
255 "Other Person",
256 vec![Op::Section(SectionOp::RestoreVersion {
257 page: page(),
258 version: restored_version,
259 guid: [7; 16],
260 })],
261 );
262 let expected = shape(&before, &step(4));
263 assert_eq!(expected.page_depends_on, "a version");
264 assert_eq!(shape(&before, &ours), expected);
265
266 let arena = Arena::default();
267 let mut reopened = Section::open(&arena, ours.clone()).unwrap();
268 assert_eq!(reopened.page(page()).unwrap().objects, version.objects);
269 let listed = reopened.versions().unwrap().remove(0).1;
270 assert_eq!(listed.len(), 2);
271 assert_eq!(listed[1].context, restored_version);
272 // As OneNote names it (`step-04`): the page's revision, its bits flipped by a salt.
273 let store = Store::parse(&before).unwrap();
274 let standing_rid = RevisionIndex::parse(&store)
275 .unwrap()
276 .active(page())
277 .unwrap();
278 let salt = |a: ExGuid, b: ExGuid| -> Vec<u8> {
279 a.guid.iter().zip(b.guid).map(|(a, b)| a ^ b).collect()
280 };
281 assert_eq!(
282 salt(listed[0].context, standing_rid),
283 salt(
284 id("{783B5BD2-65DB-082D-3AAC-47135BA38EA9},1"),
285 id("{E569A5AE-EA67-4CB8-A9AC-55880C342743},1")
286 )
287 );
288 assert_eq!(listed[0].author.as_deref(), Some("Other Person"));
289 assert_eq!(
290 reopened.version(page(), listed[0].context).unwrap(),
291 standing
292 );
293
294 if let Some(output) = std::env::var_os("ONESTORE_VERSIONS_EXPORT") {
295 export(&output, "restore", &ours);
296 }
297}
298
299/// Delete Version stores what OneNote 2010's does: the history stops listing the version
300/// (`step-04` to `step-05`), and once none is left the page's metadata and the section's copy
301/// stop saying it has versions (`step-05` to `step-06`).
302#[test]
303fn deleting_versions_stores_what_onenote_stores() {
304 let before = step(4);
305 let newest = versions(&before)[0].context;
306 let delete = |image: &[u8], versions: Vec<ExGuid>| {
307 apply(
308 image,
309 "Other Person",
310 vec![Op::Section(SectionOp::DeleteVersions {
311 page: page(),
312 versions,
313 })],
314 )
315 };
316 let ours = delete(&before, vec![newest]);
317 assert_eq!(shape(&before, &ours), shape(&before, &step(5)));
318 let emptied = delete(&step(5), vec![versions(&step(5))[0].context]);
319 let expected = shape(&step(5), &step(6));
320 assert_eq!(expected.flagged, [false, false]);
321 assert_eq!(shape(&step(5), &emptied), expected);
322 // Every version at once, as Delete All Versions in Section asks.
323 let all = versions(&before)
324 .into_iter()
325 .map(|version| version.context)
326 .collect();
327 assert_eq!(versions(&delete(&before, all)), []);
328 // A version already gone refuses the edit.
329 let arena = Arena::default();
330 let mut section = Section::open(&arena, step(5)).unwrap();
331 let refused = section.apply(
332 "Other Person",
333 &Edit {
334 at: AT,
335 ops: vec![Op::Section(SectionOp::DeleteVersions {
336 page: page(),
337 versions: vec![newest],
338 })],
339 },
340 );
341 assert_eq!(
342 refused,
343 Err(onestore::op::OpError::TargetUnavailable(newest))
344 );
345
346 if let Some(output) = std::env::var_os("ONESTORE_VERSIONS_EXPORT") {
347 export(&output, "delete", &ours);
348 export(&output, "delete-all", &emptied);
349 }
350}
351
352/// Edits not yet sealed become a version of their own when a restore follows them in the
353/// same batch, and restores stack: every state the page passed through stays listed.
354#[test]
355fn unsaved_edits_and_stacked_restores_become_versions() {
356 let before = step(2);
357 let arena = Arena::default();
358 let mut section = Section::open(&arena, before.clone()).unwrap();
359 let first = section.versions().unwrap()[0].1[0].context;
360 let text = section
361 .page(page())
362 .unwrap()
363 .objects
364 .iter()
365 .find_map(|object| match object {
366 PageObject::Outline(outline) => outline.paragraphs.last()?.text().map(|text| text.id),
367 _ => None,
368 });
369 let edit = |ops| Edit { at: AT, ops };
370 section
371 .apply(
372 "Clover Snow",
373 &edit(vec![Op::Page {
374 space: page(),
375 op: PageOp::Text {
376 text: text.unwrap(),
377 range: 0..6,
378 with: "Unsaved".into(),
379 },
380 }]),
381 )
382 .unwrap();
383 let edited = section.page(page()).unwrap();
384 let restore = |version, guid| {
385 edit(vec![Op::Section(SectionOp::RestoreVersion {
386 page: page(),
387 version,
388 guid,
389 })])
390 };
391 section
392 .apply("Clover Snow", &restore(first, [1; 16]))
393 .unwrap();
394 let listed = section.versions().unwrap()[0].1.clone();
395 assert_eq!(listed.len(), 2);
396 assert_eq!(section.version(page(), listed[0].context).unwrap(), edited);
397 // The unsaved state is still being saved, so it cannot be restored yet; the stored one can.
398 assert!(
399 section
400 .apply("Clover Snow", &restore(listed[0].context, [2; 16]))
401 .is_err()
402 );
403 section
404 .apply("Clover Snow", &restore(first, [3; 16]))
405 .unwrap();
406 assert_eq!(section.versions().unwrap()[0].1.len(), 3);
407 section.seal().unwrap().unwrap();
408 let image = section.image();
409
410 let arena = Arena::default();
411 let mut reopened = Section::open(&arena, image.clone()).unwrap();
412 let store = Store::parse(&image).unwrap();
413 let index = RevisionIndex::parse(&store).unwrap();
414 index.validate_current().unwrap();
415 Document::parse(&index).unwrap();
416 let listed = reopened.versions().unwrap().remove(0).1;
417 assert_eq!(listed.len(), 3);
418 let pages: Vec<Page> = listed
419 .iter()
420 .map(|version| reopened.version(page(), version.context).unwrap())
421 .collect();
422 let version = reopened.version(page(), first).unwrap();
423 assert_eq!(pages[1], edited);
424 assert_eq!(pages[0].objects, version.objects);
425 assert_eq!(reopened.page(page()).unwrap().objects, version.objects);
426 // The restored page edits on as any other.
427 let text = texts(&reopened.page(page()).unwrap());
428 assert_eq!(text, ["First state. Typed by virtual."]);
429}
430
431/// An edit that fails after a restore leaves the section as it was before the edit.
432#[test]
433fn a_failed_edit_undoes_its_restore() {
434 let before = step(2);
435 let arena = Arena::default();
436 let mut section = Section::open(&arena, before.clone()).unwrap();
437 let first = section.versions().unwrap()[0].1[0].context;
438 let page_before = section.page(page()).unwrap();
439 let refused = section.apply(
440 "Clover Snow",
441 &Edit {
442 at: AT,
443 ops: vec![
444 Op::Section(SectionOp::RestoreVersion {
445 page: page(),
446 version: first,
447 guid: [1; 16],
448 }),
449 Op::Section(SectionOp::DeleteVersions {
450 page: page(),
451 versions: vec![id(HISTORY)],
452 }),
453 ],
454 },
455 );
456 assert!(refused.is_err());
457 assert_eq!(section.page(page()).unwrap(), page_before);
458 assert_eq!(section.versions().unwrap()[0].1.len(), 1);
459 assert!(section.seal().unwrap().is_none());
460 assert!(section.image() == before);
461}
462
463#[test]
464fn private_notebook_versions_read() {
465 let path = format!(
466 "{}/../../corpus/private/current/Art/Album.one",
467 env!("CARGO_MANIFEST_DIR")
468 );
469 let Ok(image) = std::fs::read(path) else {
470 return;
471 };
472 let arena = Arena::default();
473 let mut section = Section::open(&arena, image).unwrap();
474 let listed = section.versions().unwrap();
475 assert!(
476 listed
477 .iter()
478 .map(|(_, versions)| versions.len())
479 .sum::<usize>()
480 >= 10
481 );
482 let contexts: BTreeSet<ExGuid> = listed
483 .iter()
484 .flat_map(|(_, versions)| versions.iter().map(|version| version.context))
485 .collect();
486 assert_eq!(
487 contexts.len(),
488 listed.iter().map(|(_, v)| v.len()).sum::<usize>()
489 );
490 for (page, versions) in &listed {
491 for version in versions {
492 section.version(*page, version.context).unwrap();
493 }
494 }
495}
496
497fn export(output: &std::ffi::OsStr, name: &str, image: &[u8]) {
498 let directory = std::path::Path::new(output).join(name);
499 std::fs::create_dir_all(&directory).unwrap();
500 std::fs::write(directory.join("History.one"), image).unwrap();
501 std::fs::write(
502 directory.join("Open Notebook.onetoc2"),
503 read(&format!("{NATIVE}/step-01/notebook/Open Notebook.onetoc2")),
504 )
505 .unwrap();
506}
tools/TESTING.md+10-4
......@@ -2,10 +2,12 @@
22
33## Public fixtures
44
5From a checkout with Rust and Python 3 plus Pillow installed:
5From a checkout with Rust and [uv](https://docs.astral.sh/uv/), which provides
6Python 3.12 with Pillow and pdfplumber without installing anything system-wide:
67
78```sh
8python3 tools/check_public.py /absolute/path/to/new-results
9uv run --no-project --python 3.12 --with pillow --with pdfplumber \
10 python tools/check_public.py /absolute/path/to/new-results
911```
1012
1113The command runs formatting, all workspace features/targets, doctests, Clippy,
......@@ -22,6 +24,11 @@ targets are versioned in this repository. Neither `corpus/private`, ignored
2224`evidence`, existing `target` outputs, credentials nor running virtual machines
2325are required. Cargo's normal dependency cache may be reused.
2426
27For the Python tests alone, end the same `uv run` with
28`python -m unittest discover -s tools -p 'test_*.py'`. Without pdfplumber they
29skip the PDF oracles (`test_pdf_format`, part of `test_document_oracle`) and
30say so; `check_public.py` refuses to start without it.
31
2532Rust explicitly reports ignored lab tests and fixture generators. Those cases
2633are **not** part of a successful public run. The Python suite tests native/lab
2734harness 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
4047leaves `soak/*.log` ending in the command that replays it; crash inputs stay in
4148`fuzz/artifacts/<target>/`. On a Linux VM, install rustup's stable and nightly
4249toolchains, `cargo install cargo-fuzz`, and `build-essential pkg-config
43libfontconfig-dev`; copy the checkout together with the untracked `evidence/`,
44which tests read, and run the script under `tmux`.
50libfontconfig-dev`; copy the checkout and run the script under `tmux`.
4551
4652## Private and native verification
4753
tools/canvas/README.md+1-1
......@@ -48,7 +48,7 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a
4848
4949## Desktop shell
5050
51`--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.
51`--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.
5252
5353Launched 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`).
5454
tools/check_public.py+1-1
......@@ -22,7 +22,7 @@ def main():
2222 environment['PYTHONPATH'] = str(root / 'tools')
2323 environment['CARGO_TARGET_DIR'] = str(root / 'target')
2424 commands = [
25 ('python-dependencies', [sys.executable, '-c', 'import PIL']),
25 ('python-dependencies', [sys.executable, '-c', 'import PIL, pdfplumber']),
2626 ('format', ['cargo', 'fmt', '--all', '--check']),
2727 ('rust', ['cargo', 'test', '--locked', '--workspace', '--all-features', '--all-targets']),
2828 ('doctests', ['cargo', 'test', '--locked', '--workspace', '--all-features', '--doc']),
tools/document_model.py+2-1
......@@ -49,7 +49,8 @@ def ordered_pages(document):
4949def version_pages(document, sid, revision):
5050 manifest = revision['nodes'][revision['roots']['1']]['kind']
5151 context = manifest['history']
52 if context is None:
52 # A page OneNote copied from a version names a history it never wrote.
53 if context not in document['spaces'][sid]['contexts']:
5354 return
5455 _, history = view(document, sid, context)
5556 root = history['nodes'][history['roots']['1']]
tools/native_enter.py created+67
......@@ -0,0 +1,67 @@
1"""Press Enter at the edges of and inside equations and links in OneNote 2010 on a
2Rust-written page and capture what it stores. The oracle for the canvas editor's Enter.
3
4Usage: native_enter.py NOTEBOOK_DIR OUTPUT_DIR
5"""
6import base64
7import json
8import sys
9from pathlib import Path
10import xml.etree.ElementTree as ET
11
12sys.path.insert(0, str(Path(__file__).resolve().parent))
13import native_runner as runner
14
15windows = runner.windows
16
17
18def text(value):
19 return 'SendText("%s")' % value.replace('"', '""')
20
21
22def interaction(name, output):
23 lines = [
24 # Enter at an equation's start moves it to a paragraph of its own.
25 text('Before '), 'Send("!=")', 'Sleep(600)', text('y^2+1'), 'Send(" ")', 'Sleep(700)',
26 'Send("{Home}")', 'Send("{Right 7}")', 'Sleep(300)', 'Send("{Enter}")', 'Sleep(600)',
27 # Enter inside the equation's own row breaks its line.
28 'Send("{End}")', 'Send("{Left 2}")', 'Sleep(300)', 'Send("{Enter}")', 'Sleep(600)',
29 # Enter inside a fraction's denominator makes it an equation array.
30 'Send("{End}{Enter}")', 'Send("!=")', 'Sleep(600)', text('(a+b)/(c+d)'), 'Send(" ")',
31 'Sleep(700)', 'Send("{Left 3}")', 'Sleep(300)', 'Send("{Enter}")', 'Sleep(600)',
32 # Enter at a link's start moves the link down whole (inside one, it follows the link).
33 'Send("^{End}{Enter}")', text('Select me please'), 'Send("+^{Left}+^{Left}")', 'Send("^k")',
34 'Sleep(1500)', text('https://example.com/selected'), 'Send("{Enter}")', 'Sleep(800)',
35 'Send("{End}")', 'Send("{Left 9}")', 'Sleep(300)', 'Send("{Enter}")', 'Sleep(600)',
36 ]
37 page = ET.parse(sorted((output / 'read').glob('page-*.xml'))[0]).getroot().attrib['ID']
38 script = '''ComObject("OneNote.Application").NavigateTo("PAGE_ID", "", false)
39WinWait("ahk_exe ONENOTE.EXE", , 30)
40WinActivate("ahk_exe ONENOTE.EXE")
41WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
42WinMaximize("ahk_exe ONENOTE.EXE")
43Sleep(3000)
44CoordMode("Mouse", "Screen")
45Click(200, 140)
46Sleep(500)
47Send("{End}")
48Sleep(300)
49Send("{Enter}")
50Sleep(500)
51%s
52Sleep(2000)
53''' % '\n'.join(lines)
54 script = script.replace('PAGE_ID', page)
55 (output / 'enter.ahk').write_text(script)
56 result = windows.do_exec(script, target=name, timeout_ms=240000, shot_delay_ms=1500)
57 screenshot = result.pop('png_b64', None)
58 if screenshot:
59 (output / 'enter.png').write_bytes(base64.b64decode(screenshot))
60 (output / 'enter.json').write_text(json.dumps(result, indent=2))
61 if result.get('error') or result.get('exit') != 0:
62 raise RuntimeError(str(result))
63
64
65if __name__ == '__main__':
66 notebook, output = Path(sys.argv[1]), Path(sys.argv[2])
67 runner.capture(notebook, output, expected_pages=1, collect_notebook=True, interaction=interaction)
tools/native_format.py+2-1
......@@ -100,7 +100,8 @@ def compare_formats(space, nodes, native):
100100 # Hidden runs and equation runs (exported as MathML) have no visible native text.
101101 if not style.get('hidden') and not style.get('math'):
102102 actual.extend((char, style) for char in text)
103 if actual and actual[-1][0] == '\r':
103 # A trailing CR ends the stored text; one between equations is a line break.
104 if actual and actual[-1][0] == '\r' and kind['text'].endswith('\r'):
104105 actual.pop()
105106 actual = [(projected, style) for char, style in actual for projected in project_text(char)]
106107 if kind['text'] == '\u00a0' and not expected:
tools/native_links.py created+94
......@@ -0,0 +1,94 @@
1"""Type hyperlinks into OneNote 2010 on a Rust-written page and capture what it stores: URLs
2it links as they are typed, links made with the Link dialog (Ctrl+K), typing after a link and
3Remove Link. The oracle for the canvas editor's link editing.
4
5Usage: native_links.py NOTEBOOK_DIR OUTPUT_DIR
6"""
7import base64
8import json
9import sys
10from pathlib import Path
11import xml.etree.ElementTree as ET
12
13sys.path.insert(0, str(Path(__file__).resolve().parent))
14import native_runner as runner
15
16windows = runner.windows
17
18# Each typed URL sits between a word and a letter, one per paragraph; a space or Enter ends it.
19TYPED = [
20 'www.example.com',
21 'http://example.org/a?b=1',
22 'https://a.example/p.',
23 '(http://b.example/q)',
24 'ftp://c.example/f',
25 'mailto:me@example.com',
26 'me@example.com',
27 'example.com',
28 'onenote:#Page&section-id={x}',
29 'file://c:/windows',
30 '\\\\server\\share\\f',
31 'news:comp.lang',
32 'www.d.example,',
33 'HTTP://E.EXAMPLE/UP',
34 'https://f.example/a_b-c~d%20e#frag',
35]
36
37
38def text(value):
39 return 'SendText("%s")' % value.replace('"', '""')
40
41
42def interaction(name, output):
43 lines = []
44 for url in TYPED:
45 lines += [text('t ' + url), 'Send(" ")', text('z'), 'Send("{Enter}")', 'Sleep(300)']
46 # A URL ended by Enter, then a letter typed straight after the link it made.
47 lines += [text('see http://g.example/end'), 'Send("{Enter}")', 'Send("{Up}{End}")', text('Z'),
48 'Send("{Down}{End}")', 'Sleep(300)']
49 # The Link dialog on the word at the caret: its text is the word, the address as typed.
50 lines += [text('plain words here'), 'Send("^{Left}")', 'Send("^k")', 'Sleep(1500)',
51 text('example.net/x'), 'Send("{Enter}")', 'Sleep(800)', 'Send("{End}")', text('Q'),
52 'Send("{Enter}")', 'Sleep(300)']
53 # The Link dialog on a selection.
54 lines += [text('select me please'), 'Send("+^{Left}+^{Left}")', 'Send("^k")', 'Sleep(1500)',
55 text('https://example.com/selected'), 'Send("{Enter}")', 'Sleep(800)',
56 'Send("{End}{Enter}")', 'Sleep(300)']
57 # The Link dialog with no text: the address becomes the text; typing after it is plain.
58 lines += ['Send("^k")', 'Sleep(1500)', 'Send("!e")', 'Sleep(300)',
59 text('https://example.com/only'), 'Send("{Enter}")', 'Sleep(800)', text(' tail'),
60 'Send("{Enter}")', 'Sleep(300)']
61 # Remove Link from the context menu (Shift+F10, R) with the caret inside a typed link.
62 lines += [text('gone ftp://h.example/f'), 'Send(" ")', text('z'), 'Send("{Home}^{Right}^{Right}")',
63 'Send("+{F10}")', 'Sleep(900)', 'Send("r")', 'Sleep(800)']
64 page = ET.parse(sorted((output / 'read').glob('page-*.xml'))[0]).getroot().attrib['ID']
65 script = '''ComObject("OneNote.Application").NavigateTo("PAGE_ID", "", false)
66WinWait("ahk_exe ONENOTE.EXE", , 30)
67WinActivate("ahk_exe ONENOTE.EXE")
68WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
69WinMaximize("ahk_exe ONENOTE.EXE")
70Sleep(3000)
71CoordMode("Mouse", "Screen")
72Click(200, 140)
73Sleep(500)
74Send("{End}")
75Sleep(300)
76Send("{Enter}")
77Sleep(500)
78%s
79Sleep(2000)
80''' % '\n'.join(lines)
81 script = script.replace('PAGE_ID', page)
82 (output / 'links.ahk').write_text(script)
83 result = windows.do_exec(script, target=name, timeout_ms=240000, shot_delay_ms=1500)
84 screenshot = result.pop('png_b64', None)
85 if screenshot:
86 (output / 'links.png').write_bytes(base64.b64decode(screenshot))
87 (output / 'links.json').write_text(json.dumps(result, indent=2))
88 if result.get('error') or result.get('exit') != 0:
89 raise RuntimeError(str(result))
90
91
92if __name__ == '__main__':
93 notebook, output = Path(sys.argv[1]), Path(sys.argv[2])
94 runner.capture(notebook, output, expected_pages=1, collect_notebook=True, interaction=interaction)
tools/native_versions.py created+219
......@@ -0,0 +1,219 @@
1#!/usr/bin/env python3
2"""OneNote 2010 page versions (Share, Page Versions): how they arise, what they store and
3how OneNote shows, restores, copies and deletes them.
4
5`observe OUTPUT` runs one disposable OneNote client on a new notebook: `virtual` writes a
6page, `Other Person` edits it (OneNote keeps the page as it was as a version), then OneNote's
7Copy Page To, Restore Version, Delete Version and Delete All Versions in Section run on it;
8two more authors' edits, a Delete Version of the only version and the author views (Hide
9Authors, Recent Edits, Find by Author) follow. The section after each step is
10`step-NN/notebook`, the screens `shots/`.
11
12`cold NOTEBOOK OUTPUT [--delete]` cold-opens a notebook whose page has versions in a fresh
13clone, shows them and opens the newest, screenshots each view, and with `--delete` deletes
14that version through its bar's menu and keeps the notebook OneNote leaves.
15"""
16import argparse
17import base64
18import json
19from pathlib import Path
20import sys
21import time
22import xml.etree.ElementTree as ET
23
24sys.path.insert(0, str(Path(__file__).resolve().parent))
25sys.path.insert(0, str(Path(__file__).resolve().parent / 'w7'))
26import native_runner as runner
27from native_runner import windows
28
29ROOT = Path(r'C:\one-tests\versions')
30SECTION = ROOT / 'Versions' / 'History.one'
31
32# Coordinates on OneNote 2010 maximized at 800x600 with its ribbon collapsed, from the
33# capture in corpus/page-versions/native: the Share tab, its Page Versions button and the
34# arrow under it, the information bar, the first body line, the first version listed under
35# the page and the page itself in the list.
36SHARE = (202, 36)
37PAGE_VERSIONS = (443, 80)
38PAGE_VERSIONS_MENU = (462, 108)
39DELETE_ALL_IN_SECTION = (525, 155)
40BAR = (320, 93)
41BODY = (160, 208)
42FIRST_VERSION = (710, 143)
43THE_PAGE = (700, 121)
44HIDE_AUTHORS = (383, 88)
45RECENT_EDITS = (296, 108)
46FIND_BY_AUTHOR = (338, 95)
47# Items of the version bar's menu.
48RESTORE, DELETE, COPY = (396, 106), (393, 128), (395, 150)
49
50# Reports any dialog OneNote raised, so a confirmation shows in the step's record.
51DIALOG = ('if WinExist("ahk_class #32770 ahk_exe ONENOTE.EXE") {\n'
52 ' FileAppend("dialog: " WinGetTitle() " | " WinGetText() "`n", "*", "UTF-8")\n'
53 '}\n')
54ACTIVATE = 'WinActivate("ahk_exe ONENOTE.EXE")\nCoordMode("Mouse", "Screen")\n'
55
56
57def click(point, wait=1000):
58 return f'Click({point[0]}, {point[1]})\nSleep({wait})\n'
59
60
61class Clone:
62 """A clone's desktop: `shot` runs an AutoHotkey step and keeps its screenshot."""
63
64 def __init__(self, name, output):
65 self.name, self.output = name, output
66 (output / 'shots').mkdir(parents=True, exist_ok=True)
67
68 def shot(self, label, script, delay=1500):
69 (self.output / 'shots' / f'{label}.ahk').write_text(script)
70 result = windows.do_exec(script, target=self.name, timeout_ms=180000, shot_delay_ms=delay)
71 screenshot = result.pop('png_b64', None)
72 if screenshot:
73 (self.output / 'shots' / f'{label}.png').write_bytes(base64.b64decode(screenshot))
74 (self.output / 'shots' / f'{label}.json').write_text(json.dumps(result, indent=2))
75 if result.get('error') or result.get('exit') != 0:
76 raise RuntimeError(f'{label}: {result}')
77 return result
78
79 def command(self, text, timeout_ms=120000):
80 runner.command(self.name, text, self.output, timeout_ms)
81
82 def keep(self, step, files=('History.one',)):
83 """Keeps the section once OneNote has stopped writing it: its size and time unchanged
84 for five seconds."""
85 self.command('powershell -NoProfile -Command "$f = \'%s\'; $last = \'\'; $still = 0; '
86 'for ($i = 0; $i -lt 120 -and $still -lt 10; $i++) { Start-Sleep -Milliseconds 500; '
87 '$item = Get-Item $f; $now = \'\' + $item.Length + $item.LastWriteTimeUtc.Ticks; '
88 'if ($now -eq $last) { $still++ } else { $still = 0; $last = $now } }"' % SECTION)
89 directory = self.output / f'step-{step:02}' / 'notebook'
90 directory.mkdir(parents=True)
91 for name in files:
92 result = windows.do_get(str(SECTION.parent / name), directory / name, self.name)
93 if result.get('error'):
94 raise RuntimeError(result['error'])
95
96
97def open_page(author):
98 """Closes OneNote, names the Office user `author` and opens the page."""
99 name, initials = author
100 return ('if WinExist("ahk_exe ONENOTE.EXE") {\n WinClose("ahk_exe ONENOTE.EXE")\n Sleep(4000)\n}\n'
101 'RunWait("cmd /c taskkill /IM ONENOTE.EXE /F", , "Hide")\nSleep(1000)\n'
102 f'RegWrite("{name}", "REG_SZ", "HKCU\\Software\\Microsoft\\Office\\Common\\UserInfo", "UserName")\n'
103 f'RegWrite("{initials}", "REG_SZ", "HKCU\\Software\\Microsoft\\Office\\Common\\UserInfo", "UserInitials")\n'
104 f'page := Trim(FileRead("{ROOT}\\page.txt", "UTF-8"), "`r`n ")\n'
105 'app := ComObject("OneNote.Application")\napp.NavigateTo(page)\n'
106 'WinWait("ahk_exe ONENOTE.EXE", , 20)\nWinMaximize("ahk_exe ONENOTE.EXE")\n'
107 + ACTIVATE + 'Sleep(3000)\n')
108
109
110def type_line(text, new_line=True):
111 return (ACTIVATE + click(BODY, 300) + ('Send("^{End}{Enter}")\n' if new_line else 'Send("{End}")\n')
112 + f'SendText("{text}")\nSleep(3000)\n')
113
114
115SETUP = r'''$ErrorActionPreference = 'Stop'
116$app = New-Object -ComObject OneNote.Application
117$notebook = ''
118$app.OpenHierarchy('%(root)s\Versions', '', [ref]$notebook, 1)
119$section = ''
120$app.OpenHierarchy('History.one', $notebook, [ref]$section, 3)
121$page = ''
122$app.CreateNewPage($section, [ref]$page, 0)
123$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
124$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>')
125$page | Out-File '%(root)s\page.txt' -Encoding UTF8
126'''
127
128VIRTUAL, OTHER = ('virtual', 'v'), ('Other Person', 'OP')
129
130
131def observe(output):
132 output.mkdir(parents=True, exist_ok=False)
133 with runner.clone(output) as name:
134 clone = Clone(name, output)
135 setup = output / 'setup.ps1'
136 setup.write_text(SETUP % {'root': ROOT})
137 clone.command(f'mkdir {ROOT}')
138 result = windows.do_put(setup, str(ROOT / 'setup.ps1'), name)
139 if result.get('error'):
140 raise RuntimeError(result['error'])
141 clone.command(f'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File {ROOT}\\setup.ps1')
142 # One author: no version.
143 clone.shot('01-typed', open_page(VIRTUAL) + type_line(' Typed by virtual.', new_line=False))
144 clone.keep(1, ('History.one', 'Open Notebook.onetoc2'))
145 # A second author's edit keeps the page as it was as a version.
146 clone.shot('02-second-author', open_page(OTHER) + type_line('Second author line.'))
147 clone.keep(2)
148 clone.shot('02-menu', ACTIVATE + click(SHARE, 600) + click(PAGE_VERSIONS_MENU, 800))
149 clone.shot('02-shown', ACTIVATE + click((486, 129), 1500))
150 clone.shot('02-version', ACTIVATE + click(FIRST_VERSION, 1500))
151 clone.shot('02-version-menu', ACTIVATE + click(BAR, 1000))
152 # Copy Page To copies the version as a page at the section's end.
153 clone.shot('03-copy-dialog', ACTIVATE + click(COPY, 1500))
154 clone.shot('03-copied', 'WinActivate("Move or Copy Pages")\n' + click((289, 200), 500)
155 + click((474, 494), 3000), 2000)
156 clone.keep(3)
157 # Restore Version: the page as it stood becomes the newest version.
158 clone.shot('04-shown', ACTIVATE + click(THE_PAGE, 1000) + click(SHARE, 600) + click(PAGE_VERSIONS, 1200))
159 clone.shot('04-restored', ACTIVATE + click(FIRST_VERSION, 1200) + click(BAR, 800)
160 + click(RESTORE, 2500) + DIALOG, 2000)
161 clone.keep(4)
162 # Delete Version, without a confirmation.
163 clone.shot('05-newest', ACTIVATE + click(FIRST_VERSION, 1500))
164 clone.shot('05-deleted', ACTIVATE + click(BAR, 800) + click(DELETE, 2500) + DIALOG, 2000)
165 clone.keep(5)
166 # Delete All Versions in Section asks first.
167 clone.shot('06-confirm', ACTIVATE + click(SHARE, 600) + click(PAGE_VERSIONS_MENU, 800)
168 + click(DELETE_ALL_IN_SECTION, 1500) + DIALOG)
169 clone.shot('06-deleted-all', 'WinActivate("Microsoft OneNote ahk_class #32770")\nSend("!y")\nSleep(2500)\n', 2500)
170 clone.keep(6)
171 # Deleting the only version clears the page's HasVersionPages.
172 clone.shot('07-third-author-edit', open_page(VIRTUAL) + type_line('Third line by virtual.')
173 + click(SHARE, 600) + click(PAGE_VERSIONS, 1200))
174 clone.keep(7)
175 clone.shot('08-deleted-only', ACTIVATE + click(FIRST_VERSION, 1500) + click(BAR, 800)
176 + click(DELETE, 2500) + DIALOG, 2000)
177 clone.keep(8)
178 # Authors: Hide Authors off, Recent Edits, Find by Author.
179 clone.shot('09-authors', open_page(OTHER) + type_line('Fourth line by Other Person.')
180 + click(SHARE, 600) + click(HIDE_AUTHORS, 1500))
181 clone.shot('09-recent-edits', ACTIVATE + click(SHARE, 600) + click(RECENT_EDITS, 1200), 1000)
182 clone.shot('09-find-by-author', ACTIVATE + 'Send("{Esc}")\nSleep(400)\n' + click(SHARE, 900)
183 + click(FIND_BY_AUTHOR, 3000), 2500)
184 clone.keep(9)
185
186
187def cold(notebook, output, delete):
188 """Opens the page with versions, shows them, opens the newest and its menu; `delete`
189 then deletes it."""
190 def interaction(name, output):
191 clone = Clone(name, output)
192 page = ET.parse(sorted((output / 'read').glob('page-*.xml'))[0]).getroot().attrib['ID']
193 clone.shot('cold-main', runner.navigation_script(page) + 'Sleep(2000)\n')
194 clone.shot('cold-shown', ACTIVATE + click(SHARE, 600) + click(PAGE_VERSIONS, 1500))
195 clone.shot('cold-version', ACTIVATE + click(FIRST_VERSION, 1500))
196 clone.shot('cold-menu', ACTIVATE + click(BAR, 1000))
197 if delete:
198 clone.shot('cold-delete', ACTIVATE + click(DELETE, 2500) + DIALOG, 2000)
199 # OneNote writes the deletion in the background; give it time before the re-read.
200 time.sleep(20)
201 else:
202 clone.shot('cold-closed', ACTIVATE + 'Send("{Esc}")\nSleep(800)\n')
203 runner.capture(notebook, output, collect_notebook=True, interaction=interaction)
204
205
206if __name__ == '__main__':
207 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
208 commands = parser.add_subparsers(dest='command', required=True)
209 observed = commands.add_parser('observe')
210 observed.add_argument('output', type=Path)
211 opened = commands.add_parser('cold')
212 opened.add_argument('notebook', type=Path)
213 opened.add_argument('output', type=Path)
214 opened.add_argument('--delete', action='store_true')
215 args = parser.parse_args()
216 if args.command == 'observe':
217 observe(args.output)
218 else:
219 cold(args.notebook, args.output, args.delete)
tools/test_document_oracle.py+8
......@@ -1,3 +1,4 @@
1from importlib.util import find_spec
12from pathlib import Path
23import re
34import runpy
......@@ -9,9 +10,12 @@ import xml.etree.ElementTree as ET
910
1011ROOT = Path(__file__).resolve().parent.parent
1112compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13requires_pdfplumber = unittest.skipUnless(
14 find_spec('pdfplumber'), 'pdfplumber is not installed; tools/TESTING.md sets up the Python lane')
1215
1316
1417class DocumentOracleTest(unittest.TestCase):
18 @requires_pdfplumber
1519 def test_locked_table_width_requires_the_native_value(self):
1620 fixture = ROOT / 'corpus/m6/native-structure-01'
1721 with TemporaryDirectory() as temporary:
......@@ -27,6 +31,7 @@ class DocumentOracleTest(unittest.TestCase):
2731 with self.assertRaisesRegex(AssertionError, 'Locked table column width differs'):
2832 compare(fixture / 'notebook', native)
2933
34 @requires_pdfplumber
3035 def test_native_2010_does_not_render_the_documented_cell_shading_control(self):
3136 import pdfplumber
3237 fixture = ROOT / 'corpus/m6/cell-shading-control-01/read/page-001'
......@@ -40,6 +45,7 @@ class DocumentOracleTest(unittest.TestCase):
4045 self.assertTrue(all(tuple(rect.get('non_stroking_color') or ()) != (1, 1, 0)
4146 for rect in table_page.rects))
4247
48 @requires_pdfplumber
4349 def test_rust_saved_expansion_is_honored_by_a_fresh_native_cache(self):
4450 import pdfplumber
4551 fixture = ROOT / 'corpus/m6/rust-collapse-control-01'
......@@ -53,6 +59,7 @@ class DocumentOracleTest(unittest.TestCase):
5359 self.assertIn('Hidden child remains stored', text)
5460 self.assertIn('Grandchild', text)
5561
62 @requires_pdfplumber
5663 def test_native_expansion_changes_the_view_without_changing_notebook_bytes(self):
5764 import pdfplumber
5865 original = ROOT / 'corpus/m6/native-features-01/notebook/Features.one'
......@@ -96,6 +103,7 @@ class DocumentOracleTest(unittest.TestCase):
96103 captures.append([ET.tostring(n) for n in equations])
97104 self.assertEqual(*captures)
98105
106 @requires_pdfplumber
99107 def test_rtl_xml_order_matches_native_visual_column_order(self):
100108 import pdfplumber
101109 fixture = ROOT / 'corpus/m6/native-page-direction-03'
tools/test_link_edit.py+32
......@@ -1,3 +1,4 @@
1import html
12import json
23import re
34from pathlib import Path
......@@ -49,6 +50,37 @@ class LinkEditTest(unittest.TestCase):
4950 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)
5051 self.assertRegex(text, r'^Linking page <a href="onenote:#Link%20target&amp;section-id=\{[0-9A-F-]{36}\}&amp;page-id=\{[0-9A-F-]{36}\}&amp;end&amp;base-path=C:\\one-tests\\runs\\capture\\notebook\\links.one">Link target</a>$')
5152
53 def test_onenote_links_typed_urls_and_dialog_addresses(self):
54 """OneNote 2010's read of `native-typed`: a typed `www.` address opens over http,
55 and a Link dialog address typed without a scheme does too."""
56 text = (FIXTURE / 'native-typed/read/page-000.xml').read_text(encoding='utf-8-sig')
57 hrefs = [html.unescape(href) for href in re.findall(r'href="([^"]*)"', text)]
58 self.assertEqual(hrefs[1], 'http://www.example.com')
59 self.assertEqual(hrefs[11], 'http://www.d.example')
60 self.assertEqual(hrefs[15], 'http://example.net/x')
61 self.assertNotIn('me@example.com', hrefs)
62
63 def test_editor_links_and_equations_render_natively(self):
64 """What the canvas editor saved for typed URLs, the Link dialog, Remove Link and
65 equations typed after Alt+= reads back in a cold OneNote 2010 with the links and
66 MathML it meant (`crates/canvas/tests/links_equations.rs`)."""
67 row = FIXTURE / 'editor'
68 with TemporaryDirectory() as temporary:
69 read = Path(temporary) / 'read'
70 shutil.copytree(row / 'cold/read', read)
71 compare(row / 'candidate', read)
72 expected = json.loads((row / 'expected.json').read_text())
73 path = next(path for path in sorted((row / 'cold/read').glob('page-*.xml'))
74 if ET.parse(path).getroot().get('name').startswith('Linking page'))
75 page = ET.parse(path).getroot()
76 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)
77 hrefs = [html.unescape(href) for href in re.findall(r'href="([^"]*)"', text)]
78 self.assertEqual(hrefs, expected['hrefs'])
79 raw = path.read_text(encoding='utf-8-sig')
80 mathml = [re.sub(r'&#(\d+);', lambda m: chr(int(m.group(1))), body)
81 for body in re.findall(r'<mml:math[^>]*>(.*?)</mml:math>', raw, re.S)]
82 self.assertEqual(mathml, expected['mathml'])
83
5284
5385if __name__ == '__main__':
5486 unittest.main()
tools/test_math_edit.py+1
......@@ -24,6 +24,7 @@ class MathEditTest(unittest.TestCase):
2424 self.assertEqual(len(exported_mathml(FIXTURE / 'native-editor/read/page-000.xml')), 5)
2525 self.assertEqual(len(exported_mathml(FIXTURE / 'native-editor-2/read/page-000.xml')), 9)
2626 self.assertEqual(len(exported_mathml(FIXTURE / 'native-editor-3/read/page-000.xml')), 10)
27 self.assertEqual(len(exported_mathml(FIXTURE / 'native-editor-4/read/page-000.xml')), 26)
2728
2829 def test_rust_written_equations_export_the_expected_mathml(self):
2930 with TemporaryDirectory() as temporary:
tools/test_page_versions.py created+103
......@@ -0,0 +1,103 @@
1"""Page versions (corpus/page-versions): what OneNote 2010 stores for them and does to them,
2and that it reads, lists and deletes the ones Rust writes."""
3import hashlib
4import json
5from pathlib import Path
6import runpy
7import shutil
8import subprocess
9from tempfile import TemporaryDirectory
10import unittest
11
12from document_model import EXPORTER, ordered_pages, version_pages, walk
13
14ROOT = Path(__file__).resolve().parent.parent
15FIXTURE = ROOT / 'corpus/page-versions'
16compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
17HISTORY = '{7111497F-1B6B-4209-9491-C98B04CF4C5A},1'
18HAS_VERSION_PAGES = 0x88003462
19
20
21def model(notebook):
22 with TemporaryDirectory() as temporary:
23 output = Path(temporary) / 'model'
24 subprocess.run([EXPORTER, notebook / 'History.one', output], check=True, capture_output=True)
25 return json.loads((output / 'document.json').read_text())
26
27
28# The title's date and time fields, the same on every state of the page.
29DATE = {'Sunday, September 27, 2026', '5:42 PM'}
30
31
32def texts(space, root):
33 return [node['kind']['text'] for _, node in walk(space, root)
34 if node['kind']['type'] == 'RichText' and node['kind']['text'] and node['kind']['text'] not in DATE]
35
36
37def versions(document):
38 """Each listed page's text and versions, newest first: each version's author and text,
39 and whether its metadata says it has versions."""
40 listed = []
41 for sid, _, page, oid in ordered_pages(document):
42 own = []
43 history = document['spaces'][sid]['revisions'].get(document['spaces'][sid]['contexts'].get(HISTORY))
44 for _, _, revision, version, proxy in version_pages(document, sid, page):
45 author = history['nodes'][proxy['latest_author']]['kind']['name']
46 own.append((author, texts(revision, version)))
47 metadata = page['nodes'][page['roots']['2']]
48 flagged = any(field['id'] == HAS_VERSION_PAGES for fields in metadata['extra'] for field in fields)
49 listed.append((texts(page, oid), own, flagged))
50 return listed
51
52
53FIRST = 'First state. Typed by virtual.'
54SECOND = 'Second author line.'
55
56
57class PageVersionsTest(unittest.TestCase):
58 def test_onenote_keeps_a_version_when_another_author_edits(self):
59 native = FIXTURE / 'native'
60 self.assertEqual(versions(model(native / 'step-01/notebook')),
61 [(['Versioned', FIRST], [], False)])
62 page = ['Versioned', FIRST, SECOND]
63 self.assertEqual(versions(model(native / 'step-02/notebook')),
64 [(page, [('virtual', ['Versioned', FIRST])], True)])
65 # Copy Page To copies the version as a page of its own, without versions.
66 self.assertEqual(versions(model(native / 'step-03/notebook'))[1],
67 (['Versioned', FIRST], [], False))
68 # Restore Version: the page as it stood becomes the newest version.
69 self.assertEqual(versions(model(native / 'step-04/notebook'))[0],
70 (['Versioned', FIRST], [('Other Person', page), ('virtual', ['Versioned', FIRST])], True))
71 # Delete Version, then Delete All Versions in Section, which clears the flag.
72 self.assertEqual(versions(model(native / 'step-05/notebook'))[0][1], [('virtual', ['Versioned', FIRST])])
73 self.assertEqual(versions(model(native / 'step-06/notebook'))[0], (['Versioned', FIRST], [], False))
74 # Optimizing keeps versions.
75 self.assertEqual(len(versions(model(native / 'step-10/notebook'))[0][1]), 1)
76
77 def test_the_scripted_capture_repeats_it(self):
78 scripted = FIXTURE / 'native-scripted'
79 counts = [len(versions(model(scripted / f'step-{n:02}/notebook'))[0][1]) for n in range(1, 10)]
80 self.assertEqual(counts, [0, 1, 1, 2, 1, 0, 1, 0, 1])
81
82 def test_onenote_reads_a_rust_restore_and_deletes_its_version(self):
83 for name in ('restore', 'delete-all'):
84 source = json.loads((FIXTURE / f'cold-{name}/source.json').read_text())
85 written = hashlib.sha256((FIXTURE / f'candidate-{name}/History.one').read_bytes()).hexdigest()
86 self.assertIn(written, [item['sha256'] for item in source])
87 with TemporaryDirectory() as temporary:
88 read = Path(temporary) / 'read'
89 shutil.copytree(FIXTURE / f'cold-{name}/before-read', read)
90 compare(FIXTURE / f'candidate-{name}', read)
91 restored = versions(model(FIXTURE / 'candidate-restore'))[0]
92 self.assertEqual(restored[0], ['Versioned', FIRST])
93 self.assertEqual([author for author, _ in restored[1]], ['Other Person', 'virtual'])
94 self.assertEqual(versions(model(FIXTURE / 'candidate-delete-all'))[0], (['Versioned', FIRST], [], False))
95 # OneNote listed the Rust versions under the page, opened the newest under its bar and
96 # deleted it through the bar's menu, leaving the older one.
97 record = lambda name: json.loads((FIXTURE / f'cold-restore/shots/{name}.json').read_text())
98 self.assertEqual(record('cold-version')['win']['title'], 'Versioned - Microsoft OneNote')
99 after = versions(model(FIXTURE / 'cold-restore/notebook'))[0]
100 self.assertEqual([author for author, _ in after[1]], ['virtual'])
101
102if __name__ == '__main__':
103 unittest.main()
tools/test_pdf_format.py+5
......@@ -1,3 +1,4 @@
1from importlib.util import find_spec
12from pathlib import Path
23import runpy
34import unittest
......@@ -6,7 +7,11 @@ from unittest.mock import patch
67
78verify = runpy.run_path(str(Path(__file__).with_name('verify-document.py')))['verify_pdf_black']
89
10requires_pdfplumber = unittest.skipUnless(
11 find_spec('pdfplumber'), 'pdfplumber is not installed; tools/TESTING.md sets up the Python lane')
912
13
14@requires_pdfplumber
1015class PdfFormatOracleTest(unittest.TestCase):
1116 def test_unmapped_text_cannot_hide_missing_text_or_incorrect_highlighting(self):
1217 chars = [{'text': c, 'x0': i * 2, 'x1': i * 2 + 1, 'top': 0, 'bottom': 1}
tools/test_recycle_bin_repair.py created+45
......@@ -0,0 +1,45 @@
1from pathlib import Path
2import unittest
3import xml.etree.ElementTree as ET
4
5ROOT = Path(__file__).resolve().parent.parent
6FIXTURE = ROOT / 'corpus/recycle-bin-repair'
7
8
9def tree(node):
10 out = []
11 for child in node:
12 tag = child.tag.rsplit('}', 1)[-1]
13 if tag in ('Section', 'SectionGroup', 'Page'):
14 flags = tuple(sorted(key for key in ('isRecycleBin', 'isDeletedPages', 'isInRecycleBin')
15 if child.get(key) == 'true'))
16 out.append((tag, child.get('name'), flags, tree(child)))
17 return out
18
19
20class RecycleBinRepairTest(unittest.TestCase):
21 def test_onenote_repairs_nothing_in_the_bin_a_page_delete_repaired(self):
22 candidates = sorted(path.relative_to(FIXTURE / 'candidate')
23 for path in (FIXTURE / 'candidate').rglob('*') if path.is_file())
24 self.assertEqual(candidates, sorted(path.relative_to(FIXTURE / 'cold/notebook')
25 for path in (FIXTURE / 'cold/notebook').rglob('*')
26 if path.is_file()))
27 for relative in candidates:
28 self.assertEqual((FIXTURE / 'cold/notebook' / relative).read_bytes(),
29 (FIXTURE / 'candidate' / relative).read_bytes(), str(relative))
30
31 def test_the_bin_holds_the_deleted_pages(self):
32 hierarchy = ET.parse(FIXTURE / 'cold/read/hierarchy.xml').getroot()
33 self.assertEqual(tree(hierarchy), [
34 ('Section', 'New Section 1', (), [('Page', 'Kept', (), [])]),
35 ('SectionGroup', 'OneNote_RecycleBin', ('isRecycleBin',), [
36 ('Section', 'Deleted Pages', ('isDeletedPages', 'isInRecycleBin'), [
37 ('Page', 'Kept', ('isInRecycleBin',), []),
38 ('Page', 'Kept', ('isInRecycleBin',), []),
39 ]),
40 ]),
41 ])
42
43
44if __name__ == '__main__':
45 unittest.main()
tools/verify-document.py+34-2
......@@ -82,7 +82,10 @@ def visible_text(node, space):
8282 text = ''.join(data[run['start'] * 2:run['end'] * 2].decode('utf-16-le') for run in kind['runs']
8383 if not run['format'] or not (space['nodes'][run['format']]['format']['hidden']
8484 or space['nodes'][run['format']]['format']['math']))
85 return "" if text == "\u00a0" else project_text(text.removesuffix('\r'))
85 # A trailing CR ends the stored text; one inside it, between equations, is a line break.
86 if kind['text'].endswith('\r'):
87 text = text.removesuffix('\r')
88 return "" if text == "\u00a0" else project_text(text)
8689
8790
8891ISF_X = bytes.fromhex('8f6a8a59c052a04b93afaf357411a561')
......@@ -247,6 +250,33 @@ def compare_objects(space, roots, page, native_roots, assets, native_payloads, a
247250 return count
248251
249252
253def exported_links(runs):
254 """Characters and links as OneNote 2010 exports them: an address without a scheme opens
255 over http, and URL text it finds in a page's text is a link, a removed link's included
256 (`corpus/link-edit/native-typed`)."""
257 def href(link):
258 if link is None or ':' in link.split('/')[0] or link.startswith('\\\\'):
259 return link
260 return 'http://' + link
261 out = [(char, href(link)) for char, link in runs]
262 start = 0
263 while start < len(out):
264 end = start
265 while end < len(out) and not out[end][0].isspace():
266 end += 1
267 word = ''.join(char for char, _ in out[start:end])
268 # The scheme stays with the text it links; trailing punctuation does not.
269 for scheme in ('http://', 'https://', 'ftp://', 'mailto:', 'news:'):
270 at = word.lower().find(scheme)
271 if at >= 0 and all(link is None for _, link in out[start:end]):
272 url = word[at:].rstrip('.,;:!?\'")]}')
273 for index in range(start + at, start + at + len(url)):
274 out[index] = (out[index][0], url)
275 break
276 start = end + 1
277 return out
278
279
250280def compare(notebook, native, versions=None, password_file=None):
251281 notebook = notebook.resolve(strict=True)
252282 native = native.resolve(strict=True)
......@@ -396,12 +426,14 @@ def compare(notebook, native, versions=None, password_file=None):
396426 # Native XML exports equation runs as MathML, not text (tools/test_math_edit.py compares that).
397427 observed_runs = [(char, run['link']) for run in resolved_text[sid][rid][text_id]
398428 if not run['format']['hidden'] and not run['format']['math'] for char in run['text']]
399 if observed_runs and observed_runs[-1][0] == '\r':
429 stored = ''.join(run['text'] for run in resolved_text[sid][rid][text_id])
430 if observed_runs and observed_runs[-1][0] == '\r' and stored.endswith('\r'):
400431 observed_runs.pop()
401432 observed_runs = [(projected, link) for char, link in observed_runs for projected in project_text(char)]
402433 expected_links = [(char, style.get('link')) for char, style in expected_runs]
403434 if observed_runs == [('\u00a0', None)] and not expected_links:
404435 observed_runs = []
436 observed_runs = exported_links(observed_runs)
405437 assert observed_runs == expected_links, 'Resolved text or associated hyperlink differs'
406438 count, differences = compare_formats(space, text_nodes, native_runs)
407439 formatting += count