togglv9 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.
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in togglv8.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Tomoya Kabe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # Toggl API v9
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/togglv9-limitusus.svg)](https://badge.fury.io/rb/togglv9-limitusus)
4
+
5
+ [Toggl](http://www.toggl.com) is a time tracking tool.
6
+
7
+ [togglv9](/) is a Ruby Wrapper for [Toggl API v9](https://engineering.toggl.com/docs/). It is designed to mirror the Toggl API as closely as possible.
8
+
9
+ togglv9 supports both [Toggl API](https://github.com/toggl/toggl_api_docs/blob/master/toggl_api.md) and [Reports API](https://github.com/toggl/toggl_api_docs/blob/master/reports.md)
10
+
11
+ NOTE: currently Reports API is not supported yet.
12
+
13
+ ## Change Log
14
+
15
+ See [CHANGELOG](CHANGELOG.md) for a summary of notable changes in each version.
16
+
17
+ ## Installation
18
+
19
+ Add this line to your application's Gemfile:
20
+
21
+ ```ruby
22
+ gem 'togglv9'
23
+ ```
24
+
25
+ And then execute:
26
+
27
+ $ bundle
28
+
29
+ Or install it yourself as:
30
+
31
+ $ gem install togglv9
32
+
33
+ ## Initialization
34
+
35
+ ### TogglV9::API
36
+
37
+ TogglV9::API communicates with [Toggl API v9](https://engineering.toggl.com/docs/) and can be initialized in one of three ways.
38
+
39
+ ```ruby
40
+ TogglV9::API.new # reads API token from file ~/.toggl
41
+ TogglV9::API.new(api_token) # explicit API token
42
+ TogglV9::API.new(email, password) # email & password
43
+ ```
44
+
45
+ ### TogglV9::ReportsV2
46
+
47
+ NOTE: not supported yet. Reports V2 API is already deprecated.
48
+
49
+ TogglV9::ReportsV2 communicates with [Toggl Reports API v2](https://github.com/toggl/toggl_api_docs/blob/master/reports.md) and can be initialized in one of three ways. Toggl.com requires authentication with an API token for Reports API v2.
50
+
51
+ ```ruby
52
+ TogglV9::ReportsV2.new # reads API token from file ~/.toggl
53
+ TogglV9::ReportsV2.new(toggl_api_file: toggl_file) # reads API token from toggl_file
54
+ TogglV9::ReportsV2.new(api_token: api_token) # explicit API token
55
+ ```
56
+
57
+ **Note:** `workspace_id` must be set in order to generate reports.
58
+
59
+ ```ruby
60
+ toggl = TogglV9::API.new
61
+ reports = TogglV9::ReportsV2.new
62
+ reports.workspace_id = toggl.workspaces.first['id']
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ This short example shows one way to create a time entry for the first workspace of the user identified by `<API_TOKEN>`. It then generates various reports containing that time entry.
68
+
69
+ ```ruby
70
+ require 'togglv9'
71
+ require 'json'
72
+
73
+ toggl_api = TogglV9::API.new(<API_TOKEN>)
74
+ user = toggl_api.me(all=true)
75
+ workspaces = toggl_api.my_workspaces(user)
76
+ workspace_id = workspaces.first['id']
77
+ time_entry = toggl_api.create_time_entry(workspace_id, {
78
+ 'description' => "My awesome workspace time entry",
79
+ 'wid' => workspace_id,
80
+ 'duration' => 1200,
81
+ 'start' => toggl_api.iso8601((Time.now - 3600).to_datetime),
82
+ 'created_with' => "My awesome Ruby application"
83
+ })
84
+
85
+ begin
86
+ reports = TogglV9::ReportsV2.new(api_token: <API_TOKEN>)
87
+ begin
88
+ reports.summary
89
+ rescue Exception => e
90
+ puts e.message # workspace_id is required
91
+ end
92
+ reports.workspace_id = workspace_id
93
+ summary = reports.summary
94
+ puts "Generating summary JSON..."
95
+ puts JSON.pretty_generate(summary)
96
+ puts "Generating summary PDF..."
97
+ reports.write_summary('toggl_summary.pdf')
98
+ puts "Generating weekly CSV..."
99
+ reports.write_weekly('toggl_weekly.csv')
100
+ puts "Generating details XLS..."
101
+ reports.write_details('toggl_details.xls')
102
+ # Note: toggl.com does not generate Weekly XLS report (as of 2016-07-24)
103
+ ensure
104
+ toggl_api.delete_time_entry(time_entry['id'])
105
+ end
106
+ ```
107
+
108
+ See specs for more examples.
109
+
110
+ **Note:** Requests are rate-limited. The togglv9 gem will handle a 429 response by pausing for 1 second and trying again, for up to 3 attempts. See [Toggl API docs](https://engineering.toggl.com/docs/#generic-responses):
111
+
112
+ > in case of 429 (Too Many Requests) - back off for a few minutes (you can expect a rate of 1req/sec to be available)
113
+
114
+ ## Debugging
115
+
116
+ The `TogglV9::API#debug` method determines if debug output is printed to STDOUT. This code snippet demonstrates the debug output.
117
+
118
+ ```ruby
119
+ require 'togglv9'
120
+
121
+ toggl = TogglV9::API.new
122
+
123
+ toggl.debug(true) # or simply toggl.debug
124
+ user1 = toggl.me
125
+ puts "user: #{user1['fullname']}, debug: true"
126
+
127
+ puts '-'*80
128
+
129
+ toggl.debug(false)
130
+ user2 = toggl.me
131
+ puts "user: #{user2['fullname']}, debug: false"
132
+ ```
133
+
134
+ ## Documentation
135
+
136
+ Run `rdoc` to generate documentation. Open `doc/index.html` in your browser.
137
+
138
+ ## Acknowledgements
139
+
140
+ - Thanks to [kanet77](https://github.com/kanet77) and its contributers, from which this repository code is based.
141
+ - Thanks to the Toggl team for exposing the API.
142
+
143
+ ## Contributing
144
+
145
+ 1. Fork it ( https://github.com/limitusus/togglv9/fork )
146
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
147
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
148
+ 4. Push to the branch (`git push origin my-new-feature`)
149
+ 5. Create a new Pull Request
150
+
151
+ Pull Requests that include tests are **much** more likely to be accepted and merged quickly.
152
+
153
+ ## License
154
+
155
+ Copyright (c) 2024 Tomoya Kabe.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rake/clean'
3
+
4
+ CLEAN.add 'coverage'
5
+ CLEAN.add 'doc'
6
+ CLEAN.include 'tmp-*'
data/lib/logging.rb ADDED
@@ -0,0 +1,38 @@
1
+ # :nocov:
2
+ require 'logger'
3
+ # require 'awesome_print' # for debug output
4
+
5
+ # From http://stackoverflow.com/questions/917566/ruby-share-logger-instance-among-module-classes
6
+ module Logging
7
+ class << self
8
+ def logger
9
+ @logger ||= Logger.new($stdout)
10
+ end
11
+
12
+ def logger=(logger)
13
+ @logger = logger
14
+ end
15
+ end
16
+
17
+ # Addition
18
+ def self.included(base)
19
+ class << base
20
+ def logger
21
+ Logging.logger
22
+ end
23
+ end
24
+ end
25
+
26
+ def logger
27
+ Logging.logger
28
+ end
29
+
30
+ def debug(debug=true)
31
+ if debug
32
+ logger.level = Logger::DEBUG
33
+ else
34
+ logger.level = Logger::WARN
35
+ end
36
+ end
37
+ end
38
+ # :nocov:
data/lib/reportsv2.rb ADDED
@@ -0,0 +1,177 @@
1
+ module TogglV9
2
+ TOGGL_REPORTS_URL = 'https://api.track.toggl.com/reports/api/'
3
+
4
+ class ReportsV2
5
+ include TogglV9::Connection
6
+
7
+ REPORTS_V2_URL = TOGGL_REPORTS_URL + 'v2/'
8
+
9
+ attr_reader :conn
10
+
11
+ attr_accessor :workspace_id
12
+
13
+ def initialize(opts={})
14
+ debug(false)
15
+
16
+ @user_agent = TogglV9::NAME
17
+
18
+ username = opts[:api_token]
19
+ if username.nil?
20
+ toggl_api_file = opts[:toggl_api_file] || File.join(Dir.home, TOGGL_FILE)
21
+ if File.exist?(toggl_api_file) then
22
+ username = IO.read(toggl_api_file)
23
+ else
24
+ raise "Expecting one of:\n" +
25
+ " 1) api_token in file #{toggl_api_file}, or\n" +
26
+ " 2) parameter: (toggl_api_file), or\n" +
27
+ " 3) parameter: (api_token), or\n" +
28
+ "\n\tSee https://github.com/limitusus/togglv9#togglv9reportsv2" +
29
+ "\n\tand https://github.com/toggl/toggl_api_docs/blob/master/reports.md#authentication"
30
+ end
31
+ end
32
+
33
+ @conn = TogglV9::Connection.open(username, API_TOKEN, REPORTS_V2_URL, opts)
34
+ end
35
+
36
+ ##
37
+ # ---------
38
+ # :section: Report
39
+ #
40
+ # The following parameters and filters can be used in all of the reports
41
+ #
42
+ # user_agent : the name of this application so Toggl can get in touch
43
+ # (string, *required*)
44
+ # workspace_id : The workspace whose data you want to access.
45
+ # (integer, *required*)
46
+ # since : ISO 8601 date (YYYY-MM-DD), by default until - 6 days.
47
+ # (string)
48
+ # until : ISO 8601 date (YYYY-MM-DD), by default today
49
+ # (string)
50
+ # billable : possible values: yes/no/both, default both
51
+ # client_ids : client ids separated by a comma, 0 if you want to filter out time entries without a client
52
+ # project_ids : project ids separated by a comma, 0 if you want to filter out time entries without a project
53
+ # user_ids : user ids separated by a comma
54
+ # members_of_group_ids : group ids separated by a comma. This limits provided user_ids to the provided group members
55
+ # or_members_of_group_ids : group ids separated by a comma. This extends provided user_ids with the provided group members
56
+ # tag_ids : tag ids separated by a comma, 0 if you want to filter out time entries without a tag
57
+ # task_ids : task ids separated by a comma, 0 if you want to filter out time entries without a task
58
+ # time_entry_ids : time entry ids separated by a comma
59
+ # description : time entry description
60
+ # (string)
61
+ # without_description : filters out the time entries which do not have a description ('(no description)')
62
+ # (true/false)
63
+ # order_field : date/description/duration/user in detailed reports
64
+ # title/duration/amount in summary reports
65
+ # title/day1/day2/day3/day4/day5/day6/day7/week_total in weekly report
66
+ # order_desc : on for descending and off for ascending order
67
+ # (on/off)
68
+ # distinct_rates : on/off, default off
69
+ # rounding : on/off, default off, rounds time according to workspace settings
70
+ # display_hours : decimal/minutes, display hours with minutes or as a decimal number, default minutes
71
+ #
72
+ # NB! Maximum date span (until - since) is one year.
73
+
74
+ # extension can be one of ['.pdf', '.csv', '.xls']. Possibly others?
75
+ def report(type, extension, params)
76
+ raise "workspace_id is required" if @workspace_id.nil?
77
+ get "#{type}#{extension}", {
78
+ :'user_agent' => @user_agent,
79
+ :'workspace_id' => @workspace_id,
80
+ }.merge(params)
81
+ end
82
+
83
+ def weekly(extension='', params={})
84
+ report('weekly', extension, params)
85
+ end
86
+
87
+ def details(extension='', params={})
88
+ report('details', extension, params)
89
+ end
90
+
91
+ def summary(extension='', params={})
92
+ report('summary', extension, params)
93
+ end
94
+
95
+ ##
96
+ # ---------
97
+ # :section: Write report to file
98
+ #
99
+ def write_report(filename)
100
+ extension = File.extname(filename)
101
+ report = yield(extension)
102
+ File.open(filename, "wb") do |file|
103
+ file.write(report)
104
+ end
105
+ end
106
+
107
+ def write_weekly(filename, params={})
108
+ write_report(filename) do |extension|
109
+ weekly(extension, params)
110
+ end
111
+ end
112
+
113
+ def write_details(filename, params={})
114
+ write_report(filename) do |extension|
115
+ details(extension, params)
116
+ end
117
+ end
118
+
119
+ def write_summary(filename, params={})
120
+ write_report(filename) do |extension|
121
+ summary(extension, params)
122
+ end
123
+ end
124
+
125
+
126
+ ##
127
+ # ---------
128
+ # :section: Miscellaneous information
129
+ #
130
+ def revision
131
+ get "revision"
132
+ end
133
+
134
+ def index
135
+ get "index"
136
+ end
137
+
138
+ def env
139
+ get "env"
140
+ end
141
+
142
+ ##
143
+ # ---------
144
+ # :section: Project Dashboard
145
+ #
146
+ # Project dashboard returns at-a-glance information for a single project.
147
+ # This feature is only available with Toggl pro.
148
+ #
149
+ # user_agent : email, or other way to contact client application developer
150
+ # (string, *required*)
151
+ # workspace_id : The workspace whose data you want to access
152
+ # (integer, *required*)
153
+ # project_id : The project whose data you want to access
154
+ # (integer, *required*)
155
+ # page : number of 'tasks_page' you want to fetch
156
+ # (integer, optional)
157
+ # order_field string : name/assignee/duration/billable_amount/estimated_seconds
158
+ # order_desc string : on/off, on for descending and off for ascending order
159
+ def project(project_id, params={})
160
+ raise "workspace_id is required" if @workspace_id.nil?
161
+ get "project", {
162
+ :'user_agent' => @user_agent,
163
+ :'workspace_id' => @workspace_id,
164
+ :'project_id' => project_id,
165
+ }.merge(params)
166
+ end
167
+
168
+ ##
169
+ # ---------
170
+ # :section: Error (for testing)
171
+ #
172
+ # excludes endpoints 'error500' and 'division_by_zero_error'
173
+ def error400
174
+ get "error400"
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,38 @@
1
+ module TogglV9
2
+ class API
3
+
4
+ ##
5
+ # ---------
6
+ # :section: Clients
7
+ #
8
+ # name : The name of the client (string, required, unique in workspace)
9
+ # wid : workspace ID, where the client will be used (integer, required)
10
+ # notes : Notes for the client (string, not required)
11
+ # hrate : The hourly rate for this client (float, not required, available only for pro workspaces)
12
+ # cur : The name of the client's currency (string, not required, available only for pro workspaces)
13
+ # at : timestamp that is sent in the response, indicates the time client was last updated
14
+
15
+ def create_client(workspace_id, params)
16
+ requireParams(params, ['name', 'wid'])
17
+ post "workspaces/#{workspace_id}/clients", params
18
+ end
19
+
20
+ def get_client(workspace_id, client_id)
21
+ get "workspaces/#{workspace_id}/clients/#{client_id}"
22
+ end
23
+
24
+ def update_client(workspace_id, client_id, params)
25
+ put "workspaces/#{workspace_id}/clients/#{client_id}", params
26
+ end
27
+
28
+ def delete_client(workspace_id, client_id)
29
+ delete "workspaces/#{workspace_id}/clients/#{client_id}"
30
+ end
31
+
32
+ def get_client_projects(workspace_id, client_id, params={})
33
+ qs = "?clients=#{client_id}"
34
+ active = params.has_key?('active') ? "&active=#{params['active']}" : ""
35
+ get "workspaces/#{workspace_id}/projects#{qs}#{active}"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,105 @@
1
+ require 'faraday'
2
+ require 'oj'
3
+
4
+ require_relative '../logging'
5
+
6
+ module TogglV9
7
+ module Connection
8
+ include Logging
9
+
10
+ DELAY_SEC = 1
11
+ MAX_RETRIES = 3
12
+
13
+ API_TOKEN = 'api_token'
14
+ TOGGL_FILE = '.toggl'
15
+
16
+ def self.open(username=nil, password=API_TOKEN, url=nil, opts={})
17
+ raise 'Missing URL' if url.nil?
18
+
19
+ Faraday.new(:url => url, :ssl => {:verify => true}) do |faraday|
20
+ faraday.request :url_encoded
21
+ faraday.response :logger, Logger.new('faraday.log') if opts[:log]
22
+ faraday.adapter Faraday.default_adapter
23
+ faraday.headers = { "Content-Type" => "application/json" }
24
+ faraday.request :authorization, :basic, username, password
25
+ end
26
+ end
27
+
28
+ def requireParams(params, fields=[])
29
+ raise ArgumentError, 'params is not a Hash' unless params.is_a? Hash
30
+ return if fields.empty?
31
+ errors = []
32
+ for f in fields
33
+ errors.push("params[#{f}] is required") unless params.has_key?(f)
34
+ end
35
+ raise ArgumentError, errors.join(', ') if !errors.empty?
36
+ end
37
+
38
+ def _call_api(procs)
39
+ # logger.debug(procs[:debug_output].call)
40
+ full_resp = nil
41
+ i = 0
42
+ loop do
43
+ i += 1
44
+ full_resp = procs[:api_call].call
45
+ # logger.ap(full_resp.env, :debug)
46
+ break if full_resp.status != 429 || i >= MAX_RETRIES
47
+ sleep(DELAY_SEC)
48
+ end
49
+
50
+ raise full_resp.headers['warning'] if full_resp.headers['warning']
51
+ raise "HTTP Status: #{full_resp.status}" unless full_resp.success?
52
+ return {} if full_resp.body.nil? || full_resp.body == 'null'
53
+
54
+ full_resp
55
+ end
56
+
57
+ def get(resource, params={})
58
+ query_params = params.map { |k,v| "#{k}=#{v}" }.join('&')
59
+ resource += "?#{query_params}" unless query_params.empty?
60
+ resource.gsub!('+', '%2B')
61
+ full_resp = _call_api(debug_output: lambda { "GET #{resource}" },
62
+ api_call: lambda { self.conn.get(resource) } )
63
+ return {} if full_resp == {}
64
+ begin
65
+ resp = Oj.load(full_resp.body)
66
+ return resp['data'] if resp.respond_to?(:has_key?) && resp.has_key?('data')
67
+ return resp
68
+ rescue Oj::ParseError
69
+ return full_resp.body
70
+ end
71
+ end
72
+
73
+ def post(resource, data='')
74
+ resource.gsub!('+', '%2B')
75
+ full_resp = _call_api(debug_output: lambda { "POST #{resource} / #{data}" },
76
+ api_call: lambda { self.conn.post(resource, Oj.dump(data)) } )
77
+ return {} if full_resp == {}
78
+ Oj.load(full_resp.body)
79
+ end
80
+
81
+ def put(resource, data='')
82
+ resource.gsub!('+', '%2B')
83
+ full_resp = _call_api(debug_output: lambda { "PUT #{resource} / #{data}" },
84
+ api_call: lambda { self.conn.put(resource, Oj.dump(data)) } )
85
+ return {} if full_resp == {}
86
+ Oj.load(full_resp.body)
87
+ end
88
+
89
+ def patch(resource, data='')
90
+ resource.gsub!('+', '%2B')
91
+ full_resp = _call_api(debug_output: lambda { "PATCH #{resource} / #{data}" },
92
+ api_call: lambda { self.conn.patch(resource, Oj.dump(data)) } )
93
+ return {} if full_resp == {}
94
+ Oj.load(full_resp.body)
95
+ end
96
+
97
+ def delete(resource)
98
+ resource.gsub!('+', '%2B')
99
+ full_resp = _call_api(debug_output: lambda { "DELETE #{resource}" },
100
+ api_call: lambda { self.conn.delete(resource) } )
101
+ return {} if full_resp == {}
102
+ full_resp.body
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,32 @@
1
+ module TogglV9
2
+ class API
3
+
4
+ ##
5
+ # ---------
6
+ # :section: Dashboard
7
+ #
8
+ # See https://github.com/toggl/toggl_api_docs/blob/master/chapters/dashboard.md
9
+
10
+ def dashboard(workspace_id)
11
+ dashboard = {}
12
+ dashboard['all_activity'] = all_activity(workspace_id)
13
+ dashboard['most_active_user'] = most_active_user(workspace_id)
14
+ dashboard['activity'] = top_activity(workspace_id)
15
+ dashboard
16
+ end
17
+
18
+ private
19
+
20
+ def all_activity(workspace_id)
21
+ get "workspaces/#{workspace_id}/dashboard/all_activity"
22
+ end
23
+
24
+ def most_active_user(workspace_id)
25
+ get "workspaces/#{workspace_id}/dashboard/most_active"
26
+ end
27
+
28
+ def top_activity(workspace_id)
29
+ get "workspaces/#{workspace_id}/dashboard/top_activity"
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ module TogglV9
2
+ class API
3
+
4
+ ##
5
+ # ---------
6
+ # :section: Project Users
7
+ #
8
+ # pid : project ID (integer, required)
9
+ # uid : user ID, who is added to the project (integer, required)
10
+ # wid : workspace ID, where the project belongs to (integer, not-required, project's workspace id is used)
11
+ # manager : admin rights for this project (boolean, default false)
12
+ # rate : hourly rate for the project user (float, not-required, only for pro workspaces) in the currency of the project's client or in workspace default currency.
13
+ # at : timestamp that is sent in the response, indicates when the project user was last updated
14
+ # -- Additional fields --
15
+ # fullname : full name of the user, who is added to the project
16
+
17
+ def create_project_user(params)
18
+ requireParams(params, ['pid', 'uid'])
19
+ params[:fields] = "fullname" # for simplicity, always request fullname field
20
+ post "project_users", { 'project_user' => params }
21
+ end
22
+
23
+ def update_project_user(project_user_id, params)
24
+ params[:fields] = "fullname" # for simplicity, always request fullname field
25
+ put "project_users/#{project_user_id}", { 'project_user' => params }
26
+ end
27
+
28
+ def delete_project_user(project_user_id)
29
+ delete "project_users/#{project_user_id}"
30
+ end
31
+ end
32
+ end