git-fit 0.1.1 → 0.3.2
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 +39 -0
- data/lib/git_fit/cli.rb +44 -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/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/sport_mapper.rb +80 -0
- data/lib/git_fit/timezone/resolver.rb +20 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +87 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 66005a7ca77d10a5922f57d085f4354c3ec4b0838faf350b9b2533e1159b7870
|
|
4
|
+
data.tar.gz: 783823d2e2e2b8031572527afdaaa8a5b2b06491b9a98f07671147233609c315
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c89175b5dc5c16ac6def3cf27bb5a9ed3019f3bd0eeac32667948e36449eb3f3f2cf398a76106314be790587233396da65c7f278665c6de92284597d6870f6c4
|
|
7
|
+
data.tar.gz: 6de6c3ed115ce17df85754c0c2d90e8ce069fc91e86593ade7061ee64acdeb3661903667680fb26a4604a4eadf57c5c9b7f8832b3bdd5648d7b15ca0aebd8091
|
data/lib/git-fit.rb
CHANGED
|
@@ -3,6 +3,8 @@ require "yaml"
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
|
|
5
5
|
module GitFit
|
|
6
|
+
SPORT_CATEGORIES = %w[run ride hike walk swim ski workout other].freeze
|
|
7
|
+
|
|
6
8
|
@registered_configs = []
|
|
7
9
|
|
|
8
10
|
class << self
|
|
@@ -11,12 +13,49 @@ module GitFit
|
|
|
11
13
|
def register_config_source(prefix:, keys:, config_path:)
|
|
12
14
|
@registered_configs << { prefix: prefix, keys: keys, config_path: config_path }
|
|
13
15
|
end
|
|
16
|
+
|
|
17
|
+
def activity_to_hash(a)
|
|
18
|
+
return nil unless a
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
run_id: a[:run_id],
|
|
22
|
+
name: a[:name],
|
|
23
|
+
distance: a[:distance],
|
|
24
|
+
moving_time: format_time(a[:moving_time]),
|
|
25
|
+
type: a[:sport_category],
|
|
26
|
+
subtype: a[:sport_type],
|
|
27
|
+
start_date: a[:start_date],
|
|
28
|
+
start_date_local: a[:start_date_local],
|
|
29
|
+
location_country: a[:location_country],
|
|
30
|
+
summary_polyline: a[:summary_polyline],
|
|
31
|
+
average_heartrate: a[:average_heartrate],
|
|
32
|
+
average_speed: a[:average_speed],
|
|
33
|
+
elevation_gain: a[:elevation_gain]
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def format_time(seconds)
|
|
38
|
+
return nil unless seconds
|
|
39
|
+
hours = seconds / 3600
|
|
40
|
+
minutes = (seconds % 3600) / 60
|
|
41
|
+
secs = seconds % 60
|
|
42
|
+
format("%d:%02d:%02d", hours, minutes, secs)
|
|
43
|
+
end
|
|
14
44
|
end
|
|
15
45
|
end
|
|
16
46
|
|
|
17
47
|
require_relative "git_fit/version"
|
|
18
48
|
require_relative "git_fit/config"
|
|
19
49
|
require_relative "git_fit/config_template"
|
|
50
|
+
require_relative "git_fit/sport_mapper"
|
|
51
|
+
require_relative "git_fit/geo"
|
|
52
|
+
require_relative "git_fit/fit"
|
|
53
|
+
require_relative "git_fit/parser"
|
|
54
|
+
require_relative "git_fit/timezone/resolver"
|
|
55
|
+
require_relative "git_fit/privacy/polyline_filter"
|
|
20
56
|
require_relative "git_fit/sync/base"
|
|
21
57
|
require_relative "git_fit/sync/strava"
|
|
58
|
+
require_relative "git_fit/db/connection"
|
|
59
|
+
require_relative "git_fit/db/activity"
|
|
60
|
+
require_relative "git_fit/db/cli"
|
|
22
61
|
require_relative "git_fit/cli"
|
data/lib/git_fit/cli.rb
CHANGED
|
@@ -10,11 +10,51 @@ module GitFit
|
|
|
10
10
|
true
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
desc "db SUBCOMMAND", "Database operations"
|
|
14
|
+
subcommand "db", DB::CLI
|
|
15
|
+
|
|
13
16
|
desc "version", "Show version"
|
|
14
17
|
def version
|
|
15
18
|
puts "git-fit v#{GitFit::VERSION}"
|
|
16
19
|
end
|
|
17
20
|
|
|
21
|
+
desc "parse FILE", "Parse a GPX or TCX file"
|
|
22
|
+
option :save, type: :boolean, desc: "Save parsed activities to database", aliases: "-s"
|
|
23
|
+
def parse(file)
|
|
24
|
+
format = file.match?(/\.gpx$/i) ? :gpx : :tcx
|
|
25
|
+
content = File.read(file)
|
|
26
|
+
parser = format == :gpx ? GitFit::Parser::GPX.new(source: "file") : GitFit::Parser::TCX.new(source: "file")
|
|
27
|
+
activities = parser.call(content)
|
|
28
|
+
|
|
29
|
+
if activities.empty?
|
|
30
|
+
say_status :warn, "No activities found in #{file}", :yellow
|
|
31
|
+
return
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
activities.each do |act|
|
|
35
|
+
say_status :activity, act[:name] || "unnamed", :green
|
|
36
|
+
say " #{act[:sport_category]}#{act[:sport_type] ? " (#{act[:sport_type]})" : ""}"
|
|
37
|
+
say " #{act[:start_date]} | #{act[:distance]&.round(2)}m" if act[:distance]
|
|
38
|
+
say " #{act[:moving_time]}s" if act[:moving_time]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if options[:save]
|
|
42
|
+
db = connect_db
|
|
43
|
+
now = Time.now.utc
|
|
44
|
+
activities.each do |act|
|
|
45
|
+
existing = db[:activities].where(run_id: act[:run_id]).first
|
|
46
|
+
if existing
|
|
47
|
+
db[:activities].where(run_id: act[:run_id]).update(act.merge(updated_at: now))
|
|
48
|
+
else
|
|
49
|
+
db[:activities].insert(act.merge(created_at: now, updated_at: now))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
say_status :done, "Saved #{activities.size} activities", :green
|
|
53
|
+
end
|
|
54
|
+
rescue => e
|
|
55
|
+
say_status :error, "Parse failed: #{e.message}", :red
|
|
56
|
+
end
|
|
57
|
+
|
|
18
58
|
desc "init", "Generate config.yml"
|
|
19
59
|
option :output, type: :string, desc: "Output path", aliases: "-o"
|
|
20
60
|
def init
|
|
@@ -49,6 +89,10 @@ module GitFit
|
|
|
49
89
|
result
|
|
50
90
|
end
|
|
51
91
|
|
|
92
|
+
def connect_db
|
|
93
|
+
GitFit::DB::Connection.new(git_fit_config.db_path).db
|
|
94
|
+
end
|
|
95
|
+
|
|
52
96
|
def deep_dup(obj)
|
|
53
97
|
case obj
|
|
54
98
|
when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Deprecated: use GitFit.activity_to_hash / GitFit.format_time instead.
|
|
2
|
+
# Kept for backward compatibility during migration.
|
|
3
|
+
module GitFit
|
|
4
|
+
module DB
|
|
5
|
+
SPORT_CATEGORIES = GitFit::SPORT_CATEGORIES
|
|
6
|
+
|
|
7
|
+
def self.activity_to_hash(a)
|
|
8
|
+
GitFit.activity_to_hash(a)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.format_time(seconds)
|
|
12
|
+
GitFit.format_time(seconds)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "thor"
|
|
2
|
+
|
|
3
|
+
module GitFit
|
|
4
|
+
module DB
|
|
5
|
+
class CLI < Thor
|
|
6
|
+
desc "migrate", "Run database migrations"
|
|
7
|
+
def migrate
|
|
8
|
+
config = GitFit::Config.new
|
|
9
|
+
GitFit::DB::Connection.new(config.db_path)
|
|
10
|
+
say_status :done, "Migrations up to date", :green
|
|
11
|
+
rescue => e
|
|
12
|
+
say_status :error, "Migration failed: #{e.message}", :red
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "sequel"
|
|
3
|
+
|
|
4
|
+
module GitFit
|
|
5
|
+
module DB
|
|
6
|
+
class Connection
|
|
7
|
+
def initialize(db_path)
|
|
8
|
+
FileUtils.mkdir_p(File.dirname(db_path)) if db_path.include?("/")
|
|
9
|
+
@db = Sequel.sqlite(db_path)
|
|
10
|
+
@db.run("PRAGMA journal_mode=WAL")
|
|
11
|
+
@db.run("PRAGMA busy_timeout=5000")
|
|
12
|
+
run_migrations
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def db
|
|
16
|
+
@db
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def run_migrations
|
|
22
|
+
Sequel.extension :migration
|
|
23
|
+
Sequel::Migrator.run(@db, File.expand_path("../../../db/migrations", __dir__))
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "mini_racer"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module GitFit
|
|
5
|
+
module FIT
|
|
6
|
+
class Decoder
|
|
7
|
+
JS_CODE = File.read(File.join(__dir__, "decoder.js")).freeze
|
|
8
|
+
CTX = MiniRacer::Context.new.tap { |c| c.eval(JS_CODE) }
|
|
9
|
+
|
|
10
|
+
def self.decode(path)
|
|
11
|
+
bytes = File.read(path, mode: "rb").bytes
|
|
12
|
+
json = CTX.eval("FitDecoder.decodeFit(#{bytes.inspect})")
|
|
13
|
+
records = JSON.parse(json)
|
|
14
|
+
records.each do |r|
|
|
15
|
+
r["positionLat"] *= 180 if r["positionLat"]
|
|
16
|
+
r["positionLong"] *= 180 if r["positionLong"]
|
|
17
|
+
end
|
|
18
|
+
records
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.sport(path)
|
|
22
|
+
bytes = File.read(path, mode: "rb").bytes
|
|
23
|
+
CTX.eval("FitDecoder.fitSport(#{bytes.inspect})")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/git_fit/fit.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require_relative "fit/decoder"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
module GitFit
|
|
2
|
+
module Geo
|
|
3
|
+
# 3 coordinate systems: WGS84 (GPS native), GCJ-02 (China offset), BD-09 (Baidu).
|
|
4
|
+
module CoordTransform
|
|
5
|
+
A = 6378245.0
|
|
6
|
+
EE = 0.00669342162296594323
|
|
7
|
+
|
|
8
|
+
X_PI = Math::PI * 3000.0 / 180.0
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def wgs84_to_gcj02(lat, lng)
|
|
13
|
+
return [lat, lng] if out_of_china(lng, lat)
|
|
14
|
+
dlat, dlng = delta(lat, lng)
|
|
15
|
+
[lat + dlat, lng + dlng]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def gcj02_to_wgs84(lat, lng)
|
|
19
|
+
return [lat, lng] if out_of_china(lng, lat)
|
|
20
|
+
dlat, dlng = delta(lat, lng)
|
|
21
|
+
[lat - dlat, lng - dlng]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def gcj02_to_wgs84_exact(lat, lng)
|
|
25
|
+
return [lat, lng] if out_of_china(lng, lat)
|
|
26
|
+
init_lat = lat
|
|
27
|
+
init_lng = lng
|
|
28
|
+
5.times do
|
|
29
|
+
dlat, dlng = delta(init_lat, init_lng)
|
|
30
|
+
init_lat = lat - dlat
|
|
31
|
+
init_lng = lng - dlng
|
|
32
|
+
end
|
|
33
|
+
[init_lat, init_lng]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def gcj02_to_bd09(lat, lng)
|
|
37
|
+
return [lat, lng] if out_of_china(lng, lat)
|
|
38
|
+
x = lng
|
|
39
|
+
y = lat
|
|
40
|
+
z = Math.sqrt(x * x + y * y) + 0.00002 * Math.sin(y * X_PI)
|
|
41
|
+
theta = Math.atan2(y, x) + 0.000003 * Math.cos(x * X_PI)
|
|
42
|
+
bd_lng = z * Math.cos(theta) + 0.0065
|
|
43
|
+
bd_lat = z * Math.sin(theta) + 0.006
|
|
44
|
+
[bd_lat, bd_lng]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def bd09_to_gcj02(lat, lng)
|
|
48
|
+
return [lat, lng] if out_of_china(lng, lat)
|
|
49
|
+
x = lng - 0.0065
|
|
50
|
+
y = lat - 0.006
|
|
51
|
+
z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI)
|
|
52
|
+
theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI)
|
|
53
|
+
gcj_lng = z * Math.cos(theta)
|
|
54
|
+
gcj_lat = z * Math.sin(theta)
|
|
55
|
+
[gcj_lat, gcj_lng]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def bd09_to_wgs84(lat, lng)
|
|
59
|
+
gcj_lat, gcj_lng = bd09_to_gcj02(lat, lng)
|
|
60
|
+
gcj02_to_wgs84_exact(gcj_lat, gcj_lng)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def wgs84_to_bd09(lat, lng)
|
|
64
|
+
gcj_lat, gcj_lng = wgs84_to_gcj02(lat, lng)
|
|
65
|
+
gcj02_to_bd09(gcj_lat, gcj_lng)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def out_of_china(lng, lat)
|
|
69
|
+
lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def delta(lat, lng)
|
|
73
|
+
dlat = transform_lat(lng - 105.0, lat - 35.0)
|
|
74
|
+
dlng = transform_lng(lng - 105.0, lat - 35.0)
|
|
75
|
+
radlat = lat / 180.0 * Math::PI
|
|
76
|
+
magic = Math.sin(radlat)
|
|
77
|
+
magic = 1 - EE * magic * magic
|
|
78
|
+
sqrt_magic = Math.sqrt(magic)
|
|
79
|
+
dlat = (dlat * 180.0) / ((A * (1 - EE)) / (magic * sqrt_magic) * Math::PI)
|
|
80
|
+
dlng = (dlng * 180.0) / (A / sqrt_magic * Math.cos(radlat) * Math::PI)
|
|
81
|
+
[dlat, dlng]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def transform_lat(x, y)
|
|
85
|
+
ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(x.abs)
|
|
86
|
+
ret += (20.0 * Math.sin(6.0 * x * Math::PI) + 20.0 * Math.sin(2.0 * x * Math::PI)) * 2.0 / 3.0
|
|
87
|
+
ret += (20.0 * Math.sin(y * Math::PI) + 40.0 * Math.sin(y / 3.0 * Math::PI)) * 2.0 / 3.0
|
|
88
|
+
ret += (160.0 * Math.sin(y / 12.0 * Math::PI) + 320.0 * Math.sin(y * Math::PI / 30.0)) * 2.0 / 3.0
|
|
89
|
+
ret
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def transform_lng(x, y)
|
|
93
|
+
ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(x.abs)
|
|
94
|
+
ret += (20.0 * Math.sin(6.0 * x * Math::PI) + 20.0 * Math.sin(2.0 * x * Math::PI)) * 2.0 / 3.0
|
|
95
|
+
ret += (20.0 * Math.sin(x * Math::PI) + 40.0 * Math.sin(x / 3.0 * Math::PI)) * 2.0 / 3.0
|
|
96
|
+
ret += (150.0 * Math.sin(x / 12.0 * Math::PI) + 300.0 * Math.sin(x / 30.0 * Math::PI)) * 2.0 / 3.0
|
|
97
|
+
ret
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module GitFit
|
|
2
|
+
module Geo
|
|
3
|
+
module Polyline
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def decode(str)
|
|
7
|
+
return nil unless str && str.length > 0
|
|
8
|
+
|
|
9
|
+
points = []
|
|
10
|
+
index = 0
|
|
11
|
+
lat = 0
|
|
12
|
+
lng = 0
|
|
13
|
+
|
|
14
|
+
while index < str.length
|
|
15
|
+
shift = 0
|
|
16
|
+
result = 0
|
|
17
|
+
begin
|
|
18
|
+
byte = str[index].ord - 63
|
|
19
|
+
index += 1
|
|
20
|
+
result |= (byte & 0x1f) << shift
|
|
21
|
+
shift += 5
|
|
22
|
+
end while byte >= 0x20
|
|
23
|
+
|
|
24
|
+
dlat = (result & 1) != 0 ? ~(result >> 1) : (result >> 1)
|
|
25
|
+
lat += dlat
|
|
26
|
+
|
|
27
|
+
shift = 0
|
|
28
|
+
result = 0
|
|
29
|
+
begin
|
|
30
|
+
byte = str[index].ord - 63
|
|
31
|
+
index += 1
|
|
32
|
+
result |= (byte & 0x1f) << shift
|
|
33
|
+
shift += 5
|
|
34
|
+
end while byte >= 0x20
|
|
35
|
+
|
|
36
|
+
dlng = (result & 1) != 0 ? ~(result >> 1) : (result >> 1)
|
|
37
|
+
lng += dlng
|
|
38
|
+
|
|
39
|
+
points << [lat * 1e-5, lng * 1e-5]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
points
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def encode(points)
|
|
46
|
+
result = ""
|
|
47
|
+
plat = 0
|
|
48
|
+
plng = 0
|
|
49
|
+
|
|
50
|
+
points.each do |lat, lng|
|
|
51
|
+
dlat = ((lat * 1e5).round - plat)
|
|
52
|
+
dlng = ((lng * 1e5).round - plng)
|
|
53
|
+
|
|
54
|
+
plat += dlat
|
|
55
|
+
plng += dlng
|
|
56
|
+
|
|
57
|
+
result += encode_number(dlat)
|
|
58
|
+
result += encode_number(dlng)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
result
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def encode_number(num)
|
|
65
|
+
num = num < 0 ? ~(num << 1) : (num << 1)
|
|
66
|
+
result = ""
|
|
67
|
+
|
|
68
|
+
while num >= 0x20
|
|
69
|
+
result += ((0x20 | (num & 0x1f)) + 63).chr
|
|
70
|
+
num >>= 5
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
result += (num + 63).chr
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/git_fit/geo.rb
ADDED
|
@@ -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,80 @@
|
|
|
1
|
+
module GitFit
|
|
2
|
+
module SportMapper
|
|
3
|
+
ALIASES = {
|
|
4
|
+
"running" => "run", "trailrun" => "run", "trail_run" => "run",
|
|
5
|
+
"virtualrun" => "run", "virtual_run" => "run",
|
|
6
|
+
"indoorrun" => "run", "indoor_run" => "run",
|
|
7
|
+
"cycling" => "ride", "biking" => "ride",
|
|
8
|
+
"mountainbike" => "ride", "gravelride" => "ride", "ebikeride" => "ride",
|
|
9
|
+
"virtualride" => "ride", "virtual_ride" => "ride",
|
|
10
|
+
"indoor_ride" => "ride", "indoorride" => "ride",
|
|
11
|
+
"hiking" => "hike",
|
|
12
|
+
"walking" => "walk",
|
|
13
|
+
"swimming" => "swim",
|
|
14
|
+
"skiing" => "ski", "backcountryski" => "ski", "nordicski" => "ski",
|
|
15
|
+
"strengthtraining" => "workout"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
KEYWORDS = [
|
|
19
|
+
[/\b(?:run|jog|sprint|marathon)\w*\b/i, "run"],
|
|
20
|
+
[/\b(?:ride|cycl|bike|mountain\.?bike|gravel|ebike)\w*\b/i, "ride"],
|
|
21
|
+
[/\b(?:hike|climb|mountaineer|trek)\w*\b/i, "hike"],
|
|
22
|
+
[/\b(?:walk|stroll)\w*\b/i, "walk"],
|
|
23
|
+
[/\b(?:swim|pool|laps?)\w*\b/i, "swim"],
|
|
24
|
+
[/\b(?:ski|snowboard|nordic)\w*\b/i, "ski"],
|
|
25
|
+
[/\b(?:workout|train|gym|fitness|yoga|pilates|crossfit|elliptical|strength)\w*\b/i, "workout"]
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
LEV_REFERENCES = %w[
|
|
29
|
+
running run trailrun trail_run virtualrun virtual_run indoorrun indoor_run
|
|
30
|
+
cycling ride biking mountainbike gravelride ebikeride
|
|
31
|
+
virtualride virtual_ride indoor_ride indoorride
|
|
32
|
+
hiking hike walking walk swimming swim skiing ski
|
|
33
|
+
workout strengthtraining training elliptical cardio functional
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
LEV_THRESHOLD = 3
|
|
37
|
+
|
|
38
|
+
def self.canonicalize(str)
|
|
39
|
+
return "other" if str.nil? || str.empty?
|
|
40
|
+
key = str.to_s.downcase.strip
|
|
41
|
+
return ALIASES[key] if ALIASES.key?(key)
|
|
42
|
+
KEYWORDS.each { |regex, sport| return sport if regex.match?(str) }
|
|
43
|
+
best = lev_match(key)
|
|
44
|
+
return best if best
|
|
45
|
+
"other"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.lev_match(str)
|
|
49
|
+
best = nil
|
|
50
|
+
best_dist = Float::INFINITY
|
|
51
|
+
LEV_REFERENCES.each do |ref|
|
|
52
|
+
dist = levenshtein(str, ref)
|
|
53
|
+
if dist < best_dist
|
|
54
|
+
best_dist = dist
|
|
55
|
+
best = ref
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
return nil if best_dist > LEV_THRESHOLD
|
|
59
|
+
ALIASES[best] || best
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.levenshtein(a, b)
|
|
63
|
+
n, m = a.length, b.length
|
|
64
|
+
return m if n == 0
|
|
65
|
+
return n if m == 0
|
|
66
|
+
cur = (0..m).to_a
|
|
67
|
+
(1..n).each do |i|
|
|
68
|
+
prev = cur[0]
|
|
69
|
+
cur[0] = i
|
|
70
|
+
(1..m).each do |j|
|
|
71
|
+
tmp = cur[j]
|
|
72
|
+
cost = a[i - 1] == b[j - 1] ? 0 : 1
|
|
73
|
+
cur[j] = [cur[j] + 1, cur[j - 1] + 1, prev + cost].min
|
|
74
|
+
prev = tmp
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
cur[m]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require "timezone_finder"
|
|
2
|
+
|
|
3
|
+
module GitFit
|
|
4
|
+
module Timezone
|
|
5
|
+
class Resolver
|
|
6
|
+
def initialize(config, finder: nil)
|
|
7
|
+
default = config.is_a?(Hash) ? config.dig("system", "timezone") : config
|
|
8
|
+
@finder = finder || TimezoneFinder.create
|
|
9
|
+
@default = default || "Asia/Shanghai"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def resolve(lat, lng)
|
|
13
|
+
return @default unless lat && lng
|
|
14
|
+
tz = @finder.timezone_at(lat: lat.to_f, lng: lng.to_f)
|
|
15
|
+
tz = nil if tz.is_a?(String) && tz.empty?
|
|
16
|
+
tz || @default
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
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
|
+
version: 0.3.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lax
|
|
@@ -23,6 +23,76 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '1.3'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: sequel
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: sqlite3
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: nokogiri
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.16'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.16'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: mini_racer
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.12'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.12'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: timezone_finder
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '1.5'
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '1.5'
|
|
26
96
|
description: A git extension CLI for aggregating, managing, and exporting fitness
|
|
27
97
|
activity data from multiple sources (Garmin, Strava, Keep, etc.)
|
|
28
98
|
email:
|
|
@@ -37,8 +107,24 @@ files:
|
|
|
37
107
|
- lib/git_fit/cli.rb
|
|
38
108
|
- lib/git_fit/config.rb
|
|
39
109
|
- lib/git_fit/config_template.rb
|
|
110
|
+
- lib/git_fit/db/activity.rb
|
|
111
|
+
- lib/git_fit/db/cli.rb
|
|
112
|
+
- lib/git_fit/db/connection.rb
|
|
113
|
+
- lib/git_fit/fit.rb
|
|
114
|
+
- lib/git_fit/fit/decoder.rb
|
|
115
|
+
- lib/git_fit/geo.rb
|
|
116
|
+
- lib/git_fit/geo/coord_transform.rb
|
|
117
|
+
- lib/git_fit/geo/polyline.rb
|
|
118
|
+
- lib/git_fit/parser.rb
|
|
119
|
+
- lib/git_fit/parser/base.rb
|
|
120
|
+
- lib/git_fit/parser/fit.rb
|
|
121
|
+
- lib/git_fit/parser/gpx.rb
|
|
122
|
+
- lib/git_fit/parser/tcx.rb
|
|
123
|
+
- lib/git_fit/privacy/polyline_filter.rb
|
|
124
|
+
- lib/git_fit/sport_mapper.rb
|
|
40
125
|
- lib/git_fit/sync/base.rb
|
|
41
126
|
- lib/git_fit/sync/strava.rb
|
|
127
|
+
- lib/git_fit/timezone/resolver.rb
|
|
42
128
|
- lib/git_fit/version.rb
|
|
43
129
|
homepage: https://github.com/Lax/git-fit
|
|
44
130
|
licenses:
|