furaffinity 0.1.0 → 26.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50e5e2ea8cd319bee7eb1665afe2888c7d763dd63ab1889ff861621d0871c96d
4
- data.tar.gz: 984287cda209a247bda7499bda6b5509cb56ae9bdda37ae1e32b9e17b98b4ce5
3
+ metadata.gz: 73eb5f5ac1b82da18816b27a511134753ee22ea2387f932e929540c639ed4f79
4
+ data.tar.gz: 578e0d9a28ebb0a991107dff1966d25ca05f2abdf47e0fe119922f0ad4dd3e91
5
5
  SHA512:
6
- metadata.gz: 961c5c9f67fcc98522a3c09f51f83c299c4016d1987f29d18f8ba516f4d6b3b16f11213b70d76902f00b4174d213a423a94b854943d983bc69044678ffe0c031
7
- data.tar.gz: 9f87aa43971869236f1f7b2d0ad02ba9bfa73a45cba39a103c84488cfbc51c60d6ea021a6dbcba2ccf6f399e2ec87608b4b4b4c16b65e9408d0276269ce785f2
6
+ metadata.gz: c7beca8cd88a0bb0da1c2d4af70434c97a1d199f99a3c9aacd82b0539cc4ff224430783ffa8eb6aa768226c943a76629cfd3b2c4eb073277915591d068d93a32
7
+ data.tar.gz: 16f425069ee8c619328dcc1ce3a433df9a3bfd3684e97d3f140215565f5807f69f928c72d3db8dd8d901f04933e4e28edaf0cb557ef60027c3731519c1dd5589
data/.rubocop.yml CHANGED
@@ -1,5 +1,12 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.6
2
+ TargetRubyVersion: 4.0
3
+ NewCops: enable
4
+
5
+ Style/TrailingCommaInArrayLiteral:
6
+ EnforcedStyleForMultiline: diff_comma
7
+
8
+ Style/TrailingCommaInHashLiteral:
9
+ EnforcedStyleForMultiline: diff_comma
3
10
 
4
11
  Style/StringLiterals:
5
12
  Enabled: true
@@ -9,5 +16,12 @@ Style/StringLiteralsInInterpolation:
9
16
  Enabled: true
10
17
  EnforcedStyle: double_quotes
11
18
 
19
+ Style/RaiseArgs:
20
+ EnforcedStyle: compact
21
+
22
+ Layout/HashAlignment:
23
+ EnforcedColonStyle: table
24
+ EnforcedHashRocketStyle: table
25
+
12
26
  Layout/LineLength:
13
27
  Max: 120
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [26.1.0] - 2026-01-12
4
+
5
+ ### Added
6
+ - On unexpected errors received from FurAffinity an `Furaffinity::RemoteError`
7
+ error is raised now.
8
+
9
+ ### Changed
10
+ - Ruby 4.0 required
11
+ - Removed `gender` field handling as these are just keywords now
12
+ - Version scheme is now `year.month.patch`.
13
+
14
+ ## [0.2.0] - 2023-12-21
15
+
16
+ ### Added
17
+ - `fa edit` command to edit submissions
18
+ - `after_upload` hook to queues
19
+
20
+ ### Changed
21
+ - `folder_name` is now called `create_folder_name` as it should
22
+
3
23
  ## [0.1.0] - 2023-11-04
4
24
 
