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:
2026-03-01 06:04:03 -05:00
co-authored by Claude Sonnet 4.6
parent 23d7c17255
commit 304cf4fde4
16 changed files with 1795 additions and 133 deletions
+303
View File
@@ -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}"