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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +29 -15
  3. data/.gitignore +4 -0
  4. data/CHANGELOG.md +95 -0
  5. data/Gemfile +1 -0
  6. data/Gemfile.lock +9 -6
  7. data/README.md +6 -0
  8. data/lib/neocities_red/cli.rb +156 -31
  9. data/lib/neocities_red/cli_display.rb +200 -5
  10. data/lib/neocities_red/client.rb +84 -77
  11. data/lib/neocities_red/errors.rb +20 -0
  12. data/lib/neocities_red/services/common/exclusions.rb +69 -0
  13. data/lib/neocities_red/services/common/pizza.rb +34 -21
  14. data/lib/neocities_red/services/common/worker_pool.rb +54 -0
  15. data/lib/neocities_red/services/file/folder_uploader.rb +39 -25
  16. data/lib/neocities_red/services/file/list.rb +60 -30
  17. data/lib/neocities_red/services/file/remover.rb +27 -12
  18. data/lib/neocities_red/services/file/uploader.rb +33 -13
  19. data/lib/neocities_red/services/site/differencer.rb +42 -12
  20. data/lib/neocities_red/services/site/exporter.rb +120 -7
  21. data/lib/neocities_red/services/site/informer.rb +29 -4
  22. data/lib/neocities_red/services/site/pusher.rb +74 -38
  23. data/lib/neocities_red/version.rb +2 -1
  24. data/lib/neocities_red.rb +32 -13
  25. data/neocities-red.gemspec +11 -6
  26. data/spec/neocities_red/cli_display_spec.rb +0 -6
  27. data/spec/neocities_red/cli_spec.rb +85 -0
  28. data/spec/neocities_red/client_spec.rb +7 -50
  29. data/spec/neocities_red/services/common/exclusions_spec.rb +47 -0
  30. data/spec/neocities_red/services/common/worker_pool_spec.rb +48 -0
  31. data/spec/neocities_red/services/file/folder_uploader_spec.rb +8 -6
  32. data/spec/neocities_red/services/file/list_spec.rb +13 -10
  33. data/spec/neocities_red/services/file/remover_spec.rb +15 -21
  34. data/spec/neocities_red/services/file/uploader_spec.rb +25 -20
  35. data/spec/neocities_red/services/site/differencer_spec.rb +32 -6
  36. data/spec/neocities_red/services/site/exporter_spec.rb +105 -38
  37. data/spec/neocities_red/services/site/informer_spec.rb +3 -3
  38. data/spec/neocities_red/services/site/pusher_spec.rb +1 -1
  39. data/spec/spec_helper.rb +1 -1
  40. 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
- def display_unknown_option(option)
64
- say @pastel.red.bold("Unknown option: #{option.inspect}")
65
- end
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
@@ -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
- # TODO: refactor
67
- def pull(sitename, last_pull_time = nil, last_pull_loc = nil, quiet: true)
68
- site_info = info(sitename)
69
-
70
- raise ArgumentError, site_info[:message] if site_info[:result] == "error"
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 = if res[:files]
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