Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions contacts-sync/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Copy to .env and fill in. Providers activate only when their vars are set,
# so you can start with two and add the third later.

# --- HubSpot (Private App access token) ---
# Create at: HubSpot > Settings > Integrations > Private Apps
# Scopes: crm.objects.contacts.read, crm.objects.contacts.write
HUBSPOT_TOKEN=

# --- Folk (API key) ---
# https://developer.folk.app/
FOLK_TOKEN=

# --- Apple Contacts via iCloud CardDAV ---
# APPLE_PASSWORD must be an *app-specific password* (appleid.apple.com).
APPLE_CARDDAV_URL=
APPLE_USERNAME=
APPLE_PASSWORD=
5 changes: 5 additions & 0 deletions contacts-sync/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.env
*.db
__pycache__/
*.pyc
.venv/
124 changes: 124 additions & 0 deletions contacts-sync/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# contacts-sync

Two-way contact synchronization across **Apple Contacts (iCloud)**, **HubSpot**,
and **Folk** — keeping the same person consistent and de-duplicated everywhere.

> Status: working core engine with passing tests. Live provider adapters are
> implemented but need real credentials to validate end-to-end. See
> [Status & next steps](#status--next-steps).

## How it works

Each provider is translated to a shared, normalized `Contact` model, so matching
and conflict resolution don't care which system a record came from.

```
Apple ─┐
HubSpot├─► fetch ─► cluster (by email) ─► decide canonical (last-write-wins)
Folk ─┘ │
└─► write back to every provider
that's missing/out-of-date
```

1. **Fetch** every contact from every configured provider.
2. **Cluster** records that are the same person — by shared email (high
precision), plus prior links remembered in the state DB so people stay merged
even if an email later changes.
3. **Decide the canonical version**: records changed since the last sync are
merged field-by-field, applied oldest→newest so the **most recent edit wins**.
Untouched fields are preserved from the most complete record. List fields
(emails, phones) are **unioned**, not overwritten.
4. **Write back** — create where missing, update where stale, on every provider.
5. **Persist** content hashes + cluster links so the next run knows what changed.

The sync is **convergent**: running it twice with no external edits does nothing.
`--dry-run` prints the plan without writing.

## Layout

```
contacts_sync/
models.py normalized Contact, normalization, field-level merge
matching.py cluster records into people (union-find over emails)
state.py SQLite state store (cluster links + content hashes)
engine.py two-way sync orchestration + conflict resolution
config.py build providers from env vars
cli.py status / sync commands
providers/
base.py Provider interface (fetch / create / update)
hubspot.py HubSpot CRM v3
folk.py Folk people API
apple.py Apple Contacts via iCloud CardDAV (vobject)
tests/ pure-logic + full round-trip tests (in-memory provider)
```

## Setup

```bash
cd contacts-sync
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then fill in credentials
```

Credentials (set only the ones you want active — two providers minimum):

| Var | Where to get it |
| --- | --- |
| `HUBSPOT_TOKEN` | HubSpot → Settings → Integrations → **Private Apps**, scopes `crm.objects.contacts.read` + `.write` |
| `FOLK_TOKEN` | Folk API key — https://developer.folk.app/ |
| `APPLE_CARDDAV_URL`, `APPLE_USERNAME`, `APPLE_PASSWORD` | iCloud CardDAV collection URL + Apple ID + an **app-specific password** |

## Usage

```bash
set -a; . .env; set +a # load credentials

python -m contacts_sync status # what's configured + state stats
python -m contacts_sync sync --dry-run -v # show the plan, write nothing
python -m contacts_sync sync # apply the two-way sync
```

Run on a schedule (cron/launchd) for continuous sync. The state DB
(`contacts_sync_state.db`) must persist between runs.

## Tests

```bash
. .venv/bin/activate
pip install pytest
python -m pytest -q # 13 passing: models, matching, full sync round-trip
```

The engine tests use an in-memory `FakeProvider`, so they exercise the real
clustering / merge / convergence logic without any network or credentials.

## Conflict resolution

- **Scalar fields** (name, company, title, LinkedIn): last-write-wins by the
source record's `updatedAt`.
- **List fields** (emails, phones): unioned — nothing is lost.
- **Duplicates within one provider**: flagged in the report, never auto-deleted.
Sync proceeds against the most complete record.

## Status & next steps

**Done and verified**
- Normalized model, email-based identity matching, field-level LWW merge,
union of multi-valued fields, convergent two-way engine, SQLite state,
CLI (`status` / `sync` / `--dry-run`) — all covered by passing tests.

**Implemented, needs live validation with real credentials**
- HubSpot adapter (pagination, create/update).
- Folk adapter — confirm the people payload shape against your workspace's
current API response; mapping is isolated to `_to_contact`/`_to_payload`.
- Apple/iCloud CardDAV adapter — the collection URL is account-specific and
must be discovered once.

**Not yet implemented (intentional, call before adding)**
- Deletions/archival propagation (current engine creates/updates only — safe by
default; a delete would need a tombstone policy).
- Companies/organizations as first-class objects (only the contact's company
*name* is synced today).
- Rate-limit backoff / retry on the HTTP adapters.
- Automatic CardDAV principal discovery.
8 changes: 8 additions & 0 deletions contacts-sync/contacts_sync/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Two-way contact sync across Apple Contacts, HubSpot, and Folk."""

from .engine import SyncEngine, SyncReport
from .models import Contact, ContactRecord
from .state import StateStore

__all__ = ["SyncEngine", "SyncReport", "Contact", "ContactRecord", "StateStore"]
__version__ = "0.1.0"
4 changes: 4 additions & 0 deletions contacts-sync/contacts_sync/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .cli import main

if __name__ == "__main__":
raise SystemExit(main())
66 changes: 66 additions & 0 deletions contacts-sync/contacts_sync/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Command-line entry point.

python -m contacts_sync status # what's configured + state stats
python -m contacts_sync sync --dry-run # plan, no writes
python -m contacts_sync sync # apply two-way sync
"""

from __future__ import annotations

import argparse
import sys

from .config import providers_from_env
from .engine import SyncEngine
from .state import StateStore

_DEFAULT_DB = "contacts_sync_state.db"


def _cmd_status(args: argparse.Namespace) -> int:
providers = providers_from_env()
print("Configured providers:", ", ".join(p.name for p in providers) or "(none)")
with StateStore(args.db) as state:
stats = state.stats()
print(f"State: {stats['records']} records across {stats['people']} people")
if len(providers) < 2:
print("\nNeed at least two providers configured to sync. See .env.example.")
return 1
return 0


def _cmd_sync(args: argparse.Namespace) -> int:
providers = providers_from_env()
if len(providers) < 2:
print("Need at least two providers configured. See .env.example.", file=sys.stderr)
return 1
with StateStore(args.db) as state:
engine = SyncEngine(providers, state)
report = engine.run(dry_run=args.dry_run)
print(report.summary())
if args.verbose:
for c in report.changes:
who = c.contact.full_name or (c.contact.primary_email or "?")
print(f" {c.action:6} {c.provider:8} {who}")
return 0


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="contacts_sync")
parser.add_argument("--db", default=_DEFAULT_DB, help="state DB path")
sub = parser.add_subparsers(dest="command", required=True)