5
25
  - 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`
@@ -73,7 +77,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
73
77
 
74
78
  ## Contributing
75
79
 
76
- Bug reports and pull requests are welcome on GitHub at https://github.com/nilsding/furaffinity. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/nilsding/furaffinity/blob/main/CODE_OF_CONDUCT.md).
80
+ Bug reports and pull requests are welcome on Codeberg at https://codeberg.org/jyrki/furaffinity-cli.com/nilsding/furaffinity. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://codeberg.org/jyrki/furaffinity-cli/src/branch/main/CODE_OF_CONDUCT.md).
77
81
 
78
82
  ## License
79
83
 
@@ -81,4 +85,4 @@ The gem is available as open source under the terms of the [AGPLv3 License](http
81
85
 
82
86
  ## Code of Conduct
83
87
 
84
- Everyone interacting in the Furaffinity project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/nilsding/furaffinity/blob/main/CODE_OF_CONDUCT.md).
88
+ Everyone interacting in the Furaffinity project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://codeberg.org/jyrki/furaffinity-cli/src/branch/main/CODE_OF_CONDUCT.md).
data/Rakefile CHANGED
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
+ require "minitest/test_task"
4
5
 
5
6
  task :default
7
+
8
+ Minitest::TestTask.create
data/exe/fa CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  lib_dir = File.expand_path("../lib", __dir__)
4
5
  $LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
@@ -1,21 +1,23 @@
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
8
10
  include CliBase
9
11
 
10
12
  class_option :log_level,
11
- type: :string,
12
- desc: "Log level to use",
13
- default: "info"
13
+ type: :string,
14
+ desc: "Log level to use",
15
+ default: "info"
14
16
 
15
17
  class_option :config,
16
- type: :string,
17
- desc: "Path to the config",
18
- default: File.join(Dir.home, ".farc")
18
+ type: :string,
19
+ desc: "Path to the config",
20
+ default: File.join(Dir.home, ".farc")
19
21
 
20
22
  desc "auth A_COOKIE B_COOKIE", "Store authentication info for FurAffinity"
21
23
  def auth(a_cookie, b_cookie)
@@ -36,37 +38,37 @@ module Furaffinity
36
38
 
37
39
  desc "upload FILE_PATH", "Upload a new submission"
38
40
  option :type,
39
- type: :string,
40
- desc: "Submission type. One of: #{Furaffinity::Client::SUBMISSION_TYPES.join(", ")}",
41
- default: "submission"
41
+ type: :string,
42
+ desc: "Submission type. One of: #{Furaffinity::Client::SUBMISSION_TYPES.join(", ")}",
43
+ default: "submission"
42
44
  option :title,
43
- type: :string,
44
- desc: "Submission title.",
45
- required: true
45
+ type: :string,
46
+ desc: "Submission title.",
47
+ required: true
46
48
  option :description,
47
- type: :string,
48
- desc: "Submission description.",
49
- required: true
49
+ type: :string,
50
+ desc: "Submission description.",
51
+ required: true
50
52
  option :rating,
51
- type: :string,
52
- desc: "Submission rating. One of: #{Furaffinity::Client::RATING_MAP.keys.join(", ")}",
53
- required: true
53
+ type: :string,
54
+ desc: "Submission rating. One of: #{Furaffinity::Client::RATING_MAP.keys.join(", ")}",
55
+ required: true
54
56
  option :lock_comments,
55
- type: :boolean,
56
- desc: "Disable comments on this submission.",
57
- default: false
57
+ type: :boolean,
58
+ desc: "Disable comments on this submission.",
59
+ default: false
58
60
  option :scrap,
59
- type: :boolean,
60
- desc: "Place this upload to your scraps.",
61
- default: false
61
+ type: :boolean,
62
+ desc: "Place this upload to your scraps.",
63
+ default: false
62
64
  option :keywords,
63
- type: :string,
64
- desc: "Keywords, separated by spaces.",
65
- default: ""
66
- option :folder_name,
67
- type: :string,
68
- desc: "Place this submission into this folder.",
69
- default: ""
65
+ type: :string,
66
+ desc: "Keywords, separated by spaces.",
67
+ default: ""
68
+ option :create_folder_name,
69
+ type: :string,
70
+ desc: "Create a new folder and place this submission into it.",
71
+ default: ""
70
72
  def upload(file_path)
71
73
  set_log_level(options)
72
74
  config = config_for(options)
@@ -76,13 +78,61 @@ module Furaffinity
76
78
  *client
77
79
  .method(:upload)
78
80
  .parameters
79
- .select { |(type, name)| %i[keyreq key].include?(type) }
81
+ .select { |(type, _name)| %i[keyreq key].include?(type) }
80
82
  .map(&:last)
81
83
  ).transform_keys(&:to_sym)
82
84
  url = client.upload(File.new(file_path), **upload_options)
83
85
  say "Submission uploaded! #{url}", :green
84
86
  end
85
87
 
88
+ EDIT_TEMPLATE = <<~YAML.freeze
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
+ file.close
126
+ CliUtils.open_editor file.path, fatal: true
127
+ params = YAML.safe_load_file(file.path, permitted_classes: [Symbol]).transform_keys(&:to_sym)
128
+
129
+ url = client.update(id:, **params)
130
+ say "Submission updated! #{url}", :green
131
+ ensure
132
+ file.close
133
+ file.unlink
134
+ end
135
+
86
136
  desc "queue SUBCOMMAND ...ARGS", "Manage an upload queue"
87
137
  long_desc <<~LONG_DESC, wrap: false
88
138
  `#{basename} queue` manages an upload queue.
