habitica-xp/habitica_sync.py
2026-09-04 14:47:27 -07:00

196 lines
5.9 KiB
Python
Executable file

#!/usr/bin/env python
import calendar
import os
import requests
import yaml
from datetime import datetime, date, time, timedelta
from dateutil.relativedelta import relativedelta, TH, FR, SA, SU, MO, TU, WE
from dateutil.rrule import rrulestr
from recurrent.event_parser import RecurringEvent
# --- Configuration ---
USER_ID = os.getenv("HABITICA_USER_ID")
API_TOKEN = os.getenv("HABITICA_API_TOKEN")
YAML_FILE_PATH = "my_tasks.yaml"
BASE_URL = "https://habitica.com/api/v3"
HEADERS = {
"x-api-user": USER_ID,
"x-api-key": API_TOKEN,
"x-client": f"{USER_ID}-PythonTaskSync",
"Content-Type": "application/json",
}
WEEKDAYS = {
"Monday": MO,
"Tuesday": TU,
"Wednesday": WE,
"Thursday": TH,
"Friday": FR,
"Saturday": SA,
"Sunday": SU,
}
def priorty(s):
if not s:
return 1.5
elif isinstance(s, str):
s = s.lower()
if s.startswith("t"):
return 0.1
elif s.startswith("e"):
return 1
elif s.startswith("h"):
return 2
else:
return 1.5
else:
return s
# --- Recurrence Engine ---
def is_task_due_today(task_def, check_date=None):
"""Evaluates if a YAML task definition is due on check_date (defaults to today)."""
if check_date is None:
check_date = date.today()
rec = task_def.get("recurrence", {})
rec_type = rec.get("type")
print(f"Analyzing {rec}")
if not rec_type:
return False
elif rec_type =="weekly":
day_name = check_date.strftime("%A")
return day_name in rec.get("days", [])
elif rec_type =="day_of_month":
target_day = rec.get("day")
if target_day > 0:
return check_date.day == target_day
elif target_day == -1:
last_day = calendar.monthrange(check_date.year, check_date.month)[1]
return check_date.day == last_day
elif rec_type =="relative_day":
weekday_str = rec.get("weekday")
ordinal = rec.get("ordinal", 1)
first_of_month = check_date.replace(day=1)
target_weekday = WEEKDAYS[weekday_str]
calculated_date = first_of_month + relativedelta(day=1, weekday=target_weekday(ordinal))
return check_date == calculated_date
elif rec_type == "interval":
interval = rec.get("interval", 2)
start_str = rec.get("start")
# anchor = (
# date.fromisoformat(start_str)
# if start_str
# else date(2000, 1, 1) # fixed default epoch
# )
# delta = (check_date - anchor).days
delta = (check_date - start_str).days
return delta >= 0 and delta % interval == 0
elif rec_type.startswith("flex"):
event_parser = RecurringEvent()
rule_date = event_parser.parse(rec.get("day"))
rule_str = event_parser.get_RFC_rrule()
if rule_str:
rule = rrulestr(rule_str)
# Define the start and end bounds for today's date
today_start = datetime.combine(date.today(), time.min)
today_end = today_start + timedelta(days=1)
# Check if today's window contains a rule occurrence
return len(rule.between(today_start, today_end, inc=True)) > 0
return False
# --- Habitica API Wrappers ---
def get_existing_tasks():
"""Fetch current tasks from Habitica."""
url = f"{BASE_URL}/tasks/user"
try:
response = requests.get(url, headers=HEADERS)
response.raise_for_status()
res_json = response.json()
if res_json.get("success"):
return res_json.get("data", [])
except Exception as err:
print(f"Failed to fetch tasks from Habitica: {err}")
return []
def create_habitica_task(task_def):
"""Create a task in Habitica via POST /api/v3/tasks/user."""
url = f"{BASE_URL}/tasks/user"
payload = {
"text": task_def.get("text"),
"type": task_def.get("type", "todo"),
"priority": priorty(task_def.get("priority")),
}
if task_def.get("notes"):
payload["notes"] = task_def.get("notes")
if task_def.get("checklist"):
payload["checklist"] = [{"text": t, "completed": False} for t in task_def.get("checklist")]
print(payload)
try:
response = requests.post(url, headers=HEADERS, json=payload)
response.raise_for_status()
res_json = response.json()
if res_json.get("success"):
print(f" -> SUCCESSFULLY CREATED: '{payload['text']}'")
return res_json.get("data")
except Exception as err:
print(f" -> ERROR creating '{payload['text']}': {err}")
# --- Main Logic ---
def sync_tasks():
today = date.today()
print(f"=== Syncing Habitica Tasks for {today} ===")
# 1. Load YAML rules
try:
with open(YAML_FILE_PATH, "r", encoding="utf-8") as f:
yaml_data = yaml.safe_load(f) or {}
defined_tasks = yaml_data.get("tasks", [])
except Exception as err:
print(f"Could not read {YAML_FILE_PATH}: {err}")
return
print(f"Read YAML file: {YAML_FILE_PATH}")
# 2. Filter tasks due today
due_tasks = [task for task in defined_tasks if is_task_due_today(task, today)]
print(f"Found {len(due_tasks)} task(s) due today in YAML.")
if not due_tasks:
print("No tasks due today. Exiting.")
return
# 3. Fetch existing Habitica tasks to avoid duplicates
existing_tasks = get_existing_tasks()
existing_titles = {t.get("text", "").strip().lower() for t in existing_tasks}
# 4. Create missing tasks
for task in due_tasks:
title = task.get("text", "").strip()
if title.lower() in existing_titles:
print(f" -> SKIPPED (Already exists): '{title}'")
else:
create_habitica_task(task)
if __name__ == "__main__":
if not USER_ID and not API_TOKEN:
print("Need to set the USER_ID and API_TOKEN variables.")
else:
sync_tasks()