git-fit 0.5.9 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 77927cc9d568e7f7b76c291e79a40144a6973a1ec959b53684156bc32e801fd4
4
- data.tar.gz: 6b740fd8f0e3877a6f22d5a287478fcb7983e3e82886d5a24a3035051e6ef73d
3
+ metadata.gz: 75362a4faf88f3ca8497484ba835358eb06d82e36d81ad3e53d7dc4ec877f478
4
+ data.tar.gz: 6cd85cd677977d84670daa160235ff65b5a7ebad55b8cd51ea59cd5661a6bd8a
5
5
  SHA512:
6
- metadata.gz: 682ab1bf78d273e5814603ee7f1fafcfe16bd0020a6703b275f12fe0a23dbe624f198fd94ef47ce9e8f8a05541cf33bd45d7583019708a7e664d650207707be3
7
- data.tar.gz: 208d7e4dd44ea7cfb04b3f343619963597a86da2d86830b7ffa82b06397bb2a9970e8b10d18ce26c1e790dac26a29c5fb6e5e2d12803417c47d09d823ee33a28
6
+ metadata.gz: 3aba69713ec7ba93ead186be8b58fbe577186b83a47ccded9c0f872abe492f15ee5337577e7c4aa36c07f7e6d33588d50231001e85e083156f79324bcf5daca1
7
+ data.tar.gz: 0d8e9e012c911e5609d00be8324995acef30f3a3ff2ba44d9e15380881c1c7882226074d70c5723953b42a0535bf4ec21b329d26fc78d4a9b0f51ca45766a820
data/lib/git-fit.rb CHANGED
@@ -57,6 +57,11 @@ require_relative "git_fit/privacy/polyline_filter"
57
57
  require_relative "git_fit/import"
58
58
  require_relative "git_fit/sync/base"
59
59
  require_relative "git_fit/sync/strava"
60
+ require_relative "git_fit/util"
61
+ require_relative "git_fit/sync/base"
62
+ require_relative "git_fit/sync/strava"
63
+ require_relative "git_fit/sync/lorem"
64
+ require_relative "git_fit/cli/sync"
60
65
  require_relative "git_fit/db/rebuild"
61
66
  require_relative "git_fit/db/connection"
62
67
  require_relative "git_fit/db/activity"
@@ -0,0 +1,50 @@
1
+ module GitFit
2
+ module Cli
3
+ module Sync
4
+ def sync
5
+ db = connect_db
6
+ source = options[:source]
7
+ unless source
8
+ say_status :error, "Use --source to specify a source (e.g. lorem)", :red
9
+ return
10
+ end
11
+
12
+ filter = options[:activity]&.flat_map { |v| v.split(",") }&.map(&:strip)&.map(&:downcase)
13
+ privacy = GitFit::Privacy::PolylineFilter.new(config: git_fit_config.privacy_config)
14
+ adapter = build_sync_adapter(source, db, filter, privacy: privacy)
15
+ unless adapter
16
+ say_status :error, "Unknown source: #{source}", :red
17
+ return
18
+ end
19
+
20
+ say_status :syncing, source, :green
21
+ count = adapter.call
22
+ if count > 0
23
+ say_status :done, "#{source}: #{count} activities synced", :green
24
+ else
25
+ say_status :warn, "#{source}: 0 activities", :yellow
26
+ end
27
+ rescue => e
28
+ say_status :error, "#{source}: #{e.message}", :red
29
+ end
30
+
31
+ private
32
+
33
+ def source_configured?(source)
34
+ return true if GitFit::Sync.const_defined?(source.capitalize)
35
+ config = git_fit_config
36
+ cfg = config.sync_config(source)
37
+ cfg && !cfg.empty?
38
+ end
39
+
40
+ def build_sync_adapter(source, db, filter, privacy: nil)
41
+ case source
42
+ when "lorem"
43
+ GitFit::Sync::Lorem.new(config: git_fit_config.sync_config(source), db: db, activity_filter: filter, privacy: privacy)
44
+ else
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
data/lib/git_fit/cli.rb CHANGED
@@ -3,6 +3,7 @@ require "thor"
3
3
  module GitFit
4
4
  class CLI < Thor
5
5
  package_name "git-fit"
6
+ include Cli::Sync
6
7
 
7
8
  class_option :config, type: :string, desc: "Config file path", aliases: "-c"
8
9
 
@@ -61,6 +62,13 @@ module GitFit
61
62
  say_status :error, "Parse failed: #{e.message}", :red
62
63
  end
63
64
 
