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/__init__.py
ADDED
|
File without changes
|
tklr/cli/main.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import click
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from rich import print
|
|
6
|
+
from typing import Dict, List, Tuple, Optional
|
|
7
|
+
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
# from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from tklr.item import Item
|
|
14
|
+
from tklr.controller import Controller, format_iso_week
|
|
15
|
+
from tklr.model import DatabaseManager
|
|
16
|
+
from tklr.view import DynamicViewApp
|
|
17
|
+
from tklr.tklr_env import TklrEnvironment
|
|
18
|
+
|
|
19
|
+
# from tklr.view_agenda import run_agenda_view
|
|
20
|
+
from tklr.versioning import get_version
|
|
21
|
+
from tklr.shared import format_time_range
|
|
22
|
+
|
|
23
|
+
from datetime import date, datetime, timedelta, time
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _DateParam(click.ParamType):
|
|
27
|
+
name = "date"
|
|
28
|
+
|
|
29
|
+
def convert(self, value, param, ctx):
|
|
30
|
+
if value is None:
|
|
31
|
+
return None
|
|
32
|
+
if isinstance(value, date):
|
|
33
|
+
return value
|
|
34
|
+
s = str(value).strip().lower()
|
|
35
|
+
if s in ("today", "now"):
|
|
36
|
+
return date.today()
|
|
37
|
+
try:
|
|
38
|
+
return datetime.strptime(s, "%Y-%m-%d").date()
|
|
39
|
+
except Exception:
|
|
40
|
+
self.fail("Expected YYYY-MM-DD or 'today'", param, ctx)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _DateOrInt(click.ParamType):
|
|
44
|
+
name = "date|int"
|
|
45
|
+
_date = _DateParam()
|
|
46
|
+
|
|
47
|
+
def convert(self, value, param, ctx):
|
|
48
|
+
if value is None:
|
|
49
|
+
return None
|
|
50
|
+
# try int
|
|
51
|
+
try:
|
|
52
|
+
return int(value)
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
pass
|
|
55
|
+
# try date
|
|
56
|
+
return self._date.convert(value, param, ctx)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_DATE = _DateParam()
|
|
60
|
+
_DATE_OR_INT = _DateOrInt()
|
|
61
|
+
|
|
62
|
+
VERSION = get_version()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def ensure_database(db_path: str, env: TklrEnvironment):
|
|
66
|
+
if not Path(db_path).exists():
|
|
67
|
+
print(
|
|
68
|
+
f"[yellow]⚠️ [/yellow]Database not found. Creating new database at {db_path}"
|
|
69
|
+
)
|
|
70
|
+
dbm = DatabaseManager(db_path, env)
|
|
71
|
+
dbm.setup_database()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def format_tokens(tokens, width=80):
|
|
75
|
+
return " ".join([f"{t['token'].strip()}" for t in tokens])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_raw_from_file(path: str) -> str:
|
|
79
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
80
|
+
return f.read().strip()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_raw_from_editor() -> str:
|
|
84
|
+
result = edit_entry()
|
|
85
|
+
return result or ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_raw_from_stdin() -> str:
|
|
89
|
+
return sys.stdin.read().strip()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@click.group()
|
|
93
|
+
@click.version_option(VERSION, prog_name="tklr", message="%(prog)s version %(version)s")
|
|
94
|
+
@click.option(
|
|
95
|
+
"--home",
|
|
96
|
+
help="Override the Tklr workspace directory (equivalent to setting $TKLR_HOME).",
|
|
97
|
+
)
|
|
98
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
|
|
99
|
+
@click.pass_context
|
|
100
|
+
def cli(ctx, home, verbose):
|
|
101
|
+
"""Tklr CLI – manage your reminders from the command line."""
|
|
102
|
+
if home:
|
|
103
|
+
os.environ["TKLR_HOME"] = (
|
|
104
|
+
home # Must be set before TklrEnvironment is instantiated
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
env = TklrEnvironment()
|
|
108
|
+
env.ensure(init_config=True, init_db_fn=lambda path: ensure_database(path, env))
|
|
109
|
+
config = env.load_config()
|
|
110
|
+
|
|
111
|
+
ctx.ensure_object(dict)
|
|
112
|
+
ctx.obj["ENV"] = env
|
|
113
|
+
ctx.obj["DB"] = env.db_path
|
|
114
|
+
ctx.obj["CONFIG"] = config
|
|
115
|
+
ctx.obj["VERBOSE"] = verbose
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@cli.command()
|
|
119
|
+
@click.argument("entry", nargs=-1)
|
|
120
|
+
@click.option(
|
|
121
|
+
"--file",
|
|
122
|
+
"-f",
|
|
123
|
+
type=click.Path(exists=True),
|
|
124
|
+
help="Path to file with multiple entries.",
|
|
125
|
+
)
|
|
126
|
+
@click.option(
|
|
127
|
+
"--batch",
|
|
128
|
+
is_flag=True,
|
|
129
|
+
help="Use editor to create multiple entries separated by blank lines.",
|
|
130
|
+
)
|
|
131
|
+
@click.pass_context
|
|
132
|
+
def add(ctx, entry, file, batch):
|
|
133
|
+
env = ctx.obj["ENV"]
|
|
134
|
+
db = ctx.obj["DB"]
|
|
135
|
+
verbose = ctx.obj["VERBOSE"]
|
|
136
|
+
bad_items = []
|
|
137
|
+
dbm = DatabaseManager(db, env)
|
|
138
|
+
|
|
139
|
+
def clean_and_split(content: str) -> list[str]:
|
|
140
|
+
"""
|
|
141
|
+
Remove comment-like lines (starting with any '#', regardless of spacing)
|
|
142
|
+
and split into entries separated by '...' lines.
|
|
143
|
+
"""
|
|
144
|
+
lines = []
|
|
145
|
+
for line in content.splitlines():
|
|
146
|
+
stripped = line.lstrip() # remove leading whitespace
|
|
147
|
+
if not stripped.startswith("#"):
|
|
148
|
+
lines.append(line)
|
|
149
|
+
cleaned = "\n".join(lines)
|
|
150
|
+
return split_entries(cleaned)
|
|
151
|
+
|
|
152
|
+
def split_entries(content: str) -> list[str]:
|
|
153
|
+
"""Split raw text into entries using '...' line as separator."""
|
|
154
|
+
return [entry.strip() for entry in content.split("\n...\n") if entry.strip()]
|
|
155
|
+
|
|
156
|
+
def get_entries_from_editor() -> list[str]:
|
|
157
|
+
result = edit_entry()
|
|
158
|
+
if not result:
|
|
159
|
+
return []
|
|
160
|
+
return split_entries(result)
|
|
161
|
+
|
|
162
|
+
def get_entries_from_file(path: str) -> list[str]:
|
|
163
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
164
|
+
content = f.read().strip()
|
|
165
|
+
return split_entries(content)
|
|
166
|
+
|
|
167
|
+
def get_entries_from_stdin() -> list[str]:
|
|
168
|
+
data = sys.stdin.read().strip()
|
|
169
|
+
return split_entries(data)
|
|
170
|
+
|
|
171
|
+
def process_entry(entry_str: str) -> bool:
|
|
172
|
+
exception = False
|
|
173
|
+
msg = None
|
|
174
|
+
try:
|
|
175
|
+
item = Item(raw=entry_str, final=True)
|
|
176
|
+
if not item.parse_ok or not item.itemtype:
|
|
177
|
+
# pm = "\n".join(item.parse_message)
|
|
178
|
+
# tks = "\n".join(item.relative_tokens)
|
|
179
|
+
msg = f"\n[red]✘ Invalid entry[/red] \nentry: {entry_str}\nparse_message: {item.parse_message}\ntokens: {item.relative_tokens}"
|
|
180
|
+
except Exception as e:
|
|
181
|
+
msg = f"\n[red]✘ Internal error during parsing:[/red]\nentry: {entry_str}\nexception: {e}"
|
|
182
|
+
|
|
183
|
+
if msg:
|
|
184
|
+
if verbose:
|
|
185
|
+
print(f"{msg}")
|
|
186
|
+
else:
|
|
187
|
+
bad_items.append(msg)
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
dry_run = False
|
|
191
|
+
if dry_run:
|
|
192
|
+
print(f"[green]would have added:\n {item = }")
|
|
193
|
+
else:
|
|
194
|
+
dbm.add_item(item)
|
|
195
|
+
# print(
|
|
196
|
+
# f"[green]✔ Added:[/green] {item.subject if hasattr(item, 'subject') else entry_str}"
|
|
197
|
+
# )
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
# Determine the source of entries
|
|
201
|
+
if file:
|
|
202
|
+
entries = clean_and_split(get_raw_from_file(file))
|
|
203
|
+
elif batch:
|
|
204
|
+
entries = clean_and_split(get_raw_from_editor())
|
|
205
|
+
elif entry:
|
|
206
|
+
entries = clean_and_split(" ".join(entry).strip())
|
|
207
|
+
elif not sys.stdin.isatty():
|
|
208
|
+
entries = clean_and_split(get_raw_from_stdin())
|
|
209
|
+
else:
|
|
210
|
+
print("[bold yellow]No entry provided.[/bold yellow]")
|
|
211
|
+
if click.confirm("Create one or more entries in your editor?", default=True):
|
|
212
|
+
entries = clean_and_split(get_entries_from_editor())
|
|
213
|
+
else:
|
|
214
|
+
print("[yellow]✘ Cancelled.[/yellow]")
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
if not entries:
|
|
218
|
+
print("[red]✘ No valid entries to add.[/red]")
|
|
219
|
+
sys.exit(1)
|
|
220
|
+
|
|
221
|
+
print(
|
|
222
|
+
f"[blue]➤ Adding {len(entries)} entr{'y' if len(entries) == 1 else 'ies'}[/blue]"
|
|
223
|
+
)
|
|
224
|
+
count = 0
|
|
225
|
+
for e in entries:
|
|
226
|
+
if process_entry(e):
|
|
227
|
+
count += 1
|
|
228
|
+
|
|
229
|
+
dbm.populate_dependent_tables()
|
|
230
|
+
print(
|
|
231
|
+
f"[green]✔ Added {count} entr{'y' if count == 1 else 'ies'} successfully.[/green]"
|
|
232
|
+
)
|
|
233
|
+
if bad_items:
|
|
234
|
+
print("\n\n=== Invalid items ===\n")
|
|
235
|
+
for item in bad_items:
|
|
236
|
+
print(item)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@cli.command()
|
|
240
|
+
@click.pass_context
|
|
241
|
+
def ui(ctx):
|
|
242
|
+
"""Launch the Tklr Textual interface."""
|
|
243
|
+
env = ctx.obj["ENV"]
|
|
244
|
+
db = ctx.obj["DB"]
|
|
245
|
+
verbose = ctx.obj["VERBOSE"]
|
|
246
|
+
|
|
247
|
+
if verbose:
|
|
248
|
+
print(f"[blue]Launching UI with database:[/blue] {db}")
|
|
249
|
+
|
|
250
|
+
controller = Controller(db, env)
|
|
251
|
+
DynamicViewApp(controller).run()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@cli.command()
|
|
255
|
+
@click.argument("entry", nargs=-1)
|
|
256
|
+
@click.pass_context
|
|
257
|
+
def check(ctx, entry):
|
|
258
|
+
"""Check whether an entry is valid (parsing only)."""
|
|
259
|
+
verbose = ctx.obj["VERBOSE"]
|
|
260
|
+
|
|
261
|
+
if not entry and not sys.stdin.isatty():
|
|
262
|
+
entry = sys.stdin.read().strip()
|
|
263
|
+
else:
|
|
264
|
+
entry = " ".join(entry).strip()
|
|
265
|
+
|
|
266
|
+
if not entry:
|
|
267
|
+
print("[bold red]✘ No entry provided. Use argument or pipe.[/bold red]")
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
item = Item(entry)
|
|
272
|
+
if item.parse_ok:
|
|
273
|
+
print("[green]✔ Entry is valid.[/green]")
|
|
274
|
+
if verbose:
|
|
275
|
+
print(f"[blue]Entry:[/blue] {format_tokens(item.relative_tokens)}")
|
|
276
|
+
else:
|
|
277
|
+
print(f"[red]✘ Invalid entry:[/red] {entry!r}")
|
|
278
|
+
print(f" {item.parse_message}")
|
|
279
|
+
if verbose:
|
|
280
|
+
print(f"[blue]Entry:[/blue] {format_tokens(item.relative_tokens)}")
|
|
281
|
+
sys.exit(1)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
print(f"[red]✘ Unexpected error:[/red] {e}")
|
|
284
|
+
sys.exit(1)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@cli.command()
|
|
288
|
+
@click.pass_context
|
|
289
|
+
def agenda(ctx):
|
|
290
|
+
"""Display the current agenda."""
|
|
291
|
+
env = ctx.obj["ENV"]
|
|
292
|
+
db = ctx.obj["DB"]
|
|
293
|
+
verbose = ctx.obj["VERBOSE"]
|
|
294
|
+
|
|
295
|
+
if verbose:
|
|
296
|
+
print(f"[blue]Launching agenda view with database:[/blue] {db}")
|
|
297
|
+
|
|
298
|
+
controller = Controller(db, env)
|
|
299
|
+
run_agenda_view(controller)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _parse_local_text_dt(s: str) -> datetime | date:
|
|
303
|
+
"""
|
|
304
|
+
Parse DateTimes TEXT ('YYYYMMDD' or 'YYYYMMDDTHHMM') into a
|
|
305
|
+
local-naive datetime or date, matching how DateTimes are stored.
|
|
306
|
+
"""
|
|
307
|
+
s = (s or "").strip()
|
|
308
|
+
if not s:
|
|
309
|
+
raise ValueError("empty datetime text")
|
|
310
|
+
|
|
311
|
+
if "T" in s:
|
|
312
|
+
# datetime (local naive)
|
|
313
|
+
return datetime.strptime(s, "%Y%m%dT%H%M")
|
|
314
|
+
else:
|
|
315
|
+
# date-only (all-day)
|
|
316
|
+
return datetime.strptime(s, "%Y%m%d").date()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _format_instance_time(
|
|
320
|
+
start_text: str, end_text: str | None, controller: Controller
|
|
321
|
+
) -> str:
|
|
322
|
+
"""
|
|
323
|
+
Render a human friendly time range from DateTimes TEXT.
|
|
324
|
+
- date-only: returns '' (treated as all-day)
|
|
325
|
+
- datetime: 'HH:MM' or 'HH:MM-HH:MM'
|
|
326
|
+
"""
|
|
327
|
+
start = _parse_local_text_dt(start_text)
|
|
328
|
+
end = _parse_local_text_dt(end_text) if end_text else None
|
|
329
|
+
# get AMPM from config.toml via environment
|
|
330
|
+
AMPM = controller.AMPM
|
|
331
|
+
|
|
332
|
+
# date-only => all-day
|
|
333
|
+
if isinstance(start, date) and not isinstance(start, datetime):
|
|
334
|
+
return ""
|
|
335
|
+
|
|
336
|
+
return format_time_range(start, end, AMPM)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _wrap_or_truncate(text: str, width: int) -> str:
|
|
340
|
+
if len(text) <= width:
|
|
341
|
+
return text
|
|
342
|
+
# leave room for an ellipsis
|
|
343
|
+
return text[: max(0, width - 3)] + "…"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _group_instances_by_date_for_weeks(events) -> Dict[date, List[dict]]:
|
|
347
|
+
"""
|
|
348
|
+
events rows from get_events_for_period:
|
|
349
|
+
(dt_id, start_text, end_text, itemtype, subject, record_id, job_id)
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
{ date -> [ { 'time': time|None,
|
|
353
|
+
'itemtype': str,
|
|
354
|
+
'subject': str,
|
|
355
|
+
'record_id': int,
|
|
356
|
+
'job_id': int|None,
|
|
357
|
+
'start_text': str,
|
|
358
|
+
'end_text': str|None } ] }
|
|
359
|
+
"""
|
|
360
|
+
grouped: Dict[date, List[dict]] = defaultdict(list)
|
|
361
|
+
|
|
362
|
+
for dt_id, start_text, end_text, itemtype, subject, record_id, job_id in events:
|
|
363
|
+
try:
|
|
364
|
+
parsed = _parse_local_text_dt(start_text)
|
|
365
|
+
except Exception:
|
|
366
|
+
continue # skip malformed rows
|
|
367
|
+
|
|
368
|
+
if isinstance(parsed, datetime):
|
|
369
|
+
d = parsed.date()
|
|
370
|
+
t = parsed.time()
|
|
371
|
+
else:
|
|
372
|
+
d = parsed # a date
|
|
373
|
+
t = None
|
|
374
|
+
|
|
375
|
+
grouped[d].append(
|
|
376
|
+
{
|
|
377
|
+
"time": t,
|
|
378
|
+
"itemtype": itemtype,
|
|
379
|
+
"subject": subject or "",
|
|
380
|
+
"record_id": record_id,
|
|
381
|
+
"job_id": job_id,
|
|
382
|
+
"start_text": start_text,
|
|
383
|
+
"end_text": end_text,
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# sort each day by time (all-day items first)
|
|
388
|
+
for d in grouped:
|
|
389
|
+
grouped[d].sort(key=lambda r: (r["time"] is not None, r["time"] or time.min))
|
|
390
|
+
|
|
391
|
+
return dict(grouped)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@cli.command()
|
|
395
|
+
@click.option(
|
|
396
|
+
"--start",
|
|
397
|
+
"start_opt",
|
|
398
|
+
help="Start date (YYYY-MM-DD) or 'today'. Defaults to today.",
|
|
399
|
+
)
|
|
400
|
+
@click.option(
|
|
401
|
+
"--end",
|
|
402
|
+
"end_opt",
|
|
403
|
+
default="4",
|
|
404
|
+
help="Either an end date (YYYY-MM-DD) or a number of weeks (int). Default: 4.",
|
|
405
|
+
)
|
|
406
|
+
@click.option(
|
|
407
|
+
"--width",
|
|
408
|
+
type=click.IntRange(10, 200),
|
|
409
|
+
default=40,
|
|
410
|
+
help="Maximum line width (good for small screens).",
|
|
411
|
+
)
|
|
412
|
+
@click.option(
|
|
413
|
+
"--rich",
|
|
414
|
+
is_flag=True,
|
|
415
|
+
help="Use Rich colors/styling (default output is plain).",
|
|
416
|
+
)
|
|
417
|
+
@click.pass_context
|
|
418
|
+
def weeks(ctx, start_opt, end_opt, width, rich):
|
|
419
|
+
"""
|
|
420
|
+
weeks(start: date = today(), end: date|int = 4, width: int = 40)
|
|
421
|
+
|
|
422
|
+
Examples:
|
|
423
|
+
tklr weeks
|
|
424
|
+
tklr weeks --start 2025-11-01 --end 8
|
|
425
|
+
tklr weeks --end 2025-12-31 --width 60
|
|
426
|
+
tklr weeks --rich
|
|
427
|
+
"""
|
|
428
|
+
env = ctx.obj["ENV"]
|
|
429
|
+
db_path = ctx.obj["DB"]
|
|
430
|
+
|
|
431
|
+
# dbm = DatabaseManager(db_path, env)
|
|
432
|
+
controller = Controller(db_path, env)
|
|
433
|
+
dbm = controller.db_manager
|
|
434
|
+
verbose = ctx.obj["VERBOSE"]
|
|
435
|
+
if verbose:
|
|
436
|
+
print(f"tklr version: {get_version()}")
|
|
437
|
+
print(f"using home directory: {env.get_home()}")
|
|
438
|
+
|
|
439
|
+
# ---- 1) parse start / end into Monday .. Sunday range ----
|
|
440
|
+
if not start_opt or start_opt.lower() == "today":
|
|
441
|
+
start_date = datetime.now().date()
|
|
442
|
+
else:
|
|
443
|
+
start_date = datetime.strptime(start_opt, "%Y-%m-%d").date()
|
|
444
|
+
|
|
445
|
+
start_monday = start_date - timedelta(days=start_date.weekday())
|
|
446
|
+
|
|
447
|
+
# end_opt can be int weeks or a date
|
|
448
|
+
try:
|
|
449
|
+
weeks_int = int(end_opt)
|
|
450
|
+
end_sunday = start_monday + timedelta(weeks=weeks_int, days=6)
|
|
451
|
+
except (ValueError, TypeError):
|
|
452
|
+
end_date = datetime.strptime(str(end_opt), "%Y-%m-%d").date()
|
|
453
|
+
end_sunday = end_date + timedelta(days=(6 - end_date.weekday()) % 7)
|
|
454
|
+
|
|
455
|
+
start_dt = datetime.combine(start_monday, time(0, 0))
|
|
456
|
+
end_dt = datetime.combine(end_sunday, time(23, 59))
|
|
457
|
+
|
|
458
|
+
# ---- 2) fetch instances and group by day ----
|
|
459
|
+
events = dbm.get_events_for_period(start_dt, end_dt)
|
|
460
|
+
by_date = _group_instances_by_date_for_weeks(events)
|
|
461
|
+
|
|
462
|
+
# ---- 3) console: plain by default; markup only if --rich ----
|
|
463
|
+
is_tty = sys.stdout.isatty()
|
|
464
|
+
console = Console(
|
|
465
|
+
force_terminal=rich and is_tty,
|
|
466
|
+
no_color=not rich,
|
|
467
|
+
markup=rich, # still allow [bold] etc when --rich
|
|
468
|
+
highlight=False, # 👈 disable auto syntax highlighting
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
today = datetime.now().date()
|
|
472
|
+
week_start = start_monday
|
|
473
|
+
|
|
474
|
+
first_week = True
|
|
475
|
+
while week_start <= end_sunday:
|
|
476
|
+
week_end = week_start + timedelta(days=6)
|
|
477
|
+
iso_year, iso_week, _ = week_start.isocalendar()
|
|
478
|
+
|
|
479
|
+
if not first_week:
|
|
480
|
+
console.print()
|
|
481
|
+
first_week = False
|
|
482
|
+
|
|
483
|
+
# week_label = format_iso_week(datetime.combine(week_start, time(0, 0)))
|
|
484
|
+
#
|
|
485
|
+
# if rich:
|
|
486
|
+
# console.print(f"[not bold]{week_label}[/not bold]")
|
|
487
|
+
# else:
|
|
488
|
+
# console.print(week_label)
|
|
489
|
+
week_label = format_iso_week(datetime.combine(week_start, time(0, 0)))
|
|
490
|
+
|
|
491
|
+
if rich:
|
|
492
|
+
console.print(f"[bold deep_sky_blue1]{week_label}[/bold deep_sky_blue1]")
|
|
493
|
+
else:
|
|
494
|
+
console.print(week_label)
|
|
495
|
+
# Days within this week
|
|
496
|
+
for i in range(7):
|
|
497
|
+
d = week_start + timedelta(days=i)
|
|
498
|
+
day_events = by_date.get(d, [])
|
|
499
|
+
if not day_events:
|
|
500
|
+
continue # skip empty days
|
|
501
|
+
|
|
502
|
+
# Day header
|
|
503
|
+
flag = " (today)" if d == today else ""
|
|
504
|
+
day_header = f" {d:%a, %b %-d}{flag}"
|
|
505
|
+
console.print(day_header)
|
|
506
|
+
|
|
507
|
+
# Day rows, max width
|
|
508
|
+
for row in day_events:
|
|
509
|
+
t = row["time"]
|
|
510
|
+
itemtype = row["itemtype"]
|
|
511
|
+
subject = row["subject"]
|
|
512
|
+
|
|
513
|
+
time_str = ""
|
|
514
|
+
if row["start_text"]:
|
|
515
|
+
time_str = _format_instance_time(
|
|
516
|
+
row["start_text"], row["end_text"], controller
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
if time_str:
|
|
520
|
+
base = f" {itemtype} {time_str} {subject}"
|
|
521
|
+
else:
|
|
522
|
+
base = f" {itemtype} {subject}"
|
|
523
|
+
|
|
524
|
+
console.print(_wrap_or_truncate(base, width))
|
|
525
|
+
|
|
526
|
+
# console.print() # blank line between days
|
|
527
|
+
|
|
528
|
+
week_start += timedelta(weeks=1)
|