furaffinity 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50e5e2ea8cd319bee7eb1665afe2888c7d763dd63ab1889ff861621d0871c96d
4
- data.tar.gz: 984287cda209a247bda7499bda6b5509cb56ae9bdda37ae1e32b9e17b98b4ce5
3
+ metadata.gz: 486881425319cc8b49b196f58f5e4784462d39baa62aa27e985c91a0606c0f21
4
+ data.tar.gz: 81eef14d9b5b32e2874ae3d1e83e6811086cdc902b47b3e861f0e70d0a1f6810
5
5
  SHA512:
6
- metadata.gz: 961c5c9f67fcc98522a3c09f51f83c299c4016d1987f29d18f8ba516f4d6b3b16f11213b70d76902f00b4174d213a423a94b854943d983bc69044678ffe0c031
7
- data.tar.gz: 9f87aa43971869236f1f7b2d0ad02ba9bfa73a45cba39a103c84488cfbc51c60d6ea021a6dbcba2ccf6f399e2ec87608b4b4b4c16b65e9408d0276269ce785f2
6
+ metadata.gz: 1d4ea145d6886a771017f00189153022947575f5184d8328ec0252748b29d17802188ad307dc9abee02806ff7af2d4bc5552cfa94f5d9e6119ca87369d7c59d6
7
+ data.tar.gz: 8e91c5403692b846cf7078735af81a67f4e462d8e04e99d51d4268fa13d7778e70ecb22c2ef2690690bfc1f25c8186d27903d179ab48c29e652a60f88855caa8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2023-12-21
4
+
5
+ ## Added
6
+ - `fa edit` command to edit submissions
7
+ - `after_upload` hook to queues
8
+
9
+ ## Changed
10
+ - `folder_name` is now called `create_folder_name` as it should
11
+
3
12
  ## [0.1.0] - 2023-11-04
4
13
 
5
14
  - Initial release
data/README.md CHANGED
@@ -30,6 +30,10 @@ fa notifications
30
30
 
31
31
  # upload a new submission
32
32
  fa upload my_image.png --title "test post please ignore" --description "This is an image as you can see" --rating general --scrap
33
+
34
+ # interactively update a submission, this needs your preferred editor in ENV
35
+ export EDITOR=vi
36
+ fa edit 54328944
33
37
  ```
34
38
 
35
39
  There is also a way to upload submissions in bulk: `fa queue`
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "tempfile"
4
5
  require "thor"
6
+ require "yaml"
5
7
 
6
8
  module Furaffinity
7
9
  class Cli < Thor
@@ -63,9 +65,9 @@ module Furaffinity
63
65
  type: :string,
64
66
  desc: "Keywords, separated by spaces.",
65
67
  default: ""
66
- option :folder_name,
68
+ option :create_folder_name,
67
69
  type: :string,
68
- desc: "Place this submission into this folder.",
70
+ desc: "Create a new folder and place this submission into it.",
69
71
  default: ""
70
72
  def upload(file_path)
71
73
  set_log_level(options)
@@ -83,6 +85,55 @@ module Furaffinity
83
85
  say "Submission uploaded! #{url}", :green
84
86
  end
85
87
 
88
+ EDIT_TEMPLATE = <<~YAML
89
+ ---
90
+ # Submission info for %<id>s
91
+
92
+ # Required field:
93
+ title: %<title>s
94
+
95
+ # Required field
96
+ description: |-
97
+ %<description>s
98
+
99
+ # Optional field, keywords separated by spaces:
100
+ keywords: %<keywords>s
101
+
102
+ # Required field, one of: [#{Furaffinity::Client::RATING_MAP.keys.join(", ")}]
103
+ rating: %<rating>s
104
+
105
+ scrap: %<scrap>s
106
+ lock_comments: %<lock_comments>s
107
+
108
+ # vim: syntax=yaml
109
+ YAML
110
+
111
+ desc "edit ID", "Edit submission info"
112
+ def edit_interactive(id)
113
+ set_log_level(options)
114
+ config = config_for(options)
115
+ client = config.new_client
116
+
117
+ submission_info = client.submission_info(id)
118
+ # write template
119
+ file = Tempfile.new("facli_edit_#{id}")
120
+ file.puts format(EDIT_TEMPLATE,
121
+ id:,
122
+ **submission_info.slice(:title, :keywords, :scrap, :lock_comments).transform_values(&:inspect),
123
+ description: submission_info.fetch(:message).gsub(/^/, " "),
124
+ rating: submission_info.fetch(:rating),
125
+ )
126
+ file.close
127
+ CliUtils.open_editor file.path, fatal: true
128
+ params = YAML.safe_load_file(file.path, permitted_classes: [Symbol]).transform_keys(&:to_sym)
129
+
130
+ url = client.update(id:, **params)
131
+ say "Submission updated! #{url}", :green
132
+ ensure
133
+ file.close
134
+ file.unlink
135
+ end
136
+
86
137
  desc "queue SUBCOMMAND ...ARGS", "Manage an upload queue"
87
138
  long_desc <<~LONG_DESC, wrap: false
88
139
  `#{basename} queue` manages an upload queue.
