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/item.py ADDED
@@ -0,0 +1,3765 @@
1
+ import re
2
+ from copy import deepcopy
3
+ import shutil
4
+ import json
5
+
6
+ # from dateutil.parser import parse as duparse
7
+ from dateutil.rrule import rruleset, rrulestr
8
+ from datetime import date, datetime, timedelta
9
+ from datetime import tzinfo
10
+
11
+ # from dateutil.tz import gettz
12
+ # import pytz
13
+ import textwrap
14
+ from dateutil import tz
15
+ from dateutil.tz import gettz
16
+
17
+ # from collections import defaultdict
18
+ from math import ceil
19
+
20
+ from typing import Iterable, List
21
+
22
+ from typing import Union, Optional, Tuple
23
+ from zoneinfo import ZoneInfo
24
+
25
+ # item.py
26
+ from dataclasses import dataclass
27
+ from dateutil.parser import parse as parse_dt
28
+
29
+ # from tklr.model import dt_to_dtstr
30
+
31
+ from .shared import (
32
+ log_msg,
33
+ print_msg,
34
+ fmt_local_compact,
35
+ parse_local_compact,
36
+ fmt_utc_z,
37
+ parse_utc_z,
38
+ timedelta_str_to_seconds,
39
+ )
40
+ from tzlocal import get_localzone_name
41
+
42
+ local_timezone = get_localzone_name() # e.g., "America/New_York"
43
+
44
+ JOB_PATTERN = re.compile(r"^@~ ( *)([^&]*)(?:(&.*))?")
45
+ LETTER_SET = set("abcdefghijklmnopqrstuvwxyz") # Define once
46
+
47
+
48
+ def is_date(obj):
49
+ if isinstance(obj, date) and not isinstance(obj, datetime):
50
+ return True
51
+ return False
52
+
53
+
54
+ def is_datetime(obj):
55
+ if isinstance(obj, date) and isinstance(obj, datetime):
56
+ return True
57
+ return False
58
+
59
+
60
+ def _is_date_only(obj) -> bool:
61
+ return isinstance(obj, date) and not isinstance(obj, datetime)
62
+
63
+
64
+ def _is_datetime(obj) -> bool:
65
+ return isinstance(obj, datetime)
66
+
67
+
68
+ # --- serialization you already use elsewhere (kept explicit here) ---
69
+ def _fmt_date(d: date) -> str:
70
+ return d.strftime("%Y%m%d")
71
+
72
+
73
+ def _fmt_naive(dt: datetime) -> str:
74
+ # no timezone, naive
75
+ return dt.strftime("%Y%m%dT%H%M")
76
+
77
+
78
+ def _fmt_utc_Z(dt: datetime) -> str:
79
+ # dt must be UTC-aware
80
+ return dt.strftime("%Y%m%dT%H%MZ")
81
+
82
+
83
+ def _local_tzname() -> str:
84
+ # string name is sometimes handy for UI/logging
85
+ try:
86
+ return get_localzone_name()
87
+ except Exception:
88
+ return "local"
89
+
90
+
91
+ def _ensure_utc(dt: datetime) -> datetime:
92
+ # make UTC aware
93
+ return dt.astimezone(tz.UTC)
94
+
95
+
96
+ def _attach_zone(dt: datetime, zone) -> datetime:
97
+ # if dt is naive, attach zone; else convert to zone
98
+ if dt.tzinfo is None:
99
+ return dt.replace(tzinfo=zone)
100
+ return dt.astimezone(zone)
101
+
102
+
103
+ def _parts(s: str) -> List[str]:
104
+ return [p for p in s.split("/") if p]
105
+
106
+
107
+ def _norm(s: str) -> str:
108
+ return "/".join(_parts(s)).lower()
109
+
110
+
111
+ def _ordered_prefix_matches(paths: List[str], frag: str, limit: int = 24) -> List[str]:
112
+ segs = [s.lower() for s in _parts(frag)]
113
+ out: List[str] = []
114
+ for p in paths:
115
+ toks = [t.lower() for t in p.split("/")]
116
+ if len(toks) >= len(segs) and all(
117
+ toks[i].startswith(segs[i]) for i in range(len(segs))
118
+ ):
119
+ out.append(p)
120
+ if len(out) >= limit:
121
+ break
122
+ out.sort(key=lambda s: (s.count("/"), s))
123
+ return out
124
+
125
+
126
+ def _lcp(strings: List[str]) -> str:
127
+ if not strings:
128
+ return ""
129
+ a, b = min(strings), max(strings)
130
+ i = 0
131
+ while i < len(a) and i < len(b) and a[i] == b[i]:
132
+ i += 1
133
+ return a[:i]
134
+
135
+
136
+ def dtstr_to_compact(dt: str) -> str:
137
+ obj = parse_dt(dt)
138
+ if not obj:
139
+ return False, f"Could not parse {obj = }"
140
+
141
+ # If the parser returns a datetime at 00:00:00, treat it as a date (your chosen convention)
142
+ # if isinstance(obj, datetime) and obj.hour == obj.minute == obj.second == 0:
143
+ # return True, obj.strftime("%Y%m%d")
144
+
145
+ if isinstance(obj, date) and not isinstance(obj, datetime):
146
+ return True, obj.strftime("%Y%m%d")
147
+
148
+ return True, obj.strftime("%Y%m%dT%H%M")
149
+
150
+
151
+ def local_dtstr_to_utc(dt: str) -> str:
152
+ obj = parse_dt(dt)
153
+ if not obj:
154
+ return False, f"Could not parse {obj = }"
155
+
156
+ # If the parser returns a datetime at 00:00:00, treat it as a date (your chosen convention)
157
+ # if isinstance(obj, datetime) and obj.hour == obj.minute == obj.second == 0:
158
+ # return True, obj.strftime("%Y%m%d")
159
+
160
+ if isinstance(obj, date) and not isinstance(obj, datetime):
161
+ return True, obj.strftime("%Y%m%d")
162
+
163
+ return True, obj.astimezone(tz.UTC).strftime("%Y%m%dT%H%MZ")
164
+
165
+
166
+ # --- parse a possible trailing " z <tzspec>" directive ---
167
+ def _split_z_directive(text: str) -> tuple[str, str | None]:
168
+ """
169
+ Accepts things like:
170
+ "2025-08-24 12:00" -> ("2025-08-24 12:00", None)
171
+ "2025-08-24 12:00 z none" -> ("2025-08-24 12:00", "none")
172
+ "2025-08-24 12:00 z Europe/Berlin" -> ("2025-08-24 12:00", "Europe/Berlin")
173
+ Only splits on the *last* " z " sequence to avoid false positives in subject text.
174
+ """
175
+ s = text.strip()
176
+ marker = " z "
177
+ idx = s.rfind(marker)
178
+ if idx == -1:
179
+ return s, None
180
+ main = s[:idx].strip()
181
+ tail = s[idx + len(marker) :].strip()
182
+ return (main or s), (tail or None)
183
+
184
+
185
+ # --- helpers used by do_offset / finish ---------------------------------
186
+
187
+
188
+ def td_str_to_td(s: str) -> timedelta:
189
+ """Parse a compact td string like '1w2d3h45m10s' -> timedelta."""
190
+ # If you already have td_str_to_td, use that instead and remove this.
191
+
192
+ units = {"w": 7 * 24 * 3600, "d": 24 * 3600, "h": 3600, "m": 60, "s": 1}
193
+ total = 0
194
+ for num, unit in re.findall(r"(\d+)\s*([wdhms])", s.lower()):
195
+ total += int(num) * units[unit]
196
+ return timedelta(seconds=total)
197
+
198
+
199
+ def td_to_td_str(td: timedelta) -> str:
200
+ """Turn a timedelta back into a compact string like '1w2d3h'."""
201
+ secs = int(td.total_seconds())
202
+ parts = []
203
+ for label, size in (("w", 604800), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)):
204
+ if secs >= size:
205
+ q, secs = divmod(secs, size)
206
+ parts.append(f"{q}{label}")
207
+ return "".join(parts) or "0s"
208
+
209
+
210
+ def _parse_o_body(body: str) -> tuple[timedelta, bool]:
211
+ """
212
+ Parse the body of @o. Supports:
213
+ '@o 3d' -> fixed interval 3 days
214
+ '@o ~3d' -> learning interval starting at 3 days
215
+ '@o learn 3d' -> same as '~3d'
216
+ Returns (td, learn).
217
+ """
218
+ b = body.strip().lower()
219
+ learn = False
220
+ if b.startswith("~"):
221
+ learn = True
222
+ b = b[1:].strip()
223
+ elif b.startswith("learn"):
224
+ learn = True
225
+ b = b[5:].strip()
226
+
227
+ td = td_str_to_td(b)
228
+ return td, learn
229
+
230
+
231
+ def parse_f_token(f_token):
232
+ """
233
+ Return (completion_dt, due_dt) from a single @f token.
234
+ The second value may be None if not provided.
235
+ """
236
+ try:
237
+ token_str = f_token["token"].split(maxsplit=1)[1]
238
+ parts = [p.strip() for p in token_str.split(",", 1)]
239
+ completion = parse_dt(parts[0])
240
+ due = parse_dt(parts[1]) if len(parts) > 1 else None
241
+ return completion, due
242
+ except Exception:
243
+ return None, None
244
+
245
+
246
+ def parse(dt_str: str, zone: tzinfo = None):
247
+ """
248
+ User-facing parser with a trailing 'z' directive:
249
+
250
+ <datetime> -> aware in local tz, normalized to UTC (returns datetime)
251
+ <datetime> z none -> naive (no tz), as typed (returns datetime)
252
+ <datetime> z <TZNAME> -> aware in TZNAME, normalized to UTC (returns datetime)
253
+ <date> -> returns date (if parsed time is 00:00:00)
254
+
255
+ Returns: datetime (UTC or naive) or date; None on failure.
256
+ """
257
+ if not dt_str or not isinstance(dt_str, str):
258
+ return None
259
+
260
+ s = dt_str.strip()
261
+
262
+ # Look for a trailing "z <arg>" (case-insensitive), e.g. " ... z none" or " ... z Europe/Berlin"
263
+ m = re.search(r"\bz\s+(\S+)\s*$", s, flags=re.IGNORECASE)
264
+ z_arg = None
265
+ if m:
266
+ z_arg = m.group(1) # e.g. "none" or "Europe/Berlin"
267
+ s = s[: m.start()].rstrip() # remove the trailing z directive
268
+
269
+ try:
270
+ # Parse the main date/time text. (If you have dayfirst/yearfirst config, add it here.)
271
+ obj = parse_dt(s)
272
+ except Exception as e:
273
+ log_msg(f"error: {e}, {s = }")
274
+ return None
275
+
276
+ # If the parser returns a datetime at 00:00:00, treat it as a date (your chosen convention)
277
+ if isinstance(obj, datetime) and obj.hour == obj.minute == obj.second == 0:
278
+ return obj.date()
279
+
280
+ # If we got a pure date already, return it as-is
281
+ if isinstance(obj, date) and not isinstance(obj, datetime):
282
+ return obj
283
+
284
+ # From here on, obj is a datetime
285
+ # Case: explicit naive requested
286
+ if z_arg and z_arg.lower() == "none":
287
+ # Return *naive* datetime exactly as parsed (strip any tzinfo, if present)
288
+ if obj.tzinfo is not None:
289
+ obj = obj.astimezone(tz.UTC).replace(
290
+ tzinfo=None
291
+ ) # normalize then drop tzinfo
292
+ else:
293
+ obj = obj.replace(tzinfo=None)
294
+ return obj
295
+
296
+ # Otherwise: aware (local by default, or the provided zone)
297
+ if z_arg:
298
+ zone = tz.gettz(z_arg)
299
+ if zone is None:
300
+ return None # unknown timezone name
301
+ else:
302
+ # default to the local machine timezone
303
+ zone = tz.gettz(get_localzone_name())
304
+
305
+ # Attach/convert to the chosen zone, then normalize to UTC
306
+ if obj.tzinfo is None:
307
+ aware = obj.replace(tzinfo=zone)
308
+ else:
309
+ aware = obj.astimezone(zone)
310
+
311
+ return aware.astimezone(tz.UTC)
312
+
313
+
314
+ # def parse_pair(dt_pair_str: str) -> str:
315
+ # """ """
316
+ # dt_strs = [x.strip() for x in dt_pair_str.split(",")]
317
+ # return [parse(x) for x in dt_strs]
318
+
319
+
320
+ def parse_completion_value(v: str) -> tuple[datetime | None, datetime | None]:
321
+ """
322
+ Parse '@f' or '&f' value text entered in *user format* (e.g. '2024-3-1 12a, 2024-3-1 10a')
323
+ into (finished_dt, due_dt).
324
+ """
325
+ parts = [p.strip() for p in v.split(",")]
326
+ completed = parse_dt(parts[0]) if parts and parts[0] else None
327
+ due = parse_dt(parts[1]) if len(parts) > 1 and parts[1] else None
328
+ return completed, due
329
+
330
+
331
+ def _parse_compact_dt(s: str) -> datetime:
332
+ """
333
+ Accepts 'YYYYMMDD' or 'YYYYMMDDTHHMMSS' (optionally with trailing 'Z')
334
+ and returns a naive datetime (local) for the 'THHMMSS' case, or
335
+ midnight local for date-only.
336
+ """
337
+ s = (s or "").strip()
338
+ if not s:
339
+ raise ValueError("empty datetime string")
340
+
341
+ z = s.endswith("Z")
342
+ if z:
343
+ s = s[:-1]
344
+
345
+ if "T" in s:
346
+ # YYYYMMDDTHHMMSS
347
+ return datetime.strptime(s, "%Y%m%dT%H%M")
348
+ else:
349
+ # YYYYMMDD -> midnight (local-naive)
350
+ d = datetime.strptime(s, "%Y%m%d").date()
351
+ return datetime(d.year, d.month, d.day, 0, 0, 0)
352
+
353
+
354
+ class CustomJSONEncoder(json.JSONEncoder):
355
+ def default(self, obj):
356
+ if isinstance(obj, datetime):
357
+ return obj.isoformat()
358
+ if isinstance(obj, timedelta):
359
+ return str(obj)
360
+ if isinstance(obj, set):
361
+ return list(obj)
362
+ if isinstance(obj, ZoneInfo):
363
+ return obj.key
364
+ return super().default(obj)
365
+
366
+
367
+ def dt_to_dtstr(dt_obj: Union[datetime, date]) -> str:
368
+ """Convert a datetime object to 'YYYYMMDDTHHMMSS' format."""
369
+ if isinstance(dt_obj, date) and not isinstance(dt_obj, datetime):
370
+ return dt_obj.strftime("%Y%m%d")
371
+ return dt_obj.strftime("%Y%m%d%H%M")
372
+
373
+
374
+ def as_timezone(dt: datetime, timezone: ZoneInfo) -> datetime:
375
+ if is_date(dt):
376
+ return dt
377
+ return dt.astimezone(timezone)
378
+
379
+
380
+ def enforce_date(dt: datetime) -> datetime:
381
+ """
382
+ Force dt to behave like a date (no meaningful time component).
383
+ """
384
+ if is_datetime(dt):
385
+ return dt.date()
386
+ if is_date:
387
+ return dt
388
+ raise ValueError(f"{dt = } cannot be converted to a date ")
389
+
390
+
391
+ def localize_rule_instances(
392
+ rule: Iterable[Union[datetime, date]],
393
+ timezone: Union[ZoneInfo, None],
394
+ to_localtime: bool = False,
395
+ ):
396
+ """
397
+ Iterate over instances from a rule parsed by rrulestr.
398
+
399
+ - Dates are yielded unchanged.
400
+ - Naive datetimes are assigned the given timezone.
401
+ - Aware datetimes are optionally converted to system localtime.
402
+ """
403
+ if timezone == "local":
404
+ timezone = get_localzone_name()
405
+
406
+ for dt in rule:
407
+ if is_date(dt) or not to_localtime:
408
+ yield dt
409
+ else:
410
+ # dt is a datetime
411
+ if dt.tzinfo is None:
412
+ if timezone is not None:
413
+ dt = dt.replace(tzinfo=timezone)
414
+ else:
415
+ dt = dt.replace(
416
+ # tzinfo=tz.UTC
417
+ tzinfo=tz.tzlocal()
418
+ ) # fallback to UTC if timezone missing
419
+ if to_localtime:
420
+ dt = dt.astimezone()
421
+
422
+ yield dt
423
+
424
+
425
+ def localize_datetime_list(
426
+ dts: List[datetime], timezone: ZoneInfo, to_localtime: bool = False
427
+ ) -> List[datetime]:
428
+ """
429
+ Localize a list of datetime objects.
430
+
431
+ - Attach timezone to naive datetimes
432
+ - Optionally convert to system local time
433
+ - Returns a new list of timezone-aware datetimes
434
+ """
435
+ localized = []
436
+ for dt in dts:
437
+ if dt.tzinfo is None:
438
+ dt = dt.replace(tzinfo=timezone)
439
+ if to_localtime:
440
+ dt = dt.astimezone()
441
+ localized.append(dt)
442
+ return localized
443
+
444
+
445
+ def preview_rule_instances(
446
+ rule: rruleset,
447
+ timezone: Union[ZoneInfo, None] = None,
448
+ count: int = 10,
449
+ after: Optional[Union[datetime, date]] = None,
450
+ to_localtime: bool = False,
451
+ ) -> List[Union[datetime, date]]:
452
+ instances = []
453
+ generator = localize_rule_instances(rule, timezone, to_localtime)
454
+
455
+ if after is None:
456
+ after_datetime = datetime.now().astimezone()
457
+ after_date = date.today()
458
+
459
+ for dt in list(generator):
460
+ if is_date(dt):
461
+ if dt < after_date:
462
+ continue
463
+ else:
464
+ if dt.astimezone() < after_datetime:
465
+ continue
466
+
467
+ instances.append(dt)
468
+ if len(instances) >= count:
469
+ break
470
+
471
+ return instances
472
+
473
+
474
+ def preview_upcoming_instances(
475
+ rule: rruleset, timezone: ZoneInfo, count: int = 10, to_localtime: bool = False
476
+ ) -> List[datetime]:
477
+ """
478
+ Shortcut to preview the next N upcoming localized instances, starting from now.
479
+ """
480
+ now = datetime.now().astimezone()
481
+ return preview_rule_instances(
482
+ rule, timezone, count=count, after=now, to_localtime=to_localtime
483
+ )
484
+
485
+
486
+ def pp_set(s):
487
+ return "{}" if not s else str(s)
488
+
489
+
490
+ def is_lowercase_letter(char):
491
+ return char in LETTER_SET # O(1) lookup
492
+
493
+
494
+ type_keys = {
495
+ "*": "event",
496
+ "~": "task",
497
+ "^": "project",
498
+ "%": "note",
499
+ "+": "goal",
500
+ "?": "draft",
501
+ "x": "finished",
502
+ # '✓': 'finished', # more a property of a task than an item type
503
+ }
504
+ common_methods = list("cdgblmnstuxz") + ["k", "#"]
505
+
506
+ repeating_methods = list("o") + [
507
+ "r",
508
+ "rr",
509
+ "rc",
510
+ "rd", # monthdays
511
+ "rm", # months
512
+ "rH", # hours
513
+ "rM", # minutes
514
+ "rE",
515
+ "ri",
516
+ "rs",
517
+ "ru",
518
+ "rW", # week numbers
519
+ "rw", # week days
520
+ ]
521
+
522
+ datetime_methods = list("anew+-")
523
+
524
+ task_methods = list("ofp")
525
+
526
+ job_methods = list("efhp") + [
527
+ "~",
528
+ "~r",
529
+ "~j",
530
+ "~a",
531
+ "~b",
532
+ "~c",
533
+ "~d",
534
+ "~e",
535
+ "~f",
536
+ "~i",
537
+ "~l",
538
+ "~m",
539
+ "~p",
540
+ "~s",
541
+ "~u",
542
+ ]
543
+
544
+ multiple_allowed = [
545
+ "a",
546
+ "b",
547
+ "u",
548
+ "r",
549
+ "t",
550
+ "~",
551
+ "~r",
552
+ "~t",
553
+ "~a",
554
+ ]
555
+
556
+ wrap_methods = ["w"]
557
+
558
+ required = {"*": ["s"], "~": [], "^": ["~"], "%": [], "?": [], "+": []}
559
+
560
+ all_keys = common_methods + datetime_methods + job_methods + repeating_methods
561
+
562
+ allowed = {
563
+ "*": common_methods + datetime_methods + repeating_methods + wrap_methods,
564
+ "~": common_methods + datetime_methods + task_methods + repeating_methods,
565
+ "+": common_methods + datetime_methods + task_methods,
566
+ "^": common_methods + datetime_methods + job_methods + repeating_methods,
567
+ "%": common_methods + datetime_methods,
568
+ "?": all_keys,
569
+ }
570
+
571
+
572
+ requires = {
573
+ "a": ["s"],
574
+ "n": ["s"],
575
+ "o": ["s"],
576
+ "+": ["s"],
577
+ "q": ["s"],
578
+ "-": ["rr"],
579
+ "r": ["s"],
580
+ "rr": ["s"],
581
+ "~s": ["s"],
582
+ "~a": ["s"],
583
+ "~b": ["s"],
584
+ }
585
+
586
+
587
+ class Paragraph:
588
+ # Placeholder to preserve line breaks
589
+ NON_PRINTING_CHAR = "\u200b"
590
+ # Placeholder for spaces within special tokens
591
+ PLACEHOLDER = "\u00a0"
592
+ # Placeholder for hyphens to prevent word breaks
593
+ NON_BREAKING_HYPHEN = "\u2011"
594
+
595
+ def __init__(self, para: str):
596
+ self.para = para
597
+
598
+ def preprocess_text(self, text):
599
+ # Regex to find "@\S" patterns and replace spaces within the pattern with PLACEHOLDER
600
+ text = re.sub(
601
+ r"(@\S+\s\S+)",
602
+ lambda m: m.group(0).replace(" ", Paragraph.PLACEHOLDER),
603
+ text,
604
+ )
605
+ # Replace hyphens within words with NON_BREAKING_HYPHEN
606
+ text = re.sub(
607
+ r"(\S)-(\S)",
608
+ lambda m: m.group(1) + Paragraph.NON_BREAKING_HYPHEN + m.group(2),
609
+ text,
610
+ )
611
+ return text
612
+
613
+ def postprocess_text(self, text):
614
+ text = text.replace(Paragraph.PLACEHOLDER, " ")
615
+ text = text.replace(Paragraph.NON_BREAKING_HYPHEN, "-")
616
+ return text
617
+
618
+ def wrap(
619
+ self, text: str, indent: int = 3, width: int = shutil.get_terminal_size()[0] - 3
620
+ ):
621
+ # Preprocess to replace spaces within specific "@\S" patterns with PLACEHOLDER
622
+ text = self.preprocess_text(text)
623
+
624
+ # Split text into paragraphs
625
+ paragraphs = text.split("\n")
626
+
627
+ # Wrap each paragraph
628
+ wrapped_paragraphs = []
629
+ for para in paragraphs:
630
+ leading_whitespace = re.match(r"^\s*", para).group()
631
+ initial_indent = leading_whitespace
632
+
633
+ # Determine subsequent_indent based on the first non-whitespace character
634
+ stripped_para = para.lstrip()
635
+ if stripped_para.startswith(("^", "~", "*", "%", "?", "+")):
636
+ subsequent_indent = initial_indent + " " * 2
637
+ elif stripped_para.startswith(("@", "&")):
638
+ subsequent_indent = initial_indent + " " * 3
639
+ else:
640
+ subsequent_indent = initial_indent + " " * indent
641
+
642
+ wrapped = textwrap.fill(
643
+ para,
644
+ initial_indent="",
645
+ subsequent_indent=subsequent_indent,
646
+ width=width,
647
+ )
648
+ wrapped_paragraphs.append(wrapped)
649
+
650
+ # Join paragraphs with newline followed by non-printing character
651
+ wrapped_text = ("\n" + Paragraph.NON_PRINTING_CHAR).join(wrapped_paragraphs)
652
+
653
+ # Postprocess to replace PLACEHOLDER and NON_BREAKING_HYPHEN back with spaces and hyphens
654
+ wrapped_text = self.postprocess_text(wrapped_text)
655
+ return wrapped_text
656
+
657
+ def unwrap(wrapped_text):
658
+ # Split wrapped text into paragraphs
659
+ paragraphs = wrapped_text.split("\n" + Paragraph.NON_PRINTING_CHAR)
660
+
661
+ # Replace newlines followed by spaces in each paragraph with a single space
662
+ unwrapped_paragraphs = []
663
+ for para in paragraphs:
664
+ unwrapped = re.sub(r"\n\s*", " ", para)
665
+ unwrapped_paragraphs.append(unwrapped)
666
+
667
+ # Join paragraphs with original newlines
668
+ unwrapped_text = "\n".join(unwrapped_paragraphs)
669
+
670
+ return unwrapped_text
671
+
672
+
673
+ @dataclass
674
+ class FinishResult:
675
+ new_relative_tokens: list # tokens to persist
676
+ new_rruleset: str | None # possibly None/"" if no more repeats
677
+ due_ts_used: int | None # the occurrence this finish applies to
678
+ finished_final: bool # True -> no more occurrences
679
+
680
+
681
+ class Item:
682
+ token_keys = {
683
+ "itemtype": [
684
+ "item type",
685
+ "character from * (event), ~ (task), ^ (project), % (note), ! (goal) or ? (draft)",
686
+ "do_itemtype",
687
+ ],
688
+ "subject": [
689
+ "subject",
690
+ "item subject. Append an '@' to add an option.",
691
+ "do_summary",
692
+ ],
693
+ "s": ["scheduled", "starting date or datetime", "do_s"],
694
+ "t": ["tag", "tag name", "do_tag"],
695
+ "r": ["recurrence", "recurrence rule", "do_rrule"],
696
+ "o": ["offset", "offset rule", "do_offset"],
697
+ "~": ["job", "job entry", "do_job"],
698
+ "+": ["rdate", "recurrence dates", "do_rdate"],
699
+ "-": ["exdate", "exception dates", "do_exdate"],
700
+ "a": ["alerts", "list of alerts", "do_alert"],
701
+ "n": ["notice", "timeperiod", "do_notice"],
702
+ "c": ["context", "context", "do_string"],
703
+ "d": ["description", "item description", "do_description"],
704
+ "e": ["extent", "timeperiod", "do_extent"],
705
+ "w": ["wrap", "list of two timeperiods", "do_two_periods"],
706
+ "f": ["finish", "completion done -> due", "do_completion"],
707
+ "g": ["goto", "url or filepath", "do_string"],
708
+ "h": [
709
+ "completions",
710
+ "list of completion datetimes",
711
+ "do_completions",
712
+ ],
713
+ "b": ["bin", "forward slash delimited string", "do_b"],
714
+ "l": [
715
+ "label",
716
+ "label for job clone",
717
+ "do_string",
718
+ ],
719
+ "m": ["mask", "string to be masked", "do_mask"],
720
+ "p": [
721
+ "priority",
722
+ "priority from 1 (someday), 2 (low), 3 (medium), 4 (high) to 5 (next)",
723
+ "do_priority",
724
+ ],
725
+ "w": ["wrap", "wrap before, after", "do_wrap"],
726
+ "z": [
727
+ "timezone",
728
+ "a timezone entry such as 'US/Eastern' or 'Europe/Paris' or 'none' to specify a naive datetime, i.e., one without timezone information",
729
+ "do_timezone",
730
+ ],
731
+ "@": ["@-key", "", "do_at"],
732
+ "rr": [
733
+ "repetition frequency",
734
+ "character from (y)ear, (m)onth, (w)eek, (d)ay, (h)our, mi(n)ute. Append an '&' to add a repetition option.",
735
+ "do_frequency",
736
+ ],
737
+ "ri": ["interval", "positive integer", "do_interval"],
738
+ "rm": ["months", "list of integers in 1 ... 12", "do_months"],
739
+ "rd": [
740
+ "monthdays",
741
+ "list of integers 1 ... 31, possibly prepended with a minus sign to count backwards from the end of the month",
742
+ "do_monthdays",
743
+ ],
744
+ "rE": [
745
+ "easterdays",
746
+ "number of days before (-), on (0) or after (+) Easter",
747
+ "do_easterdays",
748
+ ],
749
+ "rH": ["hours", "list of integers in 0 ... 23", "do_hours"],
750
+ "rM": ["minutes", "list of integers in 0 ... 59", "do_minutes"],
751
+ "rw": [
752
+ "weekdays",
753
+ "list from SU, MO, ..., SA, possibly prepended with a positive or negative integer",
754
+ "do_weekdays",
755
+ ],
756
+ "rW": [
757
+ "week numbers",
758
+ "list of integers in 1, ... 53",
759
+ "do_weeknumbers",
760
+ ],
761
+ "rc": ["count", "integer number of repetitions", "do_count"],
762
+ "ru": ["until", "datetime", "do_until"],
763
+ "rs": ["set positions", "integer", "do_setpositions"],
764
+ "r?": ["repetition &-key", "enter &-key", "do_ampr"],
765
+ "~~": [
766
+ "subject",
767
+ "do_string",
768
+ ],
769
+ "~a": [
770
+ "alert",
771
+ "list of timeperiods before job is scheduled followed by a colon and a list of commands",
772
+ "do_alert",
773
+ ],
774
+ "~n": ["notice", " notice period", "do_notice"],
775
+ "~c": ["context", " string", "do_string"],
776
+ "~d": ["description", " string", "do_description"],
777
+ "~e": ["extent", " timeperiod", "do_extent"],
778
+ "~f": ["finish", " completion done -> due", "do_completion"],
779
+ "~i": ["unique id", " integer or string", "do_string"],
780
+ "~l": ["label", " string", "do_string"],
781
+ "~m": ["mask", "string to be masked", "do_mask"],
782
+ "~r": [
783
+ "id and list of requirement ids",
784
+ "list of ids of immediate prereqs",
785
+ "do_requires",
786
+ ],
787
+ "~s": [
788
+ "scheduled",
789
+ "timeperiod after task scheduled when job is scheduled",
790
+ "do_duration",
791
+ ],
792
+ "~u": ["used time", "timeperiod: datetime", "do_usedtime"],
793
+ "~?": ["job &-key", "enter &-key", "do_ampj"],
794
+ "k": ["konnection", "not implemented", "do_nothing"],
795
+ "#": ["etm record number", "not implemented", "do_nothing"],
796
+ }
797
+
798
+ wkd_list = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]
799
+ wkd_str = ", ".join(wkd_list)
800
+
801
+ freq_map = dict(
802
+ y="YEARLY", m="MONTHLY", w="WEEKLY", d="DAILY", h="HOURLY", n="MINUTELY"
803
+ )
804
+
805
+ key_to_param = dict(
806
+ i="INTERVAL",
807
+ c="COUNT",
808
+ s="BYSETPOS",
809
+ u="UNTIL",
810
+ m="BYMONTH",
811
+ d="BYMONTHDAY",
812
+ W="BYWEEKNO",
813
+ w="BYWEEKDAY",
814
+ H="BYHOUR",
815
+ M="BYMINUTE",
816
+ E="BYEASTER",
817
+ )
818
+ param_to_key = {v: k for k, v in key_to_param.items()}
819
+
820
+ def __init__(self, *args, **kwargs):
821
+ """
822
+ Compatible constructor that accepts:
823
+ - Item(entry_str)
824
+ - Item(raw=entry_str)
825
+ - Item(env, entry_str)
826
+ - Item(env=env, raw=entry_str)
827
+ - Item(entry_str, env=env)
828
+ """
829
+ # --- resolve arguments flexibly ---
830
+ self.controller = kwargs.get("controller")
831
+
832
+ env = kwargs.get("env")
833
+ raw = kwargs.get("raw")
834
+ self.final: bool = bool(kwargs.get("final", False)) # ← NEW
835
+
836
+ # try positional decoding without importing the type
837
+ a = args[0] if len(args) > 0 else None
838
+ b = args[1] if len(args) > 1 else None
839
+
840
+ # heuristics: strings are raw; non-strings are likely env
841
+ if raw is None and isinstance(a, str):
842
+ raw = a
843
+ a = None
844
+ if env is None and a is not None and not isinstance(a, str):
845
+ env = a
846
+ a = None
847
+
848
+ if raw is None and isinstance(b, str):
849
+ raw = b
850
+ b = None
851
+ if env is None and b is not None and not isinstance(b, str):
852
+ env = b
853
+ b = None
854
+
855
+ # iso standard defaults
856
+ self.datefmt = "%Y-%m-%d"
857
+ self.timefmt = "%H:%M"
858
+
859
+ # --- environment / config ---
860
+ self.env = env
861
+
862
+ # --- core parse state ---
863
+ self.entry = ""
864
+ self.previous_entry = ""
865
+ self.itemtype = ""
866
+ self.subject = ""
867
+ self.context = ""
868
+ self.description = ""
869
+ self.token_map = {}
870
+ self.parse_ok = False
871
+ self.parse_message = ""
872
+ self.previous_tokens = []
873
+ self.relative_tokens = []
874
+ self.last_result = ()
875
+ self.bin_paths = []
876
+ self.tokens = []
877
+ self.messages = []
878
+ self.validate_messages = []
879
+
880
+ # --- schedule / tokens / jobs ---
881
+ self.extent = ""
882
+ self.rruleset = ""
883
+ self.rrule_tokens = []
884
+ self.rrule_components = []
885
+ self.rrule_parts = []
886
+ self.job_tokens = []
887
+ self.token_store = None
888
+ self.rrules = []
889
+ self.jobs = []
890
+ self.bins = []
891
+ self.jobset = []
892
+ self.priority = None
893
+ self.tags = []
894
+ self.alerts = []
895
+ self.notice = ""
896
+
897
+ # --- date/time collections (strings) ---
898
+ self.s_kind = ""
899
+ self.s_tz = None
900
+ self.rdates = []
901
+ self.exdates = []
902
+ self.rdate_str = ""
903
+ self.exdate_str = ""
904
+
905
+ # --- DTSTART / RDATE (preserve your sentinels) ---
906
+ self.dtstart = None
907
+ self.dtstart_str = None # important: keep None (not "")
908
+ self.rdstart_str = ""
909
+
910
+ # --- timezone defaults (match your previous working code) ---
911
+
912
+ self.timezone = get_localzone_name()
913
+ self.tz_str = local_timezone
914
+
915
+ # TODO: remove these
916
+ self.skip_token_positions = set()
917
+ self.token_group_anchors = {}
918
+
919
+ # --- other flags / features ---
920
+ self.completion: tuple[datetime, datetime | None] | None = None
921
+ self.over = ""
922
+ self.has_f = False # True if there is an @f to process after parsing tokens
923
+ self.has_s = False # True if there is an @f to process after parsing tokens
924
+
925
+ # --- optional initial parse ---
926
+ self.ampm = False
927
+ self.yearfirst = True
928
+ self.dayfirst = False
929
+ self.two_digit_year = True
930
+ if self.env:
931
+ self.ampm = self.env.config.ui.ampm
932
+ self.timefmt = "%-I:%M%p" if self.ampm else "%H:%M"
933
+ self.dayfirst = self.env.config.ui.dayfirst
934
+ self.yearfirst = self.env.config.ui.yearfirst
935
+ self.history_weight = self.env.config.ui.history_weight
936
+ _yr = "%y" if self.two_digit_year else "%Y"
937
+ _dm = "%d-%m" if self.dayfirst else "%m-%d"
938
+ self.datefmt = f"{_yr}-{_dm}" if self.yearfirst else f"{_dm}-{_yr}"
939
+ self.two_digit_year = self.env.config.ui.two_digit_year
940
+ self.datetimefmt = f"{self.datefmt} {self.timefmt}"
941
+
942
+ # print(f"{self.ampm = }, {self.yearfirst = }, {self.dayfirst = }")
943
+ #
944
+ # dayfirst yearfirst date interpretation standard
945
+ # ======== ========= ======== ============== ========
946
+ # True True 12-10-11 2012-11-10 Y-D-M ??
947
+ # True False 12-10-11 2011-10-12 D-M-Y EU
948
+ # False True 12-10-11 2012-10-11 Y-M-D ISO 8601
949
+ # False False 12-10-11 2011-12-10 M-D-Y US
950
+ # dayfirst D-M else M-D
951
+ # yearfirst first else last
952
+ # DM = %d-%m if dayfirst else "%m-%d"
953
+ # DMY = f"%Y-{DM}" if yearfirst else f"{DM}-%Y"
954
+
955
+ if raw:
956
+ self.entry = raw
957
+ self.parse_input(raw)
958
+ # if self.final:
959
+ # self.finalize_record()
960
+ #
961
+
962
+ def get_name_to_binpath(self) -> dict:
963
+ if self.final or not self.controller:
964
+ return {}
965
+ return self.controller.get_name_to_binpath()
966
+
967
+ def to_entry(self) -> str:
968
+ """
969
+ Rebuild a tklr entry string from this Item’s fields.
970
+ """
971
+ # --- map itemtype ---
972
+ itemtype = self.itemtype
973
+
974
+ # --- start with type and subject ---
975
+ parts = [f"{itemtype} {self.subject}"]
976
+
977
+ # --- description (optional, inline or multi-line) ---
978
+ if self.description:
979
+ parts.append(self.description)
980
+
981
+ # --- scheduling tokens ---
982
+ if getattr(self, "dtstart_str", None):
983
+ dt = self._get_start_dt()
984
+ if dt:
985
+ parts.append(f"@s {self.fmt_user(dt)}")
986
+
987
+ if getattr(self, "extent", None):
988
+ parts.append(f"@e {self.extent}")
989
+
990
+ if getattr(self, "rruleset", None):
991
+ parts.append(f"@r {self.rruleset}")
992
+
993
+ if getattr(self, "notice", None):
994
+ parts.append(f"@n {self.notice}")
995
+
996
+ # --- tags ---
997
+ if getattr(self, "tags", None):
998
+ tags = " ".join(f"@t {t}" for t in self.tags)
999
+ parts.append(tags)
1000
+
1001
+ # --- context ---
1002
+ if getattr(self, "context", None):
1003
+ parts.append(f"@c {self.context}")
1004
+
1005
+ # --- jobs ---
1006
+ if getattr(self, "jobs", None) and self.jobs not in ("[]", None):
1007
+ try:
1008
+ jobs = json.loads(self.jobs)
1009
+ except Exception:
1010
+ jobs = []
1011
+ for j in jobs:
1012
+ subj = j.get("summary") or j.get("subject")
1013
+ if subj:
1014
+ parts.append(f"@~ {subj}")
1015
+
1016
+ return " ".join(parts)
1017
+
1018
+ def parse_input(self, entry: str):
1019
+ """
1020
+ Parses the input string to extract tokens, then processes and validates the tokens.
1021
+ """
1022
+ # digits = "1234567890" * ceil(len(entry) / 10)
1023
+
1024
+ self._tokenize(entry)
1025
+ # NOTE: _tokenize sets self.itemtype and self.subject
1026
+
1027
+ message = self.validate()
1028
+ if message:
1029
+ self.parse_ok = False
1030
+ self.parse_message = message
1031
+ print(f"parse failed: {message = }")
1032
+ return
1033
+
1034
+ self.mark_grouped_tokens()
1035
+ self._parse_tokens(entry)
1036
+
1037
+ self.parse_ok = True
1038
+ self.previous_entry = entry
1039
+ self.previous_tokens = self.relative_tokens.copy()
1040
+
1041
+ if self.final:
1042
+ self.finalize_record()
1043
+
1044
+ def finalize_record(self):
1045
+ """
1046
+ When the entry and token list is complete:
1047
+ 1) finalize jobs, processing any &f entries and adding @f when all jobs are finished
1048
+ 2) finalize_rruleset so that next instances will be available
1049
+ 3) process @f entries (&f entries will have been done by finalize_jobs)
1050
+
1051
+ """
1052
+ if self.itemtype == "^":
1053
+ jobset = self.build_jobs()
1054
+ success, finalized = self.finalize_jobs(jobset)
1055
+ # rruleset is needed to get the next two occurrences
1056
+ if self.collect_grouped_tokens({"r"}):
1057
+ rruleset = self.finalize_rruleset()
1058
+ log_msg(f"got rruleset {rruleset = }")
1059
+ if rruleset:
1060
+ self.rruleset = rruleset
1061
+ elif self.rdstart_str is not None:
1062
+ # @s but not @r
1063
+ self.rruleset = self.rdstart_str
1064
+
1065
+ if self.has_f:
1066
+ self.itemtype = "x"
1067
+ self.finish()
1068
+
1069
+ if self.has_s:
1070
+ self._set_start_dt()
1071
+
1072
+ self.tokens = self._strip_positions(self.relative_tokens)
1073
+ log_msg(f"{self.relative_tokens = }; {self.tokens = }")
1074
+
1075
+ def validate(self):
1076
+ self.validate_messages = []
1077
+
1078
+ def fmt_error(message: str):
1079
+ # return [x.strip() for x in message.split(",")]
1080
+ self.validate_messages.append(message)
1081
+ return message
1082
+
1083
+ errors = []
1084
+
1085
+ if len(self.entry.strip()) < 1 or len(self.relative_tokens) < 1:
1086
+ # nothing to validate without itemtype and subject
1087
+ return fmt_error("""\
1088
+ A reminder must begin with an itemtype character
1089
+ from: * (event), ~ (task), ^ (project), % (note),
1090
+ ! (goal) or ? (draft)
1091
+ """)
1092
+
1093
+ if len(self.relative_tokens) < 2:
1094
+ # nothing to validate without itemtype and subject
1095
+ return fmt_error(
1096
+ "A subject must be provided for the reminder after the itemtype."
1097
+ )
1098
+
1099
+ self.itemtype = self.relative_tokens[0]["token"]
1100
+ if not self.itemtype:
1101
+ return "no itemtype"
1102
+
1103
+ subject = self.relative_tokens[1]["token"]
1104
+ allowed_fortype = allowed[self.itemtype]
1105
+ # required_fortype = required[self.itemtype]
1106
+ needed = deepcopy(required[self.itemtype])
1107
+
1108
+ current_atkey = None
1109
+ used_atkeys = []
1110
+ used_ampkeys = []
1111
+ count = 0
1112
+ # print(f"{len(self.relative_tokens) = }")
1113
+ for token in self.relative_tokens:
1114
+ count += 1
1115
+ if token.get("incomplete", False):
1116
+ type = token["t"]
1117
+ need = (
1118
+ f"required: {', '.join(needed)}\n" if needed and type == "@" else ""
1119
+ )
1120
+ options = []
1121
+ options = (
1122
+ [x for x in allowed_fortype if len(x) == 1]
1123
+ if type == "@"
1124
+ else [x[-1] for x in allowed_fortype if len(x) == 2]
1125
+ )
1126
+ optional = f"optional: {', '.join(options)}" if options else ""
1127
+ return fmt_error(f"{token['t']} incomplete\n{need}{optional}")
1128
+ if token["t"] == "@":
1129
+ # print(f"{token['token']}; {used_atkeys = }")
1130
+ used_ampkeys = []
1131
+ this_atkey = token["k"]
1132
+ log_msg(f"{this_atkey = }")
1133
+ if this_atkey not in all_keys:
1134
+ return fmt_error(f"@{this_atkey}, Unrecognized @-key")
1135
+ if this_atkey not in allowed_fortype:
1136
+ return fmt_error(
1137
+ f"@{this_atkey}, The use of this @-key is not supported in type '{self.itemtype}' reminders"
1138
+ )
1139
+ if this_atkey in used_atkeys and this_atkey not in multiple_allowed:
1140
+ return fmt_error(
1141
+ f"@{current_atkey}, Multiple instances of this @-key are not allowed"
1142
+ )
1143
+ current_atkey = this_atkey
1144
+ used_atkeys.append(current_atkey)
1145
+ if this_atkey in ["r", "~"]:
1146
+ used_atkeys.append(f"{current_atkey}{current_atkey}")
1147
+ used_ampkeys = []
1148
+ if current_atkey in needed:
1149
+ needed.remove(current_atkey)
1150
+ if current_atkey in requires:
1151
+ for _key in requires[current_atkey]:
1152
+ if _key not in used_atkeys and _key not in needed:
1153
+ needed.append(_key)
1154
+ elif token["t"] == "&":
1155
+ this_ampkey = f"{current_atkey}{token['k']}"
1156
+ log_msg(f"{current_atkey = }, {this_ampkey = }")
1157
+ if current_atkey not in ["r", "~"]:
1158
+ return fmt_error(
1159
+ f"&{token['k']}, The use of &-keys is not supported for @{current_atkey}"
1160
+ )
1161
+
1162
+ if this_ampkey not in all_keys:
1163
+ return fmt_error(
1164
+ f"&{token['k']}, This &-key is not supported for @{current_atkey}"
1165
+ )
1166
+ if this_ampkey in used_ampkeys and this_ampkey not in multiple_allowed:
1167
+ return fmt_error(
1168
+ f"&{this_ampkey}, Multiple instances of this &-key are not supported"
1169
+ )
1170
+ used_ampkeys.append(this_ampkey)
1171
+
1172
+ if needed:
1173
+ needed_keys = ", ".join("@" + k for k in needed)
1174
+ needed_msg = (
1175
+ # f"Required keys not yet provided: {needed_keys} in {self.entry = }"
1176
+ f"Required keys not yet provided: {needed_keys = }\n {used_atkeys = }, {used_ampkeys = }"
1177
+ )
1178
+ else:
1179
+ needed_msg = ""
1180
+ return needed_msg
1181
+
1182
+ def fmt_user(self, dt: date | datetime) -> str:
1183
+ """
1184
+ User friendly formatting for dates and datetimes using env settings
1185
+ for ampm, yearfirst, dayfirst and two_digit year.
1186
+ """
1187
+ # Simple user-facing formatter; tweak to match your prefs
1188
+ if isinstance(dt, datetime):
1189
+ d = dt
1190
+ if d.tzinfo == tz.UTC and not getattr(self, "final", False):
1191
+ d = d.astimezone()
1192
+ return d.strftime(self.datetimefmt)
1193
+ if isinstance(dt, date):
1194
+ return dt.strftime(self.datefmt)
1195
+ raise ValueError(f"Error: {dt} must either be a date or datetime")
1196
+
1197
+ def fmt_verbose(self, dt: date | datetime) -> str:
1198
+ """
1199
+ User friendly formatting for dates and datetimes using env settings
1200
+ for ampm, yearfirst, dayfirst and two_digit year.
1201
+ """
1202
+ # Simple user-facing formatter; tweak to match your prefs
1203
+ if isinstance(dt, datetime):
1204
+ d = dt
1205
+ if d.tzinfo == tz.UTC and not getattr(self, "final", False):
1206
+ d = d.astimezone()
1207
+ return d.strftime(f"%a, %b %-d %Y {self.timefmt} %Z")
1208
+ if isinstance(dt, date):
1209
+ return dt.strftime("%a, %b %-d %Y")
1210
+ raise ValueError(f"Error: {dt} must either be a date or datetime")
1211
+
1212
+ def fmt_compact(self, dt: datetime) -> str:
1213
+ """
1214
+ Compact formatting for dates and datetimes using env settings
1215
+ for ampm, yearfirst, dayfirst and two_digit year.
1216
+ """
1217
+ log_msg(f"formatting {dt = }")
1218
+ # Simple user-facing formatter; tweak to match your prefs
1219
+ if isinstance(dt, datetime):
1220
+ return _fmt_naive(dt)
1221
+ if isinstance(dt, date):
1222
+ return _fmt_date(dt)
1223
+ raise ValueError(f"Error: {dt} must either be a date or datetime")
1224
+
1225
+ def parse_user_dt_for_s(
1226
+ self, user_text: str
1227
+ ) -> tuple[date | datetime | None, str, str | None]:
1228
+ """
1229
+ Returns (obj, kind, tz_name_used)
1230
+ kind ∈ {'date','naive','aware','error'}
1231
+ tz_name_used: tz string ('' means local), or None for date/naive/error
1232
+ On error: (None, 'error', <message>)
1233
+ """
1234
+ core, zdir = _split_z_directive(user_text)
1235
+
1236
+ try:
1237
+ obj = parse_dt(core, dayfirst=self.dayfirst, yearfirst=self.yearfirst)
1238
+ except Exception as e:
1239
+ # return None, "error", f"Could not parse '{core}': {e.__class__.__name__}"
1240
+ return None, "error", f"Error parsing '{core}'"
1241
+
1242
+ # DATE if midnight or a pure date object
1243
+ if _is_date_only(obj) or (
1244
+ _is_datetime(obj)
1245
+ and obj.hour == obj.minute == obj.second == 0
1246
+ and obj.tzinfo is None
1247
+ ):
1248
+ if _is_datetime(obj):
1249
+ obj = obj.date()
1250
+ return obj, "date", None
1251
+
1252
+ # DATETIME
1253
+ if zdir and zdir.lower() == "none":
1254
+ # NAIVE: keep naive (strip tz if present)
1255
+ if _is_datetime(obj) and obj.tzinfo is not None:
1256
+ obj = obj.replace(tzinfo=None)
1257
+ return obj, "naive", None
1258
+
1259
+ # AWARE
1260
+ if zdir:
1261
+ zone = tz.gettz(zdir)
1262
+ if zone is None:
1263
+ # >>> HARD FAIL on invalid tz <<<
1264
+ return None, "error", f"Unknown timezone: {zdir!r}"
1265
+ tz_used = zdir
1266
+ else:
1267
+ zone = tz.tzlocal()
1268
+ tz_used = "" # '' means "local tz"
1269
+
1270
+ obj_aware = _attach_zone(obj, zone)
1271
+ obj_utc = _ensure_utc(obj_aware)
1272
+ return obj_utc, "aware", zone
1273
+
1274
+ def collect_grouped_tokens(self, anchor_keys: set[str]) -> list[list[dict]]:
1275
+ """
1276
+ Collect multiple groups of @-tokens and their immediately trailing &-tokens.
1277
+
1278
+ anchor_keys: e.g. {'r', '~', 's'} — only these @-keys start a group.
1279
+
1280
+ Returns:
1281
+ List of token groups: each group is a list of relative tokens:
1282
+ [ [anchor_tok, &tok, &tok, ...], ... ]
1283
+ """
1284
+ groups: list[list[dict]] = []
1285
+ current_group: list[dict] = []
1286
+ collecting = False
1287
+
1288
+ for token in self.relative_tokens:
1289
+ if token.get("t") == "@" and token.get("k") in anchor_keys:
1290
+ if current_group:
1291
+ groups.append(current_group)
1292
+ current_group = [token]
1293
+ collecting = True
1294
+ elif collecting and token.get("t") == "&":
1295
+ current_group.append(token)
1296
+ elif collecting:
1297
+ # hit a non-& token, close the current group
1298
+ groups.append(current_group)
1299
+ current_group = []
1300
+ collecting = False
1301
+
1302
+ if current_group:
1303
+ groups.append(current_group)
1304
+
1305
+ log_msg(f"{groups = }")
1306
+ return groups
1307
+
1308
+ def mark_grouped_tokens(self):
1309
+ """
1310
+ Build:
1311
+ - skip_token_positions: set of (s,e) spans for &-tokens that belong to an @-group,
1312
+ so your dispatcher can skip re-processing them.
1313
+ - token_group_anchors: map (s,e) of each grouped &-token -> (s,e) of its @-anchor.
1314
+ Also prepares self.token_group_map via build_token_group_map().
1315
+ """
1316
+ self.skip_token_positions = set()
1317
+ self.token_group_anchors = {}
1318
+
1319
+ anchor_keys = {"r", "~"}
1320
+
1321
+ groups = self.collect_grouped_tokens(anchor_keys)
1322
+
1323
+ for group in groups:
1324
+ anchor = group[0]
1325
+ anchor_pos = (anchor["s"], anchor["e"])
1326
+ for token in group[1:]:
1327
+ pos = (token["s"], token["e"])
1328
+ self.skip_token_positions.add(pos)
1329
+ self.token_group_anchors[pos] = anchor_pos
1330
+
1331
+ # Build the easy-to-consume map (e.g., token_group_map['s'] -> [("z","CET")])
1332
+ self.build_token_group_map(groups)
1333
+
1334
+ def build_token_group_map(self, groups: list[list[dict]]):
1335
+ """
1336
+ Convert grouped tokens into a simple dict:
1337
+ self.token_group_map = {
1338
+ 'r': [('i','2'), ('c','10'), ...],
1339
+ 's': [('z','CET'), ...],
1340
+ '~': [('f','20250824T120000'), ...],
1341
+ }
1342
+ Keys are only present if that @-anchor appears in self.relative_tokens.
1343
+ """
1344
+ tgm: dict[str, list[tuple[str, str]]] = {}
1345
+ for group in groups:
1346
+ anchor = group[0]
1347
+ if anchor.get("t") != "@":
1348
+ continue
1349
+ akey = anchor.get("k") # 'r', '~', or 's'
1350
+ if not akey:
1351
+ continue
1352
+ pairs: list[tuple[str, str]] = []
1353
+ for tok in group[1:]:
1354
+ if tok.get("t") != "&":
1355
+ continue
1356
+ k = (tok.get("k") or "").strip()
1357
+ # raw value after '&x ':
1358
+ try:
1359
+ _, v = tok["token"].split(" ", 1)
1360
+ v = v.strip()
1361
+ except Exception as e:
1362
+ log_msg(f"error: {e = }")
1363
+ v = ""
1364
+ pairs.append((k, v))
1365
+ if pairs:
1366
+ tgm.setdefault(akey, []).extend(pairs)
1367
+
1368
+ log_msg(f"token_group_map {tgm = }")
1369
+
1370
+ self.token_group_map = tgm
1371
+
1372
+ def add_token(self, token: dict):
1373
+ """
1374
+ keys: token (entry str), t (type: itemtype, subject, @, &),
1375
+ k (key: a, b, c, d, ... for type @ and &.
1376
+ type itemtype and subject have no key)
1377
+ add_token takes a token dict and
1378
+ 1) appends the token as is to self.relative_tokens
1379
+ 2) extract the token, t and k fields, expands the datetime value(s) for k in list("sf+-")
1380
+ and appends the resulting dict to self.stored_tokens
1381
+ """
1382
+
1383
+ self.tokens.append(token)
1384
+
1385
+ def _tokenize(self, entry: str):
1386
+ log_msg(f"_tokenize {entry = }")
1387
+
1388
+ self.entry = entry
1389
+ self.errors = []
1390
+ self.tokens = []
1391
+ self.messages = []
1392
+
1393
+ if not entry:
1394
+ self.messages.append(
1395
+ (False, ": ".join(Item.token_keys["itemtype"][:2]), [])
1396
+ )
1397
+ return
1398
+
1399
+ self.relative_tokens = []
1400
+ self.stored_tokens = []
1401
+
1402
+ # First: itemtype
1403
+ itemtype = entry[0]
1404
+ if itemtype not in {"*", "~", "^", "%", "+", "?"}:
1405
+ self.messages.append(
1406
+ (
1407
+ False,
1408
+ f"Invalid itemtype '{itemtype}' (expected *, ~, ^, %, + or ?)",
1409
+ [],
1410
+ )
1411
+ )
1412
+ return
1413
+
1414
+ self.relative_tokens.append(
1415
+ {"token": itemtype, "s": 0, "e": 1, "t": "itemtype"}
1416
+ )
1417
+ self.itemtype = itemtype
1418
+
1419
+ rest = entry[1:].lstrip()
1420
+ offset = 1 + len(entry[1:]) - len(rest)
1421
+
1422
+ # Find start of first @-key to get subject
1423
+ at_pos = rest.find("@")
1424
+ subject = rest[:at_pos].strip() if at_pos != -1 else rest
1425
+ if subject:
1426
+ start = offset
1427
+ end = offset + len(subject) + 1 # trailing space
1428
+ subject_token = subject + " "
1429
+ self.relative_tokens.append(
1430
+ {"token": subject_token, "s": start, "e": end, "t": "subject"}
1431
+ )
1432
+ self.subject = subject
1433
+ else:
1434
+ self.errors.append("Missing subject")
1435
+
1436
+ remainder = rest[len(subject) :]
1437
+
1438
+ pattern = (
1439
+ r"(?:(?<=^)|(?<=\s))(@[\w~+\-]+ [^@&]+)|(?:(?<=^)|(?<=\s))(&\w+ [^@&]+)"
1440
+ )
1441
+ for match in re.finditer(pattern, remainder):
1442
+ token = match.group(0)
1443
+ start_pos = match.start() + offset + len(subject)
1444
+ end_pos = match.end() + offset + len(subject)
1445
+
1446
+ token_type = "@" if token.startswith("@") else "&"
1447
+ key = token[1:3].strip()
1448
+ self.relative_tokens.append(
1449
+ {
1450
+ "token": token,
1451
+ "s": start_pos,
1452
+ "e": end_pos,
1453
+ "t": token_type,
1454
+ "k": key,
1455
+ }
1456
+ )
1457
+
1458
+ # Detect and append a potential partial token at the end
1459
+ partial_token = None
1460
+ if entry.endswith("@") or re.search(r"@([a-zA-Z])$", entry):
1461
+ match = re.search(r"@([a-zA-Z]?)$", entry)
1462
+ if match:
1463
+ partial_token = {
1464
+ "token": "@" + match.group(1),
1465
+ "s": len(entry) - len(match.group(0)),
1466
+ "e": len(entry),
1467
+ "t": "@",
1468
+ "k": match.group(1),
1469
+ "incomplete": True,
1470
+ }
1471
+
1472
+ elif entry.endswith("&") or re.search(r"&([a-zA-Z]+)$", entry):
1473
+ match = re.search(r"&([a-zA-Z]*)$", entry)
1474
+ if match:
1475
+ # Optionally find parent group (r or j)
1476
+ parent = None
1477
+ for tok in reversed(self.relative_tokens):
1478
+ if tok["t"] == "@" and tok["k"] in ["r", "~"]:
1479
+ parent = tok["k"]
1480
+ break
1481
+ partial_token = {
1482
+ "token": "&" + match.group(1),
1483
+ "s": len(entry) - len(match.group(0)),
1484
+ "e": len(entry),
1485
+ "t": "&",
1486
+ "k": match.group(1),
1487
+ "parent": parent,
1488
+ "incomplete": True,
1489
+ }
1490
+
1491
+ if partial_token:
1492
+ self.relative_tokens.append(partial_token)
1493
+
1494
+ def _parse_tokens(self, entry: str):
1495
+ if not self.previous_entry:
1496
+ self._parse_all_tokens()
1497
+ return
1498
+
1499
+ self.mark_grouped_tokens()
1500
+
1501
+ changes = self._find_changes(self.previous_entry, entry)
1502
+ affected_tokens = self._identify_affected_tokens(changes)
1503
+
1504
+ dispatched_anchors = set()
1505
+
1506
+ for token in affected_tokens:
1507
+ start_pos, end_pos = token["s"], token["e"]
1508
+ log_msg(f"{start_pos = }, {end_pos = }, {len(entry) = }, {token = }")
1509
+ if not self._token_has_changed(token):
1510
+ continue
1511
+
1512
+ if (start_pos, end_pos) in self.skip_token_positions:
1513
+ continue # don't dispatch grouped & tokens alone
1514
+
1515
+ if (start_pos, end_pos) in self.token_group_anchors:
1516
+ anchor_pos = self.token_group_anchors[(start_pos, end_pos)]
1517
+ if anchor_pos in dispatched_anchors:
1518
+ continue
1519
+ anchor_token_info = next(
1520
+ t for t in self.tokens if (t[1], t[2]) == anchor_pos
1521
+ )
1522
+ token_str, anchor_start, anchor_end = anchor_token_info
1523
+ token_type = token["k"]
1524
+
1525
+ log_msg(
1526
+ f"{anchor_start = }, {anchor_end = }, {len(entry) = }, {token_str = }"
1527
+ )
1528
+ self._dispatch_token(token_str, anchor_start, anchor_end, token_type)
1529
+ dispatched_anchors.add(anchor_pos)
1530
+ continue
1531
+
1532
+ if start_pos == 0:
1533
+ self._dispatch_token(token, start_pos, end_pos, "itemtype")
1534
+ elif start_pos == 2:
1535
+ self._dispatch_token(token, start_pos, end_pos, "subject")
1536
+ else:
1537
+ log_msg(f"{end_pos = }, {len(entry) = }")
1538
+ token_type = token["k"]
1539
+ self._dispatch_token(token, start_pos, end_pos, token_type)
1540
+
1541
+ def _parse_all_tokens(self):
1542
+ self.mark_grouped_tokens()
1543
+
1544
+ dispatched_anchors = set()
1545
+ self.stored_tokens = []
1546
+
1547
+ for token in self.relative_tokens:
1548
+ # print(f"parsing {token = }")
1549
+ start_pos, end_pos = token["s"], token["e"]
1550
+ if token.get("k", "") in ["+", "-", "s", "f"]:
1551
+ log_msg(f"identified @+ {token = }")
1552
+ if (start_pos, end_pos) in self.skip_token_positions:
1553
+ continue # skip component of a group
1554
+
1555
+ if (start_pos, end_pos) in self.token_group_anchors:
1556
+ anchor_pos = self.token_group_anchors[(start_pos, end_pos)]
1557
+ if anchor_pos in dispatched_anchors:
1558
+ continue
1559
+ anchor_token_info = next(
1560
+ t for t in self.tokens if (t[1], t[2]) == anchor_pos
1561
+ )
1562
+ token_str, anchor_start, anchor_end = anchor_token_info
1563
+ token_type = token["k"]
1564
+ self._dispatch_token(token_str, anchor_start, anchor_end, token_type)
1565
+ dispatched_anchors.add(anchor_pos)
1566
+ continue
1567
+
1568
+ if start_pos == 0:
1569
+ self._dispatch_token(token, start_pos, end_pos, "itemtype")
1570
+ elif start_pos == 2:
1571
+ self._dispatch_token(token, start_pos, end_pos, "subject")
1572
+ elif "k" in token:
1573
+ token_type = token["k"]
1574
+ self._dispatch_token(token, start_pos, end_pos, token_type)
1575
+
1576
+ def _find_changes(self, previous: str, current: str):
1577
+ # Find the range of changes between the previous and current strings
1578
+ start = 0
1579
+ while (
1580
+ start < len(previous)
1581
+ and start < len(current)
1582
+ and previous[start] == current[start]
1583
+ ):
1584
+ start += 1
1585
+
1586
+ end_prev = len(previous)
1587
+ end_curr = len(current)
1588
+
1589
+ while (
1590
+ end_prev > start
1591
+ and end_curr > start
1592
+ and previous[end_prev - 1] == current[end_curr - 1]
1593
+ ):
1594
+ end_prev -= 1
1595
+ end_curr -= 1
1596
+
1597
+ return start, end_curr
1598
+
1599
+ def _identify_affected_tokens(self, changes):
1600
+ start, end = changes
1601
+ affected_tokens = []
1602
+ for token in self.relative_tokens:
1603
+ start_pos, end_pos = token["s"], token["e"]
1604
+ if start <= end_pos and end >= start_pos:
1605
+ affected_tokens.append(token)
1606
+ return affected_tokens
1607
+
1608
+ def _token_has_changed(self, token):
1609
+ return token not in self.previous_tokens
1610
+
1611
+ def _dispatch_token(self, token, start_pos, end_pos, token_type):
1612
+ log_msg(f"dispatch_token {token = }")
1613
+ if token_type in self.token_keys:
1614
+ method_name = self.token_keys[token_type][2]
1615
+ method = getattr(self, method_name)
1616
+ # log_msg(f"{method_name = } returned {method = }")
1617
+ is_valid, result, sub_tokens = method(token)
1618
+ self.last_result = (is_valid, result, token)
1619
+ log_msg(f"{is_valid = }, {result = }, {sub_tokens = }")
1620
+ if is_valid:
1621
+ self.parse_ok = is_valid
1622
+ else:
1623
+ self.parse_ok = False
1624
+ log_msg(f"Error processing '{token_type}': {result}")
1625
+ else:
1626
+ self.parse_ok = False
1627
+ log_msg(f"No handler for token: {token}")
1628
+
1629
+ def _extract_job_node_and_summary(self, text):
1630
+ log_msg(f"{text = }")
1631
+ match = JOB_PATTERN.match(text)
1632
+ if match:
1633
+ number = len(match.group(1)) // 2
1634
+ summary = match.group(2).rstrip()
1635
+ content = match.group(3)
1636
+ if content:
1637
+ # the leading space is needed for parsing
1638
+ content = f" {content}"
1639
+ return number, summary, content
1640
+ return None, text # If no match, return None for number and the entire string
1641
+
1642
+ @classmethod
1643
+ def from_dict(cls, data: dict):
1644
+ # Reconstruct the entry string from tokens
1645
+ entry_str = " ".join(t["token"] for t in json.loads(data["tokens"]))
1646
+ return cls(entry_str)
1647
+
1648
+ @classmethod
1649
+ def from_item(cls, data: dict):
1650
+ # Reconstruct the entry string from tokens
1651
+ entry_str = " ".join(t["token"] for t in json.loads(data["tokens"]))
1652
+ return cls(entry_str)
1653
+
1654
+ @classmethod
1655
+ def do_itemtype(cls, token):
1656
+ # Process subject token
1657
+ if "t" in token and token["t"] == "itemtype":
1658
+ return True, token["token"].strip(), []
1659
+ else:
1660
+ return False, "itemtype cannot be empty", []
1661
+
1662
+ @classmethod
1663
+ def do_summary(cls, token):
1664
+ # Process subject token
1665
+ if "t" in token and token["t"] == "subject":
1666
+ return True, token["token"].strip(), []
1667
+ else:
1668
+ return False, "subject cannot be empty", []
1669
+
1670
+ @classmethod
1671
+ def do_duration(cls, arg: str):
1672
+ """ """
1673
+ if not arg:
1674
+ return False, f"time period {arg}"
1675
+ ok, res = timedelta_str_to_seconds(arg)
1676
+ return ok, res
1677
+
1678
+ def do_priority(self, token):
1679
+ # Process datetime token
1680
+ x = re.sub("^@. ", "", token["token"].strip()).lower()
1681
+ try:
1682
+ y = int(x)
1683
+ if 1 <= y <= 5:
1684
+ self.priority = y
1685
+ # print(f"set {self.priority = }")
1686
+ return True, y, []
1687
+ else:
1688
+ return False, x, []
1689
+ except ValueError:
1690
+ print(f"failed priority {token = }, {x = }")
1691
+ return False, x, []
1692
+
1693
+ def do_notice(self, token):
1694
+ # Process datetime token
1695
+ notice = re.sub("^[@&]. ", "", token["token"].strip()).lower()
1696
+ # notice = re.sub("^@. ", "", token["token"].strip()).lower()
1697
+
1698
+ ok, notice_obj = timedelta_str_to_seconds(notice)
1699
+ log_msg(f"{token = }, {ok = }, {notice_obj = }")
1700
+ if ok:
1701
+ self.notice = notice
1702
+ return True, notice_obj, []
1703
+ else:
1704
+ log_msg(f"failed to set self.notice: {notice = }, {notice_obj = }")
1705
+ return False, notice_obj, []
1706
+
1707
+ def do_extent(self, token):
1708
+ # Process datetime token
1709
+ extent = re.sub("^[@&]. ", "", token["token"].strip()).lower()
1710
+ ok, extent_obj = timedelta_str_to_seconds(extent)
1711
+ log_msg(f"{token = }, {ok = }, {extent_obj = }")
1712
+ if ok:
1713
+ self.extent = extent
1714
+ return True, extent_obj, []
1715
+ else:
1716
+ return False, extent_obj, []
1717
+
1718
+ def do_wrap(self, token):
1719
+ _w = re.sub("^@. ", "", token["token"].strip()).lower()
1720
+ _w_parts = [x.strip() for x in _w.split(",")]
1721
+ if len(_w_parts) != 2:
1722
+ return False, f"Invalid: {_w_parts}", []
1723
+ wrap = []
1724
+ msgs = []
1725
+
1726
+ ok, _b_obj = timedelta_str_to_seconds(_w_parts[0])
1727
+ if ok:
1728
+ wrap.append(_b_obj)
1729
+ else:
1730
+ msgs.append(f"Error parsing before {_b_obj}")
1731
+
1732
+ ok, _a_obj = timedelta_str_to_seconds(_w_parts[1])
1733
+ if ok:
1734
+ wrap.append(_a_obj)
1735
+ else:
1736
+ msgs.append(f"Error parsing after {_a_obj}")
1737
+ if msgs:
1738
+ return False, ", ".join(msgs), []
1739
+ self.wrap = wrap
1740
+ return True, wrap, []
1741
+
1742
+ def do_alert(self, token):
1743
+ """
1744
+ Process an alert string, validate it and return a corresponding string
1745
+ """
1746
+
1747
+ alert = token["token"][2:].strip()
1748
+
1749
+ parts = [x.strip() for x in alert.split(":")]
1750
+ if len(parts) != 2:
1751
+ return False, f"Invalid alert format: {alert}", []
1752
+ timedeltas, commands = parts
1753
+ secs = []
1754
+ tds = []
1755
+ cmds = []
1756
+ probs = []
1757
+ issues = []
1758
+ res = ""
1759
+ ok = True
1760
+ for cmd in [x.strip() for x in commands.split(",")]:
1761
+ if is_lowercase_letter(cmd):
1762
+ cmds.append(cmd)
1763
+ else:
1764
+ ok = False
1765
+ probs.append(f" Invalid command: {cmd}")
1766
+ for td in [x.strip() for x in timedeltas.split(",")]:
1767
+ ok, td_seconds = timedelta_str_to_seconds(td)
1768
+ if ok:
1769
+ secs.append(str(td_seconds))
1770
+ tds.append(td)
1771
+ else:
1772
+ ok = False
1773
+ probs.append(f" Invalid timedelta: {td}")
1774
+ if ok:
1775
+ res = f"{', '.join(tds)}: {', '.join(cmds)}"
1776
+ self.alerts.append(res)
1777
+ else:
1778
+ issues.append("; ".join(probs))
1779
+ if issues:
1780
+ return False, "\n".join(issues), []
1781
+ return True, res, []
1782
+
1783
+ def do_requires(self, token):
1784
+ """
1785
+ Process a requires string for a job.
1786
+ Format:
1787
+ N
1788
+ or
1789
+ N:M[,K...]
1790
+ where N is the primary id, and M,K,... are dependency ids.
1791
+
1792
+ Returns:
1793
+ (True, "", primary, dependencies) on success
1794
+ (False, "error message", None, None) on failure
1795
+ """
1796
+ requires = token["token"][2:].strip()
1797
+
1798
+ try:
1799
+ if ":" in requires:
1800
+ primary_str, deps_str = requires.split(":", 1)
1801
+ primary = int(primary_str.strip())
1802
+ dependencies = []
1803
+ for part in deps_str.split(","):
1804
+ part = part.strip()
1805
+ if part == "":
1806
+ continue
1807
+ try:
1808
+ dependencies.append(int(part))
1809
+ except ValueError:
1810
+ return (
1811
+ False,
1812
+ f"Invalid dependency value: '{part}' in token '{requires}'",
1813
+ [],
1814
+ )
1815
+ else:
1816
+ primary = int(requires.strip())
1817
+ dependencies = []
1818
+ except ValueError as e:
1819
+ return (
1820
+ False,
1821
+ f"Invalid requires token: '{requires}' ({e})",
1822
+ [],
1823
+ )
1824
+
1825
+ return True, primary, dependencies
1826
+
1827
+ def do_description(self, token):
1828
+ description = re.sub("^@. ", "", token["token"])
1829
+ log_msg(f"{token = }, {description = }")
1830
+ if not description:
1831
+ return False, "missing description", []
1832
+ if description:
1833
+ self.description = description
1834
+ # print(f"{self.description = }")
1835
+ return True, description, []
1836
+ else:
1837
+ return False, description, []
1838
+
1839
+ def do_nothing(self, token):
1840
+ return True, "passed", []
1841
+
1842
+ def do_tag(self, token):
1843
+ # Process datetime token
1844
+ tag = re.sub("^@. ", "", token["token"].strip())
1845
+
1846
+ if tag:
1847
+ self.tags.append(tag)
1848
+ # print(f"{self.tags = }")
1849
+ return True, tag, []
1850
+ else:
1851
+ return False, tag, []
1852
+
1853
+ @classmethod
1854
+ def do_paragraph(cls, arg):
1855
+ """
1856
+ Remove trailing whitespace.
1857
+ """
1858
+ obj = None
1859
+ rep = arg
1860
+ para = [x.rstrip() for x in arg.split("\n")]
1861
+ if para:
1862
+ all_ok = True
1863
+ obj_lst = []
1864
+ rep_lst = []
1865
+ for p in para:
1866
+ try:
1867
+ res = str(p)
1868
+ obj_lst.append(res)
1869
+ rep_lst.append(res)
1870
+ except Exception as e:
1871
+ log_msg(f"error: {e}")
1872
+ all_ok = False
1873
+ rep_lst.append(f"~{arg}~")
1874
+
1875
+ obj = "\n".join(obj_lst) if all_ok else False
1876
+ rep = "\n".join(rep_lst)
1877
+ if obj:
1878
+ return True, obj
1879
+ else:
1880
+ return False, rep
1881
+
1882
+ @classmethod
1883
+ def do_stringlist(cls, args: List[str]):
1884
+ """
1885
+ >>> do_stringlist('')
1886
+ (None, '')
1887
+ >>> do_stringlist('red')
1888
+ (['red'], 'red')
1889
+ >>> do_stringlist('red, green, blue')
1890
+ (['red', 'green', 'blue'], 'red, green, blue')
1891
+ >>> do_stringlist('Joe Smith <js2@whatever.com>')
1892
+ (['Joe Smith <js2@whatever.com>'], 'Joe Smith <js2@whatever.com>')
1893
+ """
1894
+ obj = None
1895
+ rep = args
1896
+ if args:
1897
+ args = [x.strip() for x in args.split(",")]
1898
+ all_ok = True
1899
+ obj_lst = []
1900
+ rep_lst = []
1901
+ for arg in args:
1902
+ try:
1903
+ res = str(arg)
1904
+ obj_lst.append(res)
1905
+ rep_lst.append(res)
1906
+ except Exception as e:
1907
+ log_msg(f"error: {e}")
1908
+ all_ok = False
1909
+ rep_lst.append(f"~{arg}~")
1910
+ obj = obj_lst if all_ok else None
1911
+ rep = ", ".join(rep_lst)
1912
+ return obj, rep
1913
+
1914
+ def do_string(self, token):
1915
+ obj = rep = token["token"][2:].strip()
1916
+ return obj, rep, []
1917
+
1918
+ def do_timezone(self, token: dict):
1919
+ """Handle @z timezone declaration in user input."""
1920
+ tz_str = token["token"][2:].strip()
1921
+ # print(f"do_timezone: {tz_str = }")
1922
+ if tz_str.lower() in {"none", "naive"}:
1923
+ self.timezone = None
1924
+ self.tz_str = "none"
1925
+ return True, None, []
1926
+ try:
1927
+ self.timezone = ZoneInfo(tz_str)
1928
+ self.tz_str = self.timezone.key
1929
+ return True, self.timezone, []
1930
+ except Exception as e:
1931
+ log_msg(f"error: {e}")
1932
+ self.timezone = None
1933
+ self.tz_str = ""
1934
+ return False, f"Invalid timezone: '{tz_str}'", []
1935
+
1936
+ def do_rrule(self, token):
1937
+ """
1938
+ Handle an @r ... group. `token` may be a token dict or the raw token string.
1939
+ This only validates / records RRULE components; RDATE/EXDATE are added later
1940
+ by finalize_rruleset().
1941
+ Returns (ok: bool, message: str, extras: list).
1942
+ """
1943
+ log_msg(f"in do_rrule: {token = }")
1944
+
1945
+ # Normalize input to raw text
1946
+ tok_text = token.get("token") if isinstance(token, dict) else str(token)
1947
+
1948
+ # Find the matching @r group (scan all groups first)
1949
+ group = None
1950
+ r_groups = list(self.collect_grouped_tokens({"r"}))
1951
+ for g in r_groups:
1952
+ if g and g[0].get("token") == tok_text:
1953
+ group = g
1954
+ break
1955
+
1956
+ # Only after scanning all groups decide if it's missing
1957
+ if group is None:
1958
+ msg = (False, f"No matching @r group found for token: {tok_text}", [])
1959
+ self.messages.append(msg)
1960
+ return msg
1961
+
1962
+ # Parse frequency from the anchor token "@r d|w|m|y"
1963
+ anchor = group[0]
1964
+ parts = anchor["token"].split(maxsplit=1)
1965
+ if len(parts) < 2:
1966
+ msg = (False, f"Missing rrule frequency: {tok_text}", [])
1967
+ self.messages.append(msg)
1968
+ return msg
1969
+
1970
+ freq_code = parts[1].strip().lower()
1971
+ if freq_code not in self.freq_map:
1972
+ keys = ", ".join(f"{k} ({v})" for k, v in self.freq_map.items())
1973
+ msg = (
1974
+ False,
1975
+ f"'{freq_code}' is not a supported frequency. Choose from:\n {keys}",
1976
+ [],
1977
+ )
1978
+ self.messages.append(msg)
1979
+ return msg
1980
+
1981
+ # Record a normalized RRULE "component" for your builder
1982
+ # (Keep this lightweight. Don't emit RDATE/EXDATE here.)
1983
+ self.rrule_tokens.append(
1984
+ {"token": f"{self.freq_map[freq_code]}", "t": "&", "k": "FREQ"}
1985
+ )
1986
+
1987
+ log_msg(f"{self.rrule_tokens = } processing remaining tokens")
1988
+ # Parse following &-tokens in this @r group (e.g., &i 3, &c 10, &u 20250101, &m..., &w..., &d...)
1989
+ for t in group[1:]:
1990
+ tstr = t.get("token", "")
1991
+ try:
1992
+ key, value = tstr[1:].split(maxsplit=1) # strip leading '&'
1993
+ key = key.upper().strip()
1994
+ value = value.strip()
1995
+ except Exception as e:
1996
+ log_msg(f"error: {e}")
1997
+ continue
1998
+
1999
+ self.rrule_tokens.append({"token": tstr, "t": "&", "k": key, "v": value})
2000
+
2001
+ log_msg(f"got {self.rrule_tokens = }")
2002
+ return (True, "", [])
2003
+
2004
+ def do_s(self, token: dict):
2005
+ """
2006
+ Parse @s, honoring optional trailing 'z <tz>' directive inside the value.
2007
+ Updates self.dtstart_str and self.rdstart_str to seed recurrence.
2008
+ """
2009
+ try:
2010
+ raw = token["token"][2:].strip()
2011
+ if not raw:
2012
+ return False, "Missing @s value", []
2013
+
2014
+ obj, kind, tz_used = self.parse_user_dt_for_s(raw)
2015
+ if kind == "error":
2016
+ return False, tz_used or f"Invalid @s value: {raw}", []
2017
+
2018
+ userfmt = self.fmt_user(obj)
2019
+ verbosefmt = self.fmt_verbose(obj)
2020
+
2021
+ if kind == "date":
2022
+ compact = self._serialize_date(obj)
2023
+ self.s_kind = "date"
2024
+ self.s_tz = None
2025
+ elif kind == "naive":
2026
+ compact = self._serialize_naive_dt(obj)
2027
+ self.s_kind = "naive"
2028
+ self.s_tz = None
2029
+ else: # aware
2030
+ compact = self._serialize_aware_dt(obj, tz_used)
2031
+ self.s_kind = "aware"
2032
+ self.s_tz = tz_used # '' == local
2033
+
2034
+ self.dtstart = compact
2035
+ self.dtstart_str = (
2036
+ f"DTSTART:{compact}"
2037
+ if kind != "date"
2038
+ else f"DTSTART;VALUE=DATE:{compact}"
2039
+ )
2040
+ self.rdstart_str = f"RDATE:{compact}"
2041
+ token["token"] = f"@s {userfmt}"
2042
+ log_msg(f"@s --- {token = }")
2043
+ retval = userfmt if self.final else verbosefmt
2044
+
2045
+ return True, retval, []
2046
+
2047
+ except Exception as e:
2048
+ return False, f"Invalid @s value: {e}", []
2049
+
2050
+ def do_b(self, token: dict) -> Tuple[bool, str, List[str]]:
2051
+ """
2052
+ Live resolver for '@b Leaf/Parent/.../Root' (leaf→root, '/' only).
2053
+ - If matches exist: preview; auto-lock when unique/exact.
2054
+ - If no matches: show per-segment status, e.g. 'Churchill (new)/quotations/library'.
2055
+ """
2056
+ path = token["token"][2:].strip() # strip '@b'
2057
+ rev_dict = self.get_name_to_binpath() # {leaf_lower: "Leaf/.../Root"}
2058
+ path = token["token"][2:].strip() # after '@b'
2059
+ parts = [p.strip() for p in path.split("/") if p.strip()]
2060
+
2061
+ # Batch/final or no controller dict → one-shot resolve
2062
+ if self.final or not self.get_name_to_binpath():
2063
+ if not parts:
2064
+ return False, "Missing bin path after @b", []
2065
+ norm = "/".join(parts) # Leaf/Parent/.../Root
2066
+ token["token"] = f"@b {parts[0]}" # keep prefix; no decoration
2067
+ if not token.get("_b_resolved"): # append ONCE (batch runs once anyway)
2068
+ self.bin_paths.append(parts) # store Leaf→…→Root parts
2069
+ token["_b_resolved"] = True
2070
+ return True, token["token"], []
2071
+
2072
+ # Fallback for batch/final or if controller not wired
2073
+ if not rev_dict:
2074
+ if not path:
2075
+ return False, "Missing bin path after @b", []
2076
+ parts = [p.strip() for p in (path or "").split("/") if p.strip()]
2077
+ if not parts:
2078
+ return False, "Missing bin path after @b", []
2079
+ # keep full token; don't truncate to parts[0]
2080
+ token["token"] = f"@b {parts[0]}"
2081
+ # don't append to bin_paths here; do it on save
2082
+ return True, token["token"], []
2083
+
2084
+ raw = token.get("token", "")
2085
+ frag = raw[2:].strip() if raw.startswith("@b") else raw
2086
+
2087
+ if not frag:
2088
+ msg = "@b Type bin as Leaf/Parent/…"
2089
+ token["token"] = msg
2090
+ token.pop("_b_resolved", None)
2091
+ token.pop("_b_new", None)
2092
+ return True, msg, []
2093
+
2094
+ paths = list(rev_dict.values()) # existing reversed paths
2095
+ matches = _ordered_prefix_matches(paths, frag, limit=24)
2096
+
2097
+ if matches:
2098
+ nf = _norm(frag)
2099
+ exact = next((m for m in matches if _norm(m) == nf), None)
2100
+ if exact or len(matches) == 1:
2101
+ resolved = exact or matches[0]
2102
+ token["token"] = f"@b {resolved}"
2103
+ token["_b_new"] = False
2104
+ token["_b_resolved"] = True
2105
+ return True, token["token"], []
2106
+ # ambiguous → preview + suggestions
2107
+ lcp = _lcp(matches)
2108
+ preview = lcp if lcp and len(lcp) >= len(frag) else matches[0]
2109
+ token["token"] = f"@b {preview}"
2110
+ token.pop("_b_resolved", None)
2111
+ token["_b_new"] = False
2112
+ return True, token["token"], matches
2113
+
2114
+ # ---------- No matches → per-segment feedback ----------
2115
+ parts = [p.strip() for p in frag.split("/") if p.strip()]
2116
+ leaf_to_path = {k.lower(): v for k, v in rev_dict.items()}
2117
+ leafnames = set(leaf_to_path.keys())
2118
+
2119
+ # Build a set of existing leaf-first prefixes for quick “does any path start with X?”
2120
+ # Example: for 'quotations/library/root' we add 'quotations', 'quotations/library', ...
2121
+ prefix_set = set()
2122
+ for p in paths:
2123
+ toks = p.split("/")
2124
+ for i in range(1, len(toks) + 1):
2125
+ prefix_set.add("/".join(toks[:i]).lower())
2126
+
2127
+ decorated: list[str] = []
2128
+ for i, seg in enumerate(parts):
2129
+ seg_l = seg.lower()
2130
+ if i == 0:
2131
+ # Leaf segment: does *any* existing path start with this leaf?
2132
+ starts = f"{seg_l}"
2133
+ if starts not in prefix_set and not any(
2134
+ s.startswith(starts + "/") for s in prefix_set
2135
+ ):
2136
+ decorated.append(f"{seg} (new)")
2137
+ else:
2138
+ decorated.append(seg)
2139
+ else:
2140
+ # Parent segments: if this segment is an existing leaf name, show its known ancestry
2141
+ if seg_l in leafnames:
2142
+ known = leaf_to_path[seg_l].split(
2143
+ "/"
2144
+ ) # e.g., ['quotations','library','root']
2145
+ # drop the leaf itself (known[0]) since we already have 'seg', and (optionally) drop 'root'
2146
+ tail = [x for x in known[1:] if x.lower() != "root"]
2147
+ if tail:
2148
+ decorated.append("/".join([seg] + tail))
2149
+ else:
2150
+ decorated.append(seg)
2151
+ else:
2152
+ # Not an exact leaf; if no prefixes suggest it, mark (new)
2153
+ any_prefix = any(k.startswith(seg_l) for k in leafnames)
2154
+ decorated.append(seg if any_prefix else f"{seg} (new)")
2155
+
2156
+ pretty = "@b " + "/".join(decorated)
2157
+ # Keep the actual token clean (no "(new)"); only the feedback string is decorated
2158
+ token["token"] = f"@b {parts[0]}"
2159
+ token.pop("_b_resolved", None)
2160
+ token["_b_new"] = True
2161
+ return True, pretty, []
2162
+
2163
+ def do_job(self, token):
2164
+ # Process journal token
2165
+ node, summary, tokens_remaining = self._extract_job_node_and_summary(
2166
+ token["token"]
2167
+ )
2168
+ log_msg(f"{token = }, {node = }, {summary = }, {tokens_remaining = }")
2169
+ job_params = {"~": summary}
2170
+ job_params["node"] = node
2171
+ log_msg(f"{self.job_tokens = }")
2172
+
2173
+ return True, job_params, []
2174
+
2175
+ def do_at(self):
2176
+ print("TODO: do_at() -> show available @ tokens")
2177
+
2178
+ def do_amp(self):
2179
+ print("TODO: do_amp() -> show available & tokens")
2180
+
2181
+ @classmethod
2182
+ def do_weekdays(cls, wkd_str: str):
2183
+ """
2184
+ Converts a string representation of weekdays into a list of rrule objects.
2185
+ """
2186
+ print(" ### do_weekdays ### ")
2187
+ wkd_str = wkd_str.upper()
2188
+ wkd_regex = r"(?<![\w-])([+-][1-4])?(MO|TU|WE|TH|FR|SA|SU)(?!\w)"
2189
+ matches = re.findall(wkd_regex, wkd_str)
2190
+ _ = [f"{x[0]}{x[1]}" for x in matches]
2191
+ all = [x.strip() for x in wkd_str.split(",")]
2192
+ bad = [x for x in all if x not in _]
2193
+ problem_str = ""
2194
+ problems = []
2195
+ for x in bad:
2196
+ probs = []
2197
+ i, w = cls.split_int_str(x)
2198
+ if i is not None:
2199
+ abs_i = abs(int(i))
2200
+ if abs_i > 4 or abs_i == 0:
2201
+ probs.append(f"{i} must be between -4 and -1 or between +1 and +4")
2202
+ elif not (i.startswith("+") or i.startswith("-")):
2203
+ probs.append(f"{i} must begin with '+' or '-'")
2204
+ w = w.strip()
2205
+ if not w:
2206
+ probs.append(f"Missing weekday abbreviation from {cls.wkd_str}")
2207
+ elif w not in cls.wkd_list:
2208
+ probs.append(f"{w} must be a weekday abbreviation from {cls.wkd_str}")
2209
+ if probs:
2210
+ problems.append(f"In '{x}': {', '.join(probs)}")
2211
+ else:
2212
+ # undiagnosed problem
2213
+ problems.append(f"{x} is invalid")
2214
+ if problems:
2215
+ probs = []
2216
+ probs.append(", ".join(bad))
2217
+ probs.append("\n", join(problems))
2218
+ probs_str = "\n".join(probs)
2219
+ problem_str = f"Problem entries: {probs_str}"
2220
+ good = []
2221
+ for x in matches:
2222
+ s = f"{x[0]}{x[1]}" if x[0] else f"{x[1]}"
2223
+ good.append(s)
2224
+ good_str = ",".join(good)
2225
+ if problem_str:
2226
+ return False, f"{problem_str}\n{good_str}"
2227
+ else:
2228
+ return True, f"BYDAY={good_str}"
2229
+
2230
+ def do_interval(cls, arg: int):
2231
+ """
2232
+ Process an integer interval as the rrule frequency.
2233
+ """
2234
+ try:
2235
+ arg = int(arg)
2236
+ except Exception:
2237
+ return False, "interval must be a postive integer"
2238
+ else:
2239
+ if arg < 1:
2240
+ return False, "interval must be a postive integer"
2241
+ return True, f"INTERVAL={arg}"
2242
+
2243
+ @classmethod
2244
+ def do_months(cls, arg):
2245
+ """
2246
+ Process a comma separated list of integer month numbers from 1, 2, ..., 12
2247
+ """
2248
+ print(" ### do_months ### ")
2249
+ monthsstr = (
2250
+ "months: a comma separated list of integer month numbers from 1, 2, ..., 12"
2251
+ )
2252
+ if arg:
2253
+ args = arg.split(",")
2254
+ ok, res = cls.integer_list(args, 0, 12, False, "")
2255
+ if ok:
2256
+ obj = res
2257
+ rep = f"{arg}"
2258
+ else:
2259
+ obj = None
2260
+ rep = f"invalid months: {res}. Required for {monthsstr}"
2261
+ else:
2262
+ obj = None
2263
+ rep = monthsstr
2264
+ if obj is None:
2265
+ return False, rep
2266
+
2267
+ return True, f"BYMONTH={rep}"
2268
+
2269
+ @classmethod
2270
+ def do_count(cls, arg):
2271
+ """
2272
+ Process an integer count for rrule
2273
+ """
2274
+ print(" ### do_count ### ")
2275
+ countstr = "count: an integer count for rrule, 1, 2, ... "
2276
+ if arg:
2277
+ args = arg.strip()
2278
+ ok, res = cls.integer(args, 1, None, False, "")
2279
+ if ok:
2280
+ obj = res
2281
+ rep = f"{arg}"
2282
+ else:
2283
+ obj = None
2284
+ rep = f"invalid count: {res}. Required for {countstr}"
2285
+ else:
2286
+ obj = None
2287
+ rep = countstr
2288
+ if obj is None:
2289
+ return False, rep
2290
+
2291
+ return True, f"COUNT={rep}"
2292
+
2293
+ @classmethod
2294
+ def do_monthdays(cls, arg):
2295
+ """
2296
+ Process a comma separated list of integer month day numbers from 1, 2, ..., 31
2297
+ """
2298
+ print(" ### do_monthdays ### ")
2299
+ monthdaysstr = "monthdays: a comma separated list of integer month day numbers from 1, 2, ..., 31"
2300
+ if arg:
2301
+ args = arg.split(",")
2302
+ ok, res = cls.integer_list(args, 1, 31, False, "")
2303
+ if ok:
2304
+ obj = res
2305
+ rep = f"{arg}"
2306
+ else:
2307
+ obj = None
2308
+ rep = f"invalid monthdays: {res}. Required for {monthdaysstr}"
2309
+ else:
2310
+ obj = None
2311
+ rep = monthdaysstr
2312
+ if obj is None:
2313
+ return False, rep
2314
+
2315
+ return True, f"BYMONTH={rep}"
2316
+
2317
+ @classmethod
2318
+ def do_hours(cls, arg):
2319
+ """
2320
+ Process a comma separated list of integer hour numbers from 0, 1, ..., 23
2321
+ """
2322
+ print(" ### do_hours ### ")
2323
+ hoursstr = (
2324
+ "hours: a comma separated list of integer hour numbers from 0, 1, ..., 23"
2325
+ )
2326
+ if arg:
2327
+ args = arg.split(",")
2328
+ ok, res = cls.integer_list(args, 0, 23, False, "")
2329
+ if ok:
2330
+ obj = res
2331
+ rep = f"{arg}"
2332
+ else:
2333
+ obj = None
2334
+ rep = f"invalid hours: {res}. Required for {hoursstr}"
2335
+ else:
2336
+ obj = None
2337
+ rep = hoursstr
2338
+ if obj is None:
2339
+ return False, rep
2340
+
2341
+ return True, f"BYHOUR={rep}"
2342
+
2343
+ @classmethod
2344
+ def do_minutes(cls, arg):
2345
+ """
2346
+ Process a comma separated list of integer minute numbers from 0, 2, ..., 59
2347
+ """
2348
+ print(" ### do_minutes ### ")
2349
+ minutesstr = "minutes: a comma separated list of integer minute numbers from 0, 2, ..., 59"
2350
+ if arg:
2351
+ args = arg.split(",")
2352
+ ok, res = cls.integer_list(args, 0, 59, False, "")
2353
+ if ok:
2354
+ obj = res
2355
+ rep = f"{arg}"
2356
+ else:
2357
+ obj = None
2358
+ rep = f"invalid minutes: {res}. Required for {minutesstr}"
2359
+ else:
2360
+ obj = None
2361
+ rep = minutesstr
2362
+ if obj is None:
2363
+ log_msg(f"returning False, {arg = }, {rep = }")
2364
+ return False, rep
2365
+
2366
+ log_msg(f"returning True, {arg = }, {rep = },")
2367
+ return True, f"BYMINUTE={rep}"
2368
+
2369
+ @classmethod
2370
+ def do_two_periods(cls, arg: List[str]) -> str:
2371
+ return True, "not implemented", []
2372
+
2373
+ @classmethod
2374
+ def do_mask(cls, arg: str) -> str:
2375
+ return True, "not implemented", []
2376
+
2377
+ def integer(cls, arg, min, max, zero, typ=None):
2378
+ """
2379
+ :param arg: integer
2380
+ :param min: minimum allowed or None
2381
+ :param max: maximum allowed or None
2382
+ :param zero: zero not allowed if False
2383
+ :param typ: label for message
2384
+ :return: (True, integer) or (False, message)
2385
+ >>> integer(-2, -10, 8, False, 'integer_test')
2386
+ (True, -2)
2387
+ >>> integer(-2, 0, 8, False, 'integer_test')
2388
+ (False, 'integer_test: -2 is less than the allowed minimum')
2389
+ """
2390
+ msg = ""
2391
+ try:
2392
+ arg = int(arg)
2393
+ except Exception:
2394
+ if typ:
2395
+ return False, "{}: {}".format(typ, arg)
2396
+ else:
2397
+ return False, arg
2398
+ if min is not None and arg < min:
2399
+ msg = "{} is less than the allowed minimum".format(arg)
2400
+ elif max is not None and arg > max:
2401
+ msg = "{} is greater than the allowed maximum".format(arg)
2402
+ elif not zero and arg == 0:
2403
+ msg = "0 is not allowed"
2404
+ if msg:
2405
+ if typ:
2406
+ return False, "{}: {}".format(typ, msg)
2407
+ else:
2408
+ return False, msg
2409
+ else:
2410
+ return True, arg
2411
+
2412
+ @classmethod
2413
+ def integer_list(cls, arg, min, max, zero, typ=None):
2414
+ """
2415
+ :param arg: comma separated list of integers
2416
+ :param min: minimum allowed or None
2417
+ :param max: maximum allowed or None
2418
+ :param zero: zero not allowed if False
2419
+ :param typ: label for message
2420
+ :return: (True, list of integers) or (False, messages)
2421
+ >>> integer_list([-13, -10, 0, "2", 27], -12, +20, True, 'integer_list test')
2422
+ (False, 'integer_list test: -13 is less than the allowed minimum; 27 is greater than the allowed maximum')
2423
+ >>> integer_list([0, 1, 2, 3, 4], 1, 3, True, "integer_list test")
2424
+ (False, 'integer_list test: 0 is less than the allowed minimum; 4 is greater than the allowed maximum')
2425
+ >>> integer_list("-1, 1, two, 3", None, None, True, "integer_list test")
2426
+ (False, 'integer_list test: -1, 1, two, 3')
2427
+ >>> integer_list([1, "2", 3], None, None, True, "integer_list test")
2428
+ (True, [1, 2, 3])
2429
+ """
2430
+ if type(arg) == str:
2431
+ try:
2432
+ args = [int(x) for x in arg.split(",")]
2433
+ except Exception:
2434
+ if typ:
2435
+ return False, "{}: {}".format(typ, arg)
2436
+ else:
2437
+ return False, arg
2438
+ elif type(arg) == list:
2439
+ try:
2440
+ args = [int(x) for x in arg]
2441
+ except Exception:
2442
+ if typ:
2443
+ return False, "{}: {}".format(typ, arg)
2444
+ else:
2445
+ return False, arg
2446
+ elif type(arg) == int:
2447
+ args = [arg]
2448
+ msg = []
2449
+ ret = []
2450
+ for arg in args:
2451
+ ok, res = cls.integer(arg, min, max, zero, None)
2452
+ if ok:
2453
+ ret.append(res)
2454
+ else:
2455
+ msg.append(res)
2456
+ if msg:
2457
+ if typ:
2458
+ return False, "{}: {}".format(typ, "; ".join(msg))
2459
+ else:
2460
+ return False, "; ".join(msg)
2461
+ else:
2462
+ return True, ret
2463
+
2464
+ @classmethod
2465
+ def split_int_str(cls, s):
2466
+ match = re.match(r"^([+-]?\d*)(.{1,})$", s)
2467
+ if match:
2468
+ integer_part = match.group(1)
2469
+ string_part = match.group(2)
2470
+ # Convert integer_part to an integer if it's not empty, otherwise None
2471
+ integer_part = integer_part if integer_part else None
2472
+ string_part = string_part if string_part else None
2473
+ return integer_part, string_part
2474
+ return None, None # Default case if no match is found
2475
+
2476
+ # ---- helpers you implement with your existing token machinery ----
2477
+
2478
+ def _get_first_two_occurrences(self) -> tuple[datetime | None, datetime | None]:
2479
+ """
2480
+ Return (first, second) occurrences from rruleset, which is the
2481
+ ultimate source of truth for this item's schedule.
2482
+ Always return the first two in sequence, even if they’re already past.
2483
+ """
2484
+ if not (self.rruleset or "").strip():
2485
+ return None, None
2486
+
2487
+ try:
2488
+ rs = rrulestr(self.rruleset)
2489
+ it = iter(rs)
2490
+ first = next(it, None)
2491
+ second = next(it, None)
2492
+ return first, second
2493
+ except Exception:
2494
+ return None, None
2495
+
2496
+ def _get_o_interval(self):
2497
+ """
2498
+ Return (timedelta, learn_bool) if @o present, else None.
2499
+ Expects self.over to hold the *original* @o string (e.g. '4d' or '~4d').
2500
+ """
2501
+ s = (self.over or "").strip()
2502
+ if not s:
2503
+ return None
2504
+ # FIXME: what about projects?
2505
+ learn = s.startswith("~")
2506
+ base = s[1:].strip() if learn else s
2507
+ ok, seconds = timedelta_str_to_seconds(base)
2508
+ if not ok:
2509
+ return None
2510
+
2511
+ return (timedelta(seconds=seconds), learn)
2512
+
2513
+ def _set_o_interval(self, td, learn: bool):
2514
+ """Write @o token back (e.g., '@o 4d3h ' or '@o ~4d3h ')."""
2515
+ # convert timedelta -> your TD string; use your existing helper if you have it
2516
+ seconds = int(td.total_seconds())
2517
+ # simple example: only days/hours; replace with your own formatter
2518
+ days, rem = divmod(seconds, 86400)
2519
+ hours, rem = divmod(rem, 3600)
2520
+ minutes = rem // 60
2521
+ parts = []
2522
+ if days:
2523
+ parts.append(f"{days}d")
2524
+ if hours:
2525
+ parts.append(f"{hours}h")
2526
+ if minutes:
2527
+ parts.append(f"{minutes}m")
2528
+ td_str = "".join(parts) or "0m"
2529
+
2530
+ prefix = "~" if learn else ""
2531
+ new_token_text = f"@o {prefix}{td_str} "
2532
+
2533
+ tok = next(
2534
+ (
2535
+ t
2536
+ for t in self.relative_tokens
2537
+ if t.get("t") == "@" and t.get("k") == "o"
2538
+ ),
2539
+ None,
2540
+ )
2541
+ if tok:
2542
+ tok["token"] = new_token_text
2543
+ else:
2544
+ self.relative_tokens.append({"token": new_token_text, "t": "@", "k": "o"})
2545
+ # keep original string field too, if you use it elsewhere
2546
+ self.over = f"{prefix}{td_str}"
2547
+
2548
+ def _smooth_interval(
2549
+ self, old: timedelta, new: timedelta, weight: int
2550
+ ) -> timedelta:
2551
+ # (w*old + new)/(w+1)
2552
+ total = old * weight + new
2553
+ secs = total.total_seconds() / (weight + 1)
2554
+ return timedelta(seconds=secs)
2555
+
2556
+ def _is_rdate_only(self) -> bool:
2557
+ """True if rruleset is only RDATE(+optional EXDATE), i.e. no RRULE."""
2558
+ if not self.rruleset:
2559
+ return False
2560
+ lines = [ln.strip() for ln in self.rruleset.splitlines() if ln.strip()]
2561
+ if not lines:
2562
+ return False
2563
+ # No RRULE anywhere
2564
+ if any(ln.upper().startswith("RRULE") for ln in lines):
2565
+ return False
2566
+ # At least one RDATE (either plain RDATE:... or RDATE;VALUE=DATE:...)
2567
+ has_rdate = any(ln.upper().startswith("RDATE") for ln in lines)
2568
+ return has_rdate
2569
+
2570
+ def _drop_first_rdate(self, first_dt: datetime) -> bool:
2571
+ """
2572
+ Mark the first RDATE occurrence as completed by appending an @- EXDATE token,
2573
+ then re-parse so rruleset reflects it. Return True if more RDATEs remain.
2574
+ """
2575
+ # 1) append @- token in the same textual style your parser already understands
2576
+ if first_dt.hour == 0 and first_dt.minute == 0 and first_dt.second == 0:
2577
+ ex_str = first_dt.strftime("%Y%m%d") # date-only
2578
+ else:
2579
+ ex_str = first_dt.strftime("%Y%m%dT%H%M") # datetime
2580
+
2581
+ self.relative_tokens.append({"token": f"@- {ex_str} ", "t": "@", "k": "-"})
2582
+
2583
+ # 2) re-parse to regenerate rruleset/derived fields consistently
2584
+ self._reparse_from_tokens()
2585
+
2586
+ # 3) decide if anything remains (any RDATE not excluded)
2587
+ # Quick check: do we still have any @+ token with a date/datetime != ex_str?
2588
+ remaining = False
2589
+ for tok in self.relative_tokens:
2590
+ if tok.get("t") == "@" and tok.get("k") == "+":
2591
+ body = tok["token"][2:].strip()
2592
+ for piece in (p.strip() for p in body.split(",") if p.strip()):
2593
+ if piece != ex_str:
2594
+ remaining = True
2595
+ break
2596
+ if remaining:
2597
+ break
2598
+
2599
+ return remaining
2600
+
2601
+ def _has_rrule(self) -> bool:
2602
+ """True if rruleset contains an RRULE line."""
2603
+ if not self.rruleset:
2604
+ return False
2605
+ return any(
2606
+ ln.strip().upper().startswith("RRULE") for ln in self.rruleset.splitlines()
2607
+ )
2608
+
2609
+ def _advance_dtstart_and_decrement_count(self, new_dtstart: datetime) -> None:
2610
+ # bump @s (or create)
2611
+ for tok in self.relative_tokens:
2612
+ if tok.get("t") == "@" and tok.get("k") == "s":
2613
+ tok["token"] = f"@s {new_dtstart.strftime('%Y%m%dT%H%M')} "
2614
+ break
2615
+ else:
2616
+ self.relative_tokens.append(
2617
+ {
2618
+ "token": f"@s {new_dtstart.strftime('%Y%m%dT%H%M')} ",
2619
+ "t": "@",
2620
+ "k": "s",
2621
+ }
2622
+ )
2623
+
2624
+ # decrement &c if present
2625
+ for tok in list(self.relative_tokens):
2626
+ if tok.get("t") == "&" and tok.get("k") == "c":
2627
+ try:
2628
+ parts = tok["token"].split()
2629
+ if len(parts) >= 2 and parts[0] == "&c":
2630
+ cnt = int(parts[1]) - 1
2631
+ if cnt > 0:
2632
+ tok["token"] = f"&c {cnt}"
2633
+ else:
2634
+ self.relative_tokens.remove(tok) # drop when it hits 0
2635
+ except Exception:
2636
+ pass
2637
+ break
2638
+
2639
+ # rebuild rruleset / derived fields from tokens
2640
+ self._reparse_from_tokens()
2641
+
2642
+ def _clear_schedule(self) -> None:
2643
+ """
2644
+ Clear *all* scheduling: @s, @r and its &-params, @+, @- and rruleset.
2645
+ Leaves non-scheduling tokens (subject, etc.) intact.
2646
+ """
2647
+ new_tokens = []
2648
+ dropping_group_r = False
2649
+
2650
+ for tok in self.relative_tokens:
2651
+ t = tok.get("t")
2652
+ k = tok.get("k")
2653
+
2654
+ # drop @s
2655
+ if t == "@" and k == "s":
2656
+ continue
2657
+
2658
+ # drop @+ / @-
2659
+ if t == "@" and k in {"+", "-"}:
2660
+ continue
2661
+
2662
+ # drop @r and all following & (r-params) until next non-& token
2663
+ if t == "@" and k == "r":
2664
+ dropping_group_r = True
2665
+ continue
2666
+
2667
+ if dropping_group_r:
2668
+ if t == "&": # r-parameter
2669
+ continue
2670
+ else:
2671
+ dropping_group_r = False
2672
+ # fall through to append this non-& token
2673
+
2674
+ new_tokens.append(tok)
2675
+
2676
+ self.relative_tokens = new_tokens
2677
+ self.rruleset = "" # remove compiled schedule string
2678
+
2679
+ def do_rdate(self, token: str):
2680
+ """
2681
+ Process an RDATE token, e.g., "@+ 2024-07-03 14:00, 2024-08-05 09:00".
2682
+ Uses the global timezone (set via @z) for all entries, and serializes
2683
+ them using TZID (even for UTC).
2684
+ """
2685
+ log_msg(f"processing rdate {token = }")
2686
+ try:
2687
+ # Remove the "@+" prefix and extra whitespace
2688
+ token_body = token["token"][2:].strip()
2689
+
2690
+ # Split on commas to get individual date strings
2691
+ dt_strs = [s.strip() for s in token_body.split(",") if s.strip()]
2692
+
2693
+ # Process each entry
2694
+ rdates = []
2695
+ udates = []
2696
+ for dt_str in dt_strs:
2697
+ if self.s_kind == "aware":
2698
+ dt = parse(dt_str, self.s_tz)
2699
+ dt_fmt = _fmt_utc_Z(dt)
2700
+ elif self.s_kind == "naive":
2701
+ dt = parse(dt_str)
2702
+ dt_fmt = _fmt_naive(dt)
2703
+ else:
2704
+ dt = parse(dt_str)
2705
+ dt_fmt = _fmt_date(dt)
2706
+
2707
+ if dt_fmt not in rdates:
2708
+ # print(f"added {dt_fmt = } to rdates")
2709
+ rdates.append(dt_fmt)
2710
+ udates.append(self.fmt_user(dt))
2711
+
2712
+ self.rdstart_str = f"{self.rdstart_str},{','.join(rdates)}"
2713
+ self.rdates = rdates
2714
+ self.token_map["+"] = ", ".join(udates)
2715
+ # Prepend RDATE in finalize_rruleset after possible insertion of DTSTART
2716
+ log_msg(f"{rdates = }, {self.rdstart_str = }")
2717
+ return True, rdates, []
2718
+ except Exception as e:
2719
+ return False, f"Invalid @+ value: {e}", []
2720
+
2721
+ def do_exdate(self, token: dict):
2722
+ """
2723
+ @- … : explicit exclusion dates
2724
+ - Maintain a de-duplicated list of compact dates in self.exdates.
2725
+ - finalize_rruleset() will emit EXDATE using this list in either path.
2726
+ """
2727
+ try:
2728
+ token_body = token["token"][2:].strip()
2729
+ dt_strs = [s.strip() for s in token_body.split(",") if s.strip()]
2730
+
2731
+ if not hasattr(self, "exdates") or self.exdates is None:
2732
+ self.exdates = []
2733
+
2734
+ new_ex = []
2735
+ udates = []
2736
+ for dt_str in dt_strs:
2737
+ if self.s_kind == "aware":
2738
+ dt = parse(dt_str, self.s_tz)
2739
+ dt_fmt = _fmt_utc_Z(dt)
2740
+ elif self.s_kind == "naive":
2741
+ dt = parse(dt_str)
2742
+ dt_fmt = _fmt_naive(dt)
2743
+ else:
2744
+ dt = parse(dt_str)
2745
+ dt_fmt = _fmt_date(dt)
2746
+
2747
+ if dt_fmt not in self.exdates and dt_fmt not in new_ex:
2748
+ new_ex.append(dt_fmt)
2749
+ udates.append(self.fmt_user(dt))
2750
+
2751
+ self.exdates.extend(new_ex)
2752
+ self.token_map["-"] = ", ".join(udates)
2753
+ # convenience string if you ever need it
2754
+ self.exdate_str = ",".join(self.exdates) if self.exdates else ""
2755
+
2756
+ return True, new_ex, []
2757
+ except Exception as e:
2758
+ return False, f"Invalid @- value: {e}", []
2759
+
2760
+ def collect_rruleset_tokens(self):
2761
+ """Return the list of relative tokens used for building the rruleset."""
2762
+ rruleset_tokens = []
2763
+ found_rrule = False
2764
+
2765
+ for token in self.relative_tokens:
2766
+ if not found_rrule:
2767
+ if token["t"] == "@" and token["k"] == "r":
2768
+ found_rrule = True
2769
+ rruleset_tokens.append(token) # relative token
2770
+ else:
2771
+ if token["t"] == "&":
2772
+ rruleset_tokens.append(token) # relative token
2773
+ else:
2774
+ break # stop collecting on first non-& after @r
2775
+
2776
+ return rruleset_tokens
2777
+
2778
+ def finalize_rruleset(self) -> str:
2779
+ """
2780
+ Build an rruleset string using self.relative_tokens, self.dtstart_str and self.rdstart_str.
2781
+ Emits:
2782
+ - DTSTART (if rrule is present)
2783
+ - RRULE:...
2784
+ - RDATE:... (from your rdstart_str or rdate_str)
2785
+ - EXDATE:... (if you track it)
2786
+ """
2787
+ rrule_tokens = self.collect_rruleset_tokens()
2788
+ # rrule_tokens = self.rrule_tokens
2789
+ log_msg(f"in finalize_rruleset {self.rrule_tokens = }")
2790
+ if not self.dtstart:
2791
+ return ""
2792
+
2793
+ # map @r y/m/w/d → RRULE:FREQ=...
2794
+ freq_map = {"y": "YEARLY", "m": "MONTHLY", "w": "WEEKLY", "d": "DAILY"}
2795
+ parts = rrule_tokens[0]["token"].split(maxsplit=1)
2796
+ freq_abbr = parts[1].strip() if len(parts) > 1 else ""
2797
+ freq = freq_map.get(freq_abbr.lower())
2798
+ if not freq:
2799
+ return ""
2800
+
2801
+ rrule_components = {"FREQ": freq}
2802
+
2803
+ # &-tokens
2804
+ for tok in rrule_tokens[1:]:
2805
+ token_str = tok["token"]
2806
+ try:
2807
+ key, value = token_str[1:].split(maxsplit=1) # strip leading '&'
2808
+ except Exception:
2809
+ key = tok.get("k", "")
2810
+ value = tok.get("v", "")
2811
+ # if not (key and value):
2812
+ # continue
2813
+ key = key.strip()
2814
+ value = value.strip()
2815
+ if key == "u":
2816
+ ok, res = local_dtstr_to_utc(value)
2817
+ value = res if ok else ""
2818
+ elif ", " in value:
2819
+ value = ",".join(value.split(", "))
2820
+ component = self.key_to_param.get(key, None)
2821
+ log_msg(f"components {key = }, {value = }, {component = }")
2822
+ if component:
2823
+ rrule_components[component] = value
2824
+
2825
+ rrule_line = "RRULE:" + ";".join(
2826
+ f"{k}={v}" for k, v in rrule_components.items()
2827
+ )
2828
+
2829
+ log_msg(f"{self.rrule_components = }")
2830
+
2831
+ log_msg(f"{rrule_line = }")
2832
+ # Assemble lines safely
2833
+ lines: list[str] = []
2834
+
2835
+ dtstart_str = getattr(self, "dtstart_str", "") or ""
2836
+ if dtstart_str:
2837
+ lines.append(dtstart_str)
2838
+ log_msg(f"appended dtstart_str: {lines = }")
2839
+
2840
+ if rrule_line:
2841
+ lines.append(rrule_line)
2842
+ log_msg(f"appended rrule_line: {lines = }")
2843
+ # only add the rdates from @+, not @s since we have a rrule_line
2844
+ if self.rdates:
2845
+ lines.append(f"RDATE:{','.join(self.rdates)}")
2846
+ log_msg(f"appended RDATE + rdates: {lines = }")
2847
+ else:
2848
+ # here we need to include @s since we do not have a rrule_line
2849
+ rdstart_str = getattr(self, "rdstart_str", "") or ""
2850
+ if rdstart_str:
2851
+ lines.append(rdstart_str)
2852
+ log_msg(f"appended rdstart_str: {lines = }")
2853
+
2854
+ exdate_str = getattr(self, "exdate_str", "") or ""
2855
+ if exdate_str:
2856
+ lines.append(f"EXDATE:{exdate_str}")
2857
+ log_msg(f"appended exdate_str: {lines = }")
2858
+
2859
+ log_msg(f"RETURNING {lines = }")
2860
+
2861
+ return "\n".join(lines)
2862
+
2863
+ def build_jobs(self):
2864
+ """
2865
+ Build self.jobs from @~ + &... token groups.
2866
+ Handles &r id: prereq1, prereq2, … and &f completion pairs.
2867
+ """
2868
+ job_groups = self.collect_grouped_tokens({"~"})
2869
+ job_entries = []
2870
+
2871
+ for idx, group in enumerate(job_groups, start=1):
2872
+ anchor = group[0]
2873
+ token_str = anchor["token"]
2874
+
2875
+ # job name before first &
2876
+ job_portion = token_str[3:].strip()
2877
+ split_index = job_portion.find("&")
2878
+ job_name = (
2879
+ job_portion[:split_index].strip() if split_index != -1 else job_portion
2880
+ )
2881
+
2882
+ job = {"~": job_name}
2883
+
2884
+ for token in group[1:]:
2885
+ try:
2886
+ k, v = token["token"][1:].split(maxsplit=1)
2887
+ k = k.strip()
2888
+ v = v.strip()
2889
+
2890
+ if k == "r":
2891
+ ok, primary, deps = self.do_requires({"token": f"&r {v}"})
2892
+ if not ok:
2893
+ self.errors.append(primary)
2894
+ continue
2895
+ job["id"] = primary
2896
+ job["reqs"] = deps
2897
+
2898
+ elif k == "f": # completion
2899
+ completed, due = parse_completion_value(v)
2900
+ if completed:
2901
+ job["f"] = self.fmt_compact(completed)
2902
+ self.token_map.setdefault("~f", {})
2903
+ self.token_map["~f"][job.get("id", idx)] = self.fmt_user(
2904
+ completed
2905
+ )
2906
+ if due:
2907
+ job["due"] = self.fmt_compact(due)
2908
+
2909
+ else:
2910
+ job[k] = v
2911
+
2912
+ except Exception as e:
2913
+ self.errors.append(
2914
+ f"Failed to parse job metadata token: {token['token']} ({e})"
2915
+ )
2916
+
2917
+ job_entries.append(job)
2918
+
2919
+ self.jobs = job_entries
2920
+ return job_entries
2921
+
2922
+ def finalize_jobs(self, jobs):
2923
+ """
2924
+ Compute job status (finished / available / waiting)
2925
+ using new &r id: prereqs format and propagate @f if all are done.
2926
+ """
2927
+ if not jobs:
2928
+ return False, "No jobs to process"
2929
+ if not self.parse_ok:
2930
+ return False, "Error parsing job tokens"
2931
+
2932
+ # index by id
2933
+ job_map = {j["id"]: j for j in jobs if "id" in j}
2934
+ finished = {jid for jid, j in job_map.items() if j.get("f")}
2935
+
2936
+ # --- transitive dependency expansion ---
2937
+ all_prereqs = {}
2938
+ for jid, job in job_map.items():
2939
+ deps = set(job.get("reqs", []))
2940
+ trans = set(deps)
2941
+ stack = list(deps)
2942
+ while stack:
2943
+ d = stack.pop()
2944
+ if d in job_map:
2945
+ for sd in job_map[d].get("reqs", []):
2946
+ if sd not in trans:
2947
+ trans.add(sd)
2948
+ stack.append(sd)
2949
+ all_prereqs[jid] = trans
2950
+
2951
+ # --- classify ---
2952
+ available, waiting = set(), set()
2953
+ for jid, deps in all_prereqs.items():
2954
+ unmet = deps - finished
2955
+ if jid in finished:
2956
+ continue
2957
+ if unmet:
2958
+ waiting.add(jid)
2959
+ else:
2960
+ available.add(jid)
2961
+
2962
+ # annotate job objects
2963
+ for jid, job in job_map.items():
2964
+ if jid in finished:
2965
+ job["status"] = "finished"
2966
+ elif jid in available:
2967
+ job["status"] = "available"
2968
+ elif jid in waiting:
2969
+ job["status"] = "waiting"
2970
+ else:
2971
+ job["status"] = "standalone"
2972
+
2973
+ # --- propagate @f if all jobs finished ---
2974
+ if finished and len(finished) == len(job_map):
2975
+ completed_dts = []
2976
+ for job in job_map.values():
2977
+ if "f" in job:
2978
+ cdt, _ = parse_completion_value(job["f"])
2979
+ if cdt:
2980
+ completed_dts.append(cdt)
2981
+
2982
+ if completed_dts:
2983
+ finished_dt = max(completed_dts)
2984
+ tok = {
2985
+ "token": f"@f {self.fmt_user(finished_dt)}",
2986
+ "t": "@",
2987
+ "k": "f",
2988
+ }
2989
+ self.add_token(tok)
2990
+ self.has_f = True
2991
+
2992
+ for job in job_map.values():
2993
+ job.pop("f", None)
2994
+
2995
+ # --- finalize ---
2996
+ self.jobs = list(job_map.values())
2997
+ self.jobset = json.dumps(self.jobs, cls=CustomJSONEncoder)
2998
+ return True, self.jobs
2999
+
3000
+ def finalize_jobs(self, jobs):
3001
+ """
3002
+ Compute job status (finished / available / waiting)
3003
+ using new &r id: prereqs format and propagate @f if all are done.
3004
+ Also sets a human-friendly `display_subject` per job.
3005
+ """
3006
+ if not jobs:
3007
+ return False, "No jobs to process"
3008
+ if not self.parse_ok:
3009
+ return False, "Error parsing job tokens"
3010
+
3011
+ # index by id
3012
+ job_map = {j["id"]: j for j in jobs if "id" in j}
3013
+ finished = {jid for jid, j in job_map.items() if j.get("f")}
3014
+
3015
+ # --- transitive dependency expansion ---
3016
+ all_prereqs = {}
3017
+ for jid, job in job_map.items():
3018
+ deps = set(job.get("reqs", []))
3019
+ trans = set(deps)
3020
+ stack = list(deps)
3021
+ while stack:
3022
+ d = stack.pop()
3023
+ if d in job_map:
3024
+ for sd in job_map[d].get("reqs", []):
3025
+ if sd not in trans:
3026
+ trans.add(sd)
3027
+ stack.append(sd)
3028
+ all_prereqs[jid] = trans
3029
+
3030
+ # --- classify ---
3031
+ available, waiting = set(), set()
3032
+ for jid, deps in all_prereqs.items():
3033
+ unmet = deps - finished
3034
+ if jid in finished:
3035
+ continue
3036
+ if unmet:
3037
+ waiting.add(jid)
3038
+ else:
3039
+ available.add(jid)
3040
+
3041
+ # annotate job objects with status
3042
+ for jid, job in job_map.items():
3043
+ if jid in finished:
3044
+ job["status"] = "finished"
3045
+ elif jid in available:
3046
+ job["status"] = "available"
3047
+ elif jid in waiting:
3048
+ job["status"] = "waiting"
3049
+ else:
3050
+ job["status"] = "standalone"
3051
+
3052
+ # --- compute counts for display_subject ---
3053
+ num_available = sum(
3054
+ 1 for j in job_map.values() if j.get("status") == "available"
3055
+ )
3056
+ num_waiting = sum(1 for j in job_map.values() if j.get("status") == "waiting")
3057
+ num_finished = sum(1 for j in job_map.values() if j.get("status") == "finished")
3058
+
3059
+ task_subject = getattr(self, "subject", "") or ""
3060
+ if len(task_subject) > 12:
3061
+ task_subject_display = task_subject[:10] + " …"
3062
+ else:
3063
+ task_subject_display = task_subject
3064
+
3065
+ # --- set display_subject per job (restoring old behavior) ---
3066
+ for jid, job in job_map.items():
3067
+ label = job.get("label") or job.get("~") or job.get("name") or f"#{jid}"
3068
+ # e.g. "A ∊ ParentTask 3/2/5"
3069
+ job["display_subject"] = (
3070
+ f"{label} ∊ {task_subject_display} {num_available}/{num_waiting}/{num_finished}"
3071
+ )
3072
+
3073
+ # --- propagate @f if all jobs finished ---
3074
+ if finished and len(finished) == len(job_map):
3075
+ completed_dts = []
3076
+ for job in job_map.values():
3077
+ if "f" in job:
3078
+ cdt, _ = parse_completion_value(job["f"])
3079
+ if cdt:
3080
+ completed_dts.append(cdt)
3081
+
3082
+ if completed_dts:
3083
+ finished_dt = max(completed_dts)
3084
+ tok = {
3085
+ "token": f"@f {self.fmt_user(finished_dt)}",
3086
+ "t": "@",
3087
+ "k": "f",
3088
+ }
3089
+ self.add_token(tok)
3090
+ self.has_f = True
3091
+
3092
+ # strip per-job @f tokens after promoting to record-level @f
3093
+ for job in job_map.values():
3094
+ job.pop("f", None)
3095
+
3096
+ # --- finalize ---
3097
+ self.jobs = list(job_map.values())
3098
+ self.jobset = json.dumps(self.jobs, cls=CustomJSONEncoder)
3099
+ return True, self.jobs
3100
+
3101
+ def do_completion(self, token: dict | str, *, job_id: str | None = None):
3102
+ """
3103
+ Handle both:
3104
+ @f <datetime>[, <datetime>] (task-level)
3105
+ &f <datetime>[, <datetime>] (job-level)
3106
+ """
3107
+ if not hasattr(self, "completions"):
3108
+ self.completions = [] # list[(completed_dt, due_dt, job_id|None)]
3109
+
3110
+ try:
3111
+ if isinstance(token, dict): # task-level @f
3112
+ val = token["token"][2:].strip()
3113
+ else: # job-level &f
3114
+ val = str(token).strip()
3115
+
3116
+ completed, due = parse_completion_value(val)
3117
+ if not completed:
3118
+ return False, f"Invalid completion value: {val}", []
3119
+
3120
+ self.completions.append((completed, due, job_id))
3121
+
3122
+ # ---- update token_map ----
3123
+ if job_id is None:
3124
+ # top-level task completion
3125
+ text = (
3126
+ f"@f {self.fmt_user(completed)}, {self.fmt_user(due)}"
3127
+ if due
3128
+ else f"@f {self.fmt_user(completed)}"
3129
+ )
3130
+ self.token_map["f"] = text
3131
+ self.has_f = True
3132
+ token["token"] = text
3133
+ token["t"] = "@"
3134
+ token["k"] = "f"
3135
+ return True, text, []
3136
+ else:
3137
+ # job-level completion
3138
+ self.token_map.setdefault("~f", {})
3139
+ self.token_map["~f"][job_id] = self.fmt_user(completed)
3140
+ return True, f"&f {self.fmt_user(completed)}", []
3141
+
3142
+ except Exception as e:
3143
+ return False, f"Error parsing completion token: {e}", []
3144
+
3145
+ def _serialize_date(self, d: date) -> str:
3146
+ return d.strftime("%Y%m%d")
3147
+
3148
+ def _serialize_naive_dt(self, dt: datetime) -> str:
3149
+ # ensure naive
3150
+ if dt.tzinfo is not None:
3151
+ dt = dt.replace(tzinfo=None)
3152
+ return dt.strftime("%Y%m%dT%H%M")
3153
+
3154
+ def _serialize_aware_dt(self, dt: datetime, zone) -> str:
3155
+ # Attach or convert to `zone`, then to UTC and append Z
3156
+ if dt.tzinfo is None:
3157
+ dt = dt.replace(tzinfo=zone)
3158
+ else:
3159
+ dt = dt.astimezone(zone)
3160
+ dt_utc = dt.astimezone(tz.UTC)
3161
+ return dt_utc.strftime("%Y%m%dT%H%MZ")
3162
+
3163
+ # --- these need attention - they don't take advantage of what's already in Item ---
3164
+
3165
+ def _has_s(self) -> bool:
3166
+ return any(
3167
+ tok.get("t") == "@" and tok.get("k") == "s" for tok in self.relative_tokens
3168
+ )
3169
+
3170
+ def _get_start_dt(self) -> datetime | None:
3171
+ # return self.dtstart
3172
+ tok = next(
3173
+ (
3174
+ t
3175
+ for t in self.relative_tokens
3176
+ if t.get("t") == "@" and t.get("k") == "s"
3177
+ ),
3178
+ None,
3179
+ )
3180
+ if not tok:
3181
+ return None
3182
+ val = tok["token"][2:].strip() # strip "@s "
3183
+ try:
3184
+ return parse(val)
3185
+ except Exception:
3186
+ return None
3187
+
3188
+ def _set_start_dt(self, dt: datetime | None = None):
3189
+ """Replace or add an @s token; keep your formatting with trailing space."""
3190
+ dt = dt | self._get_start_dt()
3191
+ if not dt:
3192
+ return
3193
+ ts = dt.strftime("%Y%m%dT%H%M")
3194
+ log_msg(f"starting {self.relative_tokens = }, {ts = }")
3195
+ tok = next(
3196
+ (
3197
+ t
3198
+ for t in self.relative_tokens
3199
+ if t.get("t") == "@" and t.get("k") == "s"
3200
+ ),
3201
+ None,
3202
+ )
3203
+ if tok:
3204
+ tok["token"] = f"@s {ts} "
3205
+ log_msg(f"{tok["token"] = }")
3206
+ else:
3207
+ self.relative_tokens.append({"token": f"@s {ts} ", "t": "@", "k": "s"})
3208
+ log_msg(f"ending {self.relative_tokens = }")
3209
+
3210
+ def _has_r(self) -> bool:
3211
+ return any(
3212
+ t.get("t") == "@" and t.get("k") == "r" for t in self.relative_tokens
3213
+ )
3214
+
3215
+ def _get_count_token(self):
3216
+ # &c N under the @r group
3217
+ for t in self.relative_tokens:
3218
+ if t.get("t") == "&" and t.get("k") == "c":
3219
+ return t
3220
+ return None
3221
+
3222
+ def _decrement_count_if_present(self) -> None:
3223
+ tok = self._get_count_token()
3224
+ if not tok:
3225
+ return
3226
+ parts = tok["token"].split()
3227
+ if len(parts) == 2 and parts[0] == "&c":
3228
+ try:
3229
+ n = int(parts[1])
3230
+ n2 = max(0, n - 1)
3231
+ if n2 > 0:
3232
+ tok["token"] = f"&c {n2}"
3233
+ else:
3234
+ # remove &c 0 entirely
3235
+ self.relative_tokens.remove(tok)
3236
+ except ValueError:
3237
+ pass
3238
+
3239
+ def _get_rdate_token(self):
3240
+ # @+ token (comma list)
3241
+ return next(
3242
+ (
3243
+ t
3244
+ for t in self.relative_tokens
3245
+ if t.get("t") == "@" and t.get("k") == "+"
3246
+ ),
3247
+ None,
3248
+ )
3249
+
3250
+ def _parse_rdate_list(self) -> list[str]:
3251
+ """Return list of compact dt strings (e.g. '20250819T110000') from @+."""
3252
+ tok = self._get_rdate_token()
3253
+ if not tok:
3254
+ return []
3255
+ body = tok["token"][2:].strip() # strip '@+ '
3256
+ parts = [p.strip() for p in body.split(",") if p.strip()]
3257
+ return parts
3258
+
3259
+ def _write_rdate_list(self, items: list[str]) -> None:
3260
+ tok = self._get_rdate_token()
3261
+ if items:
3262
+ joined = ", ".join(items)
3263
+ if tok:
3264
+ tok["token"] = f"@+ {joined}"
3265
+ else:
3266
+ self.relative_tokens.append(
3267
+ {"token": f"@+ {joined}", "t": "@", "k": "+"}
3268
+ )
3269
+ else:
3270
+ if tok:
3271
+ self.relative_tokens.remove(tok)
3272
+
3273
+ def _remove_rdate_exact(self, dt_compact: str) -> None:
3274
+ lst = self._parse_rdate_list()
3275
+ lst2 = [x for x in lst if x != dt_compact]
3276
+ self._write_rdate_list(lst2)
3277
+
3278
+ # --- for finish trial ---
3279
+
3280
+ def _unfinished_jobs(self) -> list[dict]:
3281
+ return [j for j in self.jobs if "f" not in j]
3282
+
3283
+ def _mark_job_finished(self, job_id: int, completed_dt: datetime) -> bool:
3284
+ """
3285
+ Add &f to the job (in jobs JSON) and also mutate the @~ token group if you keep that as text.
3286
+ Returns True if the job was found and marked.
3287
+ """
3288
+ if not job_id:
3289
+ return False
3290
+ found = False
3291
+ # Annotate JSON jobs
3292
+ for j in self.jobs:
3293
+ if j.get("i") == job_id and "f" not in j:
3294
+ j["f"] = round(completed_dt.timestamp())
3295
+ found = True
3296
+ break
3297
+
3298
+ # (Optional) If you also keep textual @~… &f … tokens in relative_tokens,
3299
+ # you can append/update them here. Otherwise, finalize_jobs() will rebuild jobs JSON.
3300
+ if found:
3301
+ self.finalize_jobs(self.jobs) # keeps statuses consistent
3302
+ return found
3303
+
3304
+ def _set_itemtype(self, ch: str) -> None:
3305
+ """Set itemtype and mirror into the first token if that token stores it."""
3306
+ self.itemtype = ch
3307
+ if self.relative_tokens and self.relative_tokens[0].get("t") == "itemtype":
3308
+ # tokens typically look like {'t':'itemtype', 'token':'~'} or similar
3309
+ self.relative_tokens[0]["token"] = ch
3310
+
3311
+ def _is_undated_single_shot(self) -> bool:
3312
+ """No @s, no RRULE, no @+ -> nothing to schedule (pure one-shot)."""
3313
+ return (
3314
+ (not self._has_s())
3315
+ and (not self._has_rrule())
3316
+ and (not self._find_all("@", "+"))
3317
+ )
3318
+
3319
+ def _has_any_future_instances(self, now_dt: datetime | None = None) -> bool:
3320
+ """Return True if rruleset/@+ yields at least one occurrence >= now (or at all if now is None)."""
3321
+ rule_str = self.rruleset
3322
+ if not rule_str and not self._find_all("@", "+"):
3323
+ return False
3324
+ try:
3325
+ rs = rrulestr(rule_str) if rule_str else None
3326
+ if rs is None:
3327
+ # RDATE-only path (from @+ mirrored into rruleset)
3328
+ rdates = self._parse_rdate_list() # returns compact strings
3329
+ return bool(rdates)
3330
+ if now_dt is None:
3331
+ # if we don’t care about “future”, just “any occurrences”
3332
+ return next(iter(rs), None) is not None
3333
+ # find first >= now
3334
+ try:
3335
+ got = rs.after(now_dt, inc=True)
3336
+ except TypeError:
3337
+ # handle aware/naive mismatch by using UTC-aware fallback
3338
+
3339
+ anchor = now_dt if now_dt.tzinfo else now_dt.replace(tzinfo=tz.UTC)
3340
+ got = rs.after(anchor, inc=True)
3341
+ return got is not None
3342
+ except Exception:
3343
+ return False
3344
+
3345
+ def _remove_tokens(
3346
+ self, t: str, k: str | None = None, *, max_count: int | None = None
3347
+ ) -> int:
3348
+ """
3349
+ Remove tokens from self.relative_tokens that match:
3350
+ token["t"] == t and (k is None or token["k"] == k)
3351
+
3352
+ Args:
3353
+ t: primary token type (e.g., "@", "&", "itemtype")
3354
+ k: optional subtype (e.g., "f", "s", "r"). If None, match all with type t.
3355
+ max_count: remove at most this many; None = remove all matches.
3356
+
3357
+ Returns:
3358
+ int: number of tokens removed.
3359
+ """
3360
+ if not hasattr(self, "relative_tokens") or not self.relative_tokens:
3361
+ return 0
3362
+
3363
+ removed = 0
3364
+ new_tokens = []
3365
+ for tok in self.relative_tokens:
3366
+ match = (tok.get("t") == t) and (k is None or tok.get("k") == k)
3367
+ if match and (max_count is None or removed < max_count):
3368
+ removed += 1
3369
+ continue
3370
+ new_tokens.append(tok)
3371
+
3372
+ self.relative_tokens = new_tokens
3373
+
3374
+ # Keep self.completions consistent if we removed @f tokens
3375
+ if t == "@" and (k is None or k == "f"):
3376
+ self._rebuild_completions_from_tokens()
3377
+
3378
+ return removed
3379
+
3380
+ def _rebuild_completions_from_tokens(self) -> None:
3381
+ """
3382
+ Rebuild self.completions from remaining @f tokens in relative_tokens.
3383
+ Normalizes to a list[datetime].
3384
+ """
3385
+
3386
+ comps = []
3387
+ for tok in getattr(self, "relative_tokens", []):
3388
+ if tok.get("t") == "@" and tok.get("k") == "f":
3389
+ # token text looks like "@f 20250828T211259 "
3390
+ try:
3391
+ body = (tok.get("token") or "")[2:].strip() # drop "@f"
3392
+ dt = parse(body)
3393
+ comps.append(dt)
3394
+ except Exception as e:
3395
+ log_msg(f"error: {e}")
3396
+
3397
+ self.completions = comps
3398
+
3399
+ def _clear_schedule(self) -> None:
3400
+ """Clear any schedule fields/tokens and rruleset mirror."""
3401
+ # remove @s
3402
+ self._remove_tokens("@", "s")
3403
+ # remove @+/@- (optional if you mirror in rruleset)
3404
+ self._remove_tokens("@", "+")
3405
+ self._remove_tokens("@", "-")
3406
+ # remove @r group (&-modifiers) – you likely have a grouped removal util
3407
+ self._remove_tokens("@", "r")
3408
+ self._remove_tokens("&") # if your &-mods only apply to recurrence
3409
+ # clear rruleset string
3410
+ self.rruleset = ""
3411
+
3412
+ def _has_any_occurrences_left(self) -> bool:
3413
+ """
3414
+ Return True if the current schedule (rruleset and/or RDATEs) still yields
3415
+ at least one occurrence, irrespective of whether it’s past or future.
3416
+ """
3417
+ rule_str = self.rruleset
3418
+ # If we mirror @+ into RDATE, the rrulestr path below will handle it;
3419
+ # but if you keep @+ separate, fall back to parsing @+ directly:
3420
+ if not rule_str and self._find_all("@", "+"):
3421
+ return bool(self._parse_rdate_list()) # remaining RDATEs?
3422
+
3423
+ if not rule_str:
3424
+ return False
3425
+
3426
+ try:
3427
+ rs = rrulestr(rule_str)
3428
+ return next(iter(rs), None) is not None
3429
+ except Exception:
3430
+ return False
3431
+
3432
+ def _has_o(self) -> bool:
3433
+ return any(
3434
+ t.get("t") == "@" and t.get("k") == "o" for t in self.relative_tokens
3435
+ )
3436
+
3437
+ def _get_o_interval(self) -> tuple[timedelta, bool] | None:
3438
+ """
3439
+ Read the first @o token and return (interval, learn) or None.
3440
+ """
3441
+ tok = next(
3442
+ (
3443
+ t
3444
+ for t in self.relative_tokens
3445
+ if t.get("t") == "@" and t.get("k") == "o"
3446
+ ),
3447
+ None,
3448
+ )
3449
+ if not tok:
3450
+ return None
3451
+ body = tok["token"][2:].strip() # strip '@o'
3452
+ td, learn = _parse_o_body(body)
3453
+ return td, learn
3454
+
3455
+ def _set_o_interval(self, td: timedelta, learn: bool) -> None:
3456
+ """
3457
+ Update or create the @o token with a normalized form ('@o 3d', '@o ~3d').
3458
+ """
3459
+ normalized = f"@o {'~' if learn else ''}{td_to_td_str(td)} "
3460
+ o_tok = next(
3461
+ (
3462
+ t
3463
+ for t in self.relative_tokens
3464
+ if t.get("t") == "@" and t.get("k") == "o"
3465
+ ),
3466
+ None,
3467
+ )
3468
+ if o_tok:
3469
+ o_tok["token"] = normalized
3470
+ else:
3471
+ self.relative_tokens.append({"token": normalized, "t": "@", "k": "o"})
3472
+
3473
+ # --- drop-in replacement for do_over -----------------------------------
3474
+
3475
+ def do_offset(self, token):
3476
+ """
3477
+ Normalize @o (over/offset) token.
3478
+ - Accepts '@o 3d', '@o ~3d', '@o learn 3d'
3479
+ - Stores a normalized token ('@o 3d ' or '@o ~3d ')
3480
+ Returns (ok, seconds, messages) so callers can use the numeric interval if needed.
3481
+ """
3482
+ try:
3483
+ # token is a relative token dict, like {"token": "@o 3d", "t":"@", "k":"o"}
3484
+ body = token["token"][2:].strip() # remove '@o'
3485
+ td, learn = _parse_o_body(body)
3486
+
3487
+ # Normalize token text
3488
+ normalized = f"@o {'~' if learn else ''}{td_to_td_str(td)} "
3489
+ token["token"] = normalized
3490
+ token["t"] = "@"
3491
+ token["k"] = "o"
3492
+
3493
+ return True, int(td.total_seconds()), []
3494
+ except Exception as e:
3495
+ return False, f"invalid @o interval: {e}", []
3496
+
3497
+ def finish(self) -> None:
3498
+ f_tokens = [t for t in self.relative_tokens if t.get("k") == "f"]
3499
+ if not f_tokens:
3500
+ return
3501
+ log_msg(f"{f_tokens = }")
3502
+
3503
+ # completed_dt = max(parse_dt(t["token"].split(maxsplit=1)[1]) for t in f_tokens)
3504
+ completed_dt, was_due_dt = parse_f_token(f_tokens[0])
3505
+
3506
+ due_dt = None # default
3507
+
3508
+ if offset_tok := next(
3509
+ (t for t in self.relative_tokens if t.get("k") == "o"), None
3510
+ ):
3511
+ due_dt = self._get_start_dt()
3512
+ td = td_str_to_td(offset_tok["token"].split(maxsplit=1)[1])
3513
+ self._replace_or_add_token("s", completed_dt + td)
3514
+ if offset_tok["token"].startswith("~") and due_dt:
3515
+ actual = completed_dt - due_dt
3516
+ td = self._smooth_interval(td, actual)
3517
+ offset_tok["token"] = f"@o {td_to_td_str(td)}"
3518
+ self._replace_or_add_token("s", completed_dt + td)
3519
+
3520
+ elif self.rruleset:
3521
+ first, second = self._get_first_two_occurrences()
3522
+ due_dt = first
3523
+ if second:
3524
+ self._replace_or_add_token("s", second)
3525
+ else:
3526
+ self._remove_tokens({"s", "r", "+", "-"})
3527
+ self.itemtype = "x"
3528
+
3529
+ else:
3530
+ # one-off
3531
+ due_dt = None
3532
+ self.itemtype = "x"
3533
+
3534
+ # ⬇️ single assignment here
3535
+ self.completion = (completed_dt, due_dt)
3536
+
3537
+ self._remove_tokens({"f"})
3538
+ self.reparse_finish_tokens()
3539
+
3540
+ def _replace_or_add_token(self, key: str, dt: datetime) -> None:
3541
+ """Replace token with key `key` or add new one for dt."""
3542
+ new_tok = {"token": f"@{key} {self.fmt_user(dt)}", "t": "@", "k": key}
3543
+ # replace if exists
3544
+ for tok in self.relative_tokens:
3545
+ if tok.get("k") == key:
3546
+ log_msg(f"original {key =}; {tok = }; {new_tok = }")
3547
+ tok.update(new_tok)
3548
+ return
3549
+ # else append
3550
+ self.relative_tokens.append(new_tok)
3551
+
3552
+ def _remove_tokens(self, keys: set[str]) -> None:
3553
+ """Remove tokens with matching keys from self.tokens."""
3554
+ self.relative_tokens = [
3555
+ t for t in self.relative_tokens if t.get("k") not in keys
3556
+ ]
3557
+
3558
+ def reparse_finish_tokens(self) -> None:
3559
+ """
3560
+ Re-run only the token handlers that can be affected by finish():
3561
+ @s, @r, @+.
3562
+ Works directly from self.relative_tokens.
3563
+ """
3564
+ affected_keys = {"s", "r", "+"}
3565
+
3566
+ for tok in self.relative_tokens:
3567
+ k = tok.get("k")
3568
+ if k in affected_keys:
3569
+ handler = getattr(self, f"do_{k}", None)
3570
+ if handler:
3571
+ ok, msg, extras = handler(tok)
3572
+ if not ok:
3573
+ self.parse_ok = False
3574
+ self.parse_message = msg
3575
+ return
3576
+ # process any extra tokens the handler produces
3577
+ for extra in extras:
3578
+ ek = extra.get("k")
3579
+ if ek in affected_keys:
3580
+ getattr(self, f"do_{ek}")(extra)
3581
+
3582
+ # only finalize if parse is still clean
3583
+ if self.parse_ok and self.final:
3584
+ self.finalize_record()
3585
+
3586
+ def mark_final(self) -> None:
3587
+ """
3588
+ Mark this item as final and normalize to absolute datetimes.
3589
+ """
3590
+ self.final = True
3591
+ self.rebuild_from_tokens(resolve_relative=True) # force absolute now
3592
+ # self.finalize_rruleset() # RRULE/DTSTART/RDATE/EXDATE strings updated
3593
+
3594
+ def rebuild_from_tokens(self, *, resolve_relative: bool) -> None:
3595
+ """Recompute DTSTART/RDATE/RRULE/EXDATE + rruleset + jobs from self.relative_tokens."""
3596
+ if resolve_relative is None:
3597
+ resolve_relative = self.final
3598
+ log_msg(f"{resolve_relative = }")
3599
+ # self._normalize_datetime_tokens(resolve_relative=resolve_relative)
3600
+ dtstart_str, rdstart_str, rrule_line = self._derive_rrule_pieces()
3601
+ self.dtstart_str = dtstart_str or ""
3602
+ self.rdstart_str = rdstart_str or ""
3603
+ self.rruleset = self._compose_rruleset(dtstart_str, rrule_line, rdstart_str)
3604
+ # If you derive jobs from tokens, keep this; else skip:
3605
+ if self.collect_grouped_tokens({"~"}):
3606
+ jobs = self.build_jobs()
3607
+ self.finalize_jobs(jobs)
3608
+
3609
+ def _normalize_datetime_tokens(self, *, resolve_relative: bool) -> None:
3610
+ """Normalize @s/@+/@-/@f to compact absolute strings; optionally resolve human phrases."""
3611
+
3612
+ def to_compact(dt):
3613
+ if isinstance(dt, datetime):
3614
+ return dt.strftime("%Y%m%dT%H%M")
3615
+ # If you ever allow date objects:
3616
+ return dt.strftime("%Y%m%d")
3617
+
3618
+ for tok in self.relative_tokens:
3619
+ log_msg(f"{tok = }")
3620
+ if tok.get("t") != "@":
3621
+ continue
3622
+ k = tok.get("k")
3623
+ text = (tok.get("token") or "").strip()
3624
+ if k == "s":
3625
+ body = text[2:].strip()
3626
+ log_msg(f"{body = }")
3627
+ dt = (
3628
+ parse(body)
3629
+ if resolve_relative
3630
+ else self._parse_compact_or_iso(body)
3631
+ )
3632
+ tok["token"] = f"@s {to_compact(dt)} "
3633
+ elif k in {"+", "-"}:
3634
+ body = text[2:].strip()
3635
+ parts = [p.strip() for p in body.split(",") if p.strip()]
3636
+ dts = [
3637
+ (parse(p) if resolve_relative else self._parse_compact_or_iso(p))
3638
+ for p in parts
3639
+ ]
3640
+ joined = ",".join(to_compact(dt) for dt in dts)
3641
+ tok["token"] = f"@{k} {joined} "
3642
+ elif k == "f":
3643
+ body = text[2:].strip()
3644
+ dt = (
3645
+ parse(body)
3646
+ if resolve_relative
3647
+ else self._parse_compact_or_iso(body)
3648
+ )
3649
+ tok["token"] = f"@f {to_compact(dt)} "
3650
+
3651
+ def _derive_rrule_pieces(self) -> tuple[str | None, str | None, str | None]:
3652
+ """Return (DTSTART line, RDATE line, RRULE line) from tokens."""
3653
+ dtstart = None
3654
+ rdates, exdates = [], []
3655
+ rrule_components = {}
3656
+
3657
+ for tok in self.relative_tokens:
3658
+ if tok.get("t") != "@":
3659
+ continue
3660
+ k = tok.get("k")
3661
+ text = (tok.get("token") or "").strip()
3662
+ if k == "s":
3663
+ dtstart = text[2:].strip()
3664
+ elif k == "+":
3665
+ rdates += [p.strip() for p in text[2:].split(",") if p.strip()]
3666
+ elif k == "-":
3667
+ exdates += [p.strip() for p in text[2:].split(",") if p.strip()]
3668
+ elif k == "r":
3669
+ group = next(
3670
+ (
3671
+ g
3672
+ for g in self.collect_grouped_tokens({"r"})
3673
+ if g and g[0] is tok
3674
+ ),
3675
+ None,
3676
+ )
3677
+ if group:
3678
+ rrule_components = self._rrule_components_from_group(group)
3679
+
3680
+ dtstart_str = None
3681
+ if dtstart:
3682
+ dtstart_str = (
3683
+ f"DTSTART;VALUE=DATE:{dtstart}"
3684
+ if len(dtstart) == 8
3685
+ else f"DTSTART:{dtstart}"
3686
+ )
3687
+
3688
+ rdstart_str = f"RDATE:{','.join(rdates)}" if rdates else None
3689
+ # If you want EXDATE, add it similarly and pass to _compose_rruleset.
3690
+ rrule_line = (
3691
+ f"RRULE:{';'.join(f'{k}={v}' for k, v in rrule_components.items())}"
3692
+ if rrule_components
3693
+ else None
3694
+ )
3695
+ return dtstart_str, rdstart_str, rrule_line
3696
+
3697
+ def _compose_rruleset(
3698
+ self, dtstart_str, rrule_line, rdate_line, exdate_line=None
3699
+ ) -> str:
3700
+ parts = []
3701
+ if dtstart_str:
3702
+ parts.append(dtstart_str)
3703
+ if rrule_line:
3704
+ parts.append(rrule_line)
3705
+ if rdate_line:
3706
+ parts.append(rdate_line)
3707
+ if exdate_line:
3708
+ parts.append(exdate_line)
3709
+ return "\n".join(parts)
3710
+
3711
+ def _parse_compact_or_iso(self, s: str) -> datetime:
3712
+ """Accept YYYYMMDD or YYYYMMDDTHHMMSS or any ISO-ish; return datetime."""
3713
+ s = s.strip()
3714
+ if len(s) == 8 and s.isdigit():
3715
+ return datetime.strptime(s, "%Y%m%d")
3716
+ if len(s) == 15 and s[8] == "T":
3717
+ return datetime.strptime(s, "%Y%m%dT%H%M")
3718
+ return parse(s)
3719
+
3720
+ def _rrule_components_from_group(self, group: list[dict]) -> dict:
3721
+ """Build RRULE components dict from the @r group & its &-options."""
3722
+ log_msg("IN RRULE COMPONENTS")
3723
+ freq_map = {"y": "YEARLY", "m": "MONTHLY", "w": "WEEKLY", "d": "DAILY"}
3724
+ comps = {}
3725
+ anchor = group[0]["token"] # "@r d" etc.
3726
+ parts = anchor.split(maxsplit=1)
3727
+ if len(parts) > 1:
3728
+ freq_abbr = parts[1].strip()
3729
+ freq = freq_map.get(freq_abbr)
3730
+ if freq:
3731
+ comps["FREQ"] = freq
3732
+ for tok in group[1:]:
3733
+ if tok.get("t") == "&":
3734
+ key, value = (
3735
+ tok.get("k"),
3736
+ (
3737
+ tok.get("v") or tok.get("token", "")[1:].split(maxsplit=1)[-1]
3738
+ ).strip(),
3739
+ )
3740
+ if key == "m":
3741
+ comps["BYMONTH"] = value
3742
+ elif key == "w":
3743
+ comps["BYDAY"] = value
3744
+ elif key == "d":
3745
+ comps["BYMONTHDAY"] = value
3746
+ elif key == "i":
3747
+ comps["INTERVAL"] = value
3748
+ elif key == "u":
3749
+ log_msg(f"GOT UNTIL: {value = }")
3750
+ comps["UNTIL"] = value.replace("/", "")
3751
+ elif key == "c":
3752
+ comps["COUNT"] = value
3753
+ return comps
3754
+
3755
+ def _strip_positions(self, tokens_with_pos: list[dict]) -> list[dict]:
3756
+ """Remove 'start'/'end' from editing tokens and strip whitespace from 'token'."""
3757
+ out = []
3758
+ for t in tokens_with_pos:
3759
+ t2 = dict(t)
3760
+ t2.pop("s", None)
3761
+ t2.pop("e", None)
3762
+ if "token" in t2 and isinstance(t2["token"], str):
3763
+ t2["token"] = t2["token"].strip()
3764
+ out.append(t2)
3765
+ return out