pyworklog 0.3.0__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.
@@ -0,0 +1,579 @@
1
+ """worklog commands: meta group."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import re
8
+ import sqlite3
9
+ import sys
10
+ from datetime import datetime, timedelta
11
+ from pathlib import Path
12
+
13
+ from .. import render
14
+ from ..helpers import (
15
+ _apply_top_limit,
16
+ _fmt_dur,
17
+ _is_brief,
18
+ _log_full,
19
+ _norm_sched,
20
+ _resolve_at_ts,
21
+ _resolve_concrete_date,
22
+ _resolve_log_tail,
23
+ _resolve_window,
24
+ _sched_anchor,
25
+ _sched_display,
26
+ _sched_kind,
27
+ _sched_sort_key,
28
+ _status_marker,
29
+ _term_width,
30
+ _truncate_log_body,
31
+ GENERIC_TAGS,
32
+ )
33
+ from ..queries import (
34
+ _ancestors_chain,
35
+ _check_ids_exist,
36
+ _collect_descendants,
37
+ _has_tag,
38
+ _insert_log,
39
+ _node_bucket,
40
+ _node_clock_min,
41
+ _node_exists,
42
+ _node_plan,
43
+ _node_project,
44
+ _node_tags,
45
+ _project_members,
46
+ _sec_group,
47
+ _status_filter_sql,
48
+ _upsert_prop,
49
+ )
50
+ from ..render import (
51
+ _PRI_STYLE,
52
+ _STATUS_STYLE,
53
+ _RICH_AVAIL,
54
+ _resolve_theme,
55
+ THEMES,
56
+ _c,
57
+ _hl,
58
+ _node_line,
59
+ _print_truncation_hint,
60
+ _snippet,
61
+ out,
62
+ )
63
+ from ..xdg import _resolve_db_path, _resolve_aliases_path, _xdg_data_home, _xdg_config_home
64
+
65
+ # Lazy access to cli module (for DB wrappers + module state).
66
+ # Used at function call time (not at import) to avoid the cli ↔ commands
67
+ # import cycle.
68
+ from .. import cli as _cli # noqa: E402
69
+
70
+
71
+ from .views import _cn_weekday, _date_label, _scheduled_node_ids
72
+
73
+ from .state import _ids_list
74
+ from .views import _WEEKDAY_ABBR, _cn_weekday, _date_label, _scheduled_node_ids
75
+
76
+ def cmd_init(args, con):
77
+ _cli.db_init(con)
78
+ print(f"✓ DB initialized: {_resolve_db_path(args)}")
79
+
80
+ def cmd_config(args, con):
81
+ """Print resolved configuration: where the DB and config files live + env."""
82
+ db = _resolve_db_path(args)
83
+ if getattr(args, "db", None):
84
+ db_src = "--db flag"
85
+ elif os.environ.get("WORKLOG_DB"):
86
+ db_src = "$WORKLOG_DB"
87
+ else:
88
+ db_src = "XDG default"
89
+ db_exists = db.exists()
90
+ db_size = f"{db.stat().st_size:,} bytes" if db_exists else "missing — run `wl init`"
91
+
92
+ aliases = _resolve_aliases_path()
93
+
94
+ def _row(label, value, hint=""):
95
+ hint_part = " " + _c(hint, "meta") if hint else ""
96
+ out(f" {label:<18} {value}{hint_part}")
97
+
98
+ out(_c(f"worklog {_cli.__version__}", "header"))
99
+ out("")
100
+ out(_c("paths:", "header"))
101
+ _row("database", db, f"[{db_src}] {db_size}")
102
+ _row("aliases", aliases, "(exists)" if aliases.exists() else "(not configured)")
103
+ out("")
104
+ out(_c("XDG directories:", "header"))
105
+ _row("XDG_DATA_HOME", _xdg_data_home(), "(env set)" if os.environ.get("XDG_DATA_HOME") else "(default)")
106
+ _row("XDG_CONFIG_HOME", _xdg_config_home(), "(env set)" if os.environ.get("XDG_CONFIG_HOME") else "(default)")
107
+ out("")
108
+ out(_c("environment:", "header"))
109
+ for var in ("WORKLOG_DB", "WORKLOG_COLOR", "WORKLOG_THEME", "NO_COLOR"):
110
+ val = os.environ.get(var)
111
+ _row(var, val if val else _c("(not set)", "meta"))
112
+ out("")
113
+ out(_c("runtime:", "header"))
114
+ _row("python", sys.executable, f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
115
+ _row("rich", "available" if render._RICH_AVAIL else "not installed (plain-text mode)")
116
+
117
+ def cmd_migrate(args, con):
118
+ """List + apply pending SQL migrations (`migrations/NNNN_*.sql`).
119
+
120
+ Idempotent: re-running after everything is applied prints "up to date".
121
+ Failure mid-sequence rolls back the offending migration and leaves the DB
122
+ at the last successfully-applied number — re-run after fixing.
123
+ """
124
+ files = _cli._migration_files()
125
+ current = _cli._db_version(con)
126
+ pending = [p for p in files if int(p.stem.split("_", 1)[0]) > current]
127
+ if not pending:
128
+ out(_c(f"✓ DB at version {current}, no pending migrations ({len(files)} total).", "done"))
129
+ return
130
+ out(_c(f"applying {len(pending)} migration(s) (DB at version {current}):", "header"))
131
+ applied = _cli._run_migrations(con, verbose=True)
132
+ new_version = _cli._db_version(con)
133
+ out(_c(f"✓ DB now at version {new_version} ({len(applied)} migration(s) applied).", "done"))
134
+
135
+
136
+ # ─── command handlers ───
137
+
138
+ def cmd_themes(args, con):
139
+ """List all color themes, each rendering a one-line sample in its own palette for comparison."""
140
+ req = args.theme or os.environ.get("WORKLOG_THEME") or "auto"
141
+ cur = _resolve_theme(req) # resolve auto to a real theme
142
+ auto_note = f" (auto -> {cur})" if req in (None, "auto") else ""
143
+ no_color = args.color == "never" or os.environ.get("NO_COLOR")
144
+ if not render._RICH_AVAIL or no_color:
145
+ # no rich or color explicitly off: plain text listing
146
+ for name in THEMES:
147
+ mark = " <- current" if name == cur else ""
148
+ print(f"■ {name}{mark}")
149
+ print(f"current: {req}{auto_note}")
150
+ if not render._RICH_AVAIL:
151
+ print("(rich not installed; no color preview; pip install rich)")
152
+ return
153
+ # render the sample with each theme's own palette (force_terminal: keeps colors when piped to less -R)
154
+ for name in THEMES:
155
+ prev = render._RichConsole(theme=render._RichTheme(THEMES[name]), force_terminal=True, highlight=False, soft_wrap=True)
156
+ mark = f" [done]<- current {auto_note}[/done]" if name == cur else ""
157
+ prev.print(f"[header]■ {name}[/header]{mark}")
158
+ prev.print(" [done]\\[x][/done] [pri_a]\\[#A][/pri_a] [id]#42[/id] [kind]\\[project][/kind] "
159
+ "sample task with [hit]match[/hit] [planned]·planned[/planned] [clock]⏱30min[/clock] [tag]:work:[/tag]")
160
+ prev.print(" [doing]\\[/][/doing] [pri_b]\\[#B][/pri_b] [id]#43[/id] doing sample "
161
+ "[later]\\[>][/later] [pri_c]\\[#C][/pri_c] [id]#44[/id] later sample [meta]«meta»[/meta]")
162
+ prev.print()
163
+
164
+
165
+ # --- helpers ---
166
+
167
+
168
+ # --- argparse ---
169
+
170
+ def cmd_dateinfo(args, con):
171
+ """Date metadata: holiday / vacation / working-day-swap label. Set one / batch-import a yearly holiday table / list."""
172
+ if args.import_file:
173
+ import json
174
+
175
+ raw = sys.stdin.read() if args.import_file == "-" else Path(args.import_file).read_text(encoding="utf-8")
176
+ data = json.loads(raw) # {"2026-05-01": "Labor Day", ...}
177
+ n = 0
178
+ for d, label in data.items():
179
+ con.execute("INSERT OR REPLACE INTO date_meta (date, label) VALUES (?, ?)", (d, label))
180
+ n += 1
181
+ con.commit()
182
+ out(_c(f"✓ imported {n} date metadata entries", "meta"))
183
+ return
184
+ if args.date and args.label:
185
+ con.execute("INSERT OR REPLACE INTO date_meta (date, label) VALUES (?, ?)", (args.date, args.label))
186
+ con.commit()
187
+ out(_c(f"✓ {args.date} {_cn_weekday(args.date)} · {args.label}", "meta"))
188
+ return
189
+ if args.date and args.clear:
190
+ con.execute("DELETE FROM date_meta WHERE date = ?", (args.date,))
191
+ con.commit()
192
+ out(_c(f"✓ cleared metadata for {args.date}", "meta"))
193
+ return
194
+ # no args / only date: list
195
+ if args.date:
196
+ lbl = _date_label(con, args.date)
197
+ out(_c(f"{args.date} {_cn_weekday(args.date)}" + (f" · {lbl}" if lbl else " (no label)"), "meta"))
198
+ else:
199
+ for r in con.execute("SELECT date, label FROM date_meta ORDER BY date"):
200
+ out(_c(f"{r['date']} {_cn_weekday(r['date'])} · {r['label']}", "meta"))
201
+
202
+ def cmd_goal(args, con):
203
+ """Shortcut to read/write today's goal: `wl goal` reads; `wl goal 'text'` writes. Today's day-node is auto-created if missing."""
204
+ nid = _ensure_today_day(con)
205
+ if not args.text:
206
+ v = _get_prop(con, nid, "goal")
207
+ out(v if v else _c("(no goal set for today)", "meta"))
208
+ return
209
+ _set_prop(con, nid, "goal", args.text)
210
+ out(_c(f"✓ today's goal: {args.text}", "meta"))
211
+
212
+ def cmd_summary_prop(args, con):
213
+ """Shortcut to read/write today's end-of-day recap. On write, stamps summary_at (YYYY-MM-DD HH:MM:SS),
214
+ so we can later detect changes added after the recap (wl day shows a hint to rewrite)."""
215
+ nid = _ensure_today_day(con)
216
+ if not args.text:
217
+ v = _get_prop(con, nid, "summary")
218
+ if not v:
219
+ out(_c("(no summary set for today)", "meta"))
220
+ return
221
+ at = _get_prop(con, nid, "summary_at")
222
+ out(v + (_c(f" (written at {at})", "meta") if at else ""))
223
+ return
224
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
225
+ _upsert_prop(con, nid, "summary", args.text)
226
+ _upsert_prop(con, nid, "summary_at", now)
227
+ con.commit()
228
+ out(_c(f"✓ today's summary (written at {now}): {args.text}", "meta"))
229
+
230
+ def cmd_checkin(args, con):
231
+ """Interactive check-in for today's habits.
232
+ Default: multi-select (up/down + space + Enter), pick all at once and check in.
233
+ --per-item: per-item prompt mode (allows per-item note; also the fallback for non-TTY / piped input)."""
234
+ import sys
235
+
236
+ rows, today, kinds = _checkin_collect(con, args)
237
+ if not rows:
238
+ out(_c(f"(no {'/'.join(kinds)} scheduled to check in for {today})", "meta"))
239
+ return
240
+
241
+ pending = [r for r in rows if not r["already"]]
242
+ pre_done = len(rows) - len(pending)
243
+
244
+ if not pending:
245
+ out(_c(f"all {len(rows)}/{len(rows)} already checked in for {today} ✓", "done"))
246
+ return
247
+
248
+ if getattr(args, "per_item", False) or not _is_interactive_tty():
249
+ _checkin_per_item(con, rows)
250
+ return
251
+
252
+ header = _c(f"{today} · pick habits done today (already checked in {pre_done}/{len(rows)})", "header")
253
+ # default unselected for all (use space to toggle on what you did); intuitive: 'mark what I did' not 'unmark what I missed'
254
+ options = [(f"#{r['id']} {r['title']}", False) for r in pending]
255
+ chosen = _multi_select_tty(options, header)
256
+ if chosen is None:
257
+ out(_c("(canceled, no changes made)", "meta"))
258
+ return
259
+
260
+ for i in chosen:
261
+ _insert_log(con, pending[i]["id"], "✓ done")
262
+ con.commit()
263
+ done_now = len(chosen)
264
+ skipped = len(pending) - done_now
265
+ out(_c(
266
+ f"done {pre_done + done_now}/{len(rows)} · new this run {done_now}" +
267
+ (f" · skipped {skipped}" if skipped else "") +
268
+ " · for detailed notes use `wl tick <id> --note ...` or `wl checkin --per-item`",
269
+ "header"))
270
+
271
+ def cmd_sched(args, con):
272
+ """Forward planning: schedule a task to a specific day / recurrence. A scheduled task appears as 'planned' in wl day even with no log.
273
+ Accepts multiple ids: wl sched 18 19 20 today (first N are ids; the trailing positional is the date)."""
274
+ ids = _ids_list(args)
275
+ _check_ids_exist(con, ids)
276
+ if args.clear:
277
+ for nid in ids:
278
+ cur = con.execute("DELETE FROM sched WHERE node_id = ?", (nid,))
279
+ out(_c(f"✓ #{nid} cleared {cur.rowcount} schedule entries", "meta"))
280
+ con.commit()
281
+ return
282
+ if not args.when and not args.recur:
283
+ # if multiple ids, show schedule for each (caters to single-id scenario)
284
+ for nid in ids:
285
+ rows = con.execute(
286
+ "SELECT on_date, rrule FROM sched WHERE node_id = ? ORDER BY on_date NULLS LAST, rrule", (nid,)
287
+ ).fetchall()
288
+ if not rows:
289
+ out(_c(f"#{nid} has no schedule", "meta"))
290
+ for r in rows:
291
+ out(" " + _c(f"#{nid} @" + (r["on_date"] or r["rrule"]), "planned"))
292
+ return
293
+ if args.recur:
294
+ try:
295
+ rule = _norm_rrule(args.recur)
296
+ except ValueError as e:
297
+ sys.exit(f"✗ {e}")
298
+ for nid in ids:
299
+ con.execute("INSERT INTO sched (node_id, rrule) VALUES (?, ?)", (nid, rule))
300
+ con.commit()
301
+ for nid in ids:
302
+ out(_c(f"✓ #{nid} recurring schedule: {rule}", "meta"))
303
+ if args.when:
304
+ try:
305
+ d = _resolve_concrete_date(args.when)
306
+ except ValueError:
307
+ sys.exit(f"✗ invalid date '{args.when}' (use YYYY-MM-DD / today / yesterday / tomorrow / day-after-tomorrow)")
308
+ for nid in ids:
309
+ con.execute("INSERT INTO sched (node_id, on_date) VALUES (?, ?)", (nid, d))
310
+ con.commit()
311
+ for nid in ids:
312
+ out(_c(f"✓ #{nid} scheduled to {d}", "meta"))
313
+
314
+ def _set_prop(con, nid, key, value):
315
+ _upsert_prop(con, nid, key, value)
316
+ con.commit()
317
+
318
+ def _get_prop(con, nid, key):
319
+ r = con.execute("SELECT value FROM prop WHERE node_id = ? AND key = ?", (nid, key)).fetchone()
320
+ return r["value"] if r else None
321
+
322
+ def _ensure_today_day(con):
323
+ """Return today's day-node id; create one if missing (attach to current ISO week; unparented if week absent)."""
324
+ from datetime import date
325
+
326
+ today = date.today().isoformat()
327
+ r = con.execute(
328
+ "SELECT id FROM node WHERE kind='day' AND title LIKE ? ORDER BY id LIMIT 1", (today + "%",)
329
+ ).fetchone()
330
+ if r:
331
+ return r["id"]
332
+ iso = date.today().isocalendar()
333
+ week_title = f"{iso[0]}-W{iso[1]:02d}"
334
+ w = con.execute("SELECT id FROM node WHERE kind='week' AND title = ? LIMIT 1", (week_title,)).fetchone()
335
+ cur = con.execute(
336
+ "INSERT INTO node (parent_id, title, kind) VALUES (?, ?, 'day')",
337
+ (w["id"] if w else None, today),
338
+ )
339
+ con.commit()
340
+ return cur.lastrowid
341
+
342
+ def _checkin_collect(con, args):
343
+ """Collect today's habits to check in. Returns [{id, title, priority, kind, already}]."""
344
+ from datetime import date as _d
345
+
346
+ today = _d.today().isoformat()
347
+ sched_ids = _scheduled_node_ids(con, today)
348
+ kinds = {args.kind} if args.kind else {"habit"}
349
+ if args.all_kinds:
350
+ kinds = {"habit", "task", "meetlog"}
351
+
352
+ rows = []
353
+ for nid in sorted(sched_ids):
354
+ n = con.execute("SELECT * FROM node WHERE id = ?", (nid,)).fetchone()
355
+ if not n or n["kind"] not in kinds:
356
+ continue
357
+ if n["status"] == "CANCELED" and not getattr(args, "show_canceled", False):
358
+ continue
359
+ already = con.execute(
360
+ "SELECT 1 FROM log WHERE node_id = ? AND date(logged_at) = ? "
361
+ "AND body NOT LIKE 'CLOCK\\_%' ESCAPE '\\' LIMIT 1",
362
+ (nid, today),
363
+ ).fetchone()
364
+ rows.append({
365
+ "id": n["id"], "title": n["title"], "priority": n["priority"],
366
+ "kind": n["kind"], "already": bool(already),
367
+ })
368
+ return rows, today, kinds
369
+
370
+ def _is_interactive_tty():
371
+ """Whether we can run a raw-mode TUI: both stdin and stdout are TTYs. Used by tests via monkeypatch."""
372
+ import sys
373
+ return sys.stdin.isatty() and sys.stdout.isatty()
374
+
375
+ def _multi_select_tty(options, header): # pragma: no cover -- TTY interactive, needs termios+os.read, manual smoke only
376
+ """Terminal multi-select widget (rich.Live render, no misalignment): up/down moves cursor; space toggles; Enter confirms; q/Esc cancels.
377
+ options: [(label, default_selected)]
378
+ Returns: list of selected indices, or None (canceled).
379
+ Requires rich available + both stdin/stdout are TTYs; otherwise returns None so caller can fall back."""
380
+ import sys
381
+ if not render._RICH_AVAIL or not _is_interactive_tty():
382
+ return None
383
+ import os, termios, tty, select
384
+ from rich.console import Console as _LiveConsole
385
+ from rich.live import Live
386
+ from rich.text import Text
387
+
388
+ selected = [d for _, d in options]
389
+ cursor = 0
390
+ n = len(options)
391
+
392
+ def make_view():
393
+ # header may contain [style]..[/style] markup; from_markup parses; rich output handles \r\n
394
+ t = Text.from_markup(header)
395
+ t.append("\n")
396
+ t.append("(up/down or j/k to move · space to toggle · Enter to confirm · q/Esc to cancel)\n\n",
397
+ style="dim")
398
+ for i, (label, _) in enumerate(options):
399
+ mark = "[x] " if selected[i] else "[ ] "
400
+ pointer = "▸ " if i == cursor else " "
401
+ line = f" {pointer}{mark}{label}\n"
402
+ t.append(line, style="bold reverse" if i == cursor else None)
403
+ return t
404
+
405
+ fd = sys.stdin.fileno()
406
+ old = termios.tcgetattr(fd)
407
+ canceled = False
408
+ # use a separate Console to avoid collision with wl's global render._CONSOLE theme/highlight
409
+ live_console = _LiveConsole(file=sys.stderr, force_terminal=True)
410
+ try:
411
+ # cbreak (not raw): disable echo + line buffer but keep ONLCR (\n auto-adds \r);
412
+ # otherwise rich's \n won't return to col 0 and each line drifts right
413
+ tty.setcbreak(fd)
414
+ # important: use os.read(fd, ...) to bypass Python's sys.stdin buffer.
415
+ # sys.stdin.read(1) would swallow the entire ESC[A 3-byte sequence; select would then
416
+ # see no more data and misinterpret ESC as a single keypress -> exit (root cause of prior bug)
417
+ def read_byte():
418
+ return os.read(fd, 1).decode("utf-8", errors="replace")
419
+
420
+ def peek_more(timeout):
421
+ # check whether fd has more bytes ready (terminals emit ESC[A as 3 bytes nearly instantly)
422
+ return bool(select.select([fd], [], [], timeout)[0])
423
+
424
+ with Live(make_view(), console=live_console, refresh_per_second=30,
425
+ screen=False, transient=True) as live:
426
+ while True:
427
+ ch = read_byte()
428
+ if ch == "\x1b": # ESC or arrow sequence
429
+ if peek_more(0.05): # more bytes already there = escape sequence
430
+ seq = os.read(fd, 2).decode("utf-8", errors="replace")
431
+ if seq == "[A":
432
+ cursor = (cursor - 1) % n
433
+ elif seq == "[B":
434
+ cursor = (cursor + 1) % n
435
+ # other arrows / Home / End: ignore
436
+ else:
437
+ canceled = True
438
+ break
439
+ elif ch == " ":
440
+ selected[cursor] = not selected[cursor]
441
+ elif ch in ("\r", "\n"):
442
+ break
443
+ elif ch in ("q", "Q", "\x03", "\x04"): # q / Ctrl-C / Ctrl-D
444
+ canceled = True
445
+ break
446
+ elif ch in ("j", "J"):
447
+ cursor = (cursor + 1) % n
448
+ elif ch in ("k", "K"):
449
+ cursor = (cursor - 1) % n
450
+ live.update(make_view())
451
+ finally:
452
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
453
+ if canceled:
454
+ return None
455
+ return [i for i, s in enumerate(selected) if s]
456
+
457
+ def _checkin_per_item(con, rows):
458
+ """Per-item prompt fallback mode: y/n/note/q (works on non-TTY / piped input; also supports per-item note)."""
459
+ pre_done = sum(1 for r in rows if r["already"])
460
+ out(_c(f"{len(rows)} items to check in, {pre_done} already done:", "header"))
461
+ out(_c("Input: [Enter]/y = check in · n = skip · q = quit · any other text = check in with that as note", "meta"))
462
+ print()
463
+
464
+ done_now = skipped = 0
465
+ for r in rows:
466
+ nid = r["id"]
467
+ pri = f"[#{r['priority']}]" if r["priority"] else ""
468
+ head = f"#{nid} {pri} {r['title']}".strip()
469
+ if r["already"]:
470
+ out(_c(f" ✓ {head} (already done today)", "done"))
471
+ continue
472
+ try:
473
+ ans = input(_c(f" ▸ {head}\n [y/n/note/q] > ", "header")).strip()
474
+ except (EOFError, KeyboardInterrupt):
475
+ print()
476
+ out(_c("(interrupted; remaining tasks skipped)", "meta"))
477
+ break
478
+ if ans in ("q", "Q", "exit", "quit"):
479
+ out(_c("(quit)", "meta"))
480
+ break
481
+ if ans in ("n", "N", "no", "skip"):
482
+ skipped += 1
483
+ out(_c(f" ⏭ #{nid} skipped", "meta"))
484
+ continue
485
+ body = "✓ done" if ans in ("", "y", "Y", "yes") else ans
486
+ _insert_log(con, nid, body)
487
+ con.commit()
488
+ done_now += 1
489
+ marker = _c(" ✓", "done")
490
+ if body == "✓ done":
491
+ out(f"{marker} #{nid} checked in")
492
+ else:
493
+ out(f"{marker} #{nid} checked in: {_c(body, 'meta')}")
494
+ print()
495
+ out(_c(
496
+ f"done {pre_done + done_now}/{len(rows)} · new this run {done_now}" +
497
+ (f" · skipped {skipped}" if skipped else ""),
498
+ "header"))
499
+
500
+ def _norm_rrule(s):
501
+ """Validate / normalize a recurrence rule:
502
+ - daily
503
+ - weekly:Mon,Wed,Fri | 1-7 | -1..-7 (1=Mon..7=Sun, -1=Sun..-7=Mon)
504
+ - monthly:5 / 5,15 / -1 (last day of month)
505
+ - quarterly:M-D / -1 (last day of quarter): M in 1-3 = month offset within the quarter
506
+ - yearly:03-21 / -1 (last day of year): MM-DD
507
+ """
508
+ import re as _re
509
+ rule = s.strip()
510
+ if rule == "daily":
511
+ return "daily"
512
+ if rule.startswith("weekly:"):
513
+ raw = [x.strip() for x in rule[len("weekly:"):].split(",") if x.strip()]
514
+ if not raw:
515
+ raise ValueError("weekly rule needs at least 1 weekday (Mon/Tue/.../Sun or 1-7 / -1..-7)")
516
+ norm_days = []
517
+ for tok in raw:
518
+ cap = tok.capitalize()
519
+ if cap in _WEEKDAY_ABBR:
520
+ norm_days.append(cap)
521
+ continue
522
+ try:
523
+ n = int(tok)
524
+ except ValueError:
525
+ raise ValueError(f"invalid weekly day '{tok}' (use Mon..Sun or 1-7 / -1..-7)")
526
+ if n > 0 and 1 <= n <= 7:
527
+ norm_days.append(_WEEKDAY_ABBR[n - 1])
528
+ elif n < 0 and -7 <= n <= -1:
529
+ norm_days.append(_WEEKDAY_ABBR[7 + n]) # -1 -> 6 (Sun), -7 -> 0 (Mon)
530
+ else:
531
+ raise ValueError(f"weekly number '{n}' out of range (allowed 1-7 or -1..-7)")
532
+ # dedup preserve order
533
+ seen = set()
534
+ deduped = [d for d in norm_days if not (d in seen or seen.add(d))]
535
+ return "weekly:" + ",".join(deduped)
536
+ if rule.startswith("monthly:"):
537
+ tokens = [x.strip() for x in rule[len("monthly:"):].split(",") if x.strip()]
538
+ if not tokens:
539
+ raise ValueError("monthly rule needs at least 1 day (e.g. monthly:5 / monthly:1,15 / monthly:-1)")
540
+ norm = []
541
+ for tok in tokens:
542
+ try:
543
+ n = int(tok)
544
+ except ValueError:
545
+ raise ValueError(f"monthly day must be an integer: '{tok}' (positive 1-31 / negative -1..-28 from month-end)")
546
+ if n == 0 or not (-28 <= n <= 31):
547
+ raise ValueError(f"monthly day '{n}' out of range (allowed 1-31 or -1..-28)")
548
+ norm.append(str(n))
549
+ return "monthly:" + ",".join(norm)
550
+ if rule.startswith("quarterly:"):
551
+ tokens = [x.strip() for x in rule[len("quarterly:"):].split(",") if x.strip()]
552
+ if not tokens:
553
+ raise ValueError("quarterly rule needs at least 1 M-D or -1 (e.g. quarterly:1-15 / quarterly:-1)")
554
+ for tok in tokens:
555
+ if tok == "-1":
556
+ continue
557
+ if not _re.fullmatch(r"\d{1,2}-\d{1,2}", tok):
558
+ raise ValueError(f"invalid quarterly '{tok}' (expected M-D, M in 1-3 month-in-quarter; or -1 quarter end)")
559
+ mm, dd = (int(x) for x in tok.split("-"))
560
+ if not (1 <= mm <= 3):
561
+ raise ValueError(f"quarterly '{tok}' month offset out of range (1=Q-start / 2=mid / 3=Q-end)")
562
+ if not (1 <= dd <= 31):
563
+ raise ValueError(f"quarterly '{tok}' day out of range (1-31)")
564
+ return "quarterly:" + ",".join(tokens)
565
+ if rule.startswith("yearly:"):
566
+ tokens = [x.strip() for x in rule[len("yearly:"):].split(",") if x.strip()]
567
+ if not tokens:
568
+ raise ValueError("yearly rule needs at least 1 MM-DD or -1 (e.g. yearly:03-21 / yearly:-1)")
569
+ for tok in tokens:
570
+ if tok == "-1":
571
+ continue
572
+ if not _re.fullmatch(r"\d{2}-\d{2}", tok):
573
+ raise ValueError(f"invalid yearly '{tok}' (expected MM-DD like 03-21; or -1 year end)")
574
+ mm, dd = int(tok[:2]), int(tok[3:])
575
+ if not (1 <= mm <= 12 and 1 <= dd <= 31):
576
+ raise ValueError(f"yearly '{tok}' out of range (month 1-12 / day 1-31)")
577
+ return "yearly:" + ",".join(tokens)
578
+ raise ValueError(f"unknown recurrence rule '{s}' (supports daily / weekly / monthly / quarterly / yearly, each accepting -1 = end of cycle)")
579
+