@@ -122,5 +172,10 @@ module Furaffinity
122
172
  #{basename} queue clean
123
173
  LONG_DESC
124
174
  subcommand "queue", CliQueue
175
+
176
+ map %w[--version -v] => :__print_version
177
+
178
+ desc "--version, -v", "Print the version"
179
+ def __print_version = puts "furaffinity-cli/#{VERSION}"
125
180
  end
126
181
  end
@@ -76,7 +76,7 @@ module Furaffinity
76
76
  queue.reload
77
77
 
78
78
  if queue.queue.empty?
79
- say "Nothing is in the queue yet, use `#{File.basename $0} queue add ...` to add files.", :yellow
79
+ say "Nothing is in the queue yet, use `#{File.basename $PROGRAM_NAME} queue add ...` to add files.", :yellow
80
80
 
81
81
  print_uploaded_files
82
82
  return
@@ -91,7 +91,6 @@ module Furaffinity
91
91
  print_table queue_table, borders: true
92
92
 
93
93
  print_uploaded_files
94
-
95
94
  rescue Furaffinity::Error => e
96
95
  say e.message, :red
97
96
  exit 1
@@ -125,16 +124,16 @@ module Furaffinity
125
124
 
126
125
  def print_uploaded_files
127
126
  uploaded_files = queue.uploaded_files
128
- unless uploaded_files.empty?
129
- say
130
- say "Uploaded files (will be removed when you run `#{File.basename $0} queue clean`):"
131
- uploaded_table = [["File name", "Title", "Submission URL"], :separator]
132
- uploaded_files.each do |file_name, upload_status|
133
- file_info = queue.file_info.fetch(file_name)
134
- uploaded_table << [file_name, file_info[:title], upload_status[:url]]
135
- end
136
- print_table uploaded_table, borders: true
127
+ return if uploaded_files.empty?
128
+
129
+ say
130
+ say "Uploaded files (will be removed when you run `#{File.basename $PROGRAM_NAME} queue clean`):"
131
+ uploaded_table = [["File name", "Title", "Submission URL"], :separator]
132
+ uploaded_files.each do |file_name, upload_status|
133
+ file_info = queue.file_info.fetch(file_name)
134
+ uploaded_table << [file_name, file_info[:title], upload_status[:url]]
137
135
  end
136
+ print_table uploaded_table, borders: true
138
137
  end
139
138
  end
140
139
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "shellwords"
5
+
6
+ module Furaffinity
7
+ module CliUtils
8
+ module_function
9
+
10
+ include SemanticLogger::Loggable
11
+
12
+ def open_editor(file, fatal: false)
13
+ editor = ENV["FA_EDITOR"] || ENV["VISUAL"] || ENV.fetch("EDITOR", nil)
14
+ unless editor
15
+ logger.warn "could not open editor for #{file.inspect}, set one of FA_EDITOR, VISUAL, or EDITOR in your ENV"
16
+ if fatal
17
+ raise "No suitable editor found to edit #{file.inspect}, set one of FA_EDITOR, VISUAL, or EDITOR in your ENV"
18
+ end
19
+
20
+ return
21
+ end
22
+
23
+ system(*Shellwords.shellwords(editor), file).tap do
24
+ next if $CHILD_STATUS.exitstatus.zero?
25
+
26
+ logger.error "could not run #{editor} #{file}, exit code: #{$CHILD_STATUS.exitstatus}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -51,16 +51,16 @@ module Furaffinity
51
51
  "journal" => :journals,
