toggl_cache 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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,10 @@
1
+ # frozen_string_literal: true
2
+ require "sequel"
3
+
4
+ module TogglCache
5
+ module Data
6
+ DATABASE_URL = ENV["DATABASE_URL"]
7
+ DB = Sequel.connect(DATABASE_URL)
8
+ DB.extension :pg_array, :pg_json
9
+ end
10
+ 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
@@ -0,0 +1,3 @@
1
+ module TogglCache
2
+ VERSION = File.read(File.expand_path('../../../VERSION', __FILE__)).strip
3
+ 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