git-fit 0.4.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3c36d045a4ed37a0eaad4f219367f1d514af31c26bcae72e76cbeec7bb5dbb0
4
- data.tar.gz: ab845ce910206dae48a131315427bf06604bd02988b097eb462ae5805727ab97
3
+ metadata.gz: 8bf1cd02058a384245ee15ef6471ae7847c3d104ce9774ad1c05c78d21cb2313
4
+ data.tar.gz: 7d412258ebe13b65f7f35831e6b40f4aa76fd01377b9dfa7076770c147da6bcf
5
5
  SHA512:
6
- metadata.gz: 9822b621a741ce52c68c6510f6f73a1da52ae544c4371d05ffe430cacd1d882a914f4bd6959b98add49a13595b0ffc703b4258fd5dd5a6535ad887117d5e8be0
7
- data.tar.gz: 139da861fd411de27553e13e03ec0b3c7d94e5019c64e3e6ba820415e49f87f43ed58490565c408801554f30c3b0a2fa03133e48c4d783ffa7276b54e9f67e22
6
+ metadata.gz: 82f3d058175b4a66b4e852ad42cccc3a119fe2e980a89179d9f21e19236dc1fec047e7a074db6014351f39c5080f61609759c7456b75d1aa2c0ac8e4c5861c35
7
+ data.tar.gz: 7d43fd945b988ffec45bd88879ce470345320a223afe82d0354a09302adf453c66dbf6aea8da78ec9c6c022e701a985b958164bfa691073efd67a79e814f90ad
data/lib/git-fit.rb CHANGED
@@ -54,6 +54,7 @@ require_relative "git_fit/parser"
54
54
  require_relative "git_fit/source"
55
55
  require_relative "git_fit/timezone/resolver"
56
56
  require_relative "git_fit/privacy/polyline_filter"
57
+ require_relative "git_fit/import"
57
58
  require_relative "git_fit/sync/base"
58
59
  require_relative "git_fit/sync/strava"
59
60
  require_relative "git_fit/db/connection"
@@ -61,4 +62,5 @@ require_relative "git_fit/db/activity"
61
62
  require_relative "git_fit/export"
62
63
  require_relative "git_fit/db/cli"
63
64
  require_relative "git_fit/cli/export"
65
+ require_relative "git_fit/cli/import_cli"
64
66
  require_relative "git_fit/cli"
