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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user