git-fit 0.4.1 → 0.5.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.rb +5 -0
- data/lib/git_fit/cli/import_cli.rb +169 -0
- data/lib/git_fit/cli/install_cli.rb +22 -0
- data/lib/git_fit/cli.rb +6 -0
- data/lib/git_fit/db/connection.rb +5 -1
- data/lib/git_fit/db/rebuild.rb +48 -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/install/actions.rb +90 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +36 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 565aea6097483469ad19f905c14526df7fc9acdf8490c36f5b6edc3664395778
|
|
4
|
+
data.tar.gz: fdaf45ded02748b399694745b49abc206044c6504a3ecb6767abc4269f15e07d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c7213a9ef0e3c1151601d70cac666c91e32b545cfbf18c81882eccfc6f68b54ada354fec65f1eba1ce05ddada2e54ca7f9e23f5cef75ebbf59dc16e1c99e3b7
|
|
7
|
+
data.tar.gz: 2c2df21e495f16a25ff000992325012f8ff80acdb1c255d416749648e102dbb64d1ccd041cbc05bc68ba927a6cecd34fbac0b1b511d238e36f445169fcfd7998
|
data/lib/git-fit.rb
CHANGED
|
@@ -54,11 +54,16 @@ require_relative "git_fit/parser"
|
|
|
54
54
|
require_relative "git_fit/source"
|
|
55
55
|
require_relative "git_fit/timezone/resolver"
|
|
56
56
|
require_relative "git_fit/privacy/polyline_filter"
|
|
57
|
+
require_relative "git_fit/import"
|
|
57
58
|
require_relative "git_fit/sync/base"
|
|
58
59
|
require_relative "git_fit/sync/strava"
|
|
60
|
+
require_relative "git_fit/db/rebuild"
|
|
59
61
|
require_relative "git_fit/db/connection"
|
|
60
62
|
require_relative "git_fit/db/activity"
|
|
61
63
|
require_relative "git_fit/export"
|
|
62
64
|
require_relative "git_fit/db/cli"
|
|
65
|
+
require_relative "git_fit/install/actions"
|
|
66
|
+
require_relative "git_fit/cli/install_cli"
|
|
63
67
|
require_relative "git_fit/cli/export"
|
|
68
|
+
require_relative "git_fit/cli/import_cli"
|
|
64
69
|
require_relative "git_fit/cli"
|
|
@@ -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
|
+
end
|
|
82
|
+
|
|
83
|
+
desc "tcx", "Import TCX files from data/import/tcx/"
|
|
84
|
+
option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
|
|
85
|
+
def tcx
|
|
86
|
+
config = GitFit::Config.new
|
|
87
|
+
db = GitFit::DB::Connection.new(config.db_path).db
|
|
88
|
+
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
89
|
+
time_budget = config.dig("sync", "time_budget")
|
|
90
|
+
|
|
91
|
+
say_status :import, "tcx", :green
|
|
92
|
+
adapter = GitFit::Import::LocalFile.new(
|
|
93
|
+
config: {}, db:, activity_filter: filter,
|
|
94
|
+
time_budget: time_budget,
|
|
95
|
+
sources: ["tcx"]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
begin
|
|
99
|
+
count = adapter.call
|
|
100
|
+
if count == 0
|
|
101
|
+
say_status :warn, "tcx: 0 activities (already imported or none found)", :yellow
|
|
102
|
+
else
|
|
103
|
+
say_status :done, "tcx: #{count} activities", :green
|
|
104
|
+
end
|
|
105
|
+
count
|
|
106
|
+
rescue => e
|
|
107
|
+
say_status :error, "tcx: #{e.message}", :red
|
|
108
|
+
0
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
desc "fit", "Import FIT files from data/import/fit/"
|
|
113
|
+
option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
|
|
114
|
+
def fit
|
|
115
|
+
config = GitFit::Config.new
|
|
116
|
+
db = GitFit::DB::Connection.new(config.db_path).db
|
|
117
|
+
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
118
|
+
time_budget = config.dig("sync", "time_budget")
|
|
119
|
+
|
|
120
|
+
say_status :import, "fit", :green
|
|
121
|
+
adapter = GitFit::Import::LocalFile.new(
|
|
122
|
+
config: {}, db:, activity_filter: filter,
|
|
123
|
+
time_budget: time_budget,
|
|
124
|
+
sources: ["fit"]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
begin
|
|
128
|
+
count = adapter.call
|
|
129
|
+
if count == 0
|
|
130
|
+
say_status :warn, "fit: 0 activities (already imported or none found)", :yellow
|
|
131
|
+
else
|
|
132
|
+
say_status :done, "fit: #{count} activities", :green
|
|
133
|
+
end
|
|
134
|
+
count
|
|
135
|
+
rescue => e
|
|
136
|
+
say_status :error, "fit: #{e.message}", :red
|
|
137
|
+
0
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
desc "apple_health", "Import from Apple Health export.zip"
|
|
142
|
+
option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
|
|
143
|
+
def apple_health
|
|
144
|
+
config = GitFit::Config.new
|
|
145
|
+
db = GitFit::DB::Connection.new(config.db_path).db
|
|
146
|
+
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
147
|
+
time_budget = config.dig("sync", "time_budget")
|
|
148
|
+
|
|
149
|
+
say_status :import, "apple_health", :green
|
|
150
|
+
adapter = GitFit::Import::AppleHealth.new(
|
|
151
|
+
config: {}, db:, activity_filter: filter,
|
|
152
|
+
time_budget: time_budget
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
begin
|
|
156
|
+
count = adapter.call
|
|
157
|
+
if count == 0
|
|
158
|
+
say_status :warn, "apple_health: 0 activities (already imported or none found)", :yellow
|
|
159
|
+
else
|
|
160
|
+
say_status :done, "apple_health: #{count} activities", :green
|
|
161
|
+
end
|
|
162
|
+
count
|
|
163
|
+
rescue => e
|
|
164
|
+
say_status :error, "apple_health: #{e.message}", :red
|
|
165
|
+
0
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "thor"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module GitFit
|
|
5
|
+
class InstallCLI < Thor
|
|
6
|
+
desc "actions", "Install GitHub Actions (db-cache restore/save)"
|
|
7
|
+
def actions
|
|
8
|
+
base = ".github/actions/db-cache"
|
|
9
|
+
restore_path = File.join(base, "restore", "action.yml")
|
|
10
|
+
save_path = File.join(base, "save", "action.yml")
|
|
11
|
+
|
|
12
|
+
FileUtils.mkdir_p(File.dirname(restore_path))
|
|
13
|
+
FileUtils.mkdir_p(File.dirname(save_path))
|
|
14
|
+
|
|
15
|
+
File.write(restore_path, GitFit::Install::Actions::RESTORE_ACTION)
|
|
16
|
+
File.write(save_path, GitFit::Install::Actions::SAVE_ACTION)
|
|
17
|
+
|
|
18
|
+
say_status :created, restore_path, :green
|
|
19
|
+
say_status :created, save_path, :green
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/git_fit/cli.rb
CHANGED
|
@@ -13,6 +13,9 @@ 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
|
+
|
|
16
19
|
desc "export SUBCOMMAND", "Export activities to various formats"
|
|
17
20
|
subcommand "export", ExportCLI
|
|
18
21
|
|
|
@@ -58,6 +61,9 @@ module GitFit
|
|
|
58
61
|
say_status :error, "Parse failed: #{e.message}", :red
|
|
59
62
|
end
|
|
60
63
|
|
|
64
|
+
desc "install SUBCOMMAND", "Install project assets (actions)"
|
|
65
|
+
subcommand "install", InstallCLI
|
|
66
|
+
|
|
61
67
|
desc "init", "Generate config.yml"
|
|
62
68
|
option :output, type: :string, desc: "Output path", aliases: "-o"
|
|
63
69
|
def init
|
|
@@ -20,7 +20,11 @@ module GitFit
|
|
|
20
20
|
|
|
21
21
|
def run_migrations
|
|
22
22
|
Sequel.extension :migration
|
|
23
|
-
|
|
23
|
+
path = File.expand_path("../../../db/migrations", __dir__)
|
|
24
|
+
Sequel::Migrator.run(@db, path)
|
|
25
|
+
rescue Sequel::Migrator::Error
|
|
26
|
+
GitFit::DB::Rebuild.call(@db, @db.opts[:database], path)
|
|
27
|
+
Sequel::Migrator.run(@db, path)
|
|
24
28
|
end
|
|
25
29
|
end
|
|
26
30
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module GitFit
|
|
4
|
+
module DB
|
|
5
|
+
class RebuildError < StandardError; end
|
|
6
|
+
|
|
7
|
+
module Rebuild
|
|
8
|
+
def self.call(db, db_path, migrations_path)
|
|
9
|
+
backup = "#{db_path}.rebuild_bak"
|
|
10
|
+
FileUtils.cp(db_path, backup)
|
|
11
|
+
|
|
12
|
+
tables = db.tables - [:schema_migrations]
|
|
13
|
+
data = {}
|
|
14
|
+
tables.each { |t| data[t] = export_table(db, t) }
|
|
15
|
+
|
|
16
|
+
db.run("PRAGMA foreign_keys = OFF")
|
|
17
|
+
tables.each { |t| db.drop_table(t) }
|
|
18
|
+
|
|
19
|
+
Sequel.extension :migration
|
|
20
|
+
Sequel::Migrator.run(db, migrations_path)
|
|
21
|
+
|
|
22
|
+
new_tables = db.tables.to_set
|
|
23
|
+
data.each do |table, rows|
|
|
24
|
+
next unless new_tables.include?(table)
|
|
25
|
+
import_table(db, table, rows) if rows.any?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
warn "Rebuilt #{db_path}: #{data.values.sum(&:size)} rows from #{data.size} tables"
|
|
29
|
+
rescue => e
|
|
30
|
+
if backup && File.exist?(backup)
|
|
31
|
+
FileUtils.cp(backup, db_path)
|
|
32
|
+
end
|
|
33
|
+
raise RebuildError, "Rebuild failed: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.export_table(db, table)
|
|
37
|
+
db[table].all.map { |r| r.except(:id) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.import_table(db, table, rows)
|
|
41
|
+
return if rows.empty?
|
|
42
|
+
target = db[table].columns - [:id]
|
|
43
|
+
filtered = rows.map { |r| r.select { |k, _| target.include?(k) } }
|
|
44
|
+
db[table].multi_insert(filtered)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "json"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
require "time"
|
|
6
|
+
require "tzinfo"
|
|
7
|
+
require "zip"
|
|
8
|
+
|
|
9
|
+
module GitFit
|
|
10
|
+
module Import
|
|
11
|
+
class AppleHealth < Source::Base
|
|
12
|
+
SKIPPED_SOURCE_PATTERNS = [
|
|
13
|
+
/\bStrava\b/i, /\bKeep\b/i, /\biGPSPORT\b/i,
|
|
14
|
+
/\bXOSS\b/i, /\bXingZhe\b/i, /\bConnect\b/i
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
APPLE_WATCH_PATTERN = /Apple Watch/
|
|
18
|
+
|
|
19
|
+
DEFAULT_ZIP_PATH = "data/import/apple_health/export.zip"
|
|
20
|
+
|
|
21
|
+
HeartRateRecord = Struct.new(:time, :value, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
MIN_GPX_SPEED = 0.1
|
|
24
|
+
|
|
25
|
+
def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
|
|
26
|
+
super
|
|
27
|
+
@zip_path = @config["zip_path"] || DEFAULT_ZIP_PATH
|
|
28
|
+
@heart_rate_index = nil
|
|
29
|
+
@heart_rate_by_date = nil
|
|
30
|
+
@skip_patterns = load_skip_sources
|
|
31
|
+
@timezone_resolver = GitFit::Timezone::Resolver.new(config)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def before_call
|
|
35
|
+
return false unless File.exist?(@zip_path)
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def authenticate
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call
|
|
44
|
+
return 0 unless before_call
|
|
45
|
+
return 0 unless authenticate
|
|
46
|
+
ids = pending_ids
|
|
47
|
+
return 0 if ids.nil? || ids.empty?
|
|
48
|
+
|
|
49
|
+
total = ids.size
|
|
50
|
+
processed = 0
|
|
51
|
+
ids.each do |p|
|
|
52
|
+
break if time_expired? && processed.positive?
|
|
53
|
+
r = process_one(p)
|
|
54
|
+
processed += 1 if r
|
|
55
|
+
report_progress(processed, total, build_progress_info(r)) if r
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
processed
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def pending_ids
|
|
62
|
+
existing = existing_run_ids_set
|
|
63
|
+
results = []
|
|
64
|
+
hr_records = []
|
|
65
|
+
@route_index = []
|
|
66
|
+
zip = Zip::File.new(@zip_path)
|
|
67
|
+
xml_entry = zip.find_entry("apple_health_export/export.xml")
|
|
68
|
+
return results unless xml_entry
|
|
69
|
+
|
|
70
|
+
io = xml_entry.get_input_stream
|
|
71
|
+
buffer = ""
|
|
72
|
+
in_workout = false
|
|
73
|
+
route_buf = nil
|
|
74
|
+
|
|
75
|
+
io.each_line do |line|
|
|
76
|
+
if line.include?("HKQuantityTypeIdentifierHeartRate") && APPLE_WATCH_PATTERN.match?(line)
|
|
77
|
+
hr = parse_hr_line(line)
|
|
78
|
+
hr_records << hr if hr
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if line.include?("<WorkoutRoute ")
|
|
82
|
+
route_buf = line
|
|
83
|
+
elsif route_buf
|
|
84
|
+
route_buf << line
|
|
85
|
+
if line.include?("</WorkoutRoute>")
|
|
86
|
+
process_route_entry(route_buf)
|
|
87
|
+
route_buf = nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if !in_workout
|
|
92
|
+
if line.include?("<Workout ")
|
|
93
|
+
in_workout = true
|
|
94
|
+
buffer = line
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
buffer << line
|
|
98
|
+
if line.include?("</Workout>")
|
|
99
|
+
process_workout_chunk(buffer, existing, results)
|
|
100
|
+
buffer = ""
|
|
101
|
+
in_workout = false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
@heart_rate_index = hr_records.sort_by(&:time)
|
|
107
|
+
|
|
108
|
+
@heart_rate_by_date = {}
|
|
109
|
+
@heart_rate_index.each do |hr|
|
|
110
|
+
date_key = hr.time.strftime("%Y-%m-%d")
|
|
111
|
+
@heart_rate_by_date[date_key] ||= []
|
|
112
|
+
@heart_rate_by_date[date_key] << hr
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
results
|
|
116
|
+
ensure
|
|
117
|
+
zip&.close
|
|
118
|
+
io&.close rescue nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def process_one(pending)
|
|
122
|
+
raw_xml = pending[:raw_xml]
|
|
123
|
+
run_id = pending[:run_id]
|
|
124
|
+
source_name = pending[:source_name]
|
|
125
|
+
stats = pending[:stats]
|
|
126
|
+
meta = pending[:meta]
|
|
127
|
+
route_paths = pending[:route_paths]
|
|
128
|
+
start_date = pending[:start_date]
|
|
129
|
+
end_date = pending[:end_date]
|
|
130
|
+
duration = pending[:duration]
|
|
131
|
+
duration_unit = pending[:duration_unit]
|
|
132
|
+
workout_type = pending[:workout_type]
|
|
133
|
+
|
|
134
|
+
FileUtils.mkdir_p(raw_dir)
|
|
135
|
+
tmp = File.join(raw_dir, ".#{run_id}.xml.tmp")
|
|
136
|
+
final = File.join(raw_dir, "#{run_id}.xml")
|
|
137
|
+
File.write(tmp, raw_xml)
|
|
138
|
+
File.rename(tmp, final)
|
|
139
|
+
|
|
140
|
+
hr_avg = nil
|
|
141
|
+
hr_max = nil
|
|
142
|
+
if @heart_rate_index&.any?
|
|
143
|
+
hr_avg, hr_max = correlate_heart_rate(start_date, end_date)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
l2_points = nil
|
|
147
|
+
polyline = nil
|
|
148
|
+
elevation_gain = nil
|
|
149
|
+
|
|
150
|
+
if route_paths&.any?
|
|
151
|
+
gpx_l1_path = File.join(raw_dir, "#{run_id}.gpx")
|
|
152
|
+
unless File.exist?(gpx_l1_path)
|
|
153
|
+
begin
|
|
154
|
+
zip = Zip::File.new(@zip_path)
|
|
155
|
+
route_paths.each do |gpx_path|
|
|
156
|
+
normalized = gpx_path.sub(%r{^/?}, "apple_health_export/")
|
|
157
|
+
entry = zip.find_entry(normalized)
|
|
158
|
+
next unless entry
|
|
159
|
+
data = entry.get_input_stream.read
|
|
160
|
+
tmpp = File.join(raw_dir, ".#{run_id}.gpx.tmp")
|
|
161
|
+
File.write(tmpp, data)
|
|
162
|
+
File.rename(tmpp, gpx_l1_path)
|
|
163
|
+
break
|
|
164
|
+
end
|
|
165
|
+
rescue => e
|
|
166
|
+
$stderr.puts "AppleHealth: GPX extract error for #{run_id}: #{e.message}"
|
|
167
|
+
ensure
|
|
168
|
+
zip&.close
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
l2_points = File.exist?(gpx_l1_path) ? parse_gpx_file(gpx_l1_path) : nil
|
|
173
|
+
|
|
174
|
+
if l2_points&.any?
|
|
175
|
+
FileUtils.mkdir_p(std_dir)
|
|
176
|
+
std_data = l2_points.map { |pt|
|
|
177
|
+
h = { latitude: pt[:lat], longitude: pt[:lng] }
|
|
178
|
+
h[:altitude] = pt[:ele] if pt[:ele]
|
|
179
|
+
h[:timestamp] = pt[:time] if pt[:time]
|
|
180
|
+
h[:speed] = pt[:speed] if pt[:speed]
|
|
181
|
+
h
|
|
182
|
+
}
|
|
183
|
+
tmp2 = File.join(std_dir, ".#{run_id}.json.tmp")
|
|
184
|
+
final2 = File.join(std_dir, "#{run_id}.json")
|
|
185
|
+
File.write(tmp2, JSON.generate(std_data))
|
|
186
|
+
File.rename(tmp2, final2)
|
|
187
|
+
|
|
188
|
+
coords = l2_points.map { |pt| [pt[:lat], pt[:lng]] }
|
|
189
|
+
polyline = GitFit::Geo::Polyline.encode(coords)
|
|
190
|
+
elevation_gain = elevation_gain_from_points(l2_points)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
title = build_title(workout_type, start_date)
|
|
195
|
+
attrs = build_attrs(source_name, run_id, stats, meta, polyline, elevation_gain,
|
|
196
|
+
workout_type, start_date, end_date, duration, duration_unit,
|
|
197
|
+
hr_avg, hr_max)
|
|
198
|
+
attrs[:title] = title
|
|
199
|
+
|
|
200
|
+
if attrs[:start_date_local].nil? && l2_points&.any?
|
|
201
|
+
first_pt = l2_points.first
|
|
202
|
+
tz = @timezone_resolver.resolve(first_pt[:lat], first_pt[:lng])
|
|
203
|
+
if tz && attrs[:start_date]
|
|
204
|
+
attrs[:start_date_local] = apple_utc_to_local(attrs[:start_date], tz)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
upsert_activity(attrs)
|
|
209
|
+
attrs
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def source_label
|
|
213
|
+
"AppleHealth"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def raw_dir
|
|
217
|
+
File.join("data", "raw", "apple_health")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def std_dir
|
|
221
|
+
File.join("data", "std", "apple_health")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def raw_path(platform_id)
|
|
225
|
+
File.join(raw_dir, "#{platform_id}.xml")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def known_source?(source_name)
|
|
231
|
+
return false unless source_name
|
|
232
|
+
@skip_patterns.any? { |pattern| pattern.match?(source_name) }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def load_skip_sources
|
|
236
|
+
configured = @config["skip_sources"]
|
|
237
|
+
return [] unless configured
|
|
238
|
+
|
|
239
|
+
list = configured.is_a?(String) ? configured.split(",").map(&:strip) : configured
|
|
240
|
+
validated = list.select do |name|
|
|
241
|
+
SKIPPED_SOURCE_PATTERNS.any? { |p| p.match?(name) }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
SKIPPED_SOURCE_PATTERNS.select { |p|
|
|
245
|
+
validated.any? { |v| p.match?(v) }
|
|
246
|
+
}
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def existing_run_ids_set
|
|
250
|
+
@db[:activities].where(source: "apple_health").select_map(:run_id).map(&:to_s).to_set
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def parse_hr_line(line)
|
|
254
|
+
m = line.match(/startDate="([^"]+)".*?value="(\d+)"/)
|
|
255
|
+
return nil unless m
|
|
256
|
+
time = Time.parse(m[1]) rescue nil
|
|
257
|
+
return nil unless time
|
|
258
|
+
HeartRateRecord.new(time: time, value: m[2].to_i)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def correlate_heart_rate(start_date_str, end_date_str)
|
|
262
|
+
start_t = Time.parse(start_date_str) rescue nil
|
|
263
|
+
end_t = Time.parse(end_date_str) rescue nil
|
|
264
|
+
return [nil, nil] unless start_t && end_t
|
|
265
|
+
|
|
266
|
+
padding = 30
|
|
267
|
+
records = collect_hr_in_window(start_t - padding, end_t + padding)
|
|
268
|
+
return [nil, nil] if records.empty?
|
|
269
|
+
|
|
270
|
+
vals = records.map(&:value)
|
|
271
|
+
avg = (vals.sum.to_f / vals.size).round(1)
|
|
272
|
+
max = vals.max
|
|
273
|
+
[avg, max]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def collect_hr_in_window(start_t, end_t)
|
|
277
|
+
if @heart_rate_by_date
|
|
278
|
+
results = []
|
|
279
|
+
(start_t.to_date..end_t.to_date).each do |date|
|
|
280
|
+
date_key = date.strftime("%Y-%m-%d")
|
|
281
|
+
bucket = @heart_rate_by_date[date_key]
|
|
282
|
+
if bucket&.any?
|
|
283
|
+
lo = bucket.bsearch_index { |hr| hr.time >= start_t }
|
|
284
|
+
hi = bucket.bsearch_index { |hr| hr.time > end_t }
|
|
285
|
+
if lo
|
|
286
|
+
slice = hi ? bucket[lo...hi] : bucket[lo..]
|
|
287
|
+
results.concat(slice) if slice&.any?
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
results
|
|
292
|
+
else
|
|
293
|
+
return [] unless @heart_rate_index&.any?
|
|
294
|
+
lo = @heart_rate_index.bsearch_index { |hr| hr.time >= start_t }
|
|
295
|
+
hi = @heart_rate_index.bsearch_index { |hr| hr.time > end_t }
|
|
296
|
+
return [] unless lo
|
|
297
|
+
slice = hi ? @heart_rate_index[lo...hi] : @heart_rate_index[lo..]
|
|
298
|
+
slice || []
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def process_workout_chunk(raw_xml, existing_ids, results)
|
|
303
|
+
doc = Nokogiri::XML("<root>#{raw_xml}</root>")
|
|
304
|
+
workout = doc.at_xpath("//Workout")
|
|
305
|
+
return unless workout
|
|
306
|
+
|
|
307
|
+
source_name = workout["sourceName"]
|
|
308
|
+
return if known_source?(source_name)
|
|
309
|
+
|
|
310
|
+
workout_type = workout["workoutActivityType"]
|
|
311
|
+
return unless workout_type
|
|
312
|
+
|
|
313
|
+
start_date = workout["startDate"]
|
|
314
|
+
end_date = workout["endDate"]
|
|
315
|
+
return if start_date.nil? || end_date.nil?
|
|
316
|
+
|
|
317
|
+
duration = workout["duration"]
|
|
318
|
+
duration_unit = workout["durationUnit"] || "min"
|
|
319
|
+
return if duration.nil?
|
|
320
|
+
|
|
321
|
+
creation_date = workout["creationDate"] || start_date
|
|
322
|
+
run_id = generate_run_id(source_name, creation_date, start_date, end_date)
|
|
323
|
+
return if existing_ids.include?("apple_health_#{run_id}")
|
|
324
|
+
|
|
325
|
+
category = map_sport_type(workout_type)
|
|
326
|
+
return if skip_type?(category)
|
|
327
|
+
|
|
328
|
+
stats = extract_statistics(workout)
|
|
329
|
+
meta = extract_metadata(workout)
|
|
330
|
+
route_paths = extract_route_paths(workout)
|
|
331
|
+
route_paths = match_routes_by_time(start_date, end_date) if route_paths.empty?
|
|
332
|
+
source_version = workout["sourceVersion"]
|
|
333
|
+
|
|
334
|
+
results << {
|
|
335
|
+
id: run_id,
|
|
336
|
+
type: category,
|
|
337
|
+
raw_xml: raw_xml,
|
|
338
|
+
run_id: run_id,
|
|
339
|
+
source_name: source_name,
|
|
340
|
+
source_version: source_version,
|
|
341
|
+
workout_type: workout_type,
|
|
342
|
+
duration: duration,
|
|
343
|
+
duration_unit: duration_unit,
|
|
344
|
+
start_date: start_date,
|
|
345
|
+
end_date: end_date,
|
|
346
|
+
stats: stats,
|
|
347
|
+
meta: meta,
|
|
348
|
+
route_paths: route_paths,
|
|
349
|
+
creation_date: creation_date
|
|
350
|
+
}
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def process_route_entry(xml)
|
|
354
|
+
doc = Nokogiri::XML("<root>#{xml}</root>")
|
|
355
|
+
route = doc.at_xpath("//WorkoutRoute")
|
|
356
|
+
return unless route
|
|
357
|
+
|
|
358
|
+
start_date = route["startDate"]
|
|
359
|
+
end_date = route["endDate"]
|
|
360
|
+
return unless start_date && end_date
|
|
361
|
+
|
|
362
|
+
paths = route.xpath("FileReference").filter_map { |ref| ref["path"] }
|
|
363
|
+
return if paths.empty?
|
|
364
|
+
|
|
365
|
+
@route_index << {
|
|
366
|
+
route_start: (Time.parse(start_date) rescue nil),
|
|
367
|
+
route_end: (Time.parse(end_date) rescue nil),
|
|
368
|
+
route_paths: paths
|
|
369
|
+
}
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def match_routes_by_time(workout_start, workout_end)
|
|
373
|
+
return [] unless @route_index&.any? && workout_start && workout_end
|
|
374
|
+
ws = Time.parse(workout_start) rescue nil
|
|
375
|
+
we = Time.parse(workout_end) rescue nil
|
|
376
|
+
return [] unless ws && we
|
|
377
|
+
|
|
378
|
+
window_start = ws - 300
|
|
379
|
+
window_end = we + 300
|
|
380
|
+
|
|
381
|
+
@route_index.select { |r|
|
|
382
|
+
r[:route_start] && r[:route_end] &&
|
|
383
|
+
r[:route_start] < window_end && r[:route_end] > window_start
|
|
384
|
+
}.flat_map { |r| r[:route_paths] }.uniq
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def extract_statistics(workout)
|
|
388
|
+
stats = {}
|
|
389
|
+
workout.xpath("WorkoutStatistics").each do |stat|
|
|
390
|
+
type = stat["type"]
|
|
391
|
+
sum = stat["sum"]
|
|
392
|
+
unit = stat["unit"]
|
|
393
|
+
next unless type && sum
|
|
394
|
+
stats[type] = { "sum" => sum, "unit" => unit }
|
|
395
|
+
end
|
|
396
|
+
stats
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def extract_metadata(workout)
|
|
400
|
+
meta = {}
|
|
401
|
+
workout.xpath("MetadataEntry").each do |entry|
|
|
402
|
+
key = entry["key"]
|
|
403
|
+
value = entry["value"]
|
|
404
|
+
meta[key] = value if key && value
|
|
405
|
+
end
|
|
406
|
+
meta
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def extract_route_paths(workout)
|
|
410
|
+
workout.xpath("WorkoutRoute/FileReference").filter_map do |ref|
|
|
411
|
+
ref["path"]
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def parse_gpx_file(file_path)
|
|
416
|
+
doc = Nokogiri::XML(File.read(file_path))
|
|
417
|
+
doc.remove_namespaces!
|
|
418
|
+
doc.xpath("//trkpt").filter_map do |pt|
|
|
419
|
+
lat = pt["lat"]&.to_f
|
|
420
|
+
lng = pt["lon"]&.to_f
|
|
421
|
+
next unless lat && lng
|
|
422
|
+
ele = pt.at_xpath("ele")&.text&.to_f
|
|
423
|
+
time_str = pt.at_xpath("time")&.text
|
|
424
|
+
ext = pt.at_xpath("extensions")
|
|
425
|
+
speed = ext&.at_xpath("speed")&.text&.to_f
|
|
426
|
+
speed = nil if speed && speed < MIN_GPX_SPEED
|
|
427
|
+
{ lat: lat, lng: lng, ele: ele, time: time_str, speed: speed }
|
|
428
|
+
end
|
|
429
|
+
rescue => e
|
|
430
|
+
$stderr.puts "AppleHealth: GPX parse error for #{file_path}: #{e.message}"
|
|
431
|
+
nil
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def build_attrs(_source_name, run_id, stats, meta, polyline, elevation_gain,
|
|
435
|
+
workout_type, start_date, end_date, duration, duration_unit,
|
|
436
|
+
hr_avg = nil, hr_max = nil)
|
|
437
|
+
distance = nil
|
|
438
|
+
calories = nil
|
|
439
|
+
|
|
440
|
+
cals_sum = 0.0
|
|
441
|
+
stats.each do |type_str, data|
|
|
442
|
+
if type_str.include?("Distance")
|
|
443
|
+
distance = parse_distance(data["sum"], data["unit"])
|
|
444
|
+
elsif type_str.include?("ActiveEnergyBurned") || type_str.include?("BasalEnergyBurned")
|
|
445
|
+
cals_sum += data["sum"].to_f
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
calories = cals_sum > 0 ? cals_sum.round : nil
|
|
449
|
+
|
|
450
|
+
moving_time = parse_duration(duration, duration_unit)
|
|
451
|
+
elapsed_time = compute_elapsed(start_date, end_date) || moving_time
|
|
452
|
+
avg_speed = if distance && moving_time&.positive?
|
|
453
|
+
(distance.to_f / moving_time).round(2)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
{
|
|
457
|
+
run_id: "apple_health_#{run_id}",
|
|
458
|
+
name: display_name(workout_type, start_date),
|
|
459
|
+
distance: distance,
|
|
460
|
+
moving_time: moving_time,
|
|
461
|
+
elapsed_time: elapsed_time,
|
|
462
|
+
sport_category: map_sport_type(workout_type),
|
|
463
|
+
sport_type: workout_type,
|
|
464
|
+
start_date: format_datetime(start_date),
|
|
465
|
+
start_date_local: meta["HKTimeZone"] ? format_datetime(start_date, utc: false) : nil,
|
|
466
|
+
summary_polyline: polyline,
|
|
467
|
+
average_heartrate: hr_avg,
|
|
468
|
+
max_heartrate: hr_max,
|
|
469
|
+
average_speed: avg_speed,
|
|
470
|
+
calories: calories,
|
|
471
|
+
elevation_gain: elevation_gain&.round(1),
|
|
472
|
+
average_temperature: parse_temperature(meta["HKWeatherTemperature"]),
|
|
473
|
+
source: "apple_health",
|
|
474
|
+
external_id: meta["HKExternalUUID"]
|
|
475
|
+
}
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def format_datetime(str, utc: true)
|
|
479
|
+
return nil unless str
|
|
480
|
+
t = Time.parse(str) rescue nil
|
|
481
|
+
return str unless t
|
|
482
|
+
utc ? t.utc.iso8601 : t.iso8601
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def compute_elapsed(start_str, end_str)
|
|
486
|
+
return nil unless start_str && end_str
|
|
487
|
+
s = Time.parse(start_str).utc rescue nil
|
|
488
|
+
e = Time.parse(end_str).utc rescue nil
|
|
489
|
+
return nil unless s && e
|
|
490
|
+
(e - s).round
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def parse_duration(value, unit)
|
|
494
|
+
v = value.to_f
|
|
495
|
+
case unit.to_s.downcase
|
|
496
|
+
when "min" then (v * 60).round
|
|
497
|
+
when "sec" then v.round
|
|
498
|
+
when "hour" then (v * 3600).round
|
|
499
|
+
else (v * 60).round
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def parse_distance(value, unit)
|
|
504
|
+
v = value.to_f
|
|
505
|
+
case unit.to_s.downcase
|
|
506
|
+
when "km" then (v * 1000).round(2)
|
|
507
|
+
when "mi" then (v * 1609.344).round(2)
|
|
508
|
+
when "m" then v.round(2)
|
|
509
|
+
when "ft" then (v * 0.3048).round(2)
|
|
510
|
+
else v.round(2)
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def parse_temperature(value)
|
|
515
|
+
return nil unless value && !value.empty?
|
|
516
|
+
m = value.match(/([\d.]+)\s*(deg[FC]|°[FC]|℃)/i)
|
|
517
|
+
return nil unless m
|
|
518
|
+
num = m[1].to_f
|
|
519
|
+
unit = m[2].downcase
|
|
520
|
+
if unit.start_with?("degf") || unit.start_with?("°f")
|
|
521
|
+
((num - 32) * 5.0 / 9.0).round(1)
|
|
522
|
+
else
|
|
523
|
+
num.round(1)
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def generate_run_id(source_name, creation_date, start_date, end_date)
|
|
528
|
+
s = normalize_date(start_date)
|
|
529
|
+
e = normalize_date(end_date)
|
|
530
|
+
c = normalize_date(creation_date)
|
|
531
|
+
base = Digest::MD5.hexdigest("#{source_name}|#{s}|#{e}")[0, 7]
|
|
532
|
+
disc = Digest::MD5.hexdigest("#{source_name}|#{c}|#{s}|#{e}")[0, 4]
|
|
533
|
+
"#{base}#{disc}"
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def normalize_date(date_str)
|
|
537
|
+
t = Time.parse(date_str) rescue nil
|
|
538
|
+
return date_str unless t
|
|
539
|
+
t.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def map_sport_type(workout_type)
|
|
543
|
+
return "other" unless workout_type
|
|
544
|
+
key = workout_type.sub(/\AHKWorkoutActivityType/, "")
|
|
545
|
+
return "workout" if key.downcase.include?("strength")
|
|
546
|
+
GitFit::SportMapper.canonicalize(key)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def display_name(workout_type, _start_date)
|
|
550
|
+
workout_type.sub(/\AHKWorkoutActivityType/, "")
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def build_title(workout_type, start_date)
|
|
554
|
+
label = workout_type.sub(/\AHKWorkoutActivityType/, "")
|
|
555
|
+
date = start_date ? start_date[0..9] : nil
|
|
556
|
+
date ? "#{date} · #{label}" : label
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def elevation_gain_from_points(points)
|
|
560
|
+
total = 0.0
|
|
561
|
+
(1...points.length).each do |i|
|
|
562
|
+
elev1 = points[i - 1][:ele] || 0.0
|
|
563
|
+
elev2 = points[i][:ele] || 0.0
|
|
564
|
+
gain = elev2 - elev1
|
|
565
|
+
total += gain if gain > 0
|
|
566
|
+
end
|
|
567
|
+
total
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def apple_utc_to_local(utc_iso8601, tz_name)
|
|
571
|
+
return utc_iso8601 unless utc_iso8601 && tz_name
|
|
572
|
+
t = Time.parse(utc_iso8601)
|
|
573
|
+
return utc_iso8601 unless t.utc?
|
|
574
|
+
tz = TZInfo::Timezone.get(tz_name)
|
|
575
|
+
local = tz.utc_to_local(t)
|
|
576
|
+
local.iso8601
|
|
577
|
+
rescue TZInfo::InvalidTimezoneIdentifier, ArgumentError
|
|
578
|
+
utc_iso8601
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "json"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
require "tzinfo"
|
|
6
|
+
|
|
7
|
+
module GitFit
|
|
8
|
+
module Import
|
|
9
|
+
class LocalFile < Source::Base
|
|
10
|
+
IMPORT_DIRS = {
|
|
11
|
+
"gpx" => "data/import/gpx",
|
|
12
|
+
"tcx" => "data/import/tcx",
|
|
13
|
+
"fit" => "data/import/fit"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
SUPPORTED_EXTENSIONS = %w[.gpx .tcx .fit].freeze
|
|
17
|
+
|
|
18
|
+
NS_TCX = { tcx: "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" }.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil, sources: nil, sport_override: nil)
|
|
21
|
+
super(config:, db:, activity_filter:, privacy:, time_budget:)
|
|
22
|
+
@sources = sources
|
|
23
|
+
@sport_override = sport_override
|
|
24
|
+
@timezone_resolver = GitFit::Timezone::Resolver.new(config)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
return 0 unless authenticate
|
|
29
|
+
|
|
30
|
+
total_imported = 0
|
|
31
|
+
@sources.each do |source|
|
|
32
|
+
dir = IMPORT_DIRS[source]
|
|
33
|
+
next unless dir && File.directory?(dir)
|
|
34
|
+
|
|
35
|
+
files = Dir.glob(File.join(dir, "*"), File::FNM_CASEFOLD)
|
|
36
|
+
.select { |f| SUPPORTED_EXTENSIONS.include?(File.extname(f).downcase) && File.file?(f) }
|
|
37
|
+
|
|
38
|
+
total_imported += process_files(files, source)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
total_imported
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def authenticate
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def pending_ids
|
|
49
|
+
[]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def source_label
|
|
53
|
+
"LocalFile"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def raw_dir_for(source)
|
|
57
|
+
File.join("data", "raw", source)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def std_dir_for(source)
|
|
61
|
+
File.join("data", "std", source)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def process_files(files, source)
|
|
67
|
+
total_activities = 0
|
|
68
|
+
total = files.size
|
|
69
|
+
|
|
70
|
+
files.each_with_index do |file_path, idx|
|
|
71
|
+
break if time_expired? && total_activities.positive?
|
|
72
|
+
|
|
73
|
+
result = process_single_file(file_path, source)
|
|
74
|
+
if result
|
|
75
|
+
activity_count = result[:activity_count] || 1
|
|
76
|
+
total_activities += activity_count
|
|
77
|
+
report_progress(idx + 1, total, "#{activity_count} act from #{File.basename(file_path)}")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
total_activities
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def process_single_file(file_path, source)
|
|
85
|
+
content = File.binread(file_path)
|
|
86
|
+
sha = Digest::SHA256.hexdigest(content)
|
|
87
|
+
platform_id = "#{source}_#{sha[0, 12]}"
|
|
88
|
+
|
|
89
|
+
return nil if existing_run_id?(platform_id)
|
|
90
|
+
|
|
91
|
+
ext = File.extname(file_path).downcase
|
|
92
|
+
raw_source_dir = raw_dir_for(source)
|
|
93
|
+
|
|
94
|
+
raw_path = File.join(raw_source_dir, "#{platform_id}#{ext}")
|
|
95
|
+
FileUtils.mkdir_p(raw_source_dir)
|
|
96
|
+
tmp = "#{raw_path}.tmp"
|
|
97
|
+
File.binwrite(tmp, content)
|
|
98
|
+
File.rename(tmp, raw_path)
|
|
99
|
+
|
|
100
|
+
activities = parse_file(ext, content, file_path)
|
|
101
|
+
return nil if activities.empty?
|
|
102
|
+
|
|
103
|
+
l2_points = extract_l2_points(ext, content, file_path)
|
|
104
|
+
|
|
105
|
+
imported = 0
|
|
106
|
+
activities.each do |activity|
|
|
107
|
+
activity[:source] = source
|
|
108
|
+
activity[:run_id] = imported.zero? ? platform_id : "#{platform_id}_#{imported}"
|
|
109
|
+
|
|
110
|
+
if source == "gpx" && @sport_override
|
|
111
|
+
activity[:sport_category] = SportMapper.canonicalize(@sport_override)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
if l2_points&.any?
|
|
115
|
+
std_source_dir = std_dir_for(source)
|
|
116
|
+
FileUtils.mkdir_p(std_source_dir)
|
|
117
|
+
std_data = l2_points.map { |pt|
|
|
118
|
+
h = { latitude: pt[0], longitude: pt[1] }
|
|
119
|
+
h[:altitude] = pt[2] if pt[2]
|
|
120
|
+
h[:timestamp] = pt[3]&.iso8601 if pt[3]
|
|
121
|
+
h[:heart_rate] = pt[4] if pt[4]
|
|
122
|
+
h
|
|
123
|
+
}
|
|
124
|
+
std_tmp = File.join(std_source_dir, ".#{activity[:run_id]}.json.tmp")
|
|
125
|
+
std_final = File.join(std_source_dir, "#{activity[:run_id]}.json")
|
|
126
|
+
File.write(std_tmp, JSON.generate(std_data))
|
|
127
|
+
File.rename(std_tmp, std_final)
|
|
128
|
+
|
|
129
|
+
coords = l2_points.map { |p| [p[0], p[1]] }
|
|
130
|
+
activity[:summary_polyline] = GitFit::Geo::Polyline.encode(coords)
|
|
131
|
+
|
|
132
|
+
first_pt = l2_points.first
|
|
133
|
+
tz = @timezone_resolver.resolve(first_pt[0], first_pt[1])
|
|
134
|
+
if tz && activity[:start_date]
|
|
135
|
+
activity[:start_date_local] = utc_to_local(activity[:start_date], tz)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
upsert_activity(activity)
|
|
140
|
+
imported += 1
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
{ activity_count: imported }
|
|
144
|
+
rescue => e
|
|
145
|
+
$stderr.puts "LocalFile: error importing #{file_path}: #{e.message}"
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def parse_file(ext, content, file_path)
|
|
150
|
+
case ext
|
|
151
|
+
when ".gpx"
|
|
152
|
+
GitFit::Parser::GPX.new.call(content)
|
|
153
|
+
when ".tcx"
|
|
154
|
+
GitFit::Parser::TCX.new.call(content)
|
|
155
|
+
when ".fit"
|
|
156
|
+
GitFit::Parser::FIT.new.call(file_path)
|
|
157
|
+
else
|
|
158
|
+
[]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def extract_l2_points(ext, content, file_path)
|
|
163
|
+
case ext
|
|
164
|
+
when ".gpx"
|
|
165
|
+
extract_gpx_points(content)
|
|
166
|
+
when ".tcx"
|
|
167
|
+
extract_tcx_points(content)
|
|
168
|
+
when ".fit"
|
|
169
|
+
extract_fit_points(file_path)
|
|
170
|
+
else
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def extract_gpx_points(xml)
|
|
176
|
+
doc = Nokogiri::XML(xml)
|
|
177
|
+
doc.remove_namespaces!
|
|
178
|
+
doc.xpath("//trkpt").filter_map do |pt|
|
|
179
|
+
lat = pt["lat"]&.to_f
|
|
180
|
+
lon = pt["lon"]&.to_f
|
|
181
|
+
next unless lat && lon
|
|
182
|
+
ele = pt.at_xpath("ele")&.text&.to_f
|
|
183
|
+
time_str = pt.at_xpath("time")&.text
|
|
184
|
+
time = parse_timestamp(time_str)
|
|
185
|
+
hr = pt.at_xpath("extensions//hr")&.text&.to_f
|
|
186
|
+
[lat, lon, ele, time, hr]
|
|
187
|
+
end
|
|
188
|
+
rescue => e
|
|
189
|
+
$stderr.puts "LocalFile: GPX parse error: #{e.message}"
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def extract_tcx_points(xml)
|
|
194
|
+
doc = Nokogiri::XML(xml)
|
|
195
|
+
doc.xpath("//tcx:Trackpoint", NS_TCX).filter_map do |tp|
|
|
196
|
+
pos = tp.at_xpath("tcx:Position", NS_TCX)
|
|
197
|
+
next unless pos
|
|
198
|
+
lat = pos.at_xpath("tcx:LatitudeDegrees", NS_TCX)&.text&.to_f
|
|
199
|
+
lon = pos.at_xpath("tcx:LongitudeDegrees", NS_TCX)&.text&.to_f
|
|
200
|
+
next unless lat && lon
|
|
201
|
+
ele = tp.at_xpath("tcx:AltitudeMeters", NS_TCX)&.text&.to_f
|
|
202
|
+
time_str = tp.at_xpath("tcx:Time", NS_TCX)&.text
|
|
203
|
+
time = parse_timestamp(time_str)
|
|
204
|
+
hr = tp.at_xpath("tcx:HeartRateBpm/tcx:Value", NS_TCX)&.text&.to_f
|
|
205
|
+
[lat, lon, ele, time, hr]
|
|
206
|
+
end
|
|
207
|
+
rescue => e
|
|
208
|
+
$stderr.puts "LocalFile: TCX parse error: #{e.message}"
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def extract_fit_points(file_path)
|
|
213
|
+
records = GitFit::FIT::Decoder.decode(file_path)
|
|
214
|
+
records.filter_map do |r|
|
|
215
|
+
lat = r["positionLat"]
|
|
216
|
+
lon = r["positionLong"]
|
|
217
|
+
next unless lat && lon
|
|
218
|
+
ts = r["timestamp"]
|
|
219
|
+
time = ts.is_a?(Numeric) ? (Time.utc(1989, 12, 31) + ts) : (Time.parse(ts.to_s) rescue nil)
|
|
220
|
+
[lat, lon, r["altitude"], time, r["heartRate"]]
|
|
221
|
+
end
|
|
222
|
+
rescue => e
|
|
223
|
+
$stderr.puts "LocalFile: FIT decode error: #{e.message}"
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def parse_timestamp(str)
|
|
228
|
+
return nil unless str
|
|
229
|
+
Time.parse(str)
|
|
230
|
+
rescue ArgumentError
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def existing_run_id?(platform_id)
|
|
235
|
+
!@db[:activities].where(run_id: platform_id).empty?
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def utc_to_local(utc_iso8601, tz_name)
|
|
239
|
+
return utc_iso8601 unless utc_iso8601 && tz_name
|
|
240
|
+
t = Time.parse(utc_iso8601)
|
|
241
|
+
return utc_iso8601 unless t.utc?
|
|
242
|
+
tz = TZInfo::Timezone.get(tz_name)
|
|
243
|
+
local = tz.utc_to_local(t)
|
|
244
|
+
local.iso8601
|
|
245
|
+
rescue TZInfo::InvalidTimezoneIdentifier, ArgumentError
|
|
246
|
+
utc_iso8601
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module GitFit
|
|
2
|
+
module Install
|
|
3
|
+
module Actions
|
|
4
|
+
RESTORE_ACTION = <<~RESTORE_ACTION_YAML
|
|
5
|
+
name: DB Cache Restore
|
|
6
|
+
description: Restore SQLite DB with checksum verification
|
|
7
|
+
inputs:
|
|
8
|
+
path:
|
|
9
|
+
required: false
|
|
10
|
+
default: db/fit.sqlite3
|
|
11
|
+
key-prefix:
|
|
12
|
+
required: false
|
|
13
|
+
default: git-fit-db
|
|
14
|
+
runs:
|
|
15
|
+
using: composite
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/cache/restore@v4
|
|
18
|
+
id: restore-db
|
|
19
|
+
with:
|
|
20
|
+
path: |-
|
|
21
|
+
${{ inputs.path }}
|
|
22
|
+
${{ inputs.path }}.checksum
|
|
23
|
+
key: ${{ inputs.key-prefix }}-v1-${{ github.run_id }}
|
|
24
|
+
restore-keys: ${{ inputs.key-prefix }}-v1-
|
|
25
|
+
|
|
26
|
+
- name: Verify checksum
|
|
27
|
+
shell: bash
|
|
28
|
+
run: |
|
|
29
|
+
db="${{ inputs.path }}"
|
|
30
|
+
ck="${db}.checksum"
|
|
31
|
+
if [ ! -f "$ck" ]; then
|
|
32
|
+
echo "No checksum file — first run or cache miss, skipping verify"
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
if ! sha256sum --check "$ck" --quiet 2>/dev/null; then
|
|
36
|
+
echo "::warning::Checksum mismatch — $db may be corrupted"
|
|
37
|
+
echo "Removing stale files"
|
|
38
|
+
rm -f "$db" "$ck"
|
|
39
|
+
else
|
|
40
|
+
echo "Checksum OK — $db is intact"
|
|
41
|
+
fi
|
|
42
|
+
RESTORE_ACTION_YAML
|
|
43
|
+
|
|
44
|
+
SAVE_ACTION = <<~SAVE_ACTION_YAML
|
|
45
|
+
name: DB Cache Save
|
|
46
|
+
description: Save SQLite DB with checksum, skip if unchanged
|
|
47
|
+
inputs:
|
|
48
|
+
path:
|
|
49
|
+
required: false
|
|
50
|
+
default: db/fit.sqlite3
|
|
51
|
+
key-prefix:
|
|
52
|
+
required: false
|
|
53
|
+
default: git-fit-db
|
|
54
|
+
outputs:
|
|
55
|
+
saved:
|
|
56
|
+
description: "true if cache was updated, false if unchanged"
|
|
57
|
+
value: ${{ steps.detect.outputs.saved }}
|
|
58
|
+
runs:
|
|
59
|
+
using: composite
|
|
60
|
+
steps:
|
|
61
|
+
- name: Detect changes
|
|
62
|
+
id: detect
|
|
63
|
+
shell: bash
|
|
64
|
+
run: |
|
|
65
|
+
db="${{ inputs.path }}"
|
|
66
|
+
ck="${db}.checksum"
|
|
67
|
+
new_sum=$(sha256sum "$db" 2>/dev/null || echo "")
|
|
68
|
+
if [ -z "$new_sum" ]; then
|
|
69
|
+
echo "saved=false" >> "$GITHUB_OUTPUT"
|
|
70
|
+
exit 0
|
|
71
|
+
fi
|
|
72
|
+
if [ -f "$ck" ] && sha256sum --check "$ck" --quiet 2>/dev/null; then
|
|
73
|
+
echo "DB unchanged — skip save"
|
|
74
|
+
echo "saved=false" >> "$GITHUB_OUTPUT"
|
|
75
|
+
exit 0
|
|
76
|
+
fi
|
|
77
|
+
echo "$new_sum" > "$ck"
|
|
78
|
+
echo "saved=true" >> "$GITHUB_OUTPUT"
|
|
79
|
+
|
|
80
|
+
- uses: actions/cache/save@v4
|
|
81
|
+
if: steps.detect.outputs.saved == 'true'
|
|
82
|
+
with:
|
|
83
|
+
path: |-
|
|
84
|
+
${{ inputs.path }}
|
|
85
|
+
${{ inputs.path }}.checksum
|
|
86
|
+
key: ${{ inputs.key-prefix }}-v1-${{ github.run_id }}
|
|
87
|
+
SAVE_ACTION_YAML
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lax
|
|
@@ -93,6 +93,34 @@ dependencies:
|
|
|
93
93
|
- - "~>"
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
95
|
version: '1.5'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: tzinfo
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '2.0'
|
|
103
|
+
type: :runtime
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '2.0'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: rubyzip
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '2.0'
|
|
117
|
+
type: :runtime
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '2.0'
|
|
96
124
|
description: A git extension CLI for aggregating, managing, and exporting fitness
|
|
97
125
|
activity data from multiple sources (Garmin, Strava, Keep, etc.)
|
|
98
126
|
email:
|
|
@@ -106,11 +134,14 @@ files:
|
|
|
106
134
|
- lib/git-fit.rb
|
|
107
135
|
- lib/git_fit/cli.rb
|
|
108
136
|
- lib/git_fit/cli/export.rb
|
|
137
|
+
- lib/git_fit/cli/import_cli.rb
|
|
138
|
+
- lib/git_fit/cli/install_cli.rb
|
|
109
139
|
- lib/git_fit/config.rb
|
|
110
140
|
- lib/git_fit/config_template.rb
|
|
111
141
|
- lib/git_fit/db/activity.rb
|
|
112
142
|
- lib/git_fit/db/cli.rb
|
|
113
143
|
- lib/git_fit/db/connection.rb
|
|
144
|
+
- lib/git_fit/db/rebuild.rb
|
|
114
145
|
- lib/git_fit/export.rb
|
|
115
146
|
- lib/git_fit/export/defaults.rb
|
|
116
147
|
- lib/git_fit/export/json.rb
|
|
@@ -119,6 +150,10 @@ files:
|
|
|
119
150
|
- lib/git_fit/geo.rb
|
|
120
151
|
- lib/git_fit/geo/coord_transform.rb
|
|
121
152
|
- lib/git_fit/geo/polyline.rb
|
|
153
|
+
- lib/git_fit/import.rb
|
|
154
|
+
- lib/git_fit/import/apple_health.rb
|
|
155
|
+
- lib/git_fit/import/local_file.rb
|
|
156
|
+
- lib/git_fit/install/actions.rb
|
|
122
157
|
- lib/git_fit/parser.rb
|
|
123
158
|
- lib/git_fit/parser/base.rb
|
|
124
159
|
- lib/git_fit/parser/fit.rb
|