@eeymoo/hum 0.1.14 → 0.1.16
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/bin/index.js +2 -0
- package/package.json +4 -1
- package/src/commands/config.js +17 -5
- package/src/commands/diet.js +20 -22
- package/src/commands/exercise.js +20 -22
- package/src/commands/food.js +29 -0
- package/src/commands/record.js +19 -21
- package/src/commands/sleep.js +35 -24
- package/src/commands/timeline.js +7 -9
- package/src/commands/weight.js +20 -22
- package/src/lib/api.js +18 -3
- package/src/lib/foodSearch.js +174 -0
- package/src/lib/output.js +257 -0
- package/src/lib/timezone.js +78 -0
package/bin/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import timeline from '../src/commands/timeline.js'
|
|
|
8
8
|
import weight from '../src/commands/weight.js'
|
|
9
9
|
import exercise from '../src/commands/exercise.js'
|
|
10
10
|
import diet from '../src/commands/diet.js'
|
|
11
|
+
import food from '../src/commands/food.js'
|
|
11
12
|
import sleep from '../src/commands/sleep.js'
|
|
12
13
|
import { checkVersion, getCliVersion } from '../src/lib/version-check.js'
|
|
13
14
|
|
|
@@ -32,6 +33,7 @@ program.addCommand(timeline)
|
|
|
32
33
|
program.addCommand(weight)
|
|
33
34
|
program.addCommand(exercise)
|
|
34
35
|
program.addCommand(diet)
|
|
36
|
+
program.addCommand(food)
|
|
35
37
|
program.addCommand(sleep)
|
|
36
38
|
|
|
37
39
|
program.parse()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eeymoo/hum",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "Hum CLI - A command line tool for Hum API",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
},
|
|
17
17
|
"type": "module",
|
|
18
18
|
"dependencies": {
|
|
19
|
+
"cli-table3": "^0.6.5",
|
|
20
|
+
"@toon-format/toon": "^2.3.0",
|
|
19
21
|
"commander": "^12.1.0",
|
|
20
22
|
"conf": "^13.0.1",
|
|
23
|
+
"dayjs": "^1.11.13",
|
|
21
24
|
"node-fetch": "^3.3.2"
|
|
22
25
|
},
|
|
23
26
|
"scripts": {
|
package/src/commands/config.js
CHANGED
|
@@ -5,19 +5,31 @@ const configCmd = new Command('config')
|
|
|
5
5
|
|
|
6
6
|
configCmd
|
|
7
7
|
.command('set')
|
|
8
|
-
.argument('<key>', 'Config key (api-url)')
|
|
8
|
+
.argument('<key>', 'Config key (api-url, timezone, dateFormat)')
|
|
9
9
|
.argument('<value>', 'Config value')
|
|
10
10
|
.action((key, value) => {
|
|
11
|
-
const
|
|
11
|
+
const keyMap = {
|
|
12
|
+
'api-url': 'apiUrl',
|
|
13
|
+
'timezone': 'timezone',
|
|
14
|
+
'date-format': 'dateFormat',
|
|
15
|
+
'dateFormat': 'dateFormat'
|
|
16
|
+
}
|
|
17
|
+
const configKey = keyMap[key] || key
|
|
12
18
|
config.set(configKey, value)
|
|
13
19
|
console.log(`Set ${key} to ${value}`)
|
|
14
20
|
})
|
|
15
21
|
|
|
16
22
|
configCmd
|
|
17
23
|
.command('get')
|
|
18
|
-
.argument('<key>', 'Config key (api-url)')
|
|
24
|
+
.argument('<key>', 'Config key (api-url, timezone, dateFormat)')
|
|
19
25
|
.action((key) => {
|
|
20
|
-
const
|
|
26
|
+
const keyMap = {
|
|
27
|
+
'api-url': 'apiUrl',
|
|
28
|
+
'timezone': 'timezone',
|
|
29
|
+
'date-format': 'dateFormat',
|
|
30
|
+
'dateFormat': 'dateFormat'
|
|
31
|
+
}
|
|
32
|
+
const configKey = keyMap[key] || key
|
|
21
33
|
const value = config.get(configKey)
|
|
22
34
|
if (value !== undefined) {
|
|
23
35
|
console.log(value)
|
|
@@ -32,7 +44,7 @@ configCmd
|
|
|
32
44
|
const allConfig = config.store
|
|
33
45
|
console.log('Configuration:')
|
|
34
46
|
for (const [key, value] of Object.entries(allConfig)) {
|
|
35
|
-
const displayKey = key === 'apiUrl' ? 'api-url' : key
|
|
47
|
+
const displayKey = key === 'apiUrl' ? 'api-url' : key === 'dateFormat' ? 'date-format' : key
|
|
36
48
|
console.log(` ${displayKey}: ${value}`)
|
|
37
49
|
}
|
|
38
50
|
})
|
package/src/commands/diet.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { request, createFormData } from '../lib/api.js'
|
|
3
|
+
import { appendTimezoneOffset, buildQueryParams } from '../lib/timezone.js'
|
|
4
|
+
import { outputData } from '../lib/output.js'
|
|
3
5
|
|
|
4
6
|
const diet = new Command('diet')
|
|
5
7
|
|
|
@@ -14,8 +16,9 @@ diet
|
|
|
14
16
|
.option('--sodium <value>', 'Sodium (mg)')
|
|
15
17
|
.option('--foods <string>', 'Foods in format: "name:amount,name2:amount2"')
|
|
16
18
|
.option('--water <value>', 'Water (ml)')
|
|
19
|
+
.option('--extra-data <json>', 'Extra data (JSON string)')
|
|
17
20
|
.option('--note <note>', 'Note')
|
|
18
|
-
.option('--date <date>', 'Date (YYYY-MM-DD)')
|
|
21
|
+
.option('--date <date>', 'Date (YYYY-MM-DD or ISO 8601 datetime)')
|
|
19
22
|
.option('--file <paths...>', 'File paths to attach')
|
|
20
23
|
.action(async (options) => {
|
|
21
24
|
try {
|
|
@@ -29,8 +32,9 @@ diet
|
|
|
29
32
|
sodium: options.sodium,
|
|
30
33
|
foods: options.foods,
|
|
31
34
|
water: options.water,
|
|
35
|
+
extraData: options.extraData,
|
|
32
36
|
note: options.note,
|
|
33
|
-
date: options.date
|
|
37
|
+
date: appendTimezoneOffset(options.date)
|
|
34
38
|
}, options.file || [])
|
|
35
39
|
|
|
36
40
|
const result = await request('/diets', {
|
|
@@ -51,19 +55,15 @@ diet
|
|
|
51
55
|
.option('--last <period>', 'Last N days/weeks/months/years')
|
|
52
56
|
.option('--start <date>', 'Start date (YYYY-MM-DD)')
|
|
53
57
|
.option('--end <date>', 'End date (YYYY-MM-DD)')
|
|
58
|
+
.option('--page <number>', 'Page number', '1')
|
|
59
|
+
.option('--limit <number>', 'Items per page', '20')
|
|
54
60
|
.option('--include-deleted', 'Include deleted records')
|
|
61
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
55
62
|
.action(async (options) => {
|
|
56
63
|
try {
|
|
57
|
-
const params =
|
|
58
|
-
Object.entries(options).forEach(([key, value]) => {
|
|
59
|
-
if (value) {
|
|
60
|
-
const paramKey = key === 'meal' ? 'mealType' : key === 'includeDeleted' ? 'includeDeleted' : key
|
|
61
|
-
params.append(paramKey, value === true ? 'true' : value)
|
|
62
|
-
}
|
|
63
|
-
})
|
|
64
|
-
|
|
64
|
+
const { params, page } = buildQueryParams(options)
|
|
65
65
|
const result = await request(`/diets?${params.toString()}`)
|
|
66
|
-
|
|
66
|
+
outputData(result, { format: options.format, type: 'diet-list', page })
|
|
67
67
|
} catch (error) {
|
|
68
68
|
console.error('Failed to list diet records:', error.message)
|
|
69
69
|
}
|
|
@@ -74,17 +74,12 @@ diet
|
|
|
74
74
|
.option('--last <period>', 'Last N days/weeks/months/years')
|
|
75
75
|
.option('--start <date>', 'Start date (YYYY-MM-DD)')
|
|
76
76
|
.option('--end <date>', 'End date (YYYY-MM-DD)')
|
|
77
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
77
78
|
.action(async (options) => {
|
|
78
79
|
try {
|
|
79
|
-
const params =
|
|
80
|
-
Object.entries(options).forEach(([key, value]) => {
|
|
81
|
-
if (value) {
|
|
82
|
-
params.append(key, value)
|
|
83
|
-
}
|
|
84
|
-
})
|
|
85
|
-
|
|
80
|
+
const { params } = buildQueryParams(options)
|
|
86
81
|
const result = await request(`/diets/stats?${params.toString()}`)
|
|
87
|
-
|
|
82
|
+
outputData(result, { format: options.format, type: 'diet-stats' })
|
|
88
83
|
} catch (error) {
|
|
89
84
|
console.error('Failed to get diet stats:', error.message)
|
|
90
85
|
}
|
|
@@ -93,10 +88,11 @@ diet
|
|
|
93
88
|
diet
|
|
94
89
|
.command('get')
|
|
95
90
|
.requiredOption('--id <id>', 'Diet record ID')
|
|
91
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
96
92
|
.action(async (options) => {
|
|
97
93
|
try {
|
|
98
94
|
const result = await request(`/diets/${options.id}`)
|
|
99
|
-
|
|
95
|
+
outputData(result, { format: options.format, type: 'diet-get' })
|
|
100
96
|
} catch (error) {
|
|
101
97
|
console.error('Failed to get diet record:', error.message)
|
|
102
98
|
}
|
|
@@ -114,8 +110,9 @@ diet
|
|
|
114
110
|
.option('--sodium <value>', 'Updated sodium')
|
|
115
111
|
.option('--foods <string>', 'Updated foods')
|
|
116
112
|
.option('--water <value>', 'Updated water')
|
|
113
|
+
.option('--extra-data <json>', 'Updated extra data (JSON string)')
|
|
117
114
|
.option('--note <note>', 'Updated note')
|
|
118
|
-
.option('--date <date>', 'Updated date (YYYY-MM-DD)')
|
|
115
|
+
.option('--date <date>', 'Updated date (YYYY-MM-DD or ISO 8601 datetime)')
|
|
119
116
|
.option('--file <paths...>', 'File paths to attach')
|
|
120
117
|
.option('--replace-attachments', 'Replace existing attachments instead of adding')
|
|
121
118
|
.action(async (options) => {
|
|
@@ -130,8 +127,9 @@ diet
|
|
|
130
127
|
sodium: options.sodium,
|
|
131
128
|
foods: options.foods,
|
|
132
129
|
water: options.water,
|
|
130
|
+
extraData: options.extraData,
|
|
133
131
|
note: options.note,
|
|
134
|
-
date: options.date,
|
|
132
|
+
date: appendTimezoneOffset(options.date),
|
|
135
133
|
replaceAttachments: options.replaceAttachments ? 'true' : undefined
|
|
136
134
|
}, options.file || [])
|
|
137
135
|
|
package/src/commands/exercise.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { request, createFormData } from '../lib/api.js'
|
|
3
|
+
import { appendTimezoneOffset, buildQueryParams } from '../lib/timezone.js'
|
|
4
|
+
import { outputData } from '../lib/output.js'
|
|
3
5
|
|
|
4
6
|
const exercise = new Command('exercise')
|
|
5
7
|
|
|
@@ -12,9 +14,10 @@ exercise
|
|
|
12
14
|
.option('--heart-rate-avg <value>', 'Average heart rate')
|
|
13
15
|
.option('--heart-rate-max <value>', 'Max heart rate')
|
|
14
16
|
.option('--feeling <value>', 'Feeling 1-10')
|
|
17
|
+
.option('--extra-data <json>', 'Extra data (JSON string)')
|
|
15
18
|
.option('--location <location>', 'Location')
|
|
16
19
|
.option('--note <note>', 'Note')
|
|
17
|
-
.option('--date <date>', 'Date (YYYY-MM-DD)')
|
|
20
|
+
.option('--date <date>', 'Date (YYYY-MM-DD or ISO 8601 datetime)')
|
|
18
21
|
.option('--file <paths...>', 'File paths to attach')
|
|
19
22
|
.action(async (options) => {
|
|
20
23
|
try {
|
|
@@ -26,9 +29,10 @@ exercise
|
|
|
26
29
|
heartRateAvg: options.heartRateAvg,
|
|
27
30
|
heartRateMax: options.heartRateMax,
|
|
28
31
|
feeling: options.feeling,
|
|
32
|
+
extraData: options.extraData,
|
|
29
33
|
location: options.location,
|
|
30
34
|
note: options.note,
|
|
31
|
-
date: options.date
|
|
35
|
+
date: appendTimezoneOffset(options.date)
|
|
32
36
|
}, options.file || [])
|
|
33
37
|
|
|
34
38
|
const result = await request('/exercises', {
|
|
@@ -49,19 +53,15 @@ exercise
|
|
|
49
53
|
.option('--last <period>', 'Last N days/weeks/months/years')
|
|
50
54
|
.option('--start <date>', 'Start date (YYYY-MM-DD)')
|
|
51
55
|
.option('--end <date>', 'End date (YYYY-MM-DD)')
|
|
56
|
+
.option('--page <number>', 'Page number', '1')
|
|
57
|
+
.option('--limit <number>', 'Items per page', '20')
|
|
52
58
|
.option('--include-deleted', 'Include deleted records')
|
|
59
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
53
60
|
.action(async (options) => {
|
|
54
61
|
try {
|
|
55
|
-
const params =
|
|
56
|
-
Object.entries(options).forEach(([key, value]) => {
|
|
57
|
-
if (value) {
|
|
58
|
-
const paramKey = key === 'includeDeleted' ? 'includeDeleted' : key
|
|
59
|
-
params.append(paramKey, value === true ? 'true' : value)
|
|
60
|
-
}
|
|
61
|
-
})
|
|
62
|
-
|
|
62
|
+
const { params, page } = buildQueryParams(options)
|
|
63
63
|
const result = await request(`/exercises?${params.toString()}`)
|
|
64
|
-
|
|
64
|
+
outputData(result, { format: options.format, type: 'exercise-list', page })
|
|
65
65
|
} catch (error) {
|
|
66
66
|
console.error('Failed to list exercise records:', error.message)
|
|
67
67
|
}
|
|
@@ -72,17 +72,12 @@ exercise
|
|
|
72
72
|
.option('--last <period>', 'Last N days/weeks/months/years')
|
|
73
73
|
.option('--start <date>', 'Start date (YYYY-MM-DD)')
|
|
74
74
|
.option('--end <date>', 'End date (YYYY-MM-DD)')
|
|
75
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
75
76
|
.action(async (options) => {
|
|
76
77
|
try {
|
|
77
|
-
const params =
|
|
78
|
-
Object.entries(options).forEach(([key, value]) => {
|
|
79
|
-
if (value) {
|
|
80
|
-
params.append(key, value)
|
|
81
|
-
}
|
|
82
|
-
})
|
|
83
|
-
|
|
78
|
+
const { params } = buildQueryParams(options)
|
|
84
79
|
const result = await request(`/exercises/stats?${params.toString()}`)
|
|
85
|
-
|
|
80
|
+
outputData(result, { format: options.format, type: 'exercise-stats' })
|
|
86
81
|
} catch (error) {
|
|
87
82
|
console.error('Failed to get exercise stats:', error.message)
|
|
88
83
|
}
|
|
@@ -91,10 +86,11 @@ exercise
|
|
|
91
86
|
exercise
|
|
92
87
|
.command('get')
|
|
93
88
|
.requiredOption('--id <id>', 'Exercise record ID')
|
|
89
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
94
90
|
.action(async (options) => {
|
|
95
91
|
try {
|
|
96
92
|
const result = await request(`/exercises/${options.id}`)
|
|
97
|
-
|
|
93
|
+
outputData(result, { format: options.format, type: 'exercise-get' })
|
|
98
94
|
} catch (error) {
|
|
99
95
|
console.error('Failed to get exercise record:', error.message)
|
|
100
96
|
}
|
|
@@ -110,9 +106,10 @@ exercise
|
|
|
110
106
|
.option('--heart-rate-avg <value>', 'Updated average heart rate')
|
|
111
107
|
.option('--heart-rate-max <value>', 'Updated max heart rate')
|
|
112
108
|
.option('--feeling <value>', 'Updated feeling')
|
|
109
|
+
.option('--extra-data <json>', 'Updated extra data (JSON string)')
|
|
113
110
|
.option('--location <location>', 'Updated location')
|
|
114
111
|
.option('--note <note>', 'Updated note')
|
|
115
|
-
.option('--date <date>', 'Updated date (YYYY-MM-DD)')
|
|
112
|
+
.option('--date <date>', 'Updated date (YYYY-MM-DD or ISO 8601 datetime)')
|
|
116
113
|
.option('--file <paths...>', 'File paths to attach')
|
|
117
114
|
.option('--replace-attachments', 'Replace existing attachments instead of adding')
|
|
118
115
|
.action(async (options) => {
|
|
@@ -125,9 +122,10 @@ exercise
|
|
|
125
122
|
heartRateAvg: options.heartRateAvg,
|
|
126
123
|
heartRateMax: options.heartRateMax,
|
|
127
124
|
feeling: options.feeling,
|
|
125
|
+
extraData: options.extraData,
|
|
128
126
|
location: options.location,
|
|
129
127
|
note: options.note,
|
|
130
|
-
date: options.date,
|
|
128
|
+
date: appendTimezoneOffset(options.date),
|
|
131
129
|
replaceAttachments: options.replaceAttachments ? 'true' : undefined
|
|
132
130
|
}, options.file || [])
|
|
133
131
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { searchFood } from '../lib/foodSearch.js'
|
|
3
|
+
import { outputData } from '../lib/output.js'
|
|
4
|
+
|
|
5
|
+
const food = new Command('food')
|
|
6
|
+
|
|
7
|
+
food
|
|
8
|
+
.description('查询食物营养信息')
|
|
9
|
+
.requiredOption('-n, --name <name>', '食物名称(支持模糊匹配)')
|
|
10
|
+
.option('-l, --limit <number>', '返回结果数量上限', '5')
|
|
11
|
+
.option('--no-cache', '跳过本地缓存,重新请求远端')
|
|
12
|
+
.option('--format <format>', '输出格式: json, table, toon', 'json')
|
|
13
|
+
.action(async (options) => {
|
|
14
|
+
try {
|
|
15
|
+
const limit = parseInt(options.limit, 10) || 5
|
|
16
|
+
const { items, degraded } = await searchFood(options.name, {
|
|
17
|
+
limit,
|
|
18
|
+
noCache: options.cache === false
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const cleaned = items.map(({ rawItem, ...rest }) => rest)
|
|
22
|
+
outputData({ foods: cleaned, totalPages: 1, total: cleaned.length }, { format: options.format, type: 'food-list' })
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error(error.message)
|
|
25
|
+
process.exitCode = 1
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export default food
|
package/src/commands/record.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { request } from '../lib/api.js'
|
|
3
|
+
import { appendTimezoneOffset, buildQueryParams } from '../lib/timezone.js'
|
|
4
|
+
import { outputData } from '../lib/output.js'
|
|
3
5
|
|
|
4
6
|
const record = new Command('record')
|
|
5
7
|
|
|
@@ -10,7 +12,7 @@ record
|
|
|
10
12
|
.option('--tags <tags>', 'Comma-separated tags')
|
|
11
13
|
.option('--note <note>', 'Note for the record')
|
|
12
14
|
.option('--attachments <urls>', 'Comma-separated attachment URLs')
|
|
13
|
-
.option('--date <date>', 'Date
|
|
15
|
+
.option('--date <date>', 'Date (YYYY-MM-DD or ISO 8601 datetime)')
|
|
14
16
|
.action(async (options) => {
|
|
15
17
|
try {
|
|
16
18
|
const recordData = {
|
|
@@ -19,7 +21,7 @@ record
|
|
|
19
21
|
tags: options.tags ? options.tags.split(',') : undefined,
|
|
20
22
|
note: options.note,
|
|
21
23
|
attachments: options.attachments ? options.attachments.split(',') : undefined,
|
|
22
|
-
date: options.date
|
|
24
|
+
date: appendTimezoneOffset(options.date)
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const result = await request('/records', {
|
|
@@ -41,19 +43,15 @@ record
|
|
|
41
43
|
.option('--start <date>', 'Start date (YYYY-MM-DD)')
|
|
42
44
|
.option('--end <date>', 'End date (YYYY-MM-DD)')
|
|
43
45
|
.option('--date <date>', 'Specific date (YYYY-MM-DD)')
|
|
46
|
+
.option('--page <number>', 'Page number', '1')
|
|
47
|
+
.option('--limit <number>', 'Items per page', '20')
|
|
44
48
|
.option('--include-deleted', 'Include deleted records')
|
|
49
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
45
50
|
.action(async (options) => {
|
|
46
51
|
try {
|
|
47
|
-
const params =
|
|
48
|
-
Object.entries(options).forEach(([key, value]) => {
|
|
49
|
-
if (value) {
|
|
50
|
-
const paramKey = key === 'includeDeleted' ? 'includeDeleted' : key
|
|
51
|
-
params.append(paramKey, value === true ? 'true' : value)
|
|
52
|
-
}
|
|
53
|
-
})
|
|
54
|
-
|
|
52
|
+
const { params, page } = buildQueryParams(options)
|
|
55
53
|
const result = await request(`/records?${params.toString()}`)
|
|
56
|
-
|
|
54
|
+
outputData(result, { format: options.format, type: 'record-list', page })
|
|
57
55
|
} catch (error) {
|
|
58
56
|
console.error('Failed to list records:', error.message)
|
|
59
57
|
}
|
|
@@ -63,6 +61,7 @@ record
|
|
|
63
61
|
.command('get')
|
|
64
62
|
.requiredOption('--id <id>', 'Record ID')
|
|
65
63
|
.option('--include-deleted', 'Include deleted records')
|
|
64
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
66
65
|
.action(async (options) => {
|
|
67
66
|
try {
|
|
68
67
|
const params = new URLSearchParams()
|
|
@@ -71,7 +70,7 @@ record
|
|
|
71
70
|
}
|
|
72
71
|
const queryString = params.toString()
|
|
73
72
|
const result = await request(`/records/${options.id}${queryString ? '?' + queryString : ''}`)
|
|
74
|
-
|
|
73
|
+
outputData(result, { format: options.format, type: 'record-get' })
|
|
75
74
|
} catch (error) {
|
|
76
75
|
console.error('Failed to get record:', error.message)
|
|
77
76
|
}
|
|
@@ -84,7 +83,7 @@ record
|
|
|
84
83
|
.option('--tags <tags>', 'Updated comma-separated tags')
|
|
85
84
|
.option('--note <note>', 'Updated note')
|
|
86
85
|
.option('--attachments <urls>', 'Updated comma-separated attachment URLs')
|
|
87
|
-
.option('--date <date>', 'Updated date (YYYY-MM-DD)')
|
|
86
|
+
.option('--date <date>', 'Updated date (YYYY-MM-DD or ISO 8601 datetime)')
|
|
88
87
|
.action(async (options) => {
|
|
89
88
|
try {
|
|
90
89
|
const updateData = {}
|
|
@@ -92,7 +91,7 @@ record
|
|
|
92
91
|
if (options.tags) updateData.tags = options.tags.split(',')
|
|
93
92
|
if (options.note) updateData.note = options.note
|
|
94
93
|
if (options.attachments) updateData.attachments = options.attachments.split(',')
|
|
95
|
-
if (options.date) updateData.date = options.date
|
|
94
|
+
if (options.date) updateData.date = appendTimezoneOffset(options.date)
|
|
96
95
|
|
|
97
96
|
const result = await request(`/records/${options.id}`, {
|
|
98
97
|
method: 'PATCH',
|
|
@@ -125,17 +124,16 @@ record
|
|
|
125
124
|
.requiredOption('--query <text>', 'Search query')
|
|
126
125
|
.option('--type <type>', 'Filter by type')
|
|
127
126
|
.option('--last <period>', 'Last N days/weeks/months/years')
|
|
127
|
+
.option('--page <number>', 'Page number', '1')
|
|
128
|
+
.option('--limit <number>', 'Items per page', '20')
|
|
128
129
|
.option('--include-deleted', 'Include deleted records')
|
|
130
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
129
131
|
.action(async (options) => {
|
|
130
132
|
try {
|
|
131
|
-
const params =
|
|
132
|
-
params.
|
|
133
|
-
if (options.type) params.append('type', options.type)
|
|
134
|
-
if (options.last) params.append('last', options.last)
|
|
135
|
-
if (options.includeDeleted) params.append('includeDeleted', 'true')
|
|
136
|
-
|
|
133
|
+
const { params, page } = buildQueryParams(options)
|
|
134
|
+
params.set('q', options.query)
|
|
137
135
|
const result = await request(`/records/search?${params.toString()}`)
|
|
138
|
-
|
|
136
|
+
outputData(result, { format: options.format, type: 'record-list', page })
|
|
139
137
|
} catch (error) {
|
|
140
138
|
console.error('Failed to search records:', error.message)
|
|
141
139
|
}
|
package/src/commands/sleep.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { request, createFormData } from '../lib/api.js'
|
|
3
|
+
import { appendTimezoneOffset, buildQueryParams } from '../lib/timezone.js'
|
|
4
|
+
import { outputData } from '../lib/output.js'
|
|
3
5
|
|
|
4
6
|
const sleep = new Command('sleep')
|
|
5
7
|
|
|
6
8
|
sleep
|
|
7
9
|
.command('add')
|
|
8
|
-
.
|
|
10
|
+
.option('--duration <value>', 'Sleep duration in hours')
|
|
9
11
|
.requiredOption('--bedtime <time>', 'Bedtime (HH:mm)')
|
|
10
12
|
.requiredOption('--waketime <time>', 'Wake time (HH:mm)')
|
|
11
13
|
.requiredOption('--quality <value>', 'Sleep quality 1-10')
|
|
@@ -13,13 +15,27 @@ sleep
|
|
|
13
15
|
.option('--rem-sleep <value>', 'REM sleep duration in hours')
|
|
14
16
|
.option('--awakenings <value>', 'Number of awakenings')
|
|
15
17
|
.option('--feeling <value>', 'Feeling 1-10')
|
|
18
|
+
.option('--extra-data <json>', 'Extra data (JSON string)')
|
|
16
19
|
.option('--note <note>', 'Note')
|
|
17
|
-
.option('--date <date>', 'Date (YYYY-MM-DD)')
|
|
20
|
+
.option('--date <date>', 'Date (YYYY-MM-DD or ISO 8601 datetime)')
|
|
18
21
|
.option('--file <paths...>', 'File paths to attach')
|
|
19
22
|
.action(async (options) => {
|
|
20
23
|
try {
|
|
24
|
+
let duration = options.duration
|
|
25
|
+
if (!duration && options.bedtime && options.waketime) {
|
|
26
|
+
const [bh, bm] = options.bedtime.split(':').map(Number)
|
|
27
|
+
const [wh, wm] = options.waketime.split(':').map(Number)
|
|
28
|
+
let diff = (wh * 60 + wm) - (bh * 60 + bm)
|
|
29
|
+
if (diff < 0) diff += 24 * 60
|
|
30
|
+
duration = (diff / 60).toFixed(1)
|
|
31
|
+
}
|
|
32
|
+
if (!duration) {
|
|
33
|
+
console.error('需要 --duration 或同时提供 --bedtime 和 --waketime')
|
|
34
|
+
process.exit(1)
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
const formData = createFormData({
|
|
22
|
-
duration
|
|
38
|
+
duration,
|
|
23
39
|
bedTime: options.bedtime,
|
|
24
40
|
wakeTime: options.waketime,
|
|
25
41
|
quality: options.quality,
|
|
@@ -27,8 +43,9 @@ sleep
|
|
|
27
43
|
remSleep: options.remSleep,
|
|
28
44
|
awakenings: options.awakenings,
|
|
29
45
|
feeling: options.feeling,
|
|
46
|
+
extraData: options.extraData,
|
|
30
47
|
note: options.note,
|
|
31
|
-
date: options.date
|
|
48
|
+
date: appendTimezoneOffset(options.date)
|
|
32
49
|
}, options.file || [])
|
|
33
50
|
|
|
34
51
|
const result = await request('/sleeps', {
|
|
@@ -48,19 +65,15 @@ sleep
|
|
|
48
65
|
.option('--last <period>', 'Last N days/weeks/months/years')
|
|
49
66
|
.option('--start <date>', 'Start date (YYYY-MM-DD)')
|
|
50
67
|
.option('--end <date>', 'End date (YYYY-MM-DD)')
|
|
68
|
+
.option('--page <number>', 'Page number', '1')
|
|
69
|
+
.option('--limit <number>', 'Items per page', '20')
|
|
51
70
|
.option('--include-deleted', 'Include deleted records')
|
|
71
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
52
72
|
.action(async (options) => {
|
|
53
73
|
try {
|
|
54
|
-
const params =
|
|
55
|
-
Object.entries(options).forEach(([key, value]) => {
|
|
56
|
-
if (value) {
|
|
57
|
-
const paramKey = key === 'includeDeleted' ? 'includeDeleted' : key
|
|
58
|
-
params.append(paramKey, value === true ? 'true' : value)
|
|
59
|
-
}
|
|
60
|
-
})
|
|
61
|
-
|
|
74
|
+
const { params, page } = buildQueryParams(options)
|
|
62
75
|
const result = await request(`/sleeps?${params.toString()}`)
|
|
63
|
-
|
|
76
|
+
outputData(result, { format: options.format, type: 'sleep-list', page })
|
|
64
77
|
} catch (error) {
|
|
65
78
|
console.error('Failed to list sleep records:', error.message)
|
|
66
79
|
}
|
|
@@ -71,17 +84,12 @@ sleep
|
|
|
71
84
|
.option('--last <period>', 'Last N days/weeks/months/years')
|
|
72
85
|
.option('--start <date>', 'Start date (YYYY-MM-DD)')
|
|
73
86
|
.option('--end <date>', 'End date (YYYY-MM-DD)')
|
|
87
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
74
88
|
.action(async (options) => {
|
|
75
89
|
try {
|
|
76
|
-
const params =
|
|
77
|
-
Object.entries(options).forEach(([key, value]) => {
|
|
78
|
-
if (value) {
|
|
79
|
-
params.append(key, value)
|
|
80
|
-
}
|
|
81
|
-
})
|
|
82
|
-
|
|
90
|
+
const { params } = buildQueryParams(options)
|
|
83
91
|
const result = await request(`/sleeps/stats?${params.toString()}`)
|
|
84
|
-
|
|
92
|
+
outputData(result, { format: options.format, type: 'sleep-stats' })
|
|
85
93
|
} catch (error) {
|
|
86
94
|
console.error('Failed to get sleep stats:', error.message)
|
|
87
95
|
}
|
|
@@ -90,10 +98,11 @@ sleep
|
|
|
90
98
|
sleep
|
|
91
99
|
.command('get')
|
|
92
100
|
.requiredOption('--id <id>', 'Sleep record ID')
|
|
101
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
93
102
|
.action(async (options) => {
|
|
94
103
|
try {
|
|
95
104
|
const result = await request(`/sleeps/${options.id}`)
|
|
96
|
-
|
|
105
|
+
outputData(result, { format: options.format, type: 'sleep-get' })
|
|
97
106
|
} catch (error) {
|
|
98
107
|
console.error('Failed to get sleep record:', error.message)
|
|
99
108
|
}
|
|
@@ -110,8 +119,9 @@ sleep
|
|
|
110
119
|
.option('--rem-sleep <value>', 'Updated REM sleep')
|
|
111
120
|
.option('--awakenings <value>', 'Updated number of awakenings')
|
|
112
121
|
.option('--feeling <value>', 'Updated feeling')
|
|
122
|
+
.option('--extra-data <json>', 'Updated extra data (JSON string)')
|
|
113
123
|
.option('--note <note>', 'Updated note')
|
|
114
|
-
.option('--date <date>', 'Updated date (YYYY-MM-DD)')
|
|
124
|
+
.option('--date <date>', 'Updated date (YYYY-MM-DD or ISO 8601 datetime)')
|
|
115
125
|
.option('--file <paths...>', 'File paths to attach')
|
|
116
126
|
.option('--replace-attachments', 'Replace existing attachments instead of adding')
|
|
117
127
|
.action(async (options) => {
|
|
@@ -125,8 +135,9 @@ sleep
|
|
|
125
135
|
remSleep: options.remSleep,
|
|
126
136
|
awakenings: options.awakenings,
|
|
127
137
|
feeling: options.feeling,
|
|
138
|
+
extraData: options.extraData,
|
|
128
139
|
note: options.note,
|
|
129
|
-
date: options.date,
|
|
140
|
+
date: appendTimezoneOffset(options.date),
|
|
130
141
|
replaceAttachments: options.replaceAttachments ? 'true' : undefined
|
|
131
142
|
}, options.file || [])
|
|
132
143
|
|
package/src/commands/timeline.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { request } from '../lib/api.js'
|
|
3
|
+
import { buildQueryParams } from '../lib/timezone.js'
|
|
4
|
+
import { outputData } from '../lib/output.js'
|
|
3
5
|
|
|
4
6
|
const timeline = new Command('timeline')
|
|
5
7
|
|
|
@@ -7,19 +9,15 @@ timeline
|
|
|
7
9
|
.option('--last <period>', 'Last N days/weeks/months/years (e.g., 7d, 2w, 1m)')
|
|
8
10
|
.option('--start <date>', 'Start date (YYYY-MM-DD)')
|
|
9
11
|
.option('--end <date>', 'End date (YYYY-MM-DD)')
|
|
12
|
+
.option('--page <number>', 'Page number', '1')
|
|
13
|
+
.option('--limit <number>', 'Items per page', '20')
|
|
10
14
|
.option('--include-deleted', 'Include deleted records')
|
|
15
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
11
16
|
.action(async (options) => {
|
|
12
17
|
try {
|
|
13
|
-
const params =
|
|
14
|
-
Object.entries(options).forEach(([key, value]) => {
|
|
15
|
-
if (value) {
|
|
16
|
-
const paramKey = key === 'includeDeleted' ? 'includeDeleted' : key
|
|
17
|
-
params.append(paramKey, value === true ? 'true' : value)
|
|
18
|
-
}
|
|
19
|
-
})
|
|
20
|
-
|
|
18
|
+
const { params, page } = buildQueryParams(options)
|
|
21
19
|
const result = await request(`/timeline?${params.toString()}`)
|
|
22
|
-
|
|
20
|
+
outputData(result, { format: options.format, type: 'timeline', page })
|
|
23
21
|
} catch (error) {
|
|
24
22
|
console.error('Failed to get timeline:', error.message)
|
|
25
23
|
}
|
package/src/commands/weight.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { request, createFormData } from '../lib/api.js'
|
|
3
|
+
import { appendTimezoneOffset, buildQueryParams } from '../lib/timezone.js'
|
|
4
|
+
import { outputData } from '../lib/output.js'
|
|
3
5
|
|
|
4
6
|
const weight = new Command('weight')
|
|
5
7
|
|
|
@@ -12,8 +14,9 @@ weight
|
|
|
12
14
|
.option('--water <value>', 'Water percentage')
|
|
13
15
|
.option('--bone-mass <value>', 'Bone mass (kg)')
|
|
14
16
|
.option('--visceral-fat <value>', 'Visceral fat level')
|
|
17
|
+
.option('--extra-data <json>', 'Extra data (JSON string)')
|
|
15
18
|
.option('--note <note>', 'Note for the record')
|
|
16
|
-
.option('--date <date>', 'Date
|
|
19
|
+
.option('--date <date>', 'Date (YYYY-MM-DD or ISO 8601 datetime)')
|
|
17
20
|
.option('--file <paths...>', 'File paths to attach')
|
|
18
21
|
.action(async (options) => {
|
|
19
22
|
try {
|
|
@@ -25,8 +28,9 @@ weight
|
|
|
25
28
|
water: options.water,
|
|
26
29
|
boneMass: options.boneMass,
|
|
27
30
|
visceralFat: options.visceralFat,
|
|
31
|
+
extraData: options.extraData,
|
|
28
32
|
note: options.note,
|
|
29
|
-
date: options.date
|
|
33
|
+
date: appendTimezoneOffset(options.date)
|
|
30
34
|
}, options.file || [])
|
|
31
35
|
|
|
32
36
|
const result = await request('/weights', {
|
|
@@ -46,19 +50,15 @@ weight
|
|
|
46
50
|
.option('--last <period>', 'Last N days/weeks/months/years (e.g., 10, 7d, 2w, 6m, 1y)')
|
|
47
51
|
.option('--start <date>', 'Start date (YYYY-MM-DD)')
|
|
48
52
|
.option('--end <date>', 'End date (YYYY-MM-DD)')
|
|
53
|
+
.option('--page <number>', 'Page number', '1')
|
|
54
|
+
.option('--limit <number>', 'Items per page', '20')
|
|
49
55
|
.option('--include-deleted', 'Include deleted records')
|
|
56
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
50
57
|
.action(async (options) => {
|
|
51
58
|
try {
|
|
52
|
-
const params =
|
|
53
|
-
Object.entries(options).forEach(([key, value]) => {
|
|
54
|
-
if (value) {
|
|
55
|
-
const paramKey = key === 'includeDeleted' ? 'includeDeleted' : key
|
|
56
|
-
params.append(paramKey, value === true ? 'true' : value)
|
|
57
|
-
}
|
|
58
|
-
})
|
|
59
|
-
|
|
59
|
+
const { params, page } = buildQueryParams(options)
|
|
60
60
|
const result = await request(`/weights?${params.toString()}`)
|
|
61
|
-
|
|
61
|
+
outputData(result, { format: options.format, type: 'weight-list', page })
|
|
62
62
|
} catch (error) {
|
|
63
63
|
console.error('Failed to list weight records:', error.message)
|
|
64
64
|
}
|
|
@@ -69,17 +69,12 @@ weight
|
|
|
69
69
|
.option('--last <period>', 'Last N days/weeks/months/years (e.g., 10, 7d, 2w, 6m, 1y)')
|
|
70
70
|
.option('--start <date>', 'Start date (YYYY-MM-DD)')
|
|
71
71
|
.option('--end <date>', 'End date (YYYY-MM-DD)')
|
|
72
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
72
73
|
.action(async (options) => {
|
|
73
74
|
try {
|
|
74
|
-
const params =
|
|
75
|
-
Object.entries(options).forEach(([key, value]) => {
|
|
76
|
-
if (value) {
|
|
77
|
-
params.append(key, value)
|
|
78
|
-
}
|
|
79
|
-
})
|
|
80
|
-
|
|
75
|
+
const { params } = buildQueryParams(options)
|
|
81
76
|
const result = await request(`/weights/stats?${params.toString()}`)
|
|
82
|
-
|
|
77
|
+
outputData(result, { format: options.format, type: 'weight-stats' })
|
|
83
78
|
} catch (error) {
|
|
84
79
|
console.error('Failed to get weight stats:', error.message)
|
|
85
80
|
}
|
|
@@ -88,10 +83,11 @@ weight
|
|
|
88
83
|
weight
|
|
89
84
|
.command('get')
|
|
90
85
|
.requiredOption('--id <id>', 'Weight record ID')
|
|
86
|
+
.option('--format <format>', 'Output format: json, table, toon', 'json')
|
|
91
87
|
.action(async (options) => {
|
|
92
88
|
try {
|
|
93
89
|
const result = await request(`/weights/${options.id}`)
|
|
94
|
-
|
|
90
|
+
outputData(result, { format: options.format, type: 'weight-get' })
|
|
95
91
|
} catch (error) {
|
|
96
92
|
console.error('Failed to get weight record:', error.message)
|
|
97
93
|
}
|
|
@@ -107,8 +103,9 @@ weight
|
|
|
107
103
|
.option('--water <value>', 'Updated water percentage')
|
|
108
104
|
.option('--bone-mass <value>', 'Updated bone mass (kg)')
|
|
109
105
|
.option('--visceral-fat <value>', 'Updated visceral fat level')
|
|
106
|
+
.option('--extra-data <json>', 'Updated extra data (JSON string)')
|
|
110
107
|
.option('--note <note>', 'Updated note')
|
|
111
|
-
.option('--date <date>', 'Updated date (YYYY-MM-DD)')
|
|
108
|
+
.option('--date <date>', 'Updated date (YYYY-MM-DD or ISO 8601 datetime)')
|
|
112
109
|
.option('--file <paths...>', 'File paths to attach')
|
|
113
110
|
.option('--replace-attachments', 'Replace existing attachments instead of adding')
|
|
114
111
|
.action(async (options) => {
|
|
@@ -121,8 +118,9 @@ weight
|
|
|
121
118
|
water: options.water,
|
|
122
119
|
boneMass: options.boneMass,
|
|
123
120
|
visceralFat: options.visceralFat,
|
|
121
|
+
extraData: options.extraData,
|
|
124
122
|
note: options.note,
|
|
125
|
-
date: options.date,
|
|
123
|
+
date: appendTimezoneOffset(options.date),
|
|
126
124
|
replaceAttachments: options.replaceAttachments ? 'true' : undefined
|
|
127
125
|
}, options.file || [])
|
|
128
126
|
|
package/src/lib/api.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import fetch from 'node-fetch'
|
|
2
|
-
import { FormData, File } from 'node-fetch'
|
|
3
1
|
import { readFileSync } from 'fs'
|
|
4
2
|
import config from './config.js'
|
|
5
3
|
|
|
@@ -42,10 +40,27 @@ export function createFormData(fields, files = []) {
|
|
|
42
40
|
}
|
|
43
41
|
})
|
|
44
42
|
|
|
43
|
+
const mimeMap = {
|
|
44
|
+
'.png': 'image/png',
|
|
45
|
+
'.jpg': 'image/jpeg',
|
|
46
|
+
'.jpeg': 'image/jpeg',
|
|
47
|
+
'.gif': 'image/gif',
|
|
48
|
+
'.webp': 'image/webp',
|
|
49
|
+
'.bmp': 'image/bmp',
|
|
50
|
+
'.heic': 'image/heic',
|
|
51
|
+
'.heif': 'image/heif',
|
|
52
|
+
'.pdf': 'application/pdf',
|
|
53
|
+
'.txt': 'text/plain',
|
|
54
|
+
'.gpx': 'application/gpx+xml',
|
|
55
|
+
'.fit': 'application/fit'
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
files.forEach(filePath => {
|
|
46
59
|
const buffer = readFileSync(filePath)
|
|
47
60
|
const fileName = filePath.split('/').pop()
|
|
48
|
-
const
|
|
61
|
+
const ext = '.' + fileName.split('.').pop().toLowerCase()
|
|
62
|
+
const type = mimeMap[ext] || 'application/octet-stream'
|
|
63
|
+
const file = new File([buffer], fileName, { type })
|
|
49
64
|
formData.append('file', file)
|
|
50
65
|
})
|
|
51
66
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import config from './config.js'
|
|
2
|
+
|
|
3
|
+
const CACHE_KEY = 'foodCache'
|
|
4
|
+
|
|
5
|
+
function getCache() {
|
|
6
|
+
return config.get(CACHE_KEY) || []
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function setCache(cache) {
|
|
10
|
+
config.set(CACHE_KEY, cache)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseNumber(str) {
|
|
14
|
+
if (!str || str === '—' || str === '') return null
|
|
15
|
+
const num = parseFloat(String(str).replace(/[^\d.]/g, ''))
|
|
16
|
+
return isNaN(num) ? null : num
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseKjToKcal(str) {
|
|
20
|
+
const kj = parseNumber(str)
|
|
21
|
+
if (kj === null) return null
|
|
22
|
+
return Math.round(kj / 4.184 * 10) / 10
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a single food item from the API response array into a structured object.
|
|
27
|
+
* Field mapping (based on actual API response):
|
|
28
|
+
* [0] foodCode → sourceId
|
|
29
|
+
* [2] foodName → name
|
|
30
|
+
* [5] water %
|
|
31
|
+
* [6] edible portion
|
|
32
|
+
* [7] energy (kJ) → energyKcal (converted)
|
|
33
|
+
* [8] protein (g)
|
|
34
|
+
* [9] fat (g)
|
|
35
|
+
* [11] dietary fiber (g)
|
|
36
|
+
* [12] carbohydrate (g)
|
|
37
|
+
*/
|
|
38
|
+
function parseFoodItem(item) {
|
|
39
|
+
return {
|
|
40
|
+
source: 'chinanutri',
|
|
41
|
+
sourceId: String(item[0]),
|
|
42
|
+
name: item[2] || '',
|
|
43
|
+
energyKcal: parseKjToKcal(item[7]),
|
|
44
|
+
protein: parseNumber(item[8]),
|
|
45
|
+
fat: parseNumber(item[9]),
|
|
46
|
+
carbs: parseNumber(item[12]),
|
|
47
|
+
rawItem: item
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Search foods from the remote API.
|
|
53
|
+
* Returns an array of parsed food objects.
|
|
54
|
+
*/
|
|
55
|
+
export async function searchRemote(name, limit = 20) {
|
|
56
|
+
const body = new URLSearchParams({
|
|
57
|
+
categoryOne: '0',
|
|
58
|
+
categoryTwo: '0',
|
|
59
|
+
foodName: name,
|
|
60
|
+
pageNum: '1',
|
|
61
|
+
field: '0',
|
|
62
|
+
flag: '0'
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const controller = new AbortController()
|
|
66
|
+
const timeout = setTimeout(() => controller.abort(), 5000)
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(
|
|
70
|
+
'https://nlc.chinanutri.cn/fq/FoodInfoQueryAction!queryFoodInfoList.do',
|
|
71
|
+
{
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'Referer': 'https://nlc.chinanutri.cn/fq/foodlist.htm',
|
|
75
|
+
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
|
76
|
+
},
|
|
77
|
+
body: body.toString(),
|
|
78
|
+
signal: controller.signal
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
clearTimeout(timeout)
|
|
83
|
+
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new Error(`HTTP ${response.status}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const data = await response.json()
|
|
89
|
+
const items = data.list || []
|
|
90
|
+
|
|
91
|
+
return items.slice(0, limit).map(parseFoodItem)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
clearTimeout(timeout)
|
|
94
|
+
if (error.name === 'AbortError') {
|
|
95
|
+
throw new Error('TIMEOUT')
|
|
96
|
+
}
|
|
97
|
+
throw error
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Search cached foods by name (fuzzy match).
|
|
103
|
+
*/
|
|
104
|
+
export function searchCache(name, limit = 5) {
|
|
105
|
+
const cache = getCache()
|
|
106
|
+
const keyword = name.toLowerCase()
|
|
107
|
+
return cache
|
|
108
|
+
.filter(item => item.name.toLowerCase().includes(keyword))
|
|
109
|
+
.slice(0, limit)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Upsert food items into local cache.
|
|
114
|
+
*/
|
|
115
|
+
export function upsertCache(items) {
|
|
116
|
+
const cache = getCache()
|
|
117
|
+
for (const item of items) {
|
|
118
|
+
const idx = cache.findIndex(c => c.sourceId === item.sourceId)
|
|
119
|
+
const entry = {
|
|
120
|
+
source: item.source,
|
|
121
|
+
sourceId: item.sourceId,
|
|
122
|
+
name: item.name,
|
|
123
|
+
energyKcal: item.energyKcal,
|
|
124
|
+
protein: item.protein,
|
|
125
|
+
carbs: item.carbs,
|
|
126
|
+
fat: item.fat,
|
|
127
|
+
updatedAt: new Date().toISOString()
|
|
128
|
+
}
|
|
129
|
+
if (idx >= 0) {
|
|
130
|
+
cache[idx] = entry
|
|
131
|
+
} else {
|
|
132
|
+
cache.push(entry)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
setCache(cache)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Main search function: cache-first with remote fallback.
|
|
140
|
+
*/
|
|
141
|
+
export async function searchFood(name, { limit = 5, noCache = false } = {}) {
|
|
142
|
+
// 1. If cache is allowed, try cache first
|
|
143
|
+
if (!noCache) {
|
|
144
|
+
const cached = searchCache(name, limit)
|
|
145
|
+
if (cached.length > 0) {
|
|
146
|
+
return { items: cached, fromCache: true }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 2. Query remote
|
|
151
|
+
try {
|
|
152
|
+
const remoteItems = await searchRemote(name, limit)
|
|
153
|
+
if (remoteItems.length > 0) {
|
|
154
|
+
upsertCache(remoteItems)
|
|
155
|
+
return { items: remoteItems.slice(0, limit), fromCache: false }
|
|
156
|
+
}
|
|
157
|
+
return { items: [], fromCache: false }
|
|
158
|
+
} catch (error) {
|
|
159
|
+
// 3. On remote failure, try cache as fallback
|
|
160
|
+
if (error.message === 'TIMEOUT') {
|
|
161
|
+
const cached = searchCache(name, limit)
|
|
162
|
+
if (cached.length > 0) {
|
|
163
|
+
return { items: cached, fromCache: true, degraded: true }
|
|
164
|
+
}
|
|
165
|
+
throw new Error('查询超时,请稍后重试')
|
|
166
|
+
}
|
|
167
|
+
// Other remote errors: degrade to cache
|
|
168
|
+
const cached = searchCache(name, limit)
|
|
169
|
+
if (cached.length > 0) {
|
|
170
|
+
return { items: cached, fromCache: true, degraded: true }
|
|
171
|
+
}
|
|
172
|
+
throw error
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import Table from 'cli-table3'
|
|
2
|
+
import { encode } from '@toon-format/toon'
|
|
3
|
+
import { formatDate, getConfigDateFormat } from './timezone.js'
|
|
4
|
+
|
|
5
|
+
function outputJson(data) {
|
|
6
|
+
console.log(JSON.stringify(data, null, 2))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function outputTable(headers, rows) {
|
|
10
|
+
const table = new Table({
|
|
11
|
+
head: headers,
|
|
12
|
+
style: { head: ['cyan'], border: ['gray'] }
|
|
13
|
+
})
|
|
14
|
+
for (const row of rows) {
|
|
15
|
+
table.push(row)
|
|
16
|
+
}
|
|
17
|
+
console.log(table.toString())
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function outputToon(data) {
|
|
21
|
+
if (!data || (typeof data === 'object' && Object.keys(data).length === 0)) {
|
|
22
|
+
console.log('(无数据)')
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
console.log(encode(data))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatPaginationHint(page, totalPages, total, format) {
|
|
29
|
+
if (format !== 'table') return null
|
|
30
|
+
if (totalPages <= 1) return null
|
|
31
|
+
const nextPage = page + 1
|
|
32
|
+
return `第 ${page} 页,共 ${totalPages} 页(共 ${total} 条)— 使用 --page ${nextPage} 查看下一页`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractItems(data, type) {
|
|
36
|
+
const keyMap = {
|
|
37
|
+
'weight-list': 'weights',
|
|
38
|
+
'exercise-list': 'exercises',
|
|
39
|
+
'diet-list': 'diets',
|
|
40
|
+
'sleep-list': 'sleeps',
|
|
41
|
+
'record-list': 'records',
|
|
42
|
+
'food-list': 'foods',
|
|
43
|
+
'timeline': 'items'
|
|
44
|
+
}
|
|
45
|
+
const key = keyMap[type]
|
|
46
|
+
if (key && Array.isArray(data[key])) return data[key]
|
|
47
|
+
if (Array.isArray(data)) return data
|
|
48
|
+
return []
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildRows(items, type) {
|
|
52
|
+
const df = getConfigDateFormat()
|
|
53
|
+
switch (type) {
|
|
54
|
+
case 'weight-list':
|
|
55
|
+
return items.map((item, idx) => [
|
|
56
|
+
idx + 1,
|
|
57
|
+
item.id,
|
|
58
|
+
item.weight,
|
|
59
|
+
item.bodyFat ?? '-',
|
|
60
|
+
item.bmi ?? '-',
|
|
61
|
+
item.date ? formatDate(item.date, df) : '-',
|
|
62
|
+
item.note || '-'
|
|
63
|
+
])
|
|
64
|
+
case 'exercise-list':
|
|
65
|
+
return items.map((item, idx) => [
|
|
66
|
+
idx + 1,
|
|
67
|
+
item.id,
|
|
68
|
+
item.type,
|
|
69
|
+
item.duration,
|
|
70
|
+
item.caloriesBurned ?? '-',
|
|
71
|
+
item.heartRateAvg ?? '-',
|
|
72
|
+
item.date ? formatDate(item.date, df) : '-'
|
|
73
|
+
])
|
|
74
|
+
case 'diet-list':
|
|
75
|
+
return items.map((item, idx) => [
|
|
76
|
+
idx + 1,
|
|
77
|
+
item.id,
|
|
78
|
+
item.mealType,
|
|
79
|
+
item.calories ?? '-',
|
|
80
|
+
item.protein ?? '-',
|
|
81
|
+
item.carbs ?? '-',
|
|
82
|
+
item.fat ?? '-',
|
|
83
|
+
item.date ? formatDate(item.date, df) : '-'
|
|
84
|
+
])
|
|
85
|
+
case 'sleep-list':
|
|
86
|
+
return items.map((item, idx) => [
|
|
87
|
+
idx + 1,
|
|
88
|
+
item.id,
|
|
89
|
+
item.duration,
|
|
90
|
+
item.quality,
|
|
91
|
+
item.bedTime ?? '-',
|
|
92
|
+
item.wakeTime ?? '-',
|
|
93
|
+
item.deepSleep ?? '-',
|
|
94
|
+
item.date ? formatDate(item.date, df) : '-'
|
|
95
|
+
])
|
|
96
|
+
case 'record-list':
|
|
97
|
+
return items.map((item, idx) => [
|
|
98
|
+
idx + 1,
|
|
99
|
+
item.id,
|
|
100
|
+
item.type,
|
|
101
|
+
(item.tags || []).join(', ') || '-',
|
|
102
|
+
item.date ? formatDate(item.date, df) : '-',
|
|
103
|
+
item.note || '-'
|
|
104
|
+
])
|
|
105
|
+
case 'food-list':
|
|
106
|
+
return items.map((item, idx) => [
|
|
107
|
+
idx + 1,
|
|
108
|
+
item.name,
|
|
109
|
+
item.energyKcal ?? '-',
|
|
110
|
+
item.protein ?? '-',
|
|
111
|
+
item.carbs ?? '-',
|
|
112
|
+
item.fat ?? '-'
|
|
113
|
+
])
|
|
114
|
+
case 'timeline':
|
|
115
|
+
return items.map((item, idx) => {
|
|
116
|
+
let summary = ''
|
|
117
|
+
if (item.type === 'weight') summary = `${item.data?.weight} kg`
|
|
118
|
+
else if (item.type === 'exercise') summary = `${item.data?.duration} min ${item.data?.type || ''}`
|
|
119
|
+
else if (item.type === 'diet') summary = `${item.data?.calories || 0} kcal (${item.data?.mealType || ''})`
|
|
120
|
+
else if (item.type === 'sleep') summary = `${item.data?.duration}h, 质量: ${item.data?.quality}/10`
|
|
121
|
+
else if (item.type === 'record') summary = item.data?.type || ''
|
|
122
|
+
return [
|
|
123
|
+
idx + 1,
|
|
124
|
+
item.date ? formatDate(item.date, df) : '-',
|
|
125
|
+
item.type,
|
|
126
|
+
summary
|
|
127
|
+
]
|
|
128
|
+
})
|
|
129
|
+
default:
|
|
130
|
+
return []
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getHeaders(type) {
|
|
135
|
+
switch (type) {
|
|
136
|
+
case 'weight-list': return ['#', 'ID', '体重(kg)', '体脂(%)', 'BMI', '日期', '备注']
|
|
137
|
+
case 'exercise-list': return ['#', 'ID', '类型', '时长(分)', '热量', '心率', '日期']
|
|
138
|
+
case 'diet-list': return ['#', 'ID', '餐别', '热量', '蛋白质', '碳水', '脂肪', '日期']
|
|
139
|
+
case 'sleep-list': return ['#', 'ID', '时长(h)', '质量', '入睡', '醒来', '深睡(h)', '日期']
|
|
140
|
+
case 'record-list': return ['#', 'ID', '类型', '标签', '日期', '备注']
|
|
141
|
+
case 'food-list': return ['#', '名称', '热量(kcal)', '蛋白质(g)', '碳水(g)', '脂肪(g)']
|
|
142
|
+
case 'timeline': return ['#', '时间', '类型', '摘要']
|
|
143
|
+
default: return []
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function outputStats(data, type, format) {
|
|
148
|
+
if (format === 'json') {
|
|
149
|
+
outputJson(data)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
if (format === 'toon') {
|
|
153
|
+
outputToon(data)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const rows = []
|
|
158
|
+
switch (type) {
|
|
159
|
+
case 'weight-stats':
|
|
160
|
+
if (data.avgWeight !== undefined) rows.push(['平均体重', `${data.avgWeight} kg`])
|
|
161
|
+
if (data.minWeight !== undefined) rows.push(['最低体重', `${data.minWeight} kg`])
|
|
162
|
+
if (data.maxWeight !== undefined) rows.push(['最高体重', `${data.maxWeight} kg`])
|
|
163
|
+
if (data.change !== undefined && data.change !== null) rows.push(['变化量', `${data.change > 0 ? '+' : ''}${data.change} kg`])
|
|
164
|
+
break
|
|
165
|
+
case 'exercise-stats':
|
|
166
|
+
if (data.count !== undefined) rows.push(['总次数', data.count])
|
|
167
|
+
if (data.totalDuration !== undefined) rows.push(['总时长', `${data.totalDuration} min`])
|
|
168
|
+
if (data.avgDuration !== null) rows.push(['平均时长', `${data.avgDuration.toFixed(1)} min`])
|
|
169
|
+
if (data.totalCalories !== undefined) rows.push(['总热量', `${data.totalCalories} kcal`])
|
|
170
|
+
if (data.avgCalories !== null) rows.push(['平均热量', `${data.avgCalories.toFixed(0)} kcal`])
|
|
171
|
+
if (data.frequencyByType) {
|
|
172
|
+
for (const [t, c] of Object.entries(data.frequencyByType)) {
|
|
173
|
+
rows.push([`频率 (${t})`, c])
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
break
|
|
177
|
+
case 'diet-stats':
|
|
178
|
+
if (data.avgCalories !== undefined) rows.push(['平均热量', `${data.avgCalories?.toFixed?.(0) || data.avgCalories} kcal`])
|
|
179
|
+
if (data.avgProtein !== undefined) rows.push(['平均蛋白质', `${data.avgProtein?.toFixed?.(1) || data.avgProtein}g`])
|
|
180
|
+
if (data.avgCarbs !== undefined) rows.push(['平均碳水', `${data.avgCarbs?.toFixed?.(1) || data.avgCarbs}g`])
|
|
181
|
+
if (data.avgFat !== undefined) rows.push(['平均脂肪', `${data.avgFat?.toFixed?.(1) || data.avgFat}g`])
|
|
182
|
+
if (data.totalWater !== undefined && data.totalWater !== null) rows.push(['总饮水量', `${data.totalWater}ml`])
|
|
183
|
+
if (data.count !== undefined) rows.push(['记录数', data.count])
|
|
184
|
+
break
|
|
185
|
+
case 'sleep-stats':
|
|
186
|
+
if (data.avgDuration !== undefined) rows.push(['平均时长', `${data.avgDuration?.toFixed?.(1) || data.avgDuration}h`])
|
|
187
|
+
if (data.avgQuality !== undefined) rows.push(['平均质量', `${data.avgQuality?.toFixed?.(1) || data.avgQuality}/10`])
|
|
188
|
+
if (data.avgDeepSleep !== undefined) rows.push(['平均深睡', `${data.avgDeepSleep?.toFixed?.(1) || data.avgDeepSleep}h`])
|
|
189
|
+
if (data.count !== undefined) rows.push(['记录数', data.count])
|
|
190
|
+
break
|
|
191
|
+
default:
|
|
192
|
+
rows.push(...Object.entries(data).map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : v]))
|
|
193
|
+
}
|
|
194
|
+
outputTable(['指标', '数值'], rows)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function outputGet(data, type, format) {
|
|
198
|
+
if (format === 'json') {
|
|
199
|
+
outputJson(data)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (format === 'toon') {
|
|
203
|
+
outputToon(data)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const df = getConfigDateFormat()
|
|
208
|
+
const rows = Object.entries(data).map(([k, v]) => {
|
|
209
|
+
let val = v
|
|
210
|
+
if (k === 'date' && v) val = formatDate(v, df)
|
|
211
|
+
else if (typeof v === 'object' && v !== null) val = JSON.stringify(v)
|
|
212
|
+
return [k, val]
|
|
213
|
+
})
|
|
214
|
+
outputTable(['字段', '值'], rows)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function outputData(data, options = {}) {
|
|
218
|
+
const { format = 'json', type, page = 1 } = options
|
|
219
|
+
|
|
220
|
+
if (type && type.endsWith('-stats')) {
|
|
221
|
+
outputStats(data, type, format)
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (type && type.endsWith('-get')) {
|
|
226
|
+
outputGet(data, type, format)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (format === 'toon') {
|
|
231
|
+
outputToon(data)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const items = extractItems(data, type)
|
|
236
|
+
const headers = getHeaders(type)
|
|
237
|
+
const rows = buildRows(items, type)
|
|
238
|
+
|
|
239
|
+
if (format === 'json') {
|
|
240
|
+
outputJson(data)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (format === 'table') {
|
|
245
|
+
if (rows.length > 0) {
|
|
246
|
+
outputTable(headers, rows)
|
|
247
|
+
} else {
|
|
248
|
+
console.log('(无数据)')
|
|
249
|
+
}
|
|
250
|
+
const hint = formatPaginationHint(page, data.totalPages, data.total, 'table')
|
|
251
|
+
if (hint) console.log('\n' + hint)
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// fallback
|
|
256
|
+
outputJson(data)
|
|
257
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import dayjs from 'dayjs'
|
|
2
|
+
import utc from 'dayjs/plugin/utc.js'
|
|
3
|
+
import timezone from 'dayjs/plugin/timezone.js'
|
|
4
|
+
import config from './config.js'
|
|
5
|
+
|
|
6
|
+
dayjs.extend(utc)
|
|
7
|
+
dayjs.extend(timezone)
|
|
8
|
+
|
|
9
|
+
export function getConfigTimezone() {
|
|
10
|
+
return config.get('timezone') || dayjs.tz.guess() || 'UTC'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getConfigDateFormat() {
|
|
14
|
+
return config.get('dateFormat') || 'YYYY-MM-DD HH:mm'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatDate(date, format) {
|
|
18
|
+
const tz = getConfigTimezone()
|
|
19
|
+
const fmt = format || getConfigDateFormat()
|
|
20
|
+
return dayjs(date).tz(tz).format(fmt)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function appendTimezoneOffset(dateStr) {
|
|
24
|
+
if (!dateStr) return dateStr
|
|
25
|
+
// Pure date like "2026-05-28" - no offset needed
|
|
26
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
|
27
|
+
return dateStr
|
|
28
|
+
}
|
|
29
|
+
// Already has timezone offset
|
|
30
|
+
if (/[+-]\d{2}:\d{2}$/.test(dateStr) || /Z$/.test(dateStr)) {
|
|
31
|
+
return dateStr
|
|
32
|
+
}
|
|
33
|
+
// Datetime without offset - append user's timezone
|
|
34
|
+
const tz = getConfigTimezone()
|
|
35
|
+
const offset = dayjs().tz(tz).format('Z')
|
|
36
|
+
return `${dateStr}${offset}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getDefaultLast() {
|
|
40
|
+
return '7d'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getDefaultLimit() {
|
|
44
|
+
return 20
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildQueryParams(options, defaults = {}) {
|
|
48
|
+
const params = new URLSearchParams()
|
|
49
|
+
|
|
50
|
+
const hasTimeFilter = options.last || options.start || options.end
|
|
51
|
+
if (!hasTimeFilter && defaults.last !== false) {
|
|
52
|
+
params.append('last', getDefaultLast())
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (options.last) params.append('last', options.last)
|
|
56
|
+
if (options.start) params.append('start', options.start)
|
|
57
|
+
if (options.end) params.append('end', options.end)
|
|
58
|
+
|
|
59
|
+
const page = options.page || defaults.page || 1
|
|
60
|
+
const limit = options.limit || defaults.limit || getDefaultLimit()
|
|
61
|
+
params.append('page', String(page))
|
|
62
|
+
params.append('limit', String(limit))
|
|
63
|
+
|
|
64
|
+
if (options.includeDeleted) {
|
|
65
|
+
params.append('includeDeleted', 'true')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Pass through other known filter options
|
|
69
|
+
const filterKeys = ['type', 'tag', 'meal', 'query']
|
|
70
|
+
for (const key of filterKeys) {
|
|
71
|
+
if (options[key]) {
|
|
72
|
+
const paramKey = key === 'meal' ? 'mealType' : key === 'query' ? 'q' : key
|
|
73
|
+
params.append(paramKey, options[key])
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { params, page: parseInt(String(page), 10), limit: parseInt(String(limit), 10) }
|
|
78
|
+
}
|