commit 74af70f362612f8596fc386a37668414eb302e14 Author: Howard Abrams Date: Sat Aug 22 22:30:36 2026 -0700 Initial habitica synchronizer diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0680782 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.venv +.mise.local.toml diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..ea509c6 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,3 @@ +[tools] +python = { version = "3.12", virtualenv = ".venv" } + diff --git a/habitica_sync.py b/habitica_sync.py new file mode 100755 index 0000000..9708c00 --- /dev/null +++ b/habitica_sync.py @@ -0,0 +1,180 @@ +#!/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() diff --git a/my_tasks.yaml b/my_tasks.yaml new file mode 100644 index 0000000..bcc5c13 --- /dev/null +++ b/my_tasks.yaml @@ -0,0 +1,75 @@ +# -*- mode:yaml-ts; -*- +--- +tasks: + - text: "Water Indoor Plants" + type: "todo" + priority: 1 # 0.1: Trivial, 1: Easy, 1.5: Medium, 2: Hard + recurrence: + type: "flex" + day: "1st and 3rd Saturday" + + - text: "Water Outdoor Plants" + type: "todo" + priority: medium + checklist: + - Front pepper plants + - Front porch pot + - Apartment jasmine + - Back forty beans + - Lawn and artichoke + - Tomatoes + recurrence: + type: "interval" + interval: 1.5 # every other day + start: "2026-08-19" # anchor: task is "on" this day, then every 2nd day after + + - text: "Movie Night Prep" + type: "todo" + priority: hard + checklist: + - Vaccum/sweep floor + - Clean bathroom + - Prepare the table + - Set up the Projector + - Make the salad ... + recurrence: + type: "interval" + interval: 14 # every other week + start: "2026-08-30" + + - text: "Prepare for Adventure" + type: "todo" + priority: hard + checklist: + - Vaccum/sweep floor + - Clean bathroom + - Prepare the table + - Chips and what? + recurrence: + type: "interval" + interval: 14 # every other week + start: "2026-09-06" + + - text: "Replace the furnace filter" + notes: "Get ready for winter" + type: "todo" + priority: 1 + recurrence: + type: "flex" + day: "first Saturday of October" + + - text: "Replace the furnace filter" + notes: "Get ready for summer" + type: "todo" + priority: 1 + recurrence: + type: "flex" + day: "first Saturday of April" + + - text: Reach out to Gary + type: "todo" + priority: 0.5 + recurrence: + type: "interval" + interval: 21 # every three weeks + start: "2026-09-30" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..308d2ba --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests +recurrent +pyyaml +python-dateutil