Files

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)