toggl_cache 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.codeclimate.yml +10 -0
- data/.env.test +7 -0
- data/.gitignore +15 -0
- data/.rspec +2 -0
- data/.rubocop.yml +14 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +15 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +26 -0
- data/Guardfile +1 -0
- data/HISTORY.md +5 -0
- data/LICENSE.txt +22 -0
- data/README.md +50 -0
- data/Rakefile +7 -0
- data/VERSION +1 -0
- data/bin/console +14 -0
- data/bin/db/migrate +7 -0
- data/bin/db/psql +7 -0
- data/bin/db/reset +7 -0
- data/bin/setup +7 -0
- data/config/boot.rb +11 -0
- data/config/db_migrations/001_create_toggl_cache_reports.rb +29 -0
- data/docker-compose.yml +25 -0
- data/lib/toggl_api/base_client.rb +52 -0
- data/lib/toggl_api/client.rb +62 -0
- data/lib/toggl_api/reports_client.rb +81 -0
- data/lib/toggl_cache.rb +101 -0
- data/lib/toggl_cache/data.rb +10 -0
- data/lib/toggl_cache/data/report_repository.rb +96 -0
- data/lib/toggl_cache/version.rb +3 -0
- data/lib/toggl_cli.rb +198 -0
- data/scripts/sync.rb +20 -0
- data/spec/spec_helper.rb +46 -0
- data/spec/unit/toggl_api/reports_client_spec.rb +80 -0
- data/toggl_cache.gemspec +40 -0
- metadata +237 -0
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "uri"
|
3
|
+
require "httparty"
|
4
|
+
require "toggl_api/base_client"
|
5
|
+
|
6
|
+
module TogglAPI
|
7
|
+
|
8
|
+
# The Toggl API Client
|
9
|
+
class ReportsClient < BaseClient
|
10
|
+
API_URL = "https://toggl.com/reports/api/v2"
|
11
|
+
|
12
|
+
# @param params [Hash]: Toggl API params
|
13
|
+
# - date_since
|
14
|
+
# - date_until
|
15
|
+
# - workspace_id
|
16
|
+
# - ... more params available, see Toggl API documentation for details
|
17
|
+
def fetch_reports(params)
|
18
|
+
page = 1
|
19
|
+
all_results = []
|
20
|
+
loop do
|
21
|
+
results_raw = fetch_reports_details_raw(
|
22
|
+
params.merge(page: page)
|
23
|
+
)
|
24
|
+
results = results_raw["data"]
|
25
|
+
|
26
|
+
all_results += results
|
27
|
+
break if all_results.count == results_raw["total_count"]
|
28
|
+
page += 1
|
29
|
+
end
|
30
|
+
all_results
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def fetch_reports_details_raw(params)
|
36
|
+
fetch_reports_raw(api_url(:details), params)
|
37
|
+
end
|
38
|
+
|
39
|
+
def fetch_reports_summary_raw(params)
|
40
|
+
fetch_reports_raw(api_url(:summary), params)
|
41
|
+
end
|
42
|
+
|
43
|
+
# @param url [String]
|
44
|
+
# @param params [Hash]: Toggl API params
|
45
|
+
def fetch_reports_raw(url, params)
|
46
|
+
logger.info "Fetching Toggl reports for params #{params}" if logger
|
47
|
+
params = { user_agent: @user_agent }.merge(params)
|
48
|
+
response = HTTParty.get(
|
49
|
+
url,
|
50
|
+
headers: headers,
|
51
|
+
query: params,
|
52
|
+
basic_auth: credentials
|
53
|
+
)
|
54
|
+
|
55
|
+
handle_response(response)
|
56
|
+
end
|
57
|
+
|
58
|
+
def headers
|
59
|
+
{ "Content-Type" => "application/json" }
|
60
|
+
end
|
61
|
+
|
62
|
+
def credentials
|
63
|
+
{
|
64
|
+
username: @api_token,
|
65
|
+
password: "api_token"
|
66
|
+
}
|
67
|
+
end
|
68
|
+
|
69
|
+
def handle_response(response)
|
70
|
+
if response.code == 200 || response.code == 201
|
71
|
+
JSON.parse(response.body)
|
72
|
+
else
|
73
|
+
fail(Error, "Toggl API error #{response.code}: #{response.body}")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def api_url(resource)
|
78
|
+
"#{API_URL}/#{resource}"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
data/lib/toggl_cache.rb
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "logger"
|
3
|
+
require "toggl_api/reports_client"
|
4
|
+
require "toggl_cache/data/report_repository"
|
5
|
+
require "toggl_cache/version"
|
6
|
+
|
7
|
+
# Facility to store/cache Toggl reports data in a PostgreSQL database.
|
8
|
+
module TogglCache
|
9
|
+
HOUR = 3_600
|
10
|
+
DAY = 24 * HOUR
|
11
|
+
MONTH = 31 * DAY
|
12
|
+
DEFAULT_DATE_SINCE = Time.now - 1 * DAY # MONTH
|
13
|
+
DEFAULT_WORKSPACE_ID = ENV["TOGGL_WORKSPACE_ID"]
|
14
|
+
|
15
|
+
# Fetches new and updated reports from the specified start
|
16
|
+
# date to now. By default, fetches all reports since 1 month
|
17
|
+
# ago, allowing updates on old reports to update the cached
|
18
|
+
# reports too.
|
19
|
+
#
|
20
|
+
# The fetched reports either update the already
|
21
|
+
# existing ones, or create new ones.
|
22
|
+
#
|
23
|
+
# @param client [TogglAPI::Client] a configured client
|
24
|
+
# @param workspace_id [String] Toggl workspace ID (mandatory)
|
25
|
+
# @param date_since [Date] Date since when to fetch
|
26
|
+
# the reports.
|
27
|
+
def self.sync_reports(client: default_client,
|
28
|
+
date_since: default_date_since)
|
29
|
+
reports = fetch_reports(
|
30
|
+
client: client,
|
31
|
+
date_since: date_since
|
32
|
+
)
|
33
|
+
process_reports(reports)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Fetch from Toggl
|
37
|
+
#
|
38
|
+
# Handles a fetch over multiple years, which requires splitting the requests
|
39
|
+
# over periods extending on a single year (Toggl API requirement).
|
40
|
+
# # @param client [TogglCache::Client] configured client
|
41
|
+
# @param workspace_id [String] Toggl workspace ID
|
42
|
+
# @param date_since [Date] Date since when to fetch
|
43
|
+
# the reports
|
44
|
+
# @param date_until [Date] Date until when to fetch
|
45
|
+
# the reports, defaults to Time.now
|
46
|
+
def self.fetch_reports(
|
47
|
+
client: default_client,
|
48
|
+
workspace_id: default_workspace_id,
|
49
|
+
date_since:,
|
50
|
+
date_until: Time.now
|
51
|
+
)
|
52
|
+
if date_since && date_until.year > date_since.year
|
53
|
+
fetch_reports(
|
54
|
+
client: client,
|
55
|
+
workspace_id: workspace_id,
|
56
|
+
date_since: date_since,
|
57
|
+
date_until: Date.new(date_since.year, 12, 31)
|
58
|
+
) + fetch_reports(
|
59
|
+
client: client,
|
60
|
+
workspace_id: workspace_id,
|
61
|
+
date_since: Date.new(date_since.year + 1, 1, 1),
|
62
|
+
date_until: date_until
|
63
|
+
)
|
64
|
+
else
|
65
|
+
options = {
|
66
|
+
workspace_id: workspace_id, until: date_until.strftime("%Y-%m-%d")
|
67
|
+
}
|
68
|
+
options[:since] = date_since.strftime("%Y-%m-%d") unless date_since.nil?
|
69
|
+
client.fetch_reports(options)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def self.process_reports(reports)
|
74
|
+
reports.each do |report|
|
75
|
+
Data::ReportRepository.create_or_update(report)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.default_client(logger: default_logger)
|
80
|
+
return TogglAPI::ReportsClient.new(logger: logger) if logger
|
81
|
+
TogglAPI::ReportsClient.new
|
82
|
+
end
|
83
|
+
|
84
|
+
def self.default_workspace_id
|
85
|
+
DEFAULT_WORKSPACE_ID
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.default_date_since
|
89
|
+
DEFAULT_DATE_SINCE
|
90
|
+
end
|
91
|
+
|
92
|
+
def self.default_logger
|
93
|
+
logger = ::Logger.new(STDOUT)
|
94
|
+
logger.level = default_log_level
|
95
|
+
logger
|
96
|
+
end
|
97
|
+
|
98
|
+
def self.default_log_level
|
99
|
+
Logger.const_get(ENV["TOGGL_CACHE_LOG_LEVEL"]&.upcase || "ERROR")
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "toggl_cache/data"
|
3
|
+
require "active_support/inflector"
|
4
|
+
|
5
|
+
module TogglCache
|
6
|
+
module Data
|
7
|
+
|
8
|
+
# Superclass for repositories. Simply provide some shared
|
9
|
+
# methods.
|
10
|
+
class ReportRepository
|
11
|
+
|
12
|
+
MAPPED_REPORT_ATTRIBUTES = %w(
|
13
|
+
description
|
14
|
+
pid
|
15
|
+
project
|
16
|
+
uid
|
17
|
+
user
|
18
|
+
task
|
19
|
+
tid
|
20
|
+
).freeze
|
21
|
+
|
22
|
+
# It inserts a new issue row with the specified data.
|
23
|
+
# If the issue already exists (unicity key is `id`)
|
24
|
+
# the row is updated instead.
|
25
|
+
def self.create_or_update(report)
|
26
|
+
id = report["id"].to_s
|
27
|
+
if exist_with_id?(id)
|
28
|
+
update_where({ id: id }, row(report: report))
|
29
|
+
else
|
30
|
+
table.insert row(report: report, insert_created_at: true)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.find_by_id(id)
|
35
|
+
table.where(id: id).first
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.exist_with_id?(id)
|
39
|
+
table.where(id: id).count != 0
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.delete_where(where_data)
|
43
|
+
table.where(where_data).delete
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.update_where(where_data, values)
|
47
|
+
table.where(where_data).update(values)
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.first_where(where_data)
|
51
|
+
table.where(where_data).first
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.index
|
55
|
+
table.entries
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.count
|
59
|
+
table.count
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.table
|
63
|
+
DB[:toggl_cache_reports]
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.row(report:, insert_created_at: false, insert_updated_at: true)
|
67
|
+
new_report = map_report_attributes(report: report)
|
68
|
+
new_report = add_timestamps(
|
69
|
+
report: new_report,
|
70
|
+
insert_created_at: insert_created_at,
|
71
|
+
insert_updated_at: insert_updated_at
|
72
|
+
)
|
73
|
+
new_report
|
74
|
+
end
|
75
|
+
|
76
|
+
def self.map_report_attributes(report:)
|
77
|
+
new_report = report.select { |k, _| MAPPED_REPORT_ATTRIBUTES.include?(k) }
|
78
|
+
new_report = new_report.merge(
|
79
|
+
duration: report["dur"] / 1_000,
|
80
|
+
end: report["end"] ? Time.parse(report["end"]) : nil,
|
81
|
+
id: report["id"].to_s,
|
82
|
+
start: Time.parse(report["start"]),
|
83
|
+
toggl_updated: Time.parse(report["updated"])
|
84
|
+
)
|
85
|
+
new_report
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.add_timestamps(report:, insert_created_at:, insert_updated_at:)
|
89
|
+
new_report = {}.merge(report)
|
90
|
+
new_report["created_at"] = Time.now if insert_created_at
|
91
|
+
new_report["updated_at"] = Time.now if insert_updated_at
|
92
|
+
new_report
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
data/lib/toggl_cli.rb
ADDED
@@ -0,0 +1,198 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "thor"
|
3
|
+
require "tty-pager"
|
4
|
+
require "tty-progressbar"
|
5
|
+
require "tty-spinner"
|
6
|
+
# require "tty-table"
|
7
|
+
require File.expand_path("../../config/boot", __FILE__)
|
8
|
+
require "toggl_api/client"
|
9
|
+
require "toggl_cache/data/report_repository"
|
10
|
+
|
11
|
+
class TogglCLI < Thor
|
12
|
+
package_name "TogglCLI"
|
13
|
+
include Thor::Shell
|
14
|
+
FILTERS = %w(no description user clear).freeze
|
15
|
+
SHOW_REPORTS_COLUMNS = %w(description user duration).freeze
|
16
|
+
|
17
|
+
# Batch edition
|
18
|
+
# Workflow:
|
19
|
+
# - select source project / task
|
20
|
+
# - provide information about matching reports
|
21
|
+
# - enable user to filter reports
|
22
|
+
# - select target project / task
|
23
|
+
# - apply transformation
|
24
|
+
desc "batch", "Batch edition"
|
25
|
+
method_option :no_cache_update, desc: "Don't update the local cache", type: :boolean
|
26
|
+
def batch
|
27
|
+
# TEMP
|
28
|
+
# source_project = select_project("Select the source project")
|
29
|
+
# source_task = select_task("Select the source task", source_project["id"])
|
30
|
+
source_project = { "name" => "technical (fake)", "id" => 9800254 }
|
31
|
+
source_task = { "name" => "No task", "id" => nil }
|
32
|
+
# END TEMP
|
33
|
+
|
34
|
+
reports = find_reports(source_project["id"], source_task["id"])
|
35
|
+
show_reports_info(reports)
|
36
|
+
reports = filter_reports(reports)
|
37
|
+
|
38
|
+
unless no?("Move these reports to a new project / task (enter 'n' or 'no' to cancel)")
|
39
|
+
target_project = select_project("Select the target project")
|
40
|
+
target_task = select_task("Select the target task", target_project["id"])
|
41
|
+
|
42
|
+
say(
|
43
|
+
"Will move #{reports.count} reports, " \
|
44
|
+
"from #{source_project['name']} > #{source_task['name']} " \
|
45
|
+
"to #{target_project['name']} > #{target_task['name']}"
|
46
|
+
)
|
47
|
+
if yes?("Are you sure? (enter 'y' or 'yes' to continue)")
|
48
|
+
say("MOVING!!!")
|
49
|
+
end
|
50
|
+
end
|
51
|
+
# TogglCache::Data::ReportRepository.create_or_update(report)
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def filter_reports(reports)
|
57
|
+
begin
|
58
|
+
selected_filter = ask("Add filter?", limited_to: FILTERS)
|
59
|
+
reports = (
|
60
|
+
case selected_filter
|
61
|
+
when "no" then reports
|
62
|
+
when "description" then propose_description_regexp_filter(reports)
|
63
|
+
when "user" then propose_user_filter(reports)
|
64
|
+
else raise "Unexpected filter `#{selected_filter}`"
|
65
|
+
end
|
66
|
+
)
|
67
|
+
show_reports_info(reports)
|
68
|
+
end while(selected_filter != "no")
|
69
|
+
reports
|
70
|
+
end
|
71
|
+
|
72
|
+
def propose_description_regexp_filter(reports)
|
73
|
+
show_unique_descriptions(reports)
|
74
|
+
if yes?("Add regexp filter? (enter 'y' or 'yes' to continue)")
|
75
|
+
regexp = eval(ask("Enter regexp (e.g. /string/i - will go through `eval`):"))
|
76
|
+
filtered = reports.select { |r| r[:description] =~ regexp }
|
77
|
+
show_unique_descriptions(filtered)
|
78
|
+
return filtered unless no?("Keep filter? (enter 'n' or 'no' to discard the filter)")
|
79
|
+
end
|
80
|
+
reports
|
81
|
+
end
|
82
|
+
|
83
|
+
def propose_user_filter(reports)
|
84
|
+
selected_user = select_in_list(reports.map { |r| r[:user] }.uniq, "Select user")
|
85
|
+
filtered = reports.select { |r| r[:user] == selected_user }
|
86
|
+
show_reports_info(filtered)
|
87
|
+
return filtered unless no?("Keep filter? (enter 'n' or 'no' to discard the filter)")
|
88
|
+
reports
|
89
|
+
end
|
90
|
+
|
91
|
+
def show_unique_descriptions(reports)
|
92
|
+
unique_descriptions = reports.map { |r| r[:description] }.uniq
|
93
|
+
say("Found #{unique_descriptions.count} unique descriptions.")
|
94
|
+
if yes?("Show unique descriptions? (enter 'y' or 'yes' to show)")
|
95
|
+
page(unique_descriptions.join("\n") << "\n")
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def show_reports_info(reports)
|
100
|
+
say("#{reports.count} matching reports.")
|
101
|
+
show_reports(reports) if yes?("Show reports? (enter 'y' or 'yes' to show)")
|
102
|
+
end
|
103
|
+
|
104
|
+
def show_reports(reports)
|
105
|
+
report_lines = reports.map do |r|
|
106
|
+
report_columns = SHOW_REPORTS_COLUMNS.map do |c|
|
107
|
+
r[c.to_sym]
|
108
|
+
end
|
109
|
+
report_columns.join(" | ")
|
110
|
+
end
|
111
|
+
page(report_lines.join("\n") << "\n")
|
112
|
+
end
|
113
|
+
|
114
|
+
def find_reports(project_id, task_id)
|
115
|
+
with_spinner("Fetching matching reports...") do
|
116
|
+
where_criteria = { pid: project_id.to_i }
|
117
|
+
where_criteria[:tid] = task_id if task_id
|
118
|
+
TogglCache::Data::ReportRepository.table.where(where_criteria).entries
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def select_in_list(items, msg)
|
123
|
+
items.each.with_index { |item, i| say(" #{i}. #{item}") }
|
124
|
+
begin
|
125
|
+
index = ask("#{msg} (0-#{items.count - 1})").to_i
|
126
|
+
end while(index < 0 || index > items.count - 1)
|
127
|
+
items[index]
|
128
|
+
end
|
129
|
+
|
130
|
+
def select_project(msg)
|
131
|
+
say("Projects:")
|
132
|
+
project_name = select_in_list(project_names, msg)
|
133
|
+
project = projects.find { |p| p["name"] == project_name }
|
134
|
+
say("Selected project `#{project["name"]}`")
|
135
|
+
project
|
136
|
+
end
|
137
|
+
|
138
|
+
def select_task(msg, project_id)
|
139
|
+
source_project_tasks = project_tasks(project_id)
|
140
|
+
return { "id" => nil, "name" => "No task" } if source_project_tasks.empty?
|
141
|
+
say("Tasks for selected project:")
|
142
|
+
source_project_tasks.each.with_index do |task, i|
|
143
|
+
say(" #{i}. #{task['name']}")
|
144
|
+
end
|
145
|
+
begin
|
146
|
+
source_task_index = ask("#{msg} (0-#{source_project_tasks.count - 1})").to_i
|
147
|
+
end while(source_task_index < 0 || source_task_index > source_project_tasks.count - 1)
|
148
|
+
source_task = source_project_tasks[source_task_index]
|
149
|
+
end
|
150
|
+
|
151
|
+
def projects
|
152
|
+
@projects ||= (
|
153
|
+
with_spinner("Fetching projects...") do
|
154
|
+
client.get_workspace_projects(active: "both")
|
155
|
+
end
|
156
|
+
)
|
157
|
+
end
|
158
|
+
|
159
|
+
def project_tasks(project_id)
|
160
|
+
tasks = with_spinner("Fetching tasks...") do
|
161
|
+
client.get_project_tasks(project_id: project_id)
|
162
|
+
end
|
163
|
+
tasks.map do |task|
|
164
|
+
{ "id" => task["id"], "name" => task["name"] }
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def project_names
|
169
|
+
projects.map { |p| p["name"] }
|
170
|
+
end
|
171
|
+
|
172
|
+
def client
|
173
|
+
@client ||= TogglAPI::Client.new
|
174
|
+
end
|
175
|
+
|
176
|
+
def build_spinner(title)
|
177
|
+
TTY::Spinner.new("#{title} [:spinner]")
|
178
|
+
end
|
179
|
+
|
180
|
+
def build_bar(msg, count)
|
181
|
+
TTY::ProgressBar.new("#{msg} [:bar]", total: count)
|
182
|
+
end
|
183
|
+
|
184
|
+
def with_spinner(msg)
|
185
|
+
spinner = build_spinner(msg)
|
186
|
+
result = nil
|
187
|
+
yield
|
188
|
+
spinner.run("Done!") { result = yield }
|
189
|
+
result
|
190
|
+
end
|
191
|
+
|
192
|
+
def page(text)
|
193
|
+
pager = TTY::Pager::BasicPager.new
|
194
|
+
pager.page(text)
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
TogglCLI.start
|