550cb54b5cde2a89a0aee1cf11a4c9df03c52d8a
[pyutils.git] / examples / reminder / reminder.py
1 #!/usr/bin/env python3
2
3 """
4 Reminders for upcoming important dates.
5 """
6
7 import datetime
8 import logging
9 import os
10 import re
11 import sys
12 from collections import defaultdict
13 from typing import Dict, List, Optional
14
15 from pyutils import argparse_utils, bootstrap, config, persistent, string_utils
16 from pyutils.ansi import fg, reset
17 from pyutils.datetimez import dateparse_utils as dateparse
18 from pyutils.files import file_utils
19
20 logger = logging.getLogger(__name__)
21 cfg = config.add_commandline_args(
22     f"Main ({__file__})", "Reminder of events, birthdays and anniversaries."
23 )
24 cfg.add_argument(
25     "--reminder_filename",
26     type=argparse_utils.valid_filename,
27     default='.reminder',
28     metavar='FILENAME',
29     help="Override the .reminder filepath",
30 )
31 cfg.add_argument(
32     '--reminder_cache_file',
33     type=str,
34     default=f'{os.environ["HOME"]}/.reminder_cache',
35     metavar='FILENAME',
36     help='Override the .reminder cache location',
37 )
38 cfg.add_argument(
39     "-n", "--count", type=int, metavar='COUNT', help="How many events to remind about"
40 )
41 cfg.add_argument(
42     "--days_ahead",
43     type=int,
44     metavar='#DAYS',
45     help="How many days ahead to remind about",
46 )
47 cfg.add_argument(
48     "-d",
49     "--date",
50     "--dates",
51     action="store_true",
52     help="Also include the date along with the n day countdown",
53 )
54 cfg.add_argument(
55     "--override_timestamp",
56     nargs=1,
57     type=argparse_utils.valid_datetime,
58     help="Don't use the current datetime, use this one instead.",
59     metavar="DATE/TIME STRING",
60     default=None,
61 )
62
63
64 # This decorator handles caching this object's state on disk and feeding the
65 # state back to new instances of this object at initialization time.  It also
66 # makes sure that this object is a global singleton in the program.
67 @persistent.persistent_autoloaded_singleton()
68 class Reminder(object):
69     MODE_EVENT = 0
70     MODE_BIRTHDAY = 1
71     MODE_ANNIVERSARY = 2
72
73     def __init__(
74         self, cached_state: Optional[Dict[datetime.date, List[str]]] = None
75     ) -> None:
76         if not config.config['override_timestamp']:
77             self.today = datetime.date.today()
78         else:
79             self.today = config.config['override_timestamp'][0].date()
80             logger.debug(
81                 'Overriding "now" with %s because of commandline argument.',
82                 self.today,
83             )
84         if cached_state is not None:
85             self.label_by_date = cached_state
86             return
87         self.label_by_date: Dict[datetime.date, List[str]] = defaultdict(list)
88         self.read_file(config.config['reminder_filename'])
89
90     def handle_event_by_adjusting_year_to_now(
91         self,
92         parsing_mode: int,
93         orig_date: datetime.date,
94         orig_label: str,
95         saw_overt_year: bool,
96     ) -> None:
97         for year in (self.today.year, self.today.year + 1):
98             label = orig_label
99             if saw_overt_year:
100                 delta = year - orig_date.year
101                 if parsing_mode == Reminder.MODE_BIRTHDAY:
102                     if delta != 0:
103                         label += f" ({delta} year{string_utils.pluralize(delta)} old)"
104                 elif parsing_mode == Reminder.MODE_ANNIVERSARY:
105                     if delta != 0:
106                         label += f" ({delta}{string_utils.thify(delta)} anniversary)"
107             dt = datetime.date(
108                 year=year,
109                 month=orig_date.month,
110                 day=orig_date.day,
111             )
112             logger.debug('Date in %d: %s', year, dt)
113             self.label_by_date[dt].append(label)
114             logger.debug('%s => %s', dt, label)
115
116     def handle_event_with_fixed_year(
117         self,
118         orig_date: datetime.date,
119         orig_label: str,
120     ) -> None:
121         logger.debug('Fixed date event...')
122         self.label_by_date[orig_date].append(orig_label)
123         logger.debug('%s => %s', orig_date, orig_label)
124
125     def read_file(self, filename: str) -> None:
126         logger.debug('Reading %s:', filename)
127         date_parser = dateparse.DateParser()
128         parsing_mode = Reminder.MODE_EVENT
129         with open(filename) as f:
130             lines = f.readlines()
131         for line in lines:
132             line = line.strip()
133             line = re.sub(r"#.*$", "", line)
134             if re.match(r"^ *$", line) is not None:
135                 continue
136             logger.debug('> %s', line)
137             try:
138                 if "=" in line:
139                     label, date = line.split("=")
140                 else:
141                     print(f"Skipping unparsable line: {line}", file=sys.stderr)
142                     logger.error('Skipping malformed line: %s', line)
143                     continue
144
145                 if label == "type":
146                     if "event" in date:
147                         parsing_mode = Reminder.MODE_EVENT
148                         logger.debug('--- EVENT MODE ---')
149                     elif "birthday" in date:
150                         parsing_mode = Reminder.MODE_BIRTHDAY
151                         logger.debug('--- BIRTHDAY MODE ---')
152                     elif "anniversary" in date:
153                         parsing_mode = Reminder.MODE_ANNIVERSARY
154                         logger.debug('--- ANNIVERSARY MODE ---')
155                 else:
156                     date_parser.parse(date)
157                     orig_date = date_parser.get_date()
158                     if orig_date is None:
159                         print(f"Skipping unparsable date: {line}", file=sys.stderr)
160                         logger.error('Skipping line with unparsable date')
161                         continue
162                     logger.debug('Original date: %s', orig_date)
163
164                     overt_year = date_parser.saw_overt_year
165                     if parsing_mode in (
166                         Reminder.MODE_BIRTHDAY,
167                         Reminder.MODE_ANNIVERSARY,
168                     ) or (parsing_mode == Reminder.MODE_EVENT and not overt_year):
169                         self.handle_event_by_adjusting_year_to_now(
170                             parsing_mode, orig_date, label, overt_year
171                         )
172                     else:
173                         self.handle_event_with_fixed_year(orig_date, label)
174
175             except Exception as e:
176                 print(f"Skipping unparsable line: {line}", file=sys.stderr)
177                 logger.error('Skipping malformed line: %s', line)
178                 logger.exception(e)
179
180     def remind(
181         self, count: Optional[int], days_ahead: Optional[int], say_date: bool
182     ) -> None:
183         need_both = False
184         if count is not None and days_ahead is not None:
185             need_both = True
186             seen = 0
187
188         for date in sorted(self.label_by_date.keys()):
189             delta = date - self.today
190             d = delta.days
191             if d >= 0:
192                 if days_ahead is not None:
193                     if d > days_ahead:
194                         if not need_both:
195                             return
196                         seen |= 1
197                         if seen == 3:
198                             return
199                 labels = self.label_by_date[date]
200                 for label in labels:
201                     if d > 1:
202                         if d <= 3:
203                             msg = f"{fg('blaze orange')}{d} days{reset()} until {label}"
204                         elif d <= 7:
205                             msg = f"{fg('peach orange')}{d} days{reset()} until {label}"
206                         else:
207                             msg = f"{d} days until {label}"
208                     elif d == 1:
209                         msg = f"{fg('outrageous orange')}Tomorrow{reset()} is {label}"
210                     else:
211                         assert d == 0
212                         msg = f"{fg('red')}Today{reset()} is {label}"
213                     if say_date:
214                         msg += f" {fg('battleship gray')}on {date.strftime('%A, %B %-d')}{reset()}"
215                     print(msg)
216                     if count is not None:
217                         count -= 1
218                         if count <= 0:
219                             if not need_both:
220                                 return
221                             seen |= 2
222                             if seen == 3:
223                                 return
224
225     @classmethod
226     def load(cls):
227         if not config.config['override_timestamp']:
228             now = datetime.datetime.now()
229         else:
230             now = config.config['override_timestamp'][0]
231             logger.debug(
232                 'Overriding "now" with %s because of commandline argument.', now
233             )
234
235         cache_ts = file_utils.get_file_mtime_as_datetime(
236             config.config['reminder_cache_file']
237         )
238         if cache_ts is None:
239             return None
240
241         # If the cache was already written today...
242         if (
243             now.day == cache_ts.day
244             and now.month == cache_ts.month
245             and now.year == cache_ts.year
246         ):
247             reminder_ts = file_utils.get_file_mtime_as_datetime(
248                 config.config['reminder_filename']
249             )
250
251             # ...and the .reminder file wasn't updated since the cache write...
252             if reminder_ts <= cache_ts:
253                 import pickle
254
255                 with open(config.config['reminder_cache_file'], 'rb') as rf:
256                     reminder_data = pickle.load(rf)
257                     return cls(reminder_data)
258         return None
259
260     def save(self):
261         import pickle
262
263         with open(config.config['reminder_cache_file'], 'wb') as wf:
264             pickle.dump(
265                 self.label_by_date,
266                 wf,
267                 pickle.HIGHEST_PROTOCOL,
268             )
269
270
271 @bootstrap.initialize
272 def main() -> None:
273     reminder = Reminder()
274     count = config.config['count']
275     days_ahead = config.config['days_ahead']
276     reminder.remind(count, days_ahead, config.config['date'])
277     return None
278
279
280 if __name__ == "__main__":
281     main()