p_status = sub.add_parser("status", help="show configuration and state")
p_status.set_defaults(func=_cmd_status)

p_sync = sub.add_parser("sync", help="run a two-way sync")
p_sync.add_argument("--dry-run", action="store_true", help="plan without writing")
p_sync.add_argument("-v", "--verbose", action="store_true", help="list each change")
p_sync.set_defaults(func=_cmd_sync)

args = parser.parse_args(argv)
return args.func(args)


if __name__ == "__main__":
raise SystemExit(main())
31 changes: 31 additions & 0 deletions contacts-sync/contacts_sync/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Build providers from environment variables.

Only providers whose required env vars are present are activated, so you can
start with two (e.g. HubSpot + Folk) and add Apple later.
"""

from __future__ import annotations

import os

from .providers import AppleContactsProvider, FolkProvider, HubSpotProvider, Provider


def providers_from_env(env: dict | None = None) -> list[Provider]:
env = env or dict(os.environ)
providers: list[Provider] = []

if env.get("HUBSPOT_TOKEN"):
providers.append(HubSpotProvider(env["HUBSPOT_TOKEN"]))

if env.get("FOLK_TOKEN"):
providers.append(FolkProvider(env["FOLK_TOKEN"]))

if env.get("APPLE_CARDDAV_URL") and env.get("APPLE_USERNAME") and env.get("APPLE_PASSWORD"):
providers.append(
AppleContactsProvider(
env["APPLE_CARDDAV_URL"], env["APPLE_USERNAME"], env["APPLE_PASSWORD"]
)
)

return providers
Loading