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.
@@ -0,0 +1,2 @@
1
+ """Summits, huts and passes on Intervals.icu activities, from the GPS track and OpenStreetMap."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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)]