purtea 0.0.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 +7 -0
- data/.editorconfig +6 -0
- data/.github/ISSUE_TEMPLATE.md +6 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +36 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +26 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +34 -0
- data/.github/dependabot.yml +25 -0
- data/.github/workflows/main.yml +29 -0
- data/.gitignore +78 -0
- data/.rspec +3 -0
- data/.rubocop.yml +110 -0
- data/.solargraph.yml +22 -0
- data/.vscode/settings.json +3 -0
- data/.yardopts +3 -0
- data/CODE_OF_CONDUCT.md +130 -0
- data/CONTRIBUTING.md +72 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +225 -0
- data/LICENSE +373 -0
- data/README.md +70 -0
- data/Rakefile +36 -0
- data/bin/console +8 -0
- data/bin/setup +7 -0
- data/config.example.toml +8 -0
- data/exe/purtea +6 -0
- data/fflogs_schema.json +8796 -0
- data/google_credentials.example.json +14 -0
- data/lib/purtea.rb +13 -0
- data/lib/purtea/cli.rb +131 -0
- data/lib/purtea/config.rb +71 -0
- data/lib/purtea/fflogs.rb +63 -0
- data/lib/purtea/fflogs/api.rb +100 -0
- data/lib/purtea/fflogs/fight.rb +54 -0
- data/lib/purtea/logging.rb +15 -0
- data/lib/purtea/sheet.rb +87 -0
- data/lib/purtea/version.rb +5 -0
- data/purtea.gemspec +54 -0
- metadata +307 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"installed": {
|
|
3
|
+
"client_id": "CLIENT_ID_HERE",
|
|
4
|
+
"project_id": "PROJECT_ID_HERE",
|
|
5
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
6
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
7
|
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
|
8
|
+
"client_secret": "CLIENT_SECRET_HERE",
|
|
9
|
+
"redirect_uris": [
|
|
10
|
+
"urn:ietf:wg:oauth:2.0:oob",
|
|
11
|
+
"http://localhost"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
}
|
data/lib/purtea.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A tool to handle importing of FF Logs TEA stats to a Google Sheet.
|
|
4
|
+
module Purtea; end
|
|
5
|
+
|
|
6
|
+
require 'purtea/version'
|
|
7
|
+
require 'purtea/logging'
|
|
8
|
+
require 'purtea/config'
|
|
9
|
+
|
|
10
|
+
require 'purtea/fflogs'
|
|
11
|
+
require 'purtea/sheet'
|
|
12
|
+
|
|
13
|
+
require 'purtea/cli'
|
data/lib/purtea/cli.rb
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gli'
|
|
4
|
+
|
|
5
|
+
TEA_ZONE_ID = 887
|
|
6
|
+
|
|
7
|
+
def format_percentage(percentage)
|
|
8
|
+
if percentage.nil?
|
|
9
|
+
'N/A'
|
|
10
|
+
else
|
|
11
|
+
format('%.2f%%', percentage)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def float_comp(first, second)
|
|
16
|
+
(first - second).abs < Float::EPSILON
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def calc_end_phase(fight) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
20
|
+
return '???' unless fight.zone_id == TEA_ZONE_ID
|
|
21
|
+
|
|
22
|
+
return 'N/A' if fight.boss_percentage.nil? || fight.fight_percentage.nil?
|
|
23
|
+
|
|
24
|
+
if fight.boss_percentage.zero? && fight.fight_percentage >= 80.0
|
|
25
|
+
return 'Living Liquid (LL)'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if fight.boss_percentage.positive? && fight.fight_percentage > 75.0
|
|
29
|
+
return 'Living Liquid (LL)'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if fight.boss_percentage.zero? && float_comp(fight.fight_percentage, 75.0)
|
|
33
|
+
return 'Limit Cut (LC)'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if fight.fight_percentage >= 50.0 && fight.fight_percentage <= 75.0 &&
|
|
37
|
+
fight.boss_percentage >= 0.0
|
|
38
|
+
return 'Brute Justice / Cruise Chaser (BJ/CC)'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if fight.fight_percentage <= 50.0 && fight.fight_percentage >= 30.0
|
|
42
|
+
return 'Alexander Prime (AP)'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
'Perfect Alexander (PA)'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_fight(fight) # rubocop:disable Metrics/AbcSize
|
|
49
|
+
[
|
|
50
|
+
fight.id,
|
|
51
|
+
fight.encounter_id,
|
|
52
|
+
fight.zone_id,
|
|
53
|
+
fight.zone_name,
|
|
54
|
+
fight.name,
|
|
55
|
+
fight.difficulty,
|
|
56
|
+
format_percentage(fight.boss_percentage),
|
|
57
|
+
format_percentage(fight.fight_percentage),
|
|
58
|
+
fight.start_at.strftime(Purtea::FFLogs::Fight::ISO_FORMAT),
|
|
59
|
+
fight.end_at.strftime(Purtea::FFLogs::Fight::ISO_FORMAT),
|
|
60
|
+
fight.duration.strftime('%H:%M:%S'),
|
|
61
|
+
calc_end_phase(fight),
|
|
62
|
+
fight.kill? ? 'Y' : 'N'
|
|
63
|
+
]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
module Purtea
|
|
67
|
+
# Defines the CLI interface for Purtea.
|
|
68
|
+
class CLI
|
|
69
|
+
extend GLI::App
|
|
70
|
+
|
|
71
|
+
program_desc 'FF Logs TEA stats import tool'
|
|
72
|
+
program_long_desc <<~LONGDESC
|
|
73
|
+
This tool helps you import TEA stats from FF Logs and writing it to a
|
|
74
|
+
Google Sheet.
|
|
75
|
+
LONGDESC
|
|
76
|
+
version Purtea::VERSION
|
|
77
|
+
|
|
78
|
+
subcommand_option_handling :normal
|
|
79
|
+
arguments :strict
|
|
80
|
+
|
|
81
|
+
desc 'Be verbose'
|
|
82
|
+
switch %i[v verbose]
|
|
83
|
+
|
|
84
|
+
pre do |global_options, _options, _args|
|
|
85
|
+
if global_options[:verbose]
|
|
86
|
+
Purtea.logger.debug!
|
|
87
|
+
else
|
|
88
|
+
Purtea.logger.info!
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
desc 'Import FF Logs report to Google Sheet'
|
|
93
|
+
long_desc <<~LONGDESC
|
|
94
|
+
report_code should be the code for a report containing TEA fights on
|
|
95
|
+
FF Logs.
|
|
96
|
+
|
|
97
|
+
The tool will read the fight stats and import it as a table into the
|
|
98
|
+
configured Google Sheet (at the configured range).
|
|
99
|
+
LONGDESC
|
|
100
|
+
arg :report_code
|
|
101
|
+
command :import do |c|
|
|
102
|
+
c.action do |_, _, args|
|
|
103
|
+
code = args.shift
|
|
104
|
+
config = Purtea::Config.load
|
|
105
|
+
fflogs_api = Purtea::FFLogs::API.new(
|
|
106
|
+
config['fflogs']['client_id'],
|
|
107
|
+
config['fflogs']['client_secret']
|
|
108
|
+
)
|
|
109
|
+
fights = fflogs_api.fights(code).select { |f| f.zone_id == TEA_ZONE_ID }
|
|
110
|
+
|
|
111
|
+
spreadsheet_data = fights.map { |f| parse_fight(f) }
|
|
112
|
+
|
|
113
|
+
sheet = Purtea::SheetApi.new(config['sheet']['id'])
|
|
114
|
+
sheet.append(config['sheet']['range'], spreadsheet_data)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
desc 'Tests the logging system'
|
|
119
|
+
command :testlog do |c|
|
|
120
|
+
c.action do
|
|
121
|
+
l = Purtea.logger
|
|
122
|
+
l.debug('This is a debug log')
|
|
123
|
+
l.info('This is an info log')
|
|
124
|
+
l.warn('This is a warn log')
|
|
125
|
+
l.error('This is an error log')
|
|
126
|
+
l.fatal('This is a fatal log')
|
|
127
|
+
l.unknown('This is an unknown log')
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dotenv/load'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'tomlrb'
|
|
6
|
+
|
|
7
|
+
module Purtea
|
|
8
|
+
# Contains methods to load the Purtea config.
|
|
9
|
+
module Config
|
|
10
|
+
DEFAULT_CONFIG_PATH = 'config.toml'
|
|
11
|
+
DEFAULT_ENV_PREFIX = 'PURTEA_'
|
|
12
|
+
ENV_NESTED_SEPARATOR = '__'
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def load(path = DEFAULT_CONFIG_PATH, env_prefix: DEFAULT_ENV_PREFIX)
|
|
16
|
+
path = resolve_file path, create_dir: true
|
|
17
|
+
config = if File.exist? path
|
|
18
|
+
Tomlrb.load_file path
|
|
19
|
+
else
|
|
20
|
+
{}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ENV.select { |k, _| k.start_with? env_prefix }.each do |key, value|
|
|
24
|
+
key_path = key
|
|
25
|
+
.delete_prefix(env_prefix)
|
|
26
|
+
.split(ENV_NESTED_SEPARATOR)
|
|
27
|
+
.map(&:downcase)
|
|
28
|
+
set_hash_by_path config, key_path, value unless key_path.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
config
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolve_directory(create: false)
|
|
35
|
+
base = ENV['XDG_CONFIG_HOME'] || File.join(Dir.home, '.config')
|
|
36
|
+
File.join(base, 'purtea').tap do |path|
|
|
37
|
+
if create && !Dir.exist?(path)
|
|
38
|
+
Purtea.logger.debug(
|
|
39
|
+
"Config directory doesn't exist, creating #{path}"
|
|
40
|
+
)
|
|
41
|
+
FileUtils.mkpath(path)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resolve_file(file, create_dir: false)
|
|
47
|
+
directory = resolve_directory create: create_dir
|
|
48
|
+
File.join directory, file
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def set_hash_by_path(hash, key_path, value)
|
|
54
|
+
if key_path.size == 1
|
|
55
|
+
hash[key_path[0]] = value
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
key = key_path.shift
|
|
60
|
+
|
|
61
|
+
if hash[key].nil?
|
|
62
|
+
hash[key] = {}
|
|
63
|
+
elsif !hash[key].is_a? Hash
|
|
64
|
+
raise ArgumentError, 'Specified key path contains a value'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
set_hash_by_path hash[key], key_path, value
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'graphql/client'
|
|
4
|
+
require 'graphql/client/http'
|
|
5
|
+
|
|
6
|
+
require 'purtea/fflogs/fight'
|
|
7
|
+
require 'purtea/fflogs/api'
|
|
8
|
+
|
|
9
|
+
module Purtea
|
|
10
|
+
module FFLogs
|
|
11
|
+
BASE_URL = 'https://www.fflogs.com'
|
|
12
|
+
API_URL = "#{BASE_URL}/api/v2/client"
|
|
13
|
+
SCHEMA_FILE = File.expand_path('../../fflogs_schema.json', __dir__)
|
|
14
|
+
|
|
15
|
+
HTTP = GraphQL::Client::HTTP.new(API_URL) do
|
|
16
|
+
def headers(context)
|
|
17
|
+
unless (token = context[:access_token])
|
|
18
|
+
raise 'Missing FF Logs access token'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
'Authorization' => "Bearer #{token}"
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
CLIENT = GraphQL::Client.new(
|
|
28
|
+
schema: SCHEMA_FILE,
|
|
29
|
+
execute: HTTP
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
GET_FIGHTS_QUERY = CLIENT.parse <<-GRAPHQL
|
|
33
|
+
query($code: String) {
|
|
34
|
+
reportData {
|
|
35
|
+
report(code: $code) {
|
|
36
|
+
startTime
|
|
37
|
+
endTime
|
|
38
|
+
fights {
|
|
39
|
+
id
|
|
40
|
+
encounterID
|
|
41
|
+
gameZone {
|
|
42
|
+
id
|
|
43
|
+
name
|
|
44
|
+
}
|
|
45
|
+
name
|
|
46
|
+
difficulty
|
|
47
|
+
bossPercentage
|
|
48
|
+
fightPercentage
|
|
49
|
+
startTime
|
|
50
|
+
endTime
|
|
51
|
+
kill
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
rateLimitData {
|
|
56
|
+
limitPerHour
|
|
57
|
+
pointsResetIn
|
|
58
|
+
pointsSpentThisHour
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
GRAPHQL
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oauth2'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module Purtea
|
|
7
|
+
module FFLogs
|
|
8
|
+
TOKEN_FILE = Purtea::Config.resolve_file(
|
|
9
|
+
'fflogs_token.json',
|
|
10
|
+
create_dir: true
|
|
11
|
+
)
|
|
12
|
+
EXPIRATION_THRESHOLD = 30
|
|
13
|
+
|
|
14
|
+
# Contains methods to interact with the FF Logs API.
|
|
15
|
+
class API
|
|
16
|
+
def initialize(client_id, client_secret)
|
|
17
|
+
@client_id = client_id
|
|
18
|
+
@client_secret = client_secret
|
|
19
|
+
@oa_client = OAuth2::Client.new(
|
|
20
|
+
@client_id, @client_secret, site: BASE_URL
|
|
21
|
+
)
|
|
22
|
+
authorize!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def dump_schema(is_retry: false)
|
|
26
|
+
result = GraphQL::Client.dump_schema(
|
|
27
|
+
Purtea::FFLogs::HTTP,
|
|
28
|
+
SCHEMA_FILE,
|
|
29
|
+
context: { access_token: @token.token }
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
err_code = result&.dig('errors', 0, 'message')&.[](0..2)
|
|
33
|
+
if err_code && err_code == '401'
|
|
34
|
+
return result if is_retry
|
|
35
|
+
|
|
36
|
+
Purtea.logger.info 'FF Logs API token expired or revoked, getting new'
|
|
37
|
+
|
|
38
|
+
authorize! true
|
|
39
|
+
return dump_schema true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fights(code)
|
|
46
|
+
result = CLIENT.query(
|
|
47
|
+
GET_FIGHTS_QUERY,
|
|
48
|
+
variables: { code: code },
|
|
49
|
+
context: { access_token: @token.token }
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
report = result.data.report_data.report
|
|
53
|
+
report.fights.map { |d| Fight.new d, report.start_time }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def authorize!(force_refresh: false)
|
|
57
|
+
Purtea.logger.debug 'Authorize FF Logs API'
|
|
58
|
+
|
|
59
|
+
unless force_refresh
|
|
60
|
+
load_token!
|
|
61
|
+
return unless token_expired?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
refresh_token!
|
|
65
|
+
save_token
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def save_token
|
|
69
|
+
return if @token.nil?
|
|
70
|
+
|
|
71
|
+
Purtea.logger.debug 'Saving FF Logs API token to file'
|
|
72
|
+
token_hash = @token.to_hash
|
|
73
|
+
token_json = token_hash.to_json
|
|
74
|
+
File.open(TOKEN_FILE, 'w') { |f| f.write token_json }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def load_token!
|
|
78
|
+
return unless File.exist? TOKEN_FILE
|
|
79
|
+
|
|
80
|
+
Purtea.logger.debug 'Loading FF Logs API token from file'
|
|
81
|
+
token_hash = JSON.load_file TOKEN_FILE
|
|
82
|
+
@token = OAuth2::AccessToken.from_hash(@oa_client, token_hash)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def refresh_token!
|
|
86
|
+
# FF Logs does not issue refresh tokens
|
|
87
|
+
Purtea.logger.debug 'Refreshing FF Logs API token'
|
|
88
|
+
@token = @oa_client.client_credentials.get_token
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def token_expired?
|
|
92
|
+
return true if @token.nil?
|
|
93
|
+
|
|
94
|
+
expires_at = Time.at(@token.expires_at)
|
|
95
|
+
time_until_expire = expires_at - Time.now
|
|
96
|
+
time_until_expire < EXPIRATION_THRESHOLD
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Purtea
|
|
4
|
+
module FFLogs
|
|
5
|
+
# Describes a fight entry in FF Logs.
|
|
6
|
+
class Fight
|
|
7
|
+
ISO_FORMAT = '%Y-%m-%dT%H:%M:%S%:z'
|
|
8
|
+
|
|
9
|
+
attr_reader :id, :encounter_id, :zone_id, :name, :zone_name, :difficulty,
|
|
10
|
+
:boss_percentage, :fight_percentage, :start_at, :end_at,
|
|
11
|
+
:duration
|
|
12
|
+
|
|
13
|
+
# rubocop:disable Metrics/AbcSize
|
|
14
|
+
def initialize(data, report_start_ms)
|
|
15
|
+
@id = data.id
|
|
16
|
+
@encounter_id = data.encounter_id
|
|
17
|
+
@zone_id = data.game_zone.id
|
|
18
|
+
@name = data.name
|
|
19
|
+
@zone_name = data.game_zone.name
|
|
20
|
+
@difficulty = data.difficulty
|
|
21
|
+
@boss_percentage = data.boss_percentage
|
|
22
|
+
@fight_percentage = data.fight_percentage
|
|
23
|
+
@start_at = parse_fight_timestamp report_start_ms, data.start_time
|
|
24
|
+
@end_at = parse_fight_timestamp report_start_ms, data.end_time
|
|
25
|
+
@kill = data.kill
|
|
26
|
+
@duration = parse_duration @start_at, @end_at
|
|
27
|
+
end
|
|
28
|
+
# rubocop:enable Metrics/AbcSize
|
|
29
|
+
|
|
30
|
+
def kill?
|
|
31
|
+
@kill
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_s
|
|
35
|
+
start_f = start_at.strftime(ISO_FORMAT)
|
|
36
|
+
end_f = end_at.strftime(ISO_FORMAT)
|
|
37
|
+
duration = Time.at(end_at - start_at).utc.strftime('%H:%M:%S')
|
|
38
|
+
kw = kill? ? 'CLEAR' : 'WIPE'
|
|
39
|
+
"[#{@id}] #{start_f} -> #{end_f} [#{duration}] " \
|
|
40
|
+
"#{boss_percentage}% (#{fight_percentage}%) - #{kw}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def parse_fight_timestamp(report_start_ms, time)
|
|
46
|
+
Time.at(0, report_start_ms + time, :millisecond)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_duration(start_at, end_at)
|
|
50
|
+
Time.at(end_at - start_at).utc
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|