health-sync 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/src/config.js ADDED
@@ -0,0 +1,317 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import TOML from '@iarna/toml';
4
+
5
+ const BUILTIN_DEFAULTS = {
6
+ app: {
7
+ db: './health.sqlite',
8
+ },
9
+ oura: {
10
+ enabled: false,
11
+ client_id: null,
12
+ client_secret: null,
13
+ authorize_url: 'https://moi.ouraring.com/oauth/v2/ext/oauth-authorize',
14
+ token_url: 'https://moi.ouraring.com/oauth/v2/ext/oauth-token',
15
+ redirect_uri: 'http://localhost:8080/callback',
16
+ scopes: 'extapi:daily extapi:heartrate extapi:personal extapi:workout extapi:session extapi:tag extapi:spo2',
17
+ start_date: '2010-01-01',
18
+ overlap_days: 7,
19
+ },
20
+ withings: {
21
+ enabled: false,
22
+ client_id: null,
23
+ client_secret: null,
24
+ redirect_uri: 'http://127.0.0.1:8485/callback',
25
+ scopes: 'user.metrics,user.activity',
26
+ overlap_seconds: 300,
27
+ meastypes: null,
28
+ },
29
+ hevy: {
30
+ enabled: false,
31
+ api_key: null,
32
+ base_url: 'https://api.hevyapp.com',
33
+ overlap_seconds: 300,
34
+ page_size: 10,
35
+ since: '1970-01-01T00:00:00Z',
36
+ },
37
+ strava: {
38
+ enabled: false,
39
+ access_token: null,
40
+ client_id: null,
41
+ client_secret: null,
42
+ redirect_uri: 'http://127.0.0.1:8486/callback',
43
+ scopes: 'read,activity:read_all',
44
+ approval_prompt: 'auto',
45
+ start_date: '2010-01-01',
46
+ overlap_seconds: 604800,
47
+ page_size: 100,
48
+ },
49
+ eightsleep: {
50
+ enabled: false,
51
+ access_token: null,
52
+ email: null,
53
+ password: null,
54
+ client_id: '0894c7f33bb94800a03f1f4df13a4f38',
55
+ client_secret: 'f0954a3ed5763ba3d06834c73731a32f15f168f47d4f164751275def86db0c76',
56
+ timezone: 'UTC',
57
+ auth_url: 'https://auth-api.8slp.net/v1/tokens',
58
+ client_api_url: 'https://client-api.8slp.net/v1',
59
+ start_date: '2010-01-01',
60
+ overlap_days: 2,
61
+ },
62
+ };
63
+
64
+ function clone(value) {
65
+ return JSON.parse(JSON.stringify(value));
66
+ }
67
+
68
+ function section(obj, name) {
69
+ const raw = obj?.[name];
70
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
71
+ return raw;
72
+ }
73
+ return {};
74
+ }
75
+
76
+ function getStr(raw, key, fallback = null) {
77
+ const value = raw?.[key];
78
+ if (value === undefined || value === null || typeof value === 'boolean') {
79
+ return fallback;
80
+ }
81
+ const out = String(value).trim();
82
+ return out ? out : fallback;
83
+ }
84
+
85
+ function getInt(raw, key, fallback = null) {
86
+ const value = raw?.[key];
87
+ if (value === undefined || value === null) {
88
+ return fallback;
89
+ }
90
+ if (typeof value === 'number' && Number.isFinite(value)) {
91
+ return Math.trunc(value);
92
+ }
93
+ const parsed = Number.parseInt(String(value).trim(), 10);
94
+ return Number.isFinite(parsed) ? parsed : fallback;
95
+ }
96
+
97
+ function getBool(raw, key, fallback = false) {
98
+ const value = raw?.[key];
99
+ if (value === undefined || value === null) {
100
+ return fallback;
101
+ }
102
+ if (typeof value === 'boolean') {
103
+ return value;
104
+ }
105
+ if (typeof value === 'number') {
106
+ return value !== 0;
107
+ }
108
+ const normalized = String(value).trim().toLowerCase();
109
+ if (['1', 'true', 't', 'yes', 'y', 'on'].includes(normalized)) {
110
+ return true;
111
+ }
112
+ if (['0', 'false', 'f', 'no', 'n', 'off'].includes(normalized)) {
113
+ return false;
114
+ }
115
+ return fallback;
116
+ }
117
+
118
+ function getListStr(raw, key, fallback = null) {
119
+ const value = raw?.[key];
120
+ if (value === undefined || value === null || typeof value === 'boolean') {
121
+ return fallback;
122
+ }
123
+ if (Array.isArray(value)) {
124
+ const items = value
125
+ .map((v) => (v === null || v === undefined ? '' : String(v).trim()))
126
+ .filter((v) => !!v);
127
+ return items.length ? items : fallback;
128
+ }
129
+ const single = String(value).trim();
130
+ if (!single) {
131
+ return fallback;
132
+ }
133
+ const parts = single.split(',').map((p) => p.trim()).filter((p) => !!p);
134
+ return parts.length ? parts : fallback;
135
+ }
136
+
137
+ export function defaultConfig() {
138
+ return {
139
+ app: clone(BUILTIN_DEFAULTS.app),
140
+ oura: clone(BUILTIN_DEFAULTS.oura),
141
+ withings: clone(BUILTIN_DEFAULTS.withings),
142
+ hevy: clone(BUILTIN_DEFAULTS.hevy),
143
+ strava: clone(BUILTIN_DEFAULTS.strava),
144
+ eightsleep: clone(BUILTIN_DEFAULTS.eightsleep),
145
+ plugins: {},
146
+ };
147
+ }
148
+
149
+ function loadPlugins(raw) {
150
+ const pluginsTable = section(raw, 'plugins');
151
+ const out = {};
152
+ for (const [pluginId, pluginRaw] of Object.entries(pluginsTable)) {
153
+ if (!pluginRaw || typeof pluginRaw !== 'object' || Array.isArray(pluginRaw)) {
154
+ continue;
155
+ }
156
+ const mapped = {};
157
+ for (const [k, v] of Object.entries(pluginRaw)) {
158
+ if (typeof v === 'string') {
159
+ const trimmed = v.trim();
160
+ mapped[k] = trimmed.length ? trimmed : null;
161
+ } else {
162
+ mapped[k] = v;
163
+ }
164
+ }
165
+ out[pluginId] = mapped;
166
+ }
167
+ return out;
168
+ }
169
+
170
+ export function loadConfig(configPath) {
171
+ const resolvedPath = path.resolve(configPath);
172
+ let raw = {};
173
+ if (fs.existsSync(resolvedPath)) {
174
+ const text = fs.readFileSync(resolvedPath, 'utf8');
175
+ raw = TOML.parse(text);
176
+ }
177
+
178
+ const cfg = defaultConfig();
179
+
180
+ const app = section(raw, 'app');
181
+ cfg.app.db = getStr(app, 'db', cfg.app.db);
182
+
183
+ const oura = section(raw, 'oura');
184
+ cfg.oura.enabled = getBool(oura, 'enabled', cfg.oura.enabled);
185
+ cfg.oura.client_id = getStr(oura, 'client_id', cfg.oura.client_id);
186
+ cfg.oura.client_secret = getStr(oura, 'client_secret', cfg.oura.client_secret);
187
+ cfg.oura.authorize_url = getStr(oura, 'authorize_url', cfg.oura.authorize_url);
188
+ cfg.oura.token_url = getStr(oura, 'token_url', cfg.oura.token_url);
189
+ cfg.oura.redirect_uri = getStr(oura, 'redirect_uri', cfg.oura.redirect_uri);
190
+ cfg.oura.scopes = getStr(oura, 'scopes', cfg.oura.scopes);
191
+ cfg.oura.start_date = getStr(oura, 'start_date', cfg.oura.start_date);
192
+ cfg.oura.overlap_days = getInt(oura, 'overlap_days', cfg.oura.overlap_days);
193
+
194
+ const withings = section(raw, 'withings');
195
+ cfg.withings.enabled = getBool(withings, 'enabled', cfg.withings.enabled);
196
+ cfg.withings.client_id = getStr(withings, 'client_id', cfg.withings.client_id);
197
+ cfg.withings.client_secret = getStr(withings, 'client_secret', cfg.withings.client_secret);
198
+ cfg.withings.redirect_uri = getStr(withings, 'redirect_uri', cfg.withings.redirect_uri);
199
+ cfg.withings.scopes = getStr(withings, 'scopes', cfg.withings.scopes);
200
+ cfg.withings.overlap_seconds = getInt(withings, 'overlap_seconds', cfg.withings.overlap_seconds);
201
+ cfg.withings.meastypes = getListStr(withings, 'meastypes', cfg.withings.meastypes);
202
+
203
+ const hevy = section(raw, 'hevy');
204
+ cfg.hevy.enabled = getBool(hevy, 'enabled', cfg.hevy.enabled);
205
+ cfg.hevy.api_key = getStr(hevy, 'api_key', cfg.hevy.api_key);
206
+ cfg.hevy.base_url = getStr(hevy, 'base_url', cfg.hevy.base_url);
207
+ cfg.hevy.overlap_seconds = getInt(hevy, 'overlap_seconds', cfg.hevy.overlap_seconds);
208
+ cfg.hevy.page_size = getInt(hevy, 'page_size', cfg.hevy.page_size);
209
+ cfg.hevy.since = getStr(hevy, 'since', cfg.hevy.since);
210
+
211
+ const strava = section(raw, 'strava');
212
+ cfg.strava.enabled = getBool(strava, 'enabled', cfg.strava.enabled);
213
+ cfg.strava.access_token = getStr(strava, 'access_token', cfg.strava.access_token);
214
+ cfg.strava.client_id = getStr(strava, 'client_id', cfg.strava.client_id);
215
+ cfg.strava.client_secret = getStr(strava, 'client_secret', cfg.strava.client_secret);
216
+ cfg.strava.redirect_uri = getStr(strava, 'redirect_uri', cfg.strava.redirect_uri);
217
+ cfg.strava.scopes = getStr(strava, 'scopes', cfg.strava.scopes);
218
+ cfg.strava.approval_prompt = getStr(strava, 'approval_prompt', cfg.strava.approval_prompt);
219
+ cfg.strava.start_date = getStr(strava, 'start_date', cfg.strava.start_date);
220
+ cfg.strava.overlap_seconds = getInt(strava, 'overlap_seconds', cfg.strava.overlap_seconds);
221
+ cfg.strava.page_size = getInt(strava, 'page_size', cfg.strava.page_size);
222
+
223
+ const eightsleep = section(raw, 'eightsleep');
224
+ cfg.eightsleep.enabled = getBool(eightsleep, 'enabled', cfg.eightsleep.enabled);
225
+ cfg.eightsleep.access_token = getStr(eightsleep, 'access_token', cfg.eightsleep.access_token);
226
+ cfg.eightsleep.email = getStr(eightsleep, 'email', cfg.eightsleep.email);
227
+ cfg.eightsleep.password = getStr(eightsleep, 'password', cfg.eightsleep.password);
228
+ cfg.eightsleep.client_id = getStr(eightsleep, 'client_id', cfg.eightsleep.client_id);
229
+ cfg.eightsleep.client_secret = getStr(eightsleep, 'client_secret', cfg.eightsleep.client_secret);
230
+ cfg.eightsleep.timezone = getStr(eightsleep, 'timezone', cfg.eightsleep.timezone);
231
+ cfg.eightsleep.auth_url = getStr(eightsleep, 'auth_url', cfg.eightsleep.auth_url);
232
+ cfg.eightsleep.client_api_url = getStr(eightsleep, 'client_api_url', cfg.eightsleep.client_api_url);
233
+ cfg.eightsleep.start_date = getStr(eightsleep, 'start_date', cfg.eightsleep.start_date);
234
+ cfg.eightsleep.overlap_days = getInt(eightsleep, 'overlap_days', cfg.eightsleep.overlap_days);
235
+
236
+ cfg.plugins = loadPlugins(raw);
237
+
238
+ return {
239
+ path: resolvedPath,
240
+ raw,
241
+ data: cfg,
242
+ };
243
+ }
244
+
245
+ export function requireStr(sectionData, key, message = null) {
246
+ const value = sectionData?.[key];
247
+ if (value === null || value === undefined || String(value).trim() === '') {
248
+ throw new Error(message || `Missing required config value: ${key}`);
249
+ }
250
+ return String(value).trim();
251
+ }
252
+
253
+ function upsertSectionValues(rawDoc, pathParts, values) {
254
+ let cursor = rawDoc;
255
+ for (const part of pathParts) {
256
+ if (!cursor[part] || typeof cursor[part] !== 'object' || Array.isArray(cursor[part])) {
257
+ cursor[part] = {};
258
+ }
259
+ cursor = cursor[part];
260
+ }
261
+ for (const [key, value] of Object.entries(values)) {
262
+ cursor[key] = value;
263
+ }
264
+ }
265
+
266
+ function writeToml(filePath, rawDoc) {
267
+ const rendered = TOML.stringify(rawDoc);
268
+ fs.writeFileSync(filePath, rendered, 'utf8');
269
+ }
270
+
271
+ export function initConfigFile(configPath, dbPath) {
272
+ const resolved = path.resolve(configPath);
273
+ const raw = fs.existsSync(resolved) ? TOML.parse(fs.readFileSync(resolved, 'utf8')) : {};
274
+ upsertSectionValues(raw, ['app'], { db: dbPath || BUILTIN_DEFAULTS.app.db });
275
+ writeToml(resolved, raw);
276
+ }
277
+
278
+ function scaffoldBuiltinDefaults(providerId) {
279
+ const defaults = BUILTIN_DEFAULTS[providerId];
280
+ if (!defaults) {
281
+ return null;
282
+ }
283
+ return {
284
+ ...clone(defaults),
285
+ enabled: true,
286
+ };
287
+ }
288
+
289
+ export function scaffoldProviderConfig(configPath, providerId) {
290
+ const resolved = path.resolve(configPath);
291
+ const raw = fs.existsSync(resolved) ? TOML.parse(fs.readFileSync(resolved, 'utf8')) : {};
292
+
293
+ const builtinDefaults = scaffoldBuiltinDefaults(providerId);
294
+ if (builtinDefaults) {
295
+ const sectionRaw = section(raw, providerId);
296
+ const merged = { ...builtinDefaults, ...sectionRaw, enabled: true };
297
+ upsertSectionValues(raw, [providerId], merged);
298
+ } else {
299
+ const pluginsRaw = section(raw, 'plugins');
300
+ const pluginRaw = section(pluginsRaw, providerId);
301
+ const merged = { ...pluginRaw, enabled: true };
302
+ if (!pluginRaw.module) {
303
+ merged.module = pluginRaw.module ?? null;
304
+ }
305
+ upsertSectionValues(raw, ['plugins', providerId], merged);
306
+ }
307
+
308
+ writeToml(resolved, raw);
309
+ }
310
+
311
+ export const BUILTIN_PROVIDER_IDS = Object.freeze([
312
+ 'oura',
313
+ 'withings',
314
+ 'hevy',
315
+ 'strava',
316
+ 'eightsleep',
317
+ ]);