@@ -0,0 +1,169 @@
1
+ require "thor"
2
+
3
+ module GitFit
4
+ class ImportCLI < Thor
5
+ FILE_SOURCES = %w[gpx fit tcx].freeze
6
+
7
+ desc "all", "Import from all sources (gpx, fit, tcx)"
8
+ option :source, type: :string, aliases: "-s",
9
+ desc: "Import source(s) — comma-separated (default: gpx,fit,tcx)"
10
+ option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
11
+ option :sport, type: :string, desc: "Override sport_category for GPX files"
12
+ def all
13
+ sources = (options[:source] || "gpx,fit,tcx").split(",").map(&:strip).map(&:downcase)
14
+ unknown = sources.reject { |s| FILE_SOURCES.include?(s) }
15
+ unless unknown.empty?
16
+ say_status :error, "Unknown source(s): #{unknown.join(', ')}. Supported: gpx, fit, tcx", :red
17
+ return
18
+ end
19
+
20
+ config = GitFit::Config.new
21
+ db = GitFit::DB::Connection.new(config.db_path).db
22
+ filter = options[:activity]&.map(&:strip)&.map(&:downcase)
23
+ time_budget = config.dig("sync", "time_budget")
24
+ sport_override = options[:sport]
25
+
26
+ total = 0
27
+ sources.each do |source|
28
+ say_status :import, source, :green
29
+ adapter = GitFit::Import::LocalFile.new(
30
+ config: {}, db:, activity_filter: filter,
31
+ time_budget: time_budget,
32
+ sources: [source],
33
+ sport_override: sport_override
34
+ )
35
+ count = adapter.call
36
+ say_status :done, "#{source}: #{count} activities", :green
37
+ total += count
38
+ rescue => e
39
+ say_status :error, "#{source}: #{e.message}", :red
40
+ end
41
+
42
+ if total.zero?
43
+ say_status :warn, "0 activities imported", :yellow
44
+ else
45
+ say_status :done, "Total: #{total} activities", :green
46
+ end
47
+ end
48
+
49
+ default_command :all
50
+
51
+ desc "gpx", "Import GPX files from data/import/gpx/"
52
+ option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
53
+ option :sport, type: :string, desc: "Override sport_category for GPX files"
54
+ def gpx
55
+ config = GitFit::Config.new
56
+ db = GitFit::DB::Connection.new(config.db_path).db
57
+ filter = options[:activity]&.map(&:strip)&.map(&:downcase)
58
+ time_budget = config.dig("sync", "time_budget")
59
+ sport_override = options[:sport]
60
+
61
+ say_status :import, "gpx", :green
62
+ adapter = GitFit::Import::LocalFile.new(
63
+ config: {}, db:, activity_filter: filter,
64
+ time_budget: time_budget,
65
+ sources: ["gpx"],
66
+ sport_override: sport_override
67
+ )
68
+
69
+ begin
70
+ count = adapter.call
71
+ if count == 0
72
+ say_status :warn, "gpx: 0 activities (already imported or none found)", :yellow
73
+ else
74
+ say_status :done, "gpx: #{count} activities", :green
75
+ end
76
+ count
77
+ rescue => e
78
+ say_status :error, "gpx: #{e.message}", :red
79
+ 0
80
+ end
81
+
82
+ desc "tcx", "Import TCX files from data/import/tcx/"
83
+ option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
84
+ def tcx
85
+ config = GitFit::Config.new
86
+ db = GitFit::DB::Connection.new(config.db_path).db
87
+ filter = options[:activity]&.map(&:strip)&.map(&:downcase)
88
+ time_budget = config.dig("sync", "time_budget")
89
+
90
+ say_status :import, "tcx", :green
91
+ adapter = GitFit::Import::LocalFile.new(
92
+ config: {}, db:, activity_filter: filter,
93
+ time_budget: time_budget,
94
+ sources: ["tcx"]
95
+ )
96
+
97
+ begin
98
+ count = adapter.call
99
+ if count == 0
100
+ say_status :warn, "tcx: 0 activities (already imported or none found)", :yellow
101
+ else
102
+ say_status :done, "tcx: #{count} activities", :green
103
+ end
104
+ count
105
+ rescue => e
106
+ say_status :error, "tcx: #{e.message}", :red
107
+ 0
108
+ end
109
+ end
110
+
111
+ desc "fit", "Import FIT files from data/import/fit/"
112
+ option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
113
+ def fit
114
+ config = GitFit::Config.new
115
+ db = GitFit::DB::Connection.new(config.db_path).db
116
+ filter = options[:activity]&.map(&:strip)&.map(&:downcase)
117
+ time_budget = config.dig("sync", "time_budget")
118
+
119
+ say_status :import, "fit", :green
120
+ adapter = GitFit::Import::LocalFile.new(
121
+ config: {}, db:, activity_filter: filter,
122
+ time_budget: time_budget,
123
+ sources: ["fit"]
124
+ )
125
+
126
+ begin
127
+ count = adapter.call
128
+ if count == 0
129
+ say_status :warn, "fit: 0 activities (already imported or none found)", :yellow
130
+ else
131
+ say_status :done, "fit: #{count} activities", :green
132
+ end
133
+ count
134
+ rescue => e
135
+ say_status :error, "fit: #{e.message}", :red
136
+ 0
137
+ end
138
+ end
139
+
140
+ desc "apple_health", "Import from Apple Health export.zip"
141
+ option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
142
+ def apple_health
143
+ config = GitFit::Config.new
144
+ db = GitFit::DB::Connection.new(config.db_path).db
145
+ filter = options[:activity]&.map(&:strip)&.map(&:downcase)
146
+ time_budget = config.dig("sync", "time_budget")
147
+
148
+ say_status :import, "apple_health", :green
149
+ adapter = GitFit::Import::AppleHealth.new(
150
+ config: {}, db:, activity_filter: filter,
151
+ time_budget: time_budget
152
+ )
153
+
154
+ begin
155
+ count = adapter.call
156
+ if count == 0
157
+ say_status :warn, "apple_health: 0 activities (already imported or none found)", :yellow
158
+ else
159
+ say_status :done, "apple_health: #{count} activities", :green
160
+ end
161
+ count
162
+ rescue => e
163
+ say_status :error, "apple_health: #{e.message}", :red
164
+ 0
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
data/lib/git_fit/cli.rb CHANGED
@@ -13,6 +13,9 @@ module GitFit
13
13
  desc "db SUBCOMMAND", "Database operations"
