Files

1000 lines
38 KiB
Python

#!/usr/bin/env python3
# quickshell-cal.py — calendar + weather backend for the QuickShell bar popup.
#
# Runtime architecture:
# * A home-manager user systemd timer runs `qs-cal-sync sync` every 20 min:
# - Nextcloud CalDAV -> vdirsyncer -> khal (recurrence-aware) -> events.json
# - Open-Meteo forecast (keyless) -> weather.json
# * The QuickShell popup calls `qs-cal-sync read` (instant, merges caches to
# stdout as a single JSON doc) whenever it opens, and `qs-cal-sync
# setloc "<city or lat,lon>"` from the in-popup location editor.
#
# Nextcloud credentials are NOT compiled into anything: the sync step reads the
# env file rendered from the SOPS secret (default /run/secrets/hp-laptop/
# nextcloud-cal-env; override with $QS_CAL_SECRET_ENV), writes a temporary
# vdirsyncer config with them (mode 0600), syncs, then deletes it again.
#
# Every write is atomic (tmp + rename) so the shell never reads a half file.
import datetime
import json
import math
import os
import re
import subprocess
import sys
import tempfile
import uuid
import wave
import urllib.parse
import urllib.request
CACHE = os.path.expanduser("~/.cache/quickshell-cal")
SECRET_ENV = os.environ.get("QS_CAL_SECRET_ENV", "/run/secrets/hp-laptop/nextcloud-cal-env")
# Paths are substituted at build time by the Nix home-manager module so the
# script keeps working regardless of what PATH looks like in a user unit.
VDIRSYNCER = "@vdirsyncer@/bin/vdirsyncer"
VDIR = os.path.expanduser("~/.local/share/vdirsyncer")
STATUS = os.path.expanduser("~/.local/state/vdirsyncer")
EVENTS_FILE = os.path.join(CACHE, "events.json")
WEATHER_FILE = os.path.join(CACHE, "weather.json")
LOC_FILE = os.path.join(CACHE, "loc.json")
# Last-sync health, surfaced as the header badge in the shell popup.
SYNC_FILE = os.path.join(CACHE, "sync.json")
TMP_CONF = os.path.join(CACHE, "vdirsyncer.conf")
# Reminder plumbing (paths baked in by the Nix module). notify-send talks to
# the QuickShell NotificationServer (org.freedesktop.Notifications) so toasts
# appear in our own notification center.
NOTIFY = "@libnotify@/bin/notify-send"
PW_PLAY = "@pipewire@/bin/pw-play"
CHIME_FILE = os.path.join(CACHE, "chime.wav")
# Remember which (uid, occurrence, trigger) we already fired so the per-minute
# remind timer never double-fires.
REMINDER_STATE = os.path.join(CACHE, "reminders-fired.json")
# "uid|occurrence|trigger" -> ISO time at which a snoozed reminder may fire
# again. The shell writes it via `qs-cal-sync snooze <key> <minutes>`; remind()
# skips anything whose deadline is still in the future.
SNOOZE_FILE = os.path.join(CACHE, "snoozes.json")
# First-run bootstrap location (never leaves the machine; change in the popup).
DEFAULT_LOC = {"name": "Edinburgh", "lat": "55.9533", "lon": "-3.1883"}
EVENT_DAYS = 31
UA = "Nix-Vibe-quickshell/1.0"
def log(msg):
sys.stderr.write("[qs-cal] %s\n" % msg)
sys.stderr.flush()
def rd(blob, fallback):
"""json.load a file, tolerating absence/corruption."""
try:
with open(blob, "r") as f:
return json.load(f)
except (OSError, ValueError):
return fallback
def wr(blob, obj):
"""Atomic json write."""
os.makedirs(CACHE, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=CACHE, suffix=".tmp")
with os.fdopen(fd, "w") as f:
json.dump(obj, f, ensure_ascii=False)
os.replace(tmp, blob)
def http_get(url, timeout=10):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read().decode("utf-8", "replace")
def parse_env_file(path):
"""Read a sops-rendered KEY=VALUE env file into a dict."""
if not os.path.exists(path):
return {}
out = {}
try:
with open(path, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
k, _, v = line.partition("=")
if k:
out[k.strip()] = v.strip().strip('"').strip("'")
except OSError as e:
log("cannot read secret env %s: %s" % (path, e))
return {}
return out
def write_tmp_vdirsyncer_conf(env):
"""Temporary vdirsyncer config that syncs EVERY calendar under the CalDAV
principal (collections = ["from a", "from b"] -> discovery)."""
os.makedirs(os.path.dirname(TMP_CONF), exist_ok=True)
url = env.get("NEXTCLOUD_CALDAV_URL", "")
user = env.get("NEXTCLOUD_CALDAV_USERNAME", "")
pw = env.get("NEXTCLOUD_CALDAV_PASSWORD", "")
# Nextcloud CalDAV requires the username in the URL path. If the user
# supplies the base path (/remote.php/dav/calendars/), append the username.
if url and user and url.rstrip("/").endswith("/calendars"):
url = url.rstrip("/") + "/" + user
conf = "\n".join([
"[general]",
'status_path = "%s"' % STATUS,
"",
"[pair nextcloud]",
'a = "nextcloud_local"',
'b = "nextcloud_remote"',
'collections = ["from a", "from b"]',
"",
"[storage nextcloud_local]",
'type = "filesystem"',
'path = "%s"' % VDIR,
'fileext = ".ics"',
"",
"[storage nextcloud_remote]",
'type = "caldav"',
'url = "%s"' % url,
'username = "%s"' % user,
'password = "%s"' % pw,
"",
])
with open(TMP_CONF, "w") as f:
f.write(conf)
os.chmod(TMP_CONF, 0o600)
def run_sync_cmd(argv, timeout=180, stdin_input=None):
try:
p = subprocess.run(
argv, input=stdin_input, capture_output=True, text=True, timeout=timeout)
if p.returncode != 0:
log("failed: %s -> %s" % (" ".join(argv), (p.stderr or p.stdout)[:400]))
return False
return True
except (OSError, subprocess.TimeoutExpired) as e:
log("error running %s: %s" % (argv[0], e))
return False
def list_calendars():
"""Names of the calendars vdirsyncer discovered (one subdir per calendar)."""
if not os.path.isdir(VDIR):
return []
try:
return sorted(n for n in os.listdir(VDIR)
if os.path.isdir(os.path.join(VDIR, n)))
except OSError:
return []
def ics_escape(s):
"""RFC 5545 TEXT escaping (backslash, comma, semicolon, newline)."""
return (str(s).replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\n", "\\n"))
# Repeat choices offered by the shell form -> RFC 5545 FREQ.
REPEAT_FREQ = {"DAILY": "DAILY", "WEEKLY": "WEEKLY", "MONTHLY": "MONTHLY"}
def reminder_minutes(vevent):
"""Minutes-before from the first relative TRIGGER VALARM, or 0 when the
event has none (or only absolute/dated triggers)."""
for arm in vevent.walk("VALARM"):
try:
trig = arm.get("TRIGGER")
if trig is None:
continue
delta = trig.dt
except Exception:
continue
if not isinstance(delta, datetime.timedelta):
continue
# -PT5M -> 5 (minutes before start)
m = re.fullmatch(r"-PT(\d+)M", str(trig))
if m:
return int(m.group(1))
return 0
def vdir_push():
"""Push local .ics changes up to Nextcloud (vdirsyncer sync). No-op when
the secret env file is missing."""
secret = parse_env_file(SECRET_ENV)
if not secret.get("NEXTCLOUD_CALDAV_URL"):
return True
write_tmp_vdirsyncer_conf(secret)
try:
y = ("y\n") * 50
return run_sync_cmd([VDIRSYNCER, "-c", TMP_CONF, "sync"], stdin_input=y)
finally:
if os.path.exists(TMP_CONF):
os.remove(TMP_CONF)
def add_event(params):
"""Write a VEVENT into the chosen calendar's vdirsyncer local dir, then
push it to Nextcloud. params is the JSON dict from the shell popup."""
cal = (params.get("cal") or "").strip()
title = (params.get("title") or "").strip()
if not cal or not title:
return {"ok": False, "error": "calendar and title are required"}
caldir = os.path.join(VDIR, cal)
if not os.path.isdir(caldir):
return {"ok": False, "error": "unknown calendar %r" % cal}
date = (params.get("date") or "").strip()
all_day = bool(params.get("allDay"))
start = (params.get("start") or "").strip()
end = (params.get("end") or "").strip()
loc = (params.get("loc") or "").strip()
reminder = int(params.get("reminder") or 0)
repeat = (params.get("repeat") or "NONE").strip().upper()
tzlocal = datetime.datetime.now().astimezone().tzinfo
uid = "%s@nix-vibe" % uuid.uuid4()
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
# normalise dates/times -> ISO parts
try:
dt = datetime.date.fromisoformat(date)
except ValueError:
return {"ok": False, "error": "invalid date %r" % date}
def dt_line(key, d):
return d.astimezone(datetime.timezone.utc).strftime(key + ":%Y%m%dT%H%M%SZ")
lines = ["BEGIN:VCALENDAR", "VERSION:2.0",
"PRODID:-//Nix-Vibe//quickshell//EN",
"BEGIN:VEVENT", "UID:" + uid, "DTSTAMP:" + now]
if all_day:
# RFC 5545 all-day events use VALUE=DATE and exclusive end date.
lines.append("DTSTART;VALUE=DATE:%s" % dt.strftime("%Y%m%d"))
endd = dt + datetime.timedelta(days=1)
lines.append("DTEND;VALUE=DATE:%s" % endd.strftime("%Y%m%d"))
else:
start_dt = datetime.datetime.strptime(start or "09:00", "%H:%M").replace(
year=dt.year, month=dt.month, day=dt.day, tzinfo=tzlocal)
end_dt = datetime.datetime.strptime(end or "10:00", "%H:%M").replace(
year=dt.year, month=dt.month, day=dt.day, tzinfo=tzlocal)
if end_dt <= start_dt:
end_dt += datetime.timedelta(days=1)
lines.append(dt_line("DTSTART", start_dt))
lines.append(dt_line("DTEND", end_dt))
if repeat in REPEAT_FREQ:
lines.append("RRULE:FREQ=%s" % REPEAT_FREQ[repeat])
lines += ["SUMMARY:" + ics_escape(title)]
if loc:
lines.append("LOCATION:" + ics_escape(loc))
if reminder and reminder > 0:
lines += _alarm_lines(title, reminder)
lines += ["END:VEVENT", "END:VCALENDAR", ""]
vcal = "\r\n".join(lines)
path = os.path.join(caldir, uid + ".ics")
try:
with open(path, "w") as f:
f.write(vcal)
except OSError as e:
return {"ok": False, "error": str(e)}
if not vdir_push():
log("add: push failed for %s" % title)
_mark_sync_error("push failed for %r" % title)
fetch_events()
return {"ok": True, "uid": uid, "file": os.path.basename(path),
"cal": cal, "title": title}
def _alarm_lines(title, reminder):
return [
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:-PT%dM" % reminder,
"DESCRIPTION:" + ics_escape(title),
"END:VALARM",
]
def _event_file(cal, file):
"""Resolve a (cal, basename/path) pair from the shell back to an existing
.ics on disk. Returns None when it has vanished (deleted server-side)."""
if not cal:
return None
base = os.path.basename(str(file))
cand = os.path.join(VDIR, cal, base)
if base and base.endswith(".ics") and os.path.isfile(cand):
return cand
return None
def edit_event(params):
"""Rewrite a VEVENT that the shell already knows about (matched by uid),
then push + refetch. The whole series is edited for recurring events."""
import icalendar
uid = (params.get("uid") or "").strip()
file = _event_file(params.get("cal"), params.get("file"))
title = (params.get("title") or "").strip()
if not uid or not file:
return {"ok": False, "error": "missing event reference"}
if not title:
return {"ok": False, "error": "title is required"}
date = (params.get("date") or "").strip()
all_day = bool(params.get("allDay"))
start = (params.get("start") or "").strip()
end = (params.get("end") or "").strip()
loc = (params.get("loc") or "").strip()
reminder = int(params.get("reminder") or 0)
repeat = (params.get("repeat") or "NONE").strip().upper()
try:
dt = datetime.date.fromisoformat(date)
except ValueError:
return {"ok": False, "error": "invalid date %r" % date}
try:
with open(file, "rb") as f:
cal = icalendar.Calendar.from_ical(f.read().decode("utf-8", "replace"))
except (OSError, ValueError) as e:
log("edit: cannot read %s: %s" % (file, e))
return {"ok": False, "error": "cannot read event file"}
vevent = None
for v in cal.walk("VEVENT"):
if str(v.get("UID") or "") == uid:
vevent = v
break
if vevent is None:
return {"ok": False, "error": "event uid no longer on disk (refetch?)"}
vevent["DTSTAMP"] = datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y%m%dT%H%M%SZ")
vevent.pop("DTSTART", None)
vevent.pop("DTEND", None)
vevent.pop("RRULE", None)
vevent["SUMMARY"] = ics_escape(title)
if all_day:
# vDDDTypes: a plain date -> DTSTART;VALUE=DATE (exclusive DTEND +1d)
vevent["DTSTART"] = dt
vevent["DTEND"] = dt + datetime.timedelta(days=1)
else:
tzlocal = datetime.datetime.now().astimezone().tzinfo
start_dt = datetime.datetime.strptime(start or "09:00", "%H:%M").replace(
year=dt.year, month=dt.month, day=dt.day, tzinfo=tzlocal)
end_dt = datetime.datetime.strptime(end or "10:00", "%H:%M").replace(
year=dt.year, month=dt.month, day=dt.day, tzinfo=tzlocal)
if end_dt <= start_dt:
end_dt += datetime.timedelta(days=1)
def ics_dt(d):
return d.astimezone(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
vevent["DTSTART"] = ics_dt(start_dt)
vevent["DTEND"] = ics_dt(end_dt)
if repeat in REPEAT_FREQ:
vevent["RRULE"] = "FREQ=%s" % REPEAT_FREQ[repeat]
if loc:
vevent["LOCATION"] = ics_escape(loc)
else:
vevent.pop("LOCATION", None)
# replace alarms entirely (single-child vdirsyncer items usually only hold
# one VEVENT plus optional VTIMEZONE; keep anything non-VALARM intact)
vevent.subcomponents = [
c for c in vevent.subcomponents if str(c.name) != "VALARM"]
if reminder and reminder > 0:
arm = icalendar.Alarm()
arm.add("ACTION", "DISPLAY")
arm.add("TRIGGER", datetime.timedelta(minutes=-reminder))
arm.add("DESCRIPTION", title)
vevent.add_component(arm)
try:
with open(file, "wb") as f:
f.write(cal.to_ical())
except OSError as e:
return {"ok": False, "error": str(e)}
if not vdir_push():
log("edit: push failed for %s" % uid)
_mark_sync_error("push failed for edit (uid %s)" % uid)
fetch_events()
return {"ok": True, "uid": uid, "cal": params.get("cal"), "title": title}
def delete_event(params):
"""Delete the .ics backing an event, push the removal to Nextcloud and
refetch. Deletes the whole event (every occurrence of a series)."""
file = _event_file(params.get("cal"), params.get("file"))
uid = (params.get("uid") or "").strip()
if not file or not uid:
return {"ok": False, "error": "missing event reference"}
try:
os.remove(file)
except OSError as e:
return {"ok": False, "error": str(e)}
if not vdir_push():
log("delete: push failed for %s" % uid)
_mark_sync_error("push failed for delete (uid %s)" % uid)
fetch_events()
return {"ok": True, "uid": uid}
def ensure_chime():
"""Synthesize a short two-tone 'ping' if we don't have one cached."""
if os.path.exists(CHIME_FILE):
return CHIME_FILE
os.makedirs(CACHE, exist_ok=True)
rate = 44100
n = (3.0 * 4) // 8 # make it a short pluck
# 0.55s decayed 880Hz ping
samples = []
for i in range(int(rate * 0.45)):
t = i / rate
env = math.exp(-t * 6)
samples.append(int(12000 * env * math.sin(2 * math.pi * 880 * t)))
for i in range(int(rate * 0.45)):
t = i / rate
env = math.exp(-t * 6)
samples.append(int(12000 * env * math.sin(2 * math.pi * 1320 * t)))
with wave.open(CHIME_FILE, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(b"".join(int(s).to_bytes(2, "little", signed=True)
for s in samples))
return CHIME_FILE
def remind():
"""Scan every .ics in the vdirsyncer dirs for VALARM triggers, expand
RRULE recurrences, and fire any whose reminder time has just arrived:
play the cached chime and send a desktop notification (lands in the
QuickShell notification center). Tracked by (uid, occurrence, trigger)
so the per-minute timer never fires the same reminder twice."""
import icalendar
import recurring_ical_events
fired = rd(REMINDER_STATE, {})
snoozes = rd(SNOOZE_FILE, {})
now = datetime.datetime.now().astimezone()
fired_any = False
snooze_dirty = False
for calname in list_calendars():
caldir = os.path.join(VDIR, calname)
if not os.path.isdir(caldir):
continue
for fn in os.listdir(caldir):
if not fn.endswith(".ics"):
continue
path = os.path.join(caldir, fn)
try:
with open(path, "rb") as f:
data = f.read()
except OSError as e:
log("remind: cannot read %s: %s" % (path, e))
continue
# fast pre-filter: >90% of files have no VALARM at all, so skip
# the (expensive) full icalendar + recurrence expansion for them.
if b"VALARM" not in data:
continue
try:
cal = icalendar.Calendar.from_ical(data.decode("utf-8", "replace"))
except Exception as e:
log("remind: cannot parse %s: %s" % (path, e))
continue
for vevent in cal.walk("VEVENT"):
try:
uid = str(vevent.get("UID") or str(uuid.uuid4()))
summary = str(vevent.get("SUMMARY") or "(no title)")
loc = str(vevent.get("LOCATION") or "")
alarms = vevent.walk("VALARM")
if not alarms:
continue
dtstart = vevent.get("DTSTART")
if dtstart is None:
continue
# run recurrence expansion over a generous window
comp = icalendar.Calendar()
for tz in cal.walk("VTIMEZONE"):
comp.add_component(tz)
comp.add_component(vevent)
occs = recurring_ical_events.of(comp).between(
now - datetime.timedelta(days=1),
now + datetime.timedelta(days=31))
for occ in occs:
start = occ.get("DTSTART").dt
if isinstance(start, datetime.date) and not isinstance(start, datetime.datetime):
start = datetime.datetime.combine(start, datetime.time.min, tzinfo=now.tzinfo)
if start.tzinfo is None:
start = start.replace(tzinfo=now.tzinfo)
for alarm in alarms:
try:
trig = alarm.get("TRIGGER")
delta = trig.dt
except Exception:
continue
if not isinstance(delta, datetime.timedelta):
continue # absolute triggers skipped (rare)
# TRIGGER:-PT5M means 5 min BEFORE the start, so
# a negative delta moves `when` earlier.
when = start + delta
key = "%s|%s|%s" % (uid, start.isoformat(), delta)
snoozed_until = snoozes.get(key)
refire = False
if snoozed_until:
try:
if datetime.datetime.fromisoformat(snoozed_until) > now:
continue # still snoozing this one
except ValueError:
pass
# deadline passed: fire now even though we're
# outside the usual 2-minute lateness window,
# and let it happen again later.
snoozes.pop(key, None)
snooze_dirty = True
refire = True
if key in fired:
continue
if when <= now and ((now - when) <= datetime.timedelta(minutes=2) or refire):
fired[key] = now.isoformat()
fired_any = True
body = calname
if loc:
body += " \u00b7 " + loc
if start.time() != datetime.time.min or not isinstance(occ.get("DTSTART").dt, datetime.date):
body += " \u00b7 " + start.strftime("%H:%M")
try:
subprocess.run([PW_PLAY, ensure_chime()],
capture_output=True, timeout=10)
except Exception as e:
log("chime failed: %s" % e)
try:
# Identifier payloads (Open carries the
# event date; Snooze carries the exact
# reminder key) let the shell react without
# a DBus round trip — see quickshell-shell.qml.
subprocess.run(
[NOTIFY, "-a", "Calendar Reminder",
"-u", "normal",
"-c", "calendar",
"-t", "0",
"--action=cal-open:%s=OPEN" % start.strftime("%Y-%m-%d"),
"--action=cal-snooze:%s=SNOOZE 10M" % key,
"Reminder: " + summary,
body],
capture_output=True, timeout=10)
except Exception as e:
log("notify failed: %s" % e)
log("reminder fired: %s (%s)" % (summary, calname))
except Exception as e:
log("remind: event error: %s" % e)
if fired_any:
# prune old entries (older than 40 days)
cutoff = (now - datetime.timedelta(days=40)).isoformat()
fired = {k: v for k, v in fired.items() if v >= cutoff}
wr(REMINDER_STATE, fired)
if snooze_dirty:
# drop expired snoozes (older than 40 days) and persist the rest
cutoff = (now - datetime.timedelta(days=40)).isoformat()
snoozes = {k: v for k, v in snoozes.items() if v >= cutoff}
wr(SNOOZE_FILE, snoozes)
def _mark_sync_ok():
"""Record that a sync/event write succeeded (calendar header badge)."""
now_iso = datetime.datetime.now().isoformat(timespec="seconds")
st = rd(SYNC_FILE, {})
st.update({
"ok": True,
"lastOk": now_iso,
"lastEffort": now_iso,
"error": "",
})
wr(SYNC_FILE, st)
def _mark_sync_error(msg):
"""Record a failed sync/push (calendar header badge)."""
now_iso = datetime.datetime.now().isoformat(timespec="seconds")
st = rd(SYNC_FILE, {})
st.update({
"ok": False,
"lastOk": st.get("lastOk", ""),
"lastEffort": now_iso,
"error": msg,
})
wr(SYNC_FILE, st)
def fetch_events():
"""vdirsyncer sync -> icalendar self-scan of the local stores -> events.json.
Each event carries uid + file so the shell popup can edit/delete a specific
item. Recurrences are expanded via recurring_ical_events (the same library
the reminder scanner uses)."""
secret = parse_env_file(SECRET_ENV)
url = secret.get("NEXTCLOUD_CALDAV_URL", "")
user = secret.get("NEXTCLOUD_CALDAV_USERNAME", "")
pw = secret.get("NEXTCLOUD_CALDAV_PASSWORD", "")
if not (url and user and pw):
log("nextcloud secret missing or incomplete; keeping existing events.json")
_mark_sync_error("Nextcloud cal secret missing/incomplete")
return
try:
write_tmp_vdirsyncer_conf(secret)
effective_url = url
if url.rstrip("/").endswith("/calendars"):
effective_url = url.rstrip("/") + "/" + user
log("syncing from %s" % effective_url)
# `collections = ["from a", "from b"]` triggers auto-discovery; feed y
# answers so the "Should it attempt to create?" prompts never hang
# in our headless runner.
y = ("y\n") * 50
if not run_sync_cmd([VDIRSYNCER, "-c", TMP_CONF, "discover", "nextcloud"],
stdin_input=y):
return
if not run_sync_cmd([VDIRSYNCER, "-c", TMP_CONF, "sync"],
stdin_input=y):
return
if not run_sync_cmd([VDIRSYNCER, "-c", TMP_CONF, "sync"],
stdin_input=y):
return
finally:
if os.path.exists(TMP_CONF):
os.remove(TMP_CONF)
os.makedirs(VDIR, exist_ok=True)
wkdir = os.path.dirname(STATUS)
if wkdir:
os.makedirs(wkdir, exist_ok=True)
try:
events = scan_local_events()
except Exception as e:
log("scan_local_events failed: %s" % e)
_mark_sync_error("scan failed: %s" % e)
return
events.sort(key=lambda e: (e["d"], e["t"] if e["t"] else "00:00"))
now_iso = datetime.datetime.now().isoformat(timespec="seconds")
wr(EVENTS_FILE, {"fetched": now_iso, "events": events})
_mark_sync_ok()
log("wrote %d events" % len(events))
def scan_local_events():
"""Expand every .ics in the vdirsyncer stores into flat event rows, expanded
from recurrence rules. Returns list of dicts; the shell popup and the
edit/delete commands both rely on the uid/file fields."""
import icalendar
import recurring_ical_events
now = datetime.datetime.now()
window_start = now.date()
window_end = window_start + datetime.timedelta(days=EVENT_DAYS)
events = []
for calname in list_calendars():
caldir = os.path.join(VDIR, calname)
try:
names = sorted(os.listdir(caldir))
except OSError:
continue
for fn in names:
if not fn.endswith(".ics"):
continue
path = os.path.join(caldir, fn)
try:
with open(path, "rb") as f:
data = f.read().decode("utf-8", "replace")
except OSError as e:
log("scan: cannot read %s: %s" % (path, e))
continue
try:
cal = icalendar.Calendar.from_ical(data)
except Exception as e:
log("scan: cannot parse %s: %s" % (path, e))
continue
for vevent in cal.walk("VEVENT"):
uid = str(vevent.get("UID") or str(uuid.uuid4()))
summary = str(vevent.get("SUMMARY") or "(no title)").strip()
loc = str(vevent.get("LOCATION") or "").strip()
rrule = str(vevent.get("RRULE") or "")
if vevent.get("DTSTART") is None:
continue
comp = icalendar.Calendar()
for tz in cal.walk("VTIMEZONE"):
comp.add_component(tz)
comp.add_component(vevent)
try:
occs = recurring_ical_events.of(comp).between(
window_start, window_end)
except Exception as e:
log("scan: recurrence error in %s: %s" % (fn, e))
continue
for occ in occs:
st_raw = occ.get("DTSTART").dt
allDay = isinstance(st_raw, datetime.date) and not isinstance(
st_raw, datetime.datetime)
st = _to_local_naive(st_raw, allDay)
datepart = st.strftime("%Y-%m-%d")
timepart = st.strftime("%H:%M") if not allDay else ""
en_raw = occ.get("DTEND")
if en_raw is not None:
en_raw = en_raw.dt
if en_raw is None:
end_naive = st + (datetime.timedelta(days=1) if allDay
else datetime.timedelta(hours=1))
else:
end_naive = _to_local_naive(en_raw, allDay)
endpart = end_naive.strftime("%H:%M") if not allDay else ""
# drop timed occurrences that already finished
if not allDay and end_naive < now:
continue
events.append({
"d": datepart,
"t": timepart,
"e": endpart,
"title": summary,
"cal": calname,
"allDay": allDay,
"rep": bool(rrule),
"rrule": rrule,
"loc": loc,
"rem": reminder_minutes(vevent),
"uid": uid,
"file": path,
})
return events
def _to_local_naive(dt, allDay):
"""Normalise an occurrence start/end to a local naive datetime."""
if allDay and isinstance(dt, datetime.date) and not isinstance(dt, datetime.datetime):
return datetime.datetime.combine(dt, datetime.time.min)
if dt.tzinfo is not None:
return dt.astimezone().replace(tzinfo=None)
return dt
def refresh_local_cache():
"""Re-scan the local vdirsyncer stores (no network) and rewrite the event
cache so a mutation (add/edit/delete) shows up in the popup immediately
without waiting for the next timed sync."""
try:
events = scan_local_events()
wr(EVENTS_FILE, {
"events": events,
"fetched": datetime.datetime.now().isoformat(timespec="seconds"),
})
log("rescanned %d events" % len(events))
except Exception as e:
log("refresh_local_cache failed: %s" % e)
def fetch_weather():
"""Open-Meteo forecast for the stored location -> weather.json. Keeps a
current block (temp/feels/code/humidity/wind), per-day rows (min/max/precip
chance/max wind, 7 days) and an hourly trace for the next 24h so the shell
can draw an hourly strip / more days."""
loc = rd(LOC_FILE, None)
if not loc:
loc = dict(DEFAULT_LOC)
wr(LOC_FILE, loc)
lat, lon = str(loc.get("lat", "")), str(loc.get("lon", ""))
if not (lat and lon):
return
params = {
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,apparent_temperature,weather_code,relative_humidity_2m,wind_speed_10m",
"hourly": "temperature_2m,weather_code,precipitation_probability,wind_speed_10m",
"daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max",
"forecast_days": "7",
"timezone": "auto",
}
url = "https://api.open-meteo.com/v1/forecast?" + urllib.parse.urlencode(params)
try:
data = json.loads(http_get(url))
except (OSError, ValueError) as e:
log("weather fetch failed: %s" % e)
return
cur = data.get("current") or {}
daily = data.get("daily") or {}
hourly = data.get("hourly") or {}
htime = hourly.get("time") or []
def hourly_row(i):
return {
"h": (htime[i][11:16] if i < len(htime) else ""),
"t": (hourly.get("temperature_2m") or [])[i],
"code": (hourly.get("weather_code") or [])[i],
"pop": (hourly.get("precipitation_probability") or [])[i],
"wind": (hourly.get("wind_speed_10m") or [])[i],
}
out = {
"name": loc.get("name", "?"),
"lat": lat,
"lon": lon,
"fetched": datetime.datetime.now().isoformat(timespec="seconds"),
"current": {
"t": cur.get("temperature_2m"),
"feels": cur.get("apparent_temperature"),
"code": cur.get("weather_code"),
"hum": cur.get("relative_humidity_2m"),
"wind": cur.get("wind_speed_10m"),
},
"hourly": [
hourly_row(i)
for i in range(min(24, len(htime)))
],
"daily": [
{
"d": (daily.get("time") or [])[i],
"code": (daily.get("weather_code") or [])[i],
"tmin": (daily.get("temperature_2m_min") or [])[i],
"tmax": (daily.get("temperature_2m_max") or [])[i],
"pop": (daily.get("precipitation_probability_max") or [])[i],
"wind": (daily.get("wind_speed_10m_max") or [])[i],
}
for i in range(min(7, len(daily.get("time") or [])))
],
}
wr(WEATHER_FILE, out)
log("wrote weather for %s" % out["name"])
def resolve_location(query):
"""'lat,lon' passthrough or Open-Meteo geocoding of a town name."""
q = query.strip()
if re.fullmatch(r"[-+]?\d+(?:\.\d+)?\s*,\s*[-+]?\d+(?:\.\d+)?", q):
lat, lon = [p.strip() for p in q.split(",")]
return {"name": q, "lat": lat, "lon": lon}
url = ("https://geocoding-api.open-meteo.com/v1/search?name="
+ urllib.parse.quote(q) + "&count=1&language=en&format=json")
try:
data = json.loads(http_get(url))
except (OSError, ValueError) as e:
log("geocoding failed: %s" % e)
return None
res = (data.get("results") or [None])[0]
if not res:
log("geocoding: no match for %r" % q)
return None
name = ", ".join(x for x in [
res.get("name"),
res.get("admin1"),
res.get("country_code"),
] if x)
return {"name": name, "lat": str(res["latitude"]), "lon": str(res["longitude"])}
def snooze(key, minutes):
"""Delay a specific reminder (key = uid|occurrence|trigger as shown in the
notification) by N minutes. The shell calls this when the SNOOZE action is
picked; remind() skips the key until the deadline passes, then fires it
again (the fired marker is cleared so the re-fire isn't suppressed)."""
try:
minutes = int(minutes)
except (TypeError, ValueError):
minutes = 10
if minutes < 1:
minutes = 10
snoozes = rd(SNOOZE_FILE, {})
snoozes[key] = (datetime.datetime.now().astimezone()
+ datetime.timedelta(minutes=minutes)).isoformat(timespec="seconds")
wr(SNOOZE_FILE, snoozes)
# let the notification appear again once the deadline passes
fired = rd(REMINDER_STATE, {})
fired.pop(key, None)
wr(REMINDER_STATE, fired)
print(json.dumps({"ok": True, "key": key, "until": snoozes[key]},
ensure_ascii=False))
sys.stdout.flush()
def dump_cache():
"""Merge the cache files into one stdout doc for the shell."""
ev = rd(EVENTS_FILE, None)
wx = rd(WEATHER_FILE, None)
loc = rd(LOC_FILE, None)
print(json.dumps({
"events": (ev or {}).get("events", []),
"eventsFetched": (ev or {}).get("fetched", ""),
"weather": wx,
"loc": loc,
"cals": list_calendars(),
"sync": rd(SYNC_FILE, None),
}, ensure_ascii=False))
sys.stdout.flush()
def main():
args = sys.argv[1:]
mode = args[0] if args else "read"
if mode == "sync":
fetch_events()
fetch_weather()
elif mode == "setloc" and len(args) > 1:
loc = resolve_location(args[1])
if loc:
wr(LOC_FILE, loc)
fetch_weather()
f = rd(WEATHER_FILE, None)
if f:
f["name"] = loc["name"]
f["lat"] = loc["lat"]
f["lon"] = loc["lon"]
wr(WEATHER_FILE, f)
dump_cache()
elif mode == "read":
dump_cache()
elif mode == "cals":
print(json.dumps(list_calendars(), ensure_ascii=False))
sys.stdout.flush()
elif mode == "add" and len(args) > 1:
try:
params = json.loads(args[1])
except ValueError:
params = {}
print(json.dumps(add_event(params), ensure_ascii=False))
sys.stdout.flush()
refresh_local_cache()
dump_cache()
elif mode == "edit" and len(args) > 1:
try:
params = json.loads(args[1])
except ValueError:
params = {}
print(json.dumps(edit_event(params), ensure_ascii=False))
sys.stdout.flush()
refresh_local_cache()
dump_cache()
elif mode == "delete" and len(args) > 1:
try:
params = json.loads(args[1])
except ValueError:
params = {}
print(json.dumps(delete_event(params), ensure_ascii=False))
sys.stdout.flush()
refresh_local_cache()
dump_cache()
elif mode == "snooze" and len(args) > 1:
snooze(args[1], args[2] if len(args) > 2 else "10")
elif mode == "remind":
remind()
else:
log("unknown mode %r" % mode)
sys.exit(2)
if __name__ == "__main__":
main()