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.
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/CHANGELOG.md +18 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/PKG-INFO +1 -1
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/pyproject.toml +1 -1
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/src/qodev_apollo_api/__init__.py +1 -1
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/src/qodev_apollo_api/client.py +25 -3
- qodev_apollo_api-0.2.1/src/qodev_apollo_api/utils.py +191 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/test_client.py +21 -6
- qodev_apollo_api-0.2.1/tests/test_utils.py +180 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/uv.lock +1 -1
- qodev_apollo_api-0.1.3/src/qodev_apollo_api/utils.py +0 -123
- qodev_apollo_api-0.1.3/tests/test_utils.py +0 -113
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/.github/workflows/ci.yml +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/.github/workflows/publish.yml +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/.gitignore +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/.pre-commit-config.yaml +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/CLAUDE.md +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/LICENSE +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/Makefile +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/README.md +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/src/qodev_apollo_api/exceptions.py +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/src/qodev_apollo_api/models.py +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/src/qodev_apollo_api/py.typed +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/__init__.py +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/integration/__init__.py +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/integration/validate_all_models.py +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/integration/validate_email_task_flow.py +0 -0
- {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/test_exceptions.py +0 -0
- {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
|
|
@@ -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
|
|
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]] = {
|
|
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
|
-
"""
|
|
1113
|
-
client._client.request.return_value = _make_response({"note": {"id": "n1"
|
|
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"
|
|
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
|
|
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["
|
|
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": ""}]
|
|
@@ -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) == ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.1}/tests/integration/validate_email_task_flow.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|