@eeymoo/hum 0.1.15 → 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.15",
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,6 +16,7 @@ diet
16
16
  .option('--sodium <value>', 'Sodium (mg)')
17
17
  .option('--foods <string>', 'Foods in format: "name:amount,name2:amount2"')
18
18
  .option('--water <value>', 'Water (ml)')
19
+ .option('--extra-data <json>', 'Extra data (JSON string)')
19
20
  .option('--note <note>', 'Note')
20
21
  .option('--date <date>', 'Date (YYYY-MM-DD or ISO 8601 datetime)')
21
22
  .option('--file <paths...>', 'File paths to attach')
@@ -31,6 +32,7 @@ diet
31
32
  sodium: options.sodium,
32
33
  foods: options.foods,
33
34
  water: options.water,
35
+ extraData: options.extraData,
34
36
  note: options.note,
35
37
  date: appendTimezoneOffset(options.date)
36
38
  }, options.file || [])
@@ -108,6 +110,7 @@ diet
108
110
  .option('--sodium <value>', 'Updated sodium')
109
111
  .option('--foods <string>', 'Updated foods')
110
112
  .option('--water <value>', 'Updated water')
113
+ .option('--extra-data <json>', 'Updated extra data (JSON string)')
111
114
  .option('--note <note>', 'Updated note')
112
115
  .option('--date <date>', 'Updated date (YYYY-MM-DD or ISO 8601 datetime)')
113
116
  .option('--file <paths...>', 'File paths to attach')
@@ -124,6 +127,7 @@ diet
124
127
  sodium: options.sodium,
125
128
  foods: options.foods,
126
129
  water: options.water,
130
+ extraData: options.extraData,
127
131
  note: options.note,
128
132
  date: appendTimezoneOffset(options.date),
129
133
  replaceAttachments: options.replaceAttachments ? 'true' : undefined
@@ -14,6 +14,7 @@ exercise
14
14
  .option('--heart-rate-avg <value>', 'Average heart rate')
15
15
  .option('--heart-rate-max <value>', 'Max heart rate')
16
16
  .option('--feeling <value>', 'Feeling 1-10')
17
+ .option('--extra-data <json>', 'Extra data (JSON string)')
17
18
  .option('--location <location>', 'Location')
18
19
  .option('--note <note>', 'Note')
19
20
  .option('--date <date>', 'Date (YYYY-MM-DD or ISO 8601 datetime)')
@@ -28,6 +29,7 @@ exercise
28
29
  heartRateAvg: options.heartRateAvg,
29
30
  heartRateMax: options.heartRateMax,
30
31
  feeling: options.feeling,
32
+ extraData: options.extraData,
31
33
  location: options.location,
32
34
  note: options.note,
33
35
  date: appendTimezoneOffset(options.date)
@@ -104,6 +106,7 @@ exercise
104
106
  .option('--heart-rate-avg <value>', 'Updated average heart rate')
105
107
  .option('--heart-rate-max <value>', 'Updated max heart rate')
106
108
  .option('--feeling <value>', 'Updated feeling')
109
+ .option('--extra-data <json>', 'Updated extra data (JSON string)')
107
110
  .option('--location <location>', 'Updated location')
108
111
  .option('--note <note>', 'Updated note')
109
112
  .option('--date <date>', 'Updated date (YYYY-MM-DD or ISO 8601 datetime)')
@@ -119,6 +122,7 @@ exercise
119
122
  heartRateAvg: options.heartRateAvg,
120
123
  heartRateMax: options.heartRateMax,
121
124
  feeling: options.feeling,
125
+ extraData: options.extraData,
122
126
  location: options.location,
123
127
  note: options.note,
124
128
  date: appendTimezoneOffset(options.date),
@@ -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
@@ -7,7 +7,7 @@ const sleep = new Command('sleep')
7
7
 
8
8
  sleep
9
9
  .command('add')
10
- .requiredOption('--duration <value>', 'Sleep duration in hours')
10
+ .option('--duration <value>', 'Sleep duration in hours')
11
11
  .requiredOption('--bedtime <time>', 'Bedtime (HH:mm)')
12
12
  .requiredOption('--waketime <time>', 'Wake time (HH:mm)')
13
13
  .requiredOption('--quality <value>', 'Sleep quality 1-10')
@@ -15,13 +15,27 @@ sleep
15
15
  .option('--rem-sleep <value>', 'REM sleep duration in hours')
16
16
  .option('--awakenings <value>', 'Number of awakenings')
17
17
  .option('--feeling <value>', 'Feeling 1-10')
18
+ .option('--extra-data <json>', 'Extra data (JSON string)')
18
19
  .option('--note <note>', 'Note')
19
20
  .option('--date <date>', 'Date (YYYY-MM-DD or ISO 8601 datetime)')
20
21
  .option('--file <paths...>', 'File paths to attach')
