togglv9 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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