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)