git-fit 0.3.2 → 0.4.6
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 +5 -0
- data/lib/git_fit/cli/export.rb +25 -0
- data/lib/git_fit/cli/import_cli.rb +169 -0
- data/lib/git_fit/cli.rb +6 -0
- data/lib/git_fit/export/defaults.rb +29 -0
- data/lib/git_fit/export/json.rb +112 -0
- data/lib/git_fit/export.rb +7 -0
- data/lib/git_fit/import/apple_health.rb +582 -0
- data/lib/git_fit/import/local_file.rb +250 -0
- data/lib/git_fit/import.rb +2 -0
- data/lib/git_fit/source/base.rb +155 -0
- data/lib/git_fit/source/tally.rb +55 -0
- data/lib/git_fit/source.rb +7 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +40 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8bf1cd02058a384245ee15ef6471ae7847c3d104ce9774ad1c05c78d21cb2313
|
|
4
|
+
data.tar.gz: 7d412258ebe13b65f7f35831e6b40f4aa76fd01377b9dfa7076770c147da6bcf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 82f3d058175b4a66b4e852ad42cccc3a119fe2e980a89179d9f21e19236dc1fec047e7a074db6014351f39c5080f61609759c7456b75d1aa2c0ac8e4c5861c35
|
|
7
|
+
data.tar.gz: 7d43fd945b988ffec45bd88879ce470345320a223afe82d0354a09302adf453c66dbf6aea8da78ec9c6c022e701a985b958164bfa691073efd67a79e814f90ad
|
data/lib/git-fit.rb
CHANGED
|
@@ -51,11 +51,16 @@ require_relative "git_fit/sport_mapper"
|
|
|
51
51
|
require_relative "git_fit/geo"
|
|
52
52
|
require_relative "git_fit/fit"
|
|
53
53
|
require_relative "git_fit/parser"
|
|
54
|
+
require_relative "git_fit/source"
|
|
54
55
|
require_relative "git_fit/timezone/resolver"
|
|
55
56
|
require_relative "git_fit/privacy/polyline_filter"
|
|
57
|
+
require_relative "git_fit/import"
|
|
56
58
|
require_relative "git_fit/sync/base"
|
|
57
59
|
require_relative "git_fit/sync/strava"
|
|
58
60
|
require_relative "git_fit/db/connection"
|
|
59
61
|
require_relative "git_fit/db/activity"
|
|
62
|
+
require_relative "git_fit/export"
|
|
60
63
|
require_relative "git_fit/db/cli"
|
|
64
|
+
require_relative "git_fit/cli/export"
|
|
65
|
+
require_relative "git_fit/cli/import_cli"
|
|
61
66
|
require_relative "git_fit/cli"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require "thor"
|
|
2
|
+
|
|
3
|
+
module GitFit
|
|
4
|
+
class ExportCLI < Thor
|
|
5
|
+
desc "json", "Export activities to JSON"
|
|
6
|
+
option :output, type: :string, aliases: "-o", desc: "Output path"
|
|
7
|
+
option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
|
|
8
|
+
no_commands do
|
|
9
|
+
def git_fit_config
|
|
10
|
+
@git_fit_config ||= GitFit::Config.new(options[:config])
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def json
|
|
15
|
+
config = git_fit_config
|
|
16
|
+
db = GitFit::DB::Connection.new(config.db_path).db
|
|
17
|
+
path = options[:output] || config.export_config.dig("json", "path") || "site/activities.json"
|
|
18
|
+
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
19
|
+
count = Export::JSON.new(db: db, output: path, activity_filter: filter).call
|
|
20
|
+
say_status :done, "Exported #{count} activities to #{path}", :green
|
|
21
|
+
rescue => e
|
|
22
|
+
say_status :error, "JSON export failed: #{e.message}", :red
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
require "thor"
|
|
2
|
+
|
|
3
|
+
module GitFit
|
|
4
|
+
class ImportCLI < Thor
|
|
5
|
+
FILE_SOURCES = %w[gpx fit tcx].freeze
|
|
6
|
+
|
|
7
|
+
desc "all", "Import from all sources (gpx, fit, tcx)"
|
|
8
|
+
option :source, type: :string, aliases: "-s",
|
|
9
|
+
desc: "Import source(s) — comma-separated (default: gpx,fit,tcx)"
|
|
10
|
+
option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
|
|
11
|
+
option :sport, type: :string, desc: "Override sport_category for GPX files"
|
|
12
|
+
def all
|
|
13
|
+
sources = (options[:source] || "gpx,fit,tcx").split(",").map(&:strip).map(&:downcase)
|
|
14
|
+
unknown = sources.reject { |s| FILE_SOURCES.include?(s) }
|
|
15
|
+
unless unknown.empty?
|
|
16
|
+
say_status :error, "Unknown source(s): #{unknown.join(', ')}. Supported: gpx, fit, tcx", :red
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
config = GitFit::Config.new
|
|
21
|
+
db = GitFit::DB::Connection.new(config.db_path).db
|
|
22
|
+
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
23
|
+
time_budget = config.dig("sync", "time_budget")
|
|
24
|
+
sport_override = options[:sport]
|
|
25
|
+
|
|
26
|
+
total = 0
|
|
27
|
+
sources.each do |source|
|
|
28
|
+
say_status :import, source, :green
|
|
29
|
+
adapter = GitFit::Import::LocalFile.new(
|
|
30
|
+
config: {}, db:, activity_filter: filter,
|
|
31
|
+
time_budget: time_budget,
|
|
32
|
+
sources: [source],
|
|
33
|
+
sport_override: sport_override
|
|
34
|
+
)
|
|
35
|
+
count = adapter.call
|
|
36
|
+
say_status :done, "#{source}: #{count} activities", :green
|
|
37
|
+
total += count
|
|
38
|
+
rescue => e
|
|
39
|
+
say_status :error, "#{source}: #{e.message}", :red
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if total.zero?
|
|
43
|
+
say_status :warn, "0 activities imported", :yellow
|
|
44
|
+
else
|
|
45
|
+
say_status :done, "Total: #{total} activities", :green
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
default_command :all
|
|
50
|
+
|
|
51
|
+
desc "gpx", "Import GPX files from data/import/gpx/"
|
|
52
|
+
option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
|
|
53
|
+
option :sport, type: :string, desc: "Override sport_category for GPX files"
|
|
54
|
+
def gpx
|
|
55
|
+
config = GitFit::Config.new
|
|
56
|
+
db = GitFit::DB::Connection.new(config.db_path).db
|
|
57
|
+
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
58
|
+
time_budget = config.dig("sync", "time_budget")
|
|
59
|
+
sport_override = options[:sport]
|
|
60
|
+
|
|
61
|
+
say_status :import, "gpx", :green
|
|
62
|
+
adapter = GitFit::Import::LocalFile.new(
|
|
63
|
+
config: {}, db:, activity_filter: filter,
|
|
64
|
+
time_budget: time_budget,
|
|
65
|
+
sources: ["gpx"],
|
|
66
|
+
sport_override: sport_override
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
begin
|
|
70
|
+
count = adapter.call
|
|
71
|
+
if count == 0
|
|
72
|
+
say_status :warn, "gpx: 0 activities (already imported or none found)", :yellow
|
|
73
|
+
else
|
|
74
|
+
say_status :done, "gpx: #{count} activities", :green
|
|
75
|
+
end
|
|
76
|
+
count
|
|
77
|
+
rescue => e
|
|
78
|
+
say_status :error, "gpx: #{e.message}", :red
|
|
79
|
+
0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
desc "tcx", "Import TCX files from data/import/tcx/"
|
|
83
|
+
option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
|
|
84
|
+
def tcx
|
|
85
|
+
config = GitFit::Config.new
|
|
86
|
+
db = GitFit::DB::Connection.new(config.db_path).db
|
|
87
|
+
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
88
|
+
time_budget = config.dig("sync", "time_budget")
|
|
89
|
+
|
|
90
|
+
say_status :import, "tcx", :green
|
|
91
|
+
adapter = GitFit::Import::LocalFile.new(
|
|
92
|
+
config: {}, db:, activity_filter: filter,
|
|
93
|
+
time_budget: time_budget,
|
|
94
|
+
sources: ["tcx"]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
begin
|
|
98
|
+
count = adapter.call
|
|
99
|
+
if count == 0
|
|
100
|
+
say_status :warn, "tcx: 0 activities (already imported or none found)", :yellow
|
|
101
|
+
else
|
|
102
|
+
say_status :done, "tcx: #{count} activities", :green
|
|
103
|
+
end
|
|
104
|
+
count
|
|
105
|
+
rescue => e
|
|
106
|
+
say_status :error, "tcx: #{e.message}", :red
|
|
107
|
+
0
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
desc "fit", "Import FIT files from data/import/fit/"
|
|
112
|
+
option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
|
|
113
|
+
def fit
|
|
114
|
+
config = GitFit::Config.new
|
|
115
|
+
db = GitFit::DB::Connection.new(config.db_path).db
|
|
116
|
+
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
117
|
+
time_budget = config.dig("sync", "time_budget")
|
|
118
|
+
|
|
119
|
+
say_status :import, "fit", :green
|
|
120
|
+
adapter = GitFit::Import::LocalFile.new(
|
|
121
|
+
config: {}, db:, activity_filter: filter,
|
|
122
|
+
time_budget: time_budget,
|
|
123
|
+
sources: ["fit"]
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
begin
|
|
127
|
+
count = adapter.call
|
|
128
|
+
if count == 0
|
|
129
|
+
say_status :warn, "fit: 0 activities (already imported or none found)", :yellow
|
|
130
|
+
else
|
|
131
|
+
say_status :done, "fit: #{count} activities", :green
|
|
132
|
+
end
|
|
133
|
+
count
|
|
134
|
+
rescue => e
|
|
135
|
+
say_status :error, "fit: #{e.message}", :red
|
|
136
|
+
0
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
desc "apple_health", "Import from Apple Health export.zip"
|
|
141
|
+
option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
|
|
142
|
+
def apple_health
|
|
143
|
+
config = GitFit::Config.new
|
|
144
|
+
db = GitFit::DB::Connection.new(config.db_path).db
|
|
145
|
+
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
146
|
+
time_budget = config.dig("sync", "time_budget")
|
|
147
|
+
|
|
148
|
+
say_status :import, "apple_health", :green
|
|
149
|
+
adapter = GitFit::Import::AppleHealth.new(
|
|
150
|
+
config: {}, db:, activity_filter: filter,
|
|
151
|
+
time_budget: time_budget
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
begin
|
|
155
|
+
count = adapter.call
|
|
156
|
+
if count == 0
|
|
157
|
+
say_status :warn, "apple_health: 0 activities (already imported or none found)", :yellow
|
|
158
|
+
else
|
|
159
|
+
say_status :done, "apple_health: #{count} activities", :green
|
|
160
|
+
end
|
|
161
|
+
count
|
|
162
|
+
rescue => e
|
|
163
|
+
say_status :error, "apple_health: #{e.message}", :red
|
|
164
|
+
0
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
data/lib/git_fit/cli.rb
CHANGED
|
@@ -13,6 +13,12 @@ module GitFit
|
|
|
13
13
|
desc "db SUBCOMMAND", "Database operations"
|
|
14
14
|
subcommand "db", DB::CLI
|
|
15
15
|
|
|
16
|
+
desc "import SUBCOMMAND", "Import activities from local files"
|
|
17
|
+
subcommand "import", ImportCLI
|
|
18
|
+
|
|
19
|
+
desc "export SUBCOMMAND", "Export activities to various formats"
|
|
20
|
+
subcommand "export", ExportCLI
|
|
21
|
+
|
|
16
22
|
desc "version", "Show version"
|
|
17
23
|
def version
|
|
18
24
|
puts "git-fit v#{GitFit::VERSION}"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module GitFit
|
|
2
|
+
module Export
|
|
3
|
+
module Defaults
|
|
4
|
+
SVG = {
|
|
5
|
+
background: "#222222", text: "#FFFFFF", track: "#4DD2FF",
|
|
6
|
+
track2: "#4DD2FF", special: "#FFFF00", special2: "#FFFF00",
|
|
7
|
+
empty_cell: "#444444", dim_past: "#555555", font: "Arial",
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
DASHBOARD = {
|
|
11
|
+
background: "#1a1a2e", surface: "#16213e", hover: "#0f3460",
|
|
12
|
+
active_row: "#1a3a6a", border: "#2a2a4a", text: "#e0e0e0",
|
|
13
|
+
text_secondary: "#888", text_dim: "#666", text_insight: "#aaa",
|
|
14
|
+
accent: "#4DD2FF", font: "-apple-system, BlinkMacSystemFont, sans-serif",
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
CATEGORIES = {
|
|
18
|
+
"run" => "#4DD2FF", "walk" => "#4ECDC4",
|
|
19
|
+
"ride" => "#FF6B6B", "hike" => "#FFD93D",
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
MAP = {
|
|
23
|
+
tiles_dark: "https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png",
|
|
24
|
+
tiles_light: "https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png",
|
|
25
|
+
echarts_theme: "dark",
|
|
26
|
+
}.freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module GitFit
|
|
4
|
+
module Export
|
|
5
|
+
class JSON
|
|
6
|
+
PLACEHOLDER_POLYLINE = "gqqrFurkeU??".freeze
|
|
7
|
+
|
|
8
|
+
def initialize(db:, output:, activity_filter: nil)
|
|
9
|
+
@db = db
|
|
10
|
+
@output = output
|
|
11
|
+
@activity_filter = activity_filter
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
dataset = @db[:activities].order(Sequel.desc(:start_date))
|
|
16
|
+
dataset = dataset.where(sport_category: @activity_filter) if @activity_filter
|
|
17
|
+
|
|
18
|
+
activities = dataset.all
|
|
19
|
+
|
|
20
|
+
run_id_to_group = {}
|
|
21
|
+
@db[:dedup_group_members].each { |gm| run_id_to_group[gm[:run_id]] = gm[:group_id] }
|
|
22
|
+
|
|
23
|
+
group_members_cache = {}
|
|
24
|
+
|
|
25
|
+
formatted = activities.map do |a|
|
|
26
|
+
gid = run_id_to_group[a[:run_id]]
|
|
27
|
+
if gid
|
|
28
|
+
group_members_cache[gid] ||= @db[:dedup_group_members].where(group_id: gid).all
|
|
29
|
+
composite_activity(a, gid, group_members_cache[gid])
|
|
30
|
+
else
|
|
31
|
+
format_activity(a)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
File.write(@output, ::JSON.pretty_generate(formatted))
|
|
36
|
+
formatted.size
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def format_activity(a)
|
|
42
|
+
base = {
|
|
43
|
+
run_id: a[:run_id],
|
|
44
|
+
name: a[:name],
|
|
45
|
+
distance: a[:distance],
|
|
46
|
+
moving_time: a[:moving_time],
|
|
47
|
+
type: a[:sport_category],
|
|
48
|
+
sport_type: a[:sport_type],
|
|
49
|
+
start_date: a[:start_date],
|
|
50
|
+
start_date_local: a[:start_date_local],
|
|
51
|
+
location_country: a[:location_country],
|
|
52
|
+
summary_polyline: a[:source] == "keep" && a[:summary_polyline] == PLACEHOLDER_POLYLINE ?
|
|
53
|
+
nil : a[:summary_polyline],
|
|
54
|
+
average_heartrate: a[:average_heartrate] && a[:average_heartrate] != 0 ? a[:average_heartrate] : nil,
|
|
55
|
+
max_heartrate: a[:max_heartrate],
|
|
56
|
+
average_cadence: a[:average_cadence],
|
|
57
|
+
max_cadence: a[:max_cadence],
|
|
58
|
+
average_power: a[:average_power],
|
|
59
|
+
max_power: a[:max_power],
|
|
60
|
+
calories: a[:calories],
|
|
61
|
+
average_temperature: a[:average_temperature],
|
|
62
|
+
average_speed: a[:average_speed],
|
|
63
|
+
elevation_gain: a[:elevation_gain],
|
|
64
|
+
source: a[:source],
|
|
65
|
+
}
|
|
66
|
+
if a[:divisions]
|
|
67
|
+
base[:divisions] = ::JSON.parse(a[:divisions])
|
|
68
|
+
end
|
|
69
|
+
base
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def composite_activity(activity, group_id, members)
|
|
73
|
+
run_ids = members.map { |m| m[:run_id] }
|
|
74
|
+
group_activities = @db[:activities].where(run_id: run_ids).all
|
|
75
|
+
|
|
76
|
+
composite = if defined?(GitFit::Dedup::FieldElector)
|
|
77
|
+
elector = GitFit::Dedup::FieldElector.new
|
|
78
|
+
result = {}
|
|
79
|
+
GitFit::Dedup::FieldElector::FIELD_ELECTION_RULES.each_key do |field|
|
|
80
|
+
values = group_activities.map { |a| a[field] }
|
|
81
|
+
sources = group_activities.map { |a| a[:source]&.to_sym }
|
|
82
|
+
result[field] = elector.elect(field, values, sources)
|
|
83
|
+
end
|
|
84
|
+
result
|
|
85
|
+
else
|
|
86
|
+
members.first&.slice(:run_id, :distance, :moving_time, :elapsed_time,
|
|
87
|
+
:average_heartrate, :max_heartrate, :elevation_gain,
|
|
88
|
+
:average_speed, :average_cadence, :max_cadence) || {}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
composite[:run_id] = activity[:run_id]
|
|
92
|
+
composite[:source] = activity[:source]
|
|
93
|
+
composite[:sport_type] = activity[:sport_type]
|
|
94
|
+
composite[:start_date_local] = activity[:start_date_local]
|
|
95
|
+
composite[:location_country] = activity[:location_country]
|
|
96
|
+
composite[:sport_category] = activity[:sport_category]
|
|
97
|
+
|
|
98
|
+
if composite[:distance].to_f > 0 && composite[:moving_time].to_f > 0
|
|
99
|
+
composite[:average_speed] = composite[:distance] / composite[:moving_time]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
composite[:type] = composite[:sport_category]
|
|
103
|
+
composite[:dedup_group] = group_id
|
|
104
|
+
composite[:member_sources] = members.map do |m|
|
|
105
|
+
{ source: m[:source], run_id: m[:run_id], flag: m[:flag], confidence: m[:confidence] }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
composite
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|