work-tracker 0.1.0__py3-none-any.whl
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/__init__.py +8 -0
- work_tracker/_work_tracker.py +236 -0
- work_tracker/checkpoint_manager.py +56 -0
- work_tracker/command/__init__.py +1 -0
- work_tracker/command/command_handler.py +36 -0
- work_tracker/command/command_history.py +48 -0
- work_tracker/command/command_initializer.py +88 -0
- work_tracker/command/command_manager.py +85 -0
- work_tracker/command/command_parser.py +395 -0
- work_tracker/command/command_query_handler.py +93 -0
- work_tracker/command/command_text_parser.py +42 -0
- work_tracker/command/commands/__date.py +22 -0
- work_tracker/command/commands/__macro.py +54 -0
- work_tracker/command/commands/__time.py +48 -0
- work_tracker/command/commands/block.py +9 -0
- work_tracker/command/commands/calculate.py +9 -0
- work_tracker/command/commands/calendar.py +101 -0
- work_tracker/command/commands/checkpoint.py +46 -0
- work_tracker/command/commands/clear.py +42 -0
- work_tracker/command/commands/config.py +92 -0
- work_tracker/command/commands/dayoff.py +42 -0
- work_tracker/command/commands/days.py +92 -0
- work_tracker/command/commands/deletemacro.py +20 -0
- work_tracker/command/commands/done.py +38 -0
- work_tracker/command/commands/end.py +42 -0
- work_tracker/command/commands/exit.py +20 -0
- work_tracker/command/commands/fte.py +48 -0
- work_tracker/command/commands/help.py +116 -0
- work_tracker/command/commands/history.py +60 -0
- work_tracker/command/commands/holiday.py +27 -0
- work_tracker/command/commands/info.py +9 -0
- work_tracker/command/commands/key.py +39 -0
- work_tracker/command/commands/macro.py +86 -0
- work_tracker/command/commands/minutes.py +90 -0
- work_tracker/command/commands/office.py +28 -0
- work_tracker/command/commands/recalculate.py +9 -0
- work_tracker/command/commands/redo.py +22 -0
- work_tracker/command/commands/remote.py +28 -0
- work_tracker/command/commands/rollback.py +21 -0
- work_tracker/command/commands/rwr.py +50 -0
- work_tracker/command/commands/setup.py +9 -0
- work_tracker/command/commands/start.py +42 -0
- work_tracker/command/commands/status.py +92 -0
- work_tracker/command/commands/target.py +101 -0
- work_tracker/command/commands/tutorial.py +139 -0
- work_tracker/command/commands/undo.py +22 -0
- work_tracker/command/commands/version.py +21 -0
- work_tracker/command/commands/workday.py +27 -0
- work_tracker/command/commands/zero.py +37 -0
- work_tracker/command/common.py +734 -0
- work_tracker/command/macro_manager.py +118 -0
- work_tracker/common.py +442 -0
- work_tracker/config.py +101 -0
- work_tracker/data/default.config.yaml +31 -0
- work_tracker/data/default.macros.txt +6 -0
- work_tracker/error.py +180 -0
- work_tracker/main.py +31 -0
- work_tracker/text/__init__.py +0 -0
- work_tracker/text/common.py +228 -0
- work_tracker/text/input_command_completer.py +47 -0
- work_tracker/text/input_output_handler.py +53 -0
- work_tracker/version.py +2 -0
- work_tracker-0.1.0.dist-info/LICENSE +21 -0
- work_tracker-0.1.0.dist-info/METADATA +71 -0
- work_tracker-0.1.0.dist-info/RECORD +68 -0
- work_tracker-0.1.0.dist-info/WHEEL +5 -0
- work_tracker-0.1.0.dist-info/entry_points.txt +2 -0
- work_tracker-0.1.0.dist-info/top_level.txt +1 -0
work_tracker/__init__.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from work_tracker.command.common import Command, CommandTemplate
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CommandInitializer:
|
|
7
|
+
_shortest_command_string: dict[str, str] = {} # key: value -> shortest_command_string: command_name
|
|
8
|
+
|
|
9
|
+
@classmethod
|
|
10
|
+
def reset(cls):
|
|
11
|
+
cls._shortest_command_string.clear()
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def initialize_commands(cls, commands: list[CommandTemplate]) -> list[Command]:
|
|
15
|
+
cls.reset()
|
|
16
|
+
|
|
17
|
+
commands_dict: dict[str, CommandTemplate] = {command.name: command for command in commands}
|
|
18
|
+
if len(commands_dict.keys()) != len(commands):
|
|
19
|
+
raise RuntimeError(f"Could not initialize commands, there are duplicated command names.")
|
|
20
|
+
|
|
21
|
+
cls._find_not_conflicting_shortest_command_string_for(commands)
|
|
22
|
+
|
|
23
|
+
return [
|
|
24
|
+
Command(
|
|
25
|
+
name=command_name,
|
|
26
|
+
help=commands_dict[command_name].help,
|
|
27
|
+
abbreviations=commands_dict[command_name].abbreviations,
|
|
28
|
+
shortest_valid_string=shortest_string,
|
|
29
|
+
snake_case_name=re.sub(r'[-. ]', '_', command_name),
|
|
30
|
+
camel_case_name="".join(word.capitalize() for word in re.sub(r'[-. ]', '_', command_name).split('_')),
|
|
31
|
+
valid_argument_types=sorted(commands_dict[command_name].valid_argument_types, key=len),
|
|
32
|
+
supported_modes=commands_dict[command_name].supported_modes,
|
|
33
|
+
) for shortest_string, command_name in sorted(cls._shortest_command_string.items())
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def _find_not_conflicting_shortest_command_string_for(cls, commands: list[CommandTemplate]):
|
|
38
|
+
target_string_length: int = 1
|
|
39
|
+
abbreviations: set[str] = cls._get_abbreviations(commands)
|
|
40
|
+
|
|
41
|
+
commands_left: list[str] = [command.name for command in commands]
|
|
42
|
+
while len(commands_left) != 0:
|
|
43
|
+
commands_left = cls._find_not_conflicting_command_string_of_length_for(commands_left, abbreviations, target_string_length)
|
|
44
|
+
target_string_length += 1
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def _get_abbreviations(cls, commands: list[CommandTemplate]) -> set[str]:
|
|
48
|
+
abbreviations: set[str] = set()
|
|
49
|
+
for command in commands:
|
|
50
|
+
for abbreviation in command.abbreviations:
|
|
51
|
+
if abbreviation in abbreviations:
|
|
52
|
+
raise RuntimeError(f"Could not initialize commands, found duplicated command abbreviation: '{abbreviation}'.")
|
|
53
|
+
abbreviations.add(abbreviation)
|
|
54
|
+
return abbreviations
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def _find_not_conflicting_command_string_of_length_for(cls, commands: list[str], abbreviations: set[str], target_string_length: int) -> list[str]: # TODO check if this can be simplified
|
|
58
|
+
remaining_commands: set[str] = set()
|
|
59
|
+
substrings_to_pop: set[str] = set()
|
|
60
|
+
|
|
61
|
+
for command in commands:
|
|
62
|
+
command_substring: str = command[:target_string_length]
|
|
63
|
+
substring_is_full_command_name: bool = len(command_substring) == len(command)
|
|
64
|
+
substring_is_an_abbreviation: bool = command_substring in abbreviations
|
|
65
|
+
substring_already_exists: bool = command_substring in cls._shortest_command_string.keys()
|
|
66
|
+
found_substring_is_full_command_name: bool = command_substring == cls._shortest_command_string.get(command_substring)
|
|
67
|
+
|
|
68
|
+
if substring_is_an_abbreviation and substring_is_full_command_name:
|
|
69
|
+
raise RuntimeError(f"Command '{command}' could not be shorten and is equal to one of the special abbreviations of another command.")
|
|
70
|
+
elif substring_is_an_abbreviation:
|
|
71
|
+
remaining_commands.add(command)
|
|
72
|
+
elif substring_already_exists and substring_is_full_command_name:
|
|
73
|
+
remaining_commands.add(cls._shortest_command_string[command_substring])
|
|
74
|
+
substrings_to_pop.add(command_substring)
|
|
75
|
+
cls._shortest_command_string[command_substring] = command
|
|
76
|
+
elif substring_already_exists and not found_substring_is_full_command_name:
|
|
77
|
+
remaining_commands.add(cls._shortest_command_string[command_substring])
|
|
78
|
+
remaining_commands.add(command)
|
|
79
|
+
substrings_to_pop.add(command_substring)
|
|
80
|
+
elif not substring_already_exists or substring_is_full_command_name:
|
|
81
|
+
cls._shortest_command_string[command_substring] = command
|
|
82
|
+
else:
|
|
83
|
+
remaining_commands.add(command)
|
|
84
|
+
|
|
85
|
+
for substring in substrings_to_pop:
|
|
86
|
+
cls._shortest_command_string.pop(substring)
|
|
87
|
+
|
|
88
|
+
return list(remaining_commands)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from work_tracker.command.command_initializer import CommandInitializer
|
|
2
|
+
from work_tracker.command.common import Command, TimeArgument, CommandHelp, _global_command_templates
|
|
3
|
+
from work_tracker.common import Mode
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CommandManager:
|
|
7
|
+
_commands: list[Command] = []
|
|
8
|
+
|
|
9
|
+
# special commands
|
|
10
|
+
_time_command: Command = Command(
|
|
11
|
+
help=CommandHelp(
|
|
12
|
+
full_use_case_template="",
|
|
13
|
+
short_help_description="",
|
|
14
|
+
full_help_description="",
|
|
15
|
+
use_case_description=[],
|
|
16
|
+
),
|
|
17
|
+
supported_modes=set(Mode),
|
|
18
|
+
abbreviations=[],
|
|
19
|
+
name="__time",
|
|
20
|
+
shortest_valid_string="",
|
|
21
|
+
snake_case_name="__time",
|
|
22
|
+
camel_case_name="__Time",
|
|
23
|
+
valid_argument_types=[[TimeArgument]]
|
|
24
|
+
)
|
|
25
|
+
_date_command: Command = Command(
|
|
26
|
+
help=CommandHelp(
|
|
27
|
+
full_use_case_template="",
|
|
28
|
+
short_help_description="",
|
|
29
|
+
full_help_description="",
|
|
30
|
+
use_case_description=[],
|
|
31
|
+
),
|
|
32
|
+
supported_modes=set(Mode),
|
|
33
|
+
abbreviations=[],
|
|
34
|
+
name="__date",
|
|
35
|
+
shortest_valid_string="",
|
|
36
|
+
snake_case_name="__date",
|
|
37
|
+
camel_case_name="__Date",
|
|
38
|
+
valid_argument_types=[[]]
|
|
39
|
+
)
|
|
40
|
+
_macro_execute_command: Command = Command(
|
|
41
|
+
help=CommandHelp(
|
|
42
|
+
full_use_case_template="",
|
|
43
|
+
short_help_description="",
|
|
44
|
+
full_help_description="",
|
|
45
|
+
use_case_description=[],
|
|
46
|
+
),
|
|
47
|
+
supported_modes=set(Mode),
|
|
48
|
+
abbreviations=[],
|
|
49
|
+
name="__macro",
|
|
50
|
+
shortest_valid_string="",
|
|
51
|
+
snake_case_name="__macro",
|
|
52
|
+
camel_case_name="__Macro",
|
|
53
|
+
valid_argument_types=[[object, ...]]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
@property
|
|
58
|
+
def time_command(cls) -> Command:
|
|
59
|
+
return cls._time_command
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
@property
|
|
63
|
+
def date_command(cls) -> Command:
|
|
64
|
+
return cls._date_command
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
@property
|
|
68
|
+
def macro_execute_command(cls) -> Command:
|
|
69
|
+
return cls._macro_execute_command
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
@property
|
|
73
|
+
def commands(cls) -> list[Command]:
|
|
74
|
+
if len(cls._commands) == 0:
|
|
75
|
+
cls._commands = CommandInitializer.initialize_commands(_global_command_templates)
|
|
76
|
+
return cls._commands
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def get_command_by_name(cls, name: str) -> Command | None:
|
|
80
|
+
filtered_commands: list[Command] = [command for command in cls.commands if command.name == name]
|
|
81
|
+
command_found: bool = len(filtered_commands) == 1
|
|
82
|
+
if command_found:
|
|
83
|
+
return filtered_commands[0]
|
|
84
|
+
else:
|
|
85
|
+
return None
|