14
14
  subcommand "db", DB::CLI
15
15
 
16
+ desc "import SUBCOMMAND", "Import activities from local files"
17
+ subcommand "import", ImportCLI
18
+
16
19
  desc "export SUBCOMMAND", "Export activities to various formats"
17
20
  subcommand "export", ExportCLI
18
21
 
@@ -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
@@ -0,0 +1,250 @@
1
+ require "digest"
2
+ require "fileutils"
3
+ require "json"
4
+ require "nokogiri"
5
+ require "tzinfo"
6
+
7
+ module GitFit
8
+ module Import
9
+ class LocalFile < Source::Base
10
+ IMPORT_DIRS = {
11
+ "gpx" => "data/import/gpx",
12
+ "tcx" => "data/import/tcx",
13
+ "fit" => "data/import/fit"
14
+ }.freeze
15
+
16
+ SUPPORTED_EXTENSIONS = %w[.gpx .tcx .fit].freeze
17
+
18
+ NS_TCX = { tcx: "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" }.freeze
19
+
20
+ def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil, sources: nil, sport_override: nil)
21
+ super(config:, db:, activity_filter:, privacy:, time_budget:)
22
+ @sources = sources
23
+ @sport_override = sport_override
24
+ @timezone_resolver = GitFit::Timezone::Resolver.new(config)
25
+ end
26
+
27
+ def call
28
+ return 0 unless authenticate
29
+
30
+ total_imported = 0
31
+ @sources.each do |source|
32
+ dir = IMPORT_DIRS[source]
33
+ next unless dir && File.directory?(dir)
34
+
35
+ files = Dir.glob(File.join(dir, "*"), File::FNM_CASEFOLD)
36
+ .select { |f| SUPPORTED_EXTENSIONS.include?(File.extname(f).downcase) && File.file?(f) }
37
+
38
+ total_imported += process_files(files, source)
39
+ end
40
+
41
+ total_imported
42
+ end
43
+
44
+ def authenticate
45
+ true
46
+ end
47
+
48
+ def pending_ids
49
+ []
50
+ end
51
+
52
+ def source_label
53
+ "LocalFile"
54
+ end
55
+
56
+ def raw_dir_for(source)
57
+ File.join("data", "raw", source)
58
+ end
59
+
60
+ def std_dir_for(source)
61
+ File.join("data", "std", source)
62
+ end
63
+
64
+ private
65
+
66
+ def process_files(files, source)
67
+ total_activities = 0
68
+ total = files.size
69
+
70
+ files.each_with_index do |file_path, idx|
71
+ break if time_expired? && total_activities.positive?
72
+
73
+ result = process_single_file(file_path, source)
74
+ if result
75
+ activity_count = result[:activity_count] || 1
76
+ total_activities += activity_count
77
+ report_progress(idx + 1, total, "#{activity_count} act from #{File.basename(file_path)}")
78
+ end
79
+ end
80
+
81
+ total_activities
82
+ end
83
+
84
+ def process_single_file(file_path, source)
85
+ content = File.binread(file_path)
86
+ sha = Digest::SHA256.hexdigest(content)
87
+ platform_id = "#{source}_#{sha[0, 12]}"
88
+
89
+ return nil if existing_run_id?(platform_id)
90
+
91
+ ext = File.extname(file_path).downcase
92
+ raw_source_dir = raw_dir_for(source)
93
+
94
+ raw_path = File.join(raw_source_dir, "#{platform_id}#{ext}")
95
+ FileUtils.mkdir_p(raw_source_dir)
96
+ tmp = "#{raw_path}.tmp"
97
+ File.binwrite(tmp, content)
98
+ File.rename(tmp, raw_path)
99
+
100
+ activities = parse_file(ext, content, file_path)
101
+ return nil if activities.empty?
102
+
103
+ l2_points = extract_l2_points(ext, content, file_path)
104
+
105
+ imported = 0
106
+ activities.each do |activity|
107
+ activity[:source] = source
108
+ activity[:run_id] = imported.zero? ? platform_id : "#{platform_id}_#{imported}"
109
+
110
+ if source == "gpx" && @sport_override
111
+ activity[:sport_category] = SportMapper.canonicalize(@sport_override)
112
+ end
113
+
114
+ if l2_points&.any?
115
+ std_source_dir = std_dir_for(source)
116
+ FileUtils.mkdir_p(std_source_dir)
117
+ std_data = l2_points.map { |pt|
118
+ h = { latitude: pt[0], longitude: pt[1] }
119
+ h[:altitude] = pt[2] if pt[2]
120
+ h[:timestamp] = pt[3]&.iso8601 if pt[3]
121
+ h[:heart_rate] = pt[4] if pt[4]
122
+ h
123
+ }
124
+ std_tmp = File.join(std_source_dir, ".#{activity[:run_id]}.json.tmp")
125
+ std_final = File.join(std_source_dir, "#{activity[:run_id]}.json")
126
+ File.write(std_tmp, JSON.generate(std_data))
127
+ File.rename(std_tmp, std_final)
128
+
129
+ coords = l2_points.map { |p| [p[0], p[1]] }
130
+ activity[:summary_polyline] = GitFit::Geo::Polyline.encode(coords)
131
+
132
+ first_pt = l2_points.first
133
+ tz = @timezone_resolver.resolve(first_pt[0], first_pt[1])
134
+ if tz && activity[:start_date]
135
+ activity[:start_date_local] = utc_to_local(activity[:start_date], tz)
136
+ end
137
+ end
138
+
139
+ upsert_activity(activity)
140
+ imported += 1
141
+ end
142
+
143
+ { activity_count: imported }
144
+ rescue => e
145
+ $stderr.puts "LocalFile: error importing #{file_path}: #{e.message}"
146
+ nil
147
+ end
148
+
149
+ def parse_file(ext, content, file_path)
150
+ case ext
151
+ when ".gpx"
152
+ GitFit::Parser::GPX.new.call(content)
153
+ when ".tcx"
154
+ GitFit::Parser::TCX.new.call(content)
155
+ when ".fit"
156
+ GitFit::Parser::FIT.new.call(file_path)
157
+ else
158
+ []
159
+ end
160
+ end
161
+
162
+ def extract_l2_points(ext, content, file_path)
163
+ case ext
164
+ when ".gpx"
165
+ extract_gpx_points(content)
166
+ when ".tcx"
167
+ extract_tcx_points(content)
168
+ when ".fit"
169
+ extract_fit_points(file_path)
170
+ else
171
+ nil
172
+ end
173
+ end
174
+
175
+ def extract_gpx_points(xml)
176
+ doc = Nokogiri::XML(xml)
177
+ doc.remove_namespaces!
178
+ doc.xpath("//trkpt").filter_map do |pt|
179
+ lat = pt["lat"]&.to_f
180
+ lon = pt["lon"]&.to_f
181
+ next unless lat && lon
182
+ ele = pt.at_xpath("ele")&.text&.to_f
183
+ time_str = pt.at_xpath("time")&.text
184
+ time = parse_timestamp(time_str)
185
+ hr = pt.at_xpath("extensions//hr")&.text&.to_f
186
+ [lat, lon, ele, time, hr]
187
+ end
188
+ rescue => e
189
+ $stderr.puts "LocalFile: GPX parse error: #{e.message}"
190
+ nil
191
+ end
192
+
193
+ def extract_tcx_points(xml)
194
+ doc = Nokogiri::XML(xml)
195
+ doc.xpath("//tcx:Trackpoint", NS_TCX).filter_map do |tp|
196
+ pos = tp.at_xpath("tcx:Position", NS_TCX)
197
+ next unless pos
198
+ lat = pos.at_xpath("tcx:LatitudeDegrees", NS_TCX)&.text&.to_f
199
+ lon = pos.at_xpath("tcx:LongitudeDegrees", NS_TCX)&.text&.to_f
200
+ next unless lat && lon
201
+ ele = tp.at_xpath("tcx:AltitudeMeters", NS_TCX)&.text&.to_f
202
+ time_str = tp.at_xpath("tcx:Time", NS_TCX)&.text
203
+ time = parse_timestamp(time_str)
204
+ hr = tp.at_xpath("tcx:HeartRateBpm/tcx:Value", NS_TCX)&.text&.to_f
205
+ [lat, lon, ele, time, hr]
206
+ end
207
+ rescue => e
208
+ $stderr.puts "LocalFile: TCX parse error: #{e.message}"
209
+ nil
210
+ end
211
+
212
+ def extract_fit_points(file_path)
213
+ records = GitFit::FIT::Decoder.decode(file_path)
214
+ records.filter_map do |r|
215
+ lat = r["positionLat"]
216
+ lon = r["positionLong"]
217
+ next unless lat && lon
218
+ ts = r["timestamp"]
219
+ time = ts.is_a?(Numeric) ? (Time.utc(1989, 12, 31) + ts) : (Time.parse(ts.to_s) rescue nil)
220
+ [lat, lon, r["altitude"], time, r["heartRate"]]
221
+ end
222
+ rescue => e
223
+ $stderr.puts "LocalFile: FIT decode error: #{e.message}"
224
+ nil
225
+ end
226
+
227
+ def parse_timestamp(str)
228
+ return nil unless str
229
+ Time.parse(str)
230
+ rescue ArgumentError
231
+ nil
232
+ end
233
+
234
+ def existing_run_id?(platform_id)
235
+ !@db[:activities].where(run_id: platform_id).empty?
236
+ end
237
+
238
+ def utc_to_local(utc_iso8601, tz_name)
239
+ return utc_iso8601 unless utc_iso8601 && tz_name
240
+ t = Time.parse(utc_iso8601)
241
+ return utc_iso8601 unless t.utc?
242
+ tz = TZInfo::Timezone.get(tz_name)
243
+ local = tz.utc_to_local(t)
244
+ local.iso8601
245
+ rescue TZInfo::InvalidTimezoneIdentifier, ArgumentError
246
+ utc_iso8601
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,2 @@
1
+ require_relative "import/local_file"
2
+ require_relative "import/apple_health"
@@ -1,3 +1,3 @@
1
1
  module GitFit
