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.
- jeff_contacts-0.1.2/PKG-INFO +79 -0
- jeff_contacts-0.1.2/README.md +54 -0
- jeff_contacts-0.1.2/pyproject.toml +215 -0
- jeff_contacts-0.1.2/src/jeff/__init__.py +3 -0
- jeff_contacts-0.1.2/src/jeff/carddav.py +290 -0
- jeff_contacts-0.1.2/src/jeff/cli.py +167 -0
- jeff_contacts-0.1.2/src/jeff/config.py +133 -0
- jeff_contacts-0.1.2/src/jeff/log.py +36 -0
- jeff_contacts-0.1.2/src/jeff/publish.py +136 -0
- jeff_contacts-0.1.2/src/jeff/templates/contact.html +129 -0
- jeff_contacts-0.1.2/src/jeff/templates/index.html +44 -0
- jeff_contacts-0.1.2/src/jeff/transform.py +348 -0
- jeff_contacts-0.1.2/src/jeff/urlback.py +53 -0
- jeff_contacts-0.1.2/tests/__init__.py +0 -0
- jeff_contacts-0.1.2/tests/integration/__init__.py +0 -0
- jeff_contacts-0.1.2/tests/unit/__init__.py +0 -0
- jeff_contacts-0.1.2/tests/unit/test_carddav.py +363 -0
- jeff_contacts-0.1.2/tests/unit/test_cli.py +112 -0
- jeff_contacts-0.1.2/tests/unit/test_config.py +116 -0
- jeff_contacts-0.1.2/tests/unit/test_publish.py +122 -0
- jeff_contacts-0.1.2/tests/unit/test_transform.py +201 -0
- jeff_contacts-0.1.2/tests/unit/test_urlback.py +74 -0
- jeff_contacts-0.1.2/tests/unit/test_version.py +9 -0
|
@@ -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,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
|