jeff-contacts 0.1.2__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.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.1
2
+ Name: jeff-contacts
3
+ Version: 0.1.2
4
+ Summary: CRM Markdown synchronisé depuis CardDAV (Baikal), publié en site statique Hugo.
5
+ Keywords: crm,carddav,vcard,markdown,hugo,contacts
6
+ Author: lduchosal
7
+ License: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Office/Business
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: requests>=2.28
19
+ Requires-Dist: lxml>=5.0
20
+ Requires-Dist: vobject>=0.9.8
21
+ Requires-Dist: jinja2>=3.1
22
+ Requires-Dist: click>=8.1
23
+ Requires-Dist: pyyaml>=6.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ <p align="center">
27
+ <img src="logo.svg" alt="jeff" width="128" height="128">
28
+ </p>
29
+
30
+ <h1 align="center">jeff</h1>
31
+
32
+ <p align="center">Your contacts live in CardDAV. Your CRM lives in Markdown.</p>
33
+
34
+ **jeff** syncs contacts from a Baikal CardDAV server into clean Markdown files with YAML frontmatter, then publishes a static HTML site. No database, no SaaS, no vendor lock-in — just files, Git, and a fast static site.
35
+
36
+ ## How it works
37
+
38
+ ```
39
+ Baikal (CardDAV) ──sync──> Markdown + YAML ──build──> Hugo static site
40
+ ```
41
+
42
+ 1. **Fetch** contacts from your CardDAV server (incremental, ctag/etag-based)
43
+ 2. **Transform** vCards into Markdown files with structured YAML frontmatter
44
+ 3. **Publish** a fast, searchable static site with Hugo
45
+
46
+ ## Setup
47
+
48
+ ```sh
49
+ pip install jeff-contacts
50
+ ```
51
+
52
+ Create a `.jeff` file at the root of your project:
53
+
54
+ ```
55
+ carddav_url=https://your-baikal.example.com/dav.php/addressbooks/user/default/
56
+ carddav_username=user
57
+ carddav_password=secret
58
+ ```
59
+
60
+ ```sh
61
+ chmod 600 .jeff
62
+ ```
63
+
64
+ ## Usage
65
+
66
+ ```sh
67
+ jeff sync # incremental sync (only changed contacts)
68
+ jeff sync --full # force full re-sync
69
+ jeff publish # build static HTML site
70
+ jeff --verbose sync # debug logging
71
+ ```
72
+
73
+ `jeff sync` creates one Markdown file per contact in `content/contacts/`, extracts photos to `static/photos/`, and tracks sync state in `.sync-state.json`.
74
+
75
+ `jeff publish` generates a static HTML site in `public/` with a contact index and individual profile pages.
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,54 @@
1
+ <p align="center">
2
+ <img src="logo.svg" alt="jeff" width="128" height="128">
3
+ </p>
4
+
5
+ <h1 align="center">jeff</h1>
6
+
7
+ <p align="center">Your contacts live in CardDAV. Your CRM lives in Markdown.</p>
8
+
9
+ **jeff** syncs contacts from a Baikal CardDAV server into clean Markdown files with YAML frontmatter, then publishes a static HTML site. No database, no SaaS, no vendor lock-in — just files, Git, and a fast static site.
10
+
11
+ ## How it works
12
+
13
+ ```
14
+ Baikal (CardDAV) ──sync──> Markdown + YAML ──build──> Hugo static site
15
+ ```
16
+
17
+ 1. **Fetch** contacts from your CardDAV server (incremental, ctag/etag-based)
18
+ 2. **Transform** vCards into Markdown files with structured YAML frontmatter
19
+ 3. **Publish** a fast, searchable static site with Hugo
20
+
21
+ ## Setup
22
+
23
+ ```sh
24
+ pip install jeff-contacts
25
+ ```
26
+
27
+ Create a `.jeff` file at the root of your project:
28
+
29
+ ```
30
+ carddav_url=https://your-baikal.example.com/dav.php/addressbooks/user/default/
31
+ carddav_username=user
32
+ carddav_password=secret
33
+ ```
34
+
35
+ ```sh
36
+ chmod 600 .jeff
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```sh
42
+ jeff sync # incremental sync (only changed contacts)
43
+ jeff sync --full # force full re-sync
44
+ jeff publish # build static HTML site
45
+ jeff --verbose sync # debug logging
46
+ ```
47
+
48
+ `jeff sync` creates one Markdown file per contact in `content/contacts/`, extracts photos to `static/photos/`, and tracks sync state in `.sync-state.json`.
49
+
50
+ `jeff publish` generates a static HTML site in `public/` with a contact index and individual profile pages.
51
+
52
+ ## License
53
+
54
+ MIT
@@ -0,0 +1,215 @@
1
+ [project]
2
+ name = "jeff-contacts"
3
+ version = "0.1.2"
4
+ description = "CRM Markdown synchronisé depuis CardDAV (Baikal), publié en site statique Hugo."
5
+ authors = [
6
+ { name = "lduchosal" },
7
+ ]
8
+ requires-python = ">=3.11"
9
+ readme = "README.md"
10
+ keywords = [
11
+ "crm",
12
+ "carddav",
13
+ "vcard",
14
+ "markdown",
15
+ "hugo",
16
+ "contacts",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3 :: Only",
27
+ "Topic :: Office/Business",
28
+ ]
29
+ dependencies = [
30
+ "requests>=2.28",
31
+ "lxml>=5.0",
32
+ "vobject>=0.9.8",
33
+ "jinja2>=3.1",
34
+ "click>=8.1",
35
+ "pyyaml>=6.0",
36
+ ]
37
+
38
+ [project.license]
39
+ text = "MIT"
40
+
41
+ [project.scripts]
42
+ jeff = "jeff.cli:cli"
43
+
44
+ [dependency-groups]
45
+ dev = [
46
+ "pdm>=2.26.0",
47
+ "pdm-bump>=0.9.0",
48
+ "pytest>=8.4",
49
+ "pytest-cov>=7.0",
50
+ "pytest-asyncio>=1.2.0",
51
+ "httpx>=0.24.0",
52
+ "black>=25.0",
53
+ "isort>=6.0",
54
+ "docformatter>=1.7",
55
+ "ruff>=0.13.0",
56
+ "flake8>=7.3",
57
+ "flake8-docstrings>=1.7.0",
58
+ "flake8-docstrings-complete>=1.3.0",
59
+ "mypy>=1.18",
60
+ "interrogate>=1.7.0",
61
+ "vulture>=2.14",
62
+ "refurb>=2.2.0",
63
+ "absolufy-imports>=0.3.1",
64
+ ]
65
+ publish = [
66
+ "twine>=6.2.0",
67
+ "keyring>=25.7.0",
68
+ ]
69
+
70
+ [build-system]
71
+ requires = [
72
+ "pdm-backend",
73
+ ]
74
+ build-backend = "pdm.backend"
75
+
76
+ [tool.pdm]
77
+ distribution = true
78
+
79
+ [tool.pdm.scripts]
80
+ test = "pytest tests/ -q --tb=short"
81
+ test-quick = "pytest tests/ -q --tb=short -x"
82
+ test-ci = "pytest tests/ -q --tb=short --cov=src/jeff --cov-report=xml --cov-report=term"
83
+ test-unit = "pytest tests/unit/ -q --tb=short"
84
+ test-integration = "pytest tests/integration/ -q --tb=short"
85
+ test-cov = "pytest tests/ -q --tb=short --cov=src/jeff --cov-report=term-missing --cov-report=html"
86
+ format = "black src/ tests/"
87
+ isort = "isort src/ tests/"
88
+ lint = "ruff check --fix src/ tests/"
89
+ flake8 = "flake8 src/"
90
+ typecheck = "mypy src/"
91
+ interrogate = "interrogate src/"
92
+ vulture = "vulture src/ tests/ vulture_whitelist.py"
93
+ refurb = "refurb src/"
94
+ clean = "rm -rf dist/ build/ .mypy_cache/ .pytest_cache/ htmlcov/ .coverage"
95
+ install = "pdm install"
96
+ install-dev = "pdm install -G dev"
97
+ version-show = "python -c \"import jeff; print(jeff.__version__)\""
98
+ version-patch = "pdm bump -v patch"
99
+ version-minor = "pdm bump -v minor"
100
+ version-major = "pdm bump -v major"
101
+
102
+ [tool.pdm.scripts.docformatter]
103
+ shell = "docformatter --recursive --in-place --wrap-summaries 88 --wrap-descriptions 88 --close-quotes-on-newline src/ tests/ || [ $? -eq 3 ]"
104
+
105
+ [tool.pdm.scripts.absolufy]
106
+ shell = "find src/ -name '*.py' -exec absolufy-imports {} +"
107
+
108
+ [tool.pdm.scripts.check]
109
+ composite = [
110
+ "isort",
111
+ "docformatter",
112
+ "format",
113
+ "typecheck",
114
+ "flake8",
115
+ "interrogate",
116
+ "refurb",
117
+ "lint",
118
+ "vulture",
119
+ "test-quick",
120
+ ]
121
+
122
+ [tool.pdm-bump]
123
+ version-files = [
124
+ "src/jeff/__init__.py:__version__",
125
+ ]
126
+
127
+ [tool.black]
128
+ line-length = 88
129
+ target-version = [
130
+ "py311",
131
+ ]
132
+
133
+ [tool.isort]
134
+ profile = "black"
135
+ src_paths = [
136
+ "src",
137
+ "tests",
138
+ ]
139
+ line_length = 88
140
+ multi_line_output = 3
141
+ include_trailing_comma = true
142
+
143
+ [tool.mypy]
144
+ python_version = "3.11"
145
+ warn_return_any = true
146
+ warn_unused_configs = true
147
+ disallow_untyped_defs = true
148
+ disallow_incomplete_defs = true
149
+ check_untyped_defs = true
150
+ strict_equality = true
151
+ ignore_missing_imports = true
152
+
153
+ [tool.pytest.ini_options]
154
+ testpaths = [
155
+ "tests",
156
+ ]
157
+ python_files = [
158
+ "test_*.py",
159
+ "*_test.py",
160
+ ]
161
+ python_classes = [
162
+ "Test*",
163
+ ]
164
+ python_functions = [
165
+ "test_*",
166
+ ]
167
+ markers = [
168
+ "slow: marks tests as slow",
169
+ "integration: marks integration tests",
170
+ "unit: marks unit tests",
171
+ ]
172
+
173
+ [tool.coverage.run]
174
+ source = [
175
+ "src/jeff",
176
+ ]
177
+ omit = [
178
+ "tests/*",
179
+ "src/jeff/__main__.py",
180
+ ]
181
+
182
+ [tool.coverage.report]
183
+ fail_under = 75
184
+ exclude_also = [
185
+ "def __repr__",
186
+ "if __name__ == .__main__.:",
187
+ "pragma: no cover",
188
+ "@(abc\\.)?abstractmethod",
189
+ "raise NotImplementedError",
190
+ ]
191
+
192
+ [tool.vulture]
193
+ min_confidence = 80
194
+ paths = [
195
+ "src",
196
+ "tests",
197
+ ]
198
+
199
+ [tool.interrogate]
200
+ fail-under = 95
201
+ ignore-init-method = true
202
+ ignore-init-module = true
203
+ exclude = [
204
+ "tests",
205
+ "docs",
206
+ "build",
207
+ "dist",
208
+ ]
209
+ generate-badge = "."
210
+
211
+ [tool.docformatter]
212
+ recursive = true
213
+ wrap-summaries = 88
214
+ wrap-descriptions = 88
215
+ close-quotes-on-newline = true
@@ -0,0 +1,3 @@
1
+ """Jeff — CRM Markdown synchronisé depuis CardDAV."""
2
+
3
+ __version__ = "0.1.2"
@@ -0,0 +1,290 @@
1
+ """CardDAV client for Baikal.
2
+
3
+ Minimal, no-bloat CardDAV client using ``requests`` + ``lxml``. Supports discovery,
4
+ contact listing, batch fetch, and change detection via sync-token / ctag / etags.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import requests
15
+ from lxml import etree
16
+
17
+ from jeff.log import get_logger
18
+
19
+ _log = get_logger("carddav")
20
+
21
+ # XML namespaces used in CardDAV / WebDAV.
22
+ DAV = "DAV:"
23
+ CARDDAV = "urn:ietf:params:xml:ns:carddav"
24
+ CS = "http://calendarserver.org/ns/"
25
+
26
+ _NS = {"d": DAV, "card": CARDDAV, "cs": CS}
27
+
28
+
29
+ @dataclass
30
+ class Contact:
31
+ """A single contact fetched from the server."""
32
+
33
+ href: str
34
+ etag: str
35
+ vcard_raw: str
36
+
37
+
38
+ @dataclass
39
+ class SyncState:
40
+ """Persistent sync state stored on disk as JSON."""
41
+
42
+ sync_token: str | None = None
43
+ ctag: str | None = None
44
+ contacts: dict[str, dict[str, str]] = field(default_factory=dict)
45
+
46
+ def save(self, path: Path) -> None:
47
+ """Write state to a JSON file."""
48
+ data = {
49
+ "sync_token": self.sync_token,
50
+ "ctag": self.ctag,
51
+ "contacts": self.contacts,
52
+ }
53
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
54
+
55
+ @classmethod
56
+ def load(cls, path: Path) -> SyncState:
57
+ """Load state from a JSON file, or return empty state."""
58
+ if not path.is_file():
59
+ return cls()
60
+ data = json.loads(path.read_text(encoding="utf-8"))
61
+ return cls(
62
+ sync_token=data.get("sync_token"),
63
+ ctag=data.get("ctag"),
64
+ contacts=data.get("contacts", {}),
65
+ )
66
+
67
+
68
+ @dataclass
69
+ class CardDAVConfig:
70
+ """Connection parameters for a CardDAV server."""
71
+
72
+ url: str
73
+ username: str
74
+ password: str
75
+
76
+ @property
77
+ def base_url(self) -> str:
78
+ """Return the URL without a trailing slash."""
79
+ return self.url.rstrip("/")
80
+
81
+
82
+ class CardDAVClient:
83
+ """Minimal CardDAV client for Baikal.
84
+
85
+ Talks to the server using raw WebDAV/CardDAV XML requests. No dependency on
86
+ vdirsyncer internals.
87
+ """
88
+
89
+ def __init__(self, config: CardDAVConfig) -> None:
90
+ """Initialize the CardDAV client."""
91
+ self._config = config
92
+ self._session = requests.Session()
93
+ self._session.auth = (config.username, config.password)
94
+ self._session.headers["Content-Type"] = "application/xml; charset=utf-8"
95
+
96
+ def discover_addressbooks(self) -> list[dict[str, str]]:
97
+ """Find all addressbooks for the authenticated user.
98
+
99
+ Returns a list of dicts with ``href`` and ``displayname`` keys.
100
+ """
101
+ body = (
102
+ '<?xml version="1.0" encoding="utf-8"?>'
103
+ '<d:propfind xmlns:d="DAV:">'
104
+ "<d:prop>"
105
+ "<d:resourcetype/>"
106
+ "<d:displayname/>"
107
+ "</d:prop>"
108
+ "</d:propfind>"
109
+ )
110
+ _log.debug("PROPFIND %s (discover addressbooks)", self._config.base_url)
111
+ resp = self._request("PROPFIND", self._config.base_url, body, depth="1")
112
+ tree = etree.fromstring(resp.content)
113
+ books: list[dict[str, str]] = []
114
+ for response in tree.findall("d:response", _NS):
115
+ restype = response.find(".//d:resourcetype/card:addressbook", _NS)
116
+ if restype is not None:
117
+ href = response.findtext("d:href", "", _NS)
118
+ name = response.findtext(".//d:displayname", "", _NS)
119
+ books.append({"href": href, "displayname": name})
120
+ return books
121
+
122
+ def get_ctag(self, addressbook_href: str) -> str | None:
123
+ """Fetch the CTag for an addressbook (collection-level change tag)."""
124
+ body = (
125
+ '<?xml version="1.0" encoding="utf-8"?>'
126
+ '<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">'
127
+ "<d:prop>"
128
+ "<cs:getctag/>"
129
+ "</d:prop>"
130
+ "</d:propfind>"
131
+ )
132
+ url = self._absolute(addressbook_href)
133
+ resp = self._request("PROPFIND", url, body, depth="0")
134
+ tree = etree.fromstring(resp.content)
135
+ ctag: str | None = tree.findtext(".//cs:getctag", None, _NS) # noqa: FURB184
136
+ return ctag
137
+
138
+ def list_contacts(self, addressbook_href: str) -> dict[str, str]:
139
+ """List all contact hrefs and their etags in an addressbook.
140
+
141
+ Returns a dict mapping ``href -> etag``.
142
+ """
143
+ body = (
144
+ '<?xml version="1.0" encoding="utf-8"?>'
145
+ '<d:propfind xmlns:d="DAV:">'
146
+ "<d:prop>"
147
+ "<d:getetag/>"
148
+ "<d:resourcetype/>"
149
+ "</d:prop>"
150
+ "</d:propfind>"
151
+ )
152
+ url = self._absolute(addressbook_href)
153
+ resp = self._request("PROPFIND", url, body, depth="1")
154
+ tree = etree.fromstring(resp.content)
155
+ contacts: dict[str, str] = {}
156
+ for response in tree.findall("d:response", _NS):
157
+ # Skip the addressbook itself (it has a resourcetype).
158
+ restype = response.find(".//d:resourcetype/card:addressbook", _NS)
159
+ if restype is not None:
160
+ continue
161
+ href = response.findtext("d:href", "", _NS)
162
+ etag = response.findtext(".//d:getetag", "", _NS).strip('"')
163
+ if href and href.endswith(".vcf"):
164
+ contacts[href] = etag
165
+ return contacts
166
+
167
+ def fetch_contacts(self, addressbook_href: str, hrefs: list[str]) -> list[Contact]:
168
+ """Fetch multiple contacts in a single request (multiget REPORT).
169
+
170
+ Returns a list of ``Contact`` objects with raw vCard data.
171
+ """
172
+ if not hrefs:
173
+ return []
174
+ _log.debug("Multiget %d contact(s)", len(hrefs))
175
+ href_xml = "".join(f"<d:href>{h}</d:href>" for h in hrefs)
176
+ body = (
177
+ '<?xml version="1.0" encoding="utf-8"?>'
178
+ '<card:addressbook-multiget xmlns:d="DAV:" '
179
+ 'xmlns:card="urn:ietf:params:xml:ns:carddav">'
180
+ "<d:prop>"
181
+ "<d:getetag/>"
182
+ "<card:address-data/>"
183
+ "</d:prop>"
184
+ f"{href_xml}"
185
+ "</card:addressbook-multiget>"
186
+ )
187
+ url = self._absolute(addressbook_href)
188
+ resp = self._request("REPORT", url, body, depth="1")
189
+ tree = etree.fromstring(resp.content)
190
+ result: list[Contact] = []
191
+ for response in tree.findall("d:response", _NS):
192
+ href = response.findtext("d:href", "", _NS)
193
+ etag = response.findtext(".//d:getetag", "", _NS).strip('"')
194
+ vcard_raw = response.findtext(".//card:address-data", "", _NS)
195
+ if href and vcard_raw:
196
+ result.append(Contact(href=href, etag=etag, vcard_raw=vcard_raw))
197
+ return result
198
+
199
+ def fetch_all_contacts(self, addressbook_href: str) -> list[Contact]:
200
+ """Fetch all contacts in an addressbook."""
201
+ hrefs = list(self.list_contacts(addressbook_href).keys())
202
+ return self.fetch_contacts(addressbook_href, hrefs)
203
+
204
+ def sync(
205
+ self,
206
+ addressbook_href: str,
207
+ state: SyncState,
208
+ ) -> tuple[list[Contact], list[str], SyncState]:
209
+ """Incremental sync: fetch only changed contacts.
210
+
211
+ Returns ``(new_or_updated, deleted_hrefs, new_state)``. Uses ctag for
212
+ collection-level change detection, then diffs etags to find individual changes.
213
+ """
214
+ ctag = self.get_ctag(addressbook_href)
215
+ if ctag is not None and ctag == state.ctag:
216
+ _log.debug("CTag unchanged (%s), skipping sync", ctag)
217
+ return [], [], state
218
+ _log.debug("CTag changed: %s → %s", state.ctag, ctag)
219
+
220
+ current = self.list_contacts(addressbook_href)
221
+ old = state.contacts
222
+
223
+ # Find new or updated contacts.
224
+ changed_hrefs: list[str] = []
225
+ for href, etag in current.items():
226
+ old_etag = old.get(href, {}).get("etag")
227
+ if old_etag != etag:
228
+ changed_hrefs.append(href)
229
+
230
+ # Find deleted contacts.
231
+ deleted = [h for h in old if h not in current]
232
+
233
+ # Fetch changed contacts.
234
+ updated = self.fetch_contacts(addressbook_href, changed_hrefs)
235
+
236
+ # Build new state.
237
+ new_contacts: dict[str, dict[str, str]] = {}
238
+ for href, etag in current.items():
239
+ new_contacts[href] = {"etag": etag}
240
+
241
+ new_state = SyncState(
242
+ sync_token=state.sync_token,
243
+ ctag=ctag,
244
+ contacts=new_contacts,
245
+ )
246
+ return updated, deleted, new_state
247
+
248
+ def put_contact(self, href: str, vcard_raw: str, etag: str) -> str | None:
249
+ """Update a contact on the server via PUT.
250
+
251
+ Uses ``If-Match`` with the given etag for optimistic locking. Returns the new
252
+ etag on success, or None on conflict (412).
253
+ """
254
+ url = self._absolute(href)
255
+ _log.debug("PUT %s (If-Match: %s)", href, etag)
256
+ headers = {
257
+ "Content-Type": "text/vcard",
258
+ "If-Match": f'"{etag}"',
259
+ }
260
+ resp = self._session.put(url, data=vcard_raw.encode("utf-8"), headers=headers)
261
+ if resp.status_code == 412:
262
+ return None
263
+ resp.raise_for_status()
264
+ # Baikal returns the new etag in the ETag header.
265
+ new_etag = resp.headers.get("ETag", "").strip('"')
266
+ return new_etag or None
267
+
268
+ def _absolute(self, href: str) -> str:
269
+ """Resolve a relative href to an absolute URL."""
270
+ if href.startswith("http"):
271
+ return href
272
+ # Strip the path from the config URL, keep scheme+host.
273
+ from urllib.parse import urljoin
274
+
275
+ return urljoin(self._config.base_url + "/", href)
276
+
277
+ def _request(
278
+ self,
279
+ method: str,
280
+ url: str,
281
+ body: str,
282
+ depth: str = "0",
283
+ ) -> Any:
284
+ """Send a WebDAV request and return the response."""
285
+ headers = {"Depth": depth}
286
+ resp = self._session.request(
287
+ method, url, data=body.encode("utf-8"), headers=headers
288
+ )
289
+ resp.raise_for_status()
290
+ return resp