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:
@@ -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
|
||||
|
||||
28
src/main.py
28
src/main.py
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user