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/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"]