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 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
@@ -0,0 +1,4 @@
1
+ from .birthdays import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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
+ ![birthdays GIF demo](https://raw.githubusercontent.com/l1asis/birthdays/refs/heads/main/examples/demo.gif)
29
+
30
+ [![PyPI Version](https://img.shields.io/pypi/v/birthdays-cli.svg)](https://pypi.org/project/birthdays-cli/)
31
+ [![PyPI Python version](https://img.shields.io/pypi/pyversions/birthdays-cli.svg)](https://pypi.org/project/birthdays-cli/)
32
+ ![PyPI downloads](https://img.shields.io/pypi/dm/birthdays-cli)
33
+ [![License](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
34
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ bday = birthdays.birthdays:main
3
+ birthdays = birthdays.birthdays:main
@@ -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.