birthdays-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- birthdays/__init__.py +32 -0
- birthdays/__main__.py +4 -0
- birthdays/birthdays.py +979 -0
- birthdays/emojis.py +132 -0
- birthdays_cli-0.1.0.dist-info/METADATA +134 -0
- birthdays_cli-0.1.0.dist-info/RECORD +9 -0
- birthdays_cli-0.1.0.dist-info/WHEEL +4 -0
- birthdays_cli-0.1.0.dist-info/entry_points.txt +3 -0
- birthdays_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
birthdays/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
__version__ = version("birthdays-cli")
|
|
5
|
+
except PackageNotFoundError:
|
|
6
|
+
__version__ = "unknown"
|
|
7
|
+
|
|
8
|
+
__about__ = rf"""
|
|
9
|
+
✦ ̲ ✦ ✦ 🔥 ✦
|
|
10
|
+
' (o) . 🔥 /^\ 🔥 ✦
|
|
11
|
+
╱:╲ __/^\____|_|____/^\__ *
|
|
12
|
+
+ ✦ ╱.:|╲ ✦ / |_| |_| \
|
|
13
|
+
╱..::|╲ \_____________________/ ✦
|
|
14
|
+
╱...:::|╲ ✦ | . . . . . . . . . |
|
|
15
|
+
. (_________) | -~-~-~-~-~-~-~-~-~- | ✦
|
|
16
|
+
╭╯ ╭─╯ | ~-~-~-~-~-~-~-~-~-~ | *
|
|
17
|
+
╰────────╯ *_____________________*
|
|
18
|
+
|
|
19
|
+
birthdays: Your birthday list is in your hands only.
|
|
20
|
+
----------------------------------------------------------
|
|
21
|
+
Version: {__version__}
|
|
22
|
+
Author: Volodymyr Horshenin (@l1asis)
|
|
23
|
+
License: MIT
|
|
24
|
+
Repository: https://github.com/l1asis/birthdays
|
|
25
|
+
|
|
26
|
+
Description:
|
|
27
|
+
A command-line utility for displaying and managing birthdays
|
|
28
|
+
from JSON- or vCard-based contacts.
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
Run `python -m birthdays --help` to see available commands.
|
|
32
|
+
"""
|
birthdays/__main__.py
ADDED
birthdays/birthdays.py
ADDED
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import base64
|
|
3
|
+
import calendar
|
|
4
|
+
import datetime
|
|
5
|
+
import difflib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import quopri
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import uuid
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from collections.abc import Collection
|
|
14
|
+
from dataclasses import asdict, dataclass
|
|
15
|
+
from operator import attrgetter
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, List, Literal, Optional, overload
|
|
18
|
+
|
|
19
|
+
from dateutil.relativedelta import relativedelta
|
|
20
|
+
from platformdirs import user_data_path
|
|
21
|
+
|
|
22
|
+
from . import __about__, __version__
|
|
23
|
+
from .emojis import date_to_emoji
|
|
24
|
+
|
|
25
|
+
VCARD = re.compile(r"BEGIN:VCARD.*?END:VCARD", flags=re.DOTALL | re.IGNORECASE)
|
|
26
|
+
FULL_NAME = re.compile(r"^FN(;[^:]*)?:(.*)$", flags=re.MULTILINE | re.IGNORECASE)
|
|
27
|
+
BIRTHDAY = re.compile(r"^BDAY(?:;[^:]*)?:(.*)$", flags=re.MULTILINE | re.IGNORECASE)
|
|
28
|
+
DATE = re.compile(r"^(\d{4}|--)?-?(0[1-9]|1[0-2])-?(0[1-9]|[12]\d|3[01])$")
|
|
29
|
+
NOTE = re.compile(r"^NOTE(;[^:]*)?:(.*)$", flags=re.MULTILINE | re.IGNORECASE)
|
|
30
|
+
UNFOLD = re.compile(r"\r?\n[ \t]") # glues lines that start with a space or tab
|
|
31
|
+
UNFOLD_SOFT = re.compile(r"=\r?\n") # glues lines that end with an '='
|
|
32
|
+
|
|
33
|
+
# ==========================================
|
|
34
|
+
# DATA MODELS
|
|
35
|
+
# ==========================================
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class BirthdayEntry:
|
|
40
|
+
"""A single birthday entry in the database."""
|
|
41
|
+
|
|
42
|
+
id: str
|
|
43
|
+
full_name: str
|
|
44
|
+
month: int
|
|
45
|
+
day: int
|
|
46
|
+
year: Optional[int] = None
|
|
47
|
+
notes: Optional[str] = None
|
|
48
|
+
leap_system: Literal["after", "before"] = "before"
|
|
49
|
+
|
|
50
|
+
def get_age(self) -> int | None:
|
|
51
|
+
"""Return the person's current age, or None if the year is unknown."""
|
|
52
|
+
if self.year is None:
|
|
53
|
+
return None
|
|
54
|
+
today = datetime.date.today()
|
|
55
|
+
this_year = leapling_safe_date(
|
|
56
|
+
today.year, self.month, self.day, self.leap_system
|
|
57
|
+
)
|
|
58
|
+
had_birthday = today >= this_year
|
|
59
|
+
return today.year - self.year - (0 if had_birthday else 1)
|
|
60
|
+
|
|
61
|
+
def is_today(self) -> bool:
|
|
62
|
+
"""Check if today is the person's birthday."""
|
|
63
|
+
today = datetime.date.today()
|
|
64
|
+
this_year = leapling_safe_date(
|
|
65
|
+
today.year, self.month, self.day, self.leap_system
|
|
66
|
+
)
|
|
67
|
+
return this_year.month == today.month and this_year.day == today.day
|
|
68
|
+
|
|
69
|
+
def get_next_occurrence(self, from_date: datetime.date) -> datetime.date:
|
|
70
|
+
"""Calculate the exact date of the next birthday."""
|
|
71
|
+
this_year = leapling_safe_date(
|
|
72
|
+
from_date.year, self.month, self.day, self.leap_system
|
|
73
|
+
)
|
|
74
|
+
if from_date.month < this_year.month or (
|
|
75
|
+
from_date.month == this_year.month and from_date.day <= this_year.day
|
|
76
|
+
):
|
|
77
|
+
return this_year
|
|
78
|
+
return leapling_safe_date(
|
|
79
|
+
from_date.year + 1, self.month, self.day, self.leap_system
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def get_prev_occurrence(self, from_date: datetime.date) -> datetime.date:
|
|
83
|
+
"""Calculate the exact date of the previous birthday."""
|
|
84
|
+
this_year = leapling_safe_date(
|
|
85
|
+
from_date.year, self.month, self.day, self.leap_system
|
|
86
|
+
)
|
|
87
|
+
if from_date.month < this_year.month or (
|
|
88
|
+
from_date.month == this_year.month and from_date.day < this_year.day
|
|
89
|
+
):
|
|
90
|
+
return leapling_safe_date(
|
|
91
|
+
from_date.year - 1, self.month, self.day, self.leap_system
|
|
92
|
+
)
|
|
93
|
+
return this_year
|
|
94
|
+
|
|
95
|
+
def next_occurrence_in(self, from_date: datetime.date) -> relativedelta:
|
|
96
|
+
"""Calculate the exact distance to the next birthday."""
|
|
97
|
+
return relativedelta(self.get_next_occurrence(from_date), from_date)
|
|
98
|
+
|
|
99
|
+
def prev_occurrence_in(self, from_date: datetime.date) -> relativedelta:
|
|
100
|
+
"""Calculate the exact distance to the previous birthday."""
|
|
101
|
+
return relativedelta(from_date, self.get_prev_occurrence(from_date))
|
|
102
|
+
|
|
103
|
+
def __post_init__(self):
|
|
104
|
+
if not day_might_exist(self.year, self.month, self.day):
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"Date is out of range. "
|
|
107
|
+
f"Got: {f'{self.year}-' if self.year is not None else ''}"
|
|
108
|
+
f"{self.month}-{self.day}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def __str__(self) -> str:
|
|
112
|
+
year_str = f"{self.year}-" if self.year else ""
|
|
113
|
+
date_str = f"{year_str}{self.month:02d}-{self.day:02d}"
|
|
114
|
+
|
|
115
|
+
base = f"{self.full_name} ({date_str})"
|
|
116
|
+
if self.notes:
|
|
117
|
+
base = f"{base} - {self.notes}"
|
|
118
|
+
return base
|
|
119
|
+
|
|
120
|
+
def __repr__(self) -> str:
|
|
121
|
+
return (
|
|
122
|
+
f"BirthdayEntry({repr(self.id)}, "
|
|
123
|
+
f"{repr(self.full_name)}, "
|
|
124
|
+
f"{self.month}, "
|
|
125
|
+
f"{self.day}, "
|
|
126
|
+
f"{self.year}, "
|
|
127
|
+
f"{repr(self.notes) if self.notes else 'None'})"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def __lt__(self, other: "BirthdayEntry | datetime.date") -> bool:
|
|
131
|
+
if self.year is not None and other.year is not None:
|
|
132
|
+
if self.year != other.year:
|
|
133
|
+
return self.year < other.year
|
|
134
|
+
return self.month < other.month or (
|
|
135
|
+
self.month == other.month and self.day < other.day
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def __gt__(self, other: "BirthdayEntry | datetime.date") -> bool:
|
|
139
|
+
if self.year is not None and other.year is not None:
|
|
140
|
+
if self.year != other.year:
|
|
141
|
+
return self.year > other.year
|
|
142
|
+
return self.month > other.month or (
|
|
143
|
+
self.month == other.month and self.day > other.day
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def __le__(self, other: "BirthdayEntry | datetime.date") -> bool:
|
|
147
|
+
if self.year is not None and other.year is not None:
|
|
148
|
+
if self.year != other.year:
|
|
149
|
+
return self.year < other.year
|
|
150
|
+
return self.month < other.month or (
|
|
151
|
+
self.month == other.month and self.day <= other.day
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def __ge__(self, other: "BirthdayEntry | datetime.date") -> bool:
|
|
155
|
+
if self.year is not None and other.year is not None:
|
|
156
|
+
if self.year != other.year:
|
|
157
|
+
return self.year > other.year
|
|
158
|
+
return self.month > other.month or (
|
|
159
|
+
self.month == other.month and self.day >= other.day
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ==========================================
|
|
164
|
+
# STORAGE APIs
|
|
165
|
+
# ==========================================
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_database_path() -> Path:
|
|
169
|
+
"""Resolve OS-specific config path."""
|
|
170
|
+
if custom_path := os.getenv("BIRTHDAYS_HOME"):
|
|
171
|
+
dir_path = Path(custom_path)
|
|
172
|
+
else:
|
|
173
|
+
dir_path = user_data_path(
|
|
174
|
+
"birthdays",
|
|
175
|
+
"l1asis",
|
|
176
|
+
ensure_exists=True,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
|
|
181
|
+
return dir_path / "birthdays.json"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def as_birthday_entry(dictionary: dict[str, Any]) -> BirthdayEntry:
|
|
185
|
+
"""Read a JSON dictionary and safely convert it into BirthdayEntry."""
|
|
186
|
+
return BirthdayEntry(
|
|
187
|
+
dictionary["id"],
|
|
188
|
+
dictionary["full_name"],
|
|
189
|
+
dictionary["month"],
|
|
190
|
+
dictionary["day"],
|
|
191
|
+
dictionary.get("year"),
|
|
192
|
+
dictionary.get("notes"),
|
|
193
|
+
dictionary.get("leap_system", "before"),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def load_database(db_path: Path) -> List[BirthdayEntry]:
|
|
198
|
+
"""Read the JSON file and inflate it into BirthdayEntry objects."""
|
|
199
|
+
if not db_path.exists():
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
with open(db_path, "r", encoding="utf-8") as file:
|
|
203
|
+
return json.load(file, object_hook=as_birthday_entry)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def save_database(entries: List[BirthdayEntry], db_path: Path) -> None:
|
|
207
|
+
"""Serialize BirthdayEntry objects and write them to the JSON file."""
|
|
208
|
+
dictionaries = tuple(asdict(entry) for entry in entries)
|
|
209
|
+
with open(db_path, "w", encoding="utf-8") as file:
|
|
210
|
+
json.dump(dictionaries, file, indent=4)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ==========================================
|
|
214
|
+
# CORE LOGIC APIs
|
|
215
|
+
# ==========================================
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def decode_vcard_text(raw_text: str, parameters: str | None) -> str:
|
|
219
|
+
"""Safely decode Quoted-Printable or Base64 vCard strings."""
|
|
220
|
+
if not parameters:
|
|
221
|
+
return raw_text.strip()
|
|
222
|
+
|
|
223
|
+
parameters = parameters.upper()
|
|
224
|
+
if "ENCODING=QUOTED-PRINTABLE" in parameters:
|
|
225
|
+
unquoted = quopri.decodestring(raw_text)
|
|
226
|
+
if "CHARSET=UTF-8" in parameters:
|
|
227
|
+
return unquoted.decode("utf-8", errors="replace").strip()
|
|
228
|
+
return unquoted.decode("latin1", errors="replace").strip()
|
|
229
|
+
|
|
230
|
+
elif "ENCODING=B" in parameters:
|
|
231
|
+
unbased = base64.standard_b64decode(raw_text)
|
|
232
|
+
if "CHARSET=UTF-8" in parameters:
|
|
233
|
+
return unbased.decode("utf-8", errors="replace").strip()
|
|
234
|
+
return unbased.decode("latin1", errors="replace").strip()
|
|
235
|
+
|
|
236
|
+
return raw_text.strip()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def leapling_safe_date(
|
|
240
|
+
year: int, month: int, day: int, leap_system: Literal["after", "before"] = "before"
|
|
241
|
+
) -> datetime.date:
|
|
242
|
+
if not is_leap(year) and month == 2 and day == 29:
|
|
243
|
+
if leap_system == "before":
|
|
244
|
+
return datetime.date(year, 2, 28)
|
|
245
|
+
return datetime.date(year, 3, 1)
|
|
246
|
+
return datetime.date(year, month, day)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def is_leap(year: int) -> bool:
|
|
250
|
+
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def day_might_exist(year: int | None, month: int, day: int) -> bool:
|
|
254
|
+
"""Check if a day is valid for a given month, handling missing years safely."""
|
|
255
|
+
_, num_days = calendar.monthrange(year if year is not None else 2024, month)
|
|
256
|
+
return 1 <= day <= num_days
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def parse_vcards(
|
|
260
|
+
vcf_file: Path, leap_system: Literal["after", "before"]
|
|
261
|
+
) -> List[BirthdayEntry]:
|
|
262
|
+
"""Extract names, birthdays, and notes from all vCard formats."""
|
|
263
|
+
with open(vcf_file, "r", encoding="utf-8") as file:
|
|
264
|
+
raw_text = file.read()
|
|
265
|
+
|
|
266
|
+
unfolded_text = UNFOLD.sub("", raw_text)
|
|
267
|
+
unfolded_text = UNFOLD_SOFT.sub("", raw_text)
|
|
268
|
+
|
|
269
|
+
vcards: list[str] = VCARD.findall(unfolded_text)
|
|
270
|
+
birthdays: List[BirthdayEntry] = []
|
|
271
|
+
|
|
272
|
+
for vcard in vcards:
|
|
273
|
+
fn_match = FULL_NAME.search(vcard)
|
|
274
|
+
bday_match = BIRTHDAY.search(vcard)
|
|
275
|
+
note_match = NOTE.search(vcard)
|
|
276
|
+
|
|
277
|
+
if fn_match is not None:
|
|
278
|
+
full_name = decode_vcard_text(fn_match.group(2), fn_match.group(1))
|
|
279
|
+
|
|
280
|
+
if bday_match is not None:
|
|
281
|
+
date_str = bday_match.group(1)
|
|
282
|
+
year = month = day = None
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
date = datetime.date.fromisoformat(date_str)
|
|
286
|
+
year, month, day = date.year, date.month, date.day
|
|
287
|
+
|
|
288
|
+
except ValueError:
|
|
289
|
+
date_match = DATE.match(date_str)
|
|
290
|
+
|
|
291
|
+
if date_match is not None:
|
|
292
|
+
year, month, day = (
|
|
293
|
+
int(year_match)
|
|
294
|
+
if (year_match := date_match.group(1)).isdecimal()
|
|
295
|
+
else None,
|
|
296
|
+
int(date_match.group(2)),
|
|
297
|
+
int(date_match.group(3)),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if month is not None and day is not None:
|
|
301
|
+
notes = None
|
|
302
|
+
if note_match is not None:
|
|
303
|
+
raw_note = decode_vcard_text(
|
|
304
|
+
note_match.group(2), note_match.group(1)
|
|
305
|
+
)
|
|
306
|
+
notes = raw_note.replace(r"\n", " ").replace(r"\,", ",")
|
|
307
|
+
|
|
308
|
+
birthdays.append(
|
|
309
|
+
BirthdayEntry(
|
|
310
|
+
uuid.uuid4().hex,
|
|
311
|
+
full_name,
|
|
312
|
+
month,
|
|
313
|
+
day,
|
|
314
|
+
year,
|
|
315
|
+
notes,
|
|
316
|
+
leap_system,
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return birthdays
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def merge_pair(
|
|
324
|
+
existing: BirthdayEntry, incoming: BirthdayEntry, interactive: bool = True
|
|
325
|
+
) -> BirthdayEntry:
|
|
326
|
+
"""Combine data of two entries meaningfully."""
|
|
327
|
+
|
|
328
|
+
merged_notes = tuple(n for n in (existing.notes, incoming.notes) if n)
|
|
329
|
+
merged_notes = "; ".join(merged_notes) if merged_notes else None
|
|
330
|
+
|
|
331
|
+
if interactive:
|
|
332
|
+
notes_choice = choose(("Existing", "Incoming", "Merge"))
|
|
333
|
+
if notes_choice == "1":
|
|
334
|
+
final_notes = existing.notes
|
|
335
|
+
elif notes_choice == "2":
|
|
336
|
+
final_notes = incoming.notes
|
|
337
|
+
else:
|
|
338
|
+
final_notes = merged_notes
|
|
339
|
+
|
|
340
|
+
return BirthdayEntry(
|
|
341
|
+
existing.id,
|
|
342
|
+
incoming.full_name
|
|
343
|
+
if existing.full_name != incoming.full_name
|
|
344
|
+
and confirm("Change the full name?")
|
|
345
|
+
else existing.full_name,
|
|
346
|
+
incoming.month
|
|
347
|
+
if existing.month != incoming.month and confirm("Change the month?")
|
|
348
|
+
else existing.month,
|
|
349
|
+
incoming.day
|
|
350
|
+
if existing.day != incoming.day and confirm("Change the day?")
|
|
351
|
+
else existing.day,
|
|
352
|
+
incoming.year
|
|
353
|
+
if existing.year is None
|
|
354
|
+
or (existing.year != incoming.year and confirm("Change the year?"))
|
|
355
|
+
else existing.year,
|
|
356
|
+
final_notes,
|
|
357
|
+
incoming.leap_system
|
|
358
|
+
if existing.leap_system != incoming.leap_system
|
|
359
|
+
and confirm("Change the leap system?")
|
|
360
|
+
else existing.leap_system,
|
|
361
|
+
)
|
|
362
|
+
return BirthdayEntry(
|
|
363
|
+
existing.id,
|
|
364
|
+
incoming.full_name,
|
|
365
|
+
incoming.month,
|
|
366
|
+
incoming.day,
|
|
367
|
+
incoming.year if existing.year is None else existing.year,
|
|
368
|
+
merged_notes,
|
|
369
|
+
incoming.leap_system,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def merge_entries(
|
|
374
|
+
existing: List[BirthdayEntry],
|
|
375
|
+
incoming: List[BirthdayEntry],
|
|
376
|
+
interactive: bool = True,
|
|
377
|
+
) -> List[BirthdayEntry]:
|
|
378
|
+
"""Merge two lists using fuzzy string matching to detect similar names."""
|
|
379
|
+
|
|
380
|
+
existing_map: dict[str, list[BirthdayEntry]] = defaultdict(list)
|
|
381
|
+
for entry in existing:
|
|
382
|
+
existing_map[entry.full_name].append(entry)
|
|
383
|
+
|
|
384
|
+
existing_names = tuple(existing_map.keys())
|
|
385
|
+
|
|
386
|
+
final_db = {entry.id: entry for entry in existing}
|
|
387
|
+
|
|
388
|
+
for new_entry in incoming:
|
|
389
|
+
if new_entry.full_name in existing_map:
|
|
390
|
+
if len(existing_map[new_entry.full_name]) > 1:
|
|
391
|
+
choice = choose(
|
|
392
|
+
existing_map[new_entry.full_name],
|
|
393
|
+
prompt=(
|
|
394
|
+
f"\nMultiple exact matches for '{new_entry.full_name}'. "
|
|
395
|
+
"Which one to merge into?"
|
|
396
|
+
),
|
|
397
|
+
extra={"S": "Skip this contact entirely"},
|
|
398
|
+
required=True,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
if choice == "S":
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
match = existing_map[new_entry.full_name][int(choice) - 1]
|
|
405
|
+
else:
|
|
406
|
+
match = existing_map[new_entry.full_name][0]
|
|
407
|
+
|
|
408
|
+
if (
|
|
409
|
+
match.month == new_entry.month
|
|
410
|
+
and match.day == new_entry.day
|
|
411
|
+
and match.year == new_entry.year
|
|
412
|
+
and match.notes == new_entry.notes
|
|
413
|
+
):
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
if interactive:
|
|
417
|
+
print(
|
|
418
|
+
(
|
|
419
|
+
f"\nExact name match found for '{new_entry.full_name}', "
|
|
420
|
+
"but data differs."
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
print(f"Existing: {match}")
|
|
424
|
+
print(f"Incoming: {new_entry}")
|
|
425
|
+
if confirm("Update existing entry?"):
|
|
426
|
+
final_db[match.id] = merge_pair(match, new_entry)
|
|
427
|
+
else:
|
|
428
|
+
final_db[match.id] = merge_pair(match, new_entry, interactive=False)
|
|
429
|
+
|
|
430
|
+
else:
|
|
431
|
+
close_names = difflib.get_close_matches(
|
|
432
|
+
new_entry.full_name, existing_names, n=3, cutoff=0.8
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
if not close_names:
|
|
436
|
+
final_db[new_entry.id] = new_entry
|
|
437
|
+
continue
|
|
438
|
+
|
|
439
|
+
if interactive:
|
|
440
|
+
print(f"\nIncoming contact: {new_entry}")
|
|
441
|
+
print("Found similar existing names:")
|
|
442
|
+
|
|
443
|
+
options: List[BirthdayEntry] = [
|
|
444
|
+
entry for name in close_names for entry in existing_map[name]
|
|
445
|
+
]
|
|
446
|
+
|
|
447
|
+
choice = choose(
|
|
448
|
+
options,
|
|
449
|
+
extra={
|
|
450
|
+
"A": "Add as completely new entry",
|
|
451
|
+
"S": "Skip this contact entirely",
|
|
452
|
+
},
|
|
453
|
+
required=True,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
if choice == "A":
|
|
457
|
+
final_db[new_entry.id] = new_entry
|
|
458
|
+
elif choice == "S":
|
|
459
|
+
pass
|
|
460
|
+
elif choice.isdigit():
|
|
461
|
+
selected_match = options[int(choice) - 1]
|
|
462
|
+
final_db[selected_match.id] = merge_pair(selected_match, new_entry)
|
|
463
|
+
else:
|
|
464
|
+
final_db[new_entry.id] = new_entry
|
|
465
|
+
|
|
466
|
+
return list(final_db.values())
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def find_entry(db: List[BirthdayEntry], identifier: str) -> BirthdayEntry | None:
|
|
470
|
+
"""Locate an entry by UUID, name, date, or fuzzy match."""
|
|
471
|
+
|
|
472
|
+
for entry in db:
|
|
473
|
+
if entry.id == identifier:
|
|
474
|
+
return entry
|
|
475
|
+
|
|
476
|
+
matches: list[BirthdayEntry] = []
|
|
477
|
+
ident_lower = identifier.casefold()
|
|
478
|
+
|
|
479
|
+
date_match = DATE.match(identifier)
|
|
480
|
+
year = month = day = None
|
|
481
|
+
if date_match:
|
|
482
|
+
year_group = date_match.group(1)
|
|
483
|
+
year = int(year_group) if year_group and year_group != "--" else None
|
|
484
|
+
month = int(date_match.group(2))
|
|
485
|
+
day = int(date_match.group(3))
|
|
486
|
+
|
|
487
|
+
for entry in db:
|
|
488
|
+
if entry.full_name.casefold() == ident_lower:
|
|
489
|
+
matches.append(entry)
|
|
490
|
+
elif (
|
|
491
|
+
date_match
|
|
492
|
+
and entry.month == month
|
|
493
|
+
and entry.day == day
|
|
494
|
+
and (not year or entry.year == year)
|
|
495
|
+
):
|
|
496
|
+
matches.append(entry)
|
|
497
|
+
elif ident_lower in entry.full_name.casefold():
|
|
498
|
+
matches.append(entry)
|
|
499
|
+
elif entry.notes and ident_lower in entry.notes.casefold():
|
|
500
|
+
matches.append(entry)
|
|
501
|
+
|
|
502
|
+
if not matches:
|
|
503
|
+
db_names = [e.full_name for e in db]
|
|
504
|
+
close_names = difflib.get_close_matches(identifier, db_names, n=5, cutoff=0.6)
|
|
505
|
+
|
|
506
|
+
if close_names:
|
|
507
|
+
close_names_set = set(close_names)
|
|
508
|
+
for entry in db:
|
|
509
|
+
if entry.full_name in close_names_set:
|
|
510
|
+
matches.append(entry)
|
|
511
|
+
|
|
512
|
+
if not matches:
|
|
513
|
+
print(f"Error: No entry found matching '{identifier}'.")
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
if len(matches) == 1:
|
|
517
|
+
return matches[0]
|
|
518
|
+
|
|
519
|
+
choice = choose(
|
|
520
|
+
matches,
|
|
521
|
+
prompt=f"\nMultiple entries found for '{identifier}'. Which one did you mean?",
|
|
522
|
+
extra={"S": "Skip/Cancel"},
|
|
523
|
+
required=True,
|
|
524
|
+
)
|
|
525
|
+
if choice == "S":
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
return matches[int(choice) - 1]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# ==========================================
|
|
532
|
+
# PRESENTATION APIs
|
|
533
|
+
# ==========================================
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def confirm(
|
|
537
|
+
prompt: str = "Are you sure?",
|
|
538
|
+
default_no: bool = True,
|
|
539
|
+
required: bool = False,
|
|
540
|
+
allow_skip: bool = False,
|
|
541
|
+
) -> bool | None:
|
|
542
|
+
"""Prompt user for a confirmation."""
|
|
543
|
+
|
|
544
|
+
if required:
|
|
545
|
+
suffix = "(y/n/s)" if allow_skip else "(y/n)"
|
|
546
|
+
else:
|
|
547
|
+
if allow_skip:
|
|
548
|
+
suffix = "(y/N/s)" if default_no else "(Y/n/s)"
|
|
549
|
+
else:
|
|
550
|
+
suffix = "(y/N)" if default_no else "(Y/n)"
|
|
551
|
+
|
|
552
|
+
while True:
|
|
553
|
+
user_input = input(f"{prompt} {suffix}: ").strip().lower()
|
|
554
|
+
|
|
555
|
+
if not user_input:
|
|
556
|
+
if required:
|
|
557
|
+
print("Input is required. Please choose an option.")
|
|
558
|
+
continue
|
|
559
|
+
return not default_no
|
|
560
|
+
|
|
561
|
+
elif user_input in {"yes", "y"}:
|
|
562
|
+
return True
|
|
563
|
+
|
|
564
|
+
elif allow_skip and user_input in {"skip", "s"}:
|
|
565
|
+
return None
|
|
566
|
+
|
|
567
|
+
elif user_input in {"no", "n"}:
|
|
568
|
+
return False
|
|
569
|
+
|
|
570
|
+
if required:
|
|
571
|
+
print("Invalid input. Please choose a valid option.")
|
|
572
|
+
continue
|
|
573
|
+
|
|
574
|
+
return False
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@overload
|
|
578
|
+
def choose(
|
|
579
|
+
options: Collection[Any],
|
|
580
|
+
extra: dict[str, str] | None = None,
|
|
581
|
+
prompt: str = "Choose an option:",
|
|
582
|
+
start: int = 1,
|
|
583
|
+
required: Literal[True] = True,
|
|
584
|
+
) -> str: ...
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@overload
|
|
588
|
+
def choose(
|
|
589
|
+
options: Collection[Any],
|
|
590
|
+
extra: dict[str, str] | None = None,
|
|
591
|
+
prompt: str = "Choose an option:",
|
|
592
|
+
start: int = 1,
|
|
593
|
+
required: Literal[False] = ...,
|
|
594
|
+
) -> str | None: ...
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def choose(
|
|
598
|
+
options: Collection[Any],
|
|
599
|
+
extra: dict[str, str] | None = None,
|
|
600
|
+
prompt: str = "Choose an option:",
|
|
601
|
+
start: int = 1,
|
|
602
|
+
required: bool = False,
|
|
603
|
+
) -> str | None:
|
|
604
|
+
"""Prompt user to make a choice."""
|
|
605
|
+
print(prompt)
|
|
606
|
+
for position, option in enumerate(options, start):
|
|
607
|
+
print(f"[{position}] - {option}")
|
|
608
|
+
|
|
609
|
+
if extra:
|
|
610
|
+
for key, value in extra.items():
|
|
611
|
+
print(f"[{key}] - {value}")
|
|
612
|
+
|
|
613
|
+
while True:
|
|
614
|
+
choice = input("-> My choice is... ").strip()
|
|
615
|
+
|
|
616
|
+
if not choice:
|
|
617
|
+
if required:
|
|
618
|
+
print("Input is required. Please choose a valid option.")
|
|
619
|
+
continue
|
|
620
|
+
return None
|
|
621
|
+
|
|
622
|
+
if choice.isdecimal():
|
|
623
|
+
if start <= int(choice) < start + len(options):
|
|
624
|
+
return choice
|
|
625
|
+
|
|
626
|
+
if extra:
|
|
627
|
+
choice_lower = choice.lower()
|
|
628
|
+
for key in extra:
|
|
629
|
+
if key.lower() == choice_lower:
|
|
630
|
+
return key
|
|
631
|
+
|
|
632
|
+
if required:
|
|
633
|
+
print("Invalid choice. Please try again.")
|
|
634
|
+
else:
|
|
635
|
+
return None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def to_ordinal(number: int) -> str:
|
|
639
|
+
"""Convert a cardinal number into its ordinal form."""
|
|
640
|
+
n = abs(number)
|
|
641
|
+
if n % 100 in (11, 12, 13):
|
|
642
|
+
return f"{n}th"
|
|
643
|
+
elif n % 10 == 1:
|
|
644
|
+
return f"{n}st"
|
|
645
|
+
elif n % 10 == 2:
|
|
646
|
+
return f"{n}nd"
|
|
647
|
+
elif n % 10 == 3:
|
|
648
|
+
return f"{n}rd"
|
|
649
|
+
return f"{n}th"
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def display_birthdays(
|
|
653
|
+
entries: List[BirthdayEntry],
|
|
654
|
+
sort_by: Literal["name", "date", "upcoming", "recent", "age"] = "upcoming",
|
|
655
|
+
sort_order: Literal["asc", "desc"] = "desc",
|
|
656
|
+
view_style: Literal["simple", "table", "calendar"] = "simple",
|
|
657
|
+
) -> None:
|
|
658
|
+
"""Handle all terminal printing."""
|
|
659
|
+
today = datetime.date.today()
|
|
660
|
+
|
|
661
|
+
if sort_by == "name":
|
|
662
|
+
entries.sort(
|
|
663
|
+
key=lambda entry: entry.full_name.casefold(), reverse=sort_order == "desc"
|
|
664
|
+
)
|
|
665
|
+
elif sort_by == "date":
|
|
666
|
+
entries.sort(
|
|
667
|
+
key=lambda entry: (
|
|
668
|
+
entry.year
|
|
669
|
+
if entry.year is not None
|
|
670
|
+
else float("inf" if sort_order == "asc" else "-inf"),
|
|
671
|
+
entry.month,
|
|
672
|
+
entry.day,
|
|
673
|
+
),
|
|
674
|
+
reverse=sort_order == "desc",
|
|
675
|
+
)
|
|
676
|
+
elif sort_by == "upcoming":
|
|
677
|
+
entries.sort(
|
|
678
|
+
key=lambda entry: attrgetter("years", "months", "days")(
|
|
679
|
+
entry.next_occurrence_in(today)
|
|
680
|
+
),
|
|
681
|
+
reverse=sort_order == "desc",
|
|
682
|
+
)
|
|
683
|
+
elif sort_by == "recent":
|
|
684
|
+
entries.sort(
|
|
685
|
+
key=lambda entry: attrgetter("years", "months", "days")(
|
|
686
|
+
entry.prev_occurrence_in(today)
|
|
687
|
+
),
|
|
688
|
+
reverse=sort_order == "desc",
|
|
689
|
+
)
|
|
690
|
+
elif sort_by == "age":
|
|
691
|
+
entries.sort(
|
|
692
|
+
key=lambda entry: (
|
|
693
|
+
age
|
|
694
|
+
if (age := entry.get_age()) is not None
|
|
695
|
+
else float("inf" if sort_order == "asc" else "-inf")
|
|
696
|
+
),
|
|
697
|
+
reverse=sort_order == "desc",
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
if view_style == "simple":
|
|
701
|
+
print("Birthdays 🎂")
|
|
702
|
+
for entry in entries:
|
|
703
|
+
emoji = date_to_emoji(entry.year, entry.month, entry.day)
|
|
704
|
+
age = entry.get_age()
|
|
705
|
+
next_in = entry.next_occurrence_in(today)
|
|
706
|
+
prev_in = entry.prev_occurrence_in(today)
|
|
707
|
+
|
|
708
|
+
print(f"{emoji:<2} {entry}")
|
|
709
|
+
if entry.is_today():
|
|
710
|
+
age = f"{to_ordinal(age)} " if age is not None else ""
|
|
711
|
+
print(f"Has a {age}birthday today 🥳")
|
|
712
|
+
else:
|
|
713
|
+
age = f"{age} y.o., " if age is not None else ""
|
|
714
|
+
months = tuple(
|
|
715
|
+
f" {delta.months} month{'s' if delta.months > 1 else ''}"
|
|
716
|
+
if delta.months > 0
|
|
717
|
+
else ""
|
|
718
|
+
for delta in (next_in, prev_in)
|
|
719
|
+
)
|
|
720
|
+
days = tuple(
|
|
721
|
+
(
|
|
722
|
+
f"{' and' if delta.months else ''} "
|
|
723
|
+
f"{delta.days} day{'s' if delta.days > 1 else ''}"
|
|
724
|
+
)
|
|
725
|
+
if delta.days > 0
|
|
726
|
+
else ""
|
|
727
|
+
for delta in (next_in, prev_in)
|
|
728
|
+
)
|
|
729
|
+
if sort_by != "recent":
|
|
730
|
+
print(f"{age}Next in{months[0]}{days[0]}")
|
|
731
|
+
elif sort_by == "recent":
|
|
732
|
+
print(f"{age}Previous:{months[1]}{days[1]} ago")
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# ==========================================
|
|
736
|
+
# ARGPARSE CLI
|
|
737
|
+
# ==========================================
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def setup_parser() -> argparse.ArgumentParser:
|
|
741
|
+
"""Build the CLI interface."""
|
|
742
|
+
|
|
743
|
+
parser = argparse.ArgumentParser(
|
|
744
|
+
prog="birthdays",
|
|
745
|
+
description="A robust CLI tool to manage, merge, and track birthdays.",
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
parser.add_argument(
|
|
749
|
+
"--version",
|
|
750
|
+
action="version",
|
|
751
|
+
help="Show program's version number and exit",
|
|
752
|
+
version=f"%(prog)s {__version__}",
|
|
753
|
+
)
|
|
754
|
+
parser.add_argument(
|
|
755
|
+
"--about",
|
|
756
|
+
action="store_true",
|
|
757
|
+
help="Show information about this program",
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
761
|
+
subparsers.required = True
|
|
762
|
+
|
|
763
|
+
parser_list = subparsers.add_parser("list", help="Display saved birthdays")
|
|
764
|
+
parser_list.add_argument(
|
|
765
|
+
"--sort",
|
|
766
|
+
choices=["name", "date", "upcoming", "recent", "age"],
|
|
767
|
+
default="upcoming",
|
|
768
|
+
help="How to sort the output",
|
|
769
|
+
)
|
|
770
|
+
parser_list.add_argument(
|
|
771
|
+
"--order",
|
|
772
|
+
choices=["asc", "desc"],
|
|
773
|
+
default="desc",
|
|
774
|
+
help="In which order to sort the output",
|
|
775
|
+
)
|
|
776
|
+
parser_list.add_argument(
|
|
777
|
+
"--view",
|
|
778
|
+
choices=["simple", "table", "calendar"],
|
|
779
|
+
default="simple",
|
|
780
|
+
help="Visual presentation style",
|
|
781
|
+
)
|
|
782
|
+
parser_list.add_argument(
|
|
783
|
+
"-f",
|
|
784
|
+
"--file",
|
|
785
|
+
type=Path,
|
|
786
|
+
help="Read directly from a .vcf or .json file without modifying the database",
|
|
787
|
+
)
|
|
788
|
+
parser_list.add_argument(
|
|
789
|
+
"--leap-system",
|
|
790
|
+
choices=["before", "after"],
|
|
791
|
+
default="before",
|
|
792
|
+
help="Fallback leap system when reading dynamically from a .vcf file",
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
parser_add = subparsers.add_parser("add", help="Manually add a new birthday")
|
|
796
|
+
parser_add.add_argument("name", type=str, help="Full name of the person")
|
|
797
|
+
parser_add.add_argument("date", type=str, help="Birthday (YYYY-MM-DD | MM-DD)")
|
|
798
|
+
parser_add.add_argument("--note", type=str, help="Optional note to attach")
|
|
799
|
+
parser_add.add_argument(
|
|
800
|
+
"--leap-system",
|
|
801
|
+
dest="leap_system",
|
|
802
|
+
choices=["before", "after"],
|
|
803
|
+
default="before",
|
|
804
|
+
help="When leaplings celebrate in non-leap years (default: before)",
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
parser_edit = subparsers.add_parser("edit", help="Modify an existing entry")
|
|
808
|
+
parser_edit.add_argument("identifier", type=str, help="Name or UUID of the person")
|
|
809
|
+
parser_edit.add_argument("--name", type=str, help="Update the full name")
|
|
810
|
+
parser_edit.add_argument(
|
|
811
|
+
"--date", type=str, help="Update the birthday (YYYY-MM-DD | MM-DD)"
|
|
812
|
+
)
|
|
813
|
+
parser_edit.add_argument("--note", type=str, help="Update the attached note")
|
|
814
|
+
parser_edit.add_argument(
|
|
815
|
+
"--leap-system",
|
|
816
|
+
dest="leap_system",
|
|
817
|
+
choices=["before", "after"],
|
|
818
|
+
help="Update when this leapling celebrates in non-leap years",
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
parser_delete = subparsers.add_parser("delete", help="Delete an entry")
|
|
822
|
+
parser_delete.add_argument(
|
|
823
|
+
"identifier", type=str, help="Name or UUID of the person"
|
|
824
|
+
)
|
|
825
|
+
parser_delete.add_argument(
|
|
826
|
+
"-y", "--yes", action="store_true", help="Skip the confirmation prompt"
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
parser_import = subparsers.add_parser(
|
|
830
|
+
"import", help="Import birthdays from a vCard or JSON file"
|
|
831
|
+
)
|
|
832
|
+
parser_import.add_argument("file", type=Path, help="Path to the .vcf file")
|
|
833
|
+
parser_import.add_argument(
|
|
834
|
+
"-y",
|
|
835
|
+
"--yes",
|
|
836
|
+
action="store_true",
|
|
837
|
+
help="Skip interactive collision prompts and auto-merge safe entries",
|
|
838
|
+
)
|
|
839
|
+
parser_import.add_argument(
|
|
840
|
+
"--leap-system",
|
|
841
|
+
dest="leap_system",
|
|
842
|
+
choices=["before", "after"],
|
|
843
|
+
default="before",
|
|
844
|
+
help="Default leap system to assign to imported contacts",
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
return parser
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def main():
|
|
851
|
+
if "--about" in sys.argv:
|
|
852
|
+
print(__about__)
|
|
853
|
+
sys.exit(0)
|
|
854
|
+
|
|
855
|
+
parser = setup_parser()
|
|
856
|
+
args = parser.parse_args()
|
|
857
|
+
|
|
858
|
+
db_path = get_database_path()
|
|
859
|
+
|
|
860
|
+
if args.command == "list":
|
|
861
|
+
if args.file:
|
|
862
|
+
if not args.file.exists():
|
|
863
|
+
print(f"Error: File '{args.file}' not found.")
|
|
864
|
+
sys.exit(1)
|
|
865
|
+
if args.file.suffix.lower() in [".vcf", ".vcard"]:
|
|
866
|
+
entries = parse_vcards(args.file, args.leap_system)
|
|
867
|
+
else:
|
|
868
|
+
entries = load_database(args.file)
|
|
869
|
+
else:
|
|
870
|
+
entries = load_database(db_path)
|
|
871
|
+
|
|
872
|
+
if not entries:
|
|
873
|
+
print("No birthdays found.")
|
|
874
|
+
return
|
|
875
|
+
|
|
876
|
+
display_birthdays(
|
|
877
|
+
entries, sort_by=args.sort, sort_order=args.order, view_style=args.view
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
elif args.command == "add":
|
|
881
|
+
db = load_database(db_path)
|
|
882
|
+
|
|
883
|
+
date_match = DATE.match(args.date)
|
|
884
|
+
if not date_match:
|
|
885
|
+
print("Error: Invalid date format. Use YYYY-MM-DD or MM-DD.")
|
|
886
|
+
sys.exit(1)
|
|
887
|
+
|
|
888
|
+
year_group = date_match.group(1)
|
|
889
|
+
year = int(year_group) if year_group and year_group != "--" else None
|
|
890
|
+
month = int(date_match.group(2))
|
|
891
|
+
day = int(date_match.group(3))
|
|
892
|
+
|
|
893
|
+
try:
|
|
894
|
+
new_entry = BirthdayEntry(
|
|
895
|
+
id=uuid.uuid4().hex,
|
|
896
|
+
full_name=args.name,
|
|
897
|
+
month=month,
|
|
898
|
+
day=day,
|
|
899
|
+
year=year,
|
|
900
|
+
notes=args.note,
|
|
901
|
+
leap_system=args.leap_system,
|
|
902
|
+
)
|
|
903
|
+
except ValueError as e:
|
|
904
|
+
print(f"Error creating entry: {e}")
|
|
905
|
+
sys.exit(1)
|
|
906
|
+
|
|
907
|
+
db.append(new_entry)
|
|
908
|
+
save_database(db, db_path)
|
|
909
|
+
print(f"Added: {new_entry}")
|
|
910
|
+
|
|
911
|
+
elif args.command == "edit":
|
|
912
|
+
db = load_database(db_path)
|
|
913
|
+
target = find_entry(db, args.identifier)
|
|
914
|
+
if not target:
|
|
915
|
+
sys.exit(1)
|
|
916
|
+
|
|
917
|
+
if args.name:
|
|
918
|
+
target.full_name = args.name
|
|
919
|
+
|
|
920
|
+
if args.date:
|
|
921
|
+
date_match = DATE.match(args.date)
|
|
922
|
+
if not date_match:
|
|
923
|
+
print("Error: Invalid date format. Use YYYY-MM-DD or MM-DD.")
|
|
924
|
+
sys.exit(1)
|
|
925
|
+
|
|
926
|
+
year_group = date_match.group(1)
|
|
927
|
+
target.year = int(year_group) if year_group and year_group != "--" else None
|
|
928
|
+
target.month = int(date_match.group(2))
|
|
929
|
+
target.day = int(date_match.group(3))
|
|
930
|
+
|
|
931
|
+
if args.note is not None:
|
|
932
|
+
target.notes = args.note if args.note.strip() else None
|
|
933
|
+
|
|
934
|
+
if args.leap_system:
|
|
935
|
+
target.leap_system = args.leap_system
|
|
936
|
+
|
|
937
|
+
save_database(db, db_path)
|
|
938
|
+
print(f"Updated: {target}")
|
|
939
|
+
|
|
940
|
+
elif args.command == "delete":
|
|
941
|
+
db = load_database(db_path)
|
|
942
|
+
target = find_entry(db, args.identifier)
|
|
943
|
+
if not target:
|
|
944
|
+
sys.exit(1)
|
|
945
|
+
|
|
946
|
+
if not args.yes:
|
|
947
|
+
if not confirm(f"Are you sure you want to delete {target}?"):
|
|
948
|
+
print("Deletion cancelled.")
|
|
949
|
+
return
|
|
950
|
+
|
|
951
|
+
db = [e for e in db if e.id != target.id]
|
|
952
|
+
save_database(db, db_path)
|
|
953
|
+
print(f"Deleted '{target.full_name}'.")
|
|
954
|
+
|
|
955
|
+
elif args.command == "import":
|
|
956
|
+
if not args.file.exists():
|
|
957
|
+
print(f"Error: File '{args.file}' not found.")
|
|
958
|
+
sys.exit(1)
|
|
959
|
+
|
|
960
|
+
db = load_database(db_path)
|
|
961
|
+
|
|
962
|
+
if args.file.suffix.lower() in [".vcf", ".vcard"]:
|
|
963
|
+
incoming = parse_vcards(args.file, args.leap_system)
|
|
964
|
+
else:
|
|
965
|
+
incoming = load_database(args.file)
|
|
966
|
+
|
|
967
|
+
print(f"Loaded {len(incoming)} contacts from {args.file.name}.")
|
|
968
|
+
|
|
969
|
+
merged_db = merge_entries(db, incoming, interactive=not args.yes)
|
|
970
|
+
save_database(merged_db, db_path)
|
|
971
|
+
|
|
972
|
+
added_count = len(merged_db) - len(db)
|
|
973
|
+
print(f"\nImport complete. The database grew by {added_count} entries.")
|
|
974
|
+
|
|
975
|
+
sys.exit(0)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
if __name__ == "__main__":
|
|
979
|
+
main()
|
birthdays/emojis.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from hashlib import sha256
|
|
2
|
+
|
|
3
|
+
# fmt: off
|
|
4
|
+
POSITIVE_FACES = (
|
|
5
|
+
# Basic Smiles
|
|
6
|
+
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '🫠', '😉', '😊',
|
|
7
|
+
'😇', '🥰', '😍', '🤩', '😘', '😗', '☺️', '😚', '😙', '🥲',
|
|
8
|
+
|
|
9
|
+
# Tongues, Hands, and Accessories
|
|
10
|
+
'😋', '😛', '😝', '😜', '🤪', '🤑', '🤗', '🤭', '🤫', '🤔', '🫡', '🤐', '🤨',
|
|
11
|
+
'🥳', '🥸', '😎', '🤓', '🧐',
|
|
12
|
+
|
|
13
|
+
# Neutral and skeptical
|
|
14
|
+
'😐', '😑', '😶', '🫥', '😶🌫️', '😏', '😌',
|
|
15
|
+
|
|
16
|
+
# Costumes, Creatures, and Animals
|
|
17
|
+
'😺', '😸', '😹', '😻', '😼', '😽', '🙈', '🙉', '🙊'
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
ANIMALS_AND_NATURE = (
|
|
21
|
+
# Mammals & Marsupials
|
|
22
|
+
'🐵', '🐒', '🦍', '🦧', '🐶', '🐕', '🦮', '🐕🦺', '🐩', '🐺', '🦊', '🦝', '🐱',
|
|
23
|
+
'🐈', '🐈⬛', '🦁', '🐯', '🐅', '🐆', '🐴', '🫎', '🫏', '🐎', '🦄', '🦓', '🦌',
|
|
24
|
+
'🦬', '🐮', '🐂', '🐃', '🐄', '🐷', '🐖', '🐗', '🐽', '🐏', '🐑', '🐐', '🐪',
|
|
25
|
+
'🐫', '🦙', '🦒', '🐘', '🦣', '🦏', '🦛', '🐭', '🐁', '🐀', '🐹', '🐰', '🐇',
|
|
26
|
+
'🐿️', '🦫', '🦔', '🦇', '🐻', '🐻❄️', '🐨', '🐼', '🦥', '🦦', '🦨', '🦘', '🦡',
|
|
27
|
+
'🐾',
|
|
28
|
+
|
|
29
|
+
# Birds
|
|
30
|
+
'🦃', '🐔', '🐓', '🐣', '🐤', '🐥', '🐦', '🐧', '🕊️', '🦅', '🦆', '🦢', '🦉',
|
|
31
|
+
'🦤', '🪶', '🦩', '🦚', '🦜', '🪽', '🐦⬛', '🪿', '🐦🔥', '🪹', '🪺',
|
|
32
|
+
|
|
33
|
+
# Marine Life and Reptiles
|
|
34
|
+
'🐸', '🐊', '🐢', '🦎', '🐍', '🐲', '🐉', '🦕', '🦖', '🐳', '🐋', '🐬', '🦭',
|
|
35
|
+
'🐟', '🐠', '🐡', '🦈', '🐙', '🐚', '🪸', '🪼', '🦀', '🦞', '🦐', '🦑', '🦪',
|
|
36
|
+
|
|
37
|
+
# Insects
|
|
38
|
+
'🐌', '🦋', '🐛', '🐜', '🐝', '🪲', '🐞', '🦗', '🪳', '🕷️', '🕸️', '🦂', '🦟',
|
|
39
|
+
'🪰', '🪱', '🦠',
|
|
40
|
+
|
|
41
|
+
# Plants, Flowers, and Nature
|
|
42
|
+
'💐', '🌸', '💮', '🪷', '🏵️', '🌹', '🥀', '🌺', '🌻', '🌼', '🌷', '🪻', '🌱',
|
|
43
|
+
'🪴', '🌲', '🌳', '🌴', '🌵', '🌾', '🌿', '☘️', '🍀', '🍁', '🍂', '🍃', '🍄',
|
|
44
|
+
'🫚', '🪨', '🪵',
|
|
45
|
+
|
|
46
|
+
# Sky and Weather
|
|
47
|
+
'🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘', '🌙', '🌚', '🌛', '🌜', '☀️',
|
|
48
|
+
'🌝', '🌞', '🪐', '⭐', '🌟', '🌠', '🌌', '☁️', '⛅', '⛈️', '🌤️', '🌥️', '🌦️',
|
|
49
|
+
'🌧️', '🌨️', '🌩️', '🌪️', '🌫️', '🌬️', '🌀', '🌈', '🌂', '☂️', '☔', '⛱️', '⚡',
|
|
50
|
+
'❄️', '☃️', '⛄', '☄️', '🔥', '💧', '🌊'
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
FOOD_AND_DRINK = (
|
|
54
|
+
# Fruits
|
|
55
|
+
'🍇', '🍈', '🍉', '🍊', '🍋', '🍋🟩', '🍌', '🍍', '🥭', '🍎', '🍏', '🍐', '🍑',
|
|
56
|
+
'🍒', '🍓', '🫐', '🥝', '🍅', '🫒', '🥥',
|
|
57
|
+
|
|
58
|
+
# Vegetables
|
|
59
|
+
'🥑', '🍆', '🥔', '🥕', '🌽', '🌶️', '🫑', '🥒', '🥬', '🥦', '🧄', '🧅', '🥜',
|
|
60
|
+
'🫘', '🌰', '🫚', '🫛', '🍄🟫', '🍠',
|
|
61
|
+
|
|
62
|
+
# Dishes
|
|
63
|
+
'🍞', '🥐', '🥖', '🫓', '🥨', '🥯', '🥞', '🧇', '🧀', '🍖', '🍗', '🥩', '🥓',
|
|
64
|
+
'🍔', '🍟', '🍕', '🌭', '🥪', '🌮', '🌯', '🫔', '🥙', '🧆', '🥚', '🍳', '🥘',
|
|
65
|
+
'🍲', '🫕', '🥣', '🥗', '🍿', '🧈', '🧂', '🥫', '🍝',
|
|
66
|
+
|
|
67
|
+
# Asian Dishes
|
|
68
|
+
'🍱', '🍘', '🍙', '🍚', '🍛', '🍜', '🍠', '🍢', '🍣', '🍤', '🍥', '🥮', '🍡',
|
|
69
|
+
'🥟', '🥠', '🥡',
|
|
70
|
+
|
|
71
|
+
# Sweets and Desserts
|
|
72
|
+
'🍦', '🍧', '🍨', '🍩', '🍪', '🎂', '🍰', '🧁', '🥧', '🍫', '🍬', '🍭', '🍮',
|
|
73
|
+
'🍯',
|
|
74
|
+
|
|
75
|
+
# Beverages and Tableware
|
|
76
|
+
'🍼', '🥛', '☕', '🫖', '🍵', '🍶', '🍾', '🍷', '🍸', '🍹', '🍺', '🍻', '🥂',
|
|
77
|
+
'🥃', '🫗', '🥤', '🧋', '🧃', '🧉', '🥢', '🍽️', '🍴', '🥄', '🔪', '🫙', '🏺'
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
ACTIVITIES = (
|
|
81
|
+
# Events and Celebrations
|
|
82
|
+
'🎃', '🎄', '🎆', '🎇', '🧨', '✨', '🎈', '🎉', '🎊', '🎋', '🎍', '🎎', '🎏',
|
|
83
|
+
'🎐', '🎑', '🧧', '🎁', '🎟️', '🎫', '🏮', '🪔',
|
|
84
|
+
|
|
85
|
+
# Sports and Awards
|
|
86
|
+
'🏅', '🏆', '🎖️', '🥇', '🥈', '🥉', '⚽', '⚾', '🥎', '🏀', '🏐', '🏈', '🏉',
|
|
87
|
+
'🎾', '🥏', '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅', '⛳',
|
|
88
|
+
'⛸️', '🎣', '🤿', '🎽', '🎿', '🛷', '🥌', '🎯',
|
|
89
|
+
|
|
90
|
+
# Games and Culture
|
|
91
|
+
'🪀', '🪁', '🔫', '🎱', '🔮', '🪄', '🎮', '🕹️', '🎰', '🎲', '🧩', '🪅', '🪩',
|
|
92
|
+
'🪆', '♠️', '♥️', '♦️', '♣️', '♟️', '🃏', '🀄', '🎴', '🎭', '🖼️', '🎨'
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
TRAVEL_AND_PLACES = (
|
|
96
|
+
# Maps and Geography
|
|
97
|
+
'🌍', '🌎', '🌏', '🌐', '🗺️', '🗾', '🧭', '🏔️', '⛰️', '🌋', '🗻', '🏕️', '🏖️',
|
|
98
|
+
'🏜️', '🏝️', '🏞️', '🏯', '🏰', '💒', '🗼', '🗽', '⛪', '🕌', '🛕', '🕍', '⛩️',
|
|
99
|
+
'🕋', '⛲', '⛺', '🌁', '🌃', '🏙️', '🌄', '🌅', '🌆', '🌇', '🌉', '♨️', '🎠',
|
|
100
|
+
'🎡', '🎢', '💈', '🎪', '🛎️', '🗿',
|
|
101
|
+
|
|
102
|
+
# Land Travel
|
|
103
|
+
'🚂', '🚃', '🚄', '🚅', '🚆', '🚇', '🚈', '🚉', '🚊', '🚝', '🚞', '🚋', '🚌',
|
|
104
|
+
'🚍', '🚎', '🚐', '🚑', '🚒', '🚓', '🚔', '🚕', '🚖', '🚗', '🚘', '🚙', '🛻',
|
|
105
|
+
'🚚', '🚛', '🚜', '🏎️', '🏍️', '🛵', '🦽', '🦼', '🛺', '🚲', '🛴', '🛹', '🛼',
|
|
106
|
+
'🚏', '🛣️', '🛤️', '🛢️', '⛽', '🛞', '🚨', '🚥', '🚦', '🛑', '🚧',
|
|
107
|
+
|
|
108
|
+
# Air and Sea Travel
|
|
109
|
+
'⚓', '🛟', '⛵', '🛶', '🚤', '🛳️', '⛴️', '🛥️', '🚢', '✈️', '🛩️', '🛫', '🛬',
|
|
110
|
+
'🪂', '💺', '🚁', '🚟', '🚠', '🚡', '🛰️', '🚀', '🛸'
|
|
111
|
+
)
|
|
112
|
+
# fmt:on
|
|
113
|
+
|
|
114
|
+
ALL_EMOJIS = (
|
|
115
|
+
POSITIVE_FACES
|
|
116
|
+
+ ANIMALS_AND_NATURE
|
|
117
|
+
+ FOOD_AND_DRINK
|
|
118
|
+
+ ACTIVITIES
|
|
119
|
+
+ TRAVEL_AND_PLACES
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def date_to_emoji(year: int | None, month: int, day: int) -> str:
|
|
124
|
+
"""Get a unique emoji for the specified date."""
|
|
125
|
+
if isinstance(year, int):
|
|
126
|
+
encoded = f"{year}-{month}-{day}".encode("utf-8")
|
|
127
|
+
else:
|
|
128
|
+
encoded = f"{month}-{day}".encode("utf-8")
|
|
129
|
+
|
|
130
|
+
hashed = sha256(encoded).hexdigest()
|
|
131
|
+
|
|
132
|
+
return ALL_EMOJIS[int(hashed, 16) % len(ALL_EMOJIS)]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: birthdays-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A command-line utility for displaying and managing birthdays from JSON- or vCard-based contacts.
|
|
5
|
+
Project-URL: Repository, https://github.com/l1asis/birthdays.git
|
|
6
|
+
Project-URL: Issues, https://github.com/l1asis/birthdays/issues
|
|
7
|
+
Author: Volodymyr Horshenin
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: address-book,birthday-tracker,birthdays,calendar,cli,command-line,contacts,vcard,vcf
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: platformdirs>=4.0.0
|
|
23
|
+
Requires-Dist: python-dateutil>=2.8.2
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# birthdays
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/birthdays-cli/)
|
|
31
|
+
[](https://pypi.org/project/birthdays-cli/)
|
|
32
|
+

|
|
33
|
+
[](https://opensource.org/licenses/MIT)
|
|
34
|
+
[](https://github.com/astral-sh/ruff)
|
|
35
|
+
|
|
36
|
+
`birthdays` is a robust Python command-line tool designed to conveniently manage, track, and celebrate your contacts' birthdays.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **Customizable Sorting:** List birthdays exactly how you want to see them (by upcoming, recent, age, name, or date)
|
|
41
|
+
- **CRUD Operations:** Easily `add`, `edit`, and `delete` entries. The deletion and edit commands feature a convenient fuzzy search so you don't have to type out exact names
|
|
42
|
+
- **Smart Imports:** Import contacts directly from `.vcf` vCard files or JSON databases
|
|
43
|
+
- **Interactive Merging:** During imports, the CLI intelligently detects duplicates or data collisions and prompts you to safely merge them
|
|
44
|
+
- **Leapling Support:** Configure how leap year birthdays (February 29th) are handled in non-leap years, choosing to celebrate either the day before or the day after
|
|
45
|
+
- **Festive UI:** Every date is assigned a unique, deterministic emoji to keep the terminal vibe bright and colorful
|
|
46
|
+
|
|
47
|
+
## Requirements
|
|
48
|
+
|
|
49
|
+
- Python 3.11+
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
Install the package from PyPI using your favorite package management tool such as pip, pipx, or uv:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install birthdays-cli
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or install the latest version from source:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/l1asis/birthdays.git
|
|
63
|
+
cd birthdays
|
|
64
|
+
pip install .
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
`birthdays` uses simple subcommands to organize different operations. You can append `--help` to any command to see its available arguments.
|
|
70
|
+
|
|
71
|
+
### Listing Birthdays
|
|
72
|
+
|
|
73
|
+
> [!NOTE]
|
|
74
|
+
> By default, this sorts by upcoming birthdays in descending order so the most immediate celebrations are right at your cursor.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
birthdays list
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Options:**
|
|
81
|
+
|
|
82
|
+
- `--sort`: Choose from `name`, `date`, `upcoming`, `recent`, or `age`.
|
|
83
|
+
- `--order`: Choose `asc` or `desc`.
|
|
84
|
+
- `-f`, `--file`: Temporarily read and display birthdays directly from a `.vcf` or `.json` file without modifying your local database.
|
|
85
|
+
|
|
86
|
+
### Adding an Entry
|
|
87
|
+
|
|
88
|
+
> [!NOTE]
|
|
89
|
+
> The date can be formatted as `YYYY-MM-DD`, or simply `MM-DD` if the year is unknown.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
birthdays add "John Doe" 1990-05-14 --note "Loves chocolate cake"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Editing an Entry
|
|
96
|
+
|
|
97
|
+
> [!NOTE]
|
|
98
|
+
> You can use either the name or UUID. You only need to pass the flags for the specific data you want to change.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
birthdays edit "John Doe" --date 1991-05-14
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Deleting an Entry
|
|
105
|
+
|
|
106
|
+
> [!TIP]
|
|
107
|
+
> The CLI uses fuzzy matching, so typing a partial name usually works! Append `-y` to skip the confirmation prompt.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
birthdays delete "John Doe"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Importing Contacts
|
|
114
|
+
|
|
115
|
+
> [!TIP]
|
|
116
|
+
> The interactive prompt will guide you through any data collisions. Append `-y` to automatically skip these prompts and blindly merge safe entries.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
birthdays import ./contacts.vcf
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Contributing
|
|
123
|
+
|
|
124
|
+
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
|
125
|
+
|
|
126
|
+
1. Fork the Project
|
|
127
|
+
2. Create your Feature Branch (`git checkout -b feat/amazing-feature`)
|
|
128
|
+
3. Commit your Changes (`git commit -m 'feat: ✨ add some amazing-feature'`)
|
|
129
|
+
4. Push to the Branch (`git push origin feat/amazing-feature`)
|
|
130
|
+
5. Open a Pull Request
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
Distributed under the MIT License. See `LICENSE` for more information.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
birthdays/__init__.py,sha256=zCzEvxnhtLPJjnQv1MmTa_Z1ow2tfBWRlyYeyBs-STs,1223
|
|
2
|
+
birthdays/__main__.py,sha256=W8FOgBbwE2kWvGA6X1bjPK5YMLr_4nN0Pkba7d57e1U,71
|
|
3
|
+
birthdays/birthdays.py,sha256=xE5lGQiIQhh_4QQpuP0cuqdPxazpvol_RpsNLSY69Zs,33150
|
|
4
|
+
birthdays/emojis.py,sha256=FVbIjE7oCw2gStRLcMNG_JM4KjUPqE_Gl9G84rdJylo,6486
|
|
5
|
+
birthdays_cli-0.1.0.dist-info/METADATA,sha256=QTRK4G3-tGaC4RQzeS5Uws0O0KbS9jipiqLg5TKwj30,4947
|
|
6
|
+
birthdays_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
birthdays_cli-0.1.0.dist-info/entry_points.txt,sha256=41u_T1XtdlnPo1gIfAxWKb4ykfUTpSv23Uu-TbiJCEY,87
|
|
8
|
+
birthdays_cli-0.1.0.dist-info/licenses/LICENSE,sha256=1qxp5_4r7H7IrQTXGxvpUx_Ik20Cr6XIBE3CfRxxTLU,1097
|
|
9
|
+
birthdays_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Volodymyr Horshenin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|