Initial commit, import is working
This commit is contained in:
commit
3be659f647
148
README.md
Normal file
148
README.md
Normal file
@ -0,0 +1,148 @@
|
||||
# notion2plank
|
||||
|
||||
One-shot Python script that imports tasks from a **Notion CSV export** into a **self-hosted Plane** instance via its REST API.
|
||||
|
||||
Plane's free/community tier has no built-in import UI, but the API is fully open. This script bridges the gap.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- A Plane API key — _Settings → API Tokens_
|
||||
- A Notion database exported as CSV — _··· → Export → Markdown & CSV → without subpages_
|
||||
|
||||
```
|
||||
pip -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cp config.yaml.example config.yaml
|
||||
```
|
||||
|
||||
Edit `config.yaml`:
|
||||
|
||||
```yaml
|
||||
plane_url: https://your-plane-instance.com
|
||||
workspace_slug: your-workspace
|
||||
project_id: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
api_key: your-api-key-here
|
||||
|
||||
# Optional — needed to import estimate points (вес column).
|
||||
# Get it from browser DevTools → Application → Cookies → session-id.
|
||||
# If omitted, the вес column is silently skipped.
|
||||
plane_session: your-session-id-cookie-value
|
||||
|
||||
# Map Notion status values (exact text + emoji) → Plane state names.
|
||||
# Find your state names: GET /api/v1/workspaces/<slug>/projects/<id>/states/
|
||||
status_mapping:
|
||||
"в работе 🔨": "In Progress"
|
||||
"Готово ✅": "Done"
|
||||
"На проверке 👀": "Review"
|
||||
"Утверждённые задачи 📝": "Todo"
|
||||
"Общие идеи (задачи) 📋": "Backlog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
**Dry run first** — parses every row and shows what would be created, no API calls:
|
||||
|
||||
```bash
|
||||
python import.py --dry-run
|
||||
```
|
||||
|
||||
**Real import:**
|
||||
|
||||
```bash
|
||||
python import.py
|
||||
```
|
||||
|
||||
**Custom paths:**
|
||||
|
||||
```bash
|
||||
python import.py --csv path/to/export.csv --config path/to/config.yaml
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
```
|
||||
Fetching project states…
|
||||
Found 6 states: ['Backlog', 'Todo', 'In Progress', 'Done', 'Cancelled', 'Review']
|
||||
Fetching project labels…
|
||||
Found 7 labels: ['Sound', '2d', 'Game Design', 'Org', '3d', 'Level Design', 'Devel']
|
||||
Fetching estimate points…
|
||||
Found 6 estimate points: ['1', '2', '3', '5', '8', '13']
|
||||
|
||||
Ensuring labels for disciplines: ['Game Design', 'Sound', ...]
|
||||
|
||||
Processing 37 rows…
|
||||
|
||||
[ 1] Created: #31 — 'Create Kanban Board'
|
||||
[ 2] Created: #32 — 'Add Milestones'
|
||||
...
|
||||
|
||||
Summary: 37 created, 0 failed.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field Mapping
|
||||
|
||||
| Notion column | Plane field | Notes |
|
||||
| ---------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| `Name` | `name` | Required |
|
||||
| `Status` | `state` (UUID) | Mapped via `status_mapping` in config |
|
||||
| `priority` | `priority` | Emoji stripped: 🔥 Critical→`urgent`, ⭐ High→`high`, 📌 Medium→`medium`, 💤 Low→`low`, empty→`none` |
|
||||
| `discipline` | `label_ids` | Auto-created as a Plane label if it doesn't exist yet |
|
||||
| `Date` (single) | `target_date` | Parsed with dateutil; output `YYYY-MM-DD` |
|
||||
| `Date` (range `A → B`) | `start_date` + `target_date` | Split on the `→` Unicode arrow |
|
||||
| `Description` | `description_html` | Wrapped in `<p>` |
|
||||
| `результат` | appended to `description_html` | Added as `<p><em>Result: N</em></p>` if non-zero |
|
||||
| `вес` | `estimate_point` (UUID) | Matched by value (e.g. `"5"`) then by key position; requires `plane_session` |
|
||||
| `исполнитель` | — | Skipped (no user ID mapping) |
|
||||
| `Автор задачи` | — | Skipped |
|
||||
| `Attachments` | — | Skipped |
|
||||
| `Person` | — | Skipped |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
### Estimate points (`вес`)
|
||||
|
||||
Plane's public API (`/api/v1/`) does not expose an endpoint to list estimate point UUIDs.
|
||||
The script works around this by calling an internal frontend endpoint (`/api/workspaces/…/estimates/`) using your browser session cookie.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. **Exact value match** — `вес="5"` maps to the point labeled "5" (Fibonacci-style: 1, 2, 3, 5, 8, 13)
|
||||
2. **Ordinal key fallback** — `вес="4"` maps to the 4th point in the estimate scale
|
||||
|
||||
If `plane_session` is not set or the cookie has expired, the `вес` column is skipped and everything else still imports normally.
|
||||
|
||||
### Custom fields (work item properties)
|
||||
|
||||
Plane's custom property API (`/work-item-types/…/work-item-properties/`) requires the `is_issue_type_enabled` project flag, which is a paid-tier feature. It is not available in the community/self-hosted free edition.
|
||||
|
||||
### Discipline labels
|
||||
|
||||
All unique `discipline` values in the CSV are pre-fetched and compared against existing project labels before the import starts. Missing labels are created automatically via `POST /labels/`. Existing labels are reused by UUID.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
import.py — main script
|
||||
config.yaml.example — annotated config template
|
||||
config.yaml — your local config (do not commit)
|
||||
requirements.txt — pip dependencies
|
||||
```
|
||||
19
config.yaml.example
Normal file
19
config.yaml.example
Normal file
@ -0,0 +1,19 @@
|
||||
plane_url: https://your-plane-instance.com
|
||||
workspace_slug: your-workspace
|
||||
project_id: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
api_key: your-api-key-here
|
||||
|
||||
# Optional: session-id cookie value from your browser (DevTools → Application → Cookies).
|
||||
# Used to fetch estimate point UUIDs from Plane's internal API, since the public /api/v1/
|
||||
# endpoint does not expose them. If omitted, the вес (weight) column is skipped.
|
||||
# Note: session cookies expire; refresh this value if you get authentication errors.
|
||||
plane_session: your-session-id-cookie-value-here
|
||||
|
||||
# Map Notion Status values (exact text, including emoji) to Plane state names.
|
||||
# Run: GET /api/v1/workspaces/<slug>/projects/<id>/states/ to see your state names.
|
||||
status_mapping:
|
||||
"На проверке 👀": "In Review"
|
||||
"Готово ✅": "Done"
|
||||
"в работе 🔨": "In Progress"
|
||||
"Общие идеи (задачи) 📋": "Backlog"
|
||||
"Утверждённые задачи 📝": "Todo"
|
||||
383
import.py
Normal file
383
import.py
Normal file
@ -0,0 +1,383 @@
|
||||
#!/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"<p>{description.strip()}</p>")
|
||||
result_val = clamp_estimate(result) if result else None
|
||||
if result_val is not None and result_val != 0:
|
||||
parts.append(f"<p><em>Result: {result_val}</em></p>")
|
||||
elif result and result.strip() and result.strip() != "0":
|
||||
# Non-numeric result text
|
||||
parts.append(f"<p><em>Result: {result.strip()}</em></p>")
|
||||
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"<dry-run-uuid-{discipline}>"
|
||||
else:
|
||||
new_id = client.create_label(discipline)
|
||||
label_map[discipline] = new_id
|
||||
print(f" Created label: {discipline!r} → {new_id}")
|
||||
return label_map
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Import Notion CSV tasks into Plane.")
|
||||
parser.add_argument("--csv", default=DEFAULT_CSV, help="Path to Notion CSV export")
|
||||
parser.add_argument("--config", default=DEFAULT_CONFIG, help="Path to config.yaml")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Parse and validate rows without making API calls",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
csv_path = Path(args.csv)
|
||||
config_path = Path(args.config)
|
||||
|
||||
if not csv_path.exists():
|
||||
print(f"Error: CSV file not found: {csv_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not config_path.exists():
|
||||
print(f"Error: Config file not found: {config_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with config_path.open() as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
plane_url: str = config["plane_url"]
|
||||
workspace_slug: str = config["workspace_slug"]
|
||||
project_id: str = config["project_id"]
|
||||
api_key: str = config["api_key"]
|
||||
status_mapping: dict[str, str] = config.get("status_mapping", {})
|
||||
plane_session: str | None = config.get("plane_session")
|
||||
|
||||
client = PlaneClient(plane_url, workspace_slug, project_id, api_key)
|
||||
|
||||
# Fetch runtime state and label maps (skip in dry-run)
|
||||
if args.dry_run:
|
||||
print("[dry-run] Skipping API calls for states/labels lookup.\n")
|
||||
state_map: dict[str, str] = {}
|
||||
label_map: dict[str, str] = {}
|
||||
point_map: dict[str, str] = {}
|
||||
else:
|
||||
print("Fetching project states…")
|
||||
state_map = client.get_states()
|
||||
print(f" Found {len(state_map)} states: {list(state_map.keys())}")
|
||||
|
||||
print("Fetching project labels…")
|
||||
label_map = client.get_labels()
|
||||
print(f" Found {len(label_map)} labels: {list(label_map.keys())}")
|
||||
|
||||
# Fetch estimate points via internal endpoint (requires plane_session cookie)
|
||||
if plane_session:
|
||||
print("Fetching estimate points…")
|
||||
try:
|
||||
point_map = fetch_estimate_point_map(plane_url, workspace_slug, project_id, plane_session)
|
||||
values = [k for k in point_map if not k.startswith("key:")]
|
||||
print(f" Found {len(values)} estimate points: {values}")
|
||||
except Exception as exc:
|
||||
print(f" Warning: could not fetch estimate points ({exc}). estimate_point will be skipped.")
|
||||
point_map = {}
|
||||
else:
|
||||
print("No plane_session in config — estimate_point will be skipped.")
|
||||
point_map = {}
|
||||
|
||||
# Read CSV and collect unique disciplines for label pre-creation
|
||||
with csv_path.open(encoding="utf-8-sig") as f:
|
||||
rows = list(csv.DictReader(f))
|
||||
|
||||
disciplines = list({r.get("discipline", "").strip() for r in rows if r.get("discipline", "").strip()})
|
||||
|
||||
if not args.dry_run:
|
||||
print(f"\nEnsuring labels for disciplines: {disciplines}")
|
||||
label_map = ensure_labels(disciplines, client, dry_run=False)
|
||||
else:
|
||||
label_map = ensure_labels(disciplines, client, dry_run=True)
|
||||
|
||||
print(f"\nProcessing {len(rows)} rows…\n")
|
||||
|
||||
created = 0
|
||||
failed = 0
|
||||
|
||||
for i, row in enumerate(rows, start=1):
|
||||
name = row.get("Name", "").strip()
|
||||
if not name:
|
||||
print(f" Row {i}: skipped (empty Name)")
|
||||
continue
|
||||
|
||||
# Status → state UUID
|
||||
notion_status = row.get("Status", "").strip()
|
||||
plane_state_name = status_mapping.get(notion_status)
|
||||
state_uuid = state_map.get(plane_state_name) if plane_state_name else None
|
||||
if notion_status and not state_uuid and not args.dry_run:
|
||||
print(f" Row {i} warning: no state mapping for status {notion_status!r}")
|
||||
|
||||
# Priority
|
||||
priority = parse_priority(row.get("priority", ""))
|
||||
|
||||
# Discipline → label UUID
|
||||
discipline = row.get("discipline", "").strip()
|
||||
label_uuids: list[str] = []
|
||||
if discipline:
|
||||
label_uuid = label_map.get(discipline)
|
||||
if label_uuid:
|
||||
label_uuids = [label_uuid]
|
||||
|
||||
# Dates
|
||||
start_date, target_date = parse_date(row.get("Date", ""))
|
||||
|
||||
# Description + result
|
||||
description_html = build_description_html(
|
||||
row.get("Description", ""),
|
||||
row.get("результат", ""),
|
||||
)
|
||||
|
||||
# Weight → estimate_point UUID (resolved via internal endpoint)
|
||||
estimate_uuid = resolve_estimate_point(row.get("вес", ""), point_map)
|
||||
|
||||
payload: dict = {"name": name}
|
||||
if state_uuid:
|
||||
payload["state"] = state_uuid
|
||||
payload["priority"] = priority
|
||||
if label_uuids:
|
||||
payload["label_ids"] = label_uuids
|
||||
if target_date:
|
||||
payload["target_date"] = target_date
|
||||
if start_date:
|
||||
payload["start_date"] = start_date
|
||||
if description_html:
|
||||
payload["description_html"] = description_html
|
||||
if estimate_uuid:
|
||||
payload["estimate_point"] = estimate_uuid
|
||||
|
||||
if args.dry_run:
|
||||
state_display = plane_state_name or f"(unmapped: {notion_status!r})" if notion_status else "(none)"
|
||||
raw_weight = row.get("вес", "").strip()
|
||||
print(
|
||||
f" [{i:>3}] {name!r}\n"
|
||||
f" state={state_display}, priority={priority}, "
|
||||
f"labels={label_uuids}, dates={start_date}→{target_date}, "
|
||||
f"estimate={raw_weight!r}→{estimate_uuid or '(skipped)'}"
|
||||
)
|
||||
created += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
issue = client.create_issue(payload)
|
||||
identifier = issue.get("sequence_id") or issue.get("id", "?")
|
||||
print(f" [{i:>3}] Created: #{identifier} — {name!r}")
|
||||
created += 1
|
||||
except requests.HTTPError as exc:
|
||||
body = exc.response.text[:300] if exc.response is not None else ""
|
||||
print(f" [{i:>3}] FAILED ({exc.response.status_code if exc.response is not None else '?'}): {name!r} — {body}")
|
||||
failed += 1
|
||||
|
||||
print(f"\n{'[dry-run] ' if args.dry_run else ''}Summary: {created} created, {failed} failed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
requests
|
||||
pyyaml
|
||||
python-dateutil
|
||||
Loading…
x
Reference in New Issue
Block a user