intervals-summits 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- intervals_summits/__init__.py +2 -0
- intervals_summits/__main__.py +5 -0
- intervals_summits/cli.py +266 -0
- intervals_summits/config.py +142 -0
- intervals_summits/fields.py +156 -0
- intervals_summits/intervals.py +111 -0
- intervals_summits/match.py +63 -0
- intervals_summits/osm.py +160 -0
- intervals_summits/page.py +164 -0
- intervals_summits/store.py +163 -0
- intervals_summits/sync.py +239 -0
- intervals_summits-0.1.0.dist-info/METADATA +223 -0
- intervals_summits-0.1.0.dist-info/RECORD +17 -0
- intervals_summits-0.1.0.dist-info/WHEEL +5 -0
- intervals_summits-0.1.0.dist-info/entry_points.txt +2 -0
- intervals_summits-0.1.0.dist-info/licenses/LICENSE +21 -0
- intervals_summits-0.1.0.dist-info/top_level.txt +1 -0
intervals_summits/cli.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Command line: sync, backfill, recompute, run, ui, page, status, fields."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import http.server
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from datetime import date, datetime, timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import __version__, page
|
|
16
|
+
from . import fields as F
|
|
17
|
+
from .config import Settings, load
|
|
18
|
+
from .intervals import IntervalsClient
|
|
19
|
+
from .osm import OSM
|
|
20
|
+
from .store import Store
|
|
21
|
+
from .sync import Context, backfill, sync
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("intervals_summits")
|
|
24
|
+
|
|
25
|
+
# garmin-intervals-bridge (BRIDGE_NOTIFY_URL) can tell `run --ui` about new activities with a POST /sync
|
|
26
|
+
QUIET_MINUTES = 180 # while it does, look on our own only this often (in case the bridge stops)
|
|
27
|
+
NOTIFIED_DAYS = 14 # ... as long as its last note is this recent
|
|
28
|
+
RECHECK_MINUTES = 10 # a reported activity without a track yet: ask once more this soon
|
|
29
|
+
NOTE_DELAY = 5 # seconds: notes in quick succession make one sync
|
|
30
|
+
MIN_GAP_SECONDS = 60 # and at most one sync a minute
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def context(settings: Settings) -> Context:
|
|
34
|
+
store = Store(settings.db_file, settings.tracks_dir)
|
|
35
|
+
iv = IntervalsClient(settings.api_key, settings.athlete_id, pause=settings.pause)
|
|
36
|
+
return Context(settings, iv, store, OSM(store, settings.overpass_url))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_page(ctx: Context) -> None:
|
|
40
|
+
size = page.write(ctx.store, ctx.settings.page_file, ctx.settings.language, ctx.settings.page_title)
|
|
41
|
+
ctx.counts["page_kb"] = size // 1024
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ensure_fields(ctx: Context) -> None:
|
|
45
|
+
"""Create missing fields – checked once a day (the list of custom items is not small)."""
|
|
46
|
+
key = ",".join(ctx.settings.fields)
|
|
47
|
+
if ctx.store.get("fields_checked") == f"{date.today()} {key}":
|
|
48
|
+
return
|
|
49
|
+
for code in F.ensure(ctx.iv, ctx.settings.fields, ctx.settings.language, True)["missing"]:
|
|
50
|
+
ctx.counts[f"field_created_{code}"] += 1
|
|
51
|
+
ctx.store.put("fields_checked", f"{date.today()} {key}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def report(ctx: Context, apply: bool, **extra) -> None:
|
|
55
|
+
out = {"apply": apply, **extra, **{k: v for k, v in sorted(ctx.counts.items())},
|
|
56
|
+
"intervals_requests": ctx.iv.requests, "page": str(ctx.settings.page_file)}
|
|
57
|
+
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_sync(ctx: Context, args) -> int:
|
|
61
|
+
if args.apply:
|
|
62
|
+
ensure_fields(ctx)
|
|
63
|
+
sync(ctx, apply=args.apply, force=args.force)
|
|
64
|
+
write_page(ctx)
|
|
65
|
+
report(ctx, args.apply)
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_backfill(ctx: Context, args, recompute: bool = False) -> int:
|
|
70
|
+
if args.apply:
|
|
71
|
+
ensure_fields(ctx)
|
|
72
|
+
oldest = date.fromisoformat(args.from_)
|
|
73
|
+
newest = date.fromisoformat(args.to) if args.to else date.today() + timedelta(days=1)
|
|
74
|
+
backfill(ctx, oldest, newest, apply=args.apply, force=args.force, recompute=recompute)
|
|
75
|
+
write_page(ctx)
|
|
76
|
+
report(ctx, args.apply, window=f"{oldest} – {newest}")
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_page(ctx: Context, args) -> int:
|
|
81
|
+
path = Path(args.out) if args.out else ctx.settings.page_file
|
|
82
|
+
size = page.write(ctx.store, path, args.language or ctx.settings.language, ctx.settings.page_title)
|
|
83
|
+
print(f"{path}: {size // 1024} KB")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def poll_minutes(ctx: Context, now: datetime | None = None) -> int:
|
|
88
|
+
"""How often to look on our own: SUMMITS_INTERVAL_MINUTES, or every 3 hours while the bridge reports."""
|
|
89
|
+
told = ctx.store.get("notified_at")
|
|
90
|
+
if told and (now or datetime.now()) - datetime.fromisoformat(told) <= timedelta(days=NOTIFIED_DAYS):
|
|
91
|
+
return max(ctx.settings.interval_minutes, QUIET_MINUTES)
|
|
92
|
+
return ctx.settings.interval_minutes
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def healthy(ctx: Context, now: datetime | None = None) -> tuple[bool, str]:
|
|
96
|
+
"""No request to anyone: is the last successful sync recent enough?"""
|
|
97
|
+
last = ctx.store.get("last_sync_at")
|
|
98
|
+
if not last:
|
|
99
|
+
return True, "no sync yet"
|
|
100
|
+
age = ((now or datetime.now()) - datetime.fromisoformat(last)).total_seconds() / 60
|
|
101
|
+
limit = max(60, 3 * poll_minutes(ctx, now))
|
|
102
|
+
return age <= limit, f"last sync {age:.0f} minutes ago"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_status(ctx: Context, args) -> int:
|
|
106
|
+
if args.check:
|
|
107
|
+
ok, text = healthy(ctx)
|
|
108
|
+
print(("healthy: " if ok else "unhealthy: ") + text)
|
|
109
|
+
return 0 if ok else 1
|
|
110
|
+
out = {"version": __version__, "data_dir": str(ctx.settings.data_dir), "fields": list(ctx.settings.fields),
|
|
111
|
+
"language": ctx.settings.language, "last_sync": ctx.store.get("last_sync_at"),
|
|
112
|
+
"last_bridge_note": ctx.store.get("notified_at"), **ctx.store.counts()}
|
|
113
|
+
if args.fields:
|
|
114
|
+
out["intervals_fields"] = F.ensure(ctx.iv, ctx.settings.fields, ctx.settings.language, False)
|
|
115
|
+
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_fields(ctx: Context, args) -> int:
|
|
120
|
+
print(json.dumps(F.ensure(ctx.iv, ctx.settings.fields, ctx.settings.language, args.apply), indent=2))
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def serve(settings: Settings, host: str, port: int, wake: threading.Event | None = None) -> http.server.ThreadingHTTPServer:
|
|
125
|
+
"""The summit log, rendered fresh from the database for every request (read only). With `wake`
|
|
126
|
+
(from `run`) also POST /sync: new activities in Intervals, sync now. It takes no data, only the
|
|
127
|
+
note; the run loop keeps it to one sync a minute."""
|
|
128
|
+
|
|
129
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
130
|
+
def do_GET(self): # noqa: N802
|
|
131
|
+
if self.path.split("?")[0].rstrip("/") not in ("", "/index.html"):
|
|
132
|
+
self.send_error(404)
|
|
133
|
+
return
|
|
134
|
+
store = Store(settings.db_file, settings.tracks_dir)
|
|
135
|
+
try:
|
|
136
|
+
body = page.render(store, settings.language, settings.page_title).encode("utf-8")
|
|
137
|
+
finally:
|
|
138
|
+
store.close()
|
|
139
|
+
self.send_response(200)
|
|
140
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
141
|
+
self.send_header("Content-Length", str(len(body)))
|
|
142
|
+
self.send_header("Cache-Control", "no-store")
|
|
143
|
+
self.end_headers()
|
|
144
|
+
self.wfile.write(body)
|
|
145
|
+
|
|
146
|
+
def do_POST(self): # noqa: N802
|
|
147
|
+
if wake is None or self.path.split("?")[0].rstrip("/") != "/sync":
|
|
148
|
+
self.send_error(404)
|
|
149
|
+
return
|
|
150
|
+
try:
|
|
151
|
+
length = min(max(int(self.headers.get("Content-Length") or 0), 0), 65536)
|
|
152
|
+
who = str(json.loads(self.rfile.read(length) or b"{}").get("source") or "")
|
|
153
|
+
except (ValueError, AttributeError):
|
|
154
|
+
who = ""
|
|
155
|
+
log.info("%s reports new activities: a sync follows", "".join(c for c in who[:60] if c.isprintable()) or "Someone")
|
|
156
|
+
wake.set()
|
|
157
|
+
body = b'{"ok": true}'
|
|
158
|
+
self.send_response(202)
|
|
159
|
+
self.send_header("Content-Type", "application/json")
|
|
160
|
+
self.send_header("Content-Length", str(len(body)))
|
|
161
|
+
self.end_headers()
|
|
162
|
+
self.wfile.write(body)
|
|
163
|
+
|
|
164
|
+
def log_message(self, *a):
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
return http.server.ThreadingHTTPServer((host, port), Handler)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cmd_ui(ctx: Context, args) -> int:
|
|
171
|
+
server = serve(ctx.settings, args.host, args.port)
|
|
172
|
+
print(f"Summit log on http://{args.host}:{args.port}/ (Ctrl+C stops it)")
|
|
173
|
+
try:
|
|
174
|
+
server.serve_forever()
|
|
175
|
+
except KeyboardInterrupt:
|
|
176
|
+
pass
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def cmd_run(ctx: Context, args) -> int:
|
|
181
|
+
"""Keep running: a sync every SUMMITS_INTERVAL_MINUTES, the page after each (and with --ui on the web)."""
|
|
182
|
+
wake = threading.Event()
|
|
183
|
+
if args.ui:
|
|
184
|
+
server = serve(ctx.settings, args.host, args.port, wake)
|
|
185
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
186
|
+
log.info("Summit log on http://%s:%d/ (POST /sync there: new activities, sync now)", args.host, args.port)
|
|
187
|
+
log.info("intervals-summits %s: a sync every %d minutes%s", __version__, poll_minutes(ctx),
|
|
188
|
+
"" if args.apply else " – a dry run: nothing is written to Intervals until --apply")
|
|
189
|
+
run_loop(ctx, apply=args.apply, wake=wake)
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def run_loop(ctx: Context, *, apply: bool, wake: threading.Event, iterations: int | None = None,
|
|
194
|
+
sleep=time.sleep) -> None:
|
|
195
|
+
told, count = False, 0
|
|
196
|
+
while iterations is None or count < iterations:
|
|
197
|
+
count += 1
|
|
198
|
+
started = time.monotonic()
|
|
199
|
+
ctx.counts = Counter()
|
|
200
|
+
try:
|
|
201
|
+
if told:
|
|
202
|
+
ctx.store.put("notified_at", datetime.now().isoformat(timespec="seconds"))
|
|
203
|
+
if apply:
|
|
204
|
+
ensure_fields(ctx)
|
|
205
|
+
sync(ctx, apply=apply)
|
|
206
|
+
write_page(ctx)
|
|
207
|
+
done = {k: v for k, v in ctx.counts.items() if k in ("matched", "with_summits", "activities_to_write",
|
|
208
|
+
"activities_written", "failed") and v}
|
|
209
|
+
if done:
|
|
210
|
+
log.info("Sync: %s", done)
|
|
211
|
+
except Exception as exc: # noqa: BLE001 – keep running; the next pass tries again
|
|
212
|
+
log.warning("Sync failed: %s", exc)
|
|
213
|
+
if iterations is not None and count >= iterations:
|
|
214
|
+
return
|
|
215
|
+
wait = poll_minutes(ctx) * 60
|
|
216
|
+
if told and ctx.counts["no_gps"]:
|
|
217
|
+
wait = min(wait, RECHECK_MINUTES * 60) # Intervals may not have read its track yet
|
|
218
|
+
told = wake.wait(wait)
|
|
219
|
+
if told:
|
|
220
|
+
sleep(max(NOTE_DELAY, started + MIN_GAP_SECONDS - time.monotonic()))
|
|
221
|
+
wake.clear() # notes until now: this one sync covers them
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def main(argv: list[str] | None = None) -> int:
|
|
225
|
+
ap = argparse.ArgumentParser(prog="intervals-summits", description="Summits on your Intervals.icu activities")
|
|
226
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
227
|
+
ap.add_argument("-v", "--verbose", action="store_true")
|
|
228
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
229
|
+
p = sub.add_parser("sync", help="the recent days (after a pause back to the last run)")
|
|
230
|
+
p.add_argument("--apply", action="store_true", help="write to Intervals (else: a dry run)")
|
|
231
|
+
p.add_argument("--force", action="store_true", help="also overwrite values someone else wrote")
|
|
232
|
+
for name, text in (("backfill", "the past: match activities not matched yet"), ("recompute", "match again (new OSM data or rules)")):
|
|
233
|
+
p = sub.add_parser(name, help=text)
|
|
234
|
+
p.add_argument("--from", dest="from_", required=True, metavar="DATE")
|
|
235
|
+
p.add_argument("--to", metavar="DATE")
|
|
236
|
+
p.add_argument("--apply", action="store_true")
|
|
237
|
+
p.add_argument("--force", action="store_true")
|
|
238
|
+
p = sub.add_parser("run", help="keep running: a sync every few minutes")
|
|
239
|
+
p.add_argument("--apply", action="store_true")
|
|
240
|
+
p.add_argument("--ui", action="store_true", help="also serve the summit log page")
|
|
241
|
+
p.add_argument("--host", default="127.0.0.1")
|
|
242
|
+
p.add_argument("--port", type=int, default=8766)
|
|
243
|
+
p = sub.add_parser("ui", help="serve the summit log page")
|
|
244
|
+
p.add_argument("--host", default="127.0.0.1")
|
|
245
|
+
p.add_argument("--port", type=int, default=8766)
|
|
246
|
+
p = sub.add_parser("page", help="write the summit log page")
|
|
247
|
+
p.add_argument("--out")
|
|
248
|
+
p.add_argument("--language", choices=["en", "de"])
|
|
249
|
+
p = sub.add_parser("status")
|
|
250
|
+
p.add_argument("--check", action="store_true", help="health only, no request: exit 1 when no sync came for long")
|
|
251
|
+
p.add_argument("--fields", action="store_true", help="also ask Intervals which fields exist")
|
|
252
|
+
p = sub.add_parser("fields", help="create the activity fields that are missing")
|
|
253
|
+
p.add_argument("--apply", action="store_true")
|
|
254
|
+
args = ap.parse_args(argv)
|
|
255
|
+
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(levelname)s %(message)s",
|
|
256
|
+
datefmt="%Y-%m-%d %H:%M:%S", stream=sys.stderr)
|
|
257
|
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
258
|
+
ctx = context(load())
|
|
259
|
+
try:
|
|
260
|
+
if args.cmd == "sync":
|
|
261
|
+
return cmd_sync(ctx, args)
|
|
262
|
+
if args.cmd in ("backfill", "recompute"):
|
|
263
|
+
return cmd_backfill(ctx, args, recompute=args.cmd == "recompute")
|
|
264
|
+
return {"page": cmd_page, "status": cmd_status, "fields": cmd_fields, "run": cmd_run, "ui": cmd_ui}[args.cmd](ctx, args)
|
|
265
|
+
finally:
|
|
266
|
+
ctx.store.close()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Settings: environment variables, else a settings file.
|
|
2
|
+
|
|
3
|
+
The settings file that applies: SUMMITS_ENV_FILE, else ./.env, else ~/IntervalsSummits/.env, else the
|
|
4
|
+
bridge's ~/GarminBridge/.env – so next to the Garmin bridge nothing has to be set twice. Variables
|
|
5
|
+
already set in the environment always win.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
HOME_FOLDER = "IntervalsSummits"
|
|
14
|
+
BRIDGE_FOLDER = "GarminBridge"
|
|
15
|
+
OVERPASS = "https://overpass-api.de/api/interpreter"
|
|
16
|
+
# indoor and virtual types: no track worth asking for ("Other" may well have one: paragliding, ...)
|
|
17
|
+
INDOOR = frozenset({"VirtualRide", "VirtualRun", "VirtualRow", "VirtualSki", "Elliptical", "Workout", "WeightTraining",
|
|
18
|
+
"Yoga", "Pilates", "Crossfit", "StairStepper", "Rowing", "Swim", "HighIntensityIntervalTraining"})
|
|
19
|
+
ALL_FIELDS = ("Summits", "SummitCount", "HighestPoint", "HutsPasses")
|
|
20
|
+
DEFAULT_FIELDS = ("Summits", "SummitCount", "HighestPoint")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_env_file(path: Path) -> dict[str, str]:
|
|
24
|
+
out: dict[str, str] = {}
|
|
25
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
26
|
+
line = line.strip()
|
|
27
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
28
|
+
continue
|
|
29
|
+
if line.startswith("export "):
|
|
30
|
+
line = line[7:]
|
|
31
|
+
key, value = line.split("=", 1)
|
|
32
|
+
value = value.strip()
|
|
33
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
|
34
|
+
value = value[1:-1]
|
|
35
|
+
out[key.strip()] = value
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def env_file() -> Path | None:
|
|
40
|
+
if os.environ.get("SUMMITS_ENV_FILE"):
|
|
41
|
+
return Path(os.environ["SUMMITS_ENV_FILE"]).expanduser()
|
|
42
|
+
for candidate in (Path(".env"), Path.home() / HOME_FOLDER / ".env", Path.home() / BRIDGE_FOLDER / ".env"):
|
|
43
|
+
if candidate.is_file():
|
|
44
|
+
return candidate
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def default_data_dir() -> Path:
|
|
49
|
+
"""Next to a bridge set up on this computer: ~/GarminBridge/summits; else ~/IntervalsSummits."""
|
|
50
|
+
bridge = Path.home() / BRIDGE_FOLDER
|
|
51
|
+
return bridge / "summits" if bridge.is_dir() else Path.home() / HOME_FOLDER
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _list(value: str | None) -> tuple[str, ...]:
|
|
55
|
+
return tuple(v.strip() for v in (value or "").split(",") if v.strip())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _bool(value: str | None, default: bool) -> bool:
|
|
59
|
+
return default if value in (None, "") else value.strip().lower() in ("1", "true", "yes", "on")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _metres(env: dict[str, str], key: str, default: float, low: float, high: float) -> float:
|
|
63
|
+
value = env.get(key)
|
|
64
|
+
if value in (None, ""):
|
|
65
|
+
return default
|
|
66
|
+
try:
|
|
67
|
+
number = float(value)
|
|
68
|
+
except ValueError:
|
|
69
|
+
raise SystemExit(f"{key}: a number of metres, not {value!r}") from None
|
|
70
|
+
if not low <= number <= high:
|
|
71
|
+
raise SystemExit(f"{key}: {low:g} to {high:g} m")
|
|
72
|
+
return number
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Settings:
|
|
77
|
+
api_key: str
|
|
78
|
+
athlete_id: str = "0"
|
|
79
|
+
data_dir: Path = field(default_factory=default_data_dir)
|
|
80
|
+
language: str = "en" # field names and the page: en or de
|
|
81
|
+
name_language: str = "" # OSM name:<xx> when there is one; "" = the local name
|
|
82
|
+
types: tuple[str, ...] = () # only these activity types; () = every outdoor type
|
|
83
|
+
fields: tuple[str, ...] = DEFAULT_FIELDS
|
|
84
|
+
overpass_url: str = OVERPASS
|
|
85
|
+
lookback_days: int = 3
|
|
86
|
+
catchup_days: int = 14
|
|
87
|
+
interval_minutes: int = 15
|
|
88
|
+
pause: float = 0.6 # seconds between Intervals requests (the key is shared with the bridge)
|
|
89
|
+
keep_tracks: bool = True
|
|
90
|
+
description: bool = False # the line "⛰️ Summits: …" in the description (searchable)
|
|
91
|
+
peak_radius: float = 100.0
|
|
92
|
+
peak_below: float = 80.0
|
|
93
|
+
hut_radius: float = 60.0
|
|
94
|
+
pass_radius: float = 60.0
|
|
95
|
+
page_title: str = ""
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def page_file(self) -> Path:
|
|
99
|
+
return self.data_dir / "summits.html"
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def db_file(self) -> Path:
|
|
103
|
+
return self.data_dir / "summits.sqlite"
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def tracks_dir(self) -> Path:
|
|
107
|
+
return self.data_dir / "tracks"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load(environ: dict[str, str] | None = None) -> Settings:
|
|
111
|
+
env = dict(environ if environ is not None else os.environ)
|
|
112
|
+
if environ is None and (path := env_file()):
|
|
113
|
+
for key, value in read_env_file(path).items():
|
|
114
|
+
env.setdefault(key, value)
|
|
115
|
+
key = env.get("INTERVALS_API_KEY", "")
|
|
116
|
+
if not key:
|
|
117
|
+
raise SystemExit("INTERVALS_API_KEY is not set (environment or settings file)")
|
|
118
|
+
fields = tuple(f for f in _list(env.get("SUMMITS_FIELDS")) or DEFAULT_FIELDS)
|
|
119
|
+
unknown = [f for f in fields if f not in ALL_FIELDS]
|
|
120
|
+
if unknown:
|
|
121
|
+
raise SystemExit(f"SUMMITS_FIELDS: unknown {unknown}; possible: {', '.join(ALL_FIELDS)}")
|
|
122
|
+
language = (env.get("SUMMITS_LANGUAGE") or "en").lower()[:2]
|
|
123
|
+
return Settings(
|
|
124
|
+
api_key=key,
|
|
125
|
+
athlete_id=env.get("INTERVALS_ATHLETE_ID") or "0",
|
|
126
|
+
data_dir=Path(env["SUMMITS_DATA_DIR"]).expanduser() if env.get("SUMMITS_DATA_DIR") else default_data_dir(),
|
|
127
|
+
language=language if language in ("en", "de") else "en",
|
|
128
|
+
name_language=(env.get("SUMMITS_NAME_LANGUAGE") or "").lower(),
|
|
129
|
+
types=_list(env.get("SUMMITS_TYPES")),
|
|
130
|
+
fields=fields,
|
|
131
|
+
overpass_url=env.get("SUMMITS_OVERPASS_URL") or OVERPASS,
|
|
132
|
+
lookback_days=max(1, min(30, int(env.get("SUMMITS_LOOKBACK_DAYS") or 3))),
|
|
133
|
+
interval_minutes=max(5, int(env.get("SUMMITS_INTERVAL_MINUTES") or 15)),
|
|
134
|
+
keep_tracks=_bool(env.get("SUMMITS_KEEP_TRACKS"), True),
|
|
135
|
+
description=_bool(env.get("SUMMITS_DESCRIPTION"), False),
|
|
136
|
+
# up to 200 m: a track point needs the neighbouring OpenStreetMap cell only within 0.004° of a border
|
|
137
|
+
peak_radius=_metres(env, "SUMMITS_PEAK_RADIUS", 100.0, 10, 200),
|
|
138
|
+
peak_below=_metres(env, "SUMMITS_PEAK_BELOW", 80.0, 0, 500),
|
|
139
|
+
hut_radius=_metres(env, "SUMMITS_HUT_RADIUS", 60.0, 10, 200),
|
|
140
|
+
pass_radius=_metres(env, "SUMMITS_PASS_RADIUS", 60.0, 10, 200),
|
|
141
|
+
page_title=env.get("SUMMITS_PAGE_TITLE") or "",
|
|
142
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The Intervals activity fields: their definitions, the values for an activity, and when to write.
|
|
2
|
+
|
|
3
|
+
Codes are the same for everyone (charts and scripts keep working); names follow SUMMITS_LANGUAGE
|
|
4
|
+
and may be renamed in Intervals at will – the tool goes by the code.
|
|
5
|
+
|
|
6
|
+
A value is written when the field is empty, or when it still holds what this tool wrote last time.
|
|
7
|
+
A value someone typed or another tool wrote is kept (unless --force). HighestPoint is only ever
|
|
8
|
+
filled when empty: Intervals fills it from a FIT file itself (session enhanced_max_altitude).
|
|
9
|
+
|
|
10
|
+
Optional (SUMMITS_DESCRIPTION=true): one line "⛰️ Summits: …" in the activity description, because
|
|
11
|
+
Intervals' search looks at names and descriptions, not at custom fields. Only that line is ever
|
|
12
|
+
touched; the rest of the description stays as it is.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
ATTRIBUTION = "© OpenStreetMap contributors"
|
|
21
|
+
|
|
22
|
+
DEFS = {
|
|
23
|
+
"Summits": {"names": {"en": "Summits", "de": "Gipfel"}, "type": "text", "icon": "mdi-flag-triangle",
|
|
24
|
+
"description": f"Summits reached on the activity, in order (OpenStreetMap peaks within 100 m of the track; {ATTRIBUTION})"},
|
|
25
|
+
"SummitCount": {"names": {"en": "Summit count", "de": "Gipfelanzahl"}, "type": "numeric", "icon": "mdi-flag-triangle",
|
|
26
|
+
"aggregate": "SUM", "number_format": ".0f",
|
|
27
|
+
"description": "Number of summits reached on the activity (intervals-summits)"},
|
|
28
|
+
"HighestPoint": {"names": {"en": "Highest point", "de": "Höchster Punkt"}, "type": "numeric",
|
|
29
|
+
"icon": "mdi-image-filter-hdr", "aggregate": "MAX", "units": "m", "suffix": "m", "number_format": ".0f",
|
|
30
|
+
"fit_session_field": "enhanced_max_altitude",
|
|
31
|
+
"description": "Highest point of the activity (m): from the FIT file (session enhanced_max_altitude); "
|
|
32
|
+
"otherwise from the altitude stream (intervals-summits)"},
|
|
33
|
+
"HutsPasses": {"names": {"en": "Huts & passes", "de": "Hütten & Pässe"}, "type": "text", "icon": "mdi-home-variant",
|
|
34
|
+
"description": f"Huts and passes on the activity, in order (OpenStreetMap; {ATTRIBUTION})"},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def item(code: str, language: str) -> dict:
|
|
39
|
+
d = DEFS[code]
|
|
40
|
+
name = d["names"].get(language, d["names"]["en"])
|
|
41
|
+
content = {"code": code, "name": name, "type": d["type"], "icon": d["icon"], "inline": True,
|
|
42
|
+
"fit_session_field": d.get("fit_session_field"), "script": None, "processes_fit_messages": False}
|
|
43
|
+
for key in ("aggregate", "number_format", "units", "suffix"):
|
|
44
|
+
if key in d:
|
|
45
|
+
content[key] = d[key]
|
|
46
|
+
if d["type"] == "text":
|
|
47
|
+
content["text_wrap"] = True
|
|
48
|
+
return {"type": "ACTIVITY_FIELD", "visibility": "PRIVATE", "name": name, "description": d["description"], "content": content}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ensure(iv, codes: tuple[str, ...], language: str, apply: bool) -> dict:
|
|
52
|
+
"""Create the fields that are missing (with the next free index, else Intervals' pickers hide them)."""
|
|
53
|
+
items = [i for i in iv.custom_items() if i.get("type") == "ACTIVITY_FIELD"]
|
|
54
|
+
present = {(i.get("content") or {}).get("code") for i in items}
|
|
55
|
+
missing = [c for c in codes if c not in present]
|
|
56
|
+
if apply and missing:
|
|
57
|
+
top = max((i.get("index") or 0 for i in items), default=0)
|
|
58
|
+
for code in missing:
|
|
59
|
+
created = iv.create_custom_item(item(code, language)) or {}
|
|
60
|
+
if created.get("id"):
|
|
61
|
+
top += 1
|
|
62
|
+
iv.reorder_custom_items([dict(created, index=top)])
|
|
63
|
+
log.info("Created the activity field %s", code)
|
|
64
|
+
return {"present": sorted(c for c in codes if c in present), "missing": missing}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def text(visits: list[dict], kinds: tuple[str, ...]) -> str | None:
|
|
68
|
+
parts = [f"{v['name']} ({round(v['ele'])} m)" if v.get("ele") is not None else v["name"]
|
|
69
|
+
for v in visits if v["kind"] in kinds]
|
|
70
|
+
return ", ".join(parts) or None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def wanted(visits: list[dict], max_alt: float | None, codes: tuple[str, ...]) -> dict:
|
|
74
|
+
out = {}
|
|
75
|
+
if "Summits" in codes:
|
|
76
|
+
out["Summits"] = text(visits, ("peak",))
|
|
77
|
+
if "SummitCount" in codes:
|
|
78
|
+
out["SummitCount"] = sum(1 for v in visits if v["kind"] == "peak") or None
|
|
79
|
+
if "HighestPoint" in codes:
|
|
80
|
+
out["HighestPoint"] = round(max_alt) if max_alt is not None else None
|
|
81
|
+
if "HutsPasses" in codes:
|
|
82
|
+
out["HutsPasses"] = text(visits, ("hut", "pass"))
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def same(a, b) -> bool:
|
|
87
|
+
if a in (None, "") or b in (None, ""):
|
|
88
|
+
return a in (None, "") and b in (None, "")
|
|
89
|
+
if isinstance(a, (int, float)) or isinstance(b, (int, float)):
|
|
90
|
+
try:
|
|
91
|
+
return abs(float(a) - float(b)) < 0.5
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
return False
|
|
94
|
+
return str(a).strip() == str(b).strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def decide(code: str, want, current, last: str | None, force: bool = False) -> str:
|
|
98
|
+
"""write | same | kept (someone else's value) | none (nothing to write)."""
|
|
99
|
+
if want is None:
|
|
100
|
+
return "none"
|
|
101
|
+
if same(current, want):
|
|
102
|
+
return "same"
|
|
103
|
+
if current in (None, ""):
|
|
104
|
+
return "write"
|
|
105
|
+
if code == "HighestPoint":
|
|
106
|
+
return "kept"
|
|
107
|
+
if force or (last is not None and same(current, json.loads(last))):
|
|
108
|
+
return "write"
|
|
109
|
+
return "kept"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
DESCRIPTION_LABELS = {"en": "Summits", "de": "Gipfel"}
|
|
113
|
+
MOUNTAIN = "\u26f0\ufe0f"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_our_line(line: str) -> bool:
|
|
117
|
+
s = line.strip().lstrip("\u26f0\ufe0f").strip()
|
|
118
|
+
return line.strip().startswith("\u26f0") and any(s.startswith(f"{label}:") for label in DESCRIPTION_LABELS.values())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def description_line(visits: list[dict], description: str | None, language: str) -> str | None:
|
|
122
|
+
"""None when there are no summits, or when the description names them all already (peakproject's lines)."""
|
|
123
|
+
peaks = [v for v in visits if v["kind"] == "peak"]
|
|
124
|
+
if not peaks:
|
|
125
|
+
return None
|
|
126
|
+
rest = "\n".join(line for line in (description or "").split("\n") if not is_our_line(line))
|
|
127
|
+
if all(v["name"] in rest for v in peaks):
|
|
128
|
+
return None
|
|
129
|
+
return f"{MOUNTAIN} {DESCRIPTION_LABELS.get(language, 'Summits')}: {text(peaks, ('peak',))}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def our_line(description: str | None) -> str | None:
|
|
133
|
+
return next((line.strip() for line in (description or "").split("\n") if is_our_line(line)), None)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def with_line(description: str | None, line: str) -> str:
|
|
137
|
+
"""The description with our line replaced, or appended after a blank line: the rest stays exactly as it is."""
|
|
138
|
+
text = description or ""
|
|
139
|
+
lines = text.split("\n")
|
|
140
|
+
at = [i for i, x in enumerate(lines) if is_our_line(x)]
|
|
141
|
+
if not at:
|
|
142
|
+
if not text.strip():
|
|
143
|
+
return line
|
|
144
|
+
return text + ("" if text.endswith("\n\n") else "\n" if text.endswith("\n") else "\n\n") + line
|
|
145
|
+
lines[at[0]] = line + ("\r" if lines[at[0]].endswith("\r") else "")
|
|
146
|
+
return "\n".join(x for i, x in enumerate(lines) if i not in at[1:])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def keeps_text(old: str | None, new: str) -> bool:
|
|
150
|
+
"""The last check before a description is written: all of the user's text is still there, unchanged."""
|
|
151
|
+
old = old or ""
|
|
152
|
+
if our_line(old) is None:
|
|
153
|
+
start = old if old.strip() else ""
|
|
154
|
+
added = new[len(start):].strip()
|
|
155
|
+
return new.startswith(start) and "\n" not in added and is_our_line(added)
|
|
156
|
+
return [x for x in old.split("\n") if not is_our_line(x)] == [x for x in new.split("\n") if not is_our_line(x)]
|