tklr-dgraham 0.0.0rc11__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.
Potentially problematic release.
This version of tklr-dgraham might be problematic. Click here for more details.
- tklr/__init__.py +0 -0
- tklr/cli/main.py +253 -0
- tklr/cli/migrate_etm_to_tklr.py +764 -0
- tklr/common.py +1296 -0
- tklr/controller.py +2602 -0
- tklr/item.py +3765 -0
- tklr/list_colors.py +234 -0
- tklr/model.py +3973 -0
- tklr/shared.py +654 -0
- tklr/sounds/alert.mp3 +0 -0
- tklr/tklr_env.py +461 -0
- tklr/use_system.py +64 -0
- tklr/versioning.py +21 -0
- tklr/view.py +2912 -0
- tklr/view_agenda.py +236 -0
- tklr/view_textual.css +296 -0
- tklr_dgraham-0.0.0rc11.dist-info/METADATA +699 -0
- tklr_dgraham-0.0.0rc11.dist-info/RECORD +21 -0
- tklr_dgraham-0.0.0rc11.dist-info/WHEEL +5 -0
- tklr_dgraham-0.0.0rc11.dist-info/entry_points.txt +2 -0
- tklr_dgraham-0.0.0rc11.dist-info/top_level.txt +1 -0
tklr/tklr_env.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tomllib
|
|
5
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from jinja2 import Template
|
|
8
|
+
|
|
9
|
+
from pydantic import RootModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PriorityConfig(RootModel[dict[str, float]]):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ─── Config Schema ─────────────────────────────────────────────────
|
|
17
|
+
class UIConfig(BaseModel):
|
|
18
|
+
theme: str = Field("dark", pattern="^(dark|light)$")
|
|
19
|
+
show_completed: bool = True
|
|
20
|
+
ampm: bool = False
|
|
21
|
+
dayfirst: bool = False
|
|
22
|
+
yearfirst: bool = True
|
|
23
|
+
two_digit_year: bool = True
|
|
24
|
+
history_weight: int = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DueConfig(BaseModel):
|
|
28
|
+
interval: str = "1w"
|
|
29
|
+
max: float = 8.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PastdueConfig(BaseModel):
|
|
33
|
+
interval: str = "2d"
|
|
34
|
+
max: float = 2.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RecentConfig(BaseModel):
|
|
38
|
+
interval: str = "2w"
|
|
39
|
+
max: float = 4.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AgeConfig(BaseModel):
|
|
43
|
+
interval: str = "26w"
|
|
44
|
+
max: float = 10.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ExtentConfig(BaseModel):
|
|
48
|
+
interval: str = "12h"
|
|
49
|
+
max: float = 4.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BlockingConfig(BaseModel):
|
|
53
|
+
count: int = 3
|
|
54
|
+
max: float = 6.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TagsConfig(BaseModel):
|
|
58
|
+
count: int = 3
|
|
59
|
+
max: float = 3.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ProjectConfig(BaseModel):
|
|
63
|
+
max: float = 3.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DescriptionConfig(BaseModel):
|
|
67
|
+
max: float = 2.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ColorsConfig(BaseModel):
|
|
71
|
+
min_hex_color: str = "#6495ed"
|
|
72
|
+
max_hex_color: str = "#ffff00"
|
|
73
|
+
min_urgency: float = 0.5
|
|
74
|
+
steps: int = 10
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class UrgencyConfig(BaseModel):
|
|
78
|
+
colors: ColorsConfig = ColorsConfig()
|
|
79
|
+
project: float = 2.0
|
|
80
|
+
due: DueConfig = DueConfig()
|
|
81
|
+
pastdue: PastdueConfig = PastdueConfig()
|
|
82
|
+
recent: RecentConfig = RecentConfig()
|
|
83
|
+
age: AgeConfig = AgeConfig()
|
|
84
|
+
extent: ExtentConfig = ExtentConfig()
|
|
85
|
+
blocking: BlockingConfig = BlockingConfig()
|
|
86
|
+
tags: TagsConfig = TagsConfig()
|
|
87
|
+
project: ProjectConfig = ProjectConfig()
|
|
88
|
+
description: DescriptionConfig = DescriptionConfig()
|
|
89
|
+
|
|
90
|
+
priority: PriorityConfig = PriorityConfig(
|
|
91
|
+
{
|
|
92
|
+
"1": 10.0,
|
|
93
|
+
"2": 8.0,
|
|
94
|
+
"3": 5.0,
|
|
95
|
+
"4": 2.0,
|
|
96
|
+
"5": -5.0,
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TklrConfig(BaseModel):
|
|
102
|
+
title: str = "Tklr Configuration"
|
|
103
|
+
ui: UIConfig = UIConfig()
|
|
104
|
+
alerts: dict[str, str] = {}
|
|
105
|
+
urgency: UrgencyConfig = UrgencyConfig()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
CONFIG_TEMPLATE = """\
|
|
109
|
+
# DO NOT EDIT TITLE
|
|
110
|
+
title = "{{ title }}"
|
|
111
|
+
|
|
112
|
+
[ui]
|
|
113
|
+
# theme: str = 'dark' | 'light'
|
|
114
|
+
theme = "{{ ui.theme }}"
|
|
115
|
+
|
|
116
|
+
# ampm: bool = true | false
|
|
117
|
+
# Use 12 hour AM/PM when true else 24 hour
|
|
118
|
+
ampm = {{ ui.ampm | lower }}
|
|
119
|
+
|
|
120
|
+
# history_weight: int
|
|
121
|
+
# Apply this weight to the prior history when computing
|
|
122
|
+
# the next offset for a task
|
|
123
|
+
history_weight = {{ ui.history_weight }}
|
|
124
|
+
|
|
125
|
+
# dayfirst and yearfirst settings
|
|
126
|
+
# These settings are used to resolve ambiguous date entries involving
|
|
127
|
+
# 2-digit components. E.g., the interpretation of the date "12-10-11"
|
|
128
|
+
# with the various possible settings for dayfirst and yearfirst:
|
|
129
|
+
#
|
|
130
|
+
# dayfirst yearfirst date interpretation standard
|
|
131
|
+
# ======== ========= ======== ============== ========
|
|
132
|
+
# True True 12-10-11 2012-11-10 Y-D-M ??
|
|
133
|
+
# True False 12-10-11 2011-10-12 D-M-Y EU
|
|
134
|
+
# False True 12-10-11 2012-10-11 Y-M-D ISO 8601
|
|
135
|
+
# False False 12-10-11 2011-12-10 M-D-Y US
|
|
136
|
+
#
|
|
137
|
+
# The defaults:
|
|
138
|
+
# dayfirst = false
|
|
139
|
+
# yearfirst = true
|
|
140
|
+
# correspond to the Y-M-D ISO 8601 standard.
|
|
141
|
+
|
|
142
|
+
# dayfirst: bool = true | false
|
|
143
|
+
dayfirst = {{ ui.dayfirst | lower }}
|
|
144
|
+
|
|
145
|
+
# yearfirst: bool = true | false
|
|
146
|
+
yearfirst = {{ ui.yearfirst | lower }}
|
|
147
|
+
|
|
148
|
+
# two_digit_year: bool = true | false
|
|
149
|
+
# If true, years are displayed using the last two digits, e.g.,
|
|
150
|
+
# 25 instead of 2025.
|
|
151
|
+
two_digit_year = {{ ui.two_digit_year | lower }}
|
|
152
|
+
|
|
153
|
+
[alerts]
|
|
154
|
+
# dict[str, str]: character -> command_str.
|
|
155
|
+
# E.g., this entry
|
|
156
|
+
# v = '/usr/bin/say -v Alex "{name}, {when}"'
|
|
157
|
+
# would, on my macbook, invoke the system voice to speak the name (subject)
|
|
158
|
+
# of the reminder and when (the time remaining until the scheduled datetime).
|
|
159
|
+
# The character "d" would be associated with this command so that, e.g.,
|
|
160
|
+
# the alert entry "@a 30m, 15m: d" would trigger this command 30
|
|
161
|
+
# minutes before and again 15 minutes before the scheduled datetime.
|
|
162
|
+
# Additional keys: start (scheduled datetime), time (spoken version of
|
|
163
|
+
# start), location, description.
|
|
164
|
+
{% for key, value in alerts.items() %}
|
|
165
|
+
{{ key }} = '{{ value }}'
|
|
166
|
+
{% endfor %}
|
|
167
|
+
|
|
168
|
+
# ─── Urgency Configuration ─────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
[urgency.colors]
|
|
171
|
+
# The hex color "min_hex_color" applies to urgencies in [-1.0, min_urgency].
|
|
172
|
+
# Hex colors for the interval [min_urgency, 1.0] are broken into "steps"
|
|
173
|
+
# equal steps along the gradient from "min_hex_color" to "max_hex_color".
|
|
174
|
+
# These colors are used for tasks in the urgency listing.
|
|
175
|
+
min_hex_color = "{{ urgency.colors.min_hex_color }}"
|
|
176
|
+
max_hex_color = "{{ urgency.colors.max_hex_color }}"
|
|
177
|
+
min_urgency = {{ urgency.colors.min_urgency }}
|
|
178
|
+
steps = {{ urgency.colors.steps }}
|
|
179
|
+
|
|
180
|
+
[urgency.due]
|
|
181
|
+
# The "due" urgency increases from 0.0 to "max" as now passes from
|
|
182
|
+
# due - interval to due.
|
|
183
|
+
interval = "{{ urgency.due.interval }}"
|
|
184
|
+
max = {{ urgency.due.max }}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
[urgency.pastdue]
|
|
188
|
+
# The "pastdue" urgency increases from 0.0 to "max" as now passes
|
|
189
|
+
# from due to due + interval.
|
|
190
|
+
interval = "{{ urgency.pastdue.interval }}"
|
|
191
|
+
max = {{ urgency.pastdue.max }}
|
|
192
|
+
|
|
193
|
+
[urgency.recent]
|
|
194
|
+
# The "recent" urgency decreases from "max" to 0.0 as now passes
|
|
195
|
+
# from modified to modified + interval.
|
|
196
|
+
interval = "{{ urgency.recent.interval }}"
|
|
197
|
+
max = {{ urgency.recent.max }}
|
|
198
|
+
|
|
199
|
+
[urgency.age]
|
|
200
|
+
# The "age" urgency increases from 0.0 to "max" as now increases
|
|
201
|
+
# from modified to modified + interval.
|
|
202
|
+
interval = "{{ urgency.age.interval }}"
|
|
203
|
+
max = {{ urgency.age.max }}
|
|
204
|
+
|
|
205
|
+
[urgency.extent]
|
|
206
|
+
# The "extent" urgency increases from 0.0 when extent = "0m" to "max"
|
|
207
|
+
# when extent >= interval.
|
|
208
|
+
interval = "{{ urgency.extent.interval }}"
|
|
209
|
+
max = {{ urgency.extent.max }}
|
|
210
|
+
|
|
211
|
+
[urgency.blocking]
|
|
212
|
+
# The "blocking" urgency increases from 0.0 when blocked = 0 to "max"
|
|
213
|
+
# when blocked >= count.
|
|
214
|
+
count = {{ urgency.blocking.count }}
|
|
215
|
+
max = {{ urgency.blocking.max }}
|
|
216
|
+
|
|
217
|
+
[urgency.tags]
|
|
218
|
+
# The "tags" urgency increases from 0.0 when tags = 0 to "max" when
|
|
219
|
+
# when tags >= count.
|
|
220
|
+
count = {{ urgency.tags.count }}
|
|
221
|
+
max = {{ urgency.tags.max }}
|
|
222
|
+
|
|
223
|
+
[urgency.priority]
|
|
224
|
+
# The "priority" urgency corresponds to the value from "1" to "5" of `@p`
|
|
225
|
+
# specified in the task. E.g, with "@p 3", the value would correspond to
|
|
226
|
+
# the "3" entry below. Absent an entry for "@p", the value would be 0.0.
|
|
227
|
+
{% for key, value in urgency.priority.items() %}
|
|
228
|
+
"{{ key }}" = {{ value }}
|
|
229
|
+
{% endfor %}
|
|
230
|
+
|
|
231
|
+
# In the default settings, a priority of "5" is the only one that yields
|
|
232
|
+
# a negative value, `-5`, and thus reduces the urgency of the task.
|
|
233
|
+
|
|
234
|
+
[urgency.description]
|
|
235
|
+
# The "description" urgency equals "max" if the task has an "@d" entry and
|
|
236
|
+
# 0.0 otherwise.
|
|
237
|
+
max = {{ urgency.description.max }}
|
|
238
|
+
|
|
239
|
+
[urgency.project]
|
|
240
|
+
# The "project" urgency equals "max" if the task belongs to a project and
|
|
241
|
+
# 0.0 otherwise.
|
|
242
|
+
max = {{ urgency.project.max }}
|
|
243
|
+
|
|
244
|
+
"""
|
|
245
|
+
# # ─── Commented Template ────────────────────────────────────
|
|
246
|
+
# CONFIG_TEMPLATE = """\
|
|
247
|
+
# title = "{{ title }}"
|
|
248
|
+
#
|
|
249
|
+
# [ui]
|
|
250
|
+
# # theme: str = 'dark' | 'light'
|
|
251
|
+
# theme = "{{ ui.theme }}"
|
|
252
|
+
#
|
|
253
|
+
# # ampm: bool = true | false
|
|
254
|
+
# ampm = {{ ui.ampm | lower }}
|
|
255
|
+
#
|
|
256
|
+
# # dayfirst: bool = true | false
|
|
257
|
+
# dayfirst = {{ ui.dayfirst | lower }}
|
|
258
|
+
#
|
|
259
|
+
# # yearfirst: bool = true | false
|
|
260
|
+
# yearfirst = {{ ui.yearfirst | lower }}
|
|
261
|
+
#
|
|
262
|
+
# [alerts]
|
|
263
|
+
# # dict[str, str]: character -> command_str
|
|
264
|
+
# {% for key, value in alerts.items() %}
|
|
265
|
+
# {{ key }} = '{{ value }}'
|
|
266
|
+
# {% endfor %}
|
|
267
|
+
#
|
|
268
|
+
# [urgency]
|
|
269
|
+
# # values for task urgency calculation
|
|
270
|
+
#
|
|
271
|
+
# # does this task or job have a description?
|
|
272
|
+
# description = {{ urgency.description }}
|
|
273
|
+
#
|
|
274
|
+
# # is this a job and thus part of a project?
|
|
275
|
+
# project = {{ urgency.project }}
|
|
276
|
+
#
|
|
277
|
+
# # Each of the "max/interval" settings below involves a
|
|
278
|
+
# # max and an interval over which the contribution ranges
|
|
279
|
+
# # between the max value and 0.0. In each case, "now" refers
|
|
280
|
+
# # to the current datetime, "due" to the scheduled datetime
|
|
281
|
+
# # and "modified" to the last modified datetime. Note that
|
|
282
|
+
# # necessarily, "now" >= "modified". The returned value
|
|
283
|
+
# # varies linearly over the interval in each case.
|
|
284
|
+
#
|
|
285
|
+
# [urgency.due]
|
|
286
|
+
# # Return 0.0 when now <= due - interval and max when
|
|
287
|
+
# # now >= due.
|
|
288
|
+
#
|
|
289
|
+
# max = {{ urgency.due.max }}
|
|
290
|
+
# interval = "{{ urgency.due.interval }}"
|
|
291
|
+
#
|
|
292
|
+
# [urgency.pastdue]
|
|
293
|
+
# # Return 0.0 when now <= due and max when now >=
|
|
294
|
+
# # due + interval.
|
|
295
|
+
#
|
|
296
|
+
# max = {{ urgency.pastdue.max }}
|
|
297
|
+
# interval = "{{ urgency.pastdue.interval }}"
|
|
298
|
+
#
|
|
299
|
+
# [urgency.recent]
|
|
300
|
+
# # The "recent" value is max when now = modified and
|
|
301
|
+
# # 0.0 when now >= modified + interval. The maximum of
|
|
302
|
+
# # this value and "age" (below) is returned. The returned
|
|
303
|
+
# # value thus decreases initially over the
|
|
304
|
+
#
|
|
305
|
+
# max = {{ urgency.recent.max }}
|
|
306
|
+
# interval = "{{ urgency.recent.interval }}"
|
|
307
|
+
#
|
|
308
|
+
# [urgency.age]
|
|
309
|
+
# # The "age" value is 0.0 when now = modified and max
|
|
310
|
+
# # when now >= modified + interval. The maximum of this
|
|
311
|
+
# # value and "recent" (above) is returned.
|
|
312
|
+
#
|
|
313
|
+
# max = {{ urgency.age.max }}
|
|
314
|
+
# interval = "{{ urgency.age.interval }}"
|
|
315
|
+
#
|
|
316
|
+
# [urgency.extent]
|
|
317
|
+
# # The "extent" value is 0.0 when extent = "0m" and max
|
|
318
|
+
# # when extent >= interval.
|
|
319
|
+
#
|
|
320
|
+
# max = {{ urgency.extent.max }}
|
|
321
|
+
# interval = "{{ urgency.extent.interval }}"
|
|
322
|
+
#
|
|
323
|
+
# [urgency.blocking]
|
|
324
|
+
# # The "blocking" value is 0.0 when blocking = 0 and max
|
|
325
|
+
# # when blocking >= count.
|
|
326
|
+
#
|
|
327
|
+
# max = {{ urgency.blocking.max }}
|
|
328
|
+
# count = {{ urgency.blocking.count }}
|
|
329
|
+
#
|
|
330
|
+
# [urgency.tags]
|
|
331
|
+
# # The "tags" value is 0.0 when len(tags) = 0 and max
|
|
332
|
+
# # when len(tags) >= count.
|
|
333
|
+
#
|
|
334
|
+
# max = {{ urgency.tags.max }}
|
|
335
|
+
# count = {{ urgency.tags.count }}
|
|
336
|
+
#
|
|
337
|
+
# [urgency.priority]
|
|
338
|
+
# # Priority levels used in urgency calculation.
|
|
339
|
+
# # These are mapped from user input `@p 1` through `@p 5`
|
|
340
|
+
# # so that entering "@p 1" entails the priority value for
|
|
341
|
+
# # "someday", "@p 2" the priority value for "low" and so forth.
|
|
342
|
+
# #
|
|
343
|
+
# # @p 1 = someday → least urgent
|
|
344
|
+
# # @p 2 = low
|
|
345
|
+
# # @p 3 = medium
|
|
346
|
+
# # @p 4 = high
|
|
347
|
+
# # @p 5 = next → most urgent
|
|
348
|
+
# #
|
|
349
|
+
# # Set these values to tune the effect of each level. Note
|
|
350
|
+
# # that omitting @p in a task is equivalent to setting
|
|
351
|
+
# # priority = 0.0 for the task.
|
|
352
|
+
#
|
|
353
|
+
# someday = {{ urgency.priority.someday }}
|
|
354
|
+
# low = {{ urgency.priority.low }}
|
|
355
|
+
# medium = {{ urgency.priority.medium }}
|
|
356
|
+
# high = {{ urgency.priority.high }}
|
|
357
|
+
# next = {{ urgency.priority.next }}
|
|
358
|
+
#
|
|
359
|
+
# ─── Save Config with Comments ───────────────────────────────
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def save_config_from_template(config: TklrConfig, path: Path):
|
|
363
|
+
template = Template(CONFIG_TEMPLATE)
|
|
364
|
+
rendered = template.render(**config.model_dump())
|
|
365
|
+
path.write_text(rendered.strip() + "\n", encoding="utf-8")
|
|
366
|
+
print(f"✅ Config with comments written to: {path}")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ─── Main Environment Class ───────────────────────────────
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class TklrEnvironment:
|
|
373
|
+
def __init__(self):
|
|
374
|
+
self._home = self._resolve_home()
|
|
375
|
+
self._config: Optional[TklrConfig] = None
|
|
376
|
+
print(f"using\n {self.home = }\n {self.db_path = }\n {self.config_path = }")
|
|
377
|
+
|
|
378
|
+
with open(self.config_path, "rb") as f:
|
|
379
|
+
data = tomllib.load(f)
|
|
380
|
+
print("Raw config.toml data:", data)
|
|
381
|
+
sys.exit
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def home(self) -> Path:
|
|
385
|
+
return self._home
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def config_path(self) -> Path:
|
|
389
|
+
return self.home / "config.toml"
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def db_path(self) -> Path:
|
|
393
|
+
return self.home / "tklr.db"
|
|
394
|
+
|
|
395
|
+
def ensure(self, init_config: bool = True, init_db_fn: Optional[callable] = None):
|
|
396
|
+
self.home.mkdir(parents=True, exist_ok=True)
|
|
397
|
+
|
|
398
|
+
if init_config and not self.config_path.exists():
|
|
399
|
+
save_config_from_template(TklrConfig(), self.config_path)
|
|
400
|
+
|
|
401
|
+
if init_db_fn and not self.db_path.exists():
|
|
402
|
+
init_db_fn(self.db_path)
|
|
403
|
+
|
|
404
|
+
def load_config(self) -> TklrConfig:
|
|
405
|
+
from jinja2 import Template
|
|
406
|
+
|
|
407
|
+
# Step 1: Create the file if it doesn't exist
|
|
408
|
+
if not os.path.exists(self.config_path):
|
|
409
|
+
config = TklrConfig()
|
|
410
|
+
template = Template(CONFIG_TEMPLATE)
|
|
411
|
+
rendered = template.render(**config.model_dump()).strip() + "\n"
|
|
412
|
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
|
413
|
+
f.write(rendered)
|
|
414
|
+
print(f"✅ Created new config file at {self.config_path}")
|
|
415
|
+
self._config = config
|
|
416
|
+
return config
|
|
417
|
+
|
|
418
|
+
# Step 2: Try to load and validate the config
|
|
419
|
+
try:
|
|
420
|
+
with open(self.config_path, "rb") as f:
|
|
421
|
+
data = tomllib.load(f)
|
|
422
|
+
config = TklrConfig.model_validate(data)
|
|
423
|
+
except (ValidationError, tomllib.TOMLDecodeError) as e:
|
|
424
|
+
print(f"⚠️ Config error in {self.config_path}: {e}\nUsing defaults.")
|
|
425
|
+
config = TklrConfig()
|
|
426
|
+
|
|
427
|
+
# Step 3: Always regenerate the canonical version
|
|
428
|
+
template = Template(CONFIG_TEMPLATE)
|
|
429
|
+
rendered = template.render(**config.model_dump()).strip() + "\n"
|
|
430
|
+
|
|
431
|
+
with open(self.config_path, "r", encoding="utf-8") as f:
|
|
432
|
+
current_text = f.read()
|
|
433
|
+
|
|
434
|
+
if rendered != current_text:
|
|
435
|
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
|
436
|
+
f.write(rendered)
|
|
437
|
+
print(f"✅ Updated {self.config_path} with any missing defaults.")
|
|
438
|
+
|
|
439
|
+
self._config = config
|
|
440
|
+
return config
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def config(self) -> TklrConfig:
|
|
444
|
+
if self._config is None:
|
|
445
|
+
return self.load_config()
|
|
446
|
+
return self._config
|
|
447
|
+
|
|
448
|
+
def _resolve_home(self) -> Path:
|
|
449
|
+
cwd = Path.cwd()
|
|
450
|
+
if (cwd / "config.toml").exists() and (cwd / "tklr.db").exists():
|
|
451
|
+
return cwd
|
|
452
|
+
|
|
453
|
+
env_home = os.getenv("TKLR_HOME")
|
|
454
|
+
if env_home:
|
|
455
|
+
return Path(env_home).expanduser()
|
|
456
|
+
|
|
457
|
+
xdg_home = os.getenv("XDG_CONFIG_HOME")
|
|
458
|
+
if xdg_home:
|
|
459
|
+
return Path(xdg_home).expanduser() / "tklr"
|
|
460
|
+
else:
|
|
461
|
+
return Path.home() / ".config" / "tklr"
|
tklr/use_system.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import platform
|
|
4
|
+
import subprocess
|
|
5
|
+
import contextlib
|
|
6
|
+
import io
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def open_with_default(path: Path) -> None:
|
|
12
|
+
path = Path(path).expanduser().resolve()
|
|
13
|
+
system = platform.system()
|
|
14
|
+
# Redirect stderr to capture any “Exception ignored” warnings
|
|
15
|
+
output = io.StringIO()
|
|
16
|
+
with contextlib.redirect_stderr(output):
|
|
17
|
+
try:
|
|
18
|
+
if system == "Darwin":
|
|
19
|
+
subprocess.Popen(
|
|
20
|
+
["open", str(path)],
|
|
21
|
+
stdin=subprocess.DEVNULL,
|
|
22
|
+
stdout=subprocess.DEVNULL,
|
|
23
|
+
stderr=subprocess.DEVNULL,
|
|
24
|
+
)
|
|
25
|
+
elif system == "Windows":
|
|
26
|
+
os.startfile(str(path))
|
|
27
|
+
else:
|
|
28
|
+
subprocess.Popen(
|
|
29
|
+
["xdg-open", str(path)],
|
|
30
|
+
stdin=subprocess.DEVNULL,
|
|
31
|
+
stdout=subprocess.DEVNULL,
|
|
32
|
+
stderr=subprocess.DEVNULL,
|
|
33
|
+
)
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print(f"Error opening {path}: {e}", file=sys.stderr)
|
|
36
|
+
|
|
37
|
+
res = output.getvalue()
|
|
38
|
+
if res:
|
|
39
|
+
print(f"caught by redirect_stderr:\n'{res}'", file=sys.stderr)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def play_alert_sound(filename: str) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Play a short alert sound from the packaged tklr/sounds resource.
|
|
45
|
+
:param filename: e.g., 'ding.mp3'
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
with resources.path("tklr.sounds", filename) as sound_path:
|
|
49
|
+
p = sound_path
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
print(f"⚠️ Sound file not found: {filename}", file=sys.stderr)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
system = platform.system()
|
|
55
|
+
# For macOS, prefer afplay (fast, no UI)
|
|
56
|
+
if system == "Darwin":
|
|
57
|
+
os.system(f"afplay {str(p)} &")
|
|
58
|
+
# On Windows, you might rely on default
|
|
59
|
+
elif system == "Windows":
|
|
60
|
+
open_with_default(p)
|
|
61
|
+
else:
|
|
62
|
+
# On Linux, you might try to use default or a command-line player
|
|
63
|
+
# Here we fallback to default open
|
|
64
|
+
open_with_default(p)
|
tklr/versioning.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# src/tklr/versioning.py
|
|
2
|
+
from importlib.metadata import (
|
|
3
|
+
version as _version,
|
|
4
|
+
PackageNotFoundError,
|
|
5
|
+
packages_distributions,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_version() -> str:
|
|
10
|
+
# Map package → distribution(s), then pick the first match
|
|
11
|
+
dist_name = next(iter(packages_distributions().get("tklr", [])), "tklr-dgraham")
|
|
12
|
+
try:
|
|
13
|
+
return _version(dist_name)
|
|
14
|
+
except PackageNotFoundError:
|
|
15
|
+
# Dev checkout fallback: read from pyproject.toml
|
|
16
|
+
import tomllib
|
|
17
|
+
import pathlib
|
|
18
|
+
|
|
19
|
+
root = pathlib.Path(__file__).resolve().parents[2]
|
|
20
|
+
data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
|
|
21
|
+
return data["project"]["version"]
|