52
52
  "unread" => :notes,
53
53
  "troubleticket" => :trouble_tickets,
54
- }
54
+ }.freeze
55
55
 
56
56
  def notifications
57
57
  get("/")
58
58
  .then(&method(:parse_response))
59
59
  .css("a.notification-container")
60
- .map { _1.attr(:title) }
60
+ .map { it.attr(:title) }
61
61
  .uniq
62
62
  .each_with_object({}) do |item, h|
63
- count, type, *_rest = item.split(" ")
63
+ count, type, *_rest = item.split
64
64
  count = count.tr(",", "").strip.to_i
65
65
  h[NOTIFICATIONS_MAP.fetch(type.downcase.strip)] = count
66
66
  end
@@ -73,44 +73,34 @@ 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)
81
- raise "not a file" unless file.is_a?(File)
82
- params = { MAX_FILE_SIZE: "10485760" }
83
- 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://..."
85
- end
86
-
87
76
  # @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)
77
+ def upload(file, title:, rating:, description:, keywords:, create_folder_name: "", lock_comments: false,
78
+ scrap: false, type: :submission)
79
+ validate_args!(type:, rating:) => { type:, rating: }
93
80
 
94
81
  client = http_client
95
82
 
96
83
  # step 1: get the required keys
97
84
  logger.trace "Extracting keys from upload form"
98
85
  response = get("/submit/", client:).then(&method(:parse_response))
86
+ raise_if_system_message!(response)
87
+
99
88
  params = {
100
89
  submission_type: type,
101
90
  submission: file,
102
91
  thumbnail: {
103
92
  content_type: "application/octet-stream",
104
- filename: "",
105
- body: ""
93
+ filename: "",
94
+ body: "",
106
95
  },
107
96
  }
108
97
  params.merge!(
109
98
  %w[MAX_FILE_SIZE key]
110
- .map { [_1.to_sym, response.css("form#myform input[name=#{_1}]").first.attr(:value)] }
111
- .to_h
99
+ .to_h { [it.to_sym, response.css("form#myform input[name=#{it}]").first.attr(:value)] }
112
100
  )
113
- 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
101
+ if file.size > params[:MAX_FILE_SIZE].to_i
102
+ raise ArgumentError.new("file size of #{file.size} is greater than FA limit of #{params[:MAX_FILE_SIZE]}")
103
+ end
114
104
 
115
105
  # step 2: upload the submission file
116
106
  logger.debug "Uploading submission..."
@@ -118,7 +108,10 @@ module Furaffinity
118
108
  # for some reason HTTPX performs a GET redirect with the params so we
119
109
  # can't use its plugin here
120
110
  # --> follow the redirect ourselves
121
- raise Error.new("expected a 302 response, got #{upload_response.status}") unless upload_response.status == 302
111
+ unless upload_response.status == 302
112
+ raise_if_system_message!(upload_response.then(&method(:parse_response)))
113
+ raise Error.new("expected a 302 response, got #{upload_response.status} without a system message.")
114
+ end
122
115
 
123
116
  redirect_location = upload_response.headers[:location]
124
117
  unless redirect_location == "/submit/finalize/"
@@ -127,49 +120,156 @@ module Furaffinity
127
120
  response = get(redirect_location, client:).then(&method(:parse_response))
128
121
 
