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.
- checksums.yaml +4 -4
- data/lib/git-fit.rb +42 -0
- data/lib/git_fit/cli/export.rb +25 -0
- data/lib/git_fit/cli.rb +47 -0
- data/lib/git_fit/db/activity.rb +15 -0
- data/lib/git_fit/db/cli.rb +16 -0
- data/lib/git_fit/db/connection.rb +27 -0
- data/lib/git_fit/export/defaults.rb +29 -0
- data/lib/git_fit/export/json.rb +112 -0
- data/lib/git_fit/export.rb +7 -0
- data/lib/git_fit/fit/decoder.rb +27 -0
- data/lib/git_fit/fit.rb +1 -0
- data/lib/git_fit/geo/coord_transform.rb +101 -0
- data/lib/git_fit/geo/polyline.rb +78 -0
- data/lib/git_fit/geo.rb +7 -0
- data/lib/git_fit/parser/base.rb +128 -0
- data/lib/git_fit/parser/fit.rb +69 -0
- data/lib/git_fit/parser/gpx.rb +80 -0
- data/lib/git_fit/parser/tcx.rb +89 -0
- data/lib/git_fit/parser.rb +9 -0
- data/lib/git_fit/privacy/polyline_filter.rb +79 -0
- data/lib/git_fit/source/base.rb +155 -0
- data/lib/git_fit/source/tally.rb +55 -0
- data/lib/git_fit/source.rb +7 -0
- data/lib/git_fit/sport_mapper.rb +80 -0
- data/lib/git_fit/timezone/resolver.rb +20 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +94 -1
|
@@ -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,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
|