work-tracker 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- work_tracker-0.1.0/LICENSE +21 -0
- work_tracker-0.1.0/PKG-INFO +71 -0
- work_tracker-0.1.0/README.md +35 -0
- work_tracker-0.1.0/pyproject.toml +63 -0
- work_tracker-0.1.0/setup.cfg +4 -0
- work_tracker-0.1.0/work_tracker/__init__.py +8 -0
- work_tracker-0.1.0/work_tracker/_work_tracker.py +236 -0
- work_tracker-0.1.0/work_tracker/checkpoint_manager.py +56 -0
- work_tracker-0.1.0/work_tracker/command/__init__.py +1 -0
- work_tracker-0.1.0/work_tracker/command/command_handler.py +36 -0
- work_tracker-0.1.0/work_tracker/command/command_history.py +48 -0
- work_tracker-0.1.0/work_tracker/command/command_initializer.py +88 -0
- work_tracker-0.1.0/work_tracker/command/command_manager.py +85 -0
- work_tracker-0.1.0/work_tracker/command/command_parser.py +395 -0
- work_tracker-0.1.0/work_tracker/command/command_query_handler.py +93 -0
- work_tracker-0.1.0/work_tracker/command/command_text_parser.py +42 -0
- work_tracker-0.1.0/work_tracker/command/commands/__date.py +22 -0
- work_tracker-0.1.0/work_tracker/command/commands/__macro.py +54 -0
- work_tracker-0.1.0/work_tracker/command/commands/__time.py +48 -0
- work_tracker-0.1.0/work_tracker/command/commands/block.py +9 -0
- work_tracker-0.1.0/work_tracker/command/commands/calculate.py +9 -0
- work_tracker-0.1.0/work_tracker/command/commands/calendar.py +101 -0
- work_tracker-0.1.0/work_tracker/command/commands/checkpoint.py +46 -0
- work_tracker-0.1.0/work_tracker/command/commands/clear.py +42 -0
- work_tracker-0.1.0/work_tracker/command/commands/config.py +92 -0
- work_tracker-0.1.0/work_tracker/command/commands/dayoff.py +42 -0
- work_tracker-0.1.0/work_tracker/command/commands/days.py +92 -0
- work_tracker-0.1.0/work_tracker/command/commands/deletemacro.py +20 -0
- work_tracker-0.1.0/work_tracker/command/commands/done.py +38 -0
- work_tracker-0.1.0/work_tracker/command/commands/end.py +42 -0
- work_tracker-0.1.0/work_tracker/command/commands/exit.py +20 -0
- work_tracker-0.1.0/work_tracker/command/commands/fte.py +48 -0
- work_tracker-0.1.0/work_tracker/command/commands/help.py +116 -0
- work_tracker-0.1.0/work_tracker/command/commands/history.py +60 -0
- work_tracker-0.1.0/work_tracker/command/commands/holiday.py +27 -0
- work_tracker-0.1.0/work_tracker/command/commands/info.py +9 -0
- work_tracker-0.1.0/work_tracker/command/commands/key.py +39 -0
- work_tracker-0.1.0/work_tracker/command/commands/macro.py +86 -0
- work_tracker-0.1.0/work_tracker/command/commands/minutes.py +90 -0
- work_tracker-0.1.0/work_tracker/command/commands/office.py +28 -0
- work_tracker-0.1.0/work_tracker/command/commands/recalculate.py +9 -0
- work_tracker-0.1.0/work_tracker/command/commands/redo.py +22 -0
- work_tracker-0.1.0/work_tracker/command/commands/remote.py +28 -0
- work_tracker-0.1.0/work_tracker/command/commands/rollback.py +21 -0
- work_tracker-0.1.0/work_tracker/command/commands/rwr.py +50 -0
- work_tracker-0.1.0/work_tracker/command/commands/setup.py +9 -0
- work_tracker-0.1.0/work_tracker/command/commands/start.py +42 -0
- work_tracker-0.1.0/work_tracker/command/commands/status.py +92 -0
- work_tracker-0.1.0/work_tracker/command/commands/target.py +101 -0
- work_tracker-0.1.0/work_tracker/command/commands/tutorial.py +139 -0
- work_tracker-0.1.0/work_tracker/command/commands/undo.py +22 -0
- work_tracker-0.1.0/work_tracker/command/commands/version.py +21 -0
- work_tracker-0.1.0/work_tracker/command/commands/workday.py +27 -0
- work_tracker-0.1.0/work_tracker/command/commands/zero.py +37 -0
- work_tracker-0.1.0/work_tracker/command/common.py +734 -0
- work_tracker-0.1.0/work_tracker/command/macro_manager.py +118 -0
- work_tracker-0.1.0/work_tracker/common.py +442 -0
- work_tracker-0.1.0/work_tracker/config.py +101 -0
- work_tracker-0.1.0/work_tracker/data/default.config.yaml +31 -0
- work_tracker-0.1.0/work_tracker/data/default.macros.txt +6 -0
- work_tracker-0.1.0/work_tracker/error.py +180 -0
- work_tracker-0.1.0/work_tracker/main.py +31 -0
- work_tracker-0.1.0/work_tracker/text/__init__.py +0 -0
- work_tracker-0.1.0/work_tracker/text/common.py +228 -0
- work_tracker-0.1.0/work_tracker/text/input_command_completer.py +47 -0
- work_tracker-0.1.0/work_tracker/text/input_output_handler.py +53 -0
- work_tracker-0.1.0/work_tracker/version.py +2 -0
- work_tracker-0.1.0/work_tracker.egg-info/PKG-INFO +71 -0
- work_tracker-0.1.0/work_tracker.egg-info/SOURCES.txt +71 -0
- work_tracker-0.1.0/work_tracker.egg-info/dependency_links.txt +1 -0
- work_tracker-0.1.0/work_tracker.egg-info/entry_points.txt +2 -0
- work_tracker-0.1.0/work_tracker.egg-info/requires.txt +16 -0
- work_tracker-0.1.0/work_tracker.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-present Karol Kiszka
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: work-tracker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python library that helps tracking your work time.
|
|
5
|
+
Author-email: Karol Kiszka <karolkisz22@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: source, https://github.com/kiszkacy/work-tracker
|
|
8
|
+
Keywords: work,time,tracker
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Environment :: Console
|
|
18
|
+
Requires-Python: <4.0,>=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: workalendar~=17.0.0
|
|
22
|
+
Requires-Dist: pydantic~=2.10.5
|
|
23
|
+
Requires-Dist: appdirs~=1.4.4
|
|
24
|
+
Requires-Dist: path~=17.1.0
|
|
25
|
+
Requires-Dist: PyYAML~=6.0.2
|
|
26
|
+
Requires-Dist: pyperclip~=1.9.0
|
|
27
|
+
Requires-Dist: colorama~=0.4.6
|
|
28
|
+
Requires-Dist: multimethod~=2.0
|
|
29
|
+
Requires-Dist: prompt_toolkit~=3.0.48
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest~=8.3.4; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-mock~=3.14.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-order~=1.3.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-random-order~=1.1.1; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest-cov~=6.0.0; extra == "dev"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# WorkTracker
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+

