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: def __init__(self, base_url: str, token: str) -> None:
self._base_url = base_url.rstrip("/") self._base_url = base_url.rstrip("/")
self._token = token self._token = token
# In-memory cache of notebook title → ID to avoid repeated GET /folders # In-memory cache: (parent_id | None, title) → folder ID
self._notebook_cache: dict[str, str] = {} self._notebook_cache: dict[tuple[str | None, str], str] = {}
self._notebooks_loaded = False self._notebooks_loaded = False
logger.debug("[joplin] Client initialised with base_url=%s", self._base_url) 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. """Return all Joplin notebooks (folders), handling pagination.
Returns: 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] = [] results: list[dict] = []
page = 1 page = 1
while True: while True:
logger.debug("[joplin] GET /folders page=%d", page) 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", []) items = resp.get("items", [])
results.extend(items) results.extend(items)
logger.debug("[joplin] /folders page=%d%d items, has_more=%s", page, len(items), resp.get("has_more")) 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 page += 1
return results return results
def get_or_create_notebook(self, title: str) -> str: def get_or_create_notebook(self, title: str, parent_id: str | None = None) -> str:
"""Return the Joplin folder ID for ``title``, creating it if needed. """Return the Joplin folder ID for ``title`` under ``parent_id``, creating if needed.
Args: 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: Returns:
Joplin folder ID string. Joplin folder ID string.
@@ -116,19 +117,40 @@ class JoplinClient:
if not self._notebooks_loaded: if not self._notebooks_loaded:
self._load_notebook_cache() self._load_notebook_cache()
if title in self._notebook_cache: key = (parent_id, title)
folder_id = self._notebook_cache[title] if key in self._notebook_cache:
logger.debug("[joplin] Notebook cache hit: %r%s", title, folder_id) folder_id = self._notebook_cache[key]
logger.debug("[joplin] Notebook cache hit: %r (parent=%s) → %s", title, parent_id, folder_id)
return folder_id return folder_id
# Not found — create it # Not found — create it
logger.info("[joplin] Creating notebook: %r", title) logger.info("[joplin] Creating notebook: %r (parent=%s)", title, parent_id)
resp = self._post("/folders", {"title": title}) data: dict = {"title": title}
if parent_id:
data["parent_id"] = parent_id
resp = self._post("/folders", data)
folder_id = resp["id"] 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) logger.debug("[joplin] Notebook created: %r%s", title, folder_id)
return 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 # Notes
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -233,11 +255,14 @@ class JoplinClient:
def _load_notebook_cache(self) -> None: def _load_notebook_cache(self) -> None:
logger.debug("[joplin] Loading notebook list from Joplin…") logger.debug("[joplin] Loading notebook list from Joplin…")
notebooks = self.list_notebooks() 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 self._notebooks_loaded = True
logger.debug("[joplin] Notebook cache loaded: %d notebooks", len(self._notebook_cache)) logger.debug("[joplin] Notebook cache loaded: %d notebooks", len(self._notebook_cache))
for title, folder_id in self._notebook_cache.items(): for (parent_id, title), folder_id in self._notebook_cache.items():
logger.debug("[joplin] %r%s", title, folder_id) 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 = { _PROVIDER_DISPLAY = {
"chatgpt": "ChatGPT", "chatgpt": "AI-ChatGPT",
"claude": "Claude", "claude": "AI-Claude",
} }
def notebook_title(provider: str, project: str | None) -> str: def notebook_path(provider: str, project: str | None) -> tuple[str, str]:
"""Derive a flat Joplin notebook title from provider and project name. """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: Examples:
notebook_title("chatgpt", "no-project")"ChatGPT - No Project" notebook_path("chatgpt", None) ("AI-ChatGPT", "No Project")
notebook_title("claude", "budget-tracker") → "Claude - Budget Tracker" notebook_path("chatgpt", "no-project") ("AI-ChatGPT", "No Project")
notebook_title("chatgpt", None)"ChatGPT - No Project" notebook_path("claude", "budget-tracker") ("AI-Claude", "Budget Tracker")
""" """
prov_display = _PROVIDER_DISPLAY.get(provider, provider.capitalize()) parent = _PROVIDER_DISPLAY.get(provider, f"AI-{provider.capitalize()}")
proj = (project or "no-project").replace("-", " ").title() child = (project or "no-project").replace("-", " ").title()
return f"{prov_display} - {proj}" return parent, child

View File

@@ -627,6 +627,7 @@ def export(
cache.mark_exported(prov_name, conv_id, { cache.mark_exported(prov_name, conv_id, {
"title": normalized.get("title", ""), "title": normalized.get("title", ""),
"project": normalized.get("project"), "project": normalized.get("project"),
"created_at": normalized.get("created_at", ""),
"updated_at": normalized.get("updated_at", ""), "updated_at": normalized.get("updated_at", ""),
"file_path": str(exported_path) if exported_path else "", "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 via its local REST API. Requires Joplin desktop to be running with the
Web Clipper service enabled. Web Clipper service enabled.
Notebooks are created automatically based on provider and project: Notebooks are created automatically as nested folders:
exports/chatgpt/my-project/"ChatGPT - My Project" notebook chatgpt / my-project → AI-ChatGPT / My Project
exports/claude/no-project/ "Claude - No Project" notebook claude / no-project → AI-Claude / No Project
Re-running is safe: notes are updated (not duplicated) on subsequent runs. 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) 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) 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: for conv_id, entry in pending:
file_path = entry.get("file_path", "") 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 project = entry.get("project") or None
existing_note_id = entry.get("joplin_note_id") existing_note_id = entry.get("joplin_note_id")
action = "update" if existing_note_id else "create" 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") body = Path(file_path).read_text(encoding="utf-8")
logger.debug("[joplin] Read %d chars from %s", len(body), file_path) logger.debug("[joplin] Read %d chars from %s", len(body), file_path)
# Get or create the notebook # Get or create the nested notebook
nb_title = notebook_title(prov_name, project) nb_path = notebook_path(prov_name, project)
notebook_id = client.get_or_create_notebook(nb_title) notebook_id = client.get_or_create_notebook_path(list(nb_path))
if existing_note_id: if existing_note_id:
client.update_note(existing_note_id, title, body) 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: 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 = Table(title=f"[DRY RUN] {prov_name.upper()} — Would sync {len(pending)} conversation(s)")
table.add_column("Title") 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") table.add_column("Action")
for conv_id, entry in pending[:50]: 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" 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" action = "update" if entry.get("joplin_note_id") else "create"
table.add_row(title[:50], project[:30], nb, action) 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). created_at: ISO8601 creation timestamp (used for year folder).
filename: Already-generated filename from generate_filename(). filename: Already-generated filename from generate_filename().
structure: OUTPUT_STRUCTURE value. One of: 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/project"
"provider/year" "provider/year"
@@ -64,14 +64,14 @@ def build_export_path(
parts: list[str] = [provider] parts: list[str] = [provider]
if structure == "provider/project/year": if structure == "provider/project/year":
parts += [project_slug, year] parts += [f"{project_slug}.{year}"]
elif structure == "provider/project": elif structure == "provider/project":
parts += [project_slug] parts += [project_slug]
elif structure == "provider/year": elif structure == "provider/year":
parts += [year] parts += [year]
else: else:
# Unknown structure — fall back to default # Unknown structure — fall back to default
parts += [project_slug, year] parts += [f"{project_slug}.{year}"]
return base_dir.joinpath(*parts) / filename return base_dir.joinpath(*parts) / filename

View File

@@ -139,7 +139,7 @@ class TestMarkdownFilenameGeneration:
def test_year_in_path(self, tmp_path): def test_year_in_path(self, tmp_path):
exp = MarkdownExporter(tmp_path) exp = MarkdownExporter(tmp_path)
path = exp.export(SAMPLE_CONV) path = exp.export(SAMPLE_CONV)
assert "/2024/" in str(path) assert ".2024/" in str(path)
def test_output_structure_provider_project(self, tmp_path): def test_output_structure_provider_project(self, tmp_path):
exp = MarkdownExporter(tmp_path, output_structure="provider/project") exp = MarkdownExporter(tmp_path, output_structure="provider/project")

View File

@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
import requests import requests
from src.joplin import JoplinClient, JoplinError, _http_error_message, _timeout_message, notebook_title from src.joplin import JoplinClient, JoplinError, _http_error_message, _timeout_message, notebook_path
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -31,25 +31,29 @@ def _mock_response(json_data=None, text="", status_code=200):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# notebook_title helper # notebook_path helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestNotebookTitle: class TestNotebookPath:
def test_no_project(self): def test_no_project(self):
assert notebook_title("chatgpt", None) == "ChatGPT - No Project" assert notebook_path("chatgpt", None) == ("AI-ChatGPT", "No Project")
def test_no_project_string(self): def test_no_project_string(self):
assert notebook_title("chatgpt", "no-project") == "ChatGPT - No Project" assert notebook_path("chatgpt", "no-project") == ("AI-ChatGPT", "No Project")
def test_project_with_hyphens(self): def test_project_with_hyphens(self):
assert notebook_title("chatgpt", "my-project") == "ChatGPT - My Project" assert notebook_path("chatgpt", "my-project") == ("AI-ChatGPT", "My Project")
def test_claude_provider(self): def test_claude_provider(self):
assert notebook_title("claude", "budget-tracker") == "Claude - Budget Tracker" assert notebook_path("claude", "budget-tracker") == ("AI-Claude", "Budget Tracker")
def test_multi_word_project(self): def test_multi_word_project(self):
assert notebook_title("claude", "ai-research-notes") == "Claude - Ai Research Notes" assert notebook_path("claude", "ai-research-notes") == ("AI-Claude", "Ai Research Notes")
def test_returns_tuple(self):
result = notebook_path("chatgpt", "some-project")
assert isinstance(result, tuple) and len(result) == 2
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -236,18 +240,30 @@ class TestListNotebooks:
class TestGetOrCreateNotebook: class TestGetOrCreateNotebook:
def test_returns_existing_notebook_id(self): def test_returns_existing_root_notebook_id(self):
client = _make_client() client = _make_client()
with patch("requests.get") as mock_get: with patch("requests.get") as mock_get:
mock_get.return_value = _mock_response( mock_get.return_value = _mock_response(
json_data={ json_data={
"items": [{"id": "nb-existing", "title": "ChatGPT - No Project"}], "items": [{"id": "nb-existing", "title": "AI-ChatGPT", "parent_id": ""}],
"has_more": False, "has_more": False,
} }
) )
nb_id = client.get_or_create_notebook("ChatGPT - No Project") nb_id = client.get_or_create_notebook("AI-ChatGPT")
assert nb_id == "nb-existing" assert nb_id == "nb-existing"
def test_returns_existing_child_notebook_id(self):
client = _make_client()
with patch("requests.get") as mock_get:
mock_get.return_value = _mock_response(
json_data={
"items": [{"id": "nb-child", "title": "No Project", "parent_id": "nb-parent"}],
"has_more": False,
}
)
nb_id = client.get_or_create_notebook("No Project", parent_id="nb-parent")
assert nb_id == "nb-child"
def test_creates_new_notebook_when_not_found(self): def test_creates_new_notebook_when_not_found(self):
client = _make_client() client = _make_client()
with patch("requests.get") as mock_get, patch("requests.post") as mock_post: with patch("requests.get") as mock_get, patch("requests.post") as mock_post:
@@ -255,26 +271,103 @@ class TestGetOrCreateNotebook:
json_data={"items": [], "has_more": False} json_data={"items": [], "has_more": False}
) )
mock_post.return_value = _mock_response( mock_post.return_value = _mock_response(
json_data={"id": "nb-new", "title": "ChatGPT - New Project"} json_data={"id": "nb-new", "title": "AI-ChatGPT"}
) )
nb_id = client.get_or_create_notebook("ChatGPT - New Project") nb_id = client.get_or_create_notebook("AI-ChatGPT")
assert nb_id == "nb-new" assert nb_id == "nb-new"
mock_post.assert_called_once() mock_post.assert_called_once()
def test_creates_child_notebook_with_parent_id(self):
client = _make_client()
with patch("requests.get") as mock_get, patch("requests.post") as mock_post:
mock_get.return_value = _mock_response(
json_data={"items": [], "has_more": False}
)
mock_post.return_value = _mock_response(
json_data={"id": "nb-child", "title": "My Project"}
)
nb_id = client.get_or_create_notebook("My Project", parent_id="nb-parent")
assert nb_id == "nb-child"
_, kwargs = mock_post.call_args
assert kwargs["json"]["parent_id"] == "nb-parent"
def test_does_not_include_parent_id_for_root(self):
client = _make_client()
with patch("requests.get") as mock_get, patch("requests.post") as mock_post:
mock_get.return_value = _mock_response(json_data={"items": [], "has_more": False})
mock_post.return_value = _mock_response(json_data={"id": "nb-root", "title": "AI-Claude"})
client.get_or_create_notebook("AI-Claude")
_, kwargs = mock_post.call_args
assert "parent_id" not in kwargs["json"]
def test_caches_notebook_after_first_load(self): def test_caches_notebook_after_first_load(self):
client = _make_client() client = _make_client()
with patch("requests.get") as mock_get: with patch("requests.get") as mock_get:
mock_get.return_value = _mock_response( mock_get.return_value = _mock_response(
json_data={ json_data={
"items": [{"id": "nb1", "title": "Claude - No Project"}], "items": [{"id": "nb1", "title": "AI-Claude", "parent_id": ""}],
"has_more": False, "has_more": False,
} }
) )
# Call twice — GET /folders should only happen once # Call twice — GET /folders should only happen once
client.get_or_create_notebook("Claude - No Project") client.get_or_create_notebook("AI-Claude")
client.get_or_create_notebook("Claude - No Project") client.get_or_create_notebook("AI-Claude")
assert mock_get.call_count == 1 assert mock_get.call_count == 1
def test_different_parent_ids_are_distinct_cache_entries(self):
"""Same title under different parents are different notebooks."""
client = _make_client()
with patch("requests.get") as mock_get:
mock_get.return_value = _mock_response(
json_data={
"items": [
{"id": "nb-a", "title": "No Project", "parent_id": "parent-chatgpt"},
{"id": "nb-b", "title": "No Project", "parent_id": "parent-claude"},
],
"has_more": False,
}
)
id_a = client.get_or_create_notebook("No Project", parent_id="parent-chatgpt")
id_b = client.get_or_create_notebook("No Project", parent_id="parent-claude")
assert id_a == "nb-a"
assert id_b == "nb-b"
class TestGetOrCreateNotebookPath:
def test_creates_two_level_path(self):
client = _make_client()
with patch("requests.get") as mock_get, patch("requests.post") as mock_post:
mock_get.return_value = _mock_response(json_data={"items": [], "has_more": False})
mock_post.side_effect = [
_mock_response(json_data={"id": "nb-parent", "title": "AI-ChatGPT"}),
_mock_response(json_data={"id": "nb-child", "title": "No Project"}),
]
leaf_id = client.get_or_create_notebook_path(["AI-ChatGPT", "No Project"])
assert leaf_id == "nb-child"
assert mock_post.call_count == 2
# Second POST should use the parent's ID
_, kwargs = mock_post.call_args_list[1]
assert kwargs["json"]["parent_id"] == "nb-parent"
def test_reuses_existing_parent_for_new_child(self):
client = _make_client()
with patch("requests.get") as mock_get, patch("requests.post") as mock_post:
mock_get.return_value = _mock_response(
json_data={
"items": [{"id": "nb-parent", "title": "AI-Claude", "parent_id": ""}],
"has_more": False,
}
)
mock_post.return_value = _mock_response(
json_data={"id": "nb-child", "title": "Budget Tracker"}
)
leaf_id = client.get_or_create_notebook_path(["AI-Claude", "Budget Tracker"])
assert leaf_id == "nb-child"
# Only one POST — the parent already existed
assert mock_post.call_count == 1
_, kwargs = mock_post.call_args
assert kwargs["json"]["parent_id"] == "nb-parent"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# create_note # create_note

View File

@@ -57,13 +57,13 @@ class TestBuildExportPath:
path = build_export_path( path = build_export_path(
Path("/exports"), "claude", "my-project", "2024-06-01T00:00:00Z", "file.md" Path("/exports"), "claude", "my-project", "2024-06-01T00:00:00Z", "file.md"
) )
assert str(path) == "/exports/claude/my-project/2024/file.md" assert str(path) == "/exports/claude/my-project.2024/file.md"
def test_no_project_uses_no_project_slug(self): def test_no_project_uses_no_project_slug(self):
path = build_export_path( path = build_export_path(
Path("/exports"), "chatgpt", None, "2024-06-01T00:00:00Z", "file.md" Path("/exports"), "chatgpt", None, "2024-06-01T00:00:00Z", "file.md"
) )
assert "no-project" in str(path) assert "no-project.2024" in str(path)
def test_provider_project_structure_omits_year(self): def test_provider_project_structure_omits_year(self):
path = build_export_path( path = build_export_path(