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