feat: v0.2.0 — Joplin import, ChatGPT Projects, --project filter
Core features: - Add `joplin` command: syncs exported Markdown to Joplin via local REST API - Notebooks auto-created per provider+project (e.g. "ChatGPT - My Project") - Idempotent: notes updated (not duplicated) on re-run; note ID tracked in manifest - Add `--project` filter to `export` and `list` commands (substring or 'none') - Add ChatGPT Projects support via CHATGPT_PROJECT_IDS env var Config: - Add JOPLIN_API_TOKEN, JOPLIN_API_URL, JOPLIN_REQUEST_TIMEOUT - Version now read from importlib.metadata (single source of truth: pyproject.toml) - Bump version to 0.2.0 Quality: - Explicit Timeout handling in JoplinClient with actionable error messages - token validation (validate_token) separate from connectivity (ping) - Remove debug_auth.py, debug_claude.py, and untracked .har file - Add *.har to .gitignore (may contain auth cookies/session tokens) - Update README, CHANGELOG, FUTURE.md to reflect v0.2.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+303
@@ -0,0 +1,303 @@
|
||||
"""Joplin Data API client for importing notes into Joplin desktop."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HTTP timeout for regular API calls (seconds). Notes can be large Markdown
|
||||
# files so we allow more time than a typical JSON API call.
|
||||
# Override with JOPLIN_REQUEST_TIMEOUT env var if you have very large conversations.
|
||||
_REQUEST_TIMEOUT: int = int(os.getenv("JOPLIN_REQUEST_TIMEOUT", "30"))
|
||||
|
||||
|
||||
class JoplinError(Exception):
|
||||
"""Raised when the Joplin API returns an error or is unreachable."""
|
||||
|
||||
|
||||
class JoplinClient:
|
||||
"""HTTP client for the Joplin local REST API (Web Clipper service).
|
||||
|
||||
Requires Joplin desktop to be running with the Web Clipper service enabled.
|
||||
Get your API token from: Joplin → Tools → Options → Web Clipper.
|
||||
|
||||
Args:
|
||||
base_url: Joplin API base URL (default: http://localhost:41184).
|
||||
token: API authorization token from Joplin Web Clipper settings.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, token: str) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._token = token
|
||||
# In-memory cache of notebook title → ID to avoid repeated GET /folders
|
||||
self._notebook_cache: dict[str, str] = {}
|
||||
self._notebooks_loaded = False
|
||||
logger.debug("[joplin] Client initialised with base_url=%s", self._base_url)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connectivity
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""Return True if the Joplin API is reachable and responding.
|
||||
|
||||
Note: /ping does not require authentication. A successful ping only
|
||||
confirms Joplin is running — not that the token is valid. Call
|
||||
``validate_token()`` to confirm authentication separately.
|
||||
|
||||
Raises:
|
||||
JoplinError: If the API returns an unexpected non-connection error.
|
||||
"""
|
||||
url = f"{self._base_url}/ping"
|
||||
logger.debug("[joplin] GET %s", url)
|
||||
try:
|
||||
resp = requests.get(url, timeout=5)
|
||||
resp.raise_for_status()
|
||||
ok = "JoplinClipperServer" in resp.text
|
||||
logger.debug("[joplin] ping → %s (body: %r)", "OK" if ok else "unexpected response", resp.text[:80])
|
||||
return ok
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.debug("[joplin] ping → connection refused at %s", url)
|
||||
return False
|
||||
except requests.exceptions.Timeout:
|
||||
logger.debug("[joplin] ping → timed out after 5s at %s", url)
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise JoplinError(f"Joplin ping failed: {e}") from e
|
||||
|
||||
def validate_token(self) -> None:
|
||||
"""Verify the API token is accepted by Joplin.
|
||||
|
||||
Does a minimal authenticated call (GET /folders?limit=1) and raises
|
||||
``JoplinError`` if authentication fails.
|
||||
|
||||
Raises:
|
||||
JoplinError: If the token is rejected (401) or Joplin is unreachable.
|
||||
"""
|
||||
logger.debug("[joplin] Validating API token…")
|
||||
self._get("/folders", params={"limit": 1, "fields": "id"})
|
||||
logger.debug("[joplin] Token validated OK")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Notebooks (folders)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_notebooks(self) -> list[dict]:
|
||||
"""Return all Joplin notebooks (folders), handling pagination.
|
||||
|
||||
Returns:
|
||||
List of folder dicts with at least ``id`` and ``title`` keys.
|
||||
"""
|
||||
results: list[dict] = []
|
||||
page = 1
|
||||
while True:
|
||||
logger.debug("[joplin] GET /folders page=%d", page)
|
||||
resp = self._get("/folders", params={"page": page, "fields": "id,title"})
|
||||
items = resp.get("items", [])
|
||||
results.extend(items)
|
||||
logger.debug("[joplin] /folders page=%d → %d items, has_more=%s", page, len(items), resp.get("has_more"))
|
||||
if not resp.get("has_more"):
|
||||
break
|
||||
page += 1
|
||||
return results
|
||||
|
||||
def get_or_create_notebook(self, title: str) -> str:
|
||||
"""Return the Joplin folder ID for ``title``, creating it if needed.
|
||||
|
||||
Args:
|
||||
title: Notebook display name (e.g. "ChatGPT - My Project").
|
||||
|
||||
Returns:
|
||||
Joplin folder ID string.
|
||||
"""
|
||||
if not self._notebooks_loaded:
|
||||
self._load_notebook_cache()
|
||||
|
||||
if title in self._notebook_cache:
|
||||
folder_id = self._notebook_cache[title]
|
||||
logger.debug("[joplin] Notebook cache hit: %r → %s", title, folder_id)
|
||||
return folder_id
|
||||
|
||||
# Not found — create it
|
||||
logger.info("[joplin] Creating notebook: %r", title)
|
||||
resp = self._post("/folders", {"title": title})
|
||||
folder_id = resp["id"]
|
||||
self._notebook_cache[title] = folder_id
|
||||
logger.debug("[joplin] Notebook created: %r → %s", title, folder_id)
|
||||
return folder_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Notes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_note(self, title: str, body: str, parent_id: str) -> str:
|
||||
"""Create a new note in the specified notebook.
|
||||
|
||||
Args:
|
||||
title: Note title.
|
||||
body: Note body (Markdown).
|
||||
parent_id: Notebook (folder) ID.
|
||||
|
||||
Returns:
|
||||
ID of the created note.
|
||||
"""
|
||||
logger.debug(
|
||||
"[joplin] Creating note: %r in notebook %s (%d chars)",
|
||||
title, parent_id, len(body),
|
||||
)
|
||||
resp = self._post("/notes", {"title": title, "body": body, "parent_id": parent_id})
|
||||
note_id = resp["id"]
|
||||
logger.info("[joplin] Note created: %r → %s", title, note_id)
|
||||
return note_id
|
||||
|
||||
def update_note(self, note_id: str, title: str, body: str) -> None:
|
||||
"""Update the title and body of an existing note.
|
||||
|
||||
Args:
|
||||
note_id: Joplin note ID.
|
||||
title: New note title.
|
||||
body: New note body (Markdown).
|
||||
"""
|
||||
logger.debug(
|
||||
"[joplin] Updating note %s: %r (%d chars)",
|
||||
note_id, title, len(body),
|
||||
)
|
||||
self._put(f"/notes/{note_id}", {"title": title, "body": body})
|
||||
logger.info("[joplin] Note updated: %r (%s)", title, note_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get(self, path: str, params: dict | None = None) -> dict[str, Any]:
|
||||
url = f"{self._base_url}{path}"
|
||||
query = {"token": self._token, **(params or {})}
|
||||
logger.debug("[joplin] GET %s params=%s", path, {k: v for k, v in (params or {}).items()})
|
||||
try:
|
||||
resp = requests.get(url, params=query, timeout=_REQUEST_TIMEOUT)
|
||||
logger.debug("[joplin] GET %s → HTTP %d", path, resp.status_code)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise JoplinError(
|
||||
"Cannot connect to Joplin. Is Joplin desktop running with Web Clipper enabled?"
|
||||
) from e
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise JoplinError(_timeout_message("GET", path)) from e
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise JoplinError(_http_error_message("GET", path, e)) from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise JoplinError(f"Joplin GET {path} failed: {e}") from e
|
||||
|
||||
def _post(self, path: str, data: dict) -> dict[str, Any]:
|
||||
url = f"{self._base_url}{path}"
|
||||
logger.debug("[joplin] POST %s", path)
|
||||
try:
|
||||
resp = requests.post(url, params={"token": self._token}, json=data, timeout=_REQUEST_TIMEOUT)
|
||||
logger.debug("[joplin] POST %s → HTTP %d", path, resp.status_code)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise JoplinError(
|
||||
"Cannot connect to Joplin. Is Joplin desktop running with Web Clipper enabled?"
|
||||
) from e
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise JoplinError(_timeout_message("POST", path)) from e
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise JoplinError(_http_error_message("POST", path, e)) from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise JoplinError(f"Joplin POST {path} failed: {e}") from e
|
||||
|
||||
def _put(self, path: str, data: dict) -> dict[str, Any]:
|
||||
url = f"{self._base_url}{path}"
|
||||
logger.debug("[joplin] PUT %s", path)
|
||||
try:
|
||||
resp = requests.put(url, params={"token": self._token}, json=data, timeout=_REQUEST_TIMEOUT)
|
||||
logger.debug("[joplin] PUT %s → HTTP %d", path, resp.status_code)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise JoplinError(
|
||||
"Cannot connect to Joplin. Is Joplin desktop running with Web Clipper enabled?"
|
||||
) from e
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise JoplinError(_timeout_message("PUT", path)) from e
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise JoplinError(_http_error_message("PUT", path, e)) from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise JoplinError(f"Joplin PUT {path} failed: {e}") from e
|
||||
|
||||
def _load_notebook_cache(self) -> None:
|
||||
logger.debug("[joplin] Loading notebook list from Joplin…")
|
||||
notebooks = self.list_notebooks()
|
||||
self._notebook_cache = {nb["title"]: nb["id"] for nb in notebooks}
|
||||
self._notebooks_loaded = True
|
||||
logger.debug("[joplin] Notebook cache loaded: %d notebooks", len(self._notebook_cache))
|
||||
for title, folder_id in self._notebook_cache.items():
|
||||
logger.debug("[joplin] %r → %s", title, folder_id)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Error message helper
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _timeout_message(method: str, path: str) -> str:
|
||||
"""Build a clear timeout error message with actionable suggestions."""
|
||||
return (
|
||||
f"Joplin {method} {path} timed out after {_REQUEST_TIMEOUT}s. "
|
||||
"Possible causes:\n"
|
||||
" • The note body is very large and Joplin is slow to process it.\n"
|
||||
" • Joplin is busy (syncing, indexing, or loading a large library).\n"
|
||||
" • Joplin has frozen — try restarting it.\n"
|
||||
f"If this happens repeatedly, increase JOPLIN_REQUEST_TIMEOUT in your .env "
|
||||
f"(currently {_REQUEST_TIMEOUT}s)."
|
||||
)
|
||||
|
||||
|
||||
def _http_error_message(method: str, path: str, e: requests.exceptions.HTTPError) -> str:
|
||||
"""Build a human-friendly error message from an HTTP error, with auth hint on 401."""
|
||||
resp = e.response
|
||||
status = resp.status_code if resp is not None else "?"
|
||||
if status == 401:
|
||||
return (
|
||||
f"Joplin rejected the API token (HTTP 401 on {method} {path}). "
|
||||
"Check that JOPLIN_API_TOKEN is correct: "
|
||||
"Joplin → Tools → Options → Web Clipper → Authorization token."
|
||||
)
|
||||
if status == 404:
|
||||
return f"Joplin resource not found (HTTP 404 on {method} {path}). The note may have been deleted in Joplin."
|
||||
body_snippet = ""
|
||||
if resp is not None:
|
||||
try:
|
||||
body_snippet = f" — {resp.text[:120]}"
|
||||
except Exception:
|
||||
pass
|
||||
return f"Joplin {method} {path} failed: HTTP {status}{body_snippet}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Notebook naming helper
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
_PROVIDER_DISPLAY = {
|
||||
"chatgpt": "ChatGPT",
|
||||
"claude": "Claude",
|
||||
}
|
||||
|
||||
|
||||
def notebook_title(provider: str, project: str | None) -> str:
|
||||
"""Derive a flat Joplin notebook title from provider and project name.
|
||||
|
||||
Examples:
|
||||
notebook_title("chatgpt", "no-project") → "ChatGPT - No Project"
|
||||
notebook_title("claude", "budget-tracker") → "Claude - Budget Tracker"
|
||||
notebook_title("chatgpt", None) → "ChatGPT - No Project"
|
||||
"""
|
||||
prov_display = _PROVIDER_DISPLAY.get(provider, provider.capitalize())
|
||||
proj = (project or "no-project").replace("-", " ").title()
|
||||
return f"{prov_display} - {proj}"
|
||||
Reference in New Issue
Block a user