129
122
  params = {
130
- key: response.css("form#myform input[name=key]").first.attr(:value),
123
+ key: response.css("form#myform input[name=key]").first.attr(:value),
131
124
 
132
125
  # category, "1" is "Visual Art -> All"
133
- cat: "1",
126
+ cat: "1",
134
127
  # theme, "1" is "General Things -> All"
135
- atype: "1",
128
+ atype: "1",
136
129
  # species, "1" is "Unspecified / Any"
137
- species: "1",
138
- # gender, "0" is "Any"
139
- gender: "0",
130
+ species: "1",
131
+
132
+ rating: RATING_MAP.fetch(rating),
133
+ title: title,
134
+ message: description,
135
+ keywords: keywords,
136
+
137
+ create_folder_name:,
138
+
139
+ # finalize button :)
140
+ finalize: "Finalize ",
141
+ }
142
+ params[:lock_comments] = "1" if lock_comments
143
+ params[:scrap] = "1" if scrap
144
+
145
+ logger.debug "Finalising submission..."
146
+ finalize_response = post("/submit/finalize/", form: params, client:)
147
+ unless finalize_response.status == 302
148
+ raise_if_system_message!(finalize_response.then(&method(:parse_response)))
149
+ return
150
+ end
151
+
152
+ redirect_location = finalize_response.headers[:location]
153
+ url = File.join(BASE_URL, redirect_location)
154
+ logger.info "Uploaded! #{url}"
155
+ url
156
+ end
157
+
158
+ # Returns submission information from your own gallery.
159
+ def submission_info(id)
160
+ client = http_client
161
+
162
+ logger.trace { "Retrieving submission information for #{id}" }
163
+ response = get("/controls/submissions/changeinfo/#{id}/", client:).then(&method(:parse_response))
164
+ raise_if_system_message!(response)
165
+
166
+ {}.tap do |h|
167
+ h[:title] = response.css("form[name=MsgForm] input[name=title]").first.attr(:value)
168
+
169
+ %i[message keywords].each do |field|
170
+ h[field] = response.css("form[name=MsgForm] textarea[name=#{field}]").first.text
171
+ end
172
+
173
+ h[:rating] =
174
+ RATING_MAP.invert.fetch(response.css("form[name=MsgForm] input[name=rating][checked]").first.attr(:value).to_i)
175
+
176
+ %i[lock_comments scrap].each do |field|
177
+ h[field] = !!response.css("form[name=MsgForm] input[name=#{field}][checked]").first&.attr(:value)
178
+ end
179
+
180
+ %i[cat atype species].each do |field|
181
+ h[field] = response.css("form[name=MsgForm] select[name=#{field}] option[selected]").first.attr(:value)
182
+ end
183
+
184
+ h[:folder_ids] = response.css("form[name=MsgForm] input[name=\"folder_ids[]\"][checked]").map do
185
+ it.attr(:value)
186
+ end
187
+ end
188
+ end
189
+
190
+ # NOTE: only tested with `type=submission`
191
+ def update(id:, title:, rating:, description:, keywords:, lock_comments: false, scrap: false)
192
+ validate_args!(rating:) => { rating: }
193
+
194
+ client = http_client
195
+
196
+ # step 1: get the required keys
197
+ logger.trace { "Extracting keys from submission #{id}" }
198
+ response = get("/controls/submissions/changeinfo/#{id}/", client:).then(&method(:parse_response))
199
+ raise_if_system_message!(response)
200
+
201
+ already_set_params = {
202
+ key: response.css("form[name=MsgForm] input[name=key]").first.attr(:value),
203
+ folder_ids: response.css("form[name=MsgForm] input[name=\"folder_ids[]\"][checked]").map { it.attr(:value) },
204
+ }.tap do |h|
205
+ %i[cat atype species].each do |field|
206
+ h[field] = response.css("form[name=MsgForm] select[name=#{field}] option[selected]").first.attr(:value)
207
+ end
208
+ end
209
+
210
+ params = {
211
+ update: "yes",
140
212
 
141
213
  rating: RATING_MAP.fetch(rating),
142
214
  title: title,
143
215
  message: description,
144
216
  keywords: keywords,
145
217
 
146
- create_folder_name: folder_name,
218
+ **already_set_params,
147
219
 
148
- # finalize button :)
149
- finalize: "Finalize ",
220
+ # update button ;-)
221
+ submit: "Update",
150
222
  }
