├── track ├── core │ ├── desktop_usage_info │ │ ├── backend_x11 │ │ │ ├── __init__.py │ │ │ ├── applicationinfo.py │ │ │ └── idle.py │ │ ├── backend_windows │ │ │ ├── __init__.py │ │ │ ├── applicationinfo.py │ │ │ └── idle.py │ │ └── __init__.py │ ├── errors.py │ ├── __init__.py │ ├── util.py │ ├── time_tracker.py │ ├── common.py │ └── active_applications.py ├── __init__.py ├── track.desktop ├── track-server ├── ui │ ├── qt_common.py │ ├── qreordertableview.py │ ├── qspoiler.py │ ├── active_applications_qtmodel.py │ ├── rules_model_qt.py │ ├── mainwindow.py │ ├── time_tracker_qt.py │ ├── timegraph.py │ ├── __init__.py │ └── mainwindow.ui ├── cli.py └── server │ └── __init__.py ├── .gitignore ├── track-screenshot.png ├── .gitmodules ├── poetry.toml ├── test ├── test_rules_model.py ├── test_time_tracker.py ├── test_track_qt_common.py ├── test_applicationinfo.py ├── test_server.py ├── test_timegraph.py └── test_active_applications.py ├── track_dev ├── track-cli ├── track.wpr ├── doc ├── architecture-thoughts.txt └── architecture.svg ├── .pre-commit-config.yaml ├── progress.md ├── pyproject.toml ├── Readme.md └── LICENSE /track/core/desktop_usage_info/backend_x11/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /track/core/desktop_usage_info/backend_windows/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .coverage 3 | *.json 4 | *~ 5 | ~* 6 | *.pyc 7 | *.wpu 8 | /dist 9 | -------------------------------------------------------------------------------- /track-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frans-fuerst/track/HEAD/track-screenshot.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "dev"] 2 | path = dev 3 | url = https://projects.om-office.de/frans/dev.git 4 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/python-poetry/poetry/issues/108 2 | [virtualenvs] 3 | create = true 4 | in-project = true 5 | -------------------------------------------------------------------------------- /track/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | 6 | 7 | def application_root_dir(): 8 | return os.path.dirname(__file__[max(0, __file__.rfind(":")) :]) 9 | -------------------------------------------------------------------------------- /test/test_rules_model.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8 -*- 3 | 4 | from track_qt import rules_model_qt 5 | 6 | 7 | def test_rules_model(): 8 | aa = rules_model_qt(None) 9 | 10 | 11 | if __name__ == "__main__": 12 | test_rules_model() 13 | -------------------------------------------------------------------------------- /test/test_time_tracker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from track_base import time_tracker 5 | 6 | 7 | def test_time_tracker(): 8 | aa = time_tracker() 9 | 10 | 11 | if __name__ == "__main__": 12 | test_time_tracker() 13 | -------------------------------------------------------------------------------- /track_dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Executable entry point for development purposes only""" 4 | 5 | import re 6 | import sys 7 | 8 | from track.cli import main 9 | 10 | if __name__ == "__main__": 11 | sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) 12 | sys.exit(main()) 13 | -------------------------------------------------------------------------------- /track/core/errors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """ Contains exception types that can be transported between server and client 5 | """ 6 | 7 | 8 | class RequestMalformed(Exception): 9 | """Something wrong about the request sent (see .error)""" 10 | 11 | 12 | class NotConnected(RuntimeError): 13 | """""" 14 | -------------------------------------------------------------------------------- /track/track.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Version=1.0 3 | Name=Track 4 | Comment=Record your computer work 5 | Categories=Office;WordProcessor; 6 | Exec=track 7 | Type=Application 8 | Icon=ReText-7.0.4.data/data/share/retext/icons/retext.svg 9 | StartupWMClass=ReText 10 | MimeType=text/markdown;text/x-rst; 11 | Keywords=Text;Editor;Markdown;reStructuredText; 12 | -------------------------------------------------------------------------------- /track-cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- encoding: utf-8 -*- 3 | # pylint: disable=expression-not-assigned 4 | 5 | """Extension free executable stub for track-cli 6 | """ 7 | from contextlib import suppress 8 | 9 | from track.cli import main 10 | 11 | if __name__ == "__main__": 12 | with suppress(KeyboardInterrupt, BrokenPipeError): 13 | raise SystemExit(main()) 14 | -------------------------------------------------------------------------------- /track/track-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- encoding: utf-8 -*- 3 | # pylint: disable=expression-not-assigned 4 | 5 | """Executable stub for track-server 6 | """ 7 | 8 | from contextlib import suppress 9 | 10 | from track.server import main 11 | 12 | if __name__ == "__main__": 13 | with suppress(KeyboardInterrupt, BrokenPipeError): 14 | raise SystemExit(main()) 15 | 16 | -------------------------------------------------------------------------------- /test/test_track_qt_common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8 -*- 3 | 4 | import track_qt 5 | 6 | 7 | def test_change_emitter(): 8 | class mock: 9 | pass 10 | 11 | m = mock() 12 | ce = track_qt.change_emitter(m) 13 | 14 | 15 | def test_matrix_table_model(): 16 | mtm = track_qt.matrix_table_model(None) 17 | 18 | 19 | if __name__ == "__main__": 20 | test_change_emitter() 21 | test_matrix_table_model() 22 | -------------------------------------------------------------------------------- /track/core/desktop_usage_info/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class TrackServerError(Exception): 5 | pass 6 | 7 | 8 | class WindowInformationError(TrackServerError): 9 | pass 10 | 11 | 12 | class ToolError(TrackServerError): 13 | pass 14 | 15 | 16 | if os.name == "posix": 17 | from .backend_x11 import applicationinfo, idle 18 | elif os.name == "nt": 19 | from .backend_windows import applicationinfo, idle 20 | else: 21 | raise Exception("currenty only 'posix' and 'win' are supported systems") 22 | -------------------------------------------------------------------------------- /track/core/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from collections import namedtuple 5 | 6 | version_info = namedtuple("version_info", ["major", "minor", "micro", "patch"]) 7 | version_info = version_info(major=2020, minor=5, micro=8, patch=0) 8 | 9 | from . import errors, util 10 | from .active_applications import ActiveApplications 11 | from .common import AppInfo, Category, Minute, mins_to_dur, secs_to_dur, today_int 12 | from .time_tracker import TimeTracker 13 | from .util import catch, exception_to_string, log 14 | -------------------------------------------------------------------------------- /track/core/desktop_usage_info/backend_windows/applicationinfo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Retrieve desktop usage info - Windows version""" 5 | 6 | from win32gui import GetForegroundWindow, GetWindowText 7 | 8 | 9 | def _get_active_window_title(): 10 | return GetWindowText(GetForegroundWindow()) 11 | 12 | 13 | def _get_active_process_name(): 14 | return "" 15 | 16 | 17 | def get_active_window_information(): 18 | return { 19 | "TITLE": _get_active_window_title(), 20 | # "PID": ??? 21 | # "COMMAND": ??? 22 | } 23 | -------------------------------------------------------------------------------- /track.wpr: -------------------------------------------------------------------------------- 1 | #!wing 2 | #!version=9.0 3 | ################################################################## 4 | # Wing project file # 5 | ################################################################## 6 | [project attributes] 7 | proj.directory-list = [{'dirloc': loc('.'), 8 | 'excludes': ['.venv'], 9 | 'filter': '*', 10 | 'include_hidden': False, 11 | 'recursive': True, 12 | 'watch_for_changes': True}] 13 | proj.file-type = 'shared' 14 | proj.main-file = loc('track_dev') 15 | -------------------------------------------------------------------------------- /test/test_applicationinfo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | import time 6 | 7 | from desktop_usage_info import applicationinfo, idle 8 | 9 | 10 | def test_application_info(): 11 | for i in range(2): 12 | info = applicationinfo.get_active_window_information() 13 | assert "PID" in info 14 | print("PID: ", info["PID"]) 15 | 16 | assert "TITLE" in info 17 | print("TITLE: ", info["TITLE"]) 18 | 19 | assert "COMMAND" in info 20 | print("COMMAND: ", info["COMMAND"]) 21 | 22 | time.sleep(1) 23 | 24 | 25 | if __name__ == "__main__": 26 | test_application_info() 27 | -------------------------------------------------------------------------------- /test/test_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import subprocess 5 | import sys 6 | import time 7 | 8 | import zmq 9 | 10 | 11 | def test_track_server(): 12 | p = subprocess.Popen([sys.executable, "./track_server.py"]) 13 | 14 | context = zmq.Context() 15 | req_socket = context.socket(zmq.REQ) 16 | req_socket.connect("tcp://127.0.0.1:3456") 17 | time.sleep(2) 18 | req_socket.send_json({"type": "quit"}) 19 | req_socket.recv_json() 20 | 21 | print("wait for server to terminate") 22 | p.wait() 23 | print("server terminated") 24 | 25 | 26 | if __name__ == "__main__": 27 | test_track_server() 28 | -------------------------------------------------------------------------------- /track/core/desktop_usage_info/backend_windows/idle.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | 6 | if os.name == "nt": 7 | 8 | from ctypes import Structure, byref, c_uint, sizeof, windll 9 | 10 | class LASTINPUTINFO(Structure): 11 | _fields_ = [ 12 | ("cbSize", c_uint), 13 | ("dwTime", c_uint), 14 | ] 15 | 16 | def getIdleSec() -> int: 17 | lastInputInfo = LASTINPUTINFO() 18 | lastInputInfo.cbSize = sizeof(lastInputInfo) 19 | windll.user32.GetLastInputInfo(byref(lastInputInfo)) 20 | millis = windll.kernel32.GetTickCount() - lastInputInfo.dwTime 21 | return int(millis / 1000.0) 22 | -------------------------------------------------------------------------------- /test/test_timegraph.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import signal 5 | import sys 6 | 7 | import track_qt 8 | from PyQt5 import Qt, QtCore, QtWidgets, uic 9 | 10 | 11 | class test_ui(QtWidgets.QMainWindow): 12 | def __init__(self): 13 | super().__init__() 14 | tg = track_qt.timegraph(self) 15 | self.show() 16 | q = QtCore.QTimer() 17 | q.singleShot(1000, self.quit) 18 | 19 | def quit(self): 20 | print("quit()") 21 | QtCore.QCoreApplication.instance().quit() 22 | 23 | 24 | def test_timegraph(): 25 | app = QtWidgets.QApplication(sys.argv) 26 | mainwindow = test_ui() 27 | signal.signal(signal.SIGINT, lambda signal, frame: sigint_handler(signal, mainwindow)) 28 | 29 | # catch the interpreter every now and then to be able to catch 30 | # signals 31 | timer = QtCore.QTimer() 32 | timer.start(200) 33 | timer.timeout.connect(lambda: None) 34 | 35 | app.exec_() 36 | 37 | 38 | if __name__ == "__main__": 39 | test_timegraph() 40 | -------------------------------------------------------------------------------- /doc/architecture-thoughts.txt: -------------------------------------------------------------------------------- 1 | Thoughts on architecture 2 | ======================== 3 | 4 | ---------------------------------------------------------------------------- 5 | information about running processes: keeping original data vs. consolidating equal process samples 6 | 7 | Given a fixed set of rules it would be easy to just save some meta information 8 | about a process 9 | 10 | Problem is that setting a new rule would make all preprocessed data 11 | inconsistent. 12 | 13 | So we have two plausible approaches here: 14 | - keeping all information (process name, title) and process it on load 15 | - saving pre-processed data 16 | 17 | My current intuition is to save original data and keep a pre-processed 18 | mirror which is accessible via API. 19 | ---------------------------------------------------------------------------- 20 | 21 | ---------------------------------------------------------------------------- 22 | whole in one application with API for http/CLI vs. standalone server. 23 | 24 | Currently developement is done in favor for a standalone/modularized approach. 25 | 26 | Reasons are: 27 | - better separation of concerns. Maybe s.o. only wants to use the server or 28 | event wants to refactor it for a different purpose 29 | - better maintainability (testability, clearer API) 30 | - flexibility for different UIs 31 | - Licensing issues (e.g. pyqt is GPL and track should not) 32 | ---------------------------------------------------------------------------- 33 | 34 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # To install pre-commit hooks, install `pre-commit` and activate it here: 2 | # pip3 install pre-commit 3 | # pre-commit install 4 | # 5 | --- 6 | default_stages: 7 | - commit 8 | - push 9 | - manual 10 | repos: 11 | - repo: local 12 | hooks: 13 | - id: check-shellscripts 14 | name: Check Shell Scripts 15 | entry: dev/check-shellscripts 16 | language: script 17 | types: [file, shell] 18 | - id: check-python-formatters 19 | name: Check Python Formatting 20 | entry: dev/check-python-format 21 | language: script 22 | types: [file, python] 23 | - id: check-python-linting 24 | name: Check Python Linting 25 | entry: dev/check-python-linting 26 | language: script 27 | types: [file, python] 28 | #- id: check-python-doctest 29 | # name: Doctests 30 | # entry: dev/check-python-doctests 31 | # language: script 32 | # types: [file, python] 33 | # verbose: true 34 | - id: check-python-typing 35 | name: Check Python Type Hinting 36 | entry: dev/check-python-typing 37 | language: script 38 | types: [file, python] 39 | - id: check-yaml-linting 40 | name: Check YAML Linting 41 | entry: dev/check-yaml-linting 42 | language: script 43 | types: [file, yaml] 44 | -------------------------------------------------------------------------------- /test/test_active_applications.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8 -*- 3 | 4 | import json 5 | import os 6 | import re 7 | 8 | import track_base 9 | 10 | 11 | def test_active_applications(): 12 | aa = track_base.active_applications() 13 | 14 | 15 | def test_import(): 16 | a = track_base.active_applications() 17 | _total_dur = 0 18 | _count = 0 19 | _lunch_time = 12 * 60 + 50 20 | for root, dirs, files in os.walk("."): 21 | for _f in [f for f in sorted(files) if f.endswith(".json")]: 22 | _file = os.path.join(root, _f) 23 | c = json.loads(open(_file).read().decode()) 24 | try: 25 | a.from_dict(c) 26 | except Exception as ex: 27 | print('ERROR: could not load "%s"' % _file) 28 | print('ERROR: "%s"' % ex) 29 | continue 30 | _end = a.end_index() 31 | _begin = a.begin_index() 32 | _dur = _end - _begin 33 | 34 | (_b, _e), _a = a.info_at(_lunch_time) 35 | _lunch_dur = _e - _b 36 | if not (_e - _b >= 30 and _a == "idle"): 37 | _lunch_dur = 0 38 | print("WARNING: no lunch time found for %s" % _file) 39 | _dur -= _lunch_dur 40 | if _dur > 300: 41 | _total_dur += _dur 42 | _count += 1 43 | else: 44 | print('WARNING: ignore "%s" - less than 5h tracked' % _file) 45 | 46 | print( 47 | "f: %23s, count: %3d, begin: %s (%4d), end: %s (%4d), lunch: %7s, dur: %7s" 48 | % ( 49 | _file, 50 | a.count(), 51 | track_base.mins_to_date(_begin), 52 | _begin, 53 | track_base.mins_to_date(_end), 54 | _end, 55 | track_base.mins_to_dur(_lunch_dur), 56 | track_base.mins_to_dur(_dur), 57 | ) 58 | ) 59 | # print(re.search('track-.*.json', _file) is not None) 60 | 61 | if _count > 0: 62 | print(track_base.mins_to_dur(_total_dur / _count)) 63 | 64 | 65 | if __name__ == "__main__": 66 | test_active_applications() 67 | test_import() 68 | -------------------------------------------------------------------------------- /track/ui/qt_common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Things needed by several components""" 5 | 6 | from abc import ABC, abstractmethod 7 | 8 | from PyQt5 import QtCore, QtGui 9 | 10 | from ..core import Category 11 | 12 | 13 | def CategoryColor(category): 14 | return { 15 | Category.IDLE: QtCore.Qt.white, 16 | Category.UNASSIGNED: QtGui.QColor(206, 92, 0), 17 | Category.WORK: QtCore.Qt.darkCyan, 18 | Category.PRIVATE: QtCore.Qt.cyan, 19 | Category.BREAK: QtCore.Qt.green, 20 | }.get(category, QtCore.Qt.red) 21 | 22 | 23 | class TimechartDataprovider(ABC): 24 | @abstractmethod 25 | def date(self): 26 | pass 27 | 28 | @abstractmethod 29 | def initialized(self): 30 | pass 31 | 32 | @abstractmethod 33 | def begin_index(self): 34 | pass 35 | 36 | @abstractmethod 37 | def end_index(self): 38 | pass 39 | 40 | @abstractmethod 41 | def info_at(self, index: int): 42 | pass 43 | 44 | @abstractmethod 45 | def daily_note(self) -> str: 46 | pass 47 | 48 | @abstractmethod 49 | def category_at(self, index: int): 50 | pass 51 | 52 | @abstractmethod 53 | def current_minute(self): 54 | pass 55 | 56 | @abstractmethod 57 | def time_total(self): 58 | pass 59 | 60 | @abstractmethod 61 | def time_active(self): 62 | pass 63 | 64 | @abstractmethod 65 | def time_work(self): 66 | pass 67 | 68 | @abstractmethod 69 | def time_private(self): 70 | pass 71 | 72 | @abstractmethod 73 | def time_idle(self): 74 | pass 75 | 76 | @abstractmethod 77 | def clip_from(self, index: str) -> None: 78 | pass 79 | 80 | @abstractmethod 81 | def clip_to(self, index: int) -> None: 82 | pass 83 | 84 | 85 | class change_emitter: 86 | def __init__(self, emitter): 87 | self._emitter = emitter 88 | 89 | def __enter__(self): 90 | self._emitter.layoutAboutToBeChanged.emit() 91 | return self 92 | 93 | def __exit__(self, _type, _value, _tb): 94 | self._emitter.layoutChanged.emit() 95 | 96 | 97 | class SimpleQtThread(QtCore.QThread): 98 | def __init__(self, target): 99 | super().__init__() 100 | self.run = target 101 | self.start() 102 | -------------------------------------------------------------------------------- /progress.md: -------------------------------------------------------------------------------- 1 | # Development Progress 2 | 3 | ## ToDo 4 | 5 | ### version 2.0 6 | - [ ] Allow subsequent assignment of time sections to private or work 7 | - [ ] Provide usage statistics over a week/month/.. 8 | - [ ] Connect to scheduling tools like Outlook to get more information 9 | - [ ] Support more states (private, project1, project2, ...) 10 | - [ ] Statistics: compare yourself with your average data 11 | - [ ] Statistics: draw efficiency graph 12 | - [ ] Click and mark sections for summary and subsequent assignments 13 | - [ ] Get parent of window (e.g. 'file changed') 14 | - [ ] Command line tool for statistics, tests etc. 15 | - [ ] Ability to import/combine day-charts 16 | - [ ] Markers for tagging activities 17 | - [ ] Notification when time goal achieved 18 | - [ ] Allow Multi-Device use (e.g. store hostname in tracker data) 19 | 20 | ### version 1.0 21 | - [ ] Highlight current app 22 | - [ ] Find a better name (suggestions?) (time, time machine, eta, invest) 23 | - [ ] Generate distribution script for Linux 24 | - [ ] generate distribution script for Windows 25 | - [ ] Preprocess title 26 | - [ ] Markdown-Hightlight 27 | 28 | 29 | ### release notes 30 | 31 | #### 2020.05.26 32 | - [ ] Remove category indices from UI 33 | - [ ] track theme 34 | - [ ] re-establish tests 35 | - [ ] track-cli cleanup 36 | - [ ] Complexer search: search in hostname, process name, window title 37 | - [ ] Configure case sensibility in rules 38 | - [ ] Basic evaluation 39 | - [ ] Help: Spoiler with Readme 40 | - [ ] Store spoiler visibility config 41 | 42 | #### 2020.05.26 43 | - [?] resizable spoiler sections 44 | - [x] Git update notification 45 | - [x] Log to UI 46 | - [x] Fix icon/title/status icon 47 | - [x] Reorder rules with drag&drop 48 | - [x] Allow editing private note 49 | - [x] Allow editing rules 50 | - [x] Improved UI 51 | - [x] Add CLI for manual investigation 52 | - [x] Drop Python2 support and massively improve code quality 53 | 54 | #### 2016.03.21 55 | - [x] split up client/server (server can run on startup) 56 | - [x] run UI in system tray 57 | - [x] allow regex editing 58 | - [x] autosave 59 | - [x] support to Python 3 (needs ckwnd for python3) 60 | - [x] support regex rules for work/private 61 | - [x] allow detailed per minute information when hovering over time bar 62 | - [x] support Windows 63 | 64 | #### 2015.03.30 65 | - [x] start/end of working day is defined by usage rather than run time 66 | - [x] recognize end of day and start over 67 | - [x] save day-log and resume on restart 68 | - [x] display all times with h:mm 69 | - [x] add total, active, private ratio 70 | 71 | #### 2015.03.22 72 | - [x] work/private/inactive states in time bar 73 | - [x] sort application table 74 | - [x] show time bar indicating active and inactive times 75 | - [x] show list with active applications 76 | - [x] show active time, start time 77 | - [x] retrieve process name 78 | 79 | #### 2015.03.20 80 | - [x] read current window title 81 | - [x] read idle times with help from Gajim 82 | 83 | #### 2015.03.18 84 | - [X] big bang: http://productivity.stackexchange.com/questions/13913 85 | 86 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "python-time-track" 3 | version = "0.0.1" 4 | description = "Automatic Time Tracker" 5 | authors = ["Frans Fürst "] 6 | repository = "https://projects.om-office.de/frans/track.git" 7 | readme = "Readme.md" 8 | packages = [ 9 | {include = "track/**/*.py"}, 10 | {include = "track/track-server"}, 11 | {include = "track/**/*.ui"}, 12 | #{include = "medit/styles"}, 13 | ] 14 | #include = [ 15 | # {path = "track/track-server"}, 16 | #] 17 | 18 | [tool.poetry.scripts] 19 | track = 'track.cli:main' 20 | 21 | [tool.poetry.dependencies] 22 | python = ">=3.9,<3.12" 23 | #asyncinotify = "^4.0.1" 24 | #toml = "^0.10.2" 25 | #pyqt6 = "^6.5.1" 26 | #pyqt6-qscintilla = "^2.14.0" 27 | #pyqt6-webengine = "^6.5.0" 28 | #markdown = "<3.2" 29 | #markdown-checklist = "^0.4.4" 30 | # pygments-solarized-style = "^0.1" 31 | #pyqtdarktheme = "^2.1.0" 32 | #pygments = "^2.15.1" 33 | #plantuml-markdown = "^3.9.2" 34 | #md-mermaid = "^0.1.1" 35 | pyqt5 = "^5.15.9" 36 | psutil = "^5.9.5" 37 | zmq = "^0.0.0" 38 | 39 | [tool.poetry.group.dev.dependencies] 40 | black = "^22.6.0" 41 | isort = "^5.10.1" 42 | flake8 = "^4.0.1" 43 | pytest = "^7.1.2" 44 | pytest-cov = "^3.0.0" 45 | mypy = "^1.2" 46 | pylint = "^2.15.3" 47 | ipython = "^8.8.0" 48 | twine = "^4.0.2" 49 | yamllint = "^1.31.0" 50 | types-toml = "^0.10.8.6" 51 | pylint-per-file-ignores = "^1.2.1" 52 | 53 | [build-system] 54 | requires = ["poetry-core>=1.0.0"] 55 | build-backend = "poetry.core.masonry.api" 56 | 57 | 58 | [tool.black] 59 | line-length = 100 60 | target-version = ['py311'] 61 | include = '\.pyi?$' 62 | fast = true 63 | exclude = ''' 64 | ( 65 | /( # exclude a few common directories in the 66 | \.git # root of the project 67 | | \.pytest_cache 68 | | untracked 69 | | \.venv 70 | | dist 71 | )) 72 | ''' 73 | 74 | [tool.isort] 75 | profile = "black" 76 | 77 | [tool.mypy] 78 | python_version = "3.11" 79 | strict="True" 80 | disallow_untyped_defs = "True" 81 | disallow_any_unimported = "True" 82 | no_implicit_optional = "True" 83 | check_untyped_defs = "True" 84 | warn_return_any = "True" 85 | warn_unused_ignores = "True" 86 | show_error_codes = "True" 87 | exclude = "tracks" 88 | #exclude = [ 89 | # '\.venv', 90 | # 'untracked', 91 | # '/tracks/*.py', 92 | #] 93 | mypy_path="typeshed" 94 | 95 | [tool.pylint] 96 | 97 | # Files or directories matching the regular expression patterns are skipped. The 98 | # regex matches against base names, not paths. The default value ignores Emacs 99 | # file locks 100 | #ignore-patterns = ["^\\.#"] 101 | #disable = ["multiple-statements"] 102 | 103 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 104 | # number of processors available to use, and will cap the count on Windows to 105 | # avoid hangs. 106 | jobs = 0 107 | 108 | [tool.pylint.MASTER] 109 | load-plugins=["pylint_per_file_ignores"] 110 | 111 | [tool.pylint-per-file-ignores] 112 | #"pocketrockit/examples/" = "invalid-name" 113 | -------------------------------------------------------------------------------- /track/ui/qreordertableview.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Defines QSpoiler""" 5 | 6 | from PyQt5 import QtCore, QtGui, QtWidgets 7 | 8 | 9 | class ReorderTableView(QtWidgets.QTableView): 10 | """QTableView with the ability to make the model move a row with drag & drop""" 11 | 12 | class DropmarkerStyle(QtWidgets.QProxyStyle): 13 | """Makes a QTableView behave""" 14 | 15 | def drawPrimitive( 16 | self, 17 | element: QtWidgets.QStyle.PrimitiveElement, 18 | option: QtWidgets.QStyleOption, 19 | painter: QtGui.QPainter, 20 | widget: QtWidgets.QWidget = None, 21 | ) -> None: 22 | """Draw a line across the entire row rather than just the column we're hovering over. 23 | This may not always work depending on global style - for instance I think it won't 24 | work on OSX.""" 25 | if element == self.PE_IndicatorItemViewItemDrop and not option.rect.isNull(): 26 | option_new = QtWidgets.QStyleOption(option) 27 | option_new.rect.setLeft(0) 28 | if widget: 29 | option_new.rect.setRight(widget.width()) 30 | option = option_new 31 | super().drawPrimitive(element, option, painter, widget) 32 | 33 | def __init__(self, parent: QtWidgets.QWidget = None) -> None: 34 | super().__init__(parent) 35 | self.verticalHeader().hide() 36 | self.setSelectionBehavior(self.SelectRows) 37 | self.setSelectionMode(self.SingleSelection) 38 | self.setDragDropMode(self.InternalMove) 39 | self.setDragDropOverwriteMode(False) 40 | self.setStyle(self.DropmarkerStyle()) 41 | 42 | # self.tbl_category_rules.setDragEnabled(True) 43 | # self.tbl_category_rules.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) 44 | # self.tbl_category_rules.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove) 45 | # self.tbl_category_rules.setAcceptDrops(True) 46 | # self.tbl_category_rules.setDropIndicatorShown(True) 47 | 48 | # self.tbl_category_rules.setDragDropMode(QtWidgets.QAbstractItemView.DragDrop) 49 | # self.tbl_category_rules.viewport().setAcceptDrops(True) 50 | # self.tbl_category_rules.setDragDropOverwriteMode(False) 51 | 52 | def dropEvent(self, event: QtGui.QDropEvent) -> None: 53 | """Identifies source/target row of a finished drag&drop and runs moveRow() on the model""" 54 | if event.source() is not self or ( 55 | event.dropAction() != QtCore.Qt.MoveAction 56 | and self.dragDropMode() != QtWidgets.QAbstractItemView.InternalMove 57 | ): 58 | super().dropEvent(event) 59 | 60 | selection = self.selectedIndexes() 61 | from_index = selection[0].row() if selection else -1 62 | to_index = self.indexAt(event.pos()).row() 63 | if ( 64 | 0 <= from_index < self.model().rowCount() 65 | and 0 <= to_index < self.model().rowCount() 66 | and from_index != to_index 67 | ): 68 | self.model().moveRow(QtCore.QModelIndex(), from_index, QtCore.QModelIndex(), to_index) 69 | event.accept() 70 | super().dropEvent(event) 71 | -------------------------------------------------------------------------------- /track/ui/qspoiler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Defines QSpoiler""" 5 | 6 | from PyQt5 import QtCore, QtWidgets 7 | 8 | 9 | class QSpoiler(QtWidgets.QFrame): 10 | """Collapsable spoiler widget 11 | References: 12 | # Adapted from c++ version 13 | http://stackoverflow.com/questions/32476006/how-to-make-an-expandable-collapsable-section-widget-in-qt 14 | """ 15 | 16 | def __init__(self, parent=None, title: str = "", expanded: bool = False) -> None: 17 | """Improvise a collapsable QFrame""" 18 | 19 | def set_widget_properties(checked: bool) -> None: 20 | self._content_area.setVisible(checked) 21 | self._toggle_button.setArrowType( 22 | QtCore.Qt.DownArrow if checked else QtCore.Qt.RightArrow 23 | ) 24 | 25 | super().__init__(parent=parent) 26 | self._content_area = QtWidgets.QWidget() 27 | self._header_line = QtWidgets.QFrame() 28 | self._toggle_button = QtWidgets.QToolButton() 29 | self._main_layout = QtWidgets.QGridLayout() 30 | 31 | self._toggle_button.setStyleSheet("QToolButton { border: none; }") 32 | self._toggle_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon) 33 | self.setTitle(title) 34 | self._toggle_button.setCheckable(True) 35 | self._toggle_button.setChecked(expanded) 36 | 37 | set_widget_properties(self._toggle_button.isChecked()) 38 | 39 | self._header_line.setFrameShape(QtWidgets.QFrame.HLine) 40 | self._header_line.setFrameShadow(QtWidgets.QFrame.Sunken) 41 | self._header_line.setSizePolicy( 42 | QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred 43 | ) 44 | 45 | self._content_area.setSizePolicy( 46 | QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred 47 | ) 48 | 49 | self._main_layout.setVerticalSpacing(0) 50 | self._main_layout.setHorizontalSpacing(0) 51 | self._main_layout.setContentsMargins(0, 0, 0, 0) 52 | self._main_layout.addWidget(self._toggle_button, 0, 0, 1, 1, QtCore.Qt.AlignLeft) 53 | self._main_layout.addWidget(self._header_line, 0, 2, 1, 1) 54 | self._main_layout.addWidget(self._content_area, 1, 0, 1, 3) 55 | 56 | super().setLayout(self._main_layout) 57 | 58 | default_layout = QtWidgets.QVBoxLayout() 59 | default_layout.setContentsMargins(10, 0, 0, 0) 60 | self.setLayout(default_layout) 61 | 62 | self._toggle_button.toggled.connect(set_widget_properties) 63 | 64 | def setExpanded(self, expanded: bool) -> None: 65 | self._toggle_button.setChecked(expanded) 66 | 67 | def setTitle(self, title: str) -> None: 68 | """Sets the widget title""" 69 | self._toggle_button.setText(title) 70 | 71 | def layout(self) -> QtWidgets.QLayout: 72 | """Returns the layout of the content area""" 73 | return self._content_area.layout() 74 | 75 | def setLayout(self, content_layout: QtWidgets.QLayout) -> None: 76 | """Sets the content area layout""" 77 | self._content_area.destroy() 78 | self._content_area.setLayout(content_layout) 79 | 80 | def addWidget(self, widget: QtWidgets.QWidget) -> None: 81 | """Adds a widget to the content areas layout""" 82 | self.layout().addWidget(widget) 83 | -------------------------------------------------------------------------------- /track/core/desktop_usage_info/backend_x11/applicationinfo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import re 6 | import subprocess 7 | 8 | import psutil 9 | 10 | from .. import ToolError, WindowInformationError 11 | 12 | # xprop -id $(xprop -root | awk '/_NET_ACTIVE_WINDOW\(WINDOW\)/{print $NF}') | awk '/_NET_WM_PID\(CARDINAL\)/{print $NF}' 13 | # xprop -id $(xprop -root _NET_ACTIVE_WINDOW | cut -f5 -d' ') 14 | 15 | 16 | # http://thp.io/2007/09/x11-idle-time-and-focused-window-in.html 17 | 18 | 19 | def _get_stdout(command): 20 | """run a command and return stdout""" 21 | _p = subprocess.Popen( 22 | args=command, 23 | stdout=subprocess.PIPE, 24 | stderr=subprocess.PIPE, 25 | ) 26 | _stdout, _stderr = _p.communicate() 27 | _stdout = _stdout.decode("utf-8", errors="replace").split("\n") 28 | _stderr = _stderr.decode("utf-8", errors="replace").split("\n") 29 | if _p.returncode != 0: 30 | raise WindowInformationError( 31 | 'command "%s" did not return properly' % " ".join(command) 32 | + "\n" 33 | + "output was: \n" 34 | + "\n".join(_stdout) 35 | + "\n".join(_stderr) 36 | ) 37 | return _stdout 38 | 39 | 40 | def get_active_window_information(): 41 | try: 42 | _xprop = _get_stdout(["xprop", "-root", "_NET_ACTIVE_WINDOW"]) 43 | except: 44 | raise ToolError('Could not run "xprop". Is this an X-Session?') 45 | 46 | _id_w = None 47 | for line in _xprop: 48 | m = re.search(r"^_NET_ACTIVE_WINDOW.* ([\w]+)$", line) 49 | if m is not None: 50 | _window_id = m.group(1) 51 | 52 | if _window_id is None: 53 | raise ToolError('"xprop" did not give us _NET_ACTIVE_WINDOW.') 54 | 55 | try: 56 | _id_w = _get_stdout(["xprop", "-id", _window_id, "WM_NAME", "_NET_WM_NAME", "_NET_WM_PID"]) 57 | except WindowInformationError as ex: 58 | print(repr(ex)) 59 | raise WindowInformationError( 60 | '"xprop" (ran order to get WM_NAME, _NET_WM_NAME and_NET_WM_PID) "' 61 | '"returned with error' 62 | ) 63 | except Exception as ex: 64 | print(repr(ex)) 65 | raise ToolError( 66 | 'Could not run "xprop" in order to get WM_NAME, _NET_WM_NAME and_NET_WM_PID' 67 | ) 68 | 69 | _result = {} 70 | 71 | for line in _id_w: 72 | _match = re.match(r".*WM_NAME\(\w+\) = (?P.+)$", line) 73 | if _match is not None: 74 | _entry = _match.group("name").strip('"').strip() 75 | if _entry == "": 76 | print("could not read title from '%s'" % line) 77 | raise WindowInformationError("could not read app title") 78 | _result["TITLE"] = _entry 79 | 80 | _match = re.match(r".*_NET_WM_PID\(\w+\) = (?P.+)$", line) 81 | if _match is not None: 82 | _entry = _match.group("name").strip('"').strip() 83 | if _entry != "": 84 | _result["PID"] = int(_entry) 85 | 86 | if "PID" in _result: 87 | process = psutil.Process(_result["PID"]) 88 | try: 89 | # # in psutil 2+ cmdline is a getter 90 | _result["COMMAND"] = " ".join(process.cmdline()) 91 | except TypeError: 92 | _result["COMMAND"] = " ".join(process.cmdline) 93 | 94 | return _result 95 | -------------------------------------------------------------------------------- /track/core/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Provide some non-track-specific helper functions""" 5 | 6 | import argparse 7 | import logging 8 | import logging.handlers 9 | import os 10 | import sys 11 | from contextlib import contextmanager 12 | from typing import Any, NoReturn 13 | 14 | from PyQt5 import QtCore 15 | 16 | from . import version_info 17 | 18 | 19 | class ColorFormatter(logging.Formatter): 20 | """Logging Formatter to add colors and count warning / errors""" 21 | 22 | colors = { 23 | "green": "\x1b[32m", 24 | "cyan": "\x1b[36m", 25 | "grey": "\x1b[38;21m", 26 | "yellow": "\x1b[33;21m", 27 | "red": "\x1b[31;21m", 28 | "bold_red": "\x1b[31;1m", 29 | "reset": "\x1b[0m", 30 | } 31 | level_colors = { 32 | logging.DEBUG: colors["green"], 33 | logging.INFO: colors["cyan"], 34 | logging.WARNING: colors["yellow"], 35 | logging.ERROR: colors["red"], 36 | logging.CRITICAL: colors["bold_red"], 37 | } 38 | use_color = "TERM" in os.environ 39 | 40 | def format(self, record): 41 | return ( 42 | self.level_colors[record.levelno] + super().format(record) + self.colors["reset"] 43 | if self.use_color 44 | else super().format(record) 45 | ) 46 | 47 | 48 | def setup_logging(args: argparse.Namespace, syslog=False) -> None: 49 | """Setup coloring, syslog etc""" 50 | handler = logging.StreamHandler() 51 | handler.setFormatter( 52 | ColorFormatter( 53 | fmt="%(levelname)s %(asctime)s %(name)s %(process)s:%(thread)s: %(message)s", 54 | datefmt="%Y-%m-%d %H:%M:%S", 55 | ) 56 | ) 57 | logging.getLogger().addHandler(handler) 58 | for level in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"): 59 | logging.addLevelName(getattr(logging, level), "(%s)" % (level[0] * 2)) 60 | 61 | log().setLevel(getattr(logging, args.log_level)) 62 | if syslog and os.name == "posix": 63 | handler = logging.handlers.SysLogHandler(address="/dev/log") 64 | handler.setFormatter( 65 | logging.Formatter( 66 | fmt="%(asctime)s %(name)15s %(levelname)s: %(message)s", datefmt="%y%m%d-%H%M%S" 67 | ) 68 | ) 69 | logging.getLogger().addHandler(handler) 70 | 71 | 72 | def throw(exc: Exception) -> NoReturn: 73 | """Use an exception as function""" 74 | raise exc 75 | 76 | 77 | def catch(func, exceptions, default=None) -> Any: 78 | """de-uglyfy assignments with potential known exceptions""" 79 | try: 80 | return func() 81 | except exceptions: 82 | return default 83 | 84 | 85 | def log(name: str = "main") -> logging.Logger: 86 | """Convenience function to access logger 'app logger'""" 87 | return logging.getLogger(name) 88 | 89 | 90 | @contextmanager 91 | def open_in_directory_of(file, path): 92 | file = QtCore.QFile(os.path.join(os.path.dirname(file), path)) 93 | if not QtCore.QFile.exists(file): 94 | raise FileNotFoundError(path) 95 | file.open(QtCore.QFile.ReadOnly | QtCore.QFile.Text) 96 | yield file 97 | file.close() 98 | 99 | 100 | def exception_to_string(exc: Exception) -> str: 101 | """Turn an exception into something very readable 102 | Currently only __str__ is being used - to be enrichted by file, etc""" 103 | return "%r" % exc 104 | -------------------------------------------------------------------------------- /track/core/desktop_usage_info/backend_x11/idle.py: -------------------------------------------------------------------------------- 1 | ## src/common/idle.py 2 | ## 3 | ## (C) 2008 Thorsten P. 'dGhvcnN0ZW5wIEFUIHltYWlsIGNvbQ==\n'.decode("base64") 4 | ## 5 | ## This file is part of Gajim. 6 | ## 7 | ## Gajim is free software; you can redistribute it and/or modify 8 | ## it under the terms of the GNU General Public License as published 9 | ## by the Free Software Foundation; version 3 only. 10 | ## 11 | ## Gajim is distributed in the hope that it will be useful, 12 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | ## GNU General Public License for more details. 15 | ## 16 | ## You should have received a copy of the GNU General Public License 17 | ## along with Gajim. If not, see . 18 | 19 | import ctypes 20 | import ctypes.util 21 | 22 | 23 | class XScreenSaverInfo(ctypes.Structure): 24 | _fields_ = [ 25 | ("window", ctypes.c_ulong), 26 | ("state", ctypes.c_int), 27 | ("kind", ctypes.c_int), 28 | ("til_or_since", ctypes.c_ulong), 29 | ("idle", ctypes.c_ulong), 30 | ("eventMask", ctypes.c_ulong), 31 | ] 32 | 33 | 34 | XScreenSaverInfo_p = ctypes.POINTER(XScreenSaverInfo) 35 | 36 | display_p = ctypes.c_void_p 37 | xid = ctypes.c_ulong 38 | c_int_p = ctypes.POINTER(ctypes.c_int) 39 | 40 | try: 41 | libX11path = ctypes.util.find_library("X11") 42 | if libX11path == None: 43 | raise OSError("libX11 could not be found.") 44 | libX11 = ctypes.cdll.LoadLibrary(libX11path) 45 | libX11.XOpenDisplay.restype = display_p 46 | libX11.XOpenDisplay.argtypes = (ctypes.c_char_p,) 47 | libX11.XDefaultRootWindow.restype = xid 48 | libX11.XDefaultRootWindow.argtypes = (display_p,) 49 | 50 | libXsspath = ctypes.util.find_library("Xss") 51 | if libXsspath == None: 52 | raise OSError("libXss could not be found.") 53 | libXss = ctypes.cdll.LoadLibrary(libXsspath) 54 | libXss.XScreenSaverQueryExtension.argtypes = display_p, c_int_p, c_int_p 55 | libXss.XScreenSaverAllocInfo.restype = XScreenSaverInfo_p 56 | libXss.XScreenSaverQueryInfo.argtypes = (display_p, xid, XScreenSaverInfo_p) 57 | 58 | dpy_p = libX11.XOpenDisplay(None) 59 | if dpy_p == None: 60 | raise OSError("Could not open X Display.") 61 | 62 | _event_basep = ctypes.c_int() 63 | _error_basep = ctypes.c_int() 64 | if ( 65 | libXss.XScreenSaverQueryExtension( 66 | dpy_p, ctypes.byref(_event_basep), ctypes.byref(_error_basep) 67 | ) 68 | == 0 69 | ): 70 | raise OSError("XScreenSaver Extension not available on display.") 71 | 72 | xss_info_p = libXss.XScreenSaverAllocInfo() 73 | if xss_info_p == None: 74 | raise OSError("XScreenSaverAllocInfo: Out of Memory.") 75 | 76 | rootwindow = libX11.XDefaultRootWindow(dpy_p) 77 | xss_available = True 78 | except OSError as e: 79 | # Logging? 80 | xss_available = False 81 | 82 | 83 | def getIdleSec(): 84 | global xss_available 85 | """ 86 | Return the idle time in seconds 87 | """ 88 | if not xss_available: 89 | return 0 90 | if libXss.XScreenSaverQueryInfo(dpy_p, rootwindow, xss_info_p) == 0: 91 | return 0 92 | else: 93 | return int(xss_info_p.contents.idle) / 1000 94 | 95 | 96 | def close(): 97 | global xss_available 98 | if xss_available: 99 | libX11.XFree(xss_info_p) 100 | libX11.XCloseDisplay(dpy_p) 101 | xss_available = False 102 | 103 | 104 | if __name__ == "__main__": 105 | import time 106 | 107 | while True: 108 | time.sleep(0.5) 109 | print(getIdleSec()) 110 | -------------------------------------------------------------------------------- /track/ui/active_applications_qtmodel.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # -*- coding: utf-8 -*- 4 | 5 | """Defines ActiveApplicationsModel 6 | """ 7 | 8 | from typing import Any, Dict, Tuple # pylint: disable=unused-import 9 | 10 | from PyQt5 import QtCore 11 | 12 | from ..core import ActiveApplications, common 13 | from .qt_common import change_emitter 14 | 15 | 16 | class ActiveApplicationsModel(QtCore.QAbstractTableModel, ActiveApplications): 17 | """Data model which holds all application usage data for one 18 | day. That is: 19 | 20 | app_data: {app_id: application} 21 | 22 | minutes: {i_min => [app_id], i_cat} 23 | 24 | where 25 | 26 | application: (i_secs, i_cat, s_title, s_process) 27 | 28 | 29 | model_list: 30 | * sortable by key 31 | * can be done with list of keys sorted by given value 32 | [(app_id, i_secs, i_cat)] 33 | """ 34 | 35 | def __init__(self, parent, *args) -> None: 36 | QtCore.QAbstractTableModel.__init__(self, parent, *args) 37 | ActiveApplications.__init__(self) 38 | self.header = [] 39 | self._sorted_keys = [] 40 | self._sort_col = 0 41 | 42 | def columnCount(self, _parent=None): 43 | return 3 44 | 45 | def rowCount(self, _parent=None): 46 | return len(self._sorted_keys) 47 | 48 | def headerData(self, column, orientation, role): 49 | if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole: 50 | return "Application title", "Spent", "Category"[column] 51 | return None 52 | 53 | def data(self, index, role): 54 | if not index.isValid(): 55 | return None 56 | row, column = index.row(), index.column() 57 | if role == QtCore.Qt.TextAlignmentRole: 58 | if column == 2: 59 | return QtCore.Qt.AlignCenter 60 | if not role == QtCore.Qt.DisplayRole: 61 | return None 62 | return ( 63 | (self._apps[self._sorted_keys[row]]._wndtitle) 64 | if column == 0 65 | else common.secs_to_dur(self._apps[self._sorted_keys[row]]._count) 66 | if column == 1 67 | else self._apps[self._sorted_keys[row]]._category 68 | ) 69 | 70 | @QtCore.pyqtSlot() 71 | def update_all_categories(self, get_category_from_app) -> None: 72 | for i in self._apps: 73 | self._apps[i].set_new_category(get_category_from_app(self._apps[i])) 74 | for i in self._minutes: 75 | self._minutes[i].rebuild_categories(get_category_from_app) 76 | 77 | def flags(self, _index): 78 | return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsDragEnabled 79 | 80 | def sort(self, column=1, order=QtCore.Qt.AscendingOrder): 81 | with change_emitter(self): 82 | self._sorted_keys = [ 83 | x[0] 84 | for x in sorted( 85 | self._apps.items(), 86 | key=( 87 | (lambda x: x[1]._wndtitle) 88 | if column == 0 89 | else (lambda x: x[1]._count) 90 | if column == 1 91 | else (lambda x: x[1]._category) 92 | ), 93 | reverse=(order != QtCore.Qt.DescendingOrder), 94 | ) 95 | ] 96 | 97 | def clear(self): 98 | with change_emitter(self): 99 | super().clear() 100 | 101 | def from_dict(self, data: Dict[str, Any]) -> None: 102 | with change_emitter(self): 103 | super().from_dict(data) 104 | self.sort() 105 | 106 | def update(self, minute_index, app): 107 | with change_emitter(self): 108 | super().update(minute_index, app) 109 | self._sort() 110 | # self.dataChanged.emit(QtCore.QModelIndex(), QtCore.QModelIndex()) 111 | -------------------------------------------------------------------------------- /track/ui/rules_model_qt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Defines RulesModelQt 5 | """ 6 | 7 | import re 8 | from typing import Any 9 | 10 | from PyQt5 import QtCore 11 | from PyQt5.QtCore import pyqtSignal 12 | 13 | from ..core.util import log 14 | from .qt_common import change_emitter 15 | 16 | 17 | class RulesModelQt(QtCore.QAbstractTableModel): 18 | rulesChanged = pyqtSignal() 19 | 20 | def __init__(self, *, rules=None, parent=None): 21 | super().__init__(parent) 22 | self._rules = rules or [] 23 | 24 | def headerData(self, column: int, orientation, role: QtCore.Qt.ItemDataRole) -> Any: 25 | return ( 26 | ("Regex", "Category")[column] 27 | if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal 28 | else None 29 | ) 30 | 31 | def columnCount(self, _parent: QtCore.QModelIndex = None) -> int: 32 | return 2 33 | 34 | def rowCount(self, _parent: QtCore.QModelIndex = None) -> int: 35 | return len(self._rules) + 1 36 | 37 | def data(self, index: QtCore.QModelIndex, role: QtCore.Qt.ItemDataRole) -> Any: 38 | return ( 39 | ( 40 | ( 41 | self._rules[index.row()][index.column()] 42 | if index.row() < len(self._rules) 43 | else ("", 1)[index.column()] 44 | if role == QtCore.Qt.DisplayRole 45 | else (".*", 2)[index.column()] 46 | ) 47 | if role in {QtCore.Qt.DisplayRole, QtCore.Qt.EditRole} 48 | else None 49 | ) 50 | if index.isValid() 51 | else None 52 | ) 53 | 54 | def setData(self, index: QtCore.QModelIndex, value: str, role: int): 55 | if not role == QtCore.Qt.EditRole: 56 | return True 57 | if value is None: 58 | log().error("setData(value=None)") 59 | return False 60 | row, column = index.row(), index.column() 61 | current_rule = self._rules[row] if row < len(self._rules) else [".*", 2] 62 | 63 | if column == 0: 64 | try: 65 | re.compile(value) 66 | except re.error: 67 | print("invalid regex") 68 | return False 69 | current_rule[0] = value 70 | if column == 1: 71 | try: 72 | current_rule[1] = int(value) 73 | except ValueError: 74 | print("invalid int") 75 | return False 76 | 77 | if row < len(self._rules): 78 | self._rules[row] = current_rule 79 | else: 80 | self.beginInsertRows(QtCore.QModelIndex(), row, row) 81 | self._rules.append(current_rule) 82 | self.endInsertRows() 83 | 84 | self.rulesChanged.emit() 85 | return True 86 | 87 | def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlags: 88 | # https://doc.qt.io/qt-5/qt.html#ItemFlag-enum 89 | if not index.isValid(): 90 | return QtCore.Qt.ItemIsDropEnabled 91 | if index.row() < len(self._rules): 92 | return ( 93 | QtCore.Qt.ItemIsEnabled 94 | | QtCore.Qt.ItemIsEditable 95 | | QtCore.Qt.ItemIsSelectable 96 | | QtCore.Qt.ItemIsDragEnabled 97 | ) 98 | return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable 99 | 100 | def supportedDropActions(self) -> bool: 101 | return QtCore.Qt.MoveAction | QtCore.Qt.CopyAction 102 | 103 | def removeRow(self, row: int): 104 | if row >= len(self._rules): 105 | return 106 | self.beginRemoveRows(QtCore.QModelIndex(), row, row) 107 | del self._rules[row] 108 | self.endRemoveRows() 109 | self.rulesChanged.emit() 110 | 111 | def insertRows(self, row, count, parent=None): 112 | print(row, count, parent) 113 | result = super().insertRows(row, count, parent) 114 | print(result) 115 | self.rulesChanged.emit() 116 | return result 117 | 118 | def moveRow( 119 | self, 120 | sourceParent: QtCore.QModelIndex, 121 | sourceRow: int, 122 | destinationParent: QtCore.QModelIndex, 123 | destinationChild: int, 124 | ) -> bool: 125 | row_a, row_b = max(sourceRow, destinationChild), min(sourceRow, destinationChild) 126 | self.beginMoveRows(QtCore.QModelIndex(), row_a, row_a, QtCore.QModelIndex(), row_b) 127 | self._rules.insert(destinationChild, self._rules.pop(sourceRow)) 128 | self.endMoveRows() 129 | self.rulesChanged.emit() 130 | return True 131 | 132 | def set_rules(self, rules): 133 | with change_emitter(self): 134 | self._rules = rules 135 | self.rulesChanged.emit() 136 | 137 | def rules(self): 138 | return self._rules 139 | 140 | def check_string(self, string: str) -> None: 141 | print("check", string) 142 | for regex, category in self._rules: 143 | if re.search(regex, string): 144 | print("%r matches: %r" % (string, regex)) 145 | return 146 | print("%r does not match" % string) 147 | -------------------------------------------------------------------------------- /track/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- encoding: utf-8 -*- 3 | # pylint: disable=expression-not-assigned 4 | 5 | """ track-cli - CLI interface to track 6 | 7 | """ 8 | 9 | import argparse 10 | import json 11 | import os 12 | import sys 13 | from contextlib import suppress 14 | from typing import List 15 | 16 | import zmq 17 | 18 | from .core import ActiveApplications, common, util 19 | from .core.util import log 20 | 21 | 22 | def parse_arguments(argv: List[str]) -> argparse.Namespace: 23 | """parse command line arguments and return argument object""" 24 | parser = argparse.ArgumentParser(description=__doc__) 25 | common.setup_argument_parser(parser) 26 | 27 | subparsers = parser.add_subparsers(help="available commands", metavar="CMD") 28 | 29 | parser_help = subparsers.add_parser("help", help="show this help") 30 | parser_help.set_defaults(func=lambda *_: parser.print_help()) 31 | 32 | parser_list = subparsers.add_parser("list", help="list basic logs") 33 | parser_list.set_defaults(func=fn_info) 34 | 35 | parser_server = subparsers.add_parser("server", help="send command to server") 36 | parser_server.set_defaults(func=fn_server) 37 | parser_server.add_argument("command") 38 | 39 | parser_server = subparsers.add_parser("serve", help="send command to server") 40 | parser_server.set_defaults(func=fn_serve) 41 | 42 | parser_show = subparsers.add_parser("show", help="show content of one log file") 43 | parser_show.set_defaults(func=fn_show) 44 | parser_show.add_argument("element", nargs="+") 45 | 46 | parser_ui = subparsers.add_parser("ui", help="start the track ui") 47 | parser_ui.set_defaults(func=fn_ui) 48 | 49 | parser.set_defaults(func=fn_ui) 50 | 51 | return parser.parse_args(argv) 52 | 53 | 54 | def send_request(request): 55 | context = zmq.Context() 56 | req_socket = context.socket(zmq.REQ) 57 | 58 | req_socket.connect("tcp://127.0.0.1:3456") 59 | 60 | req_socket.send_json(request) 61 | return req_socket.recv_json() 62 | 63 | 64 | def handle_result(result): 65 | if "type" in result and result["type"] == "error": 66 | raise Exception('server replied with error: "%s"' % result["what"]) 67 | print(result) 68 | 69 | 70 | def convert(data): 71 | return data if "tracker_data" in data else {"tracker_data": data} 72 | 73 | 74 | def to_time(value): 75 | return "%2d:%.2d" % (value // 60, value % 60) 76 | 77 | 78 | def fn_ui(args) -> None: 79 | from track.ui import main as main_ui 80 | 81 | main_ui() 82 | 83 | 84 | def fn_serve(args) -> None: 85 | from track.server import main as main_server 86 | 87 | main_server() 88 | 89 | 90 | def fn_server(args) -> None: 91 | if args.command not in {"quit", "version", "apps", "current", "rules", "save", "note"}: 92 | log().error("Command not known: %r", args.command) 93 | return 94 | 95 | try: 96 | result = send_request({"cmd": args.command}) 97 | handle_result(result) 98 | except zmq.ZMQError as e: 99 | log.error(e) 100 | return 101 | 102 | 103 | def fn_show(args) -> None: 104 | log_dir = args.data_dir 105 | 106 | log().info("Show infos for %r", args.element) 107 | for file in args.element: 108 | data = convert(json.load(open(os.path.join(log_dir, file)))) 109 | apps = ActiveApplications(data["tracker_data"]) 110 | daily_note = data.get("daily_note") or "" 111 | print( 112 | "%s: %s - %s = %s => %s" 113 | % ( 114 | file, 115 | to_time(apps.begin_index()), 116 | to_time(apps.end_index()), 117 | to_time(apps.end_index() - apps.begin_index()), 118 | to_time(apps.end_index() - apps.begin_index() - 60), 119 | ) 120 | ) 121 | for time in (t for t in range(apps.begin_index(), apps.end_index()) if t in apps._minutes): 122 | print(to_time(time)) 123 | print(daily_note) 124 | 125 | 126 | def fn_info(args) -> None: 127 | log_dir = args.data_dir 128 | log().info("List recorded data") 129 | for file in common.log_files(log_dir): 130 | data = convert(json.load(open(os.path.join(log_dir, file)))) 131 | if "20200503" in file: 132 | print(file) 133 | apps = ActiveApplications(data["tracker_data"]) 134 | daily_note = data.get("daily_note") or "" 135 | print( 136 | "%s: %s - %s = %s => %s (note: %r)" 137 | % ( 138 | file, 139 | to_time(apps.begin_index()), 140 | to_time(apps.end_index()), 141 | to_time(apps.end_index() - apps.begin_index()), 142 | to_time(apps.end_index() - apps.begin_index() - 60), 143 | daily_note.split("\n")[0], 144 | ) 145 | ) 146 | # print("".join(("X" if minute in apps._minutes else " ") 147 | # for minute in range(apps.begin_index(), apps.end_index() + 1))) 148 | 149 | 150 | def main(argv=None) -> int: 151 | """read command line arguments, configure application and run command 152 | specified on command line""" 153 | 154 | args = parse_arguments(argv or sys.argv[1:]) 155 | util.setup_logging(args) 156 | common.log_system_info(args) 157 | 158 | args.func(args) 159 | 160 | 161 | if __name__ == "__main__": 162 | with suppress(KeyboardInterrupt, BrokenPipeError): 163 | raise SystemExit(main()) 164 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Track - automatic time tracker for computer work 2 | 3 | Track logs the time you're actively working on your desktop computer as well as 4 | the applications you're using in order to create a time chart of your 5 | working day. 6 | 7 | Track does *not* connect *anywhere* and shares your information with nobody! 8 | 9 | Actually this is why I started this project in the first place.. 10 | 11 | However at the current development stage Track stores all data unencrypted, 12 | so please ensure nobody has access to your Track directory (usually `~/.track`). 13 | 14 | Unlike [KTimeTracker](https://www.kde.org/applications/utilities/ktimetracker/) 15 | or [Hamster](https://projecthamster.wordpress.com/about/) Track aims at zero 16 | user interaction. Once configured you just (auto)start it and Track runs on 17 | it's own. 18 | 19 | This is an early screenshot to give an idea of how Track works: 20 | ![recent screenshot](track-screenshot.png) 21 | 22 | **What it does**: 23 | * logs times when your computer is active and which applications are in focus 24 | * handles a list of regex-rules which assign certain activities to private work 25 | 26 | **What it does not**: 27 | * manage an abstract task list you would have to maintain 28 | * send any information to someone 29 | 30 | **When you should use Track**: 31 | * in case you're working mainly on a computer like software developers 32 | * if you want to know how long you've been at work today 33 | * if you want to know how much of your private time you spent on the computer 34 | * if you want to waste your time with another self profiling tool 35 | 36 | The current *project state* is (still! damn!) very early (see the 37 | [schedule](progress.md)). 38 | 39 | Very basic features are still missing so it might be wise to come back in a 40 | week or so. 41 | However it's totally save to use the tool and it's providing some interesting 42 | information already. 43 | 44 | 45 | ## Categories and Rules 46 | 47 | In order to get an idea how much of the day you spend for work and how much 48 | for private stuff (or how much you spend on project A or project B) track allows 49 | you to define special *rules* which assign each running program to a category. 50 | 51 | Right now categories are just numbers (2 for work, 3 for private stuff, 4 for break, 52 | 0 for idle and 1 for unassigned programs). In the future I plan to allow arbitrary 53 | categories (or category trees), e.g.: 54 | 55 | * work 56 | - project A 57 | - project B 58 | - browsing on github.com 59 | * private stuff 60 | - project C 61 | * procrastinating 62 | - browsing on reddit.com 63 | 64 | The technical approach is very simple: you define standard Python *regex* rules 65 | which are matched against the *title* of the active window. 66 | 67 | For example - in a very simple world - it might be enough to define that browsing 68 | the internet using "Firefox" is private stuff (category 3) and everything else 69 | is work (category 2). In this case you would just define one rule: 70 | 71 | regex = `r".*-Mozilla Firefox.*"` -> category = `3` 72 | 73 | This way every program whose title does not match `.*-Mozilla Firefox.*` would 74 | be assigned to the default category 0 and all *Firefox* browser windows would 75 | result in category 1 (which you might be *private* in your eyes). 76 | 77 | 78 | ## Requirements 79 | 80 | Basically you Python3 with `PyQt5`, `psutil` and `zmq` installed. Some PyQt5 versions do not 81 | behave well and you will need build-essentials etc to make it work. 82 | For me installing the following packages worked: 83 | 84 | * Linux with X11 (Wayland had some problems when I last checked it) 85 | * or Windows - it once worked but currently I have no way to check it 86 | * Python 3+ 87 | * `python3-devel` or equivalent via `apt`, `dnf`, etc. 88 | * `PyQt5` via pip (v5.14 worked for me) 89 | * `zmq` via pip 90 | * `psutil` via pip 91 | 92 | Try this pip command: `pip3 install --user --upgrade psutil zmq PyQt5==5.14` 93 | 94 | 95 | ## How to run 96 | 97 | Clone the repo: 98 | ``` 99 | git clone https://github.com/frans-fuerst/track 100 | ``` 101 | 102 | Run `track` to start Track client and server. The server will keep running in background if UI 103 | gets closed. 104 | 105 | To list starting / endings times of recorded days run `track-cli list` 106 | 107 | 108 | ## Know limitations / Shortcomings 109 | 110 | * Currently Track seems to not work well with Wayland, which might be an issue of both Track and 111 | Wayland. When your're on Linux consider using X11. 112 | * I started this project 5 years ago when my Python skills were still very weak. Please do either 113 | not look at the code or help me improve it. 114 | * Track *records* data but still does not evaluate it. `track-cli` does some steps in that 115 | direction. 116 | * No check for plausibility: if - for example - your computer wakes up at 3:12 for just a second 117 | this incident will be recorded and currently there is no way to remove this stray event and 118 | your day will officially start at 3:12 119 | * Setup / autostart worked once upon a time but doesn't now. But as a Linux Pro you know what to do. 120 | * Daily note does'nt get cleared on midnight (but you can simply overwrite it) 121 | * Break (cat 4) cannot be selected in timechart 122 | * Bug: Gnome Icon not working 123 | * Categories limited to 0-4 124 | * Categories as ints 125 | 126 | 127 | ## Tests 128 | 129 | Here is how I currently "test" track. It's actually more try and look for crashes :) 130 | 131 | * Delete `~/.track/`, Try to start `track` 132 | * Open all spoilers 133 | * Delete all rules by pressing delete 134 | * add rules 135 | * edit note 136 | * restart 137 | * rules still exist? 138 | * note still exists? 139 | -------------------------------------------------------------------------------- /track/ui/mainwindow.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # pylint: disable=expression-not-assigned 4 | 5 | """Defines Mainwindow - a non application specific, platform independent MainWindow with 6 | some common features 7 | """ 8 | 9 | import logging 10 | import signal 11 | import threading 12 | 13 | from PyQt5 import QtCore, QtGui, QtWidgets, uic 14 | 15 | from .. import application_root_dir 16 | from ..core.util import log, open_in_directory_of 17 | 18 | 19 | class MainWindow(QtWidgets.QMainWindow): 20 | class QPlainTextEditLogger(logging.Handler): 21 | """Invokes main thread to write to log window""" 22 | 23 | def __init__(self, receiver) -> None: 24 | super().__init__() 25 | self.log_receiver = receiver 26 | self.initial_thread = threading.get_ident() 27 | 28 | def emit(self, record: logging.LogRecord) -> None: 29 | msg = self.format(record) 30 | if self.initial_thread == threading.get_ident(): 31 | self.log_receiver.write_log(msg) 32 | else: 33 | QtCore.QMetaObject.invokeMethod( 34 | self.log_receiver, 35 | "write_log", 36 | QtCore.Qt.QueuedConnection, 37 | QtCore.Q_ARG(str, msg), 38 | ) 39 | 40 | def __init__(self, _args=None) -> None: 41 | super().__init__() 42 | self.windowTitleChanged.connect(self.on_windowTitleChanged) 43 | with open_in_directory_of(__file__, "mainwindow.ui") as file: 44 | uic.loadUi(file, self, package="track.ui") 45 | 46 | for sig in (signal.SIGABRT, signal.SIGINT, signal.SIGSEGV, signal.SIGTERM): 47 | signal.signal(sig, lambda signal, frame: self.handle_signal(signal)) 48 | 49 | # catch the interpreter every now and then to be able to catch signals 50 | self.idle_timer = QtCore.QTimer() 51 | self.idle_timer.timeout.connect(lambda: None) 52 | self.idle_timer.start(200) 53 | 54 | log().info("app dir: %r", application_root_dir()) 55 | 56 | self.setMouseTracking(True) 57 | 58 | def setup_common_widgets(self): 59 | self.log_view.setReadOnly(True) 60 | self.log_view.setLineWrapMode(0) 61 | font = QtGui.QFont("unexistent") 62 | font.setStyleHint(QtGui.QFont.Monospace) 63 | font.setPointSize(10) 64 | self.log_view.setFont(font) 65 | logTextBox = self.QPlainTextEditLogger(self) 66 | logTextBox.setFormatter( 67 | logging.Formatter("%(levelname)s %(asctime)s %(name)s: %(message)s", datefmt="%H:%M:%S") 68 | ) 69 | logging.getLogger().addHandler(logTextBox) 70 | # self.pb_quit.clicked.connect(self.close) 71 | # self.pb_log.toggled.connect(self.toggle_log) 72 | # self.pb_log.setChecked(True) 73 | # self.pb_fullscreen.clicked.connect(self.toggle_fullscreen) 74 | 75 | def _initialize_tray_icon(self) -> QtWidgets.QSystemTrayIcon: 76 | def restore_window(reason: QtWidgets.QSystemTrayIcon.ActivationReason) -> None: 77 | if reason == QtWidgets.QSystemTrayIcon.DoubleClick: 78 | self.tray_icon.hide() 79 | self.showNormal() 80 | 81 | tray_icon = QtWidgets.QSystemTrayIcon(self) 82 | tray_icon.setIcon(self.windowIcon()) 83 | tray_icon.activated.connect(restore_window) 84 | return tray_icon 85 | 86 | def on_windowTitleChanged(self, title: str) -> None: 87 | QtCore.QCoreApplication.setApplicationName(title) 88 | self.setWindowIconText(title) 89 | 90 | @QtCore.pyqtSlot(str) 91 | def write_log(self, message): 92 | self.log_view.appendPlainText(message) 93 | self.log_view.verticalScrollBar().setValue(self.log_view.verticalScrollBar().maximum()) 94 | 95 | def toggle_log(self): 96 | self.log_view.setVisible(self.pb_log.isChecked()) 97 | 98 | def toggle_fullscreen(self): 99 | (self.showNormal if self.isFullScreen() else self.showFullScreen)() 100 | 101 | def keyPressEvent(self, event): 102 | if event.key() == QtCore.Qt.Key_F11: 103 | self.toggle_fullscreen() 104 | return super().keyPressEvent(event) 105 | 106 | def event(self, e): 107 | if not isinstance( 108 | e, 109 | ( 110 | QtCore.QEvent, 111 | QtCore.QChildEvent, 112 | QtCore.QDynamicPropertyChangeEvent, 113 | QtGui.QPaintEvent, 114 | QtGui.QHoverEvent, 115 | QtGui.QMoveEvent, 116 | QtGui.QEnterEvent, 117 | QtGui.QResizeEvent, 118 | QtGui.QShowEvent, 119 | QtGui.QPlatformSurfaceEvent, 120 | QtGui.QWindowStateChangeEvent, 121 | QtGui.QKeyEvent, 122 | QtGui.QWheelEvent, 123 | QtGui.QMouseEvent, 124 | QtGui.QFocusEvent, 125 | QtGui.QHelpEvent, 126 | QtGui.QHideEvent, 127 | QtGui.QCloseEvent, 128 | QtGui.QInputMethodQueryEvent, 129 | QtGui.QContextMenuEvent, 130 | ), 131 | ): 132 | log().warning("unknown event: %r %r", e.type(), e) 133 | return super().event(e) 134 | 135 | def closeEvent(self, event): 136 | for handler in logging.getLogger().handlers: 137 | if isinstance(handler, self.QPlainTextEditLogger): 138 | logging.getLogger().removeHandler(handler) 139 | break 140 | return super().closeEvent(event) 141 | 142 | def handle_signal(self, sig: int) -> None: 143 | """Handle posix signals, i.e. shut down on CTRL-C""" 144 | log().info( 145 | "got signal %s(%d)", 146 | dict( 147 | (k, v) 148 | for v, k in reversed(sorted(signal.__dict__.items())) 149 | if v.startswith("SIG") and not v.startswith("SIG_") 150 | ).get(sig, "unknown"), 151 | sig, 152 | ) 153 | if sig == signal.SIGINT: 154 | self.close() 155 | -------------------------------------------------------------------------------- /track/core/time_tracker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Defines TimeTracker class which gathers and provides information about deskopt usage 5 | """ 6 | 7 | import json 8 | import os 9 | from typing import Any, Dict, Optional, Sequence, Tuple # pylint: disable=unused-import 10 | 11 | from ..core import desktop_usage_info 12 | from ..core.util import catch, exception_to_string, log 13 | from . import ActiveApplications, common 14 | 15 | 16 | class TimeTracker: 17 | """* retrieves system data 18 | * holds the application data object as 19 | well as some meta information 20 | * provides persistence 21 | """ 22 | 23 | def __init__(self, data_dir: str) -> None: 24 | self._last_day = common.today_int() 25 | self._storage_dir = data_dir 26 | self._current_state = {} # type: Dict[str, Any] 27 | 28 | # -- data to persist 29 | data = catch( 30 | lambda: self._load_json("track-%s.json" % common.today_str()), 31 | (FileNotFoundError, json.JSONDecodeError), 32 | {}, 33 | ) 34 | self._applications = ActiveApplications(data.get("tracker_data")) 35 | self.note = data.get("daily_note") 36 | log().info("Found app data: %r", self._applications) 37 | 38 | self._re_rules = catch( 39 | lambda: self._load_json("category_rules.json"), 40 | (FileNotFoundError, json.JSONDecodeError), 41 | [ 42 | (r"check_mk", 2), 43 | (r".*Zoom.*", 2), 44 | (r"^Slack", 2), 45 | (r"^su heute", 2), 46 | (r"^Signal", 3), 47 | (r"^Zimbra", 2), 48 | (r"^gerrit/cmk", 2), 49 | (r"\[Jenkins\]", 2), 50 | (r"Track", 2), 51 | (r"^DER SPIEGEL", 3), 52 | (r".*SZ.de", 3), 53 | ], 54 | ) 55 | common.recategorize(self._applications.apps(), self._re_rules) 56 | 57 | def _load_json(self, filename: str) -> Any: 58 | """Properly read data from a JSON file""" 59 | with open(os.path.join(self._storage_dir, filename)) as file: 60 | return json.load(file) 61 | 62 | def _save_json(self, data: Dict[str, Any], filename: str) -> None: 63 | """Properly write data to a JSON file""" 64 | os.makedirs(self._storage_dir, exist_ok=True) 65 | with open(os.path.join(self._storage_dir, filename), "w") as file: 66 | json.dump( 67 | data, 68 | file, 69 | sort_keys=True, 70 | indent=4, 71 | ) 72 | 73 | def __eq__(self, other: object) -> bool: 74 | return False 75 | 76 | def clear(self) -> None: 77 | """Clear the application store - keeps the instance""" 78 | self._applications.clear() 79 | 80 | def persist(self, filename: str) -> None: 81 | """Store tracking info and regex rules on file system""" 82 | log().info("Save tracker data to %r", filename) 83 | self._save_json( 84 | {"tracker_data": self._applications.__data__(), "daily_note": self.note}, filename 85 | ) 86 | self._save_json(self._re_rules, "category_rules.json") 87 | 88 | # just for development 89 | _test_model = ActiveApplications() 90 | _test_model.from_dict(self._applications.__data__()) 91 | assert self._applications == _test_model 92 | 93 | def get_applications_model(self) -> ActiveApplications: 94 | """Return the current application store""" 95 | return self._applications 96 | 97 | def rules(self) -> common.Rules: 98 | """Return the current set of regex rules""" 99 | return self._re_rules 100 | 101 | def set_rules(self, rules: common.Rules) -> None: 102 | """Store a new set of regex rules and recalculate categories""" 103 | self._re_rules = rules 104 | common.recategorize(self._applications.apps(), self._re_rules) 105 | 106 | def set_note(self, note: str) -> None: 107 | """Store daily note""" 108 | self.note = note 109 | 110 | def update(self) -> None: 111 | """Gather desktop usage info""" 112 | try: 113 | current_day, current_minute = common.today_int(), common.minutes_since_midnight() 114 | midnight = current_day > self._last_day 115 | 116 | if midnight: 117 | log().info("current minute is %d - it's midnight", current_minute) 118 | self.persist("track-backup-%d.json" % self._last_day) 119 | self.clear() 120 | 121 | self._last_day = current_day 122 | 123 | app_info = desktop_usage_info.applicationinfo.get_active_window_information() 124 | current_app_title = app_info["TITLE"] 125 | current_process_exe = app_info.get("COMMAND", "Process not found") 126 | app = common.AppInfo(current_app_title, current_process_exe) 127 | current_category = common.get_category(app, self._re_rules) 128 | app.set_category(current_category) 129 | 130 | idle_current = desktop_usage_info.idle.getIdleSec() 131 | user_is_active = idle_current <= 10 132 | 133 | self._current_state = { 134 | "minute": current_minute, 135 | "category": current_category, 136 | "time_total": current_minute - self._applications.begin_index() + 1, 137 | "user_idle": idle_current, 138 | "user_active": user_is_active, 139 | "app_title": current_app_title, 140 | "process_name": current_process_exe, 141 | } 142 | print(self._current_state) 143 | 144 | if user_is_active: 145 | self._applications.update(current_minute, app) 146 | 147 | except KeyError as exc: 148 | log().error("%r", app_info) 149 | log().error("Got exception %r", exception_to_string(exc)) 150 | except desktop_usage_info.WindowInformationError: 151 | pass 152 | except desktop_usage_info.ToolError as exc: 153 | log().error(exc) 154 | 155 | def current_state(self) -> Dict[str, Any]: 156 | """Retrieve current usage snapshot""" 157 | return self._current_state 158 | -------------------------------------------------------------------------------- /track/core/common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """All the stuff needed by several components 5 | """ 6 | 7 | import argparse 8 | import os 9 | import re 10 | import sys 11 | import time 12 | from datetime import datetime 13 | from enum import IntEnum 14 | from typing import ( # pylint: disable=unused-import 15 | Any, 16 | Dict, 17 | List, 18 | Optional, 19 | Sequence, 20 | Tuple, 21 | ) 22 | 23 | from . import version_info 24 | from .util import log 25 | 26 | 27 | class Category(IntEnum): 28 | """One of each currently known time categories""" 29 | 30 | IDLE = 0 31 | UNASSIGNED = 1 32 | WORK = 2 33 | PRIVATE = 3 34 | BREAK = 4 35 | 36 | 37 | class AppInfo: 38 | def __init__(self, windowtitle="", cmdline=""): 39 | self._wndtitle = windowtitle 40 | self._cmdline = cmdline 41 | self._category = 0 42 | self._count = 0 43 | 44 | def __eq__(self, other) -> bool: 45 | return ( 46 | self._wndtitle == other._wndtitle 47 | and self._cmdline == other._cmdline 48 | and self._category == other._category 49 | ) 50 | 51 | def generate_identifier(self): 52 | return self._wndtitle 53 | 54 | def __hash__(self): 55 | return hash((self._wndtitle, self._cmdline)) 56 | 57 | def __str__(self): 58 | return "%s - [cat: %s, count: %d]" % (self._wndtitle, self._category, self._count) 59 | 60 | def load(self, data): 61 | try: 62 | self._wndtitle, self._category, self._count, self._cmdline = data 63 | except: 64 | print("tried to expand %s to (title, category, count, cmdline)" % (str(data))) 65 | raise Exception("could not load AppInfo data") 66 | return self 67 | 68 | def __data__(self): # const 69 | return (self._wndtitle, self._category, self._count, self._cmdline) 70 | 71 | # def category(self): 72 | # return self._category 73 | 74 | def set_category(self, category): 75 | self._category = category 76 | 77 | def set_new_category(self, new_category): 78 | self._category = new_category 79 | 80 | def get_count(self): 81 | return self._count 82 | 83 | 84 | class Minute: 85 | """a minute holds a category and a list of apps""" 86 | 87 | def __init__(self, app_counter=None): 88 | self._app_counter = app_counter or {} 89 | 90 | def __eq__(self, other): 91 | if not self._app_counter == other._app_counter: 92 | for a, c in self._app_counter.items(): 93 | print("s: %s:'%s' - %d" % (hex(id(a)), a, c)) 94 | for a, c in other._app_counter.items(): 95 | print("o: %s - %d" % (a, c)) 96 | return False 97 | return True 98 | 99 | def main_category(self): 100 | # app1(2): 5 101 | # app2(1): 2 102 | # app3(1): 7 103 | # 2: 5, 1: 2, 1: 7 104 | # 2: 5, 1: 9 105 | return self.main_app()._category 106 | 107 | def add(self, app_instance): 108 | self._app_counter[app_instance] = self._app_counter.get(app_instance, 0) + 1 109 | 110 | def main_app(self): 111 | return max(self._app_counter, key=lambda x: self._app_counter[x]) 112 | 113 | 114 | Rule = Tuple[str, Category] 115 | Rules = Sequence[Rule] 116 | 117 | 118 | def get_category(app: AppInfo, rules: Rules) -> Category: 119 | """Maps an Application to a track category""" 120 | app_string_representation = app.generate_identifier() 121 | for rule, category in rules: 122 | if re.search(rule, app_string_representation, flags=re.IGNORECASE): 123 | return category 124 | return Category.UNASSIGNED 125 | 126 | 127 | def recategorize(apps: Sequence[AppInfo], rules: Rules) -> None: 128 | for app in apps: 129 | app.set_category(get_category(app, rules)) 130 | 131 | 132 | def mins_to_date(mins): 133 | _result = "" 134 | _minutes = mins 135 | if _minutes >= 60: 136 | _result = "%2d:" % (_minutes / 60) 137 | _minutes %= 60 138 | _result += "%02d" % _minutes 139 | return _result 140 | 141 | 142 | def secs_to_dur(mins): 143 | _result = "" 144 | _minutes = mins 145 | if _minutes >= 60: 146 | _result = str(int(_minutes / 60)) + "m " 147 | _minutes %= 60 148 | _result += str(_minutes) + "s" 149 | return _result 150 | 151 | 152 | def mins_to_dur(mins: int) -> str: 153 | return "%d:%02d" % (mins / 60, mins % 60) if mins >= 60 else "%dm" % mins 154 | 155 | 156 | def today_str() -> str: 157 | return datetime.fromtimestamp(time.time()).strftime("%Y%m%d") 158 | 159 | 160 | def today_int() -> int: 161 | now = datetime.now() 162 | return now.year * 10000 + now.month * 100 + now.day 163 | 164 | 165 | def seconds_since_midnight() -> int: 166 | now = datetime.now() 167 | return int((now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds()) 168 | 169 | 170 | def minutes_since_midnight() -> int: 171 | return seconds_since_midnight() // 60 172 | 173 | 174 | def log_files(directory: str, reverse=False, exclude_today: bool = False) -> List[str]: 175 | """Return a sorted list of Track log file names found in @directory""" 176 | 177 | def get_date(filename: str) -> Optional[str]: 178 | match = re.search(r"track-(\d{8}).json", filename, flags=re.IGNORECASE) 179 | if not match: 180 | return None 181 | return match.group(1) 182 | 183 | today_timestamp = today_str() 184 | return sorted( 185 | ( 186 | file 187 | for file in os.listdir(directory) 188 | for date in (get_date(file),) 189 | if date and (date != today_timestamp or not exclude_today) 190 | ), 191 | reverse=reverse, 192 | ) 193 | 194 | 195 | def setup_argument_parser(parser: argparse.ArgumentParser) -> None: 196 | """Set some default arguments for track components""" 197 | parser.add_argument( 198 | "--log-level", 199 | "-l", 200 | choices=["DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"], 201 | default="INFO", 202 | ) 203 | parser.add_argument( 204 | "--data-dir", 205 | "-d", 206 | default=os.path.expanduser("~/.track"), 207 | ) 208 | parser.add_argument("--port", type=int, default=3456, help="IPv4 port to connect to") 209 | 210 | 211 | def log_system_info(args) -> None: 212 | """Print some interestion system information used for problem solving""" 213 | log().info( 214 | "Used Python interpreter: v%s (%s)", 215 | ".".join((str(e) for e in sys.version_info)), 216 | sys.executable, 217 | ) 218 | log().info("App version: %s", str(version_info)) 219 | log().info("Track dir: %s", args.data_dir) 220 | -------------------------------------------------------------------------------- /track/core/active_applications.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Defines class ActiveApplications 5 | """ 6 | 7 | from typing import Any, Dict, Tuple # pylint: disable=unused-import 8 | 9 | from ..core import common 10 | 11 | 12 | class ActiveApplications: 13 | """Data model which holds all application usage data for one 14 | day. That is: 15 | 16 | app_data: {app_id: application} 17 | 18 | minutes: {i_min => [app_id], i_cat} 19 | 20 | where 21 | 22 | application: (i_secs, i_cat, s_title, s_process) 23 | 24 | 25 | model_list: 26 | * sortable by key 27 | * can be done with list of keys sorted by given value 28 | [(app_id, i_secs, i_cat)] 29 | """ 30 | 31 | def __init__(self, json_data=None): 32 | self.clear() 33 | 34 | if json_data is not None: 35 | self.from_dict(json_data) 36 | 37 | def __repr__(self) -> str: 38 | return str(self) 39 | 40 | def __str__(self) -> str: 41 | return "Apps(%r to %r)" % ( 42 | common.mins_to_date(self._index_min), 43 | common.mins_to_date(self._index_max), 44 | ) 45 | 46 | def clear(self): 47 | """Clears all data (app info and timeline)""" 48 | # todo: mutex 49 | self._index_min = None 50 | self._index_max = None 51 | self._apps = {} # app identifier => AppInfo instance 52 | self._minutes = {} # i_min => minute 53 | 54 | def clip_from(self, index): 55 | """Removes all timeline data before provided index""" 56 | self._minutes = {minute: apps for minute, apps in self._minutes.items() if minute >= index} 57 | self._index_min = min(self._minutes.keys()) 58 | 59 | def clip_to(self, index): 60 | """Removes all timeline data after provided index""" 61 | self._minutes = {minute: apps for minute, apps in self._minutes.items() if minute <= index} 62 | self._index_max = max(self._minutes.keys()) 63 | 64 | def __eq__(self, other): 65 | """Comparing is only needed for tests""" 66 | if not self._apps == other._apps: 67 | return False 68 | if not self._minutes == other._minutes: 69 | if not self._minutes.keys() == other._minutes.keys: 70 | return False 71 | for key in self._minutes: 72 | if not self._minutes[key] == other._minutes[key]: 73 | return False 74 | return True 75 | 76 | def __data__(self): # const 77 | """we have to create an indexed list here because the minutes 78 | dict has to store references to AppInfo. 79 | intermediate: _indexed: {app_id => (i_index, AppInfo)} 80 | result: app: [AppInfo] 81 | minutes: {i_minute: (i_category, [(AppInfo, i_count)])} 82 | 83 | """ 84 | _indexed = {a: i for i, a in enumerate(self._apps.values())} 85 | _apps = [d[1] for d in sorted([(e[1], e[0].__data__()) for e in _indexed.items()])] 86 | _minutes = { 87 | i: [(_indexed[a], c) for a, c in m._app_counter.items()] 88 | for i, m in self._minutes.items() 89 | } 90 | return {"apps": _apps, "minutes": _minutes} 91 | 92 | def from_dict(self, data): 93 | def convert(minutes): 94 | # todo: just translate local files 95 | if all(len(data) == 2 and isinstance(data[0], int) for index, data in minutes.items()): 96 | return {key: value[1] for key, value in minutes.items()} 97 | return minutes 98 | 99 | assert "apps" in data 100 | assert "minutes" in data 101 | _a = data["apps"] 102 | _indexed = [common.AppInfo().load(d) for d in _a] 103 | _m = convert(data["minutes"]) 104 | _minutes = {int(i): common.Minute({_indexed[a]: c for a, c in m}) for i, m in _m.items()} 105 | 106 | _apps = {a.generate_identifier(): a for a in _indexed} 107 | 108 | self._apps = _apps 109 | self._minutes = _minutes 110 | 111 | if len(self._minutes) > 0: 112 | self._index_min = min(self._minutes.keys()) 113 | self._index_max = max(self._minutes.keys()) 114 | else: 115 | self._index_min = None 116 | self._index_max = None 117 | 118 | def begin_index(self): # const 119 | return self._index_min if self._index_min else 0 120 | 121 | def end_index(self): # const 122 | return self._index_max if self._index_max else 0 123 | 124 | def update(self, minute_index, app): 125 | # todo: mutex 126 | _app_id = app.generate_identifier() 127 | 128 | if _app_id not in self._apps: 129 | self._apps[_app_id] = app 130 | 131 | _app = self._apps[_app_id] 132 | _app._count += 1 133 | 134 | if minute_index not in self._minutes: 135 | self._minutes[minute_index] = common.Minute() 136 | if not self._index_min or self._index_min > minute_index: 137 | self._index_min = minute_index 138 | 139 | if not self._index_max or self._index_max < minute_index: 140 | self._index_max = minute_index 141 | 142 | self._minutes[minute_index].add(_app) 143 | 144 | def get_chunk_size(self, minute): 145 | if not (self._index_max and self._index_min): 146 | return 0, 0 147 | 148 | _begin = minute 149 | _end = minute 150 | 151 | if minute > self._index_max or minute < self._index_min: 152 | return _begin, _end 153 | 154 | _a = self._minutes[minute].main_app() if self.is_active(minute) else None 155 | _minutes = sorted(self._minutes.keys()) 156 | 157 | _lower_range = [i for i in _minutes if i < minute] 158 | _upper_range = [i for i in _minutes if i > minute] 159 | 160 | if _a is None: 161 | return ( 162 | _lower_range[-1] if _lower_range != [] else _begin, 163 | _upper_range[0] if _upper_range != [] else _end, 164 | ) 165 | 166 | for i in reversed(_lower_range): 167 | if _begin - i > 1: 168 | break 169 | if self._minutes[i].main_app() == _a: 170 | _begin = i 171 | 172 | for i in _upper_range: 173 | if i - _end > 1: 174 | break 175 | if self._minutes[i].main_app() == _a: 176 | _end = i 177 | 178 | # todo: currently gap is max 1min - make configurable 179 | return _begin, _end 180 | 181 | def info_at(self, minute: int) -> Tuple[int, str]: 182 | return ( 183 | self.get_chunk_size(minute), 184 | self._minutes[minute].main_app() if self.is_active(minute) else "idle", 185 | ) 186 | 187 | def is_active(self, minute): 188 | return minute in self._minutes 189 | 190 | def category_at(self, minute): 191 | return self._minutes[minute].main_category() if minute in self._minutes else 0 192 | 193 | def apps(self): 194 | return (app for _, app in self._apps.items()) 195 | -------------------------------------------------------------------------------- /track/server/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """Implements the track server which lurks in the background and collects 4 | application data 5 | """ 6 | 7 | import argparse 8 | import signal 9 | import sys 10 | import threading 11 | import time 12 | import traceback 13 | from contextlib import suppress 14 | from typing import Any, Dict 15 | 16 | import zmq # type: ignore 17 | 18 | from .. import core 19 | from ..core import common, desktop_usage_info, errors, util 20 | from ..core.util import log 21 | 22 | 23 | class TrackServer: 24 | """track activities, provide them to a time_tracker instance and 25 | run a zmq/json based server which provides the info to external 26 | consumers like a UI or a web service""" 27 | 28 | def __init__(self, args: argparse.Namespace) -> None: 29 | self._running = False 30 | self._system_monitoring_thread = threading.Thread( 31 | target=self._system_monitoring_fn, daemon=True 32 | ) 33 | self._tracker = core.TimeTracker(data_dir=args.data_dir) 34 | self._last_save_time = 0.0 35 | 36 | def _save_data(self, interval: int = 20, force: bool = False) -> None: 37 | if time.time() - self._last_save_time > interval or force: 38 | self._tracker.persist("track-%s.json" % common.today_str()) 39 | self._last_save_time = time.time() 40 | 41 | def _system_monitoring_fn(self) -> None: 42 | while self._running: 43 | time.sleep(1) 44 | self._save_data(interval=120) 45 | try: 46 | self._tracker.update() 47 | except desktop_usage_info.WindowInformationError: 48 | pass 49 | except Exception as ex: 50 | traceback.print_exc() 51 | log().error("Unhandled Exception: %s", ex) 52 | raise 53 | 54 | log().debug(self._tracker.current_state()) 55 | 56 | def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]: 57 | """Process a single request""" 58 | 59 | def wrong_command_fn(request: Dict[str, Any]) -> Dict[str, Any]: 60 | raise errors.RequestMalformed("Command %r not known" % request["cmd"]) 61 | 62 | def no_command_fn(request: Dict[str, Any]) -> Dict[str, Any]: 63 | raise errors.RequestMalformed('no "cmd" given') 64 | 65 | def quit_fn(_request: Dict[str, Any]) -> Dict[str, Any]: 66 | self._running = False 67 | self._system_monitoring_thread.join() 68 | return {"type": "ok"} 69 | 70 | def version_fn(_request: Dict[str, Any]) -> Dict[str, Any]: 71 | return {"data": {"version": str(core.version_info)}} 72 | 73 | def apps_fn(_request: Dict[str, Any]) -> Dict[str, Any]: 74 | return {"data": {"apps": self._tracker.get_applications_model().__data__()}} 75 | 76 | def current_fn(_request: Dict[str, Any]) -> Dict[str, Any]: 77 | return {"data": {"current": self._tracker.current_state()}} 78 | 79 | def rules_fn(_request: Dict[str, Any]) -> Dict[str, Any]: 80 | return {"data": {"rules": self._tracker.rules()}} 81 | 82 | def set_rules_fn(request: Dict[str, Any]) -> Dict[str, Any]: 83 | if "data" not in request or "rules" not in request["data"]: 84 | raise errors.RequestMalformed('No "rules" provided') 85 | self._tracker.set_rules(request["data"]["rules"]) 86 | return {"type": "ok"} 87 | 88 | def note_fn(_request: Dict[str, Any]) -> Dict[str, Any]: 89 | return {"data": {"note": self._tracker.note}} 90 | 91 | def set_note_fn(request: Dict[str, Any]) -> Dict[str, Any]: 92 | if "data" not in request or "note" not in request["data"]: 93 | raise errors.RequestMalformed('No "note" provided') 94 | self._tracker.set_note(request["data"]["note"]) 95 | return {"type": "ok"} 96 | 97 | def clip_from_fn(request: Dict[str, Any]) -> Dict[str, Any]: 98 | if "data" not in request or "index" not in request["data"]: 99 | raise errors.RequestMalformed('No "index" provided') 100 | self._tracker.get_applications_model().clip_from(request["data"]["index"]) 101 | # self._save_data(force=True) 102 | return {"type": "ok"} 103 | 104 | def clip_to_fn(request: Dict[str, Any]) -> Dict[str, Any]: 105 | if "data" not in request or "index" not in request["data"]: 106 | raise errors.RequestMalformed('No "index" provided') 107 | self._tracker.get_applications_model().clip_to(request["data"]["index"]) 108 | # self._save_data(force=True) 109 | return {"type": "ok"} 110 | 111 | def save_fn(_request: Dict[str, Any]) -> Dict[str, Any]: 112 | self._save_data(force=True) 113 | return {"type": "ok"} 114 | 115 | return { 116 | None: no_command_fn, 117 | "quit": quit_fn, 118 | "version": version_fn, 119 | "apps": apps_fn, 120 | "current": current_fn, 121 | "rules": rules_fn, 122 | "set_rules": set_rules_fn, 123 | "note": note_fn, 124 | "set_note": set_note_fn, 125 | "clip_from": clip_from_fn, 126 | "clip_to": clip_to_fn, 127 | "save": save_fn, 128 | }.get(request.get("cmd", None), wrong_command_fn)(request) 129 | 130 | def run(self, args: argparse.Namespace) -> None: 131 | """Run zmq message dispatching loop""" 132 | context = zmq.Context() 133 | # wing disable: undefined-attribute 134 | rep_socket = context.socket(zmq.REP) 135 | try: 136 | rep_socket.bind("tcp://127.0.0.1:%s" % str(args.port)) 137 | except zmq.ZMQError as exc: 138 | log().error(exc) 139 | return 140 | self._running = True 141 | 142 | self._system_monitoring_thread.start() 143 | 144 | while self._running: 145 | log().debug("listening..") 146 | try: 147 | request = rep_socket.recv_json() 148 | except zmq.ZMQError: 149 | self._running = False 150 | self._system_monitoring_thread.join() 151 | break 152 | 153 | log().debug(request) 154 | 155 | try: 156 | reply = self.handle_request(request) 157 | except errors.RequestMalformed as exc: 158 | reply = {"type": "error", "error_type": "request_malformed", "what": str(exc)} 159 | except Exception as exc: # pylint: disable=broad-except 160 | reply = {"type": "error", "what": str(exc)} 161 | 162 | rep_socket.send_json(reply) 163 | 164 | if self._system_monitoring_thread: 165 | self._system_monitoring_thread.join() 166 | 167 | log().info("close..") 168 | rep_socket.close() 169 | 170 | 171 | def parse_arguments() -> argparse.Namespace: 172 | """parse command line arguments and return argument object""" 173 | parser = argparse.ArgumentParser(description=__doc__) 174 | common.setup_argument_parser(parser) 175 | return parser.parse_args() 176 | 177 | 178 | def main() -> None: 179 | """Doc""" 180 | args = parse_arguments() 181 | util.setup_logging(args, syslog=True) 182 | log().name = "track-server" 183 | common.log_system_info(args) 184 | 185 | for sig in (signal.SIGABRT, signal.SIGINT, signal.SIGSEGV, signal.SIGTERM): 186 | signal.signal(sig, lambda signal, frame: sys.exit) # type: ignore 187 | TrackServer(args).run(args) 188 | 189 | 190 | if __name__ == "__main__": 191 | with suppress(KeyboardInterrupt, BrokenPipeError): 192 | main() 193 | -------------------------------------------------------------------------------- /track/ui/time_tracker_qt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Client side part of TrimeTracker 5 | """ 6 | 7 | from contextlib import suppress 8 | from datetime import datetime 9 | from typing import Any, Dict, Optional 10 | 11 | import zmq # type: ignore 12 | from PyQt5 import QtWidgets # type: ignore 13 | 14 | from .. import core 15 | from ..core import errors 16 | from ..core.util import log 17 | from .active_applications_qtmodel import ActiveApplicationsModel 18 | from .qt_common import TimechartDataprovider 19 | from .rules_model_qt import RulesModelQt 20 | 21 | 22 | class TimeTrackerClientQt(TimechartDataprovider): 23 | """* retrieves system data 24 | * holds the application data object as 25 | well as some meta information 26 | * provides persistence 27 | """ 28 | 29 | def __init__(self, parent: QtWidgets.QWidget) -> None: 30 | self._req_socket = None # type: Optional[zmq.Socket] 31 | self._req_poller = zmq.Poller() 32 | self._zmq_context = zmq.Context() 33 | self._receiving = False 34 | 35 | self._current_data = {} 36 | self._initialized = False 37 | self.connected = False 38 | 39 | self._active_day = core.today_int() 40 | 41 | self._applications = ActiveApplicationsModel(parent) 42 | self._rules_model = RulesModelQt(parent=parent) 43 | self._rules_model.rulesChanged.connect(self.update_categories) 44 | self._date = datetime.now() 45 | 46 | def date(self): 47 | return self._date 48 | 49 | def initialized(self): 50 | return self._initialized 51 | 52 | def begin_index(self): 53 | return self._applications.begin_index() 54 | 55 | def end_index(self): 56 | return self._applications.end_index() 57 | 58 | def daily_note(self) -> str: 59 | return self._request("note").get("note") 60 | 61 | def info_at(self, minute: int): 62 | return self._applications.info_at(minute) 63 | 64 | def category_at(self, minute: int): 65 | return self._applications.category_at(minute) 66 | 67 | def current_minute(self): 68 | return self._current_data.get("minute", 0) 69 | 70 | def time_total(self): 71 | return self.end_index() - self.begin_index() + 1 72 | 73 | def time_active(self): 74 | return len(self._applications._minutes) 75 | 76 | def time_work(self): 77 | return sum(minute.main_category() == 2 for _, minute in self._applications._minutes.items()) 78 | 79 | def time_private(self): 80 | return sum(minute.main_category() == 3 for _, minute in self._applications._minutes.items()) 81 | 82 | def time_idle(self): 83 | return self.time_total() - len(self._applications._minutes) 84 | 85 | def clip_from(self, index: str) -> None: 86 | self._request("clip_from", data={"index": index}) 87 | 88 | def clip_to(self, index: int) -> None: 89 | self._request("clip_to", data={"index": index}) 90 | 91 | def clear(self) -> None: 92 | self._applications.clear() 93 | 94 | def _req_send(self, msg: Dict[str, Any]) -> None: 95 | if self._receiving or self._req_socket is None: 96 | raise Exception("wrong send/recv state!") 97 | self._receiving = True 98 | self._req_socket.send_json(msg) 99 | 100 | def _req_recv(self, timeout: int, raise_on_timeout: bool) -> Dict[str, Any]: 101 | if not self._receiving or self._req_socket is None: 102 | raise Exception("wrong send/recv state!") 103 | self._receiving = False 104 | _timeout = timeout 105 | while True: 106 | if self._req_poller.poll(_timeout) == []: 107 | if raise_on_timeout: 108 | raise TimeoutError("timeout on recv()") 109 | log().warning("server timeout. did you even start one?") 110 | _timeout = 2000 111 | continue 112 | break 113 | return self._req_socket.recv_json() 114 | 115 | def _request( 116 | self, 117 | cmd: str, 118 | *, 119 | data: Optional[Dict[str, Any]] = None, 120 | timeout: str = 50, 121 | raise_on_timeout: bool = False, 122 | ) -> Dict[str, Any]: 123 | def result_or_exception(result): 124 | if result.get("type") == "error": 125 | raise RuntimeError(result["what"]) 126 | return result.get("data") 127 | 128 | if not self.connected: 129 | raise errors.NotConnected("Tried to send request while not connected to server") 130 | self._req_send({"cmd": cmd, "data": data}) 131 | return result_or_exception(self._req_recv(timeout, raise_on_timeout)) 132 | 133 | def connect(self, endpoint: str) -> None: 134 | if self._req_socket: 135 | self._req_poller.unregister(self._req_socket) 136 | self._req_socket.close() 137 | 138 | self._req_socket = self._zmq_context.socket(zmq.REQ) 139 | self._req_poller.register(self._req_socket, zmq.POLLIN) 140 | self._req_socket.connect(endpoint) 141 | self._check_version() 142 | self.connected = True 143 | self._fetch_rules() 144 | 145 | def _check_version(self): 146 | self._req_send({"cmd": "version"}) 147 | server_version = self._req_recv(timeout=1000, raise_on_timeout=True)["data"]["version"] 148 | if server_version > str(core.version_info): 149 | log().critical("Server version: %s", server_version) 150 | elif server_version < str(core.version_info): 151 | log().error("Server version: %s", server_version) 152 | else: 153 | log().error("Server version: %s", server_version) 154 | 155 | def _fetch_rules(self): 156 | rules = self._request("rules").get("rules") 157 | self._rules_model.set_rules(rules) 158 | 159 | def save(self) -> None: 160 | self._request("save") 161 | 162 | def update(self) -> None: 163 | current_data = self._request("current").get("current") 164 | apps = self._request("apps").get("apps") 165 | 166 | assert current_data is not None and apps is not None 167 | 168 | self._current_data = current_data 169 | self._applications.from_dict(apps) 170 | 171 | self._initialized = True 172 | 173 | def update_categories(self): 174 | log().info("Category rules have changed") 175 | self._request("set_rules", data={"rules": self._rules_model.rules()}) 176 | 177 | def set_note(self, text) -> None: 178 | self._request("set_note", data={"note": text}) 179 | 180 | def quit_server(self): 181 | with suppress(RuntimeError): 182 | self._request("quit") 183 | self.connected = False 184 | 185 | def get_applications_model(self): 186 | return self._applications 187 | 188 | def rules_model(self): 189 | return self._rules_model 190 | 191 | def is_active(self, minute): 192 | return self._applications.is_active(minute) 193 | 194 | def get_time_per_categories(self): 195 | # TODO: cache this, so you don't do so many operations per second. 196 | # This is pretty inneficient 197 | time_dict = {} 198 | for app_name in self._applications._apps: 199 | app = self._applications._apps[app_name] 200 | category = str(app._category) 201 | if category in time_dict: 202 | time_dict[category] += app.get_count() 203 | else: 204 | time_dict[category] = app.get_count() 205 | return time_dict 206 | 207 | def get_current_category(self): 208 | return self._current_data["category"] 209 | 210 | def get_idle(self): 211 | return self._current_data["user_idle"] 212 | 213 | def get_current_app_title(self): 214 | return self._current_data["app_title"] 215 | 216 | def get_current_process_name(self): 217 | return self._current_data["process_name"] 218 | 219 | def user_is_active(self) -> bool: 220 | return self._current_data["user_active"] 221 | -------------------------------------------------------------------------------- /track/ui/timegraph.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Defines daily log visualizer classes 5 | """ 6 | import json 7 | from datetime import datetime 8 | 9 | from PyQt5 import QtCore, QtGui, QtWidgets 10 | 11 | from ..core import ActiveApplications, common 12 | from ..core.util import catch 13 | from .qt_common import CategoryColor, TimechartDataprovider 14 | 15 | 16 | class FileDataprovider(TimechartDataprovider): 17 | """""" 18 | 19 | def __init__(self, filename): 20 | def convert(data): 21 | return data if "tracker_data" in data else {"tracker_data": data} 22 | 23 | with open(filename) as file: 24 | data = catch( 25 | lambda: convert(json.load(file)), (FileNotFoundError, json.JSONDecodeError), {} 26 | ) 27 | self.apps = ActiveApplications(data.get("tracker_data")) 28 | self._daily_note = data.get("daily_note") or "" 29 | self._date = datetime.strptime(filename[-13:-5], "%Y%m%d") 30 | 31 | def date(self) -> datetime: 32 | return self._date 33 | 34 | def initialized(self): 35 | return True 36 | 37 | def current_minute(self) -> int: 38 | return self.end_index() 39 | 40 | def clip_from(self, index: str) -> None: 41 | self.apps.clip_from(index) 42 | 43 | def clip_to(self, index: int) -> None: 44 | self.apps.clip_to(index) 45 | 46 | def begin_index(self): 47 | return self.apps.begin_index() 48 | 49 | def end_index(self): 50 | return self.apps.end_index() 51 | 52 | def daily_note(self) -> str: 53 | return self._daily_note 54 | 55 | def info_at(self, minute: int): 56 | return self.apps.info_at(minute) 57 | 58 | def category_at(self, minute: int): 59 | return self.apps.category_at(minute) 60 | 61 | def time_active(self): 62 | return len(self.apps._minutes) 63 | 64 | def time_work(self): 65 | return sum(minute.main_category() == 2 for _, minute in self.apps._minutes.items()) 66 | 67 | def time_private(self): 68 | return sum(minute.main_category() == 3 for _, minute in self.apps._minutes.items()) 69 | 70 | def time_total(self): 71 | return self.end_index() - self.begin_index() 72 | 73 | def time_idle(self): 74 | return self.time_total() - self.time_active() 75 | 76 | def recategorize(self, rules: common.Rules) -> None: 77 | common.recategorize(self.apps.apps(), rules) 78 | 79 | 80 | class Timegraph(QtWidgets.QFrame): 81 | clipFromClicked = QtCore.pyqtSignal(int) 82 | clipToClicked = QtCore.pyqtSignal(int) 83 | 84 | def __init__(self, parent=None): 85 | super().__init__(parent) 86 | self._dataprovider = None 87 | self.setMouseTracking(True) 88 | self._selected = None 89 | self.setMinimumHeight(50) 90 | 91 | def leaveEvent(self, _event): 92 | self.select() 93 | 94 | def set_dataprovider(self, dataprovider): 95 | self._dataprovider = dataprovider 96 | 97 | def dataprovider(self): 98 | return self._dataprovider 99 | 100 | def paintEvent(self, event): 101 | super().paintEvent(event) 102 | if self._dataprovider is None: 103 | return 104 | 105 | qp = QtGui.QPainter() 106 | qp.begin(self) 107 | self.drawPoints(qp) 108 | qp.end() 109 | 110 | def mouseMoveEvent(self, event): 111 | if self._dataprovider is None: 112 | return 113 | index = self._dataprovider.begin_index() + event.x() - 50 - 1 114 | if self._dataprovider.begin_index() <= index <= self._dataprovider.end_index(): 115 | (start, end), app_info = self._dataprovider.info_at(index) 116 | self.select(start, end) 117 | self.setToolTip( 118 | "%s: %s (%s)" 119 | % (common.mins_to_dur(index), app_info, common.mins_to_dur(end - start)) 120 | ) 121 | else: 122 | self.setToolTip(self._dataprovider.daily_note() or "no notes") 123 | 124 | def contextMenuEvent(self, event): 125 | menu = QtWidgets.QMenu(self) 126 | index = self._dataprovider.begin_index() + event.x() - 50 - 1 127 | clip_from = menu.addAction("clip before %s (erases data!)" % common.mins_to_dur(index)) 128 | clip_to = menu.addAction("clip after %s (erases data!)" % common.mins_to_dur(index)) 129 | action = menu.exec_(self.mapToGlobal(event.pos())) 130 | if action == clip_from: 131 | self.clipFromClicked.emit(index) 132 | if action == clip_to: 133 | self.clipToClicked.emit(index) 134 | 135 | def drawPoints(self, qp): 136 | if not self._dataprovider.initialized(): 137 | return 138 | 139 | _start_index = self._dataprovider.begin_index() - 50 140 | for i in range(self.width() - 2): 141 | _index = _start_index + i 142 | qp.setPen( 143 | # dark gray on borders of tracked time 144 | QtCore.Qt.gray 145 | if i < 50 or _index > self._dataprovider.current_minute() 146 | else 147 | # black 'now' line 148 | QtCore.Qt.black 149 | if self._dataprovider.current_minute() == _index 150 | else CategoryColor(self._dataprovider.category_at(_index)) 151 | ) 152 | 153 | qp.drawLine(i + 1, 0, i + 1, self.height() - 2) 154 | 155 | if self._selected is None: 156 | return 157 | 158 | qp.setPen(QtCore.Qt.blue) 159 | for i in range(self._selected[0], self._selected[1] + 1): 160 | qp.drawLine(i - _start_index, 0 + 20, i - _start_index, self.height() - 2 - 20) 161 | 162 | def select(self, begin=None, end=None): 163 | self._selected = (begin, end) if begin is not None and end is not None else None 164 | self.update() 165 | 166 | 167 | class EvaluationWidget(QtWidgets.QFrame): 168 | def __init__(self, parent=None, *, dataprovider=None): 169 | super().__init__(parent) 170 | self.setFrameStyle(QtWidgets.QFrame.Box) 171 | layout1 = QtWidgets.QVBoxLayout() 172 | layout2 = QtWidgets.QHBoxLayout() 173 | layout3 = QtWidgets.QHBoxLayout() 174 | layout1.setContentsMargins(10, 2, 10, 0) 175 | layout2.setContentsMargins(0, 0, 0, 0) 176 | layout3.setContentsMargins(0, 0, 0, 0) 177 | 178 | self.timegraph = Timegraph() 179 | font = QtGui.QFont() 180 | font.setBold(True) 181 | 182 | self.lbl_date = QtWidgets.QLabel("Date") 183 | self.lbl_date.setFont(font) 184 | self.lbl_totals = QtWidgets.QLabel("Totals") 185 | self.lbl_totals.setFont(font) 186 | self.timegraph.clipFromClicked.connect(self.on_timegraph_clipFromClicked) 187 | self.timegraph.clipToClicked.connect(self.on_timegraph_clipToClicked) 188 | 189 | self.lbl_active = QtWidgets.QLabel("active") 190 | self.lbl_work = QtWidgets.QLabel("work") 191 | self.lbl_private = QtWidgets.QLabel("private") 192 | self.lbl_idle = QtWidgets.QLabel("idle") 193 | layout3.addWidget(self.lbl_active) 194 | layout3.addWidget(self.lbl_work) 195 | layout3.addWidget(self.lbl_private) 196 | layout3.addWidget(self.lbl_idle) 197 | 198 | layout2.addWidget(self.lbl_date) 199 | layout2.addWidget(self.lbl_totals) 200 | 201 | layout1.addLayout(layout2) 202 | layout1.addWidget(self.timegraph) 203 | layout1.addLayout(layout3) 204 | 205 | self.setLayout(layout1) 206 | if dataprovider is not None: 207 | self.set_dataprovider(dataprovider) 208 | 209 | @QtCore.pyqtSlot(int) 210 | def on_timegraph_clipFromClicked(self, index: int) -> None: 211 | self.timegraph.dataprovider().clip_from(index) 212 | self.update_widgets() 213 | 214 | @QtCore.pyqtSlot(int) 215 | def on_timegraph_clipToClicked(self, index: int) -> None: 216 | self.timegraph.dataprovider().clip_to(index) 217 | self.update_widgets() 218 | 219 | def set_dataprovider(self, dataprovider): 220 | self.timegraph.set_dataprovider(dataprovider) 221 | self.update_widgets() 222 | 223 | def update_widgets(self): 224 | def fmt(dur: int) -> str: 225 | return "%0.2d:%0.2d" % (int(dur // 60), dur % 60) 226 | 227 | self.timegraph.update() 228 | dp = self.timegraph.dataprovider() 229 | self.lbl_date.setText(dp.date().strftime("%Y/%m/%d-%A")) 230 | _time_total = dp.time_total() 231 | _time_active = dp.time_active() 232 | _time_work = dp.time_work() 233 | _time_private = dp.time_private() 234 | _time_idle = dp.time_idle() 235 | percentage = 100.0 / _time_total if _time_total else 0.0 236 | self.lbl_active.setText( 237 | "active: %s (%d%%)" % (common.mins_to_dur(_time_active), _time_active * percentage) 238 | ) 239 | self.lbl_work.setText( 240 | "work: %s (%d%%)" % (common.mins_to_dur(_time_work), _time_work * percentage) 241 | ) 242 | self.lbl_private.setText( 243 | "private: %s (%d%%)" % (common.mins_to_dur(_time_private), _time_private * percentage) 244 | ) 245 | self.lbl_idle.setText( 246 | "idle: %s (%d%%)" % (common.mins_to_dur(_time_idle), _time_idle * percentage) 247 | ) 248 | 249 | self.lbl_totals.setText( 250 | "%s - %s: %s" 251 | % (fmt(dp.begin_index()), fmt(dp.current_minute()), common.mins_to_dur(_time_total)) 252 | ) 253 | 254 | def recategorize(self, rules: common.Rules): 255 | self.timegraph.dataprovider().recategorize(rules) 256 | self.update_widgets() 257 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /track/ui/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Track main UI module 5 | """ 6 | 7 | import argparse 8 | import os 9 | import os.path 10 | import subprocess 11 | import sys 12 | from contextlib import suppress 13 | from typing import Any 14 | 15 | try: 16 | from PyQt5 import QtCore, QtGui, QtWidgets # type: ignore 17 | except ImportError: 18 | print( 19 | "you have to have PyQt5 for your version of Python (%s) installed" 20 | % ".".join(str(x) for x in sys.version_info) 21 | ) 22 | sys.exit(-1) 23 | 24 | from pathlib import Path 25 | 26 | from ..core import common, errors, util 27 | from ..core.util import log 28 | from .mainwindow import MainWindow 29 | from .qreordertableview import ReorderTableView 30 | from .qt_common import CategoryColor, SimpleQtThread 31 | from .time_tracker_qt import TimeTrackerClientQt 32 | from .timegraph import EvaluationWidget, FileDataprovider 33 | 34 | 35 | def start_server_process(args) -> None: 36 | """Start the track server""" 37 | log().info("start track server daemon") 38 | module_path = Path(__file__).parent.parent 39 | server_file = module_path / "track-server" 40 | subprocess.Popen( 41 | [ 42 | sys.executable, 43 | server_file, 44 | "--log-level", 45 | args.log_level, 46 | "--data-dir", 47 | args.data_dir, 48 | "--port", 49 | str(args.port), 50 | ], 51 | env={**os.environ, **{"PYTHONPATH": module_path.parent}}, 52 | ) 53 | 54 | 55 | def category_name(value): 56 | """Maps category value to names""" 57 | return { 58 | 0: "idle", 59 | 1: "unassigned", 60 | 2: "work", 61 | 3: "private", 62 | 4: "break", 63 | }.get(value, "?") 64 | 65 | 66 | def check_for_updates() -> None: 67 | """Identifies whether the track instance is git versioned, fetches from upstream and 68 | checks whether there are updates to pull""" 69 | log().info("Check for remote app updates on git remote..") 70 | 71 | def git_cmd(cmd) -> str: 72 | result = subprocess.run( 73 | ["git"] + cmd, 74 | cwd=os.path.dirname(__file__), 75 | stdout=subprocess.PIPE, 76 | stderr=subprocess.PIPE, 77 | check=True, 78 | universal_newlines=True, 79 | ) 80 | for line in (l for l in result.stderr.split("\n") if l.strip() != ""): 81 | log().debug("git: %r", line) 82 | return result.stdout.rstrip("\n") 83 | 84 | try: 85 | origin = git_cmd(["config", "--get", "remote.origin.url"]) 86 | log().debug("Git repo origin: %r", origin) 87 | if "frans-fuerst/track" not in origin: 88 | log().info("Identified git repo is not the original one - skip fetch") 89 | return 90 | for line in git_cmd(["fetch"]): 91 | log().debug(line) 92 | local_sha = git_cmd(["rev-parse", "@"]) 93 | remote_sha = git_cmd(["rev-parse", "@{u}"]) 94 | base_sha = git_cmd(["merge-base", "@", "@{u}"]) 95 | return ( 96 | 0 97 | if local_sha == remote_sha 98 | else 1 99 | if remote_sha == base_sha 100 | else 2 101 | if local_sha == base_sha 102 | else 3 103 | ) 104 | except (FileNotFoundError, subprocess.CalledProcessError) as exc: 105 | log().warning("Was not able to check for git updates: %r", exc) 106 | 107 | 108 | class TrackUI(MainWindow): 109 | """Track recorder UI""" 110 | 111 | class ApplicationTableDelegate(QtWidgets.QStyledItemDelegate): 112 | """Delegator which draws a coloured background for a certain column""" 113 | 114 | def initStyleOption( 115 | self, option: QtWidgets.QStyleOptionViewItem, index: QtCore.QModelIndex 116 | ) -> None: 117 | """Set text style and color""" 118 | super().initStyleOption(option, index) 119 | if index.column() == 2: 120 | option.backgroundBrush = QtGui.QBrush(CategoryColor(index.data())) 121 | 122 | def displayText(self, value: Any, locale: QtCore.QLocale) -> Any: 123 | """Convert from category to category names""" 124 | return ( 125 | category_name(value) 126 | if isinstance(value, int) 127 | else super().displayText(value, locale) 128 | ) 129 | 130 | class RulesTableDelegate(QtWidgets.QStyledItemDelegate): 131 | """Delegator which draws a coloured background for a certain column""" 132 | 133 | def initStyleOption( 134 | self, option: QtWidgets.QStyleOptionViewItem, index: QtCore.QModelIndex 135 | ) -> None: 136 | """Set text style and color""" 137 | super().initStyleOption(option, index) 138 | if index.column() == 0: 139 | option.font.setFamily("Courier New") 140 | if index.data() == "": 141 | option.displayAlignment = QtCore.Qt.AlignCenter 142 | if index.column() == 1: 143 | option.displayAlignment = QtCore.Qt.AlignCenter 144 | option.backgroundBrush = QtGui.QBrush(CategoryColor(index.data())) 145 | 146 | def displayText(self, value: Any, locale: QtCore.QLocale) -> Any: 147 | """Convert from category to category names""" 148 | return ( 149 | category_name(value) 150 | if isinstance(value, int) 151 | else "new rule" 152 | if value == "" 153 | else super().displayText(value, locale) 154 | ) 155 | 156 | def __init__(self, args: argparse.Namespace) -> None: 157 | super().__init__() 158 | 159 | self._args = args 160 | 161 | # setup log window as early as possible in order to see possible error messages 162 | self.log_view = QtWidgets.QPlainTextEdit() 163 | self.log_spoiler.setTitle("Log messages") 164 | self.log_spoiler.addWidget(self.log_view) 165 | self.log_spoiler.setFrameShape(QtWidgets.QFrame.NoFrame) 166 | self.log_spoiler.setExpanded(True) 167 | self.setup_common_widgets() 168 | 169 | common.log_system_info(args) 170 | 171 | self._endpoint = "tcp://127.0.0.1:%s" % str(args.port) 172 | self._tracker = TimeTrackerClientQt(self) 173 | self._tracker.rules_model().rulesChanged.connect(self.on_rules_changed) 174 | 175 | font = QtGui.QFont("FreeMono") 176 | font.setStyleHint(QtGui.QFont.Monospace) 177 | font.setPointSize(12) 178 | font.setBold(True) 179 | 180 | self.txt_notes = QtWidgets.QTextEdit() 181 | self.txt_notes.setFont(font) 182 | self.txt_notes.setPlaceholderText("Write a line about what you're doing today") 183 | self.txt_notes.setAcceptRichText(False) 184 | self.txt_notes.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) 185 | self.txt_notes.textChanged.connect(self.on_txt_notes_textChanged) 186 | 187 | self.notes_spoiler.setTitle("Daily notes") 188 | self.notes_spoiler.addWidget(self.txt_notes) 189 | self.notes_spoiler.setFrameShape(QtWidgets.QFrame.NoFrame) 190 | self.notes_spoiler.setExpanded(True) 191 | 192 | self.tbl_category_rules = ReorderTableView() 193 | self.tbl_category_rules.setItemDelegate(self.RulesTableDelegate()) 194 | self.tbl_category_rules.setModel(self._tracker.rules_model()) 195 | category_rules_header = self.tbl_category_rules.horizontalHeader() 196 | category_rules_header.setDefaultAlignment(QtCore.Qt.AlignLeft) 197 | category_rules_header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) 198 | category_rules_header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) 199 | 200 | self.regex_spoiler.setTitle("Category assignment rules (caution: regex)") 201 | self.regex_spoiler.addWidget(self.tbl_category_rules) 202 | self.regex_spoiler.setFrameShape(QtWidgets.QFrame.NoFrame) 203 | 204 | self.tbl_active_applications = QtWidgets.QTableView() 205 | self.tbl_active_applications.setModel(self._tracker.get_applications_model()) 206 | self.tbl_active_applications.setSortingEnabled(True) 207 | self.tbl_active_applications.verticalHeader().setVisible(False) 208 | self.tbl_active_applications.setDragDropMode(QtWidgets.QAbstractItemView.DragDrop) 209 | self.tbl_active_applications.setDragEnabled(True) 210 | self.tbl_active_applications.setItemDelegate(self.ApplicationTableDelegate()) 211 | self.tbl_active_applications.selectionModel().currentRowChanged.connect(self.cc) 212 | active_applications_header = self.tbl_active_applications.horizontalHeader() 213 | active_applications_header.setSortIndicatorShown(True) 214 | active_applications_header.setDefaultAlignment(QtCore.Qt.AlignLeft) 215 | active_applications_header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) 216 | active_applications_header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) 217 | active_applications_header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) 218 | 219 | self.active_applications_spoiler.setTitle("Active Applications") 220 | self.active_applications_spoiler.setFrameShape(QtWidgets.QFrame.NoFrame) 221 | self.active_applications_spoiler.addWidget(self.tbl_active_applications) 222 | self.active_applications_spoiler.setExpanded(True) 223 | 224 | self.tbl_evaluation = QtWidgets.QListWidget() 225 | for filename in common.log_files(args.data_dir, reverse=True, exclude_today=True): 226 | myQCustomQWidget = EvaluationWidget( 227 | dataprovider=FileDataprovider(os.path.join(args.data_dir, filename)) 228 | ) 229 | myQListWidgetItem = QtWidgets.QListWidgetItem(self.tbl_evaluation) 230 | myQListWidgetItem.setSizeHint(myQCustomQWidget.sizeHint()) 231 | self.tbl_evaluation.addItem(myQListWidgetItem) 232 | self.tbl_evaluation.setItemWidget(myQListWidgetItem, myQCustomQWidget) 233 | 234 | self.evaluation_spoiler.setTitle("Evaluation") 235 | self.evaluation_spoiler.setFrameShape(QtWidgets.QFrame.NoFrame) 236 | self.evaluation_spoiler.addWidget(self.tbl_evaluation) 237 | 238 | self.setWindowIcon(self.style().standardIcon(QtWidgets.QStyle.SP_MediaSeekForward)) 239 | self.setGeometry(0, 0, 700, 800) 240 | self.tray_icon = self._initialize_tray_icon() 241 | 242 | # self._start_git_update_check() 243 | 244 | self.pb_quit_server.setVisible(os.environ.get("USER", "") in {"frafue", "frans"}) 245 | 246 | self.frm_timegraph.set_dataprovider(self._tracker) 247 | 248 | self._update_timer = QtCore.QTimer(self) 249 | self._update_timer.timeout.connect(self.update_idle) 250 | 251 | def cc(self, current): 252 | if not current.column() == 0: 253 | return 254 | self._tracker.rules_model().check_string(current.data()) 255 | 256 | @QtCore.pyqtSlot() 257 | def on_txt_notes_textChanged(self): 258 | self._tracker.set_note(self.txt_notes.toPlainText()) 259 | 260 | @QtCore.pyqtSlot() 261 | def on_pb_quit_server_clicked(self) -> None: 262 | self._tracker.quit_server() 263 | self.close() 264 | 265 | @QtCore.pyqtSlot() 266 | def on_rules_changed(self) -> None: 267 | self.tbl_category_rules.update() 268 | for i in range(self.tbl_evaluation.count()): 269 | self.tbl_evaluation.itemWidget(self.tbl_evaluation.item(i)).recategorize( 270 | self._tracker.rules_model().rules() 271 | ) 272 | 273 | def update_idle(self) -> None: 274 | self._tracker.update() 275 | self.frm_timegraph.update_widgets() 276 | self.lbl_title.setMargin(2) 277 | self.lbl_idle.setMargin(2) 278 | self.lbl_process.setMargin(4) 279 | self.lbl_title.setText(self._tracker.get_current_app_title()) 280 | self.lbl_idle.setText("%ds" % self._tracker.get_idle()) 281 | self.lbl_process.setText(self._tracker.get_current_process_name()) 282 | 283 | palette = self.lbl_title.palette() 284 | palette.setColor( 285 | self.lbl_title.backgroundRole(), 286 | CategoryColor(self._tracker.get_current_category()) 287 | if self._tracker.user_is_active() 288 | else QtCore.Qt.gray, 289 | ) 290 | self.lbl_idle.setPalette(palette) 291 | self.lbl_title.setPalette(palette) 292 | 293 | self.update() 294 | 295 | def _connect(self) -> bool: 296 | _retried = False 297 | while True: 298 | try: 299 | log().info("connect to track server..") 300 | self._tracker.connect(self._endpoint) 301 | log().info("connected!") 302 | return True 303 | except TimeoutError: 304 | if _retried: 305 | log().error("could not connect to track server") 306 | return False 307 | log().info( 308 | "could not connect to server - assume " 309 | "it's not running and start a server instance" 310 | ) 311 | _retried = True 312 | start_server_process(self._args) 313 | 314 | @QtCore.pyqtSlot() 315 | def _show_info_popup(self): 316 | if ( 317 | QtWidgets.QMessageBox.question( 318 | self, 319 | "Good news everyone!", 320 | "Looks like Track has been updated on GitHub.\n" 321 | "Maybe you should give it a try and run `git pull` (manually)!\n" 322 | "Do you want to close Track (and its server)?", 323 | ) 324 | == QtWidgets.QMessageBox.Yes 325 | ): 326 | self._tracker.quit_server() 327 | self.close() 328 | 329 | @QtCore.pyqtSlot() 330 | def _git_update_timer_timeout(self): 331 | QtCore.QTimer.singleShot(10 * 60 * 1000, self._start_git_update_check) 332 | 333 | def _start_git_update_check(self): 334 | def check_and_restart(): 335 | git_state = check_for_updates() 336 | if git_state == 0: 337 | log().info("Local track repository is in sync with GitHub") 338 | elif git_state == 1: 339 | log().info("You have local commits not pushed yet") 340 | elif git_state in {2, 3}: 341 | QtCore.QMetaObject.invokeMethod( 342 | self, "_show_info_popup", QtCore.Qt.QueuedConnection 343 | ) 344 | QtCore.QMetaObject.invokeMethod( 345 | self, "_git_update_timer_timeout", QtCore.Qt.QueuedConnection 346 | ) 347 | 348 | self._just_to_keep_the_thread = SimpleQtThread(target=check_and_restart) 349 | 350 | def keyPressEvent(self, event: QtCore.QEvent) -> bool: 351 | if event.key() == QtCore.Qt.Key_Delete and self.tbl_category_rules.hasFocus(): 352 | rows = set(index.row() for index in self.tbl_category_rules.selectedIndexes()) 353 | for row in rows: 354 | self._tracker.rules_model().removeRow(row) 355 | 356 | return super().keyPressEvent(event) 357 | 358 | def event(self, event: QtCore.QEvent) -> bool: 359 | """Handle Qt events""" 360 | _type = event.type() 361 | if isinstance(event, QtGui.QShowEvent) and not self._tracker.connected: 362 | if self._connect(): 363 | self._update_timer.start(1000) 364 | self.txt_notes.setText(self._tracker.daily_note()) 365 | self.log_spoiler.setExpanded(False) 366 | 367 | else: 368 | QtWidgets.QMessageBox.information( 369 | self, 370 | "track service unreachable", 371 | "Cannot reach the local track service even after starting " 372 | "a new instance.\nPlease restart track on command " 373 | "line to get some more info and file a bug!\n\nBye!", 374 | buttons=QtWidgets.QMessageBox.Ok, 375 | ) 376 | QtWidgets.QApplication.quit() 377 | elif _type == QtCore.QEvent.WindowStateChange and self.isMinimized(): 378 | if "gnome" not in os.environ.get("DESKTOP_SESSION", ""): 379 | # The window is already minimized at this point. AFAIK, 380 | # there is no hook stop a minimize event. Instead, 381 | # removing the Qt.Tool flag should remove the window 382 | # from the taskbar. 383 | self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.Tool) 384 | self.tray_icon.show() 385 | return True 386 | else: 387 | # log.debug("unhandled event '%s' (%d)", 388 | # i_to_e[_type] if _type in i_to_e else "unknown", 389 | # _type) 390 | pass 391 | 392 | return super().event(event) # type: ignore 393 | 394 | def closeEvent(self, event: QtCore.QEvent) -> None: 395 | """Shut down gracefully (i.e. close threads)""" 396 | log().info( 397 | "Application is about to close %s", 398 | "(but server still running)" if self._tracker.connected else "", 399 | ) 400 | self._update_timer.stop() 401 | if self._tracker.initialized(): 402 | with suppress(errors.NotConnected, RuntimeError): 403 | self._tracker.save() 404 | return super().closeEvent(event) 405 | 406 | 407 | def parse_arguments() -> argparse.Namespace: 408 | """parse command line arguments and return argument object""" 409 | parser = argparse.ArgumentParser(description=__doc__) 410 | common.setup_argument_parser(parser) 411 | return parser.parse_args() 412 | 413 | 414 | def main() -> int: 415 | """read command line arguments, configure application and run command 416 | specified on command line 417 | """ 418 | args = parse_arguments() 419 | util.setup_logging(args) 420 | log().name = "track-ui" 421 | app = QtWidgets.QApplication(sys.argv) 422 | 423 | window = TrackUI(args) 424 | window.show() 425 | 426 | return app.exec_() 427 | -------------------------------------------------------------------------------- /doc/architecture.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 29 | 34 | 35 | 43 | 48 | 49 | 50 | 70 | 74 | 75 | 77 | 78 | 80 | image/svg+xml 81 | 83 | 84 | 85 | 86 | 87 | 92 | 100 | track_server 112 | 120 | track (UI) 131 | 139 | 147 | 155 | 163 | applications_model 174 | rules_model 185 | applications_table 196 | 204 | main window 215 | 223 | rules/categories_table 234 | track_client 245 | 253 | timegraph 264 | 272 | status_info 283 | 291 | 299 | 317 | zmq server 328 | application_info 339 | rules /categories 350 | 358 | 366 | desktop_usage_info 377 | 383 | 389 | 395 | 401 | 407 | 415 | 423 | 431 | 439 | 447 | 448 | 449 | -------------------------------------------------------------------------------- /track/ui/mainwindow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 730 10 | 807 11 | 12 | 13 | 14 | 15 | 0 16 | 0 17 | 18 | 19 | 20 | Track 21 | 22 | 23 | 24 | 25 | 26 | 27 | 6 28 | 29 | 30 | 6 31 | 32 | 33 | 6 34 | 35 | 36 | 6 37 | 38 | 39 | 40 | 41 | 42 | 0 43 | 0 44 | 45 | 46 | 47 | QFrame::StyledPanel 48 | 49 | 50 | QFrame::Sunken 51 | 52 | 53 | 54 | 0 55 | 56 | 57 | 58 | 59 | 60 | 20 61 | 16777215 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 0 74 | 0 75 | 76 | 77 | 78 | Current process: 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 0 87 | 0 88 | 89 | 90 | 91 | Current window: 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 0 100 | 0 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 46 110 | 52 111 | 54 112 | 113 | 114 | 115 | 116 | 117 | 118 | 255 119 | 255 120 | 255 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 46 130 | 52 131 | 54 132 | 133 | 134 | 135 | 136 | 137 | 138 | 255 139 | 255 140 | 255 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 190 150 | 190 151 | 190 152 | 153 | 154 | 155 | 156 | 157 | 158 | 255 159 | 255 160 | 255 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | Monospace 170 | 10 171 | 172 | 173 | 174 | true 175 | 176 | 177 | QFrame::Box 178 | 179 | 180 | /currently/started/process --with --all -args --with --all -args --with --all -args --with --all 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 0 189 | 0 190 | 191 | 192 | 193 | 194 | 12 195 | 75 196 | true 197 | 198 | 199 | 200 | true 201 | 202 | 203 | Current window title 204 | 205 | 206 | 207 | 208 | 209 | 210 | true 211 | 212 | 213 | 14s 214 | 215 | 216 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 0 228 | 0 229 | 230 | 231 | 232 | 233 | 0 234 | 150 235 | 236 | 237 | 238 | 239 | 16777215 240 | 150 241 | 242 | 243 | 244 | QFrame::StyledPanel 245 | 246 | 247 | QFrame::Sunken 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | Used applications by time 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 16 265 | 16 266 | 267 | 268 | 269 | 270 | 16 271 | 16 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 255 281 | 255 282 | 255 283 | 284 | 285 | 286 | 287 | 288 | 289 | 0 290 | 85 291 | 127 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 255 301 | 255 302 | 255 303 | 304 | 305 | 306 | 307 | 308 | 309 | 0 310 | 85 311 | 127 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 0 321 | 85 322 | 127 323 | 324 | 325 | 326 | 327 | 328 | 329 | 0 330 | 85 331 | 127 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | true 340 | 341 | 342 | QFrame::Box 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 0 354 | 0 355 | 356 | 357 | 358 | 359 | 50 360 | 0 361 | 362 | 363 | 364 | work (2) 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 16 373 | 16 374 | 375 | 376 | 377 | 378 | 16 379 | 16 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 255 389 | 255 390 | 255 391 | 392 | 393 | 394 | 395 | 396 | 397 | 0 398 | 255 399 | 255 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 255 409 | 255 410 | 255 411 | 412 | 413 | 414 | 415 | 416 | 417 | 0 418 | 255 419 | 255 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 0 429 | 255 430 | 255 431 | 432 | 433 | 434 | 435 | 436 | 437 | 0 438 | 255 439 | 255 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | true 448 | 449 | 450 | QFrame::Box 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 0 462 | 0 463 | 464 | 465 | 466 | 467 | 50 468 | 0 469 | 470 | 471 | 472 | private (3) 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 16 481 | 16 482 | 483 | 484 | 485 | 486 | 16 487 | 16 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 255 497 | 255 498 | 255 499 | 500 | 501 | 502 | 503 | 504 | 505 | 138 506 | 226 507 | 52 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 255 517 | 255 518 | 255 519 | 520 | 521 | 522 | 523 | 524 | 525 | 138 526 | 226 527 | 52 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 138 537 | 226 538 | 52 539 | 540 | 541 | 542 | 543 | 544 | 545 | 138 546 | 226 547 | 52 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | true 556 | 557 | 558 | QFrame::Box 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 0 570 | 0 571 | 572 | 573 | 574 | 575 | 50 576 | 0 577 | 578 | 579 | 580 | break (4) 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 16 589 | 16 590 | 591 | 592 | 593 | 594 | 16 595 | 16 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 206 605 | 92 606 | 0 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 206 616 | 92 617 | 0 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 206 627 | 92 628 | 0 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | true 637 | 638 | 639 | QFrame::Box 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 0 651 | 0 652 | 653 | 654 | 655 | 656 | 85 657 | 0 658 | 659 | 660 | 661 | not handled (1) 662 | 663 | 664 | 665 | 666 | 667 | 668 | 669 | 16 670 | 16 671 | 672 | 673 | 674 | 675 | 16 676 | 16 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | 685 | 255 686 | 255 687 | 255 688 | 689 | 690 | 691 | 692 | 693 | 694 | 255 695 | 255 696 | 255 697 | 698 | 699 | 700 | 701 | 702 | 703 | 704 | 705 | 255 706 | 255 707 | 255 708 | 709 | 710 | 711 | 712 | 713 | 714 | 255 715 | 255 716 | 255 717 | 718 | 719 | 720 | 721 | 722 | 723 | 724 | 725 | 255 726 | 255 727 | 255 728 | 729 | 730 | 731 | 732 | 733 | 734 | 255 735 | 255 736 | 255 737 | 738 | 739 | 740 | 741 | 742 | 743 | 744 | true 745 | 746 | 747 | QFrame::Box 748 | 749 | 750 | 751 | 752 | 753 | 754 | 755 | 756 | 757 | 758 | 0 759 | 0 760 | 761 | 762 | 763 | 764 | 50 765 | 0 766 | 767 | 768 | 769 | idle (0) 770 | 771 | 772 | 773 | 774 | 775 | 776 | 777 | 778 | 779 | 0 780 | 20 781 | 782 | 783 | 784 | QFrame::StyledPanel 785 | 786 | 787 | QFrame::Raised 788 | 789 | 790 | 791 | 792 | 793 | 794 | 795 | 0 796 | 20 797 | 798 | 799 | 800 | QFrame::StyledPanel 801 | 802 | 803 | QFrame::Raised 804 | 805 | 806 | 807 | 808 | 809 | 810 | 811 | 0 812 | 20 813 | 814 | 815 | 816 | QFrame::StyledPanel 817 | 818 | 819 | 820 | 821 | 822 | 823 | 824 | 0 825 | 20 826 | 827 | 828 | 829 | QFrame::StyledPanel 830 | 831 | 832 | QFrame::Plain 833 | 834 | 835 | 836 | 837 | 838 | 839 | 840 | 0 841 | 20 842 | 843 | 844 | 845 | QFrame::StyledPanel 846 | 847 | 848 | QFrame::Plain 849 | 850 | 851 | 852 | 853 | 854 | 855 | 856 | 857 | quit (exit server) 858 | 859 | 860 | 861 | 862 | 863 | 864 | 865 | 866 | 867 | 868 | 869 | 870 | QSpoiler 871 | QFrame 872 |
.qspoiler
873 | 1 874 |
875 | 876 | EvaluationWidget 877 | QFrame 878 |
.timegraph
879 | 1 880 |
881 |
882 | 883 | 884 |
885 | --------------------------------------------------------------------------------