qodev-apollo-api 0.1.3__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/CHANGELOG.md +18 -0
  2. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/PKG-INFO +1 -1
  3. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/pyproject.toml +1 -1
  4. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/src/qodev_apollo_api/__init__.py +1 -1
  5. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/src/qodev_apollo_api/client.py +25 -3
  6. qodev_apollo_api-0.2.1/src/qodev_apollo_api/utils.py +191 -0
  7. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/test_client.py +21 -6
  8. qodev_apollo_api-0.2.1/tests/test_utils.py +180 -0
  9. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/uv.lock +1 -1
  10. qodev_apollo_api-0.1.3/src/qodev_apollo_api/utils.py +0 -123
  11. qodev_apollo_api-0.1.3/tests/test_utils.py +0 -113
  12. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/.github/workflows/ci.yml +0 -0
  13. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/.github/workflows/publish.yml +0 -0
  14. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/.gitignore +0 -0
  15. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/.pre-commit-config.yaml +0 -0
  16. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/CLAUDE.md +0 -0
  17. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/LICENSE +0 -0
  18. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/Makefile +0 -0
  19. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/README.md +0 -0
  20. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/src/qodev_apollo_api/exceptions.py +0 -0
  21. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/src/qodev_apollo_api/models.py +0 -0
  22. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/src/qodev_apollo_api/py.typed +0 -0
  23. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/__init__.py +0 -0
  24. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/integration/__init__.py +0 -0
  25. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/integration/validate_all_models.py +0 -0
  26. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/integration/validate_email_task_flow.py +0 -0
  27. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/test_exceptions.py +0 -0
  28. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/test_models.py +0 -0
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-07-01
11
+
12
+ ### Fixed
13
+ - `normalize_linkedin_url()` normalized to `https://` and never added `www`, but Apollo stores and **exact-matches** LinkedIn URLs as `http://www.linkedin.com/in/<slug>`. As a result `find_contact_by_linkedin_url()`'s URL tier always missed (silently falling through to name search), and any `search_contacts(linkedin_url=...)` filter built from it returned zero. It now produces Apollo's `http://www` form, so URL lookups actually match.
14
+
15
+ ## [0.2.0] - 2026-06-02
16
+
17
+ ### Fixed
18
+ - `create_note()` posted `{"note": <plaintext>}`, which Apollo silently ignores — notes were created with empty content. It now serialises `content` to ProseMirror JSON and posts it in the `content` field (the format Apollo stores and `search_notes()` reads back). Closes #6.
19
+
20
+ ### Added
21
+ - `markdown_to_prosemirror()` in `utils` — inverse of `prosemirror_to_markdown()` (title, paragraphs, bullet/ordered lists).
22
+ - `create_note(..., title=...)` — optional note title (rendered as the ProseMirror `noteTitle`).
23
+ - `ApolloClient.delete_note(note_id)` — `DELETE /notes/{id}`.
24
+
25
+ ### Changed
26
+ - `create_note()` association args (`contact_ids`, `account_ids`, `opportunity_ids`) are now keyword-only, so the new positional `title` can't be confused with them.
27
+
10
28
  ## [0.1.3] - 2026-02-23
11
29
 
12
30
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qodev-apollo-api
3
- Version: 0.1.3
3
+ Version: 0.2.1
4
4
  Summary: Async Python client for Apollo.io CRM API
5
5
  Project-URL: Homepage, https://github.com/qodevai/apollo-api
6
6
  Project-URL: Repository, https://github.com/qodevai/apollo-api
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qodev-apollo-api"
3
- version = "0.1.3"
3
+ version = "0.2.1"
4
4
  description = "Async Python client for Apollo.io CRM API"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -151,4 +151,4 @@ __all__ = [
151
151
  "resolve_task",
152
152
  ]
153
153
 
154
- __version__ = "0.1.3"
154
+ __version__ = "0.2.0"
@@ -38,7 +38,7 @@ from .models import (
38
38
  TaskType,
39
39
  resolve_task,
40
40
  )
