Initial habitica synchronizer
This commit is contained in:
commit
74af70f362
5 changed files with 264 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
.venv
|
||||||
|
.mise.local.toml
|
||||||
3
.mise.toml
Normal file
3
.mise.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[tools]
|
||||||
|
python = { version = "3.12", virtualenv = ".venv" }
|
||||||
|
|
||||||
180
habitica_sync.py
Executable file
180
habitica_sync.py
Executable file
|
|
@ -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()
|
||||||
75
my_tasks.yaml
Normal file
75
my_tasks.yaml
Normal file
|
|
@ -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"
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
requests
|
||||||
|
recurrent
|
||||||
|
pyyaml
|
||||||
|
python-dateutil
|
||||||
Loading…
Reference in a new issue