Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 39 additions & 20 deletions src/planecli/commands/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
from datetime import date
from typing import Annotated

import cyclopts
Expand All @@ -12,6 +13,7 @@
from planecli.api.async_sdk import run_sdk
from planecli.api.client import get_client, get_workspace, handle_api_error
from planecli.formatters import output, output_single
from planecli.utils.body_html import body_to_html
from planecli.utils.resolve import resolve_project_async

doc_app = cyclopts.App(
Expand Down Expand Up @@ -40,6 +42,7 @@ def _enrich_doc(data: dict) -> dict:
desc_html = data.get("description_html") or ""
if desc_html:
import re

data["content_text"] = re.sub(r"<[^>]+>", "", desc_html).strip()
else:
data["content_text"] = ""
Expand Down Expand Up @@ -75,12 +78,11 @@ async def list_(
project_id = proj["id"]
# Use direct API call since SDK doesn't have list_project_pages
from planecli.api.client import get_config

config = get_config()
url = f"{config.base_url}/api/v1/workspaces/{workspace}/projects/{project_id}/pages/"
headers = {"X-Api-Key": config.api_key}
resp = await asyncio.to_thread(
requests.get, url, headers=headers, timeout=30
)
resp = await asyncio.to_thread(requests.get, url, headers=headers, timeout=30)
resp.raise_for_status()
resp_data = resp.json()
# Handle paginated response
Expand Down Expand Up @@ -129,9 +131,7 @@ async def show(
client.pages.retrieve_project_page, workspace, project_id, document
)
else:
page = await run_sdk(
client.pages.retrieve_workspace_page, workspace, document
)
page = await run_sdk(client.pages.retrieve_workspace_page, workspace, document)

data = _enrich_doc(page.model_dump())
except PlaneError as e:
Expand Down Expand Up @@ -165,20 +165,19 @@ async def create(
client = get_client()
workspace = get_workspace()

page_data = CreatePage(name=title)
if content:
page_data.description_html = f"<p>{content}</p>"
# description_html is a required SDK field — omitting it crashes
# CreatePage construction even for an empty body.
page_data = CreatePage(
name=title,
description_html=body_to_html(content) if content is not None else "<p></p>",
)

if project:
proj = await resolve_project_async(project, client, workspace)
project_id = proj["id"]
page = await run_sdk(
client.pages.create_project_page, workspace, project_id, page_data
)
page = await run_sdk(client.pages.create_project_page, workspace, project_id, page_data)
else:
page = await run_sdk(
client.pages.create_workspace_page, workspace, page_data
)
page = await run_sdk(client.pages.create_workspace_page, workspace, page_data)

data = _enrich_doc(page.model_dump())
except PlaneError as e:
Expand Down Expand Up @@ -217,13 +216,14 @@ async def update(
client = get_client()
workspace = get_workspace()
from planecli.api.client import get_config

config = get_config()

update_payload: dict = {}
if title:
update_payload["name"] = title
if content:
update_payload["description_html"] = f"<p>{content}</p>"
if content is not None:
update_payload["description_html"] = body_to_html(content)

if project:
proj = await resolve_project_async(project, client, workspace)
Expand Down Expand Up @@ -260,16 +260,21 @@ async def delete(
project
Project name/ID (required for project-level pages).

Note: Uses direct API call since the SDK doesn't support page deletion.
Note: The API only deletes pages that are already archived, and silently
ignores unknown fields on the archive request — so this PATCHes archived_at
(today, YYYY-MM-DD), verifies the response actually carries it, and only
then DELETEs.
"""
import requests

from planecli.exceptions import APIError
from planecli.formatters import console

try:
client = get_client()
workspace = get_workspace()
from planecli.api.client import get_config

config = get_config()

if project:
Expand All @@ -280,11 +285,25 @@ async def delete(
else:
url = f"{config.base_url}/api/v1/workspaces/{workspace}/pages/{document}/"

headers = {"X-Api-Key": config.api_key}
today = date.today().isoformat()
headers = {"X-Api-Key": config.api_key, "Content-Type": "application/json"}
# A 200 alone is not proof the archive happened — the API answers 200
# and ignores unknown fields, so verify archived_at came back set.
resp = await asyncio.to_thread(
requests.delete, url, headers=headers, timeout=30
requests.patch,
url,
headers=headers,
json={"archived_at": today},
timeout=30,
)
resp.raise_for_status()
if resp.json().get("archived_at") != today:
raise APIError(
"the document was not archived, so it was not deleted. "
"Archiving may require a higher project role."
)
resp = await asyncio.to_thread(requests.delete, url, headers=headers, timeout=30)
resp.raise_for_status()
except PlaneError as e:
raise handle_api_error(e)

Expand Down
89 changes: 89 additions & 0 deletions src/planecli/utils/body_html.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Convert plain user-authored text to the HTML Plane's editor stores.

The Plane API stores HTML verbatim and does not parse markdown or auto-link
URLs — only the web editor does — so text written via the CLI must arrive
already converted. Shared by comment and document write commands.
"""

from __future__ import annotations

import html
import re

_URL_RE = re.compile(r"(?<![\"'>=])(https?://[A-Za-z0-9\-._~:/?#\[\]@!$&()*+,;=%]+)")
# Trailing punctuation that belongs to the sentence, not the URL.
_URL_TRAIL = ".,;:!?)]}。,;:!?)】、"


def _linkify(text: str) -> str:
"""Turn bare http(s) URLs into anchors.

URLs already inside an href attribute are left alone (the regex excludes
matches preceded by a quote or angle bracket from an attribute context).
"""

def _sub(m: re.Match[str]) -> str:
url = m.group(1).rstrip(_URL_TRAIL)
trail = m.group(1)[len(url) :]
return f'<a href="{url}">{url}</a>{trail}'

return _URL_RE.sub(_sub, text)


def _inline_code(text: str) -> str:
"""Convert `inline code` to a code tag with HTML-escaped content."""
return re.sub(
r"`([^`\n]+)`",
lambda m: f"<code>{html.escape(m.group(1))}</code>",
text,
)


def _extract_code_blocks(text: str) -> tuple[str, list[str]]:
"""Pull fenced code blocks out of the text, replacing each with a token.

Returns the text with \x00N\x00 placeholders and the list of HTML
fragments (pre-wrapped escaped code) to restore at the end. Extracting
first keeps their content out of linkify and the newline-to-br pass.
"""
blocks: list[str] = []

def _hold(match: re.Match[str]) -> str:
code = html.escape(match.group(1).strip("\n"))
blocks.append(f"<pre><code>{code}</code></pre>")
return f"\x00{len(blocks) - 1}\x00"

text = re.sub(
r"```[ \t]*\w*[ \t]*\n(.*?)```",
_hold,
text,
flags=re.DOTALL,
)
return text, blocks


def body_to_html(body: str) -> str:
"""Convert plain text to HTML paragraphs.

Blank lines separate paragraphs; a single newline becomes a br tag —
the editor collapses whitespace inside a paragraph, so unconverted
newlines would render as one long line. Backticks become code tags and
fenced blocks become pre-wrapped code (the editor stores HTML, it does
not parse markdown).
"""
text, blocks = _extract_code_blocks(body.strip())
parts: list[str] = []
for p in re.split(r"\n\s*\n", text):
p = p.strip()
if not p:
continue
converted = _linkify(_inline_code(p)).replace(chr(10), "<br/>")
if re.fullmatch(r"(?:\x00\d+\x00[ \t]*)+", p):
# A paragraph that is only a code block keeps its pre wrapper.
parts.append(converted)
else:
parts.append(f"<p>{converted}</p>")
result = "".join(parts)
for i, fragment in enumerate(blocks):
result = result.replace(f"\x00{i}\x00", fragment)
return result
Loading