41
- from .utils import normalize_linkedin_url, prosemirror_to_markdown
41
+ from .utils import markdown_to_prosemirror, normalize_linkedin_url, prosemirror_to_markdown
42
42
 
43
43
 
44
44
  class ApolloClient:
@@ -597,14 +597,23 @@ class ApolloClient:
597
597
  async def create_note(
598
598
  self,
599
599
  content: str,
600
+ title: str | None = None,
601
+ *,
600
602
  contact_ids: list[str] | None = None,
601
603
  account_ids: list[str] | None = None,
602
604
  opportunity_ids: list[str] | None = None,
603
605
  ) -> dict:
604
606
  """Create a note.
605
607
 
608
+ Apollo stores note bodies in the ``content`` field as ProseMirror JSON,
609
+ so the plain text / Markdown ``content`` is converted via
610
+ :func:`~qodev_apollo_api.utils.markdown_to_prosemirror` before posting.
611
+ (Posting ``{"note": <plaintext>}`` is silently ignored by Apollo and
612
+ produces an empty note.)
613
+
606
614
  Args:
607
- content: Note content (plain text or Markdown)
615
+ content: Note body as plain text / Markdown.
616
+ title: Optional note title (rendered as the ProseMirror noteTitle).
608
617
  contact_ids: List of contact IDs to associate
609
618
  account_ids: List of account IDs to associate
610
619
  opportunity_ids: List of opportunity IDs to associate
@@ -612,7 +621,9 @@ class ApolloClient:
612
621
  Returns:
613
622
  Raw API response with created note data
614
623
  """
615
- data: dict[str, str | list[str]] = {"note": content}
624
+ data: dict[str, str | list[str]] = {
625
+ "content": markdown_to_prosemirror(content, title=title)
626
+ }
616
627
  if contact_ids:
617
628
  data["contact_ids"] = contact_ids
618
629
  if account_ids:
@@ -622,6 +633,17 @@ class ApolloClient:
622
633
 
623
634
  return await self._post("/notes", data)
624
635
 
636
+ async def delete_note(self, note_id: str) -> dict:
637
+ """Delete a note by ID.
638
+
639
+ Args:
640
+ note_id: Apollo note ID.
641
+
642
+ Returns:
643
+ Raw API response.
644
+ """
645
+ return await self._request("DELETE", f"/notes/{note_id}")
646
+
625
647
  # ========================================================================
626
648
  # ACTIVITIES
627
649
  # ========================================================================
