1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
|
#!/usr/bin/env python3
"""Upcoming-birthdays block for daily prep.
Reads an org-contacts file for ``:BIRTHDAY: YYYY-MM-DD`` properties, finds the
ones whose next occurrence falls within a window (default 30 days) from today,
and prints a daily-prep block: name, date, days-away, and — when the birth year
is real — the age the person is turning. Many contacts use ``1900`` as a
placeholder year when the real one is unknown; those render date-only, no age.
Anything within the callout window (default 7 days) is flagged so a gift or
plan gets prompted.
Pure core:
parse_birthdays(text) -> [Birthday(name, month, day, year|None), ...]
upcoming(birthdays, today, window=30) -> [Upcoming(name, date, days_away, age|None), ...]
format_block(items, window, callout_days=7) -> str
CLI:
upcoming_birthdays.py [--file PATH] [--today YYYY-MM-DD] [--window N] [--callout N]
prints the block on stdout. Default --file is ~/sync/org/contacts.org.
"""
import argparse
import datetime as dt
import re
import sys
from dataclasses import dataclass
from pathlib import Path
PLACEHOLDER_YEAR = 1900
DEFAULT_CONTACTS = Path.home() / "sync" / "org" / "contacts.org"
_HEADING_RE = re.compile(r"^\*+\s+(.*?)\s*$")
_TAGS_RE = re.compile(r"\s+:[A-Za-z0-9_@#%:]+:$")
_BIRTHDAY_RE = re.compile(r"^\s*:BIRTHDAY:\s*(\d{4})-(\d{2})-(\d{2})\s*$")
@dataclass(frozen=True)
class Birthday:
name: str
month: int
day: int
year: int | None # None when the source used the 1900 placeholder
@dataclass(frozen=True)
class Upcoming:
name: str
date: dt.date
days_away: int
age: int | None # None when the birth year is unknown
def _clean_name(heading_text: str) -> str:
"""Strip a trailing org tag cluster from a heading's text."""
return _TAGS_RE.sub("", heading_text).strip()
def parse_birthdays(text: str) -> list[Birthday]:
"""Extract (name, month, day, year|None) for every contact with a BIRTHDAY.
The name is the nearest preceding org heading. A birth year of 1900 is the
placeholder org-contacts uses for an unknown year and is returned as None.
Malformed birthday lines are ignored.
"""
birthdays: list[Birthday] = []
current_name: str | None = None
for line in text.splitlines():
heading = _HEADING_RE.match(line)
if heading:
current_name = _clean_name(heading.group(1))
continue
bday = _BIRTHDAY_RE.match(line)
if bday and current_name:
year, month, day = (int(g) for g in bday.groups())
# Guard against a nonsense month/day that regex width still admits.
try:
dt.date(2000, month, day)
except ValueError:
continue
birthdays.append(
Birthday(
current_name,
month,
day,
None if year == PLACEHOLDER_YEAR else year,
)
)
return birthdays
def _next_occurrence(month: int, day: int, today: dt.date) -> dt.date:
"""First date on/after ``today`` landing on this month/day.
A Feb 29 birthday maps to Feb 28 in a non-leap year.
"""
def on(year: int) -> dt.date:
try:
return dt.date(year, month, day)
except ValueError:
# Only Feb 29 can fail here; fall back to Feb 28.
return dt.date(year, 2, 28)
candidate = on(today.year)
if candidate < today:
candidate = on(today.year + 1)
return candidate
def upcoming(
birthdays: list[Birthday], today: dt.date, window: int = 30
) -> list[Upcoming]:
"""Birthdays whose next occurrence is within ``window`` days, soonest first."""
items: list[Upcoming] = []
for b in birthdays:
occ = _next_occurrence(b.month, b.day, today)
days = (occ - today).days
if 0 <= days <= window:
age = None if b.year is None else occ.year - b.year
items.append(Upcoming(b.name, occ, days, age))
items.sort(key=lambda u: (u.days_away, u.name))
return items
def _days_phrase(days: int) -> str:
if days == 0:
return "today"
if days == 1:
return "tomorrow"
return f"in {days} days"
def format_block(items: list[Upcoming], window: int, callout_days: int = 7) -> str:
"""Render the daily-prep block. Callout entries (within ``callout_days``) are
flagged with a marker and a plan-a-gift nudge."""
if not items:
return f"No birthdays in the next {window} days."
lines = [f"Upcoming birthdays (next {window} days):"]
for u in items:
callout = u.days_away <= callout_days
marker = "⚠" if callout else "·"
date_str = u.date.strftime("%a %b %d")
piece = f" {marker} {date_str} — {u.name}"
if u.age is not None:
piece += f" turns {u.age}"
piece += f" ({_days_phrase(u.days_away)})"
if callout:
piece += " — plan a gift/card"
lines.append(piece)
return "\n".join(lines)
def _parse_today(value: str | None) -> dt.date:
if value is None:
return dt.date.today()
return dt.date.fromisoformat(value)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Print the upcoming-birthdays daily-prep block.")
parser.add_argument("--file", type=Path, default=DEFAULT_CONTACTS,
help="org-contacts file (default: ~/sync/org/contacts.org)")
parser.add_argument("--today", default=None,
help="override today's date (YYYY-MM-DD), for testing")
parser.add_argument("--window", type=int, default=30,
help="look-ahead window in days (default: 30)")
parser.add_argument("--callout", type=int, default=7,
help="flag birthdays within this many days (default: 7)")
args = parser.parse_args(argv)
if not args.file.exists():
print(f"contacts file not found: {args.file}", file=sys.stderr)
return 1
text = args.file.read_text(encoding="utf-8")
items = upcoming(parse_birthdays(text), _parse_today(args.today), args.window)
print(format_block(items, args.window, args.callout))
return 0
if __name__ == "__main__":
raise SystemExit(main())
|