65
+ desc "sync", "Sync workout data from a source"
66
+ option :source, type: :string, desc: "Source name (e.g. lorem)"
67
+ option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
68
+ def sync
69
+ super
70
+ end
71
+
64
72
  desc "gh SUBCOMMAND", "Manage GitHub secrets and variables"
65
73
  subcommand "gh", GhCLI
66
74
 
@@ -23,6 +23,9 @@ module GitFit
23
23
  # phone: "" # env: GIT_FIT_IGPSPORT_PASSWORD
24
24
  # password: ""
25
25
 
26
+ # lorem: # env: GIT_FIT_LOREM_COUNT
27
+ # count: 5
28
+
26
29
  export:
27
30
  json:
28
31
  path: site/activities.json
@@ -8,6 +8,10 @@ module GitFit
8
8
  def self.call(db, db_path, migrations_path)
9
9
  backup = "#{db_path}.rebuild_bak"
10
10
  FileUtils.cp(db_path, backup)
11
+ %w[-wal -shm].each do |ext|
12
+ src = "#{db_path}#{ext}"
13
+ FileUtils.cp(src, "#{backup}#{ext}") if File.exist?(src)
14
+ end
11
15
 
12
16
  tables = db.tables - [:schema_migrations]
13
17
  data = {}
@@ -29,8 +33,16 @@ module GitFit
29
33
  rescue => e
30
34
  if backup && File.exist?(backup)
31
35
  FileUtils.cp(backup, db_path)
36
+ %w[-wal -shm].each do |ext|
37
+ src = "#{backup}#{ext}"
38
+ FileUtils.cp(src, "#{db_path}#{ext}") if File.exist?(src)
39
+ end
32
40
  end
33
41
  raise RebuildError, "Rebuild failed: #{e.message}"
42
+ ensure
43
+ [backup, "#{backup}-wal", "#{backup}-shm"].each do |f|
44
+ FileUtils.rm_f(f)
45
+ end
34
46
  end
35
47
 
36
48
  def self.export_table(db, table)
@@ -3,5 +3,7 @@ module GitFit
3
3
  end
4
4
  end
5
5
 
6
- require_relative "source/tally"
6
+ require_relative "util/tally"
7
7
  require_relative "source/base"
8
+
9
+ GitFit::Source::Tally = GitFit::Util::Tally
@@ -1,9 +1,78 @@
1
+ require "fileutils"
2
+ require "time"
3
+
1
4
  module GitFit
2
5
  module Sync
3
6
  class Base
7
+ def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
8
+ @config = config
9
+ @db = db
10
+ @activity_filter = activity_filter
11
+ @privacy = privacy
12
+ @time_budget = time_budget
13
+ @start_time = nil
14
+ @progress_reported = false
15
+ end
16
+
17
+ attr_reader :progress_reported
18
+
19
+ def call
20
+ return 0 unless before_call
21
+ return 0 unless respond_to?(:authenticate) ? authenticate : true
22
+ ids = pending_ids
23
+ return 0 if ids.nil? || ids.empty?
24
+ total = ids.size
25
+ count = 0
26
+ ids.filter_map do |p|
27
+ next if skip_type?(p[:type])
28
+ r = process_one(p)
29
+ count += 1
30
+ report_progress(count, total, build_progress_info(r)) if r
31
+ r
32
+ end.size
33
+ end
34
+
35
+ def before_call
36
+ @start_time = Time.now
37
+ true
38
+ end
39
+
40
+ def pending_ids
41
+ raise NotImplementedError, "#{self.class} must implement #pending_ids"
42
+ end
43
+
44
+ def process_one(pending)
45
+ raise NotImplementedError, "#{self.class} must implement #process_one"
46
+ end
47
+
48
+ def build_progress_info(attrs)
49
+ parts = []
50
+ sd = attrs["start_date"] || attrs[:start_date]
51
+ if sd
52
+ d = Date.parse(sd.to_s) rescue nil
53
+ if d
54
+ date_str = d.year == Date.today.year ? d.strftime("%m-%d") : d.strftime("%Y-%m-%d")
55
+ parts << date_str
56
+ end
57
+ end
58
+ type = attrs["sport_category"] || attrs[:sport_category]
59
+ parts << type if type
60
+ dist = attrs["distance"] || attrs[:distance]
61
+ if dist && dist.to_f > 0
62
+ parts << "#{format("%.1f", dist.to_f / 1000)}km"
63
+ end
64
+ source = attrs["source"] || attrs[:source] || source_label
65
+ name = attrs["name"] || attrs[:name]
66
+ display_name = name.to_s.strip
67
+ display_name = "#{source} Activity" if display_name.empty?
68
+ chars = display_name.chars
69
+ display_name = chars.first(30).join + "…" if chars.size > 30
70
+ parts << display_name
71
+ parts.join(" | ") unless parts.empty?
72
+ end
73
+
4
74
  def self.register_config(*keys)
