1009 lines
32 KiB
Python
1009 lines
32 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Todoist -> Tududi full migration (projects, labels->tags, tasks, subtasks, comments->description, completed tasks).
|
|
|
|
Key fixes in this version:
|
|
- Uses Tududi `uid` (not `id`) wherever applicable (projects/tasks/tags/areas).
|
|
- Uses `projectUid` when creating tasks (so tasks land in projects).
|
|
- Slugs Todoist labels to ASCII-safe Tududi tag names (avoids emoji/unicode issues => "undefined").
|
|
- CLEAN_FIRST deletes tasks/projects/tags using robust list parsing + uid/id fallback + multiple delete endpoints.
|
|
- USE_AREAS is optional; default false. If enabled, uses POST /api/v1/areas (NOT /api/v1/area).
|
|
|
|
Env:
|
|
TODOIST_TOKEN=...
|
|
TUDUDI_BASE_URL=https://tududi.example.org
|
|
TUDUDI_API_KEY=...
|
|
DRY_RUN=false
|
|
CLEAN_FIRST=false
|
|
USE_AREAS=false
|
|
REQUEST_SLEEP=0.25
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
import json
|
|
import random
|
|
import re
|
|
import unicodedata
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
|
|
import requests
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
|
|
|
|
# ----------------------------
|
|
# Helpers
|
|
# ----------------------------
|
|
def slug_tag(name: str) -> str:
|
|
"""
|
|
Convert label/tag to Tududi-safe slug while preserving emojis:
|
|
- emojis at the beginning are moved to the end
|
|
- text is ASCII-normalized
|
|
- result: <slug>_<emoji>
|
|
Example:
|
|
"💡 IDEAS" -> "ideas_💡"
|
|
"📚 Reading list" -> "reading_list_📚"
|
|
"""
|
|
|
|
if not name:
|
|
return "tag"
|
|
|
|
# 1) Extract emojis (very permissive, Unicode-aware)
|
|
emojis = re.findall(
|
|
r"[\U0001F300-\U0001FAFF\U00002600-\U000027BF]",
|
|
name
|
|
)
|
|
|
|
# 2) Remove emojis from text
|
|
text = re.sub(
|
|
r"[\U0001F300-\U0001FAFF\U00002600-\U000027BF]",
|
|
"",
|
|
name
|
|
)
|
|
|
|
# 3) Normalize text → ASCII slug
|
|
s = unicodedata.normalize("NFKD", text)
|
|
s = "".join(ch for ch in s if not unicodedata.combining(ch))
|
|
s = s.encode("ascii", "ignore").decode("ascii")
|
|
s = s.strip().lower()
|
|
s = re.sub(r"\s+", "_", s)
|
|
s = re.sub(r"[^a-z0-9_-]", "", s)
|
|
|
|
if not s:
|
|
s = "tag"
|
|
|
|
# 4) Append emojis at the end (if any)
|
|
if emojis:
|
|
s = f"{s}_{''.join(emojis)}"
|
|
|
|
return s
|
|
|
|
|
|
def normalize_list(resp: Any) -> List[Dict[str, Any]]:
|
|
"""
|
|
Tududi may respond as:
|
|
- [ {...}, {...} ]
|
|
- { "projects":[...] } / { "tags":[...] } / { "tasks":[...] } / { "areas":[...] }
|
|
- { "data":[...] } / { "items":[...] } / { "results":[...] }
|
|
Always return a list of dicts.
|
|
"""
|
|
if resp is None:
|
|
return []
|
|
if isinstance(resp, list):
|
|
return [x for x in resp if isinstance(x, dict)]
|
|
if isinstance(resp, dict):
|
|
for key in ("projects", "areas", "tags", "tasks", "data", "results", "items"):
|
|
v = resp.get(key)
|
|
if isinstance(v, list):
|
|
return [x for x in v if isinstance(x, dict)]
|
|
if "id" in resp or "uid" in resp or "name" in resp:
|
|
return [resp]
|
|
return []
|
|
|
|
|
|
def die(msg: str, code: int = 1) -> None:
|
|
print(f"[FATAL] {msg}", file=sys.stderr)
|
|
raise SystemExit(code)
|
|
|
|
|
|
def getenv_bool(name: str, default: bool = False) -> bool:
|
|
v = os.getenv(name)
|
|
if v is None:
|
|
return default
|
|
return v.strip().lower() in ("1", "true", "yes", "y", "on")
|
|
|
|
|
|
def getenv_float(name: str, default: float) -> float:
|
|
v = os.getenv(name)
|
|
if v is None:
|
|
return default
|
|
try:
|
|
return float(v.strip())
|
|
except ValueError:
|
|
return default
|
|
|
|
|
|
def iso_now() -> str:
|
|
return datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
|
|
|
|
|
def chunks(lst: List[Any], n: int) -> Iterable[List[Any]]:
|
|
for i in range(0, len(lst), n):
|
|
yield lst[i : i + n]
|
|
|
|
|
|
def _valid_name(x: Any) -> Optional[str]:
|
|
if x is None:
|
|
return None
|
|
s = str(x).strip()
|
|
if not s or s.lower() in ("undefined", "null", "none"):
|
|
return None
|
|
return s
|
|
|
|
|
|
def pick_uid(obj: Dict[str, Any]) -> Optional[str]:
|
|
"""
|
|
Tududi often uses uid; fallback to id.
|
|
"""
|
|
v = obj.get("uid") or obj.get("id")
|
|
if v is None:
|
|
return None
|
|
s = str(v).strip()
|
|
return s or None
|
|
|
|
def todoist_task_url(task: Dict[str, Any]) -> str:
|
|
"""
|
|
Best-effort URL to the Todoist task.
|
|
- If Todoist provides a direct url field, use it.
|
|
- Otherwise fall back to the canonical web UI endpoint.
|
|
"""
|
|
# sometimes present in some payloads (completed/rest)
|
|
url = task.get("url") or task.get("task_url") or task.get("taskUrl")
|
|
if isinstance(url, str) and url.strip():
|
|
return url.strip()
|
|
|
|
tid = task.get("id")
|
|
if tid is None:
|
|
return "https://todoist.com/"
|
|
return f"https://todoist.com/showTask?id={tid}"
|
|
|
|
|
|
def build_tududi_description_from_todoist(task: Dict[str, Any], task_name: str) -> str:
|
|
"""
|
|
Tududi description format:
|
|
|
|
<Task name>
|
|
|
|
[Todoist task](...)
|
|
|
|
<Todoist description>
|
|
"""
|
|
parts: List[str] = []
|
|
|
|
# Task name (explicit, even if Tududi already has it)
|
|
parts.append(task_name.strip())
|
|
|
|
# Todoist link
|
|
tid = task.get("id")
|
|
if tid:
|
|
parts.append(f"\n[Todoist task](https://todoist.com/showTask?id={tid})")
|
|
|
|
# Todoist description
|
|
td_desc = task.get("description")
|
|
if isinstance(td_desc, str) and td_desc.strip():
|
|
parts.append("\n" + td_desc.strip())
|
|
|
|
return "\n\n".join(parts).strip()
|
|
|
|
|
|
def build_tududi_tag_objects(tag_names: List[str], tag_by_name: Dict[str, Dict[str, Any]]) -> List[Dict[str, str]]:
|
|
objs: List[Dict[str, str]] = []
|
|
seen = set()
|
|
for n in tag_names or []:
|
|
key = str(n).strip()
|
|
if not key or key in seen:
|
|
continue
|
|
seen.add(key)
|
|
|
|
t = tag_by_name.get(key)
|
|
if not t:
|
|
continue
|
|
uid = t.get("uid")
|
|
name = t.get("name") or key
|
|
if not uid or not name:
|
|
continue
|
|
|
|
objs.append({"name": name, "uid": uid})
|
|
return objs
|
|
|
|
|
|
# ----------------------------
|
|
# Todoist client (API v1)
|
|
# ----------------------------
|
|
@dataclass
|
|
class TodoistData:
|
|
projects: List[Dict[str, Any]]
|
|
labels: List[Dict[str, Any]]
|
|
sections: List[Dict[str, Any]]
|
|
items: List[Dict[str, Any]] # active tasks in sync payload ("items")
|
|
notes: List[Dict[str, Any]] # comments in sync payload ("notes")
|
|
completed_tasks: List[Dict[str, Any]] # completed tasks payload (best-effort)
|
|
|
|
|
|
class TodoistClient:
|
|
BASE = "https://api.todoist.com"
|
|
|
|
def __init__(self, token: str, timeout: int = 60) -> None:
|
|
self.s = requests.Session()
|
|
self.s.headers.update({"Authorization": f"Bearer {token}"})
|
|
self.timeout = timeout
|
|
|
|
def sync_full(self) -> Dict[str, Any]:
|
|
url = f"{self.BASE}/api/v1/sync"
|
|
data = {"sync_token": "*", "resource_types": json.dumps(["all"])}
|
|
r = self.s.post(url, data=data, timeout=self.timeout)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
def get_completed_tasks_by_completion_date(self, since: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
candidates = [
|
|
(f"{self.BASE}/api/v1/tasks/completed/by_completion_date", {"since": since} if since else {}),
|
|
(f"{self.BASE}/api/v1/tasks/completed", {"since": since} if since else {}),
|
|
]
|
|
for url, params in candidates:
|
|
try:
|
|
out = self._get_paginated(url, params=params)
|
|
if out:
|
|
return out
|
|
except requests.HTTPError:
|
|
continue
|
|
return []
|
|
|
|
def _get_paginated(self, url: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
|
|
params = dict(params or {})
|
|
all_items: List[Dict[str, Any]] = []
|
|
cursor: Optional[str] = None
|
|
while True:
|
|
p = dict(params)
|
|
if cursor:
|
|
p["cursor"] = cursor
|
|
r = self.s.get(url, params=p, timeout=self.timeout)
|
|
r.raise_for_status()
|
|
j = r.json()
|
|
|
|
batch = j.get("results") or j.get("items") or j.get("data") or []
|
|
if isinstance(batch, list):
|
|
all_items.extend(batch)
|
|
|
|
cursor = j.get("next_cursor") or j.get("nextCursor")
|
|
if not cursor:
|
|
break
|
|
return all_items
|
|
|
|
def export_everything(self) -> TodoistData:
|
|
sync = self.sync_full()
|
|
projects = sync.get("projects", [])
|
|
labels = sync.get("labels", [])
|
|
sections = sync.get("sections", [])
|
|
items = sync.get("items", [])
|
|
notes = sync.get("notes", [])
|
|
completed = self.get_completed_tasks_by_completion_date()
|
|
return TodoistData(
|
|
projects=projects,
|
|
labels=labels,
|
|
sections=sections,
|
|
items=items,
|
|
notes=notes,
|
|
completed_tasks=completed,
|
|
)
|
|
|
|
|
|
# ----------------------------
|
|
# Tududi client
|
|
# ----------------------------
|
|
class TududiClient:
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
api_key: Optional[str] = None,
|
|
email: Optional[str] = None,
|
|
password: Optional[str] = None,
|
|
timeout: int = 60,
|
|
dry_run: bool = False,
|
|
request_sleep: float = 0.15,
|
|
) -> None:
|
|
self.base_url = base_url.rstrip("/")
|
|
self.timeout = timeout
|
|
self.dry_run = dry_run
|
|
self.request_sleep = request_sleep
|
|
self.s = requests.Session()
|
|
|
|
if api_key:
|
|
self.s.headers.update({"Authorization": f"Bearer {api_key}"})
|
|
elif email and password:
|
|
self._login_cookie(email, password)
|
|
else:
|
|
die("Need TUDUDI_API_KEY (recommended) OR TUDUDI_EMAIL + TUDUDI_PASSWORD.")
|
|
|
|
self.s.headers.setdefault("Content-Type", "application/json")
|
|
self.s.headers.setdefault("Accept", "application/json")
|
|
|
|
def _login_cookie(self, email: str, password: str) -> None:
|
|
payload = {"email": email, "password": password}
|
|
for path in ("/api/login", "/api/v1/login"):
|
|
url = self._url(path)
|
|
r = self.s.post(url, data=json.dumps(payload), timeout=self.timeout)
|
|
if r.status_code < 400:
|
|
return
|
|
die("Tududi cookie login failed. Use an API key.")
|
|
|
|
def _url(self, path: str) -> str:
|
|
if not path.startswith("/"):
|
|
path = "/" + path
|
|
return self.base_url + path
|
|
|
|
def _request_with_retry(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
payload: Optional[dict] = None,
|
|
params: Optional[dict] = None,
|
|
):
|
|
url = self._url(path)
|
|
max_attempts = 8
|
|
base_sleep = 1.0
|
|
|
|
for attempt in range(1, max_attempts + 1):
|
|
try:
|
|
if method == "GET":
|
|
r = self.s.get(url, params=params, timeout=self.timeout)
|
|
elif method == "DELETE":
|
|
if self.dry_run:
|
|
print(f"[DRY_RUN] DELETE {path}")
|
|
return {"_dry_run": True}
|
|
r = self.s.delete(url, timeout=self.timeout)
|
|
else:
|
|
if self.dry_run:
|
|
print(f"[DRY_RUN] POST {path} payload={payload}")
|
|
return {"_dry_run": True}
|
|
if payload is not None and isinstance(payload, dict):
|
|
print(f"[DEBUG] POST {path} payload.Tags={payload.get('Tags') if isinstance(payload, dict) else None}")
|
|
r = self.s.post(url, data=json.dumps(payload or {}), timeout=self.timeout)
|
|
|
|
# 429 -> retry
|
|
if r.status_code == 429:
|
|
retry_after = r.headers.get("Retry-After")
|
|
if retry_after and retry_after.isdigit():
|
|
sleep_s = float(retry_after)
|
|
else:
|
|
sleep_s = base_sleep * (2 ** (attempt - 1)) + random.uniform(0, 0.5)
|
|
print(f"[WARN] 429 on {path}. Sleeping {sleep_s:.2f}s (attempt {attempt}/{max_attempts})")
|
|
time.sleep(sleep_s)
|
|
continue
|
|
|
|
# 4xx (except 429) -> fail fast
|
|
if 400 <= r.status_code < 500:
|
|
print(f"[FATAL] {r.status_code} on {path}: {r.text[:2000]}")
|
|
if payload is not None:
|
|
print("[FATAL] sent_payload=", json.dumps(payload or {}, ensure_ascii=False)[:2000])
|
|
r.raise_for_status()
|
|
|
|
# 5xx -> retry
|
|
if 500 <= r.status_code < 600:
|
|
sleep_s = base_sleep * (2 ** (attempt - 1)) + random.uniform(0, 0.5)
|
|
print(f"[WARN] {r.status_code} on {path}. Sleeping {sleep_s:.2f}s (attempt {attempt}/{max_attempts})")
|
|
time.sleep(sleep_s)
|
|
continue
|
|
|
|
r.raise_for_status()
|
|
if r.text:
|
|
try:
|
|
return r.json()
|
|
except Exception:
|
|
return {"_raw": r.text}
|
|
return {}
|
|
|
|
except (requests.ConnectionError, requests.Timeout) as e:
|
|
sleep_s = base_sleep * (2 ** (attempt - 1)) + random.uniform(0, 0.5)
|
|
print(f"[WARN] Network error on {path}: {e}. Sleeping {sleep_s:.2f}s (attempt {attempt}/{max_attempts})")
|
|
time.sleep(sleep_s)
|
|
|
|
die(f"Request failed after retries: {method} {path}")
|
|
|
|
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
return self._request_with_retry("GET", path, params=params)
|
|
|
|
def _post(self, path: str, payload: Dict[str, Any]) -> Any:
|
|
return self._request_with_retry("POST", path, payload=payload)
|
|
|
|
def _delete(self, path: str) -> Any:
|
|
return self._request_with_retry("DELETE", path)
|
|
|
|
def list_all(self, path: str) -> List[Dict[str, Any]]:
|
|
return normalize_list(self._get(path))
|
|
|
|
# ---- entity lists ----
|
|
def list_projects(self) -> List[Dict[str, Any]]:
|
|
return self._get("/api/v1/projects")
|
|
|
|
def list_tags(self) -> List[Dict[str, Any]]:
|
|
return self._get("/api/v1/tags")
|
|
|
|
def list_tasks(self) -> List[Dict[str, Any]]:
|
|
return self._get("/api/v1/tasks")
|
|
|
|
def list_areas(self) -> List[Dict[str, Any]]:
|
|
return self._get("/api/v1/areas")
|
|
|
|
# ---- create wrappers ----
|
|
def create_area(self, name: str) -> Dict[str, Any]:
|
|
# Your server: POST /api/v1/areas works; /api/v1/area does NOT.
|
|
for path in ("/api/v1/areas", "/api/areas"):
|
|
try:
|
|
return self._post(path, {"name": name})
|
|
except requests.HTTPError:
|
|
continue
|
|
die("Cannot create area: no supported endpoint (tried /api/v1/areas, /api/areas).")
|
|
|
|
def create_project(self, name: str) -> Dict[str, Any]:
|
|
payload = {"name": name}
|
|
for path in ("/api/v1/project", "/api/v1/projects"):
|
|
try:
|
|
return self._post(path, payload)
|
|
except requests.HTTPError:
|
|
continue
|
|
die("Cannot create project: endpoint not found.")
|
|
|
|
def create_tag(self, name: str) -> Dict[str, Any]:
|
|
for path in ("/api/v1/tag", "/api/v1/tags"):
|
|
try:
|
|
return self._post(path, {"name": name})
|
|
except requests.HTTPError:
|
|
continue
|
|
die("Cannot create tag: endpoint not found.")
|
|
|
|
def create_task(
|
|
self,
|
|
name: str,
|
|
project_id: Optional[int] = None,
|
|
due_date: Optional[str] = None,
|
|
priority: Optional[str] = None,
|
|
tag_objs: Optional[List[Dict[str, str]]] = None,
|
|
description: Optional[str] = None,
|
|
note: Optional[str] = None,
|
|
parent_task_id: Optional[int] = None,
|
|
is_completed: Optional[bool] = None,
|
|
completed_at: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
payload: Dict[str, Any] = {"name": name}
|
|
|
|
if project_id is not None:
|
|
payload["project_id"] = int(project_id)
|
|
|
|
if due_date:
|
|
payload["due_date"] = due_date # oppure dueDate, dipende dal backend; se non va, lo togliamo
|
|
if priority:
|
|
payload["priority"] = priority
|
|
|
|
|
|
if tag_objs:
|
|
payload["Tags"] = tag_objs
|
|
|
|
if note:
|
|
payload["note"] = note
|
|
if description:
|
|
payload["description"] = description
|
|
|
|
if parent_task_id is not None:
|
|
payload["parent_task_id"] = int(parent_task_id)
|
|
|
|
if is_completed is not None:
|
|
payload["completed"] = is_completed
|
|
if completed_at:
|
|
payload["completed_at"] = completed_at
|
|
|
|
return self._post("/api/v1/task", payload)
|
|
|
|
|
|
# ---- delete helpers ----
|
|
def delete_best_effort(self, templates: List[str], uid: str) -> bool:
|
|
"""
|
|
Try multiple delete endpoints; return True if any succeeds, False if all 404.
|
|
"""
|
|
for tpl in templates:
|
|
path = tpl.format(uid=uid)
|
|
try:
|
|
self._delete(path)
|
|
return True
|
|
except requests.HTTPError as e:
|
|
resp = getattr(e, "response", None)
|
|
if resp is not None and resp.status_code == 404:
|
|
continue
|
|
raise
|
|
return False
|
|
|
|
|
|
# ----------------------------
|
|
# Cleanup via API (CLEAN_FIRST)
|
|
# ----------------------------
|
|
def clean_tududi(tud: TududiClient, use_areas: bool = False) -> None:
|
|
print("[CLEAN] Starting full cleanup via API")
|
|
|
|
def delete_many(list_path: str, delete_templates: List[str], label: str):
|
|
items = tud.list_all(list_path)
|
|
print(f"[CLEAN] Deleting {len(items)} {label}")
|
|
for it in items:
|
|
if not isinstance(it, dict):
|
|
continue
|
|
uid = pick_uid(it)
|
|
if not uid:
|
|
continue
|
|
ok = tud.delete_best_effort(delete_templates, uid)
|
|
if not ok:
|
|
print(f"[CLEAN][WARN] Could not delete {label[:-1]} uid={uid}")
|
|
time.sleep(max(0.10, tud.request_sleep))
|
|
|
|
# Tasks
|
|
delete_many(
|
|
"/api/v1/tasks",
|
|
[
|
|
"/api/v1/task/{uid}",
|
|
"/api/v1/tasks/{uid}",
|
|
"/api/task/{uid}",
|
|
"/api/tasks/{uid}",
|
|
],
|
|
"tasks",
|
|
)
|
|
|
|
# Projects
|
|
delete_many(
|
|
"/api/v1/projects",
|
|
[
|
|
"/api/v1/project/{uid}",
|
|
"/api/v1/projects/{uid}",
|
|
"/api/project/{uid}",
|
|
"/api/projects/{uid}",
|
|
],
|
|
"projects",
|
|
)
|
|
|
|
# Tags
|
|
delete_many(
|
|
"/api/v1/tags",
|
|
[
|
|
"/api/v1/tag/{uid}",
|
|
"/api/v1/tags/{uid}",
|
|
"/api/tag/{uid}",
|
|
"/api/tags/{uid}",
|
|
],
|
|
"tags",
|
|
)
|
|
|
|
if use_areas:
|
|
delete_many(
|
|
"/api/v1/areas",
|
|
[
|
|
"/api/v1/area/{uid}", # some installs
|
|
"/api/v1/areas/{uid}", # other installs
|
|
"/api/area/{uid}",
|
|
"/api/areas/{uid}",
|
|
],
|
|
"areas",
|
|
)
|
|
else:
|
|
print("[CLEAN] Areas cleanup skipped (USE_AREAS=false).")
|
|
|
|
print("[CLEAN] Done.")
|
|
|
|
|
|
# ----------------------------
|
|
# Migration logic
|
|
# ----------------------------
|
|
def map_priority(todoist_priority: Optional[int]) -> Optional[str]:
|
|
if not todoist_priority:
|
|
return None
|
|
return {1: "low", 2: "medium", 3: "high", 4: "urgent"}.get(int(todoist_priority), None)
|
|
|
|
|
|
def normalize_due(todoist_item: Dict[str, Any]) -> Optional[str]:
|
|
due = todoist_item.get("due")
|
|
if not due:
|
|
return None
|
|
return due.get("datetime") or due.get("date")
|
|
|
|
|
|
def build_comments_index(notes: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
|
|
by_item: Dict[str, List[Dict[str, Any]]] = {}
|
|
for n in notes:
|
|
item_id = n.get("item_id") or n.get("task_id")
|
|
if not item_id:
|
|
continue
|
|
by_item.setdefault(str(item_id), []).append(n)
|
|
|
|
for k in by_item:
|
|
by_item[k].sort(key=lambda x: x.get("posted_at") or x.get("postedAt") or "")
|
|
return by_item
|
|
|
|
|
|
def comments_to_text(comments: List[Dict[str, Any]]) -> str:
|
|
if not comments:
|
|
return ""
|
|
lines = ["\n\n---\n**Imported comments (Todoist)**\n"]
|
|
for c in comments:
|
|
ts = c.get("posted_at") or c.get("postedAt") or ""
|
|
content = c.get("content") or c.get("text") or ""
|
|
lines.append(f"- [{ts}] {content}")
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
def topo_sort_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
by_id = {str(i["id"]): i for i in items if "id" in i}
|
|
children: Dict[str, List[str]] = {}
|
|
roots: List[str] = []
|
|
|
|
for i in items:
|
|
iid = str(i["id"])
|
|
pid = i.get("parent_id") or i.get("parentId")
|
|
if pid:
|
|
children.setdefault(str(pid), []).append(iid)
|
|
else:
|
|
roots.append(iid)
|
|
|
|
out: List[Dict[str, Any]] = []
|
|
visited: set[str] = set()
|
|
|
|
def dfs(node_id: str):
|
|
if node_id in visited:
|
|
return
|
|
visited.add(node_id)
|
|
out.append(by_id[node_id])
|
|
for ch in children.get(node_id, []):
|
|
if ch in by_id:
|
|
dfs(ch)
|
|
|
|
for r in roots:
|
|
if r in by_id:
|
|
dfs(r)
|
|
|
|
for iid, obj in by_id.items():
|
|
if iid not in visited:
|
|
out.append(obj)
|
|
|
|
return out
|
|
|
|
|
|
def main() -> None:
|
|
todoist_token = os.getenv("TODOIST_TOKEN")
|
|
if not todoist_token:
|
|
die("Missing TODOIST_TOKEN in .env")
|
|
|
|
tududi_base = os.getenv("TUDUDI_BASE_URL", "").strip()
|
|
if not tududi_base:
|
|
die("Missing TUDUDI_BASE_URL in .env (e.g., https://tududi.example.org)")
|
|
|
|
tududi_key = os.getenv("TUDUDI_API_KEY")
|
|
tududi_email = os.getenv("TUDUDI_EMAIL")
|
|
tududi_password = os.getenv("TUDUDI_PASSWORD")
|
|
|
|
|
|
|
|
dry_run = getenv_bool("DRY_RUN", False)
|
|
clean_first = getenv_bool("CLEAN_FIRST", False)
|
|
use_areas = getenv_bool("USE_AREAS", False)
|
|
request_sleep = getenv_float("REQUEST_SLEEP", 0.25)
|
|
skip_completed_tasks = getenv_bool("SKIP_COMPLETED_TASKS", False)
|
|
skip_archived_projects = getenv_bool("SKIP_ARCHIVED_PROJECTS", False)
|
|
skip_empty_projects = getenv_bool("SKIP_EMPTY_PROJECTS", False)
|
|
|
|
print(f"[INFO] Starting migration @ {iso_now()} (dry_run={dry_run})")
|
|
print(f"[INFO] SKIP_COMPLETED_TASKS={skip_completed_tasks}")
|
|
print(f"[INFO] Options: "
|
|
f"SKIP_ARCHIVED_PROJECTS={skip_archived_projects}, "
|
|
f"SKIP_EMPTY_PROJECTS={skip_empty_projects}")
|
|
print("[INFO] Exporting from Todoist (sync full + completed best-effort)...")
|
|
|
|
tdc = TodoistClient(todoist_token)
|
|
data = tdc.export_everything()
|
|
|
|
# Build project -> task count map (active + completed)
|
|
project_task_count: Dict[str, int] = {}
|
|
|
|
for t in data.items:
|
|
pid = t.get("project_id") or t.get("projectId")
|
|
if pid is not None:
|
|
project_task_count[str(pid)] = project_task_count.get(str(pid), 0) + 1
|
|
|
|
for t in data.completed_tasks:
|
|
pid = t.get("project_id") or t.get("projectId")
|
|
if pid is not None:
|
|
project_task_count[str(pid)] = project_task_count.get(str(pid), 0) + 1
|
|
|
|
print(
|
|
f"[INFO] Todoist: projects={len(data.projects)} labels={len(data.labels)} "
|
|
f"sections={len(data.sections)} active_items={len(data.items)} "
|
|
f"notes={len(data.notes)} completed_tasks={len(data.completed_tasks)}"
|
|
)
|
|
|
|
tud = TududiClient(
|
|
base_url=tududi_base,
|
|
api_key=tududi_key,
|
|
email=tududi_email,
|
|
password=tududi_password,
|
|
dry_run=dry_run,
|
|
request_sleep=request_sleep,
|
|
)
|
|
|
|
if clean_first:
|
|
clean_tududi(tud, use_areas=use_areas)
|
|
|
|
# Load existing entities
|
|
print("[INFO] Reading existing entities from Tududi...")
|
|
existing_projects = normalize_list(tud.list_projects())
|
|
existing_tags = normalize_list(tud.list_tags())
|
|
existing_areas = normalize_list(tud.list_areas()) if use_areas else []
|
|
|
|
proj_by_name: Dict[str, Dict[str, Any]] = {}
|
|
for p in existing_projects:
|
|
n = _valid_name(p.get("name") if isinstance(p, dict) else None)
|
|
if n:
|
|
proj_by_name[n] = p
|
|
|
|
tag_by_name: Dict[str, Dict[str, Any]] = {}
|
|
for t in existing_tags:
|
|
n = _valid_name(t.get("name") if isinstance(t, dict) else None)
|
|
if n:
|
|
tag_by_name[n] = t
|
|
|
|
area_by_name: Dict[str, Dict[str, Any]] = {}
|
|
if use_areas:
|
|
for a in existing_areas:
|
|
n = _valid_name(a.get("name") if isinstance(a, dict) else None)
|
|
if n:
|
|
area_by_name[n] = a
|
|
|
|
# Todoist project hierarchy info
|
|
children_count: Dict[str, int] = {}
|
|
for p in data.projects:
|
|
pid = p.get("parent_id")
|
|
if pid:
|
|
children_count[str(pid)] = children_count.get(str(pid), 0) + 1
|
|
|
|
# Areas (optional)
|
|
area_map: Dict[str, str] = {}
|
|
if use_areas:
|
|
print("[INFO] Creating Areas (USE_AREAS=true)...")
|
|
for p in data.projects:
|
|
tid = str(p.get("id"))
|
|
name = p.get("name")
|
|
if not name:
|
|
continue
|
|
if children_count.get(tid, 0) <= 0:
|
|
continue
|
|
if name in area_by_name:
|
|
area_map[tid] = pick_uid(area_by_name[name]) or ""
|
|
continue
|
|
created = tud.create_area(name)
|
|
cid = pick_uid(created) or pick_uid(created.get("area", {}) if isinstance(created, dict) else {}) or ""
|
|
if cid:
|
|
area_map[tid] = cid
|
|
area_by_name[name] = {"uid": cid, "name": name}
|
|
print(f"[INFO] Area: {name} -> {cid}")
|
|
else:
|
|
print("[INFO] Skipping Areas (USE_AREAS=false).")
|
|
|
|
# Projects: create ALL Todoist projects as Tududi projects (including "container" projects)
|
|
project_map: Dict[str, str] = {}
|
|
print("[INFO] Creating Projects...")
|
|
for p in data.projects:
|
|
tid = str(p["id"])
|
|
name = p.get("name")
|
|
if not name:
|
|
continue
|
|
|
|
# --- SKIP ARCHIVED PROJECTS ---
|
|
if skip_archived_projects and p.get("is_archived"):
|
|
print(f"[SKIP] Archived project: {name}")
|
|
continue
|
|
|
|
# --- SKIP EMPTY PROJECTS ---
|
|
if skip_empty_projects and project_task_count.get(tid, 0) == 0:
|
|
print(f"[SKIP] Empty project: {name}")
|
|
continue
|
|
|
|
|
|
if name in proj_by_name:
|
|
# prendi ID numerico se esiste, fallback su uid se proprio manca (ma dovrebbe esserci)
|
|
existing = proj_by_name[name]
|
|
pid = existing.get("id")
|
|
if pid is None:
|
|
print(f"[WARN] Existing project without numeric id? name={name} obj={existing}")
|
|
continue
|
|
project_map[tid] = int(pid)
|
|
continue
|
|
|
|
created = tud.create_project(name)
|
|
pid = created.get("id")
|
|
if pid is None:
|
|
die(f"Project created but missing id: {created}")
|
|
project_map[tid] = int(pid)
|
|
proj_by_name[name] = created
|
|
print(f"[INFO] Project: {name} -> id={pid} uid={created.get('uid')}")
|
|
time.sleep(request_sleep)
|
|
|
|
# Tags: create from Todoist labels using slug
|
|
print("[INFO] Creating Tags from Todoist labels...")
|
|
todo_label_id_to_name = {str(l["id"]): l.get("name") for l in data.labels if "id" in l and l.get("name")}
|
|
for lbl in data.labels:
|
|
orig = lbl.get("name")
|
|
if not orig:
|
|
continue
|
|
slug = slug_tag(orig)
|
|
if slug in tag_by_name:
|
|
continue
|
|
created = tud.create_tag(slug)
|
|
cid = pick_uid(created) or pick_uid(created.get("tag", {}) if isinstance(created, dict) else {}) or ""
|
|
if cid:
|
|
tag_by_name[slug] = {"uid": cid, "name": slug}
|
|
print(f"[INFO] Tag: {orig} -> {slug} ({cid})")
|
|
time.sleep(request_sleep)
|
|
|
|
# Comments index
|
|
comments_idx = build_comments_index(data.notes)
|
|
|
|
# Merge active + (if requested) completed tasks
|
|
all_tasks_src: List[Tuple[Dict[str, Any], bool]] = []
|
|
for i in data.items:
|
|
all_tasks_src.append((i, False))
|
|
if skip_completed_tasks:
|
|
print(f"[INFO] Skipping completed tasks from Todoist export: {len(data.completed_tasks)}")
|
|
else:
|
|
for c in data.completed_tasks:
|
|
all_tasks_src.append((c, True))
|
|
|
|
items_like = [x for (x, _done) in all_tasks_src if "id" in x]
|
|
items_sorted = topo_sort_items(items_like)
|
|
is_done_map = {str(x["id"]): done for (x, done) in all_tasks_src if "id" in x}
|
|
|
|
created_task_map: Dict[str, int] = {}
|
|
|
|
|
|
def get_project_for_task(task: Dict[str, Any]) -> Optional[int]:
|
|
pid = task.get("project_id") or task.get("projectId")
|
|
if pid is None:
|
|
return None
|
|
return project_map.get(str(pid))
|
|
|
|
def get_project_uid_for_task(task: Dict[str, Any]) -> Optional[str]:
|
|
pid = task.get("project_id") if isinstance(task, dict) else None
|
|
if pid is None:
|
|
pid = task.get("projectId") if isinstance(task, dict) else None
|
|
if pid is None:
|
|
return None
|
|
return project_map.get(str(pid))
|
|
|
|
|
|
|
|
def get_tags_for_task(task: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
lbls = task.get("labels") or task.get("label_ids") or task.get("labelIds") or []
|
|
names: List[str] = []
|
|
|
|
for l in lbls:
|
|
if isinstance(l, int) or (isinstance(l, str) and l.isdigit()):
|
|
nm = todo_label_id_to_name.get(str(l))
|
|
if nm:
|
|
names.append(nm)
|
|
elif isinstance(l, str):
|
|
names.append(l)
|
|
|
|
# Build Tududi Tags objects: [{"name": "...", "uid": "..."}]
|
|
tags_obj: List[Dict[str, str]] = []
|
|
seen = set()
|
|
|
|
for nm in names:
|
|
nm2 = str(nm).strip()
|
|
if not nm2 or nm2.lower() in ("undefined", "null", "none"):
|
|
continue
|
|
|
|
slug = slug_tag(nm2)
|
|
|
|
tinfo = tag_by_name.get(slug)
|
|
if not tinfo:
|
|
continue
|
|
|
|
uid = tinfo.get("uid") or tinfo.get("id")
|
|
if not uid:
|
|
continue
|
|
|
|
key = (slug, str(uid))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
|
|
tags_obj.append({"name": slug, "uid": str(uid)})
|
|
|
|
return tags_obj
|
|
|
|
|
|
print(f"[INFO] Creating tasks (active+completed): {len(items_sorted)}")
|
|
for task in items_sorted:
|
|
tid = str(task["id"])
|
|
name = task.get("content") or task.get("name") or task.get("title")
|
|
if not name:
|
|
continue
|
|
|
|
parent_id = task.get("parent_id") or task.get("parentId")
|
|
tud_parent_id = created_task_map.get(str(parent_id)) if parent_id else None
|
|
|
|
due = normalize_due(task)
|
|
prio = map_priority(task.get("priority"))
|
|
project_uid = get_project_uid_for_task(task)
|
|
|
|
# Skip task if project was skipped
|
|
if project_uid is None and (task.get("project_id") or task.get("projectId")):
|
|
print(f"[SKIP] Task {tid} belongs to skipped project")
|
|
continue
|
|
|
|
# Tududi note: Todoist link + Todoist description
|
|
tududi_description = build_tududi_description_from_todoist(task, name)
|
|
|
|
desc = task.get("description") or ""
|
|
comments = comments_idx.get(tid, [])
|
|
desc_extra = comments_to_text(comments)
|
|
if desc_extra:
|
|
desc = (desc + "\n" + desc_extra).strip()
|
|
|
|
is_completed = is_done_map.get(tid, False)
|
|
completed_at = task.get("completed_at") or task.get("completedAt")
|
|
|
|
tag_objs = get_tags_for_task(task) # già [{"name":..., "uid":...}]
|
|
|
|
if tag_objs:
|
|
print(f"[DEBUG] task={tid} tags_obj={tag_objs}")
|
|
else:
|
|
raw_lbls = task.get("labels") or task.get("label_ids") or task.get("labelIds")
|
|
print(f"[DEBUG] task={tid} NO TAGS. raw_labels={raw_lbls}")
|
|
|
|
created = tud.create_task(
|
|
name=name,
|
|
project_id=project_uid,
|
|
due_date=due,
|
|
priority=prio,
|
|
tag_objs=tag_objs if tag_objs else None,
|
|
description=tududi_description,
|
|
note=tududi_description,
|
|
parent_task_id=tud_parent_id,
|
|
is_completed=is_completed if is_completed else None,
|
|
completed_at=completed_at,
|
|
)
|
|
|
|
new_id = None
|
|
if isinstance(created, dict):
|
|
new_id = created.get("id")
|
|
if new_id is None and isinstance(created.get("task"), dict):
|
|
new_id = created["task"].get("id")
|
|
|
|
if new_id is not None:
|
|
created_task_map[tid] = int(new_id)
|
|
|
|
|
|
if len(created_task_map) % 50 == 0:
|
|
print(f"[INFO] Created tasks: {len(created_task_map)}")
|
|
|
|
time.sleep(request_sleep)
|
|
|
|
print(f"[DONE] Migration finished. Created/seen tasks: {len(created_task_map)} @ {iso_now()}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except KeyboardInterrupt:
|
|
die("Interrupted by user.", 130)
|