@@ -122,5 +173,10 @@ module Furaffinity
122
173
  #{basename} queue clean
123
174
  LONG_DESC
124
175
  subcommand "queue", CliQueue
176
+
177
+ map %w[--version -v] => :__print_version
178
+
179
+ desc "--version, -v", "Print the version"
180
+ def __print_version = puts "furaffinity-cli/#{VERSION}"
125
181
  end
126
182
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Furaffinity
6
+ module CliUtils
7
+ module_function
8
+
9
+ include SemanticLogger::Loggable
10
+
11
+ def open_editor(file, fatal: false)
12
+ editor = ENV["FA_EDITOR"] || ENV["VISUAL"] || ENV["EDITOR"]
13
+ unless editor
14
+ logger.warn "could not open editor for #{file.inspect}, set one of FA_EDITOR, VISUAL, or EDITOR in your ENV"
15
+ raise "No suitable editor found to edit #{file.inspect}, set one of FA_EDITOR, VISUAL, or EDITOR in your ENV" if fatal
16
+
17
+ return
18
+ end
19
+
20
+ system(*Shellwords.shellwords(editor), file).tap do
21
+ next if $?.exitstatus == 0
22
+
23
+ logger.error "could not run #{editor} #{file}, exit code: #{$?.exitstatus}"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -73,23 +73,18 @@ module Furaffinity
73
73
  adult: 1,
74
74
  }.freeze
75
75
 
76
- def fake_upload(file, title:, rating:, description:, keywords:, folder_name: "", lock_comments: false, scrap: false, type: :submission)
77
- type = type.to_sym
78
- raise ArgumentError.new("#{type.inspect} is not in #{SUBMISSION_TYPES.inspect}") unless SUBMISSION_TYPES.include?(type)
79
- rating = rating.to_sym
80
- raise ArgumentError.new("#{rating.inspect} is not in #{RATING_MAP.keys.inspect}") unless RATING_MAP.include?(rating)
76
+ def fake_upload(file, title:, rating:, description:, keywords:, create_folder_name: "", lock_comments: false, scrap: false, type: :submission)
77
+ validate_args!(type:, rating:) => { type:, rating: }
78
+
81
79
  raise "not a file" unless file.is_a?(File)
82
80
  params = { MAX_FILE_SIZE: "10485760" }
83
81
  raise ArgumentError.new("file size of #{file.size} is greater than FA limit of #{params[:MAX_FILE_SIZE]}") if file.size > params[:MAX_FILE_SIZE].to_i
84
- "https://..."
82
+ "https://www.furaffinity.net/view/54328944/?upload-successful"
85
83
  end
86
84
 
87
85
  # @param file [File]
88
- def upload(file, title:, rating:, description:, keywords:, folder_name: "", lock_comments: false, scrap: false, type: :submission)
89
- type = type.to_sym
90
- raise ArgumentError.new("#{type.inspect} is not in #{SUBMISSION_TYPES.inspect}") unless SUBMISSION_TYPES.include?(type)
91
- rating = rating.to_sym
92
- raise ArgumentError.new("#{rating.inspect} is not in #{RATING_MAP.keys.inspect}") unless RATING_MAP.include?(rating)
86
+ def upload(file, title:, rating:, description:, keywords:, create_folder_name: "", lock_comments: false, scrap: false, type: :submission)
87
+ validate_args!(type:, rating:) => { type:, rating: }
93
88
 
