@indaco/garmin-personal-mcp 0.2.0 → 0.3.0

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.
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import process$1 from "node:process";
3
- import { homedir } from "node:os";
4
- import { dirname, join, resolve } from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync } from "node:fs";
5
5
  import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
6
- import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { delimiter, dirname, join, resolve } from "node:path";
7
8
  import { randomBytes } from "node:crypto";
8
9
  import { createRequire } from "module";
9
10
  // node_modules/.pnpm/@modelcontextprotocol+server@2.4.0/node_modules/@modelcontextprotocol/server/dist/chunk-vYduTEsO.mjs
@@ -24093,17 +24094,9 @@ function serveStdio(factory, options = {}) {
24093
24094
  function toError(value) {
24094
24095
  return value instanceof Error ? value : new Error(String(value));
24095
24096
  }
24096
- // src/config.ts
24097
- /** Defaults reuse an existing ~/.config/garmin-mcp/api-key and ~/workspace/garmin-data. */
24098
- function loadConfig(env = process.env) {
24099
- return {
24100
- baseUrl: env.GARMIN_BASE_URL || "https://personal.connectapi.garmin.com",
24101
- apiKeyFile: env.GARMIN_API_KEY_FILE || join(homedir(), ".config/garmin-mcp/api-key"),
24102
- dataDir: env.GARMIN_DATA_DIR || join(homedir(), "workspace/garmin-data")
24103
- };
24104
- }
24105
24097
  // package.json
24106
- var version = "0.2.0";
24098
+ var name = "@indaco/garmin-personal-mcp";
24099
+ var version = "0.3.0";
24107
24100
  // src/log.ts
24108
24101
  /** Past this size the log moves to `<file>.1`, replacing the one before, and a new log starts. */
24109
24102
  const MAX_BYTES = 1e6;
@@ -59005,13 +58998,13 @@ function round(v, decimals) {
59005
58998
  const f = 10 ** decimals;
59006
58999
  return Math.round(v * f) / f;
59007
59000
  }
59008
- const pad = (n) => String(n).padStart(2, "0");
59001
+ const pad$1 = (n) => String(n).padStart(2, "0");
59009
59002
  function hms(seconds) {
59010
59003
  const s = Math.round(seconds);
59011
59004
  const h = Math.floor(s / 3600);
59012
59005
  const m = Math.floor(s % 3600 / 60);
59013
59006
  const sec = s % 60;
59014
- return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
59007
+ return h > 0 ? `${h}:${pad$1(m)}:${pad$1(sec)}` : `${m}:${pad$1(sec)}`;
59015
59008
  }
59016
59009
  // src/tools.ts
59017
59010
  const DAY_MS = 864e5;
@@ -59029,7 +59022,8 @@ var GarminTools = class {
59029
59022
  this.api = api;
59030
59023
  this.dataDir = dataDir;
59031
59024
  }
59032
- async listActivities(startDate, endDate, activityType, limit, raw) {
59025
+ async listActivities(startDate, endDate, activityType, limit, raw, names) {
59026
+ const wanted = names?.map((n) => n.toLowerCase()).filter(Boolean) ?? [];
59033
59027
  const asked = limit == null || limit <= 0 ? 20 : Math.min(limit, 200);
59034
59028
  const search = (max, type, subType) => this.api.getJson("/activitylist-service/activities/search/activities", {
59035
59029
  start: "0",
@@ -59040,14 +59034,15 @@ var GarminTools = class {
59040
59034
  activitySubType: subType
59041
59035
  });
59042
59036
  const refused = (e) => activityType !== void 0 && e instanceof GarminApiError && /-> HTTP 400\b/.test(e.message);
59037
+ const max = wanted.length > 0 ? 200 : asked;
59043
59038
  let json;
59044
59039
  let local = false;
59045
59040
  try {
59046
- json = await search(asked, activityType);
59041
+ json = await search(max, activityType);
59047
59042
  } catch (e) {
59048
59043
  if (!refused(e)) throw e;
59049
59044
  try {
59050
- json = await search(asked, void 0, activityType);
59045
+ json = await search(max, void 0, activityType);
59051
59046
  log("INFO", `activityType ${activityType} refused, asked as activitySubType`);
59052
59047
  } catch (e2) {
59053
59048
  if (!refused(e2)) throw e2;
@@ -59058,8 +59053,13 @@ var GarminTools = class {
59058
59053
  }
59059
59054
  const list = Array.isArray(json) ? json : firstArray(json);
59060
59055
  if (!list) return JSON.stringify(json);
59061
- const rows = local ? list.filter((a) => field$1(isObject$3(a) ? a.activityType : void 0, "typeKey") === activityType).slice(0, asked) : list;
59062
- return JSON.stringify(raw === true ? local ? rows : json : rows.map((a) => summarize(isObject$3(a) ? a : {})));
59056
+ const named = (a) => {
59057
+ const name = isObject$3(a) ? a.activityName : void 0;
59058
+ return typeof name === "string" && wanted.some((n) => name.toLowerCase().includes(n));
59059
+ };
59060
+ const filtered = local || wanted.length > 0;
59061
+ const rows = filtered ? list.filter((a) => (!local || field$2(isObject$3(a) ? a.activityType : void 0, "typeKey") === activityType) && (wanted.length === 0 || named(a))).slice(0, asked) : list;
59062
+ return JSON.stringify(raw === true ? filtered ? rows : json : rows.map((a) => summarize(isObject$3(a) ? a : {})));
59063
59063
  }
59064
59064
  async getActivity(activityId) {
59065
59065
  return JSON.stringify(await this.api.getJson(`/activity-service/activity/${activityId}`, {}));
@@ -59182,7 +59182,7 @@ function summarize(a) {
59182
59182
  const o = {};
59183
59183
  copy$1(a, o, "activityId", "id");
59184
59184
  copy$1(a, o, "activityName", "name");
59185
- const typeKey = asString$1(field$1(a.activityType, "typeKey"));
59185
+ const typeKey = asString$1(field$2(a.activityType, "typeKey"));
59186
59186
  if (typeKey !== null) o.type = typeKey;
59187
59187
  const sport = Number.isInteger(a.sportTypeId) ? SPORTS[a.sportTypeId] : void 0;
59188
59188
  if (sport !== void 0) o.sport = sport;
@@ -59198,7 +59198,7 @@ function summarize(a) {
59198
59198
  copy$1(a, o, "elevationGain", "ascentM");
59199
59199
  const elapsedS = asDouble(a.duration, 0);
59200
59200
  if (elapsedS - durS >= 60) o.elapsed = hms(elapsedS);
59201
- if (asString$1(field$1(a.eventType, "typeKey")) === "race") o.race = true;
59201
+ if (asString$1(field$2(a.eventType, "typeKey")) === "race") o.race = true;
59202
59202
  putRounded(a, o, "activityTrainingLoad", "trainingLoad", 1);
59203
59203
  putRounded(a, o, "aerobicTrainingEffect", "aerobicTE", 1);
59204
59204
  putRounded(a, o, "anaerobicTrainingEffect", "anaerobicTE", 1);
@@ -59242,13 +59242,13 @@ function summarize(a) {
59242
59242
  }
59243
59243
  if (Object.keys(best).length > 0) o.best = best;
59244
59244
  for (const split of Array.isArray(a.splitSummaries) ? a.splitSummaries : []) {
59245
- const type = asString$1(field$1(split, "splitType")) ?? "";
59246
- const speed = asDouble(field$1(split, "averageSpeed"), 0);
59245
+ const type = asString$1(field$2(split, "splitType")) ?? "";
59246
+ const speed = asDouble(field$2(split, "averageSpeed"), 0);
59247
59247
  if (type === "RWD_RUN" && speed > 0) {
59248
59248
  const runWalk = o.runWalk ??= {};
59249
59249
  runWalk.runPace = `${hms(1e3 / speed)}/km`;
59250
- runWalk.runKm = round(asDouble(field$1(split, "distance"), 0) / 1e3, 2);
59251
- } else if (type === "RWD_WALK") (o.runWalk ??= {}).walkTime = hms(asDouble(field$1(split, "duration"), 0));
59250
+ runWalk.runKm = round(asDouble(field$2(split, "distance"), 0) / 1e3, 2);
59251
+ } else if (type === "RWD_WALK") (o.runWalk ??= {}).walkTime = hms(asDouble(field$2(split, "duration"), 0));
59252
59252
  }
59253
59253
  return o;
59254
59254
  }
@@ -59288,7 +59288,7 @@ function copy$1(from, to, name, as) {
59288
59288
  function isObject$3(v) {
59289
59289
  return typeof v === "object" && v !== null && !Array.isArray(v);
59290
59290
  }
59291
- function field$1(v, name) {
59291
+ function field$2(v, name) {
59292
59292
  return isObject$3(v) ? v[name] : void 0;
59293
59293
  }
59294
59294
  /** A number, or text holding one (spaces around it allowed); the default otherwise (null too). */
@@ -59349,9 +59349,10 @@ function registerTools(server, tools) {
59349
59349
  endDate: string().describe("End date yyyy-MM-dd (optional)").optional(),
59350
59350
  activityType: string().describe("Activity type key (optional): a parent type such as running or cycling, or a subtype such as trail_running").optional(),
59351
59351
  limit: number$1().int().describe("Max number of activities, default 20").optional(),
59352
- raw: boolean().describe("Return Garmin's raw JSON instead of the summary").optional()
59352
+ raw: boolean().describe("Return Garmin's raw JSON instead of the summary").optional(),
59353
+ names: array(string()).describe("Activity names (optional): keep only the activities whose name contains one of them, ignoring case").optional()
59353
59354
  })
59354
- }, async (a) => text(await tools.listActivities(a.startDate, a.endDate, a.activityType, a.limit, a.raw)));
59355
+ }, async (a) => text(await tools.listActivities(a.startDate, a.endDate, a.activityType, a.limit, a.raw, a.names)));
59355
59356
  server.registerTool("getActivity", {
59356
59357
  description: "Get the full detail of one activity (summary, metadata, splits/laps) as JSON.",
59357
59358
  inputSchema: object({ activityId })
@@ -59588,16 +59589,16 @@ var GarminDashboard = class {
59588
59589
  failed = true;
59589
59590
  continue;
59590
59591
  }
59591
- const summaries = field(json, "hrvSummaries");
59592
+ const summaries = field$1(json, "hrvSummaries");
59592
59593
  for (const h of Array.isArray(summaries) ? summaries : []) {
59593
- const day = date(field(h, "calendarDate"));
59594
+ const day = date(field$1(h, "calendarDate"));
59594
59595
  if (day === void 0) continue;
59595
59596
  const o = { date: day };
59596
59597
  copy(h, o, "lastNightAvg", "hrv");
59597
59598
  copy(h, o, "weeklyAvg", "weeklyAvg");
59598
59599
  copy(h, o, "status", "status");
59599
- copy(field(h, "baseline"), o, "balancedLow", "low");
59600
- copy(field(h, "baseline"), o, "balancedUpper", "high");
59600
+ copy(field$1(h, "baseline"), o, "balancedLow", "low");
59601
+ copy(field$1(h, "baseline"), o, "balancedUpper", "high");
59601
59602
  byDate[day] = o;
59602
59603
  }
59603
59604
  }
@@ -59691,10 +59692,10 @@ function recoveryRow(day, sleep, summary) {
59691
59692
  hours(sleep, r, "lightSleepSeconds", "light");
59692
59693
  hours(sleep, r, "remSleepSeconds", "rem");
59693
59694
  hours(sleep, r, "awakeSleepSeconds", "awake");
59694
- copy(field(field(sleep, "sleepScores"), "overall"), r, "value", "sleepScore");
59695
- const need = field(field(sleep, "sleepNeed"), "actual");
59695
+ copy(field$1(field$1(sleep, "sleepScores"), "overall"), r, "value", "sleepScore");
59696
+ const need = field$1(field$1(sleep, "sleepNeed"), "actual");
59696
59697
  if (typeof need === "number") r.needH = round2(need / 60);
59697
- const bed = field(sleep, "sleepStartTimestampLocal");
59698
+ const bed = field$1(sleep, "sleepStartTimestampLocal");
59698
59699
  if (typeof bed === "number") {
59699
59700
  const t = new Date(Math.trunc(bed));
59700
59701
  r.bedMin = t.getUTCHours() * 60 + t.getUTCMinutes();
@@ -59705,7 +59706,7 @@ function recoveryRow(day, sleep, summary) {
59705
59706
  copy(summary, r, "averageStressLevel", "stress");
59706
59707
  copy(summary, r, "totalSteps", "steps");
59707
59708
  copy(summary, r, "dailyStepGoal", "stepGoal");
59708
- const floors = field(summary, "floorsAscended");
59709
+ const floors = field$1(summary, "floorsAscended");
59709
59710
  if (typeof floors === "number") r.floors = Math.round(floors);
59710
59711
  return r;
59711
59712
  }
@@ -59766,7 +59767,7 @@ function setCovered(cache, fromField, toField, [from, to]) {
59766
59767
  cache[toField] = to;
59767
59768
  }
59768
59769
  function dayOf$1(a) {
59769
- const s = field(a, "start");
59770
+ const s = field$1(a, "start");
59770
59771
  return typeof s === "string" && s.length >= 10 ? date(s.slice(0, 10)) : void 0;
59771
59772
  }
59772
59773
  /** A yyyy-MM-dd date; undefined when missing or not a real date, so the range is fetched again. */
@@ -59776,11 +59777,11 @@ function date(v) {
59776
59777
  return !Number.isNaN(t) && new Date(t).toISOString().startsWith(v) ? v : void 0;
59777
59778
  }
59778
59779
  function copy(from, to, name, as) {
59779
- const v = field(from, name);
59780
+ const v = field$1(from, name);
59780
59781
  if (v !== void 0 && v !== null) to[as] = v;
59781
59782
  }
59782
59783
  function hours(from, to, name, as) {
59783
- const v = field(from, name);
59784
+ const v = field$1(from, name);
59784
59785
  if (typeof v === "number") to[as] = round2(v / 3600);
59785
59786
  }
59786
59787
  function round2(v) {
@@ -59789,7 +59790,7 @@ function round2(v) {
59789
59790
  function isObject$2(v) {
59790
59791
  return typeof v === "object" && v !== null && !Array.isArray(v);
59791
59792
  }
59792
- function field(v, name) {
59793
+ function field$1(v, name) {
59793
59794
  return isObject$2(v) ? v[name] : void 0;
59794
59795
  }
59795
59796
  /** Text, or a number as text; "" for anything else. */
@@ -60348,6 +60349,7 @@ const BUILT_IN = {
60348
60349
  "footer.license": "Licenza MIT",
60349
60350
  "menu.language": "Lingua",
60350
60351
  "lang.auto": "Automatica",
60352
+ "lang.missing": "{language} non è ancora in questa pagina. Chiedi al tuo assistente di aggiungere la lingua.",
60351
60353
  "themeGroup.auto": "Automatico",
60352
60354
  "themeGroup.dark": "Scuri",
60353
60355
  "themeGroup.light": "Chiari",
@@ -60368,6 +60370,13 @@ const BUILT_IN = {
60368
60370
  "window.all": "Tutte",
60369
60371
  "window.from": "Dalla settimana",
60370
60372
  "window.to": "Alla settimana",
60373
+ "names.label": "Nomi delle attività",
60374
+ "names.all": "Tutti",
60375
+ "names.count": "{n} scelto|{n} scelti",
60376
+ "names.search": "Cerca nomi",
60377
+ "names.noMatch": "Nessun nome corrisponde.",
60378
+ "names.remove": "Togli {name}",
60379
+ "names.clear": "Mostra tutte",
60371
60380
  "type.all": "Tutte",
60372
60381
  "type.recovery": "Recupero",
60373
60382
  "type.running": "Corsa",
@@ -60499,6 +60508,8 @@ const BUILT_IN = {
60499
60508
  "add.close": "Chiudi",
60500
60509
  "add.hiddenGroup": "Nascosti in questa scheda",
60501
60510
  "add.none": "Tutti i widget sono già in questa scheda.",
60511
+ "add.search": "Cerca widget",
60512
+ "add.noMatch": "Nessun widget corrisponde.",
60502
60513
  "copy.title": "Copia layout",
60503
60514
  "copy.hint": "Questo layout è salvato solo in questo browser. Per tenerlo nella pagina, incolla questo testo al tuo assistente e chiedigli di salvare il layout della dashboard.",
60504
60515
  "copy.copy": "Copia",
@@ -60791,6 +60802,7 @@ const BUILT_IN = {
60791
60802
  "footer.license": "MIT-Lizenz",
60792
60803
  "menu.language": "Sprache",
60793
60804
  "lang.auto": "Automatisch",
60805
+ "lang.missing": "{language} ist noch nicht auf dieser Seite. Bitte deinen Assistenten, die Sprache hinzuzufügen.",
60794
60806
  "themeGroup.auto": "Automatisch",
60795
60807
  "themeGroup.dark": "Dunkel",
60796
60808
  "themeGroup.light": "Hell",
@@ -60811,6 +60823,13 @@ const BUILT_IN = {
60811
60823
  "window.all": "Alle",
60812
60824
  "window.from": "Von Woche",
60813
60825
  "window.to": "Bis Woche",
60826
+ "names.label": "Aktivitätsnamen",
60827
+ "names.all": "Alle",
60828
+ "names.count": "{n} gewählt|{n} gewählt",
60829
+ "names.search": "Namen suchen",
60830
+ "names.noMatch": "Kein Name passt.",
60831
+ "names.remove": "{name} entfernen",
60832
+ "names.clear": "Alle zeigen",
60814
60833
  "type.all": "Alle",
60815
60834
  "type.recovery": "Erholung",
60816
60835
  "type.running": "Laufen",
@@ -60942,6 +60961,8 @@ const BUILT_IN = {
60942
60961
  "add.close": "Schließen",
60943
60962
  "add.hiddenGroup": "Auf diesem Board ausgeblendet",
60944
60963
  "add.none": "Alle Widgets sind auf diesem Board.",
60964
+ "add.search": "Widgets suchen",
60965
+ "add.noMatch": "Kein Widget passt.",
60945
60966
  "copy.title": "Layout kopieren",
60946
60967
  "copy.hint": "Dieses Layout ist nur in diesem Browser gespeichert. Um es in der Seite zu behalten, füge diesen Text bei deinem Assistenten ein und bitte ihn, das Dashboard-Layout zu speichern.",
60947
60968
  "copy.copy": "Kopieren",
@@ -61234,6 +61255,7 @@ const BUILT_IN = {
61234
61255
  "footer.license": "Licence MIT",
61235
61256
  "menu.language": "Langue",
61236
61257
  "lang.auto": "Automatique",
61258
+ "lang.missing": "{language} n'est pas encore dans cette page. Demande à ton assistant d'ajouter la langue.",
61237
61259
  "themeGroup.auto": "Automatique",
61238
61260
  "themeGroup.dark": "Sombres",
61239
61261
  "themeGroup.light": "Clairs",
@@ -61254,6 +61276,13 @@ const BUILT_IN = {
61254
61276
  "window.all": "Toutes",
61255
61277
  "window.from": "De la semaine",
61256
61278
  "window.to": "À la semaine",
61279
+ "names.label": "Noms des activités",
61280
+ "names.all": "Tous",
61281
+ "names.count": "{n} choisi|{n} choisis",
61282
+ "names.search": "Rechercher un nom",
61283
+ "names.noMatch": "Aucun nom ne correspond.",
61284
+ "names.remove": "Retirer {name}",
61285
+ "names.clear": "Tout afficher",
61257
61286
  "type.all": "Toutes",
61258
61287
  "type.recovery": "Récupération",
61259
61288
  "type.running": "Course",
@@ -61385,6 +61414,8 @@ const BUILT_IN = {
61385
61414
  "add.close": "Fermer",
61386
61415
  "add.hiddenGroup": "Masqués sur ce tableau",
61387
61416
  "add.none": "Tous les widgets sont sur ce tableau.",
61417
+ "add.search": "Rechercher un widget",
61418
+ "add.noMatch": "Aucun widget ne correspond.",
61388
61419
  "copy.title": "Copier la mise en page",
61389
61420
  "copy.hint": "Cette mise en page est enregistrée uniquement dans ce navigateur. Pour la garder dans la page, colle ce texte à ton assistant et demande-lui d'enregistrer la mise en page du tableau de bord.",
61390
61421
  "copy.copy": "Copier",
@@ -61677,6 +61708,7 @@ const BUILT_IN = {
61677
61708
  "footer.license": "Licencia MIT",
61678
61709
  "menu.language": "Idioma",
61679
61710
  "lang.auto": "Automático",
61711
+ "lang.missing": "{language} aún no está en esta página. Pide a tu asistente que añada el idioma.",
61680
61712
  "themeGroup.auto": "Automático",
61681
61713
  "themeGroup.dark": "Oscuros",
61682
61714
  "themeGroup.light": "Claros",
@@ -61697,6 +61729,13 @@ const BUILT_IN = {
61697
61729
  "window.all": "Todas",
61698
61730
  "window.from": "Desde la semana",
61699
61731
  "window.to": "Hasta la semana",
61732
+ "names.label": "Nombres de actividad",
61733
+ "names.all": "Todos",
61734
+ "names.count": "{n} elegido|{n} elegidos",
61735
+ "names.search": "Buscar nombres",
61736
+ "names.noMatch": "Ningún nombre coincide.",
61737
+ "names.remove": "Quitar {name}",
61738
+ "names.clear": "Mostrar todas",
61700
61739
  "type.all": "Todas",
61701
61740
  "type.recovery": "Recuperación",
61702
61741
  "type.running": "Carrera",
@@ -61828,6 +61867,8 @@ const BUILT_IN = {
61828
61867
  "add.close": "Cerrar",
61829
61868
  "add.hiddenGroup": "Ocultos en esta pestaña",
61830
61869
  "add.none": "Todos los widgets ya están en esta pestaña.",
61870
+ "add.search": "Buscar widgets",
61871
+ "add.noMatch": "Ningún widget coincide.",
61831
61872
  "copy.title": "Copiar diseño",
61832
61873
  "copy.hint": "Este diseño solo está guardado en este navegador. Para conservarlo en la página, pega este texto a tu asistente y pídele que guarde el diseño del panel.",
61833
61874
  "copy.copy": "Copiar",
@@ -62120,6 +62161,7 @@ const BUILT_IN = {
62120
62161
  "footer.license": "Licença MIT",
62121
62162
  "menu.language": "Idioma",
62122
62163
  "lang.auto": "Automático",
62164
+ "lang.missing": "{language} ainda não está nesta página. Peça ao seu assistente para adicionar o idioma.",
62123
62165
  "themeGroup.auto": "Automático",
62124
62166
  "themeGroup.dark": "Escuros",
62125
62167
  "themeGroup.light": "Claros",
@@ -62140,6 +62182,13 @@ const BUILT_IN = {
62140
62182
  "window.all": "Todas",
62141
62183
  "window.from": "Da semana",
62142
62184
  "window.to": "Até a semana",
62185
+ "names.label": "Nomes das atividades",
62186
+ "names.all": "Todos",
62187
+ "names.count": "{n} escolhido|{n} escolhidos",
62188
+ "names.search": "Pesquisar nomes",
62189
+ "names.noMatch": "Nenhum nome corresponde.",
62190
+ "names.remove": "Remover {name}",
62191
+ "names.clear": "Mostrar todas",
62143
62192
  "type.all": "Todas",
62144
62193
  "type.recovery": "Recuperação",
62145
62194
  "type.running": "Corrida",
@@ -62271,6 +62320,8 @@ const BUILT_IN = {
62271
62320
  "add.close": "Fechar",
62272
62321
  "add.hiddenGroup": "Ocultos neste quadro",
62273
62322
  "add.none": "Todos os widgets já estão neste quadro.",
62323
+ "add.search": "Pesquisar widgets",
62324
+ "add.noMatch": "Nenhum widget corresponde.",
62274
62325
  "copy.title": "Copiar layout",
62275
62326
  "copy.hint": "Este layout está salvo apenas neste navegador. Para mantê-lo na página, cole este texto na conversa com o seu assistente e peça que ele salve o layout do painel.",
62276
62327
  "copy.copy": "Copiar",
@@ -62565,6 +62616,7 @@ var en_default = {
62565
62616
  "footer.license": "MIT License",
62566
62617
  "menu.language": "Language",
62567
62618
  "lang.auto": "Auto",
62619
+ "lang.missing": "{language} is not in this page yet. Ask your assistant to add it.",
62568
62620
  "themeGroup.auto": "Auto",
62569
62621
  "themeGroup.dark": "Dark",
62570
62622
  "themeGroup.light": "Light",
@@ -62585,6 +62637,13 @@ var en_default = {
62585
62637
  "window.all": "All",
62586
62638
  "window.from": "From week",
62587
62639
  "window.to": "To week",
62640
+ "names.label": "Activity names",
62641
+ "names.all": "All",
62642
+ "names.count": "{n} chosen|{n} chosen",
62643
+ "names.search": "Search names",
62644
+ "names.noMatch": "No name matches.",
62645
+ "names.remove": "Remove {name}",
62646
+ "names.clear": "Show all",
62588
62647
  "type.all": "All",
62589
62648
  "type.recovery": "Recovery",
62590
62649
  "type.running": "Run",
@@ -62716,6 +62775,8 @@ var en_default = {
62716
62775
  "add.close": "Close",
62717
62776
  "add.hiddenGroup": "Hidden on this board",
62718
62777
  "add.none": "Every widget is on this board.",
62778
+ "add.search": "Search widgets",
62779
+ "add.noMatch": "No widget matches.",
62719
62780
  "copy.title": "Copy layout",
62720
62781
  "copy.hint": "This layout is saved in this browser only. To keep it in the page, paste this text to your assistant and ask it to save the dashboard layout.",
62721
62782
  "copy.copy": "Copy",
@@ -63219,22 +63280,25 @@ ctx gives:
63219
63280
  - ctx.data: the getDashboardData result (activities, previousActivities, hrv, recovery, zones, fitnessAge, sync, units) and notes (plain text). The data is always metric (km, m, kg, seconds per km); units is the measurement system of the Garmin profile (metric, statute_us or statute_uk).
63220
63281
  - ctx.type: "all" or the board the widget shows: running, strengthTraining, walking, hiking, cycling, swimming, racket, generic (Other), or another SDK sport name such as alpineSkiing. Each activity has type (the Garmin type key) and sport (the SDK sport name).
63221
63282
  - ctx.from, ctx.to: Mondays of the first and last week of the window; ctx.weeks: every Monday in it.
63222
- - ctx.activities: the activities of the window and of ctx.type. ctx.allActivities: every type. ctx.ofType(list): the same type filter for any list.
63283
+ - ctx.activities: the activities of the window and of ctx.type. ctx.allActivities: every type. ctx.ofType(list): the same type filter for any list. When the user chooses activity names on the page, ctx.activities and ctx.ofType keep only the activities with those names; ctx.allActivities and ctx.data keep every activity.
63223
63284
  - ctx.t(key, params): a page text; ctx.lang: the page language.
63285
+ - ctx.text(key, params): a text of the widget's own texts in the page language, else in English, with its {placeholders} filled from params.
63224
63286
  - ctx.fmt: num(v, digits), pct(v), day("yyyy-MM-dd"), longDay(d), hms(seconds), hours(seconds), na (the text for a missing value). In the profile's units, from metric data: dist(km, digits), pace(secondsPerKm), speed(kmh), elev(m), mass(kg), swimPace(secondsPer100m). ctx.fmt.to.dist/pace/speed/elev/mass/swim(value) give the number only, for sums and charts; ctx.fmt.unit.dist/pace/speed/elev/mass/swim give the unit names, for axis titles. Show every distance, pace, speed, elevation and weight with these, never with a fixed "km" or "kg".
63225
63287
  - ctx.calc: groupOf(activity) (its board), sportOf(typeKey), seconds("1:02:03"), sessionSeconds(activity), dayOf(activity), mondayOf(day), weekly(activities, weeks, a => value), loadOf(activity, maxHR), loadRatio(activities, day, maxHR), dailyLoads(activities, from, to, maxHR), mean, sd, rolling(values, n), change(now, before), zonesOf(ctx.data.zones), zoneSeconds(activity, floors), paceSeconds(activity), efficiency(activity), epley(kg, reps), riegel(seconds, km, km), exerciseStats(activities), muscleSets(activities, weeks), swimPace(activity), verticalPerHour(activity).
63226
63288
  - ctx.chart(el, config, heightPx): a Chart.js 4 chart that follows the theme, with tooltips. Chart types: bar, line, scatter, doughnut, pie, with the category and linear scales.
63227
63289
  - ctx.color(token): a theme colour: text, muted, accent, good, bad, warn, line, panel, panel2, or a board (running, strengthTraining, walking, hiking, cycling, swimming, racket, generic); another sport gets the generic colour.
63228
63290
  - ctx.empty(el, message): a short message in place of the content.
63229
63291
 
63292
+ Texts: give every text that the widget shows in its texts, by language code and key: { en: { title: "...", best: "Best: {value}" }, it: { ... } }. title is the widget's title. Write English and each language of pageLanguages (from editDashboard), with the same keys and {placeholders}. Draw texts with ctx.text(key, params), never as fixed strings in the code. A missing text shows in English.
63293
+
63230
63294
  Rules: build elements with document.createElement and set text with textContent, never innerHTML with data. Use only values in ctx.data; show ctx.fmt.na for a missing value; label an estimate as one. Keep the code short. Requests from the page are blocked: fetch, XMLHttpRequest, and images, scripts and styles from the web.
63231
63295
 
63232
- Example (the longest ride of the window):
63296
+ Example (the longest ride of the window), with texts { en: { title: "Longest ride", best: "{distance} on {day}" } }:
63233
63297
  const rides = ctx.activities.filter((a) => ctx.calc.groupOf(a) === "cycling" && typeof a.km === "number");
63234
63298
  if (rides.length === 0) return ctx.empty(el);
63235
63299
  const best = rides.reduce((x, y) => (y.km > x.km ? y : x));
63236
63300
  const p = document.createElement("p");
63237
- p.textContent = \`\${ctx.fmt.dist(best.km)} · \${best.name ?? ""} · \${ctx.fmt.day(best.start.slice(0, 10))}\`;
63301
+ p.textContent = ctx.text("best", { distance: ctx.fmt.dist(best.km), day: ctx.fmt.day(best.start.slice(0, 10)) });
63238
63302
  el.append(p);`;
63239
63303
  // src/prompts.ts
63240
63304
  /**
@@ -63264,6 +63328,8 @@ function registerPrompts(server) {
63264
63328
  const layout = layoutOf(arg(args.layout) === "" ? args.weeks : args.layout);
63265
63329
  const text = layout === "freeform" ? renderDashboard(freeform, w) : render(renderDashboard(build, w), {
63266
63330
  layout,
63331
+ title: en_default.title,
63332
+ headline: en_default.headline,
63267
63333
  builtIn: Object.keys(BUILT_IN).map((code) => `${languageName(code)} (${code})`).join(", "),
63268
63334
  layoutNote: layout === "blank" ? "\nThe page is an empty grid: tell me to add widgets with \"Edit layout\" and \"Add widget\"." : ""
63269
63335
  });
@@ -63398,6 +63464,8 @@ function weeks(v) {
63398
63464
  if (!/^[+-]?\d+$/.test(s)) return DEFAULT_WEEKS;
63399
63465
  return Math.min(Math.max(Number(s), 1), MAX_WEEKS);
63400
63466
  }
63467
+ /** The user's custom widgets, in the data dir: every page from the template gets them. */
63468
+ const LIBRARY_FILE = "widgets.json";
63401
63469
  const MAX_NOTES = 5;
63402
63470
  const REUSE_MS = 9e5;
63403
63471
  const marker = (b) => new RegExp(`/\\*\\s*garmin-${b}:start\\s*\\*/([\\s\\S]*?)/\\*\\s*garmin-${b}:end\\s*\\*/`);
@@ -63497,6 +63565,8 @@ function withData(page, data, s, notes) {
63497
63565
  return writeBlock(next, "data", {
63498
63566
  ...filterData(data, s),
63499
63567
  v: 3,
63568
+ server: version,
63569
+ updated: (/* @__PURE__ */ new Date()).toISOString(),
63500
63570
  notes: kept.map(escapeHtml)
63501
63571
  });
63502
63572
  }
@@ -63577,13 +63647,14 @@ var DashboardWriter = class {
63577
63647
  this.#queue = run.catch(() => {});
63578
63648
  return run;
63579
63649
  }
63580
- async #write({ weeks, notes = [], layout, page, path, settings }) {
63650
+ async #write({ weeks, notes = [], layout, page, path, settings, onlyExisting }) {
63581
63651
  if (layout && page) throw new Error("Give either `layout` (a page from the template) or `page` (your own page), not both");
63582
63652
  if (page !== void 0 && !marker("data").test(page)) throw new Error("`page` has no /* garmin-data:start */ ... /* garmin-data:end */ block");
63583
63653
  if (notes.length > MAX_NOTES) throw new Error(`At most ${MAX_NOTES} notes`);
63584
63654
  const asked = settings === void 0 ? void 0 : checkSettings(settings);
63585
63655
  const target = this.#target(path);
63586
63656
  const old = await readExisting(target);
63657
+ if (onlyExisting && old === void 0) throw new Error(`${target} does not exist: make it first with the training-dashboard prompt`);
63587
63658
  let oldSettings;
63588
63659
  let unreadable = false;
63589
63660
  try {
@@ -63618,6 +63689,7 @@ var DashboardWriter = class {
63618
63689
  }
63619
63690
  if (Object.keys(own).length > 0 && !marker("settings").test(next)) throw new Error("Settings need a page from the template; this page has no garmin-settings block");
63620
63691
  if (page === void 0 && next !== old) next = styled(next, own);
63692
+ if (page === void 0) next = await this.#withLibrary(next, old);
63621
63693
  const raw = JSON.parse(await this.#data(weeks ?? weeksOf(old)));
63622
63694
  next = withData(next, raw, own, notes);
63623
63695
  let backup;
@@ -63634,7 +63706,9 @@ var DashboardWriter = class {
63634
63706
  digest: digest(filterData(raw, own)),
63635
63707
  pageLanguages: pageLanguages(next, own),
63636
63708
  settings: own,
63637
- ...hasLanguages(next) ? { languages: missingTexts(next) } : {}
63709
+ ...hasLanguages(next) ? { languages: missingTexts(next) } : {},
63710
+ ...page === void 0 && missingLanguage(next, raw.locale) ? { missingProfileLanguage: raw.locale } : {},
63711
+ ...page === void 0 ? withMissing(widgetGaps(next, pageLanguages(next, own))) : {}
63638
63712
  };
63639
63713
  }
63640
63714
  /** Changes the layout, the custom widgets or the languages of a page from the template; no options only reads. */
@@ -63650,6 +63724,7 @@ var DashboardWriter = class {
63650
63724
  let page = old;
63651
63725
  let layout;
63652
63726
  let widgets;
63727
+ let library;
63653
63728
  let texts;
63654
63729
  let settings;
63655
63730
  try {
@@ -63662,6 +63737,8 @@ var DashboardWriter = class {
63662
63737
  } catch (e) {
63663
63738
  throw new Error(`${e.message} in ${target}`, { cause: e });
63664
63739
  }
63740
+ library = await this.#library(widgets);
63741
+ widgets = merged(widgets, library);
63665
63742
  let layoutChanged = false;
63666
63743
  if (o.layout !== void 0) {
63667
63744
  layout = parseLayout(o.layout);
@@ -63670,8 +63747,26 @@ var DashboardWriter = class {
63670
63747
  for (const w of o.widgets ?? []) {
63671
63748
  const clean = checkWidget(w);
63672
63749
  widgets = [...widgets.filter((x) => x.id !== clean.id), clean];
63750
+ library = [...library.filter((x) => x.id !== clean.id), clean];
63751
+ }
63752
+ for (const [id, byLang] of Object.entries(o.widgetTexts ?? {})) {
63753
+ const w = widgets.find((x) => x.id === id);
63754
+ if (!w) throw new Error(`Unknown custom widget ${id}`);
63755
+ const own = { ...w.texts };
63756
+ for (const [lang, entries] of Object.entries(byLang)) own[lang] = {
63757
+ ...own[lang],
63758
+ ...entries
63759
+ };
63760
+ const clean = checkWidget({
63761
+ ...w,
63762
+ texts: own
63763
+ });
63764
+ widgets = widgets.map((x) => x.id === id ? clean : x);
63765
+ library = library.map((x) => x.id === id ? clean : x);
63673
63766
  }
63674
- for (const id of o.removeWidgets ?? []) {
63767
+ const forget = new Set(o.forgetWidgets);
63768
+ library = library.filter((x) => !forget.has(x.id));
63769
+ for (const id of [...o.removeWidgets ?? [], ...forget]) {
63675
63770
  widgets = widgets.filter((x) => x.id !== id);
63676
63771
  for (const [board, list] of Object.entries(layout.boards)) if (list.some((i) => i.widget === id)) {
63677
63772
  layout = withBoard(layout, board, list.filter((i) => i.widget !== id));
@@ -63698,7 +63793,8 @@ var DashboardWriter = class {
63698
63793
  if (!Object.hasOwn(texts, lang)) throw new Error(`The page has no added texts for ${lang}: built-in languages are chosen with writeDashboard settings.languages`);
63699
63794
  delete texts[lang];
63700
63795
  }
63701
- const changed = layoutChanged || o.widgets !== void 0 || o.removeWidgets !== void 0 || o.languages !== void 0 || o.removeLanguages !== void 0;
63796
+ const changed = layoutChanged || o.widgets !== void 0 || o.removeWidgets !== void 0 || o.forgetWidgets !== void 0 || o.widgetTexts !== void 0 || o.languages !== void 0 || o.removeLanguages !== void 0;
63797
+ if (o.widgets !== void 0 || o.forgetWidgets !== void 0 || o.widgetTexts !== void 0) await this.#saveLibrary(library);
63702
63798
  if (changed) {
63703
63799
  if (layoutChanged) layout = {
63704
63800
  ...layout,
@@ -63713,12 +63809,15 @@ var DashboardWriter = class {
63713
63809
  path: target,
63714
63810
  changed,
63715
63811
  layout,
63716
- customWidgets: widgets.map(({ id, title, span, types }) => ({
63717
- id,
63718
- title,
63719
- span: span ?? 1,
63720
- ...types ? { types } : {}
63812
+ customWidgets: widgets.map((w) => ({
63813
+ id: w.id,
63814
+ title: w.title,
63815
+ span: spanOf(w.span),
63816
+ ...w.types ? { types: w.types } : {},
63817
+ ...w.texts ? { texts: w.texts } : {},
63818
+ library: library.some((l) => l.id === w.id)
63721
63819
  })),
63820
+ pageLanguages: pageLanguages(page, settings),
63722
63821
  languages: missingTexts(page),
63723
63822
  catalogue: CATALOGUE.filter((c) => !c.needs || !settings.exclude?.includes(c.needs)).map((c) => ({
63724
63823
  id: c.id,
@@ -63731,6 +63830,38 @@ var DashboardWriter = class {
63731
63830
  ...o.englishTexts ? { englishTexts: en_default } : {}
63732
63831
  };
63733
63832
  }
63833
+ /**
63834
+ * The widget library. Without a library file, the first page that has custom widgets starts it, so
63835
+ * the widgets made before the library existed join it.
63836
+ */
63837
+ async #library(seed) {
63838
+ const file = resolve(this.dataDir, LIBRARY_FILE);
63839
+ let text;
63840
+ try {
63841
+ text = await readFile(file, "utf8");
63842
+ } catch (e) {
63843
+ if (e.code !== "ENOENT") throw e;
63844
+ if (seed.length > 0) await this.#saveLibrary(seed);
63845
+ return seed;
63846
+ }
63847
+ try {
63848
+ const list = JSON.parse(text);
63849
+ if (!Array.isArray(list)) throw new Error("not a list of widgets");
63850
+ return list.map((w) => checkWidget(w));
63851
+ } catch (e) {
63852
+ throw new Error(`${file}: ${e.message}; correct or delete the file`, { cause: e });
63853
+ }
63854
+ }
63855
+ #saveLibrary(widgets) {
63856
+ return writeAtomically(resolve(this.dataDir, LIBRARY_FILE), `${JSON.stringify(widgets, null, 2)}\n`);
63857
+ }
63858
+ /** The page with the library in its widgets block; `old` is the page that it replaces, to start the library. */
63859
+ async #withLibrary(page, old) {
63860
+ if (!marker("widgets").test(page)) return page;
63861
+ const own = readBlock(page, "widgets") ?? [];
63862
+ const before = old === void 0 ? [] : readBlock(old, "widgets") ?? [];
63863
+ return writeBlock(page, "widgets", merged(own, await this.#library(before)));
63864
+ }
63734
63865
  /** The data for `weeks`; the same snapshot for 15 minutes, so the notes match the digest they come from. */
63735
63866
  async #data(weeks) {
63736
63867
  const covered = weeksCovered(weeks);
@@ -63746,11 +63877,15 @@ var DashboardWriter = class {
63746
63877
  }
63747
63878
  /** The page file: relative paths sit in the data dir; only .html files. */
63748
63879
  #target(path) {
63749
- const target = resolve(this.dataDir, (path ?? "garmin-dashboard.html").replace(/^~(?=$|[/\\])/, homedir()));
63750
- if (!/\.html$/i.test(target)) throw new Error(`The dashboard must be an .html file: ${target}`);
63751
- return target;
63880
+ return pageTarget(this.dataDir, path);
63752
63881
  }
63753
63882
  };
63883
+ /** The absolute page file: a relative path is inside the data dir; only .html files. */
63884
+ function pageTarget(dataDir, path) {
63885
+ const target = resolve(dataDir, (path || "garmin-dashboard.html").replace(/^~(?=$|[/\\])/, homedir()));
63886
+ if (!/\.html$/i.test(target)) throw new Error(`The dashboard must be an .html file: ${target}`);
63887
+ return target;
63888
+ }
63754
63889
  /** The boards: All, Recovery, the type boards, and a board for any other SDK sport. */
63755
63890
  const TYPES = /* @__PURE__ */ new Set([
63756
63891
  "all",
@@ -63759,6 +63894,8 @@ const TYPES = /* @__PURE__ */ new Set([
63759
63894
  ...Object.values(Profile.types.sport)
63760
63895
  ]);
63761
63896
  const spanOf = (v) => Math.min(3, Math.max(1, Math.round(Number(v) || 1)));
63897
+ /** The page's custom widgets and the library; the library version wins on the same id. */
63898
+ const merged = (own, library) => [...own.filter((w) => !library.some((l) => l.id === w.id)), ...library];
63762
63899
  /** A custom widget that the page can run; throws with what is wrong. */
63763
63900
  function checkWidget(w) {
63764
63901
  if (CATALOGUE.some((c) => c.id === w.id)) throw new Error(`${w.id} is a built-in widget: give the custom widget another id`);
@@ -63767,14 +63904,41 @@ function checkWidget(w) {
63767
63904
  if (!w.code?.trim() || w.code.length > 2e4) throw new Error("A widget needs code, at most 20000 characters");
63768
63905
  const unknown = (w.types ?? []).filter((t) => !TYPES.has(t));
63769
63906
  if (unknown.length > 0) throw new Error(`Unknown widget types: ${unknown.join(", ")}`);
63907
+ const texts = checkWidgetTexts(w);
63770
63908
  return {
63771
63909
  id: w.id,
63772
63910
  title: w.title.trim(),
63773
63911
  code: w.code,
63774
63912
  span: spanOf(w.span),
63775
- ...w.types?.length ? { types: w.types } : {}
63913
+ ...w.types?.length ? { types: w.types } : {},
63914
+ ...texts ? { texts } : {}
63776
63915
  };
63777
63916
  }
63917
+ /** The texts of a custom widget: language codes, short keys, and the placeholders of the English text. */
63918
+ function checkWidgetTexts(w) {
63919
+ const texts = w.texts;
63920
+ if (texts === void 0 || Object.keys(texts).length === 0) return void 0;
63921
+ if (JSON.stringify(texts).length > 2e4) throw new Error(`The texts of ${w.id} are too long: at most 20000 characters`);
63922
+ const problems = [];
63923
+ for (const [lang, entries] of Object.entries(texts)) {
63924
+ if (!/^[a-z]{2,3}(-[A-Z]{2})?$/.test(lang)) {
63925
+ problems.push(`bad language code ${lang}`);
63926
+ continue;
63927
+ }
63928
+ if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
63929
+ problems.push(`${lang}: texts by key`);
63930
+ continue;
63931
+ }
63932
+ for (const [key, text] of Object.entries(entries)) {
63933
+ const english = texts.en?.[key];
63934
+ if (!/^[a-zA-Z][\w.-]{0,40}$/.test(key)) problems.push(`bad key ${key}`);
63935
+ else if (typeof text !== "string" || !text.trim() || text.length > 500) problems.push(`${lang}.${key}: 1 to 500 characters`);
63936
+ else if (english !== void 0 && placeholders(text) !== placeholders(english)) problems.push(`${lang}.${key}: keep the placeholders ${placeholders(english) || "(none)"}`);
63937
+ }
63938
+ }
63939
+ if (problems.length > 0) throw new Error(`Texts of ${w.id}: ${problems.join("; ")}`);
63940
+ return texts;
63941
+ }
63778
63942
  const placeholders = (s) => [...new Set(s.match(/\{\w+\}/g) ?? [])].toSorted().join(" ");
63779
63943
  /** Texts of a language: known keys only, strings, with the placeholders of the English text. */
63780
63944
  function checkTexts(lang, entries) {
@@ -63799,6 +63963,30 @@ function pageLanguages(page, s) {
63799
63963
  ];
63800
63964
  return [...new Set(codes)];
63801
63965
  }
63966
+ /**
63967
+ * For each custom widget with texts, the page languages it has no texts in. A widget without texts
63968
+ * (written before widgets had them) shows fixed text, so there is nothing to add.
63969
+ */
63970
+ function widgetGaps(page, languages) {
63971
+ const out = {};
63972
+ for (const w of readBlock(page, "widgets") ?? []) {
63973
+ if (!w.texts) continue;
63974
+ const gaps = languages.filter((code) => !w.texts[code] && !w.texts[code.slice(0, 2)]);
63975
+ if (gaps.length > 0) out[w.id] = gaps;
63976
+ }
63977
+ return out;
63978
+ }
63979
+ const withMissing = (gaps) => Object.keys(gaps).length > 0 ? { missingWidgetTexts: gaps } : {};
63980
+ /** True when the profile locale is not English, not built in and not added to the page. */
63981
+ function missingLanguage(page, locale) {
63982
+ if (typeof locale !== "string" || !locale) return false;
63983
+ const known = [
63984
+ "en",
63985
+ ...Object.keys(BUILT_IN),
63986
+ ...Object.keys(readBlock(page, "i18n") ?? {})
63987
+ ];
63988
+ return ![locale, locale.slice(0, 2)].some((code) => known.includes(code));
63989
+ }
63802
63990
  /** For each language of the page, the keys that it does not translate. */
63803
63991
  function missingTexts(page) {
63804
63992
  const t = readBlock(page, "i18n") ?? {};
@@ -63819,14 +64007,19 @@ function registerDashboardWriter(server, writer) {
63819
64007
  "last `weeks` weeks, and returns its path and a short digest of the data (totals, weekly sums, load,",
63820
64008
  "best efforts, races, strength top sets, HRV, recovery, fitness age, sync) for the coach notes.",
63821
64009
  "An existing page keeps all its code, layout and widgets: only its data is replaced. A page from an",
63822
- "older template gets the new template and keeps its layout and widgets. `layout` writes a new page",
64010
+ "older template gets the new template and keeps its layout and widgets. Every page from the template",
64011
+ "also gets the custom widgets of the widget library (<data-dir>/widgets.json), not placed on a board.",
64012
+ "`layout` writes a new page",
63823
64013
  "from the template: `preset` (the default widgets) or `blank` (empty boards). `page` writes your own",
63824
64014
  "page code instead; it must contain /* garmin-data:start */null/* garmin-data:end */. A replaced",
63825
64015
  "page is kept as <file>.bak. `notes`: at most 5 plain-text coach notes; the server escapes them.",
63826
64016
  "`settings` replace the page's settings (title, headline, sports, data to leave out, languages,",
63827
64017
  "themes); without them the page keeps its own. The page holds only the data, languages and themes",
63828
64018
  "they keep. `pageLanguages` in the result lists the languages of the page's Language menu, and",
63829
- "`settings` the settings that the page keeps.",
64019
+ "`settings` the settings that the page keeps. `missingProfileLanguage` is the language of the Garmin",
64020
+ "profile when the page has no texts in it (it shows English); editDashboard `languages` adds it.",
64021
+ "`missingWidgetTexts` lists the custom widgets that miss texts in a language of the page, with",
64022
+ "those languages; editDashboard `widgetTexts` adds them.",
63830
64023
  "`preview: true` writes nothing: it returns the digest and the activities of each board."
63831
64024
  ].join("\n"),
63832
64025
  inputSchema: object({
@@ -63858,7 +64051,10 @@ function registerDashboardWriter(server, writer) {
63858
64051
  "custom widgets, the missing texts of each language, the catalogue of built-in widgets and `widgetApi`,",
63859
64052
  "the guide to writing a custom widget. `layout` replaces the whole layout (the text of the page's",
63860
64053
  "'Copy layout'); `place` adds widgets at the end of a board; `widgets` adds or replaces custom widgets",
63861
- "by id; `removeWidgets` removes them, also from the boards. `languages` adds texts by key, e.g.",
64054
+ "by id and saves them in the widget library (<data-dir>/widgets.json), which every page from the",
64055
+ "template gets; `removeWidgets` removes them from this page and its boards, `forgetWidgets` also",
64056
+ "from the library. `widgetTexts` adds texts to custom widgets by id, language and key, e.g.",
64057
+ "{\"longest-ride\": {\"de\": {\"title\": \"Längste Fahrt\"}}}. `languages` adds page texts by key, e.g.",
63862
64058
  "{\"de\": {\"title\": \"Training + Erholung\"}}: keys and {placeholders} as in `englishTexts: true`; a",
63863
64059
  "text with \"|\" has a singular and a plural form. Missing texts show in English.",
63864
64060
  "`removeLanguages` removes languages that `languages` added."
@@ -63876,9 +64072,12 @@ function registerDashboardWriter(server, writer) {
63876
64072
  title: string(),
63877
64073
  code: string().describe("The body of a function (el, ctx): see widgetApi"),
63878
64074
  span: number$1().int().optional(),
63879
- types: array(string()).describe("Types the widget can show").optional()
64075
+ types: array(string()).describe("Types the widget can show").optional(),
64076
+ texts: record(string(), record(string(), string())).describe("Texts by language code and key; title is the title: see widgetApi").optional()
63880
64077
  })).optional(),
63881
- removeWidgets: array(string()).describe("Ids of custom widgets").optional(),
64078
+ removeWidgets: array(string()).describe("Ids of custom widgets to remove from this page").optional(),
64079
+ forgetWidgets: array(string()).describe("Ids of custom widgets to remove from the library and from this page").optional(),
64080
+ widgetTexts: record(string(), record(string(), record(string(), string()))).describe("Texts of custom widgets by widget id, language code and key").optional(),
63882
64081
  languages: record(string(), record(string(), string())).describe("Texts by language code and key").optional(),
63883
64082
  removeLanguages: array(string()).describe("Codes of added languages, e.g. de").optional(),
63884
64083
  englishTexts: boolean().describe("Also return the English texts to translate").optional(),
@@ -63889,24 +64088,584 @@ function registerDashboardWriter(server, writer) {
63889
64088
  text: JSON.stringify(await writer.edit(a))
63890
64089
  }] }));
63891
64090
  }
64091
+ // src/schedule.ts
64092
+ /**
64093
+ * The automatic refresh of dashboard pages: one job in the scheduler of the operating system (launchd,
64094
+ * cron or Task Scheduler) runs `refresh --auto`, which updates the pages listed in
64095
+ * `<data-dir>/auto-refresh.json`. Nothing is installed until the user asks.
64096
+ */
64097
+ const DAYS = [
64098
+ "sunday",
64099
+ "monday",
64100
+ "tuesday",
64101
+ "wednesday",
64102
+ "thursday",
64103
+ "friday",
64104
+ "saturday"
64105
+ ];
64106
+ const MONTHS = [
64107
+ "January",
64108
+ "February",
64109
+ "March",
64110
+ "April",
64111
+ "May",
64112
+ "June",
64113
+ "July",
64114
+ "August",
64115
+ "September",
64116
+ "October",
64117
+ "November",
64118
+ "December"
64119
+ ];
64120
+ /** Garmin gets about 8 requests per refresh: two runs must be at least this far apart. */
64121
+ const MIN_GAP_MINUTES = 60;
64122
+ /** Runs a command without a shell; its output never reaches our stdout. */
64123
+ const run = (cmd, args, input) => new Promise((ok, fail) => {
64124
+ const p = spawn(cmd, args, { stdio: "pipe" });
64125
+ let stdout = "";
64126
+ let stderr = "";
64127
+ p.stdout.on("data", (d) => stdout += d);
64128
+ p.stderr.on("data", (d) => stderr += d);
64129
+ p.on("error", fail);
64130
+ p.on("close", (code) => ok({
64131
+ code: code ?? 1,
64132
+ stdout,
64133
+ stderr
64134
+ }));
64135
+ p.stdin.end(input ?? "");
64136
+ });
64137
+ const STATE_FILE = "auto-refresh.json";
64138
+ const DEFAULT_CRON = "0 7 * * *";
64139
+ const LABEL = "com.indaco.garmin-personal-mcp.refresh";
64140
+ const TASK = "garmin-personal-mcp refresh";
64141
+ const MARK = "# garmin-personal-mcp refresh";
64142
+ const FIELDS = [
64143
+ [
64144
+ "minute",
64145
+ 0,
64146
+ 59
64147
+ ],
64148
+ [
64149
+ "hour",
64150
+ 0,
64151
+ 23
64152
+ ],
64153
+ [
64154
+ "day of month",
64155
+ 1,
64156
+ 31
64157
+ ],
64158
+ [
64159
+ "month",
64160
+ 1,
64161
+ 12
64162
+ ],
64163
+ [
64164
+ "weekday",
64165
+ 0,
64166
+ 7
64167
+ ]
64168
+ ];
64169
+ /** One cron field: *, n, a-b, with an optional /step, separated by commas. */
64170
+ function field(text, [label, min, max]) {
64171
+ const values = /* @__PURE__ */ new Set();
64172
+ for (const part of text.split(",")) {
64173
+ const m = /^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/.exec(part);
64174
+ const from = m?.[2] === void 0 ? min : Number(m[2]);
64175
+ const to = m?.[3] !== void 0 ? Number(m[3]) : m?.[2] === void 0 || m[4] ? max : from;
64176
+ const step = m?.[4] === void 0 ? 1 : Number(m[4]);
64177
+ if (!m || from < min || to > max || from > to || step < 1) throw new Error(`Cron ${label}: "${part}" is not ${min}-${max}, a list, a range or a /step`);
64178
+ for (let v = from; v <= to; v += step) values.add(label === "weekday" && v === 7 ? 0 : v);
64179
+ }
64180
+ return [...values].toSorted((a, b) => a - b);
64181
+ }
64182
+ /** Reads and checks a cron expression: numbers only, and at least an hour between two runs. */
64183
+ function parseCron(text) {
64184
+ const parts = text.trim().split(/\s+/);
64185
+ if (parts.length !== 5) throw new Error(`Cron: 5 fields, minute hour day-of-month month weekday (0 = Sunday), e.g. "0 7 * * 1"`);
64186
+ const [minutes, hours, days, months, weekdays] = parts.map((p, i) => field(p, FIELDS[i]));
64187
+ const times = hours.flatMap((h) => minutes.map((m) => h * 60 + m)).toSorted((a, b) => a - b);
64188
+ const gaps = times.map((t, i) => (i === 0 ? t + 1440 : t) - times[(i || times.length) - 1]);
64189
+ if (times.length > 1 && Math.min(...gaps) < MIN_GAP_MINUTES) throw new Error("Cron: the runs must be at least 1 hour apart");
64190
+ const cron = {
64191
+ text: parts.join(" "),
64192
+ times,
64193
+ ...parts[2].startsWith("*") ? {} : { days },
64194
+ ...parts[3].startsWith("*") ? {} : { months },
64195
+ ...parts[4].startsWith("*") ? {} : { weekdays }
64196
+ };
64197
+ if (nextRuns(cron, /* @__PURE__ */ new Date(), 1).length === 0) throw new Error("Cron: this date never comes");
64198
+ return cron;
64199
+ }
64200
+ /** The CLI forms: daily [HH:MM], weekdays [HH:MM], weekly <day> [HH:MM], or a cron expression. */
64201
+ function cronOf(words) {
64202
+ if (words.length === 1 && words[0].trim().includes(" ")) return words[0];
64203
+ const [kind, ...rest] = words.map((w) => w.toLowerCase());
64204
+ const day = kind === "weekly" && rest[0] && !rest[0].includes(":") ? DAYS.indexOf(rest.shift()) : 1;
64205
+ const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(rest[0] ?? "07:00");
64206
+ const weekday = {
64207
+ daily: "*",
64208
+ weekdays: "1-5",
64209
+ weekly: String(day)
64210
+ }[kind ?? ""];
64211
+ if (!weekday || !m || rest.length > 1 || day < 0) throw new Error("Schedule: daily [HH:MM], weekdays [HH:MM], weekly <day> [HH:MM] or a cron expression in quotes, e.g. \"0 7 * * 1\"");
64212
+ return `${Number(m[2])} ${Number(m[1])} * * ${weekday}`;
64213
+ }
64214
+ /** Whether the job runs on that day. */
64215
+ function runsOn(c, d) {
64216
+ if (c.months && !c.months.includes(d.getMonth() + 1)) return false;
64217
+ if (c.days && c.weekdays) return c.days.includes(d.getDate()) || c.weekdays.includes(d.getDay());
64218
+ if (c.days) return c.days.includes(d.getDate());
64219
+ return c.weekdays?.includes(d.getDay()) ?? true;
64220
+ }
64221
+ const pad = (n) => String(n).padStart(2, "0");
64222
+ /** The next `count` runs after `from`, as local "YYYY-MM-DD HH:MM"; empty when the date never comes. */
64223
+ function nextRuns(c, from, count) {
64224
+ const runs = [];
64225
+ for (let i = 0; i < 2928 && runs.length < count; i++) {
64226
+ const d = new Date(from.getFullYear(), from.getMonth(), from.getDate() + i);
64227
+ if (!runsOn(c, d)) continue;
64228
+ for (const t of c.times) {
64229
+ const at = new Date(d.getFullYear(), d.getMonth(), d.getDate(), Math.floor(t / 60), t % 60);
64230
+ if (at > from && runs.length < count) runs.push(`${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`);
64231
+ }
64232
+ }
64233
+ return runs;
64234
+ }
64235
+ const xml = (s) => s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
64236
+ /** launchd has no cron syntax: each run time is a dict, and a run matches any dict of the array. */
64237
+ function calendar(c) {
64238
+ const months = c.months ?? [void 0];
64239
+ const days = c.days || c.weekdays ? [...(c.days ?? []).map((d) => ({ Day: d })), ...(c.weekdays ?? []).map((w) => ({ Weekday: w }))] : [{}];
64240
+ return c.times.flatMap((t) => months.flatMap((m) => days.map((d) => ({
64241
+ ...m === void 0 ? {} : { Month: m },
64242
+ ...d,
64243
+ Hour: Math.floor(t / 60),
64244
+ Minute: t % 60
64245
+ }))));
64246
+ }
64247
+ function plist(job, c) {
64248
+ const when = calendar(c).map((e) => ` <dict>${Object.entries(e).map(([k, v]) => `<key>${k}</key><integer>${v}</integer>`).join("")}</dict>`);
64249
+ const strings = (xs) => xs.map((x) => ` <string>${xml(x)}</string>`).join("\n");
64250
+ const env = Object.entries(job.env).map(([k, v]) => ` <key>${k}</key><string>${xml(v)}</string>`).join("\n");
64251
+ return `<?xml version="1.0" encoding="UTF-8"?>
64252
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
64253
+ <plist version="1.0">
64254
+ <dict>
64255
+ <key>Label</key><string>${LABEL}</string>
64256
+ <key>ProgramArguments</key>
64257
+ <array>
64258
+ ${strings([job.program, ...job.args])}
64259
+ </array>
64260
+ <key>EnvironmentVariables</key>
64261
+ <dict>
64262
+ ${env}
64263
+ </dict>
64264
+ <key>StartCalendarInterval</key>
64265
+ <array>
64266
+ ${when.join("\n")}
64267
+ </array>
64268
+ <key>StandardOutPath</key><string>${xml(job.log)}</string>
64269
+ <key>StandardErrorPath</key><string>${xml(job.log)}</string>
64270
+ </dict>
64271
+ </plist>
64272
+ `;
64273
+ }
64274
+ const sh = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
64275
+ /** The crontab line; % is a newline for cron, so it is escaped. */
64276
+ function cronLine(job, c) {
64277
+ const command = [
64278
+ ...Object.entries(job.env).map(([k, v]) => `${k}=${sh(v)}`),
64279
+ sh(job.program),
64280
+ ...job.args.map(sh),
64281
+ ">>",
64282
+ sh(job.log),
64283
+ "2>&1"
64284
+ ].join(" ");
64285
+ return `${c.text} ${command.replaceAll("%", "\\%")} ${MARK}`;
64286
+ }
64287
+ /** The crontab with our line replaced (or removed when `line` is undefined); other lines stay. */
64288
+ function withCronLine(crontab, line) {
64289
+ return [...crontab.split("\n").filter((l) => l.trim() !== "" && !l.endsWith(MARK)), ...line ? [line] : []].map((l) => `${l}\n`).join("");
64290
+ }
64291
+ /** The .cmd file the Windows task runs. */
64292
+ function windowsScript(job) {
64293
+ const env = Object.entries(job.env).map(([k, v]) => k === "PATH" ? `set "PATH=${v};%PATH%"` : `set "${k}=${v}"`);
64294
+ const command = [job.program, ...job.args].map((a) => `"${a}"`).join(" ");
64295
+ return [
64296
+ "@echo off",
64297
+ ...env,
64298
+ `call ${command} >> "${job.log}" 2>&1`,
64299
+ ""
64300
+ ].join("\r\n");
64301
+ }
64302
+ const tags = (names) => names.map((n) => `<${n} />`).join("");
64303
+ /**
64304
+ * The Task Scheduler task: schtasks /SC cannot say most cron schedules, so each run time is a calendar
64305
+ * trigger. StartWhenAvailable runs a time that the computer missed, as launchd does.
64306
+ */
64307
+ function taskXml(script, c) {
64308
+ const months = `<Months>${tags((c.months ?? MONTHS.map((_, i) => i + 1)).map((m) => MONTHS[m - 1]))}</Months>`;
64309
+ const weekdays = c.weekdays ? `<DaysOfWeek>${tags(c.weekdays.map((w) => DAYS[w][0].toUpperCase() + DAYS[w].slice(1)))}</DaysOfWeek>` : "";
64310
+ const byMonth = (days) => `<ScheduleByMonth><DaysOfMonth>${days.map((d) => `<Day>${d}</Day>`).join("")}</DaysOfMonth>${months}</ScheduleByMonth>`;
64311
+ const schedules = [
64312
+ ...c.days ? [byMonth(c.days)] : [],
64313
+ ...c.weekdays ? [c.months ? `<ScheduleByMonthDayOfWeek><Weeks><Week>1</Week><Week>2</Week><Week>3</Week><Week>4</Week><Week>Last</Week></Weeks>${weekdays}${months}</ScheduleByMonthDayOfWeek>` : `<ScheduleByWeek><WeeksInterval>1</WeeksInterval>${weekdays}</ScheduleByWeek>`] : [],
64314
+ ...c.days || c.weekdays ? [] : [c.months ? byMonth(Array.from({ length: 31 }, (_, i) => i + 1)) : "<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>"]
64315
+ ];
64316
+ const triggers = c.times.flatMap((t) => schedules.map((s) => ` <CalendarTrigger><StartBoundary>2026-01-01T${pad(Math.floor(t / 60))}:${pad(t % 60)}:00</StartBoundary>${s}</CalendarTrigger>`));
64317
+ return `<?xml version="1.0" encoding="UTF-16"?>
64318
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
64319
+ <RegistrationInfo><Description>Updates the Garmin dashboard pages (${xml(c.text)})</Description></RegistrationInfo>
64320
+ <Triggers>
64321
+ ${triggers.join("\n")}
64322
+ </Triggers>
64323
+ <Settings>
64324
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
64325
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
64326
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
64327
+ <StartWhenAvailable>true</StartWhenAvailable>
64328
+ <ExecutionTimeLimit>PT1H</ExecutionTimeLimit>
64329
+ </Settings>
64330
+ <Actions><Exec><Command>${xml(script)}</Command></Exec></Actions>
64331
+ </Task>
64332
+ `;
64333
+ }
64334
+ var AutoRefresh = class {
64335
+ config;
64336
+ platform;
64337
+ home;
64338
+ exec;
64339
+ nodeDir;
64340
+ entry;
64341
+ constructor(config, platform = process.platform, home = homedir(), exec = run, nodeDir = dirname(process.execPath), entry = realEntry(process.argv[1])) {
64342
+ this.config = config;
64343
+ this.platform = platform;
64344
+ this.home = home;
64345
+ this.exec = exec;
64346
+ this.nodeDir = nodeDir;
64347
+ this.entry = entry;
64348
+ }
64349
+ get #stateFile() {
64350
+ return join(this.config.dataDir, STATE_FILE);
64351
+ }
64352
+ get #plist() {
64353
+ return join(this.home, "Library/LaunchAgents", `${LABEL}.plist`);
64354
+ }
64355
+ get #script() {
64356
+ return join(this.config.dataDir, "auto-refresh.cmd");
64357
+ }
64358
+ get #taskFile() {
64359
+ return join(this.config.dataDir, "auto-refresh-task.xml");
64360
+ }
64361
+ get #log() {
64362
+ return join(this.home, ".cache/garmin-personal-mcp/refresh.log");
64363
+ }
64364
+ get #scheduler() {
64365
+ return {
64366
+ darwin: "launchd",
64367
+ linux: "cron",
64368
+ win32: "Task Scheduler"
64369
+ }[this.platform];
64370
+ }
64371
+ /** The pages that `refresh --auto` updates. */
64372
+ async pages() {
64373
+ return (await this.#state())?.pages ?? [];
64374
+ }
64375
+ async status() {
64376
+ const state = await this.#state();
64377
+ if (!state) return { scheduled: false };
64378
+ return {
64379
+ scheduled: true,
64380
+ cron: state.cron,
64381
+ next: nextRuns(parseCron(state.cron), /* @__PURE__ */ new Date(), 3),
64382
+ pages: state.pages,
64383
+ scheduler: this.#scheduler,
64384
+ installed: await this.#installed(),
64385
+ log: this.#log
64386
+ };
64387
+ }
64388
+ /** Adds the page (default: the main dashboard) and sets the cron, or keeps the current one. */
64389
+ async set(o) {
64390
+ if (!this.#scheduler) throw new Error("Automatic refresh works on macOS, Linux and Windows");
64391
+ if (!existsSync(this.config.apiKeyFile)) throw new Error(`Save the API key in ${this.config.apiKeyFile} first: a scheduled job cannot read the key from your MCP client`);
64392
+ const page = pageTarget(this.config.dataDir, o.path);
64393
+ if (!existsSync(page)) throw new Error(`${page} does not exist: make it first with the training-dashboard prompt`);
64394
+ const old = await this.#state();
64395
+ const cron = parseCron(o.cron ?? old?.cron ?? DEFAULT_CRON);
64396
+ const pages = [.../* @__PURE__ */ new Set([...old?.pages ?? [], page])];
64397
+ await this.#install(cron);
64398
+ await writeFile(this.#stateFile, `${JSON.stringify({
64399
+ cron: cron.text,
64400
+ pages
64401
+ }, null, 2)}\n`);
64402
+ return this.status();
64403
+ }
64404
+ /** Removes the page; without a page, or when no page is left, removes the job. */
64405
+ async off(path) {
64406
+ const old = await this.#state();
64407
+ if (!old) return { scheduled: false };
64408
+ const pages = path ? old.pages.filter((p) => p !== pageTarget(this.config.dataDir, path)) : [];
64409
+ if (pages.length > 0) {
64410
+ await writeFile(this.#stateFile, `${JSON.stringify({
64411
+ ...old,
64412
+ pages
64413
+ }, null, 2)}\n`);
64414
+ return this.status();
64415
+ }
64416
+ await this.#uninstall();
64417
+ await rm(this.#stateFile, { force: true });
64418
+ return { scheduled: false };
64419
+ }
64420
+ #job() {
64421
+ const win = this.platform === "win32";
64422
+ const npx = this.entry === "" || /[/\\]_npx[/\\]/.test(this.entry);
64423
+ return {
64424
+ program: join(this.nodeDir, npx ? win ? "npx.cmd" : "npx" : win ? "node.exe" : "node"),
64425
+ args: [
64426
+ ...npx ? ["-y", `${name}@latest`] : [this.entry],
64427
+ "refresh",
64428
+ "--auto"
64429
+ ],
64430
+ env: {
64431
+ GARMIN_DATA_DIR: this.config.dataDir,
64432
+ GARMIN_API_KEY_FILE: this.config.apiKeyFile,
64433
+ GARMIN_BASE_URL: this.config.baseUrl,
64434
+ PATH: win ? this.nodeDir : [
64435
+ this.nodeDir,
64436
+ "/usr/bin",
64437
+ "/bin"
64438
+ ].join(delimiter)
64439
+ },
64440
+ log: this.#log
64441
+ };
64442
+ }
64443
+ async #install(c) {
64444
+ await mkdir(dirname(this.#log), { recursive: true });
64445
+ const job = this.#job();
64446
+ if (this.platform === "darwin") {
64447
+ await mkdir(dirname(this.#plist), { recursive: true });
64448
+ await writeFile(this.#plist, plist(job, c));
64449
+ await this.exec("launchctl", [
64450
+ "bootout",
64451
+ this.#domain(),
64452
+ this.#plist
64453
+ ]);
64454
+ await this.#must("launchctl", [
64455
+ "bootstrap",
64456
+ this.#domain(),
64457
+ this.#plist
64458
+ ]);
64459
+ } else if (this.platform === "linux") await this.#must("crontab", ["-"], withCronLine(await this.#crontab(), cronLine(job, c)));
64460
+ else {
64461
+ await writeFile(this.#script, windowsScript(job));
64462
+ await writeFile(this.#taskFile, Buffer.from(`\ufeff${taskXml(this.#script, c)}`, "utf16le"));
64463
+ await this.#must("schtasks", [
64464
+ "/Create",
64465
+ "/F",
64466
+ "/TN",
64467
+ TASK,
64468
+ "/XML",
64469
+ this.#taskFile
64470
+ ]);
64471
+ }
64472
+ }
64473
+ async #uninstall() {
64474
+ if (this.platform === "darwin") {
64475
+ await this.exec("launchctl", [
64476
+ "bootout",
64477
+ this.#domain(),
64478
+ this.#plist
64479
+ ]);
64480
+ await rm(this.#plist, { force: true });
64481
+ } else if (this.platform === "linux") await this.#must("crontab", ["-"], withCronLine(await this.#crontab(), void 0));
64482
+ else {
64483
+ await this.exec("schtasks", [
64484
+ "/Delete",
64485
+ "/F",
64486
+ "/TN",
64487
+ TASK
64488
+ ]);
64489
+ await rm(this.#script, { force: true });
64490
+ await rm(this.#taskFile, { force: true });
64491
+ }
64492
+ }
64493
+ async #installed() {
64494
+ if (this.platform === "darwin") return existsSync(this.#plist);
64495
+ if (this.platform === "linux") return (await this.#crontab()).includes(MARK);
64496
+ return (await this.exec("schtasks", [
64497
+ "/Query",
64498
+ "/TN",
64499
+ TASK
64500
+ ])).code === 0;
64501
+ }
64502
+ #domain() {
64503
+ return `gui/${process.getuid()}`;
64504
+ }
64505
+ /** The user's crontab; "" when there is none (crontab -l then exits with 1). */
64506
+ async #crontab() {
64507
+ const r = await this.exec("crontab", ["-l"]);
64508
+ return r.code === 0 ? r.stdout : "";
64509
+ }
64510
+ async #must(cmd, args, input) {
64511
+ const r = await this.exec(cmd, args, input);
64512
+ if (r.code !== 0) throw new Error(`${cmd} failed (${r.code}): ${(r.stderr || r.stdout).trim()}`);
64513
+ }
64514
+ async #state() {
64515
+ const text = await readFile(this.#stateFile, "utf8").catch(() => void 0);
64516
+ return text === void 0 ? void 0 : JSON.parse(text);
64517
+ }
64518
+ };
64519
+ /** The real path of the running script, so the job does not follow a link that can change. */
64520
+ function realEntry(path) {
64521
+ try {
64522
+ return path ? realpathSync(path) : "";
64523
+ } catch {
64524
+ return "";
64525
+ }
64526
+ }
64527
+ /** Updates each page; one failure does not stop the others. Returns one log line per page. */
64528
+ async function refreshPages(writer, pages) {
64529
+ const lines = [];
64530
+ let failed = false;
64531
+ for (const path of pages) {
64532
+ const at = (/* @__PURE__ */ new Date()).toISOString();
64533
+ try {
64534
+ await writer.write({
64535
+ path,
64536
+ onlyExisting: true
64537
+ });
64538
+ lines.push(`${at} updated ${path}`);
64539
+ } catch (e) {
64540
+ failed = true;
64541
+ lines.push(`${at} failed ${path}: ${e.message}`);
64542
+ }
64543
+ }
64544
+ return {
64545
+ lines,
64546
+ failed
64547
+ };
64548
+ }
64549
+ /** Registers scheduleDashboardRefresh. */
64550
+ function registerSchedule(server, auto) {
64551
+ server.registerTool("scheduleDashboardRefresh", {
64552
+ description: [
64553
+ "Turns on, changes or stops the automatic refresh of a dashboard page. Use it only when the user",
64554
+ "asks for it or says yes to the question of a dashboard prompt. A job in the scheduler of the",
64555
+ "computer (launchd, cron or Task Scheduler) replaces the data of the page at the given times, also",
64556
+ "with no assistant open. The refresh removes the coach notes, because old notes do not agree",
64557
+ "with new data. The computer must be on at that time. The page must exist (writeDashboard first).",
64558
+ "`cron`: convert what the user says, in any language, into a 5-field cron expression in local",
64559
+ "time: minute hour day-of-month month weekday, numbers only (0 = Sunday), e.g. \"0 7 * * 1-5\" for",
64560
+ "weekdays at 7. Two runs must be at least 1 hour apart. The cron is for all scheduled pages;",
64561
+ "`path` adds that page (default the main page); `off` stops the refresh of `path`, or of every",
64562
+ "page when no `path` is given. Without arguments it only reads. The reply has `next`, the next",
64563
+ "runs in local time: tell them to the user in their language, so they can check the schedule."
64564
+ ].join("\n"),
64565
+ inputSchema: object({
64566
+ cron: string().describe("minute hour day-of-month month weekday, e.g. \"0 7 * * *\"; default 0 7 * * *").optional(),
64567
+ path: string().describe("The .html file; relative paths are inside the data dir").optional(),
64568
+ off: boolean().describe("Stop the refresh").optional()
64569
+ })
64570
+ }, async ({ cron, path, off }) => {
64571
+ const status = off ? await auto.off(path) : cron !== void 0 || path !== void 0 ? await auto.set({
64572
+ cron,
64573
+ path
64574
+ }) : await auto.status();
64575
+ return { content: [{
64576
+ type: "text",
64577
+ text: JSON.stringify(status)
64578
+ }] };
64579
+ });
64580
+ }
64581
+ // src/cli.ts
64582
+ /** The commands for a terminal or a scheduler; any other start is the MCP server. */
64583
+ const COMMANDS = ["refresh", "schedule"];
64584
+ /**
64585
+ * Runs `refresh [page.html | --auto]` or
64586
+ * `schedule [daily|weekdays|weekly <day>] [HH:MM] | "<cron>" | off`, each with an optional page.
64587
+ * Not MCP, so the result may go to stdout.
64588
+ */
64589
+ async function cli(command, args, auto, writer) {
64590
+ try {
64591
+ if (command === "refresh") {
64592
+ const pages = args[0] === "--auto" ? await auto.pages() : [args[0] ?? ""];
64593
+ const { lines, failed } = await refreshPages(writer(), pages);
64594
+ return {
64595
+ code: failed ? 1 : 0,
64596
+ stdout: lines.join("\n")
64597
+ };
64598
+ }
64599
+ const rest = [...args];
64600
+ const path = rest.at(-1)?.toLowerCase().endsWith(".html") ? rest.pop() : void 0;
64601
+ return {
64602
+ code: 0,
64603
+ stdout: summary(rest[0] === "off" ? await auto.off(path) : rest.length === 0 && path === void 0 ? await auto.status() : await auto.set({
64604
+ ...rest.length > 0 ? { cron: cronOf(rest) } : {},
64605
+ path
64606
+ }))
64607
+ };
64608
+ } catch (e) {
64609
+ return {
64610
+ code: 1,
64611
+ stderr: `${command} failed: ${e.message}`
64612
+ };
64613
+ }
64614
+ }
64615
+ function summary(s) {
64616
+ if (!s.scheduled) return "Automatic refresh is off.";
64617
+ return [
64618
+ `Automatic refresh "${s.cron}" with ${s.scheduler}, next: ${s.next.join(", ")}`,
64619
+ "Pages:",
64620
+ ...s.pages.map((p) => ` ${p}`),
64621
+ ...s.installed ? [] : [`The job is missing from ${s.scheduler}: run the schedule again.`],
64622
+ `Log: ${s.log}`
64623
+ ].join("\n");
64624
+ }
64625
+ // src/config.ts
64626
+ /** Defaults reuse an existing ~/.config/garmin-mcp/api-key and ~/workspace/garmin-data. */
64627
+ function loadConfig(env = process.env) {
64628
+ return {
64629
+ baseUrl: env.GARMIN_BASE_URL || "https://personal.connectapi.garmin.com",
64630
+ apiKeyFile: env.GARMIN_API_KEY_FILE || join(homedir(), ".config/garmin-mcp/api-key"),
64631
+ dataDir: env.GARMIN_DATA_DIR || join(homedir(), "workspace/garmin-data")
64632
+ };
64633
+ }
63892
64634
  // src/server.ts
63893
64635
  /** The page template; the build writes it next to dist/index.js. */
63894
64636
  const template = () => readFile(new URL("./dashboard.html", import.meta.url), "utf8");
63895
- /** The garmin-personal MCP server with its 15 tools and 5 prompts. */
64637
+ /** The tools, the dashboard data and the page writer, wired to one API client. */
64638
+ function createParts(config) {
64639
+ const api = new GarminApiClient(config);
64640
+ const tools = new GarminTools(api, config.dataDir);
64641
+ const dashboard = new GarminDashboard(tools, api, config.dataDir);
64642
+ return {
64643
+ tools,
64644
+ dashboard,
64645
+ writer: new DashboardWriter((weeks) => dashboard.getDashboardData(weeks), config.dataDir, template)
64646
+ };
64647
+ }
64648
+ /** The garmin-personal MCP server with its tools and prompts. */
63896
64649
  function createServer(config) {
63897
64650
  const server = new McpServer({
63898
64651
  name: "garmin-personal",
63899
64652
  version
63900
64653
  });
63901
- const api = new GarminApiClient(config);
63902
- const tools = new GarminTools(api, config.dataDir);
64654
+ const { tools, dashboard, writer } = createParts(config);
63903
64655
  registerTools(server, tools);
63904
- const dashboard = new GarminDashboard(tools, api, config.dataDir);
63905
64656
  registerDashboard(server, dashboard);
63906
- registerDashboardWriter(server, new DashboardWriter((weeks) => dashboard.getDashboardData(weeks), config.dataDir, template));
64657
+ registerDashboardWriter(server, writer);
64658
+ registerSchedule(server, new AutoRefresh(config));
63907
64659
  registerPrompts(server);
63908
64660
  return server;
63909
64661
  }
63910
64662
  // src/index.ts
63911
- serveStdio(() => createServer(loadConfig()));
64663
+ const [command = "", ...args] = process.argv.slice(2);
64664
+ if (COMMANDS.includes(command)) {
64665
+ const config = loadConfig();
64666
+ const r = await cli(command, args, new AutoRefresh(config), () => createParts(config).writer);
64667
+ if (r.stdout) process.stdout.write(`${r.stdout}\n`);
64668
+ if (r.stderr) process.stderr.write(`${r.stderr}\n`);
64669
+ process.exitCode = r.code;
64670
+ } else serveStdio(() => createServer(loadConfig()));
63912
64671
  export {};