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
cp config.yaml.example config.yaml
Edit config.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:
python import.py --dry-run
Real import:
python import.py
Custom paths:
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:
- Exact value match —
вес="5"maps to the point labeled "5" (Fibonacci-style: 1, 2, 3, 5, 8, 13) - 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