neocities-red 1.1.2 → 1.2.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/.github/workflows/ci.yml +29 -15
- data/.gitignore +4 -0
- data/CHANGELOG.md +95 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +9 -6
- data/README.md +6 -0
- data/lib/neocities_red/cli.rb +156 -31
- data/lib/neocities_red/cli_display.rb +200 -5
- data/lib/neocities_red/client.rb +84 -77
- data/lib/neocities_red/errors.rb +20 -0
- data/lib/neocities_red/services/common/exclusions.rb +69 -0
- data/lib/neocities_red/services/common/pizza.rb +34 -21
- data/lib/neocities_red/services/common/worker_pool.rb +54 -0
- data/lib/neocities_red/services/file/folder_uploader.rb +39 -25
- data/lib/neocities_red/services/file/list.rb +60 -30
- data/lib/neocities_red/services/file/remover.rb +27 -12
- data/lib/neocities_red/services/file/uploader.rb +33 -13
- data/lib/neocities_red/services/site/differencer.rb +42 -12
- data/lib/neocities_red/services/site/exporter.rb +120 -7
- data/lib/neocities_red/services/site/informer.rb +29 -4
- data/lib/neocities_red/services/site/pusher.rb +74 -38
- data/lib/neocities_red/version.rb +2 -1
- data/lib/neocities_red.rb +32 -13
- data/neocities-red.gemspec +11 -6
- data/spec/neocities_red/cli_display_spec.rb +0 -6
- data/spec/neocities_red/cli_spec.rb +85 -0
- data/spec/neocities_red/client_spec.rb +7 -50
- data/spec/neocities_red/services/common/exclusions_spec.rb +47 -0
- data/spec/neocities_red/services/common/worker_pool_spec.rb +48 -0
- data/spec/neocities_red/services/file/folder_uploader_spec.rb +8 -6
- data/spec/neocities_red/services/file/list_spec.rb +13 -10
- data/spec/neocities_red/services/file/remover_spec.rb +15 -21
- data/spec/neocities_red/services/file/uploader_spec.rb +25 -20
- data/spec/neocities_red/services/site/differencer_spec.rb +32 -6
- data/spec/neocities_red/services/site/exporter_spec.rb +105 -38
- data/spec/neocities_red/services/site/informer_spec.rb +3 -3
- data/spec/neocities_red/services/site/pusher_spec.rb +1 -1
- data/spec/spec_helper.rb +1 -1
- metadata +11 -25
|
@@ -3,19 +3,54 @@
|
|
|
3
3
|
require "pastel"
|
|
4
4
|
|
|
5
5
|
module NeocitiesRed
|
|
6
|
+
# Terminal output helper for the CLI.
|
|
7
|
+
#
|
|
8
|
+
# Wraps all user-facing output — progress indicators, success/error
|
|
9
|
+
# messages, help screens, and the ASCII art banner. Uses Pastel for
|
|
10
|
+
# colored and styled terminal output.
|
|
11
|
+
#
|
|
12
|
+
# Every +display_*+ method prints to stdout and may call +exit+
|
|
13
|
+
# (for help screens). Non-help methods return +nil+.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# display = NeocitiesRed::CliDisplay.new
|
|
17
|
+
# display.display_response(result: "success", message: "Uploaded!")
|
|
18
|
+
#
|
|
19
|
+
# @see NeocitiesRed::CLI Uses this class for all terminal output
|
|
6
20
|
class CliDisplay
|
|
21
|
+
# @return [Array<String>] Mouth sprites for the Penelope banner cat.
|
|
7
22
|
PENELOPE_MOUTHS = %w[^ o ~ - v U].freeze
|
|
23
|
+
|
|
24
|
+
# @return [Array<String>] Eye sprites for the Penelope banner cat.
|
|
8
25
|
PENELOPE_EYES = %w[o ~ O].freeze
|
|
9
26
|
|
|
27
|
+
# Creates a new display instance.
|
|
28
|
+
#
|
|
29
|
+
# @param io [IO] output stream (defaults to +$stdout+; inject a
|
|
30
|
+
# StringIO for testing)
|
|
10
31
|
def initialize(io: $stdout)
|
|
11
32
|
@io = io
|
|
12
33
|
@pastel = Pastel.new(eachline: "\n")
|
|
13
34
|
end
|
|
14
35
|
|
|
36
|
+
# Prints a message followed by a newline.
|
|
37
|
+
#
|
|
38
|
+
# @param message [String] text to print
|
|
39
|
+
# @return [void]
|
|
15
40
|
def say(message = "")
|
|
16
41
|
@io.puts(message)
|
|
17
42
|
end
|
|
18
43
|
|
|
44
|
+
# Displays an API response with appropriate coloring.
|
|
45
|
+
#
|
|
46
|
+
# Handles three response shapes:
|
|
47
|
+
# - +Exception+ — prints the error message in red and exits
|
|
48
|
+
# - +:result == "success"+ — prints in green
|
|
49
|
+
# - +:result == "error" && :error_type == "file_exists"+ — prints in yellow
|
|
50
|
+
# - All other errors — prints in red
|
|
51
|
+
#
|
|
52
|
+
# @param resp [Hash, Exception] API response or exception
|
|
53
|
+
# @return [void]
|
|
19
54
|
def display_response(resp)
|
|
20
55
|
if resp.is_a?(Exception)
|
|
21
56
|
say "#{@pastel.red.bold('ERROR:')} #{resp.detailed_message}"
|
|
@@ -35,6 +70,15 @@ module NeocitiesRed
|
|
|
35
70
|
end
|
|
36
71
|
end
|
|
37
72
|
|
|
73
|
+
# Displays the results of a diff operation.
|
|
74
|
+
#
|
|
75
|
+
# Prints removed files in red, modified files in yellow,
|
|
76
|
+
# and added files in green. Each section is only shown if non-empty.
|
|
77
|
+
#
|
|
78
|
+
# @param added [Array<String>] local files not present on the server
|
|
79
|
+
# @param modified [Array<String>] files whose SHA1 hash differs
|
|
80
|
+
# @param removed [Array<String>] server files not present locally
|
|
81
|
+
# @return [void]
|
|
38
82
|
def display_diff_results(added:, modified:, removed:)
|
|
39
83
|
if removed.any?
|
|
40
84
|
say @pastel.bold.red("Removed files")
|
|
@@ -52,52 +96,171 @@ module NeocitiesRed
|
|
|
52
96
|
say added
|
|
53
97
|
end
|
|
54
98
|
|
|
99
|
+
# Prints the interactive login prompt message.
|
|
100
|
+
#
|
|
101
|
+
# @return [void]
|
|
55
102
|
def display_login_prompt
|
|
56
103
|
say "Please login to get your API key:"
|
|
57
104
|
end
|
|
58
105
|
|
|
106
|
+
# Confirms that the API key has been saved to disk.
|
|
107
|
+
#
|
|
108
|
+
# @param sitename [String] the site name
|
|
109
|
+
# @param path [String] the config file path where the key was stored
|
|
110
|
+
# @return [void]
|
|
59
111
|
def display_api_key_saved(sitename, path)
|
|
60
112
|
say "The api key for #{@pastel.bold(sitename)} has been stored in #{@pastel.bold(path)}."
|
|
61
113
|
end
|
|
62
114
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
115
|
+
# Displays a logout success message.
|
|
116
|
+
#
|
|
117
|
+
# @return [void]
|
|
67
118
|
def display_logout_success
|
|
68
119
|
say @pastel.bold("Your api key has been removed.")
|
|
69
120
|
end
|
|
70
121
|
|
|
122
|
+
# Displays a notice that the current operation is a dry run.
|
|
123
|
+
#
|
|
124
|
+
# @return [void]
|
|
71
125
|
def display_dry_run_notice
|
|
72
126
|
say @pastel.green.bold("Doing a dry run, not actually pushing anything")
|
|
73
127
|
end
|
|
74
128
|
|
|
129
|
+
# Prints the file deletion progress indicator (without newline).
|
|
130
|
+
#
|
|
131
|
+
# @param path [String] remote file path being deleted
|
|
132
|
+
# @return [void]
|
|
75
133
|
def display_delete_progress(path)
|
|
76
134
|
@io.print @pastel.bold("Deleting #{path} ... ")
|
|
77
135
|
end
|
|
78
136
|
|
|
137
|
+
# Prints a green "SUCCESS" after a file is deleted.
|
|
138
|
+
#
|
|
139
|
+
# @return [void]
|
|
79
140
|
def display_delete_success
|
|
80
141
|
@io.print "#{@pastel.green.bold('SUCCESS')}\n"
|
|
81
142
|
end
|
|
82
143
|
|
|
144
|
+
# Prints an error response that occurred during file deletion.
|
|
145
|
+
#
|
|
146
|
+
# @param resp [Hash] the API error response
|
|
147
|
+
# @return [void]
|
|
83
148
|
def display_delete_error(resp)
|
|
84
149
|
@io.print "\n"
|
|
85
150
|
display_response(resp)
|
|
86
151
|
end
|
|
87
152
|
|
|
153
|
+
# Displays a hint that .gitignore entries are being excluded.
|
|
154
|
+
#
|
|
155
|
+
# @return [void]
|
|
88
156
|
def display_gitignore_hint
|
|
89
157
|
say "Not pushing .gitignore entries (--no-gitignore to disable)"
|
|
90
158
|
end
|
|
91
159
|
|
|
160
|
+
# Prints the file upload progress indicator (without newline).
|
|
161
|
+
#
|
|
162
|
+
# @param path [String] local file path being uploaded
|
|
163
|
+
# @param remote_path [String] remote destination path
|
|
164
|
+
# @return [void]
|
|
165
|
+
def display_upload_progress(path, remote_path)
|
|
166
|
+
@io.print @pastel.bold("Uploading #{path} to #{remote_path} ... ")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Prints a green "SUCCESS" after a file is uploaded.
|
|
170
|
+
#
|
|
171
|
+
# @return [void]
|
|
172
|
+
def display_upload_success
|
|
173
|
+
@io.print "#{@pastel.green.bold('SUCCESS')}\n"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Prints a yellow "EXISTS" when the uploaded file already matches remotely.
|
|
177
|
+
#
|
|
178
|
+
# @return [void]
|
|
179
|
+
def display_upload_exists
|
|
180
|
+
@io.print "#{@pastel.yellow.bold('EXISTS')}\n"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Displays a message that a directory path is being skipped.
|
|
184
|
+
#
|
|
185
|
+
# @param path [String] the directory path that was skipped
|
|
186
|
+
# @return [void]
|
|
187
|
+
def display_skip_directory(path)
|
|
188
|
+
say @pastel.bold("#{path} is a directory, skipping")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Displays a message that a non-directory path is being skipped
|
|
192
|
+
# (during folder upload).
|
|
193
|
+
#
|
|
194
|
+
# @param path [String] the file path that was skipped
|
|
195
|
+
# @return [void]
|
|
196
|
+
def display_skip_file(path)
|
|
197
|
+
say @pastel.bold("#{path} is not a directory, skipping")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Displays a message that all file uploads are complete.
|
|
201
|
+
#
|
|
202
|
+
# @return [void]
|
|
92
203
|
def display_upload_complete
|
|
93
204
|
say "All files uploaded."
|
|
94
205
|
end
|
|
95
206
|
|
|
207
|
+
# Renders a TTY::Table to the output stream.
|
|
208
|
+
#
|
|
209
|
+
# @param table [TTY::Table] the table to display
|
|
210
|
+
# @return [void]
|
|
211
|
+
def display_list_table(table)
|
|
212
|
+
say table
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Prints the file pull progress indicator (without newline).
|
|
216
|
+
#
|
|
217
|
+
# @param path [String] remote file path being pulled
|
|
218
|
+
# @return [void]
|
|
219
|
+
def display_pull_progress(path)
|
|
220
|
+
@io.print @pastel.bold("Pulling #{path} ... ")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Prints "NO NEW UPDATES" in yellow for files skipped during pull.
|
|
224
|
+
#
|
|
225
|
+
# @return [void]
|
|
226
|
+
def display_pull_no_updates
|
|
227
|
+
@io.print "#{@pastel.yellow.bold('NO NEW UPDATES')}\n"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Prints a green "SUCCESS" after a file is pulled.
|
|
231
|
+
#
|
|
232
|
+
# @return [void]
|
|
233
|
+
def display_pull_success
|
|
234
|
+
@io.print "#{@pastel.green.bold('SUCCESS')}\n"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Prints a red "FAIL" when a file pull fails.
|
|
238
|
+
#
|
|
239
|
+
# @return [void]
|
|
240
|
+
def display_pull_failure
|
|
241
|
+
@io.print "#{@pastel.red.bold('FAIL')}\n"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Displays a summary of pull statistics.
|
|
245
|
+
#
|
|
246
|
+
# @param success_loaded [Integer] number of files successfully downloaded
|
|
247
|
+
# @param total_time [Float] total elapsed time in seconds
|
|
248
|
+
# @return [void]
|
|
249
|
+
def display_pull_stats(success_loaded, total_time)
|
|
250
|
+
say @pastel.green "\nSuccessfully fetched #{success_loaded} files in #{total_time.round(2)} seconds"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Displays the pizza easter egg help screen and exits.
|
|
254
|
+
#
|
|
255
|
+
# @return [void]
|
|
96
256
|
def display_pizza_help_and_exit
|
|
97
257
|
say Services::Common::Pizza.new.make_order
|
|
98
258
|
exit
|
|
99
259
|
end
|
|
100
260
|
|
|
261
|
+
# Displays the help screen for the +list+ command and exits.
|
|
262
|
+
#
|
|
263
|
+
# @return [void]
|
|
101
264
|
def display_list_help_and_exit
|
|
102
265
|
display_banner
|
|
103
266
|
|
|
@@ -115,6 +278,9 @@ module NeocitiesRed
|
|
|
115
278
|
exit
|
|
116
279
|
end
|
|
117
280
|
|
|
281
|
+
# Displays the help screen for the +delete+ command and exits.
|
|
282
|
+
#
|
|
283
|
+
# @return [void]
|
|
118
284
|
def display_delete_help_and_exit
|
|
119
285
|
display_banner
|
|
120
286
|
|
|
@@ -132,6 +298,9 @@ module NeocitiesRed
|
|
|
132
298
|
exit
|
|
133
299
|
end
|
|
134
300
|
|
|
301
|
+
# Displays the help screen for the +upload+ command and exits.
|
|
302
|
+
#
|
|
303
|
+
# @return [void]
|
|
135
304
|
def display_upload_help_and_exit
|
|
136
305
|
display_banner
|
|
137
306
|
|
|
@@ -157,6 +326,9 @@ module NeocitiesRed
|
|
|
157
326
|
exit
|
|
158
327
|
end
|
|
159
328
|
|
|
329
|
+
# Displays the help screen for the +pull+ command and exits.
|
|
330
|
+
#
|
|
331
|
+
# @return [void]
|
|
160
332
|
def display_pull_help_and_exit
|
|
161
333
|
display_banner
|
|
162
334
|
|
|
@@ -166,6 +338,9 @@ module NeocitiesRed
|
|
|
166
338
|
exit
|
|
167
339
|
end
|
|
168
340
|
|
|
341
|
+
# Displays the help screen for the +push+ command and exits.
|
|
342
|
+
#
|
|
343
|
+
# @return [void]
|
|
169
344
|
def display_push_help_and_exit
|
|
170
345
|
display_banner
|
|
171
346
|
|
|
@@ -191,6 +366,9 @@ module NeocitiesRed
|
|
|
191
366
|
exit
|
|
192
367
|
end
|
|
193
368
|
|
|
369
|
+
# Displays the help screen for the +diff+ command and exits.
|
|
370
|
+
#
|
|
371
|
+
# @return [void]
|
|
194
372
|
def display_diff_help_and_exit
|
|
195
373
|
display_banner
|
|
196
374
|
|
|
@@ -210,6 +388,9 @@ module NeocitiesRed
|
|
|
210
388
|
exit
|
|
211
389
|
end
|
|
212
390
|
|
|
391
|
+
# Displays the help screen for the +info+ command and exits.
|
|
392
|
+
#
|
|
393
|
+
# @return [void]
|
|
213
394
|
def display_info_help_and_exit
|
|
214
395
|
display_banner
|
|
215
396
|
|
|
@@ -223,6 +404,9 @@ module NeocitiesRed
|
|
|
223
404
|
exit
|
|
224
405
|
end
|
|
225
406
|
|
|
407
|
+
# Displays the help screen for the +logout+ command and exits.
|
|
408
|
+
#
|
|
409
|
+
# @return [void]
|
|
226
410
|
def display_logout_help_and_exit
|
|
227
411
|
display_banner
|
|
228
412
|
|
|
@@ -236,6 +420,9 @@ module NeocitiesRed
|
|
|
236
420
|
exit
|
|
237
421
|
end
|
|
238
422
|
|
|
423
|
+
# Displays the help screen for the +purge+ command and exits.
|
|
424
|
+
#
|
|
425
|
+
# @return [void]
|
|
239
426
|
def display_purge_help_and_exit
|
|
240
427
|
display_banner
|
|
241
428
|
|
|
@@ -244,11 +431,16 @@ module NeocitiesRed
|
|
|
244
431
|
|
|
245
432
|
#{@pastel.dim 'Examples:'}
|
|
246
433
|
|
|
247
|
-
#{@pastel.green '$ neocities-red purge -y'}
|
|
434
|
+
#{@pastel.green '$ neocities-red purge -y'} Delete all files from your site
|
|
435
|
+
|
|
436
|
+
#{@pastel.green '$ neocities-red purge -y --dry-run'} Show what would be deleted
|
|
248
437
|
HERE
|
|
249
438
|
exit
|
|
250
439
|
end
|
|
251
440
|
|
|
441
|
+
# Renders the ASCII art banner with a random Penelope cat face.
|
|
442
|
+
#
|
|
443
|
+
# @return [void]
|
|
252
444
|
def display_banner
|
|
253
445
|
say <<~HERE
|
|
254
446
|
|
|
@@ -259,6 +451,9 @@ module NeocitiesRed
|
|
|
259
451
|
HERE
|
|
260
452
|
end
|
|
261
453
|
|
|
454
|
+
# Displays the main help screen listing all available subcommands and exits.
|
|
455
|
+
#
|
|
456
|
+
# @return [void]
|
|
262
457
|
def display_help_and_exit
|
|
263
458
|
display_banner
|
|
264
459
|
say <<~HERE
|
data/lib/neocities_red/client.rb
CHANGED
|
@@ -11,9 +11,7 @@ require "json"
|
|
|
11
11
|
require "pathname"
|
|
12
12
|
require "uri"
|
|
13
13
|
require "digest"
|
|
14
|
-
require "pastel"
|
|
15
14
|
require "date"
|
|
16
|
-
require "whirly"
|
|
17
15
|
|
|
18
16
|
require "faraday"
|
|
19
17
|
require "faraday/retry"
|
|
@@ -21,13 +19,35 @@ require "faraday/multipart"
|
|
|
21
19
|
require "faraday/follow_redirects"
|
|
22
20
|
|
|
23
21
|
module NeocitiesRed
|
|
22
|
+
# HTTP client for the Neocities API.
|
|
23
|
+
#
|
|
24
|
+
# Wraps all API interactions — listing, uploading, deleting, and querying
|
|
25
|
+
# site information. Supports both API-key (Bearer) and basic-auth
|
|
26
|
+
# (sitename/password) authentication.
|
|
27
|
+
#
|
|
28
|
+
# Retries transient failures (429, 5xx) automatically via Faraday::Retry.
|
|
29
|
+
#
|
|
30
|
+
# @example API key authentication
|
|
31
|
+
# client = NeocitiesRed::Client.new(api_key: "your-api-key")
|
|
32
|
+
# client.list
|
|
33
|
+
#
|
|
34
|
+
# @example Basic auth authentication
|
|
35
|
+
# client = NeocitiesRed::Client.new(sitename: "my-site", password: "secret")
|
|
36
|
+
# client.list
|
|
24
37
|
class Client
|
|
38
|
+
# @return [String] Base URL for the Neocities REST API.
|
|
25
39
|
API_URI = "https://neocities.org/api/"
|
|
26
40
|
|
|
41
|
+
# Creates a new API client.
|
|
42
|
+
#
|
|
43
|
+
# @param opts [Hash] authentication options
|
|
44
|
+
# @option opts [String] :api_key Bearer token for API-key authentication
|
|
45
|
+
# @option opts [String] :sitename site name for basic-auth (requires +:password+)
|
|
46
|
+
# @option opts [String] :password site password for basic-auth (requires +:sitename+)
|
|
47
|
+
# @raise [ArgumentError] if neither +:api_key+ nor (+:sitename+ and +:password+) are provided
|
|
27
48
|
def initialize(opts = {})
|
|
28
49
|
@uri = URI.parse API_URI
|
|
29
50
|
@opts = opts
|
|
30
|
-
@pastel = Pastel.new eachline: "\n"
|
|
31
51
|
@conn = Faraday.new(@uri) do |conn|
|
|
32
52
|
conn.options.timeout = 30
|
|
33
53
|
conn.options.open_timeout = 10
|
|
@@ -59,87 +79,45 @@ module NeocitiesRed
|
|
|
59
79
|
end
|
|
60
80
|
end
|
|
61
81
|
|
|
82
|
+
# Lists files on the remote Neocities site.
|
|
83
|
+
#
|
|
84
|
+
# @param path [String, nil] directory path to list (nil for root)
|
|
85
|
+
# @return [Hash] parsed API response containing +:files+ array
|
|
62
86
|
def list(path = nil)
|
|
63
87
|
get "list", path: path
|
|
64
88
|
end
|
|
65
89
|
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
info_data = site_info[:info]
|
|
73
|
-
|
|
74
|
-
domain =
|
|
75
|
-
if info_data[:domain].to_s.empty?
|
|
76
|
-
"https://#{sitename}.neocities.org/"
|
|
77
|
-
else
|
|
78
|
-
"https://#{info_data[:domain]}/"
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# start stats
|
|
82
|
-
success_loaded = 0
|
|
83
|
-
start_time = Time.now
|
|
84
|
-
curr_dir = Dir.pwd
|
|
85
|
-
|
|
86
|
-
# get list of files
|
|
87
|
-
resp = list
|
|
88
|
-
|
|
89
|
-
raise ArgumentError, resp[:message] if resp[:result] == "error"
|
|
90
|
-
|
|
91
|
-
# fetch each file
|
|
92
|
-
uri_parser = URI::Parser.new
|
|
93
|
-
resp[:files].each do |file|
|
|
94
|
-
if file[:is_directory]
|
|
95
|
-
FileUtils.mkdir_p file[:path].to_s
|
|
96
|
-
else
|
|
97
|
-
print @pastel.bold("Pulling #{file[:path]} ... ") unless quiet
|
|
98
|
-
|
|
99
|
-
if last_pull_time &&
|
|
100
|
-
last_pull_loc &&
|
|
101
|
-
Time.parse(file[:updated_at]) <= Time.parse(last_pull_time) &&
|
|
102
|
-
last_pull_loc == curr_dir &&
|
|
103
|
-
File.exist?(file[:path]) # case when user deletes file
|
|
104
|
-
|
|
105
|
-
# case when file hasn't been updated since last
|
|
106
|
-
print "#{@pastel.yellow.bold 'NO NEW UPDATES'}\n" unless quiet
|
|
107
|
-
|
|
108
|
-
next
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
pathtotry = uri_parser.escape(domain + file[:path])
|
|
112
|
-
fileconts = @conn.get pathtotry
|
|
113
|
-
|
|
114
|
-
if fileconts.status == 200
|
|
115
|
-
print "#{@pastel.green.bold 'SUCCESS'}\n" unless quiet
|
|
116
|
-
success_loaded += 1
|
|
117
|
-
|
|
118
|
-
File.write(file[:path].to_s, fileconts.body)
|
|
119
|
-
elsif !quiet
|
|
120
|
-
print "#{@pastel.red.bold 'FAIL'}\n"
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# calculate time command took
|
|
126
|
-
total_time = Time.now - start_time
|
|
127
|
-
|
|
128
|
-
# stop the spinner, if there is one
|
|
129
|
-
Whirly.stop if quiet
|
|
130
|
-
|
|
131
|
-
# display stats
|
|
132
|
-
puts @pastel.green "\nSuccessfully fetched #{success_loaded} files in #{total_time.round(2)} seconds"
|
|
133
|
-
end
|
|
134
|
-
|
|
90
|
+
# Retrieves the API key for the currently authenticated user.
|
|
91
|
+
#
|
|
92
|
+
# Only meaningful when authenticated via basic-auth (sitename/password).
|
|
93
|
+
#
|
|
94
|
+
# @return [Hash] parsed API response containing +:api_key+
|
|
135
95
|
def key
|
|
136
96
|
get "key"
|
|
137
97
|
end
|
|
138
98
|
|
|
99
|
+
# Checks whether the remote file matches the given SHA1 hash.
|
|
100
|
+
#
|
|
101
|
+
# Used by {#upload} to skip uploading files that haven't changed.
|
|
102
|
+
#
|
|
103
|
+
# @param remote_path [String] remote file path to check
|
|
104
|
+
# @param sha1_hash [String] hex-encoded SHA1 hash of the local file
|
|
105
|
+
# @return [Hash] parsed API response with +:files+ mapping paths to booleans
|
|
139
106
|
def upload_hash(remote_path, sha1_hash)
|
|
140
107
|
post "upload_hash", remote_path => sha1_hash
|
|
141
108
|
end
|
|
142
109
|
|
|
110
|
+
# Uploads a single file to the Neocities site.
|
|
111
|
+
#
|
|
112
|
+
# Computes the SHA1 hash of the local file and compares it with the
|
|
113
|
+
# remote version. If the file already exists remotely with the same
|
|
114
|
+
# hash, the upload is skipped and an "exists" response is returned.
|
|
115
|
+
#
|
|
116
|
+
# @param path [String, Pathname] local file path to upload
|
|
117
|
+
# @param remote_path [String, nil] remote destination path; defaults to the basename of +path+
|
|
118
|
+
# @param dry_run [Boolean] when true, simulates the upload without sending data
|
|
119
|
+
# @return [Hash] API response with +:result+ key ("success", "error", or "file_exists")
|
|
120
|
+
# @raise [ArgumentError] if the local file does not exist
|
|
143
121
|
def upload(path, remote_path = nil, dry_run: false)
|
|
144
122
|
path = Pathname path
|
|
145
123
|
raise ArgumentError, "#{path} does not exist." unless path.exist?
|
|
@@ -147,11 +125,7 @@ module NeocitiesRed
|
|
|
147
125
|
rpath = remote_path || path.basename
|
|
148
126
|
res = upload_hash(rpath.to_s, Digest::SHA1.file(path.to_s).hexdigest)
|
|
149
127
|
|
|
150
|
-
file_exists_remotely =
|
|
151
|
-
res[:files][rpath.to_s.to_sym] == true || res[:files][rpath.to_s] == true
|
|
152
|
-
else
|
|
153
|
-
false
|
|
154
|
-
end
|
|
128
|
+
file_exists_remotely = res[:files] ? res[:files][rpath.to_s.to_sym] == true : false
|
|
155
129
|
|
|
156
130
|
if file_exists_remotely
|
|
157
131
|
{
|
|
@@ -168,20 +142,40 @@ module NeocitiesRed
|
|
|
168
142
|
end
|
|
169
143
|
end
|
|
170
144
|
|
|
145
|
+
# Deletes one or more remote files, with optional dry-run support.
|
|
146
|
+
#
|
|
147
|
+
# @param paths [Array<String>] remote file paths to delete
|
|
148
|
+
# @param dry_run [Boolean] when true, simulates the deletion
|
|
149
|
+
# @return [Hash] API response with +:result+ key
|
|
171
150
|
def delete_wrapper_with_dry_run(paths, dry_run: false)
|
|
172
151
|
return { result: "success" } if dry_run
|
|
173
152
|
|
|
174
153
|
delete(paths)
|
|
175
154
|
end
|
|
176
155
|
|
|
156
|
+
# Deletes one or more files from the remote Neocities site.
|
|
157
|
+
#
|
|
158
|
+
# @param paths [Array<String>] remote file paths to delete
|
|
159
|
+
# @return [Hash] parsed API response
|
|
177
160
|
def delete(*paths)
|
|
178
161
|
post "delete", "filenames" => paths
|
|
179
162
|
end
|
|
180
163
|
|
|
164
|
+
# Retrieves information and statistics for a Neocities site.
|
|
165
|
+
#
|
|
166
|
+
# @param sitename [String] the site name to query
|
|
167
|
+
# @return [Hash] parsed API response containing +:info+ hash with
|
|
168
|
+
# site metadata (domain, created_at, last_updated, bandwidth, etc.)
|
|
169
|
+
# @raise [NeocitiesRed::APIError] if the API returns an error
|
|
181
170
|
def info(sitename)
|
|
182
171
|
get "info", sitename: sitename
|
|
183
172
|
end
|
|
184
173
|
|
|
174
|
+
# Performs an HTTP GET request to the Neocities API.
|
|
175
|
+
#
|
|
176
|
+
# @param path [String] API endpoint path (e.g. "list", "info")
|
|
177
|
+
# @param params [Hash] query parameters
|
|
178
|
+
# @return [Hash] parsed JSON response with symbolized keys
|
|
185
179
|
def get(path, params = {})
|
|
186
180
|
uri = @uri + path
|
|
187
181
|
uri.query = URI.encode_www_form params
|
|
@@ -190,6 +184,19 @@ module NeocitiesRed
|
|
|
190
184
|
JSON.parse resp.body, symbolize_names: true
|
|
191
185
|
end
|
|
192
186
|
|
|
187
|
+
# Downloads a file from a URL.
|
|
188
|
+
#
|
|
189
|
+
# @param url [String] full URL to download
|
|
190
|
+
# @return [Faraday::Response] raw Faraday response object
|
|
191
|
+
def download(url)
|
|
192
|
+
@conn.get(url)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Performs an HTTP POST request to the Neocities API.
|
|
196
|
+
#
|
|
197
|
+
# @param path [String] API endpoint path (e.g. "upload", "delete")
|
|
198
|
+
# @param args [Hash] request body parameters
|
|
199
|
+
# @return [Hash] parsed JSON response with symbolized keys
|
|
193
200
|
def post(path, args = {})
|
|
194
201
|
uri = @uri + path
|
|
195
202
|
resp = @conn.post(uri, args)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NeocitiesRed
|
|
4
|
+
# Base error class for all NeocitiesRed exceptions.
|
|
5
|
+
#
|
|
6
|
+
# @abstract Subclass this for domain-specific errors.
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Raised when the Neocities API returns a non-success response.
|
|
10
|
+
#
|
|
11
|
+
# Wraps error messages from the remote API so callers can inspect
|
|
12
|
+
# the +:message+ and +:error_type+ fields from the response.
|
|
13
|
+
class APIError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when a referenced file does not exist on the local filesystem.
|
|
16
|
+
#
|
|
17
|
+
# Typically raised by upload services when the source file
|
|
18
|
+
# cannot be found at the given path.
|
|
19
|
+
class FileNotFoundError < Error; end
|
|
20
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module NeocitiesRed
|
|
6
|
+
module Services
|
|
7
|
+
module Common
|
|
8
|
+
# Builds normalized exclusion lists from user-provided paths.
|
|
9
|
+
#
|
|
10
|
+
# Given a list of file or directory paths, expands them into all
|
|
11
|
+
# contained files and normalizes them relative to a base path.
|
|
12
|
+
# This is used by {NeocitiesRed::Services::Site::Pusher} to apply
|
|
13
|
+
# the +--exclude+ option.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# excluded = Exclusions.build(["node_modules", "secret.txt"], base_path: ".")
|
|
17
|
+
# # => ["node_modules/...", "secret.txt"]
|
|
18
|
+
#
|
|
19
|
+
# @see NeocitiesRed::Services::Site::Pusher#push Uses exclusions during push
|
|
20
|
+
module Exclusions
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Builds a normalized exclusion list from the given entries.
|
|
24
|
+
#
|
|
25
|
+
# For each entry:
|
|
26
|
+
# - If it is a file, includes it directly
|
|
27
|
+
# - If it is a directory, recursively includes all contained files
|
|
28
|
+
# - Non-existent entries are silently skipped
|
|
29
|
+
#
|
|
30
|
+
# @param excluded_entries [Array<String>] file or directory paths to exclude
|
|
31
|
+
# @param base_path [String, nil] base directory for path normalization;
|
|
32
|
+
# when provided, paths are made relative to this base
|
|
33
|
+
# @return [Array<String>] flattened, deduplicated list of normalized paths
|
|
34
|
+
def build(excluded_entries, base_path: nil)
|
|
35
|
+
base = base_path && Pathname.new(base_path).expand_path
|
|
36
|
+
|
|
37
|
+
excluded_entries.flat_map do |entry|
|
|
38
|
+
target = base ? Pathname.new(entry).expand_path : Pathname.new(entry).cleanpath
|
|
39
|
+
next [] unless target.exist?
|
|
40
|
+
|
|
41
|
+
paths =
|
|
42
|
+
if ::File.file?(target)
|
|
43
|
+
[target.to_s]
|
|
44
|
+
elsif ::File.directory?(target)
|
|
45
|
+
Dir.glob(::File.join(target, "**", "*"), ::File::FNM_DOTMATCH)
|
|
46
|
+
else
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
paths.push(target.to_s) if ::File.directory?(target)
|
|
51
|
+
paths.map { |path| normalize(path, base) }.uniq
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Normalizes a path relative to a base directory.
|
|
56
|
+
#
|
|
57
|
+
# @param path [String] absolute or relative path
|
|
58
|
+
# @param base [String, nil] base directory; when nil, returns the path as-is
|
|
59
|
+
# @return [String] the path relative to +base+, or the original path
|
|
60
|
+
def normalize(path, base)
|
|
61
|
+
return path unless base
|
|
62
|
+
|
|
63
|
+
Pathname.new(path).expand_path.relative_path_from(base).to_s
|
|
64
|
+
end
|
|
65
|
+
private_class_method :normalize
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|