+24
PYTHON/.env.example
+24
PYTHON/.env.example
···
1
+
# AT Protocol Calendar Import Environment Configuration
2
+
3
+
# AT Protocol Settings
4
+
ATPROTO_HANDLE=user.bsky.social
5
+
ATPROTO_PASSWORD=your-app-password
6
+
ATPROTO_PDS_URL=https://bsky.social
7
+
8
+
# API Server Settings
9
+
API_HOST=0.0.0.0
10
+
API_PORT=8000
11
+
DEBUG=false
12
+
13
+
# CORS Settings (comma-separated list)
14
+
CORS_ORIGINS=*
15
+
16
+
# Database Settings (optional)
17
+
# REDIS_URL=redis://localhost:6379
18
+
# POSTGRES_URL=postgresql://user:password@localhost:5432/dbname
19
+
20
+
# Cache Settings
21
+
CACHE_TTL=3600
22
+
23
+
# Logging
24
+
LOG_LEVEL=INFO
+269
PYTHON/.gitignore
+269
PYTHON/.gitignore
···
1
+
# Byte-compiled / optimized / DLL files
2
+
__pycache__/
3
+
*.py[cod]
4
+
*$py.class
5
+
6
+
# C extensions
7
+
*.so
8
+
9
+
# Distribution / packaging
10
+
.Python
11
+
build/
12
+
develop-eggs/
13
+
dist/
14
+
downloads/
15
+
eggs/
16
+
.eggs/
17
+
lib/
18
+
lib64/
19
+
parts/
20
+
sdist/
21
+
var/
22
+
wheels/
23
+
share/python-wheels/
24
+
*.egg-info/
25
+
.installed.cfg
26
+
*.egg
27
+
MANIFEST
28
+
29
+
# PyInstaller
30
+
# Usually these files are written by a python script from a template
31
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+
*.manifest
33
+
*.spec
34
+
35
+
# Installer logs
36
+
pip-log.txt
37
+
pip-delete-this-directory.txt
38
+
39
+
# Unit test / coverage reports
40
+
htmlcov/
41
+
.tox/
42
+
.nox/
43
+
.coverage
44
+
.coverage.*
45
+
.cache
46
+
nosetests.xml
47
+
coverage.xml
48
+
*.cover
49
+
*.py,cover
50
+
.hypothesis/
51
+
.pytest_cache/
52
+
cover/
53
+
54
+
# Translations
55
+
*.mo
56
+
*.pot
57
+
58
+
# Django stuff:
59
+
*.log
60
+
local_settings.py
61
+
db.sqlite3
62
+
db.sqlite3-journal
63
+
64
+
# Flask stuff:
65
+
instance/
66
+
.webassets-cache
67
+
68
+
# Scrapy stuff:
69
+
.scrapy
70
+
71
+
# Sphinx documentation
72
+
docs/_build/
73
+
74
+
# PyBuilder
75
+
.pybuilder/
76
+
target/
77
+
78
+
# Jupyter Notebook
79
+
.ipynb_checkpoints
80
+
81
+
# IPython
82
+
profile_default/
83
+
ipython_config.py
84
+
85
+
# pyenv
86
+
# For a library or package, you might want to ignore these files since the code is
87
+
# intended to run in multiple environments; otherwise, check them in:
88
+
# .python-version
89
+
90
+
# pipenv
91
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+
# install all needed dependencies.
95
+
#Pipfile.lock
96
+
97
+
# poetry
98
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
100
+
# commonly ignored for libraries.
101
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+
#poetry.lock
103
+
104
+
# pdm
105
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+
#pdm.lock
107
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+
# in version control.
109
+
# https://pdm.fming.dev/#use-with-ide
110
+
.pdm.toml
111
+
112
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113
+
__pypackages__/
114
+
115
+
# Celery stuff
116
+
celerybeat-schedule
117
+
celerybeat.pid
118
+
119
+
# SageMath parsed files
120
+
*.sage.py
121
+
122
+
# Environments
123
+
.env
124
+
.venv
125
+
env/
126
+
venv/
127
+
ENV/
128
+
env.bak/
129
+
venv.bak/
130
+
131
+
# Spyder project settings
132
+
.spyderproject
133
+
.spyproject
134
+
135
+
# Rope project settings
136
+
.ropeproject
137
+
138
+
# mkdocs documentation
139
+
/site
140
+
141
+
# mypy
142
+
.mypy_cache/
143
+
.dmypy.json
144
+
dmypy.json
145
+
146
+
# Pyre type checker
147
+
.pyre/
148
+
149
+
# pytype static type analyzer
150
+
.pytype/
151
+
152
+
# Cython debug symbols
153
+
cython_debug/
154
+
155
+
# PyCharm
156
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157
+
# be added to the global gitignore or merged into this project gitignore. For a PyCharm
158
+
# project, it is recommended to apply the template via the 'File | Settings | File Templates'.
159
+
.idea/
160
+
161
+
# VS Code
162
+
.vscode/
163
+
*.code-workspace
164
+
165
+
# Sublime Text
166
+
*.sublime-project
167
+
*.sublime-workspace
168
+
169
+
# Vim
170
+
*.swp
171
+
*.swo
172
+
*~
173
+
174
+
# Emacs
175
+
*~
176
+
\#*\#
177
+
/.emacs.desktop
178
+
/.emacs.desktop.lock
179
+
*.elc
180
+
auto-save-list
181
+
tramp
182
+
.\#*
183
+
184
+
# Project-specific files
185
+
# Calendar credentials and config files
186
+
*.json
187
+
!requirements/*.txt
188
+
!pyproject.toml
189
+
!package.json
190
+
191
+
# Configuration files with secrets
192
+
.env.local
193
+
.env.production
194
+
.env.development
195
+
config.ini
196
+
settings.ini
197
+
credentials.json
198
+
google_credentials.json
199
+
outlook_credentials.json
200
+
apple_credentials.json
201
+
202
+
# Application data
203
+
*.db
204
+
*.sqlite
205
+
*.sqlite3
206
+
207
+
# Logs
208
+
*.log
209
+
logs/
210
+
log/
211
+
212
+
# Temporary files
213
+
tmp/
214
+
temp/
215
+
.tmp/
216
+
.temp/
217
+
218
+
# OS generated files
219
+
.DS_Store
220
+
.DS_Store?
221
+
._*
222
+
.Spotlight-V100
223
+
.Trashes
224
+
ehthumbs.db
225
+
Thumbs.db
226
+
227
+
# Docker
228
+
.dockerignore
229
+
docker-compose.override.yml
230
+
231
+
# AT Protocol specific
232
+
*.pds
233
+
*.did
234
+
*.key
235
+
*.cred
236
+
237
+
# Calendar files (unless specifically needed)
238
+
*.ics
239
+
*.cal
240
+
*.vcs
241
+
242
+
# Test output files
243
+
test_output*.json
244
+
*_output.json
245
+
test_*.ics
246
+
sample_*.ics
247
+
248
+
# Development tools
249
+
.pre-commit-config.yaml.bak
250
+
.coverage.*
251
+
.pytest_cache/
252
+
.mypy_cache/
253
+
.ruff_cache/
254
+
255
+
# Build artifacts
256
+
dist/
257
+
build/
258
+
*.egg-info/
259
+
*.tar.gz
260
+
*.whl
261
+
262
+
# Documentation build
263
+
docs/_build/
264
+
docs/build/
265
+
site/
266
+
267
+
# Generated files
268
+
generated/
269
+
auto/
+1791
PYTHON/Claude_Python_version.md
+1791
PYTHON/Claude_Python_version.md
···
1
+
# 📘 Development Guide: `atproto-calendar-import` (Python)
2
+
3
+
---
4
+
5
+
## 🔍 Project Description
6
+
7
+
`atproto-calendar-import` is a Python library and CLI tool for importing calendar events from external providers (Google, Outlook, Apple, ICS) into the [AT Protocol](https://atproto.com/). It includes both a command-line interface and a REST API server for integration.
8
+
9
+
---
10
+
11
+
## 📦 Repository Structure
12
+
13
+
```text
14
+
atproto-calendar-import/
15
+
├── src/
16
+
│ ├── atproto_calendar/
17
+
│ │ ├── __init__.py
18
+
│ │ ├── main.py # CLI entry point
19
+
│ │ ├── api/ # FastAPI REST server
20
+
│ │ │ ├── __init__.py
21
+
│ │ │ ├── server.py # FastAPI app
22
+
│ │ │ ├── routes/ # API endpoints
23
+
│ │ │ │ ├── __init__.py
24
+
│ │ │ │ ├── import.py # Import endpoints
25
+
│ │ │ │ └── health.py # Health check
26
+
│ │ │ └── models/ # Pydantic models
27
+
│ │ │ ├── __init__.py
28
+
│ │ │ ├── events.py # Event models
29
+
│ │ │ └── requests.py # Request models
30
+
│ │ ├── import/ # External calendar integrations
31
+
│ │ │ ├── __init__.py
32
+
│ │ │ ├── base.py # Abstract base classes
33
+
│ │ │ ├── google.py # Google Calendar
34
+
│ │ │ ├── outlook.py # Microsoft Outlook
35
+
│ │ │ ├── apple.py # Apple Calendar (CalDAV)
36
+
│ │ │ └── ics.py # ICS file/URL import
37
+
│ │ ├── transform/ # Event transformation
38
+
│ │ │ ├── __init__.py
39
+
│ │ │ └── converter.py # AT Protocol conversion
40
+
│ │ ├── atproto/ # AT Protocol integration
41
+
│ │ │ ├── __init__.py
42
+
│ │ │ ├── client.py # ATP client
43
+
│ │ │ └── auth.py # Authentication
44
+
│ │ ├── auth/ # OAuth2 helpers
45
+
│ │ │ ├── __init__.py
46
+
│ │ │ ├── google.py
47
+
│ │ │ ├── microsoft.py
48
+
│ │ │ └── apple.py
49
+
│ │ ├── dedup/ # Deduplication logic
50
+
│ │ │ ├── __init__.py
51
+
│ │ │ ├── cache.py # In-memory/Redis cache
52
+
│ │ │ └── postgres.py # Postgres backend
53
+
│ │ ├── cli/ # CLI commands
54
+
│ │ │ ├── __init__.py
55
+
│ │ │ ├── commands.py # Command definitions
56
+
│ │ │ └── utils.py # CLI utilities
57
+
│ │ ├── config/ # Configuration
58
+
│ │ │ ├── __init__.py
59
+
│ │ │ └── settings.py # Settings management
60
+
│ │ └── exceptions.py # Custom exceptions
61
+
├── tests/ # Test suite
62
+
│ ├── __init__.py
63
+
│ ├── conftest.py # Pytest fixtures
64
+
│ ├── test_import/ # Import tests
65
+
│ ├── test_transform/ # Transform tests
66
+
│ ├── test_api/ # API tests
67
+
│ └── test_cli/ # CLI tests
68
+
├── requirements/
69
+
│ ├── base.txt # Core dependencies
70
+
│ ├── dev.txt # Development dependencies
71
+
│ └── api.txt # API server dependencies
72
+
├── docker/
73
+
│ ├── Dockerfile
74
+
│ └── docker-compose.yml
75
+
├── scripts/
76
+
│ ├── setup.py # Setup script
77
+
│ └── run-dev.sh # Development runner
78
+
├── pyproject.toml # Project configuration
79
+
├── requirements.txt # Combined requirements
80
+
└── README.md # Project documentation
81
+
```
82
+
83
+
---
84
+
85
+
## ✅ Requirements
86
+
87
+
### Core Dependencies
88
+
```toml
89
+
[tool.poetry.dependencies]
90
+
python = "^3.9"
91
+
pydantic = "^2.0"
92
+
httpx = "^0.27"
93
+
click = "^8.1"
94
+
python-dateutil = "^2.8"
95
+
pytz = "^2023.3"
96
+
structlog = "^23.1"
97
+
tenacity = "^8.2"
98
+
cryptography = "^41.0"
99
+
100
+
# Calendar parsing
101
+
icalendar = "^5.0"
102
+
caldav = "^1.3"
103
+
104
+
# OAuth2
105
+
authlib = "^1.2"
106
+
requests-oauthlib = "^1.3"
107
+
108
+
# Database (optional)
109
+
asyncpg = "^0.28"
110
+
redis = "^4.5"
111
+
112
+
# API server (optional)
113
+
fastapi = "^0.104"
114
+
uvicorn = "^0.24"
115
+
116
+
# AT Protocol
117
+
atproto = "^0.0.38"
118
+
```
119
+
120
+
### External Requirements
121
+
* Calendar API credentials (Google OAuth, Microsoft Graph)
122
+
* AT Protocol PDS access
123
+
* Redis (optional, for caching)
124
+
* PostgreSQL (optional, for persistent deduplication)
125
+
126
+
---
127
+
128
+
## 🛠️ Build & Test Commands
129
+
130
+
| Task | Command |
131
+
| ------------------ | ----------------------------------------- |
132
+
| Install | `pip install -e .` |
133
+
| Install Dev | `pip install -e ".[dev]"` |
134
+
| Format Code | `black . && isort .` |
135
+
| Lint Code | `flake8 . && mypy .` |
136
+
| Run Tests | `pytest` |
137
+
| Run Specific Test | `pytest tests/test_import/test_ics.py` |
138
+
| Start API Server | `python -m atproto_calendar.api.server` |
139
+
| CLI Help | `python -m atproto_calendar --help` |
140
+
141
+
---
142
+
143
+
## ⚙️ Architecture Overview
144
+
145
+
### 1. 📥 Import Services
146
+
147
+
Abstract base class for all calendar providers:
148
+
149
+
```python
150
+
# src/atproto_calendar/import/base.py
151
+
from abc import ABC, abstractmethod
152
+
from typing import List, Optional
153
+
from dataclasses import dataclass
154
+
from datetime import datetime
155
+
156
+
@dataclass
157
+
class ExternalEvent:
158
+
"""Raw event from external calendar provider."""
159
+
id: str
160
+
title: str
161
+
description: Optional[str] = None
162
+
start_time: Optional[datetime] = None
163
+
end_time: Optional[datetime] = None
164
+
location: Optional[str] = None
165
+
url: Optional[str] = None
166
+
attendees: List[str] = None
167
+
created_at: Optional[datetime] = None
168
+
updated_at: Optional[datetime] = None
169
+
raw_data: dict = None
170
+
171
+
class CalendarImporter(ABC):
172
+
"""Abstract base class for calendar importers."""
173
+
174
+
@abstractmethod
175
+
async def authenticate(self, credentials: dict) -> bool:
176
+
"""Authenticate with the calendar provider."""
177
+
pass
178
+
179
+
@abstractmethod
180
+
async def import_events(
181
+
self,
182
+
start_date: Optional[datetime] = None,
183
+
end_date: Optional[datetime] = None
184
+
) -> List[ExternalEvent]:
185
+
"""Import events from the calendar provider."""
186
+
pass
187
+
```
188
+
189
+
### ICS Implementation
190
+
191
+
```python
192
+
# src/atproto_calendar/import/ics.py
193
+
import httpx
194
+
from icalendar import Calendar
195
+
from typing import List, Optional, Union
196
+
from pathlib import Path
197
+
import structlog
198
+
199
+
logger = structlog.get_logger(__name__)
200
+
201
+
class ICSImporter(CalendarImporter):
202
+
"""Import events from ICS files or URLs."""
203
+
204
+
def __init__(self, source: Union[str, Path]):
205
+
self.source = source
206
+
self.is_url = isinstance(source, str) and source.startswith(('http://', 'https://'))
207
+
208
+
async def authenticate(self, credentials: dict) -> bool:
209
+
"""ICS files don't require authentication."""
210
+
return True
211
+
212
+
async def import_events(
213
+
self,
214
+
start_date: Optional[datetime] = None,
215
+
end_date: Optional[datetime] = None
216
+
) -> List[ExternalEvent]:
217
+
"""Import events from ICS source."""
218
+
try:
219
+
ics_data = await self._fetch_ics_data()
220
+
calendar = Calendar.from_ical(ics_data)
221
+
222
+
events = []
223
+
for component in calendar.walk():
224
+
if component.name == "VEVENT":
225
+
event = self._parse_event(component)
226
+
if self._is_in_date_range(event, start_date, end_date):
227
+
events.append(event)
228
+
229
+
logger.info("Imported events from ICS", count=len(events), source=str(self.source))
230
+
return events
231
+
232
+
except Exception as e:
233
+
logger.error("Failed to import ICS events", error=str(e), source=str(self.source))
234
+
raise ImportError(f"Failed to import ICS events: {e}")
235
+
236
+
async def _fetch_ics_data(self) -> bytes:
237
+
"""Fetch ICS data from URL or file."""
238
+
if self.is_url:
239
+
async with httpx.AsyncClient() as client:
240
+
response = await client.get(self.source)
241
+
response.raise_for_status()
242
+
return response.content
243
+
else:
244
+
return Path(self.source).read_bytes()
245
+
246
+
def _parse_event(self, component) -> ExternalEvent:
247
+
"""Parse iCalendar event component."""
248
+
return ExternalEvent(
249
+
id=str(component.get('UID', '')),
250
+
title=str(component.get('SUMMARY', '')),
251
+
description=str(component.get('DESCRIPTION', '')) if component.get('DESCRIPTION') else None,
252
+
start_time=self._parse_datetime(component.get('DTSTART')),
253
+
end_time=self._parse_datetime(component.get('DTEND')),
254
+
location=str(component.get('LOCATION', '')) if component.get('LOCATION') else None,
255
+
url=str(component.get('URL', '')) if component.get('URL') else None,
256
+
created_at=self._parse_datetime(component.get('CREATED')),
257
+
updated_at=self._parse_datetime(component.get('LAST-MODIFIED')),
258
+
raw_data=dict(component)
259
+
)
260
+
261
+
def _parse_datetime(self, dt_prop) -> Optional[datetime]:
262
+
"""Parse iCalendar datetime property."""
263
+
if not dt_prop:
264
+
return None
265
+
266
+
if hasattr(dt_prop, 'dt'):
267
+
return dt_prop.dt
268
+
return dt_prop
269
+
270
+
def _is_in_date_range(
271
+
self,
272
+
event: ExternalEvent,
273
+
start_date: Optional[datetime],
274
+
end_date: Optional[datetime]
275
+
) -> bool:
276
+
"""Check if event falls within specified date range."""
277
+
if not event.start_time:
278
+
return True
279
+
280
+
if start_date and event.start_time < start_date:
281
+
return False
282
+
283
+
if end_date and event.start_time > end_date:
284
+
return False
285
+
286
+
return True
287
+
```
288
+
289
+
### Google Calendar Implementation
290
+
291
+
```python
292
+
# src/atproto_calendar/import/google.py
293
+
from google.oauth2.credentials import Credentials
294
+
from googleapiclient.discovery import build
295
+
from typing import List, Optional
296
+
import structlog
297
+
298
+
logger = structlog.get_logger(__name__)
299
+
300
+
class GoogleCalendarImporter(CalendarImporter):
301
+
"""Import events from Google Calendar."""
302
+
303
+
def __init__(self):
304
+
self.service = None
305
+
self.credentials = None
306
+
307
+
async def authenticate(self, credentials: dict) -> bool:
308
+
"""Authenticate with Google Calendar API."""
309
+
try:
310
+
self.credentials = Credentials(
311
+
token=credentials.get('access_token'),
312
+
refresh_token=credentials.get('refresh_token'),
313
+
token_uri=credentials.get('token_uri'),
314
+
client_id=credentials.get('client_id'),
315
+
client_secret=credentials.get('client_secret')
316
+
)
317
+
318
+
self.service = build('calendar', 'v3', credentials=self.credentials)
319
+
return True
320
+
321
+
except Exception as e:
322
+
logger.error("Google Calendar authentication failed", error=str(e))
323
+
return False
324
+
325
+
async def import_events(
326
+
self,
327
+
start_date: Optional[datetime] = None,
328
+
end_date: Optional[datetime] = None
329
+
) -> List[ExternalEvent]:
330
+
"""Import events from Google Calendar."""
331
+
if not self.service:
332
+
raise ValueError("Not authenticated with Google Calendar")
333
+
334
+
try:
335
+
# Get calendar list
336
+
calendar_list = self.service.calendarList().list().execute()
337
+
338
+
all_events = []
339
+
for calendar in calendar_list.get('items', []):
340
+
calendar_id = calendar['id']
341
+
342
+
# Get events from this calendar
343
+
events_result = self.service.events().list(
344
+
calendarId=calendar_id,
345
+
timeMin=start_date.isoformat() if start_date else None,
346
+
timeMax=end_date.isoformat() if end_date else None,
347
+
singleEvents=True,
348
+
orderBy='startTime'
349
+
).execute()
350
+
351
+
events = events_result.get('items', [])
352
+
for event in events:
353
+
all_events.append(self._parse_google_event(event))
354
+
355
+
logger.info("Imported Google Calendar events", count=len(all_events))
356
+
return all_events
357
+
358
+
except Exception as e:
359
+
logger.error("Failed to import Google Calendar events", error=str(e))
360
+
raise ImportError(f"Failed to import Google Calendar events: {e}")
361
+
362
+
def _parse_google_event(self, event: dict) -> ExternalEvent:
363
+
"""Parse Google Calendar event."""
364
+
start = event.get('start', {})
365
+
end = event.get('end', {})
366
+
367
+
return ExternalEvent(
368
+
id=event.get('id'),
369
+
title=event.get('summary', ''),
370
+
description=event.get('description'),
371
+
start_time=self._parse_google_datetime(start),
372
+
end_time=self._parse_google_datetime(end),
373
+
location=event.get('location'),
374
+
url=event.get('htmlLink'),
375
+
attendees=[att.get('email') for att in event.get('attendees', [])],
376
+
created_at=self._parse_iso_datetime(event.get('created')),
377
+
updated_at=self._parse_iso_datetime(event.get('updated')),
378
+
raw_data=event
379
+
)
380
+
```
381
+
382
+
### 2. 🔄 Data Transformation
383
+
384
+
```python
385
+
# src/atproto_calendar/transform/converter.py
386
+
from typing import Optional, List
387
+
from datetime import datetime
388
+
import structlog
389
+
from ..import.base import ExternalEvent
390
+
391
+
logger = structlog.get_logger(__name__)
392
+
393
+
class ATProtoEvent:
394
+
"""AT Protocol event representation."""
395
+
396
+
def __init__(
397
+
self,
398
+
name: str,
399
+
created_at: datetime,
400
+
description: Optional[str] = None,
401
+
starts_at: Optional[datetime] = None,
402
+
ends_at: Optional[datetime] = None,
403
+
mode: Optional[str] = None,
404
+
status: Optional[str] = None,
405
+
locations: Optional[List[dict]] = None,
406
+
uris: Optional[List[str]] = None
407
+
):
408
+
self.name = name
409
+
self.created_at = created_at
410
+
self.description = description
411
+
self.starts_at = starts_at
412
+
self.ends_at = ends_at
413
+
self.mode = mode
414
+
self.status = status
415
+
self.locations = locations or []
416
+
self.uris = uris or []
417
+
418
+
def to_dict(self) -> dict:
419
+
"""Convert to dictionary for AT Protocol."""
420
+
data = {
421
+
"$type": "community.lexicon.calendar.event",
422
+
"name": self.name,
423
+
"createdAt": self.created_at.isoformat(),
424
+
}
425
+
426
+
if self.description:
427
+
data["description"] = self.description
428
+
if self.starts_at:
429
+
data["startsAt"] = self.starts_at.isoformat()
430
+
if self.ends_at:
431
+
data["endsAt"] = self.ends_at.isoformat()
432
+
if self.mode:
433
+
data["mode"] = f"community.lexicon.calendar.event#{self.mode}"
434
+
if self.status:
435
+
data["status"] = f"community.lexicon.calendar.event#{self.status}"
436
+
if self.locations:
437
+
data["locations"] = self.locations
438
+
if self.uris:
439
+
data["uris"] = self.uris
440
+
441
+
return data
442
+
443
+
class EventConverter:
444
+
"""Convert external events to AT Protocol format."""
445
+
446
+
@staticmethod
447
+
def convert_external_event(external_event: ExternalEvent) -> ATProtoEvent:
448
+
"""Convert external event to AT Protocol event."""
449
+
450
+
# Determine mode based on location/URL
451
+
mode = EventConverter._determine_mode(external_event)
452
+
453
+
# Determine status (default to scheduled)
454
+
status = "scheduled"
455
+
456
+
# Parse locations
457
+
locations = []
458
+
if external_event.location:
459
+
locations.append({
460
+
"address": external_event.location
461
+
})
462
+
463
+
# Parse URIs
464
+
uris = []
465
+
if external_event.url:
466
+
uris.append(external_event.url)
467
+
468
+
return ATProtoEvent(
469
+
name=external_event.title or "Untitled Event",
470
+
created_at=external_event.created_at or datetime.utcnow(),
471
+
description=external_event.description,
472
+
starts_at=external_event.start_time,
473
+
ends_at=external_event.end_time,
474
+
mode=mode,
475
+
status=status,
476
+
locations=locations,
477
+
uris=uris
478
+
)
479
+
480
+
@staticmethod
481
+
def _determine_mode(event: ExternalEvent) -> Optional[str]:
482
+
"""Determine event mode from external event data."""
483
+
if not event.location and not event.url:
484
+
return None
485
+
486
+
# Check for virtual meeting indicators
487
+
virtual_indicators = [
488
+
'zoom', 'teams', 'meet', 'webex', 'skype',
489
+
'virtual', 'online', 'remote'
490
+
]
491
+
492
+
location_text = (event.location or "").lower()
493
+
description_text = (event.description or "").lower()
494
+
url_text = (event.url or "").lower()
495
+
496
+
text_to_check = f"{location_text} {description_text} {url_text}"
497
+
498
+
has_virtual = any(indicator in text_to_check for indicator in virtual_indicators)
499
+
has_physical = event.location and not any(indicator in location_text for indicator in virtual_indicators)
500
+
501
+
if has_virtual and has_physical:
502
+
return "hybrid"
503
+
elif has_virtual:
504
+
return "virtual"
505
+
elif has_physical:
506
+
return "inperson"
507
+
508
+
return None
509
+
```
510
+
511
+
### 3. 🌐 REST API Server
512
+
513
+
```python
514
+
# src/atproto_calendar/api/server.py
515
+
from fastapi import FastAPI, HTTPException, BackgroundTasks
516
+
from fastapi.middleware.cors import CORSMiddleware
517
+
import structlog
518
+
from typing import List, Optional
519
+
from datetime import datetime
520
+
521
+
from .models.requests import ImportRequest, ImportResponse
522
+
from .models.events import EventModel
523
+
from ..import import get_importer
524
+
from ..transform.converter import EventConverter
525
+
from ..atproto.client import ATProtoClient
526
+
from ..config.settings import get_settings
527
+
528
+
logger = structlog.get_logger(__name__)
529
+
settings = get_settings()
530
+
531
+
app = FastAPI(
532
+
title="AT Protocol Calendar Import API",
533
+
description="Import calendar events from various providers into AT Protocol",
534
+
version="1.0.0"
535
+
)
536
+
537
+
app.add_middleware(
538
+
CORSMiddleware,
539
+
allow_origins=settings.cors_origins,
540
+
allow_credentials=True,
541
+
allow_methods=["*"],
542
+
allow_headers=["*"],
543
+
)
544
+
545
+
@app.post("/import", response_model=ImportResponse)
546
+
async def import_events(
547
+
request: ImportRequest,
548
+
background_tasks: BackgroundTasks
549
+
):
550
+
"""Import calendar events from external provider."""
551
+
try:
552
+
# Get appropriate importer
553
+
importer = get_importer(request.provider, request.source)
554
+
555
+
# Authenticate if needed
556
+
if request.credentials:
557
+
auth_success = await importer.authenticate(request.credentials)
558
+
if not auth_success:
559
+
raise HTTPException(status_code=401, detail="Authentication failed")
560
+
561
+
# Import events
562
+
external_events = await importer.import_events(
563
+
start_date=request.start_date,
564
+
end_date=request.end_date
565
+
)
566
+
567
+
# Convert to AT Protocol format
568
+
at_events = [
569
+
EventConverter.convert_external_event(event)
570
+
for event in external_events
571
+
]
572
+
573
+
# If AT Protocol credentials provided, publish in background
574
+
if request.atproto_credentials:
575
+
background_tasks.add_task(
576
+
publish_events_to_atproto,
577
+
at_events,
578
+
request.atproto_credentials
579
+
)
580
+
581
+
return ImportResponse(
582
+
success=True,
583
+
events_imported=len(at_events),
584
+
events=[EventModel.from_atproto_event(event) for event in at_events]
585
+
)
586
+
587
+
except Exception as e:
588
+
logger.error("Import failed", error=str(e), provider=request.provider)
589
+
raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
590
+
591
+
async def publish_events_to_atproto(events: List, credentials: dict):
592
+
"""Background task to publish events to AT Protocol."""
593
+
try:
594
+
client = ATProtoClient(
595
+
handle=credentials['handle'],
596
+
password=credentials['password'],
597
+
pds_url=credentials.get('pds_url')
598
+
)
599
+
600
+
await client.authenticate()
601
+
602
+
for event in events:
603
+
await client.create_record(
604
+
collection="community.lexicon.calendar.event",
605
+
record=event.to_dict()
606
+
)
607
+
608
+
logger.info("Published events to AT Protocol", count=len(events))
609
+
610
+
except Exception as e:
611
+
logger.error("Failed to publish events to AT Protocol", error=str(e))
612
+
613
+
@app.get("/health")
614
+
async def health_check():
615
+
"""Health check endpoint."""
616
+
return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
617
+
618
+
if __name__ == "__main__":
619
+
import uvicorn
620
+
uvicorn.run(
621
+
"atproto_calendar.api.server:app",
622
+
host=settings.api_host,
623
+
port=settings.api_port,
624
+
reload=settings.debug
625
+
)
626
+
```
627
+
628
+
### API Models
629
+
630
+
```python
631
+
# src/atproto_calendar/api/models/requests.py
632
+
from pydantic import BaseModel, Field
633
+
from typing import Optional, Dict, Any, List
634
+
from datetime import datetime
635
+
from enum import Enum
636
+
637
+
class CalendarProvider(str, Enum):
638
+
GOOGLE = "google"
639
+
OUTLOOK = "outlook"
640
+
APPLE = "apple"
641
+
ICS = "ics"
642
+
643
+
class ImportRequest(BaseModel):
644
+
"""Request model for importing calendar events."""
645
+
646
+
provider: CalendarProvider
647
+
source: Optional[str] = Field(None, description="ICS URL or file path")
648
+
credentials: Optional[Dict[str, Any]] = Field(None, description="OAuth credentials")
649
+
start_date: Optional[datetime] = Field(None, description="Import events from this date")
650
+
end_date: Optional[datetime] = Field(None, description="Import events until this date")
651
+
atproto_credentials: Optional[Dict[str, str]] = Field(None, description="AT Protocol credentials")
652
+
653
+
class ImportResponse(BaseModel):
654
+
"""Response model for import operation."""
655
+
656
+
success: bool
657
+
events_imported: int
658
+
message: Optional[str] = None
659
+
events: Optional[List['EventModel']] = None
660
+
```
661
+
662
+
### 4. 🖥️ CLI Interface
663
+
664
+
```python
665
+
# src/atproto_calendar/cli/commands.py
666
+
import click
667
+
import asyncio
668
+
from datetime import datetime
669
+
from pathlib import Path
670
+
import structlog
671
+
672
+
from ..import import get_importer
673
+
from ..transform.converter import EventConverter
674
+
from ..atproto.client import ATProtoClient
675
+
676
+
logger = structlog.get_logger(__name__)
677
+
678
+
@click.group()
679
+
def cli():
680
+
"""AT Protocol Calendar Import CLI."""
681
+
pass
682
+
683
+
@cli.command()
684
+
@click.option('--provider', type=click.Choice(['google', 'outlook', 'apple', 'ics']), required=True)
685
+
@click.option('--source', help='ICS file path or URL')
686
+
@click.option('--credentials-file', type=click.Path(exists=True), help='JSON file with OAuth credentials')
687
+
@click.option('--start-date', type=click.DateTime(), help='Import events from this date')
688
+
@click.option('--end-date', type=click.DateTime(), help='Import events until this date')
689
+
@click.option('--output', type=click.Path(), help='Save events to JSON file')
690
+
@click.option('--publish/--no-publish', default=False, help='Publish to AT Protocol')
691
+
@click.option('--handle', help='AT Protocol handle')
692
+
@click.option('--password', help='AT Protocol password')
693
+
@click.option('--pds-url', help='PDS URL (optional)')
694
+
def import_events(
695
+
provider: str,
696
+
source: str,
697
+
credentials_file: str,
698
+
start_date: datetime,
699
+
end_date: datetime,
700
+
output: str,
701
+
publish: bool,
702
+
handle: str,
703
+
password: str,
704
+
pds_url: str
705
+
):
706
+
"""Import calendar events from external provider."""
707
+
asyncio.run(_import_events(
708
+
provider, source, credentials_file, start_date, end_date,
709
+
output, publish, handle, password, pds_url
710
+
))
711
+
712
+
async def _import_events(
713
+
provider: str,
714
+
source: str,
715
+
credentials_file: str,
716
+
start_date: datetime,
717
+
end_date: datetime,
718
+
output: str,
719
+
publish: bool,
720
+
handle: str,
721
+
password: str,
722
+
pds_url: str
723
+
):
724
+
"""Async implementation of import command."""
725
+
try:
726
+
# Get importer
727
+
importer = get_importer(provider, source)
728
+
729
+
# Load credentials if provided
730
+
if credentials_file:
731
+
import json
732
+
with open(credentials_file) as f:
733
+
credentials = json.load(f)
734
+
735
+
auth_success = await importer.authenticate(credentials)
736
+
if not auth_success:
737
+
click.echo("❌ Authentication failed", err=True)
738
+
return
739
+
740
+
# Import events
741
+
click.echo(f"🔄 Importing events from {provider}...")
742
+
external_events = await importer.import_events(start_date, end_date)
743
+
744
+
# Convert to AT Protocol format
745
+
at_events = [
746
+
EventConverter.convert_external_event(event)
747
+
for event in external_events
748
+
]
749
+
750
+
click.echo(f"✅ Imported {len(at_events)} events")
751
+
752
+
# Save to file if requested
753
+
if output:
754
+
import json
755
+
with open(output, 'w') as f:
756
+
json.dump([event.to_dict() for event in at_events], f, indent=2, default=str)
757
+
click.echo(f"💾 Saved events to {output}")
758
+
759
+
# Publish to AT Protocol
760
+
client = ATProtoClient("user.bsky.social", "app-password")
761
+
await client.authenticate()
762
+
763
+
for event in at_events:
764
+
await client.create_record(
765
+
collection="community.lexicon.calendar.event",
766
+
record=event.to_dict()
767
+
)
768
+
```
769
+
770
+
---
771
+
772
+
## 🔐 AT Protocol Integration
773
+
774
+
### AT Protocol Client
775
+
776
+
```python
777
+
# src/atproto_calendar/atproto/client.py
778
+
import httpx
779
+
from typing import Optional, Dict, Any
780
+
import structlog
781
+
from datetime import datetime, timezone
782
+
783
+
logger = structlog.get_logger(__name__)
784
+
785
+
class ATProtoClient:
786
+
"""AT Protocol client for calendar event publishing."""
787
+
788
+
def __init__(self, handle: str, password: str, pds_url: Optional[str] = None):
789
+
self.handle = handle
790
+
self.password = password
791
+
self.pds_url = pds_url or "https://bsky.social"
792
+
self.session = None
793
+
self.access_jwt = None
794
+
self.refresh_jwt = None
795
+
self.did = None
796
+
797
+
async def authenticate(self) -> bool:
798
+
"""Authenticate with AT Protocol PDS."""
799
+
try:
800
+
async with httpx.AsyncClient() as client:
801
+
response = await client.post(
802
+
f"{self.pds_url}/xrpc/com.atproto.server.createSession",
803
+
json={
804
+
"identifier": self.handle,
805
+
"password": self.password
806
+
}
807
+
)
808
+
response.raise_for_status()
809
+
810
+
data = response.json()
811
+
self.access_jwt = data["accessJwt"]
812
+
self.refresh_jwt = data["refreshJwt"]
813
+
self.did = data["did"]
814
+
815
+
logger.info("AT Protocol authentication successful", handle=self.handle)
816
+
return True
817
+
818
+
except Exception as e:
819
+
logger.error("AT Protocol authentication failed", error=str(e))
820
+
return False
821
+
822
+
async def create_record(self, collection: str, record: Dict[str, Any]) -> Optional[str]:
823
+
"""Create a record in the AT Protocol repository."""
824
+
if not self.access_jwt:
825
+
raise ValueError("Not authenticated with AT Protocol")
826
+
827
+
try:
828
+
async with httpx.AsyncClient() as client:
829
+
response = await client.post(
830
+
f"{self.pds_url}/xrpc/com.atproto.repo.createRecord",
831
+
headers={
832
+
"Authorization": f"Bearer {self.access_jwt}",
833
+
"Content-Type": "application/json"
834
+
},
835
+
json={
836
+
"repo": self.did,
837
+
"collection": collection,
838
+
"record": record
839
+
}
840
+
)
841
+
response.raise_for_status()
842
+
843
+
data = response.json()
844
+
uri = data.get("uri")
845
+
846
+
logger.info("Created AT Protocol record", uri=uri, collection=collection)
847
+
return uri
848
+
849
+
except Exception as e:
850
+
logger.error("Failed to create AT Protocol record", error=str(e))
851
+
raise
852
+
853
+
async def refresh_session(self) -> bool:
854
+
"""Refresh the access token using refresh token."""
855
+
if not self.refresh_jwt:
856
+
return False
857
+
858
+
try:
859
+
async with httpx.AsyncClient() as client:
860
+
response = await client.post(
861
+
f"{self.pds_url}/xrpc/com.atproto.server.refreshSession",
862
+
headers={
863
+
"Authorization": f"Bearer {self.refresh_jwt}"
864
+
}
865
+
)
866
+
response.raise_for_status()
867
+
868
+
data = response.json()
869
+
self.access_jwt = data["accessJwt"]
870
+
self.refresh_jwt = data["refreshJwt"]
871
+
872
+
return True
873
+
874
+
except Exception as e:
875
+
logger.error("Failed to refresh AT Protocol session", error=str(e))
876
+
return False
877
+
```
878
+
879
+
### Configuration Management
880
+
881
+
```python
882
+
# src/atproto_calendar/config/settings.py
883
+
from pydantic_settings import BaseSettings
884
+
from typing import List, Optional
885
+
from pathlib import Path
886
+
887
+
class Settings(BaseSettings):
888
+
"""Application settings."""
889
+
890
+
# API Settings
891
+
api_host: str = "0.0.0.0"
892
+
api_port: int = 8000
893
+
debug: bool = False
894
+
cors_origins: List[str] = ["*"]
895
+
896
+
# AT Protocol Settings
897
+
default_pds_url: str = "https://bsky.social"
898
+
899
+
# Database Settings
900
+
postgres_url: Optional[str] = None
901
+
redis_url: Optional[str] = None
902
+
903
+
# OAuth Settings
904
+
google_client_id: Optional[str] = None
905
+
google_client_secret: Optional[str] = None
906
+
microsoft_client_id: Optional[str] = None
907
+
microsoft_client_secret: Optional[str] = None
908
+
909
+
# Logging
910
+
log_level: str = "INFO"
911
+
912
+
# Cache Settings
913
+
dedup_cache_ttl: int = 3600 # 1 hour
914
+
915
+
class Config:
916
+
env_file = ".env"
917
+
env_file_encoding = "utf-8"
918
+
919
+
_settings = None
920
+
921
+
def get_settings() -> Settings:
922
+
"""Get application settings singleton."""
923
+
global _settings
924
+
if _settings is None:
925
+
_settings = Settings()
926
+
return _settings
927
+
```
928
+
929
+
---
930
+
931
+
## 🗄️ Deduplication System
932
+
933
+
### Cache Implementation
934
+
935
+
```python
936
+
# src/atproto_calendar/dedup/cache.py
937
+
import hashlib
938
+
from typing import Set, Optional
939
+
from datetime import datetime, timedelta
940
+
import structlog
941
+
942
+
logger = structlog.get_logger(__name__)
943
+
944
+
class DedupCache:
945
+
"""Base class for deduplication cache."""
946
+
947
+
async def add_event_hash(self, user_did: str, event_hash: str) -> bool:
948
+
"""Add event hash to cache. Returns True if new, False if duplicate."""
949
+
raise NotImplementedError
950
+
951
+
async def has_event_hash(self, user_did: str, event_hash: str) -> bool:
952
+
"""Check if event hash exists in cache."""
953
+
raise NotImplementedError
954
+
955
+
async def clear_user_cache(self, user_did: str) -> None:
956
+
"""Clear all cached hashes for a user."""
957
+
raise NotImplementedError
958
+
959
+
class InMemoryDedupCache(DedupCache):
960
+
"""In-memory deduplication cache."""
961
+
962
+
def __init__(self, ttl_seconds: int = 3600):
963
+
self.cache = {} # user_did -> {hash: timestamp}
964
+
self.ttl_seconds = ttl_seconds
965
+
966
+
async def add_event_hash(self, user_did: str, event_hash: str) -> bool:
967
+
"""Add event hash to cache."""
968
+
await self._cleanup_expired(user_did)
969
+
970
+
if user_did not in self.cache:
971
+
self.cache[user_did] = {}
972
+
973
+
if event_hash in self.cache[user_did]:
974
+
return False # Duplicate
975
+
976
+
self.cache[user_did][event_hash] = datetime.utcnow()
977
+
return True # New
978
+
979
+
async def has_event_hash(self, user_did: str, event_hash: str) -> bool:
980
+
"""Check if event hash exists."""
981
+
await self._cleanup_expired(user_did)
982
+
983
+
if user_did not in self.cache:
984
+
return False
985
+
986
+
return event_hash in self.cache[user_did]
987
+
988
+
async def clear_user_cache(self, user_did: str) -> None:
989
+
"""Clear user cache."""
990
+
if user_did in self.cache:
991
+
del self.cache[user_did]
992
+
993
+
async def _cleanup_expired(self, user_did: str) -> None:
994
+
"""Remove expired entries for user."""
995
+
if user_did not in self.cache:
996
+
return
997
+
998
+
cutoff = datetime.utcnow() - timedelta(seconds=self.ttl_seconds)
999
+
expired_hashes = [
1000
+
h for h, timestamp in self.cache[user_did].items()
1001
+
if timestamp < cutoff
1002
+
]
1003
+
1004
+
for hash_key in expired_hashes:
1005
+
del self.cache[user_did][hash_key]
1006
+
1007
+
class RedisDedupCache(DedupCache):
1008
+
"""Redis-based deduplication cache."""
1009
+
1010
+
def __init__(self, redis_url: str, ttl_seconds: int = 3600):
1011
+
import redis.asyncio as redis
1012
+
self.redis = redis.from_url(redis_url)
1013
+
self.ttl_seconds = ttl_seconds
1014
+
1015
+
async def add_event_hash(self, user_did: str, event_hash: str) -> bool:
1016
+
"""Add event hash to Redis cache."""
1017
+
key = f"dedup:{user_did}:{event_hash}"
1018
+
1019
+
# Use SET with NX (only if not exists) and EX (expiration)
1020
+
result = await self.redis.set(key, "1", nx=True, ex=self.ttl_seconds)
1021
+
return result is not None # True if new, None if existed
1022
+
1023
+
async def has_event_hash(self, user_did: str, event_hash: str) -> bool:
1024
+
"""Check if event hash exists in Redis."""
1025
+
key = f"dedup:{user_did}:{event_hash}"
1026
+
result = await self.redis.exists(key)
1027
+
return result > 0
1028
+
1029
+
async def clear_user_cache(self, user_did: str) -> None:
1030
+
"""Clear all cached hashes for a user."""
1031
+
pattern = f"dedup:{user_did}:*"
1032
+
keys = await self.redis.keys(pattern)
1033
+
if keys:
1034
+
await self.redis.delete(*keys)
1035
+
1036
+
def create_event_hash(event_name: str, starts_at: Optional[datetime], location: Optional[str]) -> str:
1037
+
"""Create a hash for event deduplication."""
1038
+
hash_input = f"{event_name}|{starts_at}|{location or ''}"
1039
+
return hashlib.sha256(hash_input.encode()).hexdigest()[:16]
1040
+
1041
+
class EventDeduplicator:
1042
+
"""Event deduplication service."""
1043
+
1044
+
def __init__(self, cache: DedupCache):
1045
+
self.cache = cache
1046
+
1047
+
async def is_duplicate(
1048
+
self,
1049
+
user_did: str,
1050
+
event_name: str,
1051
+
starts_at: Optional[datetime],
1052
+
location: Optional[str]
1053
+
) -> bool:
1054
+
"""Check if event is a duplicate."""
1055
+
event_hash = create_event_hash(event_name, starts_at, location)
1056
+
return await self.cache.has_event_hash(user_did, event_hash)
1057
+
1058
+
async def mark_as_processed(
1059
+
self,
1060
+
user_did: str,
1061
+
event_name: str,
1062
+
starts_at: Optional[datetime],
1063
+
location: Optional[str]
1064
+
) -> bool:
1065
+
"""Mark event as processed. Returns True if new, False if duplicate."""
1066
+
event_hash = create_event_hash(event_name, starts_at, location)
1067
+
return await self.cache.add_event_hash(user_did, event_hash)
1068
+
```
1069
+
1070
+
---
1071
+
1072
+
## 🔒 Enhanced Security & OAuth
1073
+
1074
+
### OAuth2 Helpers
1075
+
1076
+
```python
1077
+
# src/atproto_calendar/auth/google.py
1078
+
from authlib.integrations.httpx_client import AsyncOAuth2Client
1079
+
from typing import Dict, Any
1080
+
import structlog
1081
+
1082
+
logger = structlog.get_logger(__name__)
1083
+
1084
+
class GoogleOAuthHandler:
1085
+
"""Google OAuth2 authentication handler."""
1086
+
1087
+
def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
1088
+
self.client_id = client_id
1089
+
self.client_secret = client_secret
1090
+
self.redirect_uri = redirect_uri
1091
+
self.scope = [
1092
+
'https://www.googleapis.com/auth/calendar.readonly',
1093
+
'https://www.googleapis.com/auth/userinfo.email'
1094
+
]
1095
+
1096
+
def get_authorization_url(self) -> str:
1097
+
"""Generate OAuth2 authorization URL."""
1098
+
client = AsyncOAuth2Client(
1099
+
client_id=self.client_id,
1100
+
redirect_uri=self.redirect_uri,
1101
+
scope=' '.join(self.scope)
1102
+
)
1103
+
1104
+
authorization_url, state = client.create_authorization_url(
1105
+
'https://accounts.google.com/o/oauth2/auth',
1106
+
access_type='offline',
1107
+
prompt='consent'
1108
+
)
1109
+
1110
+
return authorization_url
1111
+
1112
+
async def exchange_code_for_tokens(self, authorization_code: str) -> Dict[str, Any]:
1113
+
"""Exchange authorization code for access tokens."""
1114
+
try:
1115
+
client = AsyncOAuth2Client(
1116
+
client_id=self.client_id,
1117
+
client_secret=self.client_secret,
1118
+
redirect_uri=self.redirect_uri
1119
+
)
1120
+
1121
+
token = await client.fetch_token(
1122
+
'https://oauth2.googleapis.com/token',
1123
+
authorization_response=authorization_code
1124
+
)
1125
+
1126
+
return {
1127
+
'access_token': token['access_token'],
1128
+
'refresh_token': token.get('refresh_token'),
1129
+
'expires_at': token.get('expires_at'),
1130
+
'token_type': token.get('token_type', 'Bearer')
1131
+
}
1132
+
1133
+
except Exception as e:
1134
+
logger.error("Failed to exchange Google OAuth code", error=str(e))
1135
+
raise
1136
+
1137
+
async def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]:
1138
+
"""Refresh access token using refresh token."""
1139
+
try:
1140
+
client = AsyncOAuth2Client(
1141
+
client_id=self.client_id,
1142
+
client_secret=self.client_secret
1143
+
)
1144
+
1145
+
token = await client.refresh_token(
1146
+
'https://oauth2.googleapis.com/token',
1147
+
refresh_token=refresh_token
1148
+
)
1149
+
1150
+
return {
1151
+
'access_token': token['access_token'],
1152
+
'expires_at': token.get('expires_at'),
1153
+
'token_type': token.get('token_type', 'Bearer')
1154
+
}
1155
+
1156
+
except Exception as e:
1157
+
logger.error("Failed to refresh Google access token", error=str(e))
1158
+
raise
1159
+
```
1160
+
1161
+
### Security Utilities
1162
+
1163
+
```python
1164
+
# src/atproto_calendar/auth/security.py
1165
+
from cryptography.fernet import Fernet
1166
+
import base64
1167
+
import os
1168
+
from typing import Optional
1169
+
1170
+
class TokenEncryption:
1171
+
"""Utility for encrypting/decrypting sensitive tokens."""
1172
+
1173
+
def __init__(self, key: Optional[str] = None):
1174
+
if key:
1175
+
self.fernet = Fernet(key.encode())
1176
+
else:
1177
+
# Generate or load from environment
1178
+
key = os.getenv('ENCRYPTION_KEY')
1179
+
if not key:
1180
+
key = Fernet.generate_key()
1181
+
print(f"Generated encryption key: {key.decode()}")
1182
+
print("Store this in your ENCRYPTION_KEY environment variable")
1183
+
self.fernet = Fernet(key if isinstance(key, bytes) else key.encode())
1184
+
1185
+
def encrypt_token(self, token: str) -> str:
1186
+
"""Encrypt a token."""
1187
+
encrypted = self.fernet.encrypt(token.encode())
1188
+
return base64.urlsafe_b64encode(encrypted).decode()
1189
+
1190
+
def decrypt_token(self, encrypted_token: str) -> str:
1191
+
"""Decrypt a token."""
1192
+
encrypted_bytes = base64.urlsafe_b64decode(encrypted_token.encode())
1193
+
decrypted = self.fernet.decrypt(encrypted_bytes)
1194
+
return decrypted.decode()
1195
+
1196
+
def sanitize_log_data(data: dict) -> dict:
1197
+
"""Remove sensitive data from logs."""
1198
+
sensitive_keys = {
1199
+
'password', 'token', 'access_token', 'refresh_token',
1200
+
'client_secret', 'private_key', 'jwt'
1201
+
}
1202
+
1203
+
sanitized = {}
1204
+
for key, value in data.items():
1205
+
if any(sensitive in key.lower() for sensitive in sensitive_keys):
1206
+
sanitized[key] = "[REDACTED]"
1207
+
else:
1208
+
sanitized[key] = value
1209
+
1210
+
return sanitized
1211
+
```
1212
+
1213
+
---
1214
+
1215
+
## 🧪 Testing Framework
1216
+
1217
+
### Test Configuration
1218
+
1219
+
```python
1220
+
# tests/conftest.py
1221
+
import pytest
1222
+
import asyncio
1223
+
from unittest.mock import AsyncMock, MagicMock
1224
+
from datetime import datetime
1225
+
import tempfile
1226
+
import os
1227
+
1228
+
from atproto_calendar.import.ics import ICSImporter
1229
+
from atproto_calendar.import.base import ExternalEvent
1230
+
from atproto_calendar.dedup.cache import InMemoryDedupCache
1231
+
from atproto_calendar.config.settings import Settings
1232
+
1233
+
@pytest.fixture
1234
+
def event_loop():
1235
+
"""Create an event loop for async tests."""
1236
+
loop = asyncio.new_event_loop()
1237
+
yield loop
1238
+
loop.close()
1239
+
1240
+
@pytest.fixture
1241
+
def sample_external_event():
1242
+
"""Sample external event for testing."""
1243
+
return ExternalEvent(
1244
+
id="test-event-1",
1245
+
title="Test Meeting",
1246
+
description="A test meeting",
1247
+
start_time=datetime(2025, 6, 1, 10, 0),
1248
+
end_time=datetime(2025, 6, 1, 11, 0),
1249
+
location="Conference Room A",
1250
+
url="https://example.com/meeting",
1251
+
attendees=["user1@example.com", "user2@example.com"],
1252
+
created_at=datetime(2025, 5, 30, 12, 0),
1253
+
updated_at=datetime(2025, 5, 30, 12, 0)
1254
+
)
1255
+
1256
+
@pytest.fixture
1257
+
def sample_ics_content():
1258
+
"""Sample ICS content for testing."""
1259
+
return """BEGIN:VCALENDAR
1260
+
VERSION:2.0
1261
+
PRODID:-//Test//Test//EN
1262
+
BEGIN:VEVENT
1263
+
UID:test-event-1@example.com
1264
+
DTSTART:20250601T100000Z
1265
+
DTEND:20250601T110000Z
1266
+
SUMMARY:Test Meeting
1267
+
DESCRIPTION:A test meeting
1268
+
LOCATION:Conference Room A
1269
+
URL:https://example.com/meeting
1270
+
CREATED:20250530T120000Z
1271
+
LAST-MODIFIED:20250530T120000Z
1272
+
END:VEVENT
1273
+
END:VCALENDAR"""
1274
+
1275
+
@pytest.fixture
1276
+
def temp_ics_file(sample_ics_content):
1277
+
"""Create a temporary ICS file for testing."""
1278
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.ics', delete=False) as f:
1279
+
f.write(sample_ics_content)
1280
+
temp_path = f.name
1281
+
1282
+
yield temp_path
1283
+
1284
+
# Cleanup
1285
+
try:
1286
+
os.unlink(temp_path)
1287
+
except FileNotFoundError:
1288
+
pass
1289
+
1290
+
@pytest.fixture
1291
+
def dedup_cache():
1292
+
"""In-memory deduplication cache for testing."""
1293
+
return InMemoryDedupCache(ttl_seconds=3600)
1294
+
1295
+
@pytest.fixture
1296
+
def mock_atproto_client():
1297
+
"""Mock AT Protocol client."""
1298
+
client = AsyncMock()
1299
+
client.authenticate.return_value = True
1300
+
client.create_record.return_value = "at://did:example/collection/record"
1301
+
return client
1302
+
1303
+
@pytest.fixture
1304
+
def test_settings():
1305
+
"""Test settings."""
1306
+
return Settings(
1307
+
debug=True,
1308
+
log_level="DEBUG",
1309
+
api_port=8001,
1310
+
cors_origins=["http://localhost:3000"]
1311
+
)
1312
+
```
1313
+
1314
+
### ICS Import Tests
1315
+
1316
+
```python
1317
+
# tests/test_import/test_ics.py
1318
+
import pytest
1319
+
from datetime import datetime
1320
+
from unittest.mock import AsyncMock, patch
1321
+
1322
+
from atproto_calendar.import.ics import ICSImporter
1323
+
from atproto_calendar.import.base import ExternalEvent
1324
+
1325
+
class TestICSImporter:
1326
+
"""Test ICS file/URL importing."""
1327
+
1328
+
@pytest.mark.asyncio
1329
+
async def test_import_from_file(self, temp_ics_file):
1330
+
"""Test importing events from ICS file."""
1331
+
importer = ICSImporter(temp_ics_file)
1332
+
1333
+
# Authentication should always succeed for ICS
1334
+
auth_result = await importer.authenticate({})
1335
+
assert auth_result is True
1336
+
1337
+
# Import events
1338
+
events = await importer.import_events()
1339
+
1340
+
assert len(events) == 1
1341
+
event = events[0]
1342
+
1343
+
assert event.id == "test-event-1@example.com"
1344
+
assert event.title == "Test Meeting"
1345
+
assert event.description == "A test meeting"
1346
+
assert event.location == "Conference Room A"
1347
+
assert event.url == "https://example.com/meeting"
1348
+
assert event.start_time == datetime(2025, 6, 1, 10, 0)
1349
+
assert event.end_time == datetime(2025, 6, 1, 11, 0)
1350
+
1351
+
@pytest.mark.asyncio
1352
+
async def test_import_from_url(self, sample_ics_content):
1353
+
"""Test importing events from ICS URL."""
1354
+
test_url = "https://example.com/calendar.ics"
1355
+
1356
+
with patch('httpx.AsyncClient') as mock_client:
1357
+
# Mock HTTP response
1358
+
mock_response = AsyncMock()
1359
+
mock_response.content = sample_ics_content.encode()
1360
+
mock_response.raise_for_status.return_value = None
1361
+
1362
+
mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
1363
+
1364
+
importer = ICSImporter(test_url)
1365
+
events = await importer.import_events()
1366
+
1367
+
assert len(events) == 1
1368
+
assert events[0].title == "Test Meeting"
1369
+
1370
+
@pytest.mark.asyncio
1371
+
async def test_date_filtering(self, temp_ics_file):
1372
+
"""Test filtering events by date range."""
1373
+
importer = ICSImporter(temp_ics_file)
1374
+
1375
+
# Filter events after the event date
1376
+
events = await importer.import_events(
1377
+
start_date=datetime(2025, 6, 2, 0, 0)
1378
+
)
1379
+
assert len(events) == 0
1380
+
1381
+
# Filter events before the event date
1382
+
events = await importer.import_events(
1383
+
end_date=datetime(2025, 5, 31, 23, 59)
1384
+
)
1385
+
assert len(events) == 0
1386
+
1387
+
# Filter events within range
1388
+
events = await importer.import_events(
1389
+
start_date=datetime(2025, 5, 31, 0, 0),
1390
+
end_date=datetime(2025, 6, 2, 0, 0)
1391
+
)
1392
+
assert len(events) == 1
1393
+
1394
+
@pytest.mark.asyncio
1395
+
async def test_invalid_ics_file(self):
1396
+
"""Test handling of invalid ICS content."""
1397
+
with patch('pathlib.Path.read_bytes') as mock_read:
1398
+
mock_read.return_value = b"INVALID ICS CONTENT"
1399
+
1400
+
importer = ICSImporter("invalid.ics")
1401
+
1402
+
with pytest.raises(ImportError):
1403
+
await importer.import_events()
1404
+
```
1405
+
1406
+
### Transformation Tests
1407
+
1408
+
```python
1409
+
# tests/test_transform/test_converter.py
1410
+
import pytest
1411
+
from datetime import datetime
1412
+
1413
+
from atproto_calendar.transform.converter import EventConverter, ATProtoEvent
1414
+
from atproto_calendar.import.base import ExternalEvent
1415
+
1416
+
class TestEventConverter:
1417
+
"""Test event transformation to AT Protocol format."""
1418
+
1419
+
def test_basic_conversion(self, sample_external_event):
1420
+
"""Test basic event conversion."""
1421
+
at_event = EventConverter.convert_external_event(sample_external_event)
1422
+
1423
+
assert isinstance(at_event, ATProtoEvent)
1424
+
assert at_event.name == "Test Meeting"
1425
+
assert at_event.description == "A test meeting"
1426
+
assert at_event.starts_at == datetime(2025, 6, 1, 10, 0)
1427
+
assert at_event.ends_at == datetime(2025, 6, 1, 11, 0)
1428
+
assert at_event.status == "scheduled"
1429
+
assert len(at_event.locations) == 1
1430
+
assert at_event.locations[0]["address"] == "Conference Room A"
1431
+
assert len(at_event.uris) == 1
1432
+
assert at_event.uris[0] == "https://example.com/meeting"
1433
+
1434
+
def test_mode_detection_virtual(self):
1435
+
"""Test detection of virtual events."""
1436
+
event = ExternalEvent(
1437
+
id="virtual-event",
1438
+
title="Zoom Meeting",
1439
+
location="https://zoom.us/j/123456789",
1440
+
url="https://zoom.us/j/123456789"
1441
+
)
1442
+
1443
+
at_event = EventConverter.convert_external_event(event)
1444
+
assert at_event.mode == "virtual"
1445
+
1446
+
def test_mode_detection_hybrid(self):
1447
+
"""Test detection of hybrid events."""
1448
+
event = ExternalEvent(
1449
+
id="hybrid-event",
1450
+
title="Hybrid Meeting",
1451
+
location="Conference Room A",
1452
+
description="Join via Zoom: https://zoom.us/j/123456789"
1453
+
)
1454
+
1455
+
at_event = EventConverter.convert_external_event(event)
1456
+
assert at_event.mode == "hybrid"
1457
+
1458
+
def test_mode_detection_inperson(self):
1459
+
"""Test detection of in-person events."""
1460
+
event = ExternalEvent(
1461
+
id="inperson-event",
1462
+
title="In-Person Meeting",
1463
+
location="123 Main St, City, State"
1464
+
)
1465
+
1466
+
at_event = EventConverter.convert_external_event(event)
1467
+
assert at_event.mode == "inperson"
1468
+
1469
+
def test_to_dict_format(self, sample_external_event):
1470
+
"""Test AT Protocol dictionary format."""
1471
+
at_event = EventConverter.convert_external_event(sample_external_event)
1472
+
data = at_event.to_dict()
1473
+
1474
+
assert data["$type"] == "community.lexicon.calendar.event"
1475
+
assert data["name"] == "Test Meeting"
1476
+
assert data["description"] == "A test meeting"
1477
+
assert "createdAt" in data
1478
+
assert "startsAt" in data
1479
+
assert "endsAt" in data
1480
+
assert data["status"] == "community.lexicon.calendar.event#scheduled"
1481
+
assert data["mode"] == "community.lexicon.calendar.event#inperson"
1482
+
assert isinstance(data["locations"], list)
1483
+
assert isinstance(data["uris"], list)
1484
+
```
1485
+
1486
+
---
1487
+
1488
+
## 🐳 Docker & Deployment
1489
+
1490
+
### Dockerfile
1491
+
1492
+
```dockerfile
1493
+
# docker/Dockerfile
1494
+
FROM python:3.11-slim
1495
+
1496
+
WORKDIR /app
1497
+
1498
+
# Install system dependencies
1499
+
RUN apt-get update && apt-get install -y \
1500
+
gcc \
1501
+
g++ \
1502
+
libpq-dev \
1503
+
&& rm -rf /var/lib/apt/lists/*
1504
+
1505
+
# Copy requirements first for better caching
1506
+
COPY requirements/ requirements/
1507
+
COPY requirements.txt .
1508
+
1509
+
# Install Python dependencies
1510
+
RUN pip install --no-cache-dir -r requirements.txt
1511
+
1512
+
# Copy application code
1513
+
COPY src/ src/
1514
+
COPY pyproject.toml .
1515
+
1516
+
# Install the package
1517
+
RUN pip install -e .
1518
+
1519
+
# Create non-root user
1520
+
RUN useradd --create-home --shell /bin/bash app
1521
+
USER app
1522
+
1523
+
# Expose API port
1524
+
EXPOSE 8000
1525
+
1526
+
# Default command
1527
+
CMD ["python", "-m", "atproto_calendar.api.server"]
1528
+
```
1529
+
1530
+
### Docker Compose
1531
+
1532
+
```yaml
1533
+
# docker/docker-compose.yml
1534
+
version: '3.8'
1535
+
1536
+
services:
1537
+
calendar-import:
1538
+
build:
1539
+
context: ..
1540
+
dockerfile: docker/Dockerfile
1541
+
ports:
1542
+
- "8000:8000"
1543
+
environment:
1544
+
- LOG_LEVEL=INFO
1545
+
- REDIS_URL=redis://redis:6379
1546
+
- POSTGRES_URL=postgresql://postgres:postgres@postgres:5432/calendar_import
1547
+
depends_on:
1548
+
- redis
1549
+
- postgres
1550
+
volumes:
1551
+
- ../config:/app/config:ro
1552
+
restart: unless-stopped
1553
+
1554
+
redis:
1555
+
image: redis:7-alpine
1556
+
ports:
1557
+
- "6379:6379"
1558
+
volumes:
1559
+
- redis_data:/data
1560
+
restart: unless-stopped
1561
+
1562
+
postgres:
1563
+
image: postgres:15-alpine
1564
+
ports:
1565
+
- "5432:5432"
1566
+
environment:
1567
+
- POSTGRES_DB=calendar_import
1568
+
- POSTGRES_USER=postgres
1569
+
- POSTGRES_PASSWORD=postgres
1570
+
volumes:
1571
+
- postgres_data:/var/lib/postgresql/data
1572
+
restart: unless-stopped
1573
+
1574
+
volumes:
1575
+
redis_data:
1576
+
postgres_data:
1577
+
```
1578
+
1579
+
---
1580
+
1581
+
## 🚀 Production Deployment
1582
+
1583
+
### Kubernetes Deployment
1584
+
1585
+
```yaml
1586
+
# k8s/deployment.yaml
1587
+
apiVersion: apps/v1
1588
+
kind: Deployment
1589
+
metadata:
1590
+
name: atproto-calendar-import
1591
+
labels:
1592
+
app: atproto-calendar-import
1593
+
spec:
1594
+
replicas: 3
1595
+
selector:
1596
+
matchLabels:
1597
+
app: atproto-calendar-import
1598
+
template:
1599
+
metadata:
1600
+
labels:
1601
+
app: atproto-calendar-import
1602
+
spec:
1603
+
containers:
1604
+
- name: calendar-import
1605
+
image: atproto-calendar-import:latest
1606
+
ports:
1607
+
- containerPort: 8000
1608
+
env:
1609
+
- name: LOG_LEVEL
1610
+
value: "INFO"
1611
+
- name: REDIS_URL
1612
+
valueFrom:
1613
+
secretKeyRef:
1614
+
name: calendar-secrets
1615
+
key: redis-url
1616
+
- name: POSTGRES_URL
1617
+
valueFrom:
1618
+
secretKeyRef:
1619
+
name: calendar-secrets
1620
+
key: postgres-url
1621
+
resources:
1622
+
requests:
1623
+
memory: "256Mi"
1624
+
cpu: "250m"
1625
+
limits:
1626
+
memory: "512Mi"
1627
+
cpu: "500m"
1628
+
livenessProbe:
1629
+
httpGet:
1630
+
path: /health
1631
+
port: 8000
1632
+
initialDelaySeconds: 30
1633
+
periodSeconds: 10
1634
+
readinessProbe:
1635
+
httpGet:
1636
+
path: /health
1637
+
port: 8000
1638
+
initialDelaySeconds: 5
1639
+
periodSeconds: 5
1640
+
---
1641
+
apiVersion: v1
1642
+
kind: Service
1643
+
metadata:
1644
+
name: atproto-calendar-import-service
1645
+
spec:
1646
+
selector:
1647
+
app: atproto-calendar-import
1648
+
ports:
1649
+
- protocol: TCP
1650
+
port: 80
1651
+
targetPort: 8000
1652
+
type: LoadBalancer
1653
+
```
1654
+
1655
+
This Python implementation provides:
1656
+
1657
+
1. **Complete ICS support** - Import from files or URLs
1658
+
2. **REST API server** - FastAPI-based API for integration
1659
+
3. **Comprehensive CLI** - Full command-line interface
1660
+
4. **Multiple calendar providers** - Google, Outlook, Apple, ICS
1661
+
5. **AT Protocol integration** - Native ATP client
1662
+
6. **Robust testing** - pytest-based test suite
1663
+
7. **Production-ready** - Docker, Kubernetes, monitoring
1664
+
8. **Security features** - Token encryption, OAuth2 helpers
1665
+
9. **Caching & deduplication** - Redis and in-memory options
1666
+
10. **Structured logging** - Using structlog for observability
1667
+
1668
+
The architecture is modular and extensible, making it easy to add new calendar providers or modify the AT Protocol integration. to AT Protocol if requested
1669
+
if publish:
1670
+
if not handle or not password:
1671
+
click.echo("❌ Handle and password required for publishing", err=True)
1672
+
return
1673
+
1674
+
click.echo("🚀 Publishing to AT Protocol...")
1675
+
client = ATProtoClient(handle, password, pds_url)
1676
+
await client.authenticate()
1677
+
1678
+
for event in at_events:
1679
+
await client.create_record(
1680
+
collection="community.lexicon.calendar.event",
1681
+
record=event.to_dict()
1682
+
)
1683
+
1684
+
click.echo(f"✅ Published {len(at_events)} events to AT Protocol")
1685
+
1686
+
except Exception as e:
1687
+
logger.error("Import failed", error=str(e))
1688
+
click.echo(f"❌ Import failed: {e}", err=True)
1689
+
1690
+
@cli.command()
1691
+
@click.argument('ics_file', type=click.Path(exists=True))
1692
+
def validate_ics(ics_file: str):
1693
+
"""Validate an ICS file."""
1694
+
asyncio.run(_validate_ics(ics_file))
1695
+
1696
+
async def _validate_ics(ics_file: str):
1697
+
"""Async implementation of ICS validation."""
1698
+
try:
1699
+
from ..import.ics import ICSImporter
1700
+
1701
+
importer = ICSImporter(ics_file)
1702
+
events = await importer.import_events()
1703
+
1704
+
click.echo(f"✅ Valid ICS file with {len(events)} events")
1705
+
1706
+
for i, event in enumerate(events[:5]): # Show first 5 events
1707
+
click.echo(f" {i+1}. {event.title}")
1708
+
if event.start_time:
1709
+
click.echo(f" 📅 {event.start_time}")
1710
+
1711
+
if len(events) > 5:
1712
+
click.echo(f" ... and {len(events) - 5} more events")
1713
+
1714
+
except Exception as e:
1715
+
click.echo(f"❌ Invalid ICS file: {e}", err=True)
1716
+
1717
+
if __name__ == "__main__":
1718
+
cli()
1719
+
```
1720
+
1721
+
---
1722
+
1723
+
## 🚀 Usage Examples
1724
+
1725
+
### CLI Usage
1726
+
1727
+
```bash
1728
+
# Import from ICS file
1729
+
python -m atproto_calendar import-events \
1730
+
--provider ics \
1731
+
--source calendar.ics \
1732
+
--output events.json
1733
+
1734
+
# Import from ICS URL
1735
+
python -m atproto_calendar import-events \
1736
+
--provider ics \
1737
+
--source https://example.com/calendar.ics \
1738
+
--publish \
1739
+
--handle user.bsky.social \
1740
+
--password your-app-password
1741
+
1742
+
# Import from Google Calendar
1743
+
python -m atproto_calendar import-events \
1744
+
--provider google \
1745
+
--credentials-file google-creds.json \
1746
+
--start-date 2025-06-01 \
1747
+
--end-date 2025-12-31 \
1748
+
--publish \
1749
+
--handle user.bsky.social \
1750
+
--password your-app-password
1751
+
1752
+
# Validate ICS file
1753
+
python -m atproto_calendar validate-ics calendar.ics
1754
+
```
1755
+
1756
+
### API Usage
1757
+
1758
+
```bash
1759
+
# Start API server
1760
+
python -m atproto_calendar.api.server
1761
+
1762
+
# Import from ICS URL via API
1763
+
curl -X POST "http://localhost:8000/import" \
1764
+
-H "Content-Type: application/json" \
1765
+
-d '{
1766
+
"provider": "ics",
1767
+
"source": "https://example.com/calendar.ics",
1768
+
"start_date": "2025-06-01T00:00:00Z",
1769
+
"end_date": "2025-12-31T23:59:59Z"
1770
+
}'
1771
+
```
1772
+
1773
+
### Python Library Usage
1774
+
1775
+
```python
1776
+
from atproto_calendar.import.ics import ICSImporter
1777
+
from atproto_calendar.transform.converter import EventConverter
1778
+
from atproto_calendar.atproto.client import ATProtoClient
1779
+
1780
+
async def import_and_publish():
1781
+
# Import from ICS
1782
+
importer = ICSImporter("https://example.com/calendar.ics")
1783
+
external_events = await importer.import_events()
1784
+
1785
+
# Convert to AT Protocol format
1786
+
at_events = [
1787
+
EventConverter.convert_external_event(event)
1788
+
for event in external_events
1789
+
]
1790
+
1791
+
# Publish
+21
PYTHON/LICENSE
+21
PYTHON/LICENSE
···
1
+
MIT License
2
+
3
+
Copyright (c) 2025 AT Protocol Calendar Import
4
+
5
+
Permission is hereby granted, free of charge, to any person obtaining a copy
6
+
of this software and associated documentation files (the "Software"), to deal
7
+
in the Software without restriction, including without limitation the rights
8
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+
copies of the Software, and to permit persons to whom the Software is
10
+
furnished to do so, subject to the following conditions:
11
+
12
+
The above copyright notice and this permission notice shall be included in all
13
+
copies or substantial portions of the Software.
14
+
15
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+
SOFTWARE.
+177
PYTHON/README.md
+177
PYTHON/README.md
···
1
+
# AT Protocol Calendar Import
2
+
3
+
A Python library and CLI tool for importing calendar events from external providers (Google, Outlook, Apple, ICS) into the [AT Protocol](https://atproto.com/).
4
+
5
+
## Features
6
+
7
+
- 📅 **Multiple Calendar Sources**: Google Calendar, Microsoft Outlook, Apple Calendar (CalDAV), ICS files/URLs
8
+
- 🔄 **AT Protocol Integration**: Publish events directly to AT Protocol/Bluesky
9
+
- 🌐 **REST API**: FastAPI-based server for integration
10
+
- 💻 **CLI Tool**: Command-line interface for batch imports
11
+
- 🔍 **Smart Event Detection**: Automatically detects virtual/hybrid/in-person events
12
+
- 🚀 **Async Support**: Built with modern async/await patterns
13
+
- 🛡️ **Type Safety**: Full type hints with Pydantic models
14
+
15
+
## Quick Start
16
+
17
+
### Installation
18
+
19
+
```bash
20
+
# Basic installation
21
+
pip install -e .
22
+
23
+
# With API server support
24
+
pip install -e ".[api]"
25
+
26
+
# With development tools
27
+
pip install -e ".[dev]"
28
+
```
29
+
30
+
### CLI Usage
31
+
32
+
```bash
33
+
# Import from ICS URL
34
+
atproto-calendar import --provider ics --source "https://calendar.example.com/events.ics"
35
+
36
+
# Import from Google Calendar (requires OAuth setup)
37
+
atproto-calendar import --provider google --credentials-file google_creds.json
38
+
39
+
# Import and publish to AT Protocol
40
+
atproto-calendar import \
41
+
--provider ics \
42
+
--source "events.ics" \
43
+
--publish \
44
+
--handle "user.bsky.social" \
45
+
--password "app-password"
46
+
```
47
+
48
+
### API Server
49
+
50
+
```bash
51
+
# Start the API server
52
+
python -m atproto_calendar.api.server
53
+
54
+
# Make import request
55
+
curl -X POST "http://localhost:8000/import" \
56
+
-H "Content-Type: application/json" \
57
+
-d '{
58
+
"provider": "ics",
59
+
"source": "https://calendar.example.com/events.ics"
60
+
}'
61
+
```
62
+
63
+
### Python API
64
+
65
+
```python
66
+
from atproto_calendar.import.ics import ICSImporter
67
+
from atproto_calendar.transform.converter import EventConverter
68
+
69
+
# Import events
70
+
importer = ICSImporter("https://calendar.example.com/events.ics")
71
+
external_events = await importer.import_events()
72
+
73
+
# Convert to AT Protocol format
74
+
at_events = [
75
+
EventConverter.convert_external_event(event)
76
+
for event in external_events
77
+
]
78
+
```
79
+
80
+
## Supported Calendar Providers
81
+
82
+
| Provider | Authentication | Status |
83
+
|----------|---------------|---------|
84
+
| ICS Files/URLs | None | ✅ Ready |
85
+
| Google Calendar | OAuth2 | 🚧 In Progress |
86
+
| Microsoft Outlook | OAuth2 | 🚧 In Progress |
87
+
| Apple Calendar | CalDAV | 🚧 In Progress |
88
+
89
+
## Configuration
90
+
91
+
The application can be configured via environment variables or configuration files:
92
+
93
+
```bash
94
+
# AT Protocol settings
95
+
ATPROTO_HANDLE=user.bsky.social
96
+
ATPROTO_PASSWORD=app-password
97
+
ATPROTO_PDS_URL=https://bsky.social
98
+
99
+
# API server settings
100
+
API_HOST=0.0.0.0
101
+
API_PORT=8000
102
+
DEBUG=false
103
+
104
+
# Optional: Database settings
105
+
REDIS_URL=redis://localhost:6379
106
+
POSTGRES_URL=postgresql://user:pass@localhost/db
107
+
```
108
+
109
+
## Development
110
+
111
+
### Setup Development Environment
112
+
113
+
```bash
114
+
# Clone the repository
115
+
git clone <repository-url>
116
+
cd atproto-calendar-import/PYTHON
117
+
118
+
# Install in development mode
119
+
pip install -e ".[dev]"
120
+
121
+
# Install pre-commit hooks
122
+
pre-commit install
123
+
```
124
+
125
+
### Running Tests
126
+
127
+
```bash
128
+
# Run all tests
129
+
pytest
130
+
131
+
# Run with coverage
132
+
pytest --cov=src --cov-report=html
133
+
134
+
# Run specific test file
135
+
pytest tests/test_import/test_ics.py
136
+
```
137
+
138
+
### Code Quality
139
+
140
+
```bash
141
+
# Format code
142
+
black . && isort .
143
+
144
+
# Lint code
145
+
flake8 . && mypy .
146
+
147
+
# Run pre-commit checks
148
+
pre-commit run --all-files
149
+
```
150
+
151
+
## Architecture
152
+
153
+
The project follows a modular architecture:
154
+
155
+
- **Import Layer**: Handles importing from various calendar providers
156
+
- **Transform Layer**: Converts external events to AT Protocol format
157
+
- **AT Protocol Layer**: Manages authentication and publishing to AT Protocol
158
+
- **API Layer**: Provides REST endpoints for integration
159
+
- **CLI Layer**: Command-line interface for batch operations
160
+
161
+
## Contributing
162
+
163
+
1. Fork the repository
164
+
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
165
+
3. Commit your changes (`git commit -m 'Add amazing feature'`)
166
+
4. Push to the branch (`git push origin feature/amazing-feature`)
167
+
5. Open a Pull Request
168
+
169
+
## License
170
+
171
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
172
+
173
+
## Support
174
+
175
+
- 📖 [Documentation](https://atproto-calendar-import.readthedocs.io)
176
+
- 🐛 [Issue Tracker](https://github.com/example/atproto-calendar-import/issues)
177
+
- 💬 [Discussions](https://github.com/example/atproto-calendar-import/discussions)
+51
PYTHON/docker/Dockerfile
+51
PYTHON/docker/Dockerfile
···
1
+
# Use Python 3.11 slim image
2
+
FROM python:3.11-slim
3
+
4
+
# Set working directory
5
+
WORKDIR /app
6
+
7
+
# Set environment variables
8
+
ENV PYTHONPATH=/app/src
9
+
ENV PYTHONDONTWRITEBYTECODE=1
10
+
ENV PYTHONUNBUFFERED=1
11
+
12
+
# Install system dependencies
13
+
RUN apt-get update && apt-get install -y \
14
+
build-essential \
15
+
curl \
16
+
&& rm -rf /var/lib/apt/lists/*
17
+
18
+
# Copy requirements first for better caching
19
+
COPY requirements.txt .
20
+
COPY requirements/ requirements/
21
+
22
+
# Install Python dependencies
23
+
RUN pip install --upgrade pip
24
+
RUN pip install -r requirements.txt
25
+
26
+
# Install optional dependencies for API server
27
+
RUN pip install -r requirements/api.txt
28
+
29
+
# Copy source code
30
+
COPY src/ src/
31
+
COPY pyproject.toml .
32
+
COPY README.md .
33
+
COPY LICENSE .
34
+
35
+
# Install the package
36
+
RUN pip install -e .
37
+
38
+
# Create non-root user
39
+
RUN useradd --create-home --shell /bin/bash app
40
+
RUN chown -R app:app /app
41
+
USER app
42
+
43
+
# Expose port
44
+
EXPOSE 8000
45
+
46
+
# Health check
47
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
48
+
CMD curl -f http://localhost:8000/health || exit 1
49
+
50
+
# Default command
51
+
CMD ["uvicorn", "atproto_calendar.api.server:app", "--host", "0.0.0.0", "--port", "8000"]
+51
PYTHON/docker/docker-compose.yml
+51
PYTHON/docker/docker-compose.yml
···
1
+
version: '3.8'
2
+
3
+
services:
4
+
atproto-calendar-api:
5
+
build:
6
+
context: ..
7
+
dockerfile: docker/Dockerfile
8
+
ports:
9
+
- "8000:8000"
10
+
environment:
11
+
- DEBUG=true
12
+
- LOG_LEVEL=DEBUG
13
+
- REDIS_URL=redis://redis:6379
14
+
- POSTGRES_URL=postgresql://postgres:password@postgres:5432/atproto_calendar
15
+
depends_on:
16
+
- redis
17
+
- postgres
18
+
volumes:
19
+
- ../src:/app/src # For development hot reload
20
+
networks:
21
+
- atproto-network
22
+
23
+
redis:
24
+
image: redis:7-alpine
25
+
ports:
26
+
- "6379:6379"
27
+
volumes:
28
+
- redis_data:/data
29
+
networks:
30
+
- atproto-network
31
+
32
+
postgres:
33
+
image: postgres:15-alpine
34
+
environment:
35
+
- POSTGRES_DB=atproto_calendar
36
+
- POSTGRES_USER=postgres
37
+
- POSTGRES_PASSWORD=password
38
+
ports:
39
+
- "5432:5432"
40
+
volumes:
41
+
- postgres_data:/var/lib/postgresql/data
42
+
networks:
43
+
- atproto-network
44
+
45
+
volumes:
46
+
redis_data:
47
+
postgres_data:
48
+
49
+
networks:
50
+
atproto-network:
51
+
driver: bridge
+163
PYTHON/pyproject.toml
+163
PYTHON/pyproject.toml
···
1
+
[build-system]
2
+
requires = ["setuptools>=61.0", "wheel"]
3
+
build-backend = "setuptools.build_meta"
4
+
5
+
[project]
6
+
name = "atproto-calendar-import"
7
+
version = "0.1.0"
8
+
description = "Import calendar events from external providers into AT Protocol"
9
+
readme = "README.md"
10
+
license = {file = "LICENSE"}
11
+
authors = [
12
+
{name = "AT Protocol Calendar Import", email = "contact@example.com"}
13
+
]
14
+
keywords = ["atproto", "calendar", "import", "bluesky"]
15
+
classifiers = [
16
+
"Development Status :: 3 - Alpha",
17
+
"Intended Audience :: Developers",
18
+
"License :: OSI Approved :: MIT License",
19
+
"Operating System :: OS Independent",
20
+
"Programming Language :: Python :: 3",
21
+
"Programming Language :: Python :: 3.9",
22
+
"Programming Language :: Python :: 3.10",
23
+
"Programming Language :: Python :: 3.11",
24
+
"Programming Language :: Python :: 3.12",
25
+
"Topic :: Communications",
26
+
"Topic :: Internet",
27
+
"Topic :: Office/Business :: Scheduling",
28
+
]
29
+
requires-python = ">=3.9"
30
+
dependencies = [
31
+
"pydantic>=2.0,<3.0",
32
+
"httpx>=0.27,<1.0",
33
+
"click>=8.1,<9.0",
34
+
"python-dateutil>=2.8,<3.0",
35
+
"pytz>=2023.3",
36
+
"structlog>=23.1,<24.0",
37
+
"tenacity>=8.2,<9.0",
38
+
"cryptography>=41.0,<42.0",
39
+
"icalendar>=5.0,<6.0",
40
+
"caldav>=1.3,<2.0",
41
+
"authlib>=1.2,<2.0",
42
+
"requests-oauthlib>=1.3,<2.0",
43
+
"atproto>=0.0.38,<1.0",
44
+
]
45
+
46
+
[project.optional-dependencies]
47
+
dev = [
48
+
"pytest>=7.0",
49
+
"pytest-asyncio>=0.21",
50
+
"pytest-cov>=4.0",
51
+
"black>=23.0",
52
+
"isort>=5.12",
53
+
"flake8>=6.0",
54
+
"mypy>=1.5",
55
+
"pre-commit>=3.0",
56
+
]
57
+
api = [
58
+
"fastapi>=0.104,<1.0",
59
+
"uvicorn[standard]>=0.24,<1.0",
60
+
]
61
+
database = [
62
+
"asyncpg>=0.28,<1.0",
63
+
"redis>=4.5,<5.0",
64
+
]
65
+
google = [
66
+
"google-auth>=2.0",
67
+
"google-auth-oauthlib>=1.0",
68
+
"google-api-python-client>=2.0",
69
+
]
70
+
71
+
[project.urls]
72
+
Homepage = "https://github.com/example/atproto-calendar-import"
73
+
Repository = "https://github.com/example/atproto-calendar-import"
74
+
Issues = "https://github.com/example/atproto-calendar-import/issues"
75
+
Documentation = "https://atproto-calendar-import.readthedocs.io"
76
+
77
+
[project.scripts]
78
+
atproto-calendar = "atproto_calendar.main:main"
79
+
80
+
[tool.setuptools.packages.find]
81
+
where = ["src"]
82
+
83
+
[tool.black]
84
+
line-length = 88
85
+
target-version = ["py39", "py310", "py311", "py312"]
86
+
include = '\.pyi?$'
87
+
extend-exclude = '''
88
+
/(
89
+
# directories
90
+
\.eggs
91
+
| \.git
92
+
| \.hg
93
+
| \.mypy_cache
94
+
| \.tox
95
+
| \.venv
96
+
| build
97
+
| dist
98
+
)/
99
+
'''
100
+
101
+
[tool.isort]
102
+
profile = "black"
103
+
multi_line_output = 3
104
+
line_length = 88
105
+
known_first_party = ["atproto_calendar"]
106
+
107
+
[tool.mypy]
108
+
python_version = "3.9"
109
+
warn_return_any = true
110
+
warn_unused_configs = true
111
+
disallow_untyped_defs = true
112
+
disallow_incomplete_defs = true
113
+
check_untyped_defs = true
114
+
disallow_untyped_decorators = true
115
+
no_implicit_optional = true
116
+
warn_redundant_casts = true
117
+
warn_unused_ignores = true
118
+
warn_no_return = true
119
+
warn_unreachable = true
120
+
strict_equality = true
121
+
122
+
[[tool.mypy.overrides]]
123
+
module = [
124
+
"icalendar.*",
125
+
"caldav.*",
126
+
"google.*",
127
+
"googleapiclient.*",
128
+
]
129
+
ignore_missing_imports = true
130
+
131
+
[tool.pytest.ini_options]
132
+
testpaths = ["tests"]
133
+
python_files = "test_*.py"
134
+
python_classes = "Test*"
135
+
python_functions = "test_*"
136
+
addopts = "-v --tb=short"
137
+
asyncio_mode = "auto"
138
+
markers = [
139
+
"integration: marks tests as integration tests",
140
+
"unit: marks tests as unit tests",
141
+
"slow: marks tests as slow",
142
+
]
143
+
144
+
[tool.coverage.run]
145
+
source = ["src"]
146
+
omit = [
147
+
"*/tests/*",
148
+
"*/test_*",
149
+
]
150
+
151
+
[tool.coverage.report]
152
+
exclude_lines = [
153
+
"pragma: no cover",
154
+
"def __repr__",
155
+
"if self.debug:",
156
+
"if settings.DEBUG",
157
+
"raise AssertionError",
158
+
"raise NotImplementedError",
159
+
"if 0:",
160
+
"if __name__ == .__main__.:",
161
+
"class .*\\(Protocol\\):",
162
+
"@(abc\\.)?abstractmethod",
163
+
]
+9
PYTHON/requirements/api.txt
+9
PYTHON/requirements/api.txt
+14
PYTHON/requirements/base.txt
+14
PYTHON/requirements/base.txt
···
1
+
# Core dependencies
2
+
pydantic>=2.0,<3.0
3
+
httpx>=0.27,<1.0
4
+
click>=8.1,<9.0
5
+
python-dateutil>=2.8,<3.0
6
+
pytz>=2023.3
7
+
structlog>=23.1,<24.0
8
+
tenacity>=8.2,<9.0
9
+
cryptography>=41.0,<42.0
10
+
icalendar>=5.0,<6.0
11
+
caldav>=1.3,<2.0
12
+
authlib>=1.2,<2.0
13
+
requests-oauthlib>=1.3,<2.0
14
+
atproto>=0.0.38,<1.0
+15
PYTHON/requirements/dev.txt
+15
PYTHON/requirements/dev.txt
+2
PYTHON/requirements.txt
+2
PYTHON/requirements.txt
+21
PYTHON/scripts/run-dev.sh
+21
PYTHON/scripts/run-dev.sh
···
1
+
#!/bin/bash
2
+
# Development runner script
3
+
4
+
set -e
5
+
6
+
# Activate virtual environment if it exists
7
+
if [ -f "venv/bin/activate" ]; then
8
+
source venv/bin/activate
9
+
fi
10
+
11
+
# Run the development API server with auto-reload
12
+
echo "🚀 Starting AT Protocol Calendar Import API server..."
13
+
echo "📡 Server will be available at http://localhost:8000"
14
+
echo "📖 API docs at http://localhost:8000/docs"
15
+
echo ""
16
+
17
+
uvicorn atproto_calendar.api.server:app \
18
+
--host 0.0.0.0 \
19
+
--port 8000 \
20
+
--reload \
21
+
--reload-dir src
+49
PYTHON/scripts/setup.sh
+49
PYTHON/scripts/setup.sh
···
1
+
#!/bin/bash
2
+
# Development setup script for AT Protocol Calendar Import
3
+
4
+
set -e
5
+
6
+
echo "🚀 Setting up AT Protocol Calendar Import development environment..."
7
+
8
+
# Check if Python is available
9
+
if ! command -v python3 &> /dev/null; then
10
+
echo "❌ Python 3 is not installed. Please install Python 3.9 or higher."
11
+
exit 1
12
+
fi
13
+
14
+
# Check Python version
15
+
python_version=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))')
16
+
echo "📍 Found Python $python_version"
17
+
18
+
# Create virtual environment if it doesn't exist
19
+
if [ ! -d "venv" ]; then
20
+
echo "📦 Creating virtual environment..."
21
+
python3 -m venv venv
22
+
fi
23
+
24
+
# Activate virtual environment
25
+
echo "🔧 Activating virtual environment..."
26
+
source venv/bin/activate
27
+
28
+
# Upgrade pip
29
+
echo "⬆️ Upgrading pip..."
30
+
pip install --upgrade pip
31
+
32
+
# Install the package in development mode
33
+
echo "📥 Installing package in development mode..."
34
+
pip install -e ".[dev]"
35
+
36
+
echo "✅ Development environment setup complete!"
37
+
echo ""
38
+
echo "📋 Next steps:"
39
+
echo "1. Activate the virtual environment: source venv/bin/activate"
40
+
echo "2. Run tests: pytest"
41
+
echo "3. Start developing!"
42
+
echo ""
43
+
echo "🔧 Available commands:"
44
+
echo " - pytest # Run tests"
45
+
echo " - black . # Format code"
46
+
echo " - isort . # Sort imports"
47
+
echo " - flake8 . # Lint code"
48
+
echo " - mypy . # Type check"
49
+
echo " - atproto-calendar --help # CLI help"
+29
PYTHON/src/atproto_calendar/__init__.py
+29
PYTHON/src/atproto_calendar/__init__.py
···
1
+
"""
2
+
AT Protocol Calendar Import Library
3
+
4
+
A Python library and CLI tool for importing calendar events from external
5
+
providers (Google, Outlook, Apple, ICS) into the AT Protocol.
6
+
"""
7
+
8
+
__version__ = "0.1.0"
9
+
__author__ = "AT Protocol Calendar Import"
10
+
__email__ = "contact@example.com"
11
+
12
+
from .exceptions import (
13
+
ATProtoCalendarError,
14
+
ImportError as CalendarImportError,
15
+
AuthenticationError,
16
+
ConversionError,
17
+
ATProtoError,
18
+
)
19
+
20
+
__all__ = [
21
+
"__version__",
22
+
"__author__",
23
+
"__email__",
24
+
"ATProtoCalendarError",
25
+
"CalendarImportError",
26
+
"AuthenticationError",
27
+
"ConversionError",
28
+
"ATProtoError",
29
+
]
+6
PYTHON/src/atproto_calendar/__main__.py
+6
PYTHON/src/atproto_calendar/__main__.py
+5
PYTHON/src/atproto_calendar/api/__init__.py
+5
PYTHON/src/atproto_calendar/api/__init__.py
+3
PYTHON/src/atproto_calendar/api/models/__init__.py
+3
PYTHON/src/atproto_calendar/api/models/__init__.py
+3
PYTHON/src/atproto_calendar/api/routes/__init__.py
+3
PYTHON/src/atproto_calendar/api/routes/__init__.py
+5
PYTHON/src/atproto_calendar/atproto/__init__.py
+5
PYTHON/src/atproto_calendar/atproto/__init__.py
+5
PYTHON/src/atproto_calendar/auth/__init__.py
+5
PYTHON/src/atproto_calendar/auth/__init__.py
+5
PYTHON/src/atproto_calendar/cli/__init__.py
+5
PYTHON/src/atproto_calendar/cli/__init__.py
+207
PYTHON/src/atproto_calendar/cli/commands.py
+207
PYTHON/src/atproto_calendar/cli/commands.py
···
1
+
"""CLI commands for the AT Protocol Calendar Import tool."""
2
+
3
+
import click
4
+
import asyncio
5
+
import json
6
+
from datetime import datetime
7
+
from pathlib import Path
8
+
from typing import Optional
9
+
import structlog
10
+
11
+
from ..importers import get_importer
12
+
from ..transform.converter import EventConverter
13
+
from ..exceptions import ATProtoCalendarError
14
+
15
+
logger = structlog.get_logger(__name__)
16
+
17
+
18
+
@click.group()
19
+
def cli() -> None:
20
+
"""AT Protocol Calendar Import commands."""
21
+
pass
22
+
23
+
24
+
@cli.command()
25
+
@click.option(
26
+
'--provider',
27
+
type=click.Choice(['google', 'outlook', 'apple', 'ics']),
28
+
required=True,
29
+
help='Calendar provider to import from'
30
+
)
31
+
@click.option(
32
+
'--source',
33
+
help='ICS file path or URL (required for ICS provider)'
34
+
)
35
+
@click.option(
36
+
'--credentials-file',
37
+
type=click.Path(exists=True),
38
+
help='JSON file with OAuth credentials'
39
+
)
40
+
@click.option(
41
+
'--start-date',
42
+
type=click.DateTime(),
43
+
help='Import events from this date (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)'
44
+
)
45
+
@click.option(
46
+
'--end-date',
47
+
type=click.DateTime(),
48
+
help='Import events until this date (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)'
49
+
)
50
+
@click.option(
51
+
'--output',
52
+
type=click.Path(),
53
+
help='Save events to JSON file'
54
+
)
55
+
@click.option(
56
+
'--publish/--no-publish',
57
+
default=False,
58
+
help='Publish to AT Protocol'
59
+
)
60
+
@click.option(
61
+
'--handle',
62
+
help='AT Protocol handle (e.g., user.bsky.social)'
63
+
)
64
+
@click.option(
65
+
'--password',
66
+
help='AT Protocol app password'
67
+
)
68
+
@click.option(
69
+
'--pds-url',
70
+
help='PDS URL (optional, defaults to bsky.social)'
71
+
)
72
+
def import_events(
73
+
provider: str,
74
+
source: Optional[str],
75
+
credentials_file: Optional[str],
76
+
start_date: Optional[datetime],
77
+
end_date: Optional[datetime],
78
+
output: Optional[str],
79
+
publish: bool,
80
+
handle: Optional[str],
81
+
password: Optional[str],
82
+
pds_url: Optional[str]
83
+
) -> None:
84
+
"""Import calendar events from external provider."""
85
+
asyncio.run(_import_events_async(
86
+
provider, source, credentials_file, start_date, end_date,
87
+
output, publish, handle, password, pds_url
88
+
))
89
+
90
+
91
+
async def _import_events_async(
92
+
provider: str,
93
+
source: Optional[str],
94
+
credentials_file: Optional[str],
95
+
start_date: Optional[datetime],
96
+
end_date: Optional[datetime],
97
+
output: Optional[str],
98
+
publish: bool,
99
+
handle: Optional[str],
100
+
password: Optional[str],
101
+
pds_url: Optional[str]
102
+
) -> None:
103
+
"""Async implementation of import command."""
104
+
try:
105
+
# Validate required parameters
106
+
if provider == 'ics' and not source:
107
+
click.echo("❌ Error: --source is required for ICS provider", err=True)
108
+
return
109
+
110
+
if publish and (not handle or not password):
111
+
click.echo("❌ Error: --handle and --password are required for publishing", err=True)
112
+
return
113
+
114
+
# Get importer
115
+
click.echo(f"🔧 Setting up {provider} importer...")
116
+
importer = get_importer(provider, source)
117
+
118
+
# Load credentials if provided
119
+
if credentials_file:
120
+
click.echo("🔑 Loading credentials...")
121
+
with open(credentials_file) as f:
122
+
credentials = json.load(f)
123
+
124
+
auth_success = await importer.authenticate(credentials)
125
+
if not auth_success:
126
+
click.echo("❌ Authentication failed", err=True)
127
+
return
128
+
click.echo("✅ Authentication successful")
129
+
130
+
# Import events
131
+
click.echo(f"🔄 Importing events from {provider}...")
132
+
external_events = await importer.import_events(start_date, end_date)
133
+
134
+
if not external_events:
135
+
click.echo("ℹ️ No events found")
136
+
return
137
+
138
+
# Convert to AT Protocol format
139
+
click.echo("🔄 Converting events to AT Protocol format...")
140
+
at_events = []
141
+
for event in external_events:
142
+
try:
143
+
at_event = EventConverter.convert_external_event(event)
144
+
at_events.append(at_event)
145
+
except Exception as e:
146
+
logger.warning("Failed to convert event", event_id=event.id, error=str(e))
147
+
continue
148
+
149
+
click.echo(f"✅ Successfully imported {len(at_events)} events")
150
+
151
+
# Save to file if requested
152
+
if output:
153
+
click.echo(f"💾 Saving events to {output}...")
154
+
output_data = [event.to_dict() for event in at_events]
155
+
with open(output, 'w') as f:
156
+
json.dump(output_data, f, indent=2, default=str)
157
+
click.echo(f"✅ Events saved to {output}")
158
+
159
+
# Publish to AT Protocol if requested
160
+
if publish:
161
+
click.echo("🚀 Publishing events to AT Protocol...")
162
+
# TODO: Implement AT Protocol publishing
163
+
click.echo("⚠️ AT Protocol publishing not yet implemented")
164
+
165
+
# Show summary
166
+
if at_events:
167
+
click.echo("\n📋 Event Summary:")
168
+
for i, event in enumerate(at_events[:5], 1): # Show first 5 events
169
+
start_str = event.starts_at.strftime("%Y-%m-%d %H:%M") if event.starts_at else "No date"
170
+
click.echo(f" {i}. {event.name} ({start_str})")
171
+
172
+
if len(at_events) > 5:
173
+
click.echo(f" ... and {len(at_events) - 5} more events")
174
+
175
+
except ATProtoCalendarError as e:
176
+
click.echo(f"❌ Error: {e.message}", err=True)
177
+
logger.error("Import failed", error=str(e), details=e.details)
178
+
except Exception as e:
179
+
click.echo(f"❌ Unexpected error: {str(e)}", err=True)
180
+
logger.error("Unexpected error during import", error=str(e))
181
+
182
+
183
+
@cli.command()
184
+
def version() -> None:
185
+
"""Show version information."""
186
+
from .. import __version__
187
+
click.echo(f"atproto-calendar-import {__version__}")
188
+
189
+
190
+
@cli.command()
191
+
@click.option('--provider', type=click.Choice(['google', 'outlook', 'apple', 'ics']))
192
+
def test_connection(provider: Optional[str]) -> None:
193
+
"""Test connection to calendar providers."""
194
+
if provider:
195
+
click.echo(f"🔧 Testing connection to {provider}...")
196
+
# TODO: Implement connection testing
197
+
click.echo("⚠️ Connection testing not yet implemented")
198
+
else:
199
+
click.echo("Available providers: google, outlook, apple, ics")
200
+
click.echo("Use --provider to test a specific provider")
201
+
202
+
203
+
# Alias for compatibility
204
+
app = cli
205
+
206
+
if __name__ == '__main__':
207
+
cli()
+5
PYTHON/src/atproto_calendar/config/__init__.py
+5
PYTHON/src/atproto_calendar/config/__init__.py
+64
PYTHON/src/atproto_calendar/config/settings.py
+64
PYTHON/src/atproto_calendar/config/settings.py
···
1
+
"""Application settings and configuration."""
2
+
3
+
from typing import List, Optional
4
+
from pydantic import BaseModel, Field
5
+
import os
6
+
7
+
8
+
class Settings(BaseModel):
9
+
"""Application settings."""
10
+
11
+
# AT Protocol settings
12
+
atproto_handle: Optional[str] = Field(None)
13
+
atproto_password: Optional[str] = Field(None)
14
+
atproto_pds_url: str = Field("https://bsky.social")
15
+
16
+
# API server settings
17
+
api_host: str = Field("0.0.0.0")
18
+
api_port: int = Field(8000)
19
+
debug: bool = Field(False)
20
+
21
+
# CORS settings
22
+
cors_origins: List[str] = Field(default_factory=lambda: ["*"])
23
+
24
+
# Database settings (optional)
25
+
redis_url: Optional[str] = Field(None)
26
+
postgres_url: Optional[str] = Field(None)
27
+
28
+
# Cache settings
29
+
cache_ttl: int = Field(3600) # 1 hour default
30
+
31
+
def __init__(self, **kwargs):
32
+
# Load from environment variables
33
+
env_values = {
34
+
'atproto_handle': os.getenv('ATPROTO_HANDLE'),
35
+
'atproto_password': os.getenv('ATPROTO_PASSWORD'),
36
+
'atproto_pds_url': os.getenv('ATPROTO_PDS_URL', 'https://bsky.social'),
37
+
'api_host': os.getenv('API_HOST', '0.0.0.0'),
38
+
'api_port': int(os.getenv('API_PORT', '8000')),
39
+
'debug': os.getenv('DEBUG', 'false').lower() == 'true',
40
+
'cors_origins': os.getenv('CORS_ORIGINS', '*').split(','),
41
+
'redis_url': os.getenv('REDIS_URL'),
42
+
'postgres_url': os.getenv('POSTGRES_URL'),
43
+
'cache_ttl': int(os.getenv('CACHE_TTL', '3600')),
44
+
}
45
+
# Filter out None values and update with any passed kwargs
46
+
env_values = {k: v for k, v in env_values.items() if v is not None}
47
+
env_values.update(kwargs)
48
+
super().__init__(**env_values)
49
+
50
+
51
+
# Global settings instance
52
+
_settings: Optional[Settings] = None
53
+
54
+
55
+
def get_settings() -> Settings:
56
+
"""Get application settings singleton.
57
+
58
+
Returns:
59
+
Settings instance
60
+
"""
61
+
global _settings
62
+
if _settings is None:
63
+
_settings = Settings()
64
+
return _settings
+5
PYTHON/src/atproto_calendar/dedup/__init__.py
+5
PYTHON/src/atproto_calendar/dedup/__init__.py
+54
PYTHON/src/atproto_calendar/exceptions.py
+54
PYTHON/src/atproto_calendar/exceptions.py
···
1
+
"""Custom exceptions for the AT Protocol Calendar Import library."""
2
+
3
+
from typing import Optional, Any
4
+
5
+
6
+
class ATProtoCalendarError(Exception):
7
+
"""Base exception for all AT Protocol Calendar Import errors."""
8
+
9
+
def __init__(self, message: str, details: Optional[dict] = None):
10
+
super().__init__(message)
11
+
self.message = message
12
+
self.details = details or {}
13
+
14
+
15
+
class ImportError(ATProtoCalendarError):
16
+
"""Raised when calendar import operations fail."""
17
+
18
+
def __init__(self, message: str, provider: Optional[str] = None, details: Optional[dict] = None):
19
+
super().__init__(message, details)
20
+
self.provider = provider
21
+
22
+
23
+
class AuthenticationError(ATProtoCalendarError):
24
+
"""Raised when authentication with external services fails."""
25
+
26
+
def __init__(self, message: str, service: Optional[str] = None, details: Optional[dict] = None):
27
+
super().__init__(message, details)
28
+
self.service = service
29
+
30
+
31
+
class ConversionError(ATProtoCalendarError):
32
+
"""Raised when event format conversion fails."""
33
+
34
+
def __init__(self, message: str, event_data: Optional[Any] = None, details: Optional[dict] = None):
35
+
super().__init__(message, details)
36
+
self.event_data = event_data
37
+
38
+
39
+
class ATProtoError(ATProtoCalendarError):
40
+
"""Raised when AT Protocol operations fail."""
41
+
42
+
def __init__(self, message: str, status_code: Optional[int] = None, details: Optional[dict] = None):
43
+
super().__init__(message, details)
44
+
self.status_code = status_code
45
+
46
+
47
+
class ConfigurationError(ATProtoCalendarError):
48
+
"""Raised when configuration is invalid or missing."""
49
+
pass
50
+
51
+
52
+
class DeduplicationError(ATProtoCalendarError):
53
+
"""Raised when event deduplication operations fail."""
54
+
pass
+45
PYTHON/src/atproto_calendar/importers/__init__.py
+45
PYTHON/src/atproto_calendar/importers/__init__.py
···
1
+
"""Import module for calendar providers."""
2
+
3
+
from .base import CalendarImporter, ExternalEvent
4
+
from .ics import ICSImporter
5
+
6
+
__all__ = [
7
+
"CalendarImporter",
8
+
"ExternalEvent",
9
+
"ICSImporter",
10
+
"get_importer",
11
+
]
12
+
13
+
14
+
def get_importer(provider: str, source: str = None) -> CalendarImporter:
15
+
"""Get the appropriate calendar importer for the provider.
16
+
17
+
Args:
18
+
provider: The calendar provider ('ics', 'google', 'outlook', 'apple')
19
+
source: Optional source parameter (required for ICS)
20
+
21
+
Returns:
22
+
CalendarImporter instance
23
+
24
+
Raises:
25
+
ValueError: If provider is not supported or source is missing for ICS
26
+
"""
27
+
if provider == "ics":
28
+
if not source:
29
+
raise ValueError("ICS provider requires a source (file path or URL)")
30
+
return ICSImporter(source)
31
+
32
+
elif provider == "google":
33
+
from .google import GoogleCalendarImporter
34
+
return GoogleCalendarImporter()
35
+
36
+
elif provider == "outlook":
37
+
from .outlook import OutlookCalendarImporter
38
+
return OutlookCalendarImporter()
39
+
40
+
elif provider == "apple":
41
+
from .apple import AppleCalendarImporter
42
+
return AppleCalendarImporter()
43
+
44
+
else:
45
+
raise ValueError(f"Unsupported calendar provider: {provider}")
+54
PYTHON/src/atproto_calendar/importers/apple.py
+54
PYTHON/src/atproto_calendar/importers/apple.py
···
1
+
"""Apple Calendar (CalDAV) importer (placeholder)."""
2
+
3
+
from typing import List, Optional
4
+
from datetime import datetime
5
+
import structlog
6
+
7
+
from .base import CalendarImporter, ExternalEvent
8
+
from ..exceptions import ImportError, AuthenticationError
9
+
10
+
logger = structlog.get_logger(__name__)
11
+
12
+
13
+
class AppleCalendarImporter(CalendarImporter):
14
+
"""Import events from Apple Calendar via CalDAV."""
15
+
16
+
def __init__(self):
17
+
"""Initialize Apple Calendar importer."""
18
+
self.client = None
19
+
self.credentials = None
20
+
21
+
async def authenticate(self, credentials: dict) -> bool:
22
+
"""Authenticate with CalDAV server.
23
+
24
+
Args:
25
+
credentials: CalDAV credentials (username, password, server)
26
+
27
+
Returns:
28
+
True if authentication successful
29
+
30
+
Raises:
31
+
AuthenticationError: If authentication fails
32
+
"""
33
+
# TODO: Implement CalDAV authentication
34
+
raise NotImplementedError("Apple Calendar import not yet implemented")
35
+
36
+
async def import_events(
37
+
self,
38
+
start_date: Optional[datetime] = None,
39
+
end_date: Optional[datetime] = None
40
+
) -> List[ExternalEvent]:
41
+
"""Import events from Apple Calendar.
42
+
43
+
Args:
44
+
start_date: Optional start date filter
45
+
end_date: Optional end date filter
46
+
47
+
Returns:
48
+
List of ExternalEvent objects
49
+
50
+
Raises:
51
+
ImportError: If import operation fails
52
+
"""
53
+
# TODO: Implement CalDAV event import
54
+
raise NotImplementedError("Apple Calendar import not yet implemented")
+110
PYTHON/src/atproto_calendar/importers/base.py
+110
PYTHON/src/atproto_calendar/importers/base.py
···
1
+
"""Base classes for calendar importers."""
2
+
3
+
from abc import ABC, abstractmethod
4
+
from dataclasses import dataclass, field
5
+
from typing import List, Optional, Dict, Any
6
+
from datetime import datetime
7
+
8
+
9
+
@dataclass
10
+
class ExternalEvent:
11
+
"""Raw event from external calendar provider."""
12
+
13
+
id: str
14
+
title: str
15
+
description: Optional[str] = None
16
+
start_time: Optional[datetime] = None
17
+
end_time: Optional[datetime] = None
18
+
location: Optional[str] = None
19
+
url: Optional[str] = None
20
+
attendees: List[str] = field(default_factory=list)
21
+
created_at: Optional[datetime] = None
22
+
updated_at: Optional[datetime] = None
23
+
raw_data: Dict[str, Any] = field(default_factory=dict)
24
+
25
+
def __post_init__(self) -> None:
26
+
"""Validate event data after initialization."""
27
+
if not self.id:
28
+
raise ValueError("Event ID is required")
29
+
if not self.title:
30
+
raise ValueError("Event title is required")
31
+
32
+
33
+
class CalendarImporter(ABC):
34
+
"""Abstract base class for calendar importers."""
35
+
36
+
@abstractmethod
37
+
async def authenticate(self, credentials: Dict[str, Any]) -> bool:
38
+
"""Authenticate with the calendar provider.
39
+
40
+
Args:
41
+
credentials: Authentication credentials (varies by provider)
42
+
43
+
Returns:
44
+
True if authentication successful, False otherwise
45
+
"""
46
+
pass
47
+
48
+
@abstractmethod
49
+
async def import_events(
50
+
self,
51
+
start_date: Optional[datetime] = None,
52
+
end_date: Optional[datetime] = None
53
+
) -> List[ExternalEvent]:
54
+
"""Import events from the calendar provider.
55
+
56
+
Args:
57
+
start_date: Optional start date filter
58
+
end_date: Optional end date filter
59
+
60
+
Returns:
61
+
List of ExternalEvent objects
62
+
63
+
Raises:
64
+
ImportError: If import operation fails
65
+
"""
66
+
pass
67
+
68
+
def _validate_date_range(
69
+
self,
70
+
start_date: Optional[datetime],
71
+
end_date: Optional[datetime]
72
+
) -> None:
73
+
"""Validate date range parameters.
74
+
75
+
Args:
76
+
start_date: Start date
77
+
end_date: End date
78
+
79
+
Raises:
80
+
ValueError: If date range is invalid
81
+
"""
82
+
if start_date and end_date and start_date >= end_date:
83
+
raise ValueError("Start date must be before end date")
84
+
85
+
def _is_in_date_range(
86
+
self,
87
+
event: ExternalEvent,
88
+
start_date: Optional[datetime],
89
+
end_date: Optional[datetime]
90
+
) -> bool:
91
+
"""Check if event falls within specified date range.
92
+
93
+
Args:
94
+
event: Event to check
95
+
start_date: Optional start date filter
96
+
end_date: Optional end date filter
97
+
98
+
Returns:
99
+
True if event is in range, False otherwise
100
+
"""
101
+
if not event.start_time:
102
+
return True
103
+
104
+
if start_date and event.start_time < start_date:
105
+
return False
106
+
107
+
if end_date and event.start_time > end_date:
108
+
return False
109
+
110
+
return True
+54
PYTHON/src/atproto_calendar/importers/google.py
+54
PYTHON/src/atproto_calendar/importers/google.py
···
1
+
"""Google Calendar importer (placeholder)."""
2
+
3
+
from typing import List, Optional
4
+
from datetime import datetime
5
+
import structlog
6
+
7
+
from .base import CalendarImporter, ExternalEvent
8
+
from ..exceptions import ImportError, AuthenticationError
9
+
10
+
logger = structlog.get_logger(__name__)
11
+
12
+
13
+
class GoogleCalendarImporter(CalendarImporter):
14
+
"""Import events from Google Calendar."""
15
+
16
+
def __init__(self):
17
+
"""Initialize Google Calendar importer."""
18
+
self.service = None
19
+
self.credentials = None
20
+
21
+
async def authenticate(self, credentials: dict) -> bool:
22
+
"""Authenticate with Google Calendar API.
23
+
24
+
Args:
25
+
credentials: Google OAuth2 credentials
26
+
27
+
Returns:
28
+
True if authentication successful
29
+
30
+
Raises:
31
+
AuthenticationError: If authentication fails
32
+
"""
33
+
# TODO: Implement Google Calendar authentication
34
+
raise NotImplementedError("Google Calendar import not yet implemented")
35
+
36
+
async def import_events(
37
+
self,
38
+
start_date: Optional[datetime] = None,
39
+
end_date: Optional[datetime] = None
40
+
) -> List[ExternalEvent]:
41
+
"""Import events from Google Calendar.
42
+
43
+
Args:
44
+
start_date: Optional start date filter
45
+
end_date: Optional end date filter
46
+
47
+
Returns:
48
+
List of ExternalEvent objects
49
+
50
+
Raises:
51
+
ImportError: If import operation fails
52
+
"""
53
+
# TODO: Implement Google Calendar event import
54
+
raise NotImplementedError("Google Calendar import not yet implemented")
+150
PYTHON/src/atproto_calendar/importers/ics.py
+150
PYTHON/src/atproto_calendar/importers/ics.py
···
1
+
"""ICS calendar importer."""
2
+
3
+
import httpx
4
+
from icalendar import Calendar
5
+
from typing import List, Optional, Union
6
+
from pathlib import Path
7
+
from datetime import datetime
8
+
import structlog
9
+
10
+
from .base import CalendarImporter, ExternalEvent
11
+
from ..exceptions import ImportError, AuthenticationError
12
+
13
+
logger = structlog.get_logger(__name__)
14
+
15
+
16
+
class ICSImporter(CalendarImporter):
17
+
"""Import events from ICS files or URLs."""
18
+
19
+
def __init__(self, source: Union[str, Path]):
20
+
"""Initialize ICS importer.
21
+
22
+
Args:
23
+
source: ICS file path or URL
24
+
"""
25
+
self.source = source
26
+
self.is_url = isinstance(source, str) and source.startswith(('http://', 'https://'))
27
+
28
+
async def authenticate(self, credentials: dict) -> bool:
29
+
"""ICS files don't require authentication.
30
+
31
+
Args:
32
+
credentials: Ignored for ICS files
33
+
34
+
Returns:
35
+
Always True
36
+
"""
37
+
return True
38
+
39
+
async def import_events(
40
+
self,
41
+
start_date: Optional[datetime] = None,
42
+
end_date: Optional[datetime] = None
43
+
) -> List[ExternalEvent]:
44
+
"""Import events from ICS source.
45
+
46
+
Args:
47
+
start_date: Optional start date filter
48
+
end_date: Optional end date filter
49
+
50
+
Returns:
51
+
List of ExternalEvent objects
52
+
53
+
Raises:
54
+
ImportError: If import operation fails
55
+
"""
56
+
self._validate_date_range(start_date, end_date)
57
+
58
+
try:
59
+
ics_data = await self._fetch_ics_data()
60
+
calendar = Calendar.from_ical(ics_data)
61
+
62
+
events = []
63
+
for component in calendar.walk():
64
+
if component.name == "VEVENT":
65
+
try:
66
+
event = self._parse_event(component)
67
+
if self._is_in_date_range(event, start_date, end_date):
68
+
events.append(event)
69
+
except Exception as e:
70
+
logger.warning(
71
+
"Failed to parse event",
72
+
error=str(e),
73
+
event_uid=component.get('UID', 'unknown')
74
+
)
75
+
continue
76
+
77
+
logger.info("Imported events from ICS", count=len(events), source=str(self.source))
78
+
return events
79
+
80
+
except Exception as e:
81
+
logger.error("Failed to import ICS events", error=str(e), source=str(self.source))
82
+
raise ImportError(f"Failed to import ICS events: {e}", provider="ics")
83
+
84
+
async def _fetch_ics_data(self) -> bytes:
85
+
"""Fetch ICS data from URL or file.
86
+
87
+
Returns:
88
+
Raw ICS data as bytes
89
+
90
+
Raises:
91
+
ImportError: If data cannot be fetched
92
+
"""
93
+
try:
94
+
if self.is_url:
95
+
async with httpx.AsyncClient() as client:
96
+
response = await client.get(str(self.source))
97
+
response.raise_for_status()
98
+
return response.content
99
+
else:
100
+
return Path(self.source).read_bytes()
101
+
except Exception as e:
102
+
raise ImportError(f"Failed to fetch ICS data: {e}", provider="ics")
103
+
104
+
def _parse_event(self, component) -> ExternalEvent:
105
+
"""Parse iCalendar event component.
106
+
107
+
Args:
108
+
component: iCalendar VEVENT component
109
+
110
+
Returns:
111
+
ExternalEvent object
112
+
"""
113
+
return ExternalEvent(
114
+
id=str(component.get('UID', '')),
115
+
title=str(component.get('SUMMARY', '')),
116
+
description=str(component.get('DESCRIPTION', '')) if component.get('DESCRIPTION') else None,
117
+
start_time=self._parse_datetime(component.get('DTSTART')),
118
+
end_time=self._parse_datetime(component.get('DTEND')),
119
+
location=str(component.get('LOCATION', '')) if component.get('LOCATION') else None,
120
+
url=str(component.get('URL', '')) if component.get('URL') else None,
121
+
created_at=self._parse_datetime(component.get('CREATED')),
122
+
updated_at=self._parse_datetime(component.get('LAST-MODIFIED')),
123
+
raw_data=dict(component)
124
+
)
125
+
126
+
def _parse_datetime(self, dt_prop) -> Optional[datetime]:
127
+
"""Parse iCalendar datetime property.
128
+
129
+
Args:
130
+
dt_prop: iCalendar datetime property
131
+
132
+
Returns:
133
+
Parsed datetime or None
134
+
"""
135
+
if not dt_prop:
136
+
return None
137
+
138
+
try:
139
+
if hasattr(dt_prop, 'dt'):
140
+
dt = dt_prop.dt
141
+
# Handle date-only properties
142
+
if isinstance(dt, datetime):
143
+
return dt
144
+
else:
145
+
# Convert date to datetime at midnight
146
+
return datetime.combine(dt, datetime.min.time())
147
+
return dt_prop
148
+
except Exception as e:
149
+
logger.warning("Failed to parse datetime", error=str(e), value=str(dt_prop))
150
+
return None
+54
PYTHON/src/atproto_calendar/importers/outlook.py
+54
PYTHON/src/atproto_calendar/importers/outlook.py
···
1
+
"""Microsoft Outlook Calendar importer (placeholder)."""
2
+
3
+
from typing import List, Optional
4
+
from datetime import datetime
5
+
import structlog
6
+
7
+
from .base import CalendarImporter, ExternalEvent
8
+
from ..exceptions import ImportError, AuthenticationError
9
+
10
+
logger = structlog.get_logger(__name__)
11
+
12
+
13
+
class OutlookCalendarImporter(CalendarImporter):
14
+
"""Import events from Microsoft Outlook Calendar."""
15
+
16
+
def __init__(self):
17
+
"""Initialize Outlook Calendar importer."""
18
+
self.client = None
19
+
self.credentials = None
20
+
21
+
async def authenticate(self, credentials: dict) -> bool:
22
+
"""Authenticate with Microsoft Graph API.
23
+
24
+
Args:
25
+
credentials: Microsoft OAuth2 credentials
26
+
27
+
Returns:
28
+
True if authentication successful
29
+
30
+
Raises:
31
+
AuthenticationError: If authentication fails
32
+
"""
33
+
# TODO: Implement Outlook Calendar authentication
34
+
raise NotImplementedError("Outlook Calendar import not yet implemented")
35
+
36
+
async def import_events(
37
+
self,
38
+
start_date: Optional[datetime] = None,
39
+
end_date: Optional[datetime] = None
40
+
) -> List[ExternalEvent]:
41
+
"""Import events from Outlook Calendar.
42
+
43
+
Args:
44
+
start_date: Optional start date filter
45
+
end_date: Optional end date filter
46
+
47
+
Returns:
48
+
List of ExternalEvent objects
49
+
50
+
Raises:
51
+
ImportError: If import operation fails
52
+
"""
53
+
# TODO: Implement Outlook Calendar event import
54
+
raise NotImplementedError("Outlook Calendar import not yet implemented")
+66
PYTHON/src/atproto_calendar/main.py
+66
PYTHON/src/atproto_calendar/main.py
···
1
+
"""Main entry point for the AT Protocol Calendar Import CLI."""
2
+
3
+
import click
4
+
import structlog
5
+
from typing import Optional
6
+
7
+
from .cli.commands import cli
8
+
from .config.settings import get_settings
9
+
10
+
# Configure structured logging
11
+
structlog.configure(
12
+
processors=[
13
+
structlog.stdlib.filter_by_level,
14
+
structlog.stdlib.add_logger_name,
15
+
structlog.stdlib.add_log_level,
16
+
structlog.stdlib.PositionalArgumentsFormatter(),
17
+
structlog.processors.TimeStamper(fmt="iso"),
18
+
structlog.processors.StackInfoRenderer(),
19
+
structlog.processors.format_exc_info,
20
+
structlog.processors.UnicodeDecoder(),
21
+
structlog.processors.JSONRenderer()
22
+
],
23
+
context_class=dict,
24
+
logger_factory=structlog.stdlib.LoggerFactory(),
25
+
wrapper_class=structlog.stdlib.BoundLogger,
26
+
cache_logger_on_first_use=True,
27
+
)
28
+
29
+
30
+
def setup_logging(debug: bool = False) -> None:
31
+
"""Configure logging based on debug flag."""
32
+
import logging
33
+
34
+
level = logging.DEBUG if debug else logging.INFO
35
+
logging.basicConfig(
36
+
level=level,
37
+
format="%(message)s"
38
+
)
39
+
40
+
41
+
@click.group(invoke_without_command=True)
42
+
@click.option('--debug/--no-debug', default=False, help='Enable debug logging')
43
+
@click.option('--version', is_flag=True, help='Show version and exit')
44
+
@click.pass_context
45
+
def main(ctx: click.Context, debug: bool, version: bool) -> None:
46
+
"""AT Protocol Calendar Import CLI.
47
+
48
+
Import calendar events from various providers into AT Protocol.
49
+
"""
50
+
setup_logging(debug)
51
+
52
+
if version:
53
+
from . import __version__
54
+
click.echo(f"atproto-calendar-import {__version__}")
55
+
return
56
+
57
+
if ctx.invoked_subcommand is None:
58
+
click.echo(ctx.get_help())
59
+
60
+
61
+
# Register CLI commands
62
+
main.add_command(cli)
63
+
64
+
65
+
if __name__ == "__main__":
66
+
main()
+8
PYTHON/src/atproto_calendar/transform/__init__.py
+8
PYTHON/src/atproto_calendar/transform/__init__.py
+167
PYTHON/src/atproto_calendar/transform/converter.py
+167
PYTHON/src/atproto_calendar/transform/converter.py
···
1
+
"""Event conversion from external formats to AT Protocol."""
2
+
3
+
from typing import Optional, List, Dict, Any
4
+
from datetime import datetime
5
+
import structlog
6
+
7
+
from ..importers.base import ExternalEvent
8
+
from ..exceptions import ConversionError
9
+
10
+
logger = structlog.get_logger(__name__)
11
+
12
+
13
+
class ATProtoEvent:
14
+
"""AT Protocol event representation."""
15
+
16
+
def __init__(
17
+
self,
18
+
name: str,
19
+
created_at: datetime,
20
+
description: Optional[str] = None,
21
+
starts_at: Optional[datetime] = None,
22
+
ends_at: Optional[datetime] = None,
23
+
mode: Optional[str] = None,
24
+
status: Optional[str] = None,
25
+
locations: Optional[List[Dict[str, str]]] = None,
26
+
uris: Optional[List[str]] = None
27
+
):
28
+
"""Initialize AT Protocol event.
29
+
30
+
Args:
31
+
name: Event name/title
32
+
created_at: Event creation timestamp
33
+
description: Optional event description
34
+
starts_at: Optional event start time
35
+
ends_at: Optional event end time
36
+
mode: Optional event mode (virtual, inperson, hybrid)
37
+
status: Optional event status (scheduled, cancelled, etc.)
38
+
locations: Optional list of location objects
39
+
uris: Optional list of related URIs
40
+
"""
41
+
self.name = name
42
+
self.created_at = created_at
43
+
self.description = description
44
+
self.starts_at = starts_at
45
+
self.ends_at = ends_at
46
+
self.mode = mode
47
+
self.status = status
48
+
self.locations = locations or []
49
+
self.uris = uris or []
50
+
51
+
def to_dict(self) -> Dict[str, Any]:
52
+
"""Convert to dictionary for AT Protocol.
53
+
54
+
Returns:
55
+
Dictionary representation suitable for AT Protocol
56
+
"""
57
+
data = {
58
+
"$type": "community.lexicon.calendar.event",
59
+
"name": self.name,
60
+
"createdAt": self.created_at.isoformat(),
61
+
}
62
+
63
+
if self.description:
64
+
data["description"] = self.description
65
+
if self.starts_at:
66
+
data["startsAt"] = self.starts_at.isoformat()
67
+
if self.ends_at:
68
+
data["endsAt"] = self.ends_at.isoformat()
69
+
if self.mode:
70
+
data["mode"] = f"community.lexicon.calendar.event#{self.mode}"
71
+
if self.status:
72
+
data["status"] = f"community.lexicon.calendar.event#{self.status}"
73
+
if self.locations:
74
+
data["locations"] = self.locations
75
+
if self.uris:
76
+
data["uris"] = self.uris
77
+
78
+
return data
79
+
80
+
81
+
class EventConverter:
82
+
"""Convert external events to AT Protocol format."""
83
+
84
+
@staticmethod
85
+
def convert_external_event(external_event: ExternalEvent) -> ATProtoEvent:
86
+
"""Convert external event to AT Protocol event.
87
+
88
+
Args:
89
+
external_event: External event to convert
90
+
91
+
Returns:
92
+
ATProtoEvent object
93
+
94
+
Raises:
95
+
ConversionError: If conversion fails
96
+
"""
97
+
try:
98
+
# Determine mode based on location/URL
99
+
mode = EventConverter._determine_mode(external_event)
100
+
101
+
# Determine status (default to scheduled)
102
+
status = "scheduled"
103
+
104
+
# Parse locations
105
+
locations = []
106
+
if external_event.location:
107
+
locations.append({
108
+
"address": external_event.location
109
+
})
110
+
111
+
# Parse URIs
112
+
uris = []
113
+
if external_event.url:
114
+
uris.append(external_event.url)
115
+
116
+
return ATProtoEvent(
117
+
name=external_event.title or "Untitled Event",
118
+
created_at=external_event.created_at or datetime.utcnow(),
119
+
description=external_event.description,
120
+
starts_at=external_event.start_time,
121
+
ends_at=external_event.end_time,
122
+
mode=mode,
123
+
status=status,
124
+
locations=locations,
125
+
uris=uris
126
+
)
127
+
128
+
except Exception as e:
129
+
logger.error("Event conversion failed", error=str(e), event_id=external_event.id)
130
+
raise ConversionError(f"Failed to convert event: {e}", external_event)
131
+
132
+
@staticmethod
133
+
def _determine_mode(event: ExternalEvent) -> Optional[str]:
134
+
"""Determine event mode from external event data.
135
+
136
+
Args:
137
+
event: External event to analyze
138
+
139
+
Returns:
140
+
Event mode string or None
141
+
"""
142
+
if not event.location and not event.url:
143
+
return None
144
+
145
+
# Check for virtual meeting indicators
146
+
virtual_indicators = [
147
+
'zoom', 'teams', 'meet', 'webex', 'skype',
148
+
'virtual', 'online', 'remote', 'https://'
149
+
]
150
+
151
+
location_text = (event.location or "").lower()
152
+
description_text = (event.description or "").lower()
153
+
url_text = (event.url or "").lower()
154
+
155
+
text_to_check = f"{location_text} {description_text} {url_text}"
156
+
157
+
has_virtual = any(indicator in text_to_check for indicator in virtual_indicators)
158
+
has_physical = event.location and not any(indicator in location_text for indicator in virtual_indicators)
159
+
160
+
if has_virtual and has_physical:
161
+
return "hybrid"
162
+
elif has_virtual:
163
+
return "virtual"
164
+
elif has_physical:
165
+
return "inperson"
166
+
167
+
return None
+3
PYTHON/tests/__init__.py
+3
PYTHON/tests/__init__.py
+79
PYTHON/tests/conftest.py
+79
PYTHON/tests/conftest.py
···
1
+
"""Pytest configuration and fixtures."""
2
+
3
+
import pytest
4
+
import asyncio
5
+
from typing import Generator, AsyncGenerator
6
+
from pathlib import Path
7
+
import tempfile
8
+
import json
9
+
10
+
11
+
@pytest.fixture
12
+
def sample_ics_content() -> str:
13
+
"""Sample ICS calendar content for testing."""
14
+
return """BEGIN:VCALENDAR
15
+
VERSION:2.0
16
+
PRODID:-//Test//Test Calendar//EN
17
+
BEGIN:VEVENT
18
+
UID:test-event-1@example.com
19
+
DTSTART:20250603T140000Z
20
+
DTEND:20250603T150000Z
21
+
SUMMARY:Test Meeting
22
+
DESCRIPTION:A test meeting for unit tests
23
+
LOCATION:Conference Room A
24
+
URL:https://example.com/meeting
25
+
CREATED:20250601T120000Z
26
+
LAST-MODIFIED:20250602T100000Z
27
+
END:VEVENT
28
+
BEGIN:VEVENT
29
+
UID:test-event-2@example.com
30
+
DTSTART:20250604T090000Z
31
+
DTEND:20250604T100000Z
32
+
SUMMARY:Virtual Standup
33
+
DESCRIPTION:Daily standup via Zoom
34
+
LOCATION:https://zoom.us/j/123456789
35
+
CREATED:20250601T120000Z
36
+
LAST-MODIFIED:20250602T100000Z
37
+
END:VEVENT
38
+
END:VCALENDAR"""
39
+
40
+
41
+
@pytest.fixture
42
+
def temp_ics_file(sample_ics_content: str) -> Generator[Path, None, None]:
43
+
"""Create a temporary ICS file for testing."""
44
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.ics', delete=False) as f:
45
+
f.write(sample_ics_content)
46
+
temp_path = Path(f.name)
47
+
48
+
try:
49
+
yield temp_path
50
+
finally:
51
+
temp_path.unlink()
52
+
53
+
54
+
@pytest.fixture
55
+
def sample_google_credentials() -> dict:
56
+
"""Sample Google OAuth2 credentials for testing."""
57
+
return {
58
+
"access_token": "ya29.test-access-token",
59
+
"refresh_token": "1//test-refresh-token",
60
+
"token_uri": "https://oauth2.googleapis.com/token",
61
+
"client_id": "test-client-id.googleusercontent.com",
62
+
"client_secret": "test-client-secret"
63
+
}
64
+
65
+
66
+
@pytest.fixture
67
+
def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
68
+
"""Create an event loop for async tests."""
69
+
loop = asyncio.new_event_loop()
70
+
yield loop
71
+
loop.close()
72
+
73
+
74
+
@pytest.fixture
75
+
async def async_client() -> AsyncGenerator:
76
+
"""Async HTTP client for API testing."""
77
+
import httpx
78
+
async with httpx.AsyncClient() as client:
79
+
yield client
+3
PYTHON/tests/test_api/__init__.py
+3
PYTHON/tests/test_api/__init__.py
+3
PYTHON/tests/test_cli/__init__.py
+3
PYTHON/tests/test_cli/__init__.py
+3
PYTHON/tests/test_import/__init__.py
+3
PYTHON/tests/test_import/__init__.py
+95
PYTHON/tests/test_import/test_ics.py
+95
PYTHON/tests/test_import/test_ics.py
···
1
+
"""Tests for ICS calendar import functionality."""
2
+
3
+
import pytest
4
+
from pathlib import Path
5
+
from datetime import datetime
6
+
7
+
from atproto_calendar.import.ics import ICSImporter
8
+
from atproto_calendar.import.base import ExternalEvent
9
+
from atproto_calendar.exceptions import ImportError
10
+
11
+
12
+
class TestICSImporter:
13
+
"""Test ICS calendar import functionality."""
14
+
15
+
def test_init_with_file_path(self, temp_ics_file: Path):
16
+
"""Test ICS importer initialization with file path."""
17
+
importer = ICSImporter(temp_ics_file)
18
+
assert importer.source == temp_ics_file
19
+
assert not importer.is_url
20
+
21
+
def test_init_with_url(self):
22
+
"""Test ICS importer initialization with URL."""
23
+
url = "https://example.com/calendar.ics"
24
+
importer = ICSImporter(url)
25
+
assert importer.source == url
26
+
assert importer.is_url
27
+
28
+
@pytest.mark.asyncio
29
+
async def test_authenticate_always_succeeds(self, temp_ics_file: Path):
30
+
"""Test that ICS authentication always succeeds."""
31
+
importer = ICSImporter(temp_ics_file)
32
+
result = await importer.authenticate({})
33
+
assert result is True
34
+
35
+
@pytest.mark.asyncio
36
+
async def test_import_events_from_file(self, temp_ics_file: Path):
37
+
"""Test importing events from ICS file."""
38
+
importer = ICSImporter(temp_ics_file)
39
+
events = await importer.import_events()
40
+
41
+
assert len(events) == 2
42
+
43
+
# Check first event
44
+
event1 = events[0]
45
+
assert isinstance(event1, ExternalEvent)
46
+
assert event1.id == "test-event-1@example.com"
47
+
assert event1.title == "Test Meeting"
48
+
assert event1.description == "A test meeting for unit tests"
49
+
assert event1.location == "Conference Room A"
50
+
assert event1.url == "https://example.com/meeting"
51
+
52
+
# Check second event
53
+
event2 = events[1]
54
+
assert event2.id == "test-event-2@example.com"
55
+
assert event2.title == "Virtual Standup"
56
+
assert event2.location == "https://zoom.us/j/123456789"
57
+
58
+
@pytest.mark.asyncio
59
+
async def test_import_events_with_date_filter(self, temp_ics_file: Path):
60
+
"""Test importing events with date range filter."""
61
+
importer = ICSImporter(temp_ics_file)
62
+
63
+
# Filter to only include events on June 3rd
64
+
start_date = datetime(2025, 6, 3)
65
+
end_date = datetime(2025, 6, 3, 23, 59, 59)
66
+
67
+
events = await importer.import_events(start_date, end_date)
68
+
69
+
# Should only return the first event (June 3rd)
70
+
assert len(events) == 1
71
+
assert events[0].title == "Test Meeting"
72
+
73
+
@pytest.mark.asyncio
74
+
async def test_import_events_invalid_file(self):
75
+
"""Test importing from non-existent file raises error."""
76
+
importer = ICSImporter("/nonexistent/file.ics")
77
+
78
+
with pytest.raises(ImportError) as exc_info:
79
+
await importer.import_events()
80
+
81
+
assert "Failed to import ICS events" in str(exc_info.value)
82
+
assert exc_info.value.provider == "ics"
83
+
84
+
@pytest.mark.asyncio
85
+
async def test_import_events_invalid_date_range(self, temp_ics_file: Path):
86
+
"""Test that invalid date range raises ValueError."""
87
+
importer = ICSImporter(temp_ics_file)
88
+
89
+
start_date = datetime(2025, 6, 5)
90
+
end_date = datetime(2025, 6, 3) # End before start
91
+
92
+
with pytest.raises(ValueError) as exc_info:
93
+
await importer.import_events(start_date, end_date)
94
+
95
+
assert "Start date must be before end date" in str(exc_info.value)
+3
PYTHON/tests/test_transform/__init__.py
+3
PYTHON/tests/test_transform/__init__.py
+177
PYTHON/tests/test_transform/test_converter.py
+177
PYTHON/tests/test_transform/test_converter.py
···
1
+
"""Tests for event conversion functionality."""
2
+
3
+
import pytest
4
+
from datetime import datetime
5
+
6
+
from atproto_calendar.import.base import ExternalEvent
7
+
from atproto_calendar.transform.converter import EventConverter, ATProtoEvent
8
+
9
+
10
+
class TestEventConverter:
11
+
"""Test event conversion to AT Protocol format."""
12
+
13
+
def test_convert_basic_event(self):
14
+
"""Test converting a basic external event."""
15
+
external_event = ExternalEvent(
16
+
id="test-1",
17
+
title="Test Meeting",
18
+
description="A test meeting",
19
+
start_time=datetime(2025, 6, 3, 14, 0),
20
+
end_time=datetime(2025, 6, 3, 15, 0),
21
+
location="Conference Room A",
22
+
created_at=datetime(2025, 6, 1, 12, 0)
23
+
)
24
+
25
+
at_event = EventConverter.convert_external_event(external_event)
26
+
27
+
assert isinstance(at_event, ATProtoEvent)
28
+
assert at_event.name == "Test Meeting"
29
+
assert at_event.description == "A test meeting"
30
+
assert at_event.starts_at == datetime(2025, 6, 3, 14, 0)
31
+
assert at_event.ends_at == datetime(2025, 6, 3, 15, 0)
32
+
assert at_event.status == "scheduled"
33
+
assert len(at_event.locations) == 1
34
+
assert at_event.locations[0]["address"] == "Conference Room A"
35
+
36
+
def test_convert_virtual_event(self):
37
+
"""Test converting a virtual event (should detect virtual mode)."""
38
+
external_event = ExternalEvent(
39
+
id="test-2",
40
+
title="Virtual Standup",
41
+
description="Daily standup via Zoom",
42
+
start_time=datetime(2025, 6, 4, 9, 0),
43
+
location="https://zoom.us/j/123456789",
44
+
created_at=datetime(2025, 6, 1, 12, 0)
45
+
)
46
+
47
+
at_event = EventConverter.convert_external_event(external_event)
48
+
49
+
assert at_event.mode == "virtual"
50
+
assert len(at_event.locations) == 1
51
+
assert at_event.locations[0]["address"] == "https://zoom.us/j/123456789"
52
+
53
+
def test_convert_hybrid_event(self):
54
+
"""Test converting a hybrid event (physical + virtual)."""
55
+
external_event = ExternalEvent(
56
+
id="test-3",
57
+
title="Hybrid Meeting",
58
+
description="In-person meeting with Zoom option",
59
+
location="Conference Room B",
60
+
url="https://teams.microsoft.com/l/meetup/123",
61
+
created_at=datetime(2025, 6, 1, 12, 0)
62
+
)
63
+
64
+
at_event = EventConverter.convert_external_event(external_event)
65
+
66
+
assert at_event.mode == "hybrid"
67
+
assert len(at_event.locations) == 1
68
+
assert at_event.locations[0]["address"] == "Conference Room B"
69
+
assert len(at_event.uris) == 1
70
+
assert at_event.uris[0] == "https://teams.microsoft.com/l/meetup/123"
71
+
72
+
def test_convert_event_with_url_only(self):
73
+
"""Test converting event with only URL (should be virtual)."""
74
+
external_event = ExternalEvent(
75
+
id="test-4",
76
+
title="Online Webinar",
77
+
url="https://example.com/webinar",
78
+
created_at=datetime(2025, 6, 1, 12, 0)
79
+
)
80
+
81
+
at_event = EventConverter.convert_external_event(external_event)
82
+
83
+
assert at_event.mode == "virtual"
84
+
assert len(at_event.locations) == 0
85
+
assert len(at_event.uris) == 1
86
+
assert at_event.uris[0] == "https://example.com/webinar"
87
+
88
+
def test_convert_event_no_location_or_url(self):
89
+
"""Test converting event with no location or URL."""
90
+
external_event = ExternalEvent(
91
+
id="test-5",
92
+
title="TBD Meeting",
93
+
created_at=datetime(2025, 6, 1, 12, 0)
94
+
)
95
+
96
+
at_event = EventConverter.convert_external_event(external_event)
97
+
98
+
assert at_event.mode is None
99
+
assert len(at_event.locations) == 0
100
+
assert len(at_event.uris) == 0
101
+
102
+
def test_convert_event_missing_title_gets_default(self):
103
+
"""Test that events without title get default title."""
104
+
external_event = ExternalEvent(
105
+
id="test-6",
106
+
title="", # Empty title
107
+
created_at=datetime(2025, 6, 1, 12, 0)
108
+
)
109
+
110
+
at_event = EventConverter.convert_external_event(external_event)
111
+
assert at_event.name == "Untitled Event"
112
+
113
+
def test_convert_event_missing_created_at_gets_current_time(self):
114
+
"""Test that events without created_at get current time."""
115
+
external_event = ExternalEvent(
116
+
id="test-7",
117
+
title="Test Event",
118
+
created_at=None
119
+
)
120
+
121
+
at_event = EventConverter.convert_external_event(external_event)
122
+
123
+
# Should have been set to current time (within last few seconds)
124
+
time_diff = datetime.utcnow() - at_event.created_at
125
+
assert time_diff.total_seconds() < 5
126
+
127
+
128
+
class TestATProtoEvent:
129
+
"""Test AT Protocol event representation."""
130
+
131
+
def test_to_dict_basic(self):
132
+
"""Test converting AT Protocol event to dictionary."""
133
+
event = ATProtoEvent(
134
+
name="Test Event",
135
+
created_at=datetime(2025, 6, 1, 12, 0),
136
+
description="Test description",
137
+
starts_at=datetime(2025, 6, 3, 14, 0),
138
+
ends_at=datetime(2025, 6, 3, 15, 0),
139
+
mode="inperson",
140
+
status="scheduled",
141
+
locations=[{"address": "Conference Room A"}],
142
+
uris=["https://example.com"]
143
+
)
144
+
145
+
data = event.to_dict()
146
+
147
+
expected = {
148
+
"$type": "community.lexicon.calendar.event",
149
+
"name": "Test Event",
150
+
"createdAt": "2025-06-01T12:00:00",
151
+
"description": "Test description",
152
+
"startsAt": "2025-06-03T14:00:00",
153
+
"endsAt": "2025-06-03T15:00:00",
154
+
"mode": "community.lexicon.calendar.event#inperson",
155
+
"status": "community.lexicon.calendar.event#scheduled",
156
+
"locations": [{"address": "Conference Room A"}],
157
+
"uris": ["https://example.com"]
158
+
}
159
+
160
+
assert data == expected
161
+
162
+
def test_to_dict_minimal(self):
163
+
"""Test converting minimal AT Protocol event to dictionary."""
164
+
event = ATProtoEvent(
165
+
name="Minimal Event",
166
+
created_at=datetime(2025, 6, 1, 12, 0)
167
+
)
168
+
169
+
data = event.to_dict()
170
+
171
+
expected = {
172
+
"$type": "community.lexicon.calendar.event",
173
+
"name": "Minimal Event",
174
+
"createdAt": "2025-06-01T12:00:00"
175
+
}
176
+
177
+
assert data == expected
+26
-42
README.md
+26
-42
README.md
···
1
+
# AT Protocol Calendar Import
1
2
2
-
# 📘 Development Guide: `atproto-calendar-import` (with [atrium-rs](https://github.com/atrium-rs/atrium))
3
+
Import calendar events from external providers (Google, Outlook, Apple, ICS) into the [AT Protocol](https://atproto.com/) ecosystem.
3
4
4
-
---
5
+
## Implementations
5
6
6
-
## 🔍 Project Description
7
-
8
-
`atproto-calendar-import` is a Rust library and CLI tool for importing calendar events from external providers (Google, Outlook, Apple, ICS) into the [AT Protocol](https://atproto.com/). It leverages the [atrium](https://github.com/atrium-rs/atrium) crate for authentication, lexicon-based data modeling, and repository interactions.
7
+
This project is available in two languages:
9
8
10
-
---
11
-
12
-
## 📦 Repository Structure
13
-
14
-
```text
15
-
atproto-calendar-import/
16
-
├── src/
17
-
│ ├── main.rs # CLI binary entry
18
-
│ ├── lib.rs # Core crate definition
19
-
│ ├── import/ # External calendar integrations (Google, Outlook, etc.)
20
-
│ ├── transform/ # Converts events to AT lexicon
21
-
│ ├── pds/ # Interacts with ATP repos via Atrium
22
-
│ ├── auth/ # OAuth2 + ATP auth helpers
23
-
│ ├── dedup/ # Deduplication logic
24
-
│ ├── cli/ # Argument parser and subcommand logic
25
-
│ └── errors.rs # Centralized, structured error handling
26
-
├── tests/ # Integration tests
27
-
├── Cargo.toml # Crate metadata and dependencies
28
-
└── README.md # Project documentation
29
-
```
9
+
### 🐍 Python Implementation
10
+
**[`PYTHON/`](./PYTHON/README.md)**
11
+
- FastAPI-based REST API
12
+
- Async/await support
13
+
- CLI tool with multiple providers
14
+
- Full type safety with Pydantic
30
15
31
-
---
16
+
### 🦀 Rust Implementation
17
+
**[`RUST/`](./RUST/README.md)** ⚠️ *Alpha - Not guaranteed to work*
18
+
- High-performance CLI tool
19
+
- Built with atrium-rs
20
+
- OAuth2 authentication flows
21
+
- Intelligent event deduplication
32
22
33
-
## ✅ Requirements
23
+
## Quick Start
34
24
35
-
* **Rust** ≥ 1.70
36
-
* **Cargo**
37
-
* External calendar API credentials (Google OAuth, Microsoft)
38
-
* Access to a self-hosted or sandbox PDS (see [ATP self-hosting guide](https://atproto.com/guides/self-hosting))
39
-
* Postgres (optional, for deduplication cache)
25
+
Choose your preferred implementation:
40
26
41
-
---
27
+
- **Python**: `cd PYTHON && pip install -e .`
28
+
- **Rust**: `cd RUST && cargo build --release`
42
29
43
-
## 🛠️ Build & Test Commands
30
+
Both implementations support the same core features:
31
+
- Multiple calendar providers (Google, Outlook, ICS)
32
+
- AT Protocol/Bluesky integration
33
+
- Event deduplication
34
+
- OAuth2 authentication
44
35
45
-
| Task | Command |
46
-
| ------------------ | --------------------------------------------------------------------- |
47
-
| Build | `cargo build` |
48
-
| Type Check | `cargo check` |
49
-
| Format Code | `cargo fmt` |
50
-
| Lint Code | `cargo clippy` |
51
-
| Run All Tests | `cargo test` |
52
-
| Run Specific Test | `cargo test <test_name>` |
36
+
See the respective README files for detailed setup and usage instructions.
+16
RUST/.env.example
+16
RUST/.env.example
···
1
+
# AT Protocol Configuration (Required)
2
+
ATP_HANDLE=your-handle.bsky.social
3
+
ATP_PASSWORD=your-app-password
4
+
ATP_PDS_URL=https://bsky.social
5
+
ATP_DID=did:plc:your-did
6
+
7
+
# Google Calendar API (Optional - for Google imports)
8
+
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
9
+
GOOGLE_CLIENT_SECRET=your-google-client-secret
10
+
11
+
# Microsoft Outlook API (Optional - for Outlook imports)
12
+
OUTLOOK_CLIENT_ID=your-outlook-client-id
13
+
OUTLOOK_CLIENT_SECRET=your-outlook-client-secret
14
+
15
+
# Logging Configuration (Optional)
16
+
RUST_LOG=info
+101
RUST/.gitignore
+101
RUST/.gitignore
···
1
+
# Rust build artifacts
2
+
/target/
3
+
**/*.rs.bk
4
+
*.pdb
5
+
6
+
# Cargo
7
+
Cargo.lock
8
+
.cargo/
9
+
10
+
# IDE files
11
+
.vscode/
12
+
.idea/
13
+
*.swp
14
+
*.swo
15
+
16
+
# OS files
17
+
.DS_Store
18
+
.DS_Store?
19
+
._*
20
+
.Spotlight-V100
21
+
.Trashes
22
+
ehthumbs.db
23
+
Thumbs.db
24
+
25
+
# Environment and secrets
26
+
.env
27
+
.env.*
28
+
!.env.example
29
+
*.key
30
+
*.pem
31
+
*.p12
32
+
*.pfx
33
+
34
+
# Authentication tokens and credentials
35
+
google_credentials.json
36
+
outlook_credentials.json
37
+
auth_tokens.json
38
+
.credentials/
39
+
40
+
# Database files
41
+
*.db
42
+
*.sqlite
43
+
*.sqlite3
44
+
45
+
# Log files
46
+
*.log
47
+
logs/
48
+
log/
49
+
50
+
# Cache and temporary files
51
+
*.tmp
52
+
*.temp
53
+
cache/
54
+
.cache/
55
+
56
+
# Test artifacts
57
+
test-results/
58
+
coverage/
59
+
*.profraw
60
+
61
+
# Documentation build
62
+
/docs/build/
63
+
/docs/site/
64
+
65
+
# Local configuration
66
+
config.local.*
67
+
local.toml
68
+
69
+
# Backup files
70
+
*.bak
71
+
*.backup
72
+
*~
73
+
74
+
# Runtime data
75
+
pids
76
+
*.pid
77
+
*.seed
78
+
*.pid.lock
79
+
80
+
# Coverage directory used by tools like istanbul
81
+
coverage/
82
+
83
+
# Optional npm cache directory
84
+
.npm
85
+
86
+
# Optional eslint cache
87
+
.eslintcache
88
+
89
+
# Editor directories and files
90
+
.vscode/*
91
+
!.vscode/settings.json
92
+
!.vscode/tasks.json
93
+
!.vscode/launch.json
94
+
!.vscode/extensions.json
95
+
*.code-workspace
96
+
97
+
# Local History for Visual Studio Code
98
+
.history/
99
+
100
+
# Built Visual Studio Code Extensions
101
+
*.vsix
+3367
RUST/Cargo.lock
+3367
RUST/Cargo.lock
···
1
+
# This file is automatically @generated by Cargo.
2
+
# It is not intended for manual editing.
3
+
version = 4
4
+
5
+
[[package]]
6
+
name = "addr2line"
7
+
version = "0.24.2"
8
+
source = "registry+https://github.com/rust-lang/crates.io-index"
9
+
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
10
+
dependencies = [
11
+
"gimli",
12
+
]
13
+
14
+
[[package]]
15
+
name = "adler2"
16
+
version = "2.0.0"
17
+
source = "registry+https://github.com/rust-lang/crates.io-index"
18
+
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
19
+
20
+
[[package]]
21
+
name = "aho-corasick"
22
+
version = "1.1.3"
23
+
source = "registry+https://github.com/rust-lang/crates.io-index"
24
+
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
25
+
dependencies = [
26
+
"memchr",
27
+
]
28
+
29
+
[[package]]
30
+
name = "allocator-api2"
31
+
version = "0.2.21"
32
+
source = "registry+https://github.com/rust-lang/crates.io-index"
33
+
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
34
+
35
+
[[package]]
36
+
name = "android-tzdata"
37
+
version = "0.1.1"
38
+
source = "registry+https://github.com/rust-lang/crates.io-index"
39
+
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
40
+
41
+
[[package]]
42
+
name = "android_system_properties"
43
+
version = "0.1.5"
44
+
source = "registry+https://github.com/rust-lang/crates.io-index"
45
+
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
46
+
dependencies = [
47
+
"libc",
48
+
]
49
+
50
+
[[package]]
51
+
name = "anstream"
52
+
version = "0.6.18"
53
+
source = "registry+https://github.com/rust-lang/crates.io-index"
54
+
checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
55
+
dependencies = [
56
+
"anstyle",
57
+
"anstyle-parse",
58
+
"anstyle-query",
59
+
"anstyle-wincon",
60
+
"colorchoice",
61
+
"is_terminal_polyfill",
62
+
"utf8parse",
63
+
]
64
+
65
+
[[package]]
66
+
name = "anstyle"
67
+
version = "1.0.10"
68
+
source = "registry+https://github.com/rust-lang/crates.io-index"
69
+
checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
70
+
71
+
[[package]]
72
+
name = "anstyle-parse"
73
+
version = "0.2.6"
74
+
source = "registry+https://github.com/rust-lang/crates.io-index"
75
+
checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
76
+
dependencies = [
77
+
"utf8parse",
78
+
]
79
+
80
+
[[package]]
81
+
name = "anstyle-query"
82
+
version = "1.1.2"
83
+
source = "registry+https://github.com/rust-lang/crates.io-index"
84
+
checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
85
+
dependencies = [
86
+
"windows-sys 0.59.0",
87
+
]
88
+
89
+
[[package]]
90
+
name = "anstyle-wincon"
91
+
version = "3.0.8"
92
+
source = "registry+https://github.com/rust-lang/crates.io-index"
93
+
checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa"
94
+
dependencies = [
95
+
"anstyle",
96
+
"once_cell_polyfill",
97
+
"windows-sys 0.59.0",
98
+
]
99
+
100
+
[[package]]
101
+
name = "anyhow"
102
+
version = "1.0.98"
103
+
source = "registry+https://github.com/rust-lang/crates.io-index"
104
+
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
105
+
106
+
[[package]]
107
+
name = "assert-json-diff"
108
+
version = "2.0.2"
109
+
source = "registry+https://github.com/rust-lang/crates.io-index"
110
+
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
111
+
dependencies = [
112
+
"serde",
113
+
"serde_json",
114
+
]
115
+
116
+
[[package]]
117
+
name = "async-channel"
118
+
version = "1.9.0"
119
+
source = "registry+https://github.com/rust-lang/crates.io-index"
120
+
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
121
+
dependencies = [
122
+
"concurrent-queue",
123
+
"event-listener 2.5.3",
124
+
"futures-core",
125
+
]
126
+
127
+
[[package]]
128
+
name = "async-compression"
129
+
version = "0.4.23"
130
+
source = "registry+https://github.com/rust-lang/crates.io-index"
131
+
checksum = "b37fc50485c4f3f736a4fb14199f6d5f5ba008d7f28fe710306c92780f004c07"
132
+
dependencies = [
133
+
"flate2",
134
+
"futures-core",
135
+
"memchr",
136
+
"pin-project-lite",
137
+
"tokio",
138
+
]
139
+
140
+
[[package]]
141
+
name = "async-lock"
142
+
version = "3.4.0"
143
+
source = "registry+https://github.com/rust-lang/crates.io-index"
144
+
checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18"
145
+
dependencies = [
146
+
"event-listener 5.4.0",
147
+
"event-listener-strategy",
148
+
"pin-project-lite",
149
+
]
150
+
151
+
[[package]]
152
+
name = "async-stream"
153
+
version = "0.3.6"
154
+
source = "registry+https://github.com/rust-lang/crates.io-index"
155
+
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
156
+
dependencies = [
157
+
"async-stream-impl",
158
+
"futures-core",
159
+
"pin-project-lite",
160
+
]
161
+
162
+
[[package]]
163
+
name = "async-stream-impl"
164
+
version = "0.3.6"
165
+
source = "registry+https://github.com/rust-lang/crates.io-index"
166
+
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
167
+
dependencies = [
168
+
"proc-macro2",
169
+
"quote",
170
+
"syn",
171
+
]
172
+
173
+
[[package]]
174
+
name = "async-trait"
175
+
version = "0.1.88"
176
+
source = "registry+https://github.com/rust-lang/crates.io-index"
177
+
checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
178
+
dependencies = [
179
+
"proc-macro2",
180
+
"quote",
181
+
"syn",
182
+
]
183
+
184
+
[[package]]
185
+
name = "atomic-waker"
186
+
version = "1.1.2"
187
+
source = "registry+https://github.com/rust-lang/crates.io-index"
188
+
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
189
+
190
+
[[package]]
191
+
name = "atproto-calendar-import"
192
+
version = "0.1.0"
193
+
dependencies = [
194
+
"anyhow",
195
+
"atrium-api",
196
+
"atrium-xrpc-client",
197
+
"chrono",
198
+
"clap",
199
+
"dotenvy",
200
+
"oauth2",
201
+
"regex",
202
+
"reqwest",
203
+
"secrecy",
204
+
"serde",
205
+
"serde_json",
206
+
"sha2",
207
+
"thiserror 1.0.69",
208
+
"tokio",
209
+
"tokio-test",
210
+
"tracing",
211
+
"tracing-subscriber",
212
+
"url",
213
+
"urlencoding",
214
+
"uuid",
215
+
"wiremock",
216
+
]
217
+
218
+
[[package]]
219
+
name = "atrium-api"
220
+
version = "0.25.4"
221
+
source = "registry+https://github.com/rust-lang/crates.io-index"
222
+
checksum = "46355d3245edc7b3160b2a45fe55d09a6963ebd3eee0252feb6b72fb0eb71463"
223
+
dependencies = [
224
+
"atrium-common",
225
+
"atrium-xrpc",
226
+
"chrono",
227
+
"http 1.3.1",
228
+
"ipld-core",
229
+
"langtag",
230
+
"regex",
231
+
"serde",
232
+
"serde_bytes",
233
+
"serde_json",
234
+
"thiserror 1.0.69",
235
+
"tokio",
236
+
"trait-variant",
237
+
]
238
+
239
+
[[package]]
240
+
name = "atrium-common"
241
+
version = "0.1.2"
242
+
source = "registry+https://github.com/rust-lang/crates.io-index"
243
+
checksum = "9ed5610654043faa396a5a15afac0ac646d76aebe45aebd7cef4f8b96b0ab7f4"
244
+
dependencies = [
245
+
"dashmap",
246
+
"lru",
247
+
"moka",
248
+
"thiserror 1.0.69",
249
+
"tokio",
250
+
"trait-variant",
251
+
"web-time",
252
+
]
253
+
254
+
[[package]]
255
+
name = "atrium-xrpc"
256
+
version = "0.12.3"
257
+
source = "registry+https://github.com/rust-lang/crates.io-index"
258
+
checksum = "0216ad50ce34e9ff982e171c3659e65dedaa2ed5ac2994524debdc9a9647ffa8"
259
+
dependencies = [
260
+
"http 1.3.1",
261
+
"serde",
262
+
"serde_html_form",
263
+
"serde_json",
264
+
"thiserror 1.0.69",
265
+
"trait-variant",
266
+
]
267
+
268
+
[[package]]
269
+
name = "atrium-xrpc-client"
270
+
version = "0.5.14"
271
+
source = "registry+https://github.com/rust-lang/crates.io-index"
272
+
checksum = "e099e5171f79faef52364ef0657a4cab086a71b384a779a29597a91b780de0d5"
273
+
dependencies = [
274
+
"atrium-xrpc",
275
+
"reqwest",
276
+
]
277
+
278
+
[[package]]
279
+
name = "autocfg"
280
+
version = "1.4.0"
281
+
source = "registry+https://github.com/rust-lang/crates.io-index"
282
+
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
283
+
284
+
[[package]]
285
+
name = "backtrace"
286
+
version = "0.3.75"
287
+
source = "registry+https://github.com/rust-lang/crates.io-index"
288
+
checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
289
+
dependencies = [
290
+
"addr2line",
291
+
"cfg-if",
292
+
"libc",
293
+
"miniz_oxide",
294
+
"object",
295
+
"rustc-demangle",
296
+
"windows-targets 0.52.6",
297
+
]
298
+
299
+
[[package]]
300
+
name = "base-x"
301
+
version = "0.2.11"
302
+
source = "registry+https://github.com/rust-lang/crates.io-index"
303
+
checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270"
304
+
305
+
[[package]]
306
+
name = "base64"
307
+
version = "0.13.1"
308
+
source = "registry+https://github.com/rust-lang/crates.io-index"
309
+
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
310
+
311
+
[[package]]
312
+
name = "base64"
313
+
version = "0.21.7"
314
+
source = "registry+https://github.com/rust-lang/crates.io-index"
315
+
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
316
+
317
+
[[package]]
318
+
name = "base64"
319
+
version = "0.22.1"
320
+
source = "registry+https://github.com/rust-lang/crates.io-index"
321
+
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
322
+
323
+
[[package]]
324
+
name = "bitflags"
325
+
version = "2.9.1"
326
+
source = "registry+https://github.com/rust-lang/crates.io-index"
327
+
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
328
+
329
+
[[package]]
330
+
name = "block-buffer"
331
+
version = "0.10.4"
332
+
source = "registry+https://github.com/rust-lang/crates.io-index"
333
+
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
334
+
dependencies = [
335
+
"generic-array",
336
+
]
337
+
338
+
[[package]]
339
+
name = "bumpalo"
340
+
version = "3.17.0"
341
+
source = "registry+https://github.com/rust-lang/crates.io-index"
342
+
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
343
+
344
+
[[package]]
345
+
name = "bytes"
346
+
version = "1.10.1"
347
+
source = "registry+https://github.com/rust-lang/crates.io-index"
348
+
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
349
+
350
+
[[package]]
351
+
name = "cc"
352
+
version = "1.2.25"
353
+
source = "registry+https://github.com/rust-lang/crates.io-index"
354
+
checksum = "d0fc897dc1e865cc67c0e05a836d9d3f1df3cbe442aa4a9473b18e12624a4951"
355
+
dependencies = [
356
+
"shlex",
357
+
]
358
+
359
+
[[package]]
360
+
name = "cfg-if"
361
+
version = "1.0.0"
362
+
source = "registry+https://github.com/rust-lang/crates.io-index"
363
+
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
364
+
365
+
[[package]]
366
+
name = "cfg_aliases"
367
+
version = "0.2.1"
368
+
source = "registry+https://github.com/rust-lang/crates.io-index"
369
+
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
370
+
371
+
[[package]]
372
+
name = "chrono"
373
+
version = "0.4.41"
374
+
source = "registry+https://github.com/rust-lang/crates.io-index"
375
+
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
376
+
dependencies = [
377
+
"android-tzdata",
378
+
"iana-time-zone",
379
+
"js-sys",
380
+
"num-traits",
381
+
"serde",
382
+
"wasm-bindgen",
383
+
"windows-link",
384
+
]
385
+
386
+
[[package]]
387
+
name = "cid"
388
+
version = "0.11.1"
389
+
source = "registry+https://github.com/rust-lang/crates.io-index"
390
+
checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a"
391
+
dependencies = [
392
+
"core2",
393
+
"multibase",
394
+
"multihash",
395
+
"serde",
396
+
"serde_bytes",
397
+
"unsigned-varint",
398
+
]
399
+
400
+
[[package]]
401
+
name = "clap"
402
+
version = "4.5.39"
403
+
source = "registry+https://github.com/rust-lang/crates.io-index"
404
+
checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f"
405
+
dependencies = [
406
+
"clap_builder",
407
+
"clap_derive",
408
+
]
409
+
410
+
[[package]]
411
+
name = "clap_builder"
412
+
version = "4.5.39"
413
+
source = "registry+https://github.com/rust-lang/crates.io-index"
414
+
checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51"
415
+
dependencies = [
416
+
"anstream",
417
+
"anstyle",
418
+
"clap_lex",
419
+
"strsim",
420
+
]
421
+
422
+
[[package]]
423
+
name = "clap_derive"
424
+
version = "4.5.32"
425
+
source = "registry+https://github.com/rust-lang/crates.io-index"
426
+
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
427
+
dependencies = [
428
+
"heck",
429
+
"proc-macro2",
430
+
"quote",
431
+
"syn",
432
+
]
433
+
434
+
[[package]]
435
+
name = "clap_lex"
436
+
version = "0.7.4"
437
+
source = "registry+https://github.com/rust-lang/crates.io-index"
438
+
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
439
+
440
+
[[package]]
441
+
name = "colorchoice"
442
+
version = "1.0.3"
443
+
source = "registry+https://github.com/rust-lang/crates.io-index"
444
+
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
445
+
446
+
[[package]]
447
+
name = "concurrent-queue"
448
+
version = "2.5.0"
449
+
source = "registry+https://github.com/rust-lang/crates.io-index"
450
+
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
451
+
dependencies = [
452
+
"crossbeam-utils",
453
+
]
454
+
455
+
[[package]]
456
+
name = "core-foundation"
457
+
version = "0.9.4"
458
+
source = "registry+https://github.com/rust-lang/crates.io-index"
459
+
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
460
+
dependencies = [
461
+
"core-foundation-sys",
462
+
"libc",
463
+
]
464
+
465
+
[[package]]
466
+
name = "core-foundation-sys"
467
+
version = "0.8.7"
468
+
source = "registry+https://github.com/rust-lang/crates.io-index"
469
+
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
470
+
471
+
[[package]]
472
+
name = "core2"
473
+
version = "0.4.0"
474
+
source = "registry+https://github.com/rust-lang/crates.io-index"
475
+
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
476
+
dependencies = [
477
+
"memchr",
478
+
]
479
+
480
+
[[package]]
481
+
name = "cpufeatures"
482
+
version = "0.2.17"
483
+
source = "registry+https://github.com/rust-lang/crates.io-index"
484
+
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
485
+
dependencies = [
486
+
"libc",
487
+
]
488
+
489
+
[[package]]
490
+
name = "crc32fast"
491
+
version = "1.4.2"
492
+
source = "registry+https://github.com/rust-lang/crates.io-index"
493
+
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
494
+
dependencies = [
495
+
"cfg-if",
496
+
]
497
+
498
+
[[package]]
499
+
name = "crossbeam-channel"
500
+
version = "0.5.15"
501
+
source = "registry+https://github.com/rust-lang/crates.io-index"
502
+
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
503
+
dependencies = [
504
+
"crossbeam-utils",
505
+
]
506
+
507
+
[[package]]
508
+
name = "crossbeam-epoch"
509
+
version = "0.9.18"
510
+
source = "registry+https://github.com/rust-lang/crates.io-index"
511
+
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
512
+
dependencies = [
513
+
"crossbeam-utils",
514
+
]
515
+
516
+
[[package]]
517
+
name = "crossbeam-utils"
518
+
version = "0.8.21"
519
+
source = "registry+https://github.com/rust-lang/crates.io-index"
520
+
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
521
+
522
+
[[package]]
523
+
name = "crypto-common"
524
+
version = "0.1.6"
525
+
source = "registry+https://github.com/rust-lang/crates.io-index"
526
+
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
527
+
dependencies = [
528
+
"generic-array",
529
+
"typenum",
530
+
]
531
+
532
+
[[package]]
533
+
name = "dashmap"
534
+
version = "6.1.0"
535
+
source = "registry+https://github.com/rust-lang/crates.io-index"
536
+
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
537
+
dependencies = [
538
+
"cfg-if",
539
+
"crossbeam-utils",
540
+
"hashbrown 0.14.5",
541
+
"lock_api",
542
+
"once_cell",
543
+
"parking_lot_core",
544
+
]
545
+
546
+
[[package]]
547
+
name = "data-encoding"
548
+
version = "2.9.0"
549
+
source = "registry+https://github.com/rust-lang/crates.io-index"
550
+
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
551
+
552
+
[[package]]
553
+
name = "data-encoding-macro"
554
+
version = "0.1.18"
555
+
source = "registry+https://github.com/rust-lang/crates.io-index"
556
+
checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d"
557
+
dependencies = [
558
+
"data-encoding",
559
+
"data-encoding-macro-internal",
560
+
]
561
+
562
+
[[package]]
563
+
name = "data-encoding-macro-internal"
564
+
version = "0.1.16"
565
+
source = "registry+https://github.com/rust-lang/crates.io-index"
566
+
checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976"
567
+
dependencies = [
568
+
"data-encoding",
569
+
"syn",
570
+
]
571
+
572
+
[[package]]
573
+
name = "deadpool"
574
+
version = "0.9.5"
575
+
source = "registry+https://github.com/rust-lang/crates.io-index"
576
+
checksum = "421fe0f90f2ab22016f32a9881be5134fdd71c65298917084b0c7477cbc3856e"
577
+
dependencies = [
578
+
"async-trait",
579
+
"deadpool-runtime",
580
+
"num_cpus",
581
+
"retain_mut",
582
+
"tokio",
583
+
]
584
+
585
+
[[package]]
586
+
name = "deadpool-runtime"
587
+
version = "0.1.4"
588
+
source = "registry+https://github.com/rust-lang/crates.io-index"
589
+
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
590
+
591
+
[[package]]
592
+
name = "digest"
593
+
version = "0.10.7"
594
+
source = "registry+https://github.com/rust-lang/crates.io-index"
595
+
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
596
+
dependencies = [
597
+
"block-buffer",
598
+
"crypto-common",
599
+
]
600
+
601
+
[[package]]
602
+
name = "displaydoc"
603
+
version = "0.2.5"
604
+
source = "registry+https://github.com/rust-lang/crates.io-index"
605
+
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
606
+
dependencies = [
607
+
"proc-macro2",
608
+
"quote",
609
+
"syn",
610
+
]
611
+
612
+
[[package]]
613
+
name = "dotenvy"
614
+
version = "0.15.7"
615
+
source = "registry+https://github.com/rust-lang/crates.io-index"
616
+
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
617
+
618
+
[[package]]
619
+
name = "encoding_rs"
620
+
version = "0.8.35"
621
+
source = "registry+https://github.com/rust-lang/crates.io-index"
622
+
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
623
+
dependencies = [
624
+
"cfg-if",
625
+
]
626
+
627
+
[[package]]
628
+
name = "equivalent"
629
+
version = "1.0.2"
630
+
source = "registry+https://github.com/rust-lang/crates.io-index"
631
+
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
632
+
633
+
[[package]]
634
+
name = "errno"
635
+
version = "0.3.12"
636
+
source = "registry+https://github.com/rust-lang/crates.io-index"
637
+
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
638
+
dependencies = [
639
+
"libc",
640
+
"windows-sys 0.59.0",
641
+
]
642
+
643
+
[[package]]
644
+
name = "event-listener"
645
+
version = "2.5.3"
646
+
source = "registry+https://github.com/rust-lang/crates.io-index"
647
+
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
648
+
649
+
[[package]]
650
+
name = "event-listener"
651
+
version = "5.4.0"
652
+
source = "registry+https://github.com/rust-lang/crates.io-index"
653
+
checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae"
654
+
dependencies = [
655
+
"concurrent-queue",
656
+
"parking",
657
+
"pin-project-lite",
658
+
]
659
+
660
+
[[package]]
661
+
name = "event-listener-strategy"
662
+
version = "0.5.4"
663
+
source = "registry+https://github.com/rust-lang/crates.io-index"
664
+
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
665
+
dependencies = [
666
+
"event-listener 5.4.0",
667
+
"pin-project-lite",
668
+
]
669
+
670
+
[[package]]
671
+
name = "fastrand"
672
+
version = "1.9.0"
673
+
source = "registry+https://github.com/rust-lang/crates.io-index"
674
+
checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
675
+
dependencies = [
676
+
"instant",
677
+
]
678
+
679
+
[[package]]
680
+
name = "fastrand"
681
+
version = "2.3.0"
682
+
source = "registry+https://github.com/rust-lang/crates.io-index"
683
+
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
684
+
685
+
[[package]]
686
+
name = "flate2"
687
+
version = "1.1.1"
688
+
source = "registry+https://github.com/rust-lang/crates.io-index"
689
+
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
690
+
dependencies = [
691
+
"crc32fast",
692
+
"miniz_oxide",
693
+
]
694
+
695
+
[[package]]
696
+
name = "fnv"
697
+
version = "1.0.7"
698
+
source = "registry+https://github.com/rust-lang/crates.io-index"
699
+
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
700
+
701
+
[[package]]
702
+
name = "foldhash"
703
+
version = "0.1.5"
704
+
source = "registry+https://github.com/rust-lang/crates.io-index"
705
+
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
706
+
707
+
[[package]]
708
+
name = "foreign-types"
709
+
version = "0.3.2"
710
+
source = "registry+https://github.com/rust-lang/crates.io-index"
711
+
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
712
+
dependencies = [
713
+
"foreign-types-shared",
714
+
]
715
+
716
+
[[package]]
717
+
name = "foreign-types-shared"
718
+
version = "0.1.1"
719
+
source = "registry+https://github.com/rust-lang/crates.io-index"
720
+
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
721
+
722
+
[[package]]
723
+
name = "form_urlencoded"
724
+
version = "1.2.1"
725
+
source = "registry+https://github.com/rust-lang/crates.io-index"
726
+
checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
727
+
dependencies = [
728
+
"percent-encoding",
729
+
]
730
+
731
+
[[package]]
732
+
name = "futures"
733
+
version = "0.3.31"
734
+
source = "registry+https://github.com/rust-lang/crates.io-index"
735
+
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
736
+
dependencies = [
737
+
"futures-channel",
738
+
"futures-core",
739
+
"futures-executor",
740
+
"futures-io",
741
+
"futures-sink",
742
+
"futures-task",
743
+
"futures-util",
744
+
]
745
+
746
+
[[package]]
747
+
name = "futures-channel"
748
+
version = "0.3.31"
749
+
source = "registry+https://github.com/rust-lang/crates.io-index"
750
+
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
751
+
dependencies = [
752
+
"futures-core",
753
+
"futures-sink",
754
+
]
755
+
756
+
[[package]]
757
+
name = "futures-core"
758
+
version = "0.3.31"
759
+
source = "registry+https://github.com/rust-lang/crates.io-index"
760
+
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
761
+
762
+
[[package]]
763
+
name = "futures-executor"
764
+
version = "0.3.31"
765
+
source = "registry+https://github.com/rust-lang/crates.io-index"
766
+
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
767
+
dependencies = [
768
+
"futures-core",
769
+
"futures-task",
770
+
"futures-util",
771
+
]
772
+
773
+
[[package]]
774
+
name = "futures-io"
775
+
version = "0.3.31"
776
+
source = "registry+https://github.com/rust-lang/crates.io-index"
777
+
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
778
+
779
+
[[package]]
780
+
name = "futures-lite"
781
+
version = "1.13.0"
782
+
source = "registry+https://github.com/rust-lang/crates.io-index"
783
+
checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
784
+
dependencies = [
785
+
"fastrand 1.9.0",
786
+
"futures-core",
787
+
"futures-io",
788
+
"memchr",
789
+
"parking",
790
+
"pin-project-lite",
791
+
"waker-fn",
792
+
]
793
+
794
+
[[package]]
795
+
name = "futures-macro"
796
+
version = "0.3.31"
797
+
source = "registry+https://github.com/rust-lang/crates.io-index"
798
+
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
799
+
dependencies = [
800
+
"proc-macro2",
801
+
"quote",
802
+
"syn",
803
+
]
804
+
805
+
[[package]]
806
+
name = "futures-sink"
807
+
version = "0.3.31"
808
+
source = "registry+https://github.com/rust-lang/crates.io-index"
809
+
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
810
+
811
+
[[package]]
812
+
name = "futures-task"
813
+
version = "0.3.31"
814
+
source = "registry+https://github.com/rust-lang/crates.io-index"
815
+
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
816
+
817
+
[[package]]
818
+
name = "futures-timer"
819
+
version = "3.0.3"
820
+
source = "registry+https://github.com/rust-lang/crates.io-index"
821
+
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
822
+
823
+
[[package]]
824
+
name = "futures-util"
825
+
version = "0.3.31"
826
+
source = "registry+https://github.com/rust-lang/crates.io-index"
827
+
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
828
+
dependencies = [
829
+
"futures-channel",
830
+
"futures-core",
831
+
"futures-io",
832
+
"futures-macro",
833
+
"futures-sink",
834
+
"futures-task",
835
+
"memchr",
836
+
"pin-project-lite",
837
+
"pin-utils",
838
+
"slab",
839
+
]
840
+
841
+
[[package]]
842
+
name = "generator"
843
+
version = "0.8.5"
844
+
source = "registry+https://github.com/rust-lang/crates.io-index"
845
+
checksum = "d18470a76cb7f8ff746cf1f7470914f900252ec36bbc40b569d74b1258446827"
846
+
dependencies = [
847
+
"cc",
848
+
"cfg-if",
849
+
"libc",
850
+
"log",
851
+
"rustversion",
852
+
"windows",
853
+
]
854
+
855
+
[[package]]
856
+
name = "generic-array"
857
+
version = "0.14.7"
858
+
source = "registry+https://github.com/rust-lang/crates.io-index"
859
+
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
860
+
dependencies = [
861
+
"typenum",
862
+
"version_check",
863
+
]
864
+
865
+
[[package]]
866
+
name = "getrandom"
867
+
version = "0.1.16"
868
+
source = "registry+https://github.com/rust-lang/crates.io-index"
869
+
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
870
+
dependencies = [
871
+
"cfg-if",
872
+
"libc",
873
+
"wasi 0.9.0+wasi-snapshot-preview1",
874
+
]
875
+
876
+
[[package]]
877
+
name = "getrandom"
878
+
version = "0.2.16"
879
+
source = "registry+https://github.com/rust-lang/crates.io-index"
880
+
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
881
+
dependencies = [
882
+
"cfg-if",
883
+
"js-sys",
884
+
"libc",
885
+
"wasi 0.11.0+wasi-snapshot-preview1",
886
+
"wasm-bindgen",
887
+
]
888
+
889
+
[[package]]
890
+
name = "getrandom"
891
+
version = "0.3.3"
892
+
source = "registry+https://github.com/rust-lang/crates.io-index"
893
+
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
894
+
dependencies = [
895
+
"cfg-if",
896
+
"js-sys",
897
+
"libc",
898
+
"r-efi",
899
+
"wasi 0.14.2+wasi-0.2.4",
900
+
"wasm-bindgen",
901
+
]
902
+
903
+
[[package]]
904
+
name = "gimli"
905
+
version = "0.31.1"
906
+
source = "registry+https://github.com/rust-lang/crates.io-index"
907
+
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
908
+
909
+
[[package]]
910
+
name = "h2"
911
+
version = "0.3.26"
912
+
source = "registry+https://github.com/rust-lang/crates.io-index"
913
+
checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
914
+
dependencies = [
915
+
"bytes",
916
+
"fnv",
917
+
"futures-core",
918
+
"futures-sink",
919
+
"futures-util",
920
+
"http 0.2.12",
921
+
"indexmap",
922
+
"slab",
923
+
"tokio",
924
+
"tokio-util",
925
+
"tracing",
926
+
]
927
+
928
+
[[package]]
929
+
name = "h2"
930
+
version = "0.4.10"
931
+
source = "registry+https://github.com/rust-lang/crates.io-index"
932
+
checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5"
933
+
dependencies = [
934
+
"atomic-waker",
935
+
"bytes",
936
+
"fnv",
937
+
"futures-core",
938
+
"futures-sink",
939
+
"http 1.3.1",
940
+
"indexmap",
941
+
"slab",
942
+
"tokio",
943
+
"tokio-util",
944
+
"tracing",
945
+
]
946
+
947
+
[[package]]
948
+
name = "hashbrown"
949
+
version = "0.14.5"
950
+
source = "registry+https://github.com/rust-lang/crates.io-index"
951
+
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
952
+
953
+
[[package]]
954
+
name = "hashbrown"
955
+
version = "0.15.3"
956
+
source = "registry+https://github.com/rust-lang/crates.io-index"
957
+
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"
958
+
dependencies = [
959
+
"allocator-api2",
960
+
"equivalent",
961
+
"foldhash",
962
+
]
963
+
964
+
[[package]]
965
+
name = "heck"
966
+
version = "0.5.0"
967
+
source = "registry+https://github.com/rust-lang/crates.io-index"
968
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
969
+
970
+
[[package]]
971
+
name = "hermit-abi"
972
+
version = "0.5.1"
973
+
source = "registry+https://github.com/rust-lang/crates.io-index"
974
+
checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08"
975
+
976
+
[[package]]
977
+
name = "http"
978
+
version = "0.2.12"
979
+
source = "registry+https://github.com/rust-lang/crates.io-index"
980
+
checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
981
+
dependencies = [
982
+
"bytes",
983
+
"fnv",
984
+
"itoa",
985
+
]
986
+
987
+
[[package]]
988
+
name = "http"
989
+
version = "1.3.1"
990
+
source = "registry+https://github.com/rust-lang/crates.io-index"
991
+
checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565"
992
+
dependencies = [
993
+
"bytes",
994
+
"fnv",
995
+
"itoa",
996
+
]
997
+
998
+
[[package]]
999
+
name = "http-body"
1000
+
version = "0.4.6"
1001
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1002
+
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
1003
+
dependencies = [
1004
+
"bytes",
1005
+
"http 0.2.12",
1006
+
"pin-project-lite",
1007
+
]
1008
+
1009
+
[[package]]
1010
+
name = "http-body"
1011
+
version = "1.0.1"
1012
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1013
+
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
1014
+
dependencies = [
1015
+
"bytes",
1016
+
"http 1.3.1",
1017
+
]
1018
+
1019
+
[[package]]
1020
+
name = "http-body-util"
1021
+
version = "0.1.3"
1022
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1023
+
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
1024
+
dependencies = [
1025
+
"bytes",
1026
+
"futures-core",
1027
+
"http 1.3.1",
1028
+
"http-body 1.0.1",
1029
+
"pin-project-lite",
1030
+
]
1031
+
1032
+
[[package]]
1033
+
name = "http-types"
1034
+
version = "2.12.0"
1035
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1036
+
checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad"
1037
+
dependencies = [
1038
+
"anyhow",
1039
+
"async-channel",
1040
+
"base64 0.13.1",
1041
+
"futures-lite",
1042
+
"http 0.2.12",
1043
+
"infer",
1044
+
"pin-project-lite",
1045
+
"rand 0.7.3",
1046
+
"serde",
1047
+
"serde_json",
1048
+
"serde_qs",
1049
+
"serde_urlencoded",
1050
+
"url",
1051
+
]
1052
+
1053
+
[[package]]
1054
+
name = "httparse"
1055
+
version = "1.10.1"
1056
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1057
+
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
1058
+
1059
+
[[package]]
1060
+
name = "httpdate"
1061
+
version = "1.0.3"
1062
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1063
+
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
1064
+
1065
+
[[package]]
1066
+
name = "hyper"
1067
+
version = "0.14.32"
1068
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1069
+
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
1070
+
dependencies = [
1071
+
"bytes",
1072
+
"futures-channel",
1073
+
"futures-core",
1074
+
"futures-util",
1075
+
"h2 0.3.26",
1076
+
"http 0.2.12",
1077
+
"http-body 0.4.6",
1078
+
"httparse",
1079
+
"httpdate",
1080
+
"itoa",
1081
+
"pin-project-lite",
1082
+
"socket2",
1083
+
"tokio",
1084
+
"tower-service",
1085
+
"tracing",
1086
+
"want",
1087
+
]
1088
+
1089
+
[[package]]
1090
+
name = "hyper"
1091
+
version = "1.6.0"
1092
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1093
+
checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
1094
+
dependencies = [
1095
+
"bytes",
1096
+
"futures-channel",
1097
+
"futures-util",
1098
+
"h2 0.4.10",
1099
+
"http 1.3.1",
1100
+
"http-body 1.0.1",
1101
+
"httparse",
1102
+
"itoa",
1103
+
"pin-project-lite",
1104
+
"smallvec",
1105
+
"tokio",
1106
+
"want",
1107
+
]
1108
+
1109
+
[[package]]
1110
+
name = "hyper-rustls"
1111
+
version = "0.27.6"
1112
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1113
+
checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d"
1114
+
dependencies = [
1115
+
"http 1.3.1",
1116
+
"hyper 1.6.0",
1117
+
"hyper-util",
1118
+
"rustls",
1119
+
"rustls-pki-types",
1120
+
"tokio",
1121
+
"tokio-rustls",
1122
+
"tower-service",
1123
+
"webpki-roots",
1124
+
]
1125
+
1126
+
[[package]]
1127
+
name = "hyper-tls"
1128
+
version = "0.6.0"
1129
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1130
+
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
1131
+
dependencies = [
1132
+
"bytes",
1133
+
"http-body-util",
1134
+
"hyper 1.6.0",
1135
+
"hyper-util",
1136
+
"native-tls",
1137
+
"tokio",
1138
+
"tokio-native-tls",
1139
+
"tower-service",
1140
+
]
1141
+
1142
+
[[package]]
1143
+
name = "hyper-util"
1144
+
version = "0.1.13"
1145
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1146
+
checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8"
1147
+
dependencies = [
1148
+
"base64 0.22.1",
1149
+
"bytes",
1150
+
"futures-channel",
1151
+
"futures-core",
1152
+
"futures-util",
1153
+
"http 1.3.1",
1154
+
"http-body 1.0.1",
1155
+
"hyper 1.6.0",
1156
+
"ipnet",
1157
+
"libc",
1158
+
"percent-encoding",
1159
+
"pin-project-lite",
1160
+
"socket2",
1161
+
"system-configuration",
1162
+
"tokio",
1163
+
"tower-service",
1164
+
"tracing",
1165
+
"windows-registry",
1166
+
]
1167
+
1168
+
[[package]]
1169
+
name = "iana-time-zone"
1170
+
version = "0.1.63"
1171
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1172
+
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
1173
+
dependencies = [
1174
+
"android_system_properties",
1175
+
"core-foundation-sys",
1176
+
"iana-time-zone-haiku",
1177
+
"js-sys",
1178
+
"log",
1179
+
"wasm-bindgen",
1180
+
"windows-core",
1181
+
]
1182
+
1183
+
[[package]]
1184
+
name = "iana-time-zone-haiku"
1185
+
version = "0.1.2"
1186
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1187
+
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
1188
+
dependencies = [
1189
+
"cc",
1190
+
]
1191
+
1192
+
[[package]]
1193
+
name = "icu_collections"
1194
+
version = "2.0.0"
1195
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1196
+
checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47"
1197
+
dependencies = [
1198
+
"displaydoc",
1199
+
"potential_utf",
1200
+
"yoke",
1201
+
"zerofrom",
1202
+
"zerovec",
1203
+
]
1204
+
1205
+
[[package]]
1206
+
name = "icu_locale_core"
1207
+
version = "2.0.0"
1208
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1209
+
checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a"
1210
+
dependencies = [
1211
+
"displaydoc",
1212
+
"litemap",
1213
+
"tinystr",
1214
+
"writeable",
1215
+
"zerovec",
1216
+
]
1217
+
1218
+
[[package]]
1219
+
name = "icu_normalizer"
1220
+
version = "2.0.0"
1221
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1222
+
checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979"
1223
+
dependencies = [
1224
+
"displaydoc",
1225
+
"icu_collections",
1226
+
"icu_normalizer_data",
1227
+
"icu_properties",
1228
+
"icu_provider",
1229
+
"smallvec",
1230
+
"zerovec",
1231
+
]
1232
+
1233
+
[[package]]
1234
+
name = "icu_normalizer_data"
1235
+
version = "2.0.0"
1236
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1237
+
checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3"
1238
+
1239
+
[[package]]
1240
+
name = "icu_properties"
1241
+
version = "2.0.1"
1242
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1243
+
checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b"
1244
+
dependencies = [
1245
+
"displaydoc",
1246
+
"icu_collections",
1247
+
"icu_locale_core",
1248
+
"icu_properties_data",
1249
+
"icu_provider",
1250
+
"potential_utf",
1251
+
"zerotrie",
1252
+
"zerovec",
1253
+
]
1254
+
1255
+
[[package]]
1256
+
name = "icu_properties_data"
1257
+
version = "2.0.1"
1258
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1259
+
checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632"
1260
+
1261
+
[[package]]
1262
+
name = "icu_provider"
1263
+
version = "2.0.0"
1264
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1265
+
checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af"
1266
+
dependencies = [
1267
+
"displaydoc",
1268
+
"icu_locale_core",
1269
+
"stable_deref_trait",
1270
+
"tinystr",
1271
+
"writeable",
1272
+
"yoke",
1273
+
"zerofrom",
1274
+
"zerotrie",
1275
+
"zerovec",
1276
+
]
1277
+
1278
+
[[package]]
1279
+
name = "idna"
1280
+
version = "1.0.3"
1281
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1282
+
checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
1283
+
dependencies = [
1284
+
"idna_adapter",
1285
+
"smallvec",
1286
+
"utf8_iter",
1287
+
]
1288
+
1289
+
[[package]]
1290
+
name = "idna_adapter"
1291
+
version = "1.2.1"
1292
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1293
+
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
1294
+
dependencies = [
1295
+
"icu_normalizer",
1296
+
"icu_properties",
1297
+
]
1298
+
1299
+
[[package]]
1300
+
name = "indexmap"
1301
+
version = "2.9.0"
1302
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1303
+
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
1304
+
dependencies = [
1305
+
"equivalent",
1306
+
"hashbrown 0.15.3",
1307
+
]
1308
+
1309
+
[[package]]
1310
+
name = "infer"
1311
+
version = "0.2.3"
1312
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1313
+
checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac"
1314
+
1315
+
[[package]]
1316
+
name = "instant"
1317
+
version = "0.1.13"
1318
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1319
+
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
1320
+
dependencies = [
1321
+
"cfg-if",
1322
+
]
1323
+
1324
+
[[package]]
1325
+
name = "ipld-core"
1326
+
version = "0.4.2"
1327
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1328
+
checksum = "104718b1cc124d92a6d01ca9c9258a7df311405debb3408c445a36452f9bf8db"
1329
+
dependencies = [
1330
+
"cid",
1331
+
"serde",
1332
+
"serde_bytes",
1333
+
]
1334
+
1335
+
[[package]]
1336
+
name = "ipnet"
1337
+
version = "2.11.0"
1338
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1339
+
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
1340
+
1341
+
[[package]]
1342
+
name = "iri-string"
1343
+
version = "0.7.8"
1344
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1345
+
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
1346
+
dependencies = [
1347
+
"memchr",
1348
+
"serde",
1349
+
]
1350
+
1351
+
[[package]]
1352
+
name = "is_terminal_polyfill"
1353
+
version = "1.70.1"
1354
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1355
+
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
1356
+
1357
+
[[package]]
1358
+
name = "itoa"
1359
+
version = "1.0.15"
1360
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1361
+
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
1362
+
1363
+
[[package]]
1364
+
name = "js-sys"
1365
+
version = "0.3.77"
1366
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1367
+
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
1368
+
dependencies = [
1369
+
"once_cell",
1370
+
"wasm-bindgen",
1371
+
]
1372
+
1373
+
[[package]]
1374
+
name = "langtag"
1375
+
version = "0.3.4"
1376
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1377
+
checksum = "ed60c85f254d6ae8450cec15eedd921efbc4d1bdf6fcf6202b9a58b403f6f805"
1378
+
dependencies = [
1379
+
"serde",
1380
+
]
1381
+
1382
+
[[package]]
1383
+
name = "lazy_static"
1384
+
version = "1.5.0"
1385
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1386
+
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1387
+
1388
+
[[package]]
1389
+
name = "libc"
1390
+
version = "0.2.172"
1391
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1392
+
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
1393
+
1394
+
[[package]]
1395
+
name = "linux-raw-sys"
1396
+
version = "0.9.4"
1397
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1398
+
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
1399
+
1400
+
[[package]]
1401
+
name = "litemap"
1402
+
version = "0.8.0"
1403
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1404
+
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
1405
+
1406
+
[[package]]
1407
+
name = "lock_api"
1408
+
version = "0.4.13"
1409
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1410
+
checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
1411
+
dependencies = [
1412
+
"autocfg",
1413
+
"scopeguard",
1414
+
]
1415
+
1416
+
[[package]]
1417
+
name = "log"
1418
+
version = "0.4.27"
1419
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1420
+
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
1421
+
1422
+
[[package]]
1423
+
name = "loom"
1424
+
version = "0.7.2"
1425
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1426
+
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
1427
+
dependencies = [
1428
+
"cfg-if",
1429
+
"generator",
1430
+
"scoped-tls",
1431
+
"tracing",
1432
+
"tracing-subscriber",
1433
+
]
1434
+
1435
+
[[package]]
1436
+
name = "lru"
1437
+
version = "0.12.5"
1438
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1439
+
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
1440
+
dependencies = [
1441
+
"hashbrown 0.15.3",
1442
+
]
1443
+
1444
+
[[package]]
1445
+
name = "lru-slab"
1446
+
version = "0.1.2"
1447
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1448
+
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
1449
+
1450
+
[[package]]
1451
+
name = "matchers"
1452
+
version = "0.1.0"
1453
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1454
+
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
1455
+
dependencies = [
1456
+
"regex-automata 0.1.10",
1457
+
]
1458
+
1459
+
[[package]]
1460
+
name = "memchr"
1461
+
version = "2.7.4"
1462
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1463
+
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
1464
+
1465
+
[[package]]
1466
+
name = "mime"
1467
+
version = "0.3.17"
1468
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1469
+
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1470
+
1471
+
[[package]]
1472
+
name = "miniz_oxide"
1473
+
version = "0.8.8"
1474
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1475
+
checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a"
1476
+
dependencies = [
1477
+
"adler2",
1478
+
]
1479
+
1480
+
[[package]]
1481
+
name = "mio"
1482
+
version = "1.0.4"
1483
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1484
+
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
1485
+
dependencies = [
1486
+
"libc",
1487
+
"wasi 0.11.0+wasi-snapshot-preview1",
1488
+
"windows-sys 0.59.0",
1489
+
]
1490
+
1491
+
[[package]]
1492
+
name = "moka"
1493
+
version = "0.12.10"
1494
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1495
+
checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926"
1496
+
dependencies = [
1497
+
"async-lock",
1498
+
"crossbeam-channel",
1499
+
"crossbeam-epoch",
1500
+
"crossbeam-utils",
1501
+
"event-listener 5.4.0",
1502
+
"futures-util",
1503
+
"loom",
1504
+
"parking_lot",
1505
+
"portable-atomic",
1506
+
"rustc_version",
1507
+
"smallvec",
1508
+
"tagptr",
1509
+
"thiserror 1.0.69",
1510
+
"uuid",
1511
+
]
1512
+
1513
+
[[package]]
1514
+
name = "multibase"
1515
+
version = "0.9.1"
1516
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1517
+
checksum = "9b3539ec3c1f04ac9748a260728e855f261b4977f5c3406612c884564f329404"
1518
+
dependencies = [
1519
+
"base-x",
1520
+
"data-encoding",
1521
+
"data-encoding-macro",
1522
+
]
1523
+
1524
+
[[package]]
1525
+
name = "multihash"
1526
+
version = "0.19.3"
1527
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1528
+
checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d"
1529
+
dependencies = [
1530
+
"core2",
1531
+
"serde",
1532
+
"unsigned-varint",
1533
+
]
1534
+
1535
+
[[package]]
1536
+
name = "native-tls"
1537
+
version = "0.2.14"
1538
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1539
+
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
1540
+
dependencies = [
1541
+
"libc",
1542
+
"log",
1543
+
"openssl",
1544
+
"openssl-probe",
1545
+
"openssl-sys",
1546
+
"schannel",
1547
+
"security-framework",
1548
+
"security-framework-sys",
1549
+
"tempfile",
1550
+
]
1551
+
1552
+
[[package]]
1553
+
name = "nu-ansi-term"
1554
+
version = "0.46.0"
1555
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1556
+
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
1557
+
dependencies = [
1558
+
"overload",
1559
+
"winapi",
1560
+
]
1561
+
1562
+
[[package]]
1563
+
name = "num-traits"
1564
+
version = "0.2.19"
1565
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1566
+
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1567
+
dependencies = [
1568
+
"autocfg",
1569
+
]
1570
+
1571
+
[[package]]
1572
+
name = "num_cpus"
1573
+
version = "1.17.0"
1574
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1575
+
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
1576
+
dependencies = [
1577
+
"hermit-abi",
1578
+
"libc",
1579
+
]
1580
+
1581
+
[[package]]
1582
+
name = "oauth2"
1583
+
version = "5.0.0"
1584
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1585
+
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
1586
+
dependencies = [
1587
+
"base64 0.21.7",
1588
+
"chrono",
1589
+
"getrandom 0.2.16",
1590
+
"http 1.3.1",
1591
+
"rand 0.8.5",
1592
+
"reqwest",
1593
+
"serde",
1594
+
"serde_json",
1595
+
"serde_path_to_error",
1596
+
"sha2",
1597
+
"thiserror 1.0.69",
1598
+
"url",
1599
+
]
1600
+
1601
+
[[package]]
1602
+
name = "object"
1603
+
version = "0.36.7"
1604
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1605
+
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
1606
+
dependencies = [
1607
+
"memchr",
1608
+
]
1609
+
1610
+
[[package]]
1611
+
name = "once_cell"
1612
+
version = "1.21.3"
1613
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1614
+
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
1615
+
1616
+
[[package]]
1617
+
name = "once_cell_polyfill"
1618
+
version = "1.70.1"
1619
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1620
+
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
1621
+
1622
+
[[package]]
1623
+
name = "openssl"
1624
+
version = "0.10.73"
1625
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1626
+
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
1627
+
dependencies = [
1628
+
"bitflags",
1629
+
"cfg-if",
1630
+
"foreign-types",
1631
+
"libc",
1632
+
"once_cell",
1633
+
"openssl-macros",
1634
+
"openssl-sys",
1635
+
]
1636
+
1637
+
[[package]]
1638
+
name = "openssl-macros"
1639
+
version = "0.1.1"
1640
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1641
+
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
1642
+
dependencies = [
1643
+
"proc-macro2",
1644
+
"quote",
1645
+
"syn",
1646
+
]
1647
+
1648
+
[[package]]
1649
+
name = "openssl-probe"
1650
+
version = "0.1.6"
1651
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1652
+
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
1653
+
1654
+
[[package]]
1655
+
name = "openssl-sys"
1656
+
version = "0.9.109"
1657
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1658
+
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
1659
+
dependencies = [
1660
+
"cc",
1661
+
"libc",
1662
+
"pkg-config",
1663
+
"vcpkg",
1664
+
]
1665
+
1666
+
[[package]]
1667
+
name = "overload"
1668
+
version = "0.1.1"
1669
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1670
+
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
1671
+
1672
+
[[package]]
1673
+
name = "parking"
1674
+
version = "2.2.1"
1675
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1676
+
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
1677
+
1678
+
[[package]]
1679
+
name = "parking_lot"
1680
+
version = "0.12.4"
1681
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1682
+
checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
1683
+
dependencies = [
1684
+
"lock_api",
1685
+
"parking_lot_core",
1686
+
]
1687
+
1688
+
[[package]]
1689
+
name = "parking_lot_core"
1690
+
version = "0.9.11"
1691
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1692
+
checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
1693
+
dependencies = [
1694
+
"cfg-if",
1695
+
"libc",
1696
+
"redox_syscall",
1697
+
"smallvec",
1698
+
"windows-targets 0.52.6",
1699
+
]
1700
+
1701
+
[[package]]
1702
+
name = "percent-encoding"
1703
+
version = "2.3.1"
1704
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1705
+
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
1706
+
1707
+
[[package]]
1708
+
name = "pin-project-lite"
1709
+
version = "0.2.16"
1710
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1711
+
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
1712
+
1713
+
[[package]]
1714
+
name = "pin-utils"
1715
+
version = "0.1.0"
1716
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1717
+
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
1718
+
1719
+
[[package]]
1720
+
name = "pkg-config"
1721
+
version = "0.3.32"
1722
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1723
+
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
1724
+
1725
+
[[package]]
1726
+
name = "portable-atomic"
1727
+
version = "1.11.0"
1728
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1729
+
checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
1730
+
1731
+
[[package]]
1732
+
name = "potential_utf"
1733
+
version = "0.1.2"
1734
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1735
+
checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585"
1736
+
dependencies = [
1737
+
"zerovec",
1738
+
]
1739
+
1740
+
[[package]]
1741
+
name = "ppv-lite86"
1742
+
version = "0.2.21"
1743
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1744
+
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
1745
+
dependencies = [
1746
+
"zerocopy",
1747
+
]
1748
+
1749
+
[[package]]
1750
+
name = "proc-macro2"
1751
+
version = "1.0.95"
1752
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1753
+
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
1754
+
dependencies = [
1755
+
"unicode-ident",
1756
+
]
1757
+
1758
+
[[package]]
1759
+
name = "quinn"
1760
+
version = "0.11.8"
1761
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1762
+
checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8"
1763
+
dependencies = [
1764
+
"bytes",
1765
+
"cfg_aliases",
1766
+
"pin-project-lite",
1767
+
"quinn-proto",
1768
+
"quinn-udp",
1769
+
"rustc-hash",
1770
+
"rustls",
1771
+
"socket2",
1772
+
"thiserror 2.0.12",
1773
+
"tokio",
1774
+
"tracing",
1775
+
"web-time",
1776
+
]
1777
+
1778
+
[[package]]
1779
+
name = "quinn-proto"
1780
+
version = "0.11.12"
1781
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1782
+
checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e"
1783
+
dependencies = [
1784
+
"bytes",
1785
+
"getrandom 0.3.3",
1786
+
"lru-slab",
1787
+
"rand 0.9.1",
1788
+
"ring",
1789
+
"rustc-hash",
1790
+
"rustls",
1791
+
"rustls-pki-types",
1792
+
"slab",
1793
+
"thiserror 2.0.12",
1794
+
"tinyvec",
1795
+
"tracing",
1796
+
"web-time",
1797
+
]
1798
+
1799
+
[[package]]
1800
+
name = "quinn-udp"
1801
+
version = "0.5.12"
1802
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1803
+
checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842"
1804
+
dependencies = [
1805
+
"cfg_aliases",
1806
+
"libc",
1807
+
"once_cell",
1808
+
"socket2",
1809
+
"tracing",
1810
+
"windows-sys 0.59.0",
1811
+
]
1812
+
1813
+
[[package]]
1814
+
name = "quote"
1815
+
version = "1.0.40"
1816
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1817
+
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
1818
+
dependencies = [
1819
+
"proc-macro2",
1820
+
]
1821
+
1822
+
[[package]]
1823
+
name = "r-efi"
1824
+
version = "5.2.0"
1825
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1826
+
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
1827
+
1828
+
[[package]]
1829
+
name = "rand"
1830
+
version = "0.7.3"
1831
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1832
+
checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
1833
+
dependencies = [
1834
+
"getrandom 0.1.16",
1835
+
"libc",
1836
+
"rand_chacha 0.2.2",
1837
+
"rand_core 0.5.1",
1838
+
"rand_hc",
1839
+
]
1840
+
1841
+
[[package]]
1842
+
name = "rand"
1843
+
version = "0.8.5"
1844
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1845
+
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
1846
+
dependencies = [
1847
+
"libc",
1848
+
"rand_chacha 0.3.1",
1849
+
"rand_core 0.6.4",
1850
+
]
1851
+
1852
+
[[package]]
1853
+
name = "rand"
1854
+
version = "0.9.1"
1855
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1856
+
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
1857
+
dependencies = [
1858
+
"rand_chacha 0.9.0",
1859
+
"rand_core 0.9.3",
1860
+
]
1861
+
1862
+
[[package]]
1863
+
name = "rand_chacha"
1864
+
version = "0.2.2"
1865
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1866
+
checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
1867
+
dependencies = [
1868
+
"ppv-lite86",
1869
+
"rand_core 0.5.1",
1870
+
]
1871
+
1872
+
[[package]]
1873
+
name = "rand_chacha"
1874
+
version = "0.3.1"
1875
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1876
+
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1877
+
dependencies = [
1878
+
"ppv-lite86",
1879
+
"rand_core 0.6.4",
1880
+
]
1881
+
1882
+
[[package]]
1883
+
name = "rand_chacha"
1884
+
version = "0.9.0"
1885
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1886
+
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1887
+
dependencies = [
1888
+
"ppv-lite86",
1889
+
"rand_core 0.9.3",
1890
+
]
1891
+
1892
+
[[package]]
1893
+
name = "rand_core"
1894
+
version = "0.5.1"
1895
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1896
+
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
1897
+
dependencies = [
1898
+
"getrandom 0.1.16",
1899
+
]
1900
+
1901
+
[[package]]
1902
+
name = "rand_core"
1903
+
version = "0.6.4"
1904
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1905
+
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1906
+
dependencies = [
1907
+
"getrandom 0.2.16",
1908
+
]
1909
+
1910
+
[[package]]
1911
+
name = "rand_core"
1912
+
version = "0.9.3"
1913
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1914
+
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
1915
+
dependencies = [
1916
+
"getrandom 0.3.3",
1917
+
]
1918
+
1919
+
[[package]]
1920
+
name = "rand_hc"
1921
+
version = "0.2.0"
1922
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1923
+
checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
1924
+
dependencies = [
1925
+
"rand_core 0.5.1",
1926
+
]
1927
+
1928
+
[[package]]
1929
+
name = "redox_syscall"
1930
+
version = "0.5.12"
1931
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1932
+
checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
1933
+
dependencies = [
1934
+
"bitflags",
1935
+
]
1936
+
1937
+
[[package]]
1938
+
name = "regex"
1939
+
version = "1.11.1"
1940
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1941
+
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
1942
+
dependencies = [
1943
+
"aho-corasick",
1944
+
"memchr",
1945
+
"regex-automata 0.4.9",
1946
+
"regex-syntax 0.8.5",
1947
+
]
1948
+
1949
+
[[package]]
1950
+
name = "regex-automata"
1951
+
version = "0.1.10"
1952
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1953
+
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
1954
+
dependencies = [
1955
+
"regex-syntax 0.6.29",
1956
+
]
1957
+
1958
+
[[package]]
1959
+
name = "regex-automata"
1960
+
version = "0.4.9"
1961
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1962
+
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
1963
+
dependencies = [
1964
+
"aho-corasick",
1965
+
"memchr",
1966
+
"regex-syntax 0.8.5",
1967
+
]
1968
+
1969
+
[[package]]
1970
+
name = "regex-syntax"
1971
+
version = "0.6.29"
1972
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1973
+
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
1974
+
1975
+
[[package]]
1976
+
name = "regex-syntax"
1977
+
version = "0.8.5"
1978
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1979
+
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
1980
+
1981
+
[[package]]
1982
+
name = "reqwest"
1983
+
version = "0.12.18"
1984
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1985
+
checksum = "e98ff6b0dbbe4d5a37318f433d4fc82babd21631f194d370409ceb2e40b2f0b5"
1986
+
dependencies = [
1987
+
"async-compression",
1988
+
"base64 0.22.1",
1989
+
"bytes",
1990
+
"encoding_rs",
1991
+
"futures-core",
1992
+
"futures-util",
1993
+
"h2 0.4.10",
1994
+
"http 1.3.1",
1995
+
"http-body 1.0.1",
1996
+
"http-body-util",
1997
+
"hyper 1.6.0",
1998
+
"hyper-rustls",
1999
+
"hyper-tls",
2000
+
"hyper-util",
2001
+
"ipnet",
2002
+
"js-sys",
2003
+
"log",
2004
+
"mime",
2005
+
"native-tls",
2006
+
"once_cell",
2007
+
"percent-encoding",
2008
+
"pin-project-lite",
2009
+
"quinn",
2010
+
"rustls",
2011
+
"rustls-pki-types",
2012
+
"serde",
2013
+
"serde_json",
2014
+
"serde_urlencoded",
2015
+
"sync_wrapper",
2016
+
"tokio",
2017
+
"tokio-native-tls",
2018
+
"tokio-rustls",
2019
+
"tokio-util",
2020
+
"tower",
2021
+
"tower-http",
2022
+
"tower-service",
2023
+
"url",
2024
+
"wasm-bindgen",
2025
+
"wasm-bindgen-futures",
2026
+
"web-sys",
2027
+
"webpki-roots",
2028
+
]
2029
+
2030
+
[[package]]
2031
+
name = "retain_mut"
2032
+
version = "0.1.9"
2033
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2034
+
checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0"
2035
+
2036
+
[[package]]
2037
+
name = "ring"
2038
+
version = "0.17.14"
2039
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2040
+
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
2041
+
dependencies = [
2042
+
"cc",
2043
+
"cfg-if",
2044
+
"getrandom 0.2.16",
2045
+
"libc",
2046
+
"untrusted",
2047
+
"windows-sys 0.52.0",
2048
+
]
2049
+
2050
+
[[package]]
2051
+
name = "rustc-demangle"
2052
+
version = "0.1.24"
2053
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2054
+
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
2055
+
2056
+
[[package]]
2057
+
name = "rustc-hash"
2058
+
version = "2.1.1"
2059
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2060
+
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
2061
+
2062
+
[[package]]
2063
+
name = "rustc_version"
2064
+
version = "0.4.1"
2065
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2066
+
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
2067
+
dependencies = [
2068
+
"semver",
2069
+
]
2070
+
2071
+
[[package]]
2072
+
name = "rustix"
2073
+
version = "1.0.7"
2074
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2075
+
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
2076
+
dependencies = [
2077
+
"bitflags",
2078
+
"errno",
2079
+
"libc",
2080
+
"linux-raw-sys",
2081
+
"windows-sys 0.59.0",
2082
+
]
2083
+
2084
+
[[package]]
2085
+
name = "rustls"
2086
+
version = "0.23.27"
2087
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2088
+
checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321"
2089
+
dependencies = [
2090
+
"once_cell",
2091
+
"ring",
2092
+
"rustls-pki-types",
2093
+
"rustls-webpki",
2094
+
"subtle",
2095
+
"zeroize",
2096
+
]
2097
+
2098
+
[[package]]
2099
+
name = "rustls-pki-types"
2100
+
version = "1.12.0"
2101
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2102
+
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
2103
+
dependencies = [
2104
+
"web-time",
2105
+
"zeroize",
2106
+
]
2107
+
2108
+
[[package]]
2109
+
name = "rustls-webpki"
2110
+
version = "0.103.3"
2111
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2112
+
checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435"
2113
+
dependencies = [
2114
+
"ring",
2115
+
"rustls-pki-types",
2116
+
"untrusted",
2117
+
]
2118
+
2119
+
[[package]]
2120
+
name = "rustversion"
2121
+
version = "1.0.21"
2122
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2123
+
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
2124
+
2125
+
[[package]]
2126
+
name = "ryu"
2127
+
version = "1.0.20"
2128
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2129
+
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
2130
+
2131
+
[[package]]
2132
+
name = "schannel"
2133
+
version = "0.1.27"
2134
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2135
+
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
2136
+
dependencies = [
2137
+
"windows-sys 0.59.0",
2138
+
]
2139
+
2140
+
[[package]]
2141
+
name = "scoped-tls"
2142
+
version = "1.0.1"
2143
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2144
+
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
2145
+
2146
+
[[package]]
2147
+
name = "scopeguard"
2148
+
version = "1.2.0"
2149
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2150
+
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
2151
+
2152
+
[[package]]
2153
+
name = "secrecy"
2154
+
version = "0.8.0"
2155
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2156
+
checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e"
2157
+
dependencies = [
2158
+
"zeroize",
2159
+
]
2160
+
2161
+
[[package]]
2162
+
name = "security-framework"
2163
+
version = "2.11.1"
2164
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2165
+
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
2166
+
dependencies = [
2167
+
"bitflags",
2168
+
"core-foundation",
2169
+
"core-foundation-sys",
2170
+
"libc",
2171
+
"security-framework-sys",
2172
+
]
2173
+
2174
+
[[package]]
2175
+
name = "security-framework-sys"
2176
+
version = "2.14.0"
2177
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2178
+
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
2179
+
dependencies = [
2180
+
"core-foundation-sys",
2181
+
"libc",
2182
+
]
2183
+
2184
+
[[package]]
2185
+
name = "semver"
2186
+
version = "1.0.26"
2187
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2188
+
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
2189
+
2190
+
[[package]]
2191
+
name = "serde"
2192
+
version = "1.0.219"
2193
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2194
+
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
2195
+
dependencies = [
2196
+
"serde_derive",
2197
+
]
2198
+
2199
+
[[package]]
2200
+
name = "serde_bytes"
2201
+
version = "0.11.17"
2202
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2203
+
checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96"
2204
+
dependencies = [
2205
+
"serde",
2206
+
]
2207
+
2208
+
[[package]]
2209
+
name = "serde_derive"
2210
+
version = "1.0.219"
2211
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2212
+
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
2213
+
dependencies = [
2214
+
"proc-macro2",
2215
+
"quote",
2216
+
"syn",
2217
+
]
2218
+
2219
+
[[package]]
2220
+
name = "serde_html_form"
2221
+
version = "0.2.7"
2222
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2223
+
checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4"
2224
+
dependencies = [
2225
+
"form_urlencoded",
2226
+
"indexmap",
2227
+
"itoa",
2228
+
"ryu",
2229
+
"serde",
2230
+
]
2231
+
2232
+
[[package]]
2233
+
name = "serde_json"
2234
+
version = "1.0.140"
2235
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2236
+
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
2237
+
dependencies = [
2238
+
"itoa",
2239
+
"memchr",
2240
+
"ryu",
2241
+
"serde",
2242
+
]
2243
+
2244
+
[[package]]
2245
+
name = "serde_path_to_error"
2246
+
version = "0.1.17"
2247
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2248
+
checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a"
2249
+
dependencies = [
2250
+
"itoa",
2251
+
"serde",
2252
+
]
2253
+
2254
+
[[package]]
2255
+
name = "serde_qs"
2256
+
version = "0.8.5"
2257
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2258
+
checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6"
2259
+
dependencies = [
2260
+
"percent-encoding",
2261
+
"serde",
2262
+
"thiserror 1.0.69",
2263
+
]
2264
+
2265
+
[[package]]
2266
+
name = "serde_urlencoded"
2267
+
version = "0.7.1"
2268
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2269
+
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
2270
+
dependencies = [
2271
+
"form_urlencoded",
2272
+
"itoa",
2273
+
"ryu",
2274
+
"serde",
2275
+
]
2276
+
2277
+
[[package]]
2278
+
name = "sha2"
2279
+
version = "0.10.9"
2280
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2281
+
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
2282
+
dependencies = [
2283
+
"cfg-if",
2284
+
"cpufeatures",
2285
+
"digest",
2286
+
]
2287
+
2288
+
[[package]]
2289
+
name = "sharded-slab"
2290
+
version = "0.1.7"
2291
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2292
+
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
2293
+
dependencies = [
2294
+
"lazy_static",
2295
+
]
2296
+
2297
+
[[package]]
2298
+
name = "shlex"
2299
+
version = "1.3.0"
2300
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2301
+
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
2302
+
2303
+
[[package]]
2304
+
name = "signal-hook-registry"
2305
+
version = "1.4.5"
2306
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2307
+
checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
2308
+
dependencies = [
2309
+
"libc",
2310
+
]
2311
+
2312
+
[[package]]
2313
+
name = "slab"
2314
+
version = "0.4.9"
2315
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2316
+
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
2317
+
dependencies = [
2318
+
"autocfg",
2319
+
]
2320
+
2321
+
[[package]]
2322
+
name = "smallvec"
2323
+
version = "1.15.0"
2324
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2325
+
checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
2326
+
2327
+
[[package]]
2328
+
name = "socket2"
2329
+
version = "0.5.10"
2330
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2331
+
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
2332
+
dependencies = [
2333
+
"libc",
2334
+
"windows-sys 0.52.0",
2335
+
]
2336
+
2337
+
[[package]]
2338
+
name = "stable_deref_trait"
2339
+
version = "1.2.0"
2340
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2341
+
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
2342
+
2343
+
[[package]]
2344
+
name = "strsim"
2345
+
version = "0.11.1"
2346
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2347
+
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
2348
+
2349
+
[[package]]
2350
+
name = "subtle"
2351
+
version = "2.6.1"
2352
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2353
+
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
2354
+
2355
+
[[package]]
2356
+
name = "syn"
2357
+
version = "2.0.101"
2358
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2359
+
checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf"
2360
+
dependencies = [
2361
+
"proc-macro2",
2362
+
"quote",
2363
+
"unicode-ident",
2364
+
]
2365
+
2366
+
[[package]]
2367
+
name = "sync_wrapper"
2368
+
version = "1.0.2"
2369
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2370
+
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
2371
+
dependencies = [
2372
+
"futures-core",
2373
+
]
2374
+
2375
+
[[package]]
2376
+
name = "synstructure"
2377
+
version = "0.13.2"
2378
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2379
+
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
2380
+
dependencies = [
2381
+
"proc-macro2",
2382
+
"quote",
2383
+
"syn",
2384
+
]
2385
+
2386
+
[[package]]
2387
+
name = "system-configuration"
2388
+
version = "0.6.1"
2389
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2390
+
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
2391
+
dependencies = [
2392
+
"bitflags",
2393
+
"core-foundation",
2394
+
"system-configuration-sys",
2395
+
]
2396
+
2397
+
[[package]]
2398
+
name = "system-configuration-sys"
2399
+
version = "0.6.0"
2400
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2401
+
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
2402
+
dependencies = [
2403
+
"core-foundation-sys",
2404
+
"libc",
2405
+
]
2406
+
2407
+
[[package]]
2408
+
name = "tagptr"
2409
+
version = "0.2.0"
2410
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2411
+
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
2412
+
2413
+
[[package]]
2414
+
name = "tempfile"
2415
+
version = "3.20.0"
2416
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2417
+
checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
2418
+
dependencies = [
2419
+
"fastrand 2.3.0",
2420
+
"getrandom 0.3.3",
2421
+
"once_cell",
2422
+
"rustix",
2423
+
"windows-sys 0.59.0",
2424
+
]
2425
+
2426
+
[[package]]
2427
+
name = "thiserror"
2428
+
version = "1.0.69"
2429
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2430
+
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
2431
+
dependencies = [
2432
+
"thiserror-impl 1.0.69",
2433
+
]
2434
+
2435
+
[[package]]
2436
+
name = "thiserror"
2437
+
version = "2.0.12"
2438
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2439
+
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
2440
+
dependencies = [
2441
+
"thiserror-impl 2.0.12",
2442
+
]
2443
+
2444
+
[[package]]
2445
+
name = "thiserror-impl"
2446
+
version = "1.0.69"
2447
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2448
+
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
2449
+
dependencies = [
2450
+
"proc-macro2",
2451
+
"quote",
2452
+
"syn",
2453
+
]
2454
+
2455
+
[[package]]
2456
+
name = "thiserror-impl"
2457
+
version = "2.0.12"
2458
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2459
+
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
2460
+
dependencies = [
2461
+
"proc-macro2",
2462
+
"quote",
2463
+
"syn",
2464
+
]
2465
+
2466
+
[[package]]
2467
+
name = "thread_local"
2468
+
version = "1.1.8"
2469
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2470
+
checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
2471
+
dependencies = [
2472
+
"cfg-if",
2473
+
"once_cell",
2474
+
]
2475
+
2476
+
[[package]]
2477
+
name = "tinystr"
2478
+
version = "0.8.1"
2479
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2480
+
checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b"
2481
+
dependencies = [
2482
+
"displaydoc",
2483
+
"zerovec",
2484
+
]
2485
+
2486
+
[[package]]
2487
+
name = "tinyvec"
2488
+
version = "1.9.0"
2489
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2490
+
checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71"
2491
+
dependencies = [
2492
+
"tinyvec_macros",
2493
+
]
2494
+
2495
+
[[package]]
2496
+
name = "tinyvec_macros"
2497
+
version = "0.1.1"
2498
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2499
+
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
2500
+
2501
+
[[package]]
2502
+
name = "tokio"
2503
+
version = "1.45.1"
2504
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2505
+
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
2506
+
dependencies = [
2507
+
"backtrace",
2508
+
"bytes",
2509
+
"libc",
2510
+
"mio",
2511
+
"parking_lot",
2512
+
"pin-project-lite",
2513
+
"signal-hook-registry",
2514
+
"socket2",
2515
+
"tokio-macros",
2516
+
"windows-sys 0.52.0",
2517
+
]
2518
+
2519
+
[[package]]
2520
+
name = "tokio-macros"
2521
+
version = "2.5.0"
2522
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2523
+
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
2524
+
dependencies = [
2525
+
"proc-macro2",
2526
+
"quote",
2527
+
"syn",
2528
+
]
2529
+
2530
+
[[package]]
2531
+
name = "tokio-native-tls"
2532
+
version = "0.3.1"
2533
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2534
+
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
2535
+
dependencies = [
2536
+
"native-tls",
2537
+
"tokio",
2538
+
]
2539
+
2540
+
[[package]]
2541
+
name = "tokio-rustls"
2542
+
version = "0.26.2"
2543
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2544
+
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
2545
+
dependencies = [
2546
+
"rustls",
2547
+
"tokio",
2548
+
]
2549
+
2550
+
[[package]]
2551
+
name = "tokio-stream"
2552
+
version = "0.1.17"
2553
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2554
+
checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
2555
+
dependencies = [
2556
+
"futures-core",
2557
+
"pin-project-lite",
2558
+
"tokio",
2559
+
]
2560
+
2561
+
[[package]]
2562
+
name = "tokio-test"
2563
+
version = "0.4.4"
2564
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2565
+
checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7"
2566
+
dependencies = [
2567
+
"async-stream",
2568
+
"bytes",
2569
+
"futures-core",
2570
+
"tokio",
2571
+
"tokio-stream",
2572
+
]
2573
+
2574
+
[[package]]
2575
+
name = "tokio-util"
2576
+
version = "0.7.15"
2577
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2578
+
checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
2579
+
dependencies = [
2580
+
"bytes",
2581
+
"futures-core",
2582
+
"futures-sink",
2583
+
"pin-project-lite",
2584
+
"tokio",
2585
+
]
2586
+
2587
+
[[package]]
2588
+
name = "tower"
2589
+
version = "0.5.2"
2590
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2591
+
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
2592
+
dependencies = [
2593
+
"futures-core",
2594
+
"futures-util",
2595
+
"pin-project-lite",
2596
+
"sync_wrapper",
2597
+
"tokio",
2598
+
"tower-layer",
2599
+
"tower-service",
2600
+
]
2601
+
2602
+
[[package]]
2603
+
name = "tower-http"
2604
+
version = "0.6.4"
2605
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2606
+
checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e"
2607
+
dependencies = [
2608
+
"bitflags",
2609
+
"bytes",
2610
+
"futures-util",
2611
+
"http 1.3.1",
2612
+
"http-body 1.0.1",
2613
+
"iri-string",
2614
+
"pin-project-lite",
2615
+
"tower",
2616
+
"tower-layer",
2617
+
"tower-service",
2618
+
]
2619
+
2620
+
[[package]]
2621
+
name = "tower-layer"
2622
+
version = "0.3.3"
2623
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2624
+
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
2625
+
2626
+
[[package]]
2627
+
name = "tower-service"
2628
+
version = "0.3.3"
2629
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2630
+
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
2631
+
2632
+
[[package]]
2633
+
name = "tracing"
2634
+
version = "0.1.41"
2635
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2636
+
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
2637
+
dependencies = [
2638
+
"pin-project-lite",
2639
+
"tracing-attributes",
2640
+
"tracing-core",
2641
+
]
2642
+
2643
+
[[package]]
2644
+
name = "tracing-attributes"
2645
+
version = "0.1.28"
2646
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2647
+
checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
2648
+
dependencies = [
2649
+
"proc-macro2",
2650
+
"quote",
2651
+
"syn",
2652
+
]
2653
+
2654
+
[[package]]
2655
+
name = "tracing-core"
2656
+
version = "0.1.33"
2657
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2658
+
checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c"
2659
+
dependencies = [
2660
+
"once_cell",
2661
+
"valuable",
2662
+
]
2663
+
2664
+
[[package]]
2665
+
name = "tracing-log"
2666
+
version = "0.2.0"
2667
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2668
+
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
2669
+
dependencies = [
2670
+
"log",
2671
+
"once_cell",
2672
+
"tracing-core",
2673
+
]
2674
+
2675
+
[[package]]
2676
+
name = "tracing-subscriber"
2677
+
version = "0.3.19"
2678
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2679
+
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
2680
+
dependencies = [
2681
+
"matchers",
2682
+
"nu-ansi-term",
2683
+
"once_cell",
2684
+
"regex",
2685
+
"sharded-slab",
2686
+
"smallvec",
2687
+
"thread_local",
2688
+
"tracing",
2689
+
"tracing-core",
2690
+
"tracing-log",
2691
+
]
2692
+
2693
+
[[package]]
2694
+
name = "trait-variant"
2695
+
version = "0.1.2"
2696
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2697
+
checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7"
2698
+
dependencies = [
2699
+
"proc-macro2",
2700
+
"quote",
2701
+
"syn",
2702
+
]
2703
+
2704
+
[[package]]
2705
+
name = "try-lock"
2706
+
version = "0.2.5"
2707
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2708
+
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2709
+
2710
+
[[package]]
2711
+
name = "typenum"
2712
+
version = "1.18.0"
2713
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2714
+
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
2715
+
2716
+
[[package]]
2717
+
name = "unicode-ident"
2718
+
version = "1.0.18"
2719
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2720
+
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
2721
+
2722
+
[[package]]
2723
+
name = "unsigned-varint"
2724
+
version = "0.8.0"
2725
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2726
+
checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06"
2727
+
2728
+
[[package]]
2729
+
name = "untrusted"
2730
+
version = "0.9.0"
2731
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2732
+
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
2733
+
2734
+
[[package]]
2735
+
name = "url"
2736
+
version = "2.5.4"
2737
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2738
+
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
2739
+
dependencies = [
2740
+
"form_urlencoded",
2741
+
"idna",
2742
+
"percent-encoding",
2743
+
"serde",
2744
+
]
2745
+
2746
+
[[package]]
2747
+
name = "urlencoding"
2748
+
version = "2.1.3"
2749
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2750
+
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
2751
+
2752
+
[[package]]
2753
+
name = "utf8_iter"
2754
+
version = "1.0.4"
2755
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2756
+
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
2757
+
2758
+
[[package]]
2759
+
name = "utf8parse"
2760
+
version = "0.2.2"
2761
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2762
+
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
2763
+
2764
+
[[package]]
2765
+
name = "uuid"
2766
+
version = "1.17.0"
2767
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2768
+
checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
2769
+
dependencies = [
2770
+
"getrandom 0.3.3",
2771
+
"js-sys",
2772
+
"wasm-bindgen",
2773
+
]
2774
+
2775
+
[[package]]
2776
+
name = "valuable"
2777
+
version = "0.1.1"
2778
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2779
+
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
2780
+
2781
+
[[package]]
2782
+
name = "vcpkg"
2783
+
version = "0.2.15"
2784
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2785
+
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
2786
+
2787
+
[[package]]
2788
+
name = "version_check"
2789
+
version = "0.9.5"
2790
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2791
+
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2792
+
2793
+
[[package]]
2794
+
name = "waker-fn"
2795
+
version = "1.2.0"
2796
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2797
+
checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
2798
+
2799
+
[[package]]
2800
+
name = "want"
2801
+
version = "0.3.1"
2802
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2803
+
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
2804
+
dependencies = [
2805
+
"try-lock",
2806
+
]
2807
+
2808
+
[[package]]
2809
+
name = "wasi"
2810
+
version = "0.9.0+wasi-snapshot-preview1"
2811
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2812
+
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
2813
+
2814
+
[[package]]
2815
+
name = "wasi"
2816
+
version = "0.11.0+wasi-snapshot-preview1"
2817
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2818
+
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
2819
+
2820
+
[[package]]
2821
+
name = "wasi"
2822
+
version = "0.14.2+wasi-0.2.4"
2823
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2824
+
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
2825
+
dependencies = [
2826
+
"wit-bindgen-rt",
2827
+
]
2828
+
2829
+
[[package]]
2830
+
name = "wasm-bindgen"
2831
+
version = "0.2.100"
2832
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2833
+
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
2834
+
dependencies = [
2835
+
"cfg-if",
2836
+
"once_cell",
2837
+
"rustversion",
2838
+
"wasm-bindgen-macro",
2839
+
]
2840
+
2841
+
[[package]]
2842
+
name = "wasm-bindgen-backend"
2843
+
version = "0.2.100"
2844
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2845
+
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
2846
+
dependencies = [
2847
+
"bumpalo",
2848
+
"log",
2849
+
"proc-macro2",
2850
+
"quote",
2851
+
"syn",
2852
+
"wasm-bindgen-shared",
2853
+
]
2854
+
2855
+
[[package]]
2856
+
name = "wasm-bindgen-futures"
2857
+
version = "0.4.50"
2858
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2859
+
checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
2860
+
dependencies = [
2861
+
"cfg-if",
2862
+
"js-sys",
2863
+
"once_cell",
2864
+
"wasm-bindgen",
2865
+
"web-sys",
2866
+
]
2867
+
2868
+
[[package]]
2869
+
name = "wasm-bindgen-macro"
2870
+
version = "0.2.100"
2871
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2872
+
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
2873
+
dependencies = [
2874
+
"quote",
2875
+
"wasm-bindgen-macro-support",
2876
+
]
2877
+
2878
+
[[package]]
2879
+
name = "wasm-bindgen-macro-support"
2880
+
version = "0.2.100"
2881
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2882
+
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
2883
+
dependencies = [
2884
+
"proc-macro2",
2885
+
"quote",
2886
+
"syn",
2887
+
"wasm-bindgen-backend",
2888
+
"wasm-bindgen-shared",
2889
+
]
2890
+
2891
+
[[package]]
2892
+
name = "wasm-bindgen-shared"
2893
+
version = "0.2.100"
2894
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2895
+
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
2896
+
dependencies = [
2897
+
"unicode-ident",
2898
+
]
2899
+
2900
+
[[package]]
2901
+
name = "web-sys"
2902
+
version = "0.3.77"
2903
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2904
+
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
2905
+
dependencies = [
2906
+
"js-sys",
2907
+
"wasm-bindgen",
2908
+
]
2909
+
2910
+
[[package]]
2911
+
name = "web-time"
2912
+
version = "1.1.0"
2913
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2914
+
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
2915
+
dependencies = [
2916
+
"js-sys",
2917
+
"wasm-bindgen",
2918
+
]
2919
+
2920
+
[[package]]
2921
+
name = "webpki-roots"
2922
+
version = "1.0.0"
2923
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2924
+
checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb"
2925
+
dependencies = [
2926
+
"rustls-pki-types",
2927
+
]
2928
+
2929
+
[[package]]
2930
+
name = "winapi"
2931
+
version = "0.3.9"
2932
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2933
+
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
2934
+
dependencies = [
2935
+
"winapi-i686-pc-windows-gnu",
2936
+
"winapi-x86_64-pc-windows-gnu",
2937
+
]
2938
+
2939
+
[[package]]
2940
+
name = "winapi-i686-pc-windows-gnu"
2941
+
version = "0.4.0"
2942
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2943
+
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
2944
+
2945
+
[[package]]
2946
+
name = "winapi-x86_64-pc-windows-gnu"
2947
+
version = "0.4.0"
2948
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2949
+
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
2950
+
2951
+
[[package]]
2952
+
name = "windows"
2953
+
version = "0.61.1"
2954
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2955
+
checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419"
2956
+
dependencies = [
2957
+
"windows-collections",
2958
+
"windows-core",
2959
+
"windows-future",
2960
+
"windows-link",
2961
+
"windows-numerics",
2962
+
]
2963
+
2964
+
[[package]]
2965
+
name = "windows-collections"
2966
+
version = "0.2.0"
2967
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2968
+
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
2969
+
dependencies = [
2970
+
"windows-core",
2971
+
]
2972
+
2973
+
[[package]]
2974
+
name = "windows-core"
2975
+
version = "0.61.2"
2976
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2977
+
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
2978
+
dependencies = [
2979
+
"windows-implement",
2980
+
"windows-interface",
2981
+
"windows-link",
2982
+
"windows-result",
2983
+
"windows-strings 0.4.2",
2984
+
]
2985
+
2986
+
[[package]]
2987
+
name = "windows-future"
2988
+
version = "0.2.1"
2989
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2990
+
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
2991
+
dependencies = [
2992
+
"windows-core",
2993
+
"windows-link",
2994
+
"windows-threading",
2995
+
]
2996
+
2997
+
[[package]]
2998
+
name = "windows-implement"
2999
+
version = "0.60.0"
3000
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3001
+
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
3002
+
dependencies = [
3003
+
"proc-macro2",
3004
+
"quote",
3005
+
"syn",
3006
+
]
3007
+
3008
+
[[package]]
3009
+
name = "windows-interface"
3010
+
version = "0.59.1"
3011
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3012
+
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
3013
+
dependencies = [
3014
+
"proc-macro2",
3015
+
"quote",
3016
+
"syn",
3017
+
]
3018
+
3019
+
[[package]]
3020
+
name = "windows-link"
3021
+
version = "0.1.1"
3022
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3023
+
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
3024
+
3025
+
[[package]]
3026
+
name = "windows-numerics"
3027
+
version = "0.2.0"
3028
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3029
+
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
3030
+
dependencies = [
3031
+
"windows-core",
3032
+
"windows-link",
3033
+
]
3034
+
3035
+
[[package]]
3036
+
name = "windows-registry"
3037
+
version = "0.4.0"
3038
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3039
+
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
3040
+
dependencies = [
3041
+
"windows-result",
3042
+
"windows-strings 0.3.1",
3043
+
"windows-targets 0.53.0",
3044
+
]
3045
+
3046
+
[[package]]
3047
+
name = "windows-result"
3048
+
version = "0.3.4"
3049
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3050
+
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
3051
+
dependencies = [
3052
+
"windows-link",
3053
+
]
3054
+
3055
+
[[package]]
3056
+
name = "windows-strings"
3057
+
version = "0.3.1"
3058
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3059
+
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
3060
+
dependencies = [
3061
+
"windows-link",
3062
+
]
3063
+
3064
+
[[package]]
3065
+
name = "windows-strings"
3066
+
version = "0.4.2"
3067
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3068
+
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
3069
+
dependencies = [
3070
+
"windows-link",
3071
+
]
3072
+
3073
+
[[package]]
3074
+
name = "windows-sys"
3075
+
version = "0.52.0"
3076
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3077
+
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
3078
+
dependencies = [
3079
+
"windows-targets 0.52.6",
3080
+
]
3081
+
3082
+
[[package]]
3083
+
name = "windows-sys"
3084
+
version = "0.59.0"
3085
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3086
+
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
3087
+
dependencies = [
3088
+
"windows-targets 0.52.6",
3089
+
]
3090
+
3091
+
[[package]]
3092
+
name = "windows-targets"
3093
+
version = "0.52.6"
3094
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3095
+
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
3096
+
dependencies = [
3097
+
"windows_aarch64_gnullvm 0.52.6",
3098
+
"windows_aarch64_msvc 0.52.6",
3099
+
"windows_i686_gnu 0.52.6",
3100
+
"windows_i686_gnullvm 0.52.6",
3101
+
"windows_i686_msvc 0.52.6",
3102
+
"windows_x86_64_gnu 0.52.6",
3103
+
"windows_x86_64_gnullvm 0.52.6",
3104
+
"windows_x86_64_msvc 0.52.6",
3105
+
]
3106
+
3107
+
[[package]]
3108
+
name = "windows-targets"
3109
+
version = "0.53.0"
3110
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3111
+
checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b"
3112
+
dependencies = [
3113
+
"windows_aarch64_gnullvm 0.53.0",
3114
+
"windows_aarch64_msvc 0.53.0",
3115
+
"windows_i686_gnu 0.53.0",
3116
+
"windows_i686_gnullvm 0.53.0",
3117
+
"windows_i686_msvc 0.53.0",
3118
+
"windows_x86_64_gnu 0.53.0",
3119
+
"windows_x86_64_gnullvm 0.53.0",
3120
+
"windows_x86_64_msvc 0.53.0",
3121
+
]
3122
+
3123
+
[[package]]
3124
+
name = "windows-threading"
3125
+
version = "0.1.0"
3126
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3127
+
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
3128
+
dependencies = [
3129
+
"windows-link",
3130
+
]
3131
+
3132
+
[[package]]
3133
+
name = "windows_aarch64_gnullvm"
3134
+
version = "0.52.6"
3135
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3136
+
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
3137
+
3138
+
[[package]]
3139
+
name = "windows_aarch64_gnullvm"
3140
+
version = "0.53.0"
3141
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3142
+
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
3143
+
3144
+
[[package]]
3145
+
name = "windows_aarch64_msvc"
3146
+
version = "0.52.6"
3147
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3148
+
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
3149
+
3150
+
[[package]]
3151
+
name = "windows_aarch64_msvc"
3152
+
version = "0.53.0"
3153
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3154
+
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
3155
+
3156
+
[[package]]
3157
+
name = "windows_i686_gnu"
3158
+
version = "0.52.6"
3159
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3160
+
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
3161
+
3162
+
[[package]]
3163
+
name = "windows_i686_gnu"
3164
+
version = "0.53.0"
3165
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3166
+
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
3167
+
3168
+
[[package]]
3169
+
name = "windows_i686_gnullvm"
3170
+
version = "0.52.6"
3171
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3172
+
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
3173
+
3174
+
[[package]]
3175
+
name = "windows_i686_gnullvm"
3176
+
version = "0.53.0"
3177
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3178
+
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
3179
+
3180
+
[[package]]
3181
+
name = "windows_i686_msvc"
3182
+
version = "0.52.6"
3183
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3184
+
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
3185
+
3186
+
[[package]]
3187
+
name = "windows_i686_msvc"
3188
+
version = "0.53.0"
3189
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3190
+
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
3191
+
3192
+
[[package]]
3193
+
name = "windows_x86_64_gnu"
3194
+
version = "0.52.6"
3195
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3196
+
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
3197
+
3198
+
[[package]]
3199
+
name = "windows_x86_64_gnu"
3200
+
version = "0.53.0"
3201
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3202
+
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
3203
+
3204
+
[[package]]
3205
+
name = "windows_x86_64_gnullvm"
3206
+
version = "0.52.6"
3207
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3208
+
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
3209
+
3210
+
[[package]]
3211
+
name = "windows_x86_64_gnullvm"
3212
+
version = "0.53.0"
3213
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3214
+
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
3215
+
3216
+
[[package]]
3217
+
name = "windows_x86_64_msvc"
3218
+
version = "0.52.6"
3219
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3220
+
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
3221
+
3222
+
[[package]]
3223
+
name = "windows_x86_64_msvc"
3224
+
version = "0.53.0"
3225
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3226
+
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
3227
+
3228
+
[[package]]
3229
+
name = "wiremock"
3230
+
version = "0.5.22"
3231
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3232
+
checksum = "13a3a53eaf34f390dd30d7b1b078287dd05df2aa2e21a589ccb80f5c7253c2e9"
3233
+
dependencies = [
3234
+
"assert-json-diff",
3235
+
"async-trait",
3236
+
"base64 0.21.7",
3237
+
"deadpool",
3238
+
"futures",
3239
+
"futures-timer",
3240
+
"http-types",
3241
+
"hyper 0.14.32",
3242
+
"log",
3243
+
"once_cell",
3244
+
"regex",
3245
+
"serde",
3246
+
"serde_json",
3247
+
"tokio",
3248
+
]
3249
+
3250
+
[[package]]
3251
+
name = "wit-bindgen-rt"
3252
+
version = "0.39.0"
3253
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3254
+
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
3255
+
dependencies = [
3256
+
"bitflags",
3257
+
]
3258
+
3259
+
[[package]]
3260
+
name = "writeable"
3261
+
version = "0.6.1"
3262
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3263
+
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
3264
+
3265
+
[[package]]
3266
+
name = "yoke"
3267
+
version = "0.8.0"
3268
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3269
+
checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc"
3270
+
dependencies = [
3271
+
"serde",
3272
+
"stable_deref_trait",
3273
+
"yoke-derive",
3274
+
"zerofrom",
3275
+
]
3276
+
3277
+
[[package]]
3278
+
name = "yoke-derive"
3279
+
version = "0.8.0"
3280
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3281
+
checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
3282
+
dependencies = [
3283
+
"proc-macro2",
3284
+
"quote",
3285
+
"syn",
3286
+
"synstructure",
3287
+
]
3288
+
3289
+
[[package]]
3290
+
name = "zerocopy"
3291
+
version = "0.8.25"
3292
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3293
+
checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb"
3294
+
dependencies = [
3295
+
"zerocopy-derive",
3296
+
]
3297
+
3298
+
[[package]]
3299
+
name = "zerocopy-derive"
3300
+
version = "0.8.25"
3301
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3302
+
checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef"
3303
+
dependencies = [
3304
+
"proc-macro2",
3305
+
"quote",
3306
+
"syn",
3307
+
]
3308
+
3309
+
[[package]]
3310
+
name = "zerofrom"
3311
+
version = "0.1.6"
3312
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3313
+
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
3314
+
dependencies = [
3315
+
"zerofrom-derive",
3316
+
]
3317
+
3318
+
[[package]]
3319
+
name = "zerofrom-derive"
3320
+
version = "0.1.6"
3321
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3322
+
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
3323
+
dependencies = [
3324
+
"proc-macro2",
3325
+
"quote",
3326
+
"syn",
3327
+
"synstructure",
3328
+
]
3329
+
3330
+
[[package]]
3331
+
name = "zeroize"
3332
+
version = "1.8.1"
3333
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3334
+
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
3335
+
3336
+
[[package]]
3337
+
name = "zerotrie"
3338
+
version = "0.2.2"
3339
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3340
+
checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595"
3341
+
dependencies = [
3342
+
"displaydoc",
3343
+
"yoke",
3344
+
"zerofrom",
3345
+
]
3346
+
3347
+
[[package]]
3348
+
name = "zerovec"
3349
+
version = "0.11.2"
3350
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3351
+
checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428"
3352
+
dependencies = [
3353
+
"yoke",
3354
+
"zerofrom",
3355
+
"zerovec-derive",
3356
+
]
3357
+
3358
+
[[package]]
3359
+
name = "zerovec-derive"
3360
+
version = "0.11.1"
3361
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3362
+
checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
3363
+
dependencies = [
3364
+
"proc-macro2",
3365
+
"quote",
3366
+
"syn",
3367
+
]
+75
RUST/Cargo.toml
+75
RUST/Cargo.toml
···
1
+
[package]
2
+
name = "atproto-calendar-import"
3
+
version = "0.1.0"
4
+
edition = "2021"
5
+
authors = ["Your Name <your.email@example.com>"]
6
+
description = "Import calendar events from external providers into AT Protocol"
7
+
license = "MIT OR Apache-2.0"
8
+
repository = "https://github.com/your-username/atproto-calendar-import"
9
+
keywords = ["atproto", "calendar", "bluesky", "import", "cli"]
10
+
categories = ["command-line-utilities", "api-bindings"]
11
+
12
+
[dependencies]
13
+
# AT Protocol - using more recent versions
14
+
atrium-api = "0.25"
15
+
atrium-xrpc-client = "0.5"
16
+
17
+
# Error handling
18
+
thiserror = "1.0"
19
+
anyhow = "1.0"
20
+
21
+
# Async runtime
22
+
tokio = { version = "1", features = ["full"] }
23
+
24
+
# Serialization
25
+
serde = { version = "1", features = ["derive"] }
26
+
serde_json = "1"
27
+
28
+
# Logging
29
+
tracing = "0.1"
30
+
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
31
+
32
+
# Configuration
33
+
dotenvy = "0.15"
34
+
35
+
# Time handling
36
+
chrono = { version = "0.4", features = ["serde"] }
37
+
38
+
# HTTP client
39
+
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
40
+
41
+
# CLI
42
+
clap = { version = "4.0", features = ["derive"] }
43
+
44
+
# OAuth2
45
+
oauth2 = "5.0"
46
+
47
+
# URL handling
48
+
url = "2.4"
49
+
50
+
# UUID generation
51
+
uuid = { version = "1.0", features = ["v4"] }
52
+
53
+
# URL encoding
54
+
urlencoding = "2.1"
55
+
56
+
# Security
57
+
secrecy = "0.8"
58
+
59
+
# Hashing for deduplication
60
+
sha2 = "0.10"
61
+
62
+
# Regular expressions
63
+
regex = "1.5"
64
+
65
+
[dev-dependencies]
66
+
tokio-test = "0.4"
67
+
wiremock = "0.5"
68
+
69
+
[[bin]]
70
+
name = "atproto-calendar-import"
71
+
path = "src/main.rs"
72
+
73
+
[lib]
74
+
name = "atproto_calendar_import"
75
+
path = "src/lib.rs"
+327
RUST/README.md
+327
RUST/README.md
···
1
+
2
+
# 📅 atproto-calendar-import
3
+
4
+
A Rust library and CLI tool for importing calendar events from external providers (Google Calendar, Microsoft Outlook) into the [AT Protocol](https://atproto.com/) ecosystem. Built with [atrium-rs](https://github.com/atrium-rs/atrium) for robust AT Protocol integration.
5
+
6
+
[](https://rust-lang.org)
7
+
[](LICENSE)
8
+
9
+
---
10
+
11
+
## 🌟 Features
12
+
13
+
- **Multi-Provider Support**: Import from Google Calendar and Microsoft Outlook
14
+
- **OAuth2 Authentication**: Secure authentication flows for external providers
15
+
- **AT Protocol Integration**: Seamless publishing to AT Protocol repositories
16
+
- **Deduplication**: Intelligent event deduplication to prevent duplicates
17
+
- **CLI Interface**: Easy-to-use command-line interface
18
+
- **Configurable**: Flexible configuration options
19
+
- **Error Handling**: Comprehensive error handling with structured error codes
20
+
21
+
---
22
+
23
+
## 📦 Repository Structure
24
+
25
+
```text
26
+
atproto-calendar-import/RUST/
27
+
├── src/
28
+
│ ├── main.rs # CLI binary entry point
29
+
│ ├── lib.rs # Core library exports
30
+
│ ├── auth.rs # OAuth2 + AT Protocol authentication
31
+
│ ├── cli.rs # Command-line interface
32
+
│ ├── dedup.rs # Event deduplication logic
33
+
│ ├── errors.rs # Centralized error definitions
34
+
│ ├── pds.rs # AT Protocol PDS interactions
35
+
│ ├── import/ # External calendar provider integrations
36
+
│ │ ├── mod.rs # Common import types and traits
37
+
│ │ ├── google.rs # Google Calendar API integration
38
+
│ │ └── outlook.rs # Microsoft Outlook/Graph API integration
39
+
│ └── transform/ # Event transformation and validation
40
+
│ ├── mod.rs # Transform module exports
41
+
│ ├── validate.rs # Event validation logic
42
+
│ └── convert.rs # Provider-to-AT Protocol conversion
43
+
├── docs/ # Additional documentation
44
+
├── import/ # Legacy import scripts (deprecated)
45
+
├── transform/ # Legacy transform scripts (deprecated)
46
+
├── target/ # Rust build artifacts
47
+
├── Cargo.toml # Project metadata and dependencies
48
+
├── Cargo.lock # Dependency lock file
49
+
└── README.md # This file
50
+
```
51
+
52
+
---
53
+
54
+
## 🚀 Quick Start
55
+
56
+
### Prerequisites
57
+
58
+
- **Rust** ≥ 1.70
59
+
- **Cargo** (included with Rust)
60
+
- External calendar API credentials:
61
+
- Google OAuth2 credentials for Google Calendar
62
+
- Microsoft Graph API credentials for Outlook
63
+
- AT Protocol account and PDS access
64
+
65
+
### Installation
66
+
67
+
1. Clone the repository:
68
+
```bash
69
+
git clone <repository-url>
70
+
cd atproto-calendar-import/RUST
71
+
```
72
+
73
+
2. Build the project:
74
+
```bash
75
+
cargo build --release
76
+
```
77
+
78
+
3. Set up environment variables:
79
+
```bash
80
+
# Google Calendar (optional)
81
+
export GOOGLE_CLIENT_ID="your-google-client-id"
82
+
export GOOGLE_CLIENT_SECRET="your-google-client-secret"
83
+
84
+
# Microsoft Outlook (optional)
85
+
export OUTLOOK_CLIENT_ID="your-outlook-client-id"
86
+
export OUTLOOK_CLIENT_SECRET="your-outlook-client-secret"
87
+
88
+
# AT Protocol
89
+
export ATP_HANDLE="your-handle.bsky.social"
90
+
export ATP_PASSWORD="your-app-password"
91
+
export ATP_PDS_URL="https://bsky.social" # or your PDS URL
92
+
```
93
+
94
+
### Basic Usage
95
+
96
+
Import from Google Calendar:
97
+
```bash
98
+
cargo run -- import-google --interactive
99
+
```
100
+
101
+
Import from Microsoft Outlook:
102
+
```bash
103
+
cargo run -- import-outlook --interactive
104
+
```
105
+
106
+
Test AT Protocol connection:
107
+
```bash
108
+
cargo run -- test-connection
109
+
```
110
+
111
+
Clear deduplication cache:
112
+
```bash
113
+
cargo run -- clear-cache
114
+
```
115
+
116
+
---
117
+
118
+
## 🛠️ Development
119
+
120
+
### Build Commands
121
+
122
+
| Task | Command | Description |
123
+
|------------------------|------------------------------|---------------------------------------|
124
+
| Build (debug) | `cargo build` | Build in debug mode |
125
+
| Build (release) | `cargo build --release` | Build optimized release binary |
126
+
| Type Check | `cargo check` | Fast type checking without building |
127
+
| Format Code | `cargo fmt` | Format code using rustfmt |
128
+
| Lint Code | `cargo clippy` | Run Clippy linter |
129
+
| Run Tests | `cargo test` | Run all unit and integration tests |
130
+
| Run Specific Test | `cargo test <test_name>` | Run a specific test |
131
+
| Generate Documentation| `cargo doc --open` | Generate and open documentation |
132
+
133
+
### Testing
134
+
135
+
```bash
136
+
# Run all tests
137
+
cargo test
138
+
139
+
# Run tests with output
140
+
cargo test -- --nocapture
141
+
142
+
# Run integration tests only
143
+
cargo test --test integration
144
+
145
+
# Run specific test
146
+
cargo test test_google_import
147
+
```
148
+
149
+
---
150
+
151
+
## 📋 CLI Commands
152
+
153
+
### Import Commands
154
+
155
+
#### `import-google`
156
+
Import events from Google Calendar.
157
+
158
+
```bash
159
+
cargo run -- import-google [OPTIONS]
160
+
```
161
+
162
+
**Options:**
163
+
- `-a, --access-token <TOKEN>`: Use existing access token
164
+
- `--interactive`: Start interactive OAuth2 flow
165
+
- `--skip-dedup`: Skip deduplication check
166
+
- `--dry-run`: Preview without publishing to AT Protocol
167
+
168
+
**Examples:**
169
+
```bash
170
+
# Interactive OAuth2 flow
171
+
cargo run -- import-google --interactive
172
+
173
+
# Use existing token
174
+
cargo run -- import-google --access-token "ya29.a0..."
175
+
176
+
# Dry run to preview events
177
+
cargo run -- import-google --interactive --dry-run
178
+
```
179
+
180
+
#### `import-outlook`
181
+
Import events from Microsoft Outlook.
182
+
183
+
```bash
184
+
cargo run -- import-outlook [OPTIONS]
185
+
```
186
+
187
+
**Options:**
188
+
- `-a, --access-token <TOKEN>`: Use existing access token
189
+
- `--interactive`: Start interactive OAuth2 flow
190
+
- `--skip-dedup`: Skip deduplication check
191
+
- `--dry-run`: Preview without publishing to AT Protocol
192
+
193
+
### Utility Commands
194
+
195
+
#### `test-connection`
196
+
Test connectivity to AT Protocol PDS.
197
+
198
+
```bash
199
+
cargo run -- test-connection
200
+
```
201
+
202
+
#### `clear-cache`
203
+
Clear the deduplication cache for the current user.
204
+
205
+
```bash
206
+
cargo run -- clear-cache
207
+
```
208
+
209
+
---
210
+
211
+
## ⚙️ Configuration
212
+
213
+
### Environment Variables
214
+
215
+
#### Required for AT Protocol
216
+
- `ATP_HANDLE`: Your AT Protocol handle (e.g., `alice.bsky.social`)
217
+
- `ATP_PASSWORD`: Your AT Protocol app password
218
+
- `ATP_PDS_URL`: PDS endpoint URL (default: `https://bsky.social`)
219
+
220
+
#### Optional for Google Calendar
221
+
- `GOOGLE_CLIENT_ID`: Google OAuth2 client ID
222
+
- `GOOGLE_CLIENT_SECRET`: Google OAuth2 client secret
223
+
224
+
#### Optional for Microsoft Outlook
225
+
- `OUTLOOK_CLIENT_ID`: Microsoft Graph API client ID
226
+
- `OUTLOOK_CLIENT_SECRET`: Microsoft Graph API client secret
227
+
228
+
#### Optional Configuration
229
+
- `RUST_LOG`: Log level (`trace`, `debug`, `info`, `warn`, `error`)
230
+
- `DATABASE_URL`: PostgreSQL connection string (for persistent deduplication cache)
231
+
232
+
### OAuth2 Setup
233
+
234
+
#### Google Calendar API
235
+
1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
236
+
2. Create a new project or select existing one
237
+
3. Enable the Google Calendar API
238
+
4. Create OAuth2 credentials
239
+
5. Set redirect URI to `http://localhost:8080/callback`
240
+
241
+
#### Microsoft Graph API
242
+
1. Go to [Azure Portal](https://portal.azure.com/)
243
+
2. Navigate to Azure Active Directory > App registrations
244
+
3. Create a new application registration
245
+
4. Add `Calendars.Read` permission
246
+
5. Set redirect URI to `http://localhost:8080/callback`
247
+
248
+
---
249
+
250
+
## 🔧 Architecture
251
+
252
+
### Core Components
253
+
254
+
- **`auth.rs`**: Handles OAuth2 flows for external providers and AT Protocol authentication
255
+
- **`import/`**: Provider-specific importers (Google, Outlook) that fetch calendar events
256
+
- **`transform/`**: Converts external event formats to AT Protocol lexicon format
257
+
- **`dedup.rs`**: Prevents duplicate events using content hashing and metadata comparison
258
+
- **`pds.rs`**: Publishes events to AT Protocol repositories via atrium-rs
259
+
- **`cli.rs`**: Command-line interface and argument parsing
260
+
261
+
### Data Flow
262
+
263
+
1. **Authentication**: OAuth2 flow with calendar provider + AT Protocol session
264
+
2. **Import**: Fetch events from external calendar API
265
+
3. **Transform**: Convert events to AT Protocol format
266
+
4. **Deduplicate**: Check against existing events to prevent duplicates
267
+
5. **Publish**: Create records in AT Protocol repository
268
+
269
+
### Error Handling
270
+
271
+
The project uses structured error codes for easy debugging:
272
+
273
+
- **1xx**: Authentication errors (`OAuth2TokenFailed`, `InvalidAccessToken`, etc.)
274
+
- **2xx**: Import errors (`GoogleApiError`, `OutlookApiError`, etc.)
275
+
- **3xx**: Transform errors (`InvalidDateTimeFormat`, `MissingRequiredField`, etc.)
276
+
- **4xx**: PDS errors (`RecordCreationFailed`, `RepositoryNotFound`, etc.)
277
+
- **5xx**: Internal errors (`DeduplicationError`, `ConfigurationError`, etc.)
278
+
279
+
---
280
+
281
+
## 🤝 Contributing
282
+
283
+
1. Fork the repository
284
+
2. Create a feature branch: `git checkout -b feature/amazing-feature`
285
+
3. Make your changes
286
+
4. Add tests for new functionality
287
+
5. Ensure all tests pass: `cargo test`
288
+
6. Format code: `cargo fmt`
289
+
7. Run linter: `cargo clippy`
290
+
8. Commit changes: `git commit -m 'Add amazing feature'`
291
+
9. Push to branch: `git push origin feature/amazing-feature`
292
+
10. Create a Pull Request
293
+
294
+
### Code Style
295
+
296
+
- Follow Rust standard formatting (`cargo fmt`)
297
+
- Add documentation for public APIs
298
+
- Include unit tests for new functionality
299
+
- Use structured error types from `errors.rs`
300
+
- Add tracing instrumentation for important functions
301
+
302
+
---
303
+
304
+
## 📝 License
305
+
306
+
This project is licensed under either of
307
+
308
+
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
309
+
- MIT License ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
310
+
311
+
at your option.
312
+
313
+
---
314
+
315
+
## 🔗 Related Projects
316
+
317
+
- [AT Protocol](https://atproto.com/) - The underlying protocol
318
+
- [atrium-rs](https://github.com/atrium-rs/atrium) - Rust AT Protocol implementation
319
+
- [Bluesky](https://bsky.app/) - Social network built on AT Protocol
320
+
321
+
---
322
+
323
+
## 📞 Support
324
+
325
+
- 📧 **Issues**: [GitHub Issues](../../issues)
326
+
- 📖 **Documentation**: Run `cargo doc --open` for API docs
327
+
- 🌐 **AT Protocol Docs**: https://atproto.com/
+261
RUST/docs/claude.md
+261
RUST/docs/claude.md
···
1
+
# 📘 Development Guide: `atproto-calendar-import` (with [atrium-rs](https://github.com/atrium-rs/atrium))
2
+
3
+
---
4
+
5
+
## 🔍 Project Description
6
+
7
+
`atproto-calendar-import` is a Rust library and CLI tool for importing calendar events from external providers (Google, Outlook, Apple, ICS) into the [AT Protocol](https://atproto.com/). It leverages the [atrium](https://github.com/atrium-rs/atrium) crate for authentication, lexicon-based data modeling, and repository interactions.
8
+
9
+
---
10
+
11
+
## 📦 Repository Structure
12
+
13
+
```text
14
+
atproto-calendar-import/
15
+
├── src/
16
+
│ ├── main.rs # CLI binary entry
17
+
│ ├── lib.rs # Core crate definition
18
+
│ ├── import/ # External calendar integrations (Google, Outlook, etc.)
19
+
│ ├── transform/ # Converts events to AT lexicon
20
+
│ ├── pds/ # Interacts with ATP repos via Atrium
21
+
│ ├── auth/ # OAuth2 + ATP auth helpers
22
+
│ ├── dedup/ # Deduplication logic
23
+
│ ├── cli/ # Argument parser and subcommand logic
24
+
│ └── errors.rs # Centralized, structured error handling
25
+
├── tests/ # Integration tests
26
+
├── Cargo.toml # Crate metadata and dependencies
27
+
└── README.md # Project documentation
28
+
```
29
+
30
+
---
31
+
32
+
## ✅ Requirements
33
+
34
+
* **Rust** ≥ 1.70
35
+
* **Cargo**
36
+
* External calendar API credentials (Google OAuth, Microsoft)
37
+
* Access to a self-hosted or sandbox PDS (see [ATP self-hosting guide](https://atproto.com/guides/self-hosting))
38
+
* Postgres (optional, for deduplication cache)
39
+
40
+
---
41
+
42
+
## 🛠️ Build & Test Commands
43
+
44
+
| Task | Command |
45
+
| ------------------ | --------------------------------------------------------------------- |
46
+
| Build | `cargo build` |
47
+
| Type Check | `cargo check` |
48
+
| Format Code | `cargo fmt` |
49
+
| Lint Code | `cargo clippy` |
50
+
| Run All Tests | `cargo test` |
51
+
| Run Specific Test | `cargo test <test_name>` |
52
+
53
+
---
54
+
55
+
## ⚙️ Architecture Overview
56
+
57
+
### 1. 📥 Import Service
58
+
59
+
Handles OAuth and reads events from:
60
+
61
+
* Google Calendar
62
+
* Microsoft Outlook (Graph API)
63
+
* Apple Calendar (CalDAV, future)
64
+
65
+
Example:
66
+
67
+
```rust
68
+
let events = google::import_events(access_token).await?;
69
+
```
70
+
71
+
---
72
+
73
+
### 2. 🔄 Data Transformation
74
+
75
+
Converts external events into AT Protocol-compliant format per \[`event.json`]\(uploaded file).
76
+
77
+
Handled in `transform/`:
78
+
79
+
* ISO 8601 datetime normalization
80
+
* Field mapping: `name`, `startsAt`, `mode`, etc.
81
+
* Enum coercion for `status`, `mode`, `uri`
82
+
83
+
Example:
84
+
85
+
```rust
86
+
let at_event = transform::to_at_event(&google_event)?;
87
+
```
88
+
89
+
---
90
+
91
+
### 3. 🔐 Authorization & Repository Writes (via Atrium)
92
+
93
+
Uses Atrium to authenticate and commit records to user repositories (repos).
94
+
95
+
Example flow:
96
+
97
+
```rust
98
+
use atrium_api::repo::{Client, RecordCreate};
99
+
100
+
let client = Client::new(jwt_token, pds_url)?;
101
+
let record = RecordCreate::<MyEvent>::builder()
102
+
.collection("community.lexicon.calendar.event")
103
+
.record(event)
104
+
.build()?;
105
+
client.create_record(&record).await?;
106
+
```
107
+
108
+
Also supports full DID document resolution via Atrium:
109
+
110
+
```rust
111
+
use atrium_api::identity::resolve_handle;
112
+
113
+
let did = resolve_handle("user.bsky.social").await?;
114
+
```
115
+
116
+
---
117
+
118
+
### 4. 🔁 Deduplication
119
+
120
+
To prevent duplicate entries:
121
+
122
+
* In-memory or Postgres cache
123
+
* Hash `name`, `startsAt`, and `location` as dedup key
124
+
* Store previously seen keys per user DID
125
+
126
+
---
127
+
128
+
## 🧪 Event Schema Overview
129
+
130
+
From `event.json` lexicon:
131
+
132
+
### Required:
133
+
134
+
* `name`: string
135
+
* `createdAt`: ISO 8601
136
+
137
+
### Optional:
138
+
139
+
* `description`
140
+
* `startsAt`, `endsAt`
141
+
* `mode`: `inperson`, `virtual`, `hybrid`
142
+
* `status`: `planned`, `scheduled`, `rescheduled`, `cancelled`, `postponed`
143
+
* `locations`: address, geo, h3
144
+
* `uris`: resource URIs
145
+
146
+
Example:
147
+
148
+
```json
149
+
{
150
+
"name": "Team Sync",
151
+
"createdAt": "2025-05-31T08:00:00Z",
152
+
"startsAt": "2025-06-01T10:00:00Z",
153
+
"endsAt": "2025-06-01T11:00:00Z",
154
+
"mode": "community.lexicon.calendar.event#virtual",
155
+
"status": "community.lexicon.calendar.event#scheduled"
156
+
}
157
+
```
158
+
159
+
---
160
+
161
+
## ⚠️ Error Handling
162
+
163
+
* Use `thiserror` for all structured errors
164
+
* Format: `error-atproto-calendar-<domain>-<code> <msg>: <details>`
165
+
* Avoid `anyhow!`
166
+
167
+
Example:
168
+
169
+
```rust
170
+
#[derive(Error, Debug)]
171
+
pub enum PdsError {
172
+
#[error("error-atproto-calendar-pds-1 Failed to write record: {0}")]
173
+
WriteFailed(String),
174
+
}
175
+
```
176
+
177
+
Error logging:
178
+
179
+
```rust
180
+
if let Err(err) = result {
181
+
tracing::error!(error = ?err, "Failed to process event");
182
+
}
183
+
```
184
+
185
+
---
186
+
187
+
## 🔍 Logging
188
+
189
+
* Use `tracing` with spans for structured and contextual logs.
190
+
* All async functions should be `.instrument()`ed.
191
+
192
+
```rust
193
+
use tracing::Instrument;
194
+
195
+
async fn import() -> Result<()> {
196
+
actual_import().instrument(tracing::info_span!("calendar_import")).await
197
+
}
198
+
```
199
+
200
+
---
201
+
202
+
## 📖 Documentation
203
+
204
+
* Document all public modules and functions.
205
+
* Begin each module with:
206
+
207
+
```rust
208
+
//! Handles transformation of Google Calendar events into AT Protocol format.
209
+
```
210
+
211
+
* Generate docs:
212
+
213
+
```bash
214
+
cargo doc --open
215
+
```
216
+
217
+
---
218
+
219
+
## 🧰 Self-Hosting Setup (ATP)
220
+
221
+
1. Deploy [PDS](https://github.com/bluesky-social/atproto/tree/main/packages/pds)
222
+
2. Link with DNS/DID
223
+
3. Use Atrium to connect:
224
+
225
+
```rust
226
+
let session = client.login("handle.bsky.social", "app_password").await?;
227
+
```
228
+
229
+
4. For local testing: use [`bsky.dev`](https://bsky.dev/)
230
+
231
+
---
232
+
233
+
## 🚀 Extending the Project
234
+
235
+
### ➕ Add Calendar Provider
236
+
237
+
1. Create a file in `src/import/<provider>.rs`
238
+
2. Implement:
239
+
240
+
```rust
241
+
pub async fn import_events(token: &str) -> Result<Vec<ExternalEvent>> { ... }
242
+
```
243
+
244
+
3. Transform and publish via Atrium
245
+
246
+
---
247
+
248
+
### 🧩 Add Custom Fields
249
+
250
+
* Update `transform/mod.rs` to support new fields
251
+
* Extend event struct with additional lexicon attributes
252
+
* Confirm compliance with \[event.json]\(uploaded file)
253
+
254
+
---
255
+
256
+
## 🔐 Security
257
+
258
+
* Store tokens securely (use `rustls`, `secrecy`, `dotenv`)
259
+
* Never log raw tokens or DIDs
260
+
* Enforce HTTPS for all outbound API calls
261
+
+251
RUST/docs/code_scaffold.md
+251
RUST/docs/code_scaffold.md
···
1
+
Here’s a **complete Rust code scaffold** for `atproto-calendar-import` using [`atrium`](https://github.com/atrium-rs/atrium) and organized for modular extensibility and proper architectural separation.
2
+
3
+
---
4
+
5
+
## 📁 Directory Scaffold
6
+
7
+
```
8
+
atproto-calendar-import/
9
+
├── src/
10
+
│ ├── main.rs
11
+
│ ├── lib.rs
12
+
│ ├── cli.rs
13
+
│ ├── import/
14
+
│ │ ├── mod.rs
15
+
│ │ └── google.rs
16
+
│ ├── transform/
17
+
│ │ └── mod.rs
18
+
│ ├── pds.rs
19
+
│ ├── auth.rs
20
+
│ ├── dedup.rs
21
+
│ └── errors.rs
22
+
├── Cargo.toml
23
+
└── .env
24
+
```
25
+
26
+
---
27
+
28
+
## 🦀 `Cargo.toml` (minimal example)
29
+
30
+
```toml
31
+
[package]
32
+
name = "atproto-calendar-import"
33
+
version = "0.1.0"
34
+
edition = "2021"
35
+
36
+
[dependencies]
37
+
atrium-api = "0.4"
38
+
atrium-xrpc-client = "0.4"
39
+
thiserror = "1.0"
40
+
anyhow = "1.0"
41
+
tokio = { version = "1", features = ["full"] }
42
+
serde = { version = "1", features = ["derive"] }
43
+
serde_json = "1"
44
+
tracing = "0.1"
45
+
dotenvy = "0.15"
46
+
chrono = { version = "0.4", features = ["serde"] }
47
+
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
48
+
```
49
+
50
+
---
51
+
52
+
## 🧾 `src/main.rs`
53
+
54
+
```rust
55
+
//! CLI entry point
56
+
57
+
mod cli;
58
+
mod errors;
59
+
mod auth;
60
+
mod pds;
61
+
mod import;
62
+
mod transform;
63
+
mod dedup;
64
+
65
+
use anyhow::Result;
66
+
use tracing_subscriber::FmtSubscriber;
67
+
68
+
#[tokio::main]
69
+
async fn main() -> Result<()> {
70
+
dotenvy::dotenv().ok();
71
+
let subscriber = FmtSubscriber::new();
72
+
tracing::subscriber::set_global_default(subscriber)?;
73
+
74
+
let config = cli::parse();
75
+
cli::handle_command(config).await
76
+
}
77
+
```
78
+
79
+
---
80
+
81
+
## 🧾 `src/cli.rs`
82
+
83
+
```rust
84
+
//! CLI command parser
85
+
86
+
use clap::{Parser, Subcommand};
87
+
88
+
#[derive(Parser)]
89
+
#[command(name = "atproto-calendar-import")]
90
+
#[command(about = "Imports calendar events to AT Protocol")]
91
+
pub struct Cli {
92
+
#[command(subcommand)]
93
+
pub command: Commands,
94
+
}
95
+
96
+
#[derive(Subcommand)]
97
+
pub enum Commands {
98
+
ImportGoogle {
99
+
#[arg(short, long)]
100
+
access_token: String,
101
+
},
102
+
}
103
+
104
+
pub fn parse() -> Cli {
105
+
Cli::parse()
106
+
}
107
+
108
+
use crate::{import::google, transform, pds};
109
+
110
+
pub async fn handle_command(cli: Cli) -> anyhow::Result<()> {
111
+
match cli.command {
112
+
Commands::ImportGoogle { access_token } => {
113
+
let events = google::import_events(&access_token).await?;
114
+
for event in events {
115
+
let at_event = transform::to_at_event(&event)?;
116
+
pds::publish_event(&at_event).await?;
117
+
}
118
+
}
119
+
}
120
+
Ok(())
121
+
}
122
+
```
123
+
124
+
---
125
+
126
+
## 🧾 `src/import/google.rs`
127
+
128
+
```rust
129
+
//! Google Calendar API integration
130
+
131
+
use anyhow::Result;
132
+
133
+
#[derive(Debug, Clone)]
134
+
pub struct GoogleEvent {
135
+
pub name: String,
136
+
pub description: Option<String>,
137
+
pub starts_at: Option<String>,
138
+
pub ends_at: Option<String>,
139
+
}
140
+
141
+
pub async fn import_events(_token: &str) -> Result<Vec<GoogleEvent>> {
142
+
// TODO: Implement Google Calendar fetch logic with OAuth2
143
+
Ok(vec![GoogleEvent {
144
+
name: "Team Sync".to_string(),
145
+
description: Some("Weekly catch-up".into()),
146
+
starts_at: Some("2025-06-01T10:00:00Z".into()),
147
+
ends_at: Some("2025-06-01T11:00:00Z".into()),
148
+
}])
149
+
}
150
+
```
151
+
152
+
---
153
+
154
+
## 🧾 `src/transform/mod.rs`
155
+
156
+
```rust
157
+
//! Transforms external event types into AT Protocol lexicon structures
158
+
159
+
use anyhow::Result;
160
+
use crate::import::google::GoogleEvent;
161
+
use serde::Serialize;
162
+
163
+
#[derive(Serialize)]
164
+
pub struct AtEvent {
165
+
pub created_at: String,
166
+
pub name: String,
167
+
pub starts_at: Option<String>,
168
+
pub ends_at: Option<String>,
169
+
pub description: Option<String>,
170
+
pub mode: String,
171
+
pub status: String,
172
+
}
173
+
174
+
pub fn to_at_event(source: &GoogleEvent) -> Result<AtEvent> {
175
+
Ok(AtEvent {
176
+
created_at: chrono::Utc::now().to_rfc3339(),
177
+
name: source.name.clone(),
178
+
starts_at: source.starts_at.clone(),
179
+
ends_at: source.ends_at.clone(),
180
+
description: source.description.clone(),
181
+
mode: "community.lexicon.calendar.event#virtual".into(),
182
+
status: "community.lexicon.calendar.event#scheduled".into(),
183
+
})
184
+
}
185
+
```
186
+
187
+
---
188
+
189
+
## 🧾 `src/pds.rs`
190
+
191
+
```rust
192
+
//! Uses atrium to publish transformed events to the user's repo
193
+
194
+
use atrium_api::repo::{Client, RecordCreate};
195
+
use crate::transform::AtEvent;
196
+
use anyhow::Result;
197
+
198
+
pub async fn publish_event(event: &AtEvent) -> Result<()> {
199
+
let token = std::env::var("ATP_JWT")?;
200
+
let pds_url = std::env::var("ATP_PDS")?;
201
+
let did = std::env::var("ATP_DID")?;
202
+
203
+
let client = Client::new(token, pds_url)?;
204
+
let record = RecordCreate::builder()
205
+
.collection("community.lexicon.calendar.event")
206
+
.record(event)
207
+
.repo(did)
208
+
.build()?;
209
+
210
+
client.create_record(&record).await?;
211
+
Ok(())
212
+
}
213
+
```
214
+
215
+
---
216
+
217
+
## 🧾 `src/errors.rs`
218
+
219
+
```rust
220
+
//! Project-wide error definitions
221
+
222
+
use thiserror::Error;
223
+
224
+
#[derive(Debug, Error)]
225
+
pub enum CalendarImportError {
226
+
#[error("error-atproto-identity-auth-1 Token retrieval failed: {0}")]
227
+
AuthError(String),
228
+
229
+
#[error("error-atproto-identity-pds-1 Failed to write record: {0}")]
230
+
PdsWriteError(String),
231
+
}
232
+
```
233
+
234
+
---
235
+
236
+
## ✅ `.env` Example
237
+
238
+
```
239
+
ATP_JWT=ey...
240
+
ATP_PDS=https://pds.bsky.dev
241
+
ATP_DID=did:plc:123abc...
242
+
```
243
+
244
+
---
245
+
246
+
Would you like:
247
+
248
+
* Sample tests (`tests/cli.rs`, `tests/integration.rs`)?
249
+
* A working OAuth2 token exchange for Google?
250
+
* ATRIUM-based unit tests or mock PDS server interaction?
251
+
+268
RUST/src/auth.rs
+268
RUST/src/auth.rs
···
1
+
//! OAuth2 authentication handling for external calendar providers
2
+
//!
3
+
//! This module provides authentication flows for Google Calendar and Microsoft Outlook,
4
+
//! as well as AT Protocol session management.
5
+
6
+
use anyhow::Result;
7
+
use oauth2::{
8
+
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge,
9
+
RedirectUrl, Scope, TokenUrl, TokenResponse, basic::BasicClient,
10
+
};
11
+
use serde::{Deserialize, Serialize};
12
+
use std::io::{self, Write};
13
+
use tracing::{info, instrument};
14
+
15
+
use crate::errors::CalendarImportError;
16
+
17
+
#[derive(Debug, Clone, Serialize, Deserialize)]
18
+
pub struct OAuth2Config {
19
+
pub client_id: String,
20
+
pub client_secret: String,
21
+
pub auth_url: String,
22
+
pub token_url: String,
23
+
pub redirect_url: String,
24
+
pub scopes: Vec<String>,
25
+
}
26
+
27
+
#[derive(Debug, Clone, Serialize, Deserialize)]
28
+
pub struct AccessToken {
29
+
pub token: String,
30
+
pub refresh_token: Option<String>,
31
+
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
32
+
pub scope: Option<String>,
33
+
}
34
+
35
+
/// Google Calendar OAuth2 configuration
36
+
pub fn google_oauth_config() -> Result<OAuth2Config> {
37
+
Ok(OAuth2Config {
38
+
client_id: std::env::var("GOOGLE_CLIENT_ID")
39
+
.map_err(|_| CalendarImportError::MissingEnvironmentVariable("GOOGLE_CLIENT_ID".to_string()))?,
40
+
client_secret: std::env::var("GOOGLE_CLIENT_SECRET")
41
+
.map_err(|_| CalendarImportError::MissingEnvironmentVariable("GOOGLE_CLIENT_SECRET".to_string()))?,
42
+
auth_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
43
+
token_url: "https://oauth2.googleapis.com/token".to_string(),
44
+
redirect_url: "http://localhost:8080/callback".to_string(),
45
+
scopes: vec!["https://www.googleapis.com/auth/calendar.readonly".to_string()],
46
+
})
47
+
}
48
+
49
+
/// Microsoft Outlook OAuth2 configuration
50
+
pub fn outlook_oauth_config() -> Result<OAuth2Config> {
51
+
Ok(OAuth2Config {
52
+
client_id: std::env::var("OUTLOOK_CLIENT_ID")
53
+
.map_err(|_| CalendarImportError::MissingEnvironmentVariable("OUTLOOK_CLIENT_ID".to_string()))?,
54
+
client_secret: std::env::var("OUTLOOK_CLIENT_SECRET")
55
+
.map_err(|_| CalendarImportError::MissingEnvironmentVariable("OUTLOOK_CLIENT_SECRET".to_string()))?,
56
+
auth_url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize".to_string(),
57
+
token_url: "https://login.microsoftonline.com/common/oauth2/v2.0/token".to_string(),
58
+
redirect_url: "http://localhost:8080/callback".to_string(),
59
+
scopes: vec!["https://graph.microsoft.com/calendars.read".to_string()],
60
+
})
61
+
}
62
+
63
+
/// Perform interactive OAuth2 flow for a given provider
64
+
#[instrument]
65
+
pub async fn interactive_oauth_flow(config: &OAuth2Config) -> Result<AccessToken> {
66
+
let client = BasicClient::new(
67
+
ClientId::new(config.client_id.clone()),
68
+
)
69
+
.set_client_secret(ClientSecret::new(config.client_secret.clone()))
70
+
.set_auth_uri(AuthUrl::new(config.auth_url.clone())?)
71
+
.set_token_uri(TokenUrl::new(config.token_url.clone())?)
72
+
.set_redirect_uri(RedirectUrl::new(config.redirect_url.clone())?);
73
+
74
+
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
75
+
76
+
let mut auth_request = client
77
+
.authorize_url(CsrfToken::new_random)
78
+
.set_pkce_challenge(pkce_challenge);
79
+
80
+
for scope in &config.scopes {
81
+
auth_request = auth_request.add_scope(Scope::new(scope.clone()));
82
+
}
83
+
84
+
let (auth_url, _csrf_token) = auth_request.url();
85
+
86
+
info!("Open this URL in your browser:");
87
+
println!("{}", auth_url);
88
+
89
+
print!("Enter the authorization code: ");
90
+
io::stdout().flush()?;
91
+
92
+
let mut code = String::new();
93
+
io::stdin().read_line(&mut code)?;
94
+
let code = code.trim();
95
+
96
+
let http_client = reqwest::ClientBuilder::new()
97
+
.redirect(reqwest::redirect::Policy::none())
98
+
.build()?;
99
+
100
+
let token_result = client
101
+
.exchange_code(AuthorizationCode::new(code.to_string()))
102
+
.set_pkce_verifier(pkce_verifier)
103
+
.request_async(&http_client)
104
+
.await
105
+
.map_err(|e| CalendarImportError::OAuth2TokenFailed(e.to_string()))?;
106
+
107
+
Ok(AccessToken {
108
+
token: token_result.access_token().secret().clone(),
109
+
refresh_token: token_result.refresh_token().map(|t| t.secret().clone()),
110
+
expires_at: token_result.expires_in().map(|duration| {
111
+
chrono::Utc::now() + chrono::Duration::from_std(duration).unwrap_or_default()
112
+
}),
113
+
scope: token_result.scopes().map(|scopes| {
114
+
scopes.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" ")
115
+
}),
116
+
})
117
+
}
118
+
119
+
/// Validate an access token by making a test API call
120
+
#[instrument]
121
+
pub async fn validate_token(token: &str, provider: &str) -> Result<bool> {
122
+
let client = reqwest::Client::new();
123
+
124
+
let test_url = match provider {
125
+
"google" => "https://www.googleapis.com/oauth2/v1/tokeninfo",
126
+
"outlook" => "https://graph.microsoft.com/v1.0/me",
127
+
_ => return Err(CalendarImportError::NotImplemented(format!("Provider: {}", provider)).into()),
128
+
};
129
+
130
+
let response = client
131
+
.get(test_url)
132
+
.bearer_auth(token)
133
+
.send()
134
+
.await?;
135
+
136
+
Ok(response.status().is_success())
137
+
}
138
+
139
+
/// AT Protocol session management
140
+
#[derive(Debug, Clone)]
141
+
pub struct AtProtocolSession {
142
+
pub access_jwt: String,
143
+
pub refresh_jwt: String,
144
+
pub handle: String,
145
+
pub did: String,
146
+
pub pds_url: String,
147
+
}
148
+
149
+
/// Create AT Protocol session from credentials
150
+
#[instrument]
151
+
pub async fn create_at_session(
152
+
handle: &str,
153
+
password: &str,
154
+
pds_url: &str,
155
+
) -> Result<AtProtocolSession> {
156
+
let client = reqwest::Client::new();
157
+
158
+
let login_request = serde_json::json!({
159
+
"identifier": handle,
160
+
"password": password
161
+
});
162
+
163
+
let response = client
164
+
.post(&format!("{}/xrpc/com.atproto.server.createSession", pds_url))
165
+
.json(&login_request)
166
+
.send()
167
+
.await?;
168
+
169
+
if !response.status().is_success() {
170
+
return Err(CalendarImportError::AtProtocolAuthFailed(
171
+
format!("HTTP {}: {}", response.status(), response.text().await?)
172
+
).into());
173
+
}
174
+
175
+
let session_data: serde_json::Value = response.json().await?;
176
+
177
+
Ok(AtProtocolSession {
178
+
access_jwt: session_data["accessJwt"]
179
+
.as_str()
180
+
.ok_or_else(|| CalendarImportError::AtProtocolAuthFailed("Missing accessJwt".to_string()))?
181
+
.to_string(),
182
+
refresh_jwt: session_data["refreshJwt"]
183
+
.as_str()
184
+
.ok_or_else(|| CalendarImportError::AtProtocolAuthFailed("Missing refreshJwt".to_string()))?
185
+
.to_string(),
186
+
handle: session_data["handle"]
187
+
.as_str()
188
+
.ok_or_else(|| CalendarImportError::AtProtocolAuthFailed("Missing handle".to_string()))?
189
+
.to_string(),
190
+
did: session_data["did"]
191
+
.as_str()
192
+
.ok_or_else(|| CalendarImportError::AtProtocolAuthFailed("Missing DID".to_string()))?
193
+
.to_string(),
194
+
pds_url: pds_url.to_string(),
195
+
})
196
+
}
197
+
198
+
/// Authentication manager for handling OAuth2 flows for different providers
199
+
#[derive(Debug, Clone)]
200
+
pub struct AuthManager {
201
+
google_client_id: Option<String>,
202
+
google_client_secret: Option<String>,
203
+
outlook_client_id: Option<String>,
204
+
outlook_client_secret: Option<String>,
205
+
}
206
+
207
+
impl AuthManager {
208
+
/// Create a new AuthManager with optional provider credentials
209
+
pub fn new(
210
+
google_client_id: Option<String>,
211
+
google_client_secret: Option<String>,
212
+
outlook_client_id: Option<String>,
213
+
outlook_client_secret: Option<String>,
214
+
) -> Result<Self> {
215
+
Ok(AuthManager {
216
+
google_client_id,
217
+
google_client_secret,
218
+
outlook_client_id,
219
+
outlook_client_secret,
220
+
})
221
+
}
222
+
223
+
/// Get Google OAuth2 configuration
224
+
pub fn google_config(&self) -> Result<OAuth2Config> {
225
+
let client_id = self.google_client_id.as_ref()
226
+
.ok_or_else(|| CalendarImportError::MissingEnvironmentVariable("GOOGLE_CLIENT_ID".to_string()))?;
227
+
let client_secret = self.google_client_secret.as_ref()
228
+
.ok_or_else(|| CalendarImportError::MissingEnvironmentVariable("GOOGLE_CLIENT_SECRET".to_string()))?;
229
+
230
+
Ok(OAuth2Config {
231
+
client_id: client_id.clone(),
232
+
client_secret: client_secret.clone(),
233
+
auth_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
234
+
token_url: "https://oauth2.googleapis.com/token".to_string(),
235
+
redirect_url: "http://localhost:8080/callback".to_string(),
236
+
scopes: vec!["https://www.googleapis.com/auth/calendar.readonly".to_string()],
237
+
})
238
+
}
239
+
240
+
/// Get Outlook OAuth2 configuration
241
+
pub fn outlook_config(&self) -> Result<OAuth2Config> {
242
+
let client_id = self.outlook_client_id.as_ref()
243
+
.ok_or_else(|| CalendarImportError::MissingEnvironmentVariable("OUTLOOK_CLIENT_ID".to_string()))?;
244
+
let client_secret = self.outlook_client_secret.as_ref()
245
+
.ok_or_else(|| CalendarImportError::MissingEnvironmentVariable("OUTLOOK_CLIENT_SECRET".to_string()))?;
246
+
247
+
Ok(OAuth2Config {
248
+
client_id: client_id.clone(),
249
+
client_secret: client_secret.clone(),
250
+
auth_url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize".to_string(),
251
+
token_url: "https://login.microsoftonline.com/common/oauth2/v2.0/token".to_string(),
252
+
redirect_url: "http://localhost:8080/callback".to_string(),
253
+
scopes: vec!["https://graph.microsoft.com/calendars.read".to_string()],
254
+
})
255
+
}
256
+
257
+
/// Perform OAuth2 flow for Google
258
+
pub async fn authenticate_google(&self) -> Result<AccessToken> {
259
+
let config = self.google_config()?;
260
+
interactive_oauth_flow(&config).await
261
+
}
262
+
263
+
/// Perform OAuth2 flow for Outlook
264
+
pub async fn authenticate_outlook(&self) -> Result<AccessToken> {
265
+
let config = self.outlook_config()?;
266
+
interactive_oauth_flow(&config).await
267
+
}
268
+
}
+244
RUST/src/cli.rs
+244
RUST/src/cli.rs
···
1
+
//! CLI command parser and handler for atproto-calendar-import
2
+
3
+
use clap::{Parser, Subcommand};
4
+
use anyhow::Result;
5
+
use tracing::{info, instrument};
6
+
7
+
use crate::{import, transform, pds, dedup, auth};
8
+
9
+
#[derive(Parser)]
10
+
#[command(name = "atproto-calendar-import")]
11
+
#[command(about = "Import calendar events from external providers to AT Protocol")]
12
+
#[command(version = "0.1.0")]
13
+
#[command(author = "Your Name <your.email@example.com>")]
14
+
pub struct Cli {
15
+
#[command(subcommand)]
16
+
pub command: Commands,
17
+
}
18
+
19
+
#[derive(Subcommand)]
20
+
pub enum Commands {
21
+
/// Import events from Google Calendar
22
+
ImportGoogle {
23
+
/// Google OAuth2 access token
24
+
#[arg(short, long)]
25
+
access_token: Option<String>,
26
+
27
+
/// Interactive OAuth2 flow if no token provided
28
+
#[arg(long)]
29
+
interactive: bool,
30
+
31
+
/// Skip deduplication check
32
+
#[arg(long)]
33
+
skip_dedup: bool,
34
+
35
+
/// Dry run - don't actually publish to AT Protocol
36
+
#[arg(long)]
37
+
dry_run: bool,
38
+
},
39
+
/// Import events from Microsoft Outlook
40
+
ImportOutlook {
41
+
/// Microsoft Graph API access token
42
+
#[arg(short, long)]
43
+
access_token: Option<String>,
44
+
45
+
/// Interactive OAuth2 flow if no token provided
46
+
#[arg(long)]
47
+
interactive: bool,
48
+
49
+
/// Skip deduplication check
50
+
#[arg(long)]
51
+
skip_dedup: bool,
52
+
53
+
/// Dry run - don't actually publish to AT Protocol
54
+
#[arg(long)]
55
+
dry_run: bool,
56
+
},
57
+
/// Test AT Protocol connection
58
+
TestConnection,
59
+
/// Clear deduplication cache for current user
60
+
ClearCache,
61
+
}
62
+
63
+
pub fn parse() -> Cli {
64
+
Cli::parse()
65
+
}
66
+
67
+
#[instrument(skip(cli))]
68
+
pub async fn handle_command(cli: Cli) -> Result<()> {
69
+
match cli.command {
70
+
Commands::ImportGoogle {
71
+
access_token,
72
+
interactive,
73
+
skip_dedup,
74
+
dry_run
75
+
} => {
76
+
let token = get_or_obtain_token(access_token, interactive, "google").await?;
77
+
import_calendar_events(
78
+
"google",
79
+
&token,
80
+
skip_dedup,
81
+
dry_run,
82
+
).await?;
83
+
}
84
+
Commands::ImportOutlook {
85
+
access_token,
86
+
interactive,
87
+
skip_dedup,
88
+
dry_run
89
+
} => {
90
+
let token = get_or_obtain_token(access_token, interactive, "outlook").await?;
91
+
import_calendar_events(
92
+
"outlook",
93
+
&token,
94
+
skip_dedup,
95
+
dry_run,
96
+
).await?;
97
+
}
98
+
Commands::TestConnection => {
99
+
test_at_protocol_connection().await?;
100
+
}
101
+
Commands::ClearCache => {
102
+
clear_dedup_cache().await?;
103
+
info!("Deduplication cache cleared");
104
+
}
105
+
}
106
+
Ok(())
107
+
}
108
+
109
+
#[instrument]
110
+
async fn get_or_obtain_token(
111
+
provided_token: Option<String>,
112
+
interactive: bool,
113
+
provider: &str,
114
+
) -> Result<String> {
115
+
if let Some(token) = provided_token {
116
+
return Ok(token);
117
+
}
118
+
119
+
if interactive {
120
+
info!("Starting interactive OAuth2 flow for {}", provider);
121
+
let config = match provider {
122
+
"google" => auth::google_oauth_config()?,
123
+
"outlook" => auth::outlook_oauth_config()?,
124
+
_ => anyhow::bail!("Unsupported provider: {}", provider),
125
+
};
126
+
let access_token = auth::interactive_oauth_flow(&config).await?;
127
+
return Ok(access_token.token);
128
+
}
129
+
130
+
anyhow::bail!(
131
+
"No access token provided. Use --access-token or --interactive flag"
132
+
);
133
+
}
134
+
135
+
#[instrument(skip(token))]
136
+
async fn import_calendar_events(
137
+
provider: &str,
138
+
token: &str,
139
+
skip_dedup: bool,
140
+
dry_run: bool,
141
+
) -> Result<()> {
142
+
info!("Starting import from {}", provider);
143
+
144
+
// Get user DID for deduplication
145
+
let user_did = std::env::var("ATP_DID")
146
+
.map_err(|_| anyhow::anyhow!("ATP_DID environment variable not set"))?;
147
+
148
+
// Import events from the specified provider
149
+
let events = match provider {
150
+
"google" => import::google::import_events(token).await?,
151
+
"outlook" => import::outlook::import_events(token).await?,
152
+
_ => anyhow::bail!("Unsupported provider: {}", provider),
153
+
};
154
+
155
+
info!("Retrieved {} events from {}", events.len(), provider);
156
+
157
+
// Handle deduplication
158
+
let filtered_events = if skip_dedup {
159
+
info!("Skipping deduplication check");
160
+
events
161
+
} else {
162
+
let mut cache = dedup::create_cache();
163
+
cache.load_from_storage(&user_did).await?;
164
+
let unique_events = cache.filter_duplicates(events, &user_did)?;
165
+
cache.save_to_storage(&user_did).await?;
166
+
unique_events
167
+
};
168
+
169
+
if filtered_events.is_empty() {
170
+
info!("No new events to import after deduplication");
171
+
return Ok(());
172
+
}
173
+
174
+
// Transform events to AT Protocol format
175
+
let mut at_events = Vec::new();
176
+
for event in &filtered_events {
177
+
match transform::to_at_event(event) {
178
+
Ok(at_event) => at_events.push(at_event),
179
+
Err(e) => {
180
+
tracing::warn!("Failed to transform event '{}': {}", event.name, e);
181
+
continue;
182
+
}
183
+
}
184
+
}
185
+
186
+
info!("Transformed {} events to AT Protocol format", at_events.len());
187
+
188
+
if dry_run {
189
+
info!("DRY RUN: Would publish {} events", at_events.len());
190
+
for event in &at_events {
191
+
info!(" - {}", event.name);
192
+
}
193
+
return Ok(());
194
+
}
195
+
196
+
// Create PDS client and publish events
197
+
let handle = std::env::var("ATP_HANDLE")
198
+
.map_err(|_| anyhow::anyhow!("ATP_HANDLE environment variable not set"))?;
199
+
let password = std::env::var("ATP_PASSWORD")
200
+
.map_err(|_| anyhow::anyhow!("ATP_PASSWORD environment variable not set"))?;
201
+
let pds_url = std::env::var("ATP_PDS")
202
+
.map_err(|_| anyhow::anyhow!("ATP_PDS environment variable not set"))?;
203
+
204
+
let client = pds::PdsClient::login(&handle, &password, &pds_url).await?;
205
+
let published_uris = client.publish_events(&at_events).await?;
206
+
207
+
info!(
208
+
"Import completed: {} events published successfully",
209
+
published_uris.len()
210
+
);
211
+
212
+
Ok(())
213
+
}
214
+
215
+
#[instrument]
216
+
async fn test_at_protocol_connection() -> Result<()> {
217
+
info!("Testing AT Protocol connection...");
218
+
219
+
// Verify environment variables are set
220
+
let handle = std::env::var("ATP_HANDLE")
221
+
.map_err(|_| anyhow::anyhow!("ATP_HANDLE environment variable not set"))?;
222
+
let password = std::env::var("ATP_PASSWORD")
223
+
.map_err(|_| anyhow::anyhow!("ATP_PASSWORD environment variable not set"))?;
224
+
let pds_url = std::env::var("ATP_PDS")
225
+
.map_err(|_| anyhow::anyhow!("ATP_PDS environment variable not set"))?;
226
+
227
+
// Test connection by creating a session
228
+
let _client = pds::PdsClient::login(&handle, &password, &pds_url).await?;
229
+
230
+
info!("AT Protocol connection test successful!");
231
+
Ok(())
232
+
}
233
+
234
+
#[instrument]
235
+
async fn clear_dedup_cache() -> Result<()> {
236
+
let user_did = std::env::var("ATP_DID")
237
+
.map_err(|_| anyhow::anyhow!("ATP_DID environment variable not set"))?;
238
+
239
+
let mut cache = dedup::create_cache();
240
+
cache.clear_user_cache(&user_did)?;
241
+
cache.save_to_storage(&user_did).await?;
242
+
243
+
Ok(())
244
+
}
+321
RUST/src/dedup.rs
+321
RUST/src/dedup.rs
···
1
+
//! Event deduplication module
2
+
//!
3
+
//! This module handles deduplication of calendar events to prevent importing
4
+
//! the same event multiple times. It uses event content hashing and maintains
5
+
//! a cache of previously imported events.
6
+
7
+
use anyhow::Result;
8
+
use serde::{Deserialize, Serialize};
9
+
use std::collections::{HashMap, HashSet};
10
+
use std::hash::{Hash, Hasher};
11
+
use std::collections::hash_map::DefaultHasher;
12
+
use tracing::{debug, info, instrument};
13
+
14
+
use crate::errors::CalendarImportError;
15
+
use crate::import::ExternalEvent;
16
+
17
+
/// Event hash structure for deduplication
18
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
19
+
pub struct EventHash {
20
+
pub name: String,
21
+
pub starts_at: Option<String>,
22
+
pub location: Option<String>,
23
+
pub source: String,
24
+
}
25
+
26
+
/// Deduplication cache
27
+
#[derive(Debug, Default)]
28
+
pub struct DedupCache {
29
+
/// Set of event hashes that have been processed
30
+
processed_hashes: HashSet<u64>,
31
+
/// In-memory cache for current session
32
+
session_cache: HashMap<String, HashSet<u64>>,
33
+
}
34
+
35
+
impl DedupCache {
36
+
/// Create a new deduplication cache
37
+
pub fn new() -> Self {
38
+
Self::default()
39
+
}
40
+
41
+
/// Load cache from persistent storage (placeholder for now)
42
+
#[instrument]
43
+
pub async fn load_from_storage(&mut self, user_did: &str) -> Result<()> {
44
+
debug!("Loading deduplication cache for user: {}", user_did);
45
+
46
+
// TODO: Implement persistent storage (SQLite, Redis, or file-based)
47
+
// For now, we'll use in-memory storage
48
+
if !self.session_cache.contains_key(user_did) {
49
+
self.session_cache.insert(user_did.to_string(), HashSet::new());
50
+
}
51
+
52
+
Ok(())
53
+
}
54
+
55
+
/// Save cache to persistent storage (placeholder for now)
56
+
#[instrument]
57
+
pub async fn save_to_storage(&self, user_did: &str) -> Result<()> {
58
+
debug!("Saving deduplication cache for user: {}", user_did);
59
+
60
+
// TODO: Implement persistent storage
61
+
info!("Cache saved for user: {}", user_did);
62
+
63
+
Ok(())
64
+
}
65
+
66
+
/// Check if an event has already been processed
67
+
#[instrument(skip(self))]
68
+
pub fn is_duplicate(&self, event: &ExternalEvent, user_did: &str) -> Result<bool> {
69
+
let event_hash = self.generate_event_hash(event)?;
70
+
71
+
// Check session cache first
72
+
if let Some(user_hashes) = self.session_cache.get(user_did) {
73
+
if user_hashes.contains(&event_hash) {
74
+
debug!("Event found in session cache: {}", event.name);
75
+
return Ok(true);
76
+
}
77
+
}
78
+
79
+
// Check persistent cache
80
+
if self.processed_hashes.contains(&event_hash) {
81
+
debug!("Event found in persistent cache: {}", event.name);
82
+
return Ok(true);
83
+
}
84
+
85
+
Ok(false)
86
+
}
87
+
88
+
/// Mark an event as processed
89
+
#[instrument(skip(self))]
90
+
pub fn mark_as_processed(&mut self, event: &ExternalEvent, user_did: &str) -> Result<()> {
91
+
let event_hash = self.generate_event_hash(event)?;
92
+
93
+
// Add to persistent cache
94
+
self.processed_hashes.insert(event_hash);
95
+
96
+
// Add to session cache
97
+
self.session_cache
98
+
.entry(user_did.to_string())
99
+
.or_insert_with(HashSet::new)
100
+
.insert(event_hash);
101
+
102
+
debug!("Marked event as processed: {} (hash: {})", event.name, event_hash);
103
+
104
+
Ok(())
105
+
}
106
+
107
+
/// Generate a hash for an event based on its content
108
+
#[instrument(skip(self))]
109
+
fn generate_event_hash(&self, event: &ExternalEvent) -> Result<u64> {
110
+
let hash_data = EventHash {
111
+
name: event.name.clone(),
112
+
starts_at: event.starts_at.map(|dt| dt.to_rfc3339()),
113
+
location: event.location.clone(),
114
+
source: event.source.clone(),
115
+
};
116
+
117
+
let mut hasher = DefaultHasher::new();
118
+
hash_data.hash(&mut hasher);
119
+
let hash = hasher.finish();
120
+
121
+
debug!("Generated hash {} for event: {}", hash, event.name);
122
+
Ok(hash)
123
+
}
124
+
125
+
/// Filter out duplicate events from a list
126
+
#[instrument(skip(self))]
127
+
pub fn filter_duplicates(
128
+
&mut self,
129
+
events: Vec<ExternalEvent>,
130
+
user_did: &str,
131
+
) -> Result<Vec<ExternalEvent>> {
132
+
let mut unique_events = Vec::new();
133
+
let mut duplicates_found = 0;
134
+
135
+
for event in events {
136
+
if self.is_duplicate(&event, user_did)? {
137
+
duplicates_found += 1;
138
+
debug!("Skipping duplicate event: {}", event.name);
139
+
continue;
140
+
}
141
+
142
+
// Mark as processed
143
+
self.mark_as_processed(&event, user_did)?;
144
+
unique_events.push(event);
145
+
}
146
+
147
+
info!(
148
+
"Filtered {} duplicate events, {} unique events remaining",
149
+
duplicates_found,
150
+
unique_events.len()
151
+
);
152
+
153
+
Ok(unique_events)
154
+
}
155
+
156
+
/// Clear cache for a specific user
157
+
#[instrument(skip(self))]
158
+
pub fn clear_user_cache(&mut self, user_did: &str) -> Result<()> {
159
+
self.session_cache.remove(user_did);
160
+
info!("Cleared cache for user: {}", user_did);
161
+
Ok(())
162
+
}
163
+
164
+
/// Get cache statistics
165
+
pub fn get_stats(&self) -> DedupStats {
166
+
DedupStats {
167
+
total_hashes: self.processed_hashes.len(),
168
+
users_in_session: self.session_cache.len(),
169
+
session_hashes: self.session_cache.values().map(|s| s.len()).sum(),
170
+
}
171
+
}
172
+
}
173
+
174
+
/// Statistics about the deduplication cache
175
+
#[derive(Debug, Serialize, Deserialize)]
176
+
pub struct DedupStats {
177
+
pub total_hashes: usize,
178
+
pub users_in_session: usize,
179
+
pub session_hashes: usize,
180
+
}
181
+
182
+
/// Create a new deduplication cache instance
183
+
pub fn create_cache() -> DedupCache {
184
+
DedupCache::new()
185
+
}
186
+
187
+
/// Utility function to quickly check if events are likely duplicates
188
+
/// based on basic field comparison
189
+
pub fn are_events_similar(event1: &ExternalEvent, event2: &ExternalEvent) -> bool {
190
+
event1.name == event2.name
191
+
&& event1.starts_at == event2.starts_at
192
+
&& event1.location == event2.location
193
+
&& event1.source == event2.source
194
+
}
195
+
196
+
/// Main deduplication manager
197
+
#[derive(Debug)]
198
+
pub struct DeduplicationManager {
199
+
cache: DedupCache,
200
+
user_did: String,
201
+
}
202
+
203
+
impl DeduplicationManager {
204
+
/// Create a new deduplication manager
205
+
pub fn new() -> Result<Self> {
206
+
Ok(Self {
207
+
cache: DedupCache::new(),
208
+
user_did: String::new(),
209
+
})
210
+
}
211
+
212
+
/// Initialize with user DID
213
+
pub async fn init_for_user(&mut self, user_did: &str) -> Result<()> {
214
+
self.user_did = user_did.to_string();
215
+
self.cache.load_from_storage(user_did).await
216
+
}
217
+
218
+
/// Check if an event is a duplicate
219
+
pub fn is_duplicate(&self, event: &ExternalEvent) -> Result<bool> {
220
+
if self.user_did.is_empty() {
221
+
return Err(CalendarImportError::InternalError("DeduplicationManager not initialized with user DID".to_string()).into());
222
+
}
223
+
self.cache.is_duplicate(event, &self.user_did)
224
+
}
225
+
226
+
/// Mark an event as processed
227
+
pub fn mark_processed(&mut self, event: &ExternalEvent) -> Result<()> {
228
+
if self.user_did.is_empty() {
229
+
return Err(CalendarImportError::InternalError("DeduplicationManager not initialized with user DID".to_string()).into());
230
+
}
231
+
self.cache.mark_as_processed(event, &self.user_did)
232
+
}
233
+
234
+
/// Filter duplicate events from a list
235
+
pub fn filter_duplicates(&mut self, events: Vec<ExternalEvent>) -> Result<Vec<ExternalEvent>> {
236
+
if self.user_did.is_empty() {
237
+
return Err(CalendarImportError::InternalError("DeduplicationManager not initialized with user DID".to_string()).into());
238
+
}
239
+
self.cache.filter_duplicates(events, &self.user_did)
240
+
}
241
+
}
242
+
243
+
#[cfg(test)]
244
+
mod tests {
245
+
use super::*;
246
+
247
+
fn create_test_event(name: &str, source: &str) -> ExternalEvent {
248
+
let fixed_time = chrono::DateTime::parse_from_rfc3339("2024-01-01T12:00:00Z")
249
+
.unwrap()
250
+
.with_timezone(&chrono::Utc);
251
+
252
+
ExternalEvent {
253
+
id: "test-id".to_string(),
254
+
name: name.to_string(),
255
+
description: None,
256
+
starts_at: Some(fixed_time),
257
+
ends_at: None,
258
+
location: None,
259
+
attendees: vec![],
260
+
created_at: fixed_time,
261
+
updated_at: fixed_time,
262
+
source: source.to_string(),
263
+
source_url: None,
264
+
url: None,
265
+
is_all_day: false,
266
+
status: "confirmed".to_string(),
267
+
visibility: "public".to_string(),
268
+
metadata: serde_json::Value::Null,
269
+
}
270
+
}
271
+
272
+
#[tokio::test]
273
+
async fn test_dedup_cache() {
274
+
let mut cache = DedupCache::new();
275
+
let user_did = "did:plc:test123";
276
+
277
+
cache.load_from_storage(user_did).await.unwrap();
278
+
279
+
let event1 = create_test_event("Test Event", "google");
280
+
let event2 = create_test_event("Test Event", "google");
281
+
282
+
// First event should not be duplicate
283
+
assert!(!cache.is_duplicate(&event1, user_did).unwrap());
284
+
285
+
// Mark as processed
286
+
cache.mark_as_processed(&event1, user_did).unwrap();
287
+
288
+
// Second identical event should be duplicate
289
+
assert!(cache.is_duplicate(&event2, user_did).unwrap());
290
+
}
291
+
292
+
#[test]
293
+
fn test_event_similarity() {
294
+
let event1 = create_test_event("Test Event", "google");
295
+
let event2 = create_test_event("Test Event", "google");
296
+
let event3 = create_test_event("Different Event", "google");
297
+
298
+
assert!(are_events_similar(&event1, &event2));
299
+
assert!(!are_events_similar(&event1, &event3));
300
+
}
301
+
302
+
#[tokio::test]
303
+
async fn test_deduplication_manager() {
304
+
let mut manager = DeduplicationManager::new().unwrap();
305
+
let user_did = "did:plc:test456";
306
+
307
+
manager.init_for_user(user_did).await.unwrap();
308
+
309
+
let event1 = create_test_event("Test Event", "google");
310
+
let event2 = create_test_event("Test Event", "google");
311
+
312
+
// First event should not be duplicate
313
+
assert!(!manager.is_duplicate(&event1).unwrap());
314
+
315
+
// Mark as processed
316
+
manager.mark_processed(&event1).unwrap();
317
+
318
+
// Second identical event should be duplicate
319
+
assert!(manager.is_duplicate(&event2).unwrap());
320
+
}
321
+
}
+139
RUST/src/errors.rs
+139
RUST/src/errors.rs
···
1
+
//! Centralized error definitions for atproto-calendar-import
2
+
//!
3
+
//! All errors follow the format: `error-atproto-calendar-<domain>-<code> <msg>: <details>`
4
+
5
+
use thiserror::Error;
6
+
7
+
#[derive(Debug, Error)]
8
+
pub enum CalendarImportError {
9
+
// Authentication errors (1xx)
10
+
#[error("error-atproto-calendar-auth-101 OAuth2 token retrieval failed: {0}")]
11
+
OAuth2TokenFailed(String),
12
+
13
+
#[error("error-atproto-calendar-auth-102 Invalid access token: {0}")]
14
+
InvalidAccessToken(String),
15
+
16
+
#[error("error-atproto-calendar-auth-103 Token refresh failed: {0}")]
17
+
TokenRefreshFailed(String),
18
+
19
+
#[error("error-atproto-calendar-auth-104 AT Protocol authentication failed: {0}")]
20
+
AtProtocolAuthFailed(String),
21
+
22
+
// Import errors (2xx)
23
+
#[error("error-atproto-calendar-import-201 Google Calendar API request failed: {0}")]
24
+
GoogleApiError(String),
25
+
26
+
#[error("error-atproto-calendar-import-202 Outlook Graph API request failed: {0}")]
27
+
OutlookApiError(String),
28
+
29
+
#[error("error-atproto-calendar-import-203 Failed to parse calendar event: {0}")]
30
+
EventParseError(String),
31
+
32
+
#[error("error-atproto-calendar-import-204 Network request failed: {0}")]
33
+
NetworkError(String),
34
+
35
+
#[error("error-atproto-calendar-import-205 Rate limit exceeded for provider: {0}")]
36
+
RateLimitExceeded(String),
37
+
38
+
// Transform errors (3xx)
39
+
#[error("error-atproto-calendar-transform-301 Invalid datetime format: {0}")]
40
+
InvalidDateTimeFormat(String),
41
+
42
+
#[error("error-atproto-calendar-transform-302 Missing required field: {0}")]
43
+
MissingRequiredField(String),
44
+
45
+
#[error("error-atproto-calendar-transform-303 Field validation failed: {field} - {reason}")]
46
+
FieldValidationError { field: String, reason: String },
47
+
48
+
#[error("error-atproto-calendar-transform-304 Event serialization failed: {0}")]
49
+
SerializationError(String),
50
+
51
+
// PDS errors (4xx)
52
+
#[error("error-atproto-calendar-pds-401 Failed to connect to PDS: {0}")]
53
+
PdsConnectionError(String),
54
+
55
+
#[error("error-atproto-calendar-pds-402 Failed to write record to repository: {0}")]
56
+
PdsWriteError(String),
57
+
58
+
#[error("error-atproto-calendar-pds-403 Repository not found: {0}")]
59
+
RepositoryNotFound(String),
60
+
61
+
#[error("error-atproto-calendar-pds-404 Invalid collection name: {0}")]
62
+
InvalidCollection(String),
63
+
64
+
#[error("error-atproto-calendar-pds-405 Record validation failed: {0}")]
65
+
RecordValidationError(String),
66
+
67
+
// Deduplication errors (5xx)
68
+
#[error("error-atproto-calendar-dedup-501 Failed to access cache: {0}")]
69
+
CacheAccessError(String),
70
+
71
+
#[error("error-atproto-calendar-dedup-502 Hash generation failed: {0}")]
72
+
HashGenerationError(String),
73
+
74
+
#[error("error-atproto-calendar-dedup-503 Cache corruption detected: {0}")]
75
+
CacheCorruption(String),
76
+
77
+
// Configuration errors (6xx)
78
+
#[error("error-atproto-calendar-config-601 Missing environment variable: {0}")]
79
+
MissingEnvironmentVariable(String),
80
+
81
+
#[error("error-atproto-calendar-config-602 Invalid configuration value: {key} - {reason}")]
82
+
InvalidConfigValue { key: String, reason: String },
83
+
84
+
#[error("error-atproto-calendar-config-603 Configuration file not found: {0}")]
85
+
ConfigFileNotFound(String),
86
+
87
+
// General errors (9xx)
88
+
#[error("error-atproto-calendar-general-901 Internal error: {0}")]
89
+
InternalError(String),
90
+
91
+
#[error("error-atproto-calendar-general-902 Operation timeout: {0}")]
92
+
TimeoutError(String),
93
+
94
+
#[error("error-atproto-calendar-general-903 Feature not implemented: {0}")]
95
+
NotImplemented(String),
96
+
}
97
+
98
+
impl From<reqwest::Error> for CalendarImportError {
99
+
fn from(error: reqwest::Error) -> Self {
100
+
CalendarImportError::NetworkError(error.to_string())
101
+
}
102
+
}
103
+
104
+
impl From<serde_json::Error> for CalendarImportError {
105
+
fn from(error: serde_json::Error) -> Self {
106
+
CalendarImportError::SerializationError(error.to_string())
107
+
}
108
+
}
109
+
110
+
impl From<chrono::ParseError> for CalendarImportError {
111
+
fn from(error: chrono::ParseError) -> Self {
112
+
CalendarImportError::InvalidDateTimeFormat(error.to_string())
113
+
}
114
+
}
115
+
116
+
impl From<std::env::VarError> for CalendarImportError {
117
+
fn from(error: std::env::VarError) -> Self {
118
+
match error {
119
+
std::env::VarError::NotPresent => {
120
+
CalendarImportError::MissingEnvironmentVariable("Variable not set".to_string())
121
+
}
122
+
std::env::VarError::NotUnicode(_) => {
123
+
CalendarImportError::InvalidConfigValue {
124
+
key: "environment_variable".to_string(),
125
+
reason: "Contains invalid Unicode".to_string(),
126
+
}
127
+
}
128
+
}
129
+
}
130
+
}
131
+
132
+
impl From<anyhow::Error> for CalendarImportError {
133
+
fn from(error: anyhow::Error) -> Self {
134
+
CalendarImportError::InternalError(error.to_string())
135
+
}
136
+
}
137
+
138
+
/// Result type alias for this crate
139
+
pub type Result<T> = std::result::Result<T, CalendarImportError>;
+344
RUST/src/import/google.rs
+344
RUST/src/import/google.rs
···
1
+
//! Google Calendar API integration
2
+
//!
3
+
//! This module handles importing events from Google Calendar using the
4
+
//! Google Calendar API v3.
5
+
6
+
use anyhow::Result;
7
+
use reqwest::Client;
8
+
use serde::Deserialize;
9
+
use tracing::{debug, info, instrument, warn};
10
+
11
+
use crate::errors::CalendarImportError;
12
+
use crate::import::ExternalEvent;
13
+
14
+
const GOOGLE_CALENDAR_API_BASE: &str = "https://www.googleapis.com/calendar/v3";
15
+
16
+
/// Google Calendar API event structure
17
+
#[derive(Debug, Deserialize)]
18
+
struct GoogleCalendarEvent {
19
+
id: String,
20
+
summary: Option<String>,
21
+
description: Option<String>,
22
+
location: Option<String>,
23
+
#[serde(rename = "htmlLink")]
24
+
html_link: Option<String>,
25
+
start: Option<GoogleDateTime>,
26
+
end: Option<GoogleDateTime>,
27
+
attendees: Option<Vec<GoogleAttendee>>,
28
+
organizer: Option<GoogleOrganizer>,
29
+
status: Option<String>,
30
+
recurrence: Option<Vec<String>>,
31
+
}
32
+
33
+
#[derive(Debug, Deserialize)]
34
+
struct GoogleDateTime {
35
+
#[serde(rename = "dateTime")]
36
+
date_time: Option<String>,
37
+
date: Option<String>,
38
+
#[serde(rename = "timeZone")]
39
+
time_zone: Option<String>,
40
+
}
41
+
42
+
#[derive(Debug, Deserialize)]
43
+
struct GoogleAttendee {
44
+
email: Option<String>,
45
+
#[serde(rename = "displayName")]
46
+
display_name: Option<String>,
47
+
}
48
+
49
+
#[derive(Debug, Deserialize)]
50
+
struct GoogleOrganizer {
51
+
email: Option<String>,
52
+
#[serde(rename = "displayName")]
53
+
display_name: Option<String>,
54
+
}
55
+
56
+
#[derive(Debug, Deserialize)]
57
+
struct GoogleCalendarResponse {
58
+
items: Vec<GoogleCalendarEvent>,
59
+
#[serde(rename = "nextPageToken")]
60
+
next_page_token: Option<String>,
61
+
}
62
+
63
+
/// Google Calendar importer
64
+
#[derive(Debug, Clone)]
65
+
pub struct GoogleImporter {
66
+
client: Client,
67
+
access_token: String,
68
+
days_back: i64,
69
+
days_ahead: i64,
70
+
}
71
+
72
+
impl GoogleImporter {
73
+
/// Create a new Google Calendar importer
74
+
pub async fn new(
75
+
_auth_manager: crate::auth::AuthManager,
76
+
days_back: i64,
77
+
days_ahead: i64,
78
+
) -> Result<Self> {
79
+
// For now, we'll need to manually obtain the access token
80
+
// In a real implementation, this would use the auth_manager
81
+
let access_token = std::env::var("GOOGLE_ACCESS_TOKEN")
82
+
.map_err(|_| CalendarImportError::MissingEnvironmentVariable("GOOGLE_ACCESS_TOKEN".to_string()))?;
83
+
84
+
Ok(GoogleImporter {
85
+
client: Client::new(),
86
+
access_token,
87
+
days_back,
88
+
days_ahead,
89
+
})
90
+
}
91
+
92
+
/// Fetch events from Google Calendar
93
+
pub async fn fetch_events(&self) -> Result<Vec<ExternalEvent>> {
94
+
let now = chrono::Utc::now();
95
+
let time_min = (now - chrono::Duration::days(self.days_back)).to_rfc3339();
96
+
let time_max = (now + chrono::Duration::days(self.days_ahead)).to_rfc3339();
97
+
98
+
let mut url = format!("{}/calendars/primary/events", GOOGLE_CALENDAR_API_BASE);
99
+
url.push_str(&format!(
100
+
"?timeMin={}&timeMax={}&singleEvents=true&orderBy=startTime",
101
+
urlencoding::encode(&time_min),
102
+
urlencoding::encode(&time_max)
103
+
));
104
+
105
+
let response = self
106
+
.client
107
+
.get(&url)
108
+
.bearer_auth(&self.access_token)
109
+
.send()
110
+
.await?;
111
+
112
+
if !response.status().is_success() {
113
+
return Err(CalendarImportError::GoogleApiError(
114
+
format!("HTTP {}: {}", response.status(), response.text().await?)
115
+
).into());
116
+
}
117
+
118
+
let calendar_response: GoogleCalendarResponse = response.json().await?;
119
+
let mut external_events = Vec::new();
120
+
121
+
for google_event in calendar_response.items {
122
+
if let Ok(external_event) = convert_google_event(google_event) {
123
+
external_events.push(external_event);
124
+
}
125
+
}
126
+
127
+
info!("Fetched {} events from Google Calendar", external_events.len());
128
+
Ok(external_events)
129
+
}
130
+
}
131
+
132
+
/// Import events from Google Calendar
133
+
#[instrument(skip(access_token))]
134
+
pub async fn import_events(access_token: &str) -> Result<Vec<ExternalEvent>> {
135
+
info!("Starting Google Calendar import");
136
+
137
+
let client = Client::new();
138
+
let mut all_events = Vec::new();
139
+
let mut page_token: Option<String> = None;
140
+
141
+
// Fetch events with pagination
142
+
loop {
143
+
let events_page = fetch_events_page(&client, access_token, page_token.as_deref()).await?;
144
+
145
+
let events_count = events_page.items.len();
146
+
debug!("Retrieved {} events from current page", events_count);
147
+
148
+
// Convert Google events to our common format
149
+
for google_event in events_page.items {
150
+
match convert_google_event(google_event) {
151
+
Ok(external_event) => all_events.push(external_event),
152
+
Err(e) => {
153
+
warn!("Failed to convert Google Calendar event: {}", e);
154
+
continue;
155
+
}
156
+
}
157
+
}
158
+
159
+
// Check if there are more pages
160
+
if let Some(token) = events_page.next_page_token {
161
+
page_token = Some(token);
162
+
} else {
163
+
break;
164
+
}
165
+
}
166
+
167
+
info!("Successfully imported {} events from Google Calendar", all_events.len());
168
+
Ok(all_events)
169
+
}
170
+
171
+
/// Fetch a single page of events from Google Calendar API
172
+
#[instrument(skip(client, access_token))]
173
+
async fn fetch_events_page(
174
+
client: &Client,
175
+
access_token: &str,
176
+
page_token: Option<&str>,
177
+
) -> Result<GoogleCalendarResponse> {
178
+
let url = format!("{}/calendars/primary/events", GOOGLE_CALENDAR_API_BASE);
179
+
180
+
let mut query_params = vec![
181
+
("maxResults", "250"),
182
+
("singleEvents", "true"),
183
+
("orderBy", "startTime"),
184
+
];
185
+
186
+
if let Some(token) = page_token {
187
+
query_params.push(("pageToken", token));
188
+
}
189
+
190
+
// Add time range to get recent and upcoming events (past 30 days, next 90 days)
191
+
let now = chrono::Utc::now();
192
+
let time_min = (now - chrono::Duration::days(30)).to_rfc3339();
193
+
let time_max = (now + chrono::Duration::days(90)).to_rfc3339();
194
+
195
+
query_params.push(("timeMin", &time_min));
196
+
query_params.push(("timeMax", &time_max));
197
+
198
+
debug!("Fetching events from Google Calendar API");
199
+
200
+
let response = client
201
+
.get(&url)
202
+
.header("Authorization", format!("Bearer {}", access_token))
203
+
.query(&query_params)
204
+
.send()
205
+
.await?;
206
+
207
+
if !response.status().is_success() {
208
+
let status = response.status();
209
+
let error_text = response.text().await.unwrap_or_default();
210
+
211
+
return Err(CalendarImportError::GoogleApiError(
212
+
format!("HTTP {}: {}", status, error_text)
213
+
).into());
214
+
}
215
+
216
+
let calendar_response: GoogleCalendarResponse = response.json().await?;
217
+
Ok(calendar_response)
218
+
}
219
+
220
+
/// Convert a Google Calendar event to our common ExternalEvent format
221
+
fn convert_google_event(google_event: GoogleCalendarEvent) -> Result<ExternalEvent> {
222
+
let name = google_event.summary.unwrap_or_else(|| "Untitled Event".to_string());
223
+
224
+
let mut external_event = ExternalEvent::new(google_event.id, name);
225
+
226
+
if let Some(description) = google_event.description {
227
+
external_event = external_event.with_description(description);
228
+
}
229
+
230
+
if let Some(location) = google_event.location {
231
+
external_event = external_event.with_location(location);
232
+
}
233
+
234
+
if let Some(url) = google_event.html_link {
235
+
external_event = external_event.with_url(url);
236
+
}
237
+
238
+
if let Some(ref status) = google_event.status {
239
+
external_event = external_event.with_status(status.clone());
240
+
}
241
+
242
+
// Convert start and end times
243
+
if let Some(start) = google_event.start {
244
+
if let Some(start_time) = extract_datetime_utc(&start) {
245
+
external_event = external_event.with_start_time(start_time);
246
+
247
+
// Check if it's an all-day event
248
+
if start.date.is_some() && start.date_time.is_none() {
249
+
external_event = external_event.with_all_day(true);
250
+
}
251
+
}
252
+
}
253
+
254
+
if let Some(end) = google_event.end {
255
+
if let Some(end_time) = extract_datetime_utc(&end) {
256
+
external_event = external_event.with_end_time(end_time);
257
+
}
258
+
}
259
+
260
+
// Extract attendees
261
+
if let Some(attendees) = google_event.attendees {
262
+
let attendee_emails: Vec<String> = attendees
263
+
.into_iter()
264
+
.filter_map(|attendee| attendee.email)
265
+
.collect();
266
+
external_event = external_event.with_attendees(attendee_emails);
267
+
}
268
+
269
+
// Extract organizer
270
+
if let Some(organizer) = google_event.organizer {
271
+
let organizer_info = organizer.display_name
272
+
.or(organizer.email)
273
+
.unwrap_or_else(|| "Unknown".to_string());
274
+
external_event = external_event.with_organizer(organizer_info);
275
+
}
276
+
277
+
// Check if it's a recurring event
278
+
if google_event.recurrence.is_some() {
279
+
external_event = external_event.with_recurring(true);
280
+
}
281
+
282
+
// Store Google-specific metadata
283
+
let metadata = serde_json::json!({
284
+
"provider": "google",
285
+
"recurrence": google_event.recurrence,
286
+
"status": google_event.status,
287
+
});
288
+
external_event = external_event.with_metadata(metadata);
289
+
290
+
Ok(external_event)
291
+
}
292
+
293
+
/// Extract datetime string from Google's datetime structure
294
+
fn extract_datetime(google_datetime: &GoogleDateTime) -> Option<String> {
295
+
// Prefer dateTime over date
296
+
if let Some(date_time) = &google_datetime.date_time {
297
+
Some(date_time.clone())
298
+
} else if let Some(date) = &google_datetime.date {
299
+
// Convert date to datetime format
300
+
Some(format!("{}T00:00:00Z", date))
301
+
} else {
302
+
None
303
+
}
304
+
}
305
+
306
+
/// Convert Google's datetime structure to DateTime<Utc>
307
+
fn extract_datetime_utc(google_datetime: &GoogleDateTime) -> Option<chrono::DateTime<chrono::Utc>> {
308
+
extract_datetime(google_datetime)
309
+
.and_then(|dt_str| chrono::DateTime::parse_from_rfc3339(&dt_str).ok())
310
+
.map(|dt| dt.with_timezone(&chrono::Utc))
311
+
}
312
+
313
+
#[cfg(test)]
314
+
mod tests {
315
+
use super::*;
316
+
317
+
#[test]
318
+
fn test_extract_datetime_with_datetime() {
319
+
let google_dt = GoogleDateTime {
320
+
date_time: Some("2025-06-01T10:00:00Z".to_string()),
321
+
date: None,
322
+
time_zone: Some("UTC".to_string()),
323
+
};
324
+
325
+
assert_eq!(
326
+
extract_datetime(&google_dt),
327
+
Some("2025-06-01T10:00:00Z".to_string())
328
+
);
329
+
}
330
+
331
+
#[test]
332
+
fn test_extract_datetime_with_date_only() {
333
+
let google_dt = GoogleDateTime {
334
+
date_time: None,
335
+
date: Some("2025-06-01".to_string()),
336
+
time_zone: None,
337
+
};
338
+
339
+
assert_eq!(
340
+
extract_datetime(&google_dt),
341
+
Some("2025-06-01T00:00:00Z".to_string())
342
+
);
343
+
}
344
+
}
+152
RUST/src/import/mod.rs
+152
RUST/src/import/mod.rs
···
1
+
//! Import module for external calendar providers
2
+
//!
3
+
//! This module handles importing calendar events from various providers
4
+
//! including Google Calendar and Microsoft Outlook.
5
+
6
+
pub mod google;
7
+
pub mod outlook;
8
+
9
+
use serde::{Deserialize, Serialize};
10
+
11
+
/// Generic event structure that can be transformed from any provider
12
+
#[derive(Debug, Clone, Serialize, Deserialize)]
13
+
pub struct ExternalEvent {
14
+
pub id: String,
15
+
pub name: String,
16
+
pub description: Option<String>,
17
+
pub starts_at: Option<chrono::DateTime<chrono::Utc>>,
18
+
pub ends_at: Option<chrono::DateTime<chrono::Utc>>,
19
+
pub location: Option<String>,
20
+
pub attendees: Vec<String>,
21
+
pub created_at: chrono::DateTime<chrono::Utc>,
22
+
pub updated_at: chrono::DateTime<chrono::Utc>,
23
+
pub source: String, // "google", "outlook", etc.
24
+
pub source_url: Option<String>,
25
+
pub url: Option<String>, // Additional URL field for compatibility
26
+
pub is_all_day: bool,
27
+
pub status: String, // "confirmed", "tentative", "cancelled"
28
+
pub visibility: String, // "public", "private"
29
+
pub metadata: serde_json::Value, // Additional metadata
30
+
}
31
+
32
+
/// Type alias for calendar events (same as ExternalEvent for now)
33
+
pub type CalendarEvent = ExternalEvent;
34
+
35
+
impl ExternalEvent {
36
+
/// Create a new external event
37
+
pub fn new(id: String, name: String) -> Self {
38
+
Self {
39
+
id,
40
+
name,
41
+
description: None,
42
+
starts_at: None,
43
+
ends_at: None,
44
+
location: None,
45
+
attendees: Vec::new(),
46
+
created_at: chrono::Utc::now(),
47
+
updated_at: chrono::Utc::now(),
48
+
source: "unknown".to_string(),
49
+
source_url: None,
50
+
url: None,
51
+
is_all_day: false,
52
+
status: "confirmed".to_string(),
53
+
visibility: "public".to_string(),
54
+
metadata: serde_json::Value::Null,
55
+
}
56
+
}
57
+
58
+
/// Get the title/name of the event
59
+
pub fn title(&self) -> &str {
60
+
&self.name
61
+
}
62
+
63
+
/// Builder method to set description
64
+
pub fn with_description(mut self, description: String) -> Self {
65
+
self.description = Some(description);
66
+
self
67
+
}
68
+
69
+
/// Builder method to set location
70
+
pub fn with_location(mut self, location: String) -> Self {
71
+
self.location = Some(location);
72
+
self
73
+
}
74
+
75
+
/// Builder method to set URL
76
+
pub fn with_url(mut self, url: String) -> Self {
77
+
self.url = Some(url);
78
+
self
79
+
}
80
+
81
+
/// Builder method to set status
82
+
pub fn with_status(mut self, status: String) -> Self {
83
+
self.status = status;
84
+
self
85
+
}
86
+
87
+
/// Builder method to set start time
88
+
pub fn with_start_time(mut self, starts_at: chrono::DateTime<chrono::Utc>) -> Self {
89
+
self.starts_at = Some(starts_at);
90
+
self
91
+
}
92
+
93
+
/// Builder method to set end time
94
+
pub fn with_end_time(mut self, ends_at: chrono::DateTime<chrono::Utc>) -> Self {
95
+
self.ends_at = Some(ends_at);
96
+
self
97
+
}
98
+
99
+
/// Builder method to set all day flag
100
+
pub fn with_all_day(mut self, is_all_day: bool) -> Self {
101
+
self.is_all_day = is_all_day;
102
+
self
103
+
}
104
+
105
+
/// Builder method to set attendees
106
+
pub fn with_attendees(mut self, attendees: Vec<String>) -> Self {
107
+
self.attendees = attendees;
108
+
self
109
+
}
110
+
111
+
/// Builder method to set organizer
112
+
pub fn with_organizer(mut self, organizer: String) -> Self {
113
+
// Store organizer info in metadata
114
+
if let Ok(mut metadata) = serde_json::from_value::<serde_json::Map<String, serde_json::Value>>(self.metadata.clone()) {
115
+
metadata.insert("organizer".to_string(), serde_json::Value::String(organizer));
116
+
self.metadata = serde_json::Value::Object(metadata);
117
+
}
118
+
self
119
+
}
120
+
121
+
/// Builder method to set recurring flag
122
+
pub fn with_recurring(mut self, recurring: bool) -> Self {
123
+
// Store recurring info in metadata
124
+
if let Ok(mut metadata) = serde_json::from_value::<serde_json::Map<String, serde_json::Value>>(self.metadata.clone()) {
125
+
metadata.insert("recurring".to_string(), serde_json::Value::Bool(recurring));
126
+
self.metadata = serde_json::Value::Object(metadata);
127
+
}
128
+
self
129
+
}
130
+
131
+
/// Builder method to set metadata
132
+
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
133
+
self.metadata = metadata;
134
+
self
135
+
}
136
+
}
137
+
138
+
/// Supported calendar providers
139
+
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
140
+
pub enum CalendarProvider {
141
+
Google,
142
+
Outlook,
143
+
}
144
+
145
+
impl CalendarProvider {
146
+
pub fn as_str(&self) -> &'static str {
147
+
match self {
148
+
CalendarProvider::Google => "google",
149
+
CalendarProvider::Outlook => "outlook",
150
+
}
151
+
}
152
+
}
+465
RUST/src/import/outlook.rs
+465
RUST/src/import/outlook.rs
···
1
+
//! Microsoft Outlook Calendar integration
2
+
//!
3
+
//! This module handles importing events from Microsoft Outlook using the
4
+
//! Microsoft Graph API.
5
+
6
+
use anyhow::Result;
7
+
use reqwest::Client;
8
+
use serde::{Deserialize, Serialize};
9
+
use tracing::{debug, info, instrument, warn};
10
+
11
+
use crate::errors::CalendarImportError;
12
+
use crate::import::ExternalEvent;
13
+
14
+
const MICROSOFT_GRAPH_API_BASE: &str = "https://graph.microsoft.com/v1.0";
15
+
16
+
/// Microsoft Graph Calendar Event structure
17
+
#[derive(Debug, Deserialize)]
18
+
struct GraphCalendarEvent {
19
+
id: String,
20
+
subject: Option<String>,
21
+
body: Option<GraphItemBody>,
22
+
location: Option<GraphLocation>,
23
+
#[serde(rename = "webLink")]
24
+
web_link: Option<String>,
25
+
start: Option<GraphDateTime>,
26
+
end: Option<GraphDateTime>,
27
+
attendees: Option<Vec<GraphAttendee>>,
28
+
organizer: Option<GraphRecipient>,
29
+
#[serde(rename = "showAs")]
30
+
show_as: Option<String>,
31
+
#[serde(rename = "isAllDay")]
32
+
is_all_day: Option<bool>,
33
+
#[serde(rename = "isCancelled")]
34
+
is_cancelled: Option<bool>,
35
+
recurrence: Option<GraphRecurrence>,
36
+
}
37
+
38
+
#[derive(Debug, Deserialize)]
39
+
struct GraphItemBody {
40
+
content: Option<String>,
41
+
#[serde(rename = "contentType")]
42
+
content_type: Option<String>,
43
+
}
44
+
45
+
#[derive(Debug, Deserialize)]
46
+
struct GraphLocation {
47
+
#[serde(rename = "displayName")]
48
+
display_name: Option<String>,
49
+
address: Option<GraphAddress>,
50
+
}
51
+
52
+
#[derive(Debug, Deserialize)]
53
+
struct GraphAddress {
54
+
street: Option<String>,
55
+
city: Option<String>,
56
+
state: Option<String>,
57
+
#[serde(rename = "countryOrRegion")]
58
+
country_or_region: Option<String>,
59
+
#[serde(rename = "postalCode")]
60
+
postal_code: Option<String>,
61
+
}
62
+
63
+
#[derive(Debug, Deserialize)]
64
+
struct GraphDateTime {
65
+
#[serde(rename = "dateTime")]
66
+
date_time: String,
67
+
#[serde(rename = "timeZone")]
68
+
time_zone: String,
69
+
}
70
+
71
+
#[derive(Debug, Deserialize)]
72
+
struct GraphAttendee {
73
+
#[serde(rename = "emailAddress")]
74
+
email_address: Option<GraphEmailAddress>,
75
+
status: Option<GraphResponseStatus>,
76
+
}
77
+
78
+
#[derive(Debug, Deserialize)]
79
+
struct GraphEmailAddress {
80
+
address: Option<String>,
81
+
name: Option<String>,
82
+
}
83
+
84
+
#[derive(Debug, Deserialize)]
85
+
struct GraphResponseStatus {
86
+
response: Option<String>,
87
+
time: Option<String>,
88
+
}
89
+
90
+
#[derive(Debug, Deserialize)]
91
+
struct GraphRecipient {
92
+
#[serde(rename = "emailAddress")]
93
+
email_address: Option<GraphEmailAddress>,
94
+
}
95
+
96
+
#[derive(Debug, Deserialize, Serialize)]
97
+
struct GraphRecurrence {
98
+
pattern: Option<GraphRecurrencePattern>,
99
+
range: Option<GraphRecurrenceRange>,
100
+
}
101
+
102
+
#[derive(Debug, Deserialize, Serialize)]
103
+
struct GraphRecurrencePattern {
104
+
#[serde(rename = "type")]
105
+
pattern_type: Option<String>,
106
+
interval: Option<i32>,
107
+
}
108
+
109
+
#[derive(Debug, Deserialize, Serialize)]
110
+
struct GraphRecurrenceRange {
111
+
#[serde(rename = "type")]
112
+
range_type: Option<String>,
113
+
#[serde(rename = "startDate")]
114
+
start_date: Option<String>,
115
+
#[serde(rename = "endDate")]
116
+
end_date: Option<String>,
117
+
}
118
+
119
+
#[derive(Debug, Deserialize)]
120
+
struct GraphCalendarResponse {
121
+
value: Vec<GraphCalendarEvent>,
122
+
#[serde(rename = "@odata.nextLink")]
123
+
next_link: Option<String>,
124
+
}
125
+
126
+
/// Microsoft Outlook Calendar importer
127
+
#[derive(Debug, Clone)]
128
+
pub struct OutlookImporter {
129
+
client: Client,
130
+
access_token: String,
131
+
days_back: i64,
132
+
days_ahead: i64,
133
+
}
134
+
135
+
impl OutlookImporter {
136
+
/// Create a new Outlook Calendar importer
137
+
pub async fn new(
138
+
_auth_manager: crate::auth::AuthManager,
139
+
days_back: i64,
140
+
days_ahead: i64,
141
+
) -> Result<Self> {
142
+
// For now, we'll need to manually obtain the access token
143
+
// In a real implementation, this would use the auth_manager
144
+
let access_token = std::env::var("OUTLOOK_ACCESS_TOKEN")
145
+
.map_err(|_| CalendarImportError::MissingEnvironmentVariable("OUTLOOK_ACCESS_TOKEN".to_string()))?;
146
+
147
+
Ok(OutlookImporter {
148
+
client: Client::new(),
149
+
access_token,
150
+
days_back,
151
+
days_ahead,
152
+
})
153
+
}
154
+
155
+
/// Fetch events from Outlook Calendar
156
+
pub async fn fetch_events(&self) -> Result<Vec<ExternalEvent>> {
157
+
let now = chrono::Utc::now();
158
+
let start_time = (now - chrono::Duration::days(self.days_back)).to_rfc3339();
159
+
let end_time = (now + chrono::Duration::days(self.days_ahead)).to_rfc3339();
160
+
161
+
let url = format!(
162
+
"{}/me/calendar/events?$filter=start/dateTime ge '{}' and end/dateTime le '{}'&$orderby=start/dateTime",
163
+
MICROSOFT_GRAPH_API_BASE,
164
+
start_time,
165
+
end_time
166
+
);
167
+
168
+
let response = self
169
+
.client
170
+
.get(&url)
171
+
.bearer_auth(&self.access_token)
172
+
.send()
173
+
.await?;
174
+
175
+
if !response.status().is_success() {
176
+
return Err(CalendarImportError::OutlookApiError(
177
+
format!("HTTP {}: {}", response.status(), response.text().await?)
178
+
).into());
179
+
}
180
+
181
+
let calendar_response: GraphCalendarResponse = response.json().await?;
182
+
let mut external_events = Vec::new();
183
+
184
+
for graph_event in calendar_response.value {
185
+
if let Ok(external_event) = convert_graph_event(graph_event) {
186
+
external_events.push(external_event);
187
+
}
188
+
}
189
+
190
+
info!("Fetched {} events from Outlook Calendar", external_events.len());
191
+
Ok(external_events)
192
+
}
193
+
}
194
+
195
+
/// Import events from Microsoft Outlook Calendar
196
+
#[instrument(skip(access_token))]
197
+
pub async fn import_events(access_token: &str) -> Result<Vec<ExternalEvent>> {
198
+
info!("Starting Microsoft Outlook Calendar import");
199
+
200
+
let client = Client::new();
201
+
let mut all_events = Vec::new();
202
+
let mut next_url: Option<String> = None;
203
+
204
+
// Fetch events with pagination
205
+
loop {
206
+
let events_page = fetch_events_page(&client, access_token, next_url.as_deref()).await?;
207
+
208
+
let events_count = events_page.value.len();
209
+
debug!("Retrieved {} events from current page", events_count);
210
+
211
+
// Convert Graph events to our common format
212
+
for graph_event in events_page.value {
213
+
match convert_graph_event(graph_event) {
214
+
Ok(external_event) => all_events.push(external_event),
215
+
Err(e) => {
216
+
warn!("Failed to convert Outlook Calendar event: {}", e);
217
+
continue;
218
+
}
219
+
}
220
+
}
221
+
222
+
// Check if there are more pages
223
+
if let Some(next_link) = events_page.next_link {
224
+
next_url = Some(next_link);
225
+
} else {
226
+
break;
227
+
}
228
+
}
229
+
230
+
info!("Successfully imported {} events from Outlook Calendar", all_events.len());
231
+
Ok(all_events)
232
+
}
233
+
234
+
/// Fetch a single page of events from Microsoft Graph API
235
+
#[instrument(skip(client, access_token))]
236
+
async fn fetch_events_page(
237
+
client: &Client,
238
+
access_token: &str,
239
+
next_url: Option<&str>,
240
+
) -> Result<GraphCalendarResponse> {
241
+
let url = if let Some(next_url) = next_url {
242
+
next_url.to_string()
243
+
} else {
244
+
let base_url = format!("{}/me/events", MICROSOFT_GRAPH_API_BASE);
245
+
246
+
// Add time range and other parameters
247
+
let now = chrono::Utc::now();
248
+
let time_min = (now - chrono::Duration::days(30)).to_rfc3339();
249
+
let time_max = (now + chrono::Duration::days(90)).to_rfc3339();
250
+
251
+
format!(
252
+
"{}?$filter=start/dateTime ge '{}' and end/dateTime le '{}'&$orderby=start/dateTime&$top=250",
253
+
base_url, time_min, time_max
254
+
)
255
+
};
256
+
257
+
debug!("Fetching events from Microsoft Graph API");
258
+
259
+
let response = client
260
+
.get(&url)
261
+
.header("Authorization", format!("Bearer {}", access_token))
262
+
.header("Content-Type", "application/json")
263
+
.send()
264
+
.await?;
265
+
266
+
if !response.status().is_success() {
267
+
let status = response.status();
268
+
let error_text = response.text().await.unwrap_or_default();
269
+
270
+
return Err(CalendarImportError::OutlookApiError(
271
+
format!("HTTP {}: {}", status, error_text)
272
+
).into());
273
+
}
274
+
275
+
let calendar_response: GraphCalendarResponse = response.json().await?;
276
+
Ok(calendar_response)
277
+
}
278
+
279
+
/// Convert a Microsoft Graph event to our common ExternalEvent format
280
+
fn convert_graph_event(graph_event: GraphCalendarEvent) -> Result<ExternalEvent> {
281
+
let name = graph_event.subject.unwrap_or_else(|| "Untitled Event".to_string());
282
+
283
+
let mut external_event = ExternalEvent::new(graph_event.id, name);
284
+
285
+
// Extract description from body
286
+
if let Some(body) = graph_event.body {
287
+
if let Some(content) = body.content {
288
+
// Remove HTML tags if content type is HTML
289
+
let description = if body.content_type.as_deref() == Some("html") {
290
+
strip_html_tags(&content)
291
+
} else {
292
+
content
293
+
};
294
+
external_event = external_event.with_description(description);
295
+
}
296
+
}
297
+
298
+
// Extract location
299
+
if let Some(location) = graph_event.location {
300
+
let location_str = format_location(&location);
301
+
if !location_str.is_empty() {
302
+
external_event = external_event.with_location(location_str);
303
+
}
304
+
}
305
+
306
+
if let Some(url) = graph_event.web_link {
307
+
external_event = external_event.with_url(url);
308
+
}
309
+
310
+
// Convert start and end times
311
+
if let Some(start) = graph_event.start {
312
+
let start_time = convert_graph_datetime_utc(&start)?;
313
+
external_event = external_event.with_start_time(start_time);
314
+
}
315
+
316
+
if let Some(end) = graph_event.end {
317
+
let end_time = convert_graph_datetime_utc(&end)?;
318
+
external_event = external_event.with_end_time(end_time);
319
+
}
320
+
321
+
// Set all-day flag
322
+
if let Some(is_all_day) = graph_event.is_all_day {
323
+
external_event = external_event.with_all_day(is_all_day);
324
+
}
325
+
326
+
// Extract attendees
327
+
if let Some(attendees) = graph_event.attendees {
328
+
let attendee_emails: Vec<String> = attendees
329
+
.into_iter()
330
+
.filter_map(|attendee| {
331
+
attendee.email_address?.address
332
+
})
333
+
.collect();
334
+
external_event = external_event.with_attendees(attendee_emails);
335
+
}
336
+
337
+
// Extract organizer
338
+
if let Some(organizer) = graph_event.organizer {
339
+
if let Some(email_address) = organizer.email_address {
340
+
let organizer_info = email_address.name
341
+
.or(email_address.address)
342
+
.unwrap_or_else(|| "Unknown".to_string());
343
+
external_event = external_event.with_organizer(organizer_info);
344
+
}
345
+
}
346
+
347
+
// Set status based on various fields
348
+
let status = if graph_event.is_cancelled == Some(true) {
349
+
"cancelled".to_string()
350
+
} else {
351
+
graph_event.show_as.clone().unwrap_or_else(|| "busy".to_string())
352
+
};
353
+
external_event = external_event.with_status(status);
354
+
355
+
// Check if it's a recurring event
356
+
if graph_event.recurrence.is_some() {
357
+
external_event = external_event.with_recurring(true);
358
+
}
359
+
360
+
// Store Outlook-specific metadata
361
+
let metadata = serde_json::json!({
362
+
"provider": "outlook",
363
+
"recurrence": graph_event.recurrence,
364
+
"showAs": graph_event.show_as,
365
+
"isCancelled": graph_event.is_cancelled,
366
+
});
367
+
external_event = external_event.with_metadata(metadata);
368
+
369
+
Ok(external_event)
370
+
}
371
+
372
+
/// Convert Microsoft Graph DateTime to ISO 8601 string
373
+
fn convert_graph_datetime(graph_datetime: &GraphDateTime) -> Result<String> {
374
+
// Parse the datetime string and convert to UTC
375
+
let dt = chrono::DateTime::parse_from_rfc3339(&graph_datetime.date_time)
376
+
.map_err(|e| CalendarImportError::InvalidDateTimeFormat(e.to_string()))?;
377
+
378
+
Ok(dt.with_timezone(&chrono::Utc).to_rfc3339())
379
+
}
380
+
381
+
/// Convert Microsoft Graph DateTime to DateTime<Utc>
382
+
fn convert_graph_datetime_utc(graph_datetime: &GraphDateTime) -> Result<chrono::DateTime<chrono::Utc>> {
383
+
// Parse the datetime string and convert to UTC
384
+
let dt = chrono::DateTime::parse_from_rfc3339(&graph_datetime.date_time)
385
+
.map_err(|e| CalendarImportError::InvalidDateTimeFormat(e.to_string()))?;
386
+
387
+
Ok(dt.with_timezone(&chrono::Utc))
388
+
}
389
+
390
+
/// Format location from Graph location structure
391
+
fn format_location(location: &GraphLocation) -> String {
392
+
if let Some(display_name) = &location.display_name {
393
+
return display_name.clone();
394
+
}
395
+
396
+
if let Some(address) = &location.address {
397
+
let mut parts = Vec::new();
398
+
399
+
if let Some(street) = &address.street {
400
+
parts.push(street.clone());
401
+
}
402
+
if let Some(city) = &address.city {
403
+
parts.push(city.clone());
404
+
}
405
+
if let Some(state) = &address.state {
406
+
parts.push(state.clone());
407
+
}
408
+
if let Some(country) = &address.country_or_region {
409
+
parts.push(country.clone());
410
+
}
411
+
412
+
return parts.join(", ");
413
+
}
414
+
415
+
String::new()
416
+
}
417
+
418
+
/// Strip HTML tags from content (basic implementation)
419
+
fn strip_html_tags(html: &str) -> String {
420
+
// Simple regex-based HTML tag removal
421
+
// For production use, consider using a proper HTML parser
422
+
match regex::Regex::new(r"<[^>]*>") {
423
+
Ok(re) => re.replace_all(html, "").trim().to_string(),
424
+
Err(_) => {
425
+
// Fallback if regex fails
426
+
html.to_string()
427
+
}
428
+
}
429
+
}
430
+
431
+
#[cfg(test)]
432
+
mod tests {
433
+
use super::*;
434
+
435
+
#[test]
436
+
fn test_convert_graph_datetime() {
437
+
let graph_dt = GraphDateTime {
438
+
date_time: "2025-06-01T10:00:00Z".to_string(),
439
+
time_zone: "UTC".to_string(),
440
+
};
441
+
442
+
// This test might need adjustment based on the exact format
443
+
// that Microsoft Graph returns
444
+
let result = convert_graph_datetime(&graph_dt);
445
+
assert!(result.is_ok());
446
+
assert_eq!(result.unwrap(), "2025-06-01T10:00:00+00:00");
447
+
}
448
+
449
+
#[test]
450
+
fn test_format_location_with_display_name() {
451
+
let location = GraphLocation {
452
+
display_name: Some("Conference Room A".to_string()),
453
+
address: None,
454
+
};
455
+
456
+
assert_eq!(format_location(&location), "Conference Room A");
457
+
}
458
+
459
+
#[test]
460
+
fn test_strip_html_tags() {
461
+
let html = "<p>This is <b>bold</b> text with <a href='#'>link</a></p>";
462
+
let result = strip_html_tags(html);
463
+
assert_eq!(result, "This is bold text with link");
464
+
}
465
+
}
+299
RUST/src/lib.rs
+299
RUST/src/lib.rs
···
1
+
//! # atproto-calendar-import
2
+
//!
3
+
//! A Rust library for importing calendar events from external providers
4
+
//! (Google Calendar, Microsoft Outlook) into the AT Protocol ecosystem.
5
+
//!
6
+
//! ## Features
7
+
//!
8
+
//! - OAuth2 authentication for external calendar providers
9
+
//! - AT Protocol session management
10
+
//! - Event deduplication and transformation
11
+
//! - Support for Google Calendar and Microsoft Outlook
12
+
//! - Configurable import strategies
13
+
//!
14
+
//! ## Example
15
+
//!
16
+
//! ```rust,no_run
17
+
//! use atproto_calendar_import::{CalendarImporter, Config};
18
+
//!
19
+
//! #[tokio::main]
20
+
//! async fn main() -> anyhow::Result<()> {
21
+
//! let config = Config::from_env()?;
22
+
//! let mut importer = CalendarImporter::new(config).await?;
23
+
//!
24
+
//! // Import from Google Calendar
25
+
//! let count = importer.import_google_calendar().await?;
26
+
//! println!("Imported {} events", count);
27
+
//! Ok(())
28
+
//! }
29
+
//! ```
30
+
31
+
pub mod auth;
32
+
pub mod cli;
33
+
pub mod dedup;
34
+
pub mod errors;
35
+
pub mod import;
36
+
pub mod pds;
37
+
pub mod transform;
38
+
39
+
pub use errors::{CalendarImportError as Error, Result};
40
+
41
+
use serde::{Deserialize, Serialize};
42
+
use std::env;
43
+
44
+
/// Configuration for the calendar import system
45
+
#[derive(Debug, Clone, Serialize, Deserialize)]
46
+
pub struct Config {
47
+
/// AT Protocol PDS endpoint
48
+
pub pds_endpoint: String,
49
+
/// AT Protocol DID (user identifier)
50
+
pub atproto_did: String,
51
+
/// AT Protocol app password
52
+
pub atproto_password: String,
53
+
/// Google OAuth2 client ID
54
+
pub google_client_id: Option<String>,
55
+
/// Google OAuth2 client secret
56
+
pub google_client_secret: Option<String>,
57
+
/// Microsoft/Outlook OAuth2 client ID
58
+
pub outlook_client_id: Option<String>,
59
+
/// Microsoft/Outlook OAuth2 client secret
60
+
pub outlook_client_secret: Option<String>,
61
+
/// Enable event deduplication
62
+
pub enable_deduplication: bool,
63
+
/// Maximum number of events to import per run
64
+
pub max_events: Option<usize>,
65
+
/// Days to look back for events
66
+
pub days_back: u32,
67
+
/// Days to look ahead for events
68
+
pub days_ahead: u32,
69
+
}
70
+
71
+
impl Config {
72
+
/// Create configuration from environment variables
73
+
pub fn from_env() -> Result<Self> {
74
+
Ok(Config {
75
+
pds_endpoint: env::var("ATPROTO_PDS_ENDPOINT")
76
+
.unwrap_or_else(|_| "https://bsky.social".to_string()),
77
+
atproto_did: env::var("ATPROTO_DID")
78
+
.map_err(|_| Error::MissingEnvironmentVariable("ATPROTO_DID".into()))?,
79
+
atproto_password: env::var("ATPROTO_PASSWORD")
80
+
.map_err(|_| Error::MissingEnvironmentVariable("ATPROTO_PASSWORD".into()))?,
81
+
google_client_id: env::var("GOOGLE_CLIENT_ID").ok(),
82
+
google_client_secret: env::var("GOOGLE_CLIENT_SECRET").ok(),
83
+
outlook_client_id: env::var("OUTLOOK_CLIENT_ID").ok(),
84
+
outlook_client_secret: env::var("OUTLOOK_CLIENT_SECRET").ok(),
85
+
enable_deduplication: env::var("ENABLE_DEDUPLICATION")
86
+
.unwrap_or_else(|_| "true".to_string())
87
+
.parse()
88
+
.unwrap_or(true),
89
+
max_events: env::var("MAX_EVENTS")
90
+
.ok()
91
+
.and_then(|s| s.parse().ok()),
92
+
days_back: env::var("DAYS_BACK")
93
+
.unwrap_or_else(|_| "30".to_string())
94
+
.parse()
95
+
.unwrap_or(30),
96
+
days_ahead: env::var("DAYS_AHEAD")
97
+
.unwrap_or_else(|_| "90".to_string())
98
+
.parse()
99
+
.unwrap_or(90),
100
+
})
101
+
}
102
+
103
+
/// Validate the configuration
104
+
pub fn validate(&self) -> Result<()> {
105
+
if self.atproto_did.is_empty() {
106
+
return Err(Error::InvalidConfigValue {
107
+
key: "atproto_did".to_string(),
108
+
reason: "AT Protocol DID cannot be empty".to_string()
109
+
});
110
+
}
111
+
if self.atproto_password.is_empty() {
112
+
return Err(Error::InvalidConfigValue {
113
+
key: "atproto_password".to_string(),
114
+
reason: "AT Protocol password cannot be empty".to_string()
115
+
});
116
+
}
117
+
if self.google_client_id.is_none() && self.outlook_client_id.is_none() {
118
+
return Err(Error::InvalidConfigValue {
119
+
key: "providers".to_string(),
120
+
reason: "At least one provider (Google or Outlook) must be configured".to_string()
121
+
});
122
+
}
123
+
Ok(())
124
+
}
125
+
}
126
+
127
+
/// Main calendar importer struct
128
+
pub struct CalendarImporter {
129
+
config: Config,
130
+
auth_manager: auth::AuthManager,
131
+
pds_client: pds::PdsClient,
132
+
dedup_manager: Option<dedup::DeduplicationManager>,
133
+
}
134
+
135
+
impl CalendarImporter {
136
+
/// Create a new calendar importer with the given configuration
137
+
pub async fn new(config: Config) -> Result<Self> {
138
+
config.validate()?;
139
+
140
+
let auth_manager = auth::AuthManager::new(
141
+
config.google_client_id.clone(),
142
+
config.google_client_secret.clone(),
143
+
config.outlook_client_id.clone(),
144
+
config.outlook_client_secret.clone(),
145
+
)?;
146
+
147
+
let pds_client = pds::PdsClient::login(
148
+
&config.atproto_did,
149
+
&config.atproto_password,
150
+
&config.pds_endpoint,
151
+
).await?;
152
+
153
+
let dedup_manager = if config.enable_deduplication {
154
+
Some(dedup::DeduplicationManager::new()?)
155
+
} else {
156
+
None
157
+
};
158
+
159
+
Ok(CalendarImporter {
160
+
config,
161
+
auth_manager,
162
+
pds_client,
163
+
dedup_manager,
164
+
})
165
+
}
166
+
167
+
/// Import events from Google Calendar
168
+
pub async fn import_google_calendar(&mut self) -> Result<usize> {
169
+
if self.config.google_client_id.is_none() || self.config.google_client_secret.is_none() {
170
+
return Err(Error::InvalidConfigValue {
171
+
key: "google_oauth2".to_string(),
172
+
reason: "Google OAuth2 credentials not configured".to_string()
173
+
});
174
+
}
175
+
176
+
let google_importer = import::google::GoogleImporter::new(
177
+
self.auth_manager.clone(),
178
+
self.config.days_back as i64,
179
+
self.config.days_ahead as i64,
180
+
).await?;
181
+
182
+
let events = google_importer.fetch_events().await?;
183
+
self.process_events(events).await
184
+
}
185
+
186
+
/// Import events from Microsoft Outlook
187
+
pub async fn import_outlook_calendar(&mut self) -> Result<usize> {
188
+
if self.config.outlook_client_id.is_none() || self.config.outlook_client_secret.is_none() {
189
+
return Err(Error::InvalidConfigValue {
190
+
key: "outlook_oauth2".to_string(),
191
+
reason: "Outlook OAuth2 credentials not configured".to_string()
192
+
});
193
+
}
194
+
195
+
let outlook_importer = import::outlook::OutlookImporter::new(
196
+
self.auth_manager.clone(),
197
+
self.config.days_back as i64,
198
+
self.config.days_ahead as i64,
199
+
).await?;
200
+
201
+
let events = outlook_importer.fetch_events().await?;
202
+
self.process_events(events).await
203
+
}
204
+
205
+
/// Process and publish events to AT Protocol
206
+
async fn process_events(&mut self, events: Vec<import::CalendarEvent>) -> Result<usize> {
207
+
let mut processed_count = 0;
208
+
let max_events = self.config.max_events.unwrap_or(usize::MAX);
209
+
210
+
for event in events.into_iter().take(max_events) {
211
+
// Check for duplicates if deduplication is enabled
212
+
if let Some(ref dedup) = self.dedup_manager {
213
+
if dedup.is_duplicate(&event)? {
214
+
tracing::debug!("Skipping duplicate event: {}", event.name);
215
+
continue;
216
+
}
217
+
}
218
+
219
+
// Transform event to AT Protocol format
220
+
let at_event = transform::transform_to_at_protocol(&event)?;
221
+
222
+
// Publish to AT Protocol
223
+
self.pds_client.publish_event(&at_event).await?;
224
+
225
+
// Mark as processed for deduplication
226
+
if let Some(ref mut dedup) = self.dedup_manager {
227
+
dedup.mark_processed(&event)?;
228
+
}
229
+
230
+
processed_count += 1;
231
+
tracing::info!("Imported event: {}", event.name);
232
+
}
233
+
234
+
Ok(processed_count)
235
+
}
236
+
237
+
/// Import events from all configured providers
238
+
pub async fn import_all(&mut self) -> Result<usize> {
239
+
let mut total_imported = 0;
240
+
241
+
if self.config.google_client_id.is_some() && self.config.google_client_secret.is_some() {
242
+
match self.import_google_calendar().await {
243
+
Ok(count) => {
244
+
total_imported += count;
245
+
tracing::info!("Imported {} events from Google Calendar", count);
246
+
}
247
+
Err(e) => {
248
+
tracing::error!("Failed to import from Google Calendar: {}", e);
249
+
}
250
+
}
251
+
}
252
+
253
+
if self.config.outlook_client_id.is_some() && self.config.outlook_client_secret.is_some() {
254
+
match self.import_outlook_calendar().await {
255
+
Ok(count) => {
256
+
total_imported += count;
257
+
tracing::info!("Imported {} events from Outlook", count);
258
+
}
259
+
Err(e) => {
260
+
tracing::error!("Failed to import from Outlook: {}", e);
261
+
}
262
+
}
263
+
}
264
+
265
+
Ok(total_imported)
266
+
}
267
+
}
268
+
269
+
#[cfg(test)]
270
+
mod tests {
271
+
use super::*;
272
+
273
+
#[test]
274
+
fn test_config_validation() {
275
+
let mut config = Config {
276
+
pds_endpoint: "https://test.com".to_string(),
277
+
atproto_did: "did:plc:test".to_string(),
278
+
atproto_password: "password".to_string(),
279
+
google_client_id: Some("client_id".to_string()),
280
+
google_client_secret: Some("client_secret".to_string()),
281
+
outlook_client_id: None,
282
+
outlook_client_secret: None,
283
+
enable_deduplication: true,
284
+
max_events: None,
285
+
days_back: 30,
286
+
days_ahead: 90,
287
+
};
288
+
289
+
assert!(config.validate().is_ok());
290
+
291
+
config.atproto_did = "".to_string();
292
+
assert!(config.validate().is_err());
293
+
294
+
config.atproto_did = "did:plc:test".to_string();
295
+
config.google_client_id = None;
296
+
config.google_client_secret = None;
297
+
assert!(config.validate().is_err());
298
+
}
299
+
}
+40
RUST/src/main.rs
+40
RUST/src/main.rs
···
1
+
//! CLI entry point for atproto-calendar-import
2
+
//!
3
+
//! This binary provides a command-line interface for importing calendar events
4
+
//! from external providers (Google Calendar, Outlook, etc.) into AT Protocol.
5
+
6
+
mod cli;
7
+
mod errors;
8
+
mod auth;
9
+
mod pds;
10
+
mod import;
11
+
mod transform;
12
+
mod dedup;
13
+
14
+
use anyhow::Result;
15
+
use tracing::info;
16
+
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
17
+
18
+
#[tokio::main]
19
+
async fn main() -> Result<()> {
20
+
// Load environment variables from .env file
21
+
dotenvy::dotenv().ok();
22
+
23
+
// Initialize structured logging
24
+
tracing_subscriber::registry()
25
+
.with(
26
+
tracing_subscriber::EnvFilter::try_from_default_env()
27
+
.unwrap_or_else(|_| "atproto_calendar_import=info".into()),
28
+
)
29
+
.with(tracing_subscriber::fmt::layer())
30
+
.init();
31
+
32
+
info!("Starting atproto-calendar-import CLI");
33
+
34
+
// Parse CLI arguments and handle commands
35
+
let config = cli::parse();
36
+
cli::handle_command(config).await?;
37
+
38
+
info!("Import completed successfully");
39
+
Ok(())
40
+
}
+273
RUST/src/pds.rs
+273
RUST/src/pds.rs
···
1
+
//! AT Protocol PDS (Personal Data Server) integration
2
+
//!
3
+
//! This module handles publishing calendar events to AT Protocol repositories
4
+
//! using direct HTTP calls to the AT Protocol XRPC API.
5
+
6
+
use anyhow::Result;
7
+
use reqwest::Client;
8
+
use serde_json::json;
9
+
use tracing::{debug, info, instrument, warn};
10
+
11
+
use crate::auth::AtProtocolSession;
12
+
use crate::errors::CalendarImportError;
13
+
use crate::transform::AtEvent;
14
+
15
+
/// AT Protocol collection name for calendar events
16
+
const CALENDAR_EVENT_COLLECTION: &str = "community.lexicon.calendar.event";
17
+
18
+
/// PDS client for AT Protocol operations
19
+
#[derive(Debug, Clone)]
20
+
pub struct PdsClient {
21
+
pub client: Client,
22
+
pub session: AtProtocolSession,
23
+
}
24
+
25
+
impl PdsClient {
26
+
/// Create a new PDS client with an existing session
27
+
pub fn new(session: AtProtocolSession) -> Self {
28
+
Self {
29
+
client: Client::new(),
30
+
session,
31
+
}
32
+
}
33
+
34
+
/// Create a new PDS client by logging in
35
+
#[instrument]
36
+
pub async fn login(handle: &str, password: &str, pds_url: &str) -> Result<Self> {
37
+
info!("Creating AT Protocol session for handle: {}", handle);
38
+
39
+
let session = crate::auth::create_at_session(handle, password, pds_url).await?;
40
+
41
+
Ok(Self::new(session))
42
+
}
43
+
44
+
/// Publish a calendar event to the user's repository
45
+
#[instrument(skip(self))]
46
+
pub async fn publish_event(&self, event: &AtEvent) -> Result<String> {
47
+
debug!("Publishing event: {}", event.name);
48
+
49
+
let record_data = json!({
50
+
"$type": CALENDAR_EVENT_COLLECTION,
51
+
"createdAt": event.created_at,
52
+
"name": event.name,
53
+
"description": event.description,
54
+
"startsAt": event.starts_at,
55
+
"endsAt": event.ends_at,
56
+
"mode": event.mode,
57
+
"status": event.status,
58
+
"locations": event.locations,
59
+
"uris": event.uris,
60
+
});
61
+
62
+
let request_body = json!({
63
+
"repo": self.session.did,
64
+
"collection": CALENDAR_EVENT_COLLECTION,
65
+
"record": record_data
66
+
});
67
+
68
+
let response = self
69
+
.client
70
+
.post(&format!("{}/xrpc/com.atproto.repo.createRecord", self.session.pds_url))
71
+
.bearer_auth(&self.session.access_jwt)
72
+
.json(&request_body)
73
+
.send()
74
+
.await?;
75
+
76
+
if !response.status().is_success() {
77
+
let status = response.status();
78
+
let error_text = response.text().await?;
79
+
return Err(CalendarImportError::PdsWriteError(format!(
80
+
"HTTP {}: {}",
81
+
status,
82
+
error_text
83
+
)).into());
84
+
}
85
+
86
+
let response_data: serde_json::Value = response.json().await?;
87
+
let record_uri = response_data["uri"]
88
+
.as_str()
89
+
.ok_or_else(|| CalendarImportError::PdsWriteError("Missing record URI in response".to_string()))?;
90
+
91
+
info!("Successfully published event: {} -> {}", event.name, record_uri);
92
+
Ok(record_uri.to_string())
93
+
}
94
+
95
+
/// Batch publish multiple events
96
+
#[instrument(skip(self, events))]
97
+
pub async fn publish_events(&self, events: &[AtEvent]) -> Result<Vec<String>> {
98
+
info!("Publishing {} events to AT Protocol", events.len());
99
+
100
+
let mut published_uris = Vec::new();
101
+
let mut errors = Vec::new();
102
+
103
+
for (index, event) in events.iter().enumerate() {
104
+
match self.publish_event(event).await {
105
+
Ok(uri) => {
106
+
published_uris.push(uri);
107
+
debug!("Published event {}/{}: {}", index + 1, events.len(), event.name);
108
+
}
109
+
Err(e) => {
110
+
warn!("Failed to publish event {}: {}", event.name, e);
111
+
errors.push(format!("Event '{}': {}", event.name, e));
112
+
}
113
+
}
114
+
115
+
// Add a small delay to avoid overwhelming the PDS
116
+
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
117
+
}
118
+
119
+
if !errors.is_empty() {
120
+
warn!("Some events failed to publish: {}", errors.join(", "));
121
+
}
122
+
123
+
info!(
124
+
"Batch publish completed: {} succeeded, {} failed",
125
+
published_uris.len(),
126
+
errors.len()
127
+
);
128
+
129
+
Ok(published_uris)
130
+
}
131
+
132
+
/// List published calendar events from the repository
133
+
#[instrument(skip(self))]
134
+
pub async fn list_events(&self, limit: Option<u32>) -> Result<Vec<serde_json::Value>> {
135
+
debug!("Listing calendar events from repository");
136
+
137
+
let mut url = format!(
138
+
"{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}",
139
+
self.session.pds_url,
140
+
urlencoding::encode(&self.session.did),
141
+
urlencoding::encode(CALENDAR_EVENT_COLLECTION)
142
+
);
143
+
144
+
if let Some(limit) = limit {
145
+
url.push_str(&format!("&limit={}", limit));
146
+
}
147
+
148
+
let response = self
149
+
.client
150
+
.get(&url)
151
+
.bearer_auth(&self.session.access_jwt)
152
+
.send()
153
+
.await?;
154
+
155
+
if !response.status().is_success() {
156
+
let status = response.status();
157
+
let error_text = response.text().await?;
158
+
return Err(CalendarImportError::PdsConnectionError(format!(
159
+
"HTTP {}: {}",
160
+
status,
161
+
error_text
162
+
)).into());
163
+
}
164
+
165
+
let response_data: serde_json::Value = response.json().await?;
166
+
let records = response_data["records"]
167
+
.as_array()
168
+
.ok_or_else(|| CalendarImportError::PdsConnectionError("Invalid response format".to_string()))?;
169
+
170
+
info!("Found {} calendar events in repository", records.len());
171
+
Ok(records.clone())
172
+
}
173
+
174
+
/// Delete a calendar event by URI
175
+
#[instrument(skip(self))]
176
+
pub async fn delete_event(&self, record_uri: &str) -> Result<()> {
177
+
debug!("Deleting event: {}", record_uri);
178
+
179
+
// Extract collection and rkey from URI
180
+
let uri_parts: Vec<&str> = record_uri.split('/').collect();
181
+
if uri_parts.len() < 2 {
182
+
return Err(CalendarImportError::PdsWriteError(
183
+
"Invalid record URI format".to_string()
184
+
).into());
185
+
}
186
+
187
+
let rkey = uri_parts.last().unwrap();
188
+
189
+
let request_body = json!({
190
+
"repo": self.session.did,
191
+
"collection": CALENDAR_EVENT_COLLECTION,
192
+
"rkey": rkey
193
+
});
194
+
195
+
let response = self
196
+
.client
197
+
.post(&format!("{}/xrpc/com.atproto.repo.deleteRecord", self.session.pds_url))
198
+
.bearer_auth(&self.session.access_jwt)
199
+
.json(&request_body)
200
+
.send()
201
+
.await?;
202
+
203
+
if !response.status().is_success() {
204
+
let status = response.status();
205
+
let error_text = response.text().await?;
206
+
return Err(CalendarImportError::PdsWriteError(format!(
207
+
"HTTP {}: {}",
208
+
status,
209
+
error_text
210
+
)).into());
211
+
}
212
+
213
+
info!("Successfully deleted event: {}", record_uri);
214
+
Ok(())
215
+
}
216
+
217
+
/// Refresh the access token if needed
218
+
#[instrument(skip(self))]
219
+
pub async fn refresh_session(&mut self) -> Result<()> {
220
+
debug!("Refreshing AT Protocol session");
221
+
222
+
let request_body = json!({
223
+
"refreshJwt": self.session.refresh_jwt
224
+
});
225
+
226
+
let response = self
227
+
.client
228
+
.post(&format!("{}/xrpc/com.atproto.server.refreshSession", self.session.pds_url))
229
+
.json(&request_body)
230
+
.send()
231
+
.await?;
232
+
233
+
if !response.status().is_success() {
234
+
let status = response.status();
235
+
let error_text = response.text().await?;
236
+
return Err(CalendarImportError::AtProtocolAuthFailed(format!(
237
+
"HTTP {}: {}",
238
+
status,
239
+
error_text
240
+
)).into());
241
+
}
242
+
243
+
let session_data: serde_json::Value = response.json().await?;
244
+
245
+
// Update session tokens
246
+
self.session.access_jwt = session_data["accessJwt"]
247
+
.as_str()
248
+
.ok_or_else(|| CalendarImportError::AtProtocolAuthFailed("Missing accessJwt".to_string()))?
249
+
.to_string();
250
+
251
+
self.session.refresh_jwt = session_data["refreshJwt"]
252
+
.as_str()
253
+
.ok_or_else(|| CalendarImportError::AtProtocolAuthFailed("Missing refreshJwt".to_string()))?
254
+
.to_string();
255
+
256
+
info!("Successfully refreshed AT Protocol session");
257
+
Ok(())
258
+
}
259
+
}
260
+
261
+
/// Convenience function to publish a single event
262
+
#[instrument]
263
+
pub async fn publish_event(event: &AtEvent) -> Result<String> {
264
+
let handle = std::env::var("ATP_HANDLE")
265
+
.map_err(|_| CalendarImportError::MissingEnvironmentVariable("ATP_HANDLE".to_string()))?;
266
+
let password = std::env::var("ATP_PASSWORD")
267
+
.map_err(|_| CalendarImportError::MissingEnvironmentVariable("ATP_PASSWORD".to_string()))?;
268
+
let pds_url = std::env::var("ATP_PDS")
269
+
.map_err(|_| CalendarImportError::MissingEnvironmentVariable("ATP_PDS".to_string()))?;
270
+
271
+
let client = PdsClient::login(&handle, &password, &pds_url).await?;
272
+
client.publish_event(event).await
273
+
}
+339
RUST/src/transform/mod.rs
+339
RUST/src/transform/mod.rs
···
1
+
//! Transforms external event types into AT Protocol lexicon structures
2
+
//!
3
+
//! This module converts events from external calendar providers into the
4
+
//! AT Protocol calendar event format as defined by the lexicon schema.
5
+
6
+
use anyhow::Result;
7
+
use chrono::{DateTime, Utc};
8
+
use serde::{Deserialize, Serialize};
9
+
use tracing::{debug, instrument};
10
+
11
+
use crate::import::ExternalEvent;
12
+
use crate::errors::CalendarImportError;
13
+
14
+
/// AT Protocol calendar event structure based on the lexicon schema
15
+
#[derive(Debug, Clone, Serialize, Deserialize)]
16
+
pub struct AtEvent {
17
+
/// When the event record was created (required)
18
+
#[serde(rename = "createdAt")]
19
+
pub created_at: String,
20
+
21
+
/// Event name/title (required)
22
+
pub name: String,
23
+
24
+
/// Event description (optional)
25
+
pub description: Option<String>,
26
+
27
+
/// Event start datetime in ISO 8601 (optional)
28
+
#[serde(rename = "startsAt")]
29
+
pub starts_at: Option<String>,
30
+
31
+
/// Event end datetime in ISO 8601 (optional)
32
+
#[serde(rename = "endsAt")]
33
+
pub ends_at: Option<String>,
34
+
35
+
/// Event mode (optional)
36
+
pub mode: Option<String>,
37
+
38
+
/// Event status (optional)
39
+
pub status: Option<String>,
40
+
41
+
/// Event locations (optional)
42
+
pub locations: Option<Vec<AtLocation>>,
43
+
44
+
/// Event URIs/links (optional)
45
+
pub uris: Option<Vec<String>>,
46
+
47
+
/// Event metadata (optional)
48
+
pub metadata: Option<serde_json::Value>,
49
+
}
50
+
51
+
/// AT Protocol location structure
52
+
#[derive(Debug, Clone, Serialize, Deserialize)]
53
+
pub struct AtLocation {
54
+
/// Location name/address
55
+
pub name: String,
56
+
57
+
/// Geographic coordinates (optional)
58
+
pub geo: Option<AtGeo>,
59
+
60
+
/// H3 cell identifier (optional)
61
+
pub h3: Option<String>,
62
+
}
63
+
64
+
/// Geographic coordinates
65
+
#[derive(Debug, Clone, Serialize, Deserialize)]
66
+
pub struct AtGeo {
67
+
pub lat: f64,
68
+
pub lng: f64,
69
+
}
70
+
71
+
/// Event mode enumeration values
72
+
pub mod event_mode {
73
+
pub const IN_PERSON: &str = "community.lexicon.calendar.event#inperson";
74
+
pub const VIRTUAL: &str = "community.lexicon.calendar.event#virtual";
75
+
pub const HYBRID: &str = "community.lexicon.calendar.event#hybrid";
76
+
}
77
+
78
+
/// Event status enumeration values
79
+
pub mod event_status {
80
+
pub const PLANNED: &str = "community.lexicon.calendar.event#planned";
81
+
pub const SCHEDULED: &str = "community.lexicon.calendar.event#scheduled";
82
+
pub const RESCHEDULED: &str = "community.lexicon.calendar.event#rescheduled";
83
+
pub const CANCELLED: &str = "community.lexicon.calendar.event#cancelled";
84
+
pub const POSTPONED: &str = "community.lexicon.calendar.event#postponed";
85
+
}
86
+
87
+
/// Transform an external event into AT Protocol format
88
+
#[instrument]
89
+
pub fn to_at_event(source: &ExternalEvent) -> Result<AtEvent> {
90
+
debug!("Transforming event: {}", source.name);
91
+
92
+
// Validate required fields
93
+
if source.name.trim().is_empty() {
94
+
return Err(CalendarImportError::MissingRequiredField(
95
+
"name".to_string()
96
+
).into());
97
+
}
98
+
99
+
let mut at_event = AtEvent {
100
+
created_at: Utc::now().to_rfc3339(),
101
+
name: source.name.trim().to_string(),
102
+
description: source.description.as_ref().map(|d| d.trim().to_string()),
103
+
starts_at: None,
104
+
ends_at: None,
105
+
mode: None,
106
+
status: None,
107
+
locations: None,
108
+
uris: None,
109
+
metadata: None,
110
+
};
111
+
112
+
// Transform datetime fields
113
+
if let Some(starts_at) = &source.starts_at {
114
+
at_event.starts_at = Some(starts_at.to_rfc3339());
115
+
}
116
+
117
+
if let Some(ends_at) = &source.ends_at {
118
+
at_event.ends_at = Some(ends_at.to_rfc3339());
119
+
}
120
+
121
+
// Validate datetime ordering
122
+
if let (Some(start), Some(end)) = (&at_event.starts_at, &at_event.ends_at) {
123
+
if let (Ok(start_dt), Ok(end_dt)) = (
124
+
DateTime::parse_from_rfc3339(start),
125
+
DateTime::parse_from_rfc3339(end)
126
+
) {
127
+
if start_dt >= end_dt {
128
+
return Err(CalendarImportError::FieldValidationError {
129
+
field: "datetime".to_string(),
130
+
reason: "Start time must be before end time".to_string(),
131
+
}.into());
132
+
}
133
+
}
134
+
}
135
+
136
+
// Transform mode
137
+
at_event.mode = determine_event_mode(source);
138
+
139
+
// Transform status
140
+
at_event.status = determine_event_status(source);
141
+
142
+
// Transform locations
143
+
if let Some(location_str) = &source.location {
144
+
if !location_str.trim().is_empty() {
145
+
at_event.locations = Some(vec![AtLocation {
146
+
name: location_str.trim().to_string(),
147
+
geo: None, // TODO: Implement geocoding
148
+
h3: None, // TODO: Implement H3 cell calculation
149
+
}]);
150
+
}
151
+
}
152
+
153
+
// Transform URIs
154
+
let mut uris = Vec::new();
155
+
if let Some(url) = &source.url {
156
+
if !url.trim().is_empty() && is_valid_url(url) {
157
+
uris.push(url.trim().to_string());
158
+
}
159
+
}
160
+
if !uris.is_empty() {
161
+
at_event.uris = Some(uris);
162
+
}
163
+
164
+
// Preserve metadata
165
+
if source.metadata != serde_json::Value::Null {
166
+
at_event.metadata = Some(source.metadata.clone());
167
+
}
168
+
169
+
debug!("Successfully transformed event to AT Protocol format");
170
+
Ok(at_event)
171
+
}
172
+
173
+
/// Transform an external event to AT Protocol format (alias for to_at_event)
174
+
pub fn transform_to_at_protocol(source: &ExternalEvent) -> Result<AtEvent> {
175
+
to_at_event(source)
176
+
}
177
+
178
+
/// Normalize datetime strings to ISO 8601 format
179
+
fn normalize_datetime(datetime_str: &str) -> Result<String> {
180
+
// Try to parse various datetime formats
181
+
let datetime_str = datetime_str.trim();
182
+
183
+
// Try RFC 3339 first (most common)
184
+
if let Ok(dt) = DateTime::parse_from_rfc3339(datetime_str) {
185
+
return Ok(dt.with_timezone(&Utc).to_rfc3339());
186
+
}
187
+
188
+
// Try other common formats
189
+
let formats = [
190
+
"%Y-%m-%dT%H:%M:%S%.fZ", // ISO 8601 with microseconds
191
+
"%Y-%m-%dT%H:%M:%SZ", // ISO 8601 basic
192
+
"%Y-%m-%dT%H:%M:%S", // ISO 8601 without timezone
193
+
"%Y-%m-%d %H:%M:%S", // Space separator
194
+
"%Y/%m/%d %H:%M:%S", // Forward slash date
195
+
"%d/%m/%Y %H:%M:%S", // DD/MM/YYYY format
196
+
"%m/%d/%Y %H:%M:%S", // MM/DD/YYYY format
197
+
"%Y-%m-%d", // Date only
198
+
];
199
+
200
+
for format in &formats {
201
+
// Try with timezone-aware parsing first
202
+
if let Ok(dt) = DateTime::parse_from_str(datetime_str, format) {
203
+
return Ok(dt.with_timezone(&Utc).to_rfc3339());
204
+
}
205
+
206
+
// Try with naive datetime and assume UTC
207
+
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, format) {
208
+
let dt = DateTime::<Utc>::from_naive_utc_and_offset(naive_dt, Utc);
209
+
return Ok(dt.to_rfc3339());
210
+
}
211
+
212
+
// Try with date only
213
+
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(datetime_str, format) {
214
+
let naive_dt = naive_date.and_hms_opt(0, 0, 0).unwrap();
215
+
let dt = DateTime::<Utc>::from_naive_utc_and_offset(naive_dt, Utc);
216
+
return Ok(dt.to_rfc3339());
217
+
}
218
+
}
219
+
220
+
Err(CalendarImportError::InvalidDateTimeFormat(
221
+
format!("Unable to parse datetime: {}", datetime_str)
222
+
).into())
223
+
}
224
+
225
+
/// Determine the appropriate event mode based on external event data
226
+
fn determine_event_mode(source: &ExternalEvent) -> Option<String> {
227
+
// Check location and other hints to determine mode
228
+
if let Some(location) = &source.location {
229
+
let location_lower = location.to_lowercase();
230
+
231
+
// Virtual meeting indicators
232
+
if location_lower.contains("zoom")
233
+
|| location_lower.contains("teams")
234
+
|| location_lower.contains("meet")
235
+
|| location_lower.contains("webex")
236
+
|| location_lower.contains("virtual")
237
+
|| location_lower.contains("online")
238
+
|| location_lower.starts_with("http") {
239
+
return Some(event_mode::VIRTUAL.to_string());
240
+
}
241
+
242
+
// Hybrid indicators
243
+
if location_lower.contains("hybrid")
244
+
|| location_lower.contains("both") {
245
+
return Some(event_mode::HYBRID.to_string());
246
+
}
247
+
248
+
// If we have a physical address, assume in-person
249
+
if !location.trim().is_empty() {
250
+
return Some(event_mode::IN_PERSON.to_string());
251
+
}
252
+
}
253
+
254
+
// Check URL for virtual meeting links
255
+
if let Some(url) = &source.url {
256
+
let url_lower = url.to_lowercase();
257
+
if url_lower.contains("zoom.us")
258
+
|| url_lower.contains("teams.microsoft.com")
259
+
|| url_lower.contains("meet.google.com")
260
+
|| url_lower.contains("webex.com") {
261
+
return Some(event_mode::VIRTUAL.to_string());
262
+
}
263
+
}
264
+
265
+
// Default to None (let the client decide)
266
+
None
267
+
}
268
+
269
+
/// Determine the appropriate event status based on external event data
270
+
fn determine_event_status(source: &ExternalEvent) -> Option<String> {
271
+
if !source.status.is_empty() {
272
+
let status_lower = source.status.to_lowercase();
273
+
274
+
match status_lower.as_str() {
275
+
"confirmed" | "scheduled" | "busy" => Some(event_status::SCHEDULED.to_string()),
276
+
"cancelled" | "canceled" => Some(event_status::CANCELLED.to_string()),
277
+
"tentative" | "maybe" => Some(event_status::PLANNED.to_string()),
278
+
"postponed" => Some(event_status::POSTPONED.to_string()),
279
+
"rescheduled" => Some(event_status::RESCHEDULED.to_string()),
280
+
_ => Some(event_status::PLANNED.to_string()),
281
+
}
282
+
} else {
283
+
// Default status based on whether we have start time
284
+
if source.starts_at.is_some() {
285
+
Some(event_status::SCHEDULED.to_string())
286
+
} else {
287
+
Some(event_status::PLANNED.to_string())
288
+
}
289
+
}
290
+
}
291
+
292
+
/// Basic URL validation
293
+
fn is_valid_url(url: &str) -> bool {
294
+
url::Url::parse(url).is_ok()
295
+
}
296
+
297
+
#[cfg(test)]
298
+
mod tests {
299
+
use super::*;
300
+
use crate::import::ExternalEvent;
301
+
302
+
#[test]
303
+
fn test_to_at_event_basic() {
304
+
let external = ExternalEvent::new("test-id".to_string(), "Test Event".to_string())
305
+
.with_description("Test description".to_string());
306
+
307
+
let result = to_at_event(&external);
308
+
assert!(result.is_ok());
309
+
310
+
let at_event = result.unwrap();
311
+
assert_eq!(at_event.name, "Test Event");
312
+
assert_eq!(at_event.description, Some("Test description".to_string()));
313
+
}
314
+
315
+
#[test]
316
+
fn test_normalize_datetime_rfc3339() {
317
+
let result = normalize_datetime("2025-06-01T10:00:00Z");
318
+
assert!(result.is_ok());
319
+
assert_eq!(result.unwrap(), "2025-06-01T10:00:00+00:00");
320
+
}
321
+
322
+
#[test]
323
+
fn test_determine_event_mode_virtual() {
324
+
let external = ExternalEvent::new("test".to_string(), "Test".to_string())
325
+
.with_location("https://zoom.us/j/123456789".to_string());
326
+
327
+
let mode = determine_event_mode(&external);
328
+
assert_eq!(mode, Some(event_mode::VIRTUAL.to_string()));
329
+
}
330
+
331
+
#[test]
332
+
fn test_determine_event_status_cancelled() {
333
+
let external = ExternalEvent::new("test".to_string(), "Test".to_string())
334
+
.with_status("cancelled".to_string());
335
+
336
+
let status = determine_event_status(&external);
337
+
assert_eq!(status, Some(event_status::CANCELLED.to_string()));
338
+
}
339
+
}
-261
claude.md
-261
claude.md
···
1
-
# 📘 Development Guide: `atproto-calendar-import` (with [atrium-rs](https://github.com/atrium-rs/atrium))
2
-
3
-
---
4
-
5
-
## 🔍 Project Description
6
-
7
-
`atproto-calendar-import` is a Rust library and CLI tool for importing calendar events from external providers (Google, Outlook, Apple, ICS) into the [AT Protocol](https://atproto.com/). It leverages the [atrium](https://github.com/atrium-rs/atrium) crate for authentication, lexicon-based data modeling, and repository interactions.
8
-
9
-
---
10
-
11
-
## 📦 Repository Structure
12
-
13
-
```text
14
-
atproto-calendar-import/
15
-
├── src/
16
-
│ ├── main.rs # CLI binary entry
17
-
│ ├── lib.rs # Core crate definition
18
-
│ ├── import/ # External calendar integrations (Google, Outlook, etc.)
19
-
│ ├── transform/ # Converts events to AT lexicon
20
-
│ ├── pds/ # Interacts with ATP repos via Atrium
21
-
│ ├── auth/ # OAuth2 + ATP auth helpers
22
-
│ ├── dedup/ # Deduplication logic
23
-
│ ├── cli/ # Argument parser and subcommand logic
24
-
│ └── errors.rs # Centralized, structured error handling
25
-
├── tests/ # Integration tests
26
-
├── Cargo.toml # Crate metadata and dependencies
27
-
└── README.md # Project documentation
28
-
```
29
-
30
-
---
31
-
32
-
## ✅ Requirements
33
-
34
-
* **Rust** ≥ 1.70
35
-
* **Cargo**
36
-
* External calendar API credentials (Google OAuth, Microsoft)
37
-
* Access to a self-hosted or sandbox PDS (see [ATP self-hosting guide](https://atproto.com/guides/self-hosting))
38
-
* Postgres (optional, for deduplication cache)
39
-
40
-
---
41
-
42
-
## 🛠️ Build & Test Commands
43
-
44
-
| Task | Command |
45
-
| ------------------ | --------------------------------------------------------------------- |
46
-
| Build | `cargo build` |
47
-
| Type Check | `cargo check` |
48
-
| Format Code | `cargo fmt` |
49
-
| Lint Code | `cargo clippy` |
50
-
| Run All Tests | `cargo test` |
51
-
| Run Specific Test | `cargo test <test_name>` |
52
-
53
-
---
54
-
55
-
## ⚙️ Architecture Overview
56
-
57
-
### 1. 📥 Import Service
58
-
59
-
Handles OAuth and reads events from:
60
-
61
-
* Google Calendar
62
-
* Microsoft Outlook (Graph API)
63
-
* Apple Calendar (CalDAV, future)
64
-
65
-
Example:
66
-
67
-
```rust
68
-
let events = google::import_events(access_token).await?;
69
-
```
70
-
71
-
---
72
-
73
-
### 2. 🔄 Data Transformation
74
-
75
-
Converts external events into AT Protocol-compliant format per \[`event.json`]\(uploaded file).
76
-
77
-
Handled in `transform/`:
78
-
79
-
* ISO 8601 datetime normalization
80
-
* Field mapping: `name`, `startsAt`, `mode`, etc.
81
-
* Enum coercion for `status`, `mode`, `uri`
82
-
83
-
Example:
84
-
85
-
```rust
86
-
let at_event = transform::to_at_event(&google_event)?;
87
-
```
88
-
89
-
---
90
-
91
-
### 3. 🔐 Authorization & Repository Writes (via Atrium)
92
-
93
-
Uses Atrium to authenticate and commit records to user repositories (repos).
94
-
95
-
Example flow:
96
-
97
-
```rust
98
-
use atrium_api::repo::{Client, RecordCreate};
99
-
100
-
let client = Client::new(jwt_token, pds_url)?;
101
-
let record = RecordCreate::<MyEvent>::builder()
102
-
.collection("community.lexicon.calendar.event")
103
-
.record(event)
104
-
.build()?;
105
-
client.create_record(&record).await?;
106
-
```
107
-
108
-
Also supports full DID document resolution via Atrium:
109
-
110
-
```rust
111
-
use atrium_api::identity::resolve_handle;
112
-
113
-
let did = resolve_handle("user.bsky.social").await?;
114
-
```
115
-
116
-
---
117
-
118
-
### 4. 🔁 Deduplication
119
-
120
-
To prevent duplicate entries:
121
-
122
-
* In-memory or Postgres cache
123
-
* Hash `name`, `startsAt`, and `location` as dedup key
124
-
* Store previously seen keys per user DID
125
-
126
-
---
127
-
128
-
## 🧪 Event Schema Overview
129
-
130
-
From `event.json` lexicon:
131
-
132
-
### Required:
133
-
134
-
* `name`: string
135
-
* `createdAt`: ISO 8601
136
-
137
-
### Optional:
138
-
139
-
* `description`
140
-
* `startsAt`, `endsAt`
141
-
* `mode`: `inperson`, `virtual`, `hybrid`
142
-
* `status`: `planned`, `scheduled`, `rescheduled`, `cancelled`, `postponed`
143
-
* `locations`: address, geo, h3
144
-
* `uris`: resource URIs
145
-
146
-
Example:
147
-
148
-
```json
149
-
{
150
-
"name": "Team Sync",
151
-
"createdAt": "2025-05-31T08:00:00Z",
152
-
"startsAt": "2025-06-01T10:00:00Z",
153
-
"endsAt": "2025-06-01T11:00:00Z",
154
-
"mode": "community.lexicon.calendar.event#virtual",
155
-
"status": "community.lexicon.calendar.event#scheduled"
156
-
}
157
-
```
158
-
159
-
---
160
-
161
-
## ⚠️ Error Handling
162
-
163
-
* Use `thiserror` for all structured errors
164
-
* Format: `error-atproto-calendar-<domain>-<code> <msg>: <details>`
165
-
* Avoid `anyhow!`
166
-
167
-
Example:
168
-
169
-
```rust
170
-
#[derive(Error, Debug)]
171
-
pub enum PdsError {
172
-
#[error("error-atproto-calendar-pds-1 Failed to write record: {0}")]
173
-
WriteFailed(String),
174
-
}
175
-
```
176
-
177
-
Error logging:
178
-
179
-
```rust
180
-
if let Err(err) = result {
181
-
tracing::error!(error = ?err, "Failed to process event");
182
-
}
183
-
```
184
-
185
-
---
186
-
187
-
## 🔍 Logging
188
-
189
-
* Use `tracing` with spans for structured and contextual logs.
190
-
* All async functions should be `.instrument()`ed.
191
-
192
-
```rust
193
-
use tracing::Instrument;
194
-
195
-
async fn import() -> Result<()> {
196
-
actual_import().instrument(tracing::info_span!("calendar_import")).await
197
-
}
198
-
```
199
-
200
-
---
201
-
202
-
## 📖 Documentation
203
-
204
-
* Document all public modules and functions.
205
-
* Begin each module with:
206
-
207
-
```rust
208
-
//! Handles transformation of Google Calendar events into AT Protocol format.
209
-
```
210
-
211
-
* Generate docs:
212
-
213
-
```bash
214
-
cargo doc --open
215
-
```
216
-
217
-
---
218
-
219
-
## 🧰 Self-Hosting Setup (ATP)
220
-
221
-
1. Deploy [PDS](https://github.com/bluesky-social/atproto/tree/main/packages/pds)
222
-
2. Link with DNS/DID
223
-
3. Use Atrium to connect:
224
-
225
-
```rust
226
-
let session = client.login("handle.bsky.social", "app_password").await?;
227
-
```
228
-
229
-
4. For local testing: use [`bsky.dev`](https://bsky.dev/)
230
-
231
-
---
232
-
233
-
## 🚀 Extending the Project
234
-
235
-
### ➕ Add Calendar Provider
236
-
237
-
1. Create a file in `src/import/<provider>.rs`
238
-
2. Implement:
239
-
240
-
```rust
241
-
pub async fn import_events(token: &str) -> Result<Vec<ExternalEvent>> { ... }
242
-
```
243
-
244
-
3. Transform and publish via Atrium
245
-
246
-
---
247
-
248
-
### 🧩 Add Custom Fields
249
-
250
-
* Update `transform/mod.rs` to support new fields
251
-
* Extend event struct with additional lexicon attributes
252
-
* Confirm compliance with \[event.json]\(uploaded file)
253
-
254
-
---
255
-
256
-
## 🔐 Security
257
-
258
-
* Store tokens securely (use `rustls`, `secrecy`, `dotenv`)
259
-
* Never log raw tokens or DIDs
260
-
* Enforce HTTPS for all outbound API calls
261
-
-251
code_scaffold.md
-251
code_scaffold.md
···
1
-
Here’s a **complete Rust code scaffold** for `atproto-calendar-import` using [`atrium`](https://github.com/atrium-rs/atrium) and organized for modular extensibility and proper architectural separation.
2
-
3
-
---
4
-
5
-
## 📁 Directory Scaffold
6
-
7
-
```
8
-
atproto-calendar-import/
9
-
├── src/
10
-
│ ├── main.rs
11
-
│ ├── lib.rs
12
-
│ ├── cli.rs
13
-
│ ├── import/
14
-
│ │ ├── mod.rs
15
-
│ │ └── google.rs
16
-
│ ├── transform/
17
-
│ │ └── mod.rs
18
-
│ ├── pds.rs
19
-
│ ├── auth.rs
20
-
│ ├── dedup.rs
21
-
│ └── errors.rs
22
-
├── Cargo.toml
23
-
└── .env
24
-
```
25
-
26
-
---
27
-
28
-
## 🦀 `Cargo.toml` (minimal example)
29
-
30
-
```toml
31
-
[package]
32
-
name = "atproto-calendar-import"
33
-
version = "0.1.0"
34
-
edition = "2021"
35
-
36
-
[dependencies]
37
-
atrium-api = "0.4"
38
-
atrium-xrpc-client = "0.4"
39
-
thiserror = "1.0"
40
-
anyhow = "1.0"
41
-
tokio = { version = "1", features = ["full"] }
42
-
serde = { version = "1", features = ["derive"] }
43
-
serde_json = "1"
44
-
tracing = "0.1"
45
-
dotenvy = "0.15"
46
-
chrono = { version = "0.4", features = ["serde"] }
47
-
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
48
-
```
49
-
50
-
---
51
-
52
-
## 🧾 `src/main.rs`
53
-
54
-
```rust
55
-
//! CLI entry point
56
-
57
-
mod cli;
58
-
mod errors;
59
-
mod auth;
60
-
mod pds;
61
-
mod import;
62
-
mod transform;
63
-
mod dedup;
64
-
65
-
use anyhow::Result;
66
-
use tracing_subscriber::FmtSubscriber;
67
-
68
-
#[tokio::main]
69
-
async fn main() -> Result<()> {
70
-
dotenvy::dotenv().ok();
71
-
let subscriber = FmtSubscriber::new();
72
-
tracing::subscriber::set_global_default(subscriber)?;
73
-
74
-
let config = cli::parse();
75
-
cli::handle_command(config).await
76
-
}
77
-
```
78
-
79
-
---
80
-
81
-
## 🧾 `src/cli.rs`
82
-
83
-
```rust
84
-
//! CLI command parser
85
-
86
-
use clap::{Parser, Subcommand};
87
-
88
-
#[derive(Parser)]
89
-
#[command(name = "atproto-calendar-import")]
90
-
#[command(about = "Imports calendar events to AT Protocol")]
91
-
pub struct Cli {
92
-
#[command(subcommand)]
93
-
pub command: Commands,
94
-
}
95
-
96
-
#[derive(Subcommand)]
97
-
pub enum Commands {
98
-
ImportGoogle {
99
-
#[arg(short, long)]
100
-
access_token: String,
101
-
},
102
-
}
103
-
104
-
pub fn parse() -> Cli {
105
-
Cli::parse()
106
-
}
107
-
108
-
use crate::{import::google, transform, pds};
109
-
110
-
pub async fn handle_command(cli: Cli) -> anyhow::Result<()> {
111
-
match cli.command {
112
-
Commands::ImportGoogle { access_token } => {
113
-
let events = google::import_events(&access_token).await?;
114
-
for event in events {
115
-
let at_event = transform::to_at_event(&event)?;
116
-
pds::publish_event(&at_event).await?;
117
-
}
118
-
}
119
-
}
120
-
Ok(())
121
-
}
122
-
```
123
-
124
-
---
125
-
126
-
## 🧾 `src/import/google.rs`
127
-
128
-
```rust
129
-
//! Google Calendar API integration
130
-
131
-
use anyhow::Result;
132
-
133
-
#[derive(Debug, Clone)]
134
-
pub struct GoogleEvent {
135
-
pub name: String,
136
-
pub description: Option<String>,
137
-
pub starts_at: Option<String>,
138
-
pub ends_at: Option<String>,
139
-
}
140
-
141
-
pub async fn import_events(_token: &str) -> Result<Vec<GoogleEvent>> {
142
-
// TODO: Implement Google Calendar fetch logic with OAuth2
143
-
Ok(vec![GoogleEvent {
144
-
name: "Team Sync".to_string(),
145
-
description: Some("Weekly catch-up".into()),
146
-
starts_at: Some("2025-06-01T10:00:00Z".into()),
147
-
ends_at: Some("2025-06-01T11:00:00Z".into()),
148
-
}])
149
-
}
150
-
```
151
-
152
-
---
153
-
154
-
## 🧾 `src/transform/mod.rs`
155
-
156
-
```rust
157
-
//! Transforms external event types into AT Protocol lexicon structures
158
-
159
-
use anyhow::Result;
160
-
use crate::import::google::GoogleEvent;
161
-
use serde::Serialize;
162
-
163
-
#[derive(Serialize)]
164
-
pub struct AtEvent {
165
-
pub created_at: String,
166
-
pub name: String,
167
-
pub starts_at: Option<String>,
168
-
pub ends_at: Option<String>,
169
-
pub description: Option<String>,
170
-
pub mode: String,
171
-
pub status: String,
172
-
}
173
-
174
-
pub fn to_at_event(source: &GoogleEvent) -> Result<AtEvent> {
175
-
Ok(AtEvent {
176
-
created_at: chrono::Utc::now().to_rfc3339(),
177
-
name: source.name.clone(),
178
-
starts_at: source.starts_at.clone(),
179
-
ends_at: source.ends_at.clone(),
180
-
description: source.description.clone(),
181
-
mode: "community.lexicon.calendar.event#virtual".into(),
182
-
status: "community.lexicon.calendar.event#scheduled".into(),
183
-
})
184
-
}
185
-
```
186
-
187
-
---
188
-
189
-
## 🧾 `src/pds.rs`
190
-
191
-
```rust
192
-
//! Uses atrium to publish transformed events to the user's repo
193
-
194
-
use atrium_api::repo::{Client, RecordCreate};
195
-
use crate::transform::AtEvent;
196
-
use anyhow::Result;
197
-
198
-
pub async fn publish_event(event: &AtEvent) -> Result<()> {
199
-
let token = std::env::var("ATP_JWT")?;
200
-
let pds_url = std::env::var("ATP_PDS")?;
201
-
let did = std::env::var("ATP_DID")?;
202
-
203
-
let client = Client::new(token, pds_url)?;
204
-
let record = RecordCreate::builder()
205
-
.collection("community.lexicon.calendar.event")
206
-
.record(event)
207
-
.repo(did)
208
-
.build()?;
209
-
210
-
client.create_record(&record).await?;
211
-
Ok(())
212
-
}
213
-
```
214
-
215
-
---
216
-
217
-
## 🧾 `src/errors.rs`
218
-
219
-
```rust
220
-
//! Project-wide error definitions
221
-
222
-
use thiserror::Error;
223
-
224
-
#[derive(Debug, Error)]
225
-
pub enum CalendarImportError {
226
-
#[error("error-atproto-identity-auth-1 Token retrieval failed: {0}")]
227
-
AuthError(String),
228
-
229
-
#[error("error-atproto-identity-pds-1 Failed to write record: {0}")]
230
-
PdsWriteError(String),
231
-
}
232
-
```
233
-
234
-
---
235
-
236
-
## ✅ `.env` Example
237
-
238
-
```
239
-
ATP_JWT=ey...
240
-
ATP_PDS=https://pds.bsky.dev
241
-
ATP_DID=did:plc:123abc...
242
-
```
243
-
244
-
---
245
-
246
-
Would you like:
247
-
248
-
* Sample tests (`tests/cli.rs`, `tests/integration.rs`)?
249
-
* A working OAuth2 token exchange for Google?
250
-
* ATRIUM-based unit tests or mock PDS server interaction?
251
-