diff --git a/README.md b/README.md index 671f4f4..0ad456b 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,32 @@ InputScope Mouse and keyboard input heatmap visualizer and statistics. +Runs a tray program that logs mouse and keyboard input events to a local database, +and provides a local web page for viewing statistics and heatmaps by day or month. + +[![Mouse clicks heatmap](https://raw.githubusercontent.com/suurjaak/InputScope/media/th_clicks.png)](https://raw.githubusercontent.com/suurjaak/InputScope/media/clicks.png) +[![Mouse moves heatmap](https://raw.githubusercontent.com/suurjaak/InputScope/media/th_moves.png)](https://raw.githubusercontent.com/suurjaak/InputScope/media/moves.png) +[![Keyboard keys heatmap](https://raw.githubusercontent.com/suurjaak/InputScope/media/th_keys.png)](https://raw.githubusercontent.com/suurjaak/InputScope/media/keys.png) +[![Keyboard combos heatmap](https://raw.githubusercontent.com/suurjaak/InputScope/media/th_combos.png)](https://raw.githubusercontent.com/suurjaak/InputScope/media/combos.png) + + +Details +------- + +Logs mouse clicks and scrolls and movement, and keyboard key presses and key +combinations; event categories can be toggled off from tray menu. + +Provides an option to record named sessions, allowing to group inputs +with finer detail than one day. + Keypresses are logged as physical keys, ignoring Unicode mappings. Note: keyboard logging can interfere with remote control desktop, UI automation scripts, and sticky keys. Data is kept in an SQLite database. -[![Mouse clicks heatmap](https://raw.githubusercontent.com/suurjaak/InputScope/media/th_clicks.png)](https://raw.githubusercontent.com/suurjaak/InputScope/media/clicks.png) -[![Mouse moves heatmap](https://raw.githubusercontent.com/suurjaak/InputScope/media/th_moves.png)](https://raw.githubusercontent.com/suurjaak/InputScope/media/moves.png) -[![Keyboard keys heatmap](https://raw.githubusercontent.com/suurjaak/InputScope/media/th_keys.png)](https://raw.githubusercontent.com/suurjaak/InputScope/media/keys.png) -[![Keyboard combos heatmap](https://raw.githubusercontent.com/suurjaak/InputScope/media/th_combos.png)](https://raw.githubusercontent.com/suurjaak/InputScope/media/combos.png) +The local web page is viewable at http://localhost:8099/, +port can be changed in configuration file. Installation @@ -42,7 +58,7 @@ and `inputscope-webui` to path. Dependencies ------------ -* Python 2.7 +* Python 2.7 or Python 3.5+ * bottle * pynput * wxPython (optional) diff --git a/inputscope.spec b/inputscope.spec index 66cb546..04cf09e 100644 --- a/inputscope.spec +++ b/inputscope.spec @@ -1,70 +1,90 @@ -# -*- mode: python -*- -""" -Pyinstaller spec file for InputScope, produces a Windows executable. - -@author Erki Suurjaak -@created 13.04.2015 -@modified 11.02.2021 -""" -import os -import sys - -APPPATH = os.path.join(os.path.dirname(os.path.abspath(SPEC)), "inputscope") -sys.path.append(APPPATH) -import conf - - -APP_INCLUDES = [("static", "icon.ico"), ("static", "site.css"), - ("static", "heatmap.min.js"), ("static", "keyboard.svg"), - ("views", "index.tpl"), ("views", "heatmap_keyboard.tpl"), - ("views", "input.tpl"), ("views", "heatmap_mouse.tpl"), - ("views", "base.tpl"), ] -DATA_EXCLUDES = ["Include\\pyconfig.h"] # PyInstaller 2.1 bug: warning about existing pyconfig.h -MODULE_EXCLUDES = ["_gtkagg", "_tkagg", "_tkinter", "backports", "bsddb", "bz2", - "cherrypy", "colorama", "curses", "distutils", - "doctest", "email.errors", "email.feedparser", "email.header", - "email.message", "email.parser", "FixTk", "ftplib", - "future.backports", "future.builtins", "future.moves", - "future.types", "future.utils", "getpass", "gettext", "gevent", - "gzip", "html", "http", "jinja2", "mako", "multiprocessing.heap", - "multiprocessing.managers", "multiprocessing.pool", - "multiprocessing.reduction", "multiprocessing.sharedctypes", - "numpy", "OpenSSL", "optparse", "os2emxpath", "paste", - "paste.httpserver", "paste.translogger", "PIL", "pygments", - "pyreadline", "pywin", "servicemanager", "setuptools", - "sitecustomize", "sre", "tarfile", "tcl", "tk", "Tkconstants", - "tkinter", "Tkinter", "tornado", "unittest", "urllib2", - "win32com.server", "win32ui", "wx.html", "xml", - "xml.parsers.expat", "xmllib", "xmlrpclib", "zipfile", ] -MODULE_INCLUDES = ["pynput.mouse._win32", "pynput.keyboard._win32"] -BINARY_EXCLUDES = ["_ssl", "_testcapi"] -PURE_RETAINS = {"encodings.": [ - "encodings.aliases", "encodings.ascii", "encodings.base64_codec", - "encodings.hex_codec", "encodings.latin_1", "encodings.mbcs", - "encodings.utf_8", -]} - - -a = Analysis([(os.path.join(APPPATH, "main.py"))], excludes=MODULE_EXCLUDES, - hiddenimports=MODULE_INCLUDES) -a.datas -= [(n, None, "DATA") for n in DATA_EXCLUDES] # entry=(name, path, typecode) -a.datas += [(os.path.join(*x), os.path.join(APPPATH, *x), "DATA") - for x in APP_INCLUDES] -a.binaries -= [(n, None, None) for n in BINARY_EXCLUDES] -a.pure = TOC([(n, p, c) for (n, p, c) in a.pure if not any( - n.startswith(k) and n not in vv for k, vv in PURE_RETAINS.items())]) - -exename = "%s_%s.exe" % (conf.Title, conf.Version) -exe = EXE( - PYZ(a.pure), - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - name=exename, - debug=False, # Verbose or non-verbose - strip=False, # EXE and all shared libraries run through cygwin's strip, tends to render Win32 DLLs unusable - upx=True, # Using Ultimate Packer for eXecutables - icon=os.path.join(APPPATH, "static", "icon.ico"), - console=False # Use the Windows subsystem executable instead of the console one -) +# -*- mode: python -*- +""" +Pyinstaller spec file for Inputscope, produces a 32-bit or 64-bit executable, +depending on Python environment. + +Pyinstaller-provided names and variables: Analysis, EXE, PYZ, SPEC, TOC. + +@author Erki Suurjaak +@created 13.04.2015 +@modified 22.01.2022 +""" +import os +import struct +import sys + +NAME = "inputscope" +DO_DEBUGVER = False +DO_64BIT = (struct.calcsize("P") * 8 == 64) + +BUILDPATH = os.path.dirname(os.path.abspath(SPEC)) +APPPATH = os.path.join(BUILDPATH, NAME) +ROOTPATH = BUILDPATH +os.chdir(ROOTPATH) +sys.path.append(ROOTPATH) + +from inputscope import conf + + +APP_INCLUDES = [("static", "icon.ico"), ("static", "site.css"), + ("static", "heatmap.min.js"), ("static", "keyboard.svg"), + ("views", "index.tpl"), ("views", "heatmap_keyboard.tpl"), + ("views", "input.tpl"), ("views", "heatmap_mouse.tpl"), + ("views", "base.tpl"), ("views", "session.tpl")] +DATA_EXCLUDES = ["Include\\pyconfig.h"] # PyInstaller 2.1 bug: warning about existing pyconfig.h +MODULE_EXCLUDES = ["_gtkagg", "_tkagg", "_tkinter", "backports", "bsddb", "bz2", + "cherrypy", "colorama", "curses", "distutils", "doctest", + "FixTk", "ftplib", "future.backports", "future.builtins", "future.moves", + "future.types", "future.utils", "getpass", "gettext", "gevent", + "gzip", "jinja2", "mako", "numpy", "OpenSSL", "optparse", "os2emxpath", + "paste", "paste.httpserver", "paste.translogger", "PIL", "pygments", + "pyreadline", "pywin", "servicemanager", "setuptools", + "sitecustomize", "sre", "tarfile", "tcl", "tk", "Tkconstants", + "tkinter", "Tkinter", "tornado", "unittest", "urllib2", + "win32com.server", "win32ui", "wx.html", "xml", + "xml.parsers.expat", "xmllib", "xmlrpclib", "zipfile", ] +MODULE_INCLUDES = ["pynput.mouse._win32", "pynput.keyboard._win32"] +BINARY_EXCLUDES = ["_ssl", "_testcapi"] +PURE_RETAINS = {"encodings.": [ + "encodings.aliases", "encodings.ascii", "encodings.base64_codec", + "encodings.hex_codec", "encodings.latin_1", "encodings.mbcs", + "encodings.utf_8", +]} + + +app_file = "%s_%s%s%s" % (NAME, conf.Version, "_x64" if DO_64BIT else "", + ".exe" if "nt" == os.name else "") +entrypoint = os.path.join(ROOTPATH, "launch.py") + +with open(entrypoint, "w") as f: + f.write("from %s import main; main.main()" % NAME) + + +a = Analysis( + [entrypoint], excludes=MODULE_EXCLUDES, hiddenimports=MODULE_INCLUDES +) +a.datas -= [(n, None, "DATA") for n in DATA_EXCLUDES] # entry=(name, path, typecode) +a.datas += [(os.path.join(*x), os.path.join(APPPATH, *x), "DATA") + for x in APP_INCLUDES] +a.binaries -= [(n, None, None) for n in BINARY_EXCLUDES] +a.pure = TOC([(n, p, c) for (n, p, c) in a.pure if not any( + n.startswith(k) and n not in vv for k, vv in PURE_RETAINS.items())]) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts + ([("v", "", "OPTION")] if DO_DEBUGVER else []), + a.binaries, + a.datas, + name=app_file, + + debug=DO_DEBUGVER, # Verbose or non-verbose debug statements printed + exclude_binaries=False, # Binaries not left out of PKG + strip=False, # EXE and all shared libraries run through cygwin's strip, tends to render Win32 DLLs unusable + upx=True, # Using Ultimate Packer for eXecutables + console=DO_DEBUGVER, # Use the Windows subsystem executable instead of the console one + icon=os.path.join(APPPATH, "static", "icon.ico"), +) + +try: os.remove(entrypoint) +except Exception: pass diff --git a/inputscope/__init__.py b/inputscope/__init__.py index c4e62af..b7ebfa5 100644 --- a/inputscope/__init__.py +++ b/inputscope/__init__.py @@ -1,3 +1,3 @@ -import conf +from . import conf __version__ = conf.Version __VERSION__ = conf.Version diff --git a/inputscope/__main__.py b/inputscope/__main__.py index 554db55..4cc98a3 100644 --- a/inputscope/__main__.py +++ b/inputscope/__main__.py @@ -1,5 +1,5 @@ """Package entry point.""" -from inputscope import main +from . import listener, main, webui if "__main__" == __name__: main.main() diff --git a/inputscope/conf.py b/inputscope/conf.py index a7029ee..ab29fef 100644 --- a/inputscope/conf.py +++ b/inputscope/conf.py @@ -20,14 +20,13 @@ @author Erki Suurjaak @created 26.03.2015 -@modified 12.02.2021 +@modified 21.10.2021 ------------------------------------------------------------------------------ """ try: import ConfigParser as configparser # Py2 except ImportError: import configparser # Py3 -try: import cStringIO as StringIO # Py2 -except ImportError: import io as StringIO # Py3 import datetime +import io import json import logging import os @@ -36,8 +35,8 @@ """Program title, version number and version date.""" Title = "InputScope" -Version = "1.4.1" -VersionDate = "12.02.2021" +Version = "1.5.dev2" +VersionDate = "21.10.2021" """TCP port of the web user interface.""" WebHost = "localhost" @@ -107,6 +106,9 @@ """Maximum number of events to replay on statistics page.""" MaxEventsForReplay = 100 * 1000 +"""Maximum number of sessions listed in tray menu.""" +MaxSessionsInMenu = 20 + """Physical length of a pixel, in meters.""" PixelLength = 0.00024825 @@ -293,8 +295,9 @@ "CREATE TABLE IF NOT EXISTS app_events (id INTEGER NOT NULL PRIMARY KEY, dt TIMESTAMP DEFAULT (DATETIME('now', 'localtime')), type TEXT)", "CREATE TABLE IF NOT EXISTS screen_sizes (id INTEGER NOT NULL PRIMARY KEY, dt TIMESTAMP DEFAULT (DATETIME('now', 'localtime')), x INTEGER, y INTEGER, w INTEGER, h INTEGER, display INTEGER)", "CREATE TABLE IF NOT EXISTS counts (id INTEGER NOT NULL PRIMARY KEY, type TEXT, day DATETIME, count INTEGER, UNIQUE(type, day))", -) + tuple(TriggerTemplate .format(x) for x in [x for k, vv in InputTables for x in vv] -) + tuple(DayIndexTemplate.format(x) for x in [x for k, vv in InputTables for x in vv]) + "CREATE TABLE IF NOT EXISTS sessions (id INTEGER NOT NULL PRIMARY KEY, name TEXT, day1 DATETIME, day2 DATETIME, start REAL, end REAL)", +) + tuple(TriggerTemplate .format(t) for _, tt in InputTables for t in tt +) + tuple(DayIndexTemplate.format(t) for _, tt in InputTables for t in tt) """ Statements to update database v<1.3 to new schema, @@ -317,6 +320,9 @@ ] +try: text_types = (str, unicode) # Py2 +except Exception: text_types = (str, ) # Py3 + def init(filename=ConfigPath): """Loads INI configuration into this module's attributes.""" section, parts = "DEFAULT", filename.rsplit(":", 1) @@ -329,9 +335,12 @@ def init(filename=ConfigPath): def parse_value(raw): try: return json.loads(raw) # Try to interpret as JSON except ValueError: return raw # JSON failed, fall back to raw - txt = open(filename).read() # Add DEFAULT section if none present + with open(filename, "r") as f: + txt = f.read() + try: txt = txt.decode() + except Exception: pass if not re.search("\\[\\w+\\]", txt): txt = "[DEFAULT]\n" + txt - parser.readfp(StringIO.StringIO(txt), filename) + parser.readfp(io.StringIO(txt), filename) for k, v in parser.items(section): vardict[k] = parse_value(v) except Exception: logging.warn("Error reading config from %s.", filename, exc_info=True) @@ -343,7 +352,7 @@ def save(filename=ConfigPath): parser = configparser.RawConfigParser() parser.optionxform = str # Force case-sensitivity on names try: - save_types = basestring, int, float, tuple, list, dict, type(None) + save_types = text_types + (int, float, tuple, list, dict, type(None)) for k, v in sorted(globals().items()): if not isinstance(v, save_types) or k.startswith("_") \ or k not in default_values or default_values[k] == v \ @@ -352,7 +361,7 @@ def save(filename=ConfigPath): try: parser.set("DEFAULT", k, json.dumps(v)) except Exception: pass if parser.defaults(): - with open(filename, "wb") as f: + with open(filename, "w") as f: f.write("# %s %s configuration written on %s.\n" % (Title, Version, datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))) parser.write(f) @@ -366,7 +375,7 @@ def save(filename=ConfigPath): def defaults(values={}): """Returns a once-assembled dict of this module's storable attributes.""" if values: return values - save_types = basestring, int, float, tuple, list, dict, type(None) + save_types = text_types + (int, float, tuple, list, dict, type(None)) for k, v in globals().items(): if isinstance(v, save_types) and not k.startswith("_"): values[k] = v return values diff --git a/inputscope/db.py b/inputscope/db.py index ff8d11c..0d6432c 100644 --- a/inputscope/db.py +++ b/inputscope/db.py @@ -8,6 +8,7 @@ db.fetchone("test", val=None, limit=[0, 3]) db.update("test", values=[("val", "arrivederci")], val=None) db.update("test", values=[("val", "ciao")], where=[("val", ("IS NOT", None))]) +db.update("test", {"val": ("EXPR": "val || ' proxima'")}, id=4) db.fetch("test", order=["val", ("id", "DESC")], limit=[0, 4]) db.fetch("test", id=("IN", [1, 2, 3])) db.delete("test", val="something") @@ -15,7 +16,7 @@ @author Erki Suurjaak @created 05.03.2014 -@modified 25.01.2021 +@modified 18.10.2021 """ import os import re @@ -29,6 +30,7 @@ def fetch(table, cols="*", where=(), group="", order=(), limit=(), **kwargs): def fetchone(table, cols="*", where=(), group="", order=(), limit=(), **kwargs): """Convenience wrapper for database SELECT and fetch one.""" + limit = limit if limit != () else 1 return select(table, cols, where, group, order, limit, **kwargs).fetchone() @@ -85,7 +87,7 @@ def make_cursor(path, init_statements=(), _connectioncache={}): connection = sqlite3.connect(path, isolation_level=None, check_same_thread=False, detect_types=sqlite3.PARSE_DECLTYPES) for x in init_statements or (): connection.execute(x) - try: is_new and ":memory:" not in path.lower() and os.chmod(path, 0707) + try: is_new and ":memory:" not in path.lower() and os.chmod(path, 0o707) except OSError: pass connection.row_factory = lambda cur, row: dict(sqlite3.Row(cur, row)) _connectioncache[path] = connection @@ -94,10 +96,12 @@ def make_cursor(path, init_statements=(), _connectioncache={}): def makeSQL(action, table, cols="*", where=(), group="", order=(), limit=(), values=()): """Returns (SQL statement string, parameter dict).""" - cols = cols if isinstance(cols, basestring) else ", ".join(cols) - group = group if isinstance(group, basestring) else ", ".join(group) - order = [order] if isinstance(order, basestring) else order - limit = [limit] if isinstance(limit, (basestring, int)) else limit + try: text_types = (str, unicode) # Py2 + except Exception: text_types = (str, ) # Py3 + cols = cols if isinstance(cols, text_types) else ", ".join(cols) + group = group if isinstance(group, text_types) else ", ".join(group) + order = [order] if isinstance(order, text_types) else order + limit = [limit] if isinstance(limit, text_types + (int, )) else limit values = values if not isinstance(values, dict) else values.items() sql = "SELECT %s FROM %s" % (cols, table) if "SELECT" == action else "" sql = "DELETE FROM %s" % (table) if "DELETE" == action else sql @@ -105,37 +109,48 @@ def makeSQL(action, table, cols="*", where=(), group="", order=(), limit=(), val sql = "UPDATE %s" % (table) if "UPDATE" == action else sql args = {} if "INSERT" == action: - args.update(values) - cols, vals = (", ".join(x + k for k, v in values) for x in ("", ":")) - sql += " (%s) VALUES (%s)" % (cols, vals) + cols, vals = [], [] + for i, (col, val) in enumerate(values): + cols.append(col) + if isinstance(val, (list, tuple)) and len(val) == 2 and "EXPR" == val[0]: + vals.append("%s" % val[1]) + else: + vals.append(":%s" % col) + args[col] = val + sql += " (%s) VALUES (%s)" % (", ".join(cols), ", ".join(vals)) if "UPDATE" == action: sql += " SET " for i, (col, val) in enumerate(values): - sql += (", " if i else "") + "%s = :%sU%s" % (col, col, i) - args["%sU%s" % (col, i)] = val + if isinstance(val, (list, tuple)) and len(val) == 2 and "EXPR" == val[0]: + sql += (", " if i else "") + "%s = %s" % (col, val[1]) + else: + sql += (", " if i else "") + "%s = :%sU%s" % (col, col, i) + args["%sU%s" % (col, i)] = val if where: sql += " WHERE " for i, (col, val) in enumerate(where): - key = "%sW%s" % (re.sub("\\W", "_", col), i) - dbval = val[1] if isinstance(val, (list, tuple)) else val - op = "IS" if dbval == val else val[0] - - if op in ("IN", "NOT IN"): + op, dbval = val[:2] if isinstance(val, (list, tuple)) else ("IS", val) + if "EXPR" == op: + sql += (" AND " if i else "") + "%s %s" % (col, dbval) + elif op in ("IN", "NOT IN"): keys = ["%s_%s" % (col, j) for j in range(len(val[1]))] args.update(zip(keys, val[1])) sql += (" AND " if i else "") + "%s %s (%s)" % ( col, op, ", ".join(":" + x for x in keys)) else: + key = "%sW%s" % (re.sub("\\W", "_", col), i) args[key] = dbval op = "=" if dbval is not None and "IS" == op else op sql += (" AND " if i else "") + "%s %s :%s" % (col, op, key) if group: sql += " GROUP BY " + group if order: + make_direction = lambda c: (c if isinstance(c, text_types) + else "DESC" if c else "ASC") sql += " ORDER BY " for i, col in enumerate(order): name = col[0] if isinstance(col, (list, tuple)) else col - direction = "" if name == col else " " + col[1] + direction = "" if name == col else " " + make_direction(col[1]) sql += (", " if i else "") + name + direction if limit: sql += " LIMIT %s" % (", ".join(map(str, limit))) diff --git a/inputscope/listener.py b/inputscope/listener.py index 899a487..76d7ce4 100644 --- a/inputscope/listener.py +++ b/inputscope/listener.py @@ -4,14 +4,30 @@ --quiet prints out nothing + +Commands from stdin: + +start CATEGORY +stop CATEGORY +clear CATEGORY ?DATE1 ?DATE2 +screen_size [DISPLAY0 x, y, w, h], .. + +session start NAME +session stop +session rename NAME2 ID +session clear CATEGORY ID +session delete ID + + @author Erki Suurjaak @created 06.04.2015 -@modified 10.02.2021 +@modified 18.10.2021 """ from __future__ import print_function import datetime import math -import Queue +try: import Queue as queue # Py2 +except ImportError: import queue # Py3 import sys import threading import time @@ -19,8 +35,9 @@ import pynput -import conf -import db +from . import conf +from . import db +from . util import LineQueue, stamp_to_date, zhex DEBUG = False @@ -87,16 +104,56 @@ def handle_command(self, command): elif category in conf.InputEvents: tables = conf.InputEvents[category] else: tables = [category] where = [("day", (">=", dates[0])), ("day", ("<=", dates[1]))] if dates else [] + count_deleted = 0 for table in tables: + count_deleted += db.delete(table, where=where) db.delete("counts", where=where, type=table) - db.delete(table, where=where) + if count_deleted: + where = [("day1", (">=", dates[0])), ("day2", ("<=", dates[1]))] if dates else [] + for session in db.fetch("sessions", where=where): + retain = False + where = [("stamp", (">=", session["start"])), ("stamp", ("<", session["end"]))] + for table in tables: + retain = retain or db.fetchone(table, "1", where=where) + if not retain: + db.delete("sessions", id=session["id"]) elif command.startswith("screen_size "): # "screen_size [0, 0, 1920, 1200] [1920, 0, 1000, 800]" - sizestrs = filter(bool, map(str.strip, command[12:].replace("[", "").split("]"))) - sizes = sorted(map(int, s.replace(",", "").split()) for s in sizestrs) + sizestrs = list(filter(bool, (x.strip() for x in command[12:].replace("[", "").split("]")))) + sizes = sorted([[int(x) for x in s.replace(",", "").split()] for s in sizestrs]) for i, size in enumerate(sizes): db.insert("screen_sizes", x=size[0], y=size[1], w=size[2], h=size[3], display=i) self.data_handler.screen_sizes = sizes + elif command.startswith("session "): + parts = command.split()[1:] + action, args = parts[0], parts[1:] + if "start" == action and args: + stamp, day = next((x, stamp_to_date(x)) for x in [time.time()]) + db.update("sessions", {"day2": day, "end": stamp}, end=None) + db.insert("sessions", name=" ".join(args), start=stamp, day1=day) + elif "stop" == action: + stamp, day = next((x, stamp_to_date(x)) for x in [time.time()]) + db.update("sessions", {"day2": day, "end": time.time()}, end=None) + elif "rename" == action and len(args) > 1: + name2, sid = " ".join(args[:-1]), args[-1] + db.update("sessions", {"name": name2}, id=sid) + elif "clear" == action and len(args) == 2: + category, sess = args[0], db.fetchone("sessions", id=args[1]) + if "all" == category: tables = sum(conf.InputEvents.values(), ()) + elif category in conf.InputEvents: tables = conf.InputEvents[category] + else: tables = [category] + day1 = stamp_to_date(sess["start"]) + day2 = stamp_to_date(sess["end"] or time.time()) + step = datetime.timedelta(days=1) + days = [day1 + i * step for i in range(abs((day2 - day1).days + 1))] + where = [("stamp", (">=", sess["start"]))] + if sess["end"]: where += [("stamp", ("<", sess["end"]))] + + for table, day in ((t, d) for t in tables for d in days): + count = db.delete(table, [("day", day)] + where) + db.update("counts", {"count": ("EXPR", "count - %s" % count)}, day=day, type=table) + elif "delete" == action and args: + db.delete("sessions", id=args[0]) elif "vacuum" == command: db.execute("VACUUM") elif "exit" == command: @@ -120,7 +177,7 @@ def __init__(self, output): threading.Thread.__init__(self) self.counts = {} # {type: count} self.output = output - self.inqueue = Queue.Queue() + self.inqueue = queue.Queue() self.lasts = {"moves": None} self.screen_sizes = [[0, 0] + list(conf.DefaultScreenSize)] self.running = False @@ -156,7 +213,7 @@ def rescale(pt): def one_line(pt1, pt2, pt3): """Returns whether points more or less fall onto one line.""" - (x1, y1), (x2, y2), (x3, y3) = map(rescale, (pt1, pt2, pt3)) + (x1, y1), (x2, y2), (x3, y3) = list(map(rescale, (pt1, pt2, pt3))) if not (x1 >= x2 >= x3) and not (y1 >= y2 >= y3) \ and not (x1 <= x2 <= x3) and not (y1 <= y2 <= y3): return False return abs((y1 - y2) * (x1 - x3) - (y1 - y3) * (x1 - x2)) <= conf.MouseMoveJoinRadius @@ -169,7 +226,7 @@ def sign(v): return -1 if v < 0 else 1 if v > 0 else 0 while data: items.append(data) try: data = self.inqueue.get(block=False) - except Queue.Empty: data = None + except queue.Empty: data = None if not items or not self.running: continue # while self.running move0, move1, scroll0 = None, None, None @@ -205,11 +262,8 @@ def sign(v): return -1 if v < 0 else 1 if v > 0 else 0 dbqueue.append((category, data)) try: - while dbqueue: - db.insert(*dbqueue[0]) - dbqueue.pop(0) - except StandardError as e: - print(e, category, data) + while dbqueue: db.insert(*dbqueue.pop(0)) + except StandardError as e: print(e) self.output(self.counts) if conf.EventsWriteInterval > 0: time.sleep(conf.EventsWriteInterval) @@ -355,7 +409,7 @@ def on_event(self, pressed, key): self._downs[realkey] = pressed if not conf.KeyboardEnabled or not pressed: return - if DEBUG: print("Adding key %s (real %s)" % (mykey.encode("utf-8"), realkey.encode("utf-8"))) + if DEBUG: print("Adding key %r (real %r)" % (mykey, realkey)) self._output(type="keys", key=mykey, realkey=realkey) if mykey not in self.MODIFIERNAMES and conf.KeyboardCombosEnabled: @@ -365,7 +419,7 @@ def on_event(self, pressed, key): mykey = "%s-%s" % (modifier, realkey) realmodifier = "-".join(k for k, v in self._realmodifiers.items() if v) realkey = "%s-%s" % (realmodifier, realkey) - if DEBUG: print("Adding combo %s (real %s)" % (mykey.encode("utf-8"), realkey.encode("utf-8"))) + if DEBUG: print("Adding combo %r (real %r)" % (mykey, realkey)) self._output(type="combos", key=mykey, realkey=realkey) @@ -425,34 +479,14 @@ def stop(self): self._listener.stop() -class LineQueue(threading.Thread): - """Reads lines from a file-like object and pushes to self.queue.""" - def __init__(self, input): - threading.Thread.__init__(self) - self.daemon = True - self.input, self.queue = input, Queue.Queue() - self.start() - - def run(self): - for line in iter(self.input.readline, ""): - self.queue.put(line.strip()) - -def zhex(v): - """Returns number as zero-padded hex, e.g. "0x0C" for 12 and "0x0100" for 256.""" - if not v: return "0x00" - sign, v = ("-" if v < 0 else ""), abs(v) - return "%s0x%0*X" % (sign, 2 * int(1 + math.log(v) / math.log(2) // 8), v) - - def start(inqueue, outqueue=None): """Starts the listener with incoming and outgoing queues.""" conf.init(), db.init(conf.DbPath, conf.DbStatements) # Carry out db update for tables lacking expected new columns for (table, col), sqls in conf.DbUpdateStatements: - if any(col == x["name"] for x in db.execute("PRAGMA table_info(%s)" % table)): - continue # for - for sql in sqls: db.execute(sql) + if not any(col == x["name"] for x in db.execute("PRAGMA table_info(%s)" % table)): + for sql in sqls: db.execute(sql) try: db.execute("PRAGMA journal_mode = WAL") except Exception: pass diff --git a/inputscope/main.py b/inputscope/main.py index 9b71799..17ff3cf 100644 --- a/inputscope/main.py +++ b/inputscope/main.py @@ -5,21 +5,20 @@ @author Erki Suurjaak @created 05.05.2015 -@modified 11.02.2021 +@modified 21.10.2021 """ import calendar import datetime import errno import functools import multiprocessing -import multiprocessing.forking import os +import re import signal import subprocess import sys import threading import time -import urllib import webbrowser try: import win32com.client # For creating startup shortcut except ImportError: pass @@ -29,27 +28,26 @@ try: import Tkinter as tk # For getting screen size if wx unavailable except ImportError: pass -import conf -import listener -import webui +from . import conf +from . import db +from . import listener +from . import webui +from . util import QueueLine, format_session, run_later -class Popen(multiprocessing.forking.Popen): - """Support for PyInstaller-frozen Windows executables.""" - def __init__(self, *args, **kwargs): - hasattr(sys, "frozen") and os.putenv("_MEIPASS2", sys._MEIPASS + os.sep) - try: super(Popen, self).__init__(*args, **kwargs) - finally: hasattr(sys, "frozen") and os.unsetenv("_MEIPASS2") -class Process(multiprocessing.Process): _Popen = Popen +try: # Py2 + import multiprocessing.forking + class Popen(multiprocessing.forking.Popen): + """Support for PyInstaller-frozen Windows executables.""" + def __init__(self, *args, **kwargs): + hasattr(sys, "frozen") and os.putenv("_MEIPASS2", sys._MEIPASS + os.sep) + try: super(Popen, self).__init__(*args, **kwargs) + finally: hasattr(sys, "frozen") and os.unsetenv("_MEIPASS2") -class QueueLine(object): - """Queue-like interface for writing lines to a file-like object.""" - def __init__(self, output): self.output = output - def put(self, item): - try: self.output.write("%s\n" % item) - except IOError as e: - if e.errno != errno.EINVAL: raise # Invalid argument, probably stale pipe + class Process(multiprocessing.Process): _Popen = Popen +except ImportError: # Py3 + class Process(multiprocessing.Process): pass class Model(threading.Thread): @@ -58,6 +56,7 @@ class Model(threading.Thread): def __init__(self): threading.Thread.__init__(self) self.running = False + self.sessions = [] self.sizes = None # [[x, y, w, h], ] self.initialqueue = multiprocessing.Queue() self.listenerqueue = None @@ -70,6 +69,7 @@ def __init__(self): def toggle(self, category): + """Toggles logging input category on or off.""" if category not in conf.InputFlags: return # Event input (mouse|keyboard), None if category itself is input @@ -100,6 +100,13 @@ def toggle(self, category): q.put("%s %s" % ("start" if on else "stop", category)) + def session_action(self, action, session=None, arg=None): + """Carries out session action.""" + q = self.listenerqueue or self.initialqueue + q.put(" ".join(filter(bool, ("session", action, arg, session and str(session["id"]))))) + if action in ("rename", "delete", "start", "stop"): + run_later(lambda: setattr(self, "sessions", db.fetch("sessions", order="start DESC")), 1000) + def stop(self, exit=False): self.running = False if self.listener: self.listenerqueue.put("exit"), self.listener.terminate() @@ -126,11 +133,12 @@ def run(self): self.webui = Process(target=webui.start) self.listener.start(), self.webui.start() else: - args = lambda *x: [sys.executable, - os.path.join(conf.ApplicationPath, x[0])] + list(x[1:]) - self.listener = subprocess.Popen(args("listener.py", "--quiet"), - stdin=subprocess.PIPE) - self.webui = subprocess.Popen(args("webui.py", "--quiet")) + pkg = os.path.basename(conf.ApplicationPath) + root = os.path.dirname(conf.ApplicationPath) + args = lambda *x: [sys.executable, "-m", "%s.%s" % (pkg, x[0])] + list(x[1:]) + self.listener = subprocess.Popen(args("listener", "--quiet"), cwd=root, shell=True, + stdin=subprocess.PIPE, universal_newlines=True) + self.webui = subprocess.Popen(args("webui", "--quiet"), cwd=root) self.listenerqueue = QueueLine(self.listener.stdin) if conf.MouseEnabled: self.listenerqueue.put("start mouse") @@ -138,12 +146,18 @@ def run(self): while not self.initialqueue.empty(): self.listenerqueue.put(self.initialqueue.get()) + self.sessions = db.fetch("sessions", order="start DESC") + self.running = True while self.running: time.sleep(1) class MainApp(getattr(wx, "App", object)): + def InitLocale(self): + # Avoid dialog buttons in native language + pass + def OnInit(self): self.model = Model() self.startupservice = StartupService() @@ -191,8 +205,14 @@ def OnOpenMenu(self, event): """Creates and opens a popup menu for the tray icon.""" menu, makeitem = wx.Menu(), lambda m, x, **k: wx.MenuItem(m, -1, x, **k) mousemenu, keyboardmenu, histmenu = wx.Menu(), wx.Menu(), wx.Menu() - histall_menu, histday_menu, histmon_menu, histdts_menu = wx.Menu(), wx.Menu(), wx.Menu(), wx.Menu() + sessions_menu = wx.Menu() + histall_menu, histmon_menu, histday_menu = wx.Menu(), wx.Menu(), wx.Menu() + histdts_menu, histses_menu = wx.Menu(), wx.Menu() on_category = lambda c: functools.partial(self.OnToggleCategory, c) + on_session = lambda k, s=None: functools.partial(self.OnSessionAction, k, session=s) + on_clear = lambda p, c, s=False: functools.partial(self.OnSessionAction, "clear", category=c) \ + if s else functools.partial(self.OnClearHistory, p, c) + item_ui = makeitem(menu, "&Open statistics") item_startup = makeitem(menu, "Start with &Windows", kind=wx.ITEM_CHECK) \ if self.startupservice.can_start() else None @@ -207,29 +227,51 @@ def OnOpenMenu(self, event): item_keys = makeitem(keyboardmenu, "Log individual &keys", kind=wx.ITEM_CHECK) item_combos = makeitem(keyboardmenu, "Log key &combinations", kind=wx.ITEM_CHECK) - item_vacuum = makeitem(histmenu, "&Repack database to smaller size") - - for m in histall_menu, histmon_menu, histday_menu, histdts_menu: + lastsession = self.model.sessions[0] if self.model.sessions else None + activesession = lastsession if lastsession and not lastsession["end"] else None + activename = format_session(activesession, quote=True, stamp=False) if activesession else None + item_session_start = makeitem(menu, "&Start session ..") + item_session_stop = makeitem(menu, "Stop session%s" % (' ' + activename if activename else "")) + item_session_stop.Enable(bool(activename)) + for session in self.model.sessions[:conf.MaxSessionsInMenu]: + sessmenu = wx.Menu() + item_session_open = makeitem(sessmenu, "Open statistics") + item_session_rename = makeitem(sessmenu, "Rename") + item_session_clear = makeitem(sessmenu, "Clear events") + item_session_delete = makeitem(sessmenu, "Delete") + sessmenu.Bind(wx.EVT_MENU, on_session("open", session), item_session_open) + sessmenu.Bind(wx.EVT_MENU, on_session("rename", session), item_session_rename) + sessmenu.Bind(wx.EVT_MENU, on_session("clear", session), item_session_clear) + sessmenu.Bind(wx.EVT_MENU, on_session("delete", session), item_session_delete) + sessmenu.Append(item_session_open) + sessmenu.Append(item_session_rename) + sessmenu.Append(item_session_clear) + sessmenu.Append(item_session_delete) + sessions_menu.AppendSubMenu(sessmenu, format_session(session)) + + item_vacuum = makeitem(histmenu, "&Repack database to smaller size") + + for m in histall_menu, histmon_menu, histday_menu, histdts_menu, histses_menu: item_all = makeitem(m, "All inputs") - period = "all" if m is histall_menu else "month" if m is histmon_menu else \ - "today" if m is histday_menu else "range" - m.Bind(wx.EVT_MENU, functools.partial(self.OnClearHistory, period, None), - id=item_all.GetId()) + period = "all" if m in (histall_menu, histses_menu) else \ + "month" if m is histmon_menu else "today" if m is histday_menu else "range" + session = (m is histses_menu) + m.Bind(wx.EVT_MENU, on_clear(period, None, session), id=item_all.GetId()) m.Append(item_all) for input, cc in conf.InputTables: item_input = makeitem(m, " %s inputs" % input.capitalize()) - m.Bind(wx.EVT_MENU, functools.partial(self.OnClearHistory, period, input), - id=item_input.GetId()) + m.Bind(wx.EVT_MENU, on_clear(period, input, session), id=item_input.GetId()) m.Append(item_input) for c in cc: item_cat = makeitem(m, " %s" % c) - m.Bind(wx.EVT_MENU, functools.partial(self.OnClearHistory, period, c), - id=item_cat.GetId()) + m.Bind(wx.EVT_MENU, on_clear(period, c, session), id=item_cat.GetId()) m.Append(item_cat) histmenu.AppendSubMenu(histall_menu, "Clear &all history") histmenu.AppendSubMenu(histmon_menu, "Clear this &month") histmenu.AppendSubMenu(histday_menu, "Clear &today") histmenu.AppendSubMenu(histdts_menu, "Clear history &from .. to ..") + item_sessions_clear = histmenu.AppendSubMenu(histses_menu, "Clear from &session ..") + histmenu.Enable(item_sessions_clear.Id, bool(self.model.sessions)) histmenu.AppendSeparator() histmenu.Append(item_vacuum) @@ -249,6 +291,11 @@ def OnOpenMenu(self, event): menu.Append(item_keyboard) menu.AppendSubMenu(keyboardmenu, " Keyboard e&vent categories") menu.AppendSeparator() + menu.Append(item_session_start) + menu.Append(item_session_stop) + item_sessions = menu.AppendSubMenu(sessions_menu, "Sessions") + menu.Enable(item_sessions.Id, bool(self.model.sessions)) + menu.AppendSeparator() menu.AppendSubMenu(histmenu, "Clear events &history") menu.Append(item_console) menu.Append(item_exit) @@ -263,19 +310,20 @@ def OnOpenMenu(self, event): item_combos .Check(conf.KeyboardEnabled and conf.KeyboardCombosEnabled) item_console .Check(self.frame_console.Shown) - menu.Bind(wx.EVT_MENU, self.OnOpenUI, id=item_ui.GetId()) - menu.Bind(wx.EVT_MENU, self.OnVacuum, id=item_vacuum.GetId()) - menu.Bind(wx.EVT_MENU, self.OnToggleStartup, id=item_startup.GetId()) \ - if item_startup else None - menu.Bind(wx.EVT_MENU, on_category("mouse"), id=item_mouse.GetId()) - menu.Bind(wx.EVT_MENU, on_category("keyboard"), id=item_keyboard.GetId()) - menu.Bind(wx.EVT_MENU, on_category("moves"), id=item_moves.GetId()) - menu.Bind(wx.EVT_MENU, on_category("clicks"), id=item_clicks.GetId()) - menu.Bind(wx.EVT_MENU, on_category("scrolls"), id=item_scrolls.GetId()) - menu.Bind(wx.EVT_MENU, on_category("keys"), id=item_keys.GetId()) - menu.Bind(wx.EVT_MENU, on_category("combos"), id=item_combos.GetId()) - menu.Bind(wx.EVT_MENU, self.OnToggleConsole, id=item_console.GetId()) - menu.Bind(wx.EVT_MENU, self.OnClose, id=item_exit.GetId()) + menu.Bind(wx.EVT_MENU, self.OnOpenUI, item_ui) + menu.Bind(wx.EVT_MENU, self.OnVacuum, item_vacuum) + menu.Bind(wx.EVT_MENU, self.OnToggleStartup, item_startup) if item_startup else None + menu.Bind(wx.EVT_MENU, on_category("mouse"), item_mouse) + menu.Bind(wx.EVT_MENU, on_category("keyboard"), item_keyboard) + menu.Bind(wx.EVT_MENU, on_category("moves"), item_moves) + menu.Bind(wx.EVT_MENU, on_category("clicks"), item_clicks) + menu.Bind(wx.EVT_MENU, on_category("scrolls"), item_scrolls) + menu.Bind(wx.EVT_MENU, on_category("keys"), item_keys) + menu.Bind(wx.EVT_MENU, on_category("combos"), item_combos) + menu.Bind(wx.EVT_MENU, on_session("start"), item_session_start) + menu.Bind(wx.EVT_MENU, on_session("stop"), item_session_stop) + menu.Bind(wx.EVT_MENU, self.OnToggleConsole, item_console) + menu.Bind(wx.EVT_MENU, self.OnClose, item_exit) self.trayicon.PopupMenu(menu) @@ -313,8 +361,12 @@ def OnClearHistory(self, period, category, event=None): def OnLogResolution(self, event=None): if not self: return - sizes = [list(wx.Display(i).Geometry) - for i in range(wx.Display.GetCount())] + def make_size(geometry, w, h): + scale = geometry.Width / float(w) + return [int(geometry.X * scale), int(geometry.Y * scale), w, h] + sizes = [make_size(d.Geometry, m.Width, m.Height) + for i in range(wx.Display.GetCount()) + for d in [wx.Display(i)] for m in [d.GetCurrentMode()]] self.model.log_resolution(sizes) def OnOpenUI(self, event): @@ -332,6 +384,61 @@ def OnToggleStartup(self, event): def OnToggleCategory(self, category, event): self.model.toggle(category) + + def OnSessionAction(self, action, event, session=None, category=None): + arg = None + + if "open" == action: + webbrowser.open(conf.WebUrl + "/sessions/%s" % session["id"]) + return + elif "rename" == action: + dlg = wx.TextEntryDialog(None, "Enter new name for session:", "Rename session", + value=session["name"], style=wx.OK | wx.CANCEL) + if self.icons: dlg.SetIcons(self.icons) + dlg.CenterOnScreen() + if wx.ID_OK != dlg.ShowModal(): return + arg = dlg.GetValue().strip() + if not arg or arg == session["name"]: + return + elif "clear" == action: + arg = category + label = ("%s events" % arg) if arg in conf.InputTables else arg or "all events" + arg = arg or "all" + + if not session: + choices = [format_session(x) for x in self.model.sessions] + dlg = wx.SingleChoiceDialog(None, "Clear %s from:" % label, "Clear session", choices) + if self.icons: dlg.SetIcons(self.icons) + dlg.CenterOnScreen() + res, sel = dlg.ShowModal(), dlg.GetSelection() + dlg.Destroy() + if wx.ID_OK != res: return + session = self.model.sessions[sel] + else: + msg = 'Are you sure you want to clear %s from session "%s"?' % (label, session["name"]) + if wx.OK != wx.MessageBox(msg, conf.Title, wx.OK | wx.CANCEL | wx.ICON_WARNING): return + elif "delete" == action: + msg = 'Are you sure you want to delete session "%s"' % session["name"] + res = YesNoCancelMessageBox(msg, conf.Title, wx.ICON_WARNING, + yes="Delete", no="Clear events and delete") + if wx.CANCEL == res: return + if wx.NO == res: + self.model.session_action("clear", session) + elif "start" == action: + arg = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") + dlg = wx.TextEntryDialog(None, "Enter name for new session:", "Start session", + value=arg, style=wx.OK | wx.CANCEL) + if self.icons: dlg.SetIcons(self.icons) + dlg.CenterOnScreen() + res, arg = dlg.ShowModal(), dlg.GetValue().strip() + dlg.Destroy() + if wx.ID_OK != res or not arg: return + elif "stop" != action: + return + + self.model.session_action(action, session, arg) + + def OnToggleConsole(self, event): self.frame_console.Show(not self.frame_console.IsShown()) @@ -389,18 +496,36 @@ def create_shortcut(self, path, target="", workdir="", icon=""): shortcut.save() +def YesNoCancelMessageBox(message, caption, icon=wx.ICON_NONE, + yes="&Yes", no="&No", cancel="Cancel"): + """ + Opens a Yes/No/Cancel messagebox with custom labels, returns dialog result. + + @param icon dialog icon to use, one of wx.ICON_XYZ + @param default default selected button, wx.YES or wx.NO + """ + style = icon | wx.YES | wx.NO | wx.CANCEL + dlg = wx.MessageDialog(None, message, caption, style) + dlg.SetYesNoCancelLabels(yes, no, cancel) + dlg.CenterOnScreen() + res = dlg.ShowModal() + dlg.Destroy() + return res + + def main(): """Program entry point.""" - conf.init() + if conf.Frozen: multiprocessing.freeze_support() + conf.init(), db.init(conf.DbPath, conf.DbStatements) + try: db.execute("PRAGMA journal_mode = WAL") + except Exception: pass if wx: - name = urllib.quote_plus("-".join([conf.Title, conf.DbPath])) + name = re.sub(r"\W", "__", "-".join([conf.Title, conf.DbPath])) singlechecker = wx.SingleInstanceChecker(name) if singlechecker.IsAnotherRunning(): sys.exit() - app = MainApp(redirect=True) # redirect stdout/stderr to wx popup - locale = wx.Locale(wx.LANGUAGE_ENGLISH) # Avoid dialog buttons in native language - app.MainLoop() # stdout/stderr directed to wx popup + MainApp(redirect=True).MainLoop() # stdout/stderr directed to wx popup else: model = Model() if tk: @@ -419,5 +544,4 @@ def main(): if "__main__" == __name__: - if conf.Frozen: multiprocessing.freeze_support() main() diff --git a/inputscope/static/site.css b/inputscope/static/site.css index ac927c2..f17d832 100644 --- a/inputscope/static/site.css +++ b/inputscope/static/site.css @@ -3,7 +3,7 @@ * * @author Erki Suurjaak * @created 07.04.2015 - * @modified 27.01.2021 + * @modified 21.10.2021 */ * { font-family: Tahoma; @@ -63,6 +63,12 @@ #content a { text-decoration: none; } +body.index #content > div { + display: inline-block; +} +body.index #content > div > table { + width: 100%; +} #header a:hover, #content a:hover { text-decoration: underline; } @@ -70,6 +76,23 @@ float: left; position: relative; } +#session { + color: white; + font-size: 1em; + left: 250px; + position: absolute; + top: 4px; +} +#session * { + font-size: 1em; +} +#session div { + font-size: 0.9em; + max-width: 100px; + overflow-x: clip; + text-overflow: ellipsis; + white-space: nowrap; +} #indexlink { font-size: 2em; padding: 10px; @@ -173,7 +196,8 @@ clear: both; overflow: auto; } -#stats.keyboard { +#stats.keyboard, +#stats.sessions { clear: both; float: right; margin-right: 10px; @@ -251,6 +275,18 @@ table.input_index td.periods .periods a.day { table.input_index td.periods .periods span { float: right; } +#stats.sessions tr:first-child { + font-weight: bold; +} +#stats.sessions tr:not(:first-child) td:last-child { + text-align: right; +} +#stats.sessions tr:first-child td:nth-child(2), +table.sessions:not(#stats) td:first-child { + max-width: 200px; + overflow-x: hidden; + text-overflow: ellipsis; +} #overlay { display: none; align-items: center; diff --git a/inputscope/util.py b/inputscope/util.py new file mode 100644 index 0000000..a6842b9 --- /dev/null +++ b/inputscope/util.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +""" +Utilities. + +@author Erki Suurjaak +@created 17.10.2021 +@modified 18.10.2021 +""" +import datetime +import errno +import math +try: import Queue as queue # Py2 +except ImportError: import queue # Py3 +import threading +import time + +try: import wx +except ImportError: wx = None + + +def format_bytes(size, precision=2, inter=" "): + """Returns a formatted byte size (e.g. 421.45 MB).""" + result = "0 bytes" + if size: + UNITS = [("bytes", "byte")[1 == size]] + [x + "B" for x in "KMGTPEZY"] + exponent = min(int(math.log(size, 1024)), len(UNITS) - 1) + result = "%.*f" % (precision, size / (1024. ** exponent)) + result += "" if precision > 0 else "." # Do not strip integer zeroes + result = result.rstrip("0").rstrip(".") + inter + UNITS[exponent] + return result + + +def format_session(session, maxlen=20, quote=False, stamp=True): + """Returns session name, ellipsized, with start datetime appended if different from name.""" + result = session["name"] + if maxlen and len(result) > maxlen: + result = result[:maxlen] + ".." + if quote: + result = '"%s"' % result + dtstr = stamp and format_stamp(session["start"]) + return result if not stamp or dtstr == session["name"] else "%s (%s)" % (result, dtstr) + + +def format_stamp(stamp, fmt="%Y-%m-%d %H:%M"): + """Formats UNIX timestamp or datetime object as datetime string.""" + try: number_types = (float, int, long) # Py2 + except NameError: number_types = (float, int) # Py3 + dt = datetime.datetime.fromtimestamp(stamp) if isinstance(stamp, number_types) else stamp + return dt.strftime(fmt) + + +def format_timedelta(timedelta): + """Formats the timedelta as "3d 40h 23min 23.1sec".""" + dd, rem = divmod(timedelta_seconds(timedelta), 24*3600) + hh, rem = divmod(rem, 3600) + mm, ss = divmod(rem, 60) + items = [] + for c, n in (dd, "d"), (hh, "h"), (mm, "min"), (ss, "sec"): + f = "%d" % c if "sec" != n else str(round(c, 2)).rstrip("0").rstrip(".") + if f != "0": items += [f + n] + return " ".join(items or ["0 seconds"]) + + +def run_later(function, millis=0): + """Runs the function in a later thread.""" + if wx: wx.CallLater(millis, function) + else: threading.Thread(target=lambda: (time.sleep(millis / 1000.), function())).start() + + +def stamp_to_date(stamp): + """Returns UNIX timestamp as datetime.date.""" + return datetime.datetime.fromtimestamp(stamp).date() + + +def timedelta_seconds(timedelta): + """Returns the total timedelta duration in seconds.""" + if not isinstance(timedelta, datetime.timedelta): + return timedelta + return (timedelta.total_seconds() if hasattr(timedelta, "total_seconds") + else timedelta.days * 24 * 3600 + timedelta.seconds + + timedelta.microseconds / 1000000.) + + +def zhex(v): + """Returns number as zero-padded hex, e.g. "0x0C" for 12 and "0x0100" for 256.""" + if not v: return "0x00" + sign, v = ("-" if v < 0 else ""), abs(v) + return "%s0x%0*X" % (sign, 2 * int(1 + math.log(v) / math.log(2) // 8), v) + + +class LineQueue(threading.Thread): + """Reads lines from a file-like object and pushes to self.queue.""" + def __init__(self, input): + threading.Thread.__init__(self) + self.daemon = True + self.input, self.queue = input, queue.Queue() + self.start() + + def run(self): + for line in iter(self.input.readline, ""): + try: line = line.decode("utf-8") # Py2 + except Exception: pass + self.queue.put(line.strip()) + + +class QueueLine(object): + """Queue-like interface for writing lines to a file-like object.""" + def __init__(self, output): self.output = output + def put(self, item): + try: + if "b" in getattr(self.output, "mode", ""): # Py2 + try: item = item.encode("utf-8") + except Exception: pass + self.output.write("%s\n" % item) + self.output.flush() + except IOError as e: + if e.errno != errno.EINVAL: raise # Invalid argument, probably stale pipe diff --git a/inputscope/var/inputscope.db b/inputscope/var/inputscope.db index 4b187ed..de112bd 100644 Binary files a/inputscope/var/inputscope.db and b/inputscope/var/inputscope.db differ diff --git a/inputscope/views/base.tpl b/inputscope/views/base.tpl index 62e9bd0..c4c3328 100644 --- a/inputscope/views/base.tpl +++ b/inputscope/views/base.tpl @@ -7,15 +7,22 @@ Template arguments: period period for events, if any (day like "2020-02-20" or month like "2020-02") days list of available days input "mouse"|"keyboard" + page page being shown like "index" or "input" table events table shown, moves|clicks|scrolls|keys|combos + session session data, if any dbinfo [(database info label, value)] @author Erki Suurjaak @created 07.04.2015 -@modified 26.01.2021 +@modified 17.10.2021 %""" +%from inputscope.util import format_session %WEBROOT = get_url("/") -%period, days = get("period", None), get("days", []) +%INPUTURL, URLARGS = ("/", dict(input=input)) if get("input") else ("/", {}) +%period, days, session = get("period", None), get("days", []), get("session", None) +%if session: +% INPUTURL, URLARGS = "/sessions/" + INPUTURL, dict(URLARGS, session=session["id"]) +%end # if session @@ -26,18 +33,33 @@ Template arguments: - + diff --git a/inputscope/views/input.tpl b/inputscope/views/input.tpl index 1043f27..a3dfc85 100644 --- a/inputscope/views/input.tpl +++ b/inputscope/views/input.tpl @@ -3,42 +3,68 @@ Index page. Template arguments: stats data statistics as {"count": int, "periods": [{"period", "count", "class"}]} + session session data, if any + sessions sessions statistics, as [{name, start, end, ..category counts}] input "mouse"|"keyboard" - table "moves"|"clicks"|"scrolls"|"keys"|"combos", if any @author Erki Suurjaak @created 07.04.2015 -@modified 26.01.2021 +@modified 17.10.2021 %""" +%from inputscope import conf +%from inputscope.util import format_stamp %WEBROOT = get_url("/") -%title = input.capitalize() +%INPUTURL, URLARGS = ("/", dict(input=input)) +%if get("session"): +% INPUTURL, URLARGS = "/sessions/" + INPUTURL, dict(URLARGS, session=session["id"]) +%end # if get("session") +%title, page = input.capitalize(), "input" %rebase("base.tpl", **locals())
%for table, data in stats.items(): - %if not data["count"]: - %continue # for table, data - %end # if not data["count"] - %input = "keyboard" if table in ("keys", "combos") else "mouse" +% if not data["count"]: +% continue # for table, data +% end # if not data["count"] +% input = "keyboard" if table in ("keys", "combos") else "mouse" - + %end # for table, data
{{ table }}
Total:" % input, table=table) }}#{{ data["count"] }}">{{ "{:,}".format(data["count"]) }}
Total:" % INPUTURL, table=table, **URLARGS) }}#{{ data["count"] }}">{{ "{:,}".format(data["count"]) }}
Days:
{{ len([v for v in data["periods"] if "day" == v["class"]]) }}
- %for item in data["periods"]: - /" % input, table=table, period=item["period"]) }}#{{ item["count"] }}">{{ item["period"] }} +% for item in data["periods"]: + /" % INPUTURL, table=table, period=item["period"], **URLARGS) }}#{{ item["count"] }}">{{ item["period"] }} ({{ "{:,}".format(item["count"]) }})
- %end # for item +% end # for item
+ +%did_sessions = False +%for sess in (s for s in sessions if s["count"]): +% if not did_sessions: + + + +% end # if sessions +% did_sessions = True + + + + + +%end # for sess +% if did_sessions: + +
sessions
{{ sess["name"] }}:/", session=sess["id"], input=input) }}#{{ sess["count"] }}">{{ "{:,}".format(sess["count"]) }}from {{ format_stamp(sess["start"]) }} {{ "to %s" % format_stamp(sess["end"]) if sess["end"] else "" }}
+% end # if did_sessions
diff --git a/inputscope/views/session.tpl b/inputscope/views/session.tpl new file mode 100644 index 0000000..38c0bcf --- /dev/null +++ b/inputscope/views/session.tpl @@ -0,0 +1,51 @@ +%""" +Session page. + +Template arguments: + session session data, as {name, start, end} + data session stats, as {category: {count, first, last}} + +@author Erki Suurjaak +@created 15.10.2021 +@modified 19.10.2021 +%""" +%from inputscope import conf +%WEBROOT = get_url("/") +%page = "session" +%rebase("base.tpl", **locals()) + +
+ + + + + + +%for key, val in sessioninfo: + +%end # for key +
Session name{{ session["name"] }}
{{ key }}{{ val }}
+ + +%for input, table in ((k, t) for k, tt in conf.InputTables for t in tt): +% data = stats.get(table, {}) +% if not data.get("count"): +% continue # for table +% end + + +
{{ table }}
Total://", session=session["id"], input=input, table=table) }}#{{ data["count"] }}">{{ "{:,}".format(data["count"]) }} + +
Days: + diff --git a/inputscope/webui.py b/inputscope/webui.py index 6c3950e..bf8df67 100644 --- a/inputscope/webui.py +++ b/inputscope/webui.py @@ -6,7 +6,7 @@ @author Erki Suurjaak @created 06.04.2015 -@modified 12.02.2021 +@modified 19.10.2021 """ import collections import datetime @@ -14,11 +14,14 @@ import os import re import sys +import time import bottle from bottle import hook, request, route -import conf -import db +from . import conf +from . import db +from . util import format_bytes, format_stamp, format_timedelta, stamp_to_date, timedelta_seconds + app = None # Bottle application instance @@ -36,69 +39,121 @@ def server_static(filepath): return bottle.static_file(filepath, root=conf.StaticPath, mimetype=mimetype) -@route("/mouse/") -@route("/mouse/
/") -def mouse(table, period=None): - """Handler for showing mouse statistics for specified type and day.""" - days, input = db.fetch("counts", order="day", type=table), "mouse" - if period and not any(v["day"][:len(period)] == period for v in days): +@route("/sessions/") +def session(session): + """Handler for showing the GUI index page.""" + sess = db.fetchone("sessions", id=session) + if not sess: + return bottle.redirect(request.app.get_url("/")) + + stats = {} # {category: {count, first, last, periods}} + COLS = "COUNT(*) as count, day AS period, 'day' AS class" + where = [("day", (">=", stamp_to_date(sess["start"]))), + ("stamp", (">=", sess["start"]))] + if sess["end"]: where += [("day", ("<=", stamp_to_date(sess["end"]))), + ("stamp", ("<", sess["end"] or time.time()))] + for table in (t for _, tt in conf.InputTables for t in tt): + stats[table] = {"count": 0, "periods": []} + for row in db.fetch(table, COLS, where=where, group="day"): + stats[table]["count"] += row["count"] + stats[table]["periods"] += [row] + + dbinfo, sessioninfo, session = stats_db(conf.DbPath), stats_session(sess, stats), sess + return bottle.template("session.tpl", locals(), conf=conf) + + +@route("/sessions//") +def inputsessionindex(session, input): + """Handler for showing keyboard or mouse page with day and total links.""" + sess = db.fetchone("sessions", id=session) + if not sess: return bottle.redirect(request.app.get_url("/", input=input)) - - count = sum(v["count"] for v in days if not period or v["day"][:len(period)] == period) - tabledays = set(x["type"] for x in db.fetch("counts", day=("LIKE", period + "%"))) if period else {} - where = (("day", period), ) if period else () - if not period: # Mouse tables can have 100M+ rows, total order takes too long + stats = {} # {category: {count, first, last, periods}} + countminmax = "COUNT(*) AS count, MIN(day) AS first, MAX(day) AS last" + where = [("day", (">=", stamp_to_date(sess["start"]))), + ("stamp", (">=", sess["start"]))] + if sess["end"]: where += [("day", ("<=", stamp_to_date(sess["end"]))), + ("stamp", ("<", sess["end"]))] + for table in conf.InputEvents[input]: + stats[table] = db.fetchone(table, "COUNT(*) AS count, MIN(day) AS first, MAX(day) AS last", + where=where) + stats[table]["periods"] = db.fetch(table, "day AS period, COUNT(*) AS count, 'day' AS class", + where=where, group="day", order="day DESC") + + dbinfo, session, sessions = stats_db(conf.DbPath), sess, [] + return bottle.template("input.tpl", locals(), conf=conf) + + +@route("//
") +@route("//
/") +@route("/sessions///
") +@route("/sessions///
/") +def inputdetail(input, table, period=None, session=None): + """Handler for showing mouse/keyboard statistics page.""" + sess = db.fetchone("sessions", id=session) if session else None + if session and not sess: + url, kws = "//
", dict(input=input, table=table) + if period: url, kws = (url + "/", dict(kws, period=period)) + return bottle.redirect(request.app.get_url(url, **kws)) + + where = [("day", (">=", stamp_to_date(sess["start"])))] if sess else [] + if sess and sess["end"]: where += [("day", ("<=", stamp_to_date(sess["end"])))] + days = db.fetch("counts", order="day", where=where, type=table) + if period and not any(v["day"][:len(period)] == period for v in days): + url, kws = "/", dict(input=input) + if session: url, kws = ("/sessions/" + url, dict(kws, session=session)) + return bottle.redirect(request.app.get_url(url, **kws)) + + if sess: + where += [("stamp", (">=", sess["start"]))] + if sess["end"]: where += [("stamp", ("<", sess["end"]))] + days = db.fetch(table, "day || '' AS day, COUNT(*) AS count", where=where, group="day", order="day") + where2 = where + ([("day", ("LIKE", period + "%"))] if period else []) + count = db.fetchone(table, "COUNT(*) AS count", where=where2)["count"] + tabledays = set(t for _, tt in conf.InputTables for t in tt + if t != table and db.fetchone(t, "1", where=where2)) + else: + count = sum(v["count"] for v in days if not period or v["day"][:len(period)] == period) + tabledays = set(x["type"] for x in db.fetch("counts", day=("LIKE", period + "%"))) if period else {} + + if not period and "mouse" == input: # Mouse tables can have 100M+ rows, total order takes too long mydays, mycount = [], 0 for myday in days: mydays, mycount = mydays + [myday["day"]], mycount + myday["count"] if mycount >= conf.MaxEventsForStats: break # for myday if len(mydays) != len(days): - where = (("day", ("IN", mydays)), ) - elif len(period) < 8: # Month period, query by known month days - mydays = [v["day"] for v in days if v["day"][:7] == period] - where = (("day", ("IN", mydays)), ) - - events = db.select(table, where=where, order="stamp", limit=conf.MaxEventsForStats) - stats, positions, events = stats_mouse(events, table, count) - dbinfo = stats_db(conf.DbPath) - return bottle.template("heatmap_mouse.tpl", locals(), conf=conf) - - -@route("/keyboard/
") -@route("/keyboard/
/") -def keyboard(table, period=None): - """Handler for showing the keyboard statistics page.""" - days, input = db.fetch("counts", order="day", type=table), "keyboard" - if period and not any(v["day"][:len(period)] == period for v in days): - return bottle.redirect(request.app.get_url("/", input=input)) - - count = sum(v["count"] for v in days if not period or v["day"][:len(period)] == period) - tabledays = set(x["type"] for x in db.fetch("counts", day=("LIKE", period + "%"))) if period else {} - - where = (("day", period), ) if period else () - if period and len(period) < 8: # Month period, query by known month days + where += [("day", ("IN", mydays))] + elif period and len(period) < 8: # Month period, query by known month days mydays = [v["day"] for v in days if v["day"][:7] == period] - where = (("day", ("IN", mydays)), ) - cols, group = "realkey AS key, COUNT(*) AS count", "realkey" - counts_display = counts = db.fetch(table, cols, where, group, "count DESC") - if "combos" == table: - counts_display = db.fetch(table, "key, COUNT(*) AS count", where, - "key", "count DESC") + where += [("day", ("IN", mydays))] + elif period: + where += [("day", period)] + if "keyboard" == input: + cols, group = "realkey AS key, COUNT(*) AS count", "realkey" + counts_display = counts = db.fetch(table, cols, where, group, "count DESC") + if "combos" == table: + counts_display = db.fetch(table, "key, COUNT(*) AS count", where, + "key", "count DESC") events = db.select(table, where=where, order="stamp", limit=conf.MaxEventsForStats) - stats, events = stats_keyboard(events, table, count) - dbinfo = stats_db(conf.DbPath) - return bottle.template("heatmap_keyboard.tpl", locals(), conf=conf) + if "mouse" == input: + stats, positions, events = stats_mouse(events, table, count) + else: + stats, events = stats_keyboard(events, table, count) + dbinfo, session = stats_db(conf.DbPath), sess + template = "heatmap_mouse.tpl" if "mouse" == input else "heatmap_keyboard.tpl" + return bottle.template(template, locals(), conf=conf) @route("/") def inputindex(input): """Handler for showing keyboard or mouse page with day and total links.""" - stats = {} + if input not in conf.InputEvents: + return bottle.redirect(request.app.get_url("/")) + stats = {} # {category: {count, first, last, periods}} countminmax = "SUM(count) AS count, MIN(day) AS first, MAX(day) AS last" - tables = ("moves", "clicks", "scrolls") if "mouse" == input else ("keys", "combos") - for table in tables: + for table in conf.InputEvents[input]: stats[table] = db.fetchone("counts", countminmax, type=table) periods, month = [], None for data in db.fetch("counts", "day AS period, count, 'day' AS class", order="day DESC", type=table): @@ -108,7 +163,7 @@ def inputindex(input): month["count"] += data["count"] periods.append(data) stats[table]["periods"] = periods - dbinfo = stats_db(conf.DbPath) + dbinfo, sessions = stats_db(conf.DbPath), stats_sessions(input=input) return bottle.template("input.tpl", locals(), conf=conf) @@ -124,14 +179,14 @@ def index(): for func, key in [(min, "first"), (max, "last")]: stats[input][key] = (row[key] if key not in stats[input] else func(stats[input][key], row[key])) - dbinfo = stats_db(conf.DbPath) + dbinfo, sessions = stats_db(conf.DbPath), stats_sessions() return bottle.template("index.tpl", locals(), conf=conf) def stats_keyboard(events, table, count): """Return (statistics, collated and max-limited events) for keyboard events.""" deltas, first, last = [], None, None - sessions, session = [], None + tsessions, tsession = [], None UNBROKEN_DELTA = datetime.timedelta(seconds=conf.KeyboardSessionMaxDelta) blank = collections.defaultdict(lambda: collections.defaultdict(int)) collated = [blank.copy()] # [{dt, keys: {key: count}}] @@ -145,17 +200,17 @@ def stats_keyboard(events, table, count): delta = e["dt"] - last["dt"] deltas.append(delta) if delta > UNBROKEN_DELTA: - session = None + tsession = None else: - if not session: - session = [] - sessions.append(session) - session.append(delta) + if not tsession: + tsession = [] + tsessions.append(tsession) + tsession.append(delta) collated[-1]["dt"] = e["dt"] collated[-1]["keys"][e["realkey"]] += 1 last = e - longest_session = max(sessions + [[datetime.timedelta()]], key=lambda x: sum(x, datetime.timedelta())) + longest_session = max(tsessions + [[datetime.timedelta()]], key=lambda x: sum(x, datetime.timedelta())) stats = [ ("Average combo interval", format_timedelta(sum(deltas, datetime.timedelta()) / len(deltas))), @@ -166,24 +221,23 @@ def stats_keyboard(events, table, count): ("Average key interval", format_timedelta(sum(deltas, datetime.timedelta()) / len(deltas))), ("Typing sessions (key interval < %ss)" % UNBROKEN_DELTA.seconds, - len(sessions)), + len(tsessions)), ("Average keys in session", - sum(len(x) + 1 for x in sessions) / len(sessions) if sessions else 0), + int(round(sum(len(x) + 1 for x in tsessions) / len(tsessions))) if tsessions else 0), ("Average session duration", format_timedelta(sum((sum(x, datetime.timedelta()) - for x in sessions), datetime.timedelta()) / (len(sessions) or 1))), + for x in tsessions), datetime.timedelta()) / (len(tsessions) or 1))), ("Longest session duration", format_timedelta(sum(longest_session, datetime.timedelta()))), ("Keys in longest session", len(longest_session) + 1), ("Most keys in session", - max(len(x) + 1 for x in sessions) if sessions else 0), + max(len(x) + 1 for x in tsessions) if tsessions else 0), ] if deltas and "keys" == table else [] if deltas: stats += [("Total time interval", format_timedelta(last["dt"] - first["dt"]))] return stats, collated[:conf.MaxEventsForReplay] - def stats_mouse(events, table, count): """Returns (statistics, positions, max-limited events).""" first, last, totaldelta = None, None, datetime.timedelta() @@ -195,7 +249,7 @@ def stats_mouse(events, table, count): counts, lasts = collections.Counter(), {} # {display: last event} distances = collections.defaultdict(int) SIZES = {} # Scale by desktop size at event time; {display: [{size}, ]} - for row in db.fetch("screen_sizes", order=("dt",)): + for row in db.fetch("screen_sizes", order="dt"): row.update({0: row["x"], 2: row["w"] / float(HS[0]), 1: row["y"], 3: row["h"] / float(HS[1])}) SIZES.setdefault(row["display"], []).append(row) @@ -241,13 +295,13 @@ def stats_mouse(events, table, count): ("", "%.4f meters per second" % (distance * conf.PixelLength / (seconds or 1))), ] elif "scrolls" == table and count: - stats = filter(bool, [("Scrolls per hour", + stats = list(filter(bool, [("Scrolls per hour", int(count / (timedelta_seconds(last["dt"] - first["dt"]) / 3600 or 1))), ("Average interval", totaldelta / (count or 1)), ("Scrolls down", counts["-dy"]), ("Scrolls up", counts["dy"]), ("Scrolls left", counts["dx"]) if counts["dx"] else None, - ("Scrolls right", counts["-dx"]) if counts["-dx"] else None, ]) + ("Scrolls right", counts["-dx"]) if counts["-dx"] else None, ])) elif "clicks" == table and count: NAMES = {"1": "Left", "2": "Right", "3": "Middle"} stats = [("Clicks per hour", @@ -262,6 +316,32 @@ def stats_mouse(events, table, count): return stats, positions, all_events +def stats_sessions(input=None): + """Returns a list of sessions with total event counts.""" + sessions = db.fetch("sessions", order="start DESC") + for sess in sessions: + sess["count"] = 0 + where = [("day", (">=", sess["day1"]))] + if sess["end"]: + where += [("day", ("<=", sess["day1"])), ("stamp", ("<", sess["end"]))] + where += [("stamp", (">=", sess["start"]))] + for table in (t for k, tt in conf.InputTables if input in (None, k) for t in tt): + sess["count"] += db.fetchone(table, "COUNT(*) AS count", where)["count"] + return sessions + + +def stats_session(session, stats): + """Returns session information as [(label, value), ].""" + FMT = "%Y-%m-%d %H:%M:%S" + result = [("Started", format_stamp(session["start"], FMT)), + ("Ended", format_stamp(session["end"], FMT) if session["end"] else ""), + ("Duration", format_timedelta((session["end"] or time.time()) - session["start"])), + ("Mouse", "{:,}".format(sum(stats.get(t, {}).get("count", 0) for t in conf.InputEvents["mouse"]))), + ("Keyboard", "{:,}".format(sum(stats.get(t, {}).get("count", 0) for t in conf.InputEvents["keyboard"]))), + ("Total", "{:,}".format(sum(stats.get(t, {}).get("count", 0) for _, tt in conf.InputTables for t in tt))), ] + return result + + def stats_db(filename): """Returns database information as [(label, value), ].""" result = [("Database", filename), @@ -273,37 +353,7 @@ def stats_db(filename): for name, tables in conf.InputTables: countstr = "{:,}".format(sum(cmap.get(t) or 0 for t in tables)) result += [("%s events" % name.capitalize(), countstr)] - return result - - -def timedelta_seconds(timedelta): - """Returns the total timedelta duration in seconds.""" - return (timedelta.total_seconds() if hasattr(timedelta, "total_seconds") - else timedelta.days * 24 * 3600 + timedelta.seconds + - timedelta.microseconds / 1000000.) - - -def format_timedelta(timedelta): - """Formats the timedelta as "3d 40h 23min 23.1sec".""" - dd, rem = divmod(timedelta_seconds(timedelta), 24*3600) - hh, rem = divmod(rem, 3600) - mm, ss = divmod(rem, 60) - items = [] - for c, n in (dd, "d"), (hh, "h"), (mm, "min"), (ss, "sec"): - f = "%d" % c if "second" != n else str(c).rstrip("0").rstrip(".") - if f != "0": items += [f + n] - return " ".join(items or ["0 seconds"]) - - -def format_bytes(size, precision=2, inter=" "): - """Returns a formatted byte size (e.g. 421.45 MB).""" - result = "0 bytes" - if size: - UNITS = [("bytes", "byte")[1 == size]] + [x + "B" for x in "KMGTPEZY"] - exponent = min(int(math.log(size, 1024)), len(UNITS) - 1) - result = "%.*f" % (precision, size / (1024. ** exponent)) - result += "" if precision > 0 else "." # Do not strip integer zeroes - result = result.rstrip("0").rstrip(".") + inter + UNITS[exponent] + result += [("Sessions", db.fetchone("sessions", "COUNT(*) AS count")["count"])] return result diff --git a/setup.py b/setup.py index 56b31c4..54ffdeb 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ @author Erki Suurjaak @created 29.04.2015 -@modified 10.02.2021 +@modified 21.10.2021 ------------------------------------------------------------------------------ """ import setuptools @@ -31,7 +31,7 @@ packages=setuptools.find_packages(), include_package_data=True, # Use MANIFEST.in for data files classifiers=[ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: End Users/Desktop", "Operating System :: Microsoft :: Windows", "Operating System :: Unix", @@ -42,19 +42,15 @@ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", ], long_description_content_type="text/markdown", long_description= """Mouse and keyboard input heatmap visualizer and statistics. -Three components: - -- main - wxPython desktop tray program, runs listener and webui -- listener - logs mouse and keyboard input -- webui - web frontend for statistics and heatmaps - -Listener and web-UI components can be run separately, or launched from main. +Runs a tray program that logs mouse and keyboard input events to a local database, +and provides a local web page for viewing statistics and heatmaps by day or month. Data is kept in an SQLite database. """,