@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 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.14",
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": {
@@ -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 configKey = key === 'api-url' ? 'apiUrl' : key
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 configKey = key === 'api-url' ? 'apiUrl' : key
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
  })
@@ -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 = new URLSearchParams()
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
- console.log(JSON.stringify(result, null, 2))
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 = new URLSearchParams()
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
- console.log(JSON.stringify(result, null, 2))
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
- console.log(JSON.stringify(result, null, 2))
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
 
@@ -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 = new URLSearchParams()
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
- console.log(JSON.stringify(result, null, 2))
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 = new URLSearchParams()
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
- console.log(JSON.stringify(result, null, 2))
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
- console.log(JSON.stringify(result, null, 2))
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
@@ -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 for the record (YYYY-MM-DD)')
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 = new URLSearchParams()
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
- console.log(JSON.stringify(result, null, 2))
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
- console.log(JSON.stringify(result, null, 2))
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 = new URLSearchParams()
132
- params.append('q', options.query)
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
- console.log(JSON.stringify(result, null, 2))
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
  }
@@ -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
- .requiredOption('--duration <value>', 'Sleep duration in hours')
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: options.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 = new URLSearchParams()
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
- console.log(JSON.stringify(result, null, 2))
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 = new URLSearchParams()
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
- console.log(JSON.stringify(result, null, 2))
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
- console.log(JSON.stringify(result, null, 2))
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
 
@@ -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 = new URLSearchParams()
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
- console.log(JSON.stringify(result, null, 2))
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
  }
@@ -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 for the record (YYYY-MM-DD)')
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 = new URLSearchParams()
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
- console.log(JSON.stringify(result, null, 2))
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 = new URLSearchParams()
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
- console.log(JSON.stringify(result, null, 2))
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
- console.log(JSON.stringify(result, null, 2))
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 file = new File([buffer], fileName)
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
+ }