5
75
  return if keys.empty?
6
-
7
76
  source = name.split("::").last.downcase
8
77
  GitFit.register_config_source(
9
78
  prefix: "GIT_FIT_#{source.upcase}",
@@ -11,6 +80,84 @@ module GitFit
11
80
  config_path: ["sync", source]
12
81
  )
13
82
  end
83
+
84
+ protected
85
+
86
+ def raw_dir
87
+ src = self.class.name.split("::").last.downcase
88
+ File.join("data", "raw", src)
89
+ end
90
+
91
+ def std_dir
92
+ src = self.class.name.split("::").last.downcase
93
+ File.join("data", "std", src)
94
+ end
95
+
96
+ def std_path(platform_id, ext = "json")
97
+ File.join(std_dir, "#{platform_id}.#{ext}")
98
+ end
99
+
100
+ def upsert_activity(attrs)
101
+ attrs = attrs.transform_keys(&:to_s)
102
+ now = Time.now.utc
103
+
104
+ if @privacy && !@privacy.before_saving?
105
+ attrs["summary_polyline"] = @privacy.filter(attrs["summary_polyline"])
106
+ end
107
+
108
+ existing = @db[:activities].where(run_id: attrs["run_id"]).first
109
+ if existing
110
+ attrs.delete("created_at")
111
+ attrs["updated_at"] = now
112
+ @db[:activities].where(run_id: attrs["run_id"]).update(attrs)
113
+ else
114
+ attrs["created_at"] ||= now
115
+ attrs["updated_at"] ||= now
116
+ @db[:activities].insert(attrs)
117
+ end
118
+ end
119
+
120
+ def skip_type?(sport_type)
121
+ return false unless @activity_filter
122
+ !@activity_filter.include?(sport_type.to_s.downcase)
123
+ end
124
+
125
+ def report_progress(count, total = nil, info = nil)
126
+ return unless show_progress?(count, total)
127
+ @progress_reported = true
128
+ line = "#{source_label}: #{count}"
129
+ line += "/#{total}" if total
130
+ line += " #{info}" if info
131
+ line += " "
132
+ print "\r#{line}"
133
+ $stdout.flush
134
+ end
135
+
136
+ def source_label
137
+ name = self.class.name
138
+ name ? name.split("::").last : "Adapter"
139
+ end
140
+
141
+ def time_expired?
142
+ return false unless @time_budget.is_a?(Numeric) && @time_budget > 0 && @start_time
143
+ (Time.now - @start_time) > @time_budget
144
+ end
145
+
146
+ def raw_file_exists?(platform_id)
147
+ File.exist?(raw_path(platform_id))
148
+ end
149
+
150
+ def show_progress?(count, total)
151
+ return true if count == 1
152
+ return true if total && (total - count) <= 50
153
+ step = progress_step(total)
154
+ step <= 1 || count % step == 0
155
+ end
156
+
157
+ def progress_step(total)
158
+ return 1 unless total && total > 0
159
+ [1, (1.5 * (total ** 0.4)).to_i].max
160
+ end
14
161
  end
15
162
  end
16
163
  end
