feat: build Stride local app with Garmin imports and friends-only sharing
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
* text=auto
|
||||
*.py text eol=lf
|
||||
*.md text eol=lf
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.js text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.lock text eol=lf
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
+35
-35
@@ -1,15 +1,18 @@
|
||||
# Multi-user running dashboard: implementation plan
|
||||
|
||||
Status: planning only; application not yet implemented.
|
||||
Date: 2026-09-30
|
||||
Status: live local sync verified; first Flask app implemented with accounts, imports, charts, maps and friends-only sharing. See docs/APP_PROGRESS.md.
|
||||
Updated: 2026-10-01
|
||||
|
||||
## Product and agreed decisions
|
||||
|
||||
Build a self-hosted running platform with user accounts, public profiles,
|
||||
activity pages, and eventually social features similar to Strava. Initial device
|
||||
Build Stride, a self-hosted running platform with user accounts, friends-only
|
||||
profiles, activity pages, and social features similar to Strava. Initial device
|
||||
is a Garmin Forerunner 265, but the data model must support other sources.
|
||||
|
||||
- Python, Flask, Jinja templates, PostgreSQL, SQLAlchemy and Flask-Migrate.
|
||||
- Name/email/password signup; automatically generated internal profile identifiers.
|
||||
- Sharing requires an accepted friend relationship. No Explore or public activity pages.
|
||||
- Friends receive full recorded routes, with no start/end masking.
|
||||
- Leaflet for routes; Chart.js for graphs; Gunicorn for production HTTP.
|
||||
- `python-garminconnect` for unofficial Garmin integration; `fitdecode` for FIT parsing.
|
||||
- Users authenticate to Garmin on their own computer with a helper script and
|
||||
@@ -26,7 +29,7 @@ is a Garmin Forerunner 265, but the data model must support other sources.
|
||||
Browser -> Flask web process -> PostgreSQL and private activity file storage.
|
||||
Local Garmin helper -> user uploads token bundle -> connection validation job.
|
||||
Background worker -> Garmin -> shared FIT import pipeline -> owned activities.
|
||||
Public pages -> visibility checks -> sanitized public activity data.
|
||||
Friends pages -> ownership/friendship checks -> full activity data for authorized viewers.
|
||||
|
||||
Start with a PostgreSQL-backed job table to avoid adding Redis for the initial
|
||||
deployment. The worker claims jobs transactionally, leases them, retries with
|
||||
@@ -67,11 +70,11 @@ pyproject.toml
|
||||
|
||||
## Phase 0: prove the token workflow
|
||||
|
||||
- [ ] Select and pin a compatible Python and `python-garminconnect` version for
|
||||
- [x] Select and pin a compatible Python and `python-garminconnect` version for
|
||||
both helper and server; lock dependencies. Do not assume old garth exports work.
|
||||
- [ ] Write a local helper with hidden password input and interactive MFA.
|
||||
- [x] Write a local helper with hidden password input and interactive MFA.
|
||||
Authenticate directly with Garmin and export the supported session JSON.
|
||||
- [ ] Wrap exports with an explicit bundle format and library version, or accept
|
||||
- [x] Wrap exports with an explicit bundle format and library version, or accept
|
||||
the pinned library's raw token JSON with a documented compatibility check.
|
||||
- [ ] Test with the owner's account: local login, export, load in a Linux
|
||||
container, identify Garmin account, and download one recorded run.
|
||||
@@ -79,9 +82,9 @@ pyproject.toml
|
||||
work from that host; record region/network limitations rather than assuming portability.
|
||||
- [ ] Confirm refreshed credentials are captured and persisted; exercise expired
|
||||
or revoked sessions and the reconnect state.
|
||||
- [ ] Define whether the exported session remains in local use. Avoid simultaneous
|
||||
- [x] Define whether the exported session remains in local use. Avoid simultaneous
|
||||
helper/server refreshes of a shared credential set.
|
||||
- [ ] Document that session tokens grant ongoing Garmin account access and are
|
||||
- [x] Document that session tokens grant ongoing Garmin account access and are
|
||||
not an app-specific read-only permission grant.
|
||||
|
||||
Exit: a documented end-to-end local-to-host import succeeds without sending a
|
||||
@@ -114,7 +117,6 @@ identity and ownership; recovery and verification work with test email delivery.
|
||||
- ActivityLap and ActivitySample: splits and timestamped measurements.
|
||||
- ActivityFile: private storage key, checksum, size, import provenance.
|
||||
- ImportJob: type, owner/connection, status, attempts, lease, idempotency key.
|
||||
- PrivacyZone: owner and private geographic masking settings.
|
||||
- [ ] Implement bounded FIT uploads and parsing through one shared service.
|
||||
Validate contents, use generated storage paths, and bound resource consumption.
|
||||
- [ ] Extract GPS, elapsed/timer time, distance, heart rate, cadence, elevation,
|
||||
@@ -156,29 +158,27 @@ repeated imports do not create duplicates; failed imports do not leave partial a
|
||||
Exit: two accounts sync without crossing credentials or data; restarts preserve
|
||||
progress; expired sessions request reconnect; Garmin downtime leaves the site usable.
|
||||
|
||||
## Phase 4: public dashboard and privacy
|
||||
## Phase 4: dashboard and friend access
|
||||
|
||||
- [ ] Build responsive profile, activity list, run detail, and personal dashboard pages.
|
||||
- [ ] Add weekly/monthly mileage, pace, elevation, splits, and heart-rate charts.
|
||||
- [ ] Use Leaflet routes with a chosen map tile provider, attribution, and usage policy.
|
||||
- [ ] Add owner editing, publish/unpublish, delete, and configurable auto-publishing.
|
||||
- [ ] Support private and public visibility first; add followers-only with Phase 5.
|
||||
- [ ] Apply authorization to HTML, JSON, chart data, downloads, feeds, and caches.
|
||||
- [ ] Mask private route zones on the server before public serialization; do not
|
||||
leak original coordinates through chart payloads or connect lines across masked gaps.
|
||||
- [ ] Make originals downloadable only by their owner initially.
|
||||
- [ ] Invalidate public caches when visibility or masking settings change.
|
||||
- [ ] Define aggregates so private runs cannot leak through public totals or feeds.
|
||||
- [x] Build responsive profile, activity list, run detail, and personal dashboard pages.
|
||||
- [x] Add weekly mileage, pace, splits, and heart-rate charts.
|
||||
- [x] Use Leaflet routes with attribution; review tile provider for hosted deployment.
|
||||
- [x] Add owner title editing and private/friends visibility controls.
|
||||
- [x] Enforce ownership and friendship on profiles, HTML and JSON routes.
|
||||
- [x] Show the full route to authorized viewers without geographic masking.
|
||||
- [x] Exclude private activities from friends' totals and feeds.
|
||||
- [ ] Add configurable automatic sharing, deletion and owner data export.
|
||||
|
||||
Exit: public visitors see only published, sanitized data; another signed-in user
|
||||
cannot access private activity details or files by guessing IDs.
|
||||
Exit: anonymous visitors and non-friends cannot see activity data. Accepted friends
|
||||
see only friends-shared activities, including the full recorded route.
|
||||
|
||||
## Phase 5: social MVP
|
||||
|
||||
- [ ] Add following/unfollowing with private follow requests where applicable.
|
||||
- [ ] Add a paginated feed filtered by current visibility and relationship state.
|
||||
- [ ] Implement followers-only activities and test unfollow/block effects.
|
||||
- [ ] Add account blocking, reporting, and basic site moderation before broad signup.
|
||||
- [x] Add requests using an existing account's email and explicit recipient acceptance.
|
||||
- [x] Add friends-only feed and profiles; remove public Explore functionality.
|
||||
- [x] Add decline/cancel/remove; removal immediately revokes data access.
|
||||
- [ ] Paginate the feed beyond its initial recent-50 limit.
|
||||
- [ ] Add account blocking, reporting, and basic moderation before broad signup.
|
||||
- [ ] Add kudos and comments after the core feed, with ownership checks and abuse limits.
|
||||
|
||||
Deferred: challenges, segments, leaderboards, live tracking, mobile apps, coaching,
|
||||
@@ -204,11 +204,11 @@ additional wearable integrations, and a separate frontend application.
|
||||
## Verification strategy
|
||||
|
||||
- Unit tests: FIT metric calculation, malformed files, duplicate detection,
|
||||
privacy masking including repeated zone crossings, and credential serialization.
|
||||
friendship authorization and full-route preservation, and credential serialization.
|
||||
- Integration tests against PostgreSQL: per-user authorization, refresh races,
|
||||
leases/retries, disconnect races, migrations, and transactional imports.
|
||||
- Browser tests: register -> upload FIT -> inspect private run -> publish ->
|
||||
public view -> unpublish; local-token connection with mocked Garmin responses.
|
||||
- Browser tests: register -> upload FIT -> inspect private run -> share with friends ->
|
||||
accept friendship -> view -> remove friendship -> denied; local-token connection with mocked Garmin responses.
|
||||
- Use synthetic or explicitly consented and sanitized FIT fixtures. Never commit
|
||||
real tokens, passwords, home coordinates, or personal raw recordings.
|
||||
- Keep live Garmin tests opt-in; run the Phase 0 smoke test with the owner's account.
|
||||
@@ -219,17 +219,17 @@ additional wearable integrations, and a separate frontend application.
|
||||
|
||||
1. Token portability proven and integration constraints recorded.
|
||||
2. Accounts plus manual FIT upload and private activity view.
|
||||
3. Public profiles, publishing controls, and server-side route privacy.
|
||||
3. Friends-only profiles, sharing controls and full-route authorized views.
|
||||
4. Per-user token upload, automatic sync, and reconnect flow.
|
||||
5. Following/feed and operational readiness for invited users.
|
||||
6. Broader signup after privacy, recovery, moderation, and restore checks pass.
|
||||
|
||||
## Decisions to resolve during implementation
|
||||
|
||||
- Product name and public domain; repository folder is a working name.
|
||||
- Hosted domain for Stride; repository folder is a working name.
|
||||
- Initial signup mode, email provider, and map tile provider.
|
||||
- History backfill limit and file/account quotas.
|
||||
- Public aggregate semantics and handling of activity updates/deletions upstream.
|
||||
- Friends-only aggregate semantics and handling of activity updates/deletions upstream.
|
||||
- Remote Git hosting/provider, visibility, and repository name if remote hosting is desired.
|
||||
|
||||
## References
|
||||
|
||||
@@ -1,7 +1,65 @@
|
||||
# Running dashboard
|
||||
# Stride
|
||||
|
||||
A planned multi-user, self-hosted running dashboard built with Flask and deployed
|
||||
as one Dokku application.
|
||||
A multi-user running dashboard built with Flask, Jinja, SQLAlchemy, Leaflet and
|
||||
Chart.js. The local version supports accounts, activity imports, dashboards,
|
||||
full route maps, charts, and sharing with accepted friends.
|
||||
|
||||
## Run the app
|
||||
|
||||
```powershell
|
||||
.\scripts\start-app.ps1
|
||||
```
|
||||
|
||||
Or run the steps individually:
|
||||
|
||||
```powershell
|
||||
uv sync --locked
|
||||
uv run --locked flask --app app db upgrade
|
||||
uv run --locked python run.py
|
||||
```
|
||||
|
||||
Open **http://127.0.0.1:5000**. On first launch, create your account with your name,
|
||||
email, and a new Stride password. No username is required. When exactly one local
|
||||
Garmin account cache exists, setup attaches its downloaded activities to this
|
||||
first account, privately. Setup is only enabled for local requests before an
|
||||
account exists. Subsequent accounts start empty.
|
||||
|
||||
The local app uses SQLite in `instance/dashboard.db`. Set `DATABASE_URL` to a
|
||||
PostgreSQL connection string to use PostgreSQL. Database changes use Alembic
|
||||
migrations. Local schema creation/upgrade has been verified on SQLite; PostgreSQL
|
||||
runtime validation remains pending because Docker was unavailable locally.
|
||||
|
||||
### Accounts and sharing
|
||||
|
||||
- Sign up with name, email and password; sign in with email and password.
|
||||
- A generated internal profile identifier replaces a user-chosen username.
|
||||
- Activity visibility is **Only me** or **Accepted friends**. Imports default to Only me.
|
||||
- Add a friend by their registered email under Friends. The recipient must accept
|
||||
the in-app request. No invitation email is sent by this local version.
|
||||
- Accepted friends see each other's shared activities and full recorded routes.
|
||||
- Removing a friend immediately revokes access to shared profiles, pages and JSON.
|
||||
- There is no Explore page, public feed, or anonymous access to activity data.
|
||||
- Routes are not masked or trimmed. Access control, rather than geographic masking,
|
||||
determines who can see the full route, including start and finish.
|
||||
|
||||
### Bringing in more activities
|
||||
|
||||
Run the local sync below, then choose **Settings & imports -> Load new downloads**.
|
||||
This button imports the cache assigned to your account, without contacting Garmin.
|
||||
Any account can also upload an original FIT file through Settings & imports.
|
||||
Original files remain private and are not exposed by web download routes.
|
||||
|
||||
If multiple Garmin caches existed at setup, an operator can explicitly assign one:
|
||||
|
||||
```powershell
|
||||
uv run --locked flask --app app import-local --email owner@example.com --account-key ACCOUNT_KEY
|
||||
```
|
||||
|
||||
The application uses CSRF protection, hashed passwords, login throttling, and
|
||||
server-side authorization on every activity page and data endpoint. Before public
|
||||
deployment, finish email verification/recovery, shared production rate-limit
|
||||
storage, encrypted token uploads, scheduled jobs and PostgreSQL/Dokku validation.
|
||||
The current server binds only to loopback and runs without debug mode.
|
||||
|
||||
Users will upload FIT files or connect Garmin by authenticating locally with
|
||||
`python-garminconnect` and uploading their exported session tokens. The server
|
||||
@@ -10,5 +68,93 @@ will import activities and provide profiles, charts, maps, and publishing contro
|
||||
See [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md) for the architecture,
|
||||
phased checklist, acceptance criteria, and deployment plan.
|
||||
|
||||
Current status: planning only. No application or deployment has been created.
|
||||
Do not add Garmin credentials, token exports, or personal activity files to Git.
|
||||
## Run the local sync
|
||||
|
||||
Requires [uv](https://docs.astral.sh/uv/). Python 3.12 and dependencies are selected
|
||||
by the project configuration and lockfile.
|
||||
|
||||
```powershell
|
||||
uv sync --locked
|
||||
uv run --locked python -m garmin_sync login
|
||||
uv run --locked python -m garmin_sync sync --limit 5
|
||||
```
|
||||
|
||||
Run `login` yourself in an interactive terminal. It prompts for your Garmin email,
|
||||
hidden password, and any MFA code. Never paste those into chat or command-line flags.
|
||||
The login goes directly to Garmin; no web service is running or receiving your password.
|
||||
On Windows, `scripts/connect-garmin.ps1` combines login and a five-activity sync.
|
||||
|
||||
After login, commands resume the saved session without your password:
|
||||
|
||||
```powershell
|
||||
uv run --locked python -m garmin_sync verify
|
||||
uv run --locked python -m garmin_sync sync --limit 20 --type running
|
||||
```
|
||||
|
||||
These are completed activities, not planned training workouts. The default
|
||||
includes all activity types so the first test can find your most recent recording.
|
||||
The limit is how many recent matching activities to examine, including already
|
||||
downloaded ones; it is not the number of new downloads to find.
|
||||
|
||||
## Files and sessions
|
||||
|
||||
- Session: `instance/garmin/default/garmin_tokens.json` (private, ignored by Git).
|
||||
- Activities: `data/garmin/<account-key>/<activity-id>/` (private, ignored by Git).
|
||||
- `original.zip` or `original.fit`: unchanged original download.
|
||||
- `activity.json`: activity-list metadata returned by Garmin.
|
||||
- `activity-N.fit`: FIT members, assigned safe generated filenames.
|
||||
- `activity-N.json`: decoded sessions, laps, record samples, counts, and units.
|
||||
- `manifest.json`: completion marker and file checksums for safe retries.
|
||||
- `last-sync.json`: summary of the last completed sync command.
|
||||
|
||||
Decoded FIT values retain their reported units (including GPS semicircles), listed
|
||||
in the JSON `units` map. Datetimes are serialized as UTC. These outputs contain full routes and private raw data. The web app only
|
||||
serves activity data after ownership or accepted-friend authorization.
|
||||
|
||||
To load an exported token file from another machine:
|
||||
|
||||
```powershell
|
||||
uv run --locked python -m garmin_sync --profile imported import-tokens 'C:\path\garmin_tokens.json'
|
||||
uv run --locked python -m garmin_sync --profile imported sync --limit 5
|
||||
```
|
||||
|
||||
Exports must use the pinned `garminconnect==0.3.17` session format. Old garth
|
||||
`oauth1_token.json`/`oauth2_token.json` files are unsupported. The importer validates
|
||||
the session with Garmin before saving it. Use `--domain garmin.cn` before the
|
||||
subcommand for a China account, consistently for login/import and subsequent syncs.
|
||||
|
||||
Named profiles isolate local token files; provider account identity partitions
|
||||
downloads. The same account should have only one actively used session copy:
|
||||
avoid concurrently syncing a copied export on multiple machines/profiles.
|
||||
|
||||
Tokens refresh when supported by Garmin and are saved after sync. Run `login`
|
||||
again when a session expires or is rejected. Tokens grant ongoing account access;
|
||||
they are not app-specific, read-only permissions. This tool only invokes read
|
||||
operations, but possession of its token file provides broader account access.
|
||||
|
||||
Local credential storage is plain JSON with restricted permissions on POSIX; on
|
||||
Windows it relies on your directory ACLs. Use a private Windows user directory or
|
||||
restrict this workspace's ACLs on a shared machine. Encrypted database credential
|
||||
storage belongs to the future web implementation, not this local proof of concept.
|
||||
No credentials or real activity files should be committed to Git.
|
||||
|
||||
## Verification
|
||||
|
||||
```powershell
|
||||
uv run --locked pytest -q
|
||||
uv run --locked ruff check garmin_sync tests
|
||||
uv run --locked ruff format --check garmin_sync tests
|
||||
```
|
||||
|
||||
The offline tests generate a synthetic FIT file; they never authenticate to Garmin.
|
||||
A live verification requires running login, syncing at least one activity, then
|
||||
running sync again to confirm it skips the previously downloaded files. Record
|
||||
the actual outcome in `docs/SYNC_VALIDATION.md`.
|
||||
|
||||
Current scope: local Flask app and sync CLI. Scheduled worker, token-upload web
|
||||
endpoint, email verification/recovery, and Dokku deployment remain to be implemented.
|
||||
|
||||
For an isolated browser test with synthetic data, run
|
||||
`uv run python scripts/preview-fixture.py` and visit port 5001. This test process
|
||||
uses an in-memory database and auto-login, never reads Garmin files, and must not
|
||||
be deployed. The regular app does not have auto-login or seeded test accounts.
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import os
|
||||
import secrets
|
||||
from datetime import UTC, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import click
|
||||
from flask import Flask, render_template
|
||||
|
||||
from .extensions import csrf, db, limiter, login_manager, migrate
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def create_app(config=None):
|
||||
app = Flask(__name__, instance_path=str(ROOT / "instance"))
|
||||
production = os.environ.get("APP_ENV") == "production"
|
||||
app.config.from_mapping(
|
||||
SQLALCHEMY_DATABASE_URI=os.environ.get("DATABASE_URL", "sqlite:///dashboard.db"),
|
||||
SECRET_KEY=os.environ.get("SECRET_KEY"),
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
SESSION_COOKIE_SECURE=production,
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(days=7),
|
||||
MAX_CONTENT_LENGTH=16 * 1024 * 1024,
|
||||
LOCAL_SETUP=not production,
|
||||
DATA_ROOT=str(ROOT / "data" / "garmin"),
|
||||
RATELIMIT_STORAGE_URI=os.environ.get("RATELIMIT_STORAGE_URI", "memory://"),
|
||||
)
|
||||
app.config.update(config or {})
|
||||
if production and (not app.config["SECRET_KEY"] or not os.environ.get("DATABASE_URL")):
|
||||
raise RuntimeError("Production requires SECRET_KEY and DATABASE_URL.")
|
||||
if not app.config["SECRET_KEY"]:
|
||||
secret_path = Path(app.instance_path) / ".app-secret"
|
||||
secret_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
fd = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, "w") as secret:
|
||||
secret.write(secrets.token_hex(32))
|
||||
except FileExistsError:
|
||||
pass
|
||||
app.config["SECRET_KEY"] = secret_path.read_text()
|
||||
uri = app.config["SQLALCHEMY_DATABASE_URI"]
|
||||
if uri.startswith("postgres://"):
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = uri.replace(
|
||||
"postgres://", "postgresql+psycopg://", 1
|
||||
)
|
||||
elif uri.startswith("postgresql://"):
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = uri.replace(
|
||||
"postgresql://", "postgresql+psycopg://", 1
|
||||
)
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = "web.login"
|
||||
csrf.init_app(app)
|
||||
limiter.init_app(app)
|
||||
from .models import User
|
||||
from .routes import web
|
||||
|
||||
app.register_blueprint(web)
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return db.session.get(User, int(user_id)) if user_id.isdigit() else None
|
||||
|
||||
@app.template_filter("duration")
|
||||
def duration(value):
|
||||
value = round(value or 0)
|
||||
return (
|
||||
f"{value // 3600}:{value % 3600 // 60:02d}:{value % 60:02d}"
|
||||
if value >= 3600
|
||||
else f"{value // 60}:{value % 60:02d}"
|
||||
)
|
||||
|
||||
@app.template_filter("localdate")
|
||||
def localdate(value, timezone="Australia/Sydney", fmt="%d %b %Y"):
|
||||
return value.replace(tzinfo=UTC).astimezone(ZoneInfo(timezone)).strftime(fmt)
|
||||
|
||||
@app.after_request
|
||||
def security_headers(response):
|
||||
if not response.content_type.startswith(("text/css", "application/javascript", "image/")):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
return response
|
||||
|
||||
for code in (400, 403, 404, 413, 429):
|
||||
app.register_error_handler(
|
||||
code, lambda error: (render_template("error.html", code=error.code), error.code)
|
||||
)
|
||||
|
||||
@app.cli.command("import-local")
|
||||
@click.option("--email", required=True)
|
||||
@click.option("--account-key", required=True)
|
||||
def import_local(email, account_key):
|
||||
"""Operator-only command to assign an existing local Garmin export to a user."""
|
||||
import re
|
||||
|
||||
from .services import import_cache
|
||||
|
||||
if not re.fullmatch(r"[a-f0-9]{24}", account_key):
|
||||
raise click.ClickException("Invalid account key")
|
||||
user = db.session.scalar(db.select(User).filter_by(email=email.strip().lower()))
|
||||
if not user:
|
||||
raise click.ClickException("User not found")
|
||||
user.local_account_key = account_key
|
||||
count = import_cache(user, app.config["DATA_ROOT"])
|
||||
db.session.commit()
|
||||
click.echo(f"Imported {count} activities.")
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,12 @@
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
csrf = CSRFProtect()
|
||||
migrate = Migrate()
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=[])
|
||||
@@ -0,0 +1,68 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from .extensions import db
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(40), unique=True, nullable=False)
|
||||
email = db.Column(db.String(254), unique=True, nullable=False)
|
||||
display_name = db.Column(db.String(80), nullable=False)
|
||||
password_hash = db.Column(db.String(256), nullable=False)
|
||||
timezone = db.Column(db.String(64), nullable=False, default="Australia/Sydney")
|
||||
# Assigned only by local setup / operator CLI. Never accepted from registration input.
|
||||
local_account_key = db.Column(db.String(64), unique=True)
|
||||
created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
activities = db.relationship(
|
||||
"Activity", backref="owner", lazy="select", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
|
||||
class Activity(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
|
||||
source_key = db.Column(db.String(180), nullable=False)
|
||||
title = db.Column(db.String(160), nullable=False)
|
||||
sport = db.Column(db.String(40), nullable=False, default="running")
|
||||
started_at = db.Column(db.DateTime(timezone=True), nullable=False, index=True)
|
||||
distance_m = db.Column(db.Float, nullable=False, default=0)
|
||||
duration_s = db.Column(db.Float, nullable=False, default=0)
|
||||
elevation_m = db.Column(db.Float, nullable=False, default=0)
|
||||
avg_hr = db.Column(db.Float)
|
||||
visibility = db.Column(db.String(12), nullable=False, default="private")
|
||||
samples = db.Column(db.JSON, nullable=False, default=list)
|
||||
laps = db.Column(db.JSON, nullable=False, default=list)
|
||||
created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
__table_args__ = (db.UniqueConstraint("owner_id", "source_key"),)
|
||||
|
||||
@property
|
||||
def distance_km(self):
|
||||
return self.distance_m / 1000
|
||||
|
||||
@property
|
||||
def pace(self):
|
||||
if self.distance_m <= 0:
|
||||
return "—"
|
||||
seconds = round(self.duration_s / self.distance_km)
|
||||
return f"{seconds // 60}:{seconds % 60:02d}"
|
||||
|
||||
|
||||
class Friendship(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
low_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
high_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
requested_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
status = db.Column(db.String(12), nullable=False, default="pending")
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("low_id", "high_id"),
|
||||
db.CheckConstraint("low_id < high_id"),
|
||||
)
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, available_timezones
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
abort,
|
||||
current_app,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from flask_login import current_user, login_required, login_user, logout_user
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from garmin_sync.service import SyncError, atomic_write
|
||||
|
||||
from .extensions import db, limiter
|
||||
from .models import Activity, Friendship, User
|
||||
from .services import activity_data, import_cache, import_upload
|
||||
|
||||
web = Blueprint("web", __name__)
|
||||
DUMMY_HASH = generate_password_hash("not-a-real-account-password")
|
||||
|
||||
|
||||
def setup_available():
|
||||
return (
|
||||
current_app.config["LOCAL_SETUP"]
|
||||
and request.remote_addr in {"127.0.0.1", "::1"}
|
||||
and db.session.scalar(db.select(db.func.count(User.id))) == 0
|
||||
)
|
||||
|
||||
|
||||
def bootstrap_directory():
|
||||
directories = [
|
||||
p
|
||||
for p in Path(current_app.config["DATA_ROOT"]).glob("*")
|
||||
if p.is_dir() and re.fullmatch(r"[a-f0-9]{24}", p.name)
|
||||
]
|
||||
return directories[0] if len(directories) == 1 else None
|
||||
|
||||
|
||||
def get_activity(activity_id, require_owner=False):
|
||||
row = db.get_or_404(Activity, activity_id)
|
||||
owner = current_user.is_authenticated and row.owner_id == current_user.id
|
||||
allowed = (
|
||||
current_user.is_authenticated
|
||||
and row.visibility == "friends"
|
||||
and are_friends(current_user.id, row.owner_id)
|
||||
)
|
||||
if (require_owner and not owner) or (not owner and not allowed):
|
||||
abort(404)
|
||||
return row, owner
|
||||
|
||||
|
||||
def are_friends(first, second):
|
||||
low, high = sorted((first, second))
|
||||
return (
|
||||
db.session.scalar(
|
||||
db.select(Friendship.id).filter_by(low_id=low, high_id=high, status="accepted")
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def overview(rows, timezone="Australia/Sydney"):
|
||||
total_distance = sum(r.distance_m for r in rows)
|
||||
total_time = sum(r.duration_s for r in rows)
|
||||
latest = max((r.started_at for r in rows), default=datetime.now(UTC))
|
||||
latest_day = latest.replace(tzinfo=UTC).astimezone(ZoneInfo(timezone)).date()
|
||||
monday = latest_day - timedelta(days=latest_day.weekday())
|
||||
weeks = [monday - timedelta(weeks=n) for n in reversed(range(8))]
|
||||
distances = defaultdict(float)
|
||||
for row in rows:
|
||||
day = row.started_at.replace(tzinfo=UTC).astimezone(ZoneInfo(timezone)).date()
|
||||
distances[day - timedelta(days=day.weekday())] += row.distance_km
|
||||
seconds = round(total_time / (total_distance / 1000)) if total_distance else None
|
||||
return {
|
||||
"distance": total_distance / 1000,
|
||||
"duration": total_time,
|
||||
"count": len(rows),
|
||||
"elevation": sum(r.elevation_m for r in rows),
|
||||
"pace": f"{seconds // 60}:{seconds % 60:02d}" if seconds is not None else "—",
|
||||
"weeks": [w.strftime("%d %b") for w in weeks],
|
||||
"weekly_km": [round(distances[w], 2) for w in weeks],
|
||||
}
|
||||
|
||||
|
||||
@web.get("/health")
|
||||
def health():
|
||||
db.session.execute(db.text("SELECT 1"))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@web.get("/")
|
||||
def index():
|
||||
if setup_available():
|
||||
return redirect(url_for("web.setup"))
|
||||
return redirect(url_for("web.dashboard" if current_user.is_authenticated else "web.login"))
|
||||
|
||||
|
||||
def register_user(is_setup=False):
|
||||
username = secrets.token_hex(10)
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
display_name = request.form.get("display_name", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
if not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) or len(email) > 254:
|
||||
return "Enter a valid email address."
|
||||
if not 1 <= len(display_name) <= 80:
|
||||
return "Enter a display name of up to 80 characters."
|
||||
if not 12 <= len(password) <= 128:
|
||||
return "Use a password between 12 and 128 characters."
|
||||
user = User(username=username, email=email, display_name=display_name)
|
||||
user.set_password(password)
|
||||
directory = bootstrap_directory() if is_setup else None
|
||||
if directory:
|
||||
user.local_account_key = directory.name
|
||||
try:
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
count = import_cache(user, current_app.config["DATA_ROOT"]) if directory else 0
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
return "That email is already registered."
|
||||
session.clear()
|
||||
login_user(user)
|
||||
flash(
|
||||
f"Welcome to Stride. {count} activities imported privately."
|
||||
if count
|
||||
else "Welcome to Stride."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@web.route("/setup", methods=["GET", "POST"])
|
||||
@limiter.limit("10 per hour", methods=["POST"])
|
||||
def setup():
|
||||
if not setup_available():
|
||||
abort(404)
|
||||
directory = bootstrap_directory()
|
||||
count = len(list(directory.glob("*/manifest.json"))) if directory else 0
|
||||
error = register_user(True) if request.method == "POST" else None
|
||||
if request.method == "POST" and not error:
|
||||
return redirect(url_for("web.dashboard"))
|
||||
return render_template("auth.html", mode="setup", error=error, import_count=count)
|
||||
|
||||
|
||||
@web.route("/register", methods=["GET", "POST"])
|
||||
@limiter.limit("10 per hour", methods=["POST"])
|
||||
def register():
|
||||
if setup_available():
|
||||
return redirect(url_for("web.setup"))
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("web.dashboard"))
|
||||
error = register_user() if request.method == "POST" else None
|
||||
if request.method == "POST" and not error:
|
||||
return redirect(url_for("web.dashboard"))
|
||||
return render_template("auth.html", mode="register", error=error)
|
||||
|
||||
|
||||
@web.route("/login", methods=["GET", "POST"])
|
||||
@limiter.limit("5 per minute", methods=["POST"])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("web.dashboard"))
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
password = request.form.get("password", "")
|
||||
user = db.session.scalar(db.select(User).filter_by(email=email))
|
||||
valid = check_password_hash(user.password_hash if user else DUMMY_HASH, password[:129])
|
||||
if user and valid and len(password) <= 128:
|
||||
session.clear()
|
||||
login_user(user)
|
||||
return redirect(url_for("web.dashboard"))
|
||||
error = "Email or password was not recognised."
|
||||
return render_template("auth.html", mode="login", error=error)
|
||||
|
||||
|
||||
@web.post("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
session.clear()
|
||||
return redirect(url_for("web.login"))
|
||||
|
||||
|
||||
@web.get("/dashboard")
|
||||
@login_required
|
||||
def dashboard():
|
||||
rows = db.session.scalars(
|
||||
db.select(Activity).filter_by(owner_id=current_user.id).order_by(Activity.started_at.desc())
|
||||
).all()
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
rows=rows,
|
||||
stats=overview(rows, current_user.timezone),
|
||||
profile=current_user,
|
||||
personal=True,
|
||||
)
|
||||
|
||||
|
||||
@web.route("/friends", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@limiter.limit("20 per hour", methods=["POST"])
|
||||
def friends():
|
||||
if request.method == "POST":
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
other = db.session.scalar(db.select(User).filter_by(email=email))
|
||||
if other and other.id != current_user.id:
|
||||
low, high = sorted((current_user.id, other.id))
|
||||
existing = db.session.scalar(db.select(Friendship).filter_by(low_id=low, high_id=high))
|
||||
if not existing:
|
||||
db.session.add(Friendship(low_id=low, high_id=high, requested_by=current_user.id))
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
flash(
|
||||
"If that person has a Stride account, your request is available in their Friends page."
|
||||
)
|
||||
return redirect(url_for("web.friends"))
|
||||
links = db.session.scalars(
|
||||
db.select(Friendship).where(
|
||||
db.or_(Friendship.low_id == current_user.id, Friendship.high_id == current_user.id)
|
||||
)
|
||||
).all()
|
||||
people = [
|
||||
{
|
||||
"link": link,
|
||||
"person": db.session.get(
|
||||
User, link.high_id if link.low_id == current_user.id else link.low_id
|
||||
),
|
||||
}
|
||||
for link in links
|
||||
]
|
||||
accepted_ids = [p["person"].id for p in people if p["link"].status == "accepted"]
|
||||
rows = db.session.scalars(
|
||||
db.select(Activity)
|
||||
.where(Activity.owner_id.in_(accepted_ids), Activity.visibility == "friends")
|
||||
.order_by(Activity.started_at.desc())
|
||||
.limit(50)
|
||||
).all()
|
||||
return render_template("friends.html", people=people, rows=rows)
|
||||
|
||||
|
||||
@web.post("/friends/<int:friendship_id>/<action>")
|
||||
@login_required
|
||||
def friendship_action(friendship_id, action):
|
||||
link = db.get_or_404(Friendship, friendship_id)
|
||||
if current_user.id not in (link.low_id, link.high_id):
|
||||
abort(404)
|
||||
if action == "accept" and link.status == "pending" and link.requested_by != current_user.id:
|
||||
link.status = "accepted"
|
||||
flash("You’re now friends. Activities shared with friends are visible to both of you.")
|
||||
elif action == "remove":
|
||||
db.session.delete(link)
|
||||
flash("Connection removed. Shared activities are no longer accessible between you.")
|
||||
else:
|
||||
abort(400)
|
||||
db.session.commit()
|
||||
return redirect(url_for("web.friends"))
|
||||
|
||||
|
||||
@web.get("/u/<username>")
|
||||
@login_required
|
||||
def profile(username):
|
||||
user = db.first_or_404(db.select(User).filter_by(username=username))
|
||||
if user.id != current_user.id and not are_friends(current_user.id, user.id):
|
||||
abort(404)
|
||||
rows = db.session.scalars(
|
||||
db.select(Activity)
|
||||
.filter_by(owner_id=user.id, visibility="friends")
|
||||
.order_by(Activity.started_at.desc())
|
||||
).all()
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
rows=rows,
|
||||
stats=overview(rows, user.timezone),
|
||||
profile=user,
|
||||
personal=False,
|
||||
)
|
||||
|
||||
|
||||
@web.get("/activities/<int:activity_id>")
|
||||
def activity(activity_id):
|
||||
row, owner = get_activity(activity_id)
|
||||
return render_template("activity.html", activity=row, owner_view=owner)
|
||||
|
||||
|
||||
@web.get("/activities/<int:activity_id>/data")
|
||||
def data(activity_id):
|
||||
row, owner = get_activity(activity_id)
|
||||
return jsonify(activity_data(row, owner))
|
||||
|
||||
|
||||
@web.post("/activities/<int:activity_id>/edit")
|
||||
@login_required
|
||||
def edit_activity(activity_id):
|
||||
row, _ = get_activity(activity_id, True)
|
||||
title = request.form.get("title", "").strip()
|
||||
visibility = request.form.get("visibility", "private")
|
||||
if not title or len(title) > 160 or visibility not in {"friends", "private"}:
|
||||
abort(400)
|
||||
row.title, row.visibility = title, visibility
|
||||
db.session.commit()
|
||||
flash("Activity updated.")
|
||||
return redirect(url_for("web.activity", activity_id=row.id))
|
||||
|
||||
|
||||
@web.route("/settings", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def settings():
|
||||
if request.method == "POST":
|
||||
name = request.form.get("display_name", "").strip()
|
||||
timezone = request.form.get("timezone", "")
|
||||
if not 1 <= len(name) <= 80 or timezone not in available_timezones():
|
||||
abort(400)
|
||||
current_user.display_name, current_user.timezone = name, timezone
|
||||
db.session.commit()
|
||||
flash("Profile saved.")
|
||||
return redirect(url_for("web.settings"))
|
||||
return render_template("settings.html")
|
||||
|
||||
|
||||
@web.post("/imports/cache")
|
||||
@login_required
|
||||
def import_local():
|
||||
if not current_user.local_account_key:
|
||||
abort(404)
|
||||
try:
|
||||
count = import_cache(current_user, current_app.config["DATA_ROOT"])
|
||||
db.session.commit()
|
||||
flash(f"Imported {count} new activities from your local Garmin sync.")
|
||||
except (SyncError, ValueError, OSError):
|
||||
db.session.rollback()
|
||||
flash("An import could not be read. Check the local sync output and try again.", "error")
|
||||
return redirect(url_for("web.dashboard"))
|
||||
|
||||
|
||||
@web.post("/imports/fit")
|
||||
@login_required
|
||||
def upload():
|
||||
file = request.files.get("file")
|
||||
if not file or not file.filename.lower().endswith(".fit"):
|
||||
flash("Choose a FIT activity file.", "error")
|
||||
return redirect(url_for("web.settings"))
|
||||
payload = file.read(16 * 1024 * 1024 + 1)
|
||||
if len(payload) > 16 * 1024 * 1024:
|
||||
abort(413)
|
||||
try:
|
||||
row = import_upload(current_user, payload)
|
||||
if row:
|
||||
destination = Path(current_app.instance_path) / "uploads" / str(current_user.id)
|
||||
atomic_write(destination / f"{hashlib.sha256(payload).hexdigest()}.fit", payload)
|
||||
db.session.commit()
|
||||
flash("Activity imported privately.")
|
||||
return redirect(url_for("web.activity", activity_id=row.id))
|
||||
flash("You have already imported that file.")
|
||||
except (SyncError, ValueError, TypeError, OSError):
|
||||
db.session.rollback()
|
||||
flash(
|
||||
"The FIT file could not be imported. Check it contains a completed activity.", "error"
|
||||
)
|
||||
return redirect(url_for("web.settings"))
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from garmin_sync.service import SyncError, complete, decode_fit
|
||||
|
||||
from .extensions import db
|
||||
from .models import Activity
|
||||
|
||||
|
||||
def number(value, default=0):
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value):
|
||||
return float(value)
|
||||
return default
|
||||
|
||||
|
||||
def instant(value):
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||
parsed = datetime.fromisoformat(str(value))
|
||||
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
|
||||
|
||||
|
||||
def activity_from_fit(owner_id, source_key, documents, metadata=None):
|
||||
metadata = metadata or {}
|
||||
if db.session.scalar(db.select(Activity).filter_by(owner_id=owner_id, source_key=source_key)):
|
||||
return None
|
||||
sessions = [s for doc in documents for s in doc.get("session", [])]
|
||||
if not sessions:
|
||||
raise SyncError("This FIT file has no completed activity session.")
|
||||
first = sessions[0]
|
||||
sport = str(first.get("sport") or "activity")
|
||||
started = instant(first.get("start_time") or metadata.get("startTimeGMT"))
|
||||
records = [r for doc in documents for r in doc.get("record", [])]
|
||||
samples = []
|
||||
for record in records:
|
||||
lat, lng = record.get("position_lat"), record.get("position_long")
|
||||
point = None
|
||||
if lat is not None and lng is not None:
|
||||
lat = number(lat, None)
|
||||
lng = number(lng, None)
|
||||
if lat is not None and lng is not None:
|
||||
lat, lng = lat * 180 / 2**31, lng * 180 / 2**31
|
||||
if -90 <= lat <= 90 and -180 <= lng <= 180:
|
||||
point = [lat, lng]
|
||||
samples.append(
|
||||
{
|
||||
"point": point,
|
||||
"distance": number(record.get("distance"), None),
|
||||
"heart_rate": number(record.get("heart_rate"), None),
|
||||
"speed": number(record.get("enhanced_speed", record.get("speed")), None),
|
||||
"elevation": number(record.get("enhanced_altitude", record.get("altitude")), None),
|
||||
}
|
||||
)
|
||||
laps = []
|
||||
for document in documents:
|
||||
for lap in document.get("lap", []):
|
||||
distance = number(lap.get("total_distance"))
|
||||
duration = number(lap.get("total_timer_time"))
|
||||
laps.append(
|
||||
{
|
||||
"distance": distance,
|
||||
"duration": duration,
|
||||
"heart_rate": number(lap.get("avg_heart_rate"), None),
|
||||
"pace": duration * 1000 / distance if distance > 0 else None,
|
||||
}
|
||||
)
|
||||
row = Activity(
|
||||
owner_id=owner_id,
|
||||
source_key=source_key,
|
||||
title=str(metadata.get("activityName") or f"{started:%A} {sport}")[:160],
|
||||
sport=sport,
|
||||
started_at=started,
|
||||
distance_m=sum(number(s.get("total_distance")) for s in sessions),
|
||||
duration_s=sum(number(s.get("total_timer_time")) for s in sessions),
|
||||
elevation_m=sum(number(s.get("total_ascent")) for s in sessions),
|
||||
avg_hr=number(metadata.get("averageHR", first.get("avg_heart_rate")), None),
|
||||
samples=samples,
|
||||
laps=laps,
|
||||
)
|
||||
db.session.add(row)
|
||||
return row
|
||||
|
||||
|
||||
def import_cache(user, data_root):
|
||||
if not user.local_account_key:
|
||||
return 0
|
||||
directory = Path(data_root) / user.local_account_key
|
||||
count = 0
|
||||
for manifest in sorted(directory.glob("*/manifest.json")):
|
||||
if not complete(manifest.parent):
|
||||
continue
|
||||
metadata = json.loads((manifest.parent / "activity.json").read_text())
|
||||
docs = [json.loads(p.read_text()) for p in sorted(manifest.parent.glob("activity-*.json"))]
|
||||
source_key = f"garmin:{user.local_account_key}:{manifest.parent.name}"
|
||||
if activity_from_fit(user.id, source_key, docs, metadata):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def import_upload(user, payload):
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
document = decode_fit(payload)
|
||||
# Normalize datetime values before SQLAlchemy's JSON serializer.
|
||||
from garmin_sync.service import json_default
|
||||
|
||||
document = json.loads(json.dumps(document, default=json_default))
|
||||
return activity_from_fit(user.id, f"file:{digest}", [document])
|
||||
|
||||
|
||||
def activity_data(activity, owner_view=False):
|
||||
"""Call only after access checks. Owners and accepted friends see the full route."""
|
||||
segments, segment = [], []
|
||||
for sample in activity.samples:
|
||||
point = sample.get("point")
|
||||
if point:
|
||||
segment.append(point)
|
||||
elif segment:
|
||||
segments.append(segment)
|
||||
segment = []
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
step = max(1, math.ceil(len(activity.samples) / 750))
|
||||
chart = [
|
||||
{k: sample.get(k) for k in ("distance", "heart_rate", "speed", "elevation")}
|
||||
for sample in activity.samples[::step]
|
||||
]
|
||||
return {"segments": segments, "chart": chart}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
async function showActivity() {
|
||||
const container = document.getElementById("route-map");
|
||||
const status = document.getElementById("map-status");
|
||||
try {
|
||||
const response = await fetch(container.dataset.url);
|
||||
if (!response.ok) throw new Error("Activity unavailable");
|
||||
const data = await response.json();
|
||||
if (data.segments.length && window.L) {
|
||||
container.replaceChildren();
|
||||
const map = L.map(container, {scrollWheelZoom: false});
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {maxZoom: 19, attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}).addTo(map);
|
||||
const route = L.polyline(data.segments, {color: "#345e40", weight: 4, opacity: .95}).addTo(map);
|
||||
map.fitBounds(route.getBounds(), {padding: [35, 35], maxZoom: 16});
|
||||
status.textContent = "Scroll to explore your charts. Drag the map to explore the route.";
|
||||
} else {
|
||||
container.querySelector("p").textContent = "No route to display. This activity may have been recorded indoors.";
|
||||
status.textContent = "Activity statistics are available below.";
|
||||
}
|
||||
if (!window.Chart) return;
|
||||
const config = (id, key, color, formatter, reverse = false) => {
|
||||
const points = data.chart.filter(s => s.distance !== null).map(s => ({x: s.distance / 1000, y: formatter(s[key])}));
|
||||
new Chart(document.getElementById(id), {type: "line", data: {datasets: [{data: points, borderColor: color, backgroundColor: `${color}12`, fill: true, borderWidth: 1.7, pointRadius: 0, pointHitRadius: 12, tension: .2, spanGaps: false}]}, options: {responsive: true, maintainAspectRatio: false, parsing: false, plugins: {legend: {display: false}}, scales: {x: {type: "linear", grid: {display: false}, border: {display: false}, ticks: {font: {size: 10}, color: "#949c8b", callback: v => `${v} km`}}, y: {reverse, grid: {color: "#f0f2eb"}, border: {display: false}, ticks: {font: {size: 10}, color: "#949c8b"}}}}});
|
||||
};
|
||||
config("pace-chart", "speed", "#668b47", v => v > .5 ? Math.min(30, 1000 / v / 60) : null, true);
|
||||
config("hr-chart", "heart_rate", "#bd8063", v => v > 0 ? v : null);
|
||||
} catch {
|
||||
container.querySelector("p").textContent = "We couldn’t load this activity. Please refresh and try again.";
|
||||
status.textContent = "The activity may no longer be available.";
|
||||
}
|
||||
}
|
||||
showActivity();
|
||||
@@ -0,0 +1,10 @@
|
||||
"use strict";
|
||||
const volume = document.getElementById("volume-data");
|
||||
if (volume && window.Chart) {
|
||||
const data = JSON.parse(volume.textContent);
|
||||
new Chart(document.getElementById("volume-chart"), {
|
||||
type: "bar",
|
||||
data: {labels: data.labels, datasets: [{data: data.values, backgroundColor: data.values.map((_, i) => i === 7 ? "#294f3a" : "#dbe6bf"), hoverBackgroundColor: "#87a364", borderRadius: 5, maxBarThickness: 34}]},
|
||||
options: {responsive: true, maintainAspectRatio: false, plugins: {legend: {display: false}, tooltip: {callbacks: {label: context => `${context.raw.toFixed(2)} km`}}}, scales: {x: {grid: {display: false}, border: {display: false}, ticks: {font: {size: 10}, color: "#949c8b"}}, y: {beginAtZero: true, border: {display: false}, grid: {color: "#f0f2eb"}, ticks: {font: {size: 10}, color: "#949c8b", callback: v => `${v} km`}}}}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="12" fill="#233d31"/><path d="M27 12H18c-7 0-8 8-1 8h6c7 0 6 8-1 8H12" fill="none" stroke="#d8ef8c" stroke-width="4"/></svg>
|
||||
|
After Width: | Height: | Size: 217 B |
File diff suppressed because one or more lines are too long
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2024 Chart.js Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2010-2023, Volodymyr Agafonkin
|
||||
Copyright (c) 2010-2011, CloudMade
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Vendored
+14
File diff suppressed because one or more lines are too long
Vendored
+661
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
||||
{% if rows %}
|
||||
<div class="activity-table-wrap"><table class="activity-table"><thead><tr><th>ACTIVITY</th><th>DISTANCE</th><th>TIME</th><th>AVG. PACE</th><th>{{ 'VISIBILITY' if personal|default(false) else 'ATHLETE' }}</th><th><span class="sr-only">Open</span></th></tr></thead><tbody>
|
||||
{% for row in rows %}<tr><td><a class="activity-name" href="{{ url_for('web.activity',activity_id=row.id) }}"><span class="sport-icon">↗</span><span><strong>{{ row.title }}</strong><small>{{ row.started_at|localdate(row.owner.timezone) }} <span>·</span> {{ row.sport|replace('_',' ')|title }}</small></span></a></td><td><strong>{{ '%.2f'|format(row.distance_km) }}</strong><small> km</small></td><td>{{ row.duration_s|duration }}</td><td>{{ row.pace }}<small> /km</small></td><td>{% if personal|default(false) %}<span class="pill {{ row.visibility }}">{{ row.visibility|title }}</span>{% else %}<a class="athlete-link" href="{{ url_for('web.profile',username=row.owner.username) }}">{{ row.owner.display_name }}</a>{% endif %}</td><td><a class="row-arrow" aria-label="View {{ row.title }}" href="{{ url_for('web.activity',activity_id=row.id) }}">↗</a></td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
{% else %}<div class="empty-state"><span class="empty-symbol">↗</span><h3>Your next chapter awaits.</h3><p>{{ 'Import a FIT file or load your Garmin activities to get started.' if personal|default(false) else 'No shared activities just yet. Check back after the next run.' }}</p>{% if personal|default(false) %}<a class="button primary" href="{{ url_for('web.settings') }}">Import your first run ↗</a>{% endif %}</div>{% endif %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ activity.title }} · Stride{% endblock %}
|
||||
{% block breadcrumb %}ACTIVITY / {{ activity.sport|upper }}{% endblock %}
|
||||
{% block head %}<link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">{% endblock %}
|
||||
{% block content %}
|
||||
<a class="back-link" href="{{ url_for('web.dashboard') if owner_view else url_for('web.profile',username=activity.owner.username) }}">← {{ 'Your activities' if owner_view else activity.owner.display_name }}</a>
|
||||
<div class="page-heading"><div><span class="eyebrow">{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}</span><h1>{{ activity.title }}</h1><p>By <a href="{{ url_for('web.profile',username=activity.owner.username) }}">{{ activity.owner.display_name }}</a> <span class="dot-separator">·</span> {{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}</p></div><span class="pill {{ activity.visibility }}">{{ activity.visibility|title }}</span></div>
|
||||
<div class="stats-grid"><article class="stat-card featured"><div class="stat-label">DISTANCE</div><div class="stat-value">{{ '%.2f'|format(activity.distance_km) }}<small>km</small></div></article><article class="stat-card"><div class="stat-label">TIMER TIME</div><div class="stat-value time-value">{{ activity.duration_s|duration }}</div></article><article class="stat-card"><div class="stat-label">AVERAGE PACE</div><div class="stat-value">{{ activity.pace }}<small>/km</small></div></article><article class="stat-card"><div class="stat-label">AVERAGE HEART RATE</div><div class="stat-value">{{ activity.avg_hr|int if activity.avg_hr else '—' }}<small>bpm</small></div></article></div>
|
||||
<section class="panel map-panel"><div class="section-head"><h2>The route.</h2><span class="subtle-label">{{ 'Full recorded route' }}</span></div><div id="route-map" data-url="{{ url_for('web.data',activity_id=activity.id) }}"><p class="map-placeholder">Loading activity…</p></div><p id="map-status" class="map-status"></p></section>
|
||||
<div class="detail-charts"><section class="panel"><div class="section-head"><h2>Pace</h2><span class="subtle-label">min/km</span></div><div class="chart-wrap"><canvas id="pace-chart" role="img" aria-label="Pace over distance"></canvas></div></section><section class="panel"><div class="section-head"><h2>Heart rate</h2><span class="subtle-label">bpm</span></div><div class="chart-wrap"><canvas id="hr-chart" role="img" aria-label="Heart rate over distance"></canvas></div></section></div>
|
||||
<div class="detail-bottom"><section class="panel"><div class="section-head"><h2>Laps & splits</h2><span class="subtle-label">{{ activity.laps|length }} recorded laps</span></div><table class="splits-table"><thead><tr><th>LAP</th><th>DISTANCE</th><th>TIME</th><th>PACE /KM</th><th>HR</th></tr></thead><tbody>{% for lap in activity.laps %}<tr><td>{{ loop.index }}</td><td>{{ '%.2f'|format(lap.distance / 1000) }} km</td><td>{{ lap.duration|duration }}</td><td>{{ lap.pace|duration if lap.pace else '—' }}</td><td>{{ lap.heart_rate|int if lap.heart_rate else '—' }}</td></tr>{% else %}<tr><td colspan="5">No laps recorded.</td></tr>{% endfor %}</tbody></table></section>
|
||||
{% if owner_view %}<section class="panel publishing-panel"><span class="eyebrow">YOUR RUN, YOUR CHOICE</span><h2>Sharing settings</h2><form method="post" action="{{ url_for('web.edit_activity',activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Activity title<input name="title" value="{{ activity.title }}" maxlength="160" required></label><label>Who can see this activity?<select name="visibility"><option value="private" {{ 'selected' if activity.visibility == 'private' }}>Only me</option><option value="friends" {{ 'selected' if activity.visibility == 'friends' }}>Accepted friends</option></select></label><p class="field-help">Accepted friends can see the full route, date, statistics and charts of shared activities. Choose Only me to keep this activity private.</p><button class="button primary">Save changes ↗</button></form></section>{% endif %}</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='activity.js') }}"></script>{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ 'Welcome' if mode == 'setup' else 'Sign in' if mode == 'login' else 'Join' }} · Stride{% endblock %}
|
||||
{% block breadcrumb %}{{ 'WELCOME' if mode == 'setup' else 'ACCOUNT' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-layout">
|
||||
<section class="auth-intro"><span class="eyebrow">THE NEXT CHAPTER STARTS HERE</span><h1>Your miles.<br>Your story.<br><em>Your space.</em></h1><p>A home for the runs, the quiet progress,<br>and everything you’re working towards.</p><div class="auth-art" aria-hidden="true"><svg viewBox="0 0 460 230"><path d="M-20 185 C90 50 100 275 235 90 S365 170 490 20"/><path d="M-20 209 C90 74 100 299 235 114 S365 194 490 44"/><circle cx="235" cy="90" r="8"/></svg></div></section>
|
||||
<section class="panel auth-card">
|
||||
<span class="eyebrow">{{ 'FIRST STEPS' if mode == 'setup' else 'WELCOME BACK' if mode == 'login' else 'FIND YOUR STRIDE' }}</span>
|
||||
<h2>{{ 'Make yourself at home.' if mode == 'setup' else 'Good to see you.' if mode == 'login' else 'Create your account.' }}</h2>
|
||||
<p class="muted">{% if mode == 'setup' %}Create your Stride account. Your {{ import_count }} downloaded activities will be imported privately.{% elif mode == 'login' %}Sign in to your running dashboard.{% else %}Track your progress and share your runs with friends.{% endif %}</p>
|
||||
{% if error %}<p role="alert" class="form-error">{{ error }}</p>{% endif %}
|
||||
<form method="post" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if mode != 'login' %}
|
||||
<label>Your name<input name="display_name" required maxlength="80" autocomplete="name" placeholder="How should we call you?" value="{{ request.form.get('display_name','') }}"></label>
|
||||
|
||||
{% endif %}
|
||||
<label>Email address<input type="email" name="email" required maxlength="254" autocomplete="email" placeholder="you@example.com" value="{{ request.form.get('email','') }}"></label>
|
||||
<label>Password<input type="password" name="password" required minlength="{{ 1 if mode == 'login' else 12 }}" maxlength="128" autocomplete="{{ 'current-password' if mode == 'login' else 'new-password' }}" placeholder="{{ 'Your password' if mode == 'login' else 'At least 12 characters' }}"></label>
|
||||
<button class="button primary full">{{ 'Sign in' if mode == 'login' else 'Create account' }} <span>↗</span></button>
|
||||
</form>
|
||||
<p class="auth-foot">{% if mode == 'login' %}New here? <a href="{{ url_for('web.register') }}">Create an account</a>{% else %}Already have an account? <a href="{{ url_for('web.login') }}">Sign in</a>{% endif %}</p>
|
||||
{% if mode != 'login' %}<small class="muted">This is your Stride password, separate from your Garmin account.</small>{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Stride · Your running story{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<link rel="icon" href="{{ url_for('static', filename='mark.svg') }}" type="image/svg+xml">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="{{ url_for('web.index') }}"><span class="brand-mark">s</span>stride<span class="brand-dot">.</span></a>
|
||||
<div class="sidebar-label">YOUR RUNNING STORY</div>
|
||||
<nav aria-label="Main navigation">
|
||||
{% if current_user.is_authenticated %}
|
||||
<a class="nav-item {{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}"><span>◫</span> Overview</a>
|
||||
<a class="nav-item {{ 'active' if request.endpoint == 'web.profile' }}" href="{{ url_for('web.profile', username=current_user.username) }}"><span>◎</span> Shared profile</a>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}<a class="nav-item {{ 'active' if request.endpoint == 'web.friends' }}" href="{{ url_for('web.friends') }}"><span>↗</span> Friends</a>{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<a class="nav-item {{ 'active' if request.endpoint == 'web.settings' }}" href="{{ url_for('web.settings') }}"><span>⚙</span> Settings & imports</a>
|
||||
{% else %}
|
||||
<a class="nav-item {{ 'active' if request.endpoint == 'web.login' }}" href="{{ url_for('web.login') }}"><span>↪</span> Sign in</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<div class="sidebar-note"><span class="tiny-label">A LITTLE FURTHER.</span><p>Every run adds<br>to your story.</p><div class="trail-art" aria-hidden="true"><svg viewBox="0 0 180 95"><path d="M-10 80 C30 30 70 110 90 55 S120 5 195 10"/><path d="M-10 94 C30 44 70 124 90 69 S120 19 195 24"/></svg></div></div>
|
||||
<div class="sidebar-bottom">
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="account"><span class="avatar">{{ current_user.display_name[:1]|upper }}</span><div><strong>{{ current_user.display_name }}</strong><small>Your running space</small></div></div>
|
||||
<form method="post" action="{{ url_for('web.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button logout">Sign out ↗</button></form>
|
||||
{% else %}<span class="muted">Made for the long run.</span>{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
<main>
|
||||
<header class="topbar"><span class="breadcrumb">YOUR SPACE <span>/</span> {% block breadcrumb %}RUNNING{% endblock %}</span><span class="local-badge"><i></i> Your data. Your pace.</span></header>
|
||||
<div class="page">
|
||||
{% for category, message in get_flashed_messages(with_categories=true) %}<div role="status" class="flash {{ category }}">{{ message }}</div>{% endfor %}
|
||||
{% block content %}{% endblock %}
|
||||
<footer class="footer"><span>STRIDE · EVERY KILOMETRE COUNTS</span><span>Built for your own path.</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ 'Overview' if personal else profile.display_name }} · Stride{% endblock %}
|
||||
{% block breadcrumb %}{{ 'OVERVIEW' if personal else 'SHARED PROFILE' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><span class="eyebrow">{{ 'THE BIG PICTURE' if personal else 'SHARED WITH FRIENDS' }}</span><h1>{{ 'Find your stride.' if personal else profile.display_name }}</h1><p>{{ 'A little perspective on every kilometre you’ve put in.' if personal else 'The runs, the progress, and the miles along the way.' }}</p></div>{% if personal %}<a class="button primary" href="{{ url_for('web.settings') }}">+ Import activity</a>{% else %}<span class="pill public">Friends only</span>{% endif %}</div>
|
||||
<div class="stats-grid">
|
||||
<article class="stat-card featured"><div class="stat-label">DISTANCE LOGGED <span>↗</span></div><div class="stat-value">{{ '%.1f'|format(stats.distance) }}<small>km</small></div><p>{{ 'Across all your activities' if personal else 'Across shared activities' }}</p></article>
|
||||
<article class="stat-card"><div class="stat-label">ACTIVITIES <span>◫</span></div><div class="stat-value">{{ stats.count }}</div><p>One step, then another</p></article>
|
||||
<article class="stat-card"><div class="stat-label">TIME ON YOUR FEET <span>◷</span></div><div class="stat-value time-value">{{ stats.duration|duration }}</div><p>Total recorded timer time</p></article>
|
||||
<article class="stat-card"><div class="stat-label">AVERAGE PACE <span>↝</span></div><div class="stat-value">{{ stats.pace }}<small>/km</small></div><p>Weighted by distance</p></article>
|
||||
</div>
|
||||
<div class="overview-grid">
|
||||
<section class="panel volume-panel"><div class="section-head"><div><span class="eyebrow">CONSISTENCY ADDS UP</span><h2>Your miles, over time.</h2></div><span class="subtle-label">8 weeks to latest activity</span></div><div class="chart-wrap"><canvas id="volume-chart" aria-label="Weekly distance in kilometres" role="img"></canvas></div><script id="volume-data" type="application/json">{{ {'labels': stats.weeks, 'values': stats.weekly_km}|tojson }}</script></section>
|
||||
<section class="milestone-card"><span class="eyebrow">THE VIEW FROM UP HERE</span><div class="mountain" aria-hidden="true"><svg viewBox="0 0 300 120"><path d="M-20 120 L85 36 L123 76 L177 9 L310 120"/><path d="M10 120 L90 65 L123 101 L177 38 L290 120"/><circle cx="239" cy="28" r="16"/></svg></div><div class="milestone-number">{{ '{:,.0f}'.format(stats.elevation) }}<small>m</small></div><h3>Elevation earned.</h3><p>Every climb is part of the story.</p></section>
|
||||
</div>
|
||||
<section class="panel activities-panel"><div class="section-head"><div><span class="eyebrow">THE WORK YOU’VE PUT IN</span><h2>{{ 'Your activities' if personal else 'Shared activities' }}</h2></div><span class="subtle-label">{{ rows|length }} activities</span></div>{% include '_activities.html' %}</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='dashboard.js') }}"></script>{% endblock %}
|
||||
@@ -0,0 +1,2 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}<div class="empty-state"><span class="eyebrow">{{ code }}</span><h1>{{ 'Nothing to see here.' if code == 404 else 'Let’s try that again.' }}</h1><p>{{ 'This page is private or doesn’t exist.' if code == 404 else 'Too many attempts. Please wait a little.' if code == 429 else 'That upload is too large. The limit is 16 MB.' if code == 413 else 'The request could not be completed. Refresh the page and try again.' }}</p><a class="button primary" href="{{ url_for('web.index') }}">Back to Stride ↗</a></div>{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Friends · Stride{% endblock %}
|
||||
{% block breadcrumb %}FRIENDS{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><span class="eyebrow">YOUR PEOPLE, YOUR PACE</span><h1>Better with friends.</h1><p>Only accepted friends can see the activities you share.</p></div></div>
|
||||
<div class="settings-grid friends-top">
|
||||
<section class="panel settings-card"><span class="eyebrow">INVITE SOMEONE YOU KNOW</span><h2>Add a friend</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Their Stride email<input type="email" name="email" required maxlength="254" placeholder="friend@example.com"></label><p class="field-help">They’ll receive a request inside Stride. Both of you must agree before shared activities become visible. No email is sent.</p><button class="button primary">Send friend request ↗</button></form></section>
|
||||
<section class="panel settings-card"><span class="eyebrow">YOUR CIRCLE</span><h2>Connections</h2>
|
||||
{% for entry in people %}<div class="friend-row"><div><strong>{% if entry.link.status == 'accepted' %}<a href="{{ url_for('web.profile', username=entry.person.username) }}">{{ entry.person.display_name }} ↗</a>{% else %}{{ entry.person.display_name }}{% endif %}</strong><small class="muted">{{ 'Friends' if entry.link.status == 'accepted' else 'Request sent' if entry.link.requested_by == current_user.id else 'Wants to be your friend' }}</small></div><div class="friend-actions">
|
||||
{% if entry.link.status == 'pending' and entry.link.requested_by != current_user.id %}<form method="post" action="{{ url_for('web.friendship_action', friendship_id=entry.link.id, action='accept') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button primary">Accept</button></form>{% endif %}
|
||||
<form method="post" action="{{ url_for('web.friendship_action', friendship_id=entry.link.id, action='remove') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button">{{ 'Remove' if entry.link.status == 'accepted' else 'Cancel' if entry.link.requested_by == current_user.id else 'Decline' }}</button></form></div></div>{% else %}<p class="muted">Your circle starts here. Add a friend using the email they registered with.</p>{% endfor %}
|
||||
</section></div>
|
||||
<section class="panel activities-panel"><div class="section-head"><div><span class="eyebrow">MILES FROM YOUR CIRCLE</span><h2>Friends’ activities</h2></div><span class="subtle-label">Shared with you</span></div>{% include '_activities.html' %}</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Settings & imports · Stride{% endblock %}
|
||||
{% block breadcrumb %}SETTINGS & IMPORTS{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><span class="eyebrow">MAKE IT YOUR OWN</span><h1>Your space, your way.</h1><p>Manage your profile and bring your activities home.</p></div></div>
|
||||
<div class="settings-grid"><section class="panel settings-card"><span class="eyebrow">THE PERSON BEHIND THE MILES</span><h2>Your profile</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Display name<input name="display_name" value="{{ current_user.display_name }}" required maxlength="80"></label><label>Time zone<input name="timezone" value="{{ current_user.timezone }}" list="timezones" required><datalist id="timezones"><option>Australia/Sydney</option><option>Australia/Melbourne</option><option>Australia/Brisbane</option><option>UTC</option><option>Europe/London</option><option>America/New_York</option></datalist></label><p class="field-help">Your <a href="{{ url_for('web.profile',username=current_user.username) }}">shared profile</a> is visible only to accepted friends and only includes activities you share.</p><button class="button primary">Save profile ↗</button></form></section>
|
||||
<section class="panel settings-card"><span class="eyebrow">BRING YOUR RUNS WITH YOU</span><h2>Import a FIT file</h2><p class="muted">Upload an original activity exported from your watch or Garmin Connect.</p><form action="{{ url_for('web.upload') }}" method="post" enctype="multipart/form-data" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label class="upload-zone"><span class="upload-icon">↑</span><strong>Choose an activity file</strong><small>FIT format · up to 16 MB · imported privately</small><input type="file" name="file" accept=".fit" required></label><button class="button primary">Import activity ↗</button></form></section>
|
||||
<section class="panel settings-card connection-card"><div><span class="eyebrow">GARMIN CONNECT</span><h2>Your local sync</h2><p class="muted">{% if current_user.local_account_key %}Your account is linked to the activities downloaded by your local Garmin sync command.{% else %}You can import FIT files now. Web-based Garmin token connections are coming in the next development stage.{% endif %}</p></div>{% if current_user.local_account_key %}<div class="connection-actions"><span class="pill public">Local imports linked</span><p class="field-help">After running the sync command, load any new downloads here. This button reads local files; it does not contact Garmin.</p><form method="post" action="{{ url_for('web.import_local') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button secondary">Load new downloads ↻</button></form></div>{% endif %}</section></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Stride local application
|
||||
|
||||
Updated: 2026-10-01
|
||||
|
||||
## Delivered
|
||||
|
||||
- Flask application factory and Jinja UI; local Waitress server on loopback port 5000.
|
||||
- SQLite for local development, PostgreSQL driver/configuration, Alembic migrations.
|
||||
- Name/email/password signup, generated profile identifier, session login/logout.
|
||||
- CSRF checks, password hashing, login rate limits and no-store responses.
|
||||
- Local first-account setup imports the existing Garmin cache privately.
|
||||
- FIT upload, owner-specific cached imports, checksummed sync files and deduplication
|
||||
by source identity. Cross-source file-versus-Garmin reconciliation remains a follow-up.
|
||||
- Overview with distance/time/pace totals and weekly volume; activity pages with
|
||||
route maps, pace/heart-rate charts and recorded laps.
|
||||
- Profile editing and time-zone selection.
|
||||
- Activity sharing choices: private or accepted friends. No public activity access.
|
||||
- Friend requests by exact email, recipient acceptance, decline/cancel/remove,
|
||||
friends feed and friendship-gated profiles. Full routes shown without masking.
|
||||
- Local JavaScript assets with upstream licenses; OpenStreetMap tiles are fetched
|
||||
by the browser with attribution. Select an appropriate tile service for deployment.
|
||||
|
||||
## Validation
|
||||
|
||||
- Offline sync and application tests cover FIT decoding, original downloads,
|
||||
retries, credential redaction, ownership, private/friend visibility, CSRF,
|
||||
friend acceptance/removal and preservation of full start/finish coordinates.
|
||||
- Real local Garmin files imported into a temporary in-memory database successfully.
|
||||
- Schema migrations applied to local SQLite.
|
||||
- Browser checks use an isolated synthetic fixture: overview chart and route map
|
||||
render; the real application remains ready for the user to create their account.
|
||||
|
||||
## Next increments
|
||||
|
||||
1. Web token upload with encrypted per-user sessions and connection validation.
|
||||
2. Background imports, retries, refresh locking and scheduled Garmin polling.
|
||||
3. Email verification and account recovery before broader hosted signup.
|
||||
4. PostgreSQL integration checks and Dokku Docker deployment/backup process.
|
||||
5. Deletion/export controls, pagination at larger activity counts and moderation.
|
||||
|
||||
Current local development choices are not a claim of production readiness.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Local sync validation
|
||||
|
||||
Date: 2026-09-30
|
||||
|
||||
## Implemented and verified offline
|
||||
|
||||
- Python 3.12.12 virtual environment created locally with uv.
|
||||
- Garmin client pinned to 0.3.17; complete dependency resolution in uv.lock.
|
||||
- Interactive login/MFA, token import, password-free session verification, and sync CLI.
|
||||
- Bounded recent activity pagination; all activities or running filter.
|
||||
- Original download preservation, CRC-checked FIT decoding, sessions/laps/records JSON.
|
||||
- Account-partitioned storage, profile locks, atomic files, checksums and repeat-run skipping.
|
||||
- Errors suppress raw provider responses and credential-bearing exception text.
|
||||
- Automated tests cover synthetic FIT decoding/CRC, archives, pagination, retries,
|
||||
account separation, credential persistence, and secret redaction.
|
||||
|
||||
## Live account checks
|
||||
|
||||
- [x] Owner completes local login and MFA where required.
|
||||
- [x] Saved session verifies in a new process without a password.
|
||||
- [x] At least one completed activity is downloaded from Garmin.
|
||||
- [x] FIT decoding reports recorded samples (or explains a valid activity without samples).
|
||||
- [ ] Repeating the same sync skips completed activities.
|
||||
- [ ] Exported session is validated on a separate Linux/Dokku runtime.
|
||||
- [ ] A real refresh/reconnect lifecycle is observed.
|
||||
|
||||
User-confirmed live result: 5 found, 5 downloaded, 0 failed, 10,599 recorded
|
||||
samples. Login and sync ran as separate CLI processes. Local Flask importer also
|
||||
successfully read these files into a temporary database, with repeat imports
|
||||
producing no duplicates. Linux/Dokku portability and live refresh remain unverified.
|
||||
@@ -0,0 +1 @@
|
||||
"""Local, read-only Garmin sync; reusable independently of the eventual web UI."""
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Run with `uv run python -m garmin_sync --help`. Never accepts passwords as flags."""
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
from filelock import FileLock, Timeout
|
||||
from garminconnect import (
|
||||
Garmin,
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
|
||||
from .service import (
|
||||
SyncError,
|
||||
account_key,
|
||||
atomic_write,
|
||||
private_directory,
|
||||
sync_activities,
|
||||
write_json,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def parser():
|
||||
cli = argparse.ArgumentParser(description="Read-only Garmin completed-activity sync.")
|
||||
cli.add_argument("--profile", default="default", help="Local account slot (default: default)")
|
||||
cli.add_argument("--domain", choices=["garmin.com", "garmin.cn"], default="garmin.com")
|
||||
commands = cli.add_subparsers(dest="command", required=True)
|
||||
commands.add_parser("login", help="Interactive local Garmin login and MFA; save session")
|
||||
commands.add_parser("verify", help="Validate the saved session without credentials")
|
||||
imported = commands.add_parser("import-tokens", help="Validate and copy a local token export")
|
||||
imported.add_argument("file", type=Path)
|
||||
sync = commands.add_parser("sync", help="Download and decode recent completed activities")
|
||||
sync.add_argument("--limit", type=int, default=5, help="Recent activities to examine (1–1000)")
|
||||
sync.add_argument("--type", choices=["all", "running"], default="all")
|
||||
return cli
|
||||
|
||||
|
||||
def run(args):
|
||||
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", args.profile):
|
||||
raise SyncError("Profile must be 1–64 letters, numbers, underscores or hyphens.")
|
||||
if args.command == "sync" and not 1 <= args.limit <= 1000:
|
||||
raise SyncError("Limit must be between 1 and 1000.")
|
||||
# An inherited global token store must never select another account.
|
||||
os.environ.pop("GARMINTOKENS", None)
|
||||
store = ROOT / "instance" / "garmin" / args.profile
|
||||
private_directory(store)
|
||||
token_path = store / "garmin_tokens.json"
|
||||
with FileLock(str(store / ".session.lock"), timeout=0):
|
||||
api = Garmin(is_cn=args.domain == "garmin.cn")
|
||||
if args.command == "login":
|
||||
if not sys.stdin.isatty():
|
||||
raise SyncError(
|
||||
"Run login in your own interactive terminal; do not paste credentials into chat."
|
||||
)
|
||||
email = input("Garmin email: ").strip()
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", getpass.GetPassWarning)
|
||||
password = getpass.getpass("Garmin password (hidden): ")
|
||||
api = Garmin(
|
||||
email=email,
|
||||
password=password,
|
||||
is_cn=args.domain == "garmin.cn",
|
||||
prompt_mfa=lambda: getpass.getpass("Garmin MFA code (hidden): "),
|
||||
)
|
||||
password = None
|
||||
print("Authenticating with Garmin...", flush=True)
|
||||
api.login() # Deliberately do not reuse a previous account's token file.
|
||||
elif args.command == "import-tokens":
|
||||
if args.file.stat().st_size > 65536:
|
||||
raise SyncError("Token file is too large.")
|
||||
payload = json.loads(args.file.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(payload, dict) or not all(
|
||||
isinstance(payload.get(key), str) and payload[key]
|
||||
for key in ("di_token", "di_refresh_token", "di_client_id")
|
||||
):
|
||||
raise SyncError("Unsupported token format; export with garminconnect 0.3.17.")
|
||||
api.login(
|
||||
json.dumps(
|
||||
{key: payload[key] for key in ("di_token", "di_refresh_token", "di_client_id")}
|
||||
)
|
||||
)
|
||||
else:
|
||||
if not token_path.is_file():
|
||||
raise SyncError("No saved session. Run login or import-tokens first.")
|
||||
api.login(str(token_path))
|
||||
|
||||
key = account_key(api, args.domain)
|
||||
# Writes refreshed state for inline imports/login, plus every successful resume.
|
||||
atomic_write(token_path, api.client.dumps().encode())
|
||||
print(f"Garmin session verified. Local account: {key}", flush=True)
|
||||
if args.command != "sync":
|
||||
print(f"Session saved privately in instance/garmin/{args.profile}/garmin_tokens.json")
|
||||
return 0
|
||||
destination = ROOT / "data" / "garmin" / key
|
||||
try:
|
||||
result = sync_activities(
|
||||
api, destination, args.limit, None if args.type == "all" else args.type
|
||||
)
|
||||
finally:
|
||||
# Preserve rotations even if a later download fails.
|
||||
atomic_write(token_path, api.client.dumps().encode())
|
||||
write_json(destination / "last-sync.json", result)
|
||||
print(json.dumps(result, indent=2))
|
||||
print(f"Private activity files: {destination}")
|
||||
return 1 if result["failed"] else 0
|
||||
|
||||
|
||||
def main():
|
||||
# Third-party exception/log strings can contain credentials and request URLs.
|
||||
logging.disable(logging.CRITICAL)
|
||||
args = parser().parse_args()
|
||||
try:
|
||||
return run(args)
|
||||
except SyncError as error:
|
||||
print(f"Error: {error}", file=sys.stderr)
|
||||
except GarminConnectTooManyRequestsError:
|
||||
print("Garmin rate limit reached. Wait before retrying.", file=sys.stderr)
|
||||
except GarminConnectAuthenticationError:
|
||||
print("Garmin rejected the session/login. Run login locally to reconnect.", file=sys.stderr)
|
||||
except GarminConnectConnectionError:
|
||||
print("Garmin connection failed. Retry later; no credentials were logged.", file=sys.stderr)
|
||||
except Timeout:
|
||||
print("Another command is using this profile. Wait for it to finish.", file=sys.stderr)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("Cancelled.", file=sys.stderr)
|
||||
except Exception as error: # noqa: BLE001 -- CLI boundary must not expose provider secrets.
|
||||
print(
|
||||
f"Operation failed ({type(error).__name__}); details suppressed to protect credentials.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Download completed activities, preserving originals and decoded FIT data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from datetime import UTC, date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import fitdecode
|
||||
from garminconnect import Garmin
|
||||
|
||||
MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
class SyncError(Exception):
|
||||
"""An actionable error with no provider response or credentials attached."""
|
||||
|
||||
|
||||
def private_directory(path: Path) -> None:
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if os.name != "nt":
|
||||
path.chmod(0o700)
|
||||
|
||||
|
||||
def atomic_write(path: Path, content: bytes) -> None:
|
||||
private_directory(path.parent)
|
||||
fd, name = tempfile.mkstemp(prefix=".pending-", dir=path.parent)
|
||||
temporary = Path(name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def json_default(value):
|
||||
if isinstance(value, datetime):
|
||||
# FIT timestamps are UTC; fitdecode returns naive datetime objects.
|
||||
return value.replace(tzinfo=UTC).isoformat() if value.tzinfo is None else value.isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, bytes):
|
||||
return value.hex()
|
||||
raise TypeError(f"Unsupported JSON type: {type(value).__name__}")
|
||||
|
||||
|
||||
def write_json(path: Path, value) -> None:
|
||||
atomic_write(path, json.dumps(value, indent=2, default=json_default).encode("utf-8"))
|
||||
|
||||
|
||||
def fit_payloads(payload: bytes) -> list[bytes]:
|
||||
"""Read bounded FIT members without extracting archive paths to disk."""
|
||||
if not payload or len(payload) > MAX_BYTES:
|
||||
raise SyncError("Download was empty or exceeded the 64 MiB limit.")
|
||||
if len(payload) >= 12 and payload[8:12] == b".FIT":
|
||||
return [payload]
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
||||
members = [m for m in archive.infolist() if m.filename.lower().endswith(".fit")]
|
||||
if not members:
|
||||
raise SyncError(
|
||||
"Original download contains no FIT file (possibly a manual activity)."
|
||||
)
|
||||
if len(members) > 16 or sum(m.file_size for m in members) > MAX_BYTES:
|
||||
raise SyncError("FIT archive exceeds the extraction limit.")
|
||||
result = []
|
||||
for member in members:
|
||||
with archive.open(member) as stream:
|
||||
content = stream.read(MAX_BYTES + 1)
|
||||
if len(content) > MAX_BYTES:
|
||||
raise SyncError("FIT archive exceeds the extraction limit.")
|
||||
result.append(content)
|
||||
return result
|
||||
except (zipfile.BadZipFile, RuntimeError, NotImplementedError):
|
||||
raise SyncError("Original download is not a readable FIT or ZIP file.") from None
|
||||
|
||||
|
||||
def decode_fit(content: bytes) -> dict:
|
||||
counts = Counter()
|
||||
messages = {"session": [], "lap": [], "record": []}
|
||||
units = {}
|
||||
try:
|
||||
with fitdecode.FitReader(io.BytesIO(content), check_crc=fitdecode.CrcCheck.RAISE) as reader:
|
||||
for frame in reader:
|
||||
if not isinstance(frame, fitdecode.FitDataMessage):
|
||||
continue
|
||||
counts[frame.name] += 1
|
||||
if frame.name in messages:
|
||||
values = {}
|
||||
for field in frame.fields:
|
||||
values[field.name] = field.value
|
||||
if field.units:
|
||||
units[f"{frame.name}.{field.name}"] = field.units
|
||||
messages[frame.name].append(values)
|
||||
except (fitdecode.FitError, ValueError, TypeError, KeyError, OverflowError):
|
||||
raise SyncError("FIT decoding or CRC validation failed.") from None
|
||||
if not counts:
|
||||
raise SyncError("FIT file contains no data messages.")
|
||||
return {"message_counts": dict(counts), "units": units, **messages}
|
||||
|
||||
|
||||
def account_key(api: Garmin, domain: str) -> str:
|
||||
# Loaded by Garmin.login from the provider, never taken from an uploaded label.
|
||||
profile_id = getattr(api, "profile_id", None)
|
||||
identity = f"id:{profile_id}" if isinstance(profile_id, int) else api.display_name
|
||||
if not isinstance(identity, str) or not identity:
|
||||
raise SyncError("Garmin did not return an account identity.")
|
||||
return hashlib.sha256(f"{domain}:{identity}".encode()).hexdigest()[:24]
|
||||
|
||||
|
||||
def complete(directory: Path) -> bool:
|
||||
try:
|
||||
manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8"))
|
||||
files = manifest["files"]
|
||||
if manifest.get("version") != 1 or not files:
|
||||
return False
|
||||
for name, digest in files.items():
|
||||
if Path(name).name != name or "/" in name or "\\" in name:
|
||||
return False
|
||||
if hashlib.sha256((directory / name).read_bytes()).hexdigest() != digest:
|
||||
return False
|
||||
return True
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def save_activity(api: Garmin, metadata: dict, root: Path) -> tuple[str, int]:
|
||||
raw_id = metadata.get("activityId")
|
||||
if isinstance(raw_id, bool) or not str(raw_id).isascii() or not str(raw_id).isdigit():
|
||||
raise SyncError("Garmin returned an invalid activity ID.")
|
||||
activity_id = str(int(raw_id))
|
||||
if int(activity_id) <= 0:
|
||||
raise SyncError("Garmin returned an invalid activity ID.")
|
||||
directory = root / activity_id
|
||||
if complete(directory):
|
||||
return "skipped", 0
|
||||
payload = api.download_activity(activity_id, dl_fmt=Garmin.ActivityDownloadFormat.ORIGINAL)
|
||||
fits = fit_payloads(payload)
|
||||
decoded = [decode_fit(content) for content in fits]
|
||||
# Parse first; write a manifest last so incomplete imports are retried.
|
||||
originals_name = "original.fit" if payload[8:12] == b".FIT" else "original.zip"
|
||||
files = {originals_name: payload}
|
||||
files["activity.json"] = json.dumps(metadata, indent=2, default=json_default).encode()
|
||||
for index, (content, parsed) in enumerate(zip(fits, decoded, strict=True), start=1):
|
||||
files[f"activity-{index}.fit"] = content
|
||||
files[f"activity-{index}.json"] = json.dumps(
|
||||
parsed, indent=2, default=json_default
|
||||
).encode()
|
||||
for name, content in files.items():
|
||||
atomic_write(directory / name, content)
|
||||
records = sum(item["message_counts"].get("record", 0) for item in decoded)
|
||||
write_json(
|
||||
directory / "manifest.json",
|
||||
{
|
||||
"version": 1,
|
||||
"activity_id": activity_id,
|
||||
"imported_at": datetime.now(UTC),
|
||||
"fit_files": len(fits),
|
||||
"record_count": records,
|
||||
"files": {name: hashlib.sha256(content).hexdigest() for name, content in files.items()},
|
||||
},
|
||||
)
|
||||
return "downloaded", records
|
||||
|
||||
|
||||
def sync_activities(api: Garmin, root: Path, limit: int, activity_type: str | None) -> dict:
|
||||
if not 1 <= limit <= 1000:
|
||||
raise SyncError("Limit must be between 1 and 1000.")
|
||||
result = {"found": 0, "downloaded": 0, "skipped": 0, "failed": 0, "records": 0, "errors": []}
|
||||
offset = 0
|
||||
while offset < limit:
|
||||
page_size = min(50, limit - offset)
|
||||
page = api.get_activities(start=offset, limit=page_size, activitytype=activity_type)
|
||||
if not isinstance(page, list):
|
||||
raise SyncError("Garmin returned an unexpected activity list.")
|
||||
if not page:
|
||||
break
|
||||
for metadata in page[:page_size]:
|
||||
result["found"] += 1
|
||||
try:
|
||||
if not isinstance(metadata, dict):
|
||||
raise SyncError("Garmin returned invalid activity metadata.")
|
||||
state, records = save_activity(api, metadata, root)
|
||||
result[state] += 1
|
||||
result["records"] += records
|
||||
except SyncError as error:
|
||||
result["failed"] += 1
|
||||
result["errors"].append(str(error))
|
||||
offset += len(page)
|
||||
if len(page) < page_size:
|
||||
break
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
||||
@@ -0,0 +1,50 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,105 @@
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from flask import current_app
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger("alembic.env")
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||
return current_app.extensions["migrate"].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# this works with Flask-SQLAlchemy>=3
|
||||
return current_app.extensions["migrate"].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace("%", "%%")
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace("%", "%%")
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option("sqlalchemy.url", get_engine_url())
|
||||
target_db = current_app.extensions["migrate"].db
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, "metadatas"):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=get_metadata(), literal_binds=True)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, "autogenerate", False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info("No changes in schema detected.")
|
||||
|
||||
conf_args = current_app.extensions["migrate"].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=get_metadata(), **conf_args)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Restrict sharing to accepted friends
|
||||
|
||||
Revision ID: 342a45f4132a
|
||||
Revises: 3a2896986e0e
|
||||
Create Date: 2026-10-01 14:50:51.207109
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "342a45f4132a"
|
||||
down_revision = "3a2896986e0e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("UPDATE activity SET visibility = 'friends' WHERE visibility = 'public'")
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"friendship",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("low_id", sa.Integer(), nullable=False),
|
||||
sa.Column("high_id", sa.Integer(), nullable=False),
|
||||
sa.Column("requested_by", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(length=12), nullable=False),
|
||||
sa.CheckConstraint("low_id < high_id"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["high_id"],
|
||||
["user.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["low_id"],
|
||||
["user.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["requested_by"],
|
||||
["user.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("low_id", "high_id"),
|
||||
)
|
||||
with op.batch_alter_table("activity", schema=None) as batch_op:
|
||||
batch_op.drop_column("share_route")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Never restore public visibility as an incidental effect of a rollback.
|
||||
op.execute("UPDATE activity SET visibility = 'private' WHERE visibility = 'friends'")
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("activity", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("share_route", sa.BOOLEAN(), nullable=False, server_default=sa.false())
|
||||
)
|
||||
|
||||
op.drop_table("friendship")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Create users and activities
|
||||
|
||||
Revision ID: 3a2896986e0e
|
||||
Revises:
|
||||
Create Date: 2026-09-30 21:11:01.589912
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "3a2896986e0e"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"user",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("username", sa.String(length=40), nullable=False),
|
||||
sa.Column("email", sa.String(length=254), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=80), nullable=False),
|
||||
sa.Column("password_hash", sa.String(length=256), nullable=False),
|
||||
sa.Column("timezone", sa.String(length=64), nullable=False),
|
||||
sa.Column("local_account_key", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("email"),
|
||||
sa.UniqueConstraint("local_account_key"),
|
||||
sa.UniqueConstraint("username"),
|
||||
)
|
||||
op.create_table(
|
||||
"activity",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("source_key", sa.String(length=180), nullable=False),
|
||||
sa.Column("title", sa.String(length=160), nullable=False),
|
||||
sa.Column("sport", sa.String(length=40), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("distance_m", sa.Float(), nullable=False),
|
||||
sa.Column("duration_s", sa.Float(), nullable=False),
|
||||
sa.Column("elevation_m", sa.Float(), nullable=False),
|
||||
sa.Column("avg_hr", sa.Float(), nullable=True),
|
||||
sa.Column("visibility", sa.String(length=12), nullable=False),
|
||||
sa.Column("share_route", sa.Boolean(), nullable=False),
|
||||
sa.Column("samples", sa.JSON(), nullable=False),
|
||||
sa.Column("laps", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["owner_id"],
|
||||
["user.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("owner_id", "source_key"),
|
||||
)
|
||||
with op.batch_alter_table("activity", schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f("ix_activity_owner_id"), ["owner_id"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_activity_started_at"), ["started_at"], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("activity", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_activity_started_at"))
|
||||
batch_op.drop_index(batch_op.f("ix_activity_owner_id"))
|
||||
|
||||
op.drop_table("activity")
|
||||
op.drop_table("user")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,31 @@
|
||||
[project]
|
||||
name = "running-dashboard"
|
||||
version = "0.1.0"
|
||||
description = "Local Garmin activity sync for a self-hosted running dashboard"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
dependencies = [
|
||||
"garminconnect==0.3.17",
|
||||
"curl-cffi>=0.13,<1",
|
||||
"fitdecode==0.11.0",
|
||||
"filelock>=3.20,<4",
|
||||
"flask>=3.1.3",
|
||||
"flask-sqlalchemy>=3.1.1",
|
||||
"flask-migrate>=4.1.0",
|
||||
"flask-login>=0.6.3",
|
||||
"flask-wtf>=1.3.0",
|
||||
"flask-limiter>=4.1.1",
|
||||
"psycopg[binary]>=3.3.6",
|
||||
"waitress>=3.0.2",
|
||||
"gunicorn>=23,<26; sys_platform != 'win32'",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8,<10", "ruff>=0.14,<1"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Local server: uv run python run.py (no debug mode; loopback only)."""
|
||||
|
||||
from waitress import serve
|
||||
|
||||
from app import create_app
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Stride is ready at http://127.0.0.1:5000", flush=True)
|
||||
serve(create_app(), host="127.0.0.1", port=5000)
|
||||
@@ -0,0 +1,6 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location -LiteralPath (Split-Path -Parent $PSScriptRoot)
|
||||
& uv run --locked python -m garmin_sync login
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
& uv run --locked python -m garmin_sync sync --limit 5
|
||||
exit $LASTEXITCODE
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Disposable UI fixture on port 5001. Synthetic friend-shared data; never reads Garmin files."""
|
||||
|
||||
import math
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from waitress import serve
|
||||
|
||||
from app import create_app
|
||||
from app.extensions import db
|
||||
from app.models import Activity, User
|
||||
|
||||
app = create_app(
|
||||
{
|
||||
"SQLALCHEMY_DATABASE_URI": "sqlite://",
|
||||
"SECRET_KEY": "disposable-ui-fixture",
|
||||
"LOCAL_SETUP": False,
|
||||
"RATELIMIT_ENABLED": False,
|
||||
}
|
||||
)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
user = User(
|
||||
username="preview",
|
||||
display_name="Preview Runner",
|
||||
email="preview@example.test",
|
||||
password_hash="disabled",
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
for i in range(5):
|
||||
samples = [
|
||||
{
|
||||
"point": [51.507 + 0.007 * math.sin(n / 70), -0.14 + n / 25000],
|
||||
"distance": n * 20,
|
||||
"speed": 2.8 + 0.3 * math.sin(n / 20),
|
||||
"heart_rate": 140 + 12 * math.sin(n / 30),
|
||||
"elevation": 10 + n / 30,
|
||||
}
|
||||
for n in range(300)
|
||||
]
|
||||
db.session.add(
|
||||
Activity(
|
||||
owner_id=user.id,
|
||||
source_key=f"fixture:{i}",
|
||||
title=[
|
||||
"Morning miles",
|
||||
"A little further",
|
||||
"Easy Sunday",
|
||||
"Tempo session",
|
||||
"Evening loop",
|
||||
][i],
|
||||
started_at=datetime.now(UTC) - timedelta(days=i * 5),
|
||||
distance_m=6000 + i * 700,
|
||||
duration_s=2100 + i * 200,
|
||||
elevation_m=38 + i * 7,
|
||||
avg_hr=145,
|
||||
sport="running",
|
||||
visibility="friends",
|
||||
samples=samples,
|
||||
laps=[{"distance": 1000, "duration": 350, "pace": 350, "heart_rate": 145}] * 6,
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
# Test-only auto-login, isolated to this ephemeral process/database.
|
||||
from flask_login import login_user
|
||||
|
||||
|
||||
@app.before_request
|
||||
def preview_login():
|
||||
login_user(db.session.get(User, 1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(app, host="127.0.0.1", port=5001)
|
||||
@@ -0,0 +1,8 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location -LiteralPath (Split-Path -Parent $PSScriptRoot)
|
||||
& uv sync --locked
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
& uv run --locked flask --app app db upgrade
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
& uv run --locked python run.py
|
||||
exit $LASTEXITCODE
|
||||
@@ -0,0 +1,225 @@
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import create_app
|
||||
from app.extensions import db
|
||||
from app.models import Activity, Friendship, User
|
||||
from app.services import activity_data, import_cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(tmp_path):
|
||||
app = create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"SQLALCHEMY_DATABASE_URI": "sqlite://",
|
||||
"SECRET_KEY": "test-only",
|
||||
"WTF_CSRF_ENABLED": False,
|
||||
"RATELIMIT_ENABLED": False,
|
||||
"LOCAL_SETUP": False,
|
||||
"DATA_ROOT": str(tmp_path),
|
||||
}
|
||||
)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
for name in ("runner", "other"):
|
||||
user = User(username=name, email=f"{name}@example.test", display_name=name.title())
|
||||
user.set_password("a-long-test-password")
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
db.session.add(
|
||||
Activity(
|
||||
owner_id=1,
|
||||
source_key="test:1",
|
||||
title="A quiet morning run",
|
||||
sport="running",
|
||||
started_at=datetime(2026, 9, 30, tzinfo=UTC),
|
||||
distance_m=5000,
|
||||
duration_s=1800,
|
||||
elevation_m=40,
|
||||
avg_hr=140,
|
||||
samples=[
|
||||
{
|
||||
"point": [-33, 151],
|
||||
"distance": 0,
|
||||
"heart_rate": 120,
|
||||
"speed": 3,
|
||||
"elevation": 10,
|
||||
}
|
||||
],
|
||||
laps=[{"distance": 1000, "duration": 360, "heart_rate": 140, "pace": 360}],
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
yield app
|
||||
|
||||
|
||||
def login(client, name="runner"):
|
||||
return client.post(
|
||||
"/login", data={"email": f"{name}@example.test", "password": "a-long-test-password"}
|
||||
)
|
||||
|
||||
|
||||
def test_private_activity_authorization_for_pages_json_and_mutations(app):
|
||||
client = app.test_client()
|
||||
assert client.get("/activities/1").status_code == 404
|
||||
assert client.get("/activities/1/data").status_code == 404
|
||||
login(client, "other")
|
||||
assert client.get("/activities/1").status_code == 404
|
||||
assert client.get("/activities/1/data").status_code == 404
|
||||
assert client.post("/activities/1/edit", data={"title": "stolen"}).status_code == 404
|
||||
assert client.post("/imports/cache").status_code == 404
|
||||
assert b"A quiet morning run" not in client.get("/dashboard").data
|
||||
|
||||
|
||||
def test_owner_dashboard_activity_json_and_settings_render(app):
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
for path in ("/dashboard", "/activities/1", "/settings"):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 200
|
||||
assert b"Stride" in response.data
|
||||
data = client.get("/activities/1/data").json
|
||||
assert data["segments"] == [[[-33, 151]]]
|
||||
assert "point" not in data["chart"][0]
|
||||
|
||||
|
||||
def test_friend_access_requires_acceptance_and_revokes_immediately(app):
|
||||
owner = app.test_client()
|
||||
friend = app.test_client()
|
||||
anonymous = app.test_client()
|
||||
login(owner)
|
||||
login(friend, "other")
|
||||
owner.post("/activities/1/edit", data={"title": "Shared morning run", "visibility": "friends"})
|
||||
assert anonymous.get("/activities/1").status_code == 404
|
||||
assert anonymous.get("/activities/1/data").status_code == 404
|
||||
assert anonymous.get("/explore").status_code == 404
|
||||
assert friend.get("/activities/1").status_code == 404
|
||||
assert friend.get("/u/runner").status_code == 404
|
||||
owner.post("/friends", data={"email": "other@example.test"})
|
||||
assert friend.get("/activities/1/data").status_code == 404
|
||||
assert owner.post("/friends/1/accept").status_code == 400
|
||||
assert friend.post("/friends/1/accept").status_code == 302
|
||||
assert b"Shared morning run" in friend.get("/friends").data
|
||||
assert b"Shared morning run" in friend.get("/u/runner").data
|
||||
data = friend.get("/activities/1/data")
|
||||
assert data.status_code == 200
|
||||
assert data.json["segments"] == [[[-33, 151]]]
|
||||
assert "no-store" in data.headers["Cache-Control"]
|
||||
assert friend.post("/activities/1/edit", data={"title": "changed"}).status_code == 404
|
||||
friend.post("/friends/1/remove")
|
||||
assert friend.get("/activities/1/data").status_code == 404
|
||||
assert friend.get("/u/runner").status_code == 404
|
||||
assert b"Shared morning run" not in friend.get("/friends").data
|
||||
|
||||
|
||||
def test_private_activity_is_hidden_even_from_accepted_friend(app):
|
||||
with app.app_context():
|
||||
db.session.add(Friendship(low_id=1, high_id=2, requested_by=1, status="accepted"))
|
||||
db.session.commit()
|
||||
friend = app.test_client()
|
||||
login(friend, "other")
|
||||
assert friend.get("/activities/1").status_code == 404
|
||||
assert friend.get("/activities/1/data").status_code == 404
|
||||
assert b"A quiet morning run" not in friend.get("/u/runner").data
|
||||
|
||||
|
||||
def test_full_route_includes_start_and_finish():
|
||||
points = [[0, 0], [0, 0.001], [0, 0.01], [0, 0.02]]
|
||||
row = Activity(samples=[{"point": point} for point in points])
|
||||
assert activity_data(row)["segments"] == [points]
|
||||
|
||||
|
||||
def test_register_creates_separate_user_with_no_local_account(app):
|
||||
client = app.test_client()
|
||||
response = client.post(
|
||||
"/register",
|
||||
data={
|
||||
"username": "newrunner",
|
||||
"email": "new@example.test",
|
||||
"display_name": "New runner",
|
||||
"password": "a-long-safe-password",
|
||||
"local_account_key": "forged",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
with app.app_context():
|
||||
user = db.session.scalar(db.select(User).filter_by(email="new@example.test"))
|
||||
assert user.local_account_key is None
|
||||
assert user.password_hash != "a-long-safe-password"
|
||||
assert b"A quiet morning run" not in client.get("/dashboard").data
|
||||
|
||||
|
||||
def test_csrf_rejects_state_changes(app):
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
client = app.test_client()
|
||||
assert client.post("/login", data={"email": "runner@example.test"}).status_code == 400
|
||||
assert client.post("/imports/fit").status_code == 400
|
||||
|
||||
|
||||
def test_setup_only_on_loopback_and_before_accounts(tmp_path):
|
||||
app = create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"SQLALCHEMY_DATABASE_URI": "sqlite://",
|
||||
"SECRET_KEY": "test",
|
||||
"WTF_CSRF_ENABLED": False,
|
||||
"LOCAL_SETUP": True,
|
||||
"RATELIMIT_ENABLED": False,
|
||||
"DATA_ROOT": str(tmp_path),
|
||||
}
|
||||
)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
client = app.test_client()
|
||||
assert client.get("/setup", environ_base={"REMOTE_ADDR": "192.168.1.5"}).status_code == 404
|
||||
assert client.get("/setup").status_code == 200
|
||||
assert (
|
||||
client.post(
|
||||
"/setup",
|
||||
data={
|
||||
"username": "owner",
|
||||
"display_name": "Owner",
|
||||
"email": "owner@example.test",
|
||||
"password": "a-long-safe-password",
|
||||
},
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
assert client.get("/setup").status_code == 404
|
||||
|
||||
|
||||
def test_real_cache_import_compatibility_without_persisting_user_data(tmp_path):
|
||||
# Optional local check; never embeds the real data in the repository or test output.
|
||||
source = Path(__file__).resolve().parents[1] / "data" / "garmin"
|
||||
dirs = [p for p in source.glob("*") if p.is_dir()]
|
||||
if not dirs:
|
||||
pytest.skip("No local Garmin cache")
|
||||
app = create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"SQLALCHEMY_DATABASE_URI": "sqlite://",
|
||||
"SECRET_KEY": "test",
|
||||
"RATELIMIT_ENABLED": False,
|
||||
}
|
||||
)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
user = User(
|
||||
username="test",
|
||||
display_name="Test",
|
||||
email="test@example.test",
|
||||
password_hash="not-a-login",
|
||||
local_account_key=dirs[0].name,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
count = import_cache(user, source)
|
||||
db.session.commit()
|
||||
assert count > 0
|
||||
assert import_cache(user, source) == 0
|
||||
for row in db.session.scalars(db.select(Activity)):
|
||||
assert row.distance_m >= 0 and row.duration_s >= 0
|
||||
assert row.visibility == "private"
|
||||
@@ -0,0 +1,189 @@
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fitdecode.utils import compute_crc
|
||||
|
||||
from garmin_sync import __main__ as cli
|
||||
from garmin_sync.service import (
|
||||
SyncError,
|
||||
account_key,
|
||||
complete,
|
||||
decode_fit,
|
||||
fit_payloads,
|
||||
sync_activities,
|
||||
)
|
||||
|
||||
|
||||
def synthetic_fit():
|
||||
"""Tiny FIT with one timestamped heart-rate/distance record; no real user data."""
|
||||
# Definition: local message 0, little endian, global record message 20.
|
||||
definition = (
|
||||
bytes([0x40, 0, 0])
|
||||
+ struct.pack("<H", 20)
|
||||
+ bytes(
|
||||
[
|
||||
3,
|
||||
253,
|
||||
4,
|
||||
0x86,
|
||||
3,
|
||||
1,
|
||||
0x02,
|
||||
5,
|
||||
4,
|
||||
0x86,
|
||||
]
|
||||
)
|
||||
)
|
||||
record = bytes([0]) + struct.pack("<IBI", 1_000_000_000, 150, 123456)
|
||||
data = definition + record
|
||||
header = struct.pack("<BBHI4s", 14, 0x20, 2100, len(data), b".FIT")
|
||||
header += struct.pack("<H", compute_crc(header))
|
||||
content = header + data
|
||||
return content + struct.pack("<H", compute_crc(content))
|
||||
|
||||
|
||||
def zipped(content, name="activity.fit"):
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr(name, content)
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
class FakeGarmin:
|
||||
display_name = "synthetic-account"
|
||||
|
||||
def __init__(self, count=1, payload=None):
|
||||
self.activities = [{"activityId": n + 1, "distance": 1234.56} for n in range(count)]
|
||||
self.payload = zipped(synthetic_fit()) if payload is None else payload
|
||||
self.downloads = 0
|
||||
self.requests = []
|
||||
|
||||
def get_activities(self, start, limit, activitytype):
|
||||
self.requests.append((start, limit, activitytype))
|
||||
return self.activities[start : start + limit]
|
||||
|
||||
def download_activity(self, activity_id, dl_fmt):
|
||||
self.downloads += 1
|
||||
return self.payload
|
||||
|
||||
|
||||
def test_decode_real_binary_fit_and_scaled_measurements():
|
||||
result = decode_fit(synthetic_fit())
|
||||
assert result["message_counts"] == {"record": 1}
|
||||
assert result["record"][0]["heart_rate"] == 150
|
||||
assert result["record"][0]["distance"] == 1234.56
|
||||
assert result["units"]["record.distance"] == "m"
|
||||
|
||||
|
||||
def test_bad_crc_rejected():
|
||||
damaged = bytearray(synthetic_fit())
|
||||
damaged[-1] ^= 1
|
||||
with pytest.raises(SyncError, match="CRC"):
|
||||
decode_fit(bytes(damaged))
|
||||
|
||||
|
||||
def test_archive_paths_are_never_extracted(tmp_path):
|
||||
content = synthetic_fit()
|
||||
assert fit_payloads(zipped(content, "../../outside.fit")) == [content]
|
||||
assert not list(tmp_path.iterdir())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload", [b"", b"<html>Unauthorized</html>", zipped(b"text", "notes.txt")]
|
||||
)
|
||||
def test_bad_download_rejected(payload):
|
||||
with pytest.raises(SyncError):
|
||||
fit_payloads(payload)
|
||||
|
||||
|
||||
def test_download_decode_deduplicate_and_repair(tmp_path):
|
||||
api = FakeGarmin()
|
||||
first = sync_activities(api, tmp_path, 5, None)
|
||||
assert first["downloaded"] == 1
|
||||
assert first["records"] == 1
|
||||
assert complete(tmp_path / "1")
|
||||
parsed = json.loads((tmp_path / "1" / "activity-1.json").read_text())
|
||||
assert parsed["record"][0]["timestamp"].endswith("+00:00")
|
||||
assert sync_activities(api, tmp_path, 5, None)["skipped"] == 1
|
||||
assert api.downloads == 1
|
||||
(tmp_path / "1" / "activity-1.fit").write_bytes(b"corrupt")
|
||||
assert sync_activities(api, tmp_path, 5, None)["downloaded"] == 1
|
||||
assert api.downloads == 2
|
||||
|
||||
|
||||
def test_pagination_and_filter(tmp_path):
|
||||
api = FakeGarmin(count=53)
|
||||
result = sync_activities(api, tmp_path, 52, "running")
|
||||
assert result["downloaded"] == 52
|
||||
assert api.requests == [(0, 50, "running"), (50, 2, "running")]
|
||||
|
||||
|
||||
def test_partial_failure_has_no_completed_manifest(tmp_path):
|
||||
result = sync_activities(FakeGarmin(payload=b"bad"), tmp_path, 5, None)
|
||||
assert result["failed"] == 1
|
||||
assert not complete(tmp_path / "1")
|
||||
|
||||
|
||||
def test_invalid_activity_id_cannot_escape_directory(tmp_path):
|
||||
api = FakeGarmin()
|
||||
api.activities = [{"activityId": "../escape"}]
|
||||
assert sync_activities(api, tmp_path, 5, None)["failed"] == 1
|
||||
assert api.downloads == 0
|
||||
|
||||
|
||||
def test_account_isolation_and_stable_provider_id():
|
||||
a = SimpleNamespace(display_name="one", profile_id=1)
|
||||
b = SimpleNamespace(display_name="two", profile_id=2)
|
||||
assert account_key(a, "garmin.com") != account_key(b, "garmin.com")
|
||||
a.display_name = "renamed"
|
||||
assert account_key(a, "garmin.com") == account_key(
|
||||
SimpleNamespace(display_name="original", profile_id=1), "garmin.com"
|
||||
)
|
||||
|
||||
|
||||
def test_missing_session_requires_local_login(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cli, "ROOT", tmp_path)
|
||||
with pytest.raises(SyncError, match="No saved session"):
|
||||
cli.run(cli.parser().parse_args(["sync"]))
|
||||
|
||||
|
||||
def test_profile_cannot_escape(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cli, "ROOT", tmp_path)
|
||||
with pytest.raises(SyncError, match="Profile"):
|
||||
cli.run(cli.parser().parse_args(["--profile", "../outside", "verify"]))
|
||||
|
||||
|
||||
def test_cli_never_prints_raw_provider_exception(monkeypatch, capsys):
|
||||
def fail(_args):
|
||||
raise RuntimeError("SECRET_TOKEN_AND_PASSWORD")
|
||||
|
||||
monkeypatch.setattr(cli, "run", fail)
|
||||
monkeypatch.setattr("sys.argv", ["garmin_sync", "verify"])
|
||||
assert cli.main() == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "SECRET_TOKEN" not in captured.err + captured.out
|
||||
|
||||
|
||||
def test_saved_session_sync_persists_rotated_credentials_even_on_failure(tmp_path, monkeypatch):
|
||||
store = tmp_path / "instance" / "garmin" / "default"
|
||||
store.mkdir(parents=True)
|
||||
token = store / "garmin_tokens.json"
|
||||
token.write_text('{"di_token":"old"}')
|
||||
api = FakeGarmin()
|
||||
api.client = SimpleNamespace(dumps=lambda: '{"di_token":"new"}')
|
||||
api.login = lambda path: None
|
||||
monkeypatch.setattr(cli, "ROOT", tmp_path)
|
||||
monkeypatch.setattr(cli, "Garmin", lambda **kwargs: api)
|
||||
|
||||
def fail(*args):
|
||||
raise SyncError("download failed")
|
||||
|
||||
monkeypatch.setattr(cli, "sync_activities", fail)
|
||||
with pytest.raises(SyncError, match="download failed"):
|
||||
cli.run(cli.parser().parse_args(["sync"]))
|
||||
assert json.loads(token.read_text())["di_token"] == "new"
|
||||
@@ -0,0 +1,663 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = "==3.12.*"
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mako" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curl-cffi"
|
||||
version = "0.16.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "cffi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/e1/730125c43e3e331d98e17af3cb310ba526b3f1101b7635ca23d976ebfcf5/curl_cffi-0.16.3.tar.gz", hash = "sha256:d15d0c2a35f2d75bec430c28946c2a833f421c85773bdb0795182cc5c515665b", size = 239020, upload-time = "2026-09-02T11:58:23.266Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7a/ec08ef0665c4ef4ea76b47042eb1c043e4afb374d8b9218e00272c9e73a2/curl_cffi-0.16.3-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:0f1f6878863fba393801e4d59b2f2766d1983b5c9d9dfa11d4becfd6a74cc937", size = 3025646, upload-time = "2026-09-02T11:57:39.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/86/e21b8ed384db26401a4438f20f01c7bcd9c3a6f8ceede458344e2d62775c/curl_cffi-0.16.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f3b63da797912bc82911e34dfe449725514e4281527fb516931fc457087cfb44", size = 2784023, upload-time = "2026-09-02T11:57:40.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/2d/25b106e64178829be1ce171b6cd45ba354ab7a2a5169001866b38d4c440f/curl_cffi-0.16.3-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5a4103f2baa1fcf619ec3101b419827d367044ba106b206228137cc71a5a9c5", size = 12834219, upload-time = "2026-09-02T11:57:42.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/dd/db27a521777d0cf00f9a1554453ae730539dd134bca108d8df256a85c91e/curl_cffi-0.16.3-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f2795f0ef2e8cc0e6d702e52367af6600f5bcf10e44d683e256254adc7e3589f", size = 12655304, upload-time = "2026-09-02T11:57:45.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/01/2bbf141baa0fc3921d31a90de5465b7a94188845a8fe84dee86bf7bd90f1/curl_cffi-0.16.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a875a661e2f9a949be29454880bbb9553307a487c4c08819738298cf5c1622e2", size = 13484311, upload-time = "2026-09-02T11:57:47.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/d4/745ca299a2a223ee18574ec7cff75de620a92ce69b3cb09490db8fba614b/curl_cffi-0.16.3-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0851e710608122a2716bdee35788bbd7e9d4a0fd42899b2bca9181277095af8e", size = 12840616, upload-time = "2026-09-02T11:57:50.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/bf/98d72d7a081cc155a71ab66bde6a18640d4ac5d4f6766f729a92cb4257c0/curl_cffi-0.16.3-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d7e553442cefec100dfd1fca4ae7035ab6c094457244bf60a38670b8ac8185d", size = 12612602, upload-time = "2026-09-02T11:57:52.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/cf/2fdaff71fd6f39c5994495af8378e6e26bca8e447d94d2f75a76337908c8/curl_cffi-0.16.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:60621b3f561346046dd62be33abfb50c8b88a8007699d6b11d39ad4755312c4a", size = 12588697, upload-time = "2026-09-02T11:57:55.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/55/68c399019bc24ea6ac783c98139a2555f88589631f3d127fe6b9073f019b/curl_cffi-0.16.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:20a7b1b473371cfaf2118958034977e457c6fa279fbd11543c9e0ab58be9eedd", size = 13253555, upload-time = "2026-09-02T11:57:57.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/72/1732a24ef4a2aeba994b80ec163debe8deda403c07e4abbc0443bca078b8/curl_cffi-0.16.3-cp310-abi3-win_amd64.whl", hash = "sha256:fe87b66e324ed7318166698e02169f3208dbda32b872a27d2bc61a9c19b335eb", size = 1978602, upload-time = "2026-09-02T11:58:00.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/bb/67bec3132aeabac99dfe2f299a9b43dcb5de23ad96219ee98516d177fc9c/curl_cffi-0.16.3-cp310-abi3-win_arm64.whl", hash = "sha256:5a2ba880019f9e5a9e8f38ae22de6e4ea4c8d34a51ae4f1a2fce962c7b632006", size = 1713140, upload-time = "2026-09-02T11:58:01.558Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deprecated"
|
||||
version = "3.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/9c/16649913bf14c73e0a9453782e148362ff2657067deff6aa9c7ebcddcc31/deprecated-3.0.0.tar.gz", hash = "sha256:16850204d3a1e6bb0acd06bff48d96e8b0a0d25d1c52f71705405a0f4894192d", size = 166912, upload-time = "2026-09-26T13:58:10.675Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/ae/676feae8e4644a6d7169951a97f61c56f416c73f67bf1761f2461d75cc81/deprecated-3.0.0-py3-none-any.whl", hash = "sha256:58204cf4a7f6270d547af5c278ee7a6bb56045a4b3d8441a1cd11660f41b7939", size = 21912, upload-time = "2026-09-26T13:58:09.458Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.32.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/59/e19834834cb01a32febfbb0f8a23a9088088f5d45991824ff2bc3b5e8acb/filelock-3.32.7.tar.gz", hash = "sha256:37b8a3d9811b0f9aef7e5ec5c71bb320de52df51e6ca9bcd6f5ad81187660da7", size = 225154, upload-time = "2026-09-16T00:24:20.907Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/df/31098c5aeb4d966b553641472bd55fcf5fdfac953549894b8a765ba44e91/filelock-3.32.7-py3-none-any.whl", hash = "sha256:65ff0d0190ea42038b32bda4b77834fb05be2cad4c5b9b01aa4dfb3614536e52", size = 100157, upload-time = "2026-09-16T00:24:19.543Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fitdecode"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/87/19/1bde056f443d8ce890dbc0f0bd8a216cc9ebfda4221619767f80cf1835e2/fitdecode-0.11.0.tar.gz", hash = "sha256:52d920e50eaa76eb065b20bfd4e42f72195e894a079098dacc1cafa908cd4b83", size = 107149, upload-time = "2025-08-06T08:08:42.105Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/7b/75829f9b77b2546f955ec265de10de993939bf3218b5d957ca79803a2d28/fitdecode-0.11.0-py3-none-any.whl", hash = "sha256:a1bdb9b4d1e9ebac0001fc58c22fe07a3f9967ca4e13258c6ef99cdb8d697a78", size = 109961, upload-time = "2025-08-06T08:08:39.033Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blinker" },
|
||||
{ name = "click" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markupsafe" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-limiter"
|
||||
version = "4.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "limits" },
|
||||
{ name = "ordered-set" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/98/71780be5d1afb941219c4b48d241d4e246e3062b017caa4e79c4dc71314c/flask_limiter-4.1.1.tar.gz", hash = "sha256:ca11608fc7eec43dcea606964ca07c3bd4ec1ae89043a0f67f717899a4f48106", size = 403198, upload-time = "2025-12-06T17:39:00.575Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/7c/9fe9ffc83be199011bb0c6deb82cdcbc5a355601e380581de9dbc30490dd/flask_limiter-4.1.1-py3-none-any.whl", hash = "sha256:e1ae13e06e6b3e39a4902e7d240b901586b25932c2add7bd5f5eeb4bdc11111b", size = 30554, upload-time = "2025-12-06T17:38:59.162Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-login"
|
||||
version = "0.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-migrate"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "flask" },
|
||||
{ name = "flask-sqlalchemy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/47c7b3c93855ceffc2eabfa271782332942443321a07de193e4198f920cf/flask_migrate-4.1.0.tar.gz", hash = "sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d", size = 21965, upload-time = "2025-01-10T18:51:11.848Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237, upload-time = "2025-01-10T18:51:09.527Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-sqlalchemy"
|
||||
version = "3.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "sqlalchemy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-wtf"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "wtforms" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/91/f1/605a56d4ea217b307f3e6f4d663e0351253d85d841edc93ba559f0648e19/flask_wtf-1.3.0.tar.gz", hash = "sha256:61d5dabc50c3df885c297dcbd80810443a5d632106c8a69cab8ce740f0cdd7cc", size = 50414, upload-time = "2026-04-23T07:41:55.096Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d2/97adf2ec7af95522573e6dd5493ee84792d0fbfb2def010c4a581b8d6e5e/flask_wtf-1.3.0-py3-none-any.whl", hash = "sha256:dc5e3a4ce97f75c47bf6c1c72ad2c3b7bdf579a2ed13aebcc5d3d81fe2571160", size = 13959, upload-time = "2026-04-23T07:41:53.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "garminconnect"
|
||||
version = "0.3.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "curl-cffi" },
|
||||
{ name = "requests" },
|
||||
{ name = "ua-generator" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/4e/2758ce40ee00487a36154df4a3b1bfa6b7f3038954d49de3747718d853b4/garminconnect-0.3.17.tar.gz", hash = "sha256:f6b6dd282c950c394478cb5bcca506e1432e82037e27625d1456ef49c8f17ffe", size = 109120, upload-time = "2026-09-29T07:19:53.616Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/a5/cf958eab4eaae4bb94852a3819f367074a4a19e489938ae3cb1efc32bd87/garminconnect-0.3.17-py3-none-any.whl", hash = "sha256:624d8c2468d865d286c9fb7ae8133633bb1d52a2b019c8beb5ff9ae10dd43264", size = 102734, upload-time = "2026-09-29T07:19:52.182Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "25.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "limits"
|
||||
version = "5.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecated" },
|
||||
{ name = "packaging" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-set"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg"
|
||||
version = "3.3.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
binary = [
|
||||
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
version = "3.3.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/01/2cdd1824e58b4467ee0b9498664cd28c42d8794db6b1e35b6bcb834f0044/psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d", size = 4707086, upload-time = "2026-09-18T13:18:05.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/76/de9948ac06895261c84d5b9fbe283d8f3c5bc9f070691b8d9eaa1b51e322/psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0", size = 4769607, upload-time = "2026-09-18T13:18:12.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/a9/72436c9915ee4905964689e7f0e182ce7767cc0a0390b3ce703be8177625/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9", size = 5554134, upload-time = "2026-09-18T13:18:21.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/42/948bb3d2617795093512613fd96ba380e922992c7908fbc073858147d196/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de", size = 5235723, upload-time = "2026-09-18T13:18:27.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/47/93e823ff1b0088400703410939c9bda3e63ed9c850b3ee088e8769f4c10b/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe", size = 6833587, upload-time = "2026-09-18T13:18:33.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/2d/ecc69c847795aa704041a9f5667a6b0938a088cf1853636d762a6938e493/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c", size = 5070013, upload-time = "2026-09-18T13:18:39.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/36/6126f0dac21713dcae91404f2a76da18598a6252339a8c669c46370d43b2/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb", size = 4597367, upload-time = "2026-09-18T13:18:45.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/29/7ecfc04243b46c89ffd49924e9c5634ea904ef96c7d0f37e4073623584c1/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c", size = 4275419, upload-time = "2026-09-18T13:18:49.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/90/2f46d2e0de79706ac170df0a3637fe63c4498fc04f131f6049520b78b806/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79", size = 4007358, upload-time = "2026-09-18T13:18:53.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/48/6744e91291b751a8cf12d63d719977974bb94c84ceba913e7ddb2e478e51/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52", size = 4320156, upload-time = "2026-09-18T13:18:59.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/9b/94ff7fce53a64d5b286e2ec454e0a025cf3d6e6b4a9189bef16aa5de98b2/psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f", size = 3658864, upload-time = "2026-09-18T13:19:06.503Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "running-dashboard"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "curl-cffi" },
|
||||
{ name = "filelock" },
|
||||
{ name = "fitdecode" },
|
||||
{ name = "flask" },
|
||||
{ name = "flask-limiter" },
|
||||
{ name = "flask-login" },
|
||||
{ name = "flask-migrate" },
|
||||
{ name = "flask-sqlalchemy" },
|
||||
{ name = "flask-wtf" },
|
||||
{ name = "garminconnect" },
|
||||
{ name = "gunicorn", marker = "sys_platform != 'win32'" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "waitress" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "curl-cffi", specifier = ">=0.13,<1" },
|
||||
{ name = "filelock", specifier = ">=3.20,<4" },
|
||||
{ name = "fitdecode", specifier = "==0.11.0" },
|
||||
{ name = "flask", specifier = ">=3.1.3" },
|
||||
{ name = "flask-limiter", specifier = ">=4.1.1" },
|
||||
{ name = "flask-login", specifier = ">=0.6.3" },
|
||||
{ name = "flask-migrate", specifier = ">=4.1.0" },
|
||||
{ name = "flask-sqlalchemy", specifier = ">=3.1.1" },
|
||||
{ name = "flask-wtf", specifier = ">=1.3.0" },
|
||||
{ name = "garminconnect", specifier = "==0.3.17" },
|
||||
{ name = "gunicorn", marker = "sys_platform != 'win32'", specifier = ">=23,<26" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.6" },
|
||||
{ name = "waitress", specifier = ">=3.0.2" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8,<10" },
|
||||
{ name = "ruff", specifier = ">=0.14,<1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/cb/7c68da82239ffae9378eca076afe14c310e17a4f01e3d315112f266e26c7/sqlalchemy-2.1.1.tar.gz", hash = "sha256:fcf3cbb33bb23bad75d2150157c21804618745731e9931ab27e61879a9f6123b", size = 10503203, upload-time = "2026-09-25T14:59:00.117Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/44/950ac8e093784fdd5fd4fc385d110e0cf1d1d2bae781dc7be659bf3e7af3/sqlalchemy-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1abf1acecd4d0fc34d4ab7e9cda9e849ce95b647e1a5f76db7d2a5a0bea4e1e5", size = 2458475, upload-time = "2026-09-25T15:17:05.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/13/ade5c1da6221e865a57ee45132a3c61528bb244d7b22ddbbddd589989754/sqlalchemy-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61333b2f806f64a08a1edf0b0e4271905f73c1f7e525c23da7a491f74c109835", size = 4653012, upload-time = "2026-09-25T15:25:47.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/95/37b6846549a76c69beca71974905446130d78bddd88a4414b5b88c88af16/sqlalchemy-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fad3dc37851b096ef72538b18152a5c1ae9c8b2013cd89f51eca83fc6eba36b", size = 4701803, upload-time = "2026-09-25T15:25:55.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/88/abdc1ce5daa0af77c5d27183625ab7bb1155de41067fd77db4fb1cdc9616/sqlalchemy-2.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a3126c1203cdfc0f00884f9e8dbe95307b813f883ee9d9691983a563ebc96f8", size = 4351745, upload-time = "2026-09-25T15:31:43.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/28/4375244fc658c95033593429bc6dfc61df96beb9b64c8bf57821fdebf85d/sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f90e517b2eb9c4c40fcc978c8c242789fa6cb0064c501fd8c1515145ed5fe23d", size = 4576898, upload-time = "2026-09-25T15:25:49.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ce/0aa3c294150a7a018e61af42462cf299022ad71b4b94384be36fff6ec567/sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:054a2c2b6cbe3ca7cb88aba9c5e2ecdbfc43656bea22326a41d09e5398c86ec4", size = 4346156, upload-time = "2026-09-25T15:31:45.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/17/cf00fdbcdd8f1933aa4b33d499218a8d470ec9fd935a2f33ec55dcef4fdc/sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ea049f3e2702967414bc79f58a484dab191359267726607e6c1579a09ef93788", size = 4650545, upload-time = "2026-09-25T15:25:57.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/9b/8b4a17884e2b286d5f89e3b93bc2be928c6d86d7bfeadf9ae4459d64c244/sqlalchemy-2.1.1-cp312-cp312-win32.whl", hash = "sha256:842a5c00ca9719cc26fba6f401f70e4bac19dc8cc4dc36350803a03954d6aea2", size = 2369166, upload-time = "2026-09-25T15:36:05.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/5a/c91c3cf7f0e65ae7ad9ebc87ec4cc68f6d163fddaa36736621a00dac5905/sqlalchemy-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:0220e0d1d1ad2391a03cd6713ca5d795419033cc40baa1233f9ad454dffa25e6", size = 2421474, upload-time = "2026-09-25T15:36:07.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/65/fc72cd55397792553b72a8c43e14531baa436d08f832486d2cff9e806f36/sqlalchemy-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af189bd861bd31a84f0ba244d6db0c8aeae9e56448c9001bf02ee6a93758b39e", size = 2381060, upload-time = "2026-09-25T15:11:45.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/7a/7d1879d78c086737acee5f3250cbff9d285e5bd7cb6bc2c730f24e8d400e/sqlalchemy-2.1.1-py3-none-any.whl", hash = "sha256:4357c1a222e141662251a59d3702f9b124294941171dfa6bd570513e9f4905ee", size = 2047471, upload-time = "2026-09-25T15:20:37.797Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ua-generator"
|
||||
version = "2.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/48/ce6900f0e88e2158d137272820e47cf312fc691a9c26c9b447d79c3b31d3/ua_generator-2.1.6.tar.gz", hash = "sha256:532b8769f3dbb914b5ace2846a47cdfa2b3a2d17a9c82cfef53d19280045b18e", size = 29805, upload-time = "2026-09-20T14:40:42.639Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/03/d6ac97d7010451c43679c3efc46ff6b9e149cb3293ff93cf65f30fa58a54/ua_generator-2.1.6-py3-none-any.whl", hash = "sha256:877285e6adab3d6b0f9b295d954d121fc21a0305be8526038218e8f08be76df6", size = 32891, upload-time = "2026-09-20T14:40:41.391Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "waitress"
|
||||
version = "3.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "2.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3e/d2/a254a26d8ceaea87e0eee2e89fcfe53ddc1858418647493bb2937549ab6f/wrapt-2.5.0.tar.gz", hash = "sha256:c48cdb6c904dca76d9915a579e4a5fab6b0c25f650c1019ce78a78effaf7a345", size = 183288, upload-time = "2026-09-27T01:41:56.874Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/a6/44589f9b34160280a1fbccbcf206b18df034a1b3e9584336d3a7f039a33c/wrapt-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b898caea081303006decc562c7fca5126f7c96507e78dd8f1ae3285dfa50ddc7", size = 106192, upload-time = "2026-09-27T01:40:00.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/2b/94db3ba2e9528400e4173cbb67a56822a4644d4c2b319feb0cc638094b36/wrapt-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8837fbe708cb9d8a2d32a37dee836d24a531f02560db26418e2b181986fa21cb", size = 105828, upload-time = "2026-09-27T01:40:02.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/0e/3ce67af67525c0068680637c0eba823ce2f0fc6eff2d1779cab656fb8c3d/wrapt-2.5.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cabb9c17ab79b2549d1f23b36f436473ad9253ef53995a817feba26fae69d5b", size = 250177, upload-time = "2026-09-27T01:40:03.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/3b/262b2c3c38aca6fa32cfea6407dbb346ce26ff788179b98137b3b3f3d8ca/wrapt-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6761765cc520ff9616fb035c02a85d1d744f7f70edd4649718fd0d09c589eacf", size = 250332, upload-time = "2026-09-27T01:40:05.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/ec/d54d273a2223d1964ecd5b5954aca18e8fbf2c5d8471e60ef3cbaaed9325/wrapt-2.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a145a7826eddea3eb5814903f98f93756042b919bb5305544cb1331daa2705b1", size = 229600, upload-time = "2026-09-27T01:40:06.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/88/33c75ac47b13b0bf77b44d96d02b1d0a2e6db66180909bacce86ef8fcd8e/wrapt-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6a9ee62a970075738909909bdbef3a7da9f7ae03dfca584db283547a29503b56", size = 246890, upload-time = "2026-09-27T01:40:07.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/0e/c3a3a158801e5bee7a0d9aa3561a127bd80f166b02b7b520a39e45d8f9b9/wrapt-2.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:691671ea05684f921ffc2e935fd3f9311c1795a10fbbfa006b46269733668f66", size = 226463, upload-time = "2026-09-27T01:40:09.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/2c/4c48ba51698a87e2e8299fc04856bbef391cb725f195bc93f0bc6196e1f2/wrapt-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e716f47c7f61e11709d3c0904213c94fc22999abf0c41461276cef886c1e8b4d", size = 238583, upload-time = "2026-09-27T01:40:10.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/d2/077835618ed96b131730f74301a7236efd841ec2f341afe36303389b56ef/wrapt-2.5.0-cp312-cp312-win32.whl", hash = "sha256:5421acb5c363a9bc959122a8645e3f1f42010c932dc53885b11a5ff5b5a6d730", size = 100221, upload-time = "2026-09-27T01:40:11.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/9f/9e56db2a3492275be809082c6f63a7a063f52b3e1812aae1eb03ec33f64c/wrapt-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:ab45839c912777e2738fed369636589c8b2a6d9c44ca56de0fd0814581d467c2", size = 105786, upload-time = "2026-09-27T01:40:13.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e4/e37ff75e5254564aa50e13bc4313383d02d5ec78f5bd3b9b1994dd7052d5/wrapt-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:ce4cab32c37ef71e69cf88f909b7febd0dd79543e5ae3650e2b874e0f3d3b975", size = 102937, upload-time = "2026-09-27T01:40:14.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/7d/5ed859fad4b5eddd598a846150aaab2703730ed4886c5c5e03b0df0cfdd5/wrapt-2.5.0-py3-none-any.whl", hash = "sha256:107eea1a511e98a3a5033b0c2cb403fbb37f05dee6ac1fb85c0460d311ec278c", size = 81586, upload-time = "2026-09-27T01:41:55.479Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wtforms"
|
||||
version = "3.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/91/ed9b517da898e3fb747566aa3c12a734bd64ea7449a0d25ec74ce8f8b8eb/wtforms-3.2.2.tar.gz", hash = "sha256:7b00c73f8670f35d4edb0293dcd81b980528bee72fd662b182aaba27ae570b93", size = 139583, upload-time = "2026-05-03T05:53:44.147Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/76/bb225c8300f3a0ba28e01df51419c6c9574a297c43d71b29048e03b65deb/wtforms-3.2.2-py3-none-any.whl", hash = "sha256:72b90d5d921bd3119252069cf0301e9c13915f9e52792652bc91c5dda4b79e56", size = 158656, upload-time = "2026-05-03T05:53:46.072Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user