tinylinks 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.
- checksums.yaml +7 -0
- data/bin/tinylinks +6 -0
- data/lib/tinylinks/auth.rb +75 -0
- data/lib/tinylinks/cli.rb +160 -0
- data/lib/tinylinks/client.rb +69 -0
- data/lib/tinylinks/formatter.rb +36 -0
- data/lib/tinylinks.rb +12 -0
- metadata +59 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ff3190a474ea8e4c3d3591730b77295d59d806034dce59410a5ebc08a9d8c540
|
|
4
|
+
data.tar.gz: 2019b549e6959fabc443a06414a3e1567cf5f38954a5a583834dd0bb2d9aef04
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b6ca05746fc41a5f2ee89a37ce46e60079676bd9b7cc5bacf7c7bce7fdf6251b791f59056efc9ef3d923d05bd04560a03a66bb99cbf664aef652d31266bd1484
|
|
7
|
+
data.tar.gz: c43ba22c4aeab230476184cc4aaf776b12f40b75e3efeaee1072e24039f39f7265be355dd94744edbc583873d4ca161ea7eb83fc52967b6cc67da05876cceab6
|
data/bin/tinylinks
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Tinylinks
|
|
8
|
+
class Auth
|
|
9
|
+
CREDENTIALS_DIR = File.join(Dir.home, ".config", "tinylinks")
|
|
10
|
+
CREDENTIALS_FILE = File.join(CREDENTIALS_DIR, "credentials.json")
|
|
11
|
+
|
|
12
|
+
def initialize(client: Client.new)
|
|
13
|
+
@client = client
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def login
|
|
17
|
+
grant = @client.post("/device_authorizations")
|
|
18
|
+
yield(:open_browser, grant["verification_url"]) if block_given?
|
|
19
|
+
|
|
20
|
+
poll_for_token(grant["device_code"], grant["interval"], grant["expires_in"])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def token
|
|
24
|
+
return nil unless File.exist?(CREDENTIALS_FILE)
|
|
25
|
+
|
|
26
|
+
data = JSON.parse(File.read(CREDENTIALS_FILE))
|
|
27
|
+
expires_at = Time.parse(data["expires_at"]) if data["expires_at"]
|
|
28
|
+
|
|
29
|
+
if expires_at && expires_at <= Time.now
|
|
30
|
+
nil
|
|
31
|
+
else
|
|
32
|
+
data["token"]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def save_token(token, expires_at)
|
|
37
|
+
FileUtils.mkdir_p(CREDENTIALS_DIR)
|
|
38
|
+
File.write(CREDENTIALS_FILE, JSON.generate(token: token, expires_at: expires_at))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def logged_in?
|
|
42
|
+
!token.nil?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def logout
|
|
46
|
+
File.delete(CREDENTIALS_FILE) if File.exist?(CREDENTIALS_FILE)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def poll_for_token(device_code, interval, expires_in)
|
|
52
|
+
deadline = Time.now + expires_in
|
|
53
|
+
|
|
54
|
+
loop do
|
|
55
|
+
sleep(interval)
|
|
56
|
+
raise "Authorization expired" if Time.now >= deadline
|
|
57
|
+
|
|
58
|
+
response = @client.post("/device_authorizations/token", {device_code: device_code})
|
|
59
|
+
save_token(response["token"], response["expires_at"])
|
|
60
|
+
return response["token"]
|
|
61
|
+
rescue Client::ApiError => e
|
|
62
|
+
case e.body["error"]
|
|
63
|
+
when "authorization_pending"
|
|
64
|
+
next
|
|
65
|
+
when "access_denied"
|
|
66
|
+
raise "Authorization denied by user"
|
|
67
|
+
when "expired_token"
|
|
68
|
+
raise "Authorization expired"
|
|
69
|
+
else
|
|
70
|
+
raise
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Tinylinks
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
def self.exit_on_failure?
|
|
8
|
+
true
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class_option :help, aliases: "-h", type: :boolean, desc: "Show help for a command"
|
|
12
|
+
|
|
13
|
+
no_commands do
|
|
14
|
+
def invoke_command(command, *args)
|
|
15
|
+
if options[:help]
|
|
16
|
+
CLI.command_help(shell, command.name)
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
super
|
|
20
|
+
rescue Client::ApiError => e
|
|
21
|
+
if e.body.is_a?(Hash) && e.body["errors"]
|
|
22
|
+
say_error formatter.errors(e.body)
|
|
23
|
+
else
|
|
24
|
+
say_error e.message
|
|
25
|
+
end
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
desc "login", "Authenticate with TinyLinks"
|
|
31
|
+
def login
|
|
32
|
+
auth = Auth.new
|
|
33
|
+
if auth.logged_in?
|
|
34
|
+
say "Already logged in."
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
say "Starting device authorization..."
|
|
39
|
+
auth.login do |event, url|
|
|
40
|
+
if event == :open_browser
|
|
41
|
+
say "Opening browser for authorization..."
|
|
42
|
+
say "If it doesn't open, visit: #{url}"
|
|
43
|
+
system("open", url)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
say "Login successful!"
|
|
47
|
+
rescue RuntimeError => e
|
|
48
|
+
say_error "Login failed: #{e.message}"
|
|
49
|
+
exit 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
desc "logout", "Remove stored credentials"
|
|
53
|
+
def logout
|
|
54
|
+
Auth.new.logout
|
|
55
|
+
say "Logged out."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
desc "list", "List links"
|
|
59
|
+
method_option :tags, type: :string, desc: "Filter by tags (comma-separated)"
|
|
60
|
+
method_option :page, type: :numeric, desc: "Page number"
|
|
61
|
+
def list
|
|
62
|
+
params = {}
|
|
63
|
+
params[:tags] = options[:tags] if options[:tags]
|
|
64
|
+
params[:page] = options[:page] if options[:page]
|
|
65
|
+
data = client.get("/links", params)
|
|
66
|
+
say formatter.link_list(data)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
desc "show ID", "Show a link"
|
|
70
|
+
def show(id)
|
|
71
|
+
data = client.get("/links/#{id}")
|
|
72
|
+
say formatter.link(data["link"])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
desc "add URL", "Add a new link"
|
|
76
|
+
method_option :title, type: :string, desc: "Link title"
|
|
77
|
+
method_option :description, type: :string, desc: "Link description"
|
|
78
|
+
method_option :tags, type: :string, desc: "Tags (comma-separated)"
|
|
79
|
+
def add(url)
|
|
80
|
+
body = {url: url}
|
|
81
|
+
body[:title] = options[:title] if options[:title]
|
|
82
|
+
body[:description] = options[:description] if options[:description]
|
|
83
|
+
body[:tags] = options[:tags].split(",").map(&:strip) if options[:tags]
|
|
84
|
+
data = client.post("/links", body)
|
|
85
|
+
say formatter.link(data["link"])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
desc "edit ID", "Edit a link"
|
|
89
|
+
method_option :title, type: :string, desc: "Link title"
|
|
90
|
+
method_option :description, type: :string, desc: "Link description"
|
|
91
|
+
method_option :tags, type: :string, desc: "Tags (comma-separated)"
|
|
92
|
+
def edit(id)
|
|
93
|
+
body = {}
|
|
94
|
+
body[:title] = options[:title] if options[:title]
|
|
95
|
+
body[:description] = options[:description] if options[:description]
|
|
96
|
+
body[:tags] = options[:tags].split(",").map(&:strip) if options[:tags]
|
|
97
|
+
|
|
98
|
+
if body.empty?
|
|
99
|
+
say_error "No changes specified. Use --title, --description, or --tags."
|
|
100
|
+
exit 1
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
data = client.patch("/links/#{id}", body)
|
|
104
|
+
say formatter.link(data["link"])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
desc "search QUERY", "Search links"
|
|
108
|
+
method_option :page, type: :numeric, desc: "Page number"
|
|
109
|
+
def search(query)
|
|
110
|
+
params = {q: query}
|
|
111
|
+
params[:page] = options[:page] if options[:page]
|
|
112
|
+
data = client.get("/search", params)
|
|
113
|
+
say formatter.link_list(data)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
desc "tags", "List all tags"
|
|
117
|
+
method_option :sort, type: :string, desc: "Sort order: name (default) or popularity"
|
|
118
|
+
def tags
|
|
119
|
+
params = {}
|
|
120
|
+
params[:sort_by] = options[:sort] if options[:sort]
|
|
121
|
+
data = client.get("/tags", params)
|
|
122
|
+
say formatter.tags(data)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
desc "untagged", "List links without tags"
|
|
126
|
+
method_option :page, type: :numeric, desc: "Page number"
|
|
127
|
+
def untagged
|
|
128
|
+
params = {}
|
|
129
|
+
params[:page] = options[:page] if options[:page]
|
|
130
|
+
data = client.get("/untagged", params)
|
|
131
|
+
say formatter.link_list(data)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
desc "version", "Show version"
|
|
135
|
+
def version
|
|
136
|
+
say "tinylinks #{VERSION}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def client
|
|
142
|
+
@client ||= begin
|
|
143
|
+
token = Auth.new.token
|
|
144
|
+
unless token
|
|
145
|
+
say_error "Not logged in. Run `tinylinks login` first."
|
|
146
|
+
exit 1
|
|
147
|
+
end
|
|
148
|
+
Client.new(token: token)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def formatter
|
|
153
|
+
@formatter ||= Formatter.new
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def say_error(message)
|
|
157
|
+
$stderr.puts message
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Tinylinks
|
|
8
|
+
class Client
|
|
9
|
+
class ApiError < StandardError
|
|
10
|
+
attr_reader :status, :body
|
|
11
|
+
|
|
12
|
+
def initialize(status, body)
|
|
13
|
+
@status = status
|
|
14
|
+
@body = body
|
|
15
|
+
super("API error #{status}: #{body}")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(token: nil)
|
|
20
|
+
@token = token
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def get(path, params = {})
|
|
24
|
+
uri = build_uri(path, params)
|
|
25
|
+
request = Net::HTTP::Get.new(uri)
|
|
26
|
+
execute(request)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def post(path, body = {})
|
|
30
|
+
uri = build_uri(path)
|
|
31
|
+
request = Net::HTTP::Post.new(uri)
|
|
32
|
+
request.body = JSON.generate(body)
|
|
33
|
+
execute(request)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def patch(path, body = {})
|
|
37
|
+
uri = build_uri(path)
|
|
38
|
+
request = Net::HTTP::Patch.new(uri)
|
|
39
|
+
request.body = JSON.generate(body)
|
|
40
|
+
execute(request)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def build_uri(path, params = {})
|
|
46
|
+
uri = URI("#{API_BASE}#{path}")
|
|
47
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
48
|
+
uri
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def execute(request)
|
|
52
|
+
request["Content-Type"] = "application/json"
|
|
53
|
+
request["Authorization"] = "Bearer #{@token}" if @token
|
|
54
|
+
|
|
55
|
+
uri = request.uri
|
|
56
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
|
57
|
+
http.request(request)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
body = JSON.parse(response.body)
|
|
61
|
+
|
|
62
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
63
|
+
raise ApiError.new(response.code.to_i, body)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
body
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tinylinks
|
|
4
|
+
class Formatter
|
|
5
|
+
def link(data)
|
|
6
|
+
lines = []
|
|
7
|
+
lines << "#{data["title"] || "(untitled)"} [##{data["id"]}]"
|
|
8
|
+
lines << " #{data["url"]}"
|
|
9
|
+
lines << " #{data["description"]}" if data["description"] && !data["description"].empty?
|
|
10
|
+
lines << " tags: #{data["tags"].join(", ")}" if data["tags"] && !data["tags"].empty?
|
|
11
|
+
lines.join("\n")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def link_list(data)
|
|
15
|
+
lines = data["links"].map { |l| link(l) }
|
|
16
|
+
lines << pagination(data["meta"]) if data["meta"]
|
|
17
|
+
lines.join("\n\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def tags(data)
|
|
21
|
+
data["tags"].map { |t| "#{t["name"]} (#{t["count"]})" }.join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def errors(data)
|
|
25
|
+
data["errors"].flat_map do |field, messages|
|
|
26
|
+
messages.map { |msg| "#{field} #{msg}" }
|
|
27
|
+
end.join("\n")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def pagination(meta)
|
|
33
|
+
"Page #{meta["page"]} of #{meta["total_pages"]} (#{meta["total_items"]} total)"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/tinylinks.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tinylinks
|
|
4
|
+
VERSION = "0.1.0"
|
|
5
|
+
BASE_URL = "https://links.pati.to"
|
|
6
|
+
API_BASE = "#{BASE_URL}/api/v1"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "tinylinks/client"
|
|
10
|
+
require_relative "tinylinks/auth"
|
|
11
|
+
require_relative "tinylinks/formatter"
|
|
12
|
+
require_relative "tinylinks/cli"
|
metadata
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tinylinks
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jaime Rodas
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: thor
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.5'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.5'
|
|
26
|
+
description: Command-line interface for the TinyLinks bookmarking API
|
|
27
|
+
executables:
|
|
28
|
+
- tinylinks
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- bin/tinylinks
|
|
33
|
+
- lib/tinylinks.rb
|
|
34
|
+
- lib/tinylinks/auth.rb
|
|
35
|
+
- lib/tinylinks/cli.rb
|
|
36
|
+
- lib/tinylinks/client.rb
|
|
37
|
+
- lib/tinylinks/formatter.rb
|
|
38
|
+
homepage: https://github.com/jaimerodas/tinylinks-cli
|
|
39
|
+
licenses:
|
|
40
|
+
- MIT
|
|
41
|
+
metadata: {}
|
|
42
|
+
rdoc_options: []
|
|
43
|
+
require_paths:
|
|
44
|
+
- lib
|
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '4.0'
|
|
50
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
requirements: []
|
|
56
|
+
rubygems_version: 4.0.6
|
|
57
|
+
specification_version: 4
|
|
58
|
+
summary: CLI for TinyLinks
|
|
59
|
+
test_files: []
|