habitica-xp/habitica_sync.py
2026-08-22 22:30:36 -07:00

181 lines
5.2 KiB
Python
Executable file

#!/usr/bin/env python
import calendar
import datetime
import os
import requests
import yaml
from dateutil.relativedelta import relativedelta, TH, FR, SA, SU, MO, TU, WE
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 = datetime.date.today()
rec = task_def.get("recurrence", {})
rec_type = rec.get("type")
print(f"Analyzing {rec}")
if 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 = (
datetime.date.fromisoformat(start_str)
if start_str
else datetime.date(2000, 1, 1) # fixed default epoch
)
delta = (check_date - anchor).days
return delta >= 0 and delta % interval == 0
elif rec_type.startswith("flex"):
r = RecurringEvent()
target_date = r.parse(rec.get("day"))
return check_date == target_date.date()
return False
# --- Habitica API Wrappers ---
def get_existing_tasks():
"""Fetch current tasks from Habitica."""
url = f"{BASE_URL}/tasks/user"
try:
print(f"headers: {HEADERS}")
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 = datetime.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__":
sync_tasks()