tklr-dgraham 0.0.0rc22__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.
tklr/sounds/alert.mp3 ADDED
Binary file
tklr/tklr_env.py ADDED
@@ -0,0 +1,493 @@
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 Dict, List, 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
+ bin_orders: Dict[str, List[str]] = Field(default_factory=dict)
107
+
108
+
109
+ CONFIG_TEMPLATE = """\
110
+ # DO NOT EDIT TITLE
111
+ title = "{{ title }}"
112
+
113
+ [ui]
114
+ # theme: str = 'dark' | 'light'
115
+ theme = "{{ ui.theme }}"
116
+
117
+ # ampm: bool = true | false
118
+ # Use 12 hour AM/PM when true else 24 hour
119
+ ampm = {{ ui.ampm | lower }}
120
+
121
+ # history_weight: int
122
+ # Apply this weight to the prior history when computing
123
+ # the next offset for a task
124
+ history_weight = {{ ui.history_weight }}
125
+
126
+ # dayfirst and yearfirst settings
127
+ # These settings are used to resolve ambiguous date entries involving
128
+ # 2-digit components. E.g., the interpretation of the date "12-10-11"
129
+ # with the various possible settings for dayfirst and yearfirst:
130
+ #
131
+ # dayfirst yearfirst date interpretation standard
132
+ # ======== ========= ======== ============== ========
133
+ # True True 12-10-11 2012-11-10 Y-D-M ??
134
+ # True False 12-10-11 2011-10-12 D-M-Y EU
135
+ # False True 12-10-11 2012-10-11 Y-M-D ISO 8601
136
+ # False False 12-10-11 2011-12-10 M-D-Y US
137
+ #
138
+ # The defaults:
139
+ # dayfirst = false
140
+ # yearfirst = true
141
+ # correspond to the Y-M-D ISO 8601 standard.
142
+
143
+ # dayfirst: bool = true | false
144
+ dayfirst = {{ ui.dayfirst | lower }}
145
+
146
+ # yearfirst: bool = true | false
147
+ yearfirst = {{ ui.yearfirst | lower }}
148
+
149
+ # two_digit_year: bool = true | false
150
+ # If true, years are displayed using the last two digits, e.g.,
151
+ # 25 instead of 2025.
152
+ two_digit_year = {{ ui.two_digit_year | lower }}
153
+
154
+ [alerts]
155
+ # dict[str, str]: character -> command_str.
156
+ # E.g., this entry
157
+ # v = '/usr/bin/say -v Alex "{name}, {when}"'
158
+ # would, on my macbook, invoke the system voice to speak the name (subject)
159
+ # of the reminder and when (the time remaining until the scheduled datetime).
160
+ # The character "d" would be associated with this command so that, e.g.,
161
+ # the alert entry "@a 30m, 15m: d" would trigger this command 30
162
+ # minutes before and again 15 minutes before the scheduled datetime.
163
+ # Additional keys: start (scheduled datetime), time (spoken version of
164
+ # start), location, description.
165
+ {% for key, value in alerts.items() %}
166
+ {{ key }} = '{{ value }}'
167
+ {% endfor %}
168
+
169
+ # ─── Urgency Configuration ─────────────────────────────────────
170
+
171
+ [urgency.colors]
172
+ # The hex color "min_hex_color" applies to urgencies in [-1.0, min_urgency].
173
+ # Hex colors for the interval [min_urgency, 1.0] are broken into "steps"
174
+ # equal steps along the gradient from "min_hex_color" to "max_hex_color".
175
+ # These colors are used for tasks in the urgency listing.
176
+ min_hex_color = "{{ urgency.colors.min_hex_color }}"
177
+ max_hex_color = "{{ urgency.colors.max_hex_color }}"
178
+ min_urgency = {{ urgency.colors.min_urgency }}
179
+ steps = {{ urgency.colors.steps }}
180
+
181
+ [urgency.due]
182
+ # The "due" urgency increases from 0.0 to "max" as now passes from
183
+ # due - interval to due.
184
+ interval = "{{ urgency.due.interval }}"
185
+ max = {{ urgency.due.max }}
186
+
187
+
188
+ [urgency.pastdue]
189
+ # The "pastdue" urgency increases from 0.0 to "max" as now passes
190
+ # from due to due + interval.
191
+ interval = "{{ urgency.pastdue.interval }}"
192
+ max = {{ urgency.pastdue.max }}
193
+
194
+ [urgency.recent]
195
+ # The "recent" urgency decreases from "max" to 0.0 as now passes
196
+ # from modified to modified + interval.
197
+ interval = "{{ urgency.recent.interval }}"
198
+ max = {{ urgency.recent.max }}
199
+
200
+ [urgency.age]
201
+ # The "age" urgency increases from 0.0 to "max" as now increases
202
+ # from modified to modified + interval.
203
+ interval = "{{ urgency.age.interval }}"
204
+ max = {{ urgency.age.max }}
205
+
206
+ [urgency.extent]
207
+ # The "extent" urgency increases from 0.0 when extent = "0m" to "max"
208
+ # when extent >= interval.
209
+ interval = "{{ urgency.extent.interval }}"
210
+ max = {{ urgency.extent.max }}
211
+
212
+ [urgency.blocking]
213
+ # The "blocking" urgency increases from 0.0 when blocked = 0 to "max"
214
+ # when blocked >= count.
215
+ count = {{ urgency.blocking.count }}
216
+ max = {{ urgency.blocking.max }}
217
+
218
+ [urgency.tags]
219
+ # The "tags" urgency increases from 0.0 when tags = 0 to "max" when
220
+ # when tags >= count.
221
+ count = {{ urgency.tags.count }}
222
+ max = {{ urgency.tags.max }}
223
+
224
+ [urgency.priority]
225
+ # The "priority" urgency corresponds to the value from "1" to "5" of `@p`
226
+ # specified in the task. E.g, with "@p 3", the value would correspond to
227
+ # the "3" entry below. Absent an entry for "@p", the value would be 0.0.
228
+ {% for key, value in urgency.priority.items() %}
229
+ "{{ key }}" = {{ value }}
230
+ {% endfor %}
231
+
232
+ # In the default settings, a priority of "5" is the only one that yields
233
+ # a negative value, `-5`, and thus reduces the urgency of the task.
234
+
235
+ [urgency.description]
236
+ # The "description" urgency equals "max" if the task has an "@d" entry and
237
+ # 0.0 otherwise.
238
+ max = {{ urgency.description.max }}
239
+
240
+ [urgency.project]
241
+ # The "project" urgency equals "max" if the task belongs to a project and
242
+ # 0.0 otherwise.
243
+ max = {{ urgency.project.max }}
244
+
245
+ [bin_orders]
246
+ # Specify custom ordering of children for a root bin.
247
+ # Example:
248
+ # seedbed = ["seed", "germination", "seedling", "growth", "flowering"]
249
+ {% for root, order_list in bin_orders.items() %}
250
+ {{ root }} = ["{{ order_list | join('","') }}"]
251
+ {% endfor %}
252
+
253
+ """
254
+ # # ─── Commented Template ────────────────────────────────────
255
+ # CONFIG_TEMPLATE = """\
256
+ # title = "{{ title }}"
257
+ #
258
+ # [ui]
259
+ # # theme: str = 'dark' | 'light'
260
+ # theme = "{{ ui.theme }}"
261
+ #
262
+ # # ampm: bool = true | false
263
+ # ampm = {{ ui.ampm | lower }}
264
+ #
265
+ # # dayfirst: bool = true | false
266
+ # dayfirst = {{ ui.dayfirst | lower }}
267
+ #
268
+ # # yearfirst: bool = true | false
269
+ # yearfirst = {{ ui.yearfirst | lower }}
270
+ #
271
+ # [alerts]
272
+ # # dict[str, str]: character -> command_str
273
+ # {% for key, value in alerts.items() %}
274
+ # {{ key }} = '{{ value }}'
275
+ # {% endfor %}
276
+ #
277
+ # [urgency]
278
+ # # values for task urgency calculation
279
+ #
280
+ # # does this task or job have a description?
281
+ # description = {{ urgency.description }}
282
+ #
283
+ # # is this a job and thus part of a project?
284
+ # project = {{ urgency.project }}
285
+ #
286
+ # # Each of the "max/interval" settings below involves a
287
+ # # max and an interval over which the contribution ranges
288
+ # # between the max value and 0.0. In each case, "now" refers
289
+ # # to the current datetime, "due" to the scheduled datetime
290
+ # # and "modified" to the last modified datetime. Note that
291
+ # # necessarily, "now" >= "modified". The returned value
292
+ # # varies linearly over the interval in each case.
293
+ #
294
+ # [urgency.due]
295
+ # # Return 0.0 when now <= due - interval and max when
296
+ # # now >= due.
297
+ #
298
+ # max = {{ urgency.due.max }}
299
+ # interval = "{{ urgency.due.interval }}"
300
+ #
301
+ # [urgency.pastdue]
302
+ # # Return 0.0 when now <= due and max when now >=
303
+ # # due + interval.
304
+ #
305
+ # max = {{ urgency.pastdue.max }}
306
+ # interval = "{{ urgency.pastdue.interval }}"
307
+ #
308
+ # [urgency.recent]
309
+ # # The "recent" value is max when now = modified and
310
+ # # 0.0 when now >= modified + interval. The maximum of
311
+ # # this value and "age" (below) is returned. The returned
312
+ # # value thus decreases initially over the
313
+ #
314
+ # max = {{ urgency.recent.max }}
315
+ # interval = "{{ urgency.recent.interval }}"
316
+ #
317
+ # [urgency.age]
318
+ # # The "age" value is 0.0 when now = modified and max
319
+ # # when now >= modified + interval. The maximum of this
320
+ # # value and "recent" (above) is returned.
321
+ #
322
+ # max = {{ urgency.age.max }}
323
+ # interval = "{{ urgency.age.interval }}"
324
+ #
325
+ # [urgency.extent]
326
+ # # The "extent" value is 0.0 when extent = "0m" and max
327
+ # # when extent >= interval.
328
+ #
329
+ # max = {{ urgency.extent.max }}
330
+ # interval = "{{ urgency.extent.interval }}"
331
+ #
332
+ # [urgency.blocking]
333
+ # # The "blocking" value is 0.0 when blocking = 0 and max
334
+ # # when blocking >= count.
335
+ #
336
+ # max = {{ urgency.blocking.max }}
337
+ # count = {{ urgency.blocking.count }}
338
+ #
339
+ # [urgency.tags]
340
+ # # The "tags" value is 0.0 when len(tags) = 0 and max
341
+ # # when len(tags) >= count.
342
+ #
343
+ # max = {{ urgency.tags.max }}
344
+ # count = {{ urgency.tags.count }}
345
+ #
346
+ # [urgency.priority]
347
+ # # Priority levels used in urgency calculation.
348
+ # # These are mapped from user input `@p 1` through `@p 5`
349
+ # # so that entering "@p 1" entails the priority value for
350
+ # # "someday", "@p 2" the priority value for "low" and so forth.
351
+ # #
352
+ # # @p 1 = someday → least urgent
353
+ # # @p 2 = low
354
+ # # @p 3 = medium
355
+ # # @p 4 = high
356
+ # # @p 5 = next → most urgent
357
+ # #
358
+ # # Set these values to tune the effect of each level. Note
359
+ # # that omitting @p in a task is equivalent to setting
360
+ # # priority = 0.0 for the task.
361
+ #
362
+ # someday = {{ urgency.priority.someday }}
363
+ # low = {{ urgency.priority.low }}
364
+ # medium = {{ urgency.priority.medium }}
365
+ # high = {{ urgency.priority.high }}
366
+ # next = {{ urgency.priority.next }}
367
+ #
368
+ # [bin_orders]
369
+ # # Specify custom ordering of children for a root bin.
370
+ # # Example:
371
+ # # seedbed = ["seed", "germination", "seedling", "growth", "flowering"]
372
+ # {% for root, order_list in bin_orders.items() %}
373
+ # {{ root }} = ["{{ order_list | join('","') }}"]
374
+ # {% endfor %}
375
+
376
+ # ─── Save Config with Comments ───────────────────────────────
377
+
378
+
379
+ def save_config_from_template(config: TklrConfig, path: Path):
380
+ template = Template(CONFIG_TEMPLATE)
381
+ rendered = template.render(**config.model_dump())
382
+ path.write_text(rendered.strip() + "\n", encoding="utf-8")
383
+ print(f"✅ Config with comments written to: {path}")
384
+
385
+
386
+ # ─── Main Environment Class ───────────────────────────────
387
+
388
+
389
+ def collapse_home(path: str | Path) -> str:
390
+ path = Path(path).expanduser().resolve()
391
+ str_path = path.as_posix()
392
+ str_path = str_path.replace(str(Path.home()), "~")
393
+ return str_path
394
+
395
+
396
+ class TklrEnvironment:
397
+ def __init__(self):
398
+ # self.cwd = Path.cwd()
399
+ self._home = self._resolve_home()
400
+ # self.usrhome = Path.home()
401
+ self._config: Optional[TklrConfig] = None
402
+
403
+ # def norm_path(self, path: Path):
404
+ # rpath = path.resolve()
405
+ # if rpath.is_relative_to(self.usrhome):
406
+ # return rpath.relative_to(self.usrhome).as_posix()
407
+ # return rpath.as_posix()
408
+
409
+ def get_paths(self):
410
+ return [collapse_home(p) for p in [self.home, self.db_path, self.config_path]]
411
+
412
+ def get_home(self):
413
+ return collapse_home(self.home)
414
+
415
+ @property
416
+ def home(self) -> Path:
417
+ return self._home
418
+
419
+ @property
420
+ def config_path(self) -> Path:
421
+ return self.home / "config.toml"
422
+
423
+ @property
424
+ def db_path(self) -> Path:
425
+ return self.home / "tklr.db"
426
+
427
+ def ensure(self, init_config: bool = True, init_db_fn: Optional[callable] = None):
428
+ self.home.mkdir(parents=True, exist_ok=True)
429
+
430
+ if init_config and not self.config_path.exists():
431
+ save_config_from_template(TklrConfig(), self.config_path)
432
+
433
+ if init_db_fn and not self.db_path.exists():
434
+ init_db_fn(self.db_path)
435
+
436
+ def load_config(self) -> TklrConfig:
437
+ from jinja2 import Template
438
+
439
+ # Step 1: Create the file if it doesn't exist
440
+ if not os.path.exists(self.config_path):
441
+ config = TklrConfig()
442
+ template = Template(CONFIG_TEMPLATE)
443
+ rendered = template.render(**config.model_dump()).strip() + "\n"
444
+ with open(self.config_path, "w", encoding="utf-8") as f:
445
+ f.write(rendered)
446
+ print(f"✅ Created new config file at {self.config_path}")
447
+ self._config = config
448
+ return config
449
+
450
+ # Step 2: Try to load and validate the config
451
+ try:
452
+ with open(self.config_path, "rb") as f:
453
+ data = tomllib.load(f)
454
+ config = TklrConfig.model_validate(data)
455
+ except (ValidationError, tomllib.TOMLDecodeError) as e:
456
+ print(f"⚠️ Config error in {self.config_path}: {e}\nUsing defaults.")
457
+ config = TklrConfig()
458
+
459
+ # Step 3: Always regenerate the canonical version
460
+ template = Template(CONFIG_TEMPLATE)
461
+ rendered = template.render(**config.model_dump()).strip() + "\n"
462
+
463
+ with open(self.config_path, "r", encoding="utf-8") as f:
464
+ current_text = f.read()
465
+
466
+ if rendered != current_text:
467
+ with open(self.config_path, "w", encoding="utf-8") as f:
468
+ f.write(rendered)
469
+ print(f"✅ Updated {self.config_path} with any missing defaults.")
470
+
471
+ self._config = config
472
+ return config
473
+
474
+ @property
475
+ def config(self) -> TklrConfig:
476
+ if self._config is None:
477
+ return self.load_config()
478
+ return self._config
479
+
480
+ def _resolve_home(self) -> Path:
481
+ cwd = Path.cwd()
482
+ if (cwd / "config.toml").exists() and (cwd / "tklr.db").exists():
483
+ return cwd
484
+
485
+ env_home = os.getenv("TKLR_HOME")
486
+ if env_home:
487
+ return Path(env_home).expanduser()
488
+
489
+ xdg_home = os.getenv("XDG_CONFIG_HOME")
490
+ if xdg_home:
491
+ return Path(xdg_home).expanduser() / "tklr"
492
+ else:
493
+ 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"]