2
- VERSION = "0.4.1"
2
+ VERSION = "0.4.6"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-fit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -93,6 +93,34 @@ dependencies:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
95
  version: '1.5'
96
+ - !ruby/object:Gem::Dependency
97
+ name: tzinfo
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '2.0'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '2.0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: rubyzip
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '2.0'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '2.0'
96
124
  description: A git extension CLI for aggregating, managing, and exporting fitness
97
125
  activity data from multiple sources (Garmin, Strava, Keep, etc.)
98
126
  email:
@@ -106,6 +134,7 @@ files:
106
134
  - lib/git-fit.rb
107
135
  - lib/git_fit/cli.rb
108
136
  - lib/git_fit/cli/export.rb
137
+ - lib/git_fit/cli/import_cli.rb
109
138
  - lib/git_fit/config.rb
110
139
  - lib/git_fit/config_template.rb
111
140
  - lib/git_fit/db/activity.rb
@@ -119,6 +148,9 @@ files:
119
148
  - lib/git_fit/geo.rb
120
149
  - lib/git_fit/geo/coord_transform.rb
121
150
  - lib/git_fit/geo/polyline.rb
151
+ - lib/git_fit/import.rb
152
+ - lib/git_fit/import/apple_health.rb
153
+ - lib/git_fit/import/local_file.rb
122
154
  - lib/git_fit/parser.rb
123
155
  - lib/git_fit/parser/base.rb
124
156
  - lib/git_fit/parser/fit.rb