kodeco 0.1.1 → 0.2.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 +4 -4
- data/README.md +23 -7
- data/lib/periscope/cli.rb +15 -9
- data/lib/periscope/client.rb +13 -0
- data/lib/periscope/commands/shortlinks.rb +118 -0
- data/lib/periscope/errors.rb +3 -2
- data/lib/periscope/http.rb +9 -0
- data/lib/periscope/login.rb +1 -1
- data/lib/periscope/short_link_text.rb +62 -0
- data/lib/periscope/version.rb +1 -1
- data/lib/periscope.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 917b9f6e09b0d724f94ff27f1596cba5c870545dc6a1199a0ebb31cd7a68d114
|
|
4
|
+
data.tar.gz: c9e3e16f779d27b3353e42593cb37abf181f9e3d1459b60dc79cef5d1b20d48d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a5812db82c6f20d0bb3346fd4dd501149a58f382235038b6b274b78e7bae1329cdad77791469d0f83743ac6ad55022037c06eb363ec7a4c8f0bb3b8bf01d3ed3
|
|
7
|
+
data.tar.gz: 105c668ab4fc503d874401e048f6cec51e5732e7df7673f91e9cf2c6e43992486fa065310b1e842213ab45b642f8a311a60cc9f7f54be25d9c8d07a27c1dfc7d
|
data/README.md
CHANGED
|
@@ -22,6 +22,10 @@ kodeco order <id>
|
|
|
22
22
|
kodeco subscription <email|rwuuid>
|
|
23
23
|
kodeco problems [--kind overdue|stuck_in_preauth|invoice_overdue|expiry_missed] [--page N]
|
|
24
24
|
kodeco flags --app guardpost|kerching|carolus
|
|
25
|
+
kodeco shortlinks list [query] [--page N]
|
|
26
|
+
kodeco shortlinks show <slug>
|
|
27
|
+
kodeco shortlinks create <url> [--channels a,b] [--campaign NAME]
|
|
28
|
+
kodeco shortlinks update <slug> --active=true|false
|
|
25
29
|
```
|
|
26
30
|
|
|
27
31
|
`user` shows guardpost's customer summary, with kerching's and carolus's
|
|
@@ -31,10 +35,19 @@ exits 5 after printing the rest. `orders`, `order`, `subscription` and
|
|
|
31
35
|
`flags:read`. With `--json`, commands print the response's `data`, plus `meta`
|
|
32
36
|
for lists.
|
|
33
37
|
|
|
38
|
+
`shortlinks` manages carolus's kod.eco short links. `list` and `show` need
|
|
39
|
+
`shortlinks:read`, which `login` asks for by default. `create` and `update`
|
|
40
|
+
need `shortlinks:write`, which it never asks for by default; without it they
|
|
41
|
+
print the `kodeco login --scopes` line to run, and send nothing. `show` and
|
|
42
|
+
`update` take a slug or a short URL. Team members see and change only their
|
|
43
|
+
own links. Admins see every link and the fuller click counts, and can use
|
|
44
|
+
`--channels` (keys from carolus's `config/short_link_channels.yml`, such as
|
|
45
|
+
`twitter`) and `--campaign`, which needs `--channels`.
|
|
46
|
+
|
|
34
47
|
`login` uses guardpost's device-code flow: it prints a code and a link to
|
|
35
48
|
approve it, and opens the link in a browser when run in a terminal. The
|
|
36
49
|
default scopes are `users:read support:read orders:read learning:read
|
|
37
|
-
flags:read`. Scopes other than `flags:read` need a grant from a guardpost
|
|
50
|
+
flags:read shortlinks:read`. Scopes other than `flags:read` need a grant from a guardpost
|
|
38
51
|
admin. A login gets whichever of the requested scopes you hold, and `login`
|
|
39
52
|
lists any that weren't granted; a grant added later needs a fresh
|
|
40
53
|
`kodeco login`.
|
|
@@ -45,7 +58,8 @@ the server session lasts up to 7 days unless an admin revokes it.
|
|
|
45
58
|
Every command takes:
|
|
46
59
|
|
|
47
60
|
* `--json`: stdout is exactly one JSON document, and errors go to stderr as
|
|
48
|
-
`{"error": {"code": ..., "message": ...}}`.
|
|
61
|
+
`{"error": {"code": ..., "message": ...}}`. When an app refuses a value,
|
|
62
|
+
the error also has `details`, with each field's problems.
|
|
49
63
|
* `--debug`: logs each request's method, URL and status to stderr. Tokens are
|
|
50
64
|
never printed or logged.
|
|
51
65
|
|
|
@@ -55,7 +69,7 @@ Every command takes:
|
|
|
55
69
|
| ---- | ------- |
|
|
56
70
|
| 0 | OK |
|
|
57
71
|
| 1 | Error |
|
|
58
|
-
| 2 | Usage error |
|
|
72
|
+
| 2 | Usage error, or a value the app refused |
|
|
59
73
|
| 3 | Not logged in, or authentication failed |
|
|
60
74
|
| 4 | Not found |
|
|
61
75
|
| 5 | An upstream app is unavailable |
|
|
@@ -79,10 +93,12 @@ processes share one refresh rather than revoking each other's session.
|
|
|
79
93
|
The gem is published to rubygems.org as `kodeco`, with Trusted Publishing, so
|
|
80
94
|
no API key is stored anywhere.
|
|
81
95
|
|
|
82
|
-
1. Bump `Periscope::VERSION` in `lib/periscope/version.rb
|
|
83
|
-
2. Merge to `trunk`.
|
|
84
|
-
3.
|
|
85
|
-
|
|
96
|
+
1. Bump `Periscope::VERSION` in `lib/periscope/version.rb`, in a PR.
|
|
97
|
+
2. Merge the release PR to `trunk`.
|
|
98
|
+
3. Publish a GitHub release tagged `v<version>` on `trunk`. The Release
|
|
99
|
+
workflow checks that the tag matches `Periscope::VERSION`, runs the tests,
|
|
100
|
+
then builds and pushes the gem. To re-run a failed publish, run the
|
|
101
|
+
workflow by hand with the tag.
|
|
86
102
|
|
|
87
103
|
## Development
|
|
88
104
|
|
data/lib/periscope/cli.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Periscope
|
|
|
9
9
|
include Commands::User
|
|
10
10
|
include Commands::Kerching
|
|
11
11
|
include Commands::Flags
|
|
12
|
+
include Commands::Shortlinks
|
|
12
13
|
|
|
13
14
|
USAGE = <<~TEXT
|
|
14
15
|
Usage: kodeco <command> [options]
|
|
@@ -24,12 +25,18 @@ module Periscope
|
|
|
24
25
|
subscription <email|rwuuid> Show a user's kerching subscription
|
|
25
26
|
problems [--kind KIND] [--page N] List kerching orders that need attention
|
|
26
27
|
flags --app guardpost|kerching|carolus Show an app's feature flags
|
|
28
|
+
shortlinks list [query] [--page N] List kod.eco short links
|
|
29
|
+
shortlinks show <slug> Show one short link and its clicks
|
|
30
|
+
shortlinks create <url> [--channels a,b] [--campaign NAME]
|
|
31
|
+
Make short links to a kodeco.com URL
|
|
32
|
+
shortlinks update <slug> --active=true|false
|
|
33
|
+
Turn a short link on or off
|
|
27
34
|
|
|
28
35
|
Every command takes --json (one JSON document on stdout, errors as JSON
|
|
29
36
|
on stderr) and --debug (logs each request's method, URL and status).
|
|
30
37
|
TEXT
|
|
31
38
|
|
|
32
|
-
COMMANDS = %w[login logout whoami user orders order subscription problems flags].freeze
|
|
39
|
+
COMMANDS = %w[login logout whoami user orders order subscription problems flags shortlinks].freeze
|
|
33
40
|
LOGOUT_NOTE = "guardpost has no revoke endpoint, so the server session lasts up to 7 days " \
|
|
34
41
|
"unless an admin revokes it."
|
|
35
42
|
|
|
@@ -104,7 +111,7 @@ module Periscope
|
|
|
104
111
|
end
|
|
105
112
|
|
|
106
113
|
# Parses the remaining arguments and returns the options; commands that
|
|
107
|
-
# take positional arguments pass positional: n.
|
|
114
|
+
# take positional arguments pass positional: n, or a Range.
|
|
108
115
|
def parse_options(positional: 0)
|
|
109
116
|
opts = {}
|
|
110
117
|
parser = OptionParser.new
|
|
@@ -112,15 +119,14 @@ module Periscope
|
|
|
112
119
|
parser.on("--debug")
|
|
113
120
|
yield parser, opts if block_given?
|
|
114
121
|
rest = parser.parse(@argv)
|
|
115
|
-
|
|
122
|
+
unless Array(positional).include?(rest.size)
|
|
123
|
+
raise UsageError, "Expected #{positional} argument(s), got #{rest.size}"
|
|
124
|
+
end
|
|
116
125
|
|
|
117
|
-
opts
|
|
118
|
-
opts
|
|
126
|
+
opts.merge(args: rest)
|
|
119
127
|
end
|
|
120
128
|
|
|
121
|
-
def emit(data)
|
|
122
|
-
@stdout.puts(@json ? JSON.generate(data) : yield)
|
|
123
|
-
end
|
|
129
|
+
def emit(data) = @stdout.puts(@json ? JSON.generate(data) : yield)
|
|
124
130
|
|
|
125
131
|
def usage(status)
|
|
126
132
|
(status.zero? ? @stdout : @stderr).puts(USAGE)
|
|
@@ -129,7 +135,7 @@ module Periscope
|
|
|
129
135
|
|
|
130
136
|
def fail_with(error)
|
|
131
137
|
if @json
|
|
132
|
-
@stderr.puts(JSON.generate(error: { code: error.code, message: error.message }))
|
|
138
|
+
@stderr.puts(JSON.generate(error: { code: error.code, message: error.message, details: error.details }.compact))
|
|
133
139
|
else
|
|
134
140
|
@stderr.puts("kodeco: #{error.message}")
|
|
135
141
|
end
|
data/lib/periscope/client.rb
CHANGED
|
@@ -18,6 +18,10 @@ module Periscope
|
|
|
18
18
|
handle(app, scope, @http.post_json(url(app, path), payload, auth(app)))
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
def patch_json(app, path, payload, scope: nil)
|
|
22
|
+
handle(app, scope, @http.patch_json(url(app, path), payload, auth(app)))
|
|
23
|
+
end
|
|
24
|
+
|
|
21
25
|
private
|
|
22
26
|
|
|
23
27
|
def url(app, path) = "#{@config.url(app)}#{path}"
|
|
@@ -32,11 +36,20 @@ module Periscope
|
|
|
32
36
|
when 401 then raise AuthError.new("#{app} rejected the token: #{message}. Run kodeco login.", code: "auth_failed")
|
|
33
37
|
when 403 then raise AuthError.new(forbidden(app, scope, message), code: "forbidden")
|
|
34
38
|
when 404 then raise NotFoundError, message
|
|
39
|
+
when 422 then raise refused(app, message, res)
|
|
35
40
|
when 500.. then raise UnavailableError, "#{app} unavailable (#{message})"
|
|
36
41
|
end
|
|
37
42
|
raise Error.new(message, code: res.error_code || "error")
|
|
38
43
|
end
|
|
39
44
|
|
|
45
|
+
# A value the app refused, with each field's problems in the message.
|
|
46
|
+
def refused(app, message, res)
|
|
47
|
+
details = res.error_details
|
|
48
|
+
fields = details.map { |field, problems| "#{field}: #{Array(problems).join(', ')}" } if details.is_a?(Hash)
|
|
49
|
+
message = "#{message} (#{fields.join('; ')})" if fields&.any?
|
|
50
|
+
UsageError.new("#{app} refused: #{message}", code: res.error_code || "validation_failed", details: details)
|
|
51
|
+
end
|
|
52
|
+
|
|
40
53
|
def forbidden(app, scope, message)
|
|
41
54
|
return "#{app} refused: #{message}" unless scope
|
|
42
55
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Periscope
|
|
4
|
+
module Commands
|
|
5
|
+
# kodeco shortlinks list, show, create and update: carolus's kod.eco short links.
|
|
6
|
+
module Shortlinks
|
|
7
|
+
SUBCOMMANDS = %w[list show create update].freeze
|
|
8
|
+
PATH = "/api/v2/short_links"
|
|
9
|
+
SLUG = /\A[a-z2-9]{5}\z/
|
|
10
|
+
UTM_HEADERS = %w[SOURCE MEDIUM CAMPAIGN].freeze
|
|
11
|
+
LIST_HEADERS = ["SHORT URL", "ACTIVE", "CLICKS", "7D", "BOTS", *UTM_HEADERS, "DESTINATION"].freeze
|
|
12
|
+
TEAM_NOTE = "Short links are for the Kodeco team, and only admins can see or change other people's links " \
|
|
13
|
+
"or use --channels and --campaign."
|
|
14
|
+
RETRY_NOTE = "It may have been applied; running the same command again is safe."
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def cmd_shortlinks
|
|
19
|
+
subcommand = @argv.shift
|
|
20
|
+
raise UsageError, "Usage: kodeco shortlinks #{SUBCOMMANDS.join('|')}" unless SUBCOMMANDS.include?(subcommand)
|
|
21
|
+
|
|
22
|
+
send(:"shortlinks_#{subcommand}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def shortlinks_list
|
|
26
|
+
opts = parse_options(positional: 0..1) { |parser, o| page_option(parser, o) }
|
|
27
|
+
path = "#{PATH}#{query(q: opts[:args].first, page: opts[:page])}"
|
|
28
|
+
body = carolus("shortlinks:read") { client.get("carolus", path) }
|
|
29
|
+
rows = body["data"].map do |link|
|
|
30
|
+
[link["short_url"], link["active"] ? "yes" : "no", link["clicks"], link["clicks_7d"], link["bot_clicks"],
|
|
31
|
+
*ShortLinkText.utm(link), link["destination_url"]]
|
|
32
|
+
end
|
|
33
|
+
emit(body.slice("data", "meta")) { "#{Format.table(LIST_HEADERS, rows)}\n#{pages(body)}" }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def shortlinks_show
|
|
37
|
+
slug = slug_arg(parse_options(positional: 1))
|
|
38
|
+
link = carolus("shortlinks:read", slug: slug) { client.get("carolus", "#{PATH}/#{slug}") }["data"]
|
|
39
|
+
emit(link) { ShortLinkText.new(link).to_s }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def shortlinks_create
|
|
43
|
+
opts = parse_options(positional: 1) do |parser, o|
|
|
44
|
+
parser.on("--channels LIST", Array) { |v| o[:channels] = channels(v) }
|
|
45
|
+
parser.on("--campaign NAME") { |v| o[:campaign] = v }
|
|
46
|
+
end
|
|
47
|
+
raise UsageError, "--campaign needs --channels: carolus uses it only for channel links" if
|
|
48
|
+
opts[:campaign] && !opts[:channels]
|
|
49
|
+
|
|
50
|
+
payload = { destination_url: opts[:args].first, channels: opts[:channels], utm_campaign: opts[:campaign] }
|
|
51
|
+
links = carolus("shortlinks:write", write: true) { client.post_json("carolus", PATH, payload.compact) }["data"]
|
|
52
|
+
emit(links) { create_text(links) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def shortlinks_update
|
|
56
|
+
opts = parse_options(positional: 1) { |parser, o| parser.on("--active BOOL", TrueClass) { |v| o[:active] = v } }
|
|
57
|
+
slug = slug_arg(opts)
|
|
58
|
+
raise UsageError, "update needs --active=true or --active=false" if opts[:active].nil?
|
|
59
|
+
|
|
60
|
+
link = carolus("shortlinks:write", slug: slug, write: true) do
|
|
61
|
+
client.patch_json("carolus", "#{PATH}/#{slug}", { active: opts[:active] })
|
|
62
|
+
end["data"]
|
|
63
|
+
emit(link) { ShortLinkText.new(link).updated }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def create_text(links)
|
|
67
|
+
rows = links.map { |l| [l["created"] ? "created" : "existing", l["short_url"], *ShortLinkText.utm(l)] }
|
|
68
|
+
created = links.count { |link| link["created"] }
|
|
69
|
+
"#{Format.table(['STATUS', 'SHORT URL', *UTM_HEADERS], rows)}\n" \
|
|
70
|
+
"#{created} created, #{links.size - created} already existed"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def channels(list)
|
|
74
|
+
list = list.compact.reject(&:empty?)
|
|
75
|
+
raise UsageError, "--channels needs at least one channel" if list.empty?
|
|
76
|
+
|
|
77
|
+
list
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A slug, or a short URL on any host: the last path segment, lowercased.
|
|
81
|
+
def slug_arg(opts)
|
|
82
|
+
slug = opts[:args].first.split("/").last.to_s.downcase
|
|
83
|
+
raise UsageError, "#{opts[:args].first} isn't a short link slug or URL" unless slug.match?(SLUG)
|
|
84
|
+
|
|
85
|
+
slug
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Checks the login's scope before sending anything, then makes carolus's
|
|
89
|
+
# refusals say what they mean for short links.
|
|
90
|
+
def carolus(scope, slug: nil, write: false)
|
|
91
|
+
require_scope(scope)
|
|
92
|
+
yield
|
|
93
|
+
rescue AuthError => e
|
|
94
|
+
raise unless e.code == "forbidden"
|
|
95
|
+
|
|
96
|
+
raise AuthError.new("#{e.message.delete_suffix('.')}. #{TEAM_NOTE}", code: "forbidden")
|
|
97
|
+
rescue NotFoundError
|
|
98
|
+
raise unless slug
|
|
99
|
+
|
|
100
|
+
raise NotFoundError, "No short link #{slug}"
|
|
101
|
+
rescue UnavailableError => e
|
|
102
|
+
raise unless write
|
|
103
|
+
|
|
104
|
+
raise UnavailableError, "#{e.message}. #{RETRY_NOTE}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A new login replaces the stored one, so the suggested line keeps the scopes it has.
|
|
108
|
+
def require_scope(scope)
|
|
109
|
+
credentials = store.read or raise AuthError, "Not logged in. Run kodeco login."
|
|
110
|
+
held = credentials["scope"].to_s.split
|
|
111
|
+
return if held.include?(scope)
|
|
112
|
+
|
|
113
|
+
raise AuthError.new("This needs the #{scope} scope, which your login doesn't have. " \
|
|
114
|
+
"Run: kodeco login --scopes \"#{[*held, scope].join(' ')}\"", code: "missing_scope")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/periscope/errors.rb
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
module Periscope
|
|
4
4
|
# Each error carries the exit code the CLI returns for it.
|
|
5
5
|
class Error < StandardError
|
|
6
|
-
attr_reader :code
|
|
6
|
+
attr_reader :code, :details
|
|
7
7
|
|
|
8
|
-
def initialize(message, code: default_code)
|
|
8
|
+
def initialize(message, code: default_code, details: nil)
|
|
9
9
|
super(message)
|
|
10
10
|
@code = code
|
|
11
|
+
@details = details unless details.respond_to?(:empty?) && details.empty?
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def exit_code = 1
|
data/lib/periscope/http.rb
CHANGED
|
@@ -22,6 +22,11 @@ module Periscope
|
|
|
22
22
|
error = body["error"]
|
|
23
23
|
error.is_a?(Hash) ? error["message"] : body["error_description"]
|
|
24
24
|
end
|
|
25
|
+
|
|
26
|
+
def error_details
|
|
27
|
+
error = body["error"]
|
|
28
|
+
error["details"] if error.is_a?(Hash)
|
|
29
|
+
end
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
NETWORK_ERRORS = [SocketError, SystemCallError, IOError, Timeout::Error,
|
|
@@ -44,6 +49,10 @@ module Periscope
|
|
|
44
49
|
request(Net::HTTP::Post, url, headers, JSON.generate(payload), "application/json")
|
|
45
50
|
end
|
|
46
51
|
|
|
52
|
+
def patch_json(url, payload, headers = {})
|
|
53
|
+
request(Net::HTTP::Patch, url, headers, JSON.generate(payload), "application/json")
|
|
54
|
+
end
|
|
55
|
+
|
|
47
56
|
private
|
|
48
57
|
|
|
49
58
|
def request(klass, url, headers, body = nil, content_type = nil)
|
data/lib/periscope/login.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Periscope
|
|
4
4
|
# The OAuth device-code flow against guardpost.
|
|
5
5
|
class Login
|
|
6
|
-
DEFAULT_SCOPES = "users:read support:read orders:read learning:read flags:read"
|
|
6
|
+
DEFAULT_SCOPES = "users:read support:read orders:read learning:read flags:read shortlinks:read"
|
|
7
7
|
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
8
8
|
SCOPE_HINT = "If you lack a grant for one of the scopes, retry with --scopes and leave it out."
|
|
9
9
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Periscope
|
|
4
|
+
# Renders one of carolus's short links for people. Admins get extra click
|
|
5
|
+
# counts and the daily and bot sections; team members get neither.
|
|
6
|
+
class ShortLinkText
|
|
7
|
+
INACTIVE = "is now inactive. Visitors go to the kodeco.com home page."
|
|
8
|
+
|
|
9
|
+
# The UTM source, medium and campaign, with empty values as nil so tables print "-".
|
|
10
|
+
def self.utm(link)
|
|
11
|
+
%w[utm_source utm_medium utm_campaign].map { |key| link[key].to_s.empty? ? nil : link[key] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(link)
|
|
15
|
+
@link = link
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
["#{@link['short_url']} (#{@link['active'] ? 'active' : 'inactive'})", *fields, *daily, *bots].join("\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# After a PATCH, which sends neither daily clicks nor bots.
|
|
23
|
+
def updated
|
|
24
|
+
["#{@link['short_url']} #{@link['active'] ? 'is now active.' : INACTIVE}", *fields].join("\n")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def fields
|
|
30
|
+
[["Destination", @link["destination_url"]], ["Full URL", @link["full_destination_url"]], ["UTM", utm],
|
|
31
|
+
["Created", "#{@link['created_at']} by #{@link['created_by'] || '-'}"], ["Updated", @link["updated_at"]],
|
|
32
|
+
["Clicks", clicks]].map do |label, value|
|
|
33
|
+
format(" %-13<label>s%<value>s", label: "#{label}:", value: value.nil? ? "-" : value)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def utm
|
|
38
|
+
values = self.class.utm(@link)
|
|
39
|
+
values.all?(&:nil?) ? "none" : values.map { |v| v || "-" }.join(" / ")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def clicks
|
|
43
|
+
extra = { "7 days" => "clicks_7d", "30 days" => "clicks_30d", "bots" => "bot_clicks" }
|
|
44
|
+
.filter_map { |label, key| "#{label} #{@link[key]}" if @link.key?(key) }
|
|
45
|
+
extra.empty? ? @link["clicks"].to_s : "#{@link['clicks']} (#{extra.join(', ')})"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def daily
|
|
49
|
+
return [] unless @link.key?("daily_clicks")
|
|
50
|
+
|
|
51
|
+
rows = Array(@link["daily_clicks"]).last(14).map { |day| [day["date"], day["clicks"]] }
|
|
52
|
+
["", "Daily clicks, last 14 days", Format.table(%w[DATE CLICKS], rows)]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def bots
|
|
56
|
+
return [] unless @link.key?("bot_breakdown")
|
|
57
|
+
|
|
58
|
+
rows = Array(@link["bot_breakdown"]).map { |bot| [bot["name"], bot["clicks"]] }
|
|
59
|
+
["", "Bots", Format.table(%w[NAME CLICKS], rows)]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/periscope/version.rb
CHANGED
data/lib/periscope.rb
CHANGED
|
@@ -11,7 +11,9 @@ require_relative "periscope/login"
|
|
|
11
11
|
require_relative "periscope/user_lookup"
|
|
12
12
|
require_relative "periscope/user_summary"
|
|
13
13
|
require_relative "periscope/format"
|
|
14
|
+
require_relative "periscope/short_link_text"
|
|
14
15
|
require_relative "periscope/commands/user"
|
|
15
16
|
require_relative "periscope/commands/kerching"
|
|
16
17
|
require_relative "periscope/commands/flags"
|
|
18
|
+
require_relative "periscope/commands/shortlinks"
|
|
17
19
|
require_relative "periscope/cli"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kodeco
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kodeco
|
|
@@ -22,6 +22,7 @@ files:
|
|
|
22
22
|
- lib/periscope/client.rb
|
|
23
23
|
- lib/periscope/commands/flags.rb
|
|
24
24
|
- lib/periscope/commands/kerching.rb
|
|
25
|
+
- lib/periscope/commands/shortlinks.rb
|
|
25
26
|
- lib/periscope/commands/user.rb
|
|
26
27
|
- lib/periscope/config.rb
|
|
27
28
|
- lib/periscope/credential_store.rb
|
|
@@ -30,6 +31,7 @@ files:
|
|
|
30
31
|
- lib/periscope/http.rb
|
|
31
32
|
- lib/periscope/login.rb
|
|
32
33
|
- lib/periscope/session.rb
|
|
34
|
+
- lib/periscope/short_link_text.rb
|
|
33
35
|
- lib/periscope/user_lookup.rb
|
|
34
36
|
- lib/periscope/user_summary.rb
|
|
35
37
|
- lib/periscope/version.rb
|