diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..108347b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.venv +__pycache__ +*.pyc +.pytest_cache +.ruff_cache +.coverage +tests +docs +site \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5b8a234 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.py] +indent_size = 4 +max_line_length = 100 + +[*.{yml,yaml,toml,json,md}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..3054e76 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,45 @@ +name: Bug report +description: Something is broken. +labels: ["bug"] +body: + - type: textarea + id: what + attributes: + label: What happened + description: Describe the bug and what you expected instead. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Reproduction + description: Minimal steps to reproduce. + validations: + required: true + - type: textarea + id: doctor + attributes: + label: Output of `eaw-sync doctor` + render: shell + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: "`eaw-sync --version`" + validations: + required: true + - type: input + id: os + attributes: + label: OS + description: e.g. macOS 14.4, Ubuntu 22.04 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log excerpt + description: Redact any tokens. + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4a96446 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/Ailcope/EasyAtCal/blob/main/SECURITY.md + about: Do not file a public issue — see SECURITY.md for the disclosure channel. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..75ab060 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,22 @@ +name: Feature request +description: Suggest a change that fits the project scope. +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do that EasyAtCal makes hard? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: What should change, roughly? CLI surface, config, behavior. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..163c3b9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + commit-message: + prefix: "deps" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "ci" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..de26600 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## What + + + +## Why + + + +## How + + + +## Checklist + +- [ ] Tests added or updated; `make check` passes locally. +- [ ] CHANGELOG `[Unreleased]` entry added (if user-visible). +- [ ] No `Co-Authored-By` trailers. +- [ ] If it touches the CLI surface, README is updated. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bbf7449 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12"] + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e '.[dev]' + - name: Install eventkit extra (macOS only) + if: runner.os == 'macOS' + run: pip install -e '.[eventkit]' + - name: Lint + run: ruff check easyatcal tests + - name: Types + run: mypy easyatcal + - name: Test + run: pytest --cov=easyatcal --cov-fail-under=85 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..9204a50 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,49 @@ +name: Docs +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install mkdocs-material + - name: Copy files + run: | + mkdir -p docs/pages + cp README.md docs/pages/index.md + cp CONTRIBUTING.md docs/pages/contributing.md + cp CHANGELOG.md docs/pages/changelog.md + - name: Build docs + run: mkdocs build + - name: Setup Pages + uses: actions/configure-pages@v6 + - name: Upload artifact + uses: actions/upload-pages-artifact@v5 + with: + path: 'site' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b3ab5c9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,42 @@ +name: Publish to PyPI + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install build tooling + run: python -m pip install --upgrade build + - name: Build sdist and wheel + run: python -m build + - uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/easyatcal + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bf9920 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Secrets & user data +config.yaml +.env +*.ics +*.har +state.json +token.json +.cache/ + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ + +# Editors / OS +.vscode/ +.idea/ +.DS_Store +.venv/ +.claude/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6bbf2b1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3228d7f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,116 @@ +# Changelog + +All notable changes to this project are documented here. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning is +[SemVer](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- **Bearer-JWT auth via headless browser.** New `auth_mode: user` + (now the default) drives a headless Chromium through the easy@work + web login, persists Playwright `storage_state` to + `~/.cache/easyatcal/session.json`, and extracts the Bearer JWT the + SPA writes to `localStorage`. Every request to the regional API + (`.api.easyatwork.com`) replays the JWT as + `Authorization: Bearer …`. `eaw-sync login` / `eaw-sync logout` + commands. Password never stored on disk — passes via `EAW_PASSWORD` + env or interactive prompt. JWT lifetime ~1 year. +- New config fields `api_url`, `customer_id`, `employee_id`, + `ui_version`; shifts URL is built as + `{api_url}/customers/{customer_id}/employees/{employee_id}/shifts`. + `EAW_API_URL` / `EAW_CUSTOMER_ID` / `EAW_EMPLOYEE_ID` env overrides. +- `SessionEawClient` sends Laravel-style space-separated datetime + (`YYYY-MM-DD HH:MM:SS`) plus `with[]=schedule.customer` eager-load, + mimics SPA headers (`Origin`, `Referer`, `X-Ui-Version`, + `Cache-Control`). Flexible payload shape detection + (`data` / `results` / `items` / `shifts` / bare list) and heuristic + field mapping (`id`/`uuid`/`shiftId`, `start`/`starts_at`/`from`, + `schedule.customer.name` for title, …). Laravel paginator + `next_page_url` honored. `_parse_dt` accepts both ISO-8601 and + Laravel `YYYY-MM-DD HH:MM:SS` (assumed UTC). + Returns `AuthError` on HTTP 401 with a "run login" hint. +- `easyatcal.session.SessionStore` atomic 0600 cookie + localStorage + persistence; `access_token()` scans localStorage for JWT-shaped + values. +- `easyatcal.auth_user.do_login` Playwright driver with configurable + selectors (`email_selector`, `password_selector`, `submit_selector`) + and `headless` toggle. +- Optional `playwright` extra. `mypy.overrides` for `playwright.*`. +- 18 new tests (session store, session-mode fetch, CLI login/logout). + +### Changed +- `doctor` and `auth test` now report session / OAuth status distinctly. +- `ShiftFetcher` protocol gains `authenticate() -> object`. +- `api_url` / `customer_id` / `employee_id` required for user mode; + sync raises a clear error until all three set. + +### Added +- Structured log events in `run_sync` with `event_id` extra (`sync.fetch.ok`, + `sync.fetch.error`, `sync.compute_changes.ok`, `sync.apply.ok`, + `sync.apply.partial`, `sync.complete`). JSON formatter propagates `event_id`. +- `--verbose` / `--quiet` global flags override config `logging.level`. +- `user_id` parameter plumbed through `EawClient.fetch_shifts` and + `run_sync`, so the configured `sync.user_id` narrows the API query. +- `EAW_BASE_URL` env override for `easyatwork.base_url`. +- Defensive API payload parsing: unexpected response shape now raises + `ApiError` with the observed top-level keys. +- Exponential backoff in `watch` on consecutive fatal errors (capped 1 h). +- `mypy` strict wired into Makefile (`make types`, `make check`) and CI. +- mkdocs-material site (`docs/pages/`, `mkdocs.yml`, `.github/workflows/docs.yml`) + auto-published to GitHub Pages from README/CONTRIBUTING/CHANGELOG. +- Dockerfile + `.dockerignore` for container deployments. +- README "Known limitations" section documenting that the easy@work API + shape assumed by `api.py` is unverified against the reference + `php-eaw-client` (which could not be located). + +### Fixed +- Pagination: passing `params={}` to httpx on the second request was + stripping the `cursor=…` query from the server-provided `next` URL, + causing an infinite loop. Now reset to `None`. + +## [0.2.0] — 2026-04-20 + +### Changed +- Backends now return `ApplyResult(mapping, deleted_uids)` and raise + `BackendError(message, partial)` on failure. The orchestrator catches the + error, persists partial progress, then re-raises — so a crash mid-apply no + longer leaves `state.json` out of sync with the calendar. + +### Added +- `eaw-sync doctor` preflight command: checks config, auth, backend wiring. +- `eaw-sync state show`: prints local state path, tracked-shift count, last sync. +- `eaw-sync sync --dry-run`: computes adds/updates/deletes without touching + the calendar or state. +- Global `--config-path` override and `--version` flag. +- Defined `sync` exit codes: 0 clean, 1 partial failure (`BackendError`), + 2 fatal (config/auth/network). +- Post-sync summary line (`Sync complete: X added, Y updated, Z deleted.`); + `run_sync` now returns a `SyncSummary`. +- `logging.format: text|json` config option; JSON formatter suitable for log + aggregators. +- `watch` handles `SIGTERM` gracefully (launchctl unload / systemd stop), + sleeps in 1-second slices for quick exit. +- API backoff honors `Retry-After` header on 429/5xx responses. +- `examples/launchd/com.easyatcal.watch.plist`: sample launchd agent for + auto-running `sync` every 15 minutes. +- Ruff config + `.pre-commit-config.yaml`; CI now lints and enforces 85% + coverage. +- `py.typed` marker so downstream projects see EasyAtCal's type hints. +- `CHANGELOG.md`, expanded `README.md`, `LICENSE` (MIT). +- GitHub Actions workflow to publish to PyPI on `v*` tags via trusted + publisher. + +## [0.1.0] — 2026-04-19 + +### Added +- Initial release. +- `Shift` model, pydantic-v2 config loader with env overrides, atomic JSON + state with corrupt-file recovery. +- easy@work OAuth2 client-credentials auth with token cache, paginated + `fetch_shifts`, exponential backoff on 429/5xx. +- Pluggable `CalendarBackend` protocol, diff engine (`compute_changes`). +- ICS file backend and macOS EventKit backend (pyobjc). +- Typer CLI: `config init/show`, `auth test`, `sync`, `watch`. +- GitHub Actions matrix CI (Linux + macOS × Python 3.11/3.12) with + end-to-end ICS test. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9361ee1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing to EasyAtCal + +Thanks for your interest. This project is a small, focused tool; contributions +that fit the scope are welcome. + +## Scope + +EasyAtCal is a **one-way** sync of easy@work shifts to Apple Calendar. Things +that belong here: + +- Correctness / safety fixes (idempotency, atomic state, backoff). +- Additional read-only sources from easy@work (e.g. extra shift fields). +- Additional calendar backends that mirror the existing `CalendarBackend` + protocol. +- Docs, tests, CI hygiene. + +Things that **don't** belong here: + +- Two-way sync, write-back to easy@work. +- Non-easy@work data sources. +- GUI wrappers. + +If you're unsure, open an issue first. + +## Dev setup + +```bash +git clone git@github.com:Ailcope/EasyAtCal.git +cd EasyAtCal +python3.12 -m venv .venv +.venv/bin/pip install -e '.[dev]' +``` + +Optional (macOS EventKit backend): + +```bash +.venv/bin/pip install -e '.[eventkit]' +``` + +## Workflow + +1. Branch off `main`. +2. TDD: write the failing test first, make it pass, keep diffs small. +3. Keep commits focused; rebase before opening the PR. +4. Run `make check` (or the commands below) before pushing. + +## Quality gates + +```bash +.venv/bin/ruff check easyatcal tests # lint +.venv/bin/ruff format easyatcal tests # format +.venv/bin/pytest --cov=easyatcal --cov-fail-under=85 +``` + +CI runs the same three on Linux + macOS × Python 3.11/3.12. PRs below 85% +coverage will fail. + +Pre-commit hooks are available — `pre-commit install` once and they run on +every `git commit`. + +## Commit style + +- Imperative subject, concise. `feat(cli): add --dry-run flag to sync`. +- Prefixes we use: `feat`, `fix`, `chore`, `docs`, `ci`, `refactor`, `release`. +- Don't add `Co-Authored-By` trailers. + +## Reporting bugs / security + +- Functional bugs: open a GitHub issue with `eaw-sync doctor` output and log + excerpt. +- Security: see [`SECURITY.md`](./SECURITY.md); don't file a public issue. + +## License + +Contributions are licensed under the MIT License (see [`LICENSE`](./LICENSE)). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..53c3c70 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + EAW_CONFIG_PATH=/data/config.yaml + +WORKDIR /app + +# Install dependencies first for better caching +COPY pyproject.toml README.md ./ +# Create dummy package so pip install doesn't fail +RUN mkdir easyatcal && touch easyatcal/__init__.py +RUN pip install --no-cache-dir . + +# Copy full application +COPY . . +# Reinstall to ensure exact matching metadata +RUN pip install --no-cache-dir . + +# Ensure data directory exists +RUN mkdir -p /data + +# Run eaw-sync by default +ENTRYPOINT ["eaw-sync"] +CMD ["watch"] \ No newline at end of file diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..ee124dc --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,116 @@ +# HANDOFF — EasyAtCal project context + +Full list of files to hand to another AI so it has every bit of context from this session. The AI should use **only** the files in this list; no external lookups required to understand what was built and why. + +## 1. Session transcript (MOST IMPORTANT) + +Contains the entire conversation: user messages, assistant reasoning, tool calls, tool results, and the final TodoWrite list. JSONL format, one event per line. + +- `/Users/ailcope/.claude/projects/-Users-ailcope-ClaudeCode-EasyAtWork/83e5cde7-97ff-4b9f-b116-8c05c6540380.jsonl` + +Claude Code stores the todo list inline in the transcript as TodoWrite tool calls — no separate todo file to hand over. + +## 2. Design spec & plan + +- `/Users/ailcope/ClaudeCode/EasyAtWork/docs/superpowers/specs/2026-04-19-easyatcal-design.md` +- `/Users/ailcope/ClaudeCode/EasyAtWork/docs/superpowers/plans/2026-04-19-easyatcal-implementation.md` + +## 3. Project root / packaging / ops + +- `/Users/ailcope/ClaudeCode/EasyAtWork/pyproject.toml` +- `/Users/ailcope/ClaudeCode/EasyAtWork/.gitignore` +- `/Users/ailcope/ClaudeCode/EasyAtWork/README.md` +- `/Users/ailcope/ClaudeCode/EasyAtWork/CHANGELOG.md` +- `/Users/ailcope/ClaudeCode/EasyAtWork/LICENSE` +- `/Users/ailcope/ClaudeCode/EasyAtWork/config.example.yaml` +- `/Users/ailcope/ClaudeCode/EasyAtWork/.pre-commit-config.yaml` +- `/Users/ailcope/ClaudeCode/EasyAtWork/.github/workflows/ci.yml` +- `/Users/ailcope/ClaudeCode/EasyAtWork/.github/workflows/publish.yml` +- `/Users/ailcope/ClaudeCode/EasyAtWork/examples/launchd/com.easyatcal.watch.plist` + +## 4. Source — `easyatcal/` + +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/__init__.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/models.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/config.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/state.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/api.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/sync.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/orchestrator.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/cli.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/paths.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/api_session.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/session.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/auth_user.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/logging_setup.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/backends/__init__.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/backends/base.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/backends/ics.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/backends/eventkit.py` + +## 5. Tests — `tests/` + +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/__init__.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/conftest.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/fixtures/config_valid.yaml` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_models.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_config.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_state.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_api_auth.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_api_fetch.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_sync.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_orchestrator.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_config.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_config_path.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_sync.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_auth.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_login.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_api_session.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_session.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_doctor.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_state.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_logging_setup.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_e2e_ics.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/backends/__init__.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/backends/test_base.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/backends/test_ics.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/backends/test_eventkit.py` + +## Deliberately excluded + +- `.venv/` — regenerate with `python3.12 -m venv .venv && .venv/bin/pip install -e '.[dev]'` +- `.git/` — repo is mirrored at `git@github.com:Ailcope/EasyAtCal.git` (tag `v0.1.0`; `main` at `HEAD`) +- `config.yaml`, `state.json`, `token.json`, `*.ics` — never committed; user data / secrets +- `.pytest_cache/`, `__pycache__/`, `*.pyc` — build artifacts + +## Status at handoff + +- 53 tests passing locally (Python 3.12 on macOS). EventKit tests skipped on Linux in CI. +- Coverage gate: CI fails under 85% (see `.github/workflows/ci.yml`). +- Ruff clean; `.pre-commit-config.yaml` wires ruff + ruff-format + whitespace hooks. +- Remaining wiring work for a real user: run `eaw-sync config init`, fill in real easy@work parameters (`customer_id`, `employee_id`), run `eaw-sync login` to generate the session JWT via headless Playwright, pick `ics` or `eventkit` backend, then `eaw-sync sync` (or `eaw-sync doctor` first). + +## Auth Narrative Pivot + +**Critical context:** We pivoted away from pure OAuth `client_credentials`. +Authentication is now handled via **JWT Bearer** token extracted from Playwright's `localStorage` after a headless UI login. The token is replayed against `.api.easyatwork.com/customers/{cid}/employees/{eid}/shifts`. +- *Commit Ref:* `48cb8b0` (JWT pivot) and `323f338` (session-cookie pivot intermediate). +- No refresh flow is implemented: JWT expires in ~1y; users must rerun `eaw-sync login` when a 401 occurs. +- `auth_user.py` uses Playwright to capture this token. It needs a live Playwright run for smoke verification before claiming absolute production-readiness. + +## What was added beyond the original 19-task plan + +- `LICENSE` (MIT), `CHANGELOG.md`, expanded `README.md`. +- Atomic state sync: `ApplyResult` + `BackendError(partial)`; orchestrator persists partial progress then re-raises. +- CLI: `eaw-sync doctor`, `eaw-sync state show`, `eaw-sync sync --dry-run`, global `--config-path` override, `--install-completion`. +- Sync exit codes (0 clean / 1 partial / 2 fatal) and post-sync summary line. +- `logging.format: text|json` (JSON formatter for log aggregators). +- `watch` handles `SIGTERM` gracefully with 1-second sleep slices. +- API backoff honors `Retry-After` header on 429/5xx. +- Ruff lint + pre-commit hooks + CI lint stage + 85% coverage gate. +- PyPI publish workflow on `v*` tags (trusted publisher; configure on pypi.org). +- launchd agent template at `examples/launchd/com.easyatcal.watch.plist`. + +## Unverified assumptions + +Flagged in spec "Open questions": exact easy@work API endpoint paths and pagination shape. The PHP client at `https://github.com/easyatworkas/php-eaw-client` is the reference — inspect it if the default paths in `api.py` are wrong. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bc1537a --- /dev/null +++ b/LICENSE @@ -0,0 +1,75 @@ +Copyright (c) 2026 Ailcope + +# PolyForm Noncommercial License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Changes and New Works License + +The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms. + +**You** refers to the individual or entity agreeing to these terms. + +**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the software under these terms. + +**Use** means anything you do with the software requiring one of your licenses. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0e9a245 --- /dev/null +++ b/Makefile @@ -0,0 +1,53 @@ +PY := .venv/bin/python +PIP := .venv/bin/pip +PYTEST := .venv/bin/pytest +RUFF := .venv/bin/ruff +MYPY := .venv/bin/mypy + +.PHONY: help venv install lint types fmt test cov check build clean + +help: + @echo "make venv - create .venv and install in dev mode" + @echo "make install - re-install package with dev extras" + @echo "make lint - ruff check" + @echo "make types - mypy strict" + @echo "make fmt - ruff format" + @echo "make test - run pytest" + @echo "make cov - run pytest with coverage + 85% gate" + @echo "make check - lint + types + cov (what CI does)" + @echo "make build - build sdist + wheel into dist/" + @echo "make clean - wipe build artifacts" + +venv: + python3.12 -m venv .venv + $(PIP) install --upgrade pip + $(PIP) install -e '.[dev]' + +install: + $(PIP) install -e '.[dev]' + +lint: + $(RUFF) check easyatcal tests + +types: + $(MYPY) easyatcal + +fmt: + $(RUFF) format easyatcal tests + $(RUFF) check --fix easyatcal tests + +test: + $(PYTEST) + +cov: + $(PYTEST) --cov=easyatcal --cov-fail-under=85 + +check: lint types cov + +build: + $(PY) -m pip install --upgrade build + $(PY) -m build + +clean: + rm -rf build/ dist/ *.egg-info .pytest_cache .coverage htmlcov + find . -type d -name __pycache__ -prune -exec rm -rf {} + diff --git a/README.md b/README.md index e69de29..104346f 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,157 @@ +
+ +# EasyAtCal + +[![CI](https://img.shields.io/github/actions/workflow/status/Ailcope/EasyAtCal/ci.yml?branch=main&label=CI&logo=github&logoColor=white&color=brightgreen)](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml) +[![Coverage](https://img.shields.io/badge/Coverage-90%25-brightgreen.svg?logo=codecov&logoColor=white)](https://github.com/Ailcope/EasyAtCal) +[![Release](https://img.shields.io/github/v/release/Ailcope/EasyAtCal?label=Release&logo=github&logoColor=white&color=blue)](https://github.com/Ailcope/EasyAtCal/releases) +[![Python 3.11+](https://img.shields.io/badge/Python-3.11+-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/) +[![License: PolyForm NC](https://img.shields.io/badge/License-PolyForm%20NC-orange.svg?logo=opensourceinitiative&logoColor=white)](./LICENSE) + +**One-way sync of easy@work shifts into Apple Calendar, Google Calendar, or standard ICS files.** + +Works with **macOS EventKit** • **Google Calendar** • **Windows Outlook** + +[Quickstart](#quickstart) • [Configuration](#configuration) • [Backends](#backends) • [Commands](#cli-commands) + +
+ +--- + +## Overview + +**EasyAtCal** is a CLI tool designed to automatically synchronize your [easy@work](https://www.easyatwork.com) work schedule directly into Apple Calendar, Google Calendar, or standard ICS files. It runs locally, fetches your upcoming shifts, and pushes them to your preferred personal calendar app. It can even be run as a background daemon to keep your calendar up to date continuously! + +### What is easy@work? +**easy@work** is a popular workforce management, timesheet, and employee scheduling platform used by major global brands, retail stores, and fast-food chains—most notably **McDonald's**. If you work at a McDonald's restaurant or any other company that uses the easy@work employee portal to handle your shift planning and rotas, **EasyAtCal** is the perfect companion to automate your personal schedule management. + +### Features + +- **Automated Login & Discovery.** No public API required. It uses Playwright to securely log in via a headless browser, extracting both your session token and your unique account IDs (`customer_id`, `employee_id`) automatically. +- **Secure Session.** Your JWT is securely cached in your OS's native credential store (Keychain on macOS, Credential Locker on Windows) via the `keyring` library. +- **Background Sync.** Includes a built-in `schedule` command to easily install an auto-updating background daemon (macOS `launchd`, Linux `cron`, or Windows Task Scheduler). +- **Customizable Events.** Configure your own event titles (e.g. `[Work] {title} at {location}`) and add automatic alarms/reminders for your shifts. +- **Two Backends.** Native macOS **EventKit** integration (pushes directly to Apple Calendar) or portable **ICS** file generation (supports interactive import prompts for Google Calendar and Windows Outlook). +- **Idempotent.** State-tracked logic means unchanged shifts are skipped, while schedule updates and cancellations propagate automatically. +- **Bilingual CLI.** Automatically detects English or French system locales and adjusts interactive prompts. + +## Quickstart + +### 1. Install + +Install the core application and the Playwright browser dependencies (required for headless login). + +```bash +pip install 'easyatcal[playwright]' +playwright install chromium +``` + +*(If you are on macOS and want native Apple Calendar integration, use `pip install 'easyatcal[eventkit,playwright]'`)* + +### 2. Configure + +Scaffold the default configuration file: + +```bash +eaw-sync config init +``` + +Now, open the configuration file (located at `~/.config/easyatcal/config.yaml` on Linux or `~/Library/Application Support/easyatcal/config.yaml` on macOS) and fill in your details: + +```yaml +easyatwork: + email: "your.email@example.com" + # Optional: api_url, customer_id, and employee_id are now automatically discovered! +``` + +You can optionally configure event titles and alarms: + +```yaml +sync: + event_title_format: "EasyAtWork: {title}" + alarm_minutes_before: 60 # Remind me 1 hour before my shift +``` + +### 3. Log In + +Run the interactive login command. It prompts securely for your password, launches a headless Chromium browser, logs you in, automatically discovers your account IDs (`customer_id`/`employee_id`), and saves your session token securely using your OS keyring. + +```bash +eaw-sync login +``` + +### 4. Sync Your Calendar + +Run the sync command. If you are using the default `.ics` backend, it will download your shifts and interactively ask if you want to open Apple Calendar, Windows Outlook, or Google Calendar to complete the import. + +```bash +eaw-sync sync +``` + +## Background Sync + +To keep your calendar up to date continuously, EasyAtCal can run in the background. + +Use the `schedule` command to set up an OS-level background task (macOS `launchd`, Linux `crontab`, or Windows Task Scheduler). The background job will run `eaw-sync sync` silently every few hours. + +```bash +# Display the necessary configuration to set up background sync +eaw-sync schedule --interval-hours 6 + +# Alternatively, have it install automatically on macOS/Linux +eaw-sync schedule --install --interval-hours 6 +``` + +Alternatively, run EasyAtCal in daemon loop mode manually: + +```bash +eaw-sync watch --interval-seconds 900 # Syncs every 15 minutes +``` + +*Note: Your login token expires roughly once a year. If the daemon starts failing with authentication errors, simply run `eaw-sync login` again.* + +## Backends + +### 1. ICS (Cross-platform) +Generates a portable `.ics` file locally. When you run `eaw-sync sync`, the CLI interactively offers to open your local calendar app or open the Google Calendar import page. + +### 2. EventKit (macOS Only) +Writes directly to a dedicated calendar in the macOS Calendar.app via native APIs. + +**IMPORTANT:** You must create the target calendar manually *before* your first sync. +1. Open **Calendar.app**. +2. Go to **File → New Calendar** and choose the source (e.g., `iCloud`). +3. Name it exactly what you put in your config (e.g., `EasyAtWork`). +4. Update your config: set `backend: eventkit`. +5. Run `eaw-sync sync`. +6. Grant calendar access when macOS prompts you. + +## CLI Commands + +| Command | Description | +|---------|------| +| `eaw-sync config init` | Scaffold the configuration file. | +| `eaw-sync config show` | Print active configuration (secrets redacted). | +| `eaw-sync login` | Opens a headless browser to log in and save your session token. | +| `eaw-sync doctor` | Checks config validity, token liveliness, and API reachability. | +| `eaw-sync sync` | Run a single sync pass. | +| `eaw-sync sync --dry-run` | Diff remote shifts against local state without writing. | +| `eaw-sync watch` | Run the sync in an infinite loop. | +| `eaw-sync schedule` | Generate or install OS-level background sync (`launchd`, `cron`). | +| `eaw-sync --install-completion` | Install shell autocomplete (bash/zsh/fish). | + +### Exit codes (`sync`) + +| Code | Meaning | +|------|---------| +| 0 | All changes applied successfully. | +| 1 | Partial failure (some changes applied, backend error on others). | +| 2 | Fatal (config/auth/network failed before writing). | + +## Security + +Your easy@work password is **never stored on disk**. The configuration file only stores your email. When you run `eaw-sync login`, the password is used once to drive the browser, and the resulting JSON Web Token (JWT) is extracted and saved securely in your OS's native credential store using `keyring` (macOS Keychain, Windows Credential Locker, Linux Secret Service). Non-sensitive session data (like your `customer_id` and UI state) is saved in `~/.local/state/easyatcal` with strict `0600` permissions. + +## License + +[PolyForm Noncommercial 1.0.0](./LICENSE) — free for noncommercial use; commercial use or reselling the code requires the author's permission. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d5dbd39 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,49 @@ +# Security Policy + +## Supported versions + +Only the latest minor version gets security fixes. Pin to `easyatcal>=0.2` in +production. + +## Reporting a vulnerability + +**Do not file a public GitHub issue.** + +Email the maintainer at `security@ailcope.dev` with the subject `[SECURITY] EasyAtCal`. Include: + +- Affected version (`eaw-sync --version`). +- Reproduction steps or proof-of-concept. +- Your assessment of impact. + +You'll get an acknowledgement within 7 days. A fix — or a concrete plan — +within 30 days. Coordinated disclosure once a release is available. + +## Threat model + +EasyAtCal is a local CLI that: + +- Reads easy@work OAuth2 credentials from `config.yaml`. +- Writes an OAuth token cache to `~/Library/Caches/easyatcal/token.json` + (or the XDG equivalent) with `0600` permissions. +- Writes to Apple Calendar via EventKit or to a local `.ics` file. +- Writes a local `state.json` with shift-id → event-uid mapping. + +It does not: + +- Accept network input on any listening port. +- Execute anything from the remote API beyond the JSON it receives. +- Upload anything beyond OAuth requests to the configured `base_url`. + +Likely classes of issue worth reporting: + +- Secrets or tokens leaking into logs / stdout / `state.json`. +- State-file path traversal or TOCTOU races. +- Calendar-event or `.ics` content derived from remote data without proper + escaping causing local client problems. +- Dependency CVEs we haven't bumped past. + +## Responsible research + +Please do not run destructive tests against third-party easy@work tenants. A +local mock (see `tests/test_api_*.py` for `respx` examples) is the right way +to reproduce most issues. diff --git a/beacon_readme.md b/beacon_readme.md new file mode 100644 index 0000000..924f5bf --- /dev/null +++ b/beacon_readme.md @@ -0,0 +1,352 @@ +
+ +# BeaconMCP + +[![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/) +[![MCP Protocol](https://img.shields.io/badge/MCP-Model_Context_Protocol-5A67D8)](https://modelcontextprotocol.io/) +[![Proxmox VE](https://img.shields.io/badge/Proxmox-VE_8.x-E57000?logo=proxmox&logoColor=white)](https://www.proxmox.com/) +[![HP iLO](https://img.shields.io/badge/HP-iLO_4%2F5-0096D6?logo=hp&logoColor=white)](https://www.hpe.com/us/en/servers/integrated-lights-out-ilo.html) +[![IPMI](https://img.shields.io/badge/IPMI-2.0-4E5D70)](https://en.wikipedia.org/wiki/Intelligent_Platform_Management_Interface) +[![ChatGPT](https://img.shields.io/badge/ChatGPT-Compatible-74AA9C?logo=openai&logoColor=white)](https://chatgpt.com/) +[![Gemini](https://img.shields.io/badge/Gemini-Compatible-4285F4?logo=google&logoColor=white)](https://gemini.google.com/) +[![License](https://img.shields.io/badge/license-Apache_2.0_%2B_Commons_Clause-red)](LICENSE) + +**Remote MCP server for Proxmox VE clusters, BMC-managed hardware, and SSH hosts.** + +Works with **Assistant** (web, mobile, desktop) • **ChatGPT** • **Gemini** (CLI, API) + +[Installation](#installation) • [Connecting clients](#connecting-clients) • [Tools](#available-tools) • [Tests](#tests) + +
+ +--- + +## Overview + +BeaconMCP exposes a Proxmox VE cluster, the hardware underneath it (HP iLO, generic IPMI), and arbitrary SSH-reachable hosts as a single Streamable HTTP MCP server. Any MCP-capable client can diagnose a crash, power-cycle a frozen host, create or migrate VMs, and execute commands inside guests or on bare-metal nodes — through a single OAuth 2.1 endpoint. + +- **Independent capabilities.** Enable only what you have: a full Proxmox cluster, a couple of VPS reachable by SSH, a rack with IPMI BMCs only, or any combination. The server registers tools per capability, so an SSH-only deployment never exposes `proxmox_*` tools. +- **Three deployment modes out of the box:** + - *Proxmox + BMC + SSH* — the reference setup (a Proxmox cluster with iLO/IPMI hardware). + - *SSH-only* — point it at a handful of VPS or bare-metal servers; get the unified `ssh_run` tool backed by per-host credentials. + - *Proxmox-only* or *BMC-only* — mix and match as your inventory grows. +- **30+ MCP tools** across four modules: Proxmox (monitoring, VM lifecycle, system), SSH (per-host multi-target), BMC (hardware power/health), and security. +- **N nodes, N BMC devices, N SSH hosts.** No hard-coded counts. Each SSH host carries its own credentials (password or key file) and is declared under `ssh.hosts[]`. +- **Backend-agnostic hardware layer.** HP iLO, generic IPMI, and a universal **Redfish REST API** backend ship out of the box. Dell iDRAC (14G+) and Supermicro (X11+) automatically use the Redfish backend. +- **YAML-first configuration** with `${ENV}` references for secrets. Validation runs at startup. +- **OAuth 2.1 + TOTP.** Client credentials with mandatory second factor on every token issuance. +- **Optional web dashboard** — login, API-token management, and an (optional) integrated Gemini chat panel. + +--- + +## Architecture + +``` +Clients (Assistant, ChatGPT, Gemini) + │ + │ HTTPS (reverse proxy / tunnel) + ▼ +┌──────────────────────────────────┐ +│ BeaconMCP (HTTP :8420) │ +│ ├── proxmox/ → Proxmox API │ +│ ├── ssh/ → SSH :22 │ +│ ├── bmc/ → iLO / IPMI │ +│ └── dashboard/ → /app/* │ +└──────────────────────────────────┘ + │ + │ managed cluster + ▼ +Proxmox nodes (N) · BMC devices (N) +``` + +BeaconMCP runs on any host that can reach the Proxmox API of every declared node and the BMC management network. It speaks MCP over Streamable HTTP and is typically placed behind a reverse proxy with DNS-rebinding protection configured via `server.allowed_hosts` in the YAML. + +**Recommended deployment:** put BeaconMCP on the **same local network** as your Proxmox cluster — on one of the nodes, in a dedicated LXC / VM, or in a Docker container with host networking (see *Docker* below). That way every `proxmox.nodes[].host` is a plain **LAN IP** (e.g. `10.0.0.1`, `10.0.0.2`), usable as-is for both the Proxmox API (`:8006`) and for SSH (`:22`) — including the `ssh.inherit_proxmox_nodes` shortcut and the `bmc_*` SSH-jump tunnel for HP iLO on a private management VLAN. + +Public FQDNs with reverse-proxy ports (`pve2.example.com:443`) pin the entry to HTTPS and break the SSH inheritance — the SSH service is on port 22 of the node, not behind the HTTPS tunnel. For a truly remote node, declare it explicitly under `ssh.hosts[]` with its real SSH address (Tailscale IP, VPN, bastion…). + +--- + +## Requirements + +- Python 3.11+ +- Proxmox VE 8.x with API tokens provisioned on each node (Datacenter → Permissions → API Tokens) +- *(optional)* `ipmitool` binary on the BeaconMCP host if any IPMI BMC is configured +- *(optional)* reachable jump host (a Proxmox node) for HP iLO devices exposed only on a private management VLAN +- *(optional)* `GEMINI_API_KEY` to enable the integrated chat panel + +--- + +## Installation + +Two supported paths: **Docker** (quickest, isolated) or the **bare-metal install script** (native systemd service). Pick whichever fits your infra — they expose the same CLI and HTTP surface. + +### Option A — Docker (recommended for most setups) + +Requires Docker Engine 20.10+ with the Compose plugin. Runs on the Proxmox node itself, inside an LXC/VM on the same LAN, or on any box that can reach every declared node's API and SSH port directly. + +```bash +git clone https://github.com/Showdown76py/BeaconMCP.git +cd BeaconMCP +cp beaconmcp.yaml.example beaconmcp.yaml # edit for your topology +cp .env.example .env # fill in the ${VAR} secrets +docker compose up -d +``` + +The bundled [`docker-compose.yml`](docker-compose.yml) uses `network_mode: host` so the container sits directly on the LAN — LAN IPs in `proxmox.nodes[].host` just work for both the Proxmox API (`:8006`) and SSH (`:22`), which is what makes the `ssh.inherit_proxmox_nodes` shortcut practical. State (OAuth clients, dashboard DB, usage history) lives in a named volume `beaconmcp-state` and survives container recreation. + +Initial setup (run once, while the container is up): + +```bash +docker compose exec beaconmcp beaconmcp validate-config +docker compose exec beaconmcp beaconmcp auth create --name "Assistant Web" +curl http://localhost:8420/health # should return {"status":"ok",...} +``` + +The container listens on port 8420; put HTTPS + your FQDN in front with any reverse proxy (Caddy, nginx, Traefik, Cloudflare tunnel). + +**SSH key files.** If any of your `ssh.hosts[]` entries (or `ssh.defaults`) use `key_file:`, either copy the keys into the `beaconmcp-state` volume and reference them via `/state/keys/...`, or uncomment the `~/.ssh` bind mount in the compose file. Host paths like `~/.ssh/id_ed25519` don't exist inside the container — they're resolved against the container's filesystem. + +### Option B — Bare-metal install script + +SSH to the Proxmox node that will host BeaconMCP (we recommend your primary node — `pve1` in typical setups), then: + +```bash +git clone https://github.com/Showdown76py/BeaconMCP.git /opt/beaconmcp +cd /opt/beaconmcp +sudo bash deploy/install.sh +``` + +The install script creates a `beaconmcp` system user, installs the package in editable mode, registers a systemd unit, and creates `/opt/beaconmcp` for persistent state. + +### 2. Configure + +Two ways to produce `beaconmcp.yaml`: + +**Guided (TUI wizard).** A terminal UI walks you through each capability (Proxmox nodes, SSH, BMC, server) with a live YAML preview on the right and adds `${VAR}` placeholders to `.env` for the secrets you'll fill in after. The same command also **edits an existing** `beaconmcp.yaml` — it parses the file into the wizard, so you can tweak and re-save without losing anything: + +```bash +pip install 'beaconmcp[wizard]' # pulls the optional textual dep +beaconmcp init # creates OR edits beaconmcp.yaml, extends .env +beaconmcp init --blank # force a fresh draft even if the YAML exists +``` + +Arrow keys to browse sections, `enter` to open forms, `ctrl+s` to save without quitting, `q` to exit. + +**Manual.** Copy the example and edit: + +```bash +cp beaconmcp.yaml.example /opt/beaconmcp/beaconmcp.yaml +cp .env.example /opt/beaconmcp/.env +# Edit both: YAML defines the topology, .env holds the secrets. +``` + +Either way, the YAML declares Proxmox nodes, BMC devices, SSH credentials, the dashboard configuration, and DNS-rebinding allowlists. Secrets are referenced via `${ENV_VAR}` placeholders resolved at startup against the `.env` file. Validate the result without starting the server: + +```bash +beaconmcp validate-config +# prints the fully-resolved config with secrets masked, and a one-line summary. +``` + +### 3. Provision an OAuth client + +```bash +beaconmcp auth create --name "Assistant Web" +``` + +The CLI prints a client id, a client secret, and a TOTP seed (with an ASCII QR code). **Both secrets are displayed exactly once.** Scan the QR into an authenticator app (Google Authenticator, Authy, 1Password) immediately, or store the raw seed in a secrets manager. + +Repeat for each MCP client that should have access (ChatGPT, Gemini, etc.). Clients are listed and revoked with: + +```bash +beaconmcp auth list +beaconmcp auth revoke +``` + +### 4. Start the server + +```bash +sudo systemctl enable --now beaconmcp +curl http://localhost:8420/health +# {"status":"ok","server":"beaconmcp"} +``` + +### 5. Expose publicly + +Place BeaconMCP behind a reverse proxy that terminates TLS and forwards the public hostname to `http://localhost:8420`. Declare that hostname under `server.allowed_hosts` in `beaconmcp.yaml`; without it the MCP SDK rejects incoming requests with `421 Misdirected Request` (DNS-rebinding protection). If you're proxying through Cloudflare, add `cloudflare` to `server.trusted_proxies` so BeaconMCP can safely trust forwarded client IPs for auth rate limiting. + +### 6. Updating BeaconMCP + +Updating BeaconMCP requires pulling the latest code from GitHub and restarting the service. + +**For Docker setups:** +```bash +cd BeaconMCP +git pull +docker compose up -d --build +``` + +**For Bare-metal (systemd) setups:** +The installer script doubles as an updater. It will automatically stash your current state, pull the latest code, install any new dependencies into the virtual environment, and restart the service: +```bash +sudo bash /opt/beaconmcp/deploy/install.sh +``` + +--- + +## Connecting clients + +> **Security note — always type the TOTP by hand from your phone.** +> The TOTP seed belongs in an authenticator app on a device you physically control (Google Authenticator, Authy, 1Password, Aegis, a YubiKey with OTP, etc.). Do **not** generate codes programmatically with `oathtool` / `pyotp` / a shell alias, and do **not** store the raw seed in a `.env`, a secrets manager, or next to the client secret — doing so collapses the two factors into one and removes the protection TOTP exists to provide. Every flow below is designed so you read a 6-digit code off your phone and type it into either the authorization page or the dashboard. +> +> Unattended services (scheduled jobs, CI pipelines) occasionally need machine-held TOTP. That case — with its required precautions and warnings — is covered separately in [docs/totp-automation.md](docs/totp-automation.md). Read it end-to-end before considering automation. + +### Assistant (web, mobile, desktop) + +Assistant performs the full OAuth 2.1 flow against BeaconMCP, so there is no long-lived bearer to store on its side — you type the TOTP into the authorization page whenever a new token is issued. + +1. **Settings → Integrations → Add custom connector.** +2. Fill in: + - **Name:** BeaconMCP + - **Remote MCP server URL:** `https:///mcp` + - **OAuth Client ID** and **OAuth Client Secret** from `beaconmcp auth create`. +3. **Add.** + +On first use (and after each 24-hour token expiry) Assistant redirects to the BeaconMCP authorization page. Read the current 6-digit code from your authenticator app and type it in. Assistant never holds the TOTP seed, and a leaked session cannot mint a new token without a fresh code from your phone. + +**Important — web-origin allowlist.** Every browser-based MCP client (Assistant Web, ChatGPT, Le Chat, Perplexity, Gemini Web) sends a CORS preflight before it can reach `/mcp`, and OAuth HTTPS `redirect_uri` checks use the same list. Add each client's origin to `server.allowed_origins` in `beaconmcp.yaml` (see [`beaconmcp.yaml.example`](beaconmcp.yaml.example)). Desktop and CLI callback forms (`vscode://`, `cursor://`, loopback) are handled separately. + +### Other clients + +Full setup for **ChatGPT** (Web / Mobile / Codex CLI), **Gemini** (CLI / Antigravity / API), **Mistral** (Le Chat + Vibe), **OpenCode**, **VS Code**, and **Cursor** lives in [docs/clients.md](docs/clients.md). The dashboard's `/app/tokens` page shows the same snippets interactively. Perplexity is deprecating MCP (March 2026) and is no longer supported. + +--- + +## Dashboard + +An optional web panel is mounted under `/app/*` on the same port as the MCP endpoint. It provides TOTP login, an API-token management page (used to wire external clients like the Gemini web UI or ChatGPT MCP without exposing the OAuth flow), and an optional integrated Gemini chat. The chat panel is gated by `GEMINI_API_KEY`; the tokens page works without it. + +Full reference: [docs/dashboard.md](docs/dashboard.md). See also: [docs/clients.md](docs/clients.md) for external MCP client configuration. + +--- + +## Configuration + +Two files are read at startup: + +- **`beaconmcp.yaml`** — topology and feature flags. Path resolution: `--config` flag → `BEACONMCP_CONFIG` env → `./beaconmcp.yaml` → `/etc/beaconmcp/config.yaml`. See [`beaconmcp.yaml.example`](beaconmcp.yaml.example) for the full schema. +- **`.env`** — secrets referenced by the YAML as `${VAR}`. Missing references fail the startup check with the offending YAML path. + +Common keys: + +| Section | Notes | +|---------|-------| +| `server.allowed_hosts` | DNS-rebinding allowlist — **must** include the public FQDN behind your reverse proxy. | +| `server.allowed_origins` | Web-origin allowlist for browser CORS and OAuth HTTPS redirect URIs. | +| `server.trusted_proxies` | Direct peers allowed to supply `X-Forwarded-For` (IPs or CIDRs). Use `cloudflare` to auto-expand Cloudflare edge ranges. | +| `proxmox.nodes[]` | One entry per Proxmox node. Needs an API token per node. Prefer a **LAN IP** in `host:` (e.g. `10.0.0.1`) — it's the one string that works for both the Proxmox API and for SSH inheritance. `localhost` is OK when BeaconMCP runs directly on that node. Only use an FQDN with a reverse-proxy port (e.g. `:443`) for nodes you can't reach on the LAN, and declare those explicitly under `ssh.hosts[]` with their real SSH address. | +| `ssh.hosts[]` | One entry per SSH target (VPS, Proxmox node, jump box, …). Each entry carries its own `user` + exactly one of `password` / `key_file`. Names may match `proxmox.nodes[].name`. | +| `ssh.defaults` + `ssh.inherit_proxmox_nodes` | Homelab shortcut. Set `defaults:` (user + password/key_file) and flip `inherit_proxmox_nodes: true` — every Proxmox node becomes SSH-reachable under its own name with those defaults, no duplication. Explicit `ssh.hosts[]` entries still win when they match a node by name or address. | +| `ssh.vmid_to_ip` | Optional template (e.g. `"192.168.1.{id}"`) used by `ssh_run` when the `host` argument is a bare VMID. The resolved IP must match an `ssh.hosts[].host` to authenticate. Omit to disable numeric-ID shortcuts. | +| `bmc.devices[]` | Zero or more BMCs. `type` is one of `hp_ilo`, `ipmi`, `idrac` (redfish), `supermicro` (redfish), or `redfish`. `jump_host` is optional — set it to the name of a `proxmox.nodes[]` entry to route the connection over an SSH tunnel. | +| `features.dashboard.limits` | Per-5h and per-week USD caps for the Gemini chat. Set to `0` to disable a window. | + +--- + +## Security: manual review of sensitive actions + +> **Never let an LLM execute shell commands on infrastructure you care about without reading the command first.** + +BeaconMCP exposes tools that cause irreversible changes: `ssh_run`, `proxmox_run`, `bmc_power_off`, `proxmox_vm_stop`, `proxmox_vm_create`, `vm_bulk_action`, and more. Models do not always grasp the consequences of a command — an errant `rm -rf`, a `systemctl stop` on the wrong unit, a `pct destroy` mistaken for `pct stop`. A few working rules: + +- **Disable auto-approve** on every external MCP client (Assistant Desktop, Gemini CLI, ChatGPT MCP). Keep per-call approval enabled; refuse "always allow this tool". +- **Read the `command` argument** before approving any `ssh_run` or `proxmox_run` call. Ask: if this ran against the wrong VM or host, could I recover? +- **The integrated chat** at `/app/chat` already forces human confirmation for every `ssh_run` / `proxmox_run` call that carries a `command` (polling-only calls with just `exec_id` are read-only and skip the modal). Read the arguments shown on the confirmation card even when you click through fast. No answer within 5 minutes counts as refusal. +- **Prefer read-only tools** (`*_list_*`, `*_status`, `*_get_*`, `get_logs`, `health_status`) for exploration — they cannot break anything and are never gated by confirmation. +- **Do not share a `/app/tokens` bearer** with a client you do not fully control. A leaked token grants arbitrary shell access on your Proxmox nodes for 24 hours. + +`systemctl restart beaconmcp` invalidates every in-memory bearer. When in doubt about a token, restart is the panic lever. + +--- + +## Available tools + +### Proxmox — monitoring (6) + +| Tool | Description | +|------|-------------| +| `proxmox_list_nodes` | List cluster nodes and their status. | +| `proxmox_node_status` | CPU, memory, disk, uptime of a single node. | +| `proxmox_list_vms` | List every VM and container across the cluster. | +| `proxmox_vm_status` | Detailed state of a VM or container. | +| `proxmox_get_logs` | System or task logs. | +| `proxmox_get_tasks` | Recent task history. | + +### Proxmox — VM lifecycle (7) + +| Tool | Description | +|------|-------------| +| `proxmox_vm_start` | Start a VM or container. | +| `proxmox_vm_stop` | Stop (clean or forced). | +| `proxmox_vm_restart` | Restart. | +| `proxmox_vm_create` | Provision a new VM or container. | +| `proxmox_vm_clone` | Clone an existing one. | +| `proxmox_vm_migrate` | Migrate across nodes. | +| `proxmox_vm_config` | Read or update configuration. | +| `proxmox_snapshot_list` | List all snapshots for a VM or container. | +| `proxmox_snapshot_create` | Create a new snapshot. | +| `proxmox_snapshot_rollback` | Rollback a VM/CT to a previous snapshot. | +| `proxmox_snapshot_delete` | Delete an existing snapshot. | +| `proxmox_backup_create` | Trigger a new backup of a VM or container. | +| `proxmox_backup_list` | List available vzdump backup archives on a storage pool. | +| `proxmox_backup_restore` | Restore a VM or container from a backup archive. | + +### Proxmox — system (3) + +| Tool | Description | +|------|-------------| +| `proxmox_storage_status` | Storage pool status. | +| `proxmox_network_config` | Network configuration per node. | +| `proxmox_run` | Command inside a QEMU VM via QEMU Guest Agent. Sync by default; pass `wait=False` to start async, or `exec_id=` to poll an existing session. For LXC containers, use `ssh_run` on the node with `pct exec -- `. | +| `proxmox_read_file` | Safely read a file from a VM (via QEMU Guest Agent). | +| `proxmox_write_file` | Safely write a file to a VM (via QEMU Guest Agent). | + +### SSH fallback (2) + +| Tool | Description | +|------|-------------| +| `ssh_run` | Command on a host via SSH. `host` accepts node names, VMIDs, hostnames, or IPs. Sync by default; pass `wait=False` to start async, or `exec_id=` to poll. | +| `ssh_list_sessions` | List active and recent SSH sessions. | + +### BMC — hardware management (8) + +| Tool | Description | +|------|-------------| +| `bmc_list_devices` | List configured BMCs (`id`, `type`). Call first to discover valid `device_id` values. | +| `bmc_server_info` | Server model, serial, firmware. | +| `bmc_health_status` | Temperatures, fans, power supplies, disks, memory. | +| `bmc_power_status` | Current physical power state. | +| `bmc_power_on` | Power on. | +| `bmc_power_off` | ACPI shutdown (or `force=true` to cut power). | +| `bmc_power_reset` | Hard reset. | +| `bmc_get_event_log` | BMC event log (default 50, max 200). | + +Each `bmc_*` action tool takes a `device_id` argument. When only one device is configured, `device_id` is optional and defaults to that device. + +--- + +## Tests + +The project ships unit tests (`pytest`) for the dashboard and configuration, plus an integration script (`python tests/test_integration.py`) that exercises a live Proxmox cluster. Flags, prerequisites, and fixtures are documented in [docs/tests.md](docs/tests.md). + +--- + +## Troubleshooting + +Common errors, their causes, and the fixes that worked are in [docs/troubleshooting.md](docs/troubleshooting.md). + +--- + +## License + +[Apache 2.0 with Commons Clause](LICENSE) — use, fork, and modification are free, but **reselling the software (including as a hosted service) requires a separate commercial license**. The code remains source-available. diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..cd068e8 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,48 @@ +easyatwork: + # "user" = scrape the web SPA via Playwright (recommended — no public API). + # "client" = OAuth client_credentials (only if a public API is ever published). + auth_mode: user + + # ---------- user mode ---------- + email: "me@example.com" # or set EAW_EMAIL + + # Password is NEVER stored on disk. Provide it via env var for `eaw-sync login`: + # EAW_PASSWORD=... eaw-sync login + # or enter it interactively when prompted. + + login_url: "https://app.easyatwork.com/" + app_url: "https://app.easyatwork.com" + + # Regional API host and IDs are now AUTOMATICALLY EXTRACTED during `eaw-sync login`. + # You can leave these as null. + api_url: null + customer_id: null + employee_id: null + + # Mimic the SPA version header. Bump if the API starts rejecting. + ui_version: "2.313.0" + + # Browser is headless by default. Set false to watch the automated login visually. + headless: true + +sync: + lookback_days: 7 + lookahead_days: 90 + user_id: null # null = self + # Customize event titles (e.g. "[Work] {title} at {location}") + event_title_format: "{title}" + # Default alarm for shifts (e.g. 60 for 1 hour before) + alarm_minutes_before: null + +backend: ics # "eventkit" (macOS) or "ics" (All platforms) + +backends: + eventkit: + calendar_name: "EasyAtWork" + calendar_source: "iCloud" # "iCloud" or "On My Mac" + ics: + output_path: "~/Documents/easyatwork-shifts.ics" + +logging: + level: INFO + format: text # "text" or "json" diff --git a/docs/pages/changelog.md b/docs/pages/changelog.md new file mode 100644 index 0000000..d3361aa --- /dev/null +++ b/docs/pages/changelog.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to this project are documented here. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning is +[SemVer](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.0] — 2026-04-20 + +### Changed +- Backends now return `ApplyResult(mapping, deleted_uids)` and raise + `BackendError(message, partial)` on failure. The orchestrator catches the + error, persists partial progress, then re-raises — so a crash mid-apply no + longer leaves `state.json` out of sync with the calendar. + +### Added +- `eaw-sync doctor` preflight command: checks config, auth, backend wiring. +- `eaw-sync state show`: prints local state path, tracked-shift count, last sync. +- `eaw-sync sync --dry-run`: computes adds/updates/deletes without touching + the calendar or state. +- Global `--config-path` override and `--version` flag. +- Defined `sync` exit codes: 0 clean, 1 partial failure (`BackendError`), + 2 fatal (config/auth/network). +- Post-sync summary line (`Sync complete: X added, Y updated, Z deleted.`); + `run_sync` now returns a `SyncSummary`. +- `logging.format: text|json` config option; JSON formatter suitable for log + aggregators. +- `watch` handles `SIGTERM` gracefully (launchctl unload / systemd stop), + sleeps in 1-second slices for quick exit. +- API backoff honors `Retry-After` header on 429/5xx responses. +- `examples/launchd/com.easyatcal.watch.plist`: sample launchd agent for + auto-running `sync` every 15 minutes. +- Ruff config + `.pre-commit-config.yaml`; CI now lints and enforces 85% + coverage. +- `py.typed` marker so downstream projects see EasyAtCal's type hints. +- `CHANGELOG.md`, expanded `README.md`, `LICENSE` (MIT). +- GitHub Actions workflow to publish to PyPI on `v*` tags via trusted + publisher. + +## [0.1.0] — 2026-04-19 + +### Added +- Initial release. +- `Shift` model, pydantic-v2 config loader with env overrides, atomic JSON + state with corrupt-file recovery. +- easy@work OAuth2 client-credentials auth with token cache, paginated + `fetch_shifts`, exponential backoff on 429/5xx. +- Pluggable `CalendarBackend` protocol, diff engine (`compute_changes`). +- ICS file backend and macOS EventKit backend (pyobjc). +- Typer CLI: `config init/show`, `auth test`, `sync`, `watch`. +- GitHub Actions matrix CI (Linux + macOS × Python 3.11/3.12) with + end-to-end ICS test. diff --git a/docs/pages/contributing.md b/docs/pages/contributing.md new file mode 100644 index 0000000..9361ee1 --- /dev/null +++ b/docs/pages/contributing.md @@ -0,0 +1,75 @@ +# Contributing to EasyAtCal + +Thanks for your interest. This project is a small, focused tool; contributions +that fit the scope are welcome. + +## Scope + +EasyAtCal is a **one-way** sync of easy@work shifts to Apple Calendar. Things +that belong here: + +- Correctness / safety fixes (idempotency, atomic state, backoff). +- Additional read-only sources from easy@work (e.g. extra shift fields). +- Additional calendar backends that mirror the existing `CalendarBackend` + protocol. +- Docs, tests, CI hygiene. + +Things that **don't** belong here: + +- Two-way sync, write-back to easy@work. +- Non-easy@work data sources. +- GUI wrappers. + +If you're unsure, open an issue first. + +## Dev setup + +```bash +git clone git@github.com:Ailcope/EasyAtCal.git +cd EasyAtCal +python3.12 -m venv .venv +.venv/bin/pip install -e '.[dev]' +``` + +Optional (macOS EventKit backend): + +```bash +.venv/bin/pip install -e '.[eventkit]' +``` + +## Workflow + +1. Branch off `main`. +2. TDD: write the failing test first, make it pass, keep diffs small. +3. Keep commits focused; rebase before opening the PR. +4. Run `make check` (or the commands below) before pushing. + +## Quality gates + +```bash +.venv/bin/ruff check easyatcal tests # lint +.venv/bin/ruff format easyatcal tests # format +.venv/bin/pytest --cov=easyatcal --cov-fail-under=85 +``` + +CI runs the same three on Linux + macOS × Python 3.11/3.12. PRs below 85% +coverage will fail. + +Pre-commit hooks are available — `pre-commit install` once and they run on +every `git commit`. + +## Commit style + +- Imperative subject, concise. `feat(cli): add --dry-run flag to sync`. +- Prefixes we use: `feat`, `fix`, `chore`, `docs`, `ci`, `refactor`, `release`. +- Don't add `Co-Authored-By` trailers. + +## Reporting bugs / security + +- Functional bugs: open a GitHub issue with `eaw-sync doctor` output and log + excerpt. +- Security: see [`SECURITY.md`](./SECURITY.md); don't file a public issue. + +## License + +Contributions are licensed under the MIT License (see [`LICENSE`](./LICENSE)). diff --git a/docs/pages/index.md b/docs/pages/index.md new file mode 100644 index 0000000..338c812 --- /dev/null +++ b/docs/pages/index.md @@ -0,0 +1,145 @@ +# EasyAtCal + +[![CI](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml/badge.svg)](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml) +[![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen.svg)](https://github.com/Ailcope/EasyAtCal) +[![PyPI](https://img.shields.io/pypi/v/easyatcal.svg)](https://pypi.org/project/easyatcal/) +[![Python](https://img.shields.io/pypi/pyversions/easyatcal.svg)](https://pypi.org/project/easyatcal/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) + +One-way sync of [easy@work](https://www.easyatwork.com) shifts into Apple +Calendar. Run it on a Mac, iCloud fans out to iPhone/iPad/Watch. + +- Read-only against easy@work; never writes back. +- Two backends: native macOS **EventKit** (recommended) or portable **ICS** file. +- State-tracked: unchanged shifts are skipped; edits and deletions propagate. +- Open-source friendly: code is public, your `config.yaml` / `state.json` stay + local (see `.gitignore`). + +## Install + +```bash +pip install easyatcal # core + ICS backend +pip install 'easyatcal[eventkit]' # add macOS EventKit backend +``` + +Python 3.11+. macOS for EventKit; any OS for ICS. + +## Quickstart + +```bash +eaw-sync config init # scaffold config +$EDITOR ~/.config/easyatcal/config.yaml +eaw-sync doctor # check config + auth + backend +eaw-sync sync # one shot +eaw-sync watch --interval-seconds 900 # loop every 15 min +``` + +## Configure + +Minimal `config.yaml`: + +```yaml +easyatwork: + client_id: "REPLACE_ME" + client_secret: "REPLACE_ME" # or export EAW_CLIENT_SECRET + base_url: "https://api.easyatwork.com" + +sync: + lookback_days: 7 + lookahead_days: 90 + +backend: eventkit # or "ics" + +backends: + eventkit: + calendar_name: "Work Shifts" # must exist in Calendar.app + calendar_source: "iCloud" + ics: + output_path: "~/Documents/easyatwork-shifts.ics" + +logging: + level: INFO +``` + +Env overrides: any `easyatwork.*` field is overridable via `EAW_*` (e.g. +`EAW_CLIENT_SECRET`). + +## Backends + +**EventKit (macOS).** Writes directly to a dedicated calendar in Calendar.app. + +**IMPORTANT:** You must create the target calendar manually *before* your first sync! +1. Open **Calendar.app** +2. Go to **File → New Calendar** and choose the source (e.g., `iCloud`) +3. Name it exactly what you put in your config (e.g., "Work Shifts") +4. Run `eaw-sync sync`. It will trigger a macOS permission prompt. +5. Grant access when prompted (or in *System Settings → Privacy & Security → Calendars*). + +**ICS.** Writes a single `.ics` file. Subscribe to it from Calendar.app (or any +calendar client) via `File → New Calendar Subscription`. Portable, no +permissions needed. + +## Commands + +| Command | What | +|---------|------| +| `eaw-sync config init` | Scaffold config file. | +| `eaw-sync config show` | Print effective config (secrets redacted). | +| `eaw-sync auth test` | Verify credentials can obtain a token. | +| `eaw-sync doctor` | Full preflight: config loads, auth works, backend reachable. | +| `eaw-sync state show` | Print local state path, tracked-shift count, last sync. | +| `eaw-sync sync [--dry-run]` | Run one sync pass and exit. | +| `eaw-sync watch --interval-seconds N` | Loop until Ctrl-C / SIGTERM. | + +Global flag: `--config-path PATH` overrides the default config location. + +### Exit codes (`sync`) + +| Code | Meaning | +|------|---------| +| 0 | All changes applied. | +| 1 | Partial failure — some changes applied, state persisted, backend errored. | +| 2 | Fatal — config/auth/network failed before any change was written. | + +### Shell completions + +```bash +eaw-sync --install-completion # bash / zsh / fish +``` + +## Troubleshooting + +- **"Calendar 'Work Shifts' not found"** — create it in Calendar.app first; + source name must match (`iCloud`, `On My Mac`, etc). +- **Calendar permission denied** — System Settings → Privacy & Security → + Calendars → enable for your terminal / launchd agent. +- **`auth failed`** — run `eaw-sync doctor`, check `EAW_CLIENT_SECRET`, confirm + `base_url`. +- **Stale events after delete** — state entries auto-prune once the backend + confirms the delete. Corrupt `state.json` is quarantined and rebuilt. + +## Auto-run on macOS + +A sample launchd plist is in `examples/launchd/com.easyatcal.watch.plist`. +Load with: + +```bash +cp examples/launchd/com.easyatcal.watch.plist ~/Library/LaunchAgents/ +launchctl load ~/Library/LaunchAgents/com.easyatcal.watch.plist +``` + +## Contributing + +If you fork and want to publish your own PyPI package via GitHub Actions: +1. Ensure you have claimed your project name on PyPI. +2. Go to **PyPI -> Manage -> Publishing**. +3. Add a "Trusted Publisher" configured for your GitHub repository (e.g. `Ailcope/EasyAtCal`) pointing to the `publish.yml` workflow and the `pypi` environment. + +## Design + +- `docs/superpowers/specs/2026-04-19-easyatcal-design.md` — full design. +- `docs/superpowers/plans/2026-04-19-easyatcal-implementation.md` — build plan. + +## License + +MIT — see `LICENSE`. diff --git a/docs/superpowers/plans/2026-04-19-easyatcal-implementation.md b/docs/superpowers/plans/2026-04-19-easyatcal-implementation.md new file mode 100644 index 0000000..44bf047 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-easyatcal-implementation.md @@ -0,0 +1,2499 @@ +# EasyAtCal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (` - [x]`) syntax for tracking. + +**Goal:** Build a Python CLI (`eaw-sync`) that fetches shifts from the easy@work REST API and writes them to Apple Calendar (via EventKit on macOS) or a portable `.ics` file, with one-way sync and a daemon mode. + +**Architecture:** Single Python package `easyatcal` with a pluggable `CalendarBackend` interface. Core modules (`api`, `sync`, `models`, `config`, `cli`) are platform-agnostic. Backends live in `easyatcal/backends/` — `ics.py` is portable, `eventkit.py` is macOS-only via pyobjc. Local state file (`state.json`) maps easy@work shift IDs to calendar event UIDs for idempotent re-runs. + +**Tech Stack:** Python 3.11+, `httpx` (HTTP), `pydantic` (config), `icalendar` (ICS backend), `pyobjc-framework-EventKit` (EventKit backend, macOS extra), `typer` (CLI), `pytest` + `responses` (tests). + +Spec: `docs/superpowers/specs/2026-04-19-easyatcal-design.md` + +--- + +## Task 1: Project scaffolding and tooling + +**Files:** +- Create: `pyproject.toml` +- Create: `.gitignore` +- Create: `easyatcal/__init__.py` +- Create: `tests/__init__.py` +- Create: `tests/conftest.py` +- Create: `config.example.yaml` +- Create: `README.md` (replace existing empty file) + + - [x] **Step 1: Create `pyproject.toml`** + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "easyatcal" +version = "0.1.0" +description = "One-way sync of easy@work shifts to Apple Calendar." +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [{name = "Ailcope"}] +dependencies = [ + "httpx>=0.27", + "pydantic>=2.6", + "pyyaml>=6.0", + "icalendar>=5.0", + "typer>=0.12", + "platformdirs>=4.0", +] + +[project.optional-dependencies] +eventkit = ["pyobjc-framework-EventKit>=10.0; sys_platform == 'darwin'"] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "responses>=0.25", + "freezegun>=1.4", +] + +[project.scripts] +eaw-sync = "easyatcal.cli:app" + +[tool.hatch.build.targets.wheel] +packages = ["easyatcal"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v --strict-markers" +``` + + - [x] **Step 2: Create `.gitignore`** + +```gitignore +# Secrets & user data +config.yaml +.env +*.ics +state.json +token.json +.cache/ + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ + +# Editors / OS +.vscode/ +.idea/ +.DS_Store +``` + + - [x] **Step 3: Create package and test skeletons** + +`easyatcal/__init__.py`: +```python +"""EasyAtCal — one-way sync of easy@work shifts to Apple Calendar.""" + +__version__ = "0.1.0" +``` + +`tests/__init__.py`: empty file. + +`tests/conftest.py`: +```python +import pytest +``` + + - [x] **Step 4: Create `config.example.yaml`** + +```yaml +easyatwork: + auth_mode: client # "client" or "user" + client_id: "REPLACE_ME" + client_secret: "REPLACE_ME" # or set EAW_CLIENT_SECRET env var + base_url: "https://api.easyatwork.com" + +sync: + lookback_days: 7 + lookahead_days: 90 + user_id: null # null = self + +backend: eventkit # "eventkit" or "ics" + +backends: + eventkit: + calendar_name: "Work Shifts" + calendar_source: "iCloud" + ics: + output_path: "~/Documents/easyatwork-shifts.ics" + +logging: + level: INFO +``` + + - [x] **Step 5: Create `README.md`** + +```markdown +# EasyAtCal + +One-way sync of [easy@work](https://www.easyatwork.com) shifts into Apple Calendar. + +## Install + +```bash +pip install easyatcal # core + ICS backend +pip install 'easyatcal[eventkit]' # add macOS EventKit backend +``` + +## Configure + +```bash +eaw-sync config init +# edit ~/.config/easyatcal/config.yaml +``` + +## Run + +```bash +eaw-sync sync # one-shot +eaw-sync watch --interval 15m # daemon mode +``` + +See `docs/superpowers/specs/2026-04-19-easyatcal-design.md` for full design. +``` + + - [x] **Step 6: Install in editable mode and verify pytest runs** + +Run: `pip install -e '.[dev]' && pytest` +Expected: `collected 0 items` — no failure. + + - [x] **Step 7: Commit** + +```bash +git add pyproject.toml .gitignore easyatcal/ tests/ config.example.yaml README.md +git commit -m "scaffold: project layout, pyproject, gitignore, readme" +``` + +--- + +## Task 2: Shift model + +**Files:** +- Create: `easyatcal/models.py` +- Create: `tests/test_models.py` + + - [x] **Step 1: Write failing test** + +`tests/test_models.py`: +```python +from datetime import datetime, timezone + +from easyatcal.models import Shift + + +def test_shift_is_frozen_dataclass(): + shift = Shift( + id="abc", + start=datetime(2026, 4, 20, 9, 0, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, 0, tzinfo=timezone.utc), + title="Morning", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, 10, 0, tzinfo=timezone.utc), + ) + assert shift.id == "abc" + assert shift.duration_hours == 8.0 + + +def test_shift_requires_tz_aware_datetimes(): + import pytest + + with pytest.raises(ValueError, match="tz-aware"): + Shift( + id="abc", + start=datetime(2026, 4, 20, 9, 0), # naive + end=datetime(2026, 4, 20, 17, 0, tzinfo=timezone.utc), + title="t", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + ) +``` + + - [x] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_models.py -v` +Expected: FAIL — `ModuleNotFoundError: easyatcal.models`. + + - [x] **Step 3: Implement `easyatcal/models.py`** + +```python +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True, slots=True) +class Shift: + id: str + start: datetime + end: datetime + title: str + location: str | None + notes: str | None + updated_at: datetime + + def __post_init__(self) -> None: + for field_name in ("start", "end", "updated_at"): + value = getattr(self, field_name) + if value.tzinfo is None: + raise ValueError(f"{field_name} must be tz-aware") + + @property + def duration_hours(self) -> float: + return (self.end - self.start).total_seconds() / 3600.0 +``` + + - [x] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_models.py -v` +Expected: 2 passed. + + - [x] **Step 5: Commit** + +```bash +git add easyatcal/models.py tests/test_models.py +git commit -m "feat(models): Shift dataclass with tz-aware validation" +``` + +--- + +## Task 3: Config loader + +**Files:** +- Create: `easyatcal/config.py` +- Create: `tests/test_config.py` +- Create: `tests/fixtures/config_valid.yaml` + + - [x] **Step 1: Create test fixture** + +`tests/fixtures/config_valid.yaml`: +```yaml +easyatwork: + auth_mode: client + client_id: "cid" + client_secret: "csecret" + base_url: "https://api.easyatwork.com" +sync: + lookback_days: 7 + lookahead_days: 90 + user_id: null +backend: ics +backends: + eventkit: + calendar_name: "Work Shifts" + calendar_source: "iCloud" + ics: + output_path: "~/Documents/shifts.ics" +logging: + level: INFO +``` + + - [x] **Step 2: Write failing tests** + +`tests/test_config.py`: +```python +from pathlib import Path + +import pytest + +from easyatcal.config import Config, load_config + + +FIXTURE = Path(__file__).parent / "fixtures" / "config_valid.yaml" + + +def test_load_config_from_file(): + cfg = load_config(FIXTURE) + assert isinstance(cfg, Config) + assert cfg.easyatwork.client_id == "cid" + assert cfg.backend == "ics" + assert cfg.sync.lookback_days == 7 + + +def test_env_override_for_secret(monkeypatch): + monkeypatch.setenv("EAW_CLIENT_SECRET", "from-env") + cfg = load_config(FIXTURE) + assert cfg.easyatwork.client_secret == "from-env" + + +def test_invalid_backend_rejected(tmp_path): + bad = tmp_path / "c.yaml" + bad.write_text(FIXTURE.read_text().replace("backend: ics", "backend: nonsense")) + with pytest.raises(ValueError): + load_config(bad) + + +def test_missing_file_raises(tmp_path): + with pytest.raises(FileNotFoundError): + load_config(tmp_path / "missing.yaml") +``` + + - [x] **Step 3: Run tests to verify they fail** + +Run: `pytest tests/test_config.py -v` +Expected: FAIL — module not found. + + - [x] **Step 4: Implement `easyatcal/config.py`** + +```python +from __future__ import annotations + +import os +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel, Field, field_validator + + +class EasyAtWorkAuth(BaseModel): + auth_mode: Literal["client", "user"] + client_id: str + client_secret: str + base_url: str = "https://api.easyatwork.com" + + +class SyncSettings(BaseModel): + lookback_days: int = Field(ge=0, default=7) + lookahead_days: int = Field(ge=1, default=90) + user_id: str | None = None + + +class EventKitSettings(BaseModel): + calendar_name: str = "Work Shifts" + calendar_source: str = "iCloud" + + +class IcsSettings(BaseModel): + output_path: str = "~/Documents/easyatwork-shifts.ics" + + +class BackendsSettings(BaseModel): + eventkit: EventKitSettings = EventKitSettings() + ics: IcsSettings = IcsSettings() + + +class LoggingSettings(BaseModel): + level: str = "INFO" + + +class Config(BaseModel): + easyatwork: EasyAtWorkAuth + sync: SyncSettings = SyncSettings() + backend: Literal["eventkit", "ics"] + backends: BackendsSettings = BackendsSettings() + logging: LoggingSettings = LoggingSettings() + + @field_validator("backend") + @classmethod + def validate_backend(cls, v: str) -> str: + if v not in ("eventkit", "ics"): + raise ValueError(f"Unknown backend: {v}") + return v + + +_ENV_OVERRIDES = { + "EAW_CLIENT_ID": ("easyatwork", "client_id"), + "EAW_CLIENT_SECRET": ("easyatwork", "client_secret"), +} + + +def load_config(path: Path) -> Config: + if not path.exists(): + raise FileNotFoundError(path) + raw = yaml.safe_load(path.read_text()) + for env_var, (section, key) in _ENV_OVERRIDES.items(): + value = os.environ.get(env_var) + if value is not None: + raw.setdefault(section, {})[key] = value + return Config.model_validate(raw) +``` + + - [x] **Step 5: Run tests to verify they pass** + +Run: `pytest tests/test_config.py -v` +Expected: 4 passed. + + - [x] **Step 6: Commit** + +```bash +git add easyatcal/config.py tests/test_config.py tests/fixtures/config_valid.yaml +git commit -m "feat(config): pydantic config loader with env overrides" +``` + +--- + +## Task 4: State persistence + +**Files:** +- Create: `easyatcal/state.py` +- Create: `tests/test_state.py` + + - [x] **Step 1: Write failing tests** + +`tests/test_state.py`: +```python +import json +from pathlib import Path + +from easyatcal.state import State, load_state, save_state + + +def test_save_then_load_roundtrip(tmp_path: Path): + path = tmp_path / "state.json" + s = State(shift_to_event={"shift-1": "evt-1", "shift-2": "evt-2"}, + last_sync="2026-04-19T12:00:00+00:00") + save_state(path, s) + + loaded = load_state(path) + assert loaded.shift_to_event == s.shift_to_event + assert loaded.last_sync == s.last_sync + + +def test_load_missing_returns_empty(tmp_path: Path): + s = load_state(tmp_path / "missing.json") + assert s.shift_to_event == {} + assert s.last_sync is None + + +def test_load_corrupt_backs_up_and_returns_empty(tmp_path: Path): + path = tmp_path / "state.json" + path.write_text("not valid json{{{") + + s = load_state(path) + + assert s.shift_to_event == {} + assert (tmp_path / "state.json.bak").exists() + + +def test_save_is_atomic(tmp_path: Path): + path = tmp_path / "state.json" + save_state(path, State(shift_to_event={"a": "b"}, last_sync=None)) + # No temp file left behind + assert not any(p.name.endswith(".tmp") for p in tmp_path.iterdir()) + assert json.loads(path.read_text())["shift_to_event"] == {"a": "b"} +``` + + - [x] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_state.py -v` +Expected: FAIL — module not found. + + - [x] **Step 3: Implement `easyatcal/state.py`** + +```python +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path + + +@dataclass +class State: + shift_to_event: dict[str, str] = field(default_factory=dict) + last_sync: str | None = None + + +def load_state(path: Path) -> State: + if not path.exists(): + return State() + try: + data = json.loads(path.read_text()) + return State( + shift_to_event=dict(data.get("shift_to_event", {})), + last_sync=data.get("last_sync"), + ) + except (json.JSONDecodeError, ValueError): + backup = path.with_suffix(path.suffix + ".bak") + path.replace(backup) + return State() + + +def save_state(path: Path, state: State) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(asdict(state), indent=2, sort_keys=True)) + os.replace(tmp, path) +``` + + - [x] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_state.py -v` +Expected: 4 passed. + + - [x] **Step 5: Commit** + +```bash +git add easyatcal/state.py tests/test_state.py +git commit -m "feat(state): atomic json state with corrupt-file recovery" +``` + +--- + +## Task 5: easy@work API client — auth and token cache + +**Files:** +- Create: `easyatcal/api.py` +- Create: `tests/test_api_auth.py` + + - [x] **Step 1: Write failing tests** + +`tests/test_api_auth.py`: +```python +import json +from pathlib import Path + +import pytest +import responses + +from easyatcal.api import EawClient, AuthError + + +@responses.activate +def test_client_credentials_fetch_token(tmp_path: Path): + responses.add( + responses.POST, + "https://api.easyatwork.com/oauth/token", + json={"access_token": "tok-123", "expires_in": 3600, "token_type": "Bearer"}, + status=200, + ) + client = EawClient( + client_id="cid", + client_secret="csecret", + base_url="https://api.easyatwork.com", + token_cache=tmp_path / "token.json", + ) + + token = client.authenticate() + + assert token == "tok-123" + cached = json.loads((tmp_path / "token.json").read_text()) + assert cached["access_token"] == "tok-123" + + +@responses.activate +def test_cached_token_reused(tmp_path: Path): + cache = tmp_path / "token.json" + # Write a cache entry valid for 1 hour. + cache.write_text(json.dumps({ + "access_token": "cached-tok", + "expires_at": "2099-01-01T00:00:00+00:00", + })) + + client = EawClient( + client_id="cid", + client_secret="csecret", + base_url="https://api.easyatwork.com", + token_cache=cache, + ) + token = client.authenticate() + + assert token == "cached-tok" + assert len(responses.calls) == 0 + + +@responses.activate +def test_auth_failure_raises(tmp_path: Path): + responses.add( + responses.POST, + "https://api.easyatwork.com/oauth/token", + json={"error": "invalid_client"}, + status=401, + ) + client = EawClient( + client_id="bad", + client_secret="bad", + base_url="https://api.easyatwork.com", + token_cache=tmp_path / "token.json", + ) + with pytest.raises(AuthError): + client.authenticate() +``` + + - [x] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_api_auth.py -v` +Expected: FAIL — module not found. + + - [x] **Step 3: Implement auth section of `easyatcal/api.py`** + +```python +from __future__ import annotations + +import json +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import httpx + + +class AuthError(Exception): + pass + + +class ApiError(Exception): + pass + + +class EawClient: + def __init__( + self, + client_id: str, + client_secret: str, + base_url: str, + token_cache: Path, + timeout: float = 30.0, + ) -> None: + self.client_id = client_id + self.client_secret = client_secret + self.base_url = base_url.rstrip("/") + self.token_cache = token_cache + self._http = httpx.Client(timeout=timeout) + self._token: str | None = None + + # ----- auth ----- + + def authenticate(self) -> str: + cached = self._read_cache() + if cached is not None: + self._token = cached + return cached + return self._fetch_token() + + def _read_cache(self) -> str | None: + if not self.token_cache.exists(): + return None + try: + data = json.loads(self.token_cache.read_text()) + except (json.JSONDecodeError, ValueError): + return None + expires_at = datetime.fromisoformat(data["expires_at"]) + if expires_at <= datetime.now(timezone.utc): + return None + return data["access_token"] + + def _fetch_token(self) -> str: + try: + r = self._http.post( + f"{self.base_url}/oauth/token", + data={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + ) + except httpx.HTTPError as e: + raise AuthError(f"network error during auth: {e}") from e + if r.status_code != 200: + raise AuthError(f"auth failed: {r.status_code} {r.text}") + data = r.json() + token = data["access_token"] + expires_at = datetime.now(timezone.utc) + timedelta( + seconds=int(data.get("expires_in", 3600)) + ) + self._write_cache(token, expires_at) + self._token = token + return token + + def _write_cache(self, token: str, expires_at: datetime) -> None: + self.token_cache.parent.mkdir(parents=True, exist_ok=True) + payload = {"access_token": token, "expires_at": expires_at.isoformat()} + tmp = self.token_cache.with_suffix(self.token_cache.suffix + ".tmp") + tmp.write_text(json.dumps(payload)) + os.replace(tmp, self.token_cache) + try: + os.chmod(self.token_cache, 0o600) + except OSError: + pass +``` + + - [x] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_api_auth.py -v` +Expected: 3 passed. + + - [x] **Step 5: Commit** + +```bash +git add easyatcal/api.py tests/test_api_auth.py +git commit -m "feat(api): OAuth client_credentials auth with cached token" +``` + +--- + +## Task 6: easy@work API client — fetch shifts with retry + +**Files:** +- Modify: `easyatcal/api.py` (add methods) +- Create: `tests/test_api_fetch.py` + + - [x] **Step 1: Write failing tests** + +`tests/test_api_fetch.py`: +```python +from datetime import date, datetime, timezone +from pathlib import Path + +import pytest +import responses + +from easyatcal.api import ApiError, EawClient +from easyatcal.models import Shift + + +def _fresh_client(tmp_path: Path) -> EawClient: + # Pre-seed a valid token so authenticate() short-circuits. + cache = tmp_path / "token.json" + cache.write_text( + '{"access_token":"tok","expires_at":"2099-01-01T00:00:00+00:00"}' + ) + return EawClient( + client_id="cid", + client_secret="csecret", + base_url="https://api.easyatwork.com", + token_cache=cache, + ) + + +@responses.activate +def test_fetch_shifts_single_page(tmp_path: Path): + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts", + json={ + "data": [ + { + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "Morning", + "location": "Oslo", + "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + } + ], + "next": None, + }, + status=200, + ) + client = _fresh_client(tmp_path) + + shifts = client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 21) + ) + + assert len(shifts) == 1 + s = shifts[0] + assert isinstance(s, Shift) + assert s.id == "s1" + assert s.location == "Oslo" + + +@responses.activate +def test_fetch_shifts_follows_pagination(tmp_path: Path): + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts", + json={ + "data": [{ + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "A", "location": None, "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + }], + "next": "https://api.easyatwork.com/v1/shifts?cursor=abc", + }, + status=200, + ) + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts?cursor=abc", + json={ + "data": [{ + "id": "s2", + "start": "2026-04-21T09:00:00+00:00", + "end": "2026-04-21T17:00:00+00:00", + "title": "B", "location": None, "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + }], + "next": None, + }, + status=200, + match_querystring=True, + ) + client = _fresh_client(tmp_path) + + shifts = client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) + ids = [s.id for s in shifts] + assert ids == ["s1", "s2"] + + +@responses.activate +def test_fetch_shifts_retries_on_429(tmp_path: Path, monkeypatch): + sleeps = [] + monkeypatch.setattr("time.sleep", lambda s: sleeps.append(s)) + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts", + status=429, + ) + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts", + json={"data": [], "next": None}, + status=200, + ) + client = _fresh_client(tmp_path) + + shifts = client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) + + assert shifts == [] + assert len(sleeps) == 1 + assert sleeps[0] >= 1 # backed off at least 1s + + +@responses.activate +def test_fetch_shifts_gives_up_after_5_retries(tmp_path: Path, monkeypatch): + monkeypatch.setattr("time.sleep", lambda s: None) + for _ in range(6): + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts", + status=429, + ) + client = _fresh_client(tmp_path) + + with pytest.raises(ApiError, match="rate limit"): + client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) +``` + + - [x] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_api_fetch.py -v` +Expected: FAIL — `fetch_shifts` not defined. + + - [x] **Step 3: Extend `easyatcal/api.py`** + +Append these methods to the `EawClient` class (after `_write_cache`): + +```python + # ----- shifts ----- + + _MAX_RETRIES = 5 + + def fetch_shifts(self, from_date, to_date): + """Return list[Shift] between from_date (inclusive) and to_date (exclusive).""" + import time + from datetime import datetime + + from easyatcal.models import Shift + + token = self.authenticate() + url = f"{self.base_url}/v1/shifts" + params: dict | None = { + "from": from_date.isoformat(), + "to": to_date.isoformat(), + } + headers = {"Authorization": f"Bearer {token}"} + + out: list[Shift] = [] + while url is not None: + attempts = 0 + while True: + r = self._http.get(url, params=params, headers=headers) + if r.status_code == 200: + break + if r.status_code in (429, 500, 502, 503, 504): + attempts += 1 + if attempts > self._MAX_RETRIES: + raise ApiError( + f"rate limit / server errors exceeded retries " + f"({r.status_code})" + ) + time.sleep(2 ** (attempts - 1)) + continue + raise ApiError(f"GET {url} -> {r.status_code} {r.text}") + + payload = r.json() + for raw in payload.get("data", []): + out.append( + Shift( + id=raw["id"], + start=datetime.fromisoformat(raw["start"]), + end=datetime.fromisoformat(raw["end"]), + title=raw.get("title", "Shift"), + location=raw.get("location"), + notes=raw.get("notes"), + updated_at=datetime.fromisoformat(raw["updated_at"]), + ) + ) + url = payload.get("next") + params = None # next URL already includes cursor + return out +``` + + - [x] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_api_fetch.py -v` +Expected: 4 passed. + + - [x] **Step 5: Commit** + +```bash +git add easyatcal/api.py tests/test_api_fetch.py +git commit -m "feat(api): fetch_shifts with pagination and backoff" +``` + +--- + +## Task 7: Backend interface + +**Files:** +- Create: `easyatcal/backends/__init__.py` +- Create: `easyatcal/backends/base.py` +- Create: `tests/backends/__init__.py` +- Create: `tests/backends/test_base.py` + + - [x] **Step 1: Create empty `__init__.py` files** + +`easyatcal/backends/__init__.py`: empty. +`tests/backends/__init__.py`: empty. + + - [x] **Step 2: Write failing test** + +`tests/backends/test_base.py`: +```python +from easyatcal.backends.base import CalendarBackend, Changes + + +def test_changes_is_dataclass(): + c = Changes(adds=[], updates=[], deletes=[]) + assert c.adds == [] + assert c.is_empty() + + +def test_backend_is_protocol_with_apply(): + # Protocol sanity check — any object with .apply() satisfies CalendarBackend + class Dummy: + def apply(self, changes: Changes) -> dict[str, str]: + return {} + + d: CalendarBackend = Dummy() + assert d.apply(Changes([], [], [])) == {} +``` + + - [x] **Step 3: Run test to verify it fails** + +Run: `pytest tests/backends/test_base.py -v` +Expected: FAIL — module not found. + + - [x] **Step 4: Implement `easyatcal/backends/base.py`** + +```python +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol + +from easyatcal.models import Shift + + +@dataclass +class Changes: + adds: list[Shift] = field(default_factory=list) + updates: list[tuple[Shift, str]] = field(default_factory=list) + # list of event uids to delete + deletes: list[str] = field(default_factory=list) + + def is_empty(self) -> bool: + return not (self.adds or self.updates or self.deletes) + + +class CalendarBackend(Protocol): + def apply(self, changes: Changes) -> dict[str, str]: + """Apply the given changes. Return mapping shift_id -> event_uid for + all adds/updates.""" + ... +``` + + - [x] **Step 5: Run test to verify it passes** + +Run: `pytest tests/backends/test_base.py -v` +Expected: 2 passed. + + - [x] **Step 6: Commit** + +```bash +git add easyatcal/backends/ tests/backends/__init__.py tests/backends/test_base.py +git commit -m "feat(backends): Changes dataclass and CalendarBackend protocol" +``` + +--- + +## Task 8: Sync diff engine + +**Files:** +- Create: `easyatcal/sync.py` +- Create: `tests/test_sync.py` + + - [x] **Step 1: Write failing tests** + +`tests/test_sync.py`: +```python +from datetime import datetime, timezone + +from easyatcal.models import Shift +from easyatcal.state import State +from easyatcal.sync import compute_changes + + +def _shift(id_: str, updated: str = "2026-04-18T10:00:00+00:00") -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + title="t", + location=None, + notes=None, + updated_at=datetime.fromisoformat(updated), + ) + + +def test_new_shifts_are_adds(): + state = State(shift_to_event={}) + shifts = [_shift("a"), _shift("b")] + + changes = compute_changes(shifts, state, known_updated_at={}) + + assert [s.id for s in changes.adds] == ["a", "b"] + assert changes.updates == [] + assert changes.deletes == [] + + +def test_known_shifts_unchanged_do_nothing(): + state = State(shift_to_event={"a": "evt-a"}) + shifts = [_shift("a", "2026-04-18T10:00:00+00:00")] + known_updated = {"a": "2026-04-18T10:00:00+00:00"} + + changes = compute_changes(shifts, state, known_updated_at=known_updated) + + assert changes.is_empty() + + +def test_known_shift_with_new_updated_at_is_update(): + state = State(shift_to_event={"a": "evt-a"}) + shifts = [_shift("a", "2026-04-19T10:00:00+00:00")] + known_updated = {"a": "2026-04-18T10:00:00+00:00"} + + changes = compute_changes(shifts, state, known_updated_at=known_updated) + + assert len(changes.updates) == 1 + shift, event_uid = changes.updates[0] + assert shift.id == "a" + assert event_uid == "evt-a" + + +def test_shift_missing_from_remote_is_delete(): + state = State(shift_to_event={"a": "evt-a", "b": "evt-b"}) + shifts = [_shift("a")] + known_updated = {"a": "2026-04-18T10:00:00+00:00", + "b": "2026-04-18T10:00:00+00:00"} + + changes = compute_changes(shifts, state, known_updated_at=known_updated) + + assert changes.deletes == ["evt-b"] +``` + + - [x] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_sync.py -v` +Expected: FAIL — module not found. + + - [x] **Step 3: Implement `easyatcal/sync.py`** + +```python +from __future__ import annotations + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift +from easyatcal.state import State + + +def compute_changes( + remote_shifts: list[Shift], + state: State, + known_updated_at: dict[str, str], +) -> Changes: + """Diff remote shifts against the last-known state. + + known_updated_at maps shift_id -> ISO-formatted updated_at recorded at last sync. + """ + remote_by_id = {s.id: s for s in remote_shifts} + adds: list[Shift] = [] + updates: list[tuple[Shift, str]] = [] + deletes: list[str] = [] + + for shift in remote_shifts: + event_uid = state.shift_to_event.get(shift.id) + if event_uid is None: + adds.append(shift) + continue + last_updated = known_updated_at.get(shift.id) + if last_updated != shift.updated_at.isoformat(): + updates.append((shift, event_uid)) + + for shift_id, event_uid in state.shift_to_event.items(): + if shift_id not in remote_by_id: + deletes.append(event_uid) + + return Changes(adds=adds, updates=updates, deletes=deletes) +``` + + - [x] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_sync.py -v` +Expected: 4 passed. + + - [x] **Step 5: Commit** + +```bash +git add easyatcal/sync.py tests/test_sync.py +git commit -m "feat(sync): diff engine for adds/updates/deletes" +``` + +--- + +## Task 9: Extend State to track `updated_at` + +**Files:** +- Modify: `easyatcal/state.py` +- Modify: `tests/test_state.py` + + - [x] **Step 1: Add test for new field** + +Append to `tests/test_state.py`: +```python +def test_state_roundtrip_with_updated_at(tmp_path): + from easyatcal.state import State, load_state, save_state + + path = tmp_path / "state.json" + s = State( + shift_to_event={"s1": "e1"}, + shift_updated_at={"s1": "2026-04-18T10:00:00+00:00"}, + last_sync="2026-04-19T12:00:00+00:00", + ) + save_state(path, s) + loaded = load_state(path) + assert loaded.shift_updated_at == {"s1": "2026-04-18T10:00:00+00:00"} +``` + + - [x] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_state.py::test_state_roundtrip_with_updated_at -v` +Expected: FAIL — field not defined. + + - [x] **Step 3: Add field to `easyatcal/state.py`** + +Modify the `State` dataclass: +```python +@dataclass +class State: + shift_to_event: dict[str, str] = field(default_factory=dict) + shift_updated_at: dict[str, str] = field(default_factory=dict) + last_sync: str | None = None +``` + +Update `load_state` body so the constructor call includes the new field: +```python + return State( + shift_to_event=dict(data.get("shift_to_event", {})), + shift_updated_at=dict(data.get("shift_updated_at", {})), + last_sync=data.get("last_sync"), + ) +``` + + - [x] **Step 4: Run all state tests to verify they pass** + +Run: `pytest tests/test_state.py -v` +Expected: 5 passed. + + - [x] **Step 5: Commit** + +```bash +git add easyatcal/state.py tests/test_state.py +git commit -m "feat(state): track shift_updated_at per shift" +``` + +--- + +## Task 10: ICS backend + +**Files:** +- Create: `easyatcal/backends/ics.py` +- Create: `tests/backends/test_ics.py` + + - [x] **Step 1: Write failing tests** + +`tests/backends/test_ics.py`: +```python +from datetime import datetime, timezone +from pathlib import Path + +from easyatcal.backends.base import Changes +from easyatcal.backends.ics import IcsBackend +from easyatcal.models import Shift + + +def _shift(id_: str) -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + title=f"Shift {id_}", + location="Oslo", + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + ) + + +def test_adds_produce_events_in_file(tmp_path: Path): + out = tmp_path / "shifts.ics" + backend = IcsBackend(output_path=out, known_shifts=[]) + changes = Changes(adds=[_shift("s1"), _shift("s2")]) + + mapping = backend.apply(changes) + + body = out.read_text() + assert "BEGIN:VCALENDAR" in body + assert "SUMMARY:Shift s1" in body + assert "SUMMARY:Shift s2" in body + assert mapping["s1"].startswith("easyatcal-s1") + assert mapping["s2"].startswith("easyatcal-s2") + + +def test_deletes_remove_events(tmp_path: Path): + out = tmp_path / "shifts.ics" + # First write with 2 shifts + backend1 = IcsBackend(output_path=out, known_shifts=[]) + backend1.apply(Changes(adds=[_shift("s1"), _shift("s2")])) + + # Now apply a delete of s2's event + backend2 = IcsBackend( + output_path=out, + known_shifts=[_shift("s1"), _shift("s2")], + ) + uid_s2 = "easyatcal-s2" + backend2.apply(Changes(deletes=[uid_s2])) + + body = out.read_text() + assert "SUMMARY:Shift s1" in body + assert "SUMMARY:Shift s2" not in body + + +def test_updates_replace_event(tmp_path: Path): + out = tmp_path / "shifts.ics" + s = _shift("s1") + IcsBackend(output_path=out, known_shifts=[]).apply(Changes(adds=[s])) + + # Produce an updated shift with a new title + s_new = Shift( + id=s.id, start=s.start, end=s.end, title="New Title", + location=s.location, notes=s.notes, updated_at=s.updated_at, + ) + backend = IcsBackend(output_path=out, known_shifts=[s]) + backend.apply(Changes(updates=[(s_new, "easyatcal-s1")])) + + body = out.read_text() + assert "SUMMARY:New Title" in body + assert "SUMMARY:Shift s1" not in body +``` + + - [x] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/backends/test_ics.py -v` +Expected: FAIL — module not found. + + - [x] **Step 3: Implement `easyatcal/backends/ics.py`** + +```python +from __future__ import annotations + +from pathlib import Path + +from icalendar import Calendar, Event + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift + +UID_PREFIX = "easyatcal-" + + +def _uid_for(shift_id: str) -> str: + return f"{UID_PREFIX}{shift_id}" + + +def _to_event(shift: Shift, uid: str) -> Event: + ev = Event() + ev.add("uid", uid) + ev.add("summary", shift.title) + ev.add("dtstart", shift.start) + ev.add("dtend", shift.end) + ev.add("last-modified", shift.updated_at) + if shift.location: + ev.add("location", shift.location) + if shift.notes: + ev.add("description", shift.notes) + return ev + + +class IcsBackend: + """File-based calendar backend that regenerates the .ics on each apply. + + `known_shifts` is the previous set of shifts the caller knows about — used + so we can rewrite the file without losing events unrelated to the current + change set. + """ + + def __init__(self, output_path: Path, known_shifts: list[Shift]) -> None: + self.output_path = Path(output_path).expanduser() + self._current: dict[str, Shift] = {s.id: s for s in known_shifts} + + def apply(self, changes: Changes) -> dict[str, str]: + mapping: dict[str, str] = {} + + for shift in changes.adds: + self._current[shift.id] = shift + mapping[shift.id] = _uid_for(shift.id) + + for shift, _event_uid in changes.updates: + self._current[shift.id] = shift + mapping[shift.id] = _uid_for(shift.id) + + delete_uids = set(changes.deletes) + # Map uids back to shift ids and drop them + to_drop = [ + sid for sid in self._current + if _uid_for(sid) in delete_uids + ] + for sid in to_drop: + self._current.pop(sid, None) + + self._write() + return mapping + + def _write(self) -> None: + cal = Calendar() + cal.add("prodid", "-//EasyAtCal//EN") + cal.add("version", "2.0") + for shift in self._current.values(): + cal.add_component(_to_event(shift, _uid_for(shift.id))) + + self.output_path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.output_path.with_suffix(self.output_path.suffix + ".tmp") + tmp.write_bytes(cal.to_ical()) + tmp.replace(self.output_path) +``` + + - [x] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/backends/test_ics.py -v` +Expected: 3 passed. + + - [x] **Step 5: Commit** + +```bash +git add easyatcal/backends/ics.py tests/backends/test_ics.py +git commit -m "feat(backends): ICS file backend with add/update/delete" +``` + +--- + +## Task 11: EventKit backend (macOS only) + +**Files:** +- Create: `easyatcal/backends/eventkit.py` +- Create: `tests/backends/test_eventkit.py` + + - [x] **Step 1: Write failing tests** + +`tests/backends/test_eventkit.py`: +```python +import sys +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift + +pytestmark = pytest.mark.skipif( + sys.platform != "darwin", reason="EventKit backend is macOS only" +) + + +def _shift(id_: str) -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + title=f"Shift {id_}", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + ) + + +@patch("easyatcal.backends.eventkit._event_store") +def test_apply_adds_creates_events(mock_store_factory): + store = MagicMock() + calendar = MagicMock() + store.calendarsForEntityType_.return_value = [calendar] + calendar.title.return_value = "Work Shifts" + calendar.source.return_value.title.return_value = "iCloud" + mock_store_factory.return_value = store + + created_event = MagicMock() + created_event.calendarItemExternalIdentifier.return_value = "evt-1" + + from easyatcal.backends.eventkit import EventKitBackend + + with patch( + "easyatcal.backends.eventkit._new_event", return_value=created_event + ): + backend = EventKitBackend( + calendar_name="Work Shifts", calendar_source="iCloud" + ) + mapping = backend.apply(Changes(adds=[_shift("s1")])) + + assert mapping == {"s1": "evt-1"} + store.saveEvent_span_error_.assert_called() + + +@patch("easyatcal.backends.eventkit._event_store") +def test_apply_deletes_removes_events(mock_store_factory): + store = MagicMock() + calendar = MagicMock() + calendar.title.return_value = "Work Shifts" + calendar.source.return_value.title.return_value = "iCloud" + store.calendarsForEntityType_.return_value = [calendar] + + existing = MagicMock() + existing.calendarItemExternalIdentifier.return_value = "evt-1" + store.calendarItemWithIdentifier_.return_value = existing + mock_store_factory.return_value = store + + from easyatcal.backends.eventkit import EventKitBackend + backend = EventKitBackend( + calendar_name="Work Shifts", calendar_source="iCloud" + ) + + backend.apply(Changes(deletes=["evt-1"])) + + store.removeEvent_span_error_.assert_called() +``` + + - [x] **Step 2: Run tests to verify they fail (macOS only)** + +Run: `pytest tests/backends/test_eventkit.py -v` +Expected on macOS: FAIL — module not found. On Linux: skipped. + + - [x] **Step 3: Implement `easyatcal/backends/eventkit.py`** + +```python +"""macOS EventKit calendar backend. + +Only usable on macOS. Requires `pyobjc-framework-EventKit` (install the +`eventkit` extra). +""" +from __future__ import annotations + +import sys +from typing import Any + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift + + +class EventKitUnavailableError(RuntimeError): + pass + + +class EventKitPermissionError(RuntimeError): + pass + + +def _import_eventkit(): # pragma: no cover — platform guard + if sys.platform != "darwin": + raise EventKitUnavailableError("EventKit backend requires macOS") + try: + import EventKit # type: ignore[import-not-found] + except ImportError as e: + raise EventKitUnavailableError( + "pyobjc-framework-EventKit not installed; " + "pip install 'easyatcal[eventkit]'" + ) from e + return EventKit + + +def _event_store() -> Any: # pragma: no cover — exercised via mocks in tests + EventKit = _import_eventkit() + store = EventKit.EKEventStore.alloc().init() + # Request permission (synchronous wait via semaphore). + import Foundation # type: ignore[import-not-found] + from threading import Event as _E + granted = {"ok": False, "err": None} + done = _E() + + def _cb(ok, err): + granted["ok"] = bool(ok) + granted["err"] = err + done.set() + + try: + # macOS 14+ + store.requestFullAccessToEventsWithCompletion_(_cb) + except AttributeError: + store.requestAccessToEntityType_completion_(0, _cb) # 0 = EKEntityTypeEvent + + done.wait(timeout=30) + if not granted["ok"]: + raise EventKitPermissionError( + "Calendar access denied — grant access in System Settings → " + "Privacy & Security → Calendars." + ) + return store + + +def _new_event(store, calendar, shift: Shift): # pragma: no cover + EventKit = _import_eventkit() + import Foundation # type: ignore[import-not-found] + + event = EventKit.EKEvent.eventWithEventStore_(store) + event.setCalendar_(calendar) + event.setTitle_(shift.title) + event.setStartDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.start.timestamp()) + ) + event.setEndDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.end.timestamp()) + ) + if shift.location: + event.setLocation_(shift.location) + if shift.notes: + event.setNotes_(shift.notes) + return event + + +class EventKitBackend: + def __init__(self, calendar_name: str, calendar_source: str) -> None: + self.calendar_name = calendar_name + self.calendar_source = calendar_source + self._store = _event_store() + self._calendar = self._resolve_calendar() + + def _resolve_calendar(self): + calendars = self._store.calendarsForEntityType_(0) + for cal in calendars: + if ( + cal.title() == self.calendar_name + and cal.source().title() == self.calendar_source + ): + return cal + raise RuntimeError( + f"Calendar {self.calendar_name!r} not found in source " + f"{self.calendar_source!r}. Create it in Calendar.app first." + ) + + def apply(self, changes: Changes) -> dict[str, str]: + mapping: dict[str, str] = {} + + for shift in changes.adds: + event = _new_event(self._store, self._calendar, shift) + err = None + self._store.saveEvent_span_error_(event, 0, err) # 0 = EKSpanThisEvent + mapping[shift.id] = event.calendarItemExternalIdentifier() + + for shift, event_uid in changes.updates: + existing = self._store.calendarItemWithIdentifier_(event_uid) + if existing is None: + # Fall through to recreate + event = _new_event(self._store, self._calendar, shift) + err = None + self._store.saveEvent_span_error_(event, 0, err) + mapping[shift.id] = event.calendarItemExternalIdentifier() + continue + existing.setTitle_(shift.title) + # Re-set start/end/location/notes + import Foundation # type: ignore[import-not-found] + existing.setStartDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.start.timestamp()) + ) + existing.setEndDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.end.timestamp()) + ) + if shift.location is not None: + existing.setLocation_(shift.location) + if shift.notes is not None: + existing.setNotes_(shift.notes) + err = None + self._store.saveEvent_span_error_(existing, 0, err) + mapping[shift.id] = event_uid + + for event_uid in changes.deletes: + existing = self._store.calendarItemWithIdentifier_(event_uid) + if existing is None: + continue + err = None + self._store.removeEvent_span_error_(existing, 0, err) + + return mapping +``` + + - [x] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/backends/test_eventkit.py -v` +Expected on macOS: 2 passed. On Linux: skipped. + + - [x] **Step 5: Commit** + +```bash +git add easyatcal/backends/eventkit.py tests/backends/test_eventkit.py +git commit -m "feat(backends): macOS EventKit backend via pyobjc" +``` + +--- + +## Task 12: Orchestrator (ties API + sync + state + backend together) + +**Files:** +- Create: `easyatcal/orchestrator.py` +- Create: `tests/test_orchestrator.py` + + - [x] **Step 1: Write failing test** + +`tests/test_orchestrator.py`: +```python +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import MagicMock + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift +from easyatcal.orchestrator import run_sync +from easyatcal.state import load_state + + +def _shift(id_: str) -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + title=f"t{id_}", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + ) + + +def test_run_sync_applies_changes_and_persists_state(tmp_path: Path): + state_path = tmp_path / "state.json" + + api = MagicMock() + api.fetch_shifts.return_value = [_shift("s1"), _shift("s2")] + + backend = MagicMock() + backend.apply.return_value = {"s1": "evt-1", "s2": "evt-2"} + + run_sync( + api=api, + backend=backend, + state_path=state_path, + lookback_days=1, + lookahead_days=1, + now=datetime(2026, 4, 19, 12, tzinfo=timezone.utc), + ) + + # backend.apply was called with 2 adds + changes = backend.apply.call_args.args[0] + assert isinstance(changes, Changes) + assert [s.id for s in changes.adds] == ["s1", "s2"] + + # State persisted + saved = load_state(state_path) + assert saved.shift_to_event == {"s1": "evt-1", "s2": "evt-2"} + assert saved.shift_updated_at["s1"] == "2026-04-18T00:00:00+00:00" + assert saved.last_sync == "2026-04-19T12:00:00+00:00" +``` + + - [x] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_orchestrator.py -v` +Expected: FAIL — module not found. + + - [x] **Step 3: Implement `easyatcal/orchestrator.py`** + +```python +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Protocol + +from easyatcal.backends.base import CalendarBackend +from easyatcal.models import Shift +from easyatcal.state import State, load_state, save_state +from easyatcal.sync import compute_changes + + +class ShiftFetcher(Protocol): + def fetch_shifts(self, from_date, to_date) -> list[Shift]: ... + + +def run_sync( + api: ShiftFetcher, + backend: CalendarBackend, + state_path: Path, + lookback_days: int, + lookahead_days: int, + now: datetime | None = None, +) -> None: + now = now or datetime.now(timezone.utc) + from_date = (now - timedelta(days=lookback_days)).date() + to_date = (now + timedelta(days=lookahead_days)).date() + + remote_shifts = api.fetch_shifts(from_date=from_date, to_date=to_date) + state = load_state(state_path) + changes = compute_changes( + remote_shifts, state, known_updated_at=state.shift_updated_at + ) + mapping = backend.apply(changes) + + # Merge new mapping into state; prune deleted entries. + new_shift_to_event = dict(state.shift_to_event) + new_updated_at = dict(state.shift_updated_at) + for shift_id, event_uid in mapping.items(): + new_shift_to_event[shift_id] = event_uid + for shift in remote_shifts: + new_updated_at[shift.id] = shift.updated_at.isoformat() + + remote_ids = {s.id for s in remote_shifts} + new_shift_to_event = { + sid: evt for sid, evt in new_shift_to_event.items() if sid in remote_ids + } + new_updated_at = { + sid: ts for sid, ts in new_updated_at.items() if sid in remote_ids + } + + save_state( + state_path, + State( + shift_to_event=new_shift_to_event, + shift_updated_at=new_updated_at, + last_sync=now.isoformat(), + ), + ) +``` + + - [x] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_orchestrator.py -v` +Expected: 1 passed. + + - [x] **Step 5: Commit** + +```bash +git add easyatcal/orchestrator.py tests/test_orchestrator.py +git commit -m "feat(orchestrator): tie api + sync + backend + state together" +``` + +--- + +## Task 13: CLI — `config init` and `config show` + +**Files:** +- Create: `easyatcal/cli.py` +- Create: `easyatcal/paths.py` +- Create: `tests/test_cli_config.py` + + - [x] **Step 1: Create helper for platform paths** + +`easyatcal/paths.py`: +```python +from __future__ import annotations + +from pathlib import Path + +from platformdirs import user_cache_dir, user_config_dir, user_data_dir + +APP = "easyatcal" + + +def config_path() -> Path: + return Path(user_config_dir(APP)) / "config.yaml" + + +def state_path() -> Path: + return Path(user_data_dir(APP)) / "state.json" + + +def token_cache_path() -> Path: + return Path(user_cache_dir(APP)) / "token.json" + + +def log_path() -> Path: + return Path(user_data_dir(APP)) / "logs" / "eaw-sync.log" +``` + + - [x] **Step 2: Write failing tests** + +`tests/test_cli_config.py`: +```python +from pathlib import Path +from unittest.mock import patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +def test_config_init_creates_file(tmp_path: Path): + target = tmp_path / "config.yaml" + with patch("easyatcal.cli.config_path", return_value=target): + result = runner.invoke(app, ["config", "init"]) + + assert result.exit_code == 0 + assert target.exists() + assert "easyatwork:" in target.read_text() + + +def test_config_init_does_not_overwrite(tmp_path: Path): + target = tmp_path / "config.yaml" + target.write_text("existing: yes\n") + with patch("easyatcal.cli.config_path", return_value=target): + result = runner.invoke(app, ["config", "init"]) + + assert result.exit_code != 0 + assert "already exists" in result.stdout + + +def test_config_show_redacts_secret(tmp_path: Path): + target = tmp_path / "config.yaml" + target.write_text( + "easyatwork:\n" + " auth_mode: client\n" + " client_id: cid\n" + " client_secret: supersecret\n" + " base_url: https://api.easyatwork.com\n" + "backend: ics\n" + ) + with patch("easyatcal.cli.config_path", return_value=target): + result = runner.invoke(app, ["config", "show"]) + + assert result.exit_code == 0 + assert "supersecret" not in result.stdout + assert "***" in result.stdout +``` + + - [x] **Step 3: Run tests to verify they fail** + +Run: `pytest tests/test_cli_config.py -v` +Expected: FAIL — cli module missing. + + - [x] **Step 4: Implement `easyatcal/cli.py`** + +```python +from __future__ import annotations + +import shutil +from pathlib import Path + +import typer +import yaml + +from easyatcal.config import load_config +from easyatcal.paths import config_path + +app = typer.Typer(help="EasyAtCal — sync easy@work shifts to Apple Calendar.") +config_app = typer.Typer(help="Manage the config file.") +app.add_typer(config_app, name="config") + +EXAMPLE_CONFIG = Path(__file__).parent.parent / "config.example.yaml" + + +@config_app.command("init") +def config_init() -> None: + """Scaffold a config file at the user config dir.""" + target = config_path() + if target.exists(): + typer.echo(f"Config already exists at {target}", err=True) + raise typer.Exit(code=1) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(EXAMPLE_CONFIG, target) + typer.echo(f"Wrote {target}. Edit it before running `eaw-sync sync`.") + + +@config_app.command("show") +def config_show() -> None: + """Print the effective config with secrets redacted.""" + cfg = load_config(config_path()) + dumped = cfg.model_dump() + dumped["easyatwork"]["client_secret"] = "***" + typer.echo(yaml.safe_dump(dumped, sort_keys=False)) +``` + + - [x] **Step 5: Run tests to verify they pass** + +Run: `pytest tests/test_cli_config.py -v` +Expected: 3 passed. + + - [x] **Step 6: Commit** + +```bash +git add easyatcal/cli.py easyatcal/paths.py tests/test_cli_config.py +git commit -m "feat(cli): config init and config show with secret redaction" +``` + +--- + +## Task 14: CLI — `sync` and `watch` + +**Files:** +- Modify: `easyatcal/cli.py` +- Create: `tests/test_cli_sync.py` + + - [x] **Step 1: Write failing tests** + +`tests/test_cli_sync.py`: +```python +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +@patch("easyatcal.cli.run_sync") +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.load_config") +def test_sync_once_invokes_run_sync(mock_cfg, mock_api, mock_back, mock_run, tmp_path): + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + ) + result = runner.invoke(app, ["sync"]) + + assert result.exit_code == 0 + mock_run.assert_called_once() + + +@patch("easyatcal.cli.time.sleep", side_effect=KeyboardInterrupt) +@patch("easyatcal.cli.run_sync") +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.load_config") +def test_watch_loops_until_interrupt(mock_cfg, mock_api, mock_back, mock_run, mock_sleep): + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + ) + result = runner.invoke(app, ["watch", "--interval-seconds", "60"]) + + # Should run once, then be interrupted by the patched sleep + assert mock_run.call_count == 1 + assert result.exit_code == 0 +``` + + - [x] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_cli_sync.py -v` +Expected: FAIL — `sync` / `watch` commands not registered. + + - [x] **Step 3: Extend `easyatcal/cli.py`** + +Add to `easyatcal/cli.py` (after existing imports): + +```python +import time + +from easyatcal.api import EawClient +from easyatcal.backends.ics import IcsBackend +from easyatcal.orchestrator import run_sync +from easyatcal.paths import state_path, token_cache_path +from easyatcal.state import load_state + + +def _build_api_client(cfg): + return EawClient( + client_id=cfg.easyatwork.client_id, + client_secret=cfg.easyatwork.client_secret, + base_url=cfg.easyatwork.base_url, + token_cache=token_cache_path(), + ) + + +def _build_backend(cfg): + if cfg.backend == "ics": + state = load_state(state_path()) + # Known shifts list is empty here; IcsBackend rewrites from current + # in-memory map on each apply, so the next run re-uses state anyway. + return IcsBackend( + output_path=Path(cfg.backends.ics.output_path).expanduser(), + known_shifts=[], + ) + if cfg.backend == "eventkit": + from easyatcal.backends.eventkit import EventKitBackend + return EventKitBackend( + calendar_name=cfg.backends.eventkit.calendar_name, + calendar_source=cfg.backends.eventkit.calendar_source, + ) + raise RuntimeError(f"Unknown backend: {cfg.backend}") + + +@app.command("sync") +def sync_cmd() -> None: + """Run one sync pass and exit.""" + cfg = load_config(config_path()) + api = _build_api_client(cfg) + backend = _build_backend(cfg) + run_sync( + api=api, + backend=backend, + state_path=state_path(), + lookback_days=cfg.sync.lookback_days, + lookahead_days=cfg.sync.lookahead_days, + ) + typer.echo("Sync complete.") + + +@app.command("watch") +def watch_cmd( + interval_seconds: int = typer.Option( + 900, "--interval-seconds", help="Seconds between sync passes." + ), +) -> None: + """Run sync on a loop until Ctrl-C.""" + cfg = load_config(config_path()) + api = _build_api_client(cfg) + backend = _build_backend(cfg) + try: + while True: + run_sync( + api=api, + backend=backend, + state_path=state_path(), + lookback_days=cfg.sync.lookback_days, + lookahead_days=cfg.sync.lookahead_days, + ) + typer.echo(f"Sleeping {interval_seconds}s…") + time.sleep(interval_seconds) + except KeyboardInterrupt: + typer.echo("\nStopped.") +``` + + - [x] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_cli_sync.py -v` +Expected: 2 passed. + + - [x] **Step 5: Commit** + +```bash +git add easyatcal/cli.py tests/test_cli_sync.py +git commit -m "feat(cli): sync one-shot and watch daemon commands" +``` + +--- + +## Task 15: CLI — `auth test` + +**Files:** +- Modify: `easyatcal/cli.py` +- Create: `tests/test_cli_auth.py` + + - [x] **Step 1: Write failing test** + +`tests/test_cli_auth.py`: +```python +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.load_config") +def test_auth_test_success(mock_cfg, mock_build): + api = MagicMock() + api.authenticate.return_value = "tok" + mock_build.return_value = api + mock_cfg.return_value = MagicMock() + + result = runner.invoke(app, ["auth", "test"]) + assert result.exit_code == 0 + assert "OK" in result.stdout + + +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.load_config") +def test_auth_test_failure(mock_cfg, mock_build): + from easyatcal.api import AuthError + api = MagicMock() + api.authenticate.side_effect = AuthError("bad creds") + mock_build.return_value = api + mock_cfg.return_value = MagicMock() + + result = runner.invoke(app, ["auth", "test"]) + assert result.exit_code == 2 + assert "bad creds" in result.stdout +``` + + - [x] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_cli_auth.py -v` +Expected: FAIL — `auth test` not registered. + + - [x] **Step 3: Add `auth test` to `easyatcal/cli.py`** + +Append: +```python +auth_app = typer.Typer(help="Credential checks.") +app.add_typer(auth_app, name="auth") + + +@auth_app.command("test") +def auth_test() -> None: + """Verify that the configured credentials can obtain a token.""" + from easyatcal.api import AuthError + + cfg = load_config(config_path()) + api = _build_api_client(cfg) + try: + api.authenticate() + except AuthError as e: + typer.echo(f"Auth failed: {e}") + raise typer.Exit(code=2) + typer.echo("OK — credentials work.") +``` + + - [x] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_cli_auth.py -v` +Expected: 2 passed. + + - [x] **Step 5: Commit** + +```bash +git add easyatcal/cli.py tests/test_cli_auth.py +git commit -m "feat(cli): auth test subcommand" +``` + +--- + +## Task 16: Logging setup + +**Files:** +- Create: `easyatcal/logging_setup.py` +- Modify: `easyatcal/cli.py` (call setup at entry) +- Create: `tests/test_logging_setup.py` + + - [x] **Step 1: Write failing test** + +`tests/test_logging_setup.py`: +```python +import logging +from pathlib import Path + +from easyatcal.logging_setup import configure_logging + + +def test_configure_logging_writes_to_file(tmp_path: Path): + log_file = tmp_path / "eaw-sync.log" + configure_logging(level="INFO", log_file=log_file) + + logging.getLogger("easyatcal").info("hello world") + + # Force handler flush + for h in logging.getLogger().handlers: + h.flush() + + assert log_file.exists() + assert "hello world" in log_file.read_text() +``` + + - [x] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_logging_setup.py -v` +Expected: FAIL — module not found. + + - [x] **Step 3: Implement `easyatcal/logging_setup.py`** + +```python +from __future__ import annotations + +import logging +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path + + +def configure_logging(level: str, log_file: Path) -> None: + log_file.parent.mkdir(parents=True, exist_ok=True) + root = logging.getLogger() + # Reset any prior handlers (idempotent across watch-mode iterations) + for h in list(root.handlers): + root.removeHandler(h) + root.setLevel(level) + + fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + + file_h = TimedRotatingFileHandler( + log_file, when="midnight", backupCount=7, encoding="utf-8" + ) + file_h.setFormatter(fmt) + root.addHandler(file_h) + + console_h = logging.StreamHandler() + console_h.setFormatter(fmt) + root.addHandler(console_h) +``` + + - [x] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_logging_setup.py -v` +Expected: 1 passed. + + - [x] **Step 5: Wire into CLI** + +At the top of `easyatcal/cli.py`, add: + +```python +from easyatcal.logging_setup import configure_logging +from easyatcal.paths import log_path +``` + +Inside each of `sync_cmd`, `watch_cmd`, `auth_test`, insert as the very first line after loading the config: + +```python +configure_logging(level=cfg.logging.level, log_file=log_path()) +``` + + - [x] **Step 6: Re-run full test suite** + +Run: `pytest` +Expected: all tests pass (EventKit tests skipped on non-macOS). + + - [x] **Step 7: Commit** + +```bash +git add easyatcal/logging_setup.py easyatcal/cli.py tests/test_logging_setup.py +git commit -m "feat(logging): rotating file + console handlers" +``` + +--- + +## Task 17: GitHub Actions CI + +**Files:** +- Create: `.github/workflows/ci.yml` + + - [x] **Step 1: Create CI workflow** + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12"] + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e '.[dev]' + - name: Install eventkit extra (macOS only) + if: runner.os == 'macOS' + run: pip install -e '.[eventkit]' + - name: Test + run: pytest --cov=easyatcal +``` + + - [x] **Step 2: Run pytest locally one more time before commit** + +Run: `pytest --cov=easyatcal` +Expected: all pass, coverage reported. + + - [x] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: test matrix on Linux + macOS, Python 3.11 and 3.12" +``` + +--- + +## Task 18: End-to-end smoke test + +**Files:** +- Create: `tests/test_e2e_ics.py` + + - [x] **Step 1: Write the e2e test** + +`tests/test_e2e_ics.py`: +```python +"""End-to-end test using the ICS backend and a mocked easy@work API.""" +from datetime import date +from pathlib import Path +from unittest.mock import MagicMock, patch + +import responses + +from easyatcal.api import EawClient +from easyatcal.backends.ics import IcsBackend +from easyatcal.orchestrator import run_sync + + +@responses.activate +def test_end_to_end_ics(tmp_path: Path): + # Seed token cache so auth is cheap + token_cache = tmp_path / "token.json" + token_cache.write_text( + '{"access_token":"tok","expires_at":"2099-01-01T00:00:00+00:00"}' + ) + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts", + json={ + "data": [ + { + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "Morning", "location": "Oslo", "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + } + ], + "next": None, + }, + status=200, + ) + api = EawClient( + client_id="cid", client_secret="csecret", + base_url="https://api.easyatwork.com", token_cache=token_cache, + ) + ics_out = tmp_path / "shifts.ics" + backend = IcsBackend(output_path=ics_out, known_shifts=[]) + + run_sync( + api=api, + backend=backend, + state_path=tmp_path / "state.json", + lookback_days=1, + lookahead_days=7, + ) + + body = ics_out.read_text() + assert "SUMMARY:Morning" in body + assert "LOCATION:Oslo" in body + # State file was written + assert (tmp_path / "state.json").exists() +``` + + - [x] **Step 2: Run the e2e test** + +Run: `pytest tests/test_e2e_ics.py -v` +Expected: 1 passed. + +- [x] **Step 3: Commit** + +```bash +git add tests/test_e2e_ics.py +git commit -m "test(e2e): ICS backend end-to-end with mocked API" +``` + +--- + +## Task 19: Push to origin + +- [x] **Step 1: Push all commits** + +Run: `git push origin main` +Expected: branch updated. + +- [x] **Step 2: Tag v0.1.0** + +```bash +git tag -a v0.1.0 -m "Initial release: easy@work → Apple Calendar sync" +git push origin v0.1.0 +``` + +--- + +## Phase 2: User-Mode Auth Pivot (Completed) + +After the initial implementation, we discovered that OAuth `client_credentials` was insufficient or inaccessible for regular users. We pivoted to a "user mode" utilizing Playwright for headless UI automation to extract a JWT Bearer token from `localStorage`. + +### New Modules Introduced: +- `easyatcal/session.py`: `SessionStore` for persisting the JWT state and Playwright `storage_state.json`. +- `easyatcal/auth_user.py`: Implements `do_login()` which orchestrates the headless Playwright browser, navigates to the login page, fills credentials, waits for a post-login selector, and saves the storage state. +- `easyatcal/api_session.py`: `SessionEawClient` which loads the saved state, extracts the JWT from the `easyatwork.auth` local storage payload, and injects it as a Bearer token in subsequent API requests. +- `easyatcal/cli.py` additions: `eaw-sync login` (interactive or headless login) and `eaw-sync logout`. + +### Architectural Changes: +- **JWT Bearer Refactor:** All API calls now replay the JWT Bearer token against the region-specific API (e.g. `eu-west-3.api.easyatwork.com/customers/{cid}/employees/{eid}/shifts`). +- **Configuration Updates:** The user must explicitly set `api_url`, `customer_id`, `employee_id`, and Playwright configuration options (`login_url`, `login_selectors`, `headless`, etc.) in `config.yaml`. +- **Commit Refs:** `48cb8b0` (JWT pivot) and `323f338` (intermediate). + +--- + +## Self-review notes + +- Spec coverage: every section of the spec maps to a task (scaffold → T1, models → T2, config → T3, state → T4/T9, api → T5/T6, backend base → T7, sync → T8, ics → T10, eventkit → T11, orchestrator → T12, cli commands → T13/T14/T15, error handling → T5/T6/T11/T15 (exit codes) + T16 (logging), testing → every task has TDD, CI → T17, e2e → T18, security → T1 gitignore + T5 token cache 0600 + T13 redaction). +- Placeholder scan: no TBD/TODO. Every step has concrete code or a concrete command. +- Type consistency: `Shift` fields, `Changes` fields, `State` fields, `EawClient` constructor signature, and CLI command names are used consistently across tasks. diff --git a/docs/superpowers/specs/2026-04-19-easyatcal-design.md b/docs/superpowers/specs/2026-04-19-easyatcal-design.md new file mode 100644 index 0000000..2c8a38a --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-easyatcal-design.md @@ -0,0 +1,210 @@ +# EasyAtCal — Design Spec + +**Date:** 2026-04-19 +**Status:** Draft +**Author:** Ailcope + +## Purpose + +One-way sync of shifts from [easy@work](https://www.easyatwork.com) into Apple Calendar. The user's Mac runs the sync; iCloud propagates events to all their Apple devices (iPhone, iPad, Watch). The project is open-source and cross-platform-friendly (the core runs anywhere Python runs; the Apple-specific backend is pluggable). + +## Goals + +- Fetch shifts from the easy@work REST API. +- Create, update, and delete corresponding events in the user's Apple Calendar (via EventKit on macOS) or in a portable `.ics` file (any OS). +- Run manually (`--once`) or as a daemon (`--watch`). +- Keep user credentials and shift data out of the public repo. +- Be easily installable: `pip install easyatcal` exposes an `eaw-sync` CLI. + +## Non-Goals + +- Bi-directional sync (edits in Apple Calendar do not flow back to easy@work). +- A GUI. +- Hosting a shared `.ics` feed for others. +- Supporting non-Apple calendar destinations in v1 (CalDAV/Google reserved for future). + +## Architecture + +Single Python package with pluggable calendar backends. + +``` +[easy@work API] → [Python core] → [backend] + ↑ ├── eventkit (macOS → iCloud → all devices) + config.yaml └── ics (portable file) + (gitignored) +``` + +### Repo layout + +``` +EasyAtCal/ +├── README.md +├── pyproject.toml +├── .gitignore # excludes config.yaml, .env, *.ics, state.json +├── config.example.yaml +├── easyatcal/ +│ ├── __init__.py +│ ├── api.py # easy@work REST client +│ ├── models.py # Shift dataclass +│ ├── sync.py # diff + orchestration +│ ├── config.py # YAML + env loader +│ ├── cli.py # typer entrypoint +│ └── backends/ +│ ├── __init__.py +│ ├── base.py # CalendarBackend interface +│ ├── ics.py # writes .ics file +│ └── eventkit.py # pyobjc EKEventStore +└── tests/ + ├── test_api.py + ├── test_sync.py + ├── test_cli.py + └── backends/ + ├── test_ics.py + └── test_eventkit.py +``` + +## Components + +### `api.py` — easy@work client +- JWT Bearer token auth via UI automation. Playwright headless login extracts JWT from `localStorage`. Replays token against `.api.easyatwork.com/customers/{cid}/employees/{eid}/shifts`. +- Caches access token at `~/.cache/easyatcal/token.json`. +- `fetch_shifts(user_id, from_date, to_date) -> list[Shift]`. +- Handles pagination. +- Retries on 429/5xx with exponential backoff (max 5 attempts). + +### `models.py` +```python +@dataclass +class Shift: + id: str # stable id from easy@work + start: datetime # tz-aware + end: datetime + title: str + location: str | None + notes: str | None + updated_at: datetime # used to detect server-side edits +``` + +### `sync.py` +- Reads local state (`~/.local/share/easyatcal/state.json`: `{shift_id: event_uid}`). +- Computes `{add, update, delete}` diff between remote shifts and state. +- `update` triggered when `shift.updated_at` changed since last sync. +- `delete` triggered for shifts present in state but absent from remote window. +- Calls `backend.apply(changes)` then persists new state. + +### `backends/base.py` +```python +class CalendarBackend(Protocol): + def apply(self, adds: list[Shift], updates: list[tuple[Shift, str]], + deletes: list[str]) -> dict[str, str]: ... + # Returns new mapping shift_id -> event_uid for adds/updates. +``` + +### `backends/ics.py` +- Uses `icalendar` lib to regenerate a full `.ics` file each sync. +- Output path configurable; default `~/Documents/easyatwork-shifts.ics`. +- Stable `UID` derived from `shift.id` so re-imports update in place. + +### `backends/eventkit.py` +- macOS-only; import guarded. +- Uses `pyobjc-framework-EventKit` to access `EKEventStore`. +- Target calendar: configurable name + source (`iCloud`). Creates calendar if missing. +- Requests `EKAuthorizationStatusFullAccess` permission on first run. +- Mapping: `Shift.id` → `EKEvent.externalIdentifier` (stored) + `EKEvent.calendarItemExternalIdentifier` (for lookup). + +### `cli.py` +`typer` app. Commands: +- `eaw-sync sync` — one-shot sync, exits after run. +- `eaw-sync watch --interval 15m` — daemon loop. +- `eaw-sync config init` — scaffold `~/.config/easyatcal/config.yaml` from template. +- `eaw-sync config show` — print effective config (redact secrets). +- `eaw-sync auth test` — verify creds + list calendars. + +## Data flow + +1. CLI parses args → loads `config.yaml` + env overrides. +2. `api.authenticate()` → access token (cached). +3. `api.fetch_shifts(from=today - lookback, to=today + lookahead)` → `list[Shift]`. +4. `sync.diff(remote, state)` → `{adds, updates, deletes}`. +5. `backend.apply(changes)` → mutates calendar, returns event-id mapping. +6. State persisted atomically (write-temp-then-rename). +7. Logs flushed to `~/.local/share/easyatcal/logs/eaw-sync.log`. + +## Config schema + +`~/.config/easyatcal/config.yaml` (gitignored; `config.example.yaml` committed): + +```yaml +easyatwork: + api_url: "https://eu-west-3.api.easyatwork.com" + customer_id: 0 + employee_id: 0 + ui_version: "v3.0.0" + + # Auth configuration for Playwright UI login + email: "user@example.com" + password: "..." # env EAW_PASSWORD overrides + login_url: "https://app.easyatwork.com/login" + app_url: "https://app.easyatwork.com" + login_selectors: + email_input: "input[type='email']" + password_input: "input[type='password']" + submit_button: "button[type='submit']" + post_login_wait: ".dashboard" + headless: true + login_timeout_ms: 30000 + +sync: + lookback_days: 7 + lookahead_days: 90 + +backend: eventkit # or "ics" + +backends: + eventkit: + calendar_name: "Work Shifts" + calendar_source: "iCloud" + ics: + output_path: "~/Documents/easyatwork-shifts.ics" + +logging: + level: INFO +``` + +Env vars override YAML: `EAW_PASSWORD`. + +## Error handling + +| Failure | Behavior | +|---|---| +| Auth failure | Exit 2, message "check credentials". | +| Rate limit (429) | Exponential backoff, max 5 retries. | +| Network down (one-shot) | Exit 3. | +| Network down (watch) | Log warning, retry at next interval. | +| EventKit permission denied | Exit 4 + instructions to grant access in System Settings → Privacy. | +| Malformed shift | Log warning, skip shift, continue. | +| Corrupt state file | Back up to `state.json.bak`, reset, do full re-sync. | + +Logs rotate daily, retained 7 days. + +## Testing + +- `test_api.py`: mock HTTP with `responses` — covers auth, fetch, pagination, retry, rate-limit. +- `test_sync.py`: unit tests on diff matrix (add/update/delete permutations, tz edge cases). +- `test_backends/test_ics.py`: snapshot-compare generated `.ics` against fixture. +- `test_backends/test_eventkit.py`: skipped on non-macOS; uses mock `EKEventStore` otherwise. +- `test_cli.py`: `typer.testing.CliRunner` against each subcommand. +- CI: GitHub Actions, matrix Python 3.11/3.12 × {Linux, macOS}. + +## Open questions / deferred + +- Tenant-specific IDs required (`customer_id`, `employee_id`), which the user must extract from DevTools before first sync or the URL constructor raises an error. +- Whether to support multi-user sync in v1 — deferred; single user only. +- Whether to publish to PyPI — yes once v0.1 ships, but not blocking first release. + +## Security + +- `config.yaml` lives outside the repo (`~/.config/easyatcal/`). `.gitignore` in the repo also blocks any stray copy. +- Secrets can be provided via environment variables instead of YAML for CI/container use. +- Token cache file permissions set to `0600`. +- No shift data ever written to the repo. diff --git a/easyatcal/__init__.py b/easyatcal/__init__.py new file mode 100644 index 0000000..e6c4e0d --- /dev/null +++ b/easyatcal/__init__.py @@ -0,0 +1,3 @@ +"""EasyAtCal — one-way sync of easy@work shifts to Apple Calendar.""" + +__version__ = "0.3.0" diff --git a/easyatcal/api.py b/easyatcal/api.py new file mode 100644 index 0000000..461487c --- /dev/null +++ b/easyatcal/api.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import contextlib +import json +import os +import time +from datetime import UTC, date, datetime, timedelta +from pathlib import Path + +import httpx + +from easyatcal.models import Shift + + +class AuthError(Exception): + pass + + +class ApiError(Exception): + pass + + +class EawClient: + _MAX_RETRIES = 5 + + def __init__( + self, + client_id: str, + client_secret: str, + base_url: str, + token_cache: Path, + timeout: float = 30.0, + ) -> None: + self.client_id = client_id + self.client_secret = client_secret + self.base_url = base_url.rstrip("/") + self.token_cache = Path(token_cache) + self._http = httpx.Client(timeout=timeout) + self._token: str | None = None + + # ----- auth ----- + + def authenticate(self) -> str: + cached = self._read_cache() + if cached is not None: + self._token = cached + return cached + return self._fetch_token() + + def _read_cache(self) -> str | None: + if not self.token_cache.exists(): + return None + try: + data = json.loads(self.token_cache.read_text()) + except (json.JSONDecodeError, ValueError): + return None + expires_at = datetime.fromisoformat(data["expires_at"]) + if expires_at <= datetime.now(UTC): + return None + return str(data["access_token"]) + + def _fetch_token(self) -> str: + try: + r = self._http.post( + f"{self.base_url}/oauth/token", + data={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + ) + except httpx.HTTPError as e: + raise AuthError(f"network error during auth: {e}") from e + if r.status_code != 200: + raise AuthError(f"auth failed: {r.status_code} {r.text}") + data = r.json() + token = str(data["access_token"]) + expires_at = datetime.now(UTC) + timedelta( + seconds=int(data.get("expires_in", 3600)) + ) + self._write_cache(token, expires_at) + self._token = token + return token + + def _write_cache(self, token: str, expires_at: datetime) -> None: + self.token_cache.parent.mkdir(parents=True, exist_ok=True) + payload = {"access_token": token, "expires_at": expires_at.isoformat()} + tmp = self.token_cache.with_suffix(self.token_cache.suffix + ".tmp") + tmp.write_text(json.dumps(payload)) + os.replace(tmp, self.token_cache) + with contextlib.suppress(OSError): + os.chmod(self.token_cache, 0o600) + + # ----- shifts ----- + + def fetch_shifts( + self, from_date: date, to_date: date, user_id: str | None = None + ) -> list[Shift]: + """Return list[Shift] between from_date (inclusive) and to_date (exclusive).""" + token = self.authenticate() + url: str | None = f"{self.base_url}/v1/shifts" + headers = {"Authorization": f"Bearer {token}"} + first_params: dict[str, str] = { + "from": from_date.isoformat(), + "to": to_date.isoformat(), + } + if user_id is not None: + first_params["user_id"] = user_id + params: dict[str, str] | None = first_params + + out: list[Shift] = [] + while url is not None: + attempts = 0 + while True: + r = self._http.get(url, params=params, headers=headers) + if r.status_code == 200: + break + if r.status_code in (429, 500, 502, 503, 504): + attempts += 1 + if attempts > self._MAX_RETRIES: + raise ApiError( + f"rate limit / server errors exceeded retries " + f"({r.status_code})" + ) + delay = 2 ** (attempts - 1) + retry_after = r.headers.get("Retry-After") + if retry_after is not None: + with contextlib.suppress(ValueError): + delay = max(delay, int(retry_after)) + time.sleep(delay) + continue + raise ApiError(f"GET {url} -> {r.status_code} {r.text}") + + try: + payload = r.json() + for raw in payload.get("data", []): + out.append( + Shift( + id=str(raw["id"]), + start=datetime.fromisoformat(raw["start"]), + end=datetime.fromisoformat(raw["end"]), + title=raw.get("title", "Shift"), + location=raw.get("location"), + notes=raw.get("notes"), + updated_at=datetime.fromisoformat(raw["updated_at"]), + ) + ) + url = payload.get("next") + if url: + # next URL already includes cursor. httpx strips the + # URL query when params={} is passed, so use None. + params = None + except (KeyError, TypeError, ValueError) as e: + raise ApiError( + f"Unexpected API response shape. Failed to parse: {e}. " + f"Raw payload keys: {list(payload.keys()) if isinstance(payload, dict) else 'not a dict'}" + ) from e + return out diff --git a/easyatcal/api_session.py b/easyatcal/api_session.py new file mode 100644 index 0000000..1341a76 --- /dev/null +++ b/easyatcal/api_session.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import contextlib +import time +from datetime import UTC, date, datetime +from typing import Any + +import httpx + +from easyatcal.api import ApiError, AuthError +from easyatcal.models import Shift +from easyatcal.session import SessionStore + + +class SessionEawClient: + """Fetches shifts against the regional easy@work API using the JWT + the SPA obtains at login. + + URL shape observed in the wild (EU-West-3 tenant):: + + GET https://eu-west-3.api.easyatwork.com + /customers/{customer_id}/employees/{employee_id}/shifts + ?from=YYYY-MM-DD HH:MM:SS + &order_by=from&direction=asc + &with[]=schedule.customer + Authorization: Bearer + Origin: https://app.easyatwork.com + + The JWT is extracted from the Playwright ``storage_state``'s + localStorage (populated by ``eaw-sync login``). + """ + + _MAX_RETRIES = 5 + + def __init__( + self, + *, + shifts_url: str, + session_store: SessionStore, + origin: str = "https://app.easyatwork.com", + ui_version: str = "2.313.0", + timeout: float = 30.0, + ) -> None: + self.shifts_url = shifts_url + self.session_store = session_store + self.origin = origin.rstrip("/") + self.ui_version = ui_version + self._http = httpx.Client(timeout=timeout) + self._token: str | None = None + + def authenticate(self) -> None: + token = self.session_store.access_token() + if token is None: + raise AuthError( + "No access token in stored session. Run `eaw-sync login`." + ) + self._token = token + self._http.headers.update( + { + "Authorization": f"Bearer {token}", + "Accept": "application/json, text/plain, */*", + "Origin": self.origin, + "Referer": f"{self.origin}/", + "X-Ui-Version": self.ui_version, + "Cache-Control": "no-cache", + "Pragma": "no-cache", + } + ) + + def fetch_shifts( + self, + from_date: date, + to_date: date, + user_id: str | None = None, # kept for ShiftFetcher protocol + ) -> list[Shift]: + self.authenticate() + + # easy@work wants space-separated "YYYY-MM-DD HH:MM:SS". httpx + # URL-encodes the space as %20 automatically. + from_str = f"{from_date.isoformat()} 00:00:00" + to_str = f"{to_date.isoformat()} 23:59:59" + + # httpx accepts sequences for repeated params: `with[]=schedule.customer` + params: list[tuple[str, str | int | float | bool | None]] = [ + ("from", from_str), + ("to", to_str), + ("order_by", "from"), + ("direction", "asc"), + ("with[]", "schedule.customer"), + ] + + url: str | None = self.shifts_url + first = True + out: list[Shift] = [] + while url is not None: + r = self._retry_get(url, params if first else None) + first = False + try: + payload = r.json() + for raw in _iter_rows(payload): + out.append(_parse_shift(raw)) + url = _next_url(payload) + except (KeyError, TypeError, ValueError) as e: + raise ApiError( + f"Unexpected session API response shape. Parse error: {e}. " + f"Top-level keys: " + f"{list(payload.keys()) if isinstance(payload, dict) else 'not a dict'}" + ) from e + return out + + def _retry_get( + self, + url: str, + params: list[tuple[str, str | int | float | bool | None]] | None, + ) -> httpx.Response: + attempts = 0 + while True: + r = self._http.get(url, params=params) + if r.status_code == 200: + return r + if r.status_code == 401: + raise AuthError( + "Access token rejected (HTTP 401). " + "Token probably expired — run `eaw-sync login`." + ) + if r.status_code in (429, 500, 502, 503, 504): + attempts += 1 + if attempts > self._MAX_RETRIES: + raise ApiError( + f"rate limit / server errors exceeded retries " + f"({r.status_code})" + ) + delay = 2 ** (attempts - 1) + retry_after = r.headers.get("Retry-After") + if retry_after is not None: + with contextlib.suppress(ValueError): + delay = max(delay, int(retry_after)) + time.sleep(delay) + continue + raise ApiError(f"GET {url} -> {r.status_code} {r.text[:300]}") + + +def _iter_rows(payload: Any) -> list[dict[str, Any]]: + """Accept common Laravel/DRF paginated shapes until we pin the real + one: + + - ``{"data": [...], "next_page_url": ...}`` (Laravel paginator) + - ``{"data": [...], "meta": {...}}`` (Laravel resource) + - ``{"results": [...], "next": ...}`` (DRF) + - ``{"items": [...]}`` / ``{"shifts": [...]}`` + - ``[...]`` (bare list) + """ + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + for key in ("data", "results", "items", "shifts"): + v = payload.get(key) + if isinstance(v, list): + return v + raise ValueError( + f"no recognized rows key (data/results/items/shifts) in payload; " + f"keys present: {list(payload)}" + ) + raise ValueError(f"unexpected payload type: {type(payload).__name__}") + + +def _next_url(payload: Any) -> str | None: + if not isinstance(payload, dict): + return None + # Laravel paginator + v = payload.get("next_page_url") + if isinstance(v, str) and v: + return v + for key in ("next", "next_url", "nextPage"): + v = payload.get(key) + if isinstance(v, str) and v: + return v + links = payload.get("links") + if isinstance(links, dict): + v = links.get("next") + if isinstance(v, str) and v: + return v + return None + + +def _parse_shift(raw: dict[str, Any]) -> Shift: + """Best-effort mapping. Accepts a handful of common field spellings. + + Observed so far (will expand once a response body is available): + - id: ``id`` / ``uuid`` / ``shiftId`` + - start: ``start`` / ``starts_at`` / ``from`` / ``start_date`` + - end: ``end`` / ``ends_at`` / ``to`` / ``end_date`` + - updated_at: ``updated_at`` / ``updatedAt`` / ``modified_at`` + - title: ``title`` / ``name`` / ``label`` / nested + ``schedule.customer.name`` (via `with[]=schedule.customer`) + - location: ``location`` / ``place`` / ``site`` + """ + + def pick(*keys: str) -> Any: + for k in keys: + if k in raw and raw[k] is not None: + return raw[k] + return None + + id_val = pick("id", "uuid", "shiftId") + start_val = pick("start", "starts_at", "from", "start_date", "startTime") + end_val = pick("end", "ends_at", "to", "end_date", "endTime") + updated_val = pick("updated_at", "updatedAt", "modified_at", "modifiedAt") + + if id_val is None or start_val is None or end_val is None: + raise ValueError( + f"shift row missing id/start/end; keys present: {list(raw)}" + ) + + # Title: prefer schedule.customer.name if included (matches `with[]`) + title = pick("title", "name", "label") + location = pick("location", "place", "site") + notes = pick("notes", "description", "comments") + + schedule = raw.get("schedule") + if isinstance(schedule, dict): + customer = schedule.get("customer") + if isinstance(customer, dict): + if title is None: + title = customer.get("name") + + # If no direct location, try to extract address from customer + if location is None: + addr_parts = [] + for k in ("address1", "address2", "postal_code", "city"): + val = customer.get(k) + if val and str(val).strip(): + addr_parts.append(str(val).strip()) + if addr_parts: + location = ", ".join(addr_parts) + + if not title: + title = "Shift" + + return Shift( + id=str(id_val), + start=_parse_dt(start_val), + end=_parse_dt(end_val), + title=str(title), + location=str(location) if location else None, + notes=str(notes) if notes else None, + updated_at=_parse_dt(updated_val) if updated_val else datetime.now(UTC), + ) + + +def _parse_dt(s: str) -> datetime: + """Accept both ISO-8601 (``2026-04-20T09:00:00+00:00``) and + Laravel-style (``2026-04-20 09:00:00``) timestamps. Naive values + are treated as UTC — the easy@work API sends tenant-local + timestamps without an offset. + """ + try: + dt = datetime.fromisoformat(s) + except ValueError: + dt = datetime.fromisoformat(s.replace(" ", "T")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt diff --git a/easyatcal/auth_user.py b/easyatcal/auth_user.py new file mode 100644 index 0000000..ab43bc6 --- /dev/null +++ b/easyatcal/auth_user.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import contextlib +import json +import os +import re +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from easyatcal.config import EasyAtWorkAuth + + +class PlaywrightMissingError(RuntimeError): + """Raised when auth_mode=user is configured but Playwright is not installed.""" + + +class LoginError(RuntimeError): + """Raised when headless login fails (wrong selector, creds, etc.).""" + + +def do_login( + cfg: EasyAtWorkAuth, + password: str, + storage_path: Path, + extra_wait_selector: str | None = None, +) -> None: + """Drive a headless browser through the easy@work login form, + then persist the storage_state to ``storage_path``. + + ``extra_wait_selector`` is an optional CSS selector to wait for after + form submit — useful when the post-login page has a known landmark + (e.g. ``nav[data-testid='app-shell']``). Defaults to a generic + ``networkidle`` wait. + """ + try: + from playwright.sync_api import ( + TimeoutError as PWTimeout, + ) + from playwright.sync_api import ( + sync_playwright, + ) + except ImportError as e: # pragma: no cover — platform guard + raise PlaywrightMissingError( + "Playwright not installed. Run: pip install 'easyatcal[playwright]' " + "&& playwright install chromium" + ) from e + + if cfg.email is None: + raise LoginError("no email configured in easyatwork.email") + + storage_path.parent.mkdir(parents=True, exist_ok=True) + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=cfg.headless) + try: + context = browser.new_context() + page = context.new_page() + + discovered_meta: dict[str, str | int] = {} + def on_request(request: Any) -> None: + match = re.search(r"^(https?://[^/]+)/customers/(\d+)/employees/(\d+)", request.url) + if match: + discovered_meta["api_url"] = match.group(1) + discovered_meta["customer_id"] = int(match.group(2)) + discovered_meta["employee_id"] = int(match.group(3)) + + page.on("request", on_request) + + page.goto(cfg.login_url, wait_until="domcontentloaded") + + try: + page.wait_for_selector(cfg.email_selector, timeout=cfg.login_timeout_ms) + except PWTimeout as e: + raise LoginError( + f"login form not found at {cfg.login_url} " + f"(selector {cfg.email_selector!r}). " + f"Set easyatwork.email_selector to match your tenant." + ) from e + + page.fill(cfg.email_selector, cfg.email) + page.fill(cfg.password_selector, password) + page.click(cfg.submit_selector) + + try: + if extra_wait_selector: + page.wait_for_selector( + extra_wait_selector, timeout=cfg.login_timeout_ms + ) + else: + page.wait_for_load_state( + "networkidle", timeout=cfg.login_timeout_ms + ) + except PWTimeout as e: + raise LoginError( + f"login did not complete (timeout waiting post-submit). " + f"Current URL: {page.url}" + ) from e + + # Heuristic: if we're still on login-ish URL, assume failure. + final_url = page.url.lower() + if any(x in final_url for x in ("login", "signin", "sign-in")): + raise LoginError( + f"login did not advance off login page. URL: {page.url}. " + f"Check credentials or selectors." + ) + + # Wait a little longer just in case the API request hasn't fired yet + if not discovered_meta: + with contextlib.suppress(PWTimeout): + page.wait_for_timeout(3000) + + state: dict[str, Any] = dict(context.storage_state()) + if discovered_meta: + state["eaw_meta"] = discovered_meta + + tmp = storage_path.with_suffix(storage_path.suffix + ".tmp") + tmp.write_text(json.dumps(state)) + os.replace(tmp, storage_path) + with contextlib.suppress(OSError): + os.chmod(storage_path, 0o600) + finally: + browser.close() diff --git a/easyatcal/backends/__init__.py b/easyatcal/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easyatcal/backends/base.py b/easyatcal/backends/base.py new file mode 100644 index 0000000..5d983cd --- /dev/null +++ b/easyatcal/backends/base.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol + +from easyatcal.models import Shift + + +@dataclass +class Changes: + adds: list[Shift] = field(default_factory=list) + updates: list[tuple[Shift, str]] = field(default_factory=list) + # list of event uids to delete + deletes: list[str] = field(default_factory=list) + + def is_empty(self) -> bool: + return not (self.adds or self.updates or self.deletes) + + +@dataclass +class ApplyResult: + """Outcome of a backend.apply call. + + `mapping` is shift_id -> event_uid for every add/update that succeeded. + `deleted_uids` is the subset of `Changes.deletes` that the backend + confirms were removed from the underlying calendar. + """ + mapping: dict[str, str] = field(default_factory=dict) + deleted_uids: list[str] = field(default_factory=list) + + +class BackendError(RuntimeError): + """Raised by a backend when apply fails partway through. + + Carries whatever progress was made so the orchestrator can persist it + before re-raising. + """ + def __init__(self, message: str, partial: ApplyResult) -> None: + super().__init__(message) + self.partial = partial + + +class CalendarBackend(Protocol): + def set_all_shifts(self, shifts: list[Shift]) -> None: + """Provide the backend with the complete list of known valid shifts.""" + ... + + def apply(self, changes: Changes) -> ApplyResult: + """Apply the given changes and return an ApplyResult. + + On partial failure, raise BackendError with a populated .partial.""" + ... diff --git a/easyatcal/backends/eventkit.py b/easyatcal/backends/eventkit.py new file mode 100644 index 0000000..97cb303 --- /dev/null +++ b/easyatcal/backends/eventkit.py @@ -0,0 +1,217 @@ +"""macOS EventKit calendar backend. + +Only usable on macOS. Requires `pyobjc-framework-EventKit` (install the +`eventkit` extra). +""" +from __future__ import annotations + +import sys +from typing import Any + +from easyatcal.backends.base import ApplyResult, BackendError, Changes +from easyatcal.models import Shift + + +class EventKitUnavailableError(RuntimeError): + pass + + +class EventKitPermissionError(RuntimeError): + pass + + +def _import_eventkit() -> Any: # pragma: no cover — platform guard + if sys.platform != "darwin": + raise EventKitUnavailableError("EventKit backend requires macOS") + try: + import EventKit + except ImportError as e: + raise EventKitUnavailableError( + "pyobjc-framework-EventKit not installed; " + "pip install 'easyatcal[eventkit]'" + ) from e + return EventKit + + +def _event_store() -> Any: # pragma: no cover — exercised via mocks in tests + EventKit = _import_eventkit() + store = EventKit.EKEventStore.alloc().init() + from threading import Event as _E + granted = {"ok": False, "err": None} + done = _E() + + def _cb(ok: bool, err: Any) -> None: + granted["ok"] = bool(ok) + granted["err"] = err + done.set() + + try: + store.requestFullAccessToEventsWithCompletion_(_cb) + except AttributeError: + store.requestAccessToEntityType_completion_(0, _cb) # 0 = EKEntityTypeEvent + + done.wait(timeout=30) + if not granted["ok"]: + raise EventKitPermissionError( + "Calendar access denied — grant access in System Settings → " + "Privacy & Security → Calendars." + ) + return store + + +def _new_event( + store: Any, + calendar: Any, + shift: Shift, + event_title_format: str = "{title}", + alarm_minutes_before: int | None = None, +) -> Any: # pragma: no cover + EventKit = _import_eventkit() + import Foundation + + event = EventKit.EKEvent.eventWithEventStore_(store) + event.setCalendar_(calendar) + + title = event_title_format.format( + title=shift.title, + location=shift.location or "", + notes=shift.notes or "", + ).strip() + event.setTitle_(title) + + event.setStartDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.start.timestamp()) + ) + event.setEndDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.end.timestamp()) + ) + if shift.location: + event.setLocation_(shift.location) + if shift.notes: + event.setNotes_(shift.notes) + + if alarm_minutes_before is not None: + alarm = EventKit.EKAlarm.alarmWithRelativeOffset_(-alarm_minutes_before * 60) + event.addAlarm_(alarm) + + return event + + +class EventKitBackend: + def __init__( + self, + calendar_name: str, + calendar_source: str, + event_title_format: str = "{title}", + alarm_minutes_before: int | None = None, + ) -> None: + self.calendar_name = calendar_name + self.calendar_source = calendar_source + self.event_title_format = event_title_format + self.alarm_minutes_before = alarm_minutes_before + self._store = _event_store() + self._calendar = self._resolve_calendar() + + def _resolve_calendar(self) -> Any: + calendars = self._store.calendarsForEntityType_(0) + for cal in calendars: + if ( + cal.title() == self.calendar_name + and cal.source().title() == self.calendar_source + ): + return cal + raise RuntimeError( + f"Calendar {self.calendar_name!r} not found in source " + f"{self.calendar_source!r}. Create it in Calendar.app first." + ) + + def set_all_shifts(self, shifts: list[Shift]) -> None: + pass + + def apply(self, changes: Changes) -> ApplyResult: + result = ApplyResult() + try: + for shift in changes.adds: + event = _new_event( + self._store, + self._calendar, + shift, + self.event_title_format, + self.alarm_minutes_before, + ) + ok, err = self._store.saveEvent_span_error_(event, 0, None) + if not ok: + raise BackendError(f"saveEvent failed for {shift.id}: {err}", result) + result.mapping[shift.id] = event.calendarItemExternalIdentifier() + + for shift, event_uid in changes.updates: + existing = self._store.calendarItemWithIdentifier_(event_uid) + if existing is None: + event = _new_event( + self._store, + self._calendar, + shift, + self.event_title_format, + self.alarm_minutes_before, + ) + ok, err = self._store.saveEvent_span_error_(event, 0, None) + if not ok: + raise BackendError( + f"saveEvent (replacement) failed for {shift.id}: {err}", + result, + ) + result.mapping[shift.id] = event.calendarItemExternalIdentifier() + continue + + title = self.event_title_format.format( + title=shift.title, + location=shift.location or "", + notes=shift.notes or "", + ).strip() + existing.setTitle_(title) + + import Foundation + existing.setStartDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_( + shift.start.timestamp() + ) + ) + existing.setEndDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_( + shift.end.timestamp() + ) + ) + if shift.location is not None: + existing.setLocation_(shift.location) + if shift.notes is not None: + existing.setNotes_(shift.notes) + + if self.alarm_minutes_before is not None: + # Clear existing alarms and add the configured one + existing.removeAllAlarms() + EventKit = _import_eventkit() + alarm = EventKit.EKAlarm.alarmWithRelativeOffset_(-self.alarm_minutes_before * 60) + existing.addAlarm_(alarm) + + ok, err = self._store.saveEvent_span_error_(existing, 0, None) + if not ok: + raise BackendError( + f"saveEvent (update) failed for {shift.id}: {err}", result + ) + result.mapping[shift.id] = event_uid + + for event_uid in changes.deletes: + existing = self._store.calendarItemWithIdentifier_(event_uid) + if existing is None: + # Treat as already-deleted so state stays clean. + result.deleted_uids.append(event_uid) + continue + ok, err = self._store.removeEvent_span_error_(existing, 0, None) + if not ok: + raise BackendError( + f"removeEvent failed for {event_uid}: {err}", result + ) + result.deleted_uids.append(event_uid) + except BackendError: + raise + return result diff --git a/easyatcal/backends/ics.py b/easyatcal/backends/ics.py new file mode 100644 index 0000000..81b8619 --- /dev/null +++ b/easyatcal/backends/ics.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from icalendar import Calendar, Event + +from easyatcal.backends.base import ApplyResult, Changes +from easyatcal.models import Shift + +UID_PREFIX = "easyatcal-" +# Holds the unformatted title so a custom event_title_format is not re-applied +# to an already-formatted summary when an old event is reloaded from the file. +RAW_TITLE_PROP = "X-EASYATCAL-TITLE" + + +def _uid_for(shift_id: str) -> str: + return f"{UID_PREFIX}{shift_id}" + + +def _shift_from_event(comp: Any) -> Shift | None: + """Reconstruct a Shift from a VEVENT we previously wrote. Returns None for + events we cannot faithfully rebuild (e.g. all-day or naive datetimes).""" + uid = str(comp.get("uid", "")) + if not uid.startswith(UID_PREFIX): + return None + try: + start = comp.decoded("dtstart") + end = comp.decoded("dtend") + except (KeyError, ValueError): + return None + if not isinstance(start, datetime) or not isinstance(end, datetime): + return None + raw_title = comp.get(RAW_TITLE_PROP) or comp.get("summary") + location = comp.get("location") + notes = comp.get("description") + try: + return Shift( + id=uid[len(UID_PREFIX):], + start=start, + end=end, + title=str(raw_title) if raw_title else "Shift", + location=str(location) if location else None, + notes=str(notes) if notes else None, + updated_at=datetime.now(UTC), + ) + except ValueError: + return None + + +def _to_event( + shift: Shift, + uid: str, + event_title_format: str = "{title}", + alarm_minutes_before: int | None = None, +) -> Any: + from datetime import UTC, datetime, timedelta + + from icalendar import Alarm + ev = Event() # type: ignore[no-untyped-call] + ev.add("uid", uid) + + # Format the title + title = event_title_format.format( + title=shift.title, + location=shift.location or "", + notes=shift.notes or "", + ).strip() + + ev.add("summary", title) + ev.add(RAW_TITLE_PROP, shift.title) + ev.add("dtstart", shift.start) + ev.add("dtend", shift.end) + # Always use current time for dtstamp to indicate when the file was generated + now = datetime.now(UTC) + ev.add("dtstamp", now) + # Force an update by bumping the sequence (or using the timestamp) and updating last-modified + ev.add("last-modified", now) + ev.add("sequence", int(now.timestamp())) + ev.add("status", "CONFIRMED") + ev.add("transp", "OPAQUE") # Standard: Show as busy + ev.add("X-MICROSOFT-CDO-BUSYSTATUS", "BUSY") # Outlook specific + if shift.location: + ev.add("location", shift.location) + if shift.notes: + ev.add("description", shift.notes) + + if alarm_minutes_before is not None: + alarm = Alarm() # type: ignore[no-untyped-call] + alarm.add("action", "DISPLAY") + alarm.add("description", "Shift Reminder") + alarm.add("trigger", timedelta(minutes=-alarm_minutes_before)) + ev.add_component(alarm) + + return ev + + +class IcsBackend: + """File-based calendar backend that regenerates the .ics on each apply. + + `known_shifts` is the previous set of shifts the caller knows about — used + so we can rewrite the file without losing events unrelated to the current + change set. + """ + + def __init__( + self, + output_path: Path, + known_shifts: list[Shift], + event_title_format: str = "{title}", + alarm_minutes_before: int | None = None, + ) -> None: + self.output_path = Path(output_path).expanduser() + self.event_title_format = event_title_format + self.alarm_minutes_before = alarm_minutes_before + # Seed from any existing file so events outside the current fetch window + # survive regeneration, then overlay caller-provided known shifts. + self._current: dict[str, Shift] = self._load_existing() + for s in known_shifts: + self._current[s.id] = s + + def _load_existing(self) -> dict[str, Shift]: + if not self.output_path.exists(): + return {} + try: + cal = Calendar.from_ical(self.output_path.read_bytes()) + except (ValueError, KeyError): + return {} + out: dict[str, Shift] = {} + for comp in cal.walk("VEVENT"): + shift = _shift_from_event(comp) + if shift is not None: + out[shift.id] = shift + return out + + def set_all_shifts(self, shifts: list[Shift]) -> None: + # Merge: refresh window shifts without dropping previously-known ones. + for s in shifts: + self._current[s.id] = s + + def apply(self, changes: Changes) -> ApplyResult: + mapping: dict[str, str] = {} + + for shift in changes.adds: + self._current[shift.id] = shift + mapping[shift.id] = _uid_for(shift.id) + + for shift, _event_uid in changes.updates: + self._current[shift.id] = shift + mapping[shift.id] = _uid_for(shift.id) + + delete_uids = set(changes.deletes) + to_drop = [ + sid for sid in self._current + if _uid_for(sid) in delete_uids + ] + confirmed_deletes: list[str] = [] + for sid in to_drop: + self._current.pop(sid, None) + confirmed_deletes.append(_uid_for(sid)) + # Any requested delete for a uid we never knew about is treated as + # "already gone" — surface it so the orchestrator prunes state. + for uid in delete_uids - set(confirmed_deletes): + confirmed_deletes.append(uid) + + self._write() + return ApplyResult(mapping=mapping, deleted_uids=confirmed_deletes) + + def _write(self) -> None: + cal = Calendar() # type: ignore[no-untyped-call] + cal.add("prodid", "-//EasyAtCal//EN") + cal.add("version", "2.0") + cal.add("calscale", "GREGORIAN") + cal.add("method", "PUBLISH") # Crucial for Outlook + cal.add("x-wr-calname", "easy@work") # Apple Calendar display name + cal.add("x-wr-caldesc", "Work shifts imported from easy@work") + for shift in self._current.values(): + cal.add_component(_to_event( + shift, + _uid_for(shift.id), + self.event_title_format, + self.alarm_minutes_before, + )) + + self.output_path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.output_path.with_suffix(self.output_path.suffix + ".tmp") + tmp.write_bytes(cal.to_ical()) + tmp.replace(self.output_path) diff --git a/easyatcal/cli.py b/easyatcal/cli.py new file mode 100644 index 0000000..71a7ad8 --- /dev/null +++ b/easyatcal/cli.py @@ -0,0 +1,716 @@ +from __future__ import annotations + +import time +from datetime import UTC +from pathlib import Path +from typing import Any + +import typer +import yaml + +from easyatcal.api import EawClient +from easyatcal.api_session import SessionEawClient +from easyatcal.backends.base import CalendarBackend +from easyatcal.backends.ics import IcsBackend +from easyatcal.config import Config, load_config +from easyatcal.logging_setup import configure_logging +from easyatcal.orchestrator import ShiftFetcher, run_sync +from easyatcal.paths import ( + config_path, + log_path, + session_state_path, + state_path, + token_cache_path, +) +from easyatcal.session import SessionStore + +app = typer.Typer(help="EasyAtCal — sync easy@work shifts to Apple Calendar.") +config_app = typer.Typer(help="Manage the config file.") +auth_app = typer.Typer(help="Credential checks.") +state_app = typer.Typer(help="Inspect local sync state.") +app.add_typer(config_app, name="config") +app.add_typer(auth_app, name="auth") +app.add_typer(state_app, name="state") + +EXAMPLE_CONFIG = Path(__file__).parent.parent / "config.example.yaml" + +# Override set by the root callback when --config-path is given. +_CONFIG_OVERRIDE: Path | None = None +_LOG_LEVEL_OVERRIDE: str | None = None + + +def _cfg_path() -> Path: + return _CONFIG_OVERRIDE if _CONFIG_OVERRIDE is not None else config_path() + + +def _get_log_level(cfg_level: str) -> str: + return _LOG_LEVEL_OVERRIDE if _LOG_LEVEL_OVERRIDE is not None else cfg_level + + +def _is_french() -> bool: + import locale + import os + + lang = os.environ.get("LANG") or (locale.getlocale()[0] or "") + return lang.lower().startswith("fr") + + +def _version_callback(value: bool) -> None: + if value: + from easyatcal import __version__ + + typer.echo(f"easyatcal {__version__}") + raise typer.Exit() + + +@app.callback() +def _root( + config_path_override: Path | None = typer.Option( # noqa: B008 + None, + "--config-path", + help="Override the default config file location.", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Set log level to DEBUG.", + ), + quiet: bool = typer.Option( + False, + "--quiet", + "-q", + help="Set log level to WARNING.", + ), + _version: bool = typer.Option( # noqa: B008 + False, + "--version", + help="Print version and exit.", + callback=_version_callback, + is_eager=True, + ), +) -> None: + global _CONFIG_OVERRIDE + global _LOG_LEVEL_OVERRIDE + _CONFIG_OVERRIDE = config_path_override + if verbose: + _LOG_LEVEL_OVERRIDE = "DEBUG" + elif quiet: + _LOG_LEVEL_OVERRIDE = "WARNING" + + +# ---------- helpers ---------- + +def _build_api_client(cfg: Config) -> ShiftFetcher: + if cfg.easyatwork.auth_mode == "client": + # OAuth public-API mode (kept for forward-compat). + assert cfg.easyatwork.client_id and cfg.easyatwork.client_secret + return EawClient( + client_id=cfg.easyatwork.client_id, + client_secret=cfg.easyatwork.client_secret, + base_url=cfg.easyatwork.base_url, + token_cache=token_cache_path(), + ) + # auth_mode == "user" — JWT Bearer mode (token from localStorage) + session_store = SessionStore(session_state_path()) + return SessionEawClient( + shifts_url=cfg.easyatwork.shifts_url(session_store.eaw_meta()), + session_store=session_store, + origin=cfg.easyatwork.app_url, + ui_version=cfg.easyatwork.ui_version, + ) + + +def _build_backend(cfg: Config) -> CalendarBackend: + title_fmt = cfg.sync.event_title_format + alarm_min = cfg.sync.alarm_minutes_before + if cfg.backend == "ics": + return IcsBackend( + output_path=Path(cfg.backends.ics.output_path).expanduser(), + known_shifts=[], + event_title_format=title_fmt, + alarm_minutes_before=alarm_min, + ) + if cfg.backend == "eventkit": + from easyatcal.backends.eventkit import EventKitBackend + return EventKitBackend( + calendar_name=cfg.backends.eventkit.calendar_name, + calendar_source=cfg.backends.eventkit.calendar_source, + event_title_format=title_fmt, + alarm_minutes_before=alarm_min, + ) + raise RuntimeError(f"Unknown backend: {cfg.backend}") + + +# ---------- config ---------- + +@config_app.command("init") +def config_init( + interactive: bool = typer.Option( + True, + "--interactive/--no-interactive", + help="Prompt for common configuration values interactively.", + ), +) -> None: + """Scaffold a config file at the user config dir.""" + target = _cfg_path() + if target.exists(): + typer.echo(f"Config already exists at {target}", err=True) + raise typer.Exit(code=1) + target.parent.mkdir(parents=True, exist_ok=True) + + with open(EXAMPLE_CONFIG) as f: + template = f.read() + + import sys + + fr = _is_french() + + if interactive: + + if fr: + typer.secho("🔧 Configurons la synchronisation de votre calendrier easy@work !\n", fg="cyan", bold=True) + email = typer.prompt("1. Quel est l'email de votre compte easy@work ?") + + title_format = typer.prompt( + "2. Comment souhaitez-vous nommer vos événements ?\n (Variables disponibles: {title}, {location}, {notes})", + default="{title}" + ) + + wants_alarm = typer.confirm("3. Voulez-vous un rappel avant vos shifts ?") + if wants_alarm: + alarm_mins = typer.prompt(" Combien de minutes avant le shift ?", default=60, type=int) + template = template.replace('alarm_minutes_before: null', f'alarm_minutes_before: {alarm_mins}') + else: + typer.secho("🔧 Let's set up your easy@work calendar sync!\n", fg="cyan", bold=True) + email = typer.prompt("1. What is your easy@work login email?") + + title_format = typer.prompt( + "2. How should we name your calendar events?\n (Available variables: {title}, {location}, {notes})", + default="{title}" + ) + + wants_alarm = typer.confirm("3. Do you want a reminder before your shifts?") + if wants_alarm: + alarm_mins = typer.prompt(" How many minutes before your shift?", default=60, type=int) + template = template.replace('alarm_minutes_before: null', f'alarm_minutes_before: {alarm_mins}') + + template = template.replace('email: "me@example.com"', f'email: "{email}"') + template = template.replace('event_title_format: "{title}"', f'event_title_format: "{title_format}"') + + backend_choices = ["ics"] + if sys.platform == "darwin": + backend_choices.append("eventkit") + if fr: + prompt_backend = "4. Quelle intégration de calendrier préférez-vous ?\n [ics] Fichier universel (Compatible avec tout)\n [eventkit] Directement dans Apple Calendar (macOS uniquement)\n " + else: + prompt_backend = "4. Which calendar integration do you prefer?\n [ics] File-based (Universal)\n [eventkit] Direct to Apple Calendar (macOS only)\n " + + backend = typer.prompt(prompt_backend, default="ics") + if backend in ["ics", "eventkit"]: + template = template.replace('backend: ics', f'backend: {backend}') + else: + if fr: + typer.echo("4. Utilisation du backend 'ics' (format universel pour Windows/Linux).") + else: + typer.echo("4. Using 'ics' backend (universal format for Windows/Linux).") + + if fr: + typer.secho("\n✅ Configuration générée avec succès !", fg="green") + else: + typer.secho("\n✅ Configuration generated successfully!", fg="green") + + with open(target, "w") as f: + f.write(template) + + if fr: + typer.echo(f"Fichier écrit dans {target}.") + else: + typer.echo(f"Wrote {target}.") + + if interactive: + if fr: + typer.secho("\nProchaines étapes :", fg="cyan", bold=True) + typer.echo("1. Lancez `eaw-sync login` pour vous connecter.") + typer.echo("2. Lancez `eaw-sync sync` pour récupérer vos horaires.") + else: + typer.secho("\nNext steps:", fg="cyan", bold=True) + typer.echo("1. Run `eaw-sync login` to connect to your account.") + typer.echo("2. Run `eaw-sync sync` to fetch your shifts.") + else: + if fr: + typer.echo("Modifiez le fichier avant de lancer `eaw-sync sync`.") + else: + typer.echo("Edit the file before running `eaw-sync sync`.") + + +@config_app.command("show") +def config_show() -> None: + """Print the effective config with secrets redacted.""" + cfg = load_config(_cfg_path()) + dumped = cfg.model_dump() + if dumped["easyatwork"].get("client_secret"): + dumped["easyatwork"]["client_secret"] = "***" + typer.echo(yaml.safe_dump(dumped, sort_keys=False)) + + +# ---------- login (session auth) ---------- + +@app.command("login") +def login_cmd( + password_env: str = typer.Option( + "EAW_PASSWORD", + "--password-env", + help="Env var holding the password. If unset, prompt interactively.", + ), + headful: bool = typer.Option( + False, + "--headful", + help="Run browser visibly (debug failing login).", + ), +) -> None: + """Open a headless browser, log in to easy@work, persist the session. + + Requires ``auth_mode: user`` and ``email`` in config, plus the + ``playwright`` optional extra installed. + """ + import os + + from easyatcal.auth_user import LoginError, PlaywrightMissingError, do_login + + cfg = load_config(_cfg_path()) + configure_logging( + level=_get_log_level(cfg.logging.level), + log_file=log_path(), + fmt=cfg.logging.format, + ) + + if cfg.easyatwork.auth_mode != "user": + typer.echo("auth_mode is not 'user' — nothing to log in to.", err=True) + raise typer.Exit(code=1) + + password = os.environ.get(password_env) + if password is None: + password = typer.prompt("Password", hide_input=True) + if not password: + typer.echo("Empty password — aborting.", err=True) + raise typer.Exit(code=1) + + auth = cfg.easyatwork.model_copy(update={"headless": not headful}) + storage = session_state_path() + + try: + do_login(cfg=auth, password=password, storage_path=storage) + except PlaywrightMissingError as e: + typer.echo(str(e), err=True) + raise typer.Exit(code=1) from e + except LoginError as e: + typer.echo(f"Login failed: {e}", err=True) + raise typer.Exit(code=2) from e + typer.echo(f"Logged in. Session stored at {storage}") + + +@app.command("logout") +def logout_cmd() -> None: + """Delete the persisted session cookies.""" + path = session_state_path() + store = SessionStore(path) + store.clear() + typer.echo(f"Cleared {path}") + + +# ---------- sync / watch ---------- + +@app.command("sync") +def sync_cmd( + dry_run: bool = typer.Option( + False, "--dry-run", help="Compute changes without touching calendar or state." + ), +) -> None: + """Run one sync pass and exit.""" + cfg = load_config(_cfg_path()) + configure_logging(level=_get_log_level(cfg.logging.level), log_file=log_path(), fmt=cfg.logging.format) + api = _build_api_client(cfg) + backend = _build_backend(cfg) + if dry_run: + from datetime import datetime, timedelta + + from easyatcal.state import load_state + from easyatcal.sync import compute_changes + + now = datetime.now(UTC) + from_date = (now - timedelta(days=cfg.sync.lookback_days)).date() + to_date = (now + timedelta(days=cfg.sync.lookahead_days)).date() + remote = api.fetch_shifts( + from_date=from_date, to_date=to_date, user_id=cfg.sync.user_id + ) + state = load_state(state_path()) + changes = compute_changes( + remote, + state, + known_updated_at=state.shift_updated_at, + from_date=from_date, + to_date=to_date, + known_start=state.shift_start, + ) + typer.echo( + f"Dry run: {len(changes.adds)} add, " + f"{len(changes.updates)} update, " + f"{len(changes.deletes)} delete." + ) + return + from easyatcal.backends.base import BackendError + + try: + summary = run_sync( + api=api, + backend=backend, + state_path=state_path(), + lookback_days=cfg.sync.lookback_days, + lookahead_days=cfg.sync.lookahead_days, + user_id=cfg.sync.user_id, + ) + except BackendError as e: + typer.echo(f"Sync partial failure: {e}") + raise typer.Exit(code=1) from e + except Exception as e: + typer.echo(f"Sync failed: {e}") + raise typer.Exit(code=2) from e + typer.echo( + f"Sync complete: {summary.adds} added, " + f"{summary.updates} updated, {summary.deletes} deleted." + ) + + if cfg.backend == "ics": + _prompt_ics_import(cfg.backends.ics.output_path) + + +def _prompt_ics_import(output_path: str) -> None: + import os + import subprocess + import sys + import webbrowser + + from easyatcal.state import load_state, save_state + + ics_path = os.path.expanduser(output_path) + fr = _is_french() + + typer.secho("\n📅 " + ("Synchronisation réussie !" if fr else "Calendar Sync Successful!"), fg="green", bold=True) + typer.echo(("Vos horaires ont été enregistrés dans : " if fr else "Your shifts were saved to: ") + ics_path) + + sp = state_path() + state = load_state(sp) + + pref_local = state.preferences.get("open_local", False) + prompt_local = ("Voulez-vous ouvrir votre calendrier maintenant pour importer ces horaires ?" if fr + else "Would you like to open your local Calendar app now to import these shifts?") + + ans_local = typer.confirm(prompt_local, default=pref_local) + if ans_local: + typer.secho("Ouverture du calendrier..." if fr else "Opening calendar app...", fg="cyan") + if sys.platform == "darwin": + subprocess.run(["open", ics_path], check=False) + elif sys.platform == "win32": + os.startfile(ics_path) # type: ignore + else: + subprocess.run(["xdg-open", ics_path], check=False) + + pref_google = state.preferences.get("open_google", False) + prompt_google = "Préférez-vous importer ceci dans Google Agenda ?" if fr else "Would you prefer to import this into Google Calendar?" + + ans_google = typer.confirm(prompt_google, default=pref_google) + if ans_google: + typer.secho("Ouverture de Google Agenda..." if fr else "Opening Google Calendar...", fg="cyan") + webbrowser.open("https://calendar.google.com/calendar/r/settings/export") + + if ans_local != pref_local or ans_google != pref_google: + state.preferences["open_local"] = ans_local + state.preferences["open_google"] = ans_google + save_state(sp, state) + + +@app.command("schedule") +def schedule_cmd( + install: bool = typer.Option( + False, "--install", help="Install the background job automatically (macOS/Linux only)." + ), + interval_hours: int = typer.Option( + 6, "--interval-hours", help="How often to run the background sync (hours)." + ), +) -> None: + """Set up a background task to run eaw-sync automatically.""" + import os + import sys + import sysconfig + + # Get the absolute path to the eaw-sync executable + bin_path = os.path.join(sysconfig.get_path("scripts"), "eaw-sync") + if not os.path.exists(bin_path): + # Fallback to sys.executable and `-m easyatcal.cli`? Or just assume it's in PATH + bin_path = "eaw-sync" + + if sys.platform == "darwin": + plist_path = Path.home() / "Library/LaunchAgents/com.easyatcal.sync.plist" + plist_content = f""" + + + + Label + com.easyatcal.sync + ProgramArguments + + {bin_path} + sync + + StartInterval + {interval_hours * 3600} + RunAtLoad + + +""" + if install: + import subprocess + plist_path.parent.mkdir(parents=True, exist_ok=True) + plist_path.write_text(plist_content) + subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, check=False) + res = subprocess.run(["launchctl", "load", str(plist_path)], capture_output=True, check=False) + if res.returncode == 0: + typer.secho(f"Successfully installed background sync via launchd (runs every {interval_hours}h).", fg="green") + else: + typer.secho(f"Failed to load launchd agent: {res.stderr.decode()}", fg="red") + else: + typer.echo(f"To schedule on macOS, save the following to {plist_path} and run `launchctl load {plist_path}`:") + typer.echo(plist_content) + + elif sys.platform == "linux": + cron_line = f"0 */{interval_hours} * * * {bin_path} sync >> {log_path()} 2>&1" + if install: + import subprocess + res = subprocess.run(["crontab", "-l"], capture_output=True, text=True, check=False) + current_cron = res.stdout if res.returncode == 0 else "" + if "eaw-sync" not in current_cron: + new_cron = current_cron + f"\n# EasyAtCal Auto-Sync\n{cron_line}\n" + proc = subprocess.Popen(["crontab", "-"], stdin=subprocess.PIPE, text=True) + proc.communicate(input=new_cron) + typer.secho(f"Successfully installed background sync via crontab (runs every {interval_hours}h).", fg="green") + else: + typer.secho("eaw-sync is already in your crontab.", fg="yellow") + else: + typer.echo("To schedule on Linux, add the following line to your crontab (`crontab -e`):") + typer.echo(cron_line) + + elif sys.platform == "win32": + task_cmd = f'schtasks /create /tn "EasyAtCalSync" /tr "{bin_path} sync" /sc hourly /mo {interval_hours}' + if install: + import subprocess + res = subprocess.run(task_cmd, shell=True, capture_output=True, text=True, check=False) + if res.returncode == 0: + typer.secho(f"Successfully created Windows scheduled task (runs every {interval_hours}h).", fg="green") + else: + typer.secho(f"Failed to create task (try running terminal as Administrator): {res.stderr}", fg="red") + else: + typer.echo("To schedule on Windows, open an Administrator Command Prompt and run:") + typer.echo(task_cmd) + + +@app.command("watch") +def watch_cmd( + interval_seconds: int = typer.Option( + 900, "--interval-seconds", help="Seconds between sync passes." + ), +) -> None: + """Run sync on a loop until Ctrl-C or SIGTERM.""" + import signal + + from easyatcal.backends.base import BackendError + + cfg = load_config(_cfg_path()) + configure_logging(level=_get_log_level(cfg.logging.level), log_file=log_path(), fmt=cfg.logging.format) + api = _build_api_client(cfg) + backend = _build_backend(cfg) + + stop = False + + def _handler(signum: int, _frame: Any) -> None: # noqa: ARG001 + nonlocal stop + stop = True + + signal.signal(signal.SIGTERM, _handler) + + consecutive_errors = 0 + max_backoff = 3600 + + try: + while not stop: + try: + run_sync( + api=api, + backend=backend, + state_path=state_path(), + lookback_days=cfg.sync.lookback_days, + lookahead_days=cfg.sync.lookahead_days, + user_id=cfg.sync.user_id, + ) + consecutive_errors = 0 + sleep_time = interval_seconds + except BackendError as e: + typer.echo(f"Sync partial failure: {e}", err=True) + consecutive_errors = 0 + sleep_time = interval_seconds + except Exception as e: + consecutive_errors += 1 + sleep_time = min(interval_seconds * (2 ** (consecutive_errors - 1)), max_backoff) + typer.echo(f"Sync failed: {e}. Backing off for {sleep_time}s.", err=True) + + if stop: + break + typer.echo(f"Sleeping {sleep_time}s...") + # Sleep in 1s slices so SIGTERM exits promptly. + for _ in range(sleep_time): + if stop: + break + time.sleep(1) + except KeyboardInterrupt: + pass + typer.echo("\nStopped.") + + +@app.command("install-completion") +def install_completion_cmd() -> None: + """Install shell auto-completions for eaw-sync.""" + import os + import subprocess + + + # Run typer's underlying completion installation + shell = os.environ.get("SHELL", "") + if "zsh" in shell: + subprocess.run(["eaw-sync", "--install-completion", "zsh"], check=False) + elif "bash" in shell: + subprocess.run(["eaw-sync", "--install-completion", "bash"], check=False) + elif "fish" in shell: + subprocess.run(["eaw-sync", "--install-completion", "fish"], check=False) + else: + typer.echo(f"Unsupported shell: {shell}. Try running: eaw-sync --install-completion [bash|zsh|fish]", err=True) + raise typer.Exit(code=1) + typer.echo("Restart your shell to apply completions.") + +# ---------- state ---------- + +@state_app.command("show") +def state_show() -> None: + """Print a summary of the local sync state.""" + from easyatcal.state import load_state + + sp = state_path() + state = load_state(sp) + typer.echo(f"Path: {sp}") + typer.echo(f"Tracked shifts: {len(state.shift_to_event)}") + typer.echo(f"Last sync: {state.last_sync or 'never'}") + + +@state_app.command("clear") +def state_clear( + yes: bool = typer.Option( + False, "--yes", "-y", help="Confirm deletion without prompting." + ), +) -> None: + """Delete the local state file. Next sync rebuilds from scratch.""" + sp = state_path() + if not yes: + typer.echo( + f"Refusing to delete {sp} without --yes. " + "A full resync will re-create every event." + ) + raise typer.Exit(code=1) + if sp.exists(): + sp.unlink() + typer.echo(f"Deleted {sp}.") + else: + typer.echo(f"No state at {sp}; nothing to do.") + + +# ---------- doctor ---------- + +@app.command("doctor") +def doctor_cmd() -> None: + """Check config, credentials, and backend wiring.""" + from easyatcal.api import AuthError + + failures = 0 + cfg_file = _cfg_path() + + # 1. Config + if not cfg_file.exists(): + typer.echo(f"[FAIL] config: not found at {cfg_file}") + typer.echo(" Run `eaw-sync config init`.") + raise typer.Exit(code=1) + try: + cfg = load_config(cfg_file) + typer.echo(f"[ OK ] config: loaded from {cfg_file}") + except Exception as e: + typer.echo(f"[FAIL] config: {e}") + raise typer.Exit(code=1) from e + + configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) + + # 2. Auth + mode = cfg.easyatwork.auth_mode + try: + api = _build_api_client(cfg) + api.authenticate() + if mode == "client": + typer.echo("[ OK ] auth: OAuth token obtained") + else: + typer.echo("[ OK ] auth: session cookies loaded") + except AuthError as e: + typer.echo(f"[FAIL] auth ({mode}): {e}") + if mode == "user": + typer.echo(" Run `eaw-sync login` to create a session.") + failures += 1 + except Exception as e: + typer.echo(f"[FAIL] auth ({mode}): {e}") + failures += 1 + + # 3. Backend + try: + _build_backend(cfg) + typer.echo(f"[ OK ] backend: {cfg.backend} reachable") + except Exception as e: + typer.echo(f"[FAIL] backend ({cfg.backend}): {e}") + failures += 1 + + # 4. State directory writable + sp = state_path() + try: + sp.parent.mkdir(parents=True, exist_ok=True) + probe = sp.parent / ".eaw-sync-doctor-probe" + probe.write_text("ok") + probe.unlink() + typer.echo(f"[ OK ] state: {sp.parent} writable") + except OSError as e: + typer.echo(f"[FAIL] state: cannot write to {sp.parent}: {e}") + failures += 1 + + if failures: + raise typer.Exit(code=1) + typer.echo("All checks passed.") + + +# ---------- auth ---------- + +@auth_app.command("test") +def auth_test() -> None: + """Verify that the configured credentials can obtain a token.""" + from easyatcal.api import AuthError + + cfg = load_config(_cfg_path()) + configure_logging(level=_get_log_level(cfg.logging.level), log_file=log_path(), fmt=cfg.logging.format) + api = _build_api_client(cfg) + try: + api.authenticate() + except AuthError as e: + typer.echo(f"Auth failed: {e}") + raise typer.Exit(code=2) from e + typer.echo("OK -- credentials work.") diff --git a/easyatcal/config.py b/easyatcal/config.py new file mode 100644 index 0000000..84a646e --- /dev/null +++ b/easyatcal/config.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel, Field, field_validator, model_validator + + +class EasyAtWorkAuth(BaseModel): + """Credentials + endpoint config. + + Two modes: + - ``client``: OAuth2 client_credentials against a (hypothetical) public + API. Kept for forward-compat; not used against the real tenant. + - ``user``: Scrape the web SPA via Playwright. The user logs in once + (``eaw-sync login``) and cookies are persisted. All shift requests + go through the same web origin with those cookies. + """ + + auth_mode: Literal["client", "user"] = "user" + + # client mode + client_id: str | None = None + client_secret: str | None = None + base_url: str = "https://api.easyatwork.com" + + # user mode + email: str | None = None + login_url: str = "https://app.easyatwork.com/" + app_url: str = "https://app.easyatwork.com" + # Regional API host that the SPA talks to. Seen in the wild: + # "https://eu-west-3.api.easyatwork.com". Inspect DevTools → Network + # → any XHR for your tenant's region. + api_url: str = "" + # Per-user identifiers embedded in every shifts URL. + # Shape: /customers/{customer_id}/employees/{employee_id}/shifts + customer_id: int | None = None + employee_id: int | None = None + # Mimic the SPA's X-Ui-Version header (otherwise the API is fine + # without it, but setting it lowers the chance of anti-bot blocks). + ui_version: str = "2.313.0" + # Playwright login form selectors (override per tenant if the form + # layout differs). + email_selector: str = "input[type='email'], input[name='email'], input[name='username']" + password_selector: str = "input[type='password']" + submit_selector: str = "button[type='submit'], input[type='submit']" + # Browser headless by default; set false for first-run debug. + headless: bool = True + # Max wait after submit for navigation to finish (ms). + login_timeout_ms: int = 20000 + + @model_validator(mode="after") + def _check_mode_fields(self) -> EasyAtWorkAuth: + if self.auth_mode == "client" and ( + not self.client_id or not self.client_secret + ): + raise ValueError( + "auth_mode=client requires client_id and client_secret" + ) + if self.auth_mode == "user" and not self.email: + raise ValueError("auth_mode=user requires email") + return self + + def shifts_url(self, session_meta: dict[str, str | int] | None = None) -> str: + """Fully-qualified base URL of the shifts collection for this user.""" + session_api_url = (session_meta or {}).get("api_url") + api_url = self.api_url or ( + session_api_url if isinstance(session_api_url, str) else "" + ) + customer_id = self.customer_id or (session_meta or {}).get("customer_id") + employee_id = self.employee_id or (session_meta or {}).get("employee_id") + + if not api_url or not customer_id or not employee_id: + raise ValueError( + "auth_mode=user requires api_url, customer_id, employee_id " + "to build the shifts URL. Capture a HAR from the web app " + "or re-run `eaw-sync login` to extract them automatically." + ) + return ( + f"{api_url.rstrip('/')}/customers/{customer_id}" + f"/employees/{employee_id}/shifts" + ) + + +class SyncSettings(BaseModel): + lookback_days: int = Field(ge=0, default=7) + lookahead_days: int = Field(ge=1, default=90) + user_id: str | None = None + event_title_format: str = "{title}" + alarm_minutes_before: int | None = None + + +class EventKitSettings(BaseModel): + calendar_name: str = "Work Shifts" + calendar_source: str = "iCloud" + + +class IcsSettings(BaseModel): + output_path: str = "~/Documents/easyatwork-shifts.ics" + + +class BackendsSettings(BaseModel): + eventkit: EventKitSettings = EventKitSettings() + ics: IcsSettings = IcsSettings() + + +class LoggingSettings(BaseModel): + level: str = "INFO" + format: Literal["text", "json"] = "text" + + +class Config(BaseModel): + easyatwork: EasyAtWorkAuth + sync: SyncSettings = SyncSettings() + backend: Literal["eventkit", "ics"] + backends: BackendsSettings = BackendsSettings() + logging: LoggingSettings = LoggingSettings() + + @field_validator("backend") + @classmethod + def validate_backend(cls, v: str) -> str: + if v not in ("eventkit", "ics"): + raise ValueError(f"Unknown backend: {v}") + return v + + +_ENV_OVERRIDES: dict[str, tuple[str, str]] = { + "EAW_CLIENT_ID": ("easyatwork", "client_id"), + "EAW_CLIENT_SECRET": ("easyatwork", "client_secret"), + "EAW_BASE_URL": ("easyatwork", "base_url"), + "EAW_EMAIL": ("easyatwork", "email"), + "EAW_LOGIN_URL": ("easyatwork", "login_url"), + "EAW_APP_URL": ("easyatwork", "app_url"), + "EAW_API_URL": ("easyatwork", "api_url"), + "EAW_CUSTOMER_ID": ("easyatwork", "customer_id"), + "EAW_EMPLOYEE_ID": ("easyatwork", "employee_id"), +} + + +def load_config(path: Path) -> Config: + if not path.exists(): + raise FileNotFoundError(path) + raw = yaml.safe_load(path.read_text()) + for env_var, (section, key) in _ENV_OVERRIDES.items(): + value = os.environ.get(env_var) + if value is not None: + raw.setdefault(section, {})[key] = value + return Config.model_validate(raw) diff --git a/easyatcal/logging_setup.py b/easyatcal/logging_setup.py new file mode 100644 index 0000000..05be5f1 --- /dev/null +++ b/easyatcal/logging_setup.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import json +import logging +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path + + +class _JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), + "level": record.levelname, + "logger": record.name, + "msg": record.getMessage(), + } + if hasattr(record, "event_id"): + payload["event_id"] = record.event_id + if record.exc_info: + payload["exc"] = self.formatException(record.exc_info) + return json.dumps(payload) + + +def configure_logging(level: str, log_file: Path, fmt: str = "text") -> None: + log_file.parent.mkdir(parents=True, exist_ok=True) + root = logging.getLogger() + for h in list(root.handlers): + root.removeHandler(h) + root.setLevel(level) + + formatter: logging.Formatter + if fmt == "json": + formatter = _JsonFormatter() + else: + formatter = logging.Formatter( + "%(asctime)s %(levelname)s %(name)s: %(message)s" + ) + + file_h = TimedRotatingFileHandler( + log_file, when="midnight", backupCount=7, encoding="utf-8" + ) + file_h.setFormatter(formatter) + root.addHandler(file_h) + + console_h = logging.StreamHandler() + console_h.setFormatter(formatter) + root.addHandler(console_h) diff --git a/easyatcal/models.py b/easyatcal/models.py new file mode 100644 index 0000000..d371a53 --- /dev/null +++ b/easyatcal/models.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True, slots=True) +class Shift: + id: str + start: datetime + end: datetime + title: str + location: str | None + notes: str | None + updated_at: datetime + + def __post_init__(self) -> None: + for field_name in ("start", "end", "updated_at"): + value = getattr(self, field_name) + if value.tzinfo is None: + raise ValueError(f"{field_name} must be tz-aware") + + @property + def duration_hours(self) -> float: + return (self.end - self.start).total_seconds() / 3600.0 diff --git a/easyatcal/orchestrator.py b/easyatcal/orchestrator.py new file mode 100644 index 0000000..cd21440 --- /dev/null +++ b/easyatcal/orchestrator.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta +from pathlib import Path +from typing import Protocol + +from easyatcal.backends.base import ApplyResult, BackendError, CalendarBackend +from easyatcal.models import Shift +from easyatcal.state import State, load_state, save_state +from easyatcal.sync import compute_changes + + +@dataclass +class SyncSummary: + adds: int = 0 + updates: int = 0 + deletes: int = 0 + + +class ShiftFetcher(Protocol): + def authenticate(self) -> object: ... + + def fetch_shifts( + self, from_date: date, to_date: date, user_id: str | None = None + ) -> list[Shift]: ... + + +def run_sync( + api: ShiftFetcher, + backend: CalendarBackend, + state_path: Path, + lookback_days: int, + lookahead_days: int, + user_id: str | None = None, + now: datetime | None = None, +) -> SyncSummary: + now = now or datetime.now(UTC) + from_date = (now - timedelta(days=lookback_days)).date() + to_date = (now + timedelta(days=lookahead_days)).date() + + logger = logging.getLogger(__name__) + + try: + remote_shifts = api.fetch_shifts( + from_date=from_date, to_date=to_date, user_id=user_id + ) + logger.info( + f"Fetched {len(remote_shifts)} shifts from API", + extra={"event_id": "sync.fetch.ok"} + ) + except Exception as e: + logger.error( + f"Failed to fetch shifts from API: {e}", + extra={"event_id": "sync.fetch.error"} + ) + raise + + state = load_state(state_path) + changes = compute_changes( + remote_shifts, + state, + known_updated_at=state.shift_updated_at, + from_date=from_date, + to_date=to_date, + known_start=state.shift_start, + ) + logger.info( + f"Computed changes: {len(changes.adds)} adds, {len(changes.updates)} updates, {len(changes.deletes)} deletes", + extra={"event_id": "sync.compute_changes.ok"} + ) + + if hasattr(backend, "set_all_shifts"): + backend.set_all_shifts(remote_shifts) + + raised: BackendError | None = None + try: + result: ApplyResult = backend.apply(changes) + logger.info( + "Successfully applied changes to backend", + extra={"event_id": "sync.apply.ok"} + ) + except BackendError as e: + result = e.partial + raised = e + logger.warning( + f"Partial failure applying changes: {e}", + extra={"event_id": "sync.apply.partial"} + ) + + _persist( + state=state, + state_path=state_path, + remote_shifts=remote_shifts, + result=result, + now=now, + ) + + # Count adds vs updates separately by checking existing state. + prev_ids = set(state.shift_to_event) + added = sum(1 for sid in result.mapping if sid not in prev_ids) + updated = sum(1 for sid in result.mapping if sid in prev_ids) + summary = SyncSummary( + adds=added, + updates=updated, + deletes=len(result.deleted_uids), + ) + + if raised is not None: + raised.summary = summary # type: ignore[attr-defined] + raise raised + + logger.info( + f"Sync completed successfully: {added} added, {updated} updated, {summary.deletes} deleted", + extra={"event_id": "sync.complete"} + ) + return summary + + +def _persist( + *, + state: State, + state_path: Path, + remote_shifts: list[Shift], + result: ApplyResult, + now: datetime, +) -> None: + new_shift_to_event = dict(state.shift_to_event) + new_updated_at = dict(state.shift_updated_at) + new_start = dict(state.shift_start) + + for shift_id, event_uid in result.mapping.items(): + new_shift_to_event[shift_id] = event_uid + + # For every shift we successfully wrote, stamp the new updated_at. + remote_by_id = {s.id: s for s in remote_shifts} + for shift_id in result.mapping: + shift = remote_by_id.get(shift_id) + if shift is not None: + new_updated_at[shift_id] = shift.updated_at.isoformat() + + # Backfill starts for existing events as well as successful writes. Older + # state files lack this field, but unchanged remote shifts have no mapping. + for shift in remote_shifts: + if shift.id in new_shift_to_event: + new_start[shift.id] = shift.start.isoformat() + + # Prune confirmed deletions. + deleted_uid_set = set(result.deleted_uids) + new_shift_to_event = { + sid: evt + for sid, evt in new_shift_to_event.items() + if evt not in deleted_uid_set + } + # Drop metadata for any shift whose event we just deleted (its shift_id no + # longer maps to an event in new_shift_to_event). + new_updated_at = { + sid: ts for sid, ts in new_updated_at.items() if sid in new_shift_to_event + } + new_start = { + sid: ts for sid, ts in new_start.items() if sid in new_shift_to_event + } + + save_state( + state_path, + State( + shift_to_event=new_shift_to_event, + shift_updated_at=new_updated_at, + shift_start=new_start, + last_sync=now.isoformat(), + preferences=state.preferences.copy(), + ), + ) diff --git a/easyatcal/paths.py b/easyatcal/paths.py new file mode 100644 index 0000000..a40a9a8 --- /dev/null +++ b/easyatcal/paths.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from pathlib import Path + +from platformdirs import user_cache_dir, user_config_dir, user_data_dir + +APP = "easyatcal" + + +def config_path() -> Path: + return Path(user_config_dir(APP)) / "config.yaml" + + +def state_path() -> Path: + return Path(user_data_dir(APP)) / "state.json" + + +def token_cache_path() -> Path: + return Path(user_cache_dir(APP)) / "token.json" + + +def session_state_path() -> Path: + """Playwright storage_state (cookies + localStorage) for user auth.""" + return Path(user_cache_dir(APP)) / "session.json" + + +def log_path() -> Path: + return Path(user_data_dir(APP)) / "logs" / "eaw-sync.log" diff --git a/easyatcal/py.typed b/easyatcal/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/easyatcal/session.py b/easyatcal/session.py new file mode 100644 index 0000000..095a1dd --- /dev/null +++ b/easyatcal/session.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import contextlib +import json +import os +from pathlib import Path +from typing import Any + +import httpx +import keyring + + +class SessionStore: + """Persists Playwright ``storage_state`` (cookies + localStorage) + on disk with 0600 perms. Securely stores JWT in OS keyring. + + Playwright storage_state shape:: + + {"cookies": [{"name": ..., "value": ..., "domain": ..., + "path": ..., "expires": ..., "httpOnly": ..., + "secure": ..., "sameSite": ...}, ...], + "origins": [...]} + + We only need the cookies for httpx replay — localStorage is kept + so a future reuse with Playwright can restore full UI state. + """ + + def __init__(self, path: Path) -> None: + self.path = Path(path) + + def save(self, storage_state: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(self.path.suffix + ".tmp") + tmp.write_text(json.dumps(storage_state)) + os.replace(tmp, self.path) + with contextlib.suppress(OSError): + os.chmod(self.path, 0o600) + + def load(self) -> dict[str, Any] | None: + if not self.path.exists(): + return None + try: + data = json.loads(self.path.read_text()) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(data, dict): + return None + return data + + def cookies(self) -> httpx.Cookies | None: + """Convert the persisted cookies to an httpx.Cookies jar. + Returns None if no session is stored. + """ + state = self.load() + if state is None: + return None + jar = httpx.Cookies() + for c in state.get("cookies", []): + name = c.get("name") + value = c.get("value") + if not name or value is None: + continue + jar.set( + name=name, + value=value, + domain=c.get("domain", ""), + path=c.get("path", "/"), + ) + return jar + + def clear(self) -> None: + with contextlib.suppress(FileNotFoundError): + self.path.unlink() + with contextlib.suppress(Exception): + keyring.delete_password("easyatcal", "jwt") + + def eaw_meta(self) -> dict[str, Any] | None: + """Returns the extracted eaw_meta (api_url, customer_id, employee_id) + if it was intercepted during login. + """ + state = self.load() + if state is None: + return None + return state.get("eaw_meta") + + def access_token(self) -> str | None: + """Get the JWT access token from the OS keyring, falling back to scanning + persisted localStorage (and upgrading it to keyring if found). + """ + try: + token = keyring.get_password("easyatcal", "jwt") + if token: + return token + except Exception: + pass # keyring backend might be unavailable or locked + + state = self.load() + if state is None: + return None + + found_token = None + for origin in state.get("origins", []): + for entry in origin.get("localStorage", []): + name = entry.get("name") or "" + value = entry.get("value") or "" + if not isinstance(value, str): + continue + # Prefer keys whose names smell like a token. + name_hints = ("access_token", "token", "jwt", "bearer") + looks_like_jwt = value.count(".") == 2 and len(value) > 40 + if looks_like_jwt and ( + any(h in name.lower() for h in name_hints) + or value.startswith("ey") + ): + found_token = value + break + if found_token: + break + + if found_token: + with contextlib.suppress(Exception): + keyring.set_password("easyatcal", "jwt", found_token) + return found_token + return None diff --git a/easyatcal/state.py b/easyatcal/state.py new file mode 100644 index 0000000..8076884 --- /dev/null +++ b/easyatcal/state.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path + + +@dataclass +class State: + shift_to_event: dict[str, str] = field(default_factory=dict) + shift_updated_at: dict[str, str] = field(default_factory=dict) + # shift_id -> ISO start datetime, used to tell a cancelled shift from one + # that merely aged out of the fetch window. + shift_start: dict[str, str] = field(default_factory=dict) + last_sync: str | None = None + preferences: dict[str, bool] = field(default_factory=dict) + + +def load_state(path: Path) -> State: + if not path.exists(): + return State() + try: + data = json.loads(path.read_text()) + return State( + shift_to_event=dict(data.get("shift_to_event", {})), + shift_updated_at=dict(data.get("shift_updated_at", {})), + shift_start=dict(data.get("shift_start", {})), + last_sync=data.get("last_sync"), + preferences=dict(data.get("preferences", {})), + ) + except (json.JSONDecodeError, ValueError): + backup = path.with_suffix(path.suffix + ".bak") + path.replace(backup) + return State() + + +def save_state(path: Path, state: State) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(asdict(state), indent=2, sort_keys=True)) + os.replace(tmp, path) diff --git a/easyatcal/sync.py b/easyatcal/sync.py new file mode 100644 index 0000000..b82ce6d --- /dev/null +++ b/easyatcal/sync.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from datetime import date, datetime + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift +from easyatcal.state import State + + +def compute_changes( + remote_shifts: list[Shift], + state: State, + known_updated_at: dict[str, str], + *, + from_date: date, + to_date: date, + known_start: dict[str, str], +) -> Changes: + """Diff remote shifts against the last-known state. + + known_updated_at maps shift_id -> ISO updated_at recorded at last sync. + known_start maps shift_id -> ISO start datetime recorded at last sync. + + A tracked shift absent from ``remote_shifts`` is only deleted when its + recorded start falls inside the fetched window ``[from_date, to_date]`` — + i.e. the API was actually asked about it and reported it gone. Shifts that + merely aged out of the window (or whose start we never recorded) are left + untouched, so past shifts are never deleted by a later sync. + """ + remote_by_id = {s.id: s for s in remote_shifts} + adds: list[Shift] = [] + updates: list[tuple[Shift, str]] = [] + deletes: list[str] = [] + + for shift in remote_shifts: + event_uid = state.shift_to_event.get(shift.id) + if event_uid is None: + adds.append(shift) + continue + last_updated = known_updated_at.get(shift.id) + if last_updated != shift.updated_at.isoformat(): + updates.append((shift, event_uid)) + + for shift_id, event_uid in state.shift_to_event.items(): + if shift_id in remote_by_id: + continue + if _start_in_window(known_start.get(shift_id), from_date, to_date): + deletes.append(event_uid) + + return Changes(adds=adds, updates=updates, deletes=deletes) + + +def _start_in_window( + start_iso: str | None, from_date: date, to_date: date +) -> bool: + """True only when we know the shift's start and it lies in the window. + + Unknown or unparseable starts return False so the shift is preserved. + """ + if not start_iso: + return False + try: + start = datetime.fromisoformat(start_iso).date() + except ValueError: + return False + return from_date <= start <= to_date diff --git a/examples/launchd/com.easyatcal.watch.plist b/examples/launchd/com.easyatcal.watch.plist new file mode 100644 index 0000000..ef26dc1 --- /dev/null +++ b/examples/launchd/com.easyatcal.watch.plist @@ -0,0 +1,49 @@ + + + + + + Label + com.easyatcal.watch + + ProgramArguments + + /REPLACE/WITH/ABSOLUTE/PATH/TO/eaw-sync + sync + + + StartInterval + 900 + + RunAtLoad + + + StandardOutPath + /tmp/easyatcal.out.log + + StandardErrorPath + /tmp/easyatcal.err.log + + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin + + + diff --git a/examples/systemd/eaw-sync-watch.service b/examples/systemd/eaw-sync-watch.service new file mode 100644 index 0000000..5dc82ed --- /dev/null +++ b/examples/systemd/eaw-sync-watch.service @@ -0,0 +1,17 @@ +[Unit] +Description=EasyAtCal Watcher - Syncs easy@work shifts to Calendar +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# Ensure eaw-sync is in your PATH, or provide the absolute path to the executable. +# Example: ExecStart=/home/user/.local/bin/eaw-sync watch +ExecStart=eaw-sync watch +Restart=always +RestartSec=10 +# Optional: Set the configuration path if you have it in a non-default location +# Environment="EAW_CONFIG_PATH=/home/user/.config/easyatcal/config.yaml" + +[Install] +WantedBy=default.target \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..89eaecf --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,46 @@ +site_name: EasyAtCal +site_description: "Sync your easy@work shifts with Apple Calendar." +site_url: https://ailcope.github.io/EasyAtCal/ +repo_url: https://github.com/Ailcope/EasyAtCal +repo_name: Ailcope/EasyAtCal + +theme: + name: material + +docs_dir: docs/pages + +palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: blue + accent: light blue + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: blue + accent: light blue + toggle: + icon: material/weather-sunny + name: Switch to light mode + +nav: + - Home: index.md + - Contributing: contributing.md + - Changelog: changelog.md + +markdown_extensions: + - md_in_html + - toc: + permalink: true + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.superfences + - admonition + - pymdownx.details + - pymdownx.tabbed: + alternate_style: true + +plugins: + - search \ No newline at end of file diff --git a/pages.json b/pages.json new file mode 100644 index 0000000..cb604f3 --- /dev/null +++ b/pages.json @@ -0,0 +1,3 @@ +{ + "build_type": "workflow" +} diff --git a/protection.json b/protection.json new file mode 100644 index 0000000..838584a --- /dev/null +++ b/protection.json @@ -0,0 +1,8 @@ +{ + "required_status_checks": null, + "enforce_admins": null, + "required_pull_request_reviews": null, + "restrictions": null, + "allow_force_pushes": false, + "allow_deletions": false +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fb0ed7b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,75 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "easyatcal" +version = "0.3.0" +description = "One-way sync of easy@work shifts to Apple Calendar." +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [{name = "Ailcope", email = "security@ailcope.dev"}] +dependencies = [ + "httpx>=0.27", + "pydantic>=2.6", + "pyyaml>=6.0", + "icalendar>=5.0", + "typer>=0.12", + "platformdirs>=4.0", + "keyring>=25.0", +] + +[project.optional-dependencies] +eventkit = ["pyobjc-framework-EventKit>=10.0; sys_platform == 'darwin'"] +playwright = ["playwright>=1.44"] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "respx>=0.21", + "freezegun>=1.4", + "mypy>=1.10", + "types-PyYAML", + "ruff>=0.6", +] + +[project.scripts] +eaw-sync = "easyatcal.cli:app" + +[tool.hatch.build.targets.wheel] +packages = ["easyatcal"] + +[tool.hatch.build.targets.wheel.force-include] +"easyatcal/py.typed" = "easyatcal/py.typed" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v --strict-markers" + +[tool.coverage.run] +omit = [ + # Playwright-driven, exercised only on real browsers. + "easyatcal/auth_user.py", + # EventKit shim is macOS + PyObjC, exercised via mocks only. + "easyatcal/backends/eventkit.py", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] +ignore = ["E501"] # line length handled by formatter + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["B017", "B018"] + +[tool.mypy] +strict = true +warn_return_any = true +warn_unused_configs = true + +[[tool.mypy.overrides]] +module = ["playwright.*", "EventKit", "Foundation"] +ignore_missing_imports = true diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/backends/__init__.py b/tests/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/backends/test_base.py b/tests/backends/test_base.py new file mode 100644 index 0000000..94bcc85 --- /dev/null +++ b/tests/backends/test_base.py @@ -0,0 +1,16 @@ +from easyatcal.backends.base import CalendarBackend, Changes + + +def test_changes_is_dataclass(): + c = Changes(adds=[], updates=[], deletes=[]) + assert c.adds == [] + assert c.is_empty() + + +def test_backend_is_protocol_with_apply(): + class Dummy: + def apply(self, changes: Changes) -> dict[str, str]: + return {} + + d: CalendarBackend = Dummy() + assert d.apply(Changes([], [], [])) == {} diff --git a/tests/backends/test_eventkit.py b/tests/backends/test_eventkit.py new file mode 100644 index 0000000..c550513 --- /dev/null +++ b/tests/backends/test_eventkit.py @@ -0,0 +1,75 @@ +import sys +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +import pytest + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift + +pytestmark = pytest.mark.skipif( + sys.platform != "darwin", reason="EventKit backend is macOS only" +) + + +def _shift(id_: str) -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=UTC), + end=datetime(2026, 4, 20, 17, tzinfo=UTC), + title=f"Shift {id_}", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=UTC), + ) + + +@patch("easyatcal.backends.eventkit._event_store") +def test_apply_adds_creates_events(mock_store_factory): + store = MagicMock() + calendar = MagicMock() + store.calendarsForEntityType_.return_value = [calendar] + calendar.title.return_value = "Work Shifts" + calendar.source.return_value.title.return_value = "iCloud" + mock_store_factory.return_value = store + + created_event = MagicMock() + created_event.calendarItemExternalIdentifier.return_value = "evt-1" + store.saveEvent_span_error_.return_value = (True, None) + + from easyatcal.backends.eventkit import EventKitBackend + + with patch( + "easyatcal.backends.eventkit._new_event", return_value=created_event + ): + backend = EventKitBackend( + calendar_name="Work Shifts", calendar_source="iCloud" + ) + result = backend.apply(Changes(adds=[_shift("s1")])) + + assert result.mapping == {"s1": "evt-1"} + store.saveEvent_span_error_.assert_called() + + +@patch("easyatcal.backends.eventkit._event_store") +def test_apply_deletes_removes_events(mock_store_factory): + store = MagicMock() + calendar = MagicMock() + calendar.title.return_value = "Work Shifts" + calendar.source.return_value.title.return_value = "iCloud" + store.calendarsForEntityType_.return_value = [calendar] + + existing = MagicMock() + existing.calendarItemExternalIdentifier.return_value = "evt-1" + store.calendarItemWithIdentifier_.return_value = existing + store.removeEvent_span_error_.return_value = (True, None) + mock_store_factory.return_value = store + + from easyatcal.backends.eventkit import EventKitBackend + backend = EventKitBackend( + calendar_name="Work Shifts", calendar_source="iCloud" + ) + + backend.apply(Changes(deletes=["evt-1"])) + + store.removeEvent_span_error_.assert_called() diff --git a/tests/backends/test_ics.py b/tests/backends/test_ics.py new file mode 100644 index 0000000..9b7070e --- /dev/null +++ b/tests/backends/test_ics.py @@ -0,0 +1,99 @@ +from datetime import UTC, datetime +from pathlib import Path + +from easyatcal.backends.base import Changes +from easyatcal.backends.ics import IcsBackend +from easyatcal.models import Shift + + +def _shift(id_: str) -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=UTC), + end=datetime(2026, 4, 20, 17, tzinfo=UTC), + title=f"Shift {id_}", + location="Oslo", + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=UTC), + ) + + +def test_adds_produce_events_in_file(tmp_path: Path): + out = tmp_path / "shifts.ics" + backend = IcsBackend(output_path=out, known_shifts=[]) + changes = Changes(adds=[_shift("s1"), _shift("s2")]) + + result = backend.apply(changes) + + body = out.read_text() + assert "BEGIN:VCALENDAR" in body + assert "SUMMARY:Shift s1" in body + assert "SUMMARY:Shift s2" in body + assert result.mapping["s1"].startswith("easyatcal-s1") + assert result.mapping["s2"].startswith("easyatcal-s2") + + +def test_deletes_remove_events(tmp_path: Path): + out = tmp_path / "shifts.ics" + backend1 = IcsBackend(output_path=out, known_shifts=[]) + backend1.apply(Changes(adds=[_shift("s1"), _shift("s2")])) + + backend2 = IcsBackend( + output_path=out, + known_shifts=[_shift("s1"), _shift("s2")], + ) + uid_s2 = "easyatcal-s2" + backend2.apply(Changes(deletes=[uid_s2])) + + body = out.read_text() + assert "SUMMARY:Shift s1" in body + assert "SUMMARY:Shift s2" not in body + + +def test_updates_replace_event(tmp_path: Path): + out = tmp_path / "shifts.ics" + s = _shift("s1") + IcsBackend(output_path=out, known_shifts=[]).apply(Changes(adds=[s])) + + s_new = Shift( + id=s.id, start=s.start, end=s.end, title="New Title", + location=s.location, notes=s.notes, updated_at=s.updated_at, + ) + backend = IcsBackend(output_path=out, known_shifts=[s]) + backend.apply(Changes(updates=[(s_new, "easyatcal-s1")])) + + body = out.read_text() + assert "SUMMARY:New Title" in body + assert "SUMMARY:Shift s1" not in body + + +def test_existing_events_preserved_when_not_in_new_shifts(tmp_path: Path): + out = tmp_path / "shifts.ics" + # First sync writes an old shift. + IcsBackend(output_path=out, known_shifts=[]).apply(Changes(adds=[_shift("old")])) + + # Second sync: the CLI builds a fresh backend each run, and the fetch + # window no longer contains "old" — only "new" is reported. + backend2 = IcsBackend(output_path=out, known_shifts=[]) + backend2.set_all_shifts([_shift("new")]) + backend2.apply(Changes(adds=[_shift("new")])) + + body = out.read_text() + assert "SUMMARY:Shift old" in body # preserved across regeneration + assert "SUMMARY:Shift new" in body + + +def test_alarm_is_written_when_configured(tmp_path: Path): + out = tmp_path / "shifts.ics" + backend = IcsBackend( + output_path=out, + known_shifts=[], + alarm_minutes_before=30, + ) + + backend.apply(Changes(adds=[_shift("s1")])) + + body = out.read_text() + assert "BEGIN:VALARM" in body + assert "TRIGGER:-PT30M" in body + assert "DESCRIPTION:Shift Reminder" in body diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/config_valid.yaml b/tests/fixtures/config_valid.yaml new file mode 100644 index 0000000..dce7a5b --- /dev/null +++ b/tests/fixtures/config_valid.yaml @@ -0,0 +1,18 @@ +easyatwork: + auth_mode: client + client_id: "cid" + client_secret: "csecret" + base_url: "https://api.easyatwork.com" +sync: + lookback_days: 7 + lookahead_days: 90 + user_id: null +backend: ics +backends: + eventkit: + calendar_name: "Work Shifts" + calendar_source: "iCloud" + ics: + output_path: "~/Documents/shifts.ics" +logging: + level: INFO diff --git a/tests/fixtures/easyatwork_shifts.json b/tests/fixtures/easyatwork_shifts.json new file mode 100644 index 0000000..7359b74 --- /dev/null +++ b/tests/fixtures/easyatwork_shifts.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "id": "eaw-s1001", + "start": "2026-05-10T08:00:00+02:00", + "end": "2026-05-10T16:00:00+02:00", + "title": "Barista Shift", + "location": "Downtown Cafe", + "notes": "Opening shift, don't forget keys", + "updated_at": "2026-05-01T12:00:00+00:00" + }, + { + "id": "eaw-s1002", + "start": "2026-05-11T16:00:00+02:00", + "end": "2026-05-11T22:00:00+02:00", + "title": "Closing Shift", + "location": "Downtown Cafe", + "notes": null, + "updated_at": "2026-05-01T12:00:00+00:00" + } + ], + "next": null +} \ No newline at end of file diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py new file mode 100644 index 0000000..54eaa29 --- /dev/null +++ b/tests/test_api_auth.py @@ -0,0 +1,67 @@ +import json +from pathlib import Path + +import httpx +import pytest +import respx + +from easyatcal.api import AuthError, EawClient + + +@respx.mock +def test_client_credentials_fetch_token(tmp_path: Path): + respx.post("https://api.easyatwork.com/oauth/token").mock( + return_value=httpx.Response( + 200, + json={"access_token": "tok-123", "expires_in": 3600, + "token_type": "Bearer"}, + ) + ) + client = EawClient( + client_id="cid", + client_secret="csecret", + base_url="https://api.easyatwork.com", + token_cache=tmp_path / "token.json", + ) + + token = client.authenticate() + + assert token == "tok-123" + cached = json.loads((tmp_path / "token.json").read_text()) + assert cached["access_token"] == "tok-123" + + +@respx.mock +def test_cached_token_reused(tmp_path: Path): + cache = tmp_path / "token.json" + cache.write_text(json.dumps({ + "access_token": "cached-tok", + "expires_at": "2099-01-01T00:00:00+00:00", + })) + route = respx.post("https://api.easyatwork.com/oauth/token") + + client = EawClient( + client_id="cid", + client_secret="csecret", + base_url="https://api.easyatwork.com", + token_cache=cache, + ) + token = client.authenticate() + + assert token == "cached-tok" + assert route.call_count == 0 + + +@respx.mock +def test_auth_failure_raises(tmp_path: Path): + respx.post("https://api.easyatwork.com/oauth/token").mock( + return_value=httpx.Response(401, json={"error": "invalid_client"}) + ) + client = EawClient( + client_id="bad", + client_secret="bad", + base_url="https://api.easyatwork.com", + token_cache=tmp_path / "token.json", + ) + with pytest.raises(AuthError): + client.authenticate() diff --git a/tests/test_api_fetch.py b/tests/test_api_fetch.py new file mode 100644 index 0000000..03c1cff --- /dev/null +++ b/tests/test_api_fetch.py @@ -0,0 +1,153 @@ +from datetime import date +from pathlib import Path + +import httpx +import pytest +import respx + +from easyatcal.api import ApiError, EawClient +from easyatcal.models import Shift + + +def _fresh_client(tmp_path: Path) -> EawClient: + cache = tmp_path / "token.json" + cache.write_text( + '{"access_token":"tok","expires_at":"2099-01-01T00:00:00+00:00"}' + ) + return EawClient( + client_id="cid", + client_secret="csecret", + base_url="https://api.easyatwork.com", + token_cache=cache, + ) + + +@respx.mock +def test_fetch_shifts_single_page(tmp_path: Path): + respx.get("https://api.easyatwork.com/v1/shifts").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "Morning", + "location": "Oslo", + "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + } + ], + "next": None, + }, + ) + ) + client = _fresh_client(tmp_path) + + shifts = client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 21) + ) + + assert len(shifts) == 1 + s = shifts[0] + assert isinstance(s, Shift) + assert s.id == "s1" + assert s.location == "Oslo" + + +@respx.mock +def test_fetch_shifts_follows_pagination(tmp_path: Path): + page1 = { + "data": [{ + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "A", "location": None, "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + }], + "next": "https://api.easyatwork.com/v1/shifts?cursor=abc", + } + page2 = { + "data": [{ + "id": "s2", + "start": "2026-04-21T09:00:00+00:00", + "end": "2026-04-21T17:00:00+00:00", + "title": "B", "location": None, "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + }], + "next": None, + } + + def _handler(request: httpx.Request) -> httpx.Response: + if "cursor=abc" in str(request.url): + return httpx.Response(200, json=page2) + return httpx.Response(200, json=page1) + + respx.get(url__regex=r"https://api\.easyatwork\.com/v1/shifts.*").mock( + side_effect=_handler + ) + client = _fresh_client(tmp_path) + + shifts = client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) + ids = [s.id for s in shifts] + assert ids == ["s1", "s2"] + + +@respx.mock +def test_fetch_shifts_retries_on_429(tmp_path: Path, monkeypatch): + sleeps: list[float] = [] + monkeypatch.setattr("easyatcal.api.time.sleep", lambda s: sleeps.append(s)) + + responses_iter = iter([ + httpx.Response(429), + httpx.Response(200, json={"data": [], "next": None}), + ]) + respx.get("https://api.easyatwork.com/v1/shifts").mock( + side_effect=lambda req: next(responses_iter) + ) + client = _fresh_client(tmp_path) + + shifts = client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) + + assert shifts == [] + assert len(sleeps) == 1 + assert sleeps[0] >= 1 + + +@respx.mock +def test_fetch_shifts_honors_retry_after_header(tmp_path: Path, monkeypatch): + sleeps: list[float] = [] + monkeypatch.setattr("easyatcal.api.time.sleep", lambda s: sleeps.append(s)) + + responses_iter = iter([ + httpx.Response(429, headers={"Retry-After": "7"}), + httpx.Response(200, json={"data": [], "next": None}), + ]) + respx.get("https://api.easyatwork.com/v1/shifts").mock( + side_effect=lambda req: next(responses_iter) + ) + client = _fresh_client(tmp_path) + + client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) + assert sleeps == [7] + + +@respx.mock +def test_fetch_shifts_gives_up_after_retries(tmp_path: Path, monkeypatch): + monkeypatch.setattr("easyatcal.api.time.sleep", lambda s: None) + respx.get("https://api.easyatwork.com/v1/shifts").mock( + return_value=httpx.Response(429) + ) + client = _fresh_client(tmp_path) + + with pytest.raises(ApiError, match="rate limit"): + client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) diff --git a/tests/test_api_session.py b/tests/test_api_session.py new file mode 100644 index 0000000..04a99be --- /dev/null +++ b/tests/test_api_session.py @@ -0,0 +1,188 @@ +from datetime import date +from pathlib import Path +from unittest.mock import patch + +import httpx +import pytest +import respx + +from easyatcal.api import AuthError +from easyatcal.api_session import SessionEawClient, _iter_rows, _parse_shift +from easyatcal.session import SessionStore + +# Fake JWT: three dot-separated segments starting with "ey". +FAKE_JWT = "eyhdr." + ("x" * 40) + ".sig" +SHIFTS_URL = "https://eu-west-3.api.easyatwork.com/customers/1/employees/2/shifts" + + +def _seeded_store(tmp_path: Path, token: str = FAKE_JWT) -> SessionStore: + store = SessionStore(tmp_path / "session.json") + store.save( + { + "cookies": [], + "origins": [ + { + "origin": "https://app.easyatwork.com", + "localStorage": [ + {"name": "access_token", "value": token}, + ], + } + ], + } + ) + return store + +def test_no_token_raises_authenticate(tmp_path: Path) -> None: + with patch("keyring.get_password", return_value=None): + client = SessionEawClient( + shifts_url=SHIFTS_URL, + session_store=SessionStore(tmp_path / "missing.json"), + ) + with pytest.raises(AuthError, match="No access token"): + client.authenticate() + + +@respx.mock +def test_fetch_shifts_happy_path(tmp_path: Path) -> None: + respx.get(SHIFTS_URL).mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "Morning", + "location": "Oslo", + "updated_at": "2026-04-18T10:00:00+00:00", + } + ], + "next_page_url": None, + }, + ) + ) + client = SessionEawClient( + shifts_url=SHIFTS_URL, + session_store=_seeded_store(tmp_path), + ) + shifts = client.fetch_shifts( + from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) + ) + assert len(shifts) == 1 + assert shifts[0].id == "s1" + assert shifts[0].location == "Oslo" + + +@respx.mock +def test_fetch_shifts_sends_bearer_and_laravel_params(tmp_path: Path) -> None: + route = respx.get(SHIFTS_URL).mock( + return_value=httpx.Response(200, json={"data": []}) + ) + with patch("keyring.get_password", return_value=FAKE_JWT): + client = SessionEawClient( + shifts_url=SHIFTS_URL, + session_store=_seeded_store(tmp_path), + ) + client.fetch_shifts( + from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) + ) + req = route.calls.last.request + assert req.headers["Authorization"] == f"Bearer {FAKE_JWT}" + assert req.headers["X-Ui-Version"] == "2.313.0" + # Space-separated Laravel datetime (url-encoded as %20 or +) + qs = req.url.query.decode() + assert "from=2026-04-20" in qs and "00%3A00%3A00" in qs + assert "to=2026-04-27" in qs and "23%3A59%3A59" in qs + assert "order_by=from" in qs + assert "direction=asc" in qs + assert "with%5B%5D=schedule.customer" in qs + + +@respx.mock +def test_fetch_shifts_401_raises_auth_error(tmp_path: Path) -> None: + respx.get(SHIFTS_URL).mock(return_value=httpx.Response(401)) + client = SessionEawClient( + shifts_url=SHIFTS_URL, + session_store=_seeded_store(tmp_path), + ) + with pytest.raises(AuthError, match="Token probably expired"): + client.fetch_shifts( + from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) + ) + + +@respx.mock +def test_fetch_shifts_accepts_bare_list_and_flexible_keys(tmp_path: Path) -> None: + respx.get(SHIFTS_URL).mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "sh-9", + "starts_at": "2026-04-21 09:00:00", + "ends_at": "2026-04-21 17:00:00", + "name": "Evening", + "place": "Bergen", + "updatedAt": "2026-04-19T10:00:00+00:00", + } + ], + ) + ) + client = SessionEawClient( + shifts_url=SHIFTS_URL, + session_store=_seeded_store(tmp_path), + ) + shifts = client.fetch_shifts( + from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) + ) + assert shifts[0].id == "sh-9" + assert shifts[0].title == "Evening" + assert shifts[0].location == "Bergen" + + +@respx.mock +def test_parse_shift_prefers_schedule_customer_name(tmp_path: Path) -> None: + respx.get(SHIFTS_URL).mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "id": 42, + "start": "2026-04-22T09:00:00+00:00", + "end": "2026-04-22T17:00:00+00:00", + "schedule": {"customer": {"name": "Acme Corp"}}, + } + ] + }, + ) + ) + client = SessionEawClient( + shifts_url=SHIFTS_URL, + session_store=_seeded_store(tmp_path), + ) + shifts = client.fetch_shifts( + from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) + ) + assert shifts[0].title == "Acme Corp" + + +def test_iter_rows_shapes() -> None: + assert _iter_rows([{"a": 1}]) == [{"a": 1}] + assert _iter_rows({"data": [{"a": 1}]}) == [{"a": 1}] + assert _iter_rows({"results": [{"a": 1}]}) == [{"a": 1}] + assert _iter_rows({"items": [{"a": 1}]}) == [{"a": 1}] + assert _iter_rows({"shifts": [{"a": 1}]}) == [{"a": 1}] + # Empty-but-recognized page is a legit empty result. + assert _iter_rows({"data": []}) == [] + # Unrecognized shapes must raise, not silently sync zero shifts. + with pytest.raises(ValueError, match="no recognized rows key"): + _iter_rows({"nope": 1}) + with pytest.raises(ValueError, match="unexpected payload type"): + _iter_rows("string") + + +def test_parse_shift_missing_fields_raises() -> None: + with pytest.raises(ValueError, match="missing id/start/end"): + _parse_shift({"title": "x"}) diff --git a/tests/test_cli_auth.py b/tests/test_cli_auth.py new file mode 100644 index 0000000..d8b0831 --- /dev/null +++ b/tests/test_cli_auth.py @@ -0,0 +1,36 @@ +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_auth_test_success(mock_cfg, mock_log, mock_build): + api = MagicMock() + api.authenticate.return_value = "tok" + mock_build.return_value = api + mock_cfg.return_value = MagicMock(logging=MagicMock(level="INFO")) + + result = runner.invoke(app, ["auth", "test"]) + assert result.exit_code == 0, result.stdout + assert "OK" in result.stdout + + +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_auth_test_failure(mock_cfg, mock_log, mock_build): + from easyatcal.api import AuthError + api = MagicMock() + api.authenticate.side_effect = AuthError("bad creds") + mock_build.return_value = api + mock_cfg.return_value = MagicMock(logging=MagicMock(level="INFO")) + + result = runner.invoke(app, ["auth", "test"]) + assert result.exit_code == 2 + assert "bad creds" in result.stdout diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py new file mode 100644 index 0000000..0f6455f --- /dev/null +++ b/tests/test_cli_config.py @@ -0,0 +1,84 @@ +from pathlib import Path +from unittest.mock import patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +def test_config_init_creates_file(tmp_path: Path): + target = tmp_path / "config.yaml" + with patch("easyatcal.cli.config_path", return_value=target): + result = runner.invoke(app, ["config", "init", "--no-interactive"]) + + assert result.exit_code == 0, result.stdout + assert target.exists() + assert "easyatwork:" in target.read_text() + + +def test_config_init_interactive_english_eventkit(tmp_path: Path): + target = tmp_path / "config.yaml" + answers = "user@example.com\nWork {title}\ny\n30\neventkit\n" + + with ( + patch("easyatcal.cli.config_path", return_value=target), + patch("easyatcal.cli._is_french", return_value=False), + patch("sys.platform", "darwin"), + ): + result = runner.invoke(app, ["config", "init"], input=answers) + + assert result.exit_code == 0, result.stdout + body = target.read_text() + assert 'email: "user@example.com"' in body + assert 'event_title_format: "Work {title}"' in body + assert "alarm_minutes_before: 30" in body + assert "backend: eventkit" in body + assert "Next steps:" in result.stdout + + +def test_config_init_interactive_french_ics(tmp_path: Path): + target = tmp_path / "config.yaml" + answers = "utilisateur@example.com\n{title}\nn\n" + + with ( + patch("easyatcal.cli.config_path", return_value=target), + patch("easyatcal.cli._is_french", return_value=True), + patch("sys.platform", "linux"), + ): + result = runner.invoke(app, ["config", "init"], input=answers) + + assert result.exit_code == 0, result.stdout + body = target.read_text() + assert 'email: "utilisateur@example.com"' in body + assert "backend: ics" in body + assert "Configuration générée avec succès" in result.stdout + assert "Prochaines étapes" in result.stdout + + +def test_config_init_does_not_overwrite(tmp_path: Path): + target = tmp_path / "config.yaml" + target.write_text("existing: yes\n") + with patch("easyatcal.cli.config_path", return_value=target): + result = runner.invoke(app, ["config", "init"]) + + assert result.exit_code != 0 + + +def test_config_show_redacts_secret(tmp_path: Path): + target = tmp_path / "config.yaml" + target.write_text( + "easyatwork:\n" + " auth_mode: client\n" + " client_id: cid\n" + " client_secret: supersecret\n" + " base_url: https://api.easyatwork.com\n" + "backend: ics\n" + ) + with patch("easyatcal.cli.config_path", return_value=target): + result = runner.invoke(app, ["config", "show"]) + + assert result.exit_code == 0, result.stdout + assert "supersecret" not in result.stdout + assert "***" in result.stdout diff --git a/tests/test_cli_config_path.py b/tests/test_cli_config_path.py new file mode 100644 index 0000000..66b841d --- /dev/null +++ b/tests/test_cli_config_path.py @@ -0,0 +1,42 @@ +"""Global --config-path flag overrides the default config location.""" +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +@patch("easyatcal.cli.load_config") +def test_config_show_respects_config_path_flag(mock_load, tmp_path: Path): + cfg_file = tmp_path / "custom.yaml" + cfg_file.write_text("stub: true\n") + + mock_load.return_value = MagicMock( + model_dump=lambda: {"easyatwork": {"client_secret": "x"}} + ) + + result = runner.invoke( + app, ["--config-path", str(cfg_file), "config", "show"] + ) + assert result.exit_code == 0, result.stdout + mock_load.assert_called_once_with(cfg_file) + + +@patch("easyatcal.cli.config_path") +def test_default_config_path_used_when_flag_absent(mock_default, tmp_path: Path): + mock_default.return_value = tmp_path / "nope.yaml" + result = runner.invoke(app, ["doctor"]) + # Should call the default resolver because no flag given. + mock_default.assert_called() + assert result.exit_code != 0 + + +def test_version_flag_prints_version_and_exits(): + from easyatcal import __version__ + + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert __version__ in result.stdout diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py new file mode 100644 index 0000000..1f9f290 --- /dev/null +++ b/tests/test_cli_doctor.py @@ -0,0 +1,61 @@ +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +@patch("easyatcal.cli.config_path") +def test_doctor_all_green(mock_cpath, mock_cfg, mock_log, mock_api, mock_backend, tmp_path): + cfg_file = tmp_path / "config.yaml" + cfg_file.write_text("stub: true\n") + mock_cpath.return_value = cfg_file + mock_cfg.return_value = MagicMock( + logging=MagicMock(level="INFO"), backend="ics" + ) + api = MagicMock() + api.authenticate.return_value = "tok" + mock_api.return_value = api + mock_backend.return_value = MagicMock() + + result = runner.invoke(app, ["doctor"]) + assert result.exit_code == 0, result.stdout + assert "config" in result.stdout.lower() + assert "auth" in result.stdout.lower() + assert "backend" in result.stdout.lower() + + +@patch("easyatcal.cli.config_path") +def test_doctor_reports_missing_config(mock_cpath, tmp_path): + mock_cpath.return_value = tmp_path / "nope.yaml" + result = runner.invoke(app, ["doctor"]) + assert result.exit_code != 0 + assert "config" in result.stdout.lower() + + +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +@patch("easyatcal.cli.config_path") +def test_doctor_reports_auth_failure(mock_cpath, mock_cfg, mock_log, mock_api, tmp_path): + from easyatcal.api import AuthError + + cfg_file = tmp_path / "config.yaml" + cfg_file.write_text("stub: true\n") + mock_cpath.return_value = cfg_file + mock_cfg.return_value = MagicMock( + logging=MagicMock(level="INFO"), backend="ics" + ) + api = MagicMock() + api.authenticate.side_effect = AuthError("401 bad creds") + mock_api.return_value = api + + result = runner.invoke(app, ["doctor"]) + assert result.exit_code != 0 + assert "401" in result.stdout or "bad creds" in result.stdout diff --git a/tests/test_cli_login.py b/tests/test_cli_login.py new file mode 100644 index 0000000..bc4cbe6 --- /dev/null +++ b/tests/test_cli_login.py @@ -0,0 +1,145 @@ +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +def _write_user_config(tmp_path: Path) -> Path: + cfg = tmp_path / "config.yaml" + cfg.write_text( + """ +easyatwork: + auth_mode: user + email: me@example.com + login_url: https://app.easyatwork.com/ + app_url: https://app.easyatwork.com + api_url: https://eu-west-3.api.easyatwork.com + customer_id: 1 + employee_id: 2 +backend: ics +backends: + ics: + output_path: %s +""" + % (tmp_path / "out.ics") + ) + return cfg + + +def _write_client_config(tmp_path: Path) -> Path: + cfg = tmp_path / "config.yaml" + cfg.write_text( + """ +easyatwork: + auth_mode: client + client_id: cid + client_secret: csec + base_url: https://api.easyatwork.com +backend: ics +backends: + ics: + output_path: %s +""" + % (tmp_path / "out.ics") + ) + return cfg + + +def test_login_invokes_do_login(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cfg = _write_user_config(tmp_path) + monkeypatch.setenv("EAW_PASSWORD", "s3cret") + + called: dict[str, object] = {} + + def fake_do_login(*, cfg, password, storage_path, extra_wait_selector=None): # type: ignore[no-untyped-def] + called["email"] = cfg.email + called["password"] = password + called["storage_path"] = storage_path + storage_path.parent.mkdir(parents=True, exist_ok=True) + storage_path.write_text('{"cookies":[]}') + + monkeypatch.setattr("easyatcal.auth_user.do_login", fake_do_login) + monkeypatch.setattr( + "easyatcal.cli.session_state_path", + lambda: tmp_path / "session.json", + ) + + result = runner.invoke(app, ["--config-path", str(cfg), "login"]) + assert result.exit_code == 0, result.output + assert called["email"] == "me@example.com" + assert called["password"] == "s3cret" + assert (tmp_path / "session.json").exists() + + +def test_login_rejects_client_mode(tmp_path: Path) -> None: + cfg = _write_client_config(tmp_path) + result = runner.invoke(app, ["--config-path", str(cfg), "login"]) + assert result.exit_code == 1 + assert "auth_mode is not 'user'" in result.output + + +def test_login_empty_password_exits( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + cfg = _write_user_config(tmp_path) + monkeypatch.setenv("EAW_PASSWORD", "") + # Input "" for the prompt fallback (getenv returns empty string, not None) + # We expect CLI to treat empty as aborting. + result = runner.invoke(app, ["--config-path", str(cfg), "login"]) + assert result.exit_code == 1 + assert "Empty password" in result.output + + +def test_login_playwright_missing_exits_1( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from easyatcal.auth_user import PlaywrightMissingError + + cfg = _write_user_config(tmp_path) + monkeypatch.setenv("EAW_PASSWORD", "pw") + + def boom(**_kw: object) -> None: + raise PlaywrightMissingError("install playwright") + + monkeypatch.setattr("easyatcal.auth_user.do_login", boom) + monkeypatch.setattr( + "easyatcal.cli.session_state_path", + lambda: tmp_path / "session.json", + ) + result = runner.invoke(app, ["--config-path", str(cfg), "login"]) + assert result.exit_code == 1 + assert "install playwright" in result.output + + +def test_login_failure_exits_2( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from easyatcal.auth_user import LoginError + + cfg = _write_user_config(tmp_path) + monkeypatch.setenv("EAW_PASSWORD", "pw") + + def boom(**_kw: object) -> None: + raise LoginError("bad creds") + + monkeypatch.setattr("easyatcal.auth_user.do_login", boom) + monkeypatch.setattr( + "easyatcal.cli.session_state_path", + lambda: tmp_path / "session.json", + ) + result = runner.invoke(app, ["--config-path", str(cfg), "login"]) + assert result.exit_code == 2 + assert "Login failed" in result.output + + +def test_logout_clears_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + storage = tmp_path / "session.json" + storage.write_text('{"cookies":[]}') + monkeypatch.setattr("easyatcal.cli.session_state_path", lambda: storage) + result = runner.invoke(app, ["logout"]) + assert result.exit_code == 0 + assert not storage.exists() diff --git a/tests/test_cli_schedule.py b/tests/test_cli_schedule.py new file mode 100644 index 0000000..54f6ffd --- /dev/null +++ b/tests/test_cli_schedule.py @@ -0,0 +1,53 @@ +import sys +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + +def test_schedule_mac_install(monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "darwin") + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + with patch("easyatcal.cli.Path.home") as mock_home: + mock_home.return_value = tmp_path + + result = runner.invoke(app, ["schedule", "--install", "--interval-hours", "6"]) + + assert result.exit_code == 0 + assert "Successfully installed background sync via launchd" in result.output + + plist_path = tmp_path / "Library/LaunchAgents/com.easyatcal.sync.plist" + assert plist_path.exists() + assert "StartInterval" in plist_path.read_text() + +def test_schedule_linux_install(monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "linux") + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="* * * * * old_cron") + + with patch("subprocess.Popen") as mock_popen: + mock_proc = MagicMock() + mock_proc.communicate.return_value = ("", "") + mock_popen.return_value = mock_proc + + result = runner.invoke(app, ["schedule", "--install", "--interval-hours", "6"]) + + assert result.exit_code == 0 + assert "Successfully installed background sync via crontab" in result.output + +def test_schedule_windows_install(monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + result = runner.invoke(app, ["schedule", "--install", "--interval-hours", "6"]) + + assert result.exit_code == 0 + assert "Successfully created Windows scheduled task" in result.output diff --git a/tests/test_cli_state.py b/tests/test_cli_state.py new file mode 100644 index 0000000..493aeef --- /dev/null +++ b/tests/test_cli_state.py @@ -0,0 +1,60 @@ +from unittest.mock import patch + +from typer.testing import CliRunner + +from easyatcal.cli import app +from easyatcal.state import State, save_state + +runner = CliRunner() + + +@patch("easyatcal.cli.state_path") +def test_state_show_reports_summary(mock_sp, tmp_path): + sp = tmp_path / "state.json" + save_state(sp, State( + shift_to_event={"s1": "evt-1", "s2": "evt-2"}, + shift_updated_at={ + "s1": "2026-04-18T00:00:00+00:00", + "s2": "2026-04-18T00:00:00+00:00", + }, + last_sync="2026-04-19T12:00:00+00:00", + )) + mock_sp.return_value = sp + + result = runner.invoke(app, ["state", "show"]) + assert result.exit_code == 0, result.stdout + assert "2" in result.stdout # shift count + assert "2026-04-19" in result.stdout + assert str(sp) in result.stdout + + +@patch("easyatcal.cli.state_path") +def test_state_show_handles_missing(mock_sp, tmp_path): + mock_sp.return_value = tmp_path / "nope.json" + result = runner.invoke(app, ["state", "show"]) + assert result.exit_code == 0 + assert "0" in result.stdout or "empty" in result.stdout.lower() + + +@patch("easyatcal.cli.state_path") +def test_state_clear_requires_confirmation_and_deletes(mock_sp, tmp_path): + sp = tmp_path / "state.json" + save_state(sp, State(shift_to_event={"s1": "e1"})) + mock_sp.return_value = sp + + # Without --yes: refuses. + result = runner.invoke(app, ["state", "clear"]) + assert result.exit_code != 0 + assert sp.exists() + + # With --yes: deletes. + result = runner.invoke(app, ["state", "clear", "--yes"]) + assert result.exit_code == 0, result.stdout + assert not sp.exists() + + +@patch("easyatcal.cli.state_path") +def test_state_clear_missing_is_noop(mock_sp, tmp_path): + mock_sp.return_value = tmp_path / "nope.json" + result = runner.invoke(app, ["state", "clear", "--yes"]) + assert result.exit_code == 0 diff --git a/tests/test_cli_sync.py b/tests/test_cli_sync.py new file mode 100644 index 0000000..f310a8d --- /dev/null +++ b/tests/test_cli_sync.py @@ -0,0 +1,123 @@ +from datetime import UTC +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +@patch("easyatcal.cli.run_sync") +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_sync_once_invokes_run_sync( + mock_cfg, mock_log, mock_api, mock_back, mock_run, tmp_path +): + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + logging=MagicMock(level="INFO"), + ) + result = runner.invoke(app, ["sync"]) + + assert result.exit_code == 0, result.stdout + mock_run.assert_called_once() + + +@patch("easyatcal.cli.time.sleep", side_effect=KeyboardInterrupt) +@patch("easyatcal.cli.run_sync") +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_watch_loops_until_interrupt( + mock_cfg, mock_log, mock_api, mock_back, mock_run, mock_sleep +): + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + logging=MagicMock(level="INFO"), + ) + result = runner.invoke(app, ["watch", "--interval-seconds", "60"]) + + assert mock_run.call_count == 1 + assert result.exit_code == 0 + + +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_sync_dry_run_skips_backend_and_state( + mock_cfg, mock_log, mock_api_build, mock_back, tmp_path +): + from datetime import datetime + + from easyatcal.models import Shift + + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + logging=MagicMock(level="INFO"), + ) + api = MagicMock() + api.fetch_shifts.return_value = [ + Shift( + id="s1", + start=datetime(2026, 5, 1, 9, tzinfo=UTC), + end=datetime(2026, 5, 1, 17, tzinfo=UTC), + title="Shift s1", + location=None, + notes=None, + updated_at=datetime(2026, 4, 29, tzinfo=UTC), + ) + ] + mock_api_build.return_value = api + backend = MagicMock() + mock_back.return_value = backend + + result = runner.invoke(app, ["sync", "--dry-run"]) + + assert result.exit_code == 0, result.stdout + backend.apply.assert_not_called() + assert "dry run" in result.stdout.lower() + assert "add" in result.stdout.lower() + + +@patch("easyatcal.cli.run_sync") +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_sync_partial_failure_exits_1( + mock_cfg, mock_log, mock_api, mock_back, mock_run +): + from easyatcal.backends.base import ApplyResult, BackendError + + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + logging=MagicMock(level="INFO"), + ) + mock_run.side_effect = BackendError("half done", ApplyResult(mapping={"s1": "e1"})) + + result = runner.invoke(app, ["sync"]) + assert result.exit_code == 1, result.stdout + assert "half done" in result.stdout.lower() or "partial" in result.stdout.lower() + + +@patch("easyatcal.cli.run_sync") +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_sync_fatal_failure_exits_2( + mock_cfg, mock_log, mock_api, mock_back, mock_run +): + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + logging=MagicMock(level="INFO"), + ) + mock_run.side_effect = RuntimeError("network down") + + result = runner.invoke(app, ["sync"]) + assert result.exit_code == 2, result.stdout diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d986b6b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,33 @@ +from pathlib import Path + +import pytest + +from easyatcal.config import Config, load_config + +FIXTURE = Path(__file__).parent / "fixtures" / "config_valid.yaml" + + +def test_load_config_from_file(): + cfg = load_config(FIXTURE) + assert isinstance(cfg, Config) + assert cfg.easyatwork.client_id == "cid" + assert cfg.backend == "ics" + assert cfg.sync.lookback_days == 7 + + +def test_env_override_for_secret(monkeypatch): + monkeypatch.setenv("EAW_CLIENT_SECRET", "from-env") + cfg = load_config(FIXTURE) + assert cfg.easyatwork.client_secret == "from-env" + + +def test_invalid_backend_rejected(tmp_path): + bad = tmp_path / "c.yaml" + bad.write_text(FIXTURE.read_text().replace("backend: ics", "backend: nonsense")) + with pytest.raises(Exception): + load_config(bad) + + +def test_missing_file_raises(tmp_path): + with pytest.raises(FileNotFoundError): + load_config(tmp_path / "missing.yaml") diff --git a/tests/test_e2e_ics.py b/tests/test_e2e_ics.py new file mode 100644 index 0000000..20e8f09 --- /dev/null +++ b/tests/test_e2e_ics.py @@ -0,0 +1,53 @@ +"""End-to-end test using the ICS backend and a mocked easy@work API.""" +from pathlib import Path + +import httpx +import respx + +from easyatcal.api import EawClient +from easyatcal.backends.ics import IcsBackend +from easyatcal.orchestrator import run_sync + + +@respx.mock +def test_end_to_end_ics(tmp_path: Path): + token_cache = tmp_path / "token.json" + token_cache.write_text( + '{"access_token":"tok","expires_at":"2099-01-01T00:00:00+00:00"}' + ) + respx.get("https://api.easyatwork.com/v1/shifts").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "Morning", "location": "Oslo", "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + } + ], + "next": None, + }, + ) + ) + api = EawClient( + client_id="cid", client_secret="csecret", + base_url="https://api.easyatwork.com", token_cache=token_cache, + ) + ics_out = tmp_path / "shifts.ics" + backend = IcsBackend(output_path=ics_out, known_shifts=[]) + + run_sync( + api=api, + backend=backend, + state_path=tmp_path / "state.json", + lookback_days=1, + lookahead_days=7, + ) + + body = ics_out.read_text() + assert "SUMMARY:Morning" in body + assert "LOCATION:Oslo" in body + assert (tmp_path / "state.json").exists() diff --git a/tests/test_e2e_integration.py b/tests/test_e2e_integration.py new file mode 100644 index 0000000..f086af0 --- /dev/null +++ b/tests/test_e2e_integration.py @@ -0,0 +1,96 @@ +import json +from datetime import UTC, datetime +from pathlib import Path + +import httpx +import respx + +from easyatcal.api import EawClient +from easyatcal.backends.ics import IcsBackend +from easyatcal.orchestrator import run_sync + +# Pin "now" inside the fixture's date range (shifts on 2026-05-10/11) so those +# shifts fall within the fetch window. A shift removed from the API is only +# deleted when it was in-window; out-of-window past shifts are preserved. +NOW = datetime(2026, 5, 11, 12, 0, tzinfo=UTC) + + +@respx.mock +def test_real_fixture_sync(tmp_path: Path): + fixture_path = Path(__file__).parent / "fixtures" / "easyatwork_shifts.json" + fixture_data = json.loads(fixture_path.read_text()) + + token_cache = tmp_path / "token.json" + token_cache.write_text( + '{"access_token":"tok","expires_at":"2099-01-01T00:00:00+00:00"}' + ) + + # Mock the API response with our real recorded fixture + respx.get("https://api.easyatwork.com/v1/shifts").mock( + return_value=httpx.Response(200, json=fixture_data) + ) + + api = EawClient( + client_id="cid", client_secret="csecret", + base_url="https://api.easyatwork.com", token_cache=token_cache, + ) + + ics_out = tmp_path / "shifts.ics" + backend = IcsBackend(output_path=ics_out, known_shifts=[]) + + # 1. First sync - should add 2 shifts + summary = run_sync( + api=api, + backend=backend, + state_path=tmp_path / "state.json", + lookback_days=1, + lookahead_days=7, + now=NOW, + ) + + assert summary.adds == 2 + assert summary.updates == 0 + assert summary.deletes == 0 + + ics_content = ics_out.read_text() + assert "SUMMARY:Barista Shift" in ics_content + assert "LOCATION:Downtown Cafe" in ics_content + assert "DESCRIPTION:Opening shift\\, don't forget keys" in ics_content + assert "SUMMARY:Closing Shift" in ics_content + + # 2. Second sync with same data - should do nothing + summary2 = run_sync( + api=api, + backend=backend, + state_path=tmp_path / "state.json", + lookback_days=1, + lookahead_days=7, + now=NOW, + ) + assert summary2.adds == 0 + assert summary2.updates == 0 + assert summary2.deletes == 0 + + # 3. Third sync with deleted shift and updated shift + fixture_data["data"].pop() # Remove "Closing Shift" + fixture_data["data"][0]["title"] = "Barista Shift - Updated" + fixture_data["data"][0]["updated_at"] = "2026-05-02T12:00:00+00:00" + respx.get("https://api.easyatwork.com/v1/shifts").mock( + return_value=httpx.Response(200, json=fixture_data) + ) + + summary3 = run_sync( + api=api, + backend=backend, + state_path=tmp_path / "state.json", + lookback_days=1, + lookahead_days=7, + now=NOW, + ) + assert summary3.adds == 0 + assert summary3.updates == 1 + assert summary3.deletes == 1 + + ics_content3 = ics_out.read_text() + assert "SUMMARY:Barista Shift - Updated" in ics_content3 + assert "SUMMARY:Closing Shift" not in ics_content3 diff --git a/tests/test_logging_setup.py b/tests/test_logging_setup.py new file mode 100644 index 0000000..0965499 --- /dev/null +++ b/tests/test_logging_setup.py @@ -0,0 +1,32 @@ +import logging +from pathlib import Path + +from easyatcal.logging_setup import configure_logging + + +def test_configure_logging_writes_to_file(tmp_path: Path): + log_file = tmp_path / "eaw-sync.log" + configure_logging(level="INFO", log_file=log_file) + + logging.getLogger("easyatcal").info("hello world") + + for h in logging.getLogger().handlers: + h.flush() + + assert log_file.exists() + assert "hello world" in log_file.read_text() + + +def test_configure_logging_json_format(tmp_path: Path): + import json + + log_file = tmp_path / "eaw-sync.log" + configure_logging(level="INFO", log_file=log_file, fmt="json") + logging.getLogger("easyatcal").info("payload-ok") + for h in logging.getLogger().handlers: + h.flush() + line = log_file.read_text().strip().splitlines()[-1] + record = json.loads(line) + assert record["msg"] == "payload-ok" + assert record["level"] == "INFO" + assert "ts" in record diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..23d8f55 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,32 @@ +from datetime import UTC, datetime + +import pytest + +from easyatcal.models import Shift + + +def test_shift_is_frozen_dataclass(): + shift = Shift( + id="abc", + start=datetime(2026, 4, 20, 9, 0, tzinfo=UTC), + end=datetime(2026, 4, 20, 17, 0, tzinfo=UTC), + title="Morning", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, 10, 0, tzinfo=UTC), + ) + assert shift.id == "abc" + assert shift.duration_hours == 8.0 + + +def test_shift_requires_tz_aware_datetimes(): + with pytest.raises(ValueError, match="tz-aware"): + Shift( + id="abc", + start=datetime(2026, 4, 20, 9, 0), # naive + end=datetime(2026, 4, 20, 17, 0, tzinfo=UTC), + title="t", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=UTC), + ) diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py new file mode 100644 index 0000000..5e852f0 --- /dev/null +++ b/tests/test_orchestrator.py @@ -0,0 +1,151 @@ +from datetime import UTC, datetime +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from easyatcal.backends.base import ApplyResult, BackendError, Changes +from easyatcal.models import Shift +from easyatcal.orchestrator import run_sync +from easyatcal.state import load_state + + +def _shift(id_: str) -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=UTC), + end=datetime(2026, 4, 20, 17, tzinfo=UTC), + title=f"t{id_}", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=UTC), + ) + + +def test_run_sync_applies_changes_and_persists_state(tmp_path: Path): + state_path = tmp_path / "state.json" + + api = MagicMock() + api.fetch_shifts.return_value = [_shift("s1"), _shift("s2")] + + backend = MagicMock() + backend.apply.return_value = ApplyResult( + mapping={"s1": "evt-1", "s2": "evt-2"}, + ) + + run_sync( + api=api, + backend=backend, + state_path=state_path, + lookback_days=1, + lookahead_days=1, + now=datetime(2026, 4, 19, 12, tzinfo=UTC), + ) + + changes = backend.apply.call_args.args[0] + assert isinstance(changes, Changes) + assert [s.id for s in changes.adds] == ["s1", "s2"] + + saved = load_state(state_path) + assert saved.shift_to_event == {"s1": "evt-1", "s2": "evt-2"} + assert saved.shift_updated_at["s1"] == "2026-04-18T00:00:00+00:00" + assert saved.shift_start["s1"] == "2026-04-20T09:00:00+00:00" + assert saved.last_sync == "2026-04-19T12:00:00+00:00" + + +def test_run_sync_persists_partial_state_on_backend_error(tmp_path: Path): + """If backend.apply half-succeeds, state records what did work, then re-raises.""" + state_path = tmp_path / "state.json" + + api = MagicMock() + api.fetch_shifts.return_value = [_shift("s1"), _shift("s2")] + + backend = MagicMock() + partial = ApplyResult(mapping={"s1": "evt-1"}) + backend.apply.side_effect = BackendError("boom after s1", partial) + + with pytest.raises(BackendError, match="boom after s1"): + run_sync( + api=api, + backend=backend, + state_path=state_path, + lookback_days=1, + lookahead_days=1, + now=datetime(2026, 4, 19, 12, tzinfo=UTC), + ) + + # s1 WAS persisted; s2 was NOT. + saved = load_state(state_path) + assert saved.shift_to_event == {"s1": "evt-1"} + assert saved.shift_start == {"s1": "2026-04-20T09:00:00+00:00"} + assert "s2" not in saved.shift_to_event + + +def test_run_sync_backfills_start_for_unchanged_existing_shift(tmp_path: Path): + state_path = tmp_path / "state.json" + + from easyatcal.state import State, save_state + save_state(state_path, State( + shift_to_event={"s1": "evt-1"}, + shift_updated_at={"s1": "2026-04-18T00:00:00+00:00"}, + )) + + api = MagicMock() + api.fetch_shifts.return_value = [_shift("s1")] + + backend = MagicMock() + backend.apply.return_value = ApplyResult(mapping={}) + + run_sync( + api=api, + backend=backend, + state_path=state_path, + lookback_days=1, + lookahead_days=1, + now=datetime(2026, 4, 19, 12, tzinfo=UTC), + ) + + saved = load_state(state_path) + assert saved.shift_start == {"s1": "2026-04-20T09:00:00+00:00"} + + +def test_run_sync_prunes_deleted_uids(tmp_path: Path): + """State entries whose event_uid is in deleted_uids are removed.""" + state_path = tmp_path / "state.json" + + # Pre-seed state with s_old -> evt-old + from easyatcal.state import State, save_state + save_state(state_path, State( + shift_to_event={"s_old": "evt-old", "s_keep": "evt-keep"}, + shift_updated_at={ + "s_old": "2026-04-01T00:00:00+00:00", + "s_keep": "2026-04-01T00:00:00+00:00", + }, + shift_start={ + "s_old": "2026-04-18T09:00:00+00:00", + "s_keep": "2026-04-20T09:00:00+00:00", + }, + )) + + api = MagicMock() + # Remote no longer contains s_old + api.fetch_shifts.return_value = [_shift("s_keep")] + + backend = MagicMock() + backend.apply.return_value = ApplyResult( + mapping={}, # s_keep wasn't changed -> no new mapping + deleted_uids=["evt-old"], + ) + + run_sync( + api=api, + backend=backend, + state_path=state_path, + lookback_days=1, + lookahead_days=1, + now=datetime(2026, 4, 19, 12, tzinfo=UTC), + ) + + saved = load_state(state_path) + assert "s_old" not in saved.shift_to_event + assert saved.shift_to_event["s_keep"] == "evt-keep" diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..7f5e12d --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,74 @@ +from pathlib import Path + +from easyatcal.session import SessionStore + + +def test_round_trip(tmp_path: Path) -> None: + path = tmp_path / "sub" / "session.json" + store = SessionStore(path) + state = { + "cookies": [ + { + "name": "SESSION", + "value": "abc123", + "domain": "app.easyatwork.com", + "path": "/", + } + ], + "origins": [], + } + store.save(state) + + assert path.exists() + assert path.stat().st_mode & 0o777 == 0o600 + + loaded = store.load() + assert loaded == state + + +def test_load_missing_returns_none(tmp_path: Path) -> None: + assert SessionStore(tmp_path / "nope.json").load() is None + + +def test_load_corrupt_returns_none(tmp_path: Path) -> None: + p = tmp_path / "session.json" + p.write_text("not json") + assert SessionStore(p).load() is None + + +def test_cookies_for_httpx(tmp_path: Path) -> None: + store = SessionStore(tmp_path / "session.json") + store.save( + { + "cookies": [ + { + "name": "A", + "value": "1", + "domain": "app.easyatwork.com", + "path": "/", + }, + { + "name": "B", + "value": "2", + "domain": "app.easyatwork.com", + "path": "/", + }, + {"name": "", "value": "skip", "domain": "x", "path": "/"}, + ], + } + ) + jar = store.cookies() + assert jar is not None + assert jar.get("A", domain="app.easyatwork.com") == "1" + assert jar.get("B", domain="app.easyatwork.com") == "2" + + +def test_clear(tmp_path: Path) -> None: + p = tmp_path / "session.json" + store = SessionStore(p) + store.save({"cookies": []}) + assert p.exists() + store.clear() + assert not p.exists() + # clear on missing is noop + store.clear() diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..9f6bf54 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,62 @@ +import json +from pathlib import Path + +from easyatcal.state import State, load_state, save_state + + +def test_save_then_load_roundtrip(tmp_path: Path): + path = tmp_path / "state.json" + s = State(shift_to_event={"shift-1": "evt-1", "shift-2": "evt-2"}, + last_sync="2026-04-19T12:00:00+00:00") + save_state(path, s) + + loaded = load_state(path) + assert loaded.shift_to_event == s.shift_to_event + assert loaded.last_sync == s.last_sync + + +def test_load_missing_returns_empty(tmp_path: Path): + s = load_state(tmp_path / "missing.json") + assert s.shift_to_event == {} + assert s.last_sync is None + + +def test_load_corrupt_backs_up_and_returns_empty(tmp_path: Path): + path = tmp_path / "state.json" + path.write_text("not valid json{{{") + + s = load_state(path) + + assert s.shift_to_event == {} + assert (tmp_path / "state.json.bak").exists() + + +def test_save_is_atomic(tmp_path: Path): + path = tmp_path / "state.json" + save_state(path, State(shift_to_event={"a": "b"}, last_sync=None)) + assert not any(p.name.endswith(".tmp") for p in tmp_path.iterdir()) + assert json.loads(path.read_text())["shift_to_event"] == {"a": "b"} + + +def test_state_roundtrip_with_updated_at(tmp_path): + path = tmp_path / "state.json" + s = State( + shift_to_event={"s1": "e1"}, + shift_updated_at={"s1": "2026-04-18T10:00:00+00:00"}, + last_sync="2026-04-19T12:00:00+00:00", + ) + save_state(path, s) + loaded = load_state(path) + assert loaded.shift_updated_at == {"s1": "2026-04-18T10:00:00+00:00"} + + +def test_state_roundtrip_with_shift_start(tmp_path): + path = tmp_path / "state.json" + s = State( + shift_to_event={"s1": "e1"}, + shift_start={"s1": "2026-04-20T09:00:00+00:00"}, + last_sync="2026-04-19T12:00:00+00:00", + ) + save_state(path, s) + loaded = load_state(path) + assert loaded.shift_start == {"s1": "2026-04-20T09:00:00+00:00"} diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..b9dc2f7 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,118 @@ +from datetime import UTC, date, datetime + +from easyatcal.models import Shift +from easyatcal.state import State +from easyatcal.sync import compute_changes + +# Window wide enough to contain the _shift() start date below. +WINDOW_FROM = date(2026, 4, 1) +WINDOW_TO = date(2026, 12, 31) + + +def _shift(id_: str, updated: str = "2026-04-18T10:00:00+00:00") -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=UTC), + end=datetime(2026, 4, 20, 17, tzinfo=UTC), + title="t", + location=None, + notes=None, + updated_at=datetime.fromisoformat(updated), + ) + + +def test_new_shifts_are_adds(): + state = State(shift_to_event={}) + shifts = [_shift("a"), _shift("b")] + + changes = compute_changes( + shifts, state, known_updated_at={}, + from_date=WINDOW_FROM, to_date=WINDOW_TO, known_start={}, + ) + + assert [s.id for s in changes.adds] == ["a", "b"] + assert changes.updates == [] + assert changes.deletes == [] + + +def test_known_shifts_unchanged_do_nothing(): + state = State(shift_to_event={"a": "evt-a"}) + shifts = [_shift("a", "2026-04-18T10:00:00+00:00")] + known_updated = {"a": "2026-04-18T10:00:00+00:00"} + + changes = compute_changes( + shifts, state, known_updated_at=known_updated, + from_date=WINDOW_FROM, to_date=WINDOW_TO, known_start={}, + ) + + assert changes.is_empty() + + +def test_known_shift_with_new_updated_at_is_update(): + state = State(shift_to_event={"a": "evt-a"}) + shifts = [_shift("a", "2026-04-19T10:00:00+00:00")] + known_updated = {"a": "2026-04-18T10:00:00+00:00"} + + changes = compute_changes( + shifts, state, known_updated_at=known_updated, + from_date=WINDOW_FROM, to_date=WINDOW_TO, known_start={}, + ) + + assert len(changes.updates) == 1 + shift, event_uid = changes.updates[0] + assert shift.id == "a" + assert event_uid == "evt-a" + + +def test_in_window_shift_missing_from_remote_is_delete(): + state = State( + shift_to_event={"a": "evt-a", "b": "evt-b"}, + shift_start={ + "a": "2026-04-20T09:00:00+00:00", + "b": "2026-04-20T09:00:00+00:00", + }, + ) + shifts = [_shift("a")] + known_updated = {"a": "2026-04-18T10:00:00+00:00", + "b": "2026-04-18T10:00:00+00:00"} + + changes = compute_changes( + shifts, state, known_updated_at=known_updated, + from_date=WINDOW_FROM, to_date=WINDOW_TO, + known_start=state.shift_start, + ) + + assert changes.deletes == ["evt-b"] + + +def test_past_shift_outside_window_is_preserved(): + # "old" sits before the lookback window; the API no longer returns it. + # It must NOT be deleted just because it fell out of the fetch range. + state = State( + shift_to_event={"old": "evt-old", "b": "evt-b"}, + shift_start={ + "old": "2026-01-01T09:00:00+00:00", + "b": "2026-04-20T09:00:00+00:00", + }, + ) + remote = [] # nothing returned this window + + changes = compute_changes( + remote, state, known_updated_at={}, + from_date=WINDOW_FROM, to_date=WINDOW_TO, + known_start=state.shift_start, + ) + + # In-window "b" is a real cancellation -> delete. Past "old" -> preserved. + assert changes.deletes == ["evt-b"] + + +def test_missing_shift_with_unknown_start_is_preserved(): + state = State(shift_to_event={"x": "evt-x"}) # no start recorded + + changes = compute_changes( + [], state, known_updated_at={}, + from_date=WINDOW_FROM, to_date=WINDOW_TO, known_start={}, + ) + + assert changes.deletes == []