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,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"
@@ -0,0 +1,155 @@
1
+ require "date"
2
+
3
+ module GitFit
4
+ module Source
5
+ class Base
6
+ def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
7
+ @config = config
8
+ @db = db
9
+ @activity_filter = activity_filter
10
+ @privacy = privacy
11
+ @time_budget = time_budget
12
+ @start_time = nil
13
+ @progress_reported = false
14
+ end
15
+
16
+ attr_reader :progress_reported
17
+
18
+ def call
19
+ return 0 unless before_call
20
+ return 0 unless respond_to?(:authenticate) ? authenticate : true
21
+ ids = pending_ids
22
+ return 0 if ids.nil? || ids.empty?
23
+ total = ids.size
24
+ count = 0
25
+ ids.filter_map do |p|
26
+ r = process_one(p)
27
+ count += 1
28
+ report_progress(count, total, build_progress_info(r)) if r
29
+ r
30
+ end.size
31
+ end
32
+
33
+ def before_call
34
+ @start_time = Time.now
35
+ true
36
+ end
37
+
38
+ def pending_ids
39
+ raise NotImplementedError
40
+ end
41
+
42
+ def process_one(pending)
43
+ raise NotImplementedError
44
+ end
45
+
46
+ def build_progress_info(attrs)
47
+ parts = []
48
+ sd = attrs["start_date"] || attrs[:start_date]
49
+ if sd
50
+ d = Date.parse(sd.to_s) rescue nil
51
+ if d
52
+ date_str = d.year == Date.today.year ? d.strftime("%m-%d") : d.strftime("%Y-%m-%d")
53
+ parts << date_str
54
+ end
55
+ end
56
+ type = attrs["sport_category"] || attrs[:sport_category]
57
+ parts << type if type
58
+ dist = attrs["distance"] || attrs[:distance]
59
+ if dist && dist.to_f > 0
60
+ parts << "#{format('%.1f', dist.to_f / 1000)}km"
61
+ end
62
+ source = attrs["source"] || attrs[:source] || source_label
63
+ name = attrs["name"] || attrs[:name]
64
+ display_name = name.to_s.strip
65
+ display_name = "#{source} Activity" if display_name.empty?
66
+ chars = display_name.chars
67
+ display_name = chars.first(30).join + "…" if chars.size > 30
68
+ parts << display_name
69
+ parts.join(" | ") unless parts.empty?
70
+ end
71
+
72
+ protected
73
+
74
+ def time_expired?
75
+ return false unless @time_budget.is_a?(Numeric) && @time_budget > 0 && @start_time
76
+ (Time.now - @start_time) > @time_budget
77
+ end
78
+
79
+ def raw_dir
80
+ src = self.class.name.split("::").last.downcase
81
+ File.join("data", "raw", src)
82
+ end
83
+
84
+ def raw_path(platform_id)
85
+ raise NotImplementedError
86
+ end
87
+
88
+ def raw_file_exists?(platform_id)
89
+ File.exist?(raw_path(platform_id))
90
+ end
91
+
92
+ def std_dir
93
+ src = self.class.name.split("::").last.downcase
94
+ File.join("data", "std", src)
95
+ end
96
+
97
+ def std_path(platform_id, ext = "json")
98
+ File.join(std_dir, "#{platform_id}.#{ext}")
99
+ end
100
+
101
+ def report_progress(count, total = nil, info = nil)
102
+ return unless show_progress?(count, total)
103
+ @progress_reported = true
104
+ line = "#{source_label}: #{count}"
105
+ line += "/#{total}" if total
106
+ line += " #{info}" if info
107
+ line += " "
108
+ print "\r#{line}"
109
+ $stdout.flush
110
+ end
111
+
112
+ def show_progress?(count, total)
113
+ return true if count == 1
114
+ return true if total && (total - count) <= 50
115
+ step = progress_step(total)
116
+ step <= 1 || count % step == 0
117
+ end
118
+
119
+ def progress_step(total)
120
+ return 1 unless total && total > 0
121
+ [1, (1.5 * (total ** 0.4)).to_i].max
122
+ end
123
+
124
+ def source_label
125
+ name = self.class.name
126
+ name ? name.split("::").last : "Source"
127
+ end
128
+
129
+ def upsert_activity(attrs)
130
+ attrs = attrs.transform_keys(&:to_s)
131
+ now = Time.now.utc
132
+
133
+ if @privacy && !@privacy.before_saving?
134
+ attrs["summary_polyline"] = @privacy.filter(attrs["summary_polyline"])
135
+ end
136
+
137
+ existing = @db[:activities].where(run_id: attrs["run_id"]).first
138
+ if existing
139
+ attrs.delete("created_at")
140
+ attrs["updated_at"] = now
141
+ @db[:activities].where(run_id: attrs["run_id"]).update(attrs)
142
+ else
143
+ attrs["created_at"] ||= now
144
+ attrs["updated_at"] ||= now
145
+ @db[:activities].insert(attrs)
146
+ end
147
+ end
148
+
149
+ def skip_type?(sport_type)
150
+ return false unless @activity_filter
151
+ !@activity_filter.include?(sport_type.to_s.downcase)
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,55 @@
1
+ module GitFit
2
+ module Source
3
+ class Tally
4
+ DIGITS = %w[零 一 二 三 四 五 六 七 八 九 十]
5
+
6
+ def initialize(count = 0)
7
+ @count = count
8
+ end
9
+
10
+ def add(n = 1)
11
+ @count += n
12
+ end
13
+
14
+ def to_s
15
+ return @count.to_s if @count > 9999
16
+ chinese_numeral
17
+ rescue StandardError
18
+ @count.to_s
19
+ end
20
+
21
+ private
22
+
23
+ def chinese_numeral
24
+ n = @count
25
+ return DIGITS[0] if n == 0
26
+ return DIGITS[n] if n <= 10
27
+
28
+ result = ""
29
+ thousands = n / 1000
30
+ n %= 1000
31
+ result += DIGITS[thousands] + "千" if thousands > 0
32
+
33
+ hundreds = n / 100
34
+ n %= 100
35
+ if hundreds > 0
36
+ result += DIGITS[hundreds] + "百"
37
+ elsif thousands > 0 && n > 0
38
+ result += "零"
39
+ end
40
+
41
+ tens = n / 10
42
+ ones = n % 10
43
+ if tens > 0
44
+ join_ten = tens > 1 || !result.empty?
45
+ result += (join_ten ? DIGITS[tens] : "") + "十"
46
+ elsif !result.empty? && tens == 0 && ones > 0 && !result.end_with?("零")
47
+ result += "零"
48
+ end
49
+
50
+ result += DIGITS[ones] if ones > 0
51
+ result
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,7 @@
1
+ module GitFit
2
+ module Source
3
+ end
4
+ end
5
+
6
+ require_relative "source/tally"
7
+ require_relative "source/base"
@@ -1,3 +1,3 @@
1
1
  module GitFit