151
223
  params[:lock_comments] = "1" if lock_comments
152
224
  params[:scrap] = "1" if scrap
153
225
 
154
- logger.debug "Finalising submission..."
155
- finalize_response = post("/submit/finalize/", form: params, client:)
156
- if finalize_response.status == 302
157
- redirect_location = finalize_response.headers[:location]
158
- url = File.join(BASE_URL, redirect_location)
159
- logger.info "Uploaded! #{url}"
160
- return url
161
- else
162
- fa_error = parse_response(finalize_response).css(".redirect-message").text
163
- raise Error.new("FA returned: #{fa_error}")
226
+ logger.debug { "Updating submission #{id}..." }
227
+ update_response = post("/controls/submissions/changeinfo/#{id}/", form: params, client:)
228
+ unless update_response.status == 302
229
+ raise_if_system_message!(update_response.then(&method(:parse_response)))
230
+ return
164
231
  end
232
+
233
+ redirect_location = update_response.headers[:location]
234
+ url = File.join(BASE_URL, redirect_location)
235
+ logger.info "Updated! #{url}"
236
+ url
165
237
  end
166
238
 
167
239
  private
168
240
 
241
+ def validate_args!(rating:, type: nil)
242
+ if type
243
+ type = type.to_sym
244
+ unless SUBMISSION_TYPES.include?(type)
245
+ raise ArgumentError.new("#{type.inspect} is not in #{SUBMISSION_TYPES.inspect}")
246
+ end
247
+ end
248
+ rating = rating.to_sym
249
+ unless RATING_MAP.include?(rating)
250
+ raise ArgumentError.new("#{rating.inspect} is not in #{RATING_MAP.keys.inspect}")
251
+ end
252
+
253
+ { type:, rating: }
254
+ end
255
+
256
+ # @return [Nokogiri::HTML::Document]
169
257
  def parse_response(httpx_response)
170
258
  logger.measure_trace "Parsing response" do
171
259
  Nokogiri::HTML.parse(httpx_response)
172
260
  end
173
261
  end
262
+
263
+ # @param response [Nokogiri::HTML::Document]
264
+ # @return String
265
+ def system_message(response) = response.css(".redirect-message")&.text
266
+
267
+ # @param response [Nokogiri::HTML::Document]
268
+ def raise_if_system_message!(response)
269
+ fa_error = system_message(response)
270
+ return if fa_error&.empty?
271
+
272
+ raise RemoteError.new(fa_error)
273
+ end
174
274
  end
175
275
  end
@@ -15,7 +15,7 @@ module Furaffinity
15
15
  else
16
16
  @config_hash = {}
17
17
  end
18
- rescue => e
18
+ rescue StandardError => e
19
19
  logger.fatal("Error while loading configuration:", e)
20
20
  raise
21
21
  end
@@ -36,8 +36,8 @@ module Furaffinity
36
36
  end
37
37
  end
38
38
 
39
- def set!(**kwargs)
40
- set(**kwargs)
39
+ def set!(**)
40
+ set(**)
41
41
  save
42
42
  end
43
43
 
@@ -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
@@ -12,7 +11,7 @@ module Furaffinity
12
11
 
13
12
  SUBMISSION_INFO_EXT = ".info.yml"
14
13
 
15
- SUBMISSION_TEMPLATE = <<~YAML
14
+ SUBMISSION_TEMPLATE = <<~YAML.freeze
16
15
  ---
17
16
  # Submission info for %<file_name>s
18
17
 
@@ -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
@@ -63,9 +100,10 @@ module Furaffinity
63
100
 
64
101
  @queue = YAML.safe_load_file(fa_info_path("queue.yml"), permitted_classes: [Symbol])
65
102
  @upload_status = YAML.safe_load_file(fa_info_path("status.yml"), permitted_classes: [Symbol])
