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/shared.py ADDED
@@ -0,0 +1,654 @@
1
+ import inspect
2
+ import textwrap
3
+ import shutil
4
+ import re
5
+ from rich import print as rich_print
6
+ from datetime import date, datetime, timedelta, timezone
7
+ from typing import Literal, Tuple
8
+ from dateutil import tz
9
+ from pathlib import Path
10
+ from dateutil.parser import parse as dateutil_parse
11
+ from dateutil.parser import parserinfo
12
+ from zoneinfo import ZoneInfo
13
+ from .versioning import get_version
14
+
15
+ from tklr.tklr_env import TklrEnvironment
16
+
17
+ env = TklrEnvironment()
18
+ AMPM = env.config.ui.ampm
19
+ HRS_MINS = "12" if AMPM else "24"
20
+
21
+ ELLIPSIS_CHAR = "…"
22
+
23
+
24
+ CORNSILK = "#FFF8DC"
25
+ DARK_GRAY = "#A9A9A9"
26
+ DARK_GREY = "#A9A9A9" # same as DARK_GRAY
27
+ DARK_OLIVEGREEN = "#556B2F"
28
+ DARK_ORANGE = "#FF8C00"
29
+ DARK_SALMON = "#E9967A"
30
+ GOLD = "#FFD700"
31
+ GOLDENROD = "#DAA520"
32
+ KHAKI = "#F0E68C"
33
+ LAWN_GREEN = "#7CFC00"
34
+ LEMON_CHIFFON = "#FFFACD"
35
+ LIGHT_CORAL = "#F08080"
36
+ LIGHT_SKY_BLUE = "#87CEFA"
37
+ LIME_GREEN = "#32CD32"
38
+ ORANGE_RED = "#FF4500"
39
+ PALE_GREEN = "#98FB98"
40
+ PEACHPUFF = "#FFDAB9"
41
+ SALMON = "#FA8072"
42
+ SANDY_BROWN = "#F4A460"
43
+ SEA_GREEN = "#2E8B57"
44
+ SLATE_GREY = "#708090"
45
+ TOMATO = "#FF6347"
46
+
47
+ # Colors for UI elements
48
+ DAY_COLOR = LEMON_CHIFFON
49
+ FRAME_COLOR = KHAKI
50
+ HEADER_COLOR = LIGHT_SKY_BLUE
51
+ DIM_COLOR = DARK_GRAY
52
+ ALLDAY_COLOR = SANDY_BROWN
53
+ EVENT_COLOR = LIME_GREEN
54
+ NOTE_COLOR = DARK_SALMON
55
+ PASSED_EVENT = DARK_OLIVEGREEN
56
+ ACTIVE_EVENT = LAWN_GREEN
57
+ TASK_COLOR = LIGHT_SKY_BLUE
58
+ AVAILABLE_COLOR = LIGHT_SKY_BLUE
59
+ WAITING_COLOR = SLATE_GREY
60
+ FINISHED_COLOR = DARK_GREY
61
+ GOAL_COLOR = GOLDENROD
62
+ BIN_COLOR = GOLDENROD
63
+ ACTIVE_BIN = GOLD
64
+ CHORE_COLOR = KHAKI
65
+ PASTDUE_COLOR = DARK_ORANGE
66
+ NOTICE_COLOR = GOLD
67
+ DRAFT_COLOR = ORANGE_RED
68
+ TODAY_COLOR = TOMATO
69
+ SELECTED_BACKGROUND = "#566573"
70
+ MATCH_COLOR = TOMATO
71
+ TITLE_COLOR = CORNSILK
72
+ BUSY_COLOR = "#9acd32"
73
+ BUSY_COLOR = "#adff2f"
74
+ CONF_COLOR = TOMATO
75
+ BUSY_FRAME_COLOR = "#5d5d5d"
76
+
77
+ TYPE_TO_COLOR = {
78
+ "*": EVENT_COLOR, # event
79
+ "~": AVAILABLE_COLOR, # available task
80
+ "x": FINISHED_COLOR, # finished task
81
+ "^": AVAILABLE_COLOR, # available task
82
+ "+": WAITING_COLOR, # waiting task
83
+ "%": NOTE_COLOR, # note
84
+ "<": PASTDUE_COLOR, # past due task
85
+ ">": NOTICE_COLOR, # begin
86
+ "!": GOAL_COLOR, # draft
87
+ "?": DRAFT_COLOR, # draft
88
+ "b": BIN_COLOR,
89
+ "B": ACTIVE_BIN,
90
+ }
91
+
92
+
93
+ def get_anchor(aware: bool) -> datetime:
94
+ dt = datetime(1970, 1, 1, 0, 0, 0)
95
+ if aware:
96
+ return dt.replace(tzinfo=ZoneInfo("UTC"))
97
+ return dt
98
+
99
+
100
+ def fmt_user(dt_str: str) -> str:
101
+ """
102
+ User friendly formatting for dates and datetimes using env settings
103
+ for ampm, yearfirst, dayfirst and two_digit year.
104
+ """
105
+ if not dt_str:
106
+ return "unscheduled"
107
+ try:
108
+ dt = dateutil_parse(dt_str)
109
+ except Exception as e:
110
+ return f"error parsing {dt_str}: {e}"
111
+ if dt_str.endswith("T0000"):
112
+ return dt.strftime("%Y-%m-%d")
113
+ return dt.strftime("%Y-%m-%d %H:%M")
114
+
115
+
116
+ def parse(s, yearfirst: bool = True, dayfirst: bool = False):
117
+ # enable pi when read by main and settings is available
118
+ pi = parserinfo(
119
+ dayfirst=dayfirst, yearfirst=yearfirst
120
+ ) # FIXME: should come from config
121
+ # logger.debug(f"parsing {s = } with {kwd = }")
122
+ dt = dateutil_parse(s, parserinfo=pi)
123
+ if isinstance(dt, date) and not isinstance(dt, datetime):
124
+ return dt
125
+ if isinstance(dt, datetime):
126
+ if dt.hour == dt.minute == 0:
127
+ return dt.date()
128
+ return dt
129
+ return ""
130
+
131
+
132
+ def dt_as_utc_timestamp(dt: datetime) -> int:
133
+ if not isinstance(dt, datetime):
134
+ return 0
135
+ return round(dt.astimezone(tz.UTC).timestamp())
136
+
137
+
138
+ def timedelta_str_to_seconds(time_str: str) -> tuple[bool, int]:
139
+ """
140
+ Converts a time string composed of integers followed by 'w', 'd', 'h', or 'm'
141
+ into the total number of seconds.
142
+ Args:
143
+ time_str (str): The time string (e.g., '3h15s').
144
+ Returns:
145
+ int: The total number of seconds.
146
+ Raises:
147
+ ValueError: If the input string is not in the expected format.
148
+ """
149
+ # Define time multipliers for each unit
150
+ multipliers = {
151
+ "w": 7 * 24 * 60 * 60, # Weeks to seconds
152
+ "d": 24 * 60 * 60, # Days to seconds
153
+ "h": 60 * 60, # Hours to seconds
154
+ "m": 60, # Minutes to seconds
155
+ "s": 1, # Seconds to seconds
156
+ }
157
+ # Match all integer-unit pairs (e.g., "3h", "15s")
158
+ matches = re.findall(r"(\d+)([wdhms])", time_str)
159
+ if not matches:
160
+ return (
161
+ False,
162
+ "Invalid time string format. Expected integers followed by 'w', 'd', 'h', or 'm'.",
163
+ )
164
+ # Convert each match to seconds and sum them
165
+ total_seconds = sum(int(value) * multipliers[unit] for value, unit in matches)
166
+ return True, total_seconds
167
+
168
+
169
+ # ---------- DateTimes (local-naive, minute precision) ----------
170
+ def fmt_local_compact(dt: datetime) -> str:
171
+ """Local-naive → 'YYYYMMDD' or 'YYYYMMDDTHHMM' (no seconds)."""
172
+ if dt.hour == dt.minute == dt.second == 0:
173
+ return dt.strftime("%Y%m%d")
174
+ return dt.strftime("%Y%m%dT%H%M")
175
+
176
+
177
+ def parse_local_compact(s: str) -> datetime:
178
+ """'YYYYMMDD' or 'YYYYMMDDTHHMM' → local-naive datetime."""
179
+ if len(s) == 8:
180
+ return datetime.strptime(s, "%Y%m%d")
181
+ if len(s) == 13 and s[8] == "T":
182
+ return datetime.strptime(s, "%Y%m%dT%H%M")
183
+ raise ValueError(f"Bad local-compact datetime: {s!r}")
184
+
185
+
186
+ # FIXME: not needed without seconds
187
+ # ---------- Alerts (local-naive, second precision) ----------
188
+ # def fmt_local_seconds(dt: datetime) -> str:
189
+ # """Local-naive → 'YYYYMMDDTHHMMSS'."""
190
+ # return dt.strftime("%Y%m%dT%H%M%S")
191
+ #
192
+ #
193
+ # def parse_local_seconds(s: str) -> datetime:
194
+ # """'YYYYMMDDTHHMMSS' → local-naive datetime."""
195
+ # return datetime.strptime(s, "%Y%m%dT%H%M%S")
196
+ #
197
+
198
+
199
+ # ---------- Aware UTC (with trailing 'Z', minute precision) ----------
200
+ def fmt_utc_z(dt: datetime) -> str:
201
+ """Aware/naive → UTC aware → 'YYYYMMDDTHHMMZ' (no seconds)."""
202
+ if dt.tzinfo is None:
203
+ dt = dt.replace(tzinfo=timezone.utc) # or attach your local tz then convert
204
+ dt = dt.astimezone(timezone.utc)
205
+ return dt.strftime("%Y%m%dT%H%MZ")
206
+
207
+
208
+ def parse_utc_z(s: str) -> datetime:
209
+ """
210
+ 'YYYYMMDDTHHMMZ' or 'YYYYMMDDTHHMMSSZ' → aware datetime in UTC.
211
+ Accept seconds if present; normalize to tz-aware UTC object.
212
+ """
213
+ if not s.endswith("Z"):
214
+ raise ValueError(f"UTC-Z string must end with 'Z': {s!r}")
215
+ body = s[:-1]
216
+ fmt = "%Y%m%dT%H%M"
217
+ dt = datetime.strptime(body, fmt)
218
+ return dt.replace(tzinfo=timezone.utc)
219
+
220
+
221
+ def truncate_string(s: str, max_length: int) -> str:
222
+ # log_msg(f"Truncating string '{s}' to {max_length} characters")
223
+ if len(s) > max_length:
224
+ return f"{s[: max_length - 2]} {ELLIPSIS_CHAR}"
225
+ else:
226
+ return s
227
+
228
+
229
+ def log_msg(msg: str, file_path: str = "log_msg.md", print_output: bool = False):
230
+ """
231
+ Log a message and save it directly to a specified file.
232
+
233
+ Args:
234
+ msg (str): The message to log.
235
+ file_path (str, optional): Path to the log file. Defaults to "log_msg.md".
236
+ print_output (bool, optional): If True, also print to console.
237
+ """
238
+ frame = inspect.stack()[1].frame
239
+ func_name = frame.f_code.co_name
240
+
241
+ # Default: just function name
242
+ caller_name = func_name
243
+
244
+ # Detect instance/class/static context
245
+ if "self" in frame.f_locals: # instance method
246
+ cls_name = frame.f_locals["self"].__class__.__name__
247
+ caller_name = f"{cls_name}.{func_name}"
248
+ elif "cls" in frame.f_locals: # classmethod
249
+ cls_name = frame.f_locals["cls"].__name__
250
+ caller_name = f"{cls_name}.{func_name}"
251
+
252
+ # Format the line header
253
+ lines = [
254
+ f"- {datetime.now().strftime('%y-%m-%d %H:%M:%S')} ({caller_name}): ",
255
+ ]
256
+ # Wrap the message text
257
+ lines.extend(
258
+ [
259
+ f"\n{x}"
260
+ for x in textwrap.wrap(
261
+ msg.strip(),
262
+ width=shutil.get_terminal_size()[0] - 6,
263
+ initial_indent=" ",
264
+ subsequent_indent=" ",
265
+ )
266
+ ]
267
+ )
268
+ lines.append("\n\n")
269
+
270
+ # Save the message to the file
271
+ with open(file_path, "a") as f:
272
+ f.writelines(lines)
273
+
274
+ # Optional console print
275
+ if print_output:
276
+ print("".join(lines))
277
+
278
+
279
+ def print_msg(msg: str, file_path: str = "log_msg.md", print_output: bool = False):
280
+ """
281
+ Log a message and save it directly to a specified file.
282
+
283
+ Args:
284
+ msg (str): The message to log.
285
+ file_path (str, optional): Path to the log file. Defaults to "log_msg.txt".
286
+ """
287
+ caller_name = inspect.stack()[1].function
288
+ lines = [
289
+ f"{caller_name}",
290
+ ]
291
+ lines.extend(
292
+ [
293
+ f"\n{x}"
294
+ for x in textwrap.wrap(
295
+ msg.strip(),
296
+ width=shutil.get_terminal_size()[0] - 6,
297
+ initial_indent=" ",
298
+ subsequent_indent=" ",
299
+ )
300
+ ]
301
+ )
302
+
303
+ # Save the message to the file
304
+ # print("".join(lines))
305
+ for line in lines:
306
+ rich_print(line)
307
+
308
+
309
+ def display_messages(file_path: str = "log_msg.md"):
310
+ """
311
+ Display all logged messages from the specified file.
312
+
313
+ Args:
314
+ file_path (str, optional): Path to the log file. Defaults to "log_msg.txt".
315
+ """
316
+ try:
317
+ # Read messages from the file
318
+ with open(file_path, "r") as f:
319
+ markdown_content = f.read()
320
+ markdown = Markdown(markdown_content)
321
+ console = Console()
322
+ console.print(markdown)
323
+ except FileNotFoundError:
324
+ print(f"Error: Log file '{file_path}' not found.")
325
+
326
+
327
+ def format_time_range(start_time: str, end_time: str, ampm: bool = False) -> str:
328
+ """Format time range respecting ampm setting."""
329
+ start_dt = datetime_from_timestamp(start_time)
330
+ end_dt = datetime_from_timestamp(end_time) if end_time else None
331
+ # log_msg(f"{start_dt = }, {end_dt = }")
332
+
333
+ if not end_dt:
334
+ end_dt = start_dt
335
+
336
+ extent = start_dt != end_dt
337
+
338
+ if start_dt == end_dt and start_dt.hour == 0 and start_dt.minute == 0:
339
+ return ""
340
+
341
+ if ampm:
342
+ start_fmt = "%-I:%M%p" if start_dt.hour < 12 and end_dt.hour >= 12 else "%-I:%M"
343
+ start_hour = start_dt.strftime(f"{start_fmt}").lower().replace(":00", "")
344
+ end_hour = (
345
+ end_dt.strftime("%-I:%M%p").lower().replace(":00", "") # .replace("m", "")
346
+ )
347
+ # log_msg(f"{start_hour = }, {end_hour = }")
348
+ return f"{start_hour}-{end_hour}" if extent else f"{end_hour}"
349
+ else:
350
+ start_hour = start_dt.strftime("%H:%M").replace(":00", "")
351
+ if start_hour.startswith("0"):
352
+ start_hour = start_hour[1:]
353
+ end_hour = end_dt.strftime("%H:%M") # .replace(":00", "")
354
+ if end_hour.startswith("0"):
355
+ end_hour = end_hour[1:]
356
+ # log_msg(f"{start_hour = }, {end_hour = }")
357
+ return f"{start_hour}-{end_hour}" if extent else f"{end_hour}"
358
+
359
+
360
+ def speak_time(time_int: int, mode: Literal["24", "12"]) -> str:
361
+ """Convert time into a spoken phrase for 24-hour or 12-hour format."""
362
+ dt = datetime.fromtimestamp(time_int)
363
+ hour = dt.hour
364
+ minute = dt.minute
365
+
366
+ if mode == "24":
367
+ if minute == 0:
368
+ return f"{hour} hours"
369
+ else:
370
+ return f"{hour} {minute} hours"
371
+ else:
372
+ return dt.strftime("%-I:%M %p").lower().replace(":00", "")
373
+
374
+
375
+ def duration_in_words(seconds: int, short=False):
376
+ """
377
+ Convert a duration in seconds into a human-readable string (weeks, days, hours, minutes).
378
+
379
+ Args:
380
+ seconds (int): Duration in seconds.
381
+ short (bool): If True, return a shortened version (max 2 components).
382
+
383
+ Returns:
384
+ str: Human-readable duration (e.g., "1 week 2 days", "3 hours 27 minutes").
385
+ """
386
+ try:
387
+ # Handle sign for negative durations
388
+ sign = "" if seconds >= 0 else "- "
389
+ total_seconds = abs(int(seconds))
390
+
391
+ # Define time units in seconds
392
+ units = [
393
+ ("week", 604800), # 7 * 24 * 60 * 60
394
+ ("day", 86400), # 24 * 60 * 60
395
+ ("hour", 3600), # 60 * 60
396
+ ("minute", 60), # 60
397
+ ("second", 1), # 1
398
+ ]
399
+
400
+ # Compute time components
401
+ result = []
402
+ for name, unit_seconds in units:
403
+ value, total_seconds = divmod(total_seconds, unit_seconds)
404
+ if value:
405
+ result.append(f"{sign}{value} {name}{'s' if value > 1 else ''}")
406
+
407
+ # Handle case where duration is zero
408
+ if not result:
409
+ return "zero minutes"
410
+
411
+ # Return formatted duration
412
+ return " ".join(result[:2]) if short else " ".join(result)
413
+
414
+ except Exception as e:
415
+ log_msg(f"{seconds = } raised exception: {e}")
416
+ return None
417
+
418
+
419
+ def format_timedelta(seconds: int, short=False):
420
+ """
421
+ Convert a duration in seconds into a human-readable string (weeks, days, hours, minutes).
422
+
423
+ Args:
424
+ seconds (int): Duration in seconds.
425
+ short (bool): If True, return a shortened version (max 2 components).
426
+
427
+ Returns:
428
+ str: Human-readable duration (e.g., "1 week 2 days", "3 hours 27 minutes").
429
+ """
430
+ try:
431
+ # Handle sign for negative durations
432
+ sign = "+" if seconds >= 0 else "-"
433
+ total_seconds = abs(int(seconds))
434
+
435
+ # Define time units in seconds
436
+ units = [
437
+ ("w", 604800), # 7 * 24 * 60 * 60
438
+ ("d", 86400), # 24 * 60 * 60
439
+ ("h", 3600), # 60 * 60
440
+ ("m", 60), # 60
441
+ ("s", 1), # 1
442
+ ]
443
+
444
+ # Compute time components
445
+ result = []
446
+ for name, unit_seconds in units:
447
+ value, total_seconds = divmod(total_seconds, unit_seconds)
448
+ if value:
449
+ result.append(f"{value}{name}")
450
+
451
+ # Handle case where duration is zero
452
+ if not result:
453
+ return "now"
454
+
455
+ # Return formatted duration
456
+ return sign + ("".join(result[:2]) if short else "".join(result))
457
+
458
+ except Exception as e:
459
+ log_msg(f"{seconds = } raised exception: {e}")
460
+ return None
461
+
462
+
463
+ # def format_datetime(
464
+ # seconds: int,
465
+ # mode: Literal["24", "12"] = HRS_MINS,
466
+ # ) -> str:
467
+ # """Return the date and time components of a timestamp using 12 or 24 hour format."""
468
+ # date_time = datetime.fromtimestamp(seconds)
469
+ #
470
+ # date_part = date_time.strftime("%Y-%m-%d")
471
+ #
472
+ # if mode == "24":
473
+ # time_part = date_time.strftime("%H:%Mh").lstrip("0").replace(":00", "")
474
+ # else:
475
+ # time_part = (
476
+ # date_time.strftime("%-I:%M%p").lower().replace(":00", "").rstrip("m")
477
+ # )
478
+ # return date_part, time_part
479
+
480
+
481
+ # def format_datetime(fmt_dt: str, ampm: bool = False) -> str:
482
+ # """
483
+ # Convert a timestamp into a human-readable phrase based on the current time.
484
+ #
485
+ # Args:
486
+ # seconds (int): Timestamp in seconds since the epoch.
487
+ # mode (str): "24" for 24-hour time (e.g., "15 30 hours"), "12" for 12-hour time (e.g., "3 30 p m").
488
+ #
489
+ # Returns:
490
+ # str: Formatted datetime phrase.
491
+ # """
492
+ # dt = datetime.fromtimestamp(seconds)
493
+ # today = date.today()
494
+ # delta_days = (dt.date() - today).days
495
+ #
496
+ # time_str = (
497
+ # dt.strftime("%I:%M%p").lower() if ampm else dt.strftime("%H:%Mh")
498
+ # ).replace(":00", "")
499
+ # if time_str.startswith("0"):
500
+ # time_str = "".join(time_str[1:])
501
+ #
502
+ # # ✅ Case 1: Today → "3 30 p m" or "15 30 hours"
503
+ # if delta_days == 0:
504
+ # return time_str
505
+ #
506
+ # # ✅ Case 2: Within the past/future 6 days → "Monday at 3 30 p m"
507
+ # elif -6 <= delta_days <= 6:
508
+ # day_of_week = dt.strftime("%A")
509
+ # return f"{day_of_week} at {time_str}"
510
+ #
511
+ # # ✅ Case 3: Beyond 6 days → "January 1, 2022 at 3 30 p m"
512
+ # else:
513
+ # date_str = dt.strftime("%B %-d, %Y") # "January 1, 2022"
514
+ # return f"{date_str} at {time_str}"
515
+ #
516
+
517
+ # def datetime_in_words(seconds: int, mode: Literal["24", "12"]) -> str:
518
+ # """Convert a timestamp into a human-readable phrase.
519
+ # If the datetime is today, return the time only, e.g. "3 30 p m" or "15 30 hours".
520
+ # Else if the datetime is within 6 days, return the day of the week and time. e.g. "Monday at 3 30 p m".
521
+ # Else return the full date and time, e.g. "January 1, 2022 at 3 30 p m".
522
+ # """
523
+ #
524
+ # date_time = datetime.fromtimestamp(seconds)
525
+ # date_part = date_time.strftime("%A, %B %d, %Y")
526
+ # time_part = date_time.strftime("%-I:%M %p").lower().replace(":00", "")
527
+ # return f"{date_part} at {time_part}"
528
+ #
529
+
530
+
531
+ def datetime_from_timestamp(fmt_dt: str) -> str:
532
+ if isinstance(fmt_dt, datetime):
533
+ return fmt_dt
534
+ if fmt_dt is None:
535
+ return None
536
+ try:
537
+ if "T" in fmt_dt:
538
+ dt = datetime.strptime(fmt_dt, "%Y%m%dT%H%M")
539
+ # is_date_only = False
540
+ else:
541
+ dt = datetime.strptime(fmt_dt, "%Y%m%d")
542
+ # is_date_only = True
543
+ except ValueError:
544
+ print(f"could not parse {fmt_dt}")
545
+ return None
546
+ return dt
547
+
548
+
549
+ def format_datetime(fmt_dt: str, ampm: bool = False) -> str:
550
+ """
551
+ Convert a compact naive-local datetime string into a human-readable phrase.
552
+
553
+ Args:
554
+ fmt_dt: 'YYYYMMDD' (date) or 'YYYYMMDDTHHMMSS' (naive datetime, local).
555
+ ampm: True -> '3:30pm' / False -> '15h30'.
556
+
557
+ Returns:
558
+ str: Human-readable phrase like 'today', 'Monday at 3pm', or
559
+ 'January 5, 2026 at 15h'.
560
+ """
561
+ # Parse
562
+ if "T" in fmt_dt:
563
+ dt = datetime.strptime(fmt_dt, "%Y%m%dT%H%M")
564
+ is_date_only = False
565
+ else:
566
+ dt = datetime.strptime(fmt_dt, "%Y%m%d")
567
+ is_date_only = True
568
+
569
+ today = date.today()
570
+ delta_days = (dt.date() - today).days
571
+
572
+ # Date-only cases
573
+ if is_date_only:
574
+ if delta_days == 0:
575
+ return "today"
576
+ elif -6 <= delta_days <= 6:
577
+ return dt.strftime("%A")
578
+ else:
579
+ # Note: %-d is POSIX; if you need Windows support, use an alternate path.
580
+ return dt.strftime("%B %-d, %Y")
581
+
582
+ suffix = dt.strftime("%p").lower() if ampm else ""
583
+ hours = dt.strftime("%-I") if ampm else dt.strftime("%H")
584
+ minutes = dt.strftime(":%M") if not ampm or dt.minute else ""
585
+ seconds = dt.strftime(":%S") if dt.second else ""
586
+ time_str = hours + minutes + seconds + suffix
587
+
588
+ # Time string
589
+ # time_str = dt.strftime("%I:%M%p").lower() if ampm else dt.strftime("%H:%M")
590
+ # Drop :00 minutes
591
+ # if time_str.endswith(":00pm") or time_str.endswith(":00am"):
592
+ # if ampm:
593
+ # time_str = time_str.replace(":00", "")
594
+ # # else:
595
+ # # time_str = time_str.replace(":00", "h")
596
+ # # Drop leading zero for 12-hour format
597
+ # if ampm and time_str.startswith("0"):
598
+ # time_str = time_str[1:]
599
+
600
+ # Phrasing
601
+ if delta_days == 0:
602
+ return time_str
603
+ elif -6 <= delta_days <= 6:
604
+ return f"{dt.strftime('%A')} at {time_str}"
605
+ else:
606
+ return f"{dt.strftime('%B %-d, %Y')} at {time_str}"
607
+
608
+
609
+ def datetime_in_words(fmt_dt: str, ampm: bool = False) -> str:
610
+ """
611
+ Convert a compact datetime string into a human-readable phrase based on the current time.
612
+
613
+ Args:
614
+ fmt_dt: 'YYYYMMDD' (date) or 'YYYYMMDDTHHMMSS' (naive datetime, local).
615
+ ampm: True -> '3:30pm' / False -> '15h30'.
616
+
617
+ Returns:
618
+ str: Human-readable phrase like 'today', 'Monday at 3pm', or
619
+ 'January 5, 2026 at 15h'.
620
+ """
621
+ if "T" in fmt_dt:
622
+ dt = datetime.strptime(fmt_dt, "%Y%m%dT%H%M%S")
623
+ is_date_only = False
624
+ else:
625
+ dt = datetime.strptime(fmt_dt, "%Y%m%d")
626
+ is_date_only = True
627
+ today = date.today()
628
+ delta_days = (dt.date() - today).days
629
+
630
+ # ✅ Format time based on mode
631
+ minutes = dt.minute
632
+ minutes_str = (
633
+ "" if minutes == 0 else f" o {minutes}" if minutes < 10 else f" {minutes}"
634
+ )
635
+ hours_str = dt.strftime("%H") if ampm else dt.strftime("%I")
636
+ if hours_str.startswith("0"):
637
+ hours_str = hours_str[1:] # Remove leading zero
638
+ suffix = " hours" if ampm else " a m" if dt.hour < 12 else " p m"
639
+
640
+ time_str = f"{hours_str}{minutes_str}{suffix}"
641
+
642
+ # ✅ Case 1: Today → "3 30 p m" or "15 30 hours"
643
+ if delta_days == 0:
644
+ return time_str
645
+
646
+ # ✅ Case 2: Within the past/future 6 days → "Monday at 3 30 p m"
647
+ elif -6 <= delta_days <= 6:
648
+ day_of_week = dt.strftime("%A")
649
+ return f"{day_of_week} at {time_str}"
650
+
651
+ # ✅ Case 3: Beyond 6 days → "January 1, 2022 at 3 30 p m"
652
+ else:
653
+ date_str = dt.strftime("%B %-d, %Y") # "January 1, 2022"
654
+ return f"{date_str} at {time_str}"
tklr/sounds/alert.mp3 ADDED
Binary file