Tweerichtings-kalendersync tussen een persoonlijke Google-agenda (Gmail) en een Microsoft 365 (Outlook) werkagenda, met verschillende privacy per richting. Python, draait lokaal, geen externe sync-dienst.
Ik bouwde dit omdat ik mijn werkafspraken mét details in mijn privé-agenda wilde (zodat een meekijker thuis ze ziet), maar mijn privé-afspraken alleen als geblokkeerde tijd in mijn werkagenda, zonder details voor collega's.
Twee aparte een-richtings-pipes, zodat de privacy per richting verschilt:
| Pipe | Transform | Wat er overkomt |
|---|---|---|
| Outlook -> Gmail | full_copy |
echte events met titel, body, locatie, tijd. Een meekijker ziet ze. |
| Gmail -> Outlook | busy_block |
titel "Bezet", geen body, showAs=busy, private. Alleen geblokkeerde tijd. |
Het worden echte gekloonde events (harde boekingen), geen overlay-view.
┌─────────────────────────────┐ pipe 1: full_copy (detail) ┌─────────────────────────────┐
│ Microsoft 365 / Outlook │ ───────────────────────────▶ │ Google Calendar │
│ (werkagenda) │ │ (persoonlijk) │
│ │ ◀─────────────────────────── │ │
│ collega's zien: "Bezet" │ pipe 2: busy_block ("Bezet") │ meekijker ziet: volledig │
└──────────────┬──────────────┘ └──────────────┬──────────────┘
│ Graph API Calendar API │
└───────────────────────┐ ┌─────────────────────────┘
▼ ▼
┌──────────────────────────────────┐
│ calsync (host, elke 5 min) │
│ leest beide, schrijft beide │
└──────────────────────────────────┘
De twee clouds praten niet rechtstreeks met elkaar. calsync is het orkestrator-procesje ertussen dat beide pollt en gelijktrekt. Intern:
cli.py ─▶ engine.py (SyncEngine.run_pipe) ─┬─▶ transforms.py (full_copy | busy_block)
├─▶ providers/ (graph_provider | google_provider)
└─▶ state.py (SQLite: (pipe, src_id) -> dst_id + hash)
per bron-event: lees venster ▸ skip clones (origin-tag) ▸ de-dup ▸ transform ▸ create/update/delete ▸ map
calsync/
config.py # paden, venster, markers (waarden via .env)
state.py # SQLite mapping-store (pipe, src_id) -> dst_id + hash
transforms.py # full_copy, busy_block
engine.py # SyncEngine.run_pipe: lees -> transform -> map -> schrijf/verwijder
cli.py # --dry-run / --loop / --pipe
providers/
base.py # CanonicalEvent (provider-neutraal event)
graph_provider.py # Microsoft Graph (MSAL token-cache, calendarView)
google_provider.py # Google Calendar (OAuth desktop, singleEvents)
probe_graph_access.py # eenmalige toegangstest Microsoft Graph
data/ # tokens + calsync.db (lokaal, niet committen)
Vijf ontwerpkeuzes die de moeilijke randen wegnemen:
- Terugkerende afspraken via instance-venster.
calendarView(Graph) enevents.list?singleEvents=true(Google) leveren losse instances binnen een rollend venster. Geen RRULE-vertaling, geen exception-gedoe. - Loop-preventie via origin-tag. Elke clone krijgt een marker
(
extendedProperties.privatein Google, eensingleValueExtendedPropertyin Graph). De engine slaat getagde events bij het lezen over, dus een clone wordt nooit teruggesynct. - De-dup tegen dubbelingen. Bestaat een afspraak al in beide agenda's, dan maakt de engine geen tweede kopie: bij de detail-richting op gelijke titel+tijd, bij de Bezet-richting op tijdsoverlap.
- Mapping-state in SQLite.
(pipe, bron_id) -> doel_idplus een content-hash om ongewijzigde events over te slaan en verwijderingen te vinden. Dunne laag, makkelijk te vervangen door een andere database-adapter. - Verwijderingen via venster-diff. Een mapping waarvan het bron-event uit het venster verdween, betekent: clone weg.
- Alles draait lokaal. Tokens,
credentials.json,.enven de SQLite-db staan indata//de projectmap en worden niet gecommit (zie.gitignore). - Alleen gedelegeerde rechten: de app werkt namens jou, op jouw eigen agenda. Geen toegang tot agenda's van anderen.
- Er gaat geen data naar derden. Alleen verkeer naar de twee agenda-API's van Microsoft en Google, met jouw eigen app-registraties.
Setup-tijd: ~30-45 min. De volledige klik-voor-klik runbook (Entra-app, admin-consent, Google OAuth, valkuilen) staat in SETUP.md. Kort:
git clone <repo> calsync && cd calsync
python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
cp .env.example .env # vul je Entra client-id + tenant-id in
# plaats je Google credentials.json in deze map (zie SETUP.md, Deel B)
./run.sh --dry-run # toon wat er zou gebeuren, schrijf niets
./run.sh # een keer syncen
./run.sh --loop 5 # blijf draaien, elke 5 minuten
./run.sh --pipe outlook->google # alleen die richtingGebruik ./run.sh (of .venv/bin/python cli.py), niet python3 cli.py: in sommige
shells is python3 een alias die de venv negeert, met ModuleNotFoundError: msal als
gevolg. De wrapper pakt altijd de venv-python.
Bij de eerste run vraagt Graph eenmalig een device-code-login en Google om browser-toestemming. Daarna draaien beide stil op bewaarde tokens.
Je hoeft geen developer te zijn. Clone de repo, open de map in een AI-coding-assistent (Claude, ChatGPT, of een CLI-variant) en plak deze prompt:
Ik wil de calsync-tool uit deze repo op mijn computer opzetten (ik gebruik macOS/Windows). Loop me stap voor stap door, stop bij elke stap en wacht op mij: (1) Python, een virtuele omgeving en de dependencies; (2) een Microsoft Entra app-registratie met gedelegeerd
Calendars.ReadWrite+offline_accessen device-code-login aan; (3) een Google Cloud OAuth desktop-client metcredentials.json; (4) een.envmet mijn client-id en tenant-id; (5) de eerste dry-run. GebruikSETUP.mdin deze repo als leidraad.
Twee dingen die een assistent niet voor je oplost: als je bedrijfstenant admin-consent vereist, heb je je IT-beheerder nodig (zie SETUP.md, Deel A-6). En de sync moet ergens blijven draaien, dus je hebt een computer of server nodig die aan blijft staan.
./run.sh --loop 5 in een terminal of tmux, of een launchd-job (macOS) / cron (Linux).
Voorbeeld-launchd-plist staat in SETUP.md, Deel E.
- Python 3.10+
- Een eigen Microsoft Entra app-registratie (gedelegeerd
Calendars.ReadWrite+offline_access). Let op: veel bedrijfstenants vereisen admin-consent. - Een eigen Google Cloud OAuth desktop-client.
MIT, zie LICENSE.