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 +4 -4
- data/lib/git-fit.rb +2 -0
- data/lib/git_fit/cli/import_cli.rb +169 -0
- data/lib/git_fit/cli.rb +3 -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/version.rb +1 -1
- metadata +33 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8bf1cd02058a384245ee15ef6471ae7847c3d104ce9774ad1c05c78d21cb2313
|
|
4
|
+
data.tar.gz: 7d412258ebe13b65f7f35831e6b40f4aa76fd01377b9dfa7076770c147da6bcf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/git_fit/version.rb
CHANGED
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.
|
|
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
|