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 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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "tinylinks"
5
+
6
+ Tinylinks::CLI.start(ARGV)
@@ -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: []