@@ -0,0 +1,191 @@
1
+ """Utility functions for Apollo client.
2
+
3
+ Includes ProseMirror JSON <-> Markdown conversion for notes.
4
+ """
5
+
6
+ import json
7
+ import re
8
+
9
+ _ORDERED_ITEM_RE = re.compile(r"^\d+\.\s+(.*)$")
10
+
11
+
12
+ def prosemirror_to_markdown(content_json: str) -> tuple[str, str]:
13
+ """Convert ProseMirror JSON content to Markdown.
14
+
15
+ Args:
16
+ content_json: ProseMirror JSON string from Apollo notes
17
+
18
+ Returns:
19
+ Tuple of (title, markdown_content)
20
+ """
21
+ if not content_json:
22
+ return "Untitled", ""
23
+
24
+ try:
25
+ doc = json.loads(content_json)
26
+ except json.JSONDecodeError:
27
+ return "Untitled", content_json # Return raw if not valid JSON
28
+
29
+ if not isinstance(doc, dict) or doc.get("type") != "doc":
30
+ return "Untitled", content_json
31
+
32
+ content = doc.get("content", [])
33
+ if not content:
34
+ return "Untitled", ""
35
+
36
+ title = "Untitled"
37
+ lines = []
38
+
39
+ for node in content:
40
+ node_type = node.get("type")
41
+
42
+ if node_type == "noteTitle":
43
+ # Extract title
44
+ title = _extract_text(node)
45
+
46
+ elif node_type == "paragraph":
47
+ # Extract paragraph text
48
+ text = _extract_text(node)
49
+ if text:
50
+ lines.append(text)
51
+
52
+ elif node_type == "bulletList":
53
+ # Extract bullet list
54
+ for item in node.get("content", []):
55
+ if item.get("type") == "listItem":
56
+ item_text = _extract_text_from_list_item(item)
57
+ if item_text:
58
+ lines.append(f"- {item_text}")
59
+
60
+ elif node_type == "orderedList":
61
+ # Extract ordered list
62
+ for idx, item in enumerate(node.get("content", []), 1):
63
+ if item.get("type") == "listItem":
64
+ item_text = _extract_text_from_list_item(item)
65
+ if item_text:
66
+ lines.append(f"{idx}. {item_text}")
67
+
68
+ markdown = "\n\n".join(lines)
69
+ return title, markdown
70
+
71
+
72
+ def _extract_text(node: dict) -> str:
73
+ """Extract plain text from a ProseMirror node."""
74
+ content = node.get("content", [])
75
+ if not content:
76
+ return ""
77
+
78
+ text_parts = []
79
+ for item in content:
80
+ if item.get("type") == "text":
81
+ text_parts.append(item.get("text", ""))
82
+ elif item.get("type") == "hardBreak":
83
+ text_parts.append("\n")
84
+ else:
85
+ # Recursively extract from nested content
86
+ nested_text = _extract_text(item)
87
+ if nested_text:
88
+ text_parts.append(nested_text)
89
+
90
+ return "".join(text_parts)
91
+
92
+
93
+ def _extract_text_from_list_item(item: dict) -> str:
94
+ """Extract text from a list item, which may contain paragraphs."""
95
+ content = item.get("content", [])
96
+ if not content:
97
+ return ""
98
+
99
+ text_parts = []
100
+ for node in content:
101
+ if node.get("type") == "paragraph":
102
+ text = _extract_text(node)
103
+ if text:
104
+ text_parts.append(text)
105
+
106
+ return " ".join(text_parts)
107
+
108
+
109
+ def markdown_to_prosemirror(content: str, title: str | None = None) -> str:
110
+ """Convert plain text / lightweight Markdown to a ProseMirror JSON string.
111
+
112
+ Apollo's ``POST /notes`` stores the note body in the ``content`` field as a
113
+ ProseMirror JSON string; this is the inverse of :func:`prosemirror_to_markdown`
114
+ for the node types Apollo notes use.
115
+
116
+ Supports:
117
+ * an optional note title (``noteTitle`` node)
118
+ * paragraphs (one per line; blank lines become empty paragraphs)
119
+ * bullet lists (lines starting with ``- `` or ``* ``)
120
+ * ordered lists (lines starting with ``1. ``)
121
+
122
+ Args:
123
+ content: Note body as plain text / Markdown.
124
+ title: Optional note title (rendered as the ProseMirror ``noteTitle``).
125
+
126
+ Returns:
127
+ ProseMirror document as a JSON string, ready to post to ``content``.
128
+ """
129
+ nodes: list[dict] = []
130
+ if title:
131
+ nodes.append({"type": "noteTitle", "content": [{"type": "text", "text": title}]})
132
+
133
+ def _paragraph(text: str) -> dict:
134
+ # Empty text nodes are invalid in ProseMirror — emit a bare paragraph.
135
+ if not text:
136
+ return {"type": "paragraph"}
137
+ return {"type": "paragraph", "content": [{"type": "text", "text": text}]}
138
+
139
+ def _list_item(text: str) -> dict:
140
+ return {"type": "listItem", "content": [_paragraph(text)]}
141
+
142
+ lines = (content or "").split("\n")
143
+ i = 0
144
+ while i < len(lines):
145
+ stripped = lines[i].strip()
146
+
147
+ if stripped[:2] in ("- ", "* "):
148
+ items = []
149
+ while i < len(lines) and lines[i].strip()[:2] in ("- ", "* "):
150
+ items.append(_list_item(lines[i].strip()[2:].strip()))
151
+ i += 1
152
+ nodes.append({"type": "bulletList", "content": items})
153
+ continue
154
+
155
+ if _ORDERED_ITEM_RE.match(stripped):
156
+ items = []
157
+ while i < len(lines) and (m := _ORDERED_ITEM_RE.match(lines[i].strip())):
158
+ items.append(_list_item(m.group(1).strip()))
159
+ i += 1
160
+ nodes.append({"type": "orderedList", "content": items})
161
+ continue
162
+
163
+ nodes.append(_paragraph(lines[i]))
164
+ i += 1
165
+
166
+ return json.dumps({"type": "doc", "content": nodes}, ensure_ascii=False)
167
+
168
+
169
+ def normalize_linkedin_url(url: str) -> str:
170
+ """Normalize a LinkedIn URL to Apollo's stored, exact-match form.
171
+
172
+ Apollo stores and exact-matches LinkedIn URLs as ``http://www.linkedin.com/in/<slug>``
173
+ — **http** scheme (not https), ``www`` host, no trailing slash, lowercase. Producing
174
+ that exact form is what lets a ``linkedin_url`` search actually match; a ``https://``
175
+ or ``www``-less form silently returns zero results. It also doubles as a stable key for
176
+ comparing two URLs.
177
+
178
+ Args:
179
+ url: LinkedIn profile URL (any common shape).
180
+
181
+ Returns:
182
+ The URL as ``http://www.linkedin.com/...`` (or ``""`` for a falsy input).
183
+ """
184
+ if not url:
185
+ return ""
186
+ url = url.lower().strip().rstrip("/")
187
+ # Rebuild to Apollo's stored form: strip the scheme, force the www host, force http.
188
+ url = re.sub(r"^https?://", "", url)
189
+ if url.startswith("linkedin.com/"):
190
+ url = "www." + url
191
+ return f"http://{url}"
@@ -1,5 +1,6 @@
1
1
  """Tests for Apollo client methods."""
