git-fit 0.3.2 → 0.4.6
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.
- checksums.yaml +4 -4
- data/lib/git-fit.rb +5 -0
- data/lib/git_fit/cli/export.rb +25 -0
- data/lib/git_fit/cli/import_cli.rb +169 -0
- data/lib/git_fit/cli.rb +6 -0
- data/lib/git_fit/export/defaults.rb +29 -0
- data/lib/git_fit/export/json.rb +112 -0
- data/lib/git_fit/export.rb +7 -0
- data/lib/git_fit/import/apple_health.rb +582 -0
- data/lib/git_fit/import/local_file.rb +250 -0
- data/lib/git_fit/import.rb +2 -0
- data/lib/git_fit/source/base.rb +155 -0
- data/lib/git_fit/source/tally.rb +55 -0
- data/lib/git_fit/source.rb +7 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +40 -1
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "json"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
require "time"
|
|
6
|
+
require "tzinfo"
|
|
7
|
+
require "zip"
|
|
8
|
+
|
|
9
|
+
module GitFit
|
|
10
|
+
module Import
|
|
11
|
+
class AppleHealth < Source::Base
|
|
12
|
+
SKIPPED_SOURCE_PATTERNS = [
|
|
13
|
+
/\bStrava\b/i, /\bKeep\b/i, /\biGPSPORT\b/i,
|
|
14
|
+
/\bXOSS\b/i, /\bXingZhe\b/i, /\bConnect\b/i
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
APPLE_WATCH_PATTERN = /Apple Watch/
|
|
18
|
+
|
|
19
|
+
DEFAULT_ZIP_PATH = "data/import/apple_health/export.zip"
|
|
20
|
+
|
|
21
|
+
HeartRateRecord = Struct.new(:time, :value, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
MIN_GPX_SPEED = 0.1
|
|
24
|
+
|
|
25
|
+
def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
|
|
26
|
+
super
|
|
27
|
+
@zip_path = @config["zip_path"] || DEFAULT_ZIP_PATH
|
|
28
|
+
@heart_rate_index = nil
|
|
29
|
+
@heart_rate_by_date = nil
|
|
30
|
+
@skip_patterns = load_skip_sources
|
|
31
|
+
@timezone_resolver = GitFit::Timezone::Resolver.new(config)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def before_call
|
|
35
|
+
return false unless File.exist?(@zip_path)
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def authenticate
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call
|
|
44
|
+
return 0 unless before_call
|
|
45
|
+
return 0 unless authenticate
|
|
46
|
+
ids = pending_ids
|
|
47
|
+
return 0 if ids.nil? || ids.empty?
|
|
48
|
+
|
|
49
|
+
total = ids.size
|
|
50
|
+
processed = 0
|
|
51
|
+
ids.each do |p|
|
|
52
|
+
break if time_expired? && processed.positive?
|
|
53
|
+
r = process_one(p)
|
|
54
|
+
processed += 1 if r
|
|
55
|
+
report_progress(processed, total, build_progress_info(r)) if r
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
processed
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def pending_ids
|
|
62
|
+
existing = existing_run_ids_set
|
|
63
|
+
results = []
|
|
64
|
+
hr_records = []
|
|
65
|
+
@route_index = []
|
|
66
|
+
zip = Zip::File.new(@zip_path)
|
|
67
|
+
xml_entry = zip.find_entry("apple_health_export/export.xml")
|
|
68
|
+
return results unless xml_entry
|
|
69
|
+
|
|
70
|
+
io = xml_entry.get_input_stream
|
|
71
|
+
buffer = ""
|
|
72
|
+
in_workout = false
|
|
73
|
+
route_buf = nil
|
|
74
|
+
|
|
75
|
+
io.each_line do |line|
|
|
76
|
+
if line.include?("HKQuantityTypeIdentifierHeartRate") && APPLE_WATCH_PATTERN.match?(line)
|
|
77
|
+
hr = parse_hr_line(line)
|
|
78
|
+
hr_records << hr if hr
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if line.include?("<WorkoutRoute ")
|
|
82
|
+
route_buf = line
|
|
83
|
+
elsif route_buf
|
|
84
|
+
route_buf << line
|
|
85
|
+
if line.include?("</WorkoutRoute>")
|
|
86
|
+
process_route_entry(route_buf)
|
|
87
|
+
route_buf = nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if !in_workout
|
|
92
|
+
if line.include?("<Workout ")
|
|
93
|
+
in_workout = true
|
|
94
|
+
buffer = line
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
buffer << line
|
|
98
|
+
if line.include?("</Workout>")
|
|
99
|
+
process_workout_chunk(buffer, existing, results)
|
|
100
|
+
buffer = ""
|
|
101
|
+
in_workout = false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
@heart_rate_index = hr_records.sort_by(&:time)
|
|
107
|
+
|
|
108
|
+
@heart_rate_by_date = {}
|
|
109
|
+
@heart_rate_index.each do |hr|
|
|
110
|
+
date_key = hr.time.strftime("%Y-%m-%d")
|
|
111
|
+
@heart_rate_by_date[date_key] ||= []
|
|
112
|
+
@heart_rate_by_date[date_key] << hr
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
results
|
|
116
|
+
ensure
|
|
117
|
+
zip&.close
|
|
118
|
+
io&.close rescue nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def process_one(pending)
|
|
122
|
+
raw_xml = pending[:raw_xml]
|
|
123
|
+
run_id = pending[:run_id]
|
|
124
|
+
source_name = pending[:source_name]
|
|
125
|
+
stats = pending[:stats]
|
|
126
|
+
meta = pending[:meta]
|
|
127
|
+
route_paths = pending[:route_paths]
|
|
128
|
+
start_date = pending[:start_date]
|
|
129
|
+
end_date = pending[:end_date]
|
|
130
|
+
duration = pending[:duration]
|
|
131
|
+
duration_unit = pending[:duration_unit]
|
|
132
|
+
workout_type = pending[:workout_type]
|
|
133
|
+
|
|
134
|
+
FileUtils.mkdir_p(raw_dir)
|
|
135
|
+
tmp = File.join(raw_dir, ".#{run_id}.xml.tmp")
|
|
136
|
+
final = File.join(raw_dir, "#{run_id}.xml")
|
|
137
|
+
File.write(tmp, raw_xml)
|
|
138
|
+
File.rename(tmp, final)
|
|
139
|
+
|
|
140
|
+
hr_avg = nil
|
|
141
|
+
hr_max = nil
|
|
142
|
+
if @heart_rate_index&.any?
|
|
143
|
+
hr_avg, hr_max = correlate_heart_rate(start_date, end_date)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
l2_points = nil
|
|
147
|
+
polyline = nil
|
|
148
|
+
elevation_gain = nil
|
|
149
|
+
|
|
150
|
+
if route_paths&.any?
|
|
151
|
+
gpx_l1_path = File.join(raw_dir, "#{run_id}.gpx")
|
|
152
|
+
unless File.exist?(gpx_l1_path)
|
|
153
|
+
begin
|
|
154
|
+
zip = Zip::File.new(@zip_path)
|
|
155
|
+
route_paths.each do |gpx_path|
|
|
156
|
+
normalized = gpx_path.sub(%r{^/?}, "apple_health_export/")
|
|
157
|
+
entry = zip.find_entry(normalized)
|
|
158
|
+
next unless entry
|
|
159
|
+
data = entry.get_input_stream.read
|
|
160
|
+
tmpp = File.join(raw_dir, ".#{run_id}.gpx.tmp")
|
|
161
|
+
File.write(tmpp, data)
|
|
162
|
+
File.rename(tmpp, gpx_l1_path)
|
|
163
|
+
break
|
|
164
|
+
end
|
|
165
|
+
rescue => e
|
|
166
|
+
$stderr.puts "AppleHealth: GPX extract error for #{run_id}: #{e.message}"
|
|
167
|
+
ensure
|
|
168
|
+
zip&.close
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
l2_points = File.exist?(gpx_l1_path) ? parse_gpx_file(gpx_l1_path) : nil
|
|
173
|
+
|
|
174
|
+
if l2_points&.any?
|
|
175
|
+
FileUtils.mkdir_p(std_dir)
|
|
176
|
+
std_data = l2_points.map { |pt|
|
|
177
|
+
h = { latitude: pt[:lat], longitude: pt[:lng] }
|
|
178
|
+
h[:altitude] = pt[:ele] if pt[:ele]
|
|
179
|
+
h[:timestamp] = pt[:time] if pt[:time]
|
|
180
|
+
h[:speed] = pt[:speed] if pt[:speed]
|
|
181
|
+
h
|
|
182
|
+
}
|
|
183
|
+
tmp2 = File.join(std_dir, ".#{run_id}.json.tmp")
|
|
184
|
+
final2 = File.join(std_dir, "#{run_id}.json")
|
|
185
|
+
File.write(tmp2, JSON.generate(std_data))
|
|
186
|
+
File.rename(tmp2, final2)
|
|
187
|
+
|
|
188
|
+
coords = l2_points.map { |pt| [pt[:lat], pt[:lng]] }
|
|
189
|
+
polyline = GitFit::Geo::Polyline.encode(coords)
|
|
190
|
+
elevation_gain = elevation_gain_from_points(l2_points)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
title = build_title(workout_type, start_date)
|
|
195
|
+
attrs = build_attrs(source_name, run_id, stats, meta, polyline, elevation_gain,
|
|
196
|
+
workout_type, start_date, end_date, duration, duration_unit,
|
|
197
|
+
hr_avg, hr_max)
|
|
198
|
+
attrs[:title] = title
|
|
199
|
+
|
|
200
|
+
if attrs[:start_date_local].nil? && l2_points&.any?
|
|
201
|
+
first_pt = l2_points.first
|
|
202
|
+
tz = @timezone_resolver.resolve(first_pt[:lat], first_pt[:lng])
|
|
203
|
+
if tz && attrs[:start_date]
|
|
204
|
+
attrs[:start_date_local] = apple_utc_to_local(attrs[:start_date], tz)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
upsert_activity(attrs)
|
|
209
|
+
attrs
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def source_label
|
|
213
|
+
"AppleHealth"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def raw_dir
|
|
217
|
+
File.join("data", "raw", "apple_health")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def std_dir
|
|
221
|
+
File.join("data", "std", "apple_health")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def raw_path(platform_id)
|
|
225
|
+
File.join(raw_dir, "#{platform_id}.xml")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def known_source?(source_name)
|
|
231
|
+
return false unless source_name
|
|
232
|
+
@skip_patterns.any? { |pattern| pattern.match?(source_name) }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def load_skip_sources
|
|
236
|
+
configured = @config["skip_sources"]
|
|
237
|
+
return [] unless configured
|
|
238
|
+
|
|
239
|
+
list = configured.is_a?(String) ? configured.split(",").map(&:strip) : configured
|
|
240
|
+
validated = list.select do |name|
|
|
241
|
+
SKIPPED_SOURCE_PATTERNS.any? { |p| p.match?(name) }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
SKIPPED_SOURCE_PATTERNS.select { |p|
|
|
245
|
+
validated.any? { |v| p.match?(v) }
|
|
246
|
+
}
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def existing_run_ids_set
|
|
250
|
+
@db[:activities].where(source: "apple_health").select_map(:run_id).map(&:to_s).to_set
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def parse_hr_line(line)
|
|
254
|
+
m = line.match(/startDate="([^"]+)".*?value="(\d+)"/)
|
|
255
|
+
return nil unless m
|
|
256
|
+
time = Time.parse(m[1]) rescue nil
|
|
257
|
+
return nil unless time
|
|
258
|
+
HeartRateRecord.new(time: time, value: m[2].to_i)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def correlate_heart_rate(start_date_str, end_date_str)
|
|
262
|
+
start_t = Time.parse(start_date_str) rescue nil
|
|
263
|
+
end_t = Time.parse(end_date_str) rescue nil
|
|
264
|
+
return [nil, nil] unless start_t && end_t
|
|
265
|
+
|
|
266
|
+
padding = 30
|
|
267
|
+
records = collect_hr_in_window(start_t - padding, end_t + padding)
|
|
268
|
+
return [nil, nil] if records.empty?
|
|
269
|
+
|
|
270
|
+
vals = records.map(&:value)
|
|
271
|
+
avg = (vals.sum.to_f / vals.size).round(1)
|
|
272
|
+
max = vals.max
|
|
273
|
+
[avg, max]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def collect_hr_in_window(start_t, end_t)
|
|
277
|
+
if @heart_rate_by_date
|
|
278
|
+
results = []
|
|
279
|
+
(start_t.to_date..end_t.to_date).each do |date|
|
|
280
|
+
date_key = date.strftime("%Y-%m-%d")
|
|
281
|
+
bucket = @heart_rate_by_date[date_key]
|
|
282
|
+
if bucket&.any?
|
|
283
|
+
lo = bucket.bsearch_index { |hr| hr.time >= start_t }
|
|
284
|
+
hi = bucket.bsearch_index { |hr| hr.time > end_t }
|
|
285
|
+
if lo
|
|
286
|
+
slice = hi ? bucket[lo...hi] : bucket[lo..]
|
|
287
|
+
results.concat(slice) if slice&.any?
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
results
|
|
292
|
+
else
|
|
293
|
+
return [] unless @heart_rate_index&.any?
|
|
294
|
+
lo = @heart_rate_index.bsearch_index { |hr| hr.time >= start_t }
|
|
295
|
+
hi = @heart_rate_index.bsearch_index { |hr| hr.time > end_t }
|
|
296
|
+
return [] unless lo
|
|
297
|
+
slice = hi ? @heart_rate_index[lo...hi] : @heart_rate_index[lo..]
|
|
298
|
+
slice || []
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def process_workout_chunk(raw_xml, existing_ids, results)
|
|
303
|
+
doc = Nokogiri::XML("<root>#{raw_xml}</root>")
|
|
304
|
+
workout = doc.at_xpath("//Workout")
|
|
305
|
+
return unless workout
|
|
306
|
+
|
|
307
|
+
source_name = workout["sourceName"]
|
|
308
|
+
return if known_source?(source_name)
|
|
309
|
+
|
|
310
|
+
workout_type = workout["workoutActivityType"]
|
|
311
|
+
return unless workout_type
|
|
312
|
+
|
|
313
|
+
start_date = workout["startDate"]
|
|
314
|
+
end_date = workout["endDate"]
|
|
315
|
+
return if start_date.nil? || end_date.nil?
|
|
316
|
+
|
|
317
|
+
duration = workout["duration"]
|
|
318
|
+
duration_unit = workout["durationUnit"] || "min"
|
|
319
|
+
return if duration.nil?
|
|
320
|
+
|
|
321
|
+
creation_date = workout["creationDate"] || start_date
|
|
322
|
+
run_id = generate_run_id(source_name, creation_date, start_date, end_date)
|
|
323
|
+
return if existing_ids.include?("apple_health_#{run_id}")
|
|
324
|
+
|
|
325
|
+
category = map_sport_type(workout_type)
|
|
326
|
+
return if skip_type?(category)
|
|
327
|
+
|
|
328
|
+
stats = extract_statistics(workout)
|
|
329
|
+
meta = extract_metadata(workout)
|
|
330
|
+
route_paths = extract_route_paths(workout)
|
|
331
|
+
route_paths = match_routes_by_time(start_date, end_date) if route_paths.empty?
|
|
332
|
+
source_version = workout["sourceVersion"]
|
|
333
|
+
|
|
334
|
+
results << {
|
|
335
|
+
id: run_id,
|
|
336
|
+
type: category,
|
|
337
|
+
raw_xml: raw_xml,
|
|
338
|
+
run_id: run_id,
|
|
339
|
+
source_name: source_name,
|
|
340
|
+
source_version: source_version,
|
|
341
|
+
workout_type: workout_type,
|
|
342
|
+
duration: duration,
|
|
343
|
+
duration_unit: duration_unit,
|
|
344
|
+
start_date: start_date,
|
|
345
|
+
end_date: end_date,
|
|
346
|
+
stats: stats,
|
|
347
|
+
meta: meta,
|
|
348
|
+
route_paths: route_paths,
|
|
349
|
+
creation_date: creation_date
|
|
350
|
+
}
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def process_route_entry(xml)
|
|
354
|
+
doc = Nokogiri::XML("<root>#{xml}</root>")
|
|
355
|
+
route = doc.at_xpath("//WorkoutRoute")
|
|
356
|
+
return unless route
|
|
357
|
+
|
|
358
|
+
start_date = route["startDate"]
|
|
359
|
+
end_date = route["endDate"]
|
|
360
|
+
return unless start_date && end_date
|
|
361
|
+
|
|
362
|
+
paths = route.xpath("FileReference").filter_map { |ref| ref["path"] }
|
|
363
|
+
return if paths.empty?
|
|
364
|
+
|
|
365
|
+
@route_index << {
|
|
366
|
+
route_start: (Time.parse(start_date) rescue nil),
|
|
367
|
+
route_end: (Time.parse(end_date) rescue nil),
|
|
368
|
+
route_paths: paths
|
|
369
|
+
}
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def match_routes_by_time(workout_start, workout_end)
|
|
373
|
+
return [] unless @route_index&.any? && workout_start && workout_end
|
|
374
|
+
ws = Time.parse(workout_start) rescue nil
|
|
375
|
+
we = Time.parse(workout_end) rescue nil
|
|
376
|
+
return [] unless ws && we
|
|
377
|
+
|
|
378
|
+
window_start = ws - 300
|
|
379
|
+
window_end = we + 300
|
|
380
|
+
|
|
381
|
+
@route_index.select { |r|
|
|
382
|
+
r[:route_start] && r[:route_end] &&
|
|
383
|
+
r[:route_start] < window_end && r[:route_end] > window_start
|
|
384
|
+
}.flat_map { |r| r[:route_paths] }.uniq
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def extract_statistics(workout)
|
|
388
|
+
stats = {}
|
|
389
|
+
workout.xpath("WorkoutStatistics").each do |stat|
|
|
390
|
+
type = stat["type"]
|
|
391
|
+
sum = stat["sum"]
|
|
392
|
+
unit = stat["unit"]
|
|
393
|
+
next unless type && sum
|
|
394
|
+
stats[type] = { "sum" => sum, "unit" => unit }
|
|
395
|
+
end
|
|
396
|
+
stats
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def extract_metadata(workout)
|
|
400
|
+
meta = {}
|
|
401
|
+
workout.xpath("MetadataEntry").each do |entry|
|
|
402
|
+
key = entry["key"]
|
|
403
|
+
value = entry["value"]
|
|
404
|
+
meta[key] = value if key && value
|
|
405
|
+
end
|
|
406
|
+
meta
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def extract_route_paths(workout)
|
|
410
|
+
workout.xpath("WorkoutRoute/FileReference").filter_map do |ref|
|
|
411
|
+
ref["path"]
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def parse_gpx_file(file_path)
|
|
416
|
+
doc = Nokogiri::XML(File.read(file_path))
|
|
417
|
+
doc.remove_namespaces!
|
|
418
|
+
doc.xpath("//trkpt").filter_map do |pt|
|
|
419
|
+
lat = pt["lat"]&.to_f
|
|
420
|
+
lng = pt["lon"]&.to_f
|
|
421
|
+
next unless lat && lng
|
|
422
|
+
ele = pt.at_xpath("ele")&.text&.to_f
|
|
423
|
+
time_str = pt.at_xpath("time")&.text
|
|
424
|
+
ext = pt.at_xpath("extensions")
|
|
425
|
+
speed = ext&.at_xpath("speed")&.text&.to_f
|
|
426
|
+
speed = nil if speed && speed < MIN_GPX_SPEED
|
|
427
|
+
{ lat: lat, lng: lng, ele: ele, time: time_str, speed: speed }
|
|
428
|
+
end
|
|
429
|
+
rescue => e
|
|
430
|
+
$stderr.puts "AppleHealth: GPX parse error for #{file_path}: #{e.message}"
|
|
431
|
+
nil
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def build_attrs(_source_name, run_id, stats, meta, polyline, elevation_gain,
|
|
435
|
+
workout_type, start_date, end_date, duration, duration_unit,
|
|
436
|
+
hr_avg = nil, hr_max = nil)
|
|
437
|
+
distance = nil
|
|
438
|
+
calories = nil
|
|
439
|
+
|
|
440
|
+
cals_sum = 0.0
|
|
441
|
+
stats.each do |type_str, data|
|
|
442
|
+
if type_str.include?("Distance")
|
|
443
|
+
distance = parse_distance(data["sum"], data["unit"])
|
|
444
|
+
elsif type_str.include?("ActiveEnergyBurned") || type_str.include?("BasalEnergyBurned")
|
|
445
|
+
cals_sum += data["sum"].to_f
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
calories = cals_sum > 0 ? cals_sum.round : nil
|
|
449
|
+
|
|
450
|
+
moving_time = parse_duration(duration, duration_unit)
|
|
451
|
+
elapsed_time = compute_elapsed(start_date, end_date) || moving_time
|
|
452
|
+
avg_speed = if distance && moving_time&.positive?
|
|
453
|
+
(distance.to_f / moving_time).round(2)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
{
|
|
457
|
+
run_id: "apple_health_#{run_id}",
|
|
458
|
+
name: display_name(workout_type, start_date),
|
|
459
|
+
distance: distance,
|
|
460
|
+
moving_time: moving_time,
|
|
461
|
+
elapsed_time: elapsed_time,
|
|
462
|
+
sport_category: map_sport_type(workout_type),
|
|
463
|
+
sport_type: workout_type,
|
|
464
|
+
start_date: format_datetime(start_date),
|
|
465
|
+
start_date_local: meta["HKTimeZone"] ? format_datetime(start_date, utc: false) : nil,
|
|
466
|
+
summary_polyline: polyline,
|
|
467
|
+
average_heartrate: hr_avg,
|
|
468
|
+
max_heartrate: hr_max,
|
|
469
|
+
average_speed: avg_speed,
|
|
470
|
+
calories: calories,
|
|
471
|
+
elevation_gain: elevation_gain&.round(1),
|
|
472
|
+
average_temperature: parse_temperature(meta["HKWeatherTemperature"]),
|
|
473
|
+
source: "apple_health",
|
|
474
|
+
external_id: meta["HKExternalUUID"]
|
|
475
|
+
}
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def format_datetime(str, utc: true)
|
|
479
|
+
return nil unless str
|
|
480
|
+
t = Time.parse(str) rescue nil
|
|
481
|
+
return str unless t
|
|
482
|
+
utc ? t.utc.iso8601 : t.iso8601
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def compute_elapsed(start_str, end_str)
|
|
486
|
+
return nil unless start_str && end_str
|
|
487
|
+
s = Time.parse(start_str).utc rescue nil
|
|
488
|
+
e = Time.parse(end_str).utc rescue nil
|
|
489
|
+
return nil unless s && e
|
|
490
|
+
(e - s).round
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def parse_duration(value, unit)
|
|
494
|
+
v = value.to_f
|
|
495
|
+
case unit.to_s.downcase
|
|
496
|
+
when "min" then (v * 60).round
|
|
497
|
+
when "sec" then v.round
|
|
498
|
+
when "hour" then (v * 3600).round
|
|
499
|
+
else (v * 60).round
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def parse_distance(value, unit)
|
|
504
|
+
v = value.to_f
|
|
505
|
+
case unit.to_s.downcase
|
|
506
|
+
when "km" then (v * 1000).round(2)
|
|
507
|
+
when "mi" then (v * 1609.344).round(2)
|
|
508
|
+
when "m" then v.round(2)
|
|
509
|
+
when "ft" then (v * 0.3048).round(2)
|
|
510
|
+
else v.round(2)
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def parse_temperature(value)
|
|
515
|
+
return nil unless value && !value.empty?
|
|
516
|
+
m = value.match(/([\d.]+)\s*(deg[FC]|°[FC]|℃)/i)
|
|
517
|
+
return nil unless m
|
|
518
|
+
num = m[1].to_f
|
|
519
|
+
unit = m[2].downcase
|
|
520
|
+
if unit.start_with?("degf") || unit.start_with?("°f")
|
|
521
|
+
((num - 32) * 5.0 / 9.0).round(1)
|
|
522
|
+
else
|
|
523
|
+
num.round(1)
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def generate_run_id(source_name, creation_date, start_date, end_date)
|
|
528
|
+
s = normalize_date(start_date)
|
|
529
|
+
e = normalize_date(end_date)
|
|
530
|
+
c = normalize_date(creation_date)
|
|
531
|
+
base = Digest::MD5.hexdigest("#{source_name}|#{s}|#{e}")[0, 7]
|
|
532
|
+
disc = Digest::MD5.hexdigest("#{source_name}|#{c}|#{s}|#{e}")[0, 4]
|
|
533
|
+
"#{base}#{disc}"
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def normalize_date(date_str)
|
|
537
|
+
t = Time.parse(date_str) rescue nil
|
|
538
|
+
return date_str unless t
|
|
539
|
+
t.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def map_sport_type(workout_type)
|
|
543
|
+
return "other" unless workout_type
|
|
544
|
+
key = workout_type.sub(/\AHKWorkoutActivityType/, "")
|
|
545
|
+
return "workout" if key.downcase.include?("strength")
|
|
546
|
+
GitFit::SportMapper.canonicalize(key)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def display_name(workout_type, _start_date)
|
|
550
|
+
workout_type.sub(/\AHKWorkoutActivityType/, "")
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def build_title(workout_type, start_date)
|
|
554
|
+
label = workout_type.sub(/\AHKWorkoutActivityType/, "")
|
|
555
|
+
date = start_date ? start_date[0..9] : nil
|
|
556
|
+
date ? "#{date} · #{label}" : label
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def elevation_gain_from_points(points)
|
|
560
|
+
total = 0.0
|
|
561
|
+
(1...points.length).each do |i|
|
|
562
|
+
elev1 = points[i - 1][:ele] || 0.0
|
|
563
|
+
elev2 = points[i][:ele] || 0.0
|
|
564
|
+
gain = elev2 - elev1
|
|
565
|
+
total += gain if gain > 0
|
|
566
|
+
end
|
|
567
|
+
total
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def apple_utc_to_local(utc_iso8601, tz_name)
|
|
571
|
+
return utc_iso8601 unless utc_iso8601 && tz_name
|
|
572
|
+
t = Time.parse(utc_iso8601)
|
|
573
|
+
return utc_iso8601 unless t.utc?
|
|
574
|
+
tz = TZInfo::Timezone.get(tz_name)
|
|
575
|
+
local = tz.utc_to_local(t)
|
|
576
|
+
local.iso8601
|
|
577
|
+
rescue TZInfo::InvalidTimezoneIdentifier, ArgumentError
|
|
578
|
+
utc_iso8601
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
end
|