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:
JesseMarkowitz
2026-05-05 11:05:39 -04:00
parent 68e8d532be
commit e9b2e42893
6 changed files with 185 additions and 59 deletions

View File

@@ -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

View File

@@ -627,6 +627,7 @@ def export(
cache.mark_exported(prov_name, conv_id, {
"title": normalized.get("title", ""),
"project": normalized.get("project"),
"created_at": normalized.get("created_at", ""),
"updated_at": normalized.get("updated_at", ""),
"file_path": str(exported_path) if exported_path else "",
})
@@ -916,9 +917,9 @@ def joplin(ctx: click.Context, provider: str, project_filter: str | None, dry_ru
via its local REST API. Requires Joplin desktop to be running with the
Web Clipper service enabled.
Notebooks are created automatically based on provider and project:
exports/chatgpt/my-project/"ChatGPT - My Project" notebook
exports/claude/no-project/ "Claude - No Project" notebook
Notebooks are created automatically as nested folders:
chatgpt / my-project → AI-ChatGPT / My Project
claude / no-project → AI-Claude / No Project
Re-running is safe: notes are updated (not duplicated) on subsequent runs.
@@ -944,7 +945,7 @@ def joplin(ctx: click.Context, provider: str, project_filter: str | None, dry_ru
)
sys.exit(1)
from src.joplin import JoplinClient, JoplinError, notebook_title
from src.joplin import JoplinClient, JoplinError, notebook_path
client = JoplinClient(cfg.joplin_api_url, cfg.joplin_api_token)
@@ -1024,7 +1025,9 @@ def joplin(ctx: click.Context, provider: str, project_filter: str | None, dry_ru
for conv_id, entry in pending:
file_path = entry.get("file_path", "")
title = entry.get("title") or "Untitled"
raw_title = entry.get("title") or "Untitled"
created_date = (entry.get("created_at") or "")[:10]
title = f"{created_date} {raw_title}" if created_date else raw_title
project = entry.get("project") or None
existing_note_id = entry.get("joplin_note_id")
action = "update" if existing_note_id else "create"
@@ -1039,9 +1042,9 @@ def joplin(ctx: click.Context, provider: str, project_filter: str | None, dry_ru
body = Path(file_path).read_text(encoding="utf-8")
logger.debug("[joplin] Read %d chars from %s", len(body), file_path)
# Get or create the notebook
nb_title = notebook_title(prov_name, project)
notebook_id = client.get_or_create_notebook(nb_title)
# Get or create the nested notebook
nb_path = notebook_path(prov_name, project)
notebook_id = client.get_or_create_notebook_path(list(nb_path))
if existing_note_id:
client.update_note(existing_note_id, title, body)
@@ -1078,7 +1081,7 @@ def joplin(ctx: click.Context, provider: str, project_filter: str | None, dry_ru
def _print_joplin_dry_run_table(prov_name: str, pending: list[tuple[str, dict]]) -> None:
from src.joplin import notebook_title
from src.joplin import notebook_path
table = Table(title=f"[DRY RUN] {prov_name.upper()} — Would sync {len(pending)} conversation(s)")
table.add_column("Title")
@@ -1087,9 +1090,12 @@ def _print_joplin_dry_run_table(prov_name: str, pending: list[tuple[str, dict]])
table.add_column("Action")
for conv_id, entry in pending[:50]:
title = entry.get("title") or "Untitled"
raw_title = entry.get("title") or "Untitled"
created_date = (entry.get("created_at") or "")[:10]
title = f"{created_date} {raw_title}" if created_date else raw_title
project = entry.get("project") or "no-project"
nb = notebook_title(prov_name, entry.get("project"))
parent, child = notebook_path(prov_name, entry.get("project"))
nb = f"{parent} / {child}"
action = "update" if entry.get("joplin_note_id") else "create"
table.add_row(title[:50], project[:30], nb, action)

View File

@@ -50,7 +50,7 @@ def build_export_path(
created_at: ISO8601 creation timestamp (used for year folder).
filename: Already-generated filename from generate_filename().
structure: OUTPUT_STRUCTURE value. One of:
"provider/project/year" (default)
"provider/project/year" (default) — project and year combined, e.g. no-project.2025/
"provider/project"
"provider/year"
@@ -64,14 +64,14 @@ def build_export_path(
parts: list[str] = [provider]
if structure == "provider/project/year":
parts += [project_slug, year]
parts += [f"{project_slug}.{year}"]
elif structure == "provider/project":
parts += [project_slug]
elif structure == "provider/year":
parts += [year]
else:
# Unknown structure — fall back to default
parts += [project_slug, year]
parts += [f"{project_slug}.{year}"]
return base_dir.joinpath(*parts) / filename