@@ -0,0 +1,124 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require "time"
4
+
5
+ module GitFit
6
+ module Sync
7
+ class Lorem < Base
8
+ register_config :count
9
+
10
+ DEMO_ACTIVITIES = [
11
+ { id: "alpha_001", name: "Lorem Morning Run", sport_category: "run",
12
+ distance: 5200.0, moving_time: 1800, elapsed_time: 2000,
13
+ start_date: "2025-01-15T06:30:00Z", start_date_local: "2025-01-15T14:30:00+08:00",
14
+ location_country: "CN", average_heartrate: 148.0, max_heartrate: 165.0,
15
+ average_cadence: 172.0, max_cadence: 185.0, average_power: nil, max_power: nil,
16
+ average_temperature: 22.0, calories: 380,
17
+ average_speed: 5200.0 / 1800, elevation_gain: 125.0, external_id: "lorem_001" },
18
+ { id: "beta_002", name: "Ipsum Cycling Session", sport_category: "ride",
19
+ distance: 35000.0, moving_time: 5400, elapsed_time: 6000,
20
+ start_date: "2025-01-14T08:00:00Z", start_date_local: "2025-01-14T16:00:00+08:00",
21
+ location_country: "CN", average_heartrate: 138.0, max_heartrate: 155.0,
22
+ average_cadence: 85.0, max_cadence: 105.0, average_power: 185.0, max_power: 420.0,
23
+ average_temperature: 18.0, calories: 850,
24
+ average_speed: 35000.0 / 5400, elevation_gain: 450.0, external_id: "lorem_002" },
25
+ { id: "gamma_003", name: "Dolor Hike", sport_category: "hike",
26
+ distance: 12000.0, moving_time: 7200, elapsed_time: 8100,
27
+ start_date: "2025-01-13T09:00:00Z", start_date_local: "2025-01-13T17:00:00+08:00",
28
+ location_country: "CN", average_heartrate: 122.0, max_heartrate: 142.0,
29
+ average_cadence: 110.0, max_cadence: 135.0, average_power: nil, max_power: nil,
30
+ average_temperature: 15.0, calories: 620,
31
+ average_speed: 12000.0 / 7200, elevation_gain: 820.0, external_id: "lorem_003" },
32
+ { id: "delta_004", name: "Sit Amet Walk", sport_category: "walk",
33
+ distance: 5000.0, moving_time: 3600, elapsed_time: 3900,
34
+ start_date: "2025-01-12T18:00:00Z", start_date_local: "2025-01-12T18:00:00+08:00",
35
+ location_country: "CN", average_heartrate: 108.0, max_heartrate: 125.0,
36
+ average_cadence: 105.0, max_cadence: 118.0, average_power: nil, max_power: nil,
37
+ average_temperature: 20.0, calories: 280,
38
+ average_speed: 5000.0 / 3600, elevation_gain: 35.0, external_id: "lorem_004" },
39
+ { id: "epsilon_005", name: "Consectetur Swim", sport_category: "swim",
40
+ distance: 2000.0, moving_time: 2400, elapsed_time: 2700,
41
+ start_date: "2025-01-11T07:00:00Z", start_date_local: "2025-01-11T15:00:00+08:00",
42
+ location_country: "CN", average_heartrate: 135.0, max_heartrate: 150.0,
43
+ average_cadence: 28.0, max_cadence: 32.0, average_power: nil, max_power: nil,
44
+ average_temperature: 26.0, calories: 520,
45
+ average_speed: 2000.0 / 2400, elevation_gain: 0.0, external_id: "lorem_005" }
46
+ ].freeze
47
+
48
+ def authenticate
49
+ true
50
+ end
51
+
52
+ def pending_ids
53
+ DEMO_ACTIVITIES.map { |act| { id: act[:id], type: act[:sport_category], summary: act } }
54
+ end
55
+
56
+ def process_one(pending)
57
+ attrs = build_attrs(pending[:summary])
58
+
59
+ dir = raw_dir
60
+ FileUtils.mkdir_p(dir)
61
+ File.write(File.join(dir, "#{pending[:id]}.json"), JSON.generate(pending[:summary]))
62
+
63
+ upsert_activity(attrs)
64
+ attrs
65
+ end
66
+
67
+ def raw_path(platform_id)
68
+ File.join(raw_dir, "#{platform_id}.json")
69
+ end
70
+
71
+ private
72
+
73
+ def build_attrs(act)
74
+ coords = generate_route_coords(act[:sport_category], act[:distance])
75
+ polyline = Geo::Polyline.encode(coords)
76
+
77
+ {
78
+ run_id: "lorem_#{act[:id]}",
79
+ name: act[:name],
80
+ distance: act[:distance],
81
+ moving_time: act[:moving_time],
82
+ elapsed_time: act[:elapsed_time],
83
+ sport_category: act[:sport_category],
84
+ sport_type: act[:sport_category],
85
+ start_date: Time.parse(act[:start_date])&.iso8601,
86
+ start_date_local: act[:start_date_local],
87
+ location_country: act[:location_country],
88
+ summary_polyline: polyline,
89
+ average_heartrate: act[:average_heartrate],
90
+ max_heartrate: act[:max_heartrate],
91
+ average_cadence: act[:average_cadence],
92
+ max_cadence: act[:max_cadence],
93
+ average_power: act[:average_power],
94
+ max_power: act[:max_power],
95
+ average_temperature: act[:average_temperature],
96
+ calories: act[:calories],
97
+ average_speed: act[:average_speed],
98
+ elevation_gain: act[:elevation_gain],
99
+ source: "lorem",
100
+ external_id: act[:external_id]
101
+ }
102
+ end
103
+
104
+ def generate_route_coords(category, distance)
105
+ base_lat = 31.2304
106
+ base_lng = 121.4737
107
+ num_points = [(distance / 500.0).ceil, 20].max
108
+
109
+ case category
110
+ when "run"
111
+ (0...num_points).map { |i| [base_lat + i * 0.0002, base_lng + i * 0.0003] }
112
+ when "ride"
113
+ (0...num_points).map { |i| [base_lat + i * 0.0005, base_lng + i * 0.0008] }
114
+ when "hike"
115
+ (0...num_points).map { |i| [base_lat + i * 0.0003 + Math.sin(i * 0.5) * 0.0001, base_lng + i * 0.0004] }
116
+ when "swim"
117
+ [[base_lat, base_lng]]
118
+ else
119
+ (0...num_points).map { |i| [base_lat + i * 0.0002, base_lng + i * 0.0002] }
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,92 @@
1
+ require "json"
2
+
3
+ module GitFit
4
+ module Util
5
+ class Tally
6
+ DIGITS = %w[零 一 二 三 四 五 六 七 八 九 十].freeze
7
+
8
+ attr_reader :value
9
+
10
+ def initialize(count = 0)
11
+ @value = count
12
+ end
13
+
14
+ def add(n = 1)
15
+ @value += n
16
+ self
17
+ end
18
+
19
+ def reset(count = 0)
20
+ @value = count
21
+ self
22
+ end
23
+
24
+ def to_i
25
+ @value.to_i
26
+ end
27
+
28
+ def to_int
29
+ to_i
30
+ end
31
+
32
+ def ==(other)
33
+ to_i == other.to_i
34
+ end
35
+
36
+ def <(other)
37
+ to_i < other.to_i
38
+ end
39
+
40
+ def to_s(format: :chinese)
41
+ case format
42
+ when :arabic then @value.to_s
43
+ else to_chinese
44
+ end
45
+ rescue StandardError
46
+ @value.to_s
47
+ end
48
+
49
+ def inspect
50
+ "#<#{self.class.name} value=#{@value}>"
51
+ end
52
+
53
+ private
54
+
55
+ def to_chinese
56
+ return @value.to_s if @value > 9999
57
+ chinese_numeral
58
+ end
59
+
60
+ def chinese_numeral
61
+ n = @value
62
+ return DIGITS[0] if n == 0
63
+ return DIGITS[n] if n <= 10
64
+
65
+ result = ""
66
+ thousands = n / 1000
67
+ n %= 1000
68
+ result += DIGITS[thousands] + "千" if thousands > 0
69
+
70
+ hundreds = n / 100
71
+ n %= 100
72
+ if hundreds > 0
73
+ result += DIGITS[hundreds] + "百"
74
+ elsif thousands > 0 && n > 0
75
+ result += "零"
76
+ end
77
+
78
+ tens = n / 10
79
+ ones = n % 10
80
+ if tens > 0
81
+ join_ten = tens > 1 || !result.empty?
82
+ result += (join_ten ? DIGITS[tens] : "") + "十"
83
+ elsif !result.empty? && tens == 0 && ones > 0 && !result.end_with?("零")
84
+ result += "零"
85
+ end
86
+
87
+ result += DIGITS[ones] if ones > 0
88
+ result
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,4 @@
1
+ module GitFit
2
+ module Util
3
+ end
4
+ end
@@ -1,3 +1,3 @@
1
1
  module GitFit
2
- VERSION = "0.5.9"
2
+ VERSION = "0.6.1"
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.5.9
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -137,6 +137,7 @@ files:
137
137
  - lib/git_fit/cli/gh_cli.rb
138
138
  - lib/git_fit/cli/import_cli.rb
139
139
  - lib/git_fit/cli/install_cli.rb
140
+ - lib/git_fit/cli/sync.rb
140
141
  - lib/git_fit/config.rb
141
142
  - lib/git_fit/config_template.rb
142
143
  - lib/git_fit/db/activity.rb
@@ -166,8 +167,11 @@ files:
166
167
  - lib/git_fit/source/tally.rb
167
168
  - lib/git_fit/sport_mapper.rb
168
169
  - lib/git_fit/sync/base.rb
170
+ - lib/git_fit/sync/lorem.rb
169
171
  - lib/git_fit/sync/strava.rb
170
172
  - lib/git_fit/timezone/resolver.rb
173
+ - lib/git_fit/util.rb
174
+ - lib/git_fit/util/tally.rb
171
175
  - lib/git_fit/version.rb
172
176
  homepage: https://github.com/Lax/git-fit
173
177
  licenses: