-
Notifications
You must be signed in to change notification settings - Fork 0
/
edit-macro.py
executable file
·297 lines (267 loc) · 13.5 KB
/
edit-macro.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Edit a macro contained in the kbd_macro_new table of an ObinsKit SQLITE db.
This program makes a copy of the ObinsKit SQLITE database at the path
specified by --db-path, and operates on this copy until changes are
validated. It `SELECT`s the `macro_value` column of the row in the
`kbd_macro_new` table that has the name specified by --macro-name.
This `macro_value`, a list of ints, is converted into a list of
tuples of helper objects with useful (more human-readable) string
representations, then dumped into a temporary file for manual editing.
A header containing instructions is prepended to this file to guide the
user.
When the $EDITOR process exits, its contents (human-readable
representation of the "macro events) are re-serialized back into the
"list of ints" that can be compared against the original, and converted
into the raw list expected by `macro_value`.
If the new `macro_value` is valid, it is written back out to the table
in the db copy using an SQLITE `UPDATE` command.
If `--dry-run` is False, the db copy will be written back over the original
`--db-path`.
If the results are to be used in the ObinsKit app, the user must manually
replace the SQLITE db used by their app with the one generated by this
script.
Requirements:
1. Must be using a recent version of ObinsKit that uses the new
`kbd_macro_new` table/schema.
"""
import argparse
import difflib
import enum
import http.server
import logging
import os
import pathlib
import pprint
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import textwrap
import time
import typing
import urllib
import webbrowser
from keycodes import keycodes_by_value, keycodes_by_name
class ObinsKitMacroItemKey(enum.Enum):
"""
Enumerated values for first value in each ObinsKit Macro 3-tuple.
TODO: confirm this reverse-engineered information
Each ObinsKit macro consists of sequences of 3 8-bit ints.
The first value is an event key, one of (1=keyUp, 2=keyDown, 3=wait).
The 2nd and 3rd values denote the keycode or wait time:
If 1st == (KeyUp/Down):
2nd = keycode
3rd ignored
If 1st == Wait:
Wait time = 2nd + 3rd * 256
"""
KEY_UP = 1
KEY_DOWN = 2
WAIT = 3
def __str__(self) -> str:
return f"{self.name:8}"
def to_int(self) -> int:
return int(self.value)
class ObinsKitMacroItemTuple(typing.NamedTuple):
key: ObinsKitMacroItemKey
value_2: int
value_3: int
def __repr__(self) -> str:
"""Detailed string repr of an ObinsKitMacroItemTuple used for debugging."""
s = f'ObinsKitMacroItemTuple: key={ObinsKitMacroItemKey(self.key):8}'
if ObinsKitMacroItemKey(self.key) in [ObinsKitMacroItemKey.KEY_UP, ObinsKitMacroItemKey.KEY_DOWN]:
s += f' value_2={self.value_2:3} (keycode={keycodes_by_value[self.value_2]["name"]})'
else:
s += f' value_2={self.value_2:3} value_3={self.value_3:3} (wait={self.value_2 + self.value_3 * 256})'
return s
def __str__(self) -> str:
"""Simple string repr of an ObinsKitMacroItemTuple used in human-editable file."""
s = f'{ObinsKitMacroItemKey(self.key):8}'
if ObinsKitMacroItemKey(self.key) in [ObinsKitMacroItemKey.KEY_UP, ObinsKitMacroItemKey.KEY_DOWN]:
s += f' {keycodes_by_value[self.value_2]["name"]}'
else:
s += f' {self.value_2 + self.value_3 * 256}'
return s
def from_str(strepr):
"""Convert a string repr of an ObinsKitMacroItemTuple to an ObinsKitMacroItemTuple."""
logger.debug(f"from_str: '{strepr.strip()}'")
key, value = strepr.strip().split()
if key.upper() in ["KEY_UP", "KEY_DOWN"] and value not in keycodes_by_name.keys():
raise KeyError(f"Macro Event Line \"{strepr}\" contains value \"{value}\""
f" which is not in keycodes maps! Must be one of: {', '.join(list(keycodes_by_name.keys()))}."
f" See keycodes.py for details.")
if key.upper() == "KEY_UP":
return ObinsKitMacroItemTuple(ObinsKitMacroItemKey.KEY_UP, keycodes_by_name[value]['value'], 0)
elif key.upper() == "KEY_DOWN":
return ObinsKitMacroItemTuple(ObinsKitMacroItemKey.KEY_DOWN, keycodes_by_name[value]['value'], 0)
elif key.upper() == "WAIT":
# Nothing to validate for waits, time can be any value
return ObinsKitMacroItemTuple(ObinsKitMacroItemKey.WAIT, int(value), 0)
def to_int_macro_value_list(self):
"""Convert this object into the 3 int list used by macro_value in SQLITE db."""
return [self.key.to_int(), self.value_2, self.value_3]
def main(db_path, macro_name, dry_run=False):
"""
"""
tmp_db_dir = tempfile.mkdtemp(prefix=str(db_path).replace(os.path.sep, '_') + '-')
tmp_db_path = os.path.join(tmp_db_dir, os.path.basename(db_path))
shutil.copyfile(db_path, tmp_db_path)
logger.debug(f"Copied SQLITE db at path '{db_path}' to temp path '{tmp_db_path}'.")
logger.debug(f"Connecting to SQLITE db at path '{tmp_db_path}'...")
connection = sqlite3.connect(tmp_db_path)
logger.debug(f"Getting a cursor from SQLITE db connection...")
cursor = connection.cursor()
# could LIMIT cmd to only SELECT 1, but let's assert instead
cmd = f"SELECT macro_value FROM kbd_macro_new WHERE name='{macro_name}'"
logger.debug(f"SELECTing macro_value with command {cmd}")
cursor.execute(cmd)
selections = cursor.fetchall()
assert len(selections) == 1, (
f"{len(selections)} rows were SELECTed by cmd '{cmd}'."
f" There must be exactly one selected row."
f" Check your SELECT command.")
logger.debug(f'There are {len(selections)} items returned by cursor.fetch_all:\n{selections}')
macro_value = selections[0][0].strip('[]').split(',')
int_macro_value = [int(x) for x in macro_value]
int_3_tuples = [tuple(int_macro_value[i:i+3]) for i in range(0, len(int_macro_value), 3)]
logger.debug(f'There are {len(int_3_tuples)} 3-tuples in our modified macro_value list:\n{int_3_tuples}')
fd, temppath = tempfile.mkstemp(text=True)
with os.fdopen(fd, 'w') as f:
logger.debug(f"Writing instructions to header in temp file {temppath}...")
instructions = \
f'''# INSTRUCTIONS FOR EDITING THIS OBINSKIT MACRO
#
# Change existing event values, or add new events, noting:
# 1. One event per line
# 2. Each event has 2 tokens separated by whitespace:
# 1. One of:
# 1. KEY_DOWN
# 2. KEY_UP
# 3. WAIT
# 2. Either a keycode for a KEY_* event, or a time in msec for a WAIT event.
# 3. See keycodes.py for details. Keycodes must be one of:
{chr(10).join(textwrap.wrap(str(list(keycodes_by_name.keys())), width=80, initial_indent='#'+' '*9, subsequent_indent='#'+' '*9, expand_tabs=False))}.
# 3. Lines beginning with # are ignored (treated as comments).
# Example: Press J for 10msec before letting go.
# KEY_DOWN J
# WAIT 10
# KEY_UP J
'''
f.write(instructions)
logger.debug(f"Dumping macro events to temporary file {temppath} for editing...")
for i,t in enumerate(map(ObinsKitMacroItemTuple._make, int_3_tuples)):
logger.debug(f"{i:3}: {t}")
f.write(f"{t}\n")
logger.debug(f"Opening temp file in $EDITOR: {os.environ['EDITOR']}...")
cmd = f"{os.environ['EDITOR']} {temppath}"
completed_process = subprocess.run(cmd, shell=True)
logger.debug(f"Return code: {completed_process.returncode}")
if completed_process.returncode != 0:
logger.error(f"{os.environ['EDITOR']} process returned error: {completed_process.returncode}!")
sys.exit(completed_process.returncode)
# On successful return code, validate file (stripping comments and whitespace-only lines)
# and re-encode macro_value and UPDATE table
with open(temppath, 'r') as f:
lines = [line for line in f.readlines() if (not line.startswith('#') and not line.isspace())]
logger.debug(f"lines:\n{''.join(lines)}")
new_macro_value = []
for line in lines:
macro_tuple_for_line = ObinsKitMacroItemTuple.from_str(line)
new_macro_value += macro_tuple_for_line.to_int_macro_value_list()
logger.debug(f"new_macro_value: {new_macro_value}")
logger.debug(f"old_macro_value: {int_macro_value}")
if new_macro_value == int_macro_value:
logger.info("Macro Int Value not changed, nothing to update. Exiting..")
sys.exit(0)
# Print various diffs between old and new macro value lists
# difference = list(set(macro_value) - set(new_macro_value))
# logger.debug(f"Difference between old and new macro_values is:\n{difference}")
# symmetrical_difference = list(set(macro_value).symmetric_difference(set(new_macro_value)))
# logger.debug(f"Symmetrical Difference between old and new macro_values is:\n{symmetrical_difference}")
old_strings = [f"{t}" for t in int_3_tuples]
new_int_3_tuples = [tuple(new_macro_value[i:i+3]) for i in range(0, len(new_macro_value), 3)]
new_strings = [f"{t}" for t in new_int_3_tuples]
t0 = time.time()
result = list(difflib.unified_diff(new_strings, old_strings, lineterm=''))
logger.debug(f"Text unified-diff between old and new macro_values (took {time.time()-t0}s):\n{pprint.pformat(result)}")
t0 = time.time()
html_str = difflib.HtmlDiff().make_file(old_strings, new_strings, fromdesc='old macro_values', todesc='new macro_values', context=True, numlines=5)
html_dir = tempfile.mkdtemp()
html_file_path = os.path.join(html_dir, 'index.html')
with open(html_file_path, 'w') as f:
f.write(html_str)
logger.debug(f"HTML diff between old and new macro_values (took {time.time()-t0:6.3}s, html_file_path={html_file_path}):\n{html_str}")
logger.debug(f"Opening HTML diff in browser...")
# TODO: Fix this horrible platform-dependence
# Webbrowser module doesn't work on WSL out of the box...
# ret = webbrowser.open(f"{html_file_path}")
# logger.debug(f"webbrowser.open returned {ret}...")
# ... So we just directly open our generated HTML file in win10 Firefox
wsl_distro = "Debian"
wsl_path_prefix = f"file://///wsl$/{wsl_distro}"
p = subprocess.Popen([
"/mnt/c/Program Files/Firefox Nightly/firefox.exe",
f"{wsl_path_prefix}{html_file_path}"],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL) # required to detach sub from parent
# rely on user to manage browser process(es), i.e. do not terminate
# UPDATE the macro_value column in the macro_name row in the kbd_macro_new table.
cmd = f"UPDATE kbd_macro_new SET macro_value='{new_macro_value}' WHERE name='{macro_name}'"
logger.debug(f"UPDATEing macro_value with command {cmd}")
cursor.execute(cmd)
# Commit any pending transaction to the database and close connection to it
connection.commit()
connection.close()
logger.debug(f"Committed SQLITE transactions and closed connection.")
if dry_run:
logger.info(f"Dry-run complete! Modified db copy {tmp_db_path} will not "
f"be written over the original {db_path}. You could manually apply your "
f"changes by copying the temp SQLITE db file {tmp_db_path} to the "
f"default location for ObinsKit on your platform, e.g. on Windows it's"
f" /mnt/c/Users/876738897/AppData/Roaming/ObinsKit/Run.core.")
sys.exit(0)
logger.info(f"Copying the temp SQLITE db file {tmp_db_path} back over the original db at {db_path}...")
shutil.copyfile(tmp_db_path, db_path)
logger.info(f"Done!")
if __name__ == '__main__':
logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', level=logging.DEBUG)
logger = logging.getLogger(os.path.splitext(__file__)[0])
argparser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
argparser.add_argument("--db-path",
# only required for some use cases
action='store',
type=pathlib.Path,
help="Absolute path to local SQLITE database file")
argparser.add_argument("--macro-name",
# only required for some use cases
action='store',
type=str,
help="Name of macro to edit. Use `SELECT name FROM kbd_macro_new` to list all macro names")
argparser.add_argument("--dry-run",
action='store_true',
help="When True, won't write any changes to sqlite database at --db-path. "
"This program always operates on a temp copy, and when not --dry-run, "
"it will write the temp copy back over the original --db-path.")
argparser.add_argument("--verbose",
action='store_true',
help="Log all the things")
argparser.add_argument("--list-keycodes",
action='store_true',
help="Print mappings between key values and names used by ObinsKit."
"Does not require other args.")
args = argparser.parse_args()
logger.setLevel(logging.DEBUG if args.verbose else logging.INFO)
if args.list_keycodes:
print("\nkeycodes_by_value:")
pprint.pprint(keycodes_by_value)
print("\nkeycodes_by_name:")
pprint.pprint(keycodes_by_name)
required_args = [args.db_path, args.macro_name]
if args.list_keycodes is None and any([x is None for x in required_args]):
parser.error(f"{required_args} are required if not using --list-keycodes.")
if all([x for x in required_args]):
main(args.db_path, args.macro_name, dry_run=args.dry_run)