git-fit 0.1.1 → 0.4.1

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,128 @@
1
+ require "time"
2
+
3
+ module GitFit
4
+ module Parser
5
+ class Base
6
+ def initialize(source: nil)
7
+ @source_name = source
8
+ end
9
+
10
+ def call(_input)
11
+ raise NotImplementedError
12
+ end
13
+
14
+ private
15
+
16
+ def haversine(p1, p2)
17
+ lat1 = deg_to_rad(p1[0])
18
+ lat2 = deg_to_rad(p2[0])
19
+ dlat = deg_to_rad(p2[0] - p1[0])
20
+ dlon = deg_to_rad(p2[1] - p1[1])
21
+ a = Math.sin(dlat / 2) ** 2 +
22
+ Math.cos(lat1) * Math.cos(lat2) *
23
+ Math.sin(dlon / 2) ** 2
24
+ c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
25
+ 6371000.0 * c
26
+ end
27
+
28
+ def deg_to_rad(deg)
29
+ deg * Math::PI / 180.0
30
+ end
31
+
32
+ def total_distance(points)
33
+ return 0.0 if points.length < 2
34
+ (1...points.length).sum { |i| haversine(points[i - 1], points[i]) }
35
+ end
36
+
37
+ def elevation_gain(points)
38
+ return 0.0 if points.length < 2
39
+ (1...points.length).sum do |i|
40
+ elev1 = points[i - 1][2] || 0.0
41
+ elev2 = points[i][2] || 0.0
42
+ gain = elev2 - elev1
43
+ gain > 0 ? gain : 0.0
44
+ end
45
+ end
46
+
47
+ def moving_time_from_points(points, threshold_s: 30)
48
+ return 0 if points.length < 2
49
+ total = 0
50
+ (1...points.length).each do |i|
51
+ t1 = points[i - 1][3]
52
+ t2 = points[i][3]
53
+ next unless t1 && t2
54
+ diff = (t2 - t1).abs
55
+ total += diff if diff <= threshold_s
56
+ end
57
+ total.round
58
+ end
59
+
60
+ def elapsed_time_from_points(points)
61
+ return 0 if points.length < 2
62
+ times = points.map { |p| p[3] }.compact
63
+ return 0 if times.length < 2
64
+ ((times.last - times.first).abs).round
65
+ end
66
+
67
+ def build_run_id(source_prefix, value)
68
+ "#{source_prefix}_#{value}"
69
+ end
70
+
71
+ def default_sport
72
+ "other"
73
+ end
74
+
75
+ def simplify_polyline(points, max_points: 500)
76
+ return GitFit::Geo::Polyline.encode(points) if points.length <= max_points
77
+ step = (points.length.to_f / max_points).ceil
78
+ sampled = (0...points.length).step(step).map { |i| points[i] }
79
+ sampled << points.last if sampled.last != points.last
80
+ GitFit::Geo::Polyline.encode(sampled)
81
+ end
82
+
83
+ def normalize_sport_type(type)
84
+ return default_sport if type.nil? || type.empty?
85
+ mapping = {
86
+ "running" => "run", "run" => "run",
87
+ "cycling" => "ride", "biking" => "ride", "ride" => "ride",
88
+ "hiking" => "hike", "hike" => "hike",
89
+ "walking" => "walk", "walk" => "walk",
90
+ "swimming" => "swim", "swim" => "swim",
91
+ "skiing" => "ski", "ski" => "ski",
92
+ "workout" => "workout", "other" => "other"
93
+ }
94
+ mapping[type.downcase.strip] || default_sport
95
+ end
96
+
97
+ def build_activity_attrs(data)
98
+ {
99
+ run_id: data[:run_id],
100
+ name: data[:name],
101
+ distance: data[:distance]&.round(2),
102
+ moving_time: data[:moving_time]&.round,
103
+ elapsed_time: data[:elapsed_time]&.round,
104
+ sport_category: data[:sport_category] || default_sport,
105
+ sport_type: data[:sport_type],
106
+ start_date: format_time(data[:start_date]),
107
+ start_date_local: format_time_local(data[:start_date_local]),
108
+ location_country: data[:location_country],
109
+ summary_polyline: data[:summary_polyline],
110
+ average_heartrate: data[:average_heartrate]&.round(1),
111
+ average_speed: data[:distance] && data[:moving_time].to_f.positive? ? (data[:distance].to_f / data[:moving_time]).round(2) : data[:average_speed],
112
+ elevation_gain: data[:elevation_gain]&.round(1),
113
+ source: @source_name || data[:source]
114
+ }
115
+ end
116
+
117
+ def format_time(t)
118
+ return nil unless t
119
+ t.respond_to?(:iso8601) ? t.getutc.iso8601 : t.to_s
120
+ end
121
+
122
+ def format_time_local(t)
123
+ return nil unless t
124
+ t.respond_to?(:iso8601) ? t.iso8601 : t.to_s
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,69 @@
1
+ require "json"
2
+ require "securerandom"
3
+ require "time"
4
+
5
+ module GitFit
6
+ module Parser
7
+ class FIT < Base
8
+ FIT_EPOCH = Time.utc(1989, 12, 31).freeze
9
+
10
+ def call(input)
11
+ path = input.is_a?(String) ? input : nil
12
+ return [] unless path && File.exist?(path)
13
+
14
+ records = GitFit::FIT::Decoder.decode(path)
15
+ return [] if records.empty?
16
+
17
+ points = records.filter_map { |r| record_to_point(r) }
18
+ return [] if points.empty?
19
+
20
+ times = points.map { |p| p[3] }.compact
21
+ start_date = times.min
22
+ end_date = times.max
23
+
24
+ sport_type = GitFit::FIT::Decoder.sport(path)
25
+
26
+ [build_activity_attrs({
27
+ run_id: build_run_id("fit", SecureRandom.hex(8)),
28
+ name: nil,
29
+ distance: total_distance(points),
30
+ moving_time: moving_time_from_points(points),
31
+ elapsed_time: elapsed_time_from_points(points),
32
+ sport_category: normalize_sport_type(sport_type),
33
+ sport_type: sport_type,
34
+ start_date: start_date,
35
+ start_date_local: start_date,
36
+ location_country: nil,
37
+ summary_polyline: simplify_polyline(points.map { |p| [p[0], p[1]] }),
38
+ average_heartrate: average_heartrate(points),
39
+ elevation_gain: elevation_gain(points),
40
+ source: "fit"
41
+ })]
42
+ end
43
+
44
+ private
45
+
46
+ def record_to_point(rec)
47
+ lat = rec["positionLat"]
48
+ lon = rec["positionLong"]
49
+ return nil unless lat && lon
50
+ ts = rec["timestamp"]
51
+ time = ts.is_a?(Numeric) ? FIT_EPOCH + ts : parse_time(ts)
52
+ [lat, lon, rec["altitude"], time, rec["heartRate"]]
53
+ end
54
+
55
+ def average_heartrate(points)
56
+ hrs = points.map { |p| p[4] }.compact
57
+ return nil if hrs.empty?
58
+ hrs.sum / hrs.size
59
+ end
60
+
61
+ def parse_time(str)
62
+ return nil unless str
63
+ Time.parse(str.to_s)
64
+ rescue ArgumentError
65
+ nil
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,80 @@
1
+ require "time"
2
+ require "nokogiri"
3
+
4
+ module GitFit
5
+ module Parser
6
+ class GPX < Base
7
+ NS = { gpx: "http://www.topografix.com/GPX/1/1" }.freeze
8
+
9
+ def call(input)
10
+ doc = input.is_a?(String) ? Nokogiri::XML(input) : input
11
+ doc.remove_namespaces!
12
+ tracks = doc.xpath("//trk")
13
+ return [] if tracks.empty?
14
+ tracks.filter_map { |trk| parse_track(trk) }
15
+ end
16
+
17
+ private
18
+
19
+ def parse_track(trk)
20
+ name = trk.at_xpath("name")&.text
21
+ segments = trk.xpath("trkseg")
22
+ return nil if segments.empty?
23
+ points = segments.flat_map { |seg| parse_segment(seg) }
24
+ return nil if points.empty?
25
+ times = points.map { |p| p[3] }.compact
26
+ start_date = times.min
27
+ end_date = times.max
28
+ data = {
29
+ run_id: build_run_id("gpx", start_date&.strftime("%Y%m%dT%H%M%S") || SecureRandom.hex(4)),
30
+ name: name,
31
+ distance: total_distance(points),
32
+ moving_time: moving_time_from_points(points),
33
+ elapsed_time: elapsed_time_from_points(points),
34
+ sport_category: default_sport,
35
+ sport_type: nil,
36
+ start_date: start_date,
37
+ start_date_local: start_date,
38
+ location_country: nil,
39
+ summary_polyline: simplify_polyline(points.map { |p| [p[0], p[1]] }),
40
+ average_heartrate: average_heartrate(points),
41
+ elevation_gain: elevation_gain(points),
42
+ source: "gpx"
43
+ }
44
+ build_activity_attrs(data)
45
+ end
46
+
47
+ def parse_segment(seg)
48
+ seg.xpath("trkpt").filter_map do |pt|
49
+ lat = pt["lat"]&.to_f
50
+ lon = pt["lon"]&.to_f
51
+ next unless lat && lon
52
+ elev = pt.at_xpath("ele")&.text&.to_f
53
+ time = parse_time(pt.at_xpath("time")&.text)
54
+ hr = parse_heartrate(pt)
55
+ [lat, lon, elev, time, hr]
56
+ end
57
+ end
58
+
59
+ def parse_heartrate(pt)
60
+ ext = pt.at_xpath("extensions")
61
+ return nil unless ext
62
+ hr = ext.at_xpath(".//hr")
63
+ hr&.text&.to_f
64
+ end
65
+
66
+ def average_heartrate(points)
67
+ hrs = points.map { |p| p[4] }.compact
68
+ return nil if hrs.empty?
69
+ hrs.sum / hrs.size
70
+ end
71
+
72
+ def parse_time(str)
73
+ return nil unless str
74
+ Time.parse(str)
75
+ rescue ArgumentError
76
+ nil
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,89 @@
1
+ require "time"
2
+ require "nokogiri"
3
+
4
+ module GitFit
5
+ module Parser
6
+ class TCX < Base
7
+ NS = { tcx: "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" }.freeze
8
+
9
+ def call(input)
10
+ doc = input.is_a?(String) ? Nokogiri::XML(input) : input
11
+ activities = doc.xpath("//tcx:Activities/tcx:Activity", NS)
12
+ return [] if activities.empty?
13
+ activities.filter_map { |act| parse_activity(act) }
14
+ end
15
+
16
+ private
17
+
18
+ def parse_activity(act)
19
+ sport = act["Sport"]
20
+ laps = act.xpath("tcx:Lap", NS)
21
+ return nil if laps.empty?
22
+ lap_data = laps.map { |lap| parse_lap(lap) }.compact
23
+ return nil if lap_data.empty?
24
+ total_distance = lap_data.sum { |ld| ld[:distance] || 0.0 }
25
+ total_moving_time = lap_data.sum { |ld| ld[:moving_time] || 0 }
26
+ total_elapsed_time = lap_data.sum { |ld| ld[:elapsed_time] || 0 }
27
+ elevation_gain = lap_data.sum { |ld| ld[:elevation_gain] || 0.0 }
28
+ avg_hr = lap_data.map { |ld| ld[:avg_hr] }.compact
29
+ all_points = lap_data.flat_map { |ld| ld[:points] }
30
+ times = all_points.map { |p| p[3] }.compact
31
+ start_date = times.min
32
+ data = {
33
+ run_id: build_run_id("tcx", start_date&.strftime("%Y%m%dT%H%M%S") || SecureRandom.hex(4)),
34
+ name: act.at_xpath("tcx:Notes", NS)&.text,
35
+ distance: total_distance,
36
+ moving_time: total_moving_time,
37
+ elapsed_time: total_elapsed_time,
38
+ sport_category: normalize_sport_type(sport),
39
+ sport_type: sport,
40
+ start_date: start_date,
41
+ start_date_local: start_date,
42
+ location_country: nil,
43
+ summary_polyline: simplify_polyline(all_points.map { |p| [p[0], p[1]] }),
44
+ average_heartrate: avg_hr.empty? ? nil : (avg_hr.sum / avg_hr.size).round(1),
45
+ elevation_gain: elevation_gain,
46
+ source: "tcx"
47
+ }
48
+ build_activity_attrs(data)
49
+ end
50
+
51
+ def parse_lap(lap)
52
+ points = parse_track(lap)
53
+ return nil if points.empty?
54
+ distance = lap.at_xpath("tcx:DistanceMeters", NS)&.text&.to_f
55
+ total_time = lap.at_xpath("tcx:TotalTimeSeconds", NS)&.text&.to_f
56
+ avg_hr_node = lap.at_xpath("tcx:AverageHeartRateBpm/tcx:Value", NS)
57
+ avg_hr = avg_hr_node&.text&.to_f
58
+ elev_gain = points.length >= 2 ? elevation_gain(points) : 0.0
59
+ moving_time = moving_time_from_points(points, threshold_s: 30)
60
+ { distance: distance, elapsed_time: total_time&.round,
61
+ moving_time: moving_time > 0 ? moving_time : (total_time&.round || 0),
62
+ avg_hr: avg_hr, elevation_gain: elev_gain, points: points }
63
+ end
64
+
65
+ def parse_track(lap)
66
+ track = lap.at_xpath("tcx:Track", NS)
67
+ return [] unless track
68
+ track.xpath("tcx:Trackpoint", NS).filter_map do |tp|
69
+ pos = tp.at_xpath("tcx:Position", NS)
70
+ next unless pos
71
+ lat = pos.at_xpath("tcx:LatitudeDegrees", NS)&.text&.to_f
72
+ lon = pos.at_xpath("tcx:LongitudeDegrees", NS)&.text&.to_f
73
+ next unless lat && lon
74
+ elev = tp.at_xpath("tcx:AltitudeMeters", NS)&.text&.to_f
75
+ time = parse_time(tp.at_xpath("tcx:Time", NS)&.text)
76
+ hr = tp.at_xpath("tcx:HeartRateBpm/tcx:Value", NS)&.text&.to_f
77
+ [lat, lon, elev, time, hr]
78
+ end
79
+ end
80
+
81
+ def parse_time(str)
82
+ return nil unless str
83
+ Time.parse(str)
84
+ rescue ArgumentError
85
+ nil
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,9 @@
1
+ module GitFit
2
+ module Parser
3
+ end
4
+ end
5
+
6
+ require_relative "parser/base"
7
+ require_relative "parser/gpx"
8
+ require_relative "parser/tcx"
9
+ require_relative "parser/fit"
@@ -0,0 +1,79 @@
1
+ module GitFit
2
+ module Privacy
3
+ class PolylineFilter
4
+ def initialize(config: {})
5
+ @start_end_range = (config["start_end_range"] || 200).to_f / 1000.0
6
+ @ignore_polyline = decode_polyline(config["polyline"]) if config["polyline"]
7
+ @ignore_range = (config["range"] || 0).to_f / 1000.0
8
+ @before_saving = config["before_saving"] || false
9
+ end
10
+
11
+ def before_saving?
12
+ @before_saving
13
+ end
14
+
15
+ def filter(polyline_str)
16
+ return nil if polyline_str.nil? || polyline_str.empty?
17
+ points = decode_polyline(polyline_str)
18
+ return polyline_str if points.nil? || points.empty?
19
+ points = filter_start_end(points)
20
+ points = filter_range(points)
21
+ return nil if points.empty?
22
+ encode_polyline(points)
23
+ end
24
+
25
+ private
26
+
27
+ def filter_start_end(points)
28
+ return points if @start_end_range <= 0
29
+ start_idx = 0
30
+ dist = 0.0
31
+ (1...points.length).each do |i|
32
+ dist += haversine(points[i], points[i - 1])
33
+ start_idx = i
34
+ break if dist > @start_end_range
35
+ end
36
+ end_idx = points.length - 1
37
+ dist = 0.0
38
+ (points.length - 2).downto(0) do |i|
39
+ dist += haversine(points[i], points[i + 1])
40
+ end_idx = i
41
+ break if dist > @start_end_range
42
+ end
43
+ return [] if start_idx >= end_idx
44
+ points[start_idx..end_idx]
45
+ end
46
+
47
+ def filter_range(points)
48
+ return points if @ignore_polyline.nil? || @ignore_range <= 0
49
+ points.reject do |point|
50
+ @ignore_polyline.any? { |ip| haversine(point, ip) < @ignore_range }
51
+ end
52
+ end
53
+
54
+ def haversine(p1, p2)
55
+ lat1 = deg_to_rad(p1[0])
56
+ lat2 = deg_to_rad(p2[0])
57
+ dlat = deg_to_rad(p2[0] - p1[0])
58
+ dlon = deg_to_rad(p2[1] - p1[1])
59
+ a = Math.sin(dlat / 2) ** 2 +
60
+ Math.cos(lat1) * Math.cos(lat2) *
61
+ Math.sin(dlon / 2) ** 2
62
+ c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
63
+ 6371.0 * c
64
+ end
65
+
66
+ def deg_to_rad(deg)
67
+ deg * Math::PI / 180.0
68
+ end
69
+
70
+ def decode_polyline(str)
71
+ GitFit::Geo::Polyline.decode(str)
72
+ end
73
+
74
+ def encode_polyline(points)
75
+ GitFit::Geo::Polyline.encode(points)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -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"