@cmichel/healthlog 0.1.0 → 0.2.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 +10 -2
- package/dist/cli.js +4 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/dump.js +1 -1
- package/dist/commands/dump.js.map +1 -1
- package/dist/commands/setup-withings.js +87 -0
- package/dist/commands/setup-withings.js.map +1 -0
- package/dist/db/body-measurements.js +130 -0
- package/dist/db/body-measurements.js.map +1 -0
- package/dist/db/provider-state.js +20 -17
- package/dist/db/provider-state.js.map +1 -1
- package/dist/db/schema.js +28 -4
- package/dist/db/schema.js.map +1 -1
- package/dist/domain/body-measurement.js +7 -0
- package/dist/domain/body-measurement.js.map +1 -0
- package/dist/domain/dump.js.map +1 -1
- package/dist/domain/provider.js +3 -1
- package/dist/domain/provider.js.map +1 -1
- package/dist/domain/workout.js.map +1 -1
- package/dist/providers/garmin/sync.js +2 -2
- package/dist/providers/garmin/sync.js.map +1 -1
- package/dist/providers/hevy/sync.js +2 -2
- package/dist/providers/hevy/sync.js.map +1 -1
- package/dist/providers/withings/client.js +115 -0
- package/dist/providers/withings/client.js.map +1 -0
- package/dist/providers/withings/normalize.js +76 -0
- package/dist/providers/withings/normalize.js.map +1 -0
- package/dist/providers/withings/oauth.js +28 -0
- package/dist/providers/withings/oauth.js.map +1 -0
- package/dist/providers/withings/sync.js +66 -0
- package/dist/providers/withings/sync.js.map +1 -0
- package/dist/providers/withings/types.js +113 -0
- package/dist/providers/withings/types.js.map +1 -0
- package/dist/services/dump-service.js +24 -0
- package/dist/services/dump-service.js.map +1 -1
- package/dist/services/setup-service.js +6 -2
- package/dist/services/setup-service.js.map +1 -1
- package/dist/services/sync-service.js +18 -6
- package/dist/services/sync-service.js.map +1 -1
- package/package.json +20 -12
|
@@ -27,10 +27,10 @@ export async function syncHevy(db, state) {
|
|
|
27
27
|
updateProviderCursor(db, "hevy", stringifyJson({
|
|
28
28
|
version: 1,
|
|
29
29
|
since: nextSince,
|
|
30
|
-
})
|
|
30
|
+
}));
|
|
31
31
|
});
|
|
32
32
|
transaction();
|
|
33
|
-
return { newWorkoutCount };
|
|
33
|
+
return { newCount: newWorkoutCount };
|
|
34
34
|
}
|
|
35
35
|
async function getEventsToSync(client, since) {
|
|
36
36
|
const events = [];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync.js","sourceRoot":"","sources":["../../../src/providers/hevy/sync.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,oBAAoB,GACrB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAE/D,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAC7E,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,0BAA0B,EAAE,MAAM,gBAAgB,CAAC;AAC5D,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAMrD,OAAO,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAErE,MAAM,CAAC,KAAK,UAAU,QAAQ,CAC5B,EAAqB,EACrB,KAAoB;IAEpB,MAAM,WAAW,GAAG,oBAAoB,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC;IAChE,MAAM,MAAM,GAAG,eAAe,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IACjD,MAAM,MAAM,GAAG,UAAU,CAAC,eAAe,CAAC,WAAW,CAAC,CAAC;IACvD,MAAM,MAAM,GAAG,wBAAwB,CACrC,MAAM,eAAe,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAC5C,CAAC;IAEF,IAAI,eAAe,GAAG,CAAC,CAAC;IACxB,IAAI,SAAS,GAAG,MAAM,CAAC,KAAK,CAAC;IAE7B,MAAM,WAAW,GAAG,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE;QACtC,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,SAAS,GAAG,eAAe,CAAC,SAAS,EAAE,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;YAE9D,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;gBAC7B,SAAS;YACX,CAAC;YAED,MAAM,MAAM,GAAG,sBAAsB,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YACrD,MAAM,IAAI,GAAG,0BAA0B,CAAC,MAAM,CAAC,CAAC;YAChD,IAAI,uBAAuB,CAAC,EAAE,EAAE,IAAI,CAAC,EAAE,CAAC;gBACtC,eAAe,IAAI,CAAC,CAAC;YACvB,CAAC;QACH,CAAC;QAED,oBAAoB,CAClB,EAAE,EACF,MAAM,EACN,aAAa,CAAC;YACZ,OAAO,EAAE,CAAC;YACV,KAAK,EAAE,SAAS;SACI,CAAC,
|
|
1
|
+
{"version":3,"file":"sync.js","sourceRoot":"","sources":["../../../src/providers/hevy/sync.ts"],"names":[],"mappings":"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","sourcesContent":["import type { HealthlogDatabase } from \"../../db/database.js\";\nimport {\n type ProviderState,\n updateProviderCursor,\n} from \"../../db/provider-state.js\";\nimport { upsertNormalizedWorkout } from \"../../db/workouts.js\";\nimport type { ProviderSyncResult } from \"../../domain/provider.js\";\nimport { parseJson, parseSchema, stringifyJson } from \"../../utils/parse.js\";\nimport { HevyClient } from \"./client.js\";\nimport { normalizeHevyWorkoutSource } from \"./normalize.js\";\nimport { buildHevyWorkoutSource } from \"./source.js\";\nimport type {\n HevyApiWorkoutEvent,\n HevyCredentials,\n HevyCursor,\n} from \"./types.js\";\nimport { HevyCredentialsSchema, HevyCursorSchema } from \"./types.js\";\n\nexport async function syncHevy(\n db: HealthlogDatabase,\n state: ProviderState,\n): Promise<ProviderSyncResult> {\n const credentials = parseHevyCredentials(state.credentialsJson);\n const cursor = parseHevyCursor(state.cursorJson);\n const client = HevyClient.fromCredentials(credentials);\n const events = sortEventsByTimestampAsc(\n await getEventsToSync(client, cursor.since),\n );\n\n let newWorkoutCount = 0;\n let nextSince = cursor.since;\n\n const transaction = db.transaction(() => {\n for (const event of events) {\n nextSince = maxIsoTimestamp(nextSince, eventTimestamp(event));\n\n if (event.type === \"deleted\") {\n continue;\n }\n\n const source = buildHevyWorkoutSource(event.workout);\n const rows = normalizeHevyWorkoutSource(source);\n if (upsertNormalizedWorkout(db, rows)) {\n newWorkoutCount += 1;\n }\n }\n\n updateProviderCursor(\n db,\n \"hevy\",\n stringifyJson({\n version: 1,\n since: nextSince,\n } satisfies HevyCursor),\n );\n });\n\n transaction();\n return { newCount: newWorkoutCount };\n}\n\nasync function getEventsToSync(\n client: HevyClient,\n since: string,\n): Promise<HevyApiWorkoutEvent[]> {\n const events: HevyApiWorkoutEvent[] = [];\n let page = 1;\n\n while (true) {\n const response = await client.getWorkoutEvents(since, page);\n events.push(...response.events);\n\n if (response.page >= response.page_count) {\n return events;\n }\n\n page += 1;\n }\n}\n\nfunction sortEventsByTimestampAsc(\n events: HevyApiWorkoutEvent[],\n): HevyApiWorkoutEvent[] {\n // Hevy returns events newest-first; writing oldest-first keeps the newest\n // duplicate workout event as the final local version.\n return [...events].sort(\n (left, right) => eventSortTimestamp(left) - eventSortTimestamp(right),\n );\n}\n\nfunction eventSortTimestamp(event: HevyApiWorkoutEvent): number {\n const timestamp = eventTimestamp(event);\n if (timestamp === null) {\n return Number.NEGATIVE_INFINITY;\n }\n\n return parseIsoTimestamp(timestamp);\n}\n\nfunction eventTimestamp(event: HevyApiWorkoutEvent): string | null {\n if (event.type === \"updated\") {\n return event.workout.updated_at;\n }\n\n return event.deleted_at ?? null;\n}\n\nfunction maxIsoTimestamp(left: string, right: string | null): string {\n if (right === null) {\n return left;\n }\n\n const leftMs = parseIsoTimestamp(left);\n const rightMs = parseIsoTimestamp(right);\n return rightMs > leftMs ? right : left;\n}\n\nfunction parseIsoTimestamp(value: string): number {\n const ms = Date.parse(value);\n if (!Number.isFinite(ms)) {\n throw new Error(`Invalid Hevy cursor timestamp \"${value}\"`);\n }\n return ms;\n}\n\nfunction parseHevyCredentials(json: string): HevyCredentials {\n return parseSchema(\n HevyCredentialsSchema,\n parseJson(json, \"hevy credentials_json\"),\n \"Hevy credentials\",\n );\n}\n\nfunction parseHevyCursor(json: string): HevyCursor {\n const cursor = parseSchema(\n HevyCursorSchema,\n parseJson(json, \"hevy cursor_json\"),\n \"Hevy cursor\",\n );\n parseIsoTimestamp(cursor.since);\n return cursor;\n}\n"]}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { parseSchema } from "../../utils/parse.js";
|
|
2
|
+
import { WithingsApiGetMeasResponseSchema, WithingsApiResponseEnvelopeSchema, WithingsApiTokenResponseSchema, withingsOAuthScope, withingsTargetMeasureTypesParam, } from "./types.js";
|
|
3
|
+
const withingsAccountBaseUrl = "https://account.withings.com";
|
|
4
|
+
const withingsApiBaseUrl = "https://wbsapi.withings.net";
|
|
5
|
+
export class WithingsClient {
|
|
6
|
+
#accessToken;
|
|
7
|
+
constructor(credentials) {
|
|
8
|
+
this.#accessToken = credentials.accessToken;
|
|
9
|
+
}
|
|
10
|
+
static fromCredentials(credentials) {
|
|
11
|
+
return new WithingsClient(credentials);
|
|
12
|
+
}
|
|
13
|
+
async getMeasurements(lastUpdateUnixSeconds, offset) {
|
|
14
|
+
const response = await postWithingsRequest("/measure", {
|
|
15
|
+
action: "getmeas",
|
|
16
|
+
category: "1",
|
|
17
|
+
meastypes: withingsTargetMeasureTypesParam,
|
|
18
|
+
lastupdate: String(lastUpdateUnixSeconds),
|
|
19
|
+
offset: String(offset),
|
|
20
|
+
}, this.#accessToken);
|
|
21
|
+
return parseSchema(WithingsApiGetMeasResponseSchema, response, "Withings getmeas response");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function buildWithingsAuthorizationUrl(params) {
|
|
25
|
+
const url = new URL("/oauth2_user/authorize2", withingsAccountBaseUrl);
|
|
26
|
+
url.searchParams.set("response_type", "code");
|
|
27
|
+
url.searchParams.set("client_id", params.clientId);
|
|
28
|
+
url.searchParams.set("scope", withingsOAuthScope);
|
|
29
|
+
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
30
|
+
url.searchParams.set("state", params.state);
|
|
31
|
+
return url.toString();
|
|
32
|
+
}
|
|
33
|
+
export async function exchangeWithingsAuthorizationCode(params) {
|
|
34
|
+
const response = await postWithingsRequest("/v2/oauth2", {
|
|
35
|
+
action: "requesttoken",
|
|
36
|
+
grant_type: "authorization_code",
|
|
37
|
+
client_id: params.clientId,
|
|
38
|
+
client_secret: params.clientSecret,
|
|
39
|
+
code: params.code,
|
|
40
|
+
redirect_uri: params.redirectUri,
|
|
41
|
+
});
|
|
42
|
+
const tokenResponse = parseSchema(WithingsApiTokenResponseSchema, response, "Withings token response");
|
|
43
|
+
return {
|
|
44
|
+
version: 1,
|
|
45
|
+
clientId: params.clientId,
|
|
46
|
+
clientSecret: params.clientSecret,
|
|
47
|
+
redirectUri: params.redirectUri,
|
|
48
|
+
userId: tokenResponse.body.userid,
|
|
49
|
+
accessToken: tokenResponse.body.access_token,
|
|
50
|
+
refreshToken: tokenResponse.body.refresh_token,
|
|
51
|
+
accessTokenExpiresAtMs: Date.now() + tokenResponse.body.expires_in * 1000,
|
|
52
|
+
tokenType: tokenResponse.body.token_type,
|
|
53
|
+
scope: tokenResponse.body.scope,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export async function refreshWithingsCredentials(credentials) {
|
|
57
|
+
const response = await postWithingsRequest("/v2/oauth2", {
|
|
58
|
+
action: "requesttoken",
|
|
59
|
+
grant_type: "refresh_token",
|
|
60
|
+
client_id: credentials.clientId,
|
|
61
|
+
client_secret: credentials.clientSecret,
|
|
62
|
+
refresh_token: credentials.refreshToken,
|
|
63
|
+
});
|
|
64
|
+
const tokenResponse = parseSchema(WithingsApiTokenResponseSchema, response, "Withings refresh token response");
|
|
65
|
+
return {
|
|
66
|
+
...credentials,
|
|
67
|
+
userId: tokenResponse.body.userid,
|
|
68
|
+
accessToken: tokenResponse.body.access_token,
|
|
69
|
+
refreshToken: tokenResponse.body.refresh_token,
|
|
70
|
+
accessTokenExpiresAtMs: Date.now() + tokenResponse.body.expires_in * 1000,
|
|
71
|
+
tokenType: tokenResponse.body.token_type,
|
|
72
|
+
scope: tokenResponse.body.scope,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function shouldRefreshWithingsCredentials(credentials) {
|
|
76
|
+
const refreshBufferMs = 5 * 60 * 1000;
|
|
77
|
+
return Date.now() + refreshBufferMs >= credentials.accessTokenExpiresAtMs;
|
|
78
|
+
}
|
|
79
|
+
async function postWithingsRequest(path, values, accessToken) {
|
|
80
|
+
const url = new URL(path, withingsApiBaseUrl);
|
|
81
|
+
const headers = {
|
|
82
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
83
|
+
};
|
|
84
|
+
if (accessToken) {
|
|
85
|
+
headers.Authorization = `Bearer ${accessToken}`;
|
|
86
|
+
}
|
|
87
|
+
const response = await fetch(url, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers,
|
|
90
|
+
body: new URLSearchParams(values),
|
|
91
|
+
});
|
|
92
|
+
const responseText = await response.text();
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
throw new Error(`Withings API request failed: ${response.status} ${response.statusText}${responseText.length > 0 ? `: ${responseText}` : ""}`);
|
|
95
|
+
}
|
|
96
|
+
const json = parseWithingsJson(responseText);
|
|
97
|
+
const envelope = parseSchema(WithingsApiResponseEnvelopeSchema, json, "Withings response envelope");
|
|
98
|
+
assertWithingsSuccess(envelope, responseText);
|
|
99
|
+
return json;
|
|
100
|
+
}
|
|
101
|
+
function parseWithingsJson(responseText) {
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(responseText);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
throw new Error(`Invalid JSON in Withings response: ${error instanceof Error ? error.message : String(error)}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function assertWithingsSuccess(envelope, responseText) {
|
|
110
|
+
if (envelope.status === 0) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
throw new Error(`Withings API request failed with status ${envelope.status}${envelope.error ? `: ${envelope.error}` : `: ${responseText}`}`);
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../../../src/providers/withings/client.ts"],"names":[],"mappings":"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","sourcesContent":["import { parseSchema } from \"../../utils/parse.js\";\nimport {\n type WithingsApiGetMeasResponse,\n WithingsApiGetMeasResponseSchema,\n type WithingsApiResponseEnvelope,\n WithingsApiResponseEnvelopeSchema,\n WithingsApiTokenResponseSchema,\n type WithingsCredentials,\n withingsOAuthScope,\n withingsTargetMeasureTypesParam,\n} from \"./types.js\";\n\nconst withingsAccountBaseUrl = \"https://account.withings.com\";\nconst withingsApiBaseUrl = \"https://wbsapi.withings.net\";\n\nexport type WithingsAuthorizationUrlParams = {\n clientId: string;\n redirectUri: string;\n state: string;\n};\n\nexport type WithingsAuthorizationCodeParams = {\n clientId: string;\n clientSecret: string;\n redirectUri: string;\n code: string;\n};\n\nexport class WithingsClient {\n readonly #accessToken: string;\n\n private constructor(credentials: WithingsCredentials) {\n this.#accessToken = credentials.accessToken;\n }\n\n static fromCredentials(credentials: WithingsCredentials): WithingsClient {\n return new WithingsClient(credentials);\n }\n\n async getMeasurements(\n lastUpdateUnixSeconds: number,\n offset: number,\n ): Promise<WithingsApiGetMeasResponse> {\n const response = await postWithingsRequest(\n \"/measure\",\n {\n action: \"getmeas\",\n category: \"1\",\n meastypes: withingsTargetMeasureTypesParam,\n lastupdate: String(lastUpdateUnixSeconds),\n offset: String(offset),\n },\n this.#accessToken,\n );\n\n return parseSchema(\n WithingsApiGetMeasResponseSchema,\n response,\n \"Withings getmeas response\",\n );\n }\n}\n\nexport function buildWithingsAuthorizationUrl(\n params: WithingsAuthorizationUrlParams,\n): string {\n const url = new URL(\"/oauth2_user/authorize2\", withingsAccountBaseUrl);\n url.searchParams.set(\"response_type\", \"code\");\n url.searchParams.set(\"client_id\", params.clientId);\n url.searchParams.set(\"scope\", withingsOAuthScope);\n url.searchParams.set(\"redirect_uri\", params.redirectUri);\n url.searchParams.set(\"state\", params.state);\n return url.toString();\n}\n\nexport async function exchangeWithingsAuthorizationCode(\n params: WithingsAuthorizationCodeParams,\n): Promise<WithingsCredentials> {\n const response = await postWithingsRequest(\"/v2/oauth2\", {\n action: \"requesttoken\",\n grant_type: \"authorization_code\",\n client_id: params.clientId,\n client_secret: params.clientSecret,\n code: params.code,\n redirect_uri: params.redirectUri,\n });\n const tokenResponse = parseSchema(\n WithingsApiTokenResponseSchema,\n response,\n \"Withings token response\",\n );\n\n return {\n version: 1,\n clientId: params.clientId,\n clientSecret: params.clientSecret,\n redirectUri: params.redirectUri,\n userId: tokenResponse.body.userid,\n accessToken: tokenResponse.body.access_token,\n refreshToken: tokenResponse.body.refresh_token,\n accessTokenExpiresAtMs: Date.now() + tokenResponse.body.expires_in * 1000,\n tokenType: tokenResponse.body.token_type,\n scope: tokenResponse.body.scope,\n };\n}\n\nexport async function refreshWithingsCredentials(\n credentials: WithingsCredentials,\n): Promise<WithingsCredentials> {\n const response = await postWithingsRequest(\"/v2/oauth2\", {\n action: \"requesttoken\",\n grant_type: \"refresh_token\",\n client_id: credentials.clientId,\n client_secret: credentials.clientSecret,\n refresh_token: credentials.refreshToken,\n });\n const tokenResponse = parseSchema(\n WithingsApiTokenResponseSchema,\n response,\n \"Withings refresh token response\",\n );\n\n return {\n ...credentials,\n userId: tokenResponse.body.userid,\n accessToken: tokenResponse.body.access_token,\n refreshToken: tokenResponse.body.refresh_token,\n accessTokenExpiresAtMs: Date.now() + tokenResponse.body.expires_in * 1000,\n tokenType: tokenResponse.body.token_type,\n scope: tokenResponse.body.scope,\n };\n}\n\nexport function shouldRefreshWithingsCredentials(\n credentials: WithingsCredentials,\n): boolean {\n const refreshBufferMs = 5 * 60 * 1000;\n return Date.now() + refreshBufferMs >= credentials.accessTokenExpiresAtMs;\n}\n\nasync function postWithingsRequest(\n path: string,\n values: Record<string, string>,\n accessToken?: string,\n): Promise<unknown> {\n const url = new URL(path, withingsApiBaseUrl);\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n };\n if (accessToken) {\n headers.Authorization = `Bearer ${accessToken}`;\n }\n\n const response = await fetch(url, {\n method: \"POST\",\n headers,\n body: new URLSearchParams(values),\n });\n const responseText = await response.text();\n\n if (!response.ok) {\n throw new Error(\n `Withings API request failed: ${response.status} ${response.statusText}${responseText.length > 0 ? `: ${responseText}` : \"\"}`,\n );\n }\n\n const json = parseWithingsJson(responseText);\n const envelope = parseSchema(\n WithingsApiResponseEnvelopeSchema,\n json,\n \"Withings response envelope\",\n );\n assertWithingsSuccess(envelope, responseText);\n return json;\n}\n\nfunction parseWithingsJson(responseText: string): unknown {\n try {\n return JSON.parse(responseText) as unknown;\n } catch (error) {\n throw new Error(\n `Invalid JSON in Withings response: ${\n error instanceof Error ? error.message : String(error)\n }`,\n );\n }\n}\n\nfunction assertWithingsSuccess(\n envelope: WithingsApiResponseEnvelope,\n responseText: string,\n): void {\n if (envelope.status === 0) {\n return;\n }\n\n throw new Error(\n `Withings API request failed with status ${envelope.status}${\n envelope.error ? `: ${envelope.error}` : `: ${responseText}`\n }`,\n );\n}\n"]}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { stringifyJson } from "../../utils/parse.js";
|
|
2
|
+
import { withingsMeasureType, } from "./types.js";
|
|
3
|
+
const metricKeyByWithingsMeasureType = new Map([
|
|
4
|
+
[withingsMeasureType.weightKg, "weightKg"],
|
|
5
|
+
[withingsMeasureType.fatFreeMassKg, "fatFreeMassKg"],
|
|
6
|
+
[withingsMeasureType.fatMassKg, "fatMassKg"],
|
|
7
|
+
[withingsMeasureType.heartRateBpm, "heartRateBpm"],
|
|
8
|
+
[withingsMeasureType.muscleMassKg, "muscleMassKg"],
|
|
9
|
+
[withingsMeasureType.waterMassKg, "waterMassKg"],
|
|
10
|
+
[withingsMeasureType.boneMassKg, "boneMassKg"],
|
|
11
|
+
[
|
|
12
|
+
withingsMeasureType.pulseWaveVelocityMetersPerSecond,
|
|
13
|
+
"pulseWaveVelocityMetersPerSecond",
|
|
14
|
+
],
|
|
15
|
+
[withingsMeasureType.vascularAgeYears, "vascularAgeYears"],
|
|
16
|
+
[withingsMeasureType.visceralFatIndex, "visceralFatIndex"],
|
|
17
|
+
[
|
|
18
|
+
withingsMeasureType.basalMetabolicRateKcalPerDay,
|
|
19
|
+
"basalMetabolicRateKcalPerDay",
|
|
20
|
+
],
|
|
21
|
+
]);
|
|
22
|
+
export function normalizeWithingsMeasureGroup(group) {
|
|
23
|
+
const providerId = String(group.grpid);
|
|
24
|
+
const metrics = normalizeMetrics(group.measures);
|
|
25
|
+
if (!hasAnyMetric(metrics)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
id: `withings:${providerId}`,
|
|
30
|
+
provider: "withings",
|
|
31
|
+
providerId,
|
|
32
|
+
measuredAtMs: group.date * 1000,
|
|
33
|
+
...metrics,
|
|
34
|
+
sourceJson: stringifyJson(group),
|
|
35
|
+
providerExtrasJson: stringifyJson({
|
|
36
|
+
deviceId: group.deviceid ?? null,
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function normalizeMetrics(measures) {
|
|
41
|
+
const metrics = {
|
|
42
|
+
weightKg: null,
|
|
43
|
+
fatMassKg: null,
|
|
44
|
+
muscleMassKg: null,
|
|
45
|
+
boneMassKg: null,
|
|
46
|
+
waterMassKg: null,
|
|
47
|
+
fatFreeMassKg: null,
|
|
48
|
+
heartRateBpm: null,
|
|
49
|
+
vascularAgeYears: null,
|
|
50
|
+
visceralFatIndex: null,
|
|
51
|
+
basalMetabolicRateKcalPerDay: null,
|
|
52
|
+
pulseWaveVelocityMetersPerSecond: null,
|
|
53
|
+
};
|
|
54
|
+
for (const measure of measures) {
|
|
55
|
+
const metricKey = metricKeyByWithingsMeasureType.get(measure.type);
|
|
56
|
+
if (!metricKey || metrics[metricKey] !== null) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const value = withingsRealValue(measure);
|
|
60
|
+
metrics[metricKey] = value;
|
|
61
|
+
}
|
|
62
|
+
return metrics;
|
|
63
|
+
}
|
|
64
|
+
function withingsRealValue(measure) {
|
|
65
|
+
const value = measure.value * 10 ** measure.unit;
|
|
66
|
+
if (!Number.isFinite(value)) {
|
|
67
|
+
throw new Error(`Invalid Withings measure value for type ${measure.type}`);
|
|
68
|
+
}
|
|
69
|
+
// Withings uses decimal units; rounding removes binary float noise like
|
|
70
|
+
// 15.200000000000001 without changing meaningful precision.
|
|
71
|
+
return Number(value.toFixed(6));
|
|
72
|
+
}
|
|
73
|
+
function hasAnyMetric(metrics) {
|
|
74
|
+
return Object.values(metrics).some((value) => value !== null);
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=normalize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalize.js","sourceRoot":"","sources":["../../../src/providers/withings/normalize.ts"],"names":[],"mappings":"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","sourcesContent":["import type { BodyMeasurement } from \"../../domain/body-measurement.js\";\nimport { stringifyJson } from \"../../utils/parse.js\";\nimport {\n type WithingsApiMeasure,\n type WithingsApiMeasureGroup,\n withingsMeasureType,\n} from \"./types.js\";\n\ntype BodyMeasurementMetrics = Pick<\n BodyMeasurement,\n | \"weightKg\"\n | \"fatMassKg\"\n | \"muscleMassKg\"\n | \"boneMassKg\"\n | \"waterMassKg\"\n | \"fatFreeMassKg\"\n | \"heartRateBpm\"\n | \"vascularAgeYears\"\n | \"visceralFatIndex\"\n | \"basalMetabolicRateKcalPerDay\"\n | \"pulseWaveVelocityMetersPerSecond\"\n>;\n\nconst metricKeyByWithingsMeasureType = new Map<\n number,\n keyof BodyMeasurementMetrics\n>([\n [withingsMeasureType.weightKg, \"weightKg\"],\n [withingsMeasureType.fatFreeMassKg, \"fatFreeMassKg\"],\n [withingsMeasureType.fatMassKg, \"fatMassKg\"],\n [withingsMeasureType.heartRateBpm, \"heartRateBpm\"],\n [withingsMeasureType.muscleMassKg, \"muscleMassKg\"],\n [withingsMeasureType.waterMassKg, \"waterMassKg\"],\n [withingsMeasureType.boneMassKg, \"boneMassKg\"],\n [\n withingsMeasureType.pulseWaveVelocityMetersPerSecond,\n \"pulseWaveVelocityMetersPerSecond\",\n ],\n [withingsMeasureType.vascularAgeYears, \"vascularAgeYears\"],\n [withingsMeasureType.visceralFatIndex, \"visceralFatIndex\"],\n [\n withingsMeasureType.basalMetabolicRateKcalPerDay,\n \"basalMetabolicRateKcalPerDay\",\n ],\n]);\n\nexport function normalizeWithingsMeasureGroup(\n group: WithingsApiMeasureGroup,\n): BodyMeasurement | null {\n const providerId = String(group.grpid);\n const metrics = normalizeMetrics(group.measures);\n if (!hasAnyMetric(metrics)) {\n return null;\n }\n\n return {\n id: `withings:${providerId}`,\n provider: \"withings\",\n providerId,\n measuredAtMs: group.date * 1000,\n ...metrics,\n sourceJson: stringifyJson(group),\n providerExtrasJson: stringifyJson({\n deviceId: group.deviceid ?? null,\n }),\n };\n}\n\nfunction normalizeMetrics(\n measures: WithingsApiMeasure[],\n): BodyMeasurementMetrics {\n const metrics: BodyMeasurementMetrics = {\n weightKg: null,\n fatMassKg: null,\n muscleMassKg: null,\n boneMassKg: null,\n waterMassKg: null,\n fatFreeMassKg: null,\n heartRateBpm: null,\n vascularAgeYears: null,\n visceralFatIndex: null,\n basalMetabolicRateKcalPerDay: null,\n pulseWaveVelocityMetersPerSecond: null,\n };\n\n for (const measure of measures) {\n const metricKey = metricKeyByWithingsMeasureType.get(measure.type);\n if (!metricKey || metrics[metricKey] !== null) {\n continue;\n }\n\n const value = withingsRealValue(measure);\n metrics[metricKey] = value;\n }\n\n return metrics;\n}\n\nfunction withingsRealValue(measure: WithingsApiMeasure): number {\n const value = measure.value * 10 ** measure.unit;\n if (!Number.isFinite(value)) {\n throw new Error(`Invalid Withings measure value for type ${measure.type}`);\n }\n // Withings uses decimal units; rounding removes binary float noise like\n // 15.200000000000001 without changing meaningful precision.\n return Number(value.toFixed(6));\n}\n\nfunction hasAnyMetric(metrics: BodyMeasurementMetrics): boolean {\n return Object.values(metrics).some((value) => value !== null);\n}\n"]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function parseWithingsAuthorizationCode(value, expectedState) {
|
|
2
|
+
const trimmedValue = value.trim();
|
|
3
|
+
if (trimmedValue === "") {
|
|
4
|
+
throw new Error("Withings authorization code or redirected URL is required");
|
|
5
|
+
}
|
|
6
|
+
const url = parseUrl(trimmedValue);
|
|
7
|
+
if (url === null) {
|
|
8
|
+
return trimmedValue;
|
|
9
|
+
}
|
|
10
|
+
const code = url.searchParams.get("code");
|
|
11
|
+
if (code === null || code.length === 0) {
|
|
12
|
+
throw new Error("Withings redirected URL did not contain a code parameter");
|
|
13
|
+
}
|
|
14
|
+
const returnedState = url.searchParams.get("state");
|
|
15
|
+
if (returnedState !== expectedState) {
|
|
16
|
+
throw new Error("Withings redirected URL state did not match setup state");
|
|
17
|
+
}
|
|
18
|
+
return code;
|
|
19
|
+
}
|
|
20
|
+
function parseUrl(value) {
|
|
21
|
+
try {
|
|
22
|
+
return new URL(value);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=oauth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth.js","sourceRoot":"","sources":["../../../src/providers/withings/oauth.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,8BAA8B,CAC5C,KAAa,EACb,aAAqB;IAErB,MAAM,YAAY,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAClC,IAAI,YAAY,KAAK,EAAE,EAAE,CAAC;QACxB,MAAM,IAAI,KAAK,CACb,2DAA2D,CAC5D,CAAC;IACJ,CAAC;IAED,MAAM,GAAG,GAAG,QAAQ,CAAC,YAAY,CAAC,CAAC;IACnC,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;QACjB,OAAO,YAAY,CAAC;IACtB,CAAC;IAED,MAAM,IAAI,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAC1C,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvC,MAAM,IAAI,KAAK,CAAC,0DAA0D,CAAC,CAAC;IAC9E,CAAC;IAED,MAAM,aAAa,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACpD,IAAI,aAAa,KAAK,aAAa,EAAE,CAAC;QACpC,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;IAC7E,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,QAAQ,CAAC,KAAa;IAC7B,IAAI,CAAC;QACH,OAAO,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;IACxB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC","sourcesContent":["export function parseWithingsAuthorizationCode(\n value: string,\n expectedState: string,\n): string {\n const trimmedValue = value.trim();\n if (trimmedValue === \"\") {\n throw new Error(\n \"Withings authorization code or redirected URL is required\",\n );\n }\n\n const url = parseUrl(trimmedValue);\n if (url === null) {\n return trimmedValue;\n }\n\n const code = url.searchParams.get(\"code\");\n if (code === null || code.length === 0) {\n throw new Error(\"Withings redirected URL did not contain a code parameter\");\n }\n\n const returnedState = url.searchParams.get(\"state\");\n if (returnedState !== expectedState) {\n throw new Error(\"Withings redirected URL state did not match setup state\");\n }\n\n return code;\n}\n\nfunction parseUrl(value: string): URL | null {\n try {\n return new URL(value);\n } catch {\n return null;\n }\n}\n"]}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { upsertBodyMeasurement } from "../../db/body-measurements.js";
|
|
2
|
+
import { updateProviderCredentials, updateProviderCursor, } from "../../db/provider-state.js";
|
|
3
|
+
import { parseJson, parseSchema, stringifyJson } from "../../utils/parse.js";
|
|
4
|
+
import { refreshWithingsCredentials, shouldRefreshWithingsCredentials, WithingsClient, } from "./client.js";
|
|
5
|
+
import { normalizeWithingsMeasureGroup } from "./normalize.js";
|
|
6
|
+
import { WithingsCredentialsSchema, WithingsCursorSchema } from "./types.js";
|
|
7
|
+
const withingsOverlapSeconds = 24 * 60 * 60;
|
|
8
|
+
export async function syncWithings(db, state) {
|
|
9
|
+
let credentials = parseWithingsCredentials(state.credentialsJson);
|
|
10
|
+
const cursor = parseWithingsCursor(state.cursorJson);
|
|
11
|
+
const pages = [];
|
|
12
|
+
const queryLastUpdate = Math.max(0, cursor.lastUpdateUnixSeconds - withingsOverlapSeconds);
|
|
13
|
+
let offset = 0;
|
|
14
|
+
while (true) {
|
|
15
|
+
credentials = await refreshCredentialsIfNeeded(db, credentials);
|
|
16
|
+
const client = WithingsClient.fromCredentials(credentials);
|
|
17
|
+
const response = await client.getMeasurements(queryLastUpdate, offset);
|
|
18
|
+
pages.push(response);
|
|
19
|
+
if (response.body.more === 0) {
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
if (response.body.offset === offset) {
|
|
23
|
+
throw new Error(`Withings pagination did not advance past offset ${offset}`);
|
|
24
|
+
}
|
|
25
|
+
offset = response.body.offset;
|
|
26
|
+
}
|
|
27
|
+
const measurements = pages
|
|
28
|
+
.flatMap((page) => page.body.measuregrps)
|
|
29
|
+
.map(normalizeWithingsMeasureGroup)
|
|
30
|
+
.filter((measurement) => measurement !== null)
|
|
31
|
+
.sort((left, right) => left.measuredAtMs - right.measuredAtMs ||
|
|
32
|
+
left.id.localeCompare(right.id));
|
|
33
|
+
let nextLastUpdateUnixSeconds = cursor.lastUpdateUnixSeconds;
|
|
34
|
+
for (const page of pages) {
|
|
35
|
+
nextLastUpdateUnixSeconds = Math.max(nextLastUpdateUnixSeconds, page.body.updatetime, ...page.body.measuregrps.map((group) => group.created));
|
|
36
|
+
}
|
|
37
|
+
let newBodyMeasurementCount = 0;
|
|
38
|
+
const transaction = db.transaction(() => {
|
|
39
|
+
for (const measurement of measurements) {
|
|
40
|
+
if (upsertBodyMeasurement(db, measurement)) {
|
|
41
|
+
newBodyMeasurementCount += 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
updateProviderCursor(db, "withings", stringifyJson({
|
|
45
|
+
version: 1,
|
|
46
|
+
lastUpdateUnixSeconds: nextLastUpdateUnixSeconds,
|
|
47
|
+
}));
|
|
48
|
+
});
|
|
49
|
+
transaction();
|
|
50
|
+
return { newCount: newBodyMeasurementCount };
|
|
51
|
+
}
|
|
52
|
+
async function refreshCredentialsIfNeeded(db, credentials) {
|
|
53
|
+
if (!shouldRefreshWithingsCredentials(credentials)) {
|
|
54
|
+
return credentials;
|
|
55
|
+
}
|
|
56
|
+
const refreshedCredentials = await refreshWithingsCredentials(credentials);
|
|
57
|
+
updateProviderCredentials(db, "withings", stringifyJson(refreshedCredentials));
|
|
58
|
+
return refreshedCredentials;
|
|
59
|
+
}
|
|
60
|
+
function parseWithingsCredentials(json) {
|
|
61
|
+
return parseSchema(WithingsCredentialsSchema, parseJson(json, "withings credentials_json"), "Withings credentials");
|
|
62
|
+
}
|
|
63
|
+
function parseWithingsCursor(json) {
|
|
64
|
+
return parseSchema(WithingsCursorSchema, parseJson(json, "withings cursor_json"), "Withings cursor");
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=sync.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync.js","sourceRoot":"","sources":["../../../src/providers/withings/sync.ts"],"names":[],"mappings":"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","sourcesContent":["import { upsertBodyMeasurement } from \"../../db/body-measurements.js\";\nimport type { HealthlogDatabase } from \"../../db/database.js\";\nimport {\n type ProviderState,\n updateProviderCredentials,\n updateProviderCursor,\n} from \"../../db/provider-state.js\";\nimport type { ProviderSyncResult } from \"../../domain/provider.js\";\nimport { parseJson, parseSchema, stringifyJson } from \"../../utils/parse.js\";\nimport {\n refreshWithingsCredentials,\n shouldRefreshWithingsCredentials,\n WithingsClient,\n} from \"./client.js\";\nimport { normalizeWithingsMeasureGroup } from \"./normalize.js\";\nimport type {\n WithingsApiGetMeasResponse,\n WithingsCredentials,\n WithingsCursor,\n} from \"./types.js\";\nimport { WithingsCredentialsSchema, WithingsCursorSchema } from \"./types.js\";\n\nconst withingsOverlapSeconds = 24 * 60 * 60;\n\nexport async function syncWithings(\n db: HealthlogDatabase,\n state: ProviderState,\n): Promise<ProviderSyncResult> {\n let credentials = parseWithingsCredentials(state.credentialsJson);\n const cursor = parseWithingsCursor(state.cursorJson);\n const pages: WithingsApiGetMeasResponse[] = [];\n const queryLastUpdate = Math.max(\n 0,\n cursor.lastUpdateUnixSeconds - withingsOverlapSeconds,\n );\n let offset = 0;\n\n while (true) {\n credentials = await refreshCredentialsIfNeeded(db, credentials);\n const client = WithingsClient.fromCredentials(credentials);\n const response = await client.getMeasurements(queryLastUpdate, offset);\n pages.push(response);\n\n if (response.body.more === 0) {\n break;\n }\n\n if (response.body.offset === offset) {\n throw new Error(\n `Withings pagination did not advance past offset ${offset}`,\n );\n }\n offset = response.body.offset;\n }\n\n const measurements = pages\n .flatMap((page) => page.body.measuregrps)\n .map(normalizeWithingsMeasureGroup)\n .filter((measurement) => measurement !== null)\n .sort(\n (left, right) =>\n left.measuredAtMs - right.measuredAtMs ||\n left.id.localeCompare(right.id),\n );\n let nextLastUpdateUnixSeconds = cursor.lastUpdateUnixSeconds;\n for (const page of pages) {\n nextLastUpdateUnixSeconds = Math.max(\n nextLastUpdateUnixSeconds,\n page.body.updatetime,\n ...page.body.measuregrps.map((group) => group.created),\n );\n }\n\n let newBodyMeasurementCount = 0;\n const transaction = db.transaction(() => {\n for (const measurement of measurements) {\n if (upsertBodyMeasurement(db, measurement)) {\n newBodyMeasurementCount += 1;\n }\n }\n\n updateProviderCursor(\n db,\n \"withings\",\n stringifyJson({\n version: 1,\n lastUpdateUnixSeconds: nextLastUpdateUnixSeconds,\n } satisfies WithingsCursor),\n );\n });\n\n transaction();\n return { newCount: newBodyMeasurementCount };\n}\n\nasync function refreshCredentialsIfNeeded(\n db: HealthlogDatabase,\n credentials: WithingsCredentials,\n): Promise<WithingsCredentials> {\n if (!shouldRefreshWithingsCredentials(credentials)) {\n return credentials;\n }\n\n const refreshedCredentials = await refreshWithingsCredentials(credentials);\n updateProviderCredentials(\n db,\n \"withings\",\n stringifyJson(refreshedCredentials),\n );\n return refreshedCredentials;\n}\n\nfunction parseWithingsCredentials(json: string): WithingsCredentials {\n return parseSchema(\n WithingsCredentialsSchema,\n parseJson(json, \"withings credentials_json\"),\n \"Withings credentials\",\n );\n}\n\nfunction parseWithingsCursor(json: string): WithingsCursor {\n return parseSchema(\n WithingsCursorSchema,\n parseJson(json, \"withings cursor_json\"),\n \"Withings cursor\",\n );\n}\n"]}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const withingsDefaultRedirectUri = "http://localhost:8088/callback";
|
|
3
|
+
export const withingsOAuthScope = "user.metrics";
|
|
4
|
+
export const WithingsCredentialsSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
version: z.literal(1),
|
|
7
|
+
clientId: z.string().min(1),
|
|
8
|
+
clientSecret: z.string().min(1),
|
|
9
|
+
redirectUri: z.string().min(1),
|
|
10
|
+
userId: z.string().min(1),
|
|
11
|
+
accessToken: z.string().min(1),
|
|
12
|
+
refreshToken: z.string().min(1),
|
|
13
|
+
accessTokenExpiresAtMs: z.number(),
|
|
14
|
+
tokenType: z.literal("Bearer"),
|
|
15
|
+
scope: z.literal(withingsOAuthScope),
|
|
16
|
+
})
|
|
17
|
+
.strict();
|
|
18
|
+
export const WithingsCursorSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
version: z.literal(1),
|
|
21
|
+
lastUpdateUnixSeconds: z.number(),
|
|
22
|
+
})
|
|
23
|
+
.strict();
|
|
24
|
+
export const initialWithingsCursor = {
|
|
25
|
+
version: 1,
|
|
26
|
+
lastUpdateUnixSeconds: 0,
|
|
27
|
+
};
|
|
28
|
+
// Withings measure type IDs:
|
|
29
|
+
// https://developer.withings.com/developer-guide/v3/data-api/notifications/notification-content/
|
|
30
|
+
// https://developer.withings.com/developer-guide/v3/integration-guide/onsite-mode/data-api/all-available-health-data-body-scan/
|
|
31
|
+
export const withingsMeasureType = {
|
|
32
|
+
weightKg: 1,
|
|
33
|
+
fatFreeMassKg: 5,
|
|
34
|
+
fatMassKg: 8,
|
|
35
|
+
heartRateBpm: 11,
|
|
36
|
+
muscleMassKg: 76,
|
|
37
|
+
waterMassKg: 77,
|
|
38
|
+
boneMassKg: 88,
|
|
39
|
+
pulseWaveVelocityMetersPerSecond: 91,
|
|
40
|
+
vascularAgeYears: 155,
|
|
41
|
+
visceralFatIndex: 170,
|
|
42
|
+
basalMetabolicRateKcalPerDay: 226,
|
|
43
|
+
};
|
|
44
|
+
export const withingsTargetMeasureTypes = Object.values(withingsMeasureType);
|
|
45
|
+
export const withingsTargetMeasureTypesParam = withingsTargetMeasureTypes.join(",");
|
|
46
|
+
const WithingsApiUserIdSchema = z
|
|
47
|
+
.union([z.string().min(1), z.number()])
|
|
48
|
+
.transform((value) => String(value));
|
|
49
|
+
export const WithingsApiTokenBodySchema = z.looseObject({
|
|
50
|
+
userid: WithingsApiUserIdSchema,
|
|
51
|
+
access_token: z.string().min(1),
|
|
52
|
+
refresh_token: z.string().min(1),
|
|
53
|
+
expires_in: z.number().positive(),
|
|
54
|
+
token_type: z.literal("Bearer"),
|
|
55
|
+
scope: z.literal(withingsOAuthScope),
|
|
56
|
+
});
|
|
57
|
+
export const WithingsApiResponseEnvelopeSchema = z.looseObject({
|
|
58
|
+
status: z.number(),
|
|
59
|
+
error: z.string().optional(),
|
|
60
|
+
});
|
|
61
|
+
export const WithingsApiTokenResponseSchema = z.looseObject({
|
|
62
|
+
status: z.literal(0),
|
|
63
|
+
body: WithingsApiTokenBodySchema,
|
|
64
|
+
});
|
|
65
|
+
export const WithingsApiUnixSecondsSchema = z
|
|
66
|
+
.union([z.number(), z.string().min(1)])
|
|
67
|
+
.transform((value) => {
|
|
68
|
+
const timestamp = typeof value === "number" ? value : Number(value);
|
|
69
|
+
if (!Number.isFinite(timestamp)) {
|
|
70
|
+
throw new Error(`Invalid Withings unix timestamp "${value}"`);
|
|
71
|
+
}
|
|
72
|
+
return timestamp;
|
|
73
|
+
});
|
|
74
|
+
export const WithingsApiMeasureSchema = z.looseObject({
|
|
75
|
+
value: z.number(),
|
|
76
|
+
type: z.number(),
|
|
77
|
+
unit: z.number(),
|
|
78
|
+
algo: z.number().optional(),
|
|
79
|
+
fm: z.number().optional(),
|
|
80
|
+
fw: z.number().optional(),
|
|
81
|
+
});
|
|
82
|
+
export const WithingsApiMeasureGroupSchema = z.looseObject({
|
|
83
|
+
grpid: z.union([z.number(), z.string().min(1)]),
|
|
84
|
+
attrib: z.number(),
|
|
85
|
+
date: WithingsApiUnixSecondsSchema,
|
|
86
|
+
created: WithingsApiUnixSecondsSchema,
|
|
87
|
+
category: z.number(),
|
|
88
|
+
deviceid: z.string().nullish(),
|
|
89
|
+
measures: z.array(WithingsApiMeasureSchema),
|
|
90
|
+
comment: z.string().nullable().optional(),
|
|
91
|
+
});
|
|
92
|
+
const WithingsApiGetMeasBodySchema = z
|
|
93
|
+
.looseObject({
|
|
94
|
+
updatetime: WithingsApiUnixSecondsSchema,
|
|
95
|
+
timezone: z.string().optional(),
|
|
96
|
+
measuregrps: z.array(WithingsApiMeasureGroupSchema),
|
|
97
|
+
more: z.union([z.literal(0), z.literal(1)]).default(0),
|
|
98
|
+
offset: z.number().optional(),
|
|
99
|
+
})
|
|
100
|
+
.transform((body) => {
|
|
101
|
+
if (body.more === 1 && body.offset === undefined) {
|
|
102
|
+
throw new Error("Withings paginated response is missing offset");
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
...body,
|
|
106
|
+
offset: body.offset ?? 0,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
export const WithingsApiGetMeasResponseSchema = z.looseObject({
|
|
110
|
+
status: z.literal(0),
|
|
111
|
+
body: WithingsApiGetMeasBodySchema,
|
|
112
|
+
});
|
|
113
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/providers/withings/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,MAAM,CAAC,MAAM,0BAA0B,GAAG,gCAAgC,CAAC;AAC3E,MAAM,CAAC,MAAM,kBAAkB,GAAG,cAAc,CAAC;AAejD,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC;KACvC,MAAM,CAAC;IACN,OAAO,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IACrB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAC3B,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAC/B,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAC9B,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACzB,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAC9B,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAC/B,sBAAsB,EAAE,CAAC,CAAC,MAAM,EAAE;IAClC,SAAS,EAAE,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC;IAC9B,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,kBAAkB,CAAC;CACrC,CAAC;KACD,MAAM,EAAE,CAAC;AAOZ,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC;KAClC,MAAM,CAAC;IACN,OAAO,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IACrB,qBAAqB,EAAE,CAAC,CAAC,MAAM,EAAE;CAClC,CAAC;KACD,MAAM,EAAE,CAAC;AAEZ,MAAM,CAAC,MAAM,qBAAqB,GAAmB;IACnD,OAAO,EAAE,CAAC;IACV,qBAAqB,EAAE,CAAC;CACzB,CAAC;AAEF,6BAA6B;AAC7B,iGAAiG;AACjG,gIAAgI;AAChI,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,QAAQ,EAAE,CAAC;IACX,aAAa,EAAE,CAAC;IAChB,SAAS,EAAE,CAAC;IACZ,YAAY,EAAE,EAAE;IAChB,YAAY,EAAE,EAAE;IAChB,WAAW,EAAE,EAAE;IACf,UAAU,EAAE,EAAE;IACd,gCAAgC,EAAE,EAAE;IACpC,gBAAgB,EAAE,GAAG;IACrB,gBAAgB,EAAE,GAAG;IACrB,4BAA4B,EAAE,GAAG;CACzB,CAAC;AAEX,MAAM,CAAC,MAAM,0BAA0B,GAAG,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC;AAE7E,MAAM,CAAC,MAAM,+BAA+B,GAC1C,0BAA0B,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAEvC,MAAM,uBAAuB,GAAG,CAAC;KAC9B,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;KACtC,SAAS,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;AAEvC,MAAM,CAAC,MAAM,0BAA0B,GAAG,CAAC,CAAC,WAAW,CAAC;IACtD,MAAM,EAAE,uBAAuB;IAC/B,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAC/B,aAAa,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAChC,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACjC,UAAU,EAAE,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC;IAC/B,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,kBAAkB,CAAC;CACrC,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,iCAAiC,GAAG,CAAC,CAAC,WAAW,CAAC;IAC7D,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE;IAClB,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CAC7B,CAAC,CAAC;AAMH,MAAM,CAAC,MAAM,8BAA8B,GAAG,CAAC,CAAC,WAAW,CAAC;IAC1D,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IACpB,IAAI,EAAE,0BAA0B;CACjC,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAAC;KAC1C,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;KACtC,SAAS,CAAC,CAAC,KAAK,EAAE,EAAE;IACnB,MAAM,SAAS,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACpE,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;QAChC,MAAM,IAAI,KAAK,CAAC,oCAAoC,KAAK,GAAG,CAAC,CAAC;IAChE,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC,CAAC,CAAC;AAEL,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,CAAC,WAAW,CAAC;IACpD,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;IACjB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC3B,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACzB,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CAC1B,CAAC,CAAC;AAIH,MAAM,CAAC,MAAM,6BAA6B,GAAG,CAAC,CAAC,WAAW,CAAC;IACzD,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAC/C,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE;IAClB,IAAI,EAAE,4BAA4B;IAClC,OAAO,EAAE,4BAA4B;IACrC,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE;IACpB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE;IAC9B,QAAQ,EAAE,CAAC,CAAC,KAAK,CAAC,wBAAwB,CAAC;IAC3C,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE;CAC1C,CAAC,CAAC;AAMH,MAAM,4BAA4B,GAAG,CAAC;KACnC,WAAW,CAAC;IACX,UAAU,EAAE,4BAA4B;IACxC,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC/B,WAAW,EAAE,CAAC,CAAC,KAAK,CAAC,6BAA6B,CAAC;IACnD,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IACtD,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CAC9B,CAAC;KACD,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE;IAClB,IAAI,IAAI,CAAC,IAAI,KAAK,CAAC,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;QACjD,MAAM,IAAI,KAAK,CAAC,+CAA+C,CAAC,CAAC;IACnE,CAAC;IAED,OAAO;QACL,GAAG,IAAI;QACP,MAAM,EAAE,IAAI,CAAC,MAAM,IAAI,CAAC;KACzB,CAAC;AACJ,CAAC,CAAC,CAAC;AAEL,MAAM,CAAC,MAAM,gCAAgC,GAAG,CAAC,CAAC,WAAW,CAAC;IAC5D,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IACpB,IAAI,EAAE,4BAA4B;CACnC,CAAC,CAAC","sourcesContent":["import { z } from \"zod\";\n\nexport const withingsDefaultRedirectUri = \"http://localhost:8088/callback\";\nexport const withingsOAuthScope = \"user.metrics\";\n\nexport type WithingsCredentials = {\n version: 1;\n clientId: string;\n clientSecret: string;\n redirectUri: string;\n userId: string;\n accessToken: string;\n refreshToken: string;\n accessTokenExpiresAtMs: number;\n tokenType: \"Bearer\";\n scope: typeof withingsOAuthScope;\n};\n\nexport const WithingsCredentialsSchema = z\n .object({\n version: z.literal(1),\n clientId: z.string().min(1),\n clientSecret: z.string().min(1),\n redirectUri: z.string().min(1),\n userId: z.string().min(1),\n accessToken: z.string().min(1),\n refreshToken: z.string().min(1),\n accessTokenExpiresAtMs: z.number(),\n tokenType: z.literal(\"Bearer\"),\n scope: z.literal(withingsOAuthScope),\n })\n .strict();\n\nexport type WithingsCursor = {\n version: 1;\n lastUpdateUnixSeconds: number;\n};\n\nexport const WithingsCursorSchema = z\n .object({\n version: z.literal(1),\n lastUpdateUnixSeconds: z.number(),\n })\n .strict();\n\nexport const initialWithingsCursor: WithingsCursor = {\n version: 1,\n lastUpdateUnixSeconds: 0,\n};\n\n// Withings measure type IDs:\n// https://developer.withings.com/developer-guide/v3/data-api/notifications/notification-content/\n// https://developer.withings.com/developer-guide/v3/integration-guide/onsite-mode/data-api/all-available-health-data-body-scan/\nexport const withingsMeasureType = {\n weightKg: 1,\n fatFreeMassKg: 5,\n fatMassKg: 8,\n heartRateBpm: 11,\n muscleMassKg: 76,\n waterMassKg: 77,\n boneMassKg: 88,\n pulseWaveVelocityMetersPerSecond: 91,\n vascularAgeYears: 155,\n visceralFatIndex: 170,\n basalMetabolicRateKcalPerDay: 226,\n} as const;\n\nexport const withingsTargetMeasureTypes = Object.values(withingsMeasureType);\n\nexport const withingsTargetMeasureTypesParam =\n withingsTargetMeasureTypes.join(\",\");\n\nconst WithingsApiUserIdSchema = z\n .union([z.string().min(1), z.number()])\n .transform((value) => String(value));\n\nexport const WithingsApiTokenBodySchema = z.looseObject({\n userid: WithingsApiUserIdSchema,\n access_token: z.string().min(1),\n refresh_token: z.string().min(1),\n expires_in: z.number().positive(),\n token_type: z.literal(\"Bearer\"),\n scope: z.literal(withingsOAuthScope),\n});\n\nexport const WithingsApiResponseEnvelopeSchema = z.looseObject({\n status: z.number(),\n error: z.string().optional(),\n});\n\nexport type WithingsApiResponseEnvelope = z.infer<\n typeof WithingsApiResponseEnvelopeSchema\n>;\n\nexport const WithingsApiTokenResponseSchema = z.looseObject({\n status: z.literal(0),\n body: WithingsApiTokenBodySchema,\n});\n\nexport const WithingsApiUnixSecondsSchema = z\n .union([z.number(), z.string().min(1)])\n .transform((value) => {\n const timestamp = typeof value === \"number\" ? value : Number(value);\n if (!Number.isFinite(timestamp)) {\n throw new Error(`Invalid Withings unix timestamp \"${value}\"`);\n }\n return timestamp;\n });\n\nexport const WithingsApiMeasureSchema = z.looseObject({\n value: z.number(),\n type: z.number(),\n unit: z.number(),\n algo: z.number().optional(),\n fm: z.number().optional(),\n fw: z.number().optional(),\n});\n\nexport type WithingsApiMeasure = z.infer<typeof WithingsApiMeasureSchema>;\n\nexport const WithingsApiMeasureGroupSchema = z.looseObject({\n grpid: z.union([z.number(), z.string().min(1)]),\n attrib: z.number(),\n date: WithingsApiUnixSecondsSchema,\n created: WithingsApiUnixSecondsSchema,\n category: z.number(),\n deviceid: z.string().nullish(),\n measures: z.array(WithingsApiMeasureSchema),\n comment: z.string().nullable().optional(),\n});\n\nexport type WithingsApiMeasureGroup = z.infer<\n typeof WithingsApiMeasureGroupSchema\n>;\n\nconst WithingsApiGetMeasBodySchema = z\n .looseObject({\n updatetime: WithingsApiUnixSecondsSchema,\n timezone: z.string().optional(),\n measuregrps: z.array(WithingsApiMeasureGroupSchema),\n more: z.union([z.literal(0), z.literal(1)]).default(0),\n offset: z.number().optional(),\n })\n .transform((body) => {\n if (body.more === 1 && body.offset === undefined) {\n throw new Error(\"Withings paginated response is missing offset\");\n }\n\n return {\n ...body,\n offset: body.offset ?? 0,\n };\n });\n\nexport const WithingsApiGetMeasResponseSchema = z.looseObject({\n status: z.literal(0),\n body: WithingsApiGetMeasBodySchema,\n});\n\nexport type WithingsApiGetMeasResponse = z.infer<\n typeof WithingsApiGetMeasResponseSchema\n>;\n"]}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { getBodyMeasurements } from "../db/body-measurements.js";
|
|
1
2
|
import { getWorkoutsWithMetrics } from "../db/workouts.js";
|
|
3
|
+
import { BodyMeasurementProviderExtrasSchema } from "../domain/body-measurement.js";
|
|
2
4
|
import { ActivityMetricsSchema, StrengthExercisesSchema, } from "../domain/workout.js";
|
|
3
5
|
import { parseJson, parseSchema } from "../utils/parse.js";
|
|
4
6
|
export function buildDumpDocument(db, range) {
|
|
5
7
|
const workoutsWithMetrics = getWorkoutsWithMetrics(db, range);
|
|
8
|
+
const bodyMeasurements = getBodyMeasurements(db, range);
|
|
6
9
|
return {
|
|
7
10
|
generatedAt: new Date().toISOString(),
|
|
8
11
|
range: {
|
|
@@ -55,6 +58,24 @@ export function buildDumpDocument(db, range) {
|
|
|
55
58
|
exercises: parseStrengthExercises(workoutWithMetrics.strengthMetrics.exercisesJson, workout.id),
|
|
56
59
|
};
|
|
57
60
|
}),
|
|
61
|
+
bodyMeasurements: bodyMeasurements.map((measurement) => ({
|
|
62
|
+
id: measurement.id,
|
|
63
|
+
provider: measurement.provider,
|
|
64
|
+
providerId: measurement.providerId,
|
|
65
|
+
measuredAt: new Date(measurement.measuredAtMs).toISOString(),
|
|
66
|
+
weightKg: measurement.weightKg,
|
|
67
|
+
fatMassKg: measurement.fatMassKg,
|
|
68
|
+
muscleMassKg: measurement.muscleMassKg,
|
|
69
|
+
boneMassKg: measurement.boneMassKg,
|
|
70
|
+
waterMassKg: measurement.waterMassKg,
|
|
71
|
+
fatFreeMassKg: measurement.fatFreeMassKg,
|
|
72
|
+
heartRateBpm: measurement.heartRateBpm,
|
|
73
|
+
vascularAgeYears: measurement.vascularAgeYears,
|
|
74
|
+
visceralFatIndex: measurement.visceralFatIndex,
|
|
75
|
+
basalMetabolicRateKcalPerDay: measurement.basalMetabolicRateKcalPerDay,
|
|
76
|
+
pulseWaveVelocityMetersPerSecond: measurement.pulseWaveVelocityMetersPerSecond,
|
|
77
|
+
providerExtras: parseBodyMeasurementProviderExtras(measurement.providerExtrasJson, measurement.id),
|
|
78
|
+
})),
|
|
58
79
|
};
|
|
59
80
|
}
|
|
60
81
|
function parseProviderExtrasJson(providerExtrasJson, workoutId) {
|
|
@@ -68,4 +89,7 @@ function parseStrengthExercises(exercisesJson, workoutId) {
|
|
|
68
89
|
function parseActivityMetrics(activityMetricsJson, workoutId) {
|
|
69
90
|
return parseSchema(ActivityMetricsSchema, parseJson(activityMetricsJson, `activity_metrics_json for ${workoutId}`), `activity metrics for workout ${workoutId}`);
|
|
70
91
|
}
|
|
92
|
+
function parseBodyMeasurementProviderExtras(providerExtrasJson, measurementId) {
|
|
93
|
+
return parseSchema(BodyMeasurementProviderExtrasSchema, parseJson(providerExtrasJson, `body measurement provider_extras_json for ${measurementId}`), `provider extras for body measurement ${measurementId}`);
|
|
94
|
+
}
|
|
71
95
|
//# sourceMappingURL=dump-service.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dump-service.js","sourceRoot":"","sources":["../../src/services/dump-service.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dump-service.js","sourceRoot":"","sources":["../../src/services/dump-service.ts"],"names":[],"mappings":"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","sourcesContent":["import { getBodyMeasurements } from \"../db/body-measurements.js\";\nimport type { HealthlogDatabase } from \"../db/database.js\";\nimport { getWorkoutsWithMetrics } from \"../db/workouts.js\";\nimport { BodyMeasurementProviderExtrasSchema } from \"../domain/body-measurement.js\";\nimport type {\n DumpBodyMeasurement,\n DumpDocument,\n DumpWorkout,\n} from \"../domain/dump.js\";\nimport type { ActivityMetric, StrengthExercise } from \"../domain/workout.js\";\nimport {\n ActivityMetricsSchema,\n StrengthExercisesSchema,\n} from \"../domain/workout.js\";\nimport type { DateRange } from \"../utils/dates.js\";\nimport { parseJson, parseSchema } from \"../utils/parse.js\";\n\nexport function buildDumpDocument(\n db: HealthlogDatabase,\n range: DateRange,\n): DumpDocument {\n const workoutsWithMetrics = getWorkoutsWithMetrics(db, range);\n const bodyMeasurements = getBodyMeasurements(db, range);\n\n return {\n generatedAt: new Date().toISOString(),\n range: {\n from: range.from,\n to: range.to,\n },\n workouts: workoutsWithMetrics.map((workoutWithMetrics): DumpWorkout => {\n const workout = workoutWithMetrics.workout;\n if (workoutWithMetrics.type === \"endurance\") {\n const metrics = workoutWithMetrics.enduranceMetrics;\n return {\n id: workout.id,\n provider: workout.provider,\n providerId: workout.providerId,\n type: \"endurance\",\n sport: workout.sport,\n title: workout.title,\n startedAt: new Date(workout.startedAtMs).toISOString(),\n endedAt:\n workout.endedAtMs === null\n ? null\n : new Date(workout.endedAtMs).toISOString(),\n providerExtras: parseProviderExtrasJson(\n workout.providerExtrasJson,\n workout.id,\n ),\n durationSeconds: metrics.durationSeconds,\n distanceMeters: metrics.distanceMeters,\n elevationGainMeters: metrics.elevationGainMeters,\n elevationLossMeters: metrics.elevationLossMeters,\n startLocation: metrics.startLocation,\n calories: metrics.calories,\n averageHeartRate: metrics.averageHeartRate,\n maxHeartRate: metrics.maxHeartRate,\n averageRunningCadenceStepsPerMinute:\n metrics.averageRunningCadenceStepsPerMinute,\n averageStrideLengthCentimeters:\n metrics.averageStrideLengthCentimeters,\n averagePaceMinutesPerKilometer:\n metrics.averagePaceMinutesPerKilometer,\n fastestPaceMinutesPerKilometer:\n metrics.fastestPaceMinutesPerKilometer,\n activityMetrics: parseActivityMetrics(\n metrics.activityMetricsJson,\n workout.id,\n ),\n };\n }\n\n return {\n id: workout.id,\n provider: workout.provider,\n providerId: workout.providerId,\n type: \"strength\",\n sport: workout.sport,\n title: workout.title,\n startedAt: new Date(workout.startedAtMs).toISOString(),\n endedAt:\n workout.endedAtMs === null\n ? null\n : new Date(workout.endedAtMs).toISOString(),\n providerExtras: parseProviderExtrasJson(\n workout.providerExtrasJson,\n workout.id,\n ),\n exercises: parseStrengthExercises(\n workoutWithMetrics.strengthMetrics.exercisesJson,\n workout.id,\n ),\n };\n }),\n bodyMeasurements: bodyMeasurements.map(\n (measurement): DumpBodyMeasurement => ({\n id: measurement.id,\n provider: measurement.provider,\n providerId: measurement.providerId,\n measuredAt: new Date(measurement.measuredAtMs).toISOString(),\n weightKg: measurement.weightKg,\n fatMassKg: measurement.fatMassKg,\n muscleMassKg: measurement.muscleMassKg,\n boneMassKg: measurement.boneMassKg,\n waterMassKg: measurement.waterMassKg,\n fatFreeMassKg: measurement.fatFreeMassKg,\n heartRateBpm: measurement.heartRateBpm,\n vascularAgeYears: measurement.vascularAgeYears,\n visceralFatIndex: measurement.visceralFatIndex,\n basalMetabolicRateKcalPerDay: measurement.basalMetabolicRateKcalPerDay,\n pulseWaveVelocityMetersPerSecond:\n measurement.pulseWaveVelocityMetersPerSecond,\n providerExtras: parseBodyMeasurementProviderExtras(\n measurement.providerExtrasJson,\n measurement.id,\n ),\n }),\n ),\n };\n}\n\nfunction parseProviderExtrasJson(\n providerExtrasJson: string | null,\n workoutId: string,\n): unknown | null {\n return providerExtrasJson === null\n ? null\n : parseJson(providerExtrasJson, `provider_extras_json for ${workoutId}`);\n}\n\nfunction parseStrengthExercises(\n exercisesJson: string,\n workoutId: string,\n): StrengthExercise[] {\n return parseSchema(\n StrengthExercisesSchema,\n parseJson(exercisesJson, `strength metrics for ${workoutId}`),\n `strength metrics for workout ${workoutId}`,\n );\n}\n\nfunction parseActivityMetrics(\n activityMetricsJson: string,\n workoutId: string,\n): ActivityMetric[] {\n return parseSchema(\n ActivityMetricsSchema,\n parseJson(activityMetricsJson, `activity_metrics_json for ${workoutId}`),\n `activity metrics for workout ${workoutId}`,\n );\n}\n\nfunction parseBodyMeasurementProviderExtras(\n providerExtrasJson: string,\n measurementId: string,\n) {\n return parseSchema(\n BodyMeasurementProviderExtrasSchema,\n parseJson(\n providerExtrasJson,\n `body measurement provider_extras_json for ${measurementId}`,\n ),\n `provider extras for body measurement ${measurementId}`,\n );\n}\n"]}
|