feat: v0.6.0 — collapse policy, session limiter, Claude Code provider, prune, browser auth, media downloads
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
"""Joplin Data API client for importing notes into Joplin desktop."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
@@ -190,6 +193,46 @@ class JoplinClient:
|
||||
self._put(f"/notes/{note_id}", {"title": title, "body": body})
|
||||
logger.info("[joplin] Note updated: %r (%s)", title, note_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Resources (embedded media)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_resource(self, file_path: "Path", title: str | None = None) -> str:
|
||||
"""Upload a local file as a Joplin resource and return its ID.
|
||||
|
||||
Joplin's ``POST /resources`` is multipart: a ``data`` file part plus a
|
||||
``props`` JSON part. The returned ID is referenced from note bodies as
|
||||
``:/<id>``.
|
||||
"""
|
||||
url = f"{self._base_url}/resources"
|
||||
props = json.dumps({"title": title or file_path.name})
|
||||
logger.debug("[joplin] POST /resources (%s)", file_path.name)
|
||||
try:
|
||||
with file_path.open("rb") as fh:
|
||||
resp = requests.post(
|
||||
url,
|
||||
params={"token": self._token},
|
||||
files={
|
||||
"data": (file_path.name, fh),
|
||||
"props": (None, props),
|
||||
},
|
||||
timeout=_REQUEST_TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
resource_id = resp.json()["id"]
|
||||
logger.info("[joplin] Resource created: %s → %s", file_path.name, resource_id)
|
||||
return resource_id
|
||||
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", "/resources")) from e
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise JoplinError(_http_error_message("POST", "/resources", e)) from e
|
||||
except (requests.exceptions.RequestException, KeyError, OSError) as e:
|
||||
raise JoplinError(f"Joplin resource upload failed for {file_path.name}: {e}") from e
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -304,6 +347,46 @@ def _http_error_message(method: str, path: str, e: requests.exceptions.HTTPError
|
||||
return f"Joplin {method} {path} failed: HTTP {status}{body_snippet}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Embedded-media rewriting
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Markdown image/link targets pointing at the local media/ sibling directory,
|
||||
# e.g.  or [name](media/clip.wav).
|
||||
_MEDIA_LINK_RE = re.compile(r"(!?\[[^\]]*\])\((media/[^)\s]+)\)")
|
||||
|
||||
|
||||
def upload_media_and_rewrite(
|
||||
body: str,
|
||||
note_dir: Path,
|
||||
client: "JoplinClient",
|
||||
resource_map: dict[str, str],
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
"""Rewrite local ``media/…`` links to Joplin ``:/resourceId`` references.
|
||||
|
||||
Uploads each referenced file as a Joplin resource (once), rewriting both
|
||||
image embeds and file links. ``resource_map`` (relative-path → resource_id)
|
||||
is the idempotency record from the cache: known paths are reused, new ones
|
||||
are added and returned so the caller can persist them. Missing files are
|
||||
left as-is so the link still points at the on-disk copy.
|
||||
"""
|
||||
new_map = dict(resource_map)
|
||||
|
||||
def replace(match: re.Match) -> str:
|
||||
label, rel_path = match.group(1), match.group(2)
|
||||
resource_id = new_map.get(rel_path)
|
||||
if resource_id is None:
|
||||
file_path = (note_dir / rel_path).resolve()
|
||||
if not file_path.is_file():
|
||||
logger.warning("[joplin] Media file missing, leaving link: %s", rel_path)
|
||||
return match.group(0)
|
||||
resource_id = client.create_resource(file_path)
|
||||
new_map[rel_path] = resource_id
|
||||
return f"{label}(:/{resource_id})"
|
||||
|
||||
return _MEDIA_LINK_RE.sub(replace, body), new_map
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Notebook naming helper
|
||||
# ------------------------------------------------------------------
|
||||
@@ -312,6 +395,9 @@ def _http_error_message(method: str, path: str, e: requests.exceptions.HTTPError
|
||||
_PROVIDER_DISPLAY = {
|
||||
"chatgpt": "AI-ChatGPT",
|
||||
"claude": "AI-Claude",
|
||||
# Decision 2026-06-12: Claude Code coding projects nest under the same
|
||||
# AI-Claude parent, alongside Claude web projects.
|
||||
"claude-code": "AI-Claude",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user