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.
@@ -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