2
2
 
3
+ import json
3
4
  import os
4
5
  from datetime import UTC, datetime
5
6
  from unittest.mock import AsyncMock, MagicMock, patch
@@ -1109,18 +1110,23 @@ async def test_search_people(client: ApolloClient):
1109
1110
 
1110
1111
 
1111
1112
  async def test_create_note(client: ApolloClient):
1112
- """Test POST /notes."""
1113
- client._client.request.return_value = _make_response({"note": {"id": "n1", "content": "Hello"}})
1113
+ """create_note posts ProseMirror JSON in `content` (not plaintext in `note`)."""
1114
+ client._client.request.return_value = _make_response({"note": {"id": "n1"}})
1114
1115
 
1115
- result = await client.create_note("Hello")
1116
+ result = await client.create_note("Hello", title="Greeting")
1116
1117
 
1117
- assert result == {"note": {"id": "n1", "content": "Hello"}}
1118
+ assert result == {"note": {"id": "n1"}}
1118
1119
 
1119
1120
  call_args = client._client.request.call_args
1120
1121
  assert call_args[0] == ("POST", "/notes")
1121
1122
  payload = call_args[1]["json"]
1122
- assert payload["note"] == "Hello"
1123
+ assert "note" not in payload # old (ignored) field must be gone
1123
1124
  assert "contact_ids" not in payload
1125
+ doc = json.loads(payload["content"])
1126
+ assert doc["type"] == "doc"
1127
+ assert doc["content"][0]["type"] == "noteTitle"
1128
+ assert doc["content"][0]["content"][0]["text"] == "Greeting"
1129
+ assert doc["content"][1]["content"][0]["text"] == "Hello"
1124
1130
 
1125
1131
 
1126
1132
  async def test_create_note_with_associations(client: ApolloClient):
@@ -1135,12 +1141,21 @@ async def test_create_note_with_associations(client: ApolloClient):
1135
1141
  )
1136
1142
 
1137
1143
  payload = client._client.request.call_args[1]["json"]
1138
- assert payload["note"] == "Meeting notes"
1144
+ assert "Meeting notes" in payload["content"] # body serialised into ProseMirror
1139
1145
  assert payload["contact_ids"] == ["c1", "c2"]
