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
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ class TestMarkdownFilenameGeneration:
|
||||
def test_year_in_path(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path)
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
assert "/2024/" in str(path)
|
||||
assert ".2024/" in str(path)
|
||||
|
||||
def test_output_structure_provider_project(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path, output_structure="provider/project")
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
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):
|
||||
assert notebook_title("chatgpt", None) == "ChatGPT - No Project"
|
||||
assert notebook_path("chatgpt", None) == ("AI-ChatGPT", "No Project")
|
||||
|
||||
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):
|
||||
assert notebook_title("chatgpt", "my-project") == "ChatGPT - My Project"
|
||||
assert notebook_path("chatgpt", "my-project") == ("AI-ChatGPT", "My Project")
|
||||
|
||||
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):
|
||||
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:
|
||||
def test_returns_existing_notebook_id(self):
|
||||
def test_returns_existing_root_notebook_id(self):
|
||||
client = _make_client()
|
||||
with patch("requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(
|
||||
json_data={
|
||||
"items": [{"id": "nb-existing", "title": "ChatGPT - No Project"}],
|
||||
"items": [{"id": "nb-existing", "title": "AI-ChatGPT", "parent_id": ""}],
|
||||
"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"
|
||||
|
||||
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):
|
||||
client = _make_client()
|
||||
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}
|
||||
)
|
||||
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"
|
||||
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):
|
||||
client = _make_client()
|
||||
with patch("requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(
|
||||
json_data={
|
||||
"items": [{"id": "nb1", "title": "Claude - No Project"}],
|
||||
"items": [{"id": "nb1", "title": "AI-Claude", "parent_id": ""}],
|
||||
"has_more": False,
|
||||
}
|
||||
)
|
||||
# Call twice — GET /folders should only happen once
|
||||
client.get_or_create_notebook("Claude - No Project")
|
||||
client.get_or_create_notebook("Claude - No Project")
|
||||
client.get_or_create_notebook("AI-Claude")
|
||||
client.get_or_create_notebook("AI-Claude")
|
||||
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
|
||||
|
||||
@@ -57,13 +57,13 @@ class TestBuildExportPath:
|
||||
path = build_export_path(
|
||||
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):
|
||||
path = build_export_path(
|
||||
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):
|
||||
path = build_export_path(
|
||||
|
||||
Reference in New Issue
Block a user