from {{ format_stamp(sess["start"]) }} {{ "to %s" % format_stamp(sess["end"]) if sess["end"] else "" }}
+
+%end # for sess
+%if sessions:
+
+
+%end # if sessions
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"
+%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
+
+
/")
-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.
""",