commit a519e464823cdbb0d87d80475425c863d33ad9a4 Author: LockeShor <75901583+LockeShor@users.noreply.github.com> Date: Mon Jun 8 22:34:54 2026 -0400 work yay diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5c3db70 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.venv +__pycache__ +*.pyc +.env +.git +.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cb1f1ce --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Copy this to .env and fill values (never commit .env) +WORKHIVE_TOKEN=your_workhive_session_token_here +CALDAV_USER=your_caldav_username +CALDAV_PASSWORD=your_caldav_password +CALDAV_LOCATION=https://your-caldav-url.example/path/ +NAME=Your Name +BASE_URL=https://app.getworkhive.com/schedule +TIMEZONE=America/New_York diff --git a/.gitea/workflows/docker-image.yaml b/.gitea/workflows/docker-image.yaml new file mode 100644 index 0000000..76bb332 --- /dev/null +++ b/.gitea/workflows/docker-image.yaml @@ -0,0 +1,35 @@ +name: Docker Image + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to container registry + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY_URL }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and publish Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: ${{ github.event_name == 'push' }} + tags: | + ${{ secrets.REGISTRY_URL }}/${{ github.repository }}:${{ github.sha }} + ${{ secrets.REGISTRY_URL }}/${{ github.repository }}:latest \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e47da48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +.venv +.vscode \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..11e4d51 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +WORKDIR /app + +# install dependencies +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# copy app +COPY . /app + +# copy run loop helper and make it executable +COPY run-loop.sh /app/run-loop.sh +RUN chmod +x /app/run-loop.sh + +# do not copy any local .env into image; container uses env at runtime +ENV PYTHONUNBUFFERED=1 + +# run the loop which executes sync.py once per hour +CMD ["/app/run-loop.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..41060c8 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# workhive-caldav-sync + +Sync Workhive schedule shifts to a CalDAV calendar. + +Basic usage + +1. Copy `.env.example` to `.env` and fill values (do NOT commit `.env`). + +2. Run locally with Docker Compose (recommended): + +```bash +# build and run +docker compose up --build + +# or run in background +docker compose up --build -d + +# stop +docker compose down +``` + +3. Or run with Docker directly: + +```bash +docker build -t workhive-sync:latest . +docker run --rm --env-file .env workhive-sync:latest +``` + +Notes + +- Secrets are read from environment via `python-dotenv` when running locally (.env file). +- The script expects the website to provide times in the timezone set by `TIMEZONE` (default America/New_York). +- Events are checked for duplicates by deterministic UID and summary+start match before creation. + +Container behavior + +- The container runs `sync.py` once immediately and then repeats every hour. +- If you prefer a one-shot run (no loop), change the `CMD` in the `Dockerfile` to `python sync.py` or run the image with an override command. + +Files of interest: + +- [sync.py](sync.py) +- [Dockerfile](Dockerfile) +- [.env.example](.env.example) +- [requirements.txt](requirements.txt) +- [docker-compose.yml](docker-compose.yml) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e080829 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: '3.8' + +services: + workhive-sync: + build: . + env_file: .env + restart: 'no' + # keep logs visible; remove volume if not desired + volumes: + - ./:/app:ro + command: /app/run-loop.sh diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..78d2e00 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +beautifulsoup4 +requests +caldav +python-dotenv diff --git a/run-loop.sh b/run-loop.sh new file mode 100644 index 0000000..3482497 --- /dev/null +++ b/run-loop.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Run sync.py once immediately, then sleep for one hour between runs. +# Using sh for maximum compatibility in slim images. + +set -e + +while true; do + echo "Running sync.py at $(date -u)" + python sync.py || echo "sync.py exited with non-zero status" + echo "Sleeping for 3600 seconds" + sleep 3600 +done diff --git a/sync.py b/sync.py new file mode 100644 index 0000000..5133794 --- /dev/null +++ b/sync.py @@ -0,0 +1,198 @@ +from bs4 import BeautifulSoup +import requests +from datetime import datetime, timedelta +from caldav import DAVClient +import hashlib +import re +import os +from dotenv import load_dotenv + +# load environment from .env when present +load_dotenv() +import hashlib + +# Workhive and credentials (must be set via environment variables) +# Required secrets: WORKHIVE_TOKEN, CALDAV_USER, CALDAV_PASSWORD, CALDAV_LOCATION +BASE_URL = os.environ.get('BASE_URL') +TOKEN = os.environ.get('WORKHIVE_TOKEN') +NAME = os.environ.get('NAME', 'Jonathan Slivka') + +# CalDAV credentials (must be provided via env) +USER = os.environ.get('CALDAV_USER') +PASSWORD = os.environ.get('CALDAV_PASSWORD') +LOCATION = os.environ.get('CALDAV_LOCATION') + +# timezone to use for parsed datetimes (script runs in EST by default) +TIMEZONE = os.environ.get('TIMEZONE', 'America/New_York') + +if not TOKEN: + raise SystemExit('WORKHIVE_TOKEN not set in environment') +if not USER or not PASSWORD or not LOCATION: + raise SystemExit('CALDAV_USER, CALDAV_PASSWORD, and CALDAV_LOCATION must be set in environment') + + +# with cookie passed as raw header (avoid requests' cookie encoding issues) +headers = {"Cookie": f"workhive_session={TOKEN}"} + +#calculate the mondays of last week and the next 3 weeks +periods = [] +today = datetime.today() +monday = today - timedelta(days=today.weekday()) - timedelta(weeks=1) # start from last week to catch any late-posted shifts +for i in range(4): + period = (monday + timedelta(weeks=i)).strftime("%Y-%m-%d") + periods.append(period) + +print("Periods to check: " + ", ".join(periods)) + +facilities = { + "fac_8ed0d011c748":"Cary Swim Club", + "fac_f38dd7211e7e":"Scottish Hills" +} + +urls = [f"{BASE_URL}?facility_id={facility}&period={period}" for facility in facilities for period in periods] + +shifts = [] + +for url in urls: + response = requests.get(url, headers=headers) + soup = BeautifulSoup(response.content, "html.parser") + for li in soup.find_all("li"): + if NAME in li.get_text().strip(): + shift_time = list(li.find_parent("div", class_="text-white").children)[1].get_text().strip().split("\n")[1].strip() + + td = li.find_parent('td') + row = td.find_parent('tr') + cells = [c for c in row.find_all(['td', 'th'], recursive=False) if getattr(c, 'name', None) is not None] + col_index = None + for i, c in enumerate(cells): + if c is td: + col_index = i + break + + table = row.find_parent('table') + header_ths = [] + thead = table.find('thead') + header_row = thead.find('tr') + header_ths = [th for th in header_row.find_all('th', recursive=False)] + + # extract date text from header (