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 +4 -4
- data/.rubocop.yml +15 -1
- data/CHANGELOG.md +20 -0
- data/README.md +6 -2
- data/Rakefile +3 -0
- data/exe/fa +1 -0
- data/lib/furaffinity/cli.rb +87 -32
- data/lib/furaffinity/cli_queue.rb +10 -11
- data/lib/furaffinity/cli_utils.rb +30 -0
- data/lib/furaffinity/client.rb +144 -44
- data/lib/furaffinity/config.rb +3 -3
- data/lib/furaffinity/queue.rb +58 -25
- data/lib/furaffinity/queue_hook.rb +67 -0
- data/lib/furaffinity/version.rb +1 -1
- data/lib/furaffinity.rb +3 -0
- metadata +27 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 73eb5f5ac1b82da18816b27a511134753ee22ea2387f932e929540c639ed4f79
|
|
4
|
+
data.tar.gz: 578e0d9a28ebb0a991107dff1966d25ca05f2abdf47e0fe119922f0ad4dd3e91
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c7beca8cd88a0bb0da1c2d4af70434c97a1d199f99a3c9aacd82b0539cc4ff224430783ffa8eb6aa768226c943a76629cfd3b2c4eb073277915591d068d93a32
|
|
7
|
+
data.tar.gz: 16f425069ee8c619328dcc1ce3a433df9a3bfd3684e97d3f140215565f5807f69f928c72d3db8dd8d901f04933e4e28edaf0cb557ef60027c3731519c1dd5589
|
data/.rubocop.yml
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
AllCops:
|
|
2
|
-
TargetRubyVersion:
|
|
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
|
|
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://
|
|
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
data/exe/fa
CHANGED
data/lib/furaffinity/cli.rb
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
type: :string,
|
|
14
|
+
desc: "Log level to use",
|
|
15
|
+
default: "info"
|
|
14
16
|
|
|
15
17
|
class_option :config,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
type: :string,
|
|
42
|
+
desc: "Submission type. One of: #{Furaffinity::Client::SUBMISSION_TYPES.join(", ")}",
|
|
43
|
+
default: "submission"
|
|
42
44
|
option :title,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
type: :string,
|
|
46
|
+
desc: "Submission title.",
|
|
47
|
+
required: true
|
|
46
48
|
option :description,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
type: :string,
|
|
50
|
+
desc: "Submission description.",
|
|
51
|
+
required: true
|
|
50
52
|
option :rating,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
type: :string,
|
|
54
|
+
desc: "Submission rating. One of: #{Furaffinity::Client::RATING_MAP.keys.join(", ")}",
|
|
55
|
+
required: true
|
|
54
56
|
option :lock_comments,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
type: :boolean,
|
|
58
|
+
desc: "Disable comments on this submission.",
|
|
59
|
+
default: false
|
|
58
60
|
option :scrap,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
type: :boolean,
|
|
62
|
+
desc: "Place this upload to your scraps.",
|
|
63
|
+
default: false
|
|
62
64
|
option :keywords,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
option :
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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,
|
|
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 $
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
data/lib/furaffinity/client.rb
CHANGED
|
@@ -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 {
|
|
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:,
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
.
|
|
111
|
-
.to_h
|
|
99
|
+
.to_h { [it.to_sym, response.css("form#myform input[name=#{it}]").first.attr(:value)] }
|
|
112
100
|
)
|
|
113
|
-
|
|
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
|
-
|
|
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:
|
|
123
|
+
key: response.css("form#myform input[name=key]").first.attr(:value),
|
|
131
124
|
|
|
132
125
|
# category, "1" is "Visual Art -> All"
|
|
133
|
-
cat:
|
|
126
|
+
cat: "1",
|
|
134
127
|
# theme, "1" is "General Things -> All"
|
|
135
|
-
atype:
|
|
128
|
+
atype: "1",
|
|
136
129
|
# species, "1" is "Unspecified / Any"
|
|
137
|
-
species:
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
218
|
+
**already_set_params,
|
|
147
219
|
|
|
148
|
-
#
|
|
149
|
-
|
|
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 "
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
data/lib/furaffinity/config.rb
CHANGED
|
@@ -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!(**
|
|
40
|
-
set(**
|
|
39
|
+
def set!(**)
|
|
40
|
+
set(**)
|
|
41
41
|
save
|
|
42
42
|
end
|
|
43
43
|
|
data/lib/furaffinity/queue.rb
CHANGED
|
@@ -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
|
-
#
|
|
39
|
-
|
|
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}")].
|
|
67
|
-
[path.delete_suffix(SUBMISSION_INFO_EXT).sub(
|
|
68
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
data/lib/furaffinity/version.rb
CHANGED
data/lib/furaffinity.rb
CHANGED
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:
|
|
4
|
+
version: 26.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
8
|
-
autorequire:
|
|
7
|
+
- Jyrki Gadinger
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
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.
|
|
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.
|
|
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://
|
|
135
|
+
homepage: https://codeberg.org/jyrki/furaffinity-cli
|
|
121
136
|
licenses:
|
|
122
137
|
- AGPLv3
|
|
123
138
|
metadata:
|
|
124
|
-
homepage_uri: https://
|
|
125
|
-
source_code_uri: https://
|
|
126
|
-
|
|
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:
|
|
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:
|
|
142
|
-
signing_key:
|
|
156
|
+
rubygems_version: 4.0.3
|
|
143
157
|
specification_version: 4
|
|
144
158
|
summary: FurAffinity CLI tool
|
|
145
159
|
test_files: []
|