diff --git a/src/joplin.py b/src/joplin.py index 9c844d4..25e1ac0 100644 --- a/src/joplin.py +++ b/src/joplin.py @@ -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 diff --git a/src/main.py b/src/main.py index dbc7975..db8aca4 100644 --- a/src/main.py +++ b/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) diff --git a/src/utils.py b/src/utils.py index 07dadbe..c5e9238 100644 --- a/src/utils.py +++ b/src/utils.py @@ -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 diff --git a/tests/test_exporters.py b/tests/test_exporters.py index bd67311..8708bc2 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -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") diff --git a/tests/test_joplin.py b/tests/test_joplin.py index c0284c4..c480f12 100644 --- a/tests/test_joplin.py +++ b/tests/test_joplin.py @@ -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 diff --git a/tests/test_utils.py b/tests/test_utils.py index 99bd381..a91713a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -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(