cfg-coder · diff

 ipt/ipt.py                     | 336 +++++++++++++++++++++++++++++++++++------
 lib/calendars.nix              |   8 +
 lib/sync-intervals-training.py | 253 ++++++++++++++++++++++++++++---
 profiles/beryllium.nix         |  11 +-
 result                         |   2 +-
 5 files changed, 543 insertions(+), 67 deletions(-)
diff --git a/ipt/ipt.py b/ipt/ipt.py
index b5af25d..7f1f1e0 100644
--- a/ipt/ipt.py
+++ b/ipt/ipt.py
@@ -72,22 +72,201 @@ def create_session(cookies):
return session
-def search_torrents(session, base_url, query, category=None):
+def ensure_logged_in(response):
+ """Exit with a clear error when cookies are invalid."""
+ if "login" in response.url.lower() or "uid" not in response.text.lower():
+ print("Session expired or invalid cookies. Please re-export cookies.", file=sys.stderr)
+ sys.exit(1)
+
+
+def normalize_category_name(value):
+ """Normalize category names for case/spacing-insensitive matching."""
+ return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9]+", " ", value.lower())).strip()
+
+
+def category_aliases(name):
+ """Generate loose aliases for a category label."""
+ normalized = normalize_category_name(name)
+ if not normalized:
+ return set()
+
+ aliases = {normalized, normalized.replace(" ", "")}
+
+ # Support matching leaf nodes in labels like "Books > Audiobooks".
+ for sep in (">", "/", "|", ":"):
+ if sep in name:
+ tail = normalize_category_name(name.split(sep)[-1])
+ if tail:
+ aliases.add(tail)
+ aliases.add(tail.replace(" ", ""))
+
+ # Basic singular/plural tolerance.
+ if normalized.endswith("s") and len(normalized) > 3:
+ singular = normalized[:-1]
+ aliases.add(singular)
+ aliases.add(singular.replace(" ", ""))
+ else:
+ plural = f"{normalized}s"
+ aliases.add(plural)
+ aliases.add(plural.replace(" ", ""))
+
+ return aliases
+
+
+def fetch_categories(session, base_url):
+ """Fetch available categories from IPT browse page."""
+ response = session.get(f"{base_url}/t")
+ response.raise_for_status()
+ ensure_logged_in(response)
+
+ soup = BeautifulSoup(response.text, "html.parser")
+ categories_by_value = {}
+
+ # Current IPT UI exposes category filters as numeric checkbox names, e.g. ?64 for AudioBook.
+ for checkbox in soup.find_all("input", {"type": "checkbox"}):
+ value = (checkbox.get("name") or "").strip()
+ if not re.fullmatch(r"\d+", value):
+ continue
+
+ label = checkbox.find_parent("label")
+ if not label:
+ continue
+
+ name = " ".join(label.get_text(" ", strip=True).split())
+ if not name:
+ continue
+
+ categories_by_value[value] = name
+
+ # Fallback for older/newer layouts that use <select><option value="<id>">...</option></select>.
+ for option in soup.find_all("option"):
+ value = (option.get("value") or "").strip()
+ if not re.fullmatch(r"\d+", value):
+ continue
+
+ name = " ".join(option.get_text(" ", strip=True).split())
+ if not name:
+ continue
+
+ categories_by_value[value] = name
+
+ categories = [
+ {
+ "value": value,
+ "name": name,
+ "aliases": sorted(category_aliases(name)),
+ }
+ for value, name in sorted(categories_by_value.items(), key=lambda kv: kv[1].lower())
+ ]
+
+ return categories
+
+
+def parse_raw_category_ids(category):
+ """Parse raw numeric category IDs from --category."""
+ parts = [p for p in re.split(r"[\s,;]+", category.strip()) if p]
+ if not parts:
+ return []
+
+ if not all(re.fullmatch(r"\d+", p) for p in parts):
+ return None
+
+ deduped = []
+ seen = set()
+ for p in parts:
+ if p in seen:
+ continue
+ seen.add(p)
+ deduped.append(p)
+ return deduped
+
+
+def resolve_category_filter(session, base_url, category):
+ """Resolve a category name (e.g. 'audiobooks') to IPT category ID(s)."""
+ if not category:
+ return []
+
+ category = category.strip()
+ if not category:
+ return []
+
+ raw_ids = parse_raw_category_ids(category)
+ if raw_ids is not None:
+ return raw_ids
+
+ categories = fetch_categories(session, base_url)
+ if not categories:
+ print(
+ "Could not discover categories from IPT. Pass raw numeric IDs or run `ipt categories`.",
+ file=sys.stderr,
+ )
+ sys.exit(2)
+
+ query = normalize_category_name(category)
+ if not query:
+ print(f"Invalid category name '{category}'.", file=sys.stderr)
+ sys.exit(2)
+
+ query_compact = query.replace(" ", "")
+
+ exact_matches = []
+ prefix_matches = []
+ contains_matches = []
+
+ for cat in categories:
+ aliases = set(cat["aliases"])
+ name_norm = normalize_category_name(cat["name"])
+ name_compact = name_norm.replace(" ", "")
+
+ if query in aliases or query_compact in aliases:
+ exact_matches.append(cat)
+ continue
+
+ if any(alias.startswith(query) for alias in aliases) or any(alias.startswith(query_compact) for alias in aliases):
+ prefix_matches.append(cat)
+ continue
+
+ if query and (query in name_norm or query_compact in name_compact):
+ contains_matches.append(cat)
+
+ matches = exact_matches or prefix_matches or contains_matches
+
+ deduped_matches = []
+ seen_values = set()
+ for cat in matches:
+ if cat["value"] in seen_values:
+ continue
+ seen_values.add(cat["value"])
+ deduped_matches.append(cat)
+
+ if len(deduped_matches) == 1:
+ return [deduped_matches[0]["value"]]
+
+ if not deduped_matches:
+ print(f"No category matched '{category}'. Run `ipt categories` to list available names.", file=sys.stderr)
+ sys.exit(2)
+
+ print(f"Category '{category}' is ambiguous. Matches:", file=sys.stderr)
+ for cat in deduped_matches[:10]:
+ print(f" - {cat['name']} ({cat['value']})", file=sys.stderr)
+ if len(deduped_matches) > 10:
+ print(f" ... and {len(deduped_matches) - 10} more", file=sys.stderr)
+ print("Use a more specific name or pass raw numeric IDs with --category.", file=sys.stderr)
+ sys.exit(2)
+
+
+def search_torrents(session, base_url, query, category_ids=None):
"""Search IPTorrents and return results."""
- # IPT search URL format
search_url = f"{base_url}/t"
- params = {"q": query}
-
- if category:
- params["qf"] = category
-
+ params = [("q", query)]
+
+ for category_id in category_ids or []:
+ params.append((category_id, ""))
+
response = session.get(search_url, params=params)
response.raise_for_status()
-
- if "login" in response.url.lower() or "uid" not in response.text.lower():
- print("Session expired or invalid cookies. Please re-export cookies.", file=sys.stderr)
- sys.exit(1)
-
+ ensure_logged_in(response)
+
return parse_search_results(response.text, base_url)
@@ -297,20 +476,45 @@ def sort_results(results, sort_by):
return sorted(results, key=get_sort_key, reverse=True)
-def print_results(results):
- """Print search results in a formatted table."""
+def print_json(data):
+ """Print data as JSON."""
+ print(json.dumps(data, indent=2, ensure_ascii=False))
+
+
+def print_results(results, json_output=False):
+ """Print search results in a formatted table or JSON."""
+ if json_output:
+ print_json(results)
+ return
+
if not results:
print("No results found.")
return
-
+
print(f"{'#':<4} {'Name':<55} {'Size':<10} {'Sn':<6} {'Se':<5} {'Le':<5}")
print("-" * 95)
-
+
for i, r in enumerate(results, 1):
name = r["name"][:52] + "..." if len(r["name"]) > 55 else r["name"]
print(f"{i:<4} {name:<55} {r['size']:<10} {r['snatches']:<6} {r['seeders']:<5} {r['leechers']:<5}")
+def print_categories(categories, json_output=False):
+ """Print discovered IPT categories."""
+ if json_output:
+ print_json([{"value": c["value"], "name": c["name"]} for c in categories])
+ return
+
+ if not categories:
+ print("No categories discovered.")
+ return
+
+ print(f"{'Value':<12} Name")
+ print("-" * 72)
+ for cat in categories:
+ print(f"{cat['value']:<12} {cat['name']}")
+
+
def main():
parser = argparse.ArgumentParser(description="IPTorrents CLI")
subparsers = parser.add_subparsers(dest="command", help="Commands")
@@ -318,38 +522,66 @@ def main():
# Search command
search_parser = subparsers.add_parser("search", aliases=["s"], help="Search torrents")
search_parser.add_argument("query", nargs="+", help="Search query")
- search_parser.add_argument("-c", "--category", help="Category filter")
- search_parser.add_argument("-s", "--sort-by", choices=["snatches", "seeders", "leechers", "size"],
+ search_parser.add_argument("-c", "--category", help="Category filter (raw numeric ID(s) or name like 'audiobooks')")
+ search_parser.add_argument("-s", "--sort-by", choices=["snatches", "seeders", "leechers", "size"],
help="Sort results by column (descending)")
-
+ search_parser.add_argument("--json", action="store_true", dest="json_output",
+ help="Output search results as JSON")
+
# Add command (search + select + add to transmission)
add_parser = subparsers.add_parser("add", aliases=["a"], help="Search and add to transmission")
add_parser.add_argument("query", nargs="+", help="Search query")
add_parser.add_argument("-n", "--number", type=int, help="Auto-select result number")
- add_parser.add_argument("-c", "--category", help="Category filter")
+ add_parser.add_argument("-c", "--category", help="Category filter (raw numeric ID(s) or name like 'audiobooks')")
add_parser.add_argument("-s", "--sort-by", choices=["snatches", "seeders", "leechers", "size"],
help="Sort results by column (descending)")
-
+ add_parser.add_argument("--json", action="store_true", dest="json_output",
+ help="Output add result as JSON")
+
+ # Categories command
+ categories_parser = subparsers.add_parser("categories", aliases=["cats"], help="List available IPT categories")
+ categories_parser.add_argument("--json", action="store_true", dest="json_output",
+ help="Output categories as JSON")
+
# Config command
config_parser = subparsers.add_parser("config", help="Show config location")
+ config_parser.add_argument("--json", action="store_true", dest="json_output",
+ help="Output config locations as JSON")
args = parser.parse_args()
-
+ json_output = getattr(args, "json_output", False)
+
if args.command in ("config",):
- print(f"Config file: {CONFIG_FILE}")
- print(f"Cookies file: {COOKIES_FILE}")
+ if json_output:
+ print_json({
+ "config_file": str(CONFIG_FILE),
+ "cookies_file": str(COOKIES_FILE),
+ })
+ else:
+ print(f"Config file: {CONFIG_FILE}")
+ print(f"Cookies file: {COOKIES_FILE}")
return
-
+
if not args.command:
parser.print_help()
return
-
+
+ if args.command in ("add", "a") and json_output and args.number is None:
+ print("--json for add requires --number.", file=sys.stderr)
+ sys.exit(2)
+
config = load_config()
cookies = load_cookies()
session = create_session(cookies)
+
+ if args.command in ("categories", "cats"):
+ categories = fetch_categories(session, config["base_url"])
+ print_categories(categories, json_output=json_output)
+ return
+
query = " ".join(args.query)
-
- results = search_torrents(session, config["base_url"], query, getattr(args, "category", None))
+ resolved_category = resolve_category_filter(session, config["base_url"], getattr(args, "category", None))
+ results = search_torrents(session, config["base_url"], query, resolved_category)
# Sort if requested
sort_by = getattr(args, "sort_by", None)
@@ -357,15 +589,18 @@ def main():
results = sort_results(results, sort_by)
if args.command in ("search", "s"):
- print_results(results)
-
+ print_results(results, json_output=json_output)
+
elif args.command in ("add", "a"):
- print_results(results)
-
+ if not json_output:
+ print_results(results)
+
if not results:
+ if json_output:
+ print_json({"status": "no_results", "results": []})
return
-
- if args.number:
+
+ if args.number is not None:
selection = args.number
else:
print()
@@ -373,27 +608,40 @@ def main():
selection = int(input("Select torrent number (0 to cancel): "))
except (ValueError, EOFError):
selection = 0
-
+
if selection < 1 or selection > len(results):
- print("Cancelled.")
+ if json_output:
+ print_json({"status": "cancelled", "selection": selection})
+ else:
+ print("Cancelled.")
return
-
+
torrent = results[selection - 1]
- print(f"\nDownloading: {torrent['name']}")
-
+ if not json_output:
+ print(f"\nDownloading: {torrent['name']}")
+
download_url = torrent["download_url"]
if not download_url:
download_url = get_download_url(session, config["base_url"], torrent["id"])
-
+
if not download_url:
print("Could not find download URL.", file=sys.stderr)
sys.exit(1)
-
+
torrent_data = download_torrent(session, download_url)
- print(f"Adding to transmission at {config['transmission_host']}...")
-
+ if not json_output:
+ print(f"Adding to transmission at {config['transmission_host']}...")
+
name = add_to_transmission(torrent_data, config)
- print(f"Added: {name}")
+ if json_output:
+ print_json({
+ "status": "added",
+ "selection": selection,
+ "added_name": name,
+ "torrent": torrent,
+ })
+ else:
+ print(f"Added: {name}")
if __name__ == "__main__":
diff --git a/lib/calendars.nix b/lib/calendars.nix
index 6dcf860..ea05098 100644
--- a/lib/calendars.nix
+++ b/lib/calendars.nix
@@ -9,6 +9,14 @@ let
export INTERVALS_API_KEY=$(${pkgs.pass}/bin/pass show intervals.icu/api)
export INTERVALS_TRAINING_DIR="${homeDir}/Calendars/intervals_training"
export INTERVALS_SHARED_DIR="${homeDir}/Calendars/bensima_shared/ben"
+ export INTERVALS_SHARED_CALDAV_URL="https://cal.bensima.com/shared/ben"
+ export INTERVALS_SHARED_CALDAV_USER="ben"
+ export INTERVALS_SHARED_CALDAV_PASSWORD=$(${pkgs.pass}/bin/pass show cal.bensima.com)
+ export INTERVALS_PARASAIL_CALENDAR_ID="ben.sima@parasail.io"
+ export INTERVALS_PARASAIL_LOCAL_DIR="${homeDir}/Calendars/parasail/ben.sima@parasail.io"
+ export INTERVALS_PARASAIL_TOKEN_FILE="${config.xdg.dataHome}/vdirsyncer/parasail.token"
+ export INTERVALS_PARASAIL_CLIENT_ID=$(${pkgs.pass}/bin/pass show parasail/calendar-client-id)
+ export INTERVALS_PARASAIL_CLIENT_SECRET=$(${pkgs.pass}/bin/pass show parasail/calendar-client-secret)
${pkgs.python3}/bin/python3 ${./sync-intervals-training.py}
'';
diff --git a/lib/sync-intervals-training.py b/lib/sync-intervals-training.py
index bdd6d0e..519aaad 100644
--- a/lib/sync-intervals-training.py
+++ b/lib/sync-intervals-training.py
@@ -4,7 +4,8 @@
1. Push any time changes from shared calendar → intervals.icu API
2. Fetch fresh ICS feed, skip training-plan container events, split into files
3. Delete removed intervals events from shared CalDAV by UID
-4. vdirsyncer then syncs files to BenSimaShared
+4. Delete removed intervals events from Parasail Google Calendar by iCalUID
+5. vdirsyncer then syncs files to BenSimaShared
"""
import json
@@ -16,6 +17,7 @@ import urllib.request
import urllib.error
import urllib.parse
import xml.etree.ElementTree as ET
+import time
from datetime import datetime, timedelta, timezone
@@ -29,7 +31,14 @@ SHARED_CALDAV_URL = os.environ.get("INTERVALS_SHARED_CALDAV_URL")
SHARED_CALDAV_USER = os.environ.get("INTERVALS_SHARED_CALDAV_USER")
SHARED_CALDAV_PASSWORD = os.environ.get("INTERVALS_SHARED_CALDAV_PASSWORD")
+PARASAIL_CALENDAR_ID = os.environ.get("INTERVALS_PARASAIL_CALENDAR_ID", "ben.sima@parasail.io")
+PARASAIL_LOCAL_DIR = os.environ.get("INTERVALS_PARASAIL_LOCAL_DIR")
+PARASAIL_TOKEN_FILE = os.environ.get("INTERVALS_PARASAIL_TOKEN_FILE")
+PARASAIL_CLIENT_ID = os.environ.get("INTERVALS_PARASAIL_CLIENT_ID")
+PARASAIL_CLIENT_SECRET = os.environ.get("INTERVALS_PARASAIL_CLIENT_SECRET")
+
PENDING_SHARED_DELETES = os.path.join(OUTDIR, ".pending_shared_deletes.json")
+PENDING_PARASAIL_DELETES = os.path.join(OUTDIR, ".pending_parasail_deletes.json")
LOWER_UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
@@ -260,10 +269,7 @@ def fetch_and_split_ics():
return removed_uids
-def collect_stale_shared_interval_uids(current_training_uids):
- """Find likely intervals-origin events in shared that no longer exist in training."""
- stale = set()
-
+def _is_trainingish(summary, categories):
intervalish_categories = {
"WORKOUT",
"NOTE",
@@ -272,6 +278,14 @@ def collect_stale_shared_interval_uids(current_training_uids):
"RUN",
"WEIGHTTRAINING",
}
+ return bool(categories & intervalish_categories) or summary.startswith(
+ ("VirtualBike:", "Run:", "WeightTraining:")
+ ) or summary == "Recovery Week"
+
+
+def collect_stale_shared_interval_uids(current_training_uids):
+ """Find likely intervals-origin events in shared that no longer exist in training."""
+ stale = set()
for f in os.listdir(SHARED):
if not f.endswith(".ics"):
@@ -284,12 +298,8 @@ def collect_stale_shared_interval_uids(current_training_uids):
if not LOWER_UUID_RE.match(uid):
continue
- categories = fields["categories"]
- summary = fields["summary"]
origin_marker = fields["has_intervals_prodid"] or fields["has_parasail_calname"]
- trainingish = bool(categories & intervalish_categories) or summary.startswith(
- ("VirtualBike:", "Run:", "WeightTraining:")
- ) or summary == "Recovery Week"
+ trainingish = _is_trainingish(fields["summary"], fields["categories"])
if origin_marker and trainingish:
stale.add(uid)
@@ -297,24 +307,64 @@ def collect_stale_shared_interval_uids(current_training_uids):
return stale
-def _load_pending_shared_deletes():
+def collect_stale_parasail_interval_uids(current_training_uids):
+ """Find likely intervals-origin events in Parasail that no longer exist in training."""
+ if not PARASAIL_LOCAL_DIR or not os.path.isdir(PARASAIL_LOCAL_DIR):
+ return set()
+
+ stale = set()
+ for f in os.listdir(PARASAIL_LOCAL_DIR):
+ if not f.endswith(".ics"):
+ continue
+
+ fields = parse_event_fields(os.path.join(PARASAIL_LOCAL_DIR, f))
+ uid = fields["uid"]
+ if not uid or uid in current_training_uids:
+ continue
+ if not LOWER_UUID_RE.match(uid):
+ continue
+ if not _is_trainingish(fields["summary"], fields["categories"]):
+ continue
+
+ stale.add(uid)
+
+ return stale
+
+
+def _load_pending(path):
try:
- with open(PENDING_SHARED_DELETES) as f:
+ with open(path) as f:
data = json.load(f)
return set(data.get("uids", []))
except FileNotFoundError:
return set()
except Exception as e:
- print(f"WARNING: could not read {PENDING_SHARED_DELETES}: {e}", file=sys.stderr)
+ print(f"WARNING: could not read {path}: {e}", file=sys.stderr)
return set()
-def _save_pending_shared_deletes(uids):
+def _save_pending(path, uids):
data = {"uids": sorted(uids)}
- with open(PENDING_SHARED_DELETES, "w") as f:
+ with open(path, "w") as f:
json.dump(data, f)
+def _load_pending_shared_deletes():
+ return _load_pending(PENDING_SHARED_DELETES)
+
+
+def _save_pending_shared_deletes(uids):
+ _save_pending(PENDING_SHARED_DELETES, uids)
+
+
+def _load_pending_parasail_deletes():
+ return _load_pending(PENDING_PARASAIL_DELETES)
+
+
+def _save_pending_parasail_deletes(uids):
+ _save_pending(PENDING_PARASAIL_DELETES, uids)
+
+
def _caldav_auth_header():
creds = base64.b64encode(
f"{SHARED_CALDAV_USER}:{SHARED_CALDAV_PASSWORD}".encode()
@@ -394,12 +444,13 @@ def reconcile_missing_shared_events():
print(f"WARNING: failed re-uploading shared UID {uid}: {e}", file=sys.stderr)
-def delete_removed_from_shared(removed_uids):
+def delete_removed_from_shared(removed_uids, stale_shared_uids=None):
"""Delete removed/stale intervals events from shared CalDAV by UID."""
- current_training_uids = {
- f[:-4] for f in os.listdir(OUTDIR) if f.endswith(".ics")
- }
- stale_shared_uids = collect_stale_shared_interval_uids(current_training_uids)
+ if stale_shared_uids is None:
+ current_training_uids = {
+ f[:-4] for f in os.listdir(OUTDIR) if f.endswith(".ics")
+ }
+ stale_shared_uids = collect_stale_shared_interval_uids(current_training_uids)
pending = _load_pending_shared_deletes()
pending.update(removed_uids)
@@ -454,9 +505,169 @@ def delete_removed_from_shared(removed_uids):
_save_pending_shared_deletes(still_pending)
+def _google_api_request(path, access_token, method="GET", data=None):
+ url = "https://www.googleapis.com/calendar/v3" + path
+ if data is not None:
+ body = json.dumps(data).encode()
+ else:
+ body = None
+
+ req = urllib.request.Request(url, method=method, data=body)
+ req.add_header("Authorization", f"Bearer {access_token}")
+ if body is not None:
+ req.add_header("Content-Type", "application/json")
+
+ try:
+ with urllib.request.urlopen(req) as resp:
+ raw = resp.read()
+ if raw:
+ return json.loads(raw.decode())
+ return None
+ except urllib.error.HTTPError as e:
+ payload = e.read().decode()
+ raise RuntimeError(f"HTTP {e.code}: {payload}") from e
+
+
+def _get_google_access_token():
+ if not PARASAIL_TOKEN_FILE:
+ raise RuntimeError("INTERVALS_PARASAIL_TOKEN_FILE is not set")
+
+ with open(PARASAIL_TOKEN_FILE) as f:
+ token = json.load(f)
+
+ access_token = token.get("access_token")
+ expires_at = float(token.get("expires_at", 0))
+ if access_token and expires_at > time.time() + 60:
+ return access_token
+
+ refresh_token = token.get("refresh_token")
+ if not (refresh_token and PARASAIL_CLIENT_ID and PARASAIL_CLIENT_SECRET):
+ raise RuntimeError("Google token expired and refresh credentials are missing")
+
+ payload = urllib.parse.urlencode(
+ {
+ "client_id": PARASAIL_CLIENT_ID,
+ "client_secret": PARASAIL_CLIENT_SECRET,
+ "refresh_token": refresh_token,
+ "grant_type": "refresh_token",
+ }
+ ).encode()
+
+ req = urllib.request.Request(
+ "https://oauth2.googleapis.com/token",
+ method="POST",
+ data=payload,
+ )
+ req.add_header("Content-Type", "application/x-www-form-urlencoded")
+
+ with urllib.request.urlopen(req) as resp:
+ refreshed = json.loads(resp.read().decode())
+
+ token["access_token"] = refreshed["access_token"]
+ token["expires_in"] = refreshed.get("expires_in", 3600)
+ token["expires_at"] = time.time() + int(token["expires_in"])
+ token["token_type"] = refreshed.get("token_type", token.get("token_type", "Bearer"))
+ if "refresh_token" in refreshed:
+ token["refresh_token"] = refreshed["refresh_token"]
+
+ with open(PARASAIL_TOKEN_FILE, "w") as f:
+ json.dump(token, f)
+
+ return token["access_token"]
+
+
+def _parasail_event_ids_by_uid(uid, access_token):
+ params = urllib.parse.urlencode(
+ {
+ "iCalUID": uid,
+ "showDeleted": "false",
+ "singleEvents": "true",
+ "maxResults": "250",
+ }
+ )
+ path = (
+ f"/calendars/{urllib.parse.quote(PARASAIL_CALENDAR_ID, safe='')}/events"
+ f"?{params}"
+ )
+ data = _google_api_request(path, access_token, method="GET")
+ return [item["id"] for item in data.get("items", []) if item.get("id")]
+
+
+def _delete_parasail_event(event_id, access_token):
+ path = (
+ f"/calendars/{urllib.parse.quote(PARASAIL_CALENDAR_ID, safe='')}/events/"
+ f"{urllib.parse.quote(event_id, safe='')}?sendUpdates=none"
+ )
+ _google_api_request(path, access_token, method="DELETE")
+
+
+def delete_removed_from_parasail(removed_uids, stale_parasail_uids):
+ """Delete removed/stale intervals events from Parasail Google Calendar by iCalUID."""
+ pending = _load_pending_parasail_deletes()
+ pending.update(removed_uids)
+ pending.update(stale_parasail_uids)
+ if not pending:
+ return
+
+ if stale_parasail_uids:
+ print(
+ f"Queued {len(stale_parasail_uids)} stale parasail intervals event(s) for delete"
+ )
+
+ try:
+ access_token = _get_google_access_token()
+ except Exception as e:
+ print(
+ f"WARNING: could not get Google access token for parasail deletes: {e}",
+ file=sys.stderr,
+ )
+ _save_pending_parasail_deletes(pending)
+ return
+
+ still_pending = set()
+ for uid in sorted(pending):
+ try:
+ event_ids = _parasail_event_ids_by_uid(uid, access_token)
+ except Exception as e:
+ print(
+ f"WARNING: failed querying parasail events for UID {uid}: {e}",
+ file=sys.stderr,
+ )
+ still_pending.add(uid)
+ continue
+
+ if not event_ids:
+ continue
+
+ failed = False
+ for event_id in event_ids:
+ try:
+ _delete_parasail_event(event_id, access_token)
+ print(f"Deleted parasail event for removed intervals UID {uid}")
+ except Exception as e:
+ print(
+ f"WARNING: failed deleting parasail UID {uid} (event {event_id}): {e}",
+ file=sys.stderr,
+ )
+ failed = True
+
+ if failed:
+ still_pending.add(uid)
+
+ _save_pending_parasail_deletes(still_pending)
+
+
if __name__ == "__main__":
os.makedirs(OUTDIR, exist_ok=True)
push_time_changes()
removed = fetch_and_split_ics()
+
+ current_training_uids = {
+ f[:-4] for f in os.listdir(OUTDIR) if f.endswith(".ics")
+ }
+ stale_shared_uids = collect_stale_shared_interval_uids(current_training_uids)
+ stale_parasail_uids = collect_stale_parasail_interval_uids(current_training_uids)
+
reconcile_missing_shared_events()
- delete_removed_from_shared(removed)
+ delete_removed_from_shared(removed, stale_shared_uids=stale_shared_uids)
+ delete_removed_from_parasail(removed | stale_shared_uids, stale_parasail_uids)
diff --git a/profiles/beryllium.nix b/profiles/beryllium.nix
index bd6d23b..19f687a 100644
--- a/profiles/beryllium.nix
+++ b/profiles/beryllium.nix
@@ -95,6 +95,12 @@ in {
enable = true;
backend = "glx";
vSync = true;
+ settings = {
+ # NVIDIA + GLX stability tweaks
+ use-damage = false;
+ unredir-if-possible = false;
+ xrender-sync-fence = true;
+ };
};
# Screen lock — xsecurelock (single PAM call, works with U2F)
@@ -130,6 +136,9 @@ in {
export XSECURELOCK_SINGLE_AUTH_WINDOW=1
export XSECURELOCK_PAM_SERVICE=xsecurelock
+ # Compositor compatibility (picom + NVIDIA)
+ export XSECURELOCK_COMPOSITE_OBSCURER=0
+
# Saver: show current wallpaper, resized to fill portrait screen
export XSECURELOCK_SAVER=${pkgs.writeShellScript "xsecurelock-wallpaper" ''
wall="${homedir}/.current-wallpaper"
@@ -152,7 +161,7 @@ in {
in "${lock}";
inactiveInterval = 10;
xautolock = {
- enable = true;
+ enable = false;
detectSleep = true;
};
};
diff --git a/result b/result
index 524ed10..2da4bcf 120000
--- a/result
+++ b/result
@@ -1 +1 @@
-/nix/store/rhj1n4lkf244yzrzskd00vv096d79fa3-home-manager-generation
\ No newline at end of file
+/nix/store/4pyb59aiyipmjiqa96fjcpzflvlp9sxs-home-manager-generation
\ No newline at end of file