2
- VERSION = "0.3.2"
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.3.2
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:
@@ -105,22 +133,33 @@ files:
105
133
  - exe/git-fit
106
134
  - lib/git-fit.rb
107
135
  - lib/git_fit/cli.rb
136
+ - lib/git_fit/cli/export.rb
137
+ - lib/git_fit/cli/import_cli.rb
108
138
  - lib/git_fit/config.rb
109
139
  - lib/git_fit/config_template.rb
110
140
  - lib/git_fit/db/activity.rb
111
141
  - lib/git_fit/db/cli.rb
112
142
  - lib/git_fit/db/connection.rb
143
+ - lib/git_fit/export.rb
144
+ - lib/git_fit/export/defaults.rb
145
+ - lib/git_fit/export/json.rb
113
146
  - lib/git_fit/fit.rb
114
147
  - lib/git_fit/fit/decoder.rb
115
148
  - lib/git_fit/geo.rb
116
149
  - lib/git_fit/geo/coord_transform.rb
117
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
118
154
  - lib/git_fit/parser.rb
119
155
  - lib/git_fit/parser/base.rb
120
156
  - lib/git_fit/parser/fit.rb
121
157
  - lib/git_fit/parser/gpx.rb
122
158
  - lib/git_fit/parser/tcx.rb
123
159
  - lib/git_fit/privacy/polyline_filter.rb
160
+ - lib/git_fit/source.rb
161
+ - lib/git_fit/source/base.rb
162
+ - lib/git_fit/source/tally.rb
124
163
  - lib/git_fit/sport_mapper.rb
125
164
  - lib/git_fit/sync/base.rb
126
165
  - lib/git_fit/sync/strava.rb