#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Standalone watcher for ob02 daily activity. This watcher is deliberately separated from tagfeed. It listens to source file changes and updates only today's daily-activity block. """ from datetime import datetime from pathlib import Path from typing import Optional import os import threading import time import traceback from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer from daily_activity import DEFAULT_VAULT, TRACKED_SUFFIXES, should_skip_diary_path, update_diaries VAULT = Path(os.environ.get("DAILY_ACTIVITY_VAULT", DEFAULT_VAULT)).expanduser() LOG_PATH = Path(os.environ.get("DAILY_ACTIVITY_WATCH_LOG", "/Users/ar/.daily-activity-watch.log")) DEBOUNCE_SECONDS = float(os.environ.get("DAILY_ACTIVITY_DEBOUNCE", "1.5")) AUTO_DIARY_BACKFILL_DAYS = 0 def log(message: str) -> None: stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") line = f"[{stamp}] {message}" print(line, flush=True) try: with LOG_PATH.open("a", encoding="utf-8") as fh: fh.write(line + "\n") except OSError: pass def is_relevant(path: Path) -> bool: if not path: return False try: path.relative_to(VAULT) except ValueError: return False if path.name == ".DS_Store" or path.name.startswith("."): return False if should_skip_diary_path(str(path), str(VAULT)): return False return path.name.lower().endswith(TRACKED_SUFFIXES) class DebouncedDailyActivity: def __init__(self, debounce_seconds: float) -> None: self.debounce_seconds = debounce_seconds self._lock = threading.Lock() self._timer: Optional[threading.Timer] = None self._running = False self._pending = False self.known_written = {} def trigger(self, reason: str) -> None: log(f"event: {reason}") with self._lock: if self._running: self._pending = True return if self._timer is not None: self._timer.cancel() self._timer = threading.Timer(self.debounce_seconds, self._fire) self._timer.daemon = True self._timer.start() def _fire(self) -> None: with self._lock: self._timer = None self._running = True try: changed = update_diaries( str(VAULT), AUTO_DIARY_BACKFILL_DAYS, log=log, known_written=self.known_written, ) log(f"Daily activity: updated {changed} diaries") except Exception: log("daily activity sync failed:\n" + traceback.format_exc().rstrip()) finally: with self._lock: self._running = False pending = self._pending self._pending = False if pending: self.trigger("coalesced") class VaultEventHandler(FileSystemEventHandler): def __init__(self, scheduler: DebouncedDailyActivity) -> None: self.scheduler = scheduler def on_any_event(self, event) -> None: if getattr(event, "is_directory", False): return src = Path(getattr(event, "src_path", "") or "") dest = Path(getattr(event, "dest_path", "") or "") if is_relevant(src) or is_relevant(dest): self.scheduler.trigger(f"{event.event_type}: {src or dest}") def main() -> None: if not VAULT.is_dir(): raise SystemExit(f"vault not found: {VAULT}") log(f"daily activity watch start vault={VAULT} mode=event debounce={DEBOUNCE_SECONDS:g}s") scheduler = DebouncedDailyActivity(DEBOUNCE_SECONDS) handler = VaultEventHandler(scheduler) observer = Observer() observer.schedule(handler, str(VAULT), recursive=True) observer.start() scheduler.trigger("startup") try: while True: time.sleep(1) except KeyboardInterrupt: log("daily activity watch stopped by user") finally: observer.stop() observer.join(timeout=5) if __name__ == "__main__": main()