git-fit 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e868be2f4295bf56a1af4e653dcadb26bca9c67a3f4cf42e8c5822b55ae380e6
4
- data.tar.gz: 49470e6b69e829a484e7347a44cb48241f9366a6cb2a345023da903e4045d00f
3
+ metadata.gz: 66005a7ca77d10a5922f57d085f4354c3ec4b0838faf350b9b2533e1159b7870
4
+ data.tar.gz: 783823d2e2e2b8031572527afdaaa8a5b2b06491b9a98f07671147233609c315
5
5
  SHA512:
6
- metadata.gz: d5a45ab91987725f37a00dadf6e16f8c2c45a4258a9bde275bebc8203c12bd7376e9d4605dcd87c1f5432c735c50d9ac30e42deb18b4d92611b1a90418834b7f
7
- data.tar.gz: 2ce6cd5123f4d55ff5f8316c5cdd6c912dc0be3721db63b5a844c149fa38743792a5d2e4c3d46feb3e2d4d23562a7f0c6f04f17d4f9c29728384eb2647f49e07
6
+ metadata.gz: c89175b5dc5c16ac6def3cf27bb5a9ed3019f3bd0eeac32667948e36449eb3f3f2cf398a76106314be790587233396da65c7f278665c6de92284597d6870f6c4
7
+ data.tar.gz: 6de6c3ed115ce17df85754c0c2d90e8ce069fc91e86593ade7061ee64acdeb3661903667680fb26a4604a4eadf57c5c9b7f8832b3bdd5648d7b15ca0aebd8091
data/lib/git-fit.rb CHANGED
@@ -2,7 +2,60 @@ require "thor"
2
2
  require "yaml"
3
3
  require "fileutils"
4
4
 
5
+ module GitFit
6
+ SPORT_CATEGORIES = %w[run ride hike walk swim ski workout other].freeze
7
+
8
+ @registered_configs = []
9
+
10
+ class << self
11
+ attr_reader :registered_configs
12
+
13
+ def register_config_source(prefix:, keys:, config_path:)
14
+ @registered_configs << { prefix: prefix, keys: keys, config_path: config_path }
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
44
+ end
45
+ end
46
+
5
47
  require_relative "git_fit/version"
6
48
  require_relative "git_fit/config"
7
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"
56
+ require_relative "git_fit/sync/base"
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"
8
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) }
@@ -59,10 +59,16 @@ module GitFit
59
59
 
60
60
  private
61
61
 
62
- # v0.1.0: 仅支持 DATABASE_PATH 覆盖
63
- # v0.1.1+: 引入 register_config 注册机制
64
62
  def load_env
65
63
  @data["database"]["path"] = ENV["GIT_FIT_DATABASE_PATH"] if ENV["GIT_FIT_DATABASE_PATH"]
64
+
65
+ GitFit.registered_configs.each do |reg|
66
+ reg[:keys].each do |key|
67
+ env_name = "#{reg[:prefix]}_#{key.to_s.upcase}"
68
+ next unless ENV.key?(env_name)
69
+ set_nested(@data, reg[:config_path] + [key.to_s], ENV[env_name])
70
+ end
71
+ end
66
72
  end
67
73
 
68
74
  def validate!
@@ -79,6 +85,23 @@ module GitFit
79
85
  warn "Config: failed to load #{path}: #{e.message}"
80
86
  end
81
87
 
88
+ def set_nested(hash, keys, value)
89
+ last = keys.pop
90
+ keys.each { |k| hash[k] ||= {}; hash = hash[k] }
91
+ hash[last] = typed_value(value)
92
+ end
93
+
94
+ def typed_value(str)
95
+ case str
96
+ when /\A\d+\z/ then str.to_i
97
+ when /\A\d+\.\d+\z/ then str.to_f
98
+ when /\Atrue\z/i then true
99
+ when /\Afalse\z/i then false
100
+ when /\Anil\z/i then nil
101
+ else str
102
+ end
103
+ end
104
+
82
105
  def deep_merge!(target, source)
83
106
  source.each do |key, val|
84
107
  if val.is_a?(Hash) && target[key].is_a?(Hash)
@@ -7,20 +7,20 @@ module GitFit
7
7
  path: db/fit.sqlite3
8
8
 
9
9
  sync:
10
- # strava:
11
- # client_id: ""
12
- # refresh_token: ""
10
+ # strava: # env: GIT_FIT_STRAVA_CLIENT_ID
11
+ # client_id: "" # env: GIT_FIT_STRAVA_CLIENT_SECRET
12
+ # refresh_token: "" # env: GIT_FIT_STRAVA_REFRESH_TOKEN
13
13
 
14
- # garmin:
15
- # email: ""
14
+ # garmin: # env: GIT_FIT_GARMIN_EMAIL
15
+ # email: "" # env: GIT_FIT_GARMIN_PASSWORD
16
16
  # password: ""
17
17
 
18
- # keep:
19
- # phone: ""
18
+ # keep: # env: GIT_FIT_KEEP_PHONE
19
+ # phone: "" # env: GIT_FIT_KEEP_PASSWORD
20
20
  # password: ""
21
21
 
22
- # igpsport:
23
- # phone: ""
22
+ # igpsport: # env: GIT_FIT_IGPSPORT_PHONE
23
+ # phone: "" # env: GIT_FIT_IGPSPORT_PASSWORD
24
24
  # password: ""
25
25
 
26
26
  export:
@@ -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
@@ -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
@@ -0,0 +1,7 @@
1
+ module GitFit
2
+ module Geo
3
+ end
4
+ end
5
+
6
+ require_relative "geo/polyline"
7
+ require_relative "geo/coord_transform"
@@ -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,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,16 @@
1
+ module GitFit
2
+ module Sync
3
+ class Base
4
+ def self.register_config(*keys)
5
+ return if keys.empty?
6
+
7
+ source = name.split("::").last.downcase
8
+ GitFit.register_config_source(
9
+ prefix: "GIT_FIT_#{source.upcase}",
10
+ keys: keys,
11
+ config_path: ["sync", source]
12
+ )
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,7 @@
1
+ module GitFit
2
+ module Sync
3
+ class Strava < Base
4
+ register_config :client_id, :client_secret, :refresh_token
5
+ end
6
+ end
7
+ 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
@@ -1,3 +1,3 @@
1
1
  module GitFit
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.2"
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.1.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,6 +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
125
+ - lib/git_fit/sync/base.rb
126
+ - lib/git_fit/sync/strava.rb
127
+ - lib/git_fit/timezone/resolver.rb
40
128
  - lib/git_fit/version.rb
41
129
  homepage: https://github.com/Lax/git-fit
42
130
  licenses: