git-fit 0.11.2 → 0.12.0
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/cli/import_cli.rb +31 -0
- data/lib/git_fit/import/tbulu.rb +181 -0
- data/lib/git_fit/import.rb +1 -0
- data/lib/git_fit/parser/tbulu.rb +304 -0
- data/lib/git_fit/parser.rb +1 -0
- data/lib/git_fit/sync/garmin_base_di.rb +0 -39
- data/lib/git_fit/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d2f295d660e818e0baf5fcd3a624aabff068288c1cc93cbb4a661b046fd58a3c
|
|
4
|
+
data.tar.gz: ee63230c3e1ae93aa7ef1bec736b5011ccbfcf013b35e70c39afcc38acbe2c06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6498333581ad0d68044f77c8de1ffddb1a3a4f6634a92f99081df76e5dda26fc7eb25eeb0eaf8ac0b04637db40257d43ade12c2325a6ebdec63cb95430964113
|
|
7
|
+
data.tar.gz: edda04a53ebaadd13d4bd15c325251bf4ab0f7b500917fe323c52968ca55303162f5fe95a62f4baaf2734ff8e9dc278c1786e44506b32fedcbb9cad43dbfe5c2
|
|
@@ -184,5 +184,36 @@ module GitFit
|
|
|
184
184
|
0
|
|
185
185
|
end
|
|
186
186
|
end
|
|
187
|
+
|
|
188
|
+
desc 'tbulu', 'Import 2bulu (两步路) activities from data/import/2bulu/'
|
|
189
|
+
option :activity, type: :array, desc: 'Filter by sport type', aliases: '--act'
|
|
190
|
+
option :checkpoint, type: :boolean, desc: 'Run WAL checkpoint after import'
|
|
191
|
+
def tbulu
|
|
192
|
+
config = GitFit::Config.new
|
|
193
|
+
conn = GitFit::DB::Connection.new(config.db_path)
|
|
194
|
+
conn.migrate!
|
|
195
|
+
db = conn.db
|
|
196
|
+
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
197
|
+
time_budget = config.dig('sync', 'time_budget')
|
|
198
|
+
|
|
199
|
+
say_status :import, '2bulu', :green
|
|
200
|
+
adapter = GitFit::Import::Tbulu.new(
|
|
201
|
+
config: {}, db:, activity_filter: filter,
|
|
202
|
+
time_budget: time_budget
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
begin
|
|
206
|
+
count = adapter.call
|
|
207
|
+
if count == 0
|
|
208
|
+
say_status :warn, '2bulu: 0 activities (already imported or none found)', :yellow
|
|
209
|
+
else
|
|
210
|
+
say_status :done, "2bulu: #{count} activities", :green
|
|
211
|
+
end
|
|
212
|
+
checkpoint_db(db) if options[:checkpoint]
|
|
213
|
+
rescue StandardError => e
|
|
214
|
+
say_status :error, "2bulu: #{e.message}", :red
|
|
215
|
+
0
|
|
216
|
+
end
|
|
217
|
+
end
|
|
187
218
|
end
|
|
188
219
|
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'time'
|
|
7
|
+
require 'tzinfo'
|
|
8
|
+
|
|
9
|
+
module GitFit
|
|
10
|
+
module Import
|
|
11
|
+
class Tbulu < Source::Base
|
|
12
|
+
DEFAULT_IMPORT_DIR = 'data/import/2bulu'
|
|
13
|
+
|
|
14
|
+
def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
|
|
15
|
+
super
|
|
16
|
+
@import_dir = config['import_dir'] || DEFAULT_IMPORT_DIR
|
|
17
|
+
@timezone_resolver = GitFit::Timezone::Resolver.new(config)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def before_call
|
|
21
|
+
return false unless File.directory?(@import_dir)
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def authenticate
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
return 0 unless before_call
|
|
31
|
+
|
|
32
|
+
files = scan_files
|
|
33
|
+
total = 0
|
|
34
|
+
files.each do |file_path|
|
|
35
|
+
break if time_expired? && total.positive?
|
|
36
|
+
r = process_one(file_path)
|
|
37
|
+
total += 1 if r
|
|
38
|
+
report_progress(total, files.size, File.basename(file_path)) if r
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
total
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pending_ids
|
|
45
|
+
[]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def source_label
|
|
49
|
+
'2bulu'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def raw_dir
|
|
53
|
+
File.join('data', 'raw', '2bulu')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def std_dir
|
|
57
|
+
File.join('data', 'std', '2bulu')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def scan_files
|
|
63
|
+
Dir.glob(File.join(@import_dir, '*'), File::FNM_CASEFOLD).select do |f|
|
|
64
|
+
File.file?(f) && GitFit::Parser::Tbulu.sniff_format(File.binread(f))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def process_one(file_path)
|
|
69
|
+
content = File.binread(file_path)
|
|
70
|
+
format = GitFit::Parser::Tbulu.sniff_format(content)
|
|
71
|
+
return nil unless format
|
|
72
|
+
|
|
73
|
+
parser = GitFit::Parser::Tbulu.new
|
|
74
|
+
activities, points = parser.call_with_points(content)
|
|
75
|
+
return nil if activities.empty?
|
|
76
|
+
|
|
77
|
+
activity = activities.first
|
|
78
|
+
return nil if skip_type?(activity[:sport_category])
|
|
79
|
+
|
|
80
|
+
run_id = build_run_id(activity, points)
|
|
81
|
+
return nil if existing_run_id?(run_id)
|
|
82
|
+
|
|
83
|
+
ext = format == :tk ? '.2tk' : '.kml'
|
|
84
|
+
write_raw(content, run_id, ext)
|
|
85
|
+
write_std(activity, run_id, points)
|
|
86
|
+
|
|
87
|
+
activity[:source] = '2bulu'
|
|
88
|
+
activity[:run_id] = run_id
|
|
89
|
+
upsert_activity(activity)
|
|
90
|
+
activity
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_run_id(activity, points)
|
|
94
|
+
date = extract_date_prefix(activity[:start_date])
|
|
95
|
+
"2bulu_#{date}_#{content_hash(points, activity)[0, 12]}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def content_hash(points, activity)
|
|
99
|
+
point_part = points.map do |p|
|
|
100
|
+
t = p[3].respond_to?(:to_i) ? p[3].to_i : p[3]
|
|
101
|
+
[p[0].round(9), p[1].round(9), p[2].round(3), t].join('|')
|
|
102
|
+
end.join("\n")
|
|
103
|
+
sensor_part = [
|
|
104
|
+
activity[:steps].to_i,
|
|
105
|
+
activity[:calories].to_f.round(1),
|
|
106
|
+
activity[:total_up].to_f.round(1),
|
|
107
|
+
activity[:total_down].to_f.round(1),
|
|
108
|
+
].join('|')
|
|
109
|
+
Digest::SHA256.hexdigest("#{point_part}\n#{sensor_part}")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def upsert_activity(activity)
|
|
113
|
+
# steps/total_up/total_down are hash-only signals, not DB columns
|
|
114
|
+
persistable = activity.reject { |k, _| %i[steps total_up total_down].include?(k) }
|
|
115
|
+
super(persistable)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def extract_date_prefix(start_date)
|
|
119
|
+
t = parse_start_time(start_date)
|
|
120
|
+
return '00000000' unless t
|
|
121
|
+
t.getutc.strftime('%Y%m%d')
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def parse_start_time(start_date)
|
|
125
|
+
return nil unless start_date
|
|
126
|
+
start_date.respond_to?(:getutc) ? start_date : Time.parse(start_date.to_s)
|
|
127
|
+
rescue ArgumentError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def write_raw(content, run_id, ext)
|
|
132
|
+
FileUtils.mkdir_p(raw_dir)
|
|
133
|
+
tmp = File.join(raw_dir, ".#{run_id}#{ext}.tmp")
|
|
134
|
+
final = File.join(raw_dir, "#{run_id}#{ext}")
|
|
135
|
+
File.binwrite(tmp, content)
|
|
136
|
+
File.rename(tmp, final)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def write_std(activity, run_id, points)
|
|
140
|
+
return unless points&.any?
|
|
141
|
+
|
|
142
|
+
std_data = points.map do |pt|
|
|
143
|
+
h = { latitude: pt[0], longitude: pt[1] }
|
|
144
|
+
h[:altitude] = pt[2] if pt[2]
|
|
145
|
+
h[:timestamp] = pt[3]&.iso8601 if pt[3]
|
|
146
|
+
h
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
natural_id = run_id.sub('2bulu_', '')
|
|
150
|
+
FileUtils.mkdir_p(std_dir)
|
|
151
|
+
tmp = File.join(std_dir, ".#{natural_id}.json.tmp")
|
|
152
|
+
final = File.join(std_dir, "#{natural_id}.json")
|
|
153
|
+
File.write(tmp, JSON.generate(std_data))
|
|
154
|
+
File.rename(tmp, final)
|
|
155
|
+
|
|
156
|
+
coords = points.map { |p| [p[0], p[1]] }
|
|
157
|
+
activity[:summary_polyline] = GitFit::Geo::Polyline.encode(coords)
|
|
158
|
+
|
|
159
|
+
first_pt = points.first
|
|
160
|
+
tz = @timezone_resolver.resolve(first_pt[0], first_pt[1])
|
|
161
|
+
return unless tz && activity[:start_date]
|
|
162
|
+
activity[:start_date_local] = utc_to_local(activity[:start_date], tz)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def existing_run_id?(run_id)
|
|
166
|
+
!@db[:activities].where(run_id: run_id).empty?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def utc_to_local(utc_iso8601, tz_name)
|
|
170
|
+
return utc_iso8601 unless utc_iso8601 && tz_name
|
|
171
|
+
t = Time.parse(utc_iso8601)
|
|
172
|
+
return utc_iso8601 unless t.utc?
|
|
173
|
+
tz = TZInfo::Timezone.get(tz_name)
|
|
174
|
+
local = tz.utc_to_local(t)
|
|
175
|
+
local.iso8601
|
|
176
|
+
rescue TZInfo::InvalidTimezoneIdentifier, ArgumentError
|
|
177
|
+
utc_iso8601
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
data/lib/git_fit/import.rb
CHANGED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module GitFit
|
|
7
|
+
module Parser
|
|
8
|
+
class Tbulu < Base
|
|
9
|
+
MAGIC = '##tk'.b
|
|
10
|
+
MOD32 = 2**32
|
|
11
|
+
HOUR8 = 8 * 3600 * 1000
|
|
12
|
+
|
|
13
|
+
SPORT_TYPE_MAP = {
|
|
14
|
+
3 => 'hike',
|
|
15
|
+
18 => 'hike',
|
|
16
|
+
24 => 'run',
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def self.sniff_format(content)
|
|
20
|
+
return :tk if content.start_with?(MAGIC)
|
|
21
|
+
return :kml if content.lstrip.start_with?('<kml', '<?xml')
|
|
22
|
+
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(input)
|
|
27
|
+
activities, = call_with_points(input)
|
|
28
|
+
activities
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call_with_points(input)
|
|
32
|
+
content = input.is_a?(String) ? input.b : input
|
|
33
|
+
return [[], []] if content.nil? || content.empty?
|
|
34
|
+
|
|
35
|
+
format = sniff_format(content)
|
|
36
|
+
case format
|
|
37
|
+
when :tk then parse_2tk_full(content)
|
|
38
|
+
when :kml then parse_kml_full(content)
|
|
39
|
+
else
|
|
40
|
+
[[], []]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def extract_points(input)
|
|
45
|
+
_, points = call_with_points(input)
|
|
46
|
+
points
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def sniff_format(content)
|
|
52
|
+
self.class.sniff_format(content)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# ── 2tk ──────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def parse_2tk_full(content)
|
|
58
|
+
header = extract_2tk_header(content)
|
|
59
|
+
ti = header['trackinfo']
|
|
60
|
+
return [[], []] if ti.nil?
|
|
61
|
+
|
|
62
|
+
start_ms = ti['starttime']
|
|
63
|
+
return [[], []] unless start_ms
|
|
64
|
+
|
|
65
|
+
points = extract_2tk_points(content, header, start_ms)
|
|
66
|
+
return [[], points] if points.empty?
|
|
67
|
+
|
|
68
|
+
times = points.map { |p| p[3] }.compact
|
|
69
|
+
return [[], points] if times.empty?
|
|
70
|
+
|
|
71
|
+
sport = classify_sport(ti['tracktype'], header)
|
|
72
|
+
attrs = build_activity_attrs({
|
|
73
|
+
run_id: build_run_id('2bulu', SecureRandom.hex(8)),
|
|
74
|
+
name: ti['title'],
|
|
75
|
+
distance: total_distance(points),
|
|
76
|
+
moving_time: moving_time_from_points(points),
|
|
77
|
+
elapsed_time: elapsed_time_from_points(points),
|
|
78
|
+
sport_category: sport,
|
|
79
|
+
sport_type: ti['tracktype'],
|
|
80
|
+
start_date: times.min,
|
|
81
|
+
start_date_local: times.min,
|
|
82
|
+
location_country: nil,
|
|
83
|
+
summary_polyline: nil,
|
|
84
|
+
average_heartrate: nil,
|
|
85
|
+
elevation_gain: elevation_gain(points),
|
|
86
|
+
source: '2bulu',
|
|
87
|
+
})
|
|
88
|
+
attach_sensor_stats(attrs, ti)
|
|
89
|
+
[[attrs], points]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_2tk(content)
|
|
93
|
+
attrs, = parse_2tk_full(content)
|
|
94
|
+
attrs
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def attach_sensor_stats(attrs, track_info)
|
|
98
|
+
attrs[:steps] = track_info['steps']
|
|
99
|
+
attrs[:calories] = track_info['calorie']
|
|
100
|
+
attrs[:total_up] = track_info['totalup']
|
|
101
|
+
attrs[:total_down] = track_info['totaldown']
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def extract_2tk_header(content)
|
|
105
|
+
pos = content.index('{')
|
|
106
|
+
return {} unless pos
|
|
107
|
+
|
|
108
|
+
depth = 0
|
|
109
|
+
json_end = nil
|
|
110
|
+
(pos...content.bytesize).each do |i|
|
|
111
|
+
case content.getbyte(i)
|
|
112
|
+
when 0x7b then depth += 1
|
|
113
|
+
when 0x7d then depth -= 1
|
|
114
|
+
end
|
|
115
|
+
json_end = i if depth.zero?
|
|
116
|
+
break if depth.zero?
|
|
117
|
+
end
|
|
118
|
+
return {} unless json_end
|
|
119
|
+
|
|
120
|
+
JSON.parse(content[pos..json_end])
|
|
121
|
+
rescue JSON::ParserError
|
|
122
|
+
{}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def extract_2tk_points(content, _header, start_ms)
|
|
126
|
+
pos = content.index('{')
|
|
127
|
+
return [] unless pos
|
|
128
|
+
|
|
129
|
+
depth = 0
|
|
130
|
+
json_end = nil
|
|
131
|
+
(pos...content.bytesize).each do |i|
|
|
132
|
+
case content.getbyte(i)
|
|
133
|
+
when 0x7b then depth += 1
|
|
134
|
+
when 0x7d then depth -= 1
|
|
135
|
+
end
|
|
136
|
+
json_end = i if depth.zero?
|
|
137
|
+
break if depth.zero?
|
|
138
|
+
end
|
|
139
|
+
return [] unless json_end
|
|
140
|
+
|
|
141
|
+
bin_start = json_end + 1
|
|
142
|
+
pcount = content[bin_start + 10, 4].unpack1('L<')
|
|
143
|
+
pts_raw = content[bin_start + 14, pcount * 36]
|
|
144
|
+
return [] unless pts_raw && pts_raw.bytesize == pcount * 36
|
|
145
|
+
|
|
146
|
+
times = []
|
|
147
|
+
coords = []
|
|
148
|
+
(0...pcount).each do |i|
|
|
149
|
+
off = i * 36
|
|
150
|
+
lat, lng = pts_raw[off, 16].unpack('E2')
|
|
151
|
+
alt = pts_raw[off + 16, 4].unpack1('l<')
|
|
152
|
+
t = pts_raw[off + 24, 4].unpack1('L<')
|
|
153
|
+
q = ((start_ms - t).to_f / MOD32).round
|
|
154
|
+
times << t + q * MOD32
|
|
155
|
+
coords << [lat, lng, alt]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
delta = timezone_fix(times, start_ms)
|
|
159
|
+
times.map! { |t| t + delta }
|
|
160
|
+
|
|
161
|
+
times.each_with_index.map do |t, i|
|
|
162
|
+
c = coords[i]
|
|
163
|
+
[c[0], c[1], c[2], Time.at(t / 1000.0).utc, nil]
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def timezone_fix(times, start_ms)
|
|
168
|
+
return 0 if times.empty?
|
|
169
|
+
(-2..2).each do |k|
|
|
170
|
+
cand = times[0] + k * HOUR8
|
|
171
|
+
return k * HOUR8 if cand >= start_ms - 30 * 60_000 && cand <= start_ms + HOUR8
|
|
172
|
+
end
|
|
173
|
+
0
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# ── KML ─────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def parse_kml_full(content)
|
|
179
|
+
doc = parse_xml(content)
|
|
180
|
+
return [[], []] unless doc
|
|
181
|
+
|
|
182
|
+
ext = extract_extended_data(doc)
|
|
183
|
+
points = extract_kml_points(doc)
|
|
184
|
+
return [[], points] if points.empty?
|
|
185
|
+
|
|
186
|
+
times = points.map { |p| p[3] }.compact
|
|
187
|
+
return [[], points] if times.empty?
|
|
188
|
+
start_date = times.min
|
|
189
|
+
|
|
190
|
+
sport = classify_sport(nil, ext)
|
|
191
|
+
distance = ext['Mileage'].to_f
|
|
192
|
+
distance = ext['Distance'].to_f if distance.zero?
|
|
193
|
+
|
|
194
|
+
attrs = build_activity_attrs({
|
|
195
|
+
run_id: build_run_id('2bulu', SecureRandom.hex(8)),
|
|
196
|
+
name: doc_text(doc, '//Document/name'),
|
|
197
|
+
distance: distance,
|
|
198
|
+
moving_time: moving_time_from_points(points),
|
|
199
|
+
elapsed_time: elapsed_time_from_points(points),
|
|
200
|
+
sport_category: sport,
|
|
201
|
+
sport_type: sport_type_name(ext['SportTypeId']),
|
|
202
|
+
start_date: start_date,
|
|
203
|
+
start_date_local: start_date,
|
|
204
|
+
location_country: nil,
|
|
205
|
+
summary_polyline: nil,
|
|
206
|
+
average_heartrate: nil,
|
|
207
|
+
elevation_gain: elevation_gain(points),
|
|
208
|
+
source: '2bulu',
|
|
209
|
+
})
|
|
210
|
+
attach_kml_stats(attrs, ext)
|
|
211
|
+
[[attrs], points]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def parse_kml(content)
|
|
215
|
+
attrs, = parse_kml_full(content)
|
|
216
|
+
attrs
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def attach_kml_stats(attrs, ext)
|
|
220
|
+
attrs[:steps] = ext['Step'].to_i
|
|
221
|
+
attrs[:calories] = ext['Calorie'].to_f
|
|
222
|
+
attrs[:total_up] = ext['ElevationGain'].to_f
|
|
223
|
+
attrs[:total_down] = ext['ElevationLoss'].to_f
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def extract_kml_points(doc)
|
|
227
|
+
whens = doc.xpath('//when').map(&:text)
|
|
228
|
+
coords = doc.xpath('//coord').map { |c| c.text.strip.split.map(&:to_f) }
|
|
229
|
+
|
|
230
|
+
if whens.empty? || coords.empty?
|
|
231
|
+
whens = []
|
|
232
|
+
coords = []
|
|
233
|
+
doc.xpath('//coordinates').each do |cs|
|
|
234
|
+
cs.text.strip.split.each do |triplet|
|
|
235
|
+
c = triplet.split(',').map(&:to_f)
|
|
236
|
+
coords << c
|
|
237
|
+
whens << nil
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
whens.each_with_index.map do |w, i|
|
|
243
|
+
c = coords[i] || [0, 0, 0]
|
|
244
|
+
[c[1], c[0], c[2], w ? parse_time(w) : nil, nil]
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def extract_extended_data(doc)
|
|
249
|
+
ext = {}
|
|
250
|
+
doc.xpath('//Data').each do |d|
|
|
251
|
+
name = d['name']
|
|
252
|
+
value = d.at_xpath('value')&.text
|
|
253
|
+
ext[name] = value if name && value
|
|
254
|
+
end
|
|
255
|
+
ext
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def parse_xml(content)
|
|
259
|
+
require 'nokogiri'
|
|
260
|
+
doc = Nokogiri::XML(content)
|
|
261
|
+
doc.remove_namespaces!
|
|
262
|
+
doc
|
|
263
|
+
rescue Nokogiri::XML::SyntaxError
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def doc_text(doc, xpath)
|
|
268
|
+
doc.at_xpath(xpath)&.text&.strip
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def parse_time(str)
|
|
272
|
+
return nil unless str
|
|
273
|
+
Time.parse(str)
|
|
274
|
+
rescue ArgumentError
|
|
275
|
+
nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# ── sport classification ────────────────────────────────
|
|
279
|
+
|
|
280
|
+
TRACKTYPE_MAP = {
|
|
281
|
+
'爬山' => 'hike',
|
|
282
|
+
'徒步' => 'hike',
|
|
283
|
+
'跑步' => 'run',
|
|
284
|
+
'骑行' => 'ride',
|
|
285
|
+
}.freeze
|
|
286
|
+
|
|
287
|
+
def classify_sport(tracktype, data)
|
|
288
|
+
sport_type_id = data['SportTypeId'] if data.is_a?(Hash)
|
|
289
|
+
mapped = SPORT_TYPE_MAP[sport_type_id.to_i] if sport_type_id
|
|
290
|
+
return mapped if mapped
|
|
291
|
+
|
|
292
|
+
track_type_id = data['TrackTypeId'] if data.is_a?(Hash)
|
|
293
|
+
return 'hike' if track_type_id.to_i == 8
|
|
294
|
+
|
|
295
|
+
TRACKTYPE_MAP[tracktype.to_s] || normalize_sport_type(tracktype.to_s)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def sport_type_name(id)
|
|
299
|
+
return nil if id.nil? || id.to_i.zero?
|
|
300
|
+
SPORT_TYPE_MAP[id.to_i]
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
data/lib/git_fit/parser.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'garmin_base'
|
|
4
|
-
require 'open3'
|
|
5
4
|
|
|
6
5
|
module GitFit
|
|
7
6
|
module Sync
|
|
@@ -157,44 +156,6 @@ module GitFit
|
|
|
157
156
|
save_tokens
|
|
158
157
|
true
|
|
159
158
|
end
|
|
160
|
-
|
|
161
|
-
# @deprecated Kept for local script reference only.
|
|
162
|
-
# email+password SSO strategies are not called by `authenticate`
|
|
163
|
-
# because garmin.com DI auth requires MFA, handled by
|
|
164
|
-
# git fit auth garmin outside Ruby.
|
|
165
|
-
#
|
|
166
|
-
# Note: method naming (strategy_a = curl_cffi, strategy_b = Playwright) is
|
|
167
|
-
# historical. In git fit auth garmin, Playwright runs first (cookies
|
|
168
|
-
# persist 365d → no repeat MFA), curl_cffi is the fallback.
|
|
169
|
-
def strategy_a_login
|
|
170
|
-
script = File.expand_path('../../../scripts/garmin_auth.py', __dir__)
|
|
171
|
-
return nil unless File.exist?(script)
|
|
172
|
-
|
|
173
|
-
stdout, stderr, status = Open3.capture3(
|
|
174
|
-
{'SYNC__GARMIN__EMAIL' => @email, 'SYNC__GARMIN__PASSWORD' => @password, 'SYNC__GARMIN__DOMAIN' => @domain},
|
|
175
|
-
'python3', script
|
|
176
|
-
)
|
|
177
|
-
warn stderr unless stderr.empty?
|
|
178
|
-
JSON.parse(stdout) if status.success?
|
|
179
|
-
rescue StandardError => e
|
|
180
|
-
puts "Strategy A (curl_cffi) failed: #{e.message}"
|
|
181
|
-
nil
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def strategy_b_login
|
|
185
|
-
script = File.expand_path('../../../scripts/garmin_auth_playwright.py', __dir__)
|
|
186
|
-
return nil unless File.exist?(script)
|
|
187
|
-
|
|
188
|
-
stdout, stderr, status = Open3.capture3(
|
|
189
|
-
{'SYNC__GARMIN__EMAIL' => @email, 'SYNC__GARMIN__PASSWORD' => @password, 'SYNC__GARMIN__DOMAIN' => @domain},
|
|
190
|
-
'python3', script
|
|
191
|
-
)
|
|
192
|
-
warn stderr unless stderr.empty?
|
|
193
|
-
JSON.parse(stdout) if status.success?
|
|
194
|
-
rescue StandardError => e
|
|
195
|
-
puts "Strategy B (Playwright) failed: #{e.message}"
|
|
196
|
-
nil
|
|
197
|
-
end
|
|
198
159
|
end
|
|
199
160
|
end
|
|
200
161
|
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.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lax
|
|
@@ -294,6 +294,7 @@ files:
|
|
|
294
294
|
- lib/git_fit/import.rb
|
|
295
295
|
- lib/git_fit/import/apple_health.rb
|
|
296
296
|
- lib/git_fit/import/local_file.rb
|
|
297
|
+
- lib/git_fit/import/tbulu.rb
|
|
297
298
|
- lib/git_fit/install/actions.rb
|
|
298
299
|
- lib/git_fit/install/actions/db-store/restore.yml.erb
|
|
299
300
|
- lib/git_fit/install/actions/db-store/save.yml.erb
|
|
@@ -301,6 +302,7 @@ files:
|
|
|
301
302
|
- lib/git_fit/parser/base.rb
|
|
302
303
|
- lib/git_fit/parser/fit.rb
|
|
303
304
|
- lib/git_fit/parser/gpx.rb
|
|
305
|
+
- lib/git_fit/parser/tbulu.rb
|
|
304
306
|
- lib/git_fit/parser/tcx.rb
|
|
305
307
|
- lib/git_fit/privacy/polyline_filter.rb
|
|
306
308
|
- lib/git_fit/source.rb
|