@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/README.md +45 -25
- package/dist/dashboard.html +7 -7
- package/dist/index.js +827 -68
- package/package.json +1 -1
- package/prompts/dashboard-language.md +3 -0
- package/prompts/dashboard-setup.md +18 -0
- package/prompts/dashboard-widget.md +6 -1
- package/prompts/training-dashboard-freeform.md +11 -0
- package/prompts/training-dashboard-update.md +12 -0
- package/prompts/training-dashboard.md +32 -5
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 {
|
|
4
|
-
import {
|
|
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 {
|
|
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
|
|
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(
|
|
59041
|
+
json = await search(max, activityType);
|
|
59047
59042
|
} catch (e) {
|
|
59048
59043
|
if (!refused(e)) throw e;
|
|
59049
59044
|
try {
|
|
59050
|
-
json = await search(
|
|
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
|
|
59062
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
59246
|
-
const speed = asDouble(field$
|
|
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$
|
|
59251
|
-
} else if (type === "RWD_WALK") (o.runWalk ??= {}).walkTime = hms(asDouble(field$
|
|
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$
|
|
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 =
|
|
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
|
-
|
|
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((
|
|
63717
|
-
id,
|
|
63718
|
-
title,
|
|
63719
|
-
span: span
|
|
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
|
-
|
|
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.
|
|
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
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 {};
|