#!/usr/bin/env python3 """ Notion CSV → Plane import script. Usage: python import.py [--csv PATH] [--config PATH] [--dry-run] Defaults: --csv "Tasks 300144872f3480f19272e04d4cf0ee7e.csv" --config config.yaml """ import argparse import csv import re import sys from pathlib import Path import requests import yaml from dateutil import parser as dateparser DEFAULT_CSV = "Tasks 300144872f3480f19272e04d4cf0ee7e.csv" DEFAULT_CONFIG = "config.yaml" # Maps lowercased, emoji-stripped priority text to Plane's enum values PRIORITY_MAP = { "critical": "urgent", "high": "high", "medium": "medium", "low": "low", } def strip_priority_emoji(raw: str) -> str: """Remove leading emoji and whitespace from a priority string, return lowercase.""" # Remove any leading non-letter characters (emoji, spaces, punctuation) stripped = re.sub(r"^[^\w]+", "", raw.strip(), flags=re.UNICODE) return stripped.lower() def parse_priority(raw: str) -> str: """Map Notion priority string to Plane priority enum value.""" if not raw or not raw.strip(): return "none" key = strip_priority_emoji(raw) return PRIORITY_MAP.get(key, "none") def parse_date(raw: str) -> tuple[str | None, str | None]: """ Parse a Notion date field. Returns (start_date, end_date) as 'YYYY-MM-DD' strings or None. Single date → (None, date) Range 'A → B' → (date_a, date_b) Empty → (None, None) """ if not raw or not raw.strip(): return None, None # Unicode arrow used by Notion for date ranges if " → " in raw: parts = raw.split(" → ", 1) start = _parse_single_date(parts[0].strip()) end = _parse_single_date(parts[1].strip()) return start, end return None, _parse_single_date(raw.strip()) def _parse_single_date(text: str) -> str | None: if not text: return None try: dt = dateparser.parse(text, dayfirst=False) return dt.strftime("%Y-%m-%d") except (ValueError, OverflowError): return None def clamp_estimate(raw: str) -> int | None: """Cast weight to int and clamp to Plane's 0–7 range; None if empty.""" if not raw or not raw.strip(): return None try: value = int(float(raw.strip())) return max(0, min(7, value)) except ValueError: return None def build_description_html(description: str, result: str) -> str: """Combine description and result into HTML.""" parts = [] if description and description.strip(): parts.append(f"
{description.strip()}
") result_val = clamp_estimate(result) if result else None if result_val is not None and result_val != 0: parts.append(f"Result: {result_val}
") elif result and result.strip() and result.strip() != "0": # Non-numeric result text parts.append(f"Result: {result.strip()}
") return "".join(parts) def fetch_estimate_point_map(base_url: str, workspace_slug: str, project_id: str, session_id: str) -> dict[str, str]: """ Fetch estimate points from Plane's internal (non-v1) endpoint using session cookie. Returns {value_string: uuid} e.g. {"1": "e7ae...", "2": "157d...", ...}. Also includes key-based entries {"key:1": uuid, ...} as fallback. """ url = f"{base_url.rstrip('/')}/api/workspaces/{workspace_slug}/projects/{project_id}/estimates/" resp = requests.get( url, headers={"accept": "application/json"}, cookies={"session-id": session_id}, ) resp.raise_for_status() data = resp.json() if not data: return {} # Prefer the last-used estimate; fall back to first estimate = next((e for e in data if e.get("last_used")), data[0]) result: dict[str, str] = {} for pt in estimate.get("points", []): result[str(pt["value"])] = pt["id"] # match by display value, e.g. "5" result[f"key:{pt['key']}"] = pt["id"] # match by ordinal key, e.g. "key:3" return result def resolve_estimate_point(raw_weight: str, point_map: dict[str, str]) -> str | None: """ Resolve a Notion вес value to a Plane estimate point UUID. Tries exact value match first ("3" → UUID for value "3"), then ordinal key match ("3" → UUID for key=3). """ if not raw_weight or not raw_weight.strip() or not point_map: return None raw = raw_weight.strip() # 1. Exact value match (e.g. вес="5" → point with value="5") if raw in point_map: return point_map[raw] # 2. Ordinal key match (e.g. вес="3" → point with key=3) try: key = int(float(raw)) fallback = point_map.get(f"key:{key}") if fallback: return fallback except ValueError: pass return None class PlaneClient: def __init__(self, base_url: str, workspace_slug: str, project_id: str, api_key: str): self.base_url = base_url.rstrip("/") self.workspace_slug = workspace_slug self.project_id = project_id self.session = requests.Session() self.session.headers.update({"X-API-Key": api_key}) def _project_url(self, *path_parts: str) -> str: parts = [ self.base_url, "api/v1/workspaces", self.workspace_slug, "projects", self.project_id, *path_parts, ] return "/".join(p.strip("/") for p in parts) + "/" def get_states(self) -> dict[str, str]: """Return {state_name: state_uuid} for all project states.""" url = self._project_url("states") resp = self.session.get(url) resp.raise_for_status() data = resp.json() results = data.get("results", data) if isinstance(data, dict) else data return {s["name"]: s["id"] for s in results} def get_labels(self) -> dict[str, str]: """Return {label_name: label_uuid} for all project labels.""" url = self._project_url("labels") resp = self.session.get(url) resp.raise_for_status() data = resp.json() results = data.get("results", data) if isinstance(data, dict) else data return {lb["name"]: lb["id"] for lb in results} def create_label(self, name: str) -> str: """Create a label and return its UUID.""" url = self._project_url("labels") resp = self.session.post(url, json={"name": name}) resp.raise_for_status() return resp.json()["id"] def create_issue(self, payload: dict) -> dict: """POST a new work item. Returns the created issue dict.""" url = self._project_url("issues") resp = self.session.post(url, json=payload) resp.raise_for_status() return resp.json() def ensure_labels(disciplines: list[str], client: PlaneClient, dry_run: bool) -> dict[str, str]: """ Ensure all discipline values have a corresponding Plane label. Returns {discipline_name: label_uuid}. """ label_map = client.get_labels() if not dry_run else {} for discipline in disciplines: if not discipline or discipline in label_map: continue if dry_run: print(f" [dry-run] Would create label: {discipline!r}") label_map[discipline] = f"