1140
1146
  assert payload["account_ids"] == ["a1"]
1141
1147
  assert payload["opportunity_ids"] == ["o1"]
1142
1148
 
1143
1149
 
1150
+ async def test_delete_note(client: ApolloClient):
1151
+ """delete_note issues DELETE /notes/{id}."""
1152
+ client._client.request.return_value = _make_response({})
1153
+
1154
+ await client.delete_note("n1")
1155
+
1156
+ client._client.request.assert_called_once_with("DELETE", "/notes/n1")
1157
+
1158
+
1144
1159
  async def test_get_api_usage(client: ApolloClient):
1145
1160
  """Test POST /usage_stats/api_usage_stats."""
1146
1161
  usage_data = {
@@ -0,0 +1,180 @@
1
+ """Tests for Apollo utility functions."""
2
+
3
+ import json
4
+
5
+ from qodev_apollo_api.utils import (
6
+ markdown_to_prosemirror,
7
+ normalize_linkedin_url,
8
+ prosemirror_to_markdown,
9
+ )
10
+
11
+
12
+ def test_prosemirror_to_markdown_simple():
13
+ """Test basic ProseMirror to Markdown conversion."""
14
+ prosemirror_json = json.dumps(
15
+ {
16
+ "type": "doc",
17
+ "content": [
18
+ {"type": "noteTitle", "content": [{"type": "text", "text": "Test Title"}]},
19
+ {"type": "paragraph", "content": [{"type": "text", "text": "Test paragraph"}]},
20
+ ],
21
+ }
22
+ )
23
+
24
+ title, markdown = prosemirror_to_markdown(prosemirror_json)
25
+ assert title == "Test Title"
26
+ assert markdown == "Test paragraph"
27
+
28
+
29
+ def test_prosemirror_to_markdown_with_lists():
30
+ """Test ProseMirror conversion with bullet lists."""
31
+ prosemirror_json = json.dumps(
32
+ {
33
+ "type": "doc",
34
+ "content": [
35
+ {"type": "noteTitle", "content": [{"type": "text", "text": "Notes"}]},
36
+ {
37
+ "type": "bulletList",
38
+ "content": [
39
+ {
40
+ "type": "listItem",
41
+ "content": [
42
+ {
43
+ "type": "paragraph",
44
+ "content": [{"type": "text", "text": "First item"}],
45
+ }
46
+ ],
47
+ },
48
+ {
49
+ "type": "listItem",
50
+ "content": [
51
+ {
52
+ "type": "paragraph",
53
+ "content": [{"type": "text", "text": "Second item"}],
54
+ }
55
+ ],
56
+ },
57
+ ],
58
+ },
59
+ ],
60
+ }
61
+ )
62
+
63
+ title, markdown = prosemirror_to_markdown(prosemirror_json)
64
+ assert title == "Notes"
65
+ assert "- First item" in markdown
66
+ assert "- Second item" in markdown
67
+
68
+
69
+ def test_prosemirror_invalid_json():
70
+ """Test handling of invalid JSON."""
71
+ title, markdown = prosemirror_to_markdown("not valid json")
72
+ assert title == "Untitled"
73
+ assert markdown == "not valid json"
74
+
75
+
76
+ def test_prosemirror_empty_doc():
77
+ """Test handling of empty document."""
78
+ prosemirror_json = json.dumps({"type": "doc", "content": []})
79
+ title, markdown = prosemirror_to_markdown(prosemirror_json)
80
+ assert title == "Untitled"
81
+ assert markdown == ""
82
+
83
+
84
+ def test_normalize_linkedin_url():
85
+ """Normalizes to Apollo's stored, exact-match form: http://www.linkedin.com/..."""
86
+ # https is rewritten to Apollo's http, and www is added
87
+ assert (
88
+ normalize_linkedin_url("https://linkedin.com/in/johndoe")
89
+ == "http://www.linkedin.com/in/johndoe"
90
+ )
91
+
92
+ # Lowercase conversion
93
+ assert (
94
+ normalize_linkedin_url("HTTPS://LinkedIn.com/in/JohnDoe")
95
+ == "http://www.linkedin.com/in/johndoe"
96
+ )
97
+
98
+ # Trailing slash removal
99
+ assert (
100
+ normalize_linkedin_url("https://linkedin.com/in/johndoe/")
101
+ == "http://www.linkedin.com/in/johndoe"
102
+ )
103
+
104
+ # Protocol addition
105
+ assert normalize_linkedin_url("linkedin.com/in/johndoe") == "http://www.linkedin.com/in/johndoe"
106
+
107
+ # An already-www URL keeps a single www (no www.www)
108
+ assert (
109
+ normalize_linkedin_url("https://www.linkedin.com/in/johndoe")
110
+ == "http://www.linkedin.com/in/johndoe"
111
+ )
112
+
113
+ # Whitespace handling
114
+ assert (
115
+ normalize_linkedin_url(" https://linkedin.com/in/johndoe ")
116
+ == "http://www.linkedin.com/in/johndoe"
117
+ )
118
+
119
+
120
+ def test_normalize_linkedin_url_empty():
121
+ """Test normalization of empty URL."""
122
+ assert normalize_linkedin_url("") == ""
123
+ assert normalize_linkedin_url(None) == ""
124
+
125
+
126
+ def test_markdown_to_prosemirror_simple():
127
+ """Title + body become a noteTitle node and a paragraph node."""
128
+ doc = json.loads(markdown_to_prosemirror("Hello world", title="My Title"))
129
+ assert doc["type"] == "doc"
130
+ assert doc["content"][0] == {
131
+ "type": "noteTitle",
132
+ "content": [{"type": "text", "text": "My Title"}],
133
+ }
134
+ assert doc["content"][1] == {
135
+ "type": "paragraph",
136
+ "content": [{"type": "text", "text": "Hello world"}],
137
+ }
138
+
139
+
140
+ def test_markdown_to_prosemirror_blank_line_is_empty_paragraph():
141
+ """Blank lines become bare paragraphs (empty text nodes are invalid)."""
142
+ doc = json.loads(markdown_to_prosemirror("a\n\nb"))
143
+ assert doc["content"] == [
144
+ {"type": "paragraph", "content": [{"type": "text", "text": "a"}]},
145
+ {"type": "paragraph"},
146
+ {"type": "paragraph", "content": [{"type": "text", "text": "b"}]},
147
+ ]
148
+
149
+
150
+ def test_markdown_to_prosemirror_no_title():
151
+ """Without a title there is no noteTitle node."""
152
+ doc = json.loads(markdown_to_prosemirror("body only"))
153
+ assert all(n["type"] != "noteTitle" for n in doc["content"])
154
+
155
+
156
+ def test_markdown_to_prosemirror_lists():
157
+ """Bullet and ordered list lines become bulletList / orderedList nodes."""
158
+ doc = json.loads(markdown_to_prosemirror("- one\n- two\n1. first\n2. second"))
159
+ types = [n["type"] for n in doc["content"]]
160
+ assert types == ["bulletList", "orderedList"]
161
+ assert len(doc["content"][0]["content"]) == 2 # two bullet items
162
+ assert doc["content"][0]["content"][0]["content"][0]["content"][0]["text"] == "one"
163
+ assert doc["content"][1]["content"][1]["content"][0]["content"][0]["text"] == "second"
164
+
165
+
166
+ def test_markdown_prosemirror_roundtrip():
167
+ """markdown_to_prosemirror output is readable back by prosemirror_to_markdown."""
168
+ pm = markdown_to_prosemirror("First para\n\nSecond para", title="Round Trip")
169
+ title, markdown = prosemirror_to_markdown(pm)
170
+ assert title == "Round Trip"
171
+ assert markdown == "First para\n\nSecond para"
172
+
173
+
174
+ def test_markdown_to_prosemirror_empty():
175
+ """Empty content yields a valid empty doc."""
176
+ doc = json.loads(markdown_to_prosemirror(""))
177
+ assert doc["type"] == "doc"
178
+ # No invalid empty text nodes anywhere.
179
+ for node in doc["content"]:
180
+ assert node.get("content") != [{"type": "text", "text": ""}]
@@ -407,7 +407,7 @@ wheels = [
407
407
 
408
408
  [[package]]
409
409
  name = "qodev-apollo-api"
410
- version = "0.1.1"
410
+ version = "0.2.1"
411
411
  source = { editable = "." }
412
412
  dependencies = [
413
413
  { name = "httpx" },
@@ -1,123 +0,0 @@
1
- """Utility functions for Apollo client.
2
-
3
- Includes ProseMirror JSON to Markdown conversion for notes.
4
- """
5
-
6
- import json
7
-
8
-
9
- def prosemirror_to_markdown(content_json: str) -> tuple[str, str]:
10
- """Convert ProseMirror JSON content to Markdown.
11
-
12
- Args:
13
- content_json: ProseMirror JSON string from Apollo notes
14
-
15
- Returns:
16
- Tuple of (title, markdown_content)
17
- """
18
- if not content_json:
19
- return "Untitled", ""
20
-
21
- try:
22
- doc = json.loads(content_json)
23
- except json.JSONDecodeError:
24
- return "Untitled", content_json # Return raw if not valid JSON
25
-
26
- if not isinstance(doc, dict) or doc.get("type") != "doc":
27
- return "Untitled", content_json
28
-
29
- content = doc.get("content", [])
30
- if not content:
31
- return "Untitled", ""
32
-
33
- title = "Untitled"
34
- lines = []
35
-
36
- for node in content:
37
- node_type = node.get("type")
38
-
39
- if node_type == "noteTitle":
40
- # Extract title
41
- title = _extract_text(node)
42
-
43
- elif node_type == "paragraph":
44
- # Extract paragraph text
45
- text = _extract_text(node)
46
- if text:
47
- lines.append(text)
48
-
49
- elif node_type == "bulletList":
50
- # Extract bullet list
51
- for item in node.get("content", []):
52
- if item.get("type") == "listItem":
53
- item_text = _extract_text_from_list_item(item)
54
- if item_text:
55
- lines.append(f"- {item_text}")
56
-
57
- elif node_type == "orderedList":
58
- # Extract ordered list
59
- for idx, item in enumerate(node.get("content", []), 1):
60
- if item.get("type") == "listItem":
61
- item_text = _extract_text_from_list_item(item)
62
- if item_text:
63
- lines.append(f"{idx}. {item_text}")
64
-
65
- markdown = "\n\n".join(lines)
66
- return title, markdown
67
-
68
-
69
- def _extract_text(node: dict) -> str:
70
- """Extract plain text from a ProseMirror node."""
71
- content = node.get("content", [])
72
- if not content:
73
- return ""
74
-
75
- text_parts = []
76
- for item in content:
77
- if item.get("type") == "text":
78
- text_parts.append(item.get("text", ""))
79
- elif item.get("type") == "hardBreak":
80
- text_parts.append("\n")
81
- else:
82
- # Recursively extract from nested content
83
- nested_text = _extract_text(item)
84
- if nested_text:
85
- text_parts.append(nested_text)
86
-
87
- return "".join(text_parts)
88
-
89
-
90
- def _extract_text_from_list_item(item: dict) -> str:
91
- """Extract text from a list item, which may contain paragraphs."""
92
- content = item.get("content", [])
93
- if not content:
94
- return ""
95
-
96
- text_parts = []
97
- for node in content:
98
- if node.get("type") == "paragraph":
99
- text = _extract_text(node)
100
- if text:
101
- text_parts.append(text)
102
-
103
- return " ".join(text_parts)
104
-
105
-
106
- def normalize_linkedin_url(url: str) -> str:
107
- """Normalize LinkedIn URL for comparison.
108
-
109
- Args:
110
- url: LinkedIn profile URL
111
-
112
- Returns:
113
- Normalized URL (lowercase, stripped, trailing slash removed)
114
- """
115
- if not url:
116
- return ""
117
- url = url.lower().strip().rstrip("/")
118
- # Normalize scheme to https://
119
- if url.startswith("http://"):
120
- url = "https://" + url[7:]
121
- elif not url.startswith("https://"):
122
- url = f"https://{url}"
123
- return url
@@ -1,113 +0,0 @@
1
- """Tests for Apollo utility functions."""
2
-
3
- import json
4
-
5
- from qodev_apollo_api.utils import normalize_linkedin_url, prosemirror_to_markdown
6
-
7
-
8
- def test_prosemirror_to_markdown_simple():
9
- """Test basic ProseMirror to Markdown conversion."""
10
- prosemirror_json = json.dumps(
11
- {
12
- "type": "doc",
13
- "content": [
14
- {"type": "noteTitle", "content": [{"type": "text", "text": "Test Title"}]},
15
- {"type": "paragraph", "content": [{"type": "text", "text": "Test paragraph"}]},
16
- ],
17
- }
18
- )
19
-
20
- title, markdown = prosemirror_to_markdown(prosemirror_json)
21
- assert title == "Test Title"
22
- assert markdown == "Test paragraph"
23
-
24
-
25
- def test_prosemirror_to_markdown_with_lists():
26
- """Test ProseMirror conversion with bullet lists."""
27
- prosemirror_json = json.dumps(
28
- {
29
- "type": "doc",
30
- "content": [
31
- {"type": "noteTitle", "content": [{"type": "text", "text": "Notes"}]},
32
- {
33
- "type": "bulletList",
34
- "content": [
35
- {
36
- "type": "listItem",
37
- "content": [
38
- {
39
- "type": "paragraph",
40
- "content": [{"type": "text", "text": "First item"}],
41
- }
42
- ],
43
- },
44
- {
45
- "type": "listItem",
46
- "content": [
47
- {
48
- "type": "paragraph",
49
- "content": [{"type": "text", "text": "Second item"}],
50
- }
51
- ],
52
- },
53
- ],
54
- },
55
- ],
56
- }
57
- )
58
-
59
- title, markdown = prosemirror_to_markdown(prosemirror_json)
60
- assert title == "Notes"
61
- assert "- First item" in markdown
62
- assert "- Second item" in markdown
63
-
64
-
65
- def test_prosemirror_invalid_json():
66
- """Test handling of invalid JSON."""
67
- title, markdown = prosemirror_to_markdown("not valid json")
68
- assert title == "Untitled"
69
- assert markdown == "not valid json"
70
-
71
-
72
- def test_prosemirror_empty_doc():
73
- """Test handling of empty document."""
74
- prosemirror_json = json.dumps({"type": "doc", "content": []})
75
- title, markdown = prosemirror_to_markdown(prosemirror_json)
76
- assert title == "Untitled"
77
- assert markdown == ""
78
-
79
-
80
- def test_normalize_linkedin_url():
81
- """Test LinkedIn URL normalization."""
82
- # Test basic normalization
83
- assert (
84
- normalize_linkedin_url("https://linkedin.com/in/johndoe")
85
- == "https://linkedin.com/in/johndoe"
86
- )
87
-
88
- # Test lowercase conversion
89
- assert (
90
- normalize_linkedin_url("HTTPS://LinkedIn.com/in/JohnDoe")
91
- == "https://linkedin.com/in/johndoe"
92
- )
93
-
94
- # Test trailing slash removal
95
- assert (
96
- normalize_linkedin_url("https://linkedin.com/in/johndoe/")
97
- == "https://linkedin.com/in/johndoe"
98
- )
99
-
100
- # Test protocol addition
101
- assert normalize_linkedin_url("linkedin.com/in/johndoe") == "https://linkedin.com/in/johndoe"
102
-
103
- # Test whitespace handling
104
- assert (
105
- normalize_linkedin_url(" https://linkedin.com/in/johndoe ")
106
- == "https://linkedin.com/in/johndoe"
107
- )
108
-
109
-
110
- def test_normalize_linkedin_url_empty():
111
- """Test normalization of empty URL."""
112
- assert normalize_linkedin_url("") == ""
113
- assert normalize_linkedin_url(None) == ""