alarmy-cli 1.0.1__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.
@@ -0,0 +1,4 @@
1
+ """
2
+ Alarm Clock Package
3
+ """
4
+ __version__ = "1.0.0"
alarm_clock/audio.py ADDED
@@ -0,0 +1,66 @@
1
+ import time
2
+ import threading
3
+ import platform
4
+ import sys
5
+ import logging
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class AlarmSoundController:
10
+ """
11
+ Manages non-blocking audio alerts across platforms.
12
+ On Windows, it uses the built-in winsound.Beep API.
13
+ On Linux, it uses the terminal bell character (\a) with a quiet sleep.
14
+ """
15
+ def __init__(self):
16
+ self._stop_event = threading.Event()
17
+ self._thread: Optional[threading.Thread] = None
18
+ self._lock = threading.Lock()
19
+
20
+ def _beep_loop(self) -> None:
21
+ system = platform.system()
22
+ while not self._stop_event.is_set():
23
+ if system == "Windows":
24
+ try:
25
+ import winsound
26
+ # 1000 Hz frequency, 600 ms duration
27
+ winsound.Beep(1000, 600)
28
+ except Exception as e:
29
+ # Fallback to ASCII bell if winsound fails
30
+ sys.stdout.write('\a')
31
+ sys.stdout.flush()
32
+ else:
33
+ # Linux/macOS fallback using ASCII bell character
34
+ sys.stdout.write('\a')
35
+ sys.stdout.flush()
36
+
37
+ # Sleep in small increments to respond quickly to stop events
38
+ for _ in range(10):
39
+ if self._stop_event.is_set():
40
+ break
41
+ time.sleep(0.1)
42
+
43
+ def start(self) -> bool:
44
+ """
45
+ Starts the alarm beep loop in a background thread if not already running.
46
+ Returns True if a new thread was started, False otherwise.
47
+ """
48
+ with self._lock:
49
+ if self._thread and self._thread.is_alive():
50
+ return False # Already running
51
+
52
+ self._stop_event.clear()
53
+ self._thread = threading.Thread(target=self._beep_loop, name="AlarmSoundThread", daemon=True)
54
+ self._thread.start()
55
+ return True
56
+
57
+ def stop(self) -> None:
58
+ """
59
+ Stops the alarm beep loop.
60
+ """
61
+ with self._lock:
62
+ if self._thread and self._thread.is_alive():
63
+ self._stop_event.set()
64
+ # Wait briefly for the thread to stop, but don't block indefinitely
65
+ self._thread.join(timeout=1.5)
66
+ self._thread = None
alarm_clock/cli.py ADDED
@@ -0,0 +1,489 @@
1
+ import sys
2
+ import datetime
3
+ import time
4
+ import threading
5
+ from typing import List, Optional
6
+
7
+ from alarm_clock.models import Alarm, AlarmState, parse_days
8
+ from alarm_clock.scheduler import AlarmScheduler, parse_time
9
+ from alarm_clock.ui import TerminalUI, Colors, enable_ansi_support, safe_print
10
+
11
+ def on_alarm_trigger(alarm: Alarm) -> None:
12
+ """
13
+ Callback executed when an alarm fires during interactive mode.
14
+ """
15
+ TerminalUI.print_alarm_trigger(alarm)
16
+ sys.stdout.write(f"\n({datetime.datetime.now().strftime('%H:%M:%S')}) alarm-clock > ")
17
+ sys.stdout.flush()
18
+
19
+ def run_add_wizard(scheduler: AlarmScheduler) -> None:
20
+ """
21
+ Guides the user step-by-step to create an alarm with input validation.
22
+ """
23
+ safe_print(f"\n{Colors.CYAN}{Colors.BOLD}--- Interactive Alarm Setup Wizard ---{Colors.RESET}")
24
+
25
+ # 1. Prompt Time
26
+ while True:
27
+ try:
28
+ time_input = input("Enter alarm time (HH:MM or HH:MM PM) [e.g., 08:30]: ").strip()
29
+ if not time_input:
30
+ safe_print(f"{Colors.RED}Time cannot be empty.{Colors.RESET}")
31
+ continue
32
+ parse_time(time_input)
33
+ break
34
+ except ValueError as e:
35
+ safe_print(f"{Colors.RED}Error: {e}{Colors.RESET}")
36
+ except KeyboardInterrupt:
37
+ safe_print(f"\n{Colors.YELLOW}Setup cancelled.{Colors.RESET}")
38
+ return
39
+
40
+ # 2. Prompt Label
41
+ try:
42
+ label_input = input("Enter alarm label [default: 'Alarm']: ").strip()
43
+ label = label_input if label_input else "Alarm"
44
+ except KeyboardInterrupt:
45
+ safe_print(f"\n{Colors.YELLOW}Setup cancelled.{Colors.RESET}")
46
+ return
47
+
48
+ # 3. Prompt Days (Recurrence)
49
+ while True:
50
+ try:
51
+ days_input = input("Repeat on days (e.g. Mon,Wed,Fri, or 'daily', or press Enter for Once): ").strip()
52
+ days = parse_days(days_input)
53
+ break
54
+ except ValueError as e:
55
+ safe_print(f"{Colors.RED}Error: {e}{Colors.RESET}")
56
+ except KeyboardInterrupt:
57
+ safe_print(f"\n{Colors.YELLOW}Setup cancelled.{Colors.RESET}")
58
+ return
59
+
60
+ # 4. Prompt Auto-dismiss Seconds
61
+ while True:
62
+ try:
63
+ dismiss_input = input("Auto-dismiss duration in seconds [default: 60]: ").strip()
64
+ if not dismiss_input:
65
+ auto_dismiss = 60
66
+ break
67
+ auto_dismiss = int(dismiss_input)
68
+ if auto_dismiss <= 0:
69
+ safe_print(f"{Colors.RED}Duration must be a positive integer.{Colors.RESET}")
70
+ continue
71
+ break
72
+ except ValueError:
73
+ safe_print(f"{Colors.RED}Please enter a valid integer.{Colors.RESET}")
74
+ except KeyboardInterrupt:
75
+ safe_print(f"\n{Colors.YELLOW}Setup cancelled.{Colors.RESET}")
76
+ return
77
+
78
+ # 5. Prompt Snooze Minutes
79
+ while True:
80
+ try:
81
+ snooze_input = input("Default snooze duration in minutes [default: 5]: ").strip()
82
+ if not snooze_input:
83
+ snooze_minutes = 5
84
+ break
85
+ snooze_minutes = int(snooze_input)
86
+ if snooze_minutes <= 0:
87
+ safe_print(f"{Colors.RED}Snooze duration must be a positive integer.{Colors.RESET}")
88
+ continue
89
+ break
90
+ except ValueError:
91
+ safe_print(f"{Colors.RED}Please enter a valid integer.{Colors.RESET}")
92
+ except KeyboardInterrupt:
93
+ safe_print(f"\n{Colors.YELLOW}Setup cancelled.{Colors.RESET}")
94
+ return
95
+
96
+ # Create the alarm
97
+ try:
98
+ alarm = scheduler.add_alarm(time_input, label, days, auto_dismiss, snooze_minutes)
99
+ recurrence_text = f"repeating on {','.join(alarm.days)}" if alarm.days else "one-time"
100
+ safe_print(f"\n{Colors.GREEN}Success: Created Alarm {alarm.id} for {alarm.time.strftime('%H:%M')} ('{alarm.label}') - {recurrence_text}, auto-dismiss: {alarm.auto_dismiss_sec}s, snooze: {alarm.snooze_duration_min}m.{Colors.RESET}\n")
101
+ except Exception as e:
102
+ safe_print(f"{Colors.RED}Error creating alarm: {e}{Colors.RESET}")
103
+
104
+ def handle_add(scheduler: AlarmScheduler, args: List[str]) -> None:
105
+ # If no arguments, fallback to interactive setup wizard
106
+ if not args:
107
+ run_add_wizard(scheduler)
108
+ return
109
+
110
+ days = []
111
+ auto_dismiss = 60
112
+ snooze_minutes = 5
113
+
114
+ # Parse optional --days flag
115
+ if "--days" in args:
116
+ try:
117
+ idx = args.index("--days")
118
+ if idx + 1 < len(args):
119
+ days_str = args[idx + 1]
120
+ days = parse_days(days_str)
121
+ args.pop(idx + 1)
122
+ args.pop(idx)
123
+ else:
124
+ safe_print(f"{Colors.RED}Error: --days flag requires a value (e.g. Mon,Wed).{Colors.RESET}")
125
+ return
126
+ except ValueError as e:
127
+ safe_print(f"{Colors.RED}Error parsing --days: {e}{Colors.RESET}")
128
+ return
129
+
130
+ # Parse optional --auto-dismiss flag
131
+ if "--auto-dismiss" in args:
132
+ try:
133
+ idx = args.index("--auto-dismiss")
134
+ if idx + 1 < len(args):
135
+ dismiss_str = args[idx + 1]
136
+ auto_dismiss = int(dismiss_str)
137
+ if auto_dismiss <= 0:
138
+ raise ValueError("Duration must be a positive integer.")
139
+ args.pop(idx + 1)
140
+ args.pop(idx)
141
+ else:
142
+ safe_print(f"{Colors.RED}Error: --auto-dismiss flag requires an integer value.{Colors.RESET}")
143
+ return
144
+ except ValueError as e:
145
+ safe_print(f"{Colors.RED}Error parsing --auto-dismiss: {e}{Colors.RESET}")
146
+ return
147
+
148
+ # Parse optional --snooze-minutes flag
149
+ if "--snooze-minutes" in args:
150
+ try:
151
+ idx = args.index("--snooze-minutes")
152
+ if idx + 1 < len(args):
153
+ snooze_str = args[idx + 1]
154
+ snooze_minutes = int(snooze_str)
155
+ if snooze_minutes <= 0:
156
+ raise ValueError("Snooze duration must be a positive integer.")
157
+ args.pop(idx + 1)
158
+ args.pop(idx)
159
+ else:
160
+ safe_print(f"{Colors.RED}Error: --snooze-minutes flag requires an integer value.{Colors.RESET}")
161
+ return
162
+ except ValueError as e:
163
+ safe_print(f"{Colors.RED}Error parsing --snooze-minutes: {e}{Colors.RESET}")
164
+ return
165
+
166
+ if not args:
167
+ safe_print(f"{Colors.RED}Error: 'add' command requires a time (HH:MM).{Colors.RESET}")
168
+ return
169
+
170
+ time_str = args[0]
171
+ label = " ".join(args[1:]) if len(args) > 1 else "Alarm"
172
+
173
+ try:
174
+ alarm = scheduler.add_alarm(time_str, label, days, auto_dismiss, snooze_minutes)
175
+ recurrence_text = f"repeating on {','.join(alarm.days)}" if alarm.days else "one-time"
176
+ safe_print(f"{Colors.GREEN}Success: Created Alarm {alarm.id} for {alarm.time.strftime('%H:%M')} ('{alarm.label}') - {recurrence_text}, auto-dismiss: {alarm.auto_dismiss_sec}s, snooze: {alarm.snooze_duration_min}m.{Colors.RESET}")
177
+ except ValueError as e:
178
+ safe_print(f"{Colors.RED}Error: {e}{Colors.RESET}")
179
+
180
+ def handle_list(scheduler: AlarmScheduler) -> None:
181
+ alarms = scheduler.get_all_alarms()
182
+ now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
183
+ TerminalUI.print_status_bar(now_str)
184
+ TerminalUI.print_alarms_table(alarms)
185
+
186
+ def handle_remove(scheduler: AlarmScheduler, args: List[str]) -> None:
187
+ if not args:
188
+ safe_print(f"{Colors.RED}Error: 'remove' command requires an alarm ID.{Colors.RESET}")
189
+ return
190
+
191
+ try:
192
+ alarm_id = int(args[0])
193
+ success = scheduler.remove_alarm(alarm_id)
194
+ if success:
195
+ safe_print(f"{Colors.GREEN}Success: Removed alarm {alarm_id}.{Colors.RESET}")
196
+ else:
197
+ safe_print(f"{Colors.RED}Error: Alarm ID {alarm_id} not found.{Colors.RESET}")
198
+ except ValueError:
199
+ safe_print(f"{Colors.RED}Error: Alarm ID must be an integer.{Colors.RESET}")
200
+
201
+ def handle_snooze(scheduler: AlarmScheduler, args: List[str]) -> None:
202
+ if not args:
203
+ safe_print(f"{Colors.RED}Error: 'snooze' command requires an alarm ID.{Colors.RESET}")
204
+ return
205
+
206
+ try:
207
+ alarm_id = int(args[0])
208
+ minutes = int(args[1]) if len(args) > 1 else None
209
+
210
+ alarm = scheduler.snooze_alarm(alarm_id, minutes)
211
+ if alarm:
212
+ resume_time = alarm.snooze_until.strftime('%H:%M:%S')
213
+ snooze_len = minutes if minutes is not None else alarm.snooze_duration_min
214
+ safe_print(f"{Colors.GREEN}Success: Alarm {alarm_id} snoozed for {snooze_len} minutes (until {resume_time}).{Colors.RESET}")
215
+ else:
216
+ safe_print(f"{Colors.RED}Error: Alarm ID {alarm_id} not found.{Colors.RESET}")
217
+ except ValueError:
218
+ safe_print(f"{Colors.RED}Error: Invalid arguments. Usage: snooze <ID> [minutes]{Colors.RESET}")
219
+
220
+ def handle_dismiss(scheduler: AlarmScheduler, args: List[str]) -> None:
221
+ if not args:
222
+ safe_print(f"{Colors.RED}Error: 'dismiss' command requires an alarm ID.{Colors.RESET}")
223
+ return
224
+
225
+ try:
226
+ alarm_id = int(args[0])
227
+ alarm = scheduler.dismiss_alarm(alarm_id)
228
+ if alarm:
229
+ safe_print(f"{Colors.GREEN}Success: Alarm {alarm_id} dismissed.{Colors.RESET}")
230
+ else:
231
+ safe_print(f"{Colors.RED}Error: Alarm ID {alarm_id} not found.{Colors.RESET}")
232
+ except ValueError:
233
+ safe_print(f"{Colors.RED}Error: Alarm ID must be an integer.{Colors.RESET}")
234
+
235
+ def run_daemon() -> None:
236
+ """
237
+ Runs the Alarm Scheduler process indefinitely in the foreground.
238
+ Monitors database JSON, updates states, and plays audio when alarms trigger.
239
+ """
240
+ enable_ansi_support()
241
+ safe_print(f"{Colors.CYAN}Starting Alarm Clock Daemon...{Colors.RESET}")
242
+ safe_print(f"{Colors.DIM}Monitoring alarms. Press Ctrl+C to terminate.{Colors.RESET}\n")
243
+
244
+ def daemon_trigger_callback(alarm: Alarm) -> None:
245
+ TerminalUI.print_alarm_trigger(alarm)
246
+
247
+ scheduler = AlarmScheduler(on_trigger_callback=daemon_trigger_callback)
248
+ scheduler.start()
249
+
250
+ try:
251
+ while True:
252
+ time.sleep(1)
253
+ except KeyboardInterrupt:
254
+ safe_print(f"\n{Colors.CYAN}Stopping Daemon. Goodbye!{Colors.RESET}")
255
+ finally:
256
+ scheduler.stop()
257
+
258
+ def get_non_blocking_input(stop_event: threading.Event) -> Optional[str]:
259
+ """
260
+ Reads user input from terminal in a non-blocking manner to watch the stop_event.
261
+ Uses msvcrt on Windows and select on Linux.
262
+ """
263
+ import platform
264
+ if platform.system() == "Windows":
265
+ import msvcrt
266
+ input_str = ""
267
+ while not stop_event.is_set():
268
+ if msvcrt.kbhit():
269
+ char = msvcrt.getwch()
270
+ if char in ('\r', '\n'):
271
+ sys.stdout.write('\n')
272
+ sys.stdout.flush()
273
+ return input_str
274
+ elif char == '\b': # Backspace
275
+ if len(input_str) > 0:
276
+ input_str = input_str[:-1]
277
+ sys.stdout.write('\b \b')
278
+ sys.stdout.flush()
279
+ elif ord(char) >= 32: # Printable
280
+ input_str += char
281
+ sys.stdout.write(char)
282
+ sys.stdout.flush()
283
+ time.sleep(0.05)
284
+ return None
285
+ else:
286
+ import select
287
+ while not stop_event.is_set():
288
+ ready, _, _ = select.select([sys.stdin], [], [], 0.1)
289
+ if ready:
290
+ return sys.stdin.readline().strip()
291
+ return None
292
+
293
+ def run_ring(alarm_id: int) -> None:
294
+ """
295
+ Plays audio buzzer and displays prompt when scheduled task triggers.
296
+ Executed in a short-lived subprocess task window.
297
+ """
298
+ enable_ansi_support()
299
+ scheduler = AlarmScheduler()
300
+ alarm = scheduler.ring_alarm(alarm_id)
301
+ if not alarm:
302
+ # Alarm deleted, or already handled in another process
303
+ return
304
+
305
+ # Start audio buzz
306
+ scheduler._sound_controller.start()
307
+
308
+ TerminalUI.clear_screen()
309
+ TerminalUI.print_alarm_trigger(alarm)
310
+
311
+ stop_event = threading.Event()
312
+
313
+ def monitor_disk_state() -> None:
314
+ """
315
+ Monitors database file changes externally or handles sound expiry.
316
+ """
317
+ while not stop_event.is_set():
318
+ time.sleep(0.5)
319
+ alarms = {a.id: a for a in scheduler.get_all_alarms()}
320
+ if alarm_id not in alarms:
321
+ stop_event.set()
322
+ break
323
+
324
+ current_alarm = alarms[alarm_id]
325
+ if current_alarm.state != AlarmState.RINGING:
326
+ stop_event.set()
327
+ break
328
+
329
+ # Handle sound expiry (auto-dismiss)
330
+ if current_alarm.ring_start_time:
331
+ elapsed = (datetime.datetime.now() - current_alarm.ring_start_time).total_seconds()
332
+ if elapsed >= current_alarm.auto_dismiss_sec:
333
+ scheduler.dismiss_alarm(alarm_id)
334
+ stop_event.set()
335
+ safe_print(f"\n{Colors.YELLOW}Alarm auto-dismissed after {current_alarm.auto_dismiss_sec} seconds.{Colors.RESET}")
336
+ break
337
+
338
+ monitor_thread = threading.Thread(target=monitor_disk_state, name="RingMonitorThread", daemon=True)
339
+ monitor_thread.start()
340
+
341
+ try:
342
+ while not stop_event.is_set():
343
+ sys.stdout.write("Press Enter to dismiss, or type 'snooze' to snooze: ")
344
+ sys.stdout.flush()
345
+
346
+ user_input = get_non_blocking_input(stop_event)
347
+ if stop_event.is_set() or user_input is None:
348
+ break
349
+
350
+ cmd = user_input.strip().lower()
351
+ if cmd == "snooze":
352
+ scheduler.snooze_alarm(alarm_id, None) # Pass None to trigger alarm-specific snooze duration
353
+ # Calculate correct snooze length for stdout log print
354
+ snooze_len = alarm.snooze_duration_min
355
+ safe_print(f"{Colors.GREEN}Alarm snoozed for {snooze_len} minutes.{Colors.RESET}")
356
+ break
357
+ else:
358
+ scheduler.dismiss_alarm(alarm_id)
359
+ safe_print(f"{Colors.GREEN}Alarm dismissed.{Colors.RESET}")
360
+ break
361
+ except KeyboardInterrupt:
362
+ scheduler.dismiss_alarm(alarm_id)
363
+ finally:
364
+ stop_event.set()
365
+ scheduler.stop()
366
+
367
+ def print_cli_help() -> None:
368
+ enable_ansi_support()
369
+ TerminalUI.print_banner()
370
+ help_text = f"""
371
+ {Colors.BOLD}CLI Alarm Clock Usage:{Colors.RESET}
372
+ {Colors.GREEN}alarm-clock{Colors.RESET} - Launches the interactive console
373
+ {Colors.GREEN}alarm-clock add{Colors.RESET} - Start the interactive Setup Wizard
374
+ {Colors.GREEN}alarm-clock add <HH:MM> [label] [--days d] [--auto-dismiss s] [--snooze-minutes m]{Colors.RESET}
375
+ - Create a new alarm directly
376
+ Flags:
377
+ --days: repeat on days (e.g. Mon,Wed or daily)
378
+ --auto-dismiss: seconds limit (e.g. 30)
379
+ --snooze-minutes: custom snooze limit (e.g. 10)
380
+ {Colors.GREEN}alarm-clock list{Colors.RESET} - List all alarms and exit
381
+ {Colors.GREEN}alarm-clock remove <ID>{Colors.RESET} - Remove an alarm and exit
382
+ {Colors.GREEN}alarm-clock snooze <ID> [minutes]{Colors.RESET} - Snooze a ringing alarm and exit
383
+ {Colors.GREEN}alarm-clock dismiss <ID>{Colors.RESET} - Dismiss a ringing alarm and exit
384
+ {Colors.GREEN}alarm-clock clear{Colors.RESET} - Wipes the database and cancels all OS tasks
385
+ {Colors.GREEN}alarm-clock daemon{Colors.RESET} - Run the background sound and time monitor
386
+ {Colors.GREEN}alarm-clock help{Colors.RESET} - Show this CLI command usage
387
+ """
388
+ print(help_text)
389
+
390
+ def run_interactive() -> None:
391
+ """
392
+ Runs the interactive menu loop.
393
+ """
394
+ enable_ansi_support()
395
+ scheduler = AlarmScheduler(on_trigger_callback=on_alarm_trigger)
396
+ scheduler.start()
397
+
398
+ TerminalUI.clear_screen()
399
+ TerminalUI.print_banner()
400
+ safe_print(f"{Colors.CYAN}Welcome to the CLI Alarm Clock! (Interactive Mode){Colors.RESET}")
401
+ TerminalUI.print_help()
402
+
403
+ try:
404
+ while True:
405
+ now_str = datetime.datetime.now().strftime("%H:%M:%S")
406
+ prompt = f"({now_str}) alarm-clock > "
407
+
408
+ try:
409
+ cmd_line = input(prompt).strip()
410
+ except EOFError:
411
+ break
412
+
413
+ if not cmd_line:
414
+ continue
415
+
416
+ parts = cmd_line.split()
417
+ cmd = parts[0].lower()
418
+ args = parts[1:]
419
+
420
+ if cmd in ("exit", "quit"):
421
+ safe_print(f"\n{Colors.CYAN}Exiting Alarm Clock. Goodbye!{Colors.RESET}")
422
+ break
423
+ elif cmd == "help":
424
+ TerminalUI.print_help()
425
+ elif cmd == "add":
426
+ handle_add(scheduler, args)
427
+ elif cmd == "list":
428
+ handle_list(scheduler)
429
+ elif cmd == "remove":
430
+ handle_remove(scheduler, args)
431
+ elif cmd == "snooze":
432
+ handle_snooze(scheduler, args)
433
+ elif cmd == "dismiss":
434
+ handle_dismiss(scheduler, args)
435
+ elif cmd == "clear":
436
+ scheduler.clear_all_alarms()
437
+ safe_print(f"{Colors.GREEN}Success: Cleared all alarms and OS tasks.{Colors.RESET}")
438
+ else:
439
+ safe_print(f"{Colors.RED}Unknown command: '{cmd}'. Type 'help' for a list of commands.{Colors.RESET}")
440
+
441
+ except KeyboardInterrupt:
442
+ safe_print(f"\n\n{Colors.CYAN}Session interrupted. Exiting Alarm Clock. Goodbye!{Colors.RESET}")
443
+ finally:
444
+ scheduler.stop()
445
+
446
+ def main() -> None:
447
+ if len(sys.argv) > 1:
448
+ cmd = sys.argv[1].lower()
449
+ args = sys.argv[2:]
450
+
451
+ if cmd == "help":
452
+ print_cli_help()
453
+ elif cmd == "daemon":
454
+ run_daemon()
455
+ elif cmd == "ring":
456
+ if args:
457
+ try:
458
+ alarm_id = int(args[0])
459
+ run_ring(alarm_id)
460
+ except ValueError:
461
+ print("Error: Alarm ID must be an integer.")
462
+ sys.exit(1)
463
+ else:
464
+ print("Error: Ring command requires an Alarm ID.")
465
+ sys.exit(1)
466
+ elif cmd == "clear":
467
+ scheduler = AlarmScheduler()
468
+ scheduler.clear_all_alarms()
469
+ safe_print(f"{Colors.GREEN}Success: Cleared all alarms and OS tasks.{Colors.RESET}")
470
+ else:
471
+ scheduler = AlarmScheduler()
472
+ if cmd == "add":
473
+ handle_add(scheduler, args)
474
+ elif cmd == "list":
475
+ handle_list(scheduler)
476
+ elif cmd == "remove":
477
+ handle_remove(scheduler, args)
478
+ elif cmd == "snooze":
479
+ handle_snooze(scheduler, args)
480
+ elif cmd == "dismiss":
481
+ handle_dismiss(scheduler, args)
482
+ else:
483
+ safe_print(f"Unknown command: '{cmd}'. Type 'alarm-clock help' for usage.")
484
+ sys.exit(1)
485
+ else:
486
+ run_interactive()
487
+
488
+ if __name__ == "__main__":
489
+ main()
alarm_clock/logger.py ADDED
@@ -0,0 +1,32 @@
1
+ import logging
2
+ import os
3
+
4
+ LOG_FILE_PATH = os.path.expanduser("~/.cli_alarms.log")
5
+
6
+ # Initialize the audit logger
7
+ logger = logging.getLogger("AlarmClockAudit")
8
+ logger.setLevel(logging.INFO)
9
+
10
+ # Prevent duplicate handlers on re-import
11
+ if not logger.handlers:
12
+ try:
13
+ # Configure file handler with UTF-8 encoding
14
+ file_handler = logging.FileHandler(LOG_FILE_PATH, encoding='utf-8')
15
+ formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
16
+ file_handler.setFormatter(formatter)
17
+ logger.addHandler(file_handler)
18
+ except Exception:
19
+ # Fall back to console stream handler if file permissions are restricted
20
+ console_handler = logging.StreamHandler()
21
+ logger.addHandler(console_handler)
22
+
23
+ def audit_log(message: str, level: int = logging.INFO) -> None:
24
+ """
25
+ Appends an entry to the audit log.
26
+ Strips or replaces non-ascii characters to ensure log files remain completely safe.
27
+ """
28
+ try:
29
+ # Strip or replace non-ascii if needed, but logging handle with utf-8 should be safe
30
+ logger.log(level, message)
31
+ except Exception:
32
+ pass