@eeymoo/hum 0.1.13 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeymoo/hum",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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
 
@@ -15,7 +17,7 @@ diet
15
17
  .option('--foods <string>', 'Foods in format: "name:amount,name2:amount2"')
16
18
  .option('--water <value>', 'Water (ml)')
17
19
  .option('--note <note>', 'Note')
18
- .option('--date <date>', 'Date (YYYY-MM-DD)')
20
+ .option('--date <date>', 'Date (YYYY-MM-DD or ISO 8601 datetime)')
19
21
  .option('--file <paths...>', 'File paths to attach')
20
22
  .action(async (options) => {
21
23
  try {
@@ -30,7 +32,7 @@ diet
30
32
  foods: options.foods,
31
33
  water: options.water,
32
34
  note: options.note,
33
- date: options.date
35
+ date: appendTimezoneOffset(options.date)
34
36
  }, options.file || [])
35
37
 
36
38
  const result = await request('/diets', {
@@ -51,19 +53,15 @@ diet
51
53
  .option('--last <period>', 'Last N days/weeks/months/years')
52
54
  .option('--start <date>', 'Start date (YYYY-MM-DD)')
53
55
  .option('--end <date>', 'End date (YYYY-MM-DD)')
56
+ .option('--page <number>', 'Page number', '1')
57
+ .option('--limit <number>', 'Items per page', '20')
54
58
  .option('--include-deleted', 'Include deleted records')
59
+ .option('--format <format>', 'Output format: json, table, toon', 'json')
55
60
  .action(async (options) => {
56
61
  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
-
62
+ const { params, page } = buildQueryParams(options)
65
63
  const result = await request(`/diets?${params.toString()}`)
66
- console.log(JSON.stringify(result, null, 2))
64
+ outputData(result, { format: options.format, type: 'diet-list', page })
67
65
  } catch (error) {
68
66
  console.error('Failed to list diet records:', error.message)
69
67
  }
@@ -74,17 +72,12 @@ diet
74
72
  .option('--last <period>', 'Last N days/weeks/months/years')
75
73
  .option('--start <date>', 'Start date (YYYY-MM-DD)')
76
74
  .option('--end <date>', 'End date (YYYY-MM-DD)')
75
+ .option('--format <format>', 'Output format: json, table, toon', 'json')
77
76
  .action(async (options) => {
78
77
  try {
79
- const params = new URLSearchParams()
80
- Object.entries(options).forEach(([key, value]) => {
81
- if (value) {
82
- params.append(key, value)
83
- }
84
- })
85
-
78
+ const { params } = buildQueryParams(options)
86
79
  const result = await request(`/diets/stats?${params.toString()}`)
87
- console.log(JSON.stringify(result, null, 2))
80
+ outputData(result, { format: options.format, type: 'diet-stats' })
88
81
  } catch (error) {
89
82
  console.error('Failed to get diet stats:', error.message)
90
83
  }
@@ -93,10 +86,11 @@ diet
93
86
  diet
94
87
  .command('get')
95
88
  .requiredOption('--id <id>', 'Diet record ID')
89
+ .option('--format <format>', 'Output format: json, table, toon', 'json')
96
90
  .action(async (options) => {
97
91
  try {
98
92
  const result = await request(`/diets/${options.id}`)
99
- console.log(JSON.stringify(result, null, 2))
93
+ outputData(result, { format: options.format, type: 'diet-get' })
100
94
  } catch (error) {
101
95
  console.error('Failed to get diet record:', error.message)
102
96
  }
@@ -115,7 +109,7 @@ diet
115
109
  .option('--foods <string>', 'Updated foods')
116
110
  .option('--water <value>', 'Updated water')
117
111
  .option('--note <note>', 'Updated note')
118
- .option('--date <date>', 'Updated date (YYYY-MM-DD)')
112
+ .option('--date <date>', 'Updated date (YYYY-MM-DD or ISO 8601 datetime)')
119
113
  .option('--file <paths...>', 'File paths to attach')
120
114
  .option('--replace-attachments', 'Replace existing attachments instead of adding')
121
115
  .action(async (options) => {
@@ -131,7 +125,7 @@ diet
131
125
  foods: options.foods,
132
126
  water: options.water,
133
127
  note: options.note,
134
- date: options.date,
128
+ date: appendTimezoneOffset(options.date),
135
129
  replaceAttachments: options.replaceAttachments ? 'true' : undefined
136
130
  }, options.file || [])
137
131
 
@@ -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
 
@@ -14,7 +16,7 @@ exercise
14
16
  .option('--feeling <value>', 'Feeling 1-10')
15
17
  .option('--location <location>', 'Location')
16
18
  .option('--note <note>', 'Note')
17
- .option('--date <date>', 'Date (YYYY-MM-DD)')
19
+ .option('--date <date>', 'Date (YYYY-MM-DD or ISO 8601 datetime)')
18
20
  .option('--file <paths...>', 'File paths to attach')
19
21
  .action(async (options) => {
20
22
  try {
@@ -28,7 +30,7 @@ exercise
28
30
  feeling: options.feeling,
29
31
  location: options.location,
30
32
  note: options.note,
31
- date: options.date
33
+ date: appendTimezoneOffset(options.date)
32
34
  }, options.file || [])
33
35
 
34
36
  const result = await request('/exercises', {
@@ -49,19 +51,15 @@ exercise
49
51
  .option('--last <period>', 'Last N days/weeks/months/years')
50
52
  .option('--start <date>', 'Start date (YYYY-MM-DD)')
51
53
  .option('--end <date>', 'End date (YYYY-MM-DD)')
54
+ .option('--page <number>', 'Page number', '1')
55
+ .option('--limit <number>', 'Items per page', '20')
52
56
  .option('--include-deleted', 'Include deleted records')
57
+ .option('--format <format>', 'Output format: json, table, toon', 'json')
53
58
  .action(async (options) => {
54
59
  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
-
60
+ const { params, page } = buildQueryParams(options)
63
61
  const result = await request(`/exercises?${params.toString()}`)
64
- console.log(JSON.stringify(result, null, 2))
62
+ outputData(result, { format: options.format, type: 'exercise-list', page })
65
63
  } catch (error) {
66
64
  console.error('Failed to list exercise records:', error.message)
67
65
  }
@@ -72,17 +70,12 @@ exercise
72
70
  .option('--last <period>', 'Last N days/weeks/months/years')
73
71
  .option('--start <date>', 'Start date (YYYY-MM-DD)')
74
72
  .option('--end <date>', 'End date (YYYY-MM-DD)')
73
+ .option('--format <format>', 'Output format: json, table, toon', 'json')
75
74
  .action(async (options) => {
76
75
  try {
77
- const params = new URLSearchParams()
78
- Object.entries(options).forEach(([key, value]) => {
79
- if (value) {
80
- params.append(key, value)
81
- }
82
- })
83
-
76
+ const { params } = buildQueryParams(options)
84
77
  const result = await request(`/exercises/stats?${params.toString()}`)
85
- console.log(JSON.stringify(result, null, 2))
78
+ outputData(result, { format: options.format, type: 'exercise-stats' })
86
79
  } catch (error) {
87
80
  console.error('Failed to get exercise stats:', error.message)
88
81
  }
@@ -91,10 +84,11 @@ exercise
91
84
  exercise
92
85
  .command('get')
93
86
  .requiredOption('--id <id>', 'Exercise record ID')
87
+ .option('--format <format>', 'Output format: json, table, toon', 'json')
94
88
  .action(async (options) => {
95
89
  try {
96
90
  const result = await request(`/exercises/${options.id}`)
97
- console.log(JSON.stringify(result, null, 2))
91
+ outputData(result, { format: options.format, type: 'exercise-get' })
98
92
  } catch (error) {
99
93
  console.error('Failed to get exercise record:', error.message)
100
94
  }
@@ -112,7 +106,7 @@ exercise
112
106
  .option('--feeling <value>', 'Updated feeling')
113
107
  .option('--location <location>', 'Updated location')
114
108
  .option('--note <note>', 'Updated note')
115
- .option('--date <date>', 'Updated date (YYYY-MM-DD)')
109
+ .option('--date <date>', 'Updated date (YYYY-MM-DD or ISO 8601 datetime)')
116
110
  .option('--file <paths...>', 'File paths to attach')
117
111
  .option('--replace-attachments', 'Replace existing attachments instead of adding')
118
112
  .action(async (options) => {
@@ -127,7 +121,7 @@ exercise
127
121
  feeling: options.feeling,
128
122
  location: options.location,
129
123
  note: options.note,
130
- date: options.date,
124
+ date: appendTimezoneOffset(options.date),
131
125
  replaceAttachments: options.replaceAttachments ? 'true' : undefined
132
126
  }, options.file || [])
133
127
 
@@ -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,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 sleep = new Command('sleep')
5
7
 
@@ -14,7 +16,7 @@ sleep
14
16
  .option('--awakenings <value>', 'Number of awakenings')
15
17
  .option('--feeling <value>', 'Feeling 1-10')
16
18
  .option('--note <note>', 'Note')
17
- .option('--date <date>', 'Date (YYYY-MM-DD)')
19
+ .option('--date <date>', 'Date (YYYY-MM-DD or ISO 8601 datetime)')
18
20
  .option('--file <paths...>', 'File paths to attach')
19
21
  .action(async (options) => {
20
22
  try {
@@ -28,7 +30,7 @@ sleep
28
30
  awakenings: options.awakenings,
29
31
  feeling: options.feeling,
30
32
  note: options.note,
31
- date: options.date
33
+ date: appendTimezoneOffset(options.date)
32
34
  }, options.file || [])
33
35
 
34
36
  const result = await request('/sleeps', {
@@ -48,19 +50,15 @@ sleep
48
50
  .option('--last <period>', 'Last N days/weeks/months/years')
49
51
  .option('--start <date>', 'Start date (YYYY-MM-DD)')
50
52
  .option('--end <date>', 'End date (YYYY-MM-DD)')
53
+ .option('--page <number>', 'Page number', '1')
54
+ .option('--limit <number>', 'Items per page', '20')
51
55
  .option('--include-deleted', 'Include deleted records')
56
+ .option('--format <format>', 'Output format: json, table, toon', 'json')
52
57
  .action(async (options) => {
53
58
  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
-
59
+ const { params, page } = buildQueryParams(options)
62
60
  const result = await request(`/sleeps?${params.toString()}`)
63
- console.log(JSON.stringify(result, null, 2))
61
+ outputData(result, { format: options.format, type: 'sleep-list', page })
64
62
  } catch (error) {
65
63
  console.error('Failed to list sleep records:', error.message)
66
64
  }
@@ -71,17 +69,12 @@ sleep
71
69
  .option('--last <period>', 'Last N days/weeks/months/years')
72
70
  .option('--start <date>', 'Start date (YYYY-MM-DD)')
73
71
  .option('--end <date>', 'End date (YYYY-MM-DD)')
72
+ .option('--format <format>', 'Output format: json, table, toon', 'json')
74
73
  .action(async (options) => {
75
74
  try {
76
- const params = new URLSearchParams()
77
- Object.entries(options).forEach(([key, value]) => {
78
- if (value) {
79
- params.append(key, value)
80
- }
81
- })
82
-
75
+ const { params } = buildQueryParams(options)
83
76
  const result = await request(`/sleeps/stats?${params.toString()}`)
84
- console.log(JSON.stringify(result, null, 2))
77
+ outputData(result, { format: options.format, type: 'sleep-stats' })
85
78
  } catch (error) {
86
79
  console.error('Failed to get sleep stats:', error.message)
87
80
  }
@@ -90,10 +83,11 @@ sleep
90
83
  sleep
91
84
  .command('get')
92
85
  .requiredOption('--id <id>', 'Sleep record ID')
86
+ .option('--format <format>', 'Output format: json, table, toon', 'json')
93
87
  .action(async (options) => {
94
88
  try {
95
89
  const result = await request(`/sleeps/${options.id}`)
96
- console.log(JSON.stringify(result, null, 2))
90
+ outputData(result, { format: options.format, type: 'sleep-get' })
97
91
  } catch (error) {
98
92
  console.error('Failed to get sleep record:', error.message)
99
93
  }
@@ -111,7 +105,7 @@ sleep
111
105
  .option('--awakenings <value>', 'Updated number of awakenings')
112
106
  .option('--feeling <value>', 'Updated feeling')
113
107
  .option('--note <note>', 'Updated note')
114
- .option('--date <date>', 'Updated date (YYYY-MM-DD)')
108
+ .option('--date <date>', 'Updated date (YYYY-MM-DD or ISO 8601 datetime)')
115
109
  .option('--file <paths...>', 'File paths to attach')
116
110
  .option('--replace-attachments', 'Replace existing attachments instead of adding')
117
111
  .action(async (options) => {
@@ -126,7 +120,7 @@ sleep
126
120
  awakenings: options.awakenings,
127
121
  feeling: options.feeling,
128
122
  note: options.note,
129
- date: options.date,
123
+ date: appendTimezoneOffset(options.date),
130
124
  replaceAttachments: options.replaceAttachments ? 'true' : undefined
131
125
  }, options.file || [])
132
126
 
@@ -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
 
@@ -13,7 +15,7 @@ weight
13
15
  .option('--bone-mass <value>', 'Bone mass (kg)')
14
16
  .option('--visceral-fat <value>', 'Visceral fat level')
15
17
  .option('--note <note>', 'Note for the record')
16
- .option('--date <date>', 'Date for the record (YYYY-MM-DD)')
18
+ .option('--date <date>', 'Date (YYYY-MM-DD or ISO 8601 datetime)')
17
19
  .option('--file <paths...>', 'File paths to attach')
18
20
  .action(async (options) => {
19
21
  try {
@@ -26,7 +28,7 @@ weight
26
28
  boneMass: options.boneMass,
27
29
  visceralFat: options.visceralFat,
28
30
  note: options.note,
29
- date: options.date
31
+ date: appendTimezoneOffset(options.date)
30
32
  }, options.file || [])
31
33
 
32
34
  const result = await request('/weights', {
@@ -46,19 +48,15 @@ weight
46
48
  .option('--last <period>', 'Last N days/weeks/months/years (e.g., 10, 7d, 2w, 6m, 1y)')
47
49
  .option('--start <date>', 'Start date (YYYY-MM-DD)')
48
50
  .option('--end <date>', 'End date (YYYY-MM-DD)')
51
+ .option('--page <number>', 'Page number', '1')
52
+ .option('--limit <number>', 'Items per page', '20')
49
53
  .option('--include-deleted', 'Include deleted records')
54
+ .option('--format <format>', 'Output format: json, table, toon', 'json')
50
55
  .action(async (options) => {
51
56
  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
-
57
+ const { params, page } = buildQueryParams(options)
60
58
  const result = await request(`/weights?${params.toString()}`)
61
- console.log(JSON.stringify(result, null, 2))
59
+ outputData(result, { format: options.format, type: 'weight-list', page })
62
60
  } catch (error) {
63
61
  console.error('Failed to list weight records:', error.message)
64
62
  }
@@ -69,17 +67,12 @@ weight
69
67
  .option('--last <period>', 'Last N days/weeks/months/years (e.g., 10, 7d, 2w, 6m, 1y)')
70
68
  .option('--start <date>', 'Start date (YYYY-MM-DD)')
71
69
  .option('--end <date>', 'End date (YYYY-MM-DD)')
70
+ .option('--format <format>', 'Output format: json, table, toon', 'json')
72
71
  .action(async (options) => {
73
72
  try {
74
- const params = new URLSearchParams()
75
- Object.entries(options).forEach(([key, value]) => {
76
- if (value) {
77
- params.append(key, value)
78
- }
79
- })
80
-
73
+ const { params } = buildQueryParams(options)
81
74
  const result = await request(`/weights/stats?${params.toString()}`)
82
- console.log(JSON.stringify(result, null, 2))
75
+ outputData(result, { format: options.format, type: 'weight-stats' })
83
76
  } catch (error) {
84
77
  console.error('Failed to get weight stats:', error.message)
85
78
  }
@@ -88,10 +81,11 @@ weight
88
81
  weight
89
82
  .command('get')
90
83
  .requiredOption('--id <id>', 'Weight record ID')
84
+ .option('--format <format>', 'Output format: json, table, toon', 'json')
91
85
  .action(async (options) => {
92
86
  try {
93
87
  const result = await request(`/weights/${options.id}`)
94
- console.log(JSON.stringify(result, null, 2))
88
+ outputData(result, { format: options.format, type: 'weight-get' })
95
89
  } catch (error) {
96
90
  console.error('Failed to get weight record:', error.message)
97
91
  }
@@ -108,7 +102,7 @@ weight
108
102
  .option('--bone-mass <value>', 'Updated bone mass (kg)')
109
103
  .option('--visceral-fat <value>', 'Updated visceral fat level')
110
104
  .option('--note <note>', 'Updated note')
111
- .option('--date <date>', 'Updated date (YYYY-MM-DD)')
105
+ .option('--date <date>', 'Updated date (YYYY-MM-DD or ISO 8601 datetime)')
112
106
  .option('--file <paths...>', 'File paths to attach')
113
107
  .option('--replace-attachments', 'Replace existing attachments instead of adding')
114
108
  .action(async (options) => {
@@ -122,7 +116,7 @@ weight
122
116
  boneMass: options.boneMass,
123
117
  visceralFat: options.visceralFat,
124
118
  note: options.note,
125
- date: options.date,
119
+ date: appendTimezoneOffset(options.date),
126
120
  replaceAttachments: options.replaceAttachments ? 'true' : undefined
127
121
  }, options.file || [])
128
122
 
@@ -0,0 +1,244 @@
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
+ 'timeline': 'items'
43
+ }
44
+ const key = keyMap[type]
45
+ if (key && Array.isArray(data[key])) return data[key]
46
+ if (Array.isArray(data)) return data
47
+ return []
48
+ }
49
+
50
+ function buildRows(items, type) {
51
+ const df = getConfigDateFormat()
52
+ switch (type) {
53
+ case 'weight-list':
54
+ return items.map((item, idx) => [
55
+ idx + 1,
56
+ item.id,
57
+ item.weight,
58
+ item.bodyFat ?? '-',
59
+ item.bmi ?? '-',
60
+ item.date ? formatDate(item.date, df) : '-',
61
+ item.note || '-'
62
+ ])
63
+ case 'exercise-list':
64
+ return items.map((item, idx) => [
65
+ idx + 1,
66
+ item.id,
67
+ item.type,
68
+ item.duration,
69
+ item.caloriesBurned ?? '-',
70
+ item.heartRateAvg ?? '-',
71
+ item.date ? formatDate(item.date, df) : '-'
72
+ ])
73
+ case 'diet-list':
74
+ return items.map((item, idx) => [
75
+ idx + 1,
76
+ item.id,
77
+ item.mealType,
78
+ item.calories ?? '-',
79
+ item.protein ?? '-',
80
+ item.carbs ?? '-',
81
+ item.fat ?? '-',
82
+ item.date ? formatDate(item.date, df) : '-'
83
+ ])
84
+ case 'sleep-list':
85
+ return items.map((item, idx) => [
86
+ idx + 1,
87
+ item.id,
88
+ item.duration,
89
+ item.quality,
90
+ item.bedTime ?? '-',
91
+ item.wakeTime ?? '-',
92
+ item.deepSleep ?? '-',
93
+ item.date ? formatDate(item.date, df) : '-'
94
+ ])
95
+ case 'record-list':
96
+ return items.map((item, idx) => [
97
+ idx + 1,
98
+ item.id,
99
+ item.type,
100
+ (item.tags || []).join(', ') || '-',
101
+ item.date ? formatDate(item.date, df) : '-',
102
+ item.note || '-'
103
+ ])
104
+ case 'timeline':
105
+ return items.map((item, idx) => {
106
+ let summary = ''
107
+ if (item.type === 'weight') summary = `${item.data?.weight} kg`
108
+ else if (item.type === 'exercise') summary = `${item.data?.duration} min ${item.data?.type || ''}`
109
+ else if (item.type === 'diet') summary = `${item.data?.calories || 0} kcal (${item.data?.mealType || ''})`
110
+ else if (item.type === 'sleep') summary = `${item.data?.duration}h, 质量: ${item.data?.quality}/10`
111
+ else if (item.type === 'record') summary = item.data?.type || ''
112
+ return [
113
+ idx + 1,
114
+ item.date ? formatDate(item.date, df) : '-',
115
+ item.type,
116
+ summary
117
+ ]
118
+ })
119
+ default:
120
+ return []
121
+ }
122
+ }
123
+
124
+ function getHeaders(type) {
125
+ switch (type) {
126
+ case 'weight-list': return ['#', 'ID', '体重(kg)', '体脂(%)', 'BMI', '日期', '备注']
127
+ case 'exercise-list': return ['#', 'ID', '类型', '时长(分)', '热量', '心率', '日期']
128
+ case 'diet-list': return ['#', 'ID', '餐别', '热量', '蛋白质', '碳水', '脂肪', '日期']
129
+ case 'sleep-list': return ['#', 'ID', '时长(h)', '质量', '入睡', '醒来', '深睡(h)', '日期']
130
+ case 'record-list': return ['#', 'ID', '类型', '标签', '日期', '备注']
131
+ case 'timeline': return ['#', '时间', '类型', '摘要']
132
+ default: return []
133
+ }
134
+ }
135
+
136
+ function outputStats(data, type, format) {
137
+ if (format === 'json') {
138
+ outputJson(data)
139
+ return
140
+ }
141
+ if (format === 'toon') {
142
+ outputToon(data)
143
+ return
144
+ }
145
+
146
+ const rows = []
147
+ switch (type) {
148
+ case 'weight-stats':
149
+ if (data.avgWeight !== undefined) rows.push(['平均体重', `${data.avgWeight} kg`])
150
+ if (data.minWeight !== undefined) rows.push(['最低体重', `${data.minWeight} kg`])
151
+ if (data.maxWeight !== undefined) rows.push(['最高体重', `${data.maxWeight} kg`])
152
+ if (data.change !== undefined && data.change !== null) rows.push(['变化量', `${data.change > 0 ? '+' : ''}${data.change} kg`])
153
+ break
154
+ case 'exercise-stats':
155
+ if (data.count !== undefined) rows.push(['总次数', data.count])
156
+ if (data.totalDuration !== undefined) rows.push(['总时长', `${data.totalDuration} min`])
157
+ if (data.totalCalories !== undefined) rows.push(['总热量', `${data.totalCalories} kcal`])
158
+ if (data.frequencyByType) {
159
+ for (const [t, c] of Object.entries(data.frequencyByType)) {
160
+ rows.push([`频率 (${t})`, c])
161
+ }
162
+ }
163
+ break
164
+ case 'diet-stats':
165
+ if (data.avgCaloriesPerDay !== undefined) rows.push(['日均热量', `${data.avgCaloriesPerDay?.toFixed?.(0) || data.avgCaloriesPerDay} kcal`])
166
+ if (data.avgProtein !== undefined) rows.push(['平均蛋白质', `${data.avgProtein?.toFixed?.(1) || data.avgProtein}g`])
167
+ if (data.avgCarbs !== undefined) rows.push(['平均碳水', `${data.avgCarbs?.toFixed?.(1) || data.avgCarbs}g`])
168
+ if (data.avgFat !== undefined) rows.push(['平均脂肪', `${data.avgFat?.toFixed?.(1) || data.avgFat}g`])
169
+ if (data.totalWater !== undefined && data.totalWater !== null) rows.push(['总饮水量', `${data.totalWater}ml`])
170
+ if (data.count !== undefined) rows.push(['记录数', data.count])
171
+ break
172
+ case 'sleep-stats':
173
+ if (data.avgDuration !== undefined) rows.push(['平均时长', `${data.avgDuration?.toFixed?.(1) || data.avgDuration}h`])
174
+ if (data.avgQuality !== undefined) rows.push(['平均质量', `${data.avgQuality?.toFixed?.(1) || data.avgQuality}/10`])
175
+ if (data.avgDeepSleep !== undefined) rows.push(['平均深睡', `${data.avgDeepSleep?.toFixed?.(1) || data.avgDeepSleep}h`])
176
+ if (data.count !== undefined) rows.push(['记录数', data.count])
177
+ break
178
+ default:
179
+ rows.push(...Object.entries(data).map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : v]))
180
+ }
181
+ outputTable(['指标', '数值'], rows)
182
+ }
183
+
184
+ function outputGet(data, type, format) {
185
+ if (format === 'json') {
186
+ outputJson(data)
187
+ return
188
+ }
189
+ if (format === 'toon') {
190
+ outputToon(data)
191
+ return
192
+ }
193
+
194
+ const df = getConfigDateFormat()
195
+ const rows = Object.entries(data).map(([k, v]) => {
196
+ let val = v
197
+ if (k === 'date' && v) val = formatDate(v, df)
198
+ else if (typeof v === 'object' && v !== null) val = JSON.stringify(v)
199
+ return [k, val]
200
+ })
201
+ outputTable(['字段', '值'], rows)
202
+ }
203
+
204
+ export function outputData(data, options = {}) {
205
+ const { format = 'json', type, page = 1 } = options
206
+
207
+ if (type && type.endsWith('-stats')) {
208
+ outputStats(data, type, format)
209
+ return
210
+ }
211
+
212
+ if (type && type.endsWith('-get')) {
213
+ outputGet(data, type, format)
214
+ return
215
+ }
216
+
217
+ if (format === 'toon') {
218
+ outputToon(data)
219
+ return
220
+ }
221
+
222
+ const items = extractItems(data, type)
223
+ const headers = getHeaders(type)
224
+ const rows = buildRows(items, type)
225
+
226
+ if (format === 'json') {
227
+ outputJson(data)
228
+ return
229
+ }
230
+
231
+ if (format === 'table') {
232
+ if (rows.length > 0) {
233
+ outputTable(headers, rows)
234
+ } else {
235
+ console.log('(无数据)')
236
+ }
237
+ const hint = formatPaginationHint(page, data.totalPages, data.total, 'table')
238
+ if (hint) console.log('\n' + hint)
239
+ return
240
+ }
241
+
242
+ // fallback
243
+ outputJson(data)
244
+ }
@@ -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
+ }