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.
@@ -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