21
22
  .action(async (options) => {
22
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
+
23
37
  const formData = createFormData({
24
- duration: options.duration,
38
+ duration,
25
39
  bedTime: options.bedtime,
26
40
  wakeTime: options.waketime,
27
41
  quality: options.quality,
@@ -29,6 +43,7 @@ sleep
29
43
  remSleep: options.remSleep,
30
44
  awakenings: options.awakenings,
31
45
  feeling: options.feeling,
46
+ extraData: options.extraData,
32
47
  note: options.note,
33
48
  date: appendTimezoneOffset(options.date)
34
49
  }, options.file || [])
@@ -104,6 +119,7 @@ sleep
104
119
  .option('--rem-sleep <value>', 'Updated REM sleep')
105
120
  .option('--awakenings <value>', 'Updated number of awakenings')
106
121
  .option('--feeling <value>', 'Updated feeling')
122
+ .option('--extra-data <json>', 'Updated extra data (JSON string)')
107
123
  .option('--note <note>', 'Updated note')
108
124
  .option('--date <date>', 'Updated date (YYYY-MM-DD or ISO 8601 datetime)')
109
125
  .option('--file <paths...>', 'File paths to attach')
@@ -119,6 +135,7 @@ sleep
119
135
  remSleep: options.remSleep,
120
136
  awakenings: options.awakenings,
121
137
  feeling: options.feeling,
138
+ extraData: options.extraData,
122
139
  note: options.note,
123
140
  date: appendTimezoneOffset(options.date),
124
141
  replaceAttachments: options.replaceAttachments ? 'true' : undefined
@@ -14,6 +14,7 @@ weight
14
14
  .option('--water <value>', 'Water percentage')
15
15
  .option('--bone-mass <value>', 'Bone mass (kg)')
16
16
  .option('--visceral-fat <value>', 'Visceral fat level')
17
+ .option('--extra-data <json>', 'Extra data (JSON string)')
17
18
  .option('--note <note>', 'Note for the record')
18
19
  .option('--date <date>', 'Date (YYYY-MM-DD or ISO 8601 datetime)')
19
20
  .option('--file <paths...>', 'File paths to attach')
@@ -27,6 +28,7 @@ weight
27
28
  water: options.water,
28
29
  boneMass: options.boneMass,
29
30
  visceralFat: options.visceralFat,
31
+ extraData: options.extraData,
30
32
  note: options.note,
31
33
  date: appendTimezoneOffset(options.date)
32
34
  }, options.file || [])
@@ -101,6 +103,7 @@ weight
101
103
  .option('--water <value>', 'Updated water percentage')
102
104
  .option('--bone-mass <value>', 'Updated bone mass (kg)')
103
105
  .option('--visceral-fat <value>', 'Updated visceral fat level')
106
+ .option('--extra-data <json>', 'Updated extra data (JSON string)')
104
107
  .option('--note <note>', 'Updated note')
105
108
  .option('--date <date>', 'Updated date (YYYY-MM-DD or ISO 8601 datetime)')
106
109
  .option('--file <paths...>', 'File paths to attach')
@@ -115,6 +118,7 @@ weight
115
118
  water: options.water,
116
119
  boneMass: options.boneMass,
117
120
  visceralFat: options.visceralFat,
121
+ extraData: options.extraData,
118
122
  note: options.note,
119
123
  date: appendTimezoneOffset(options.date),
120
124
  replaceAttachments: options.replaceAttachments ? 'true' : undefined
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
+ }
package/src/lib/output.js CHANGED
@@ -39,6 +39,7 @@ function extractItems(data, type) {
39
39
  'diet-list': 'diets',
40
40
  'sleep-list': 'sleeps',
41
41
  'record-list': 'records',
42
+ 'food-list': 'foods',
42
43
  'timeline': 'items'
43
44
  }
44
45
  const key = keyMap[type]
@@ -101,6 +102,15 @@ function buildRows(items, type) {
101
102
  item.date ? formatDate(item.date, df) : '-',
102
103
  item.note || '-'
103
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
+ ])
104
114
  case 'timeline':
105
115
  return items.map((item, idx) => {
106
116
  let summary = ''
@@ -128,6 +138,7 @@ function getHeaders(type) {
128
138
  case 'diet-list': return ['#', 'ID', '餐别', '热量', '蛋白质', '碳水', '脂肪', '日期']
129
139
  case 'sleep-list': return ['#', 'ID', '时长(h)', '质量', '入睡', '醒来', '深睡(h)', '日期']
130
140
  case 'record-list': return ['#', 'ID', '类型', '标签', '日期', '备注']
141
+ case 'food-list': return ['#', '名称', '热量(kcal)', '蛋白质(g)', '碳水(g)', '脂肪(g)']
131
142
  case 'timeline': return ['#', '时间', '类型', '摘要']
132
143
  default: return []
133
144
  }
@@ -154,7 +165,9 @@ function outputStats(data, type, format) {
154
165
  case 'exercise-stats':
155
166
  if (data.count !== undefined) rows.push(['总次数', data.count])
156
167
  if (data.totalDuration !== undefined) rows.push(['总时长', `${data.totalDuration} min`])
168
+ if (data.avgDuration !== null) rows.push(['平均时长', `${data.avgDuration.toFixed(1)} min`])
157
169
  if (data.totalCalories !== undefined) rows.push(['总热量', `${data.totalCalories} kcal`])
170
+ if (data.avgCalories !== null) rows.push(['平均热量', `${data.avgCalories.toFixed(0)} kcal`])
158
171
  if (data.frequencyByType) {
159
172
  for (const [t, c] of Object.entries(data.frequencyByType)) {
160
173
  rows.push([`频率 (${t})`, c])
@@ -162,7 +175,7 @@ function outputStats(data, type, format) {
162
175
  }
163
176
  break
164
177
  case 'diet-stats':
165
- if (data.avgCaloriesPerDay !== undefined) rows.push(['日均热量', `${data.avgCaloriesPerDay?.toFixed?.(0) || data.avgCaloriesPerDay} kcal`])
178
+ if (data.avgCalories !== undefined) rows.push(['平均热量', `${data.avgCalories?.toFixed?.(0) || data.avgCalories} kcal`])
166
179
  if (data.avgProtein !== undefined) rows.push(['平均蛋白质', `${data.avgProtein?.toFixed?.(1) || data.avgProtein}g`])
167
180
  if (data.avgCarbs !== undefined) rows.push(['平均碳水', `${data.avgCarbs?.toFixed?.(1) || data.avgCarbs}g`])
168
181
  if (data.avgFat !== undefined) rows.push(['平均脂肪', `${data.avgFat?.toFixed?.(1) || data.avgFat}g`])