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