66
- @file_info = Dir[File.join(queue_dir, "**/*#{SUBMISSION_INFO_EXT}")].map do |path|
67
- [path.delete_suffix(SUBMISSION_INFO_EXT).sub(/^#{Regexp.escape(queue_dir)}\/?/, ""), YAML.safe_load_file(path, permitted_classes: [Symbol]).transform_keys(&:to_sym)]
68
- end.to_h
103
+ @file_info = Dir[File.join(queue_dir, "**/*#{SUBMISSION_INFO_EXT}")].to_h do |path|
104
+ [path.delete_suffix(SUBMISSION_INFO_EXT).sub(%r{^#{Regexp.escape(queue_dir)}/?}, ""),
105
+ YAML.safe_load_file(path, permitted_classes: [Symbol]).transform_keys(&:to_sym)]
106
+ end
69
107
 
70
108
  logger.trace "Loaded state info", queue:, file_info:
71
109
  end
@@ -109,7 +147,7 @@ module Furaffinity
109
147
  end
110
148
 
111
149
  submission_info_path = create_submission_info(file)
112
- open_editor submission_info_path
150
+ CliUtils.open_editor submission_info_path
113
151
 
114
152
  queue << file
115
153
  upload_status[file] = {
@@ -140,7 +178,7 @@ module Furaffinity
140
178
  end
141
179
 
142
180
  def clean
143
- uploaded_files.each do |file, _upload_info|
181
+ uploaded_files.each_key do |file|
144
182
  logger.trace { "Deleting #{file} ..." }
145
183
  queue.delete(file)
146
184
  upload_status.delete(file)
@@ -151,19 +189,23 @@ module Furaffinity
151
189
  end
152
190
 
153
191
  def reorder
154
- open_editor fa_info_path("queue.yml")
192
+ CliUtils.open_editor fa_info_path("queue.yml")
155
193
  end
156
194
 
157
195
  def upload(wait_time = 60)
158
196
  raise ArgumentError.new("wait_time must be at least 30") if wait_time < 30
159
197
 
160
- while file_name = queue.shift
198
+ hook_handler = QueueHook.new(client, file_info)
199
+
200
+ while (file_name = queue.shift)
161
201
  info = file_info[file_name]
162
202
  unless info
163
203
  logger.warn "no file info found for #{file_name}, ignoring"
164
204
  next
165
205
  end
166
206
 
207
+ code = file_info[file_name].delete(:after_upload)
208
+
167
209
  logger.info "Uploading #{info[:title].inspect} (#{file_name.inspect})"
168
210
  url = client.upload(
169
211
  File.new(file_name),
@@ -175,6 +217,11 @@ module Furaffinity
175
217
 
176
218
  save
177
219
 
220
+ if code
221
+ hook_handler.update_ids(upload_status)
222
+ hook_handler.run_hook(file_name, code)
223
+ end
224
+
178
225
  unless queue.empty?
179
226
  logger.info "Waiting #{wait_time} seconds until the next upload"
180
227
  sleep wait_time
@@ -210,19 +257,5 @@ module Furaffinity
210
257
 
211
258
  submission_info_path
212
259
  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
260
  end
228
261
  end
@@ -0,0 +1,67 @@
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 do
27
+ "passed hash does not have an ID, probably not uploaded yet? hash keys: #{url_or_submission.keys.inspect}"
28
+ end
29
+ end
30
+ url_or_submission.to_s
31
+ end
32
+
33
+ "[url=#{url}]#{text}[/url]"
34
+ end
35
+ end
36
+
37
+ attr_reader :client, :file_info
38
+
39
+ def initialize(client, file_info)
40
+ @client = client
41
+ @file_info = file_info.transform_values do |info|
42
+ # Hash#except duplicates the hash, which is good here as we don't want
43
+ # to modify the queue.
44
+ # exclude after_upload as it's not needed, and create_folder_name and
45
+ # type is only relevant when initially uploading the submission.
46
+ info.except(:create_folder_name, :after_upload, :type)
47
+ end
48
+ end
49
+
50
+ def update_ids(upload_status)
51
+ logger.trace { "Updating file info ids" }
52
+ upload_status.each do |file_name, status|
53
+ next unless status[:uploaded]
54
+
55
+ @file_info[file_name][:id] = status[:url].match(%{/view/(?<id>[^/]+)})[:id]
56
+ end
57
+ end
58
+
59
+ def run_hook(file_name, code)
60
+ logger.debug { "Running hook" }
61
+ logger.trace { "Hook code:\n#{code}" }
62
+ HookRunner
63
+ .new(client, file_info, file_name)
64
+ .instance_eval(code, File.join(Dir.pwd, "#{file_name}.info.yml"), 0)
65
+ end
66
+ end
67
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Furaffinity
4
- VERSION = "0.1.0"
4
+ VERSION = "26.1.0"
5
5
  end
data/lib/furaffinity.rb CHANGED
@@ -7,4 +7,7 @@ loader.setup
7
7
 
8
8
  module Furaffinity
9
9
  class Error < StandardError; end
10
+
11
+ # Raised when FurAffinity returned a system message where we did not expect one.
12
+ class RemoteError < Error; end
10
13
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: furaffinity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 26.1.0
5
5
  platform: ruby
6
6
  authors:
7
- - Georg Gadinger
8
- autorequire:
7
+ - Jyrki Gadinger
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2023-11-06 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: httpx
@@ -16,14 +15,14 @@ dependencies:
16
15
  requirements:
17
16
  - - "~>"
18
17
  - !ruby/object:Gem::Version
19
- version: '1.1'
18
+ version: '1.7'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - "~>"
25
24
  - !ruby/object:Gem::Version
26
- version: '1.1'
25
+ version: '1.7'
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: json
29
28
  requirement: !ruby/object:Gem::Requirement
@@ -38,6 +37,20 @@ dependencies:
38
37
  - - ">="
39
38
  - !ruby/object:Gem::Version
40
39
  version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: logger
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.6'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.6'
41
54
  - !ruby/object:Gem::Dependency
42
55
  name: nokogiri
43
56
  requirement: !ruby/object:Gem::Requirement
@@ -113,17 +126,19 @@ files:
113
126
  - lib/furaffinity/cli.rb
114
127
  - lib/furaffinity/cli_base.rb
115
128
  - lib/furaffinity/cli_queue.rb
129
+ - lib/furaffinity/cli_utils.rb
116
130
  - lib/furaffinity/client.rb
117
131
  - lib/furaffinity/config.rb
118
132
  - lib/furaffinity/queue.rb
133
+ - lib/furaffinity/queue_hook.rb
119
134
  - lib/furaffinity/version.rb
120
- homepage: https://github.com/nilsding/furaffinity-cli
135
+ homepage: https://codeberg.org/jyrki/furaffinity-cli
121
136
  licenses:
122
137
  - AGPLv3
123
138
  metadata:
124
- homepage_uri: https://github.com/nilsding/furaffinity-cli
125
- source_code_uri: https://github.com/nilsding/furaffinity-cli
126
- post_install_message:
139
+ homepage_uri: https://codeberg.org/jyrki/furaffinity-cli
140
+ source_code_uri: https://codeberg.org/jyrki/furaffinity-cli
141
+ rubygems_mfa_required: 'true'
127
142
  rdoc_options: []
128
143
  require_paths:
129
144
  - lib
@@ -131,15 +146,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
131
146
  requirements:
132
147
  - - ">="
133
148
  - !ruby/object:Gem::Version
134
- version: 3.2.0
149
+ version: 4.0.0
135
150
  required_rubygems_version: !ruby/object:Gem::Requirement
136
151
  requirements:
137
152
  - - ">="
138
153
  - !ruby/object:Gem::Version
139
154
  version: '0'
140
155
  requirements: []
141
- rubygems_version: 3.4.10
142
- signing_key:
156
+ rubygems_version: 4.0.3
143
157
  specification_version: 4
144
158
  summary: FurAffinity CLI tool
145
159
  test_files: []