| 1 | #!/usr/bin/env python3 |
| 2 | """OneNote 2010 page versions (Share, Page Versions): how they arise, what they store and |
| 3 | how OneNote shows, restores, copies and deletes them. |
| 4 | |
| 5 | `observe OUTPUT` runs one disposable OneNote client on a new notebook: `virtual` writes a |
| 6 | page, `Other Person` edits it (OneNote keeps the page as it was as a version), then OneNote's |
| 7 | Copy Page To, Restore Version, Delete Version and Delete All Versions in Section run on it; |
| 8 | two more authors' edits, a Delete Version of the only version and the author views (Hide |
| 9 | Authors, 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 |
| 13 | clone, shows them and opens the newest, screenshots each view, and with `--delete` deletes |
| 14 | that version through its bar's menu and keeps the notebook OneNote leaves. |
| 15 | """ |
| 16 | import argparse |
| 17 | import base64 |
| 18 | import json |
| 19 | from pathlib import Path |
| 20 | import sys |
| 21 | import time |
| 22 | import xml.etree.ElementTree as ET |
| 23 | |
| 24 | sys.path.insert(0, str(Path(__file__).resolve().parent)) |
| 25 | sys.path.insert(0, str(Path(__file__).resolve().parent / 'w7')) |
| 26 | import native_runner as runner |
| 27 | from native_runner import windows |
| 28 | |
| 29 | ROOT = Path(r'C:\one-tests\versions') |
| 30 | SECTION = 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. |
| 36 | SHARE = (202, 36) |
| 37 | PAGE_VERSIONS = (443, 80) |
| 38 | PAGE_VERSIONS_MENU = (462, 108) |
| 39 | DELETE_ALL_IN_SECTION = (525, 155) |
| 40 | BAR = (320, 93) |
| 41 | BODY = (160, 208) |
| 42 | FIRST_VERSION = (710, 143) |
| 43 | THE_PAGE = (700, 121) |
| 44 | HIDE_AUTHORS = (383, 88) |
| 45 | RECENT_EDITS = (296, 108) |
| 46 | FIND_BY_AUTHOR = (338, 95) |
| 47 | # Items of the version bar's menu. |
| 48 | RESTORE, DELETE, COPY = (396, 106), (393, 128), (395, 150) |
| 49 | |
| 50 | # Reports any dialog OneNote raised, so a confirmation shows in the step's record. |
| 51 | DIALOG = ('if WinExist("ahk_class #32770 ahk_exe ONENOTE.EXE") {\n' |
| 52 | ' FileAppend("dialog: " WinGetTitle() " | " WinGetText() "`n", "*", "UTF-8")\n' |
| 53 | '}\n') |
| 54 | ACTIVATE = 'WinActivate("ahk_exe ONENOTE.EXE")\nCoordMode("Mouse", "Screen")\n' |
| 55 | |
| 56 | |
| 57 | def click(point, wait=1000): |
| 58 | return f'Click({point[0]}, {point[1]})\nSleep({wait})\n' |
| 59 | |
| 60 | |
| 61 | class 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 | |
| 97 | def 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 | |
| 110 | def 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 | |
| 115 | SETUP = 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 | |
| 128 | VIRTUAL, OTHER = ('virtual', 'v'), ('Other Person', 'OP') |
| 129 | |
| 130 | |
| 131 | def 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 | |
| 187 | def 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 | |
| 206 | if __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) |