feat: v0.5.0 — nested Joplin notebooks, date-prefixed note titles, flat year folders
Joplin notebooks now use a two-level hierarchy: AI-ChatGPT / <project> and AI-Claude / <project> instead of a single flat title. Note titles are prefixed with the conversation created_at date (YYYY-MM-DD). Export folders collapse provider/project/year into a single provider/project.year directory. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
68e8d532be
commit
e9b2e42893
+53
-26
@@ -32,8 +32,8 @@ class JoplinClient:
|
||||
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] = {}
|
||||
# In-memory cache: (parent_id | None, title) → folder ID
|
||||
self._notebook_cache: dict[tuple[str | None, str], str] = {}
|
||||
self._notebooks_loaded = False
|
||||
logger.debug("[joplin] Client initialised with base_url=%s", self._base_url)
|
||||
|
||||
@@ -89,13 +89,13 @@ class JoplinClient:
|
||||
"""Return all Joplin notebooks (folders), handling pagination.
|
||||
|
||||
Returns:
|
||||
List of folder dicts with at least ``id`` and ``title`` keys.
|
||||
List of folder dicts with at least ``id``, ``title``, and ``parent_id`` 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"})
|
||||
resp = self._get("/folders", params={"page": page, "fields": "id,title,parent_id"})
|
||||
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"))
|
||||
@@ -104,11 +104,12 @@ class JoplinClient:
|
||||
page += 1
|
||||
return results
|
||||
|
||||
def get_or_create_notebook(self, title: str) -> str:
|
||||
"""Return the Joplin folder ID for ``title``, creating it if needed.
|
||||
def get_or_create_notebook(self, title: str, parent_id: str | None = None) -> str:
|
||||
"""Return the Joplin folder ID for ``title`` under ``parent_id``, creating if needed.
|
||||
|
||||
Args:
|
||||
title: Notebook display name (e.g. "ChatGPT - My Project").
|
||||
title: Notebook display name.
|
||||
parent_id: ID of the parent folder, or None for a root notebook.
|
||||
|
||||
Returns:
|
||||
Joplin folder ID string.
|
||||
@@ -116,19 +117,40 @@ class JoplinClient:
|
||||
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)
|
||||
key = (parent_id, title)
|
||||
if key in self._notebook_cache:
|
||||
folder_id = self._notebook_cache[key]
|
||||
logger.debug("[joplin] Notebook cache hit: %r (parent=%s) → %s", title, parent_id, folder_id)
|
||||
return folder_id
|
||||
|
||||
# Not found — create it
|
||||
logger.info("[joplin] Creating notebook: %r", title)
|
||||
resp = self._post("/folders", {"title": title})
|
||||
logger.info("[joplin] Creating notebook: %r (parent=%s)", title, parent_id)
|
||||
data: dict = {"title": title}
|
||||
if parent_id:
|
||||
data["parent_id"] = parent_id
|
||||
resp = self._post("/folders", data)
|
||||
folder_id = resp["id"]
|
||||
self._notebook_cache[title] = folder_id
|
||||
self._notebook_cache[key] = folder_id
|
||||
logger.debug("[joplin] Notebook created: %r → %s", title, folder_id)
|
||||
return folder_id
|
||||
|
||||
def get_or_create_notebook_path(self, path: list[str]) -> str:
|
||||
"""Ensure a nested notebook path exists and return the leaf folder ID.
|
||||
|
||||
Creates intermediate notebooks as needed.
|
||||
|
||||
Args:
|
||||
path: Ordered list of notebook names, e.g. ["AI-ChatGPT", "No Project"].
|
||||
|
||||
Returns:
|
||||
Joplin folder ID of the deepest (leaf) notebook.
|
||||
"""
|
||||
parent_id: str | None = None
|
||||
for name in path:
|
||||
parent_id = self.get_or_create_notebook(name, parent_id)
|
||||
assert parent_id is not None
|
||||
return parent_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Notes
|
||||
# ------------------------------------------------------------------
|
||||
@@ -233,11 +255,14 @@ class JoplinClient:
|
||||
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._notebook_cache = {
|
||||
(nb.get("parent_id") or None, 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)
|
||||
for (parent_id, title), folder_id in self._notebook_cache.items():
|
||||
logger.debug("[joplin] (%s) %r → %s", parent_id or "root", title, folder_id)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -285,19 +310,21 @@ def _http_error_message(method: str, path: str, e: requests.exceptions.HTTPError
|
||||
|
||||
|
||||
_PROVIDER_DISPLAY = {
|
||||
"chatgpt": "ChatGPT",
|
||||
"claude": "Claude",
|
||||
"chatgpt": "AI-ChatGPT",
|
||||
"claude": "AI-Claude",
|
||||
}
|
||||
|
||||
|
||||
def notebook_title(provider: str, project: str | None) -> str:
|
||||
"""Derive a flat Joplin notebook title from provider and project name.
|
||||
def notebook_path(provider: str, project: str | None) -> tuple[str, str]:
|
||||
"""Return (parent_notebook, child_notebook) for the given provider and project.
|
||||
|
||||
The parent is the top-level provider notebook; the child is the 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"
|
||||
notebook_path("chatgpt", None) → ("AI-ChatGPT", "No Project")
|
||||
notebook_path("chatgpt", "no-project") → ("AI-ChatGPT", "No Project")
|
||||
notebook_path("claude", "budget-tracker") → ("AI-Claude", "Budget Tracker")
|
||||
"""
|
||||
prov_display = _PROVIDER_DISPLAY.get(provider, provider.capitalize())
|
||||
proj = (project or "no-project").replace("-", " ").title()
|
||||
return f"{prov_display} - {proj}"
|
||||
parent = _PROVIDER_DISPLAY.get(provider, f"AI-{provider.capitalize()}")
|
||||
child = (project or "no-project").replace("-", " ").title()
|
||||
return parent, child
|
||||
|
||||
Reference in New Issue
Block a user