94
89
  client = http_client
95
90
 
@@ -143,7 +138,7 @@ module Furaffinity
143
138
  message: description,
144
139
  keywords: keywords,
145
140
 
146
- create_folder_name: folder_name,
141
+ create_folder_name:,
147
142
 
148
143
  # finalize button :)
149
144
  finalize: "Finalize ",
@@ -164,8 +159,100 @@ module Furaffinity
164
159
  end
165
160
  end
166
161
 
162
+ # Returns submission information from your own gallery.
163
+ def submission_info(id)
164
+ client = http_client
165
+
166
+ logger.trace { "Retrieving submission information for #{id}" }
167
+ response = get("/controls/submissions/changeinfo/#{id}/", client:).then(&method(:parse_response))
168
+
169
+ {}.tap do |h|
170
+ h[:title] = response.css("form[name=MsgForm] input[name=title]").first.attr(:value)
171
+
172
+ %i[message keywords].each do |field|
173
+ h[field] = response.css("form[name=MsgForm] textarea[name=#{field}]").first.text
174
+ end
175
+
176
+ h[:rating] = RATING_MAP.invert.fetch(response.css("form[name=MsgForm] input[name=rating][checked]").first.attr(:value).to_i)
177
+
178
+ %i[lock_comments scrap].each do |field|
179
+ h[field] = !!response.css("form[name=MsgForm] input[name=#{field}][checked]").first&.attr(:value)
180
+ end
181
+
182
+ %i[cat atype species gender].each do |field|
183
+ h[field] = response.css("form[name=MsgForm] select[name=#{field}] option[selected]").first.attr(:value)
184
+ end
185
+
186
+ h[:folder_ids] = response.css("form[name=MsgForm] input[name=\"folder_ids[]\"][checked]").map { _1.attr(:value) }
187
+ end
188
+ end
189
+
190
+ def fake_update(id:, title:, rating:, description:, keywords:, lock_comments: false, scrap: false)
191
+ validate_args!(rating:) => { rating: }
192
+
193
+ "https://www.furaffinity.net/view/54328944/"
194
+ end
195
+
196
+ # NOTE: only tested with `type=submission`
197
+ def update(id:, title:, rating:, description:, keywords:, lock_comments: false, scrap: false)
198
+ validate_args!(rating:) => { rating: }
199
+
200
+ client = http_client
201
+
202
+ # step 1: get the required keys
203
+ logger.trace { "Extracting keys from submission #{id}" }
204
+ response = get("/controls/submissions/changeinfo/#{id}/", client:).then(&method(:parse_response))
205
+ already_set_params = {
206
+ key: response.css("form[name=MsgForm] input[name=key]").first.attr(:value),
207
+ folder_ids: response.css("form[name=MsgForm] input[name=\"folder_ids[]\"][checked]").map { _1.attr(:value) },
208
+ }.tap do |h|
209
+ %i[cat atype species gender].each do |field|
210
+ h[field] = response.css("form[name=MsgForm] select[name=#{field}] option[selected]").first.attr(:value)
211
+ end
212
+ end
213
+
214
+ params = {
215
+ update: "yes",
216
+
217
+ rating: RATING_MAP.fetch(rating),
218
+ title: title,
219
+ message: description,
220
+ keywords: keywords,
221
+
222
+ **already_set_params,
223
+
224
+ # update button ;-)
225
+ submit: "Update",
226
+ }
227
+ params[:lock_comments] = "1" if lock_comments
228
+ params[:scrap] = "1" if scrap
229
+
230
+ logger.debug { "Updating submission #{id}..." }
231
+ update_response = post("/controls/submissions/changeinfo/#{id}/", form: params, client:)
232
+ if update_response.status == 302
233
+ redirect_location = update_response.headers[:location]
234
+ url = File.join(BASE_URL, redirect_location)
235
+ logger.info "Updated! #{url}"
236
+ return url
237
+ else
238
+ fa_error = parse_response(update_response).css(".redirect-message").text
239
+ raise Error.new("FA returned: #{fa_error}")
240
+ end
241
+ end
242
+
167
243
  private
168
244
 
