@dlwiest/ts-tonal-client 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 +12 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.esm.js +152 -9
- package/dist/index.js +152 -9
- package/dist/src/auth/auth-manager.d.ts +4 -1
- package/dist/src/client.d.ts +2 -1
- package/dist/src/services/movement-service.d.ts +3 -1
- package/dist/src/utils/cache-manager.d.ts +11 -0
- package/package.json +14 -3
- package/dist/client.d.ts +0 -16
- package/dist/types.d.ts +0 -115
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# TypeScript Tonal Client
|
|
2
2
|
|
|
3
|
+
[](https://badge.fury.io/js/@dlwiest%2Fts-tonal-client)
|
|
4
|
+
[](https://www.npmjs.com/package/@dlwiest/ts-tonal-client)
|
|
5
|
+
|
|
3
6
|
A comprehensive TypeScript client for accessing Tonal's API. This library provides a robust interface to retrieve workout data, user information, movements, and more from your Tonal account.
|
|
4
7
|
|
|
5
8
|
## Features
|
|
@@ -17,12 +20,19 @@ A comprehensive TypeScript client for accessing Tonal's API. This library provid
|
|
|
17
20
|
|
|
18
21
|
## Installation
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
### Option 1: Install from npm (Recommended)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @dlwiest/ts-tonal-client
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Option 2: Clone and build from source
|
|
21
30
|
|
|
22
31
|
```bash
|
|
23
32
|
git clone https://github.com/dlwiest/ts-tonal-client.git
|
|
24
33
|
cd ts-tonal-client
|
|
25
34
|
npm install
|
|
35
|
+
npm run build
|
|
26
36
|
```
|
|
27
37
|
|
|
28
38
|
## Quick Start
|
|
@@ -30,7 +40,7 @@ npm install
|
|
|
30
40
|
Basic usage:
|
|
31
41
|
|
|
32
42
|
```typescript
|
|
33
|
-
import TonalClient from '
|
|
43
|
+
import TonalClient from '@dlwiest/ts-tonal-client'
|
|
34
44
|
|
|
35
45
|
const client = await TonalClient.create({
|
|
36
46
|
username: 'your_email@example.com',
|
package/dist/index.d.ts
CHANGED
|
@@ -645,7 +645,8 @@ declare class TonalClient {
|
|
|
645
645
|
username: string;
|
|
646
646
|
password: string;
|
|
647
647
|
}): Promise<TonalClient>;
|
|
648
|
-
getMovements(): Promise<TonalMovement[]>;
|
|
648
|
+
getMovements(useCache?: boolean): Promise<TonalMovement[]>;
|
|
649
|
+
invalidateMovementsCache(): Promise<void>;
|
|
649
650
|
getUserInfo(): Promise<TonalUserInfo>;
|
|
650
651
|
getGoals(): Promise<TonalGoal[]>;
|
|
651
652
|
getTrainingEffectGoals(): Promise<TonalTrainingEffectGoalsResponse>;
|
package/dist/index.esm.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
1
4
|
class TonalClientError extends Error {
|
|
2
5
|
constructor(message, statusCode, originalError) {
|
|
3
6
|
super(message);
|
|
@@ -10,7 +13,9 @@ class TonalClientError extends Error {
|
|
|
10
13
|
class AuthManager {
|
|
11
14
|
constructor(username, password) {
|
|
12
15
|
this.idToken = '';
|
|
16
|
+
this.refreshToken = '';
|
|
13
17
|
this.tokenExpiresAt = 0;
|
|
18
|
+
this.isRefreshing = false;
|
|
14
19
|
this.authUrl = 'https://tonal.auth0.com/oauth/token';
|
|
15
20
|
this.clientId = 'ERCyexW-xoVG_Yy3RDe-eV4xsOnRHP6L';
|
|
16
21
|
this.username = username;
|
|
@@ -46,18 +51,70 @@ class AuthManager {
|
|
|
46
51
|
}
|
|
47
52
|
const tokenData = await response.json();
|
|
48
53
|
this.idToken = tokenData.id_token;
|
|
54
|
+
this.refreshToken = tokenData.refresh_token;
|
|
49
55
|
this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
|
|
50
56
|
return this.idToken;
|
|
51
57
|
}
|
|
52
|
-
getValidToken() {
|
|
53
|
-
if (
|
|
54
|
-
|
|
58
|
+
async getValidToken() {
|
|
59
|
+
if (this.isTokenValid()) {
|
|
60
|
+
return this.idToken;
|
|
55
61
|
}
|
|
56
|
-
|
|
62
|
+
if (this.refreshToken) {
|
|
63
|
+
if (!this.isRefreshing) {
|
|
64
|
+
await this.refreshTokens();
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
// Wait for ongoing refresh to complete
|
|
68
|
+
while (this.isRefreshing) {
|
|
69
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (this.isTokenValid()) {
|
|
73
|
+
return this.idToken;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
throw new TonalClientError('Token expired and refresh failed. Call authenticate() first.');
|
|
57
77
|
}
|
|
58
78
|
isTokenValid() {
|
|
59
79
|
return !!this.idToken && Date.now() < this.tokenExpiresAt - 60000; // 1 minute buffer
|
|
60
80
|
}
|
|
81
|
+
async refreshTokens() {
|
|
82
|
+
if (this.isRefreshing) {
|
|
83
|
+
return; // Prevent concurrent refresh attempts
|
|
84
|
+
}
|
|
85
|
+
this.isRefreshing = true;
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(this.authUrl, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: {
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
client_id: this.clientId,
|
|
94
|
+
grant_type: 'refresh_token',
|
|
95
|
+
refresh_token: this.refreshToken,
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const errorText = await response.text();
|
|
100
|
+
let errorData;
|
|
101
|
+
try {
|
|
102
|
+
errorData = JSON.parse(errorText);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
errorData = { error: errorText };
|
|
106
|
+
}
|
|
107
|
+
throw new TonalClientError(errorData.error_description || errorData.error || 'Token refresh failed', response.status, errorData);
|
|
108
|
+
}
|
|
109
|
+
const tokenData = await response.json();
|
|
110
|
+
this.idToken = tokenData.id_token;
|
|
111
|
+
this.refreshToken = tokenData.refresh_token;
|
|
112
|
+
this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
this.isRefreshing = false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
61
118
|
}
|
|
62
119
|
|
|
63
120
|
class HttpClient {
|
|
@@ -75,7 +132,7 @@ class HttpClient {
|
|
|
75
132
|
const controller = new AbortController();
|
|
76
133
|
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
77
134
|
try {
|
|
78
|
-
const token = this.authManager.getValidToken();
|
|
135
|
+
const token = await this.authManager.getValidToken();
|
|
79
136
|
const response = await fetch(url, {
|
|
80
137
|
...options,
|
|
81
138
|
headers: {
|
|
@@ -123,6 +180,16 @@ class HttpClient {
|
|
|
123
180
|
}
|
|
124
181
|
catch (error) {
|
|
125
182
|
lastError = error instanceof TonalClientError ? error : new TonalClientError('Unknown error', undefined, error);
|
|
183
|
+
// If it's an auth error on first attempt, try to refresh token and retry once
|
|
184
|
+
if (attempt === 1 && lastError.statusCode && (lastError.statusCode === 401 || lastError.statusCode === 403)) {
|
|
185
|
+
try {
|
|
186
|
+
await this.authManager.getValidToken(); // This will refresh if needed
|
|
187
|
+
continue; // Retry the request with the new token
|
|
188
|
+
}
|
|
189
|
+
catch (refreshError) {
|
|
190
|
+
// If refresh fails, continue with normal retry logic
|
|
191
|
+
}
|
|
192
|
+
}
|
|
126
193
|
if (attempt === this.maxRetries || (lastError.statusCode && lastError.statusCode < 500)) {
|
|
127
194
|
throw lastError;
|
|
128
195
|
}
|
|
@@ -259,12 +326,85 @@ class WorkoutService {
|
|
|
259
326
|
}
|
|
260
327
|
}
|
|
261
328
|
|
|
329
|
+
class CacheManager {
|
|
330
|
+
constructor(cacheDir = '.cache', defaultTTL = 24 * 60 * 60 * 1000) {
|
|
331
|
+
this.cacheDir = cacheDir;
|
|
332
|
+
this.defaultTTL = defaultTTL;
|
|
333
|
+
this.ensureCacheDir();
|
|
334
|
+
}
|
|
335
|
+
ensureCacheDir() {
|
|
336
|
+
if (!fs.existsSync(this.cacheDir)) {
|
|
337
|
+
fs.mkdirSync(this.cacheDir, { recursive: true });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
getCachePath(key) {
|
|
341
|
+
return path.join(this.cacheDir, `${key}.json`);
|
|
342
|
+
}
|
|
343
|
+
async get(key) {
|
|
344
|
+
const cachePath = this.getCachePath(key);
|
|
345
|
+
if (!fs.existsSync(cachePath)) {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
const content = fs.readFileSync(cachePath, 'utf-8');
|
|
350
|
+
const entry = JSON.parse(content);
|
|
351
|
+
const cachedAt = new Date(entry.cachedAt).getTime();
|
|
352
|
+
const now = Date.now();
|
|
353
|
+
const age = now - cachedAt;
|
|
354
|
+
if (age > entry.ttl) {
|
|
355
|
+
// Cache expired
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
return entry.data;
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
// If there's an error reading/parsing cache, treat as cache miss
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async set(key, data, ttl) {
|
|
366
|
+
const cachePath = this.getCachePath(key);
|
|
367
|
+
const entry = {
|
|
368
|
+
cachedAt: new Date().toISOString(),
|
|
369
|
+
ttl: ttl || this.defaultTTL,
|
|
370
|
+
data,
|
|
371
|
+
};
|
|
372
|
+
fs.writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
|
|
373
|
+
}
|
|
374
|
+
async invalidate(key) {
|
|
375
|
+
const cachePath = this.getCachePath(key);
|
|
376
|
+
if (fs.existsSync(cachePath)) {
|
|
377
|
+
fs.unlinkSync(cachePath);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async clear() {
|
|
381
|
+
if (fs.existsSync(this.cacheDir)) {
|
|
382
|
+
const files = fs.readdirSync(this.cacheDir);
|
|
383
|
+
for (const file of files) {
|
|
384
|
+
fs.unlinkSync(path.join(this.cacheDir, file));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
262
390
|
class MovementService {
|
|
263
391
|
constructor(httpClient) {
|
|
264
392
|
this.httpClient = httpClient;
|
|
393
|
+
this.cacheManager = new CacheManager();
|
|
394
|
+
}
|
|
395
|
+
async getMovements(useCache = true) {
|
|
396
|
+
if (useCache) {
|
|
397
|
+
const cached = await this.cacheManager.get('movements');
|
|
398
|
+
if (cached) {
|
|
399
|
+
return cached;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const movements = await this.httpClient.request('/movements');
|
|
403
|
+
await this.cacheManager.set('movements', movements);
|
|
404
|
+
return movements;
|
|
265
405
|
}
|
|
266
|
-
async
|
|
267
|
-
|
|
406
|
+
async invalidateMovementsCache() {
|
|
407
|
+
await this.cacheManager.invalidate('movements');
|
|
268
408
|
}
|
|
269
409
|
}
|
|
270
410
|
|
|
@@ -377,8 +517,11 @@ class TonalClient {
|
|
|
377
517
|
return client;
|
|
378
518
|
}
|
|
379
519
|
// Movement operations
|
|
380
|
-
async getMovements() {
|
|
381
|
-
return this.movementService.getMovements();
|
|
520
|
+
async getMovements(useCache = true) {
|
|
521
|
+
return this.movementService.getMovements(useCache);
|
|
522
|
+
}
|
|
523
|
+
async invalidateMovementsCache() {
|
|
524
|
+
return this.movementService.invalidateMovementsCache();
|
|
382
525
|
}
|
|
383
526
|
// User operations
|
|
384
527
|
async getUserInfo() {
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
4
|
|
|
5
|
+
var fs = require('fs');
|
|
6
|
+
var path = require('path');
|
|
7
|
+
|
|
5
8
|
class TonalClientError extends Error {
|
|
6
9
|
constructor(message, statusCode, originalError) {
|
|
7
10
|
super(message);
|
|
@@ -14,7 +17,9 @@ class TonalClientError extends Error {
|
|
|
14
17
|
class AuthManager {
|
|
15
18
|
constructor(username, password) {
|
|
16
19
|
this.idToken = '';
|
|
20
|
+
this.refreshToken = '';
|
|
17
21
|
this.tokenExpiresAt = 0;
|
|
22
|
+
this.isRefreshing = false;
|
|
18
23
|
this.authUrl = 'https://tonal.auth0.com/oauth/token';
|
|
19
24
|
this.clientId = 'ERCyexW-xoVG_Yy3RDe-eV4xsOnRHP6L';
|
|
20
25
|
this.username = username;
|
|
@@ -50,18 +55,70 @@ class AuthManager {
|
|
|
50
55
|
}
|
|
51
56
|
const tokenData = await response.json();
|
|
52
57
|
this.idToken = tokenData.id_token;
|
|
58
|
+
this.refreshToken = tokenData.refresh_token;
|
|
53
59
|
this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
|
|
54
60
|
return this.idToken;
|
|
55
61
|
}
|
|
56
|
-
getValidToken() {
|
|
57
|
-
if (
|
|
58
|
-
|
|
62
|
+
async getValidToken() {
|
|
63
|
+
if (this.isTokenValid()) {
|
|
64
|
+
return this.idToken;
|
|
59
65
|
}
|
|
60
|
-
|
|
66
|
+
if (this.refreshToken) {
|
|
67
|
+
if (!this.isRefreshing) {
|
|
68
|
+
await this.refreshTokens();
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// Wait for ongoing refresh to complete
|
|
72
|
+
while (this.isRefreshing) {
|
|
73
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (this.isTokenValid()) {
|
|
77
|
+
return this.idToken;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
throw new TonalClientError('Token expired and refresh failed. Call authenticate() first.');
|
|
61
81
|
}
|
|
62
82
|
isTokenValid() {
|
|
63
83
|
return !!this.idToken && Date.now() < this.tokenExpiresAt - 60000; // 1 minute buffer
|
|
64
84
|
}
|
|
85
|
+
async refreshTokens() {
|
|
86
|
+
if (this.isRefreshing) {
|
|
87
|
+
return; // Prevent concurrent refresh attempts
|
|
88
|
+
}
|
|
89
|
+
this.isRefreshing = true;
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetch(this.authUrl, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
'Content-Type': 'application/json',
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
client_id: this.clientId,
|
|
98
|
+
grant_type: 'refresh_token',
|
|
99
|
+
refresh_token: this.refreshToken,
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
const errorText = await response.text();
|
|
104
|
+
let errorData;
|
|
105
|
+
try {
|
|
106
|
+
errorData = JSON.parse(errorText);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
errorData = { error: errorText };
|
|
110
|
+
}
|
|
111
|
+
throw new TonalClientError(errorData.error_description || errorData.error || 'Token refresh failed', response.status, errorData);
|
|
112
|
+
}
|
|
113
|
+
const tokenData = await response.json();
|
|
114
|
+
this.idToken = tokenData.id_token;
|
|
115
|
+
this.refreshToken = tokenData.refresh_token;
|
|
116
|
+
this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
this.isRefreshing = false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
65
122
|
}
|
|
66
123
|
|
|
67
124
|
class HttpClient {
|
|
@@ -79,7 +136,7 @@ class HttpClient {
|
|
|
79
136
|
const controller = new AbortController();
|
|
80
137
|
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
81
138
|
try {
|
|
82
|
-
const token = this.authManager.getValidToken();
|
|
139
|
+
const token = await this.authManager.getValidToken();
|
|
83
140
|
const response = await fetch(url, {
|
|
84
141
|
...options,
|
|
85
142
|
headers: {
|
|
@@ -127,6 +184,16 @@ class HttpClient {
|
|
|
127
184
|
}
|
|
128
185
|
catch (error) {
|
|
129
186
|
lastError = error instanceof TonalClientError ? error : new TonalClientError('Unknown error', undefined, error);
|
|
187
|
+
// If it's an auth error on first attempt, try to refresh token and retry once
|
|
188
|
+
if (attempt === 1 && lastError.statusCode && (lastError.statusCode === 401 || lastError.statusCode === 403)) {
|
|
189
|
+
try {
|
|
190
|
+
await this.authManager.getValidToken(); // This will refresh if needed
|
|
191
|
+
continue; // Retry the request with the new token
|
|
192
|
+
}
|
|
193
|
+
catch (refreshError) {
|
|
194
|
+
// If refresh fails, continue with normal retry logic
|
|
195
|
+
}
|
|
196
|
+
}
|
|
130
197
|
if (attempt === this.maxRetries || (lastError.statusCode && lastError.statusCode < 500)) {
|
|
131
198
|
throw lastError;
|
|
132
199
|
}
|
|
@@ -263,12 +330,85 @@ class WorkoutService {
|
|
|
263
330
|
}
|
|
264
331
|
}
|
|
265
332
|
|
|
333
|
+
class CacheManager {
|
|
334
|
+
constructor(cacheDir = '.cache', defaultTTL = 24 * 60 * 60 * 1000) {
|
|
335
|
+
this.cacheDir = cacheDir;
|
|
336
|
+
this.defaultTTL = defaultTTL;
|
|
337
|
+
this.ensureCacheDir();
|
|
338
|
+
}
|
|
339
|
+
ensureCacheDir() {
|
|
340
|
+
if (!fs.existsSync(this.cacheDir)) {
|
|
341
|
+
fs.mkdirSync(this.cacheDir, { recursive: true });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
getCachePath(key) {
|
|
345
|
+
return path.join(this.cacheDir, `${key}.json`);
|
|
346
|
+
}
|
|
347
|
+
async get(key) {
|
|
348
|
+
const cachePath = this.getCachePath(key);
|
|
349
|
+
if (!fs.existsSync(cachePath)) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const content = fs.readFileSync(cachePath, 'utf-8');
|
|
354
|
+
const entry = JSON.parse(content);
|
|
355
|
+
const cachedAt = new Date(entry.cachedAt).getTime();
|
|
356
|
+
const now = Date.now();
|
|
357
|
+
const age = now - cachedAt;
|
|
358
|
+
if (age > entry.ttl) {
|
|
359
|
+
// Cache expired
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
return entry.data;
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
// If there's an error reading/parsing cache, treat as cache miss
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async set(key, data, ttl) {
|
|
370
|
+
const cachePath = this.getCachePath(key);
|
|
371
|
+
const entry = {
|
|
372
|
+
cachedAt: new Date().toISOString(),
|
|
373
|
+
ttl: ttl || this.defaultTTL,
|
|
374
|
+
data,
|
|
375
|
+
};
|
|
376
|
+
fs.writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
|
|
377
|
+
}
|
|
378
|
+
async invalidate(key) {
|
|
379
|
+
const cachePath = this.getCachePath(key);
|
|
380
|
+
if (fs.existsSync(cachePath)) {
|
|
381
|
+
fs.unlinkSync(cachePath);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async clear() {
|
|
385
|
+
if (fs.existsSync(this.cacheDir)) {
|
|
386
|
+
const files = fs.readdirSync(this.cacheDir);
|
|
387
|
+
for (const file of files) {
|
|
388
|
+
fs.unlinkSync(path.join(this.cacheDir, file));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
266
394
|
class MovementService {
|
|
267
395
|
constructor(httpClient) {
|
|
268
396
|
this.httpClient = httpClient;
|
|
397
|
+
this.cacheManager = new CacheManager();
|
|
398
|
+
}
|
|
399
|
+
async getMovements(useCache = true) {
|
|
400
|
+
if (useCache) {
|
|
401
|
+
const cached = await this.cacheManager.get('movements');
|
|
402
|
+
if (cached) {
|
|
403
|
+
return cached;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const movements = await this.httpClient.request('/movements');
|
|
407
|
+
await this.cacheManager.set('movements', movements);
|
|
408
|
+
return movements;
|
|
269
409
|
}
|
|
270
|
-
async
|
|
271
|
-
|
|
410
|
+
async invalidateMovementsCache() {
|
|
411
|
+
await this.cacheManager.invalidate('movements');
|
|
272
412
|
}
|
|
273
413
|
}
|
|
274
414
|
|
|
@@ -381,8 +521,11 @@ class TonalClient {
|
|
|
381
521
|
return client;
|
|
382
522
|
}
|
|
383
523
|
// Movement operations
|
|
384
|
-
async getMovements() {
|
|
385
|
-
return this.movementService.getMovements();
|
|
524
|
+
async getMovements(useCache = true) {
|
|
525
|
+
return this.movementService.getMovements(useCache);
|
|
526
|
+
}
|
|
527
|
+
async invalidateMovementsCache() {
|
|
528
|
+
return this.movementService.invalidateMovementsCache();
|
|
386
529
|
}
|
|
387
530
|
// User operations
|
|
388
531
|
async getUserInfo() {
|
|
@@ -2,11 +2,14 @@ export declare class AuthManager {
|
|
|
2
2
|
private username;
|
|
3
3
|
private password;
|
|
4
4
|
private idToken;
|
|
5
|
+
private refreshToken;
|
|
5
6
|
private tokenExpiresAt;
|
|
7
|
+
private isRefreshing;
|
|
6
8
|
private readonly authUrl;
|
|
7
9
|
private readonly clientId;
|
|
8
10
|
constructor(username: string, password: string);
|
|
9
11
|
authenticate(): Promise<string>;
|
|
10
|
-
getValidToken(): string
|
|
12
|
+
getValidToken(): Promise<string>;
|
|
11
13
|
private isTokenValid;
|
|
14
|
+
private refreshTokens;
|
|
12
15
|
}
|
package/dist/src/client.d.ts
CHANGED
|
@@ -10,7 +10,8 @@ export declare class TonalClient {
|
|
|
10
10
|
username: string;
|
|
11
11
|
password: string;
|
|
12
12
|
}): Promise<TonalClient>;
|
|
13
|
-
getMovements(): Promise<TonalMovement[]>;
|
|
13
|
+
getMovements(useCache?: boolean): Promise<TonalMovement[]>;
|
|
14
|
+
invalidateMovementsCache(): Promise<void>;
|
|
14
15
|
getUserInfo(): Promise<TonalUserInfo>;
|
|
15
16
|
getGoals(): Promise<TonalGoal[]>;
|
|
16
17
|
getTrainingEffectGoals(): Promise<TonalTrainingEffectGoalsResponse>;
|
|
@@ -2,6 +2,8 @@ import { HttpClient } from '../http/http-client';
|
|
|
2
2
|
import { TonalMovement } from '../types';
|
|
3
3
|
export declare class MovementService {
|
|
4
4
|
private httpClient;
|
|
5
|
+
private cacheManager;
|
|
5
6
|
constructor(httpClient: HttpClient);
|
|
6
|
-
getMovements(): Promise<TonalMovement[]>;
|
|
7
|
+
getMovements(useCache?: boolean): Promise<TonalMovement[]>;
|
|
8
|
+
invalidateMovementsCache(): Promise<void>;
|
|
7
9
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare class CacheManager {
|
|
2
|
+
private cacheDir;
|
|
3
|
+
private defaultTTL;
|
|
4
|
+
constructor(cacheDir?: string, defaultTTL?: number);
|
|
5
|
+
private ensureCacheDir;
|
|
6
|
+
private getCachePath;
|
|
7
|
+
get<T>(key: string): Promise<T | null>;
|
|
8
|
+
set<T>(key: string, data: T, ttl?: number): Promise<void>;
|
|
9
|
+
invalidate(key: string): Promise<void>;
|
|
10
|
+
clear(): Promise<void>;
|
|
11
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dlwiest/ts-tonal-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "TypeScript client for Tonal API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.esm.js",
|
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
"build": "rollup -c --bundleConfigAsCjs",
|
|
21
21
|
"typecheck": "tsc --noEmit",
|
|
22
22
|
"dev": "tsc --watch --noEmit",
|
|
23
|
-
"
|
|
23
|
+
"test": "jest",
|
|
24
|
+
"test:watch": "jest --watch",
|
|
25
|
+
"test:coverage": "jest --coverage",
|
|
26
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build",
|
|
24
27
|
"example:movements": "tsx examples/get-movements.ts",
|
|
25
28
|
"example:movements:save": "tsx examples/get-movements.ts --save",
|
|
26
29
|
"example:workout:id": "tsx examples/get-workout-by-id.ts",
|
|
@@ -53,7 +56,12 @@
|
|
|
53
56
|
"type": "git",
|
|
54
57
|
"url": "git+https://github.com/dlwiest/ts-tonal-client.git"
|
|
55
58
|
},
|
|
56
|
-
"keywords": [
|
|
59
|
+
"keywords": [
|
|
60
|
+
"tonal",
|
|
61
|
+
"tonal-api",
|
|
62
|
+
"typescript",
|
|
63
|
+
"client"
|
|
64
|
+
],
|
|
57
65
|
"author": "dlwiest",
|
|
58
66
|
"license": "ISC",
|
|
59
67
|
"bugs": {
|
|
@@ -62,10 +70,13 @@
|
|
|
62
70
|
"homepage": "https://github.com/dlwiest/ts-tonal-client#readme",
|
|
63
71
|
"devDependencies": {
|
|
64
72
|
"@rollup/plugin-typescript": "^11.1.0",
|
|
73
|
+
"@types/jest": "^30.0.0",
|
|
65
74
|
"@types/node": "^20.14.8",
|
|
66
75
|
"dotenv": "^16.4.5",
|
|
76
|
+
"jest": "^30.2.0",
|
|
67
77
|
"rollup": "^3.20.2",
|
|
68
78
|
"rollup-plugin-dts": "^5.3.1",
|
|
79
|
+
"ts-jest": "^29.4.5",
|
|
69
80
|
"ts-node": "^10.9.2",
|
|
70
81
|
"tslib": "^2.7.0",
|
|
71
82
|
"tsx": "^4.20.6",
|
package/dist/client.d.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { TonalMovement, TonalSharedWorkout, TonalWorkout } from './types';
|
|
2
|
-
export declare class TonalClient {
|
|
3
|
-
private username;
|
|
4
|
-
private password;
|
|
5
|
-
private idToken;
|
|
6
|
-
private tokenExpiresAt;
|
|
7
|
-
private constructor();
|
|
8
|
-
static create({ username, password }: {
|
|
9
|
-
username: string;
|
|
10
|
-
password: string;
|
|
11
|
-
}): Promise<TonalClient>;
|
|
12
|
-
private refreshToken;
|
|
13
|
-
getMovements(): Promise<TonalMovement[]>;
|
|
14
|
-
getWorkoutById(id: string): Promise<TonalWorkout>;
|
|
15
|
-
getWorkoutByShareUrl(shareUrl: string): Promise<TonalSharedWorkout>;
|
|
16
|
-
}
|
package/dist/types.d.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
export interface OAuthTokenResponse {
|
|
2
|
-
access_token: string;
|
|
3
|
-
id_token: string;
|
|
4
|
-
refresh_token: string;
|
|
5
|
-
scope: string;
|
|
6
|
-
token_type: string;
|
|
7
|
-
expires_in: number;
|
|
8
|
-
}
|
|
9
|
-
export type MuscleGroup = 'Obliques' | 'Abs' | 'Shoulders' | 'Glutes' | 'Back' | 'Biceps' | 'Quads' | 'Triceps' | 'Chest' | 'Hamstrings' | 'Calves' | 'Forearms';
|
|
10
|
-
export interface TonalMovement {
|
|
11
|
-
id: string;
|
|
12
|
-
createdAt: string;
|
|
13
|
-
updatedAt: string;
|
|
14
|
-
name: string;
|
|
15
|
-
shortName: string;
|
|
16
|
-
muscleGroups: MuscleGroup[];
|
|
17
|
-
bodyRegion: string;
|
|
18
|
-
bodyRegionDisplay: string;
|
|
19
|
-
baseOfSupport: string;
|
|
20
|
-
pushPull: string;
|
|
21
|
-
family: string;
|
|
22
|
-
familyDisplay: string;
|
|
23
|
-
inFreeLift: boolean;
|
|
24
|
-
onMachine: boolean;
|
|
25
|
-
countReps: boolean;
|
|
26
|
-
isTwoSided: boolean;
|
|
27
|
-
isBilateral: boolean;
|
|
28
|
-
isAlternating: boolean;
|
|
29
|
-
offMachineAccessory: string;
|
|
30
|
-
descriptionHow: string;
|
|
31
|
-
descriptionWhy: string;
|
|
32
|
-
sortOrder: number;
|
|
33
|
-
imageAssetId: string;
|
|
34
|
-
skillLevel: number;
|
|
35
|
-
active: boolean;
|
|
36
|
-
featureGroupIds: null | string[];
|
|
37
|
-
isGeneric: boolean;
|
|
38
|
-
}
|
|
39
|
-
export interface TonalWorkout {
|
|
40
|
-
id: string;
|
|
41
|
-
createdAt: string;
|
|
42
|
-
title: string;
|
|
43
|
-
shortDescription: string;
|
|
44
|
-
description: string;
|
|
45
|
-
productionCode: string;
|
|
46
|
-
assetId: string;
|
|
47
|
-
coachId: string;
|
|
48
|
-
sets: WorkoutSet[];
|
|
49
|
-
duration: number;
|
|
50
|
-
publishState: string;
|
|
51
|
-
programId: string | null;
|
|
52
|
-
level: string;
|
|
53
|
-
groupIds: string[];
|
|
54
|
-
targetArea: string;
|
|
55
|
-
tags: string[] | null;
|
|
56
|
-
bodyRegions: MuscleGroup[];
|
|
57
|
-
goalIds: string[] | null;
|
|
58
|
-
trainingEffectGoals: string[];
|
|
59
|
-
disableModification: boolean;
|
|
60
|
-
publishedAt: string;
|
|
61
|
-
localPublishedAt: string;
|
|
62
|
-
type: string;
|
|
63
|
-
userId: string;
|
|
64
|
-
style: string;
|
|
65
|
-
trainingType: string;
|
|
66
|
-
trainingTypeIds: string[] | null;
|
|
67
|
-
mobileFriendly: boolean;
|
|
68
|
-
live: boolean;
|
|
69
|
-
recoveryWeight: boolean;
|
|
70
|
-
supportedDevices: string[] | null;
|
|
71
|
-
featureGroupIds: string[] | null;
|
|
72
|
-
movementIds: string[];
|
|
73
|
-
muscleGroupsForExclusion: MuscleGroup[] | null;
|
|
74
|
-
playbackType: string;
|
|
75
|
-
isImported: boolean;
|
|
76
|
-
}
|
|
77
|
-
interface WorkoutSet {
|
|
78
|
-
id: string;
|
|
79
|
-
workoutId: string;
|
|
80
|
-
blockStart: boolean;
|
|
81
|
-
movementId: string;
|
|
82
|
-
prescribedReps: number;
|
|
83
|
-
repetition: number;
|
|
84
|
-
repetitionTotal: number;
|
|
85
|
-
blockNumber: number;
|
|
86
|
-
burnout: boolean;
|
|
87
|
-
spotter: boolean;
|
|
88
|
-
eccentric: boolean;
|
|
89
|
-
chains: boolean;
|
|
90
|
-
skipSetup: boolean;
|
|
91
|
-
skipDemo: boolean;
|
|
92
|
-
finalSet: boolean;
|
|
93
|
-
calibration: boolean;
|
|
94
|
-
practice: boolean;
|
|
95
|
-
flex: boolean;
|
|
96
|
-
progressive: boolean;
|
|
97
|
-
weightPercentage: number;
|
|
98
|
-
warmUp: boolean;
|
|
99
|
-
durationBasedRepGoal: number;
|
|
100
|
-
setGroup: number;
|
|
101
|
-
round: number;
|
|
102
|
-
description: string;
|
|
103
|
-
dropSet: boolean;
|
|
104
|
-
omitempty: null | any;
|
|
105
|
-
}
|
|
106
|
-
export interface TonalSharedWorkout {
|
|
107
|
-
id: string;
|
|
108
|
-
sharerUserId: string;
|
|
109
|
-
parentWorkoutId: string;
|
|
110
|
-
workoutSnapshotId: string;
|
|
111
|
-
workoutSnapshotHash: string;
|
|
112
|
-
deepLinkUrl: string;
|
|
113
|
-
workoutSnapshot: TonalWorkout;
|
|
114
|
-
}
|
|
115
|
-
export {};
|