toggl_cache 0.1.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 +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
|