245
+ def validate_args!(type: nil, rating:)
246
+ if type
247
+ type = type.to_sym
248
+ raise ArgumentError.new("#{type.inspect} is not in #{SUBMISSION_TYPES.inspect}") unless SUBMISSION_TYPES.include?(type)
249
+ end
250
+ rating = rating.to_sym
251
+ raise ArgumentError.new("#{rating.inspect} is not in #{RATING_MAP.keys.inspect}") unless RATING_MAP.include?(rating)
252
+
253
+ { type:, rating: }
254
+ end
255
+
169
256
  def parse_response(httpx_response)
170
257
  logger.measure_trace "Parsing response" do
171
258
  Nokogiri::HTML.parse(httpx_response)
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "shellwords"
5
4
  require "yaml"
6
5
 
7
6
  module Furaffinity
@@ -35,8 +34,46 @@ module Furaffinity
35
34
  scrap: false
36
35
  lock_comments: false
37
36
 
38
- # Folder name to place this submission under, leave blank if it should not be in any.
39
- folder_name: ""
37
+ # Create a new folder to place this submission under, leave blank if none should be created.
38
+ create_folder_name: ""
39
+
40
+ # Run this Ruby code after uploading
41
+ after_upload: |-
42
+ # Quick reference
43
+ #
44
+ # Available objects:
45
+ # - `client` (Furaffinity::Client)
46
+ # The client object used to interact with FurAffinity.
47
+ # - `submission_info` (Hash)
48
+ # The current submission information of this YAML file. Keys are symbols.
49
+ # To get the description use e.g. `submission_info[:description]`.
50
+ # This also contains the ID of the uploaded submission, e.g.
51
+ # `submission_info[:id]`.
52
+ # - `file_info` (Hash)
53
+ # A hash of all YAML files. Format is
54
+ # `{ "filename.png" => { submission_info } }`.
55
+ # Like `submission_info` it also contains the submission's ID as the `:id`
56
+ # field if it's been uploaded.
57
+ #
58
+ # Helper functions:
59
+ # - `submission_url(submission_id)`
60
+ # Generates a submission URL, e.g.
61
+ # "https://www.furaffinity.net/view/54328944/"
62
+ # - `link_to(url_or_submission, text)`
63
+ # Generates a link. If the first parameter is a submission info hash it will
64
+ # generate the URL using `submission_url(submission_info[:id])`.
65
+
66
+ # Remove this `return` if you want to run Ruby code
67
+ return
68
+
69
+ # Append a link to this submission to a previously uploaded one
70
+ previous_submission = file_info.fetch("previous_file.png")
71
+ previous_submission[:description] += ("\n\n" + link_to(submission_info, "Alt version 2"))
72
+ client.update(**previous_submission)
73
+
74
+ # Append a link to the previous submission to the current one
75
+ submission_info[:description] += ("\n\n" + link_to(previous_submission, "Alt version 1"))
76
+ client.update(**submission_info)
40
77
  YAML
41
78
 
42
79
  attr_reader :client, :queue_dir, :queue, :upload_status, :file_info
@@ -109,7 +146,7 @@ module Furaffinity
109
146
  end
110
147
 
111
148
  submission_info_path = create_submission_info(file)
112
- open_editor submission_info_path
149
+ CliUtils.open_editor submission_info_path
113
150
 
114
151
  queue << file