|
|
43
|
+

|
|
44
|
+
[](https://wakatime.com/badge/user/92cf3ac1-102d-4d79-b9a8-c960cf206839/project/8c8f1653-1d14-4d7e-90bc-01591ea36159 "Total time spent coding")
|
|
45
|
+
|
|
46
|
+
**WorkTracker** is a Python library that helps you track your time spent at work and manage your work schedule, providing a simple way to monitor your working hours.
|
|
47
|
+
|
|
48
|
+
> **Note:** This library is still in very early development. Many features may not be fully stable, and things could and probably will break with future updates until the final release.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
Before installing **WorkTracker**, ensure you have Python 3.10 or a newer version installed. You can download and install it from the [official Python website](https://www.python.org/downloads/).
|
|
54
|
+
|
|
55
|
+
To install the latest **WorkTracker** version, run the following command in your terminal:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install work-tracker
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
Once installed, you can launch the **WorkTracker** by running the following command in your terminal:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
work-tracker
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
This project is licensed under the MIT License.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
|
|
2
|
+
# WorkTracker
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
[](https://wakatime.com/badge/user/92cf3ac1-102d-4d79-b9a8-c960cf206839/project/8c8f1653-1d14-4d7e-90bc-01591ea36159 "Total time spent coding")
|
|
9
|
+
|
|
10
|
+
**WorkTracker** is a Python library that helps you track your time spent at work and manage your work schedule, providing a simple way to monitor your working hours.
|
|
11
|
+
|
|
12
|
+
> **Note:** This library is still in very early development. Many features may not be fully stable, and things could and probably will break with future updates until the final release.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Before installing **WorkTracker**, ensure you have Python 3.10 or a newer version installed. You can download and install it from the [official Python website](https://www.python.org/downloads/).
|
|
18
|
+
|
|
19
|
+
To install the latest **WorkTracker** version, run the following command in your terminal:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install work-tracker
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
Once installed, you can launch the **WorkTracker** by running the following command in your terminal:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
work-tracker
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
|
|
35
|
+
This project is licensed under the MIT License.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "work-tracker"
|
|
3
|
+
description = "A Python library that helps tracking your work time."
|
|
4
|
+
authors = [
|
|
5
|
+
{name = "Karol Kiszka", email = "karolkisz22@gmail.com"}
|
|
6
|
+
]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
license = { text = "MIT" }
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Programming Language :: Python :: 3",
|
|
11
|
+
"Programming Language :: Python :: 3.10",
|
|
12
|
+
"Programming Language :: Python :: 3.11",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"workalendar~=17.0.0",
|
|
22
|
+
"pydantic~=2.10.5",
|
|
23
|
+
"appdirs~=1.4.4",
|
|
24
|
+
"path~=17.1.0",
|
|
25
|
+
"PyYAML~=6.0.2",
|
|
26
|
+
"pyperclip~=1.9.0",
|
|
27
|
+
"colorama~=0.4.6",
|
|
28
|
+
"multimethod~=2.0",
|
|
29
|
+
"prompt_toolkit~=3.0.48",
|
|
30
|
+
]
|
|
31
|
+
requires-python = ">=3.10,<4.0"
|
|
32
|
+
keywords = ["work", "time", "tracker"]
|
|
33
|
+
dynamic = ["version"]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
work-tracker = "work_tracker.main:main"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest~=8.3.4",
|
|
41
|
+
"pytest-mock~=3.14.0",
|
|
42
|
+
"pytest-order~=1.3.0",
|
|
43
|
+
"pytest-random-order~=1.1.1",
|
|
44
|
+
"pytest-cov~=6.0.0",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
source = "https://github.com/kiszkacy/work-tracker"
|
|
49
|
+
|
|
50
|
+
[build-system]
|
|
51
|
+
requires = ["setuptools", "wheel"]
|
|
52
|
+
build-backend = "setuptools.build_meta"
|
|
53
|
+
|
|
54
|
+
[tool.setuptools]
|
|
55
|
+
packages = ["work_tracker"]
|
|
56
|
+
|
|
57
|
+
[tool.setuptools.dynamic]
|
|
58
|
+
version = {attr = "work_tracker.version.__version__"}
|
|
59
|
+
|
|
60
|
+
[tool.coverage.report]
|
|
61
|
+
include_namespace_packages = true
|
|
62
|
+
omit = ["**/__init__.py"]
|
|
63
|
+
skip_empty = true
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import calendar
|
|
2
|
+
import datetime
|
|
3
|
+
import shutil
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from workalendar.registry import registry
|
|
11
|
+
|
|
12
|
+
from work_tracker import __version__
|
|
13
|
+
from work_tracker.checkpoint_manager import CheckpointManager
|
|
14
|
+
from work_tracker.command.command_parser import CommandParser
|
|
15
|
+
from work_tracker.command.command_query_handler import CommandQueryHandler
|
|
16
|
+
from work_tracker.command.common import ParseResult
|
|
17
|
+
from work_tracker.common import AppData, Date, Mode, AppState, get_data_path
|
|
18
|
+
from work_tracker.text.common import Color
|
|
19
|
+
from work_tracker.text.input_output_handler import InputOutputHandler
|
|
20
|
+
from .config import Config
|
|
21
|
+
from .error import VersionCheckError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WorkTracker:
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self.state: AppState = AppState()
|
|
27
|
+
self.data: AppData
|
|
28
|
+
self.io: InputOutputHandler
|
|
29
|
+
self.command_handler: CommandQueryHandler
|
|
30
|
+
self._initialized: bool = False
|
|
31
|
+
|
|
32
|
+
def initialize(self, check_is_new_version_available: bool = True):
|
|
33
|
+
self._initialized = True
|
|
34
|
+
|
|
35
|
+
if is_first_time_launch := self._is_first_time_launch():
|
|
36
|
+
self._create_basic_files()
|
|
37
|
+
if was_version_file_missing := self._is_version_file_missing():
|
|
38
|
+
self._update_version_file()
|
|
39
|
+
if was_config_file_missing := self._is_config_file_missing():
|
|
40
|
+
self._create_default_config_file()
|
|
41
|
+
if was_macros_file_missing := self._is_macros_file_missing():
|
|
42
|
+
self._create_default_macros_file()
|
|
43
|
+
if was_updated_since_last_launch := self._was_updated_since_last_launch():
|
|
44
|
+
self._update_version_file()
|
|
45
|
+
|
|
46
|
+
# config access check must be before io, because io uses config values
|
|
47
|
+
if not Config.ready():
|
|
48
|
+
raise RuntimeError("Something went wrong trying to access config.")
|
|
49
|
+
|
|
50
|
+
self._initialize_io()
|
|
51
|
+
if is_first_time_launch:
|
|
52
|
+
self._first_time_prompt()
|
|
53
|
+
else:
|
|
54
|
+
if was_version_file_missing:
|
|
55
|
+
self.io.output("WARNING: version file could not be found. This may lead to unpredicted behavior.", color=Color.Yellow)
|
|
56
|
+
if was_config_file_missing:
|
|
57
|
+
self.io.output("WARNING: config file could not be found. Default config will be used instead.", color=Color.Yellow)
|
|
58
|
+
if was_macros_file_missing:
|
|
59
|
+
self.io.output("WARNING: macros file could not be found. Default macros will be used instead.", color=Color.Yellow)
|
|
60
|
+
|
|
61
|
+
if not is_first_time_launch:
|
|
62
|
+
self._load_data()
|
|
63
|
+
if self.data is None:
|
|
64
|
+
self.io.output("WARNING: it appears that no saved data file was found to load from. As a result, the app will default to the first-time launch screen.", color=Color.Yellow)
|
|
65
|
+
self._first_time_prompt()
|
|
66
|
+
elif not self.data.is_latest_data_version():
|
|
67
|
+
self.data.update_data_to_latest_version()
|
|
68
|
+
|
|
69
|
+
self._initialize_command_handler()
|
|
70
|
+
self._clear_old_cache()
|
|
71
|
+
|
|
72
|
+
if not was_updated_since_last_launch and check_is_new_version_available and (latest_version := self._is_new_version_available()) is not None:
|
|
73
|
+
self._display_new_version_available_message(latest_version)
|
|
74
|
+
self.io.output(f"Using {Color.Brightblue.value}WorkTracker{Color.Clear.value} version {Color.Brightblue.value}{__version__}{Color.Clear.value}.")
|
|
75
|
+
|
|
76
|
+
def _is_first_time_launch(self) -> bool:
|
|
77
|
+
expected_files: list[Path] = [get_data_path().joinpath("version"), get_data_path().joinpath("config.yaml"), get_data_path().joinpath("macros.txt")]
|
|
78
|
+
return all(not path.exists() for path in expected_files)
|
|
79
|
+
|
|
80
|
+
def _create_basic_files(self):
|
|
81
|
+
self._create_default_config_file()
|
|
82
|
+
self._create_default_macros_file()
|
|
83
|
+
|
|
84
|
+
def _create_default_config_file(self):
|
|
85
|
+
shutil.copy(Path(__file__).parent.joinpath("data/default.config.yaml"), get_data_path().joinpath("config.yaml"))
|
|
86
|
+
|
|
87
|
+
def _create_default_macros_file(self):
|
|
88
|
+
shutil.copy(Path(__file__).parent.joinpath("data/default.macros.txt"), get_data_path().joinpath("macros.txt"))
|
|
89
|
+
|
|
90
|
+
def _initialize_io(self):
|
|
91
|
+
self.io = InputOutputHandler()
|
|
92
|
+
|
|
93
|
+
def _load_data(self):
|
|
94
|
+
self.data = CheckpointManager.load_latest()
|
|
95
|
+
|
|
96
|
+
def _first_time_prompt(self):
|
|
97
|
+
self.io.write("Welcome to WorkTracker!", color=Color.Brightblue)
|
|
98
|
+
self.io.write("WorkTracker helps you track your work hours efficiently and manage your schedule with ease.", color=Color.Cyan)
|
|
99
|
+
self.io.output(f"Before you start using it, please provide your country code. This will allow {Color.Brightblue.value}WorkTracker{Color.Reset.value} to automatically import all relevant holidays and mark your non-working days accordingly.")
|
|
100
|
+
|
|
101
|
+
country_code: str
|
|
102
|
+
valid_codes: list[str] = sorted(list(registry.get_calendars().keys()))
|
|
103
|
+
while True:
|
|
104
|
+
country_code = self.io.input(f"{Config.data.input.prefix} ", custom_autocomplete=valid_codes).upper()
|
|
105
|
+
if country_code in valid_codes: # TODO use of private method
|
|
106
|
+
break
|
|
107
|
+
else:
|
|
108
|
+
self.io.output("Invalid country code. Please input valid contry code.", color=Color.Brightred)
|
|
109
|
+
|
|
110
|
+
self.io.output("Would you like to run the initial setup? This process can take some time but is recommended, as it enables the app to automatically fill your calendar with the suggested work schedule.")
|
|
111
|
+
user_input: str = self.io.input(f"{Config.data.input.prefix} ", custom_autocomplete=["yes", "no"])
|
|
112
|
+
if "yes".startswith(user_input):
|
|
113
|
+
self.io.output("Setup command is not yet implemented. Skipping setup phase...", color=Color.Brightred)
|
|
114
|
+
else:
|
|
115
|
+
self.io.output(f"Initial setup skipped. You can always run setup later by using {Color.Brightblue.value}setup{Color.Reset.value} command.")
|
|
116
|
+
|
|
117
|
+
self.data = AppData(country_code=country_code)
|
|
118
|
+
CheckpointManager.save("initial", self.data)
|
|
119
|
+
|
|
120
|
+
self.io.write(f"Everything is set up and ready.", color=Color.Cyan, end=" ")
|
|
121
|
+
self.io.write(f"To view a list of available commands type {Color.Brightblue.value}help{Color.Reset.value}.", end=" ")
|
|
122
|
+
self.io.write(f"For detailed information about a specific command use {Color.Brightblue.value}help <command_name>{Color.Reset.value}.", end=" ")
|
|
123
|
+
self.io.write(f"It is recommended that you use {Color.Brightblue.value}tutorial{Color.Reset.value} command to quickly get familiar with the available features.", end=" ")
|
|
124
|
+
self.io.output(f"Remember to leave {Color.Brightblue.value}WorkTracker{Color.Reset.value} by using {Color.Brightblue.value}exit{Color.Reset.value} command to avoid {Color.Underline.value}{Color.Brightred.value}loss of data{Color.Reset.value}!")
|
|
125
|
+
|
|
126
|
+
def _is_version_file_missing(self) -> bool:
|
|
127
|
+
return not get_data_path().joinpath("version").exists() # TODO make file name a constant
|
|
128
|
+
|
|
129
|
+
def _is_config_file_missing(self) -> bool:
|
|
130
|
+
return not get_data_path().joinpath("config.yaml").exists() # TODO make file name a constant
|
|
131
|
+
|
|
132
|
+
def _is_macros_file_missing(self) -> bool:
|
|
133
|
+
return not get_data_path().joinpath("macros.txt").exists() # TODO make file name a constant
|
|
134
|
+
|
|
135
|
+
def _was_updated_since_last_launch(self) -> bool:
|
|
136
|
+
path: Path = get_data_path().joinpath("version")
|
|
137
|
+
if not path.exists():
|
|
138
|
+
raise RuntimeError("Something went wrong when trying to access version file.")
|
|
139
|
+
|
|
140
|
+
with open(path, "r") as file:
|
|
141
|
+
version: str = file.read()
|
|
142
|
+
|
|
143
|
+
return version < __version__
|
|
144
|
+
|
|
145
|
+
def _update_version_file(self):
|
|
146
|
+
with open(get_data_path().joinpath("version"), "w") as file:
|
|
147
|
+
file.write(__version__)
|
|
148
|
+
|
|
149
|
+
def _initialize_command_handler(self):
|
|
150
|
+
self.command_handler = CommandQueryHandler(self.data, self.io, self.state)
|
|
151
|
+
|
|
152
|
+
def _is_new_version_available(self) -> str | None:
|
|
153
|
+
try:
|
|
154
|
+
installed_version: str = __version__
|
|
155
|
+
result = subprocess.run(
|
|
156
|
+
["pip", "index", "versions", "work-tracker"],
|
|
157
|
+
capture_output=True, text=True
|
|
158
|
+
)
|
|
159
|
+
if result.returncode != 0:
|
|
160
|
+
self.io.output("WARNING: unable to retrieve data from pip while checking for available updates.", color=Color.Yellow)
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
latest_version: str = result.stdout.strip().split("\n")[-1].split()[-1]
|
|
164
|
+
return latest_version if installed_version != latest_version else None
|
|
165
|
+
except subprocess.SubprocessError as e:
|
|
166
|
+
raise VersionCheckError("Subprocess error during version check.")
|
|
167
|
+
except Exception as e:
|
|
168
|
+
raise VersionCheckError(f"Unexpected error: {e}")
|
|
169
|
+
|
|
170
|
+
def _display_new_version_available_message(self, version: str):
|
|
171
|
+
self.io.write(f"New version {Color.Brightcyan.value}{version}{Color.Reset.value} is available!", end=" ")
|
|
172
|
+
self.io.output(f"Update with {Color.Brightblue.value}pip install --upgrade work-tracker{Color.Reset.value}.")
|
|
173
|
+
|
|
174
|
+
def _clear_old_cache(self):
|
|
175
|
+
CheckpointManager.clear_cache()
|
|
176
|
+
|
|
177
|
+
def _at_crash_exit(self, crash_message: str | None = None, exception: Exception | None = None):
|
|
178
|
+
CheckpointManager.save(f"crash-{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}", self.data)
|
|
179
|
+
with open(get_data_path().joinpath(f"crash-log-{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.txt"), "w") as file:
|
|
180
|
+
if crash_message is not None:
|
|
181
|
+
file.write(crash_message)
|
|
182
|
+
if exception is not None:
|
|
183
|
+
traceback.print_exc(file=file)
|
|
184
|
+
sys.exit()
|
|
185
|
+
|
|
186
|
+
def _at_exit(self):
|
|
187
|
+
CheckpointManager.save(f"{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}", self.data)
|
|
188
|
+
sys.exit()
|
|
189
|
+
|
|
190
|
+
def handle_exit_signal(self): # TODO check if this works | no it doesnt :(
|
|
191
|
+
self._at_crash_exit(crash_message=f"received exit signal")
|
|
192
|
+
|
|
193
|
+
def _run(self):
|
|
194
|
+
error_log_last_processed_input: str = "None"
|
|
195
|
+
try:
|
|
196
|
+
while True:
|
|
197
|
+
today: Date = Date.today()
|
|
198
|
+
mode: Mode = self.state.mode
|
|
199
|
+
date: Date = self.state.active_date
|
|
200
|
+
prefix: str = (
|
|
201
|
+
f"{Config.data.input.prefix} " if mode == Mode.Today else
|
|
202
|
+
f"[{date.day:02}.{date.month:02}.{date.year}]{Config.data.input.prefix} " if mode == Mode.Day and today.year != date.year else
|
|
203
|
+
f"[{date.day:02}.{date.month:02}]{Config.data.input.prefix} " if mode == Mode.Day and today.year == date.year else
|
|
204
|
+
f"[{calendar.month_abbr[date.month].lower()}]{Config.data.input.prefix} " if mode == Mode.Month else
|
|
205
|
+
"?> "
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
user_input: str = self.get_user_input(prefix)
|
|
209
|
+
error_log_last_processed_input = user_input
|
|
210
|
+
|
|
211
|
+
result: ParseResult = CommandParser.parse(user_input)
|
|
212
|
+
if result.error:
|
|
213
|
+
self.io.output(f"ERROR: {result.error.message or 'missing error description'}", color=Color.Brightred)
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
for query in result.queries:
|
|
217
|
+
self.command_handler.run(query)
|
|
218
|
+
except KeyboardInterrupt:
|
|
219
|
+
self.io.output("UNSAFE EXIT: saving data...", color=Color.Brightred, end="")
|
|
220
|
+
self._at_exit()
|
|
221
|
+
except Exception as exception:
|
|
222
|
+
self.io.output("FATAL ERROR: saving data and creating error log...", color=Color.Brightred, end="")
|
|
223
|
+
self._at_crash_exit(crash_message=f"{error_log_last_processed_input}\n\n", exception=exception)
|
|
224
|
+
|
|
225
|
+
def start(self):
|
|
226
|
+
if not self._initialized:
|
|
227
|
+
self.initialize()
|
|
228
|
+
|
|
229
|
+
# TODO add atexit.register() to automatically save data somewhere here ?
|
|
230
|
+
signal.signal(signal.SIGINT, self.handle_exit_signal)
|
|
231
|
+
signal.signal(signal.SIGTERM, self.handle_exit_signal)
|
|
232
|
+
|
|
233
|
+
self._run()
|
|
234
|
+
|
|
235
|
+
def get_user_input(self, prefix: str) -> str:
|
|
236
|
+
return self.io.input(prefix)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import lzma
|
|
3
|
+
import os
|
|
4
|
+
import pickle
|
|
5
|
+
|
|
6
|
+
from path import Path
|
|
7
|
+
|
|
8
|
+
from .common import AppData, get_data_path, get_cache_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CheckpointManager:
|
|
12
|
+
@classmethod
|
|
13
|
+
def load(cls, identifier: str, manual_checkpoint: bool = False) -> AppData | None: # TODO os exceptions
|
|
14
|
+
if manual_checkpoint:
|
|
15
|
+
for checkpoint_path in cls.all_manual_checkpoints():
|
|
16
|
+
name, date = checkpoint_path.name.removesuffix('.save.checkpoint').split("__") # TODO hardcoded '.save.checkpoint'
|
|
17
|
+
if name == identifier:
|
|
18
|
+
identifier = f"{name}__{date}"
|
|
19
|
+
|
|
20
|
+
path: Path = (get_data_path() if not manual_checkpoint else get_cache_path()).joinpath(f"{identifier}.save.checkpoint")
|
|
21
|
+
if not path.exists():
|
|
22
|
+
return None
|
|
23
|
+
with lzma.open(path, "rb") as file:
|
|
24
|
+
data: AppData = pickle.load(file)
|
|
25
|
+
return data
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def save(identifier: str, data: AppData, manual_checkpoint: bool = False): # TODO os exceptions
|
|
29
|
+
full_identifier: str = identifier if not manual_checkpoint else f"{identifier}__{datetime.datetime.now().strftime('%H-%M-%S')}"
|
|
30
|
+
path: Path = (get_data_path() if not manual_checkpoint else get_cache_path()).joinpath(f"{full_identifier}.save.checkpoint")
|
|
31
|
+
with lzma.open(path, "wb") as file:
|
|
32
|
+
pickle.dump(data, file)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def load_latest(cls) -> AppData | None: # TODO os exceptions
|
|
36
|
+
files: list[Path] = cls.all_automatic_checkpoints()
|
|
37
|
+
if not files:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
newest_file: str = max(files, key=os.path.getctime) # TODO this sorting might be unclear for user
|
|
41
|
+
return cls.load(os.path.basename(newest_file.rstrip(".save.checkpoint")))
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def all_automatic_checkpoints() -> list[Path]: # TODO os exceptions
|
|
45
|
+
return [get_data_path().joinpath(file) for file in os.listdir(get_data_path()) if file.endswith(".save.checkpoint") and os.path.isfile(get_data_path().joinpath(file))]
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def all_manual_checkpoints() -> list[Path]: # TODO os exceptions
|
|
49
|
+
return [get_cache_path().joinpath(file) for file in os.listdir(get_cache_path()) if file.endswith(".save.checkpoint") and os.path.isfile(get_cache_path().joinpath(file))]
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def clear_cache():
|
|
53
|
+
for name in os.listdir(get_cache_path()):
|
|
54
|
+
if not os.path.isfile(get_cache_path().joinpath(name)):
|
|
55
|
+
continue
|
|
56
|
+
os.remove(get_cache_path().joinpath(name))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from work_tracker.command.command_parser import CommandParser
|
|
5
|
+
from work_tracker.command.common import CommandArgument, AdditionalInputArgument, CommandQuery
|
|
6
|
+
from work_tracker.error import CommandError
|
|
7
|
+
from work_tracker.common import AppData, Date, ReadonlyAppState
|
|
8
|
+
from work_tracker.config import Config
|
|
9
|
+
from work_tracker.text.input_output_handler import InputOutputHandler
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class CommandHandlerResult:
|
|
14
|
+
undoable: bool
|
|
15
|
+
error: CommandError | None = None
|
|
16
|
+
change_active_date: Date | None = None
|
|
17
|
+
change_state_by: int | None = None
|
|
18
|
+
execute_after: list[CommandQuery] | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CommandHandler(ABC):
|
|
22
|
+
def __init__(self, work_data: AppData, io: InputOutputHandler):
|
|
23
|
+
self.data: AppData = work_data
|
|
24
|
+
self.io: InputOutputHandler = io
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def command_name(self) -> str:
|
|
28
|
+
return self.__class__.__name__.split("Handler")[0].lower()
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def handle(self, dates: list[Date], date_count: int, arguments: list[CommandArgument], argument_count: int, state: ReadonlyAppState) -> CommandHandlerResult:
|
|
32
|
+
raise NotImplementedError()
|
|
33
|
+
|
|
34
|
+
def get_additional_input(self, custom_autocomplete: list[str] = None) -> list[AdditionalInputArgument]:
|
|
35
|
+
text: str = self.io.input(f"{Config.data.input.sub_prefix} ", show_autocomplete=custom_autocomplete is not None, custom_autocomplete=custom_autocomplete)
|
|
36
|
+
return CommandParser.parse_arguments(text)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class CommandHistoryEntry:
|
|
6
|
+
state_key: str
|
|
7
|
+
command: str
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CommandHistory:
|
|
11
|
+
def __init__(self, max_size: int):
|
|
12
|
+
self._max_size: int = max_size
|
|
13
|
+
self._current_state_index: int = -1
|
|
14
|
+
self._states: list[CommandHistoryEntry] = []
|
|
15
|
+
|
|
16
|
+
def add(self, state_key: str, command_used: str):
|
|
17
|
+
if self._current_state_index < self._max_size:
|
|
18
|
+
self._states = self._states[:self._current_state_index + 1]
|
|
19
|
+
|
|
20
|
+
if self._current_state_index == self._max_size:
|
|
21
|
+
self._states.pop(0)
|
|
22
|
+
else:
|
|
23
|
+
self._current_state_index += 1
|
|
24
|
+
|
|
25
|
+
self._states.append(CommandHistoryEntry(
|
|
26
|
+
state_key=state_key,
|
|
27
|
+
command=command_used
|
|
28
|
+
))
|
|
29
|
+
|
|
30
|
+
def undo(self) -> str | None:
|
|
31
|
+
if self._current_state_index > 0:
|
|
32
|
+
self._current_state_index -= 1
|
|
33
|
+
return self._states[self._current_state_index].state_key
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
def redo(self) -> str | None:
|
|
37
|
+
if self._current_state_index < len(self._states) - 1:
|
|
38
|
+
self._current_state_index += 1
|
|
39
|
+
return self._states[self._current_state_index].state_key
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def states(self) -> tuple[CommandHistoryEntry]:
|
|
44
|
+
return tuple(self._states)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def current_state_index(self) -> int:
|
|
48
|
+
return self._current_state_index
|