atproto-calendar-import is a library and CLI tool for importing calendar events in python & rust

Compare changes

Choose any two refs to compare.

+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + -r base.txt 2 + 3 + # API server 4 + fastapi>=0.104,<1.0 5 + uvicorn[standard]>=0.24,<1.0 6 + 7 + # Optional database support 8 + asyncpg>=0.28,<1.0 9 + redis>=4.5,<5.0
+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
··· 1 + -r base.txt 2 + 3 + # Development tools 4 + pytest>=7.0 5 + pytest-asyncio>=0.21 6 + pytest-cov>=4.0 7 + black>=23.0 8 + isort>=5.12 9 + flake8>=6.0 10 + mypy>=1.5 11 + pre-commit>=3.0 12 + 13 + # Documentation 14 + sphinx>=7.0 15 + sphinx-rtd-theme>=1.3
+2
PYTHON/requirements.txt
··· 1 + # Main requirements file - combines all core dependencies 2 + -r requirements/base.txt
+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
··· 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
··· 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
··· 1 + """Entry point for running atproto_calendar as a module.""" 2 + 3 + from .main import main 4 + 5 + if __name__ == "__main__": 6 + main()
+5
PYTHON/src/atproto_calendar/api/__init__.py
··· 1 + """API module for REST endpoints.""" 2 + 3 + # Placeholder - FastAPI server will be implemented here 4 + 5 + __all__ = []
+3
PYTHON/src/atproto_calendar/api/models/__init__.py
··· 1 + """API models module.""" 2 + 3 + __all__ = []
+3
PYTHON/src/atproto_calendar/api/routes/__init__.py
··· 1 + """API routes module.""" 2 + 3 + __all__ = []
+5
PYTHON/src/atproto_calendar/atproto/__init__.py
··· 1 + """AT Protocol integration module.""" 2 + 3 + # Placeholder - AT Protocol client and authentication will be implemented here 4 + 5 + __all__ = []
+5
PYTHON/src/atproto_calendar/auth/__init__.py
··· 1 + """Authentication helpers for OAuth2 providers.""" 2 + 3 + # Placeholder - OAuth2 authentication helpers will be implemented here 4 + 5 + __all__ = []
+5
PYTHON/src/atproto_calendar/cli/__init__.py
··· 1 + """CLI module for command-line interface.""" 2 + 3 + from .commands import cli 4 + 5 + __all__ = ["cli"]
+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
··· 1 + """Configuration module.""" 2 + 3 + from .settings import Settings, get_settings 4 + 5 + __all__ = ["Settings", "get_settings"]
+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
··· 1 + """Deduplication system for avoiding duplicate events.""" 2 + 3 + # Placeholder - Deduplication logic will be implemented here 4 + 5 + __all__ = []
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + """Transform module for converting events to AT Protocol format.""" 2 + 3 + from .converter import EventConverter, ATProtoEvent 4 + 5 + __all__ = [ 6 + "EventConverter", 7 + "ATProtoEvent", 8 + ]
+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
··· 1 + """Test suite for AT Protocol Calendar Import.""" 2 + 3 + __all__ = []
+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
··· 1 + """Tests for API functionality.""" 2 + 3 + __all__ = []
+3
PYTHON/tests/test_cli/__init__.py
··· 1 + """Tests for CLI functionality.""" 2 + 3 + __all__ = []
+3
PYTHON/tests/test_import/__init__.py
··· 1 + """Tests for import functionality.""" 2 + 3 + __all__ = []
+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
··· 1 + """Tests for transform functionality.""" 2 + 3 + __all__ = []
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + [![Rust](https://img.shields.io/badge/rust-1.70+-blue.svg)](https://rust-lang.org) 7 + [![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 -