115
152
  upload_status[file] = {
@@ -151,12 +188,14 @@ module Furaffinity
151
188
  end
152
189
 
153
190
  def reorder
154
- open_editor fa_info_path("queue.yml")
191
+ CliUtils.open_editor fa_info_path("queue.yml")
155
192
  end
156
193
 
157
194
  def upload(wait_time = 60)
158
195
  raise ArgumentError.new("wait_time must be at least 30") if wait_time < 30
159
196
 
197
+ hook_handler = QueueHook.new(client, file_info)
198
+
160
199
  while file_name = queue.shift
161
200
  info = file_info[file_name]
162
201
  unless info
@@ -164,6 +203,8 @@ module Furaffinity
164
203
  next
165
204
  end
166
205
 
206
+ code = file_info[file_name].delete(:after_upload)
207
+
167
208
  logger.info "Uploading #{info[:title].inspect} (#{file_name.inspect})"
168
209
  url = client.upload(
169
210
  File.new(file_name),
@@ -175,6 +216,11 @@ module Furaffinity
175
216
 
176
217
  save
177
218
 
219
+ if code
220
+ hook_handler.update_ids(upload_status)
221
+ hook_handler.run_hook(file_name, code)
222
+ end
223
+
178
224
  unless queue.empty?
179
225
  logger.info "Waiting #{wait_time} seconds until the next upload"
180
226
  sleep wait_time
@@ -210,19 +256,5 @@ module Furaffinity
210
256
 
211
257
  submission_info_path
212
258
  end
213
-
214
- def open_editor(file)
215
- editor = ENV["FA_EDITOR"] || ENV["VISUAL"] || ENV["EDITOR"]
216
- unless editor
217
- logger.warn "could not open editor for #{file.inspect}, set one of FA_EDITOR, VISUAL, or EDITOR in your ENV"
218
- return
219
- end
220
-
221
- system(*Shellwords.shellwords(editor), file).tap do
222
- next if $?.exitstatus == 0
223
-
224
- logger.error "could not run #{editor} #{file}, exit code: #{$?.exitstatus}"
225
- end
226
- end
227
259
  end
228
260
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Furaffinity
4
+ class QueueHook
5
+ include SemanticLogger::Loggable
6
+
7
+ class HookRunner
8
+ include SemanticLogger::Loggable
9
+
10
+ attr_reader :client, :file_info, :submission_info
11
+
12
+ def initialize(client, file_info, file_name)
13
+ @client = client
14
+ @file_info = file_info
15
+ @submission_info = file_info.fetch(file_name)
16
+ end
17
+
18
+ def submission_url(submission_id) = "https://www.furaffinity.net/view/#{submission_id}/"
19
+
20
+ def link_to(url_or_submission, text)
21
+ url = case url_or_submission
22
+ in { id: }
23
+ submission_url(id)
24
+ else
25
+ if url_or_submission.is_a?(Hash)
26
+ logger.warn { "passed hash does not have an ID, probably not uploaded yet? hash keys: #{url_or_submission.keys.inspect}" }
27
+ end
28
+ url_or_submission.to_s
29
+ end
30
+
31
+ "[url=#{url}]#{text}[/url]"
32
+ end
33
+ end
34
+
35
+ attr_reader :client, :file_info
36
+
37
+ def initialize(client, file_info)
38
+ @client = client
39
+ @file_info = file_info.each_with_object({}) do |(file_name, info), h|
40
+ # Hash#except duplicates the hash, which is good here as we don't want
41
+ # to modify the queue.
42
+ # exclude after_upload as it's not needed, and create_folder_name and
43
+ # type is only relevant when initially uploading the submission.
44
+ h[file_name] = info.except(:create_folder_name, :after_upload, :type)
45
+ end
46
+ end
47
+
48
+ def update_ids(upload_status)
49
+ logger.trace { "Updating file info ids" }
50
+ upload_status.each do |file_name, status|
51
+ next unless status[:uploaded]
52
+
53
+ @file_info[file_name][:id] = status[:url].match(%{/view/(?<id>[^/]+)})[:id]
54
+ end
55
+ end
56
+
57
+ def run_hook(file_name, code)
58
+ logger.debug { "Running hook" }
59
+ logger.trace { "Hook code:\n#{code}" }
60
+ HookRunner
61
+ .new(client, file_info, file_name)
62
+ .instance_eval(code, File.join(Dir.pwd, "#{file_name}.info.yml"), 0)
63
+ end
64
+ end
65
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Furaffinity
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: furaffinity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Georg Gadinger
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-11-06 00:00:00.000000000 Z
11
+ date: 2023-12-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: httpx
@@ -113,9 +113,11 @@ files:
113
113
  - lib/furaffinity/cli.rb
114
114
  - lib/furaffinity/cli_base.rb
115
115
  - lib/furaffinity/cli_queue.rb
116
+ - lib/furaffinity/cli_utils.rb
116
117
  - lib/furaffinity/client.rb
117
118
  - lib/furaffinity/config.rb
118
119
  - lib/furaffinity/queue.rb
120
+ - lib/furaffinity/queue_hook.rb
119
121
  - lib/furaffinity/version.rb
120
122
  homepage: https://github.com/nilsding/furaffinity-cli
121
123
  licenses: