neocities-red 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: eec23bd190087f4364c5a68d394b5662a3709e1a567663e0f218c025d0eeb83a
4
+ data.tar.gz: 3f22fc98249d04d665e9d848cae49507cc475949efc1c0253a3e7f606a487650
5
+ SHA512:
6
+ metadata.gz: '0966d54d18cafd78536595231de74f89ca46664b87227b65b17263ef1101c9e9b3fb684909b8a5d3393c2a3df3f510c93c77f8cf5809dd6597fc9c5ebbfe2d9b'
7
+ data.tar.gz: bbef63e19889dd92d1d676c0137b0843feaf6845afaf2f3282b1122281411a51d06d8df63873e8c99f240223676595c38460204963165bf71c147f4219488bc0
@@ -0,0 +1,6 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "bundler"
4
+ directory: "/" # Location of package manifests
5
+ schedule:
6
+ interval: "weekly"
data/.gitignore ADDED
@@ -0,0 +1,7 @@
1
+ Gemfile.lock
2
+ test.rb
3
+ *.gem
4
+
5
+ mise.toml
6
+
7
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,4 @@
1
+ # .rspec
2
+ --require spec_helper
3
+ --format documentation
4
+ --color
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ group :test, :development do
8
+ gem 'rspec'
9
+ gem 'rubocop', '~> 1.82'
10
+ gem 'rubocop-rspec', '~> 3.8'
11
+ gem 'vcr'
12
+ gem 'webmock'
13
+ end
data/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # Neocities Red
2
+
3
+ Hello, there is a fork of [neocities-ruby gem](https://github.com/neocities/neocities-ruby) with my own features and implementations. A much of my changes doesn't make sense to be pushed into original repository, so i pushed it here.
4
+
5
+ ## Main changes/Features:
6
+
7
+ ### Currently, this gems tests with ruby 3.4.*, it doesn't supports ruby 4 due of dependencies
8
+
9
+ 1) Added MultiThread uploading of files to neocities. This feature boosts `neocities push`;
10
+ 2) Moves from `http.rb` to `faraday` gem;
11
+ 3) Fixed `-e` flag to exclude folders recursively;
12
+ 4) Added `--ignore-dotfiles` to ignore all files-folders starts with '.';
13
+ 5) Added `--optimized` for `neocities push` flag to upload only modified files;
14
+ 6) Fixed bug with neocities info on modern rubies;
15
+ 7) Re-designed `upload` method;
16
+
17
+ ## TODO'S:
18
+ 1) Check all entire cli and client logic, fix bugs.
19
+ 2) Change dependencies for modern analogs.
20
+ 3) Refactor `cli.rb` or use `rails/thor` gem instead.
21
+ 4) Add tests
22
+ 5) Make sure that gem is compatible with Linux, Freebsd, Windows
23
+ 6) Make it compatible with ruby 4.0.0
24
+
25
+ # The Neocities Gem
26
+
27
+ A CLI and library for using the Neocities API. Makes it easy to quickly upload, push, delete, and list your Neocities site.
28
+
29
+ ## Installation
30
+
31
+ ```
32
+ gem install neocities-red
33
+ ```
34
+
35
+ ### Running
36
+
37
+ After that, you are all set! Run `neocities-red` in a command line to see the options and get started.
38
+
39
+ ## Gem modules
40
+
41
+ This gem also transpose all processes to several class in lib/neocities, which could be used to write code that interfaces with the Neocities API.
42
+
43
+ ```ruby
44
+ require 'neocities-red'
45
+
46
+ # use api key
47
+ params = {
48
+ api_key: 'MyKeyFromNeocities'
49
+ }
50
+
51
+ # or sitename and password
52
+ # params = {
53
+ # sitename: 'petrapixel,
54
+ # password: 'mypass'
55
+ # }
56
+
57
+ client = Neocities::Client.new(params)
58
+ client.key
59
+ client.upload(path, remote_path)
60
+ client.info(sitename)
61
+ client.delete(path)
62
+ client.push(path)
63
+ client.list(path)
64
+ ```
data/bin/neocities-red ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require File.join(File.dirname(__FILE__), '..', 'lib', 'neocities')
5
+ cli = Neocities::CLI.new ARGV
6
+ cli.run
data/ext/mkrf_conf.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubygems'
4
+ require 'rubygems/command'
5
+ require 'rubygems/dependency_installer'
6
+ begin
7
+ Gem::Command.build_args = ARGV
8
+ rescue NoMethodError
9
+ end
10
+ inst = Gem::DependencyInstaller.new
11
+ begin
12
+ inst.install 'openssl-win-root', '~> 1.1' if Gem.win_platform?
13
+ rescue StandardError
14
+ exit(1)
15
+ end
16
+
17
+ f = File.open(File.join(File.dirname(__FILE__), 'Rakefile'), 'w') # create dummy rakefile to indicate success
18
+ f.write("task :default\n")
19
+ f.close
@@ -0,0 +1,522 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'pastel'
5
+ require 'tty/table'
6
+ require 'tty/prompt'
7
+ require 'fileutils'
8
+ require 'json'
9
+ require 'whirly'
10
+ require 'digest'
11
+ require 'time'
12
+
13
+ # warning - the big quantity of working threads could be considered like-a DDOS. Your ip-address could get banned for a few days.
14
+ MAX_THREADS = 5
15
+
16
+ module Neocities
17
+ class CLI
18
+ SUBCOMMANDS = %w[upload delete list info push logout pizza pull].freeze
19
+ HELP_SUBCOMMANDS = ['-h', '--help', 'help'].freeze
20
+ PENELOPE_MOUTHS = %w[^ o ~ - v U].freeze
21
+ PENELOPE_EYES = %w[o ~ O].freeze
22
+
23
+ def initialize(argv)
24
+ @argv = argv.dup
25
+ @pastel = Pastel.new eachline: "\n"
26
+ @subcmd = @argv.first
27
+ @subargs = @argv[1..@argv.length]
28
+ @prompt = TTY::Prompt.new
29
+ @api_key = ENV['NEOCITIES_API_KEY'] || nil
30
+ @app_config_path = File.join self.class.app_config_path('neocities'), 'config.json'
31
+ end
32
+
33
+ def display_response(resp)
34
+ if resp.is_a?(Exception)
35
+ out = "#{@pastel.red.bold 'ERROR:'} #{resp.detailed_message}"
36
+ puts out
37
+ exit
38
+ end
39
+
40
+ if resp[:result] == 'success'
41
+ puts "#{@pastel.green.bold 'SUCCESS:'} #{resp[:message]}"
42
+ elsif resp[:result] == 'error' && resp[:error_type] == 'file_exists'
43
+ out = "#{@pastel.yellow.bold 'EXISTS:'} #{resp[:message]}"
44
+ out += " (#{resp[:error_type]})" if resp[:error_type]
45
+ puts out
46
+ else
47
+ out = "#{@pastel.red.bold 'ERROR:'} #{resp[:message]}"
48
+ out += " (#{resp[:error_type]})" if resp[:error_type]
49
+ puts out
50
+ end
51
+ end
52
+
53
+ def run
54
+ if @argv[0] == 'version'
55
+ puts Neocities::VERSION
56
+ exit
57
+ end
58
+
59
+ if HELP_SUBCOMMANDS.include?(@subcmd) && SUBCOMMANDS.include?(@subargs[0])
60
+ send "display_#{@subargs[0]}_help_and_exit"
61
+ elsif @subcmd.nil? || !SUBCOMMANDS.include?(@subcmd)
62
+ display_help_and_exit
63
+ elsif @subargs.join('').match(HELP_SUBCOMMANDS.join('|')) && @subcmd != 'info'
64
+ send "display_#{@subcmd}_help_and_exit"
65
+ end
66
+
67
+ unless @api_key
68
+ begin
69
+ file = File.read @app_config_path
70
+ data = JSON.parse file
71
+
72
+ if data
73
+ @api_key = data['API_KEY'].strip
74
+ @sitename = data['SITENAME']
75
+ @last_pull = data['LAST_PULL'] # Store the last time a pull was performed so that we only fetch from updated files
76
+ end
77
+ rescue Errno::ENOENT
78
+ @api_key = nil
79
+ end
80
+ end
81
+
82
+ if @api_key.nil?
83
+ puts 'Please login to get your API key:'
84
+
85
+ if !@sitename && !@password
86
+ @sitename = @prompt.ask('sitename:', default: ENV['NEOCITIES_SITENAME'])
87
+ @password = @prompt.mask('password:', default: ENV['NEOCITIES_PASSWORD'])
88
+ end
89
+
90
+ @client = Neocities::Client.new sitename: @sitename, password: @password
91
+
92
+ resp = @client.key
93
+ if resp[:api_key]
94
+ conf = {
95
+ "API_KEY": resp[:api_key],
96
+ "SITENAME": @sitename
97
+ }
98
+
99
+ FileUtils.mkdir_p Pathname(@app_config_path).dirname
100
+ File.write @app_config_path, conf.to_json
101
+
102
+ puts "The api key for #{@pastel.bold @sitename} has been stored in #{@pastel.bold @app_config_path}."
103
+ else
104
+ display_response resp
105
+ exit
106
+ end
107
+ else
108
+ @client = Neocities::Client.new api_key: @api_key
109
+ end
110
+
111
+ send @subcmd
112
+ end
113
+
114
+ def delete
115
+ display_delete_help_and_exit if @subargs.empty?
116
+
117
+ @subargs.each do |path|
118
+ FileRemover.new(@client, path).remove
119
+ end
120
+ end
121
+
122
+ def logout
123
+ confirmed = false
124
+
125
+ loop do
126
+ case @subargs[0]
127
+ when '-y'
128
+ @subargs.shift
129
+ confirmed = true
130
+ when /^-/
131
+ puts @pastel.red.bold("Unknown option: #{@subargs[0].inspect}")
132
+ break
133
+ else
134
+ break
135
+ end
136
+ end
137
+
138
+ if confirmed
139
+ FileUtils.rm @app_config_path
140
+ puts @pastel.bold('Your api key has been removed.')
141
+ else
142
+ display_logout_help_and_exit
143
+ end
144
+ end
145
+
146
+ def info
147
+ profile_info = ProfileInfo.new(@client, @subargs, @sitename).pretty_print
148
+ puts TTY::Table.new(profile_info)
149
+ rescue Exception => e
150
+ display_response(e)
151
+ end
152
+
153
+ def list
154
+ display_list_help_and_exit if @subargs.empty?
155
+
156
+ @detail = true if @subargs.delete('-d') == '-d'
157
+
158
+ @subargs[0] = nil if @subargs.delete('-a')
159
+
160
+ path = @subargs[0]
161
+
162
+ FileList.new(@client, path, @detail).show
163
+ end
164
+
165
+ def push
166
+ display_push_help_and_exit if @subargs.empty?
167
+ @no_gitignore = false
168
+ @ignore_dotfiles = false
169
+ @excluded_files = []
170
+ @dry_run = false
171
+ @prune = false
172
+ @optimized = false
173
+
174
+ loop do
175
+ case @subargs[0]
176
+ when '--no-gitignore'
177
+ @subargs.shift
178
+ @no_gitignore = true
179
+ when '--ignore-dotfiles'
180
+ @subargs.shift
181
+ @ignore_dotfiles = true
182
+ when '-e'
183
+ @subargs.shift
184
+ filepath = Pathname.new(@subargs.shift).cleanpath.to_s
185
+
186
+ if File.file?(filepath)
187
+ @excluded_files.push(filepath)
188
+ elsif File.directory?(filepath)
189
+ folder_files = Dir.glob(File.join(filepath, '**', '*'), File::FNM_DOTMATCH).push(filepath)
190
+ @excluded_files += folder_files
191
+ end
192
+ when '--dry-run'
193
+ @subargs.shift
194
+ @dry_run = true
195
+ when '--prune'
196
+ @subargs.shift
197
+ @prune = true
198
+ when '--optimized'
199
+ @subargs.shift
200
+ @optimized = true
201
+ when /^-/
202
+ puts @pastel.red.bold("Unknown option: #{@subargs[0].inspect}")
203
+ display_push_help_and_exit
204
+ else
205
+ break
206
+ end
207
+ end
208
+
209
+ if @subargs[0].nil?
210
+ display_response result: 'error', message: 'no local path provided'
211
+ display_push_help_and_exit
212
+ end
213
+
214
+ root_path = Pathname @subargs[0]
215
+
216
+ unless root_path.exist?
217
+ display_response result: 'error', message: "path #{root_path} does not exist"
218
+ display_push_help_and_exit
219
+ end
220
+
221
+ unless root_path.directory?
222
+ display_response result: 'error', message: 'provided path is not a directory'
223
+ display_push_help_and_exit
224
+ end
225
+
226
+ puts @pastel.green.bold('Doing a dry run, not actually pushing anything') if @dry_run
227
+
228
+ if @prune
229
+ pruned_dirs = []
230
+ resp = @client.list
231
+ resp[:files].each do |file|
232
+ path = Pathname(File.join(@subargs[0], file[:path]))
233
+
234
+ pruned_dirs << path if !path.exist? && file[:is_directory]
235
+
236
+ next unless !path.exist? && !pruned_dirs.include?(path.dirname)
237
+
238
+ print @pastel.bold("Deleting #{file[:path]} ... ")
239
+ resp = @client.delete_wrapper_with_dry_run file[:path], @dry_run
240
+
241
+ if resp[:result] == 'success'
242
+ print "#{@pastel.green.bold('SUCCESS')}\n"
243
+ else
244
+ print "\n"
245
+ display_response resp
246
+ end
247
+ end
248
+ end
249
+
250
+ Dir.chdir(root_path) do
251
+ paths = Dir.glob(File.join('**', '*'), File::FNM_DOTMATCH)
252
+
253
+ if @no_gitignore == false
254
+ begin
255
+ ignores = File.readlines('.gitignore').collect! do |ignore|
256
+ ignore.strip!
257
+ File.directory?(ignore) ? "#{ignore}**" : ignore
258
+ end
259
+ paths.select! do |path|
260
+ res = true
261
+ ignores.each do |ignore|
262
+ if File.fnmatch?(ignore.strip, path)
263
+ res = false
264
+ break
265
+ end
266
+ end
267
+ end
268
+ puts 'Not pushing .gitignore entries (--no-gitignore to disable)'
269
+ rescue Errno::ENOENT
270
+ end
271
+ end
272
+
273
+ @excluded_files += paths.select { |path| path.start_with?('.') } if @ignore_dotfiles
274
+
275
+ # do not upload files which already uploaded (checking by sha1_hash)
276
+ if @optimized
277
+ hex = paths.select { |path| File.file?(path) }
278
+ .map { |file| { filepath: file, sha1_hash: Digest::SHA1.file(file).hexdigest } }
279
+
280
+ res = @client.list
281
+ server_hex = res[:files].map { |n| n[:sha1_hash] }.compact
282
+
283
+ uploaded_files = hex.select { |n| server_hex.include?(n[:sha1_hash]) }
284
+ .map { |n| n[:filepath] }
285
+ @excluded_files += uploaded_files
286
+ end
287
+
288
+ paths -= @excluded_files
289
+ paths.collect! { |path| Pathname path }
290
+
291
+ task_queue = Queue.new
292
+ paths.each { |path| task_queue.push(path) }
293
+
294
+ threads = []
295
+
296
+ MAX_THREADS.times do
297
+ threads << Thread.new do
298
+ until task_queue.empty?
299
+ path = begin
300
+ task_queue.pop(true)
301
+ rescue StandardError
302
+ nil
303
+ end
304
+ next if path.nil? || path.directory?
305
+
306
+ Neocities::FileUploader.new(@client, path).upload
307
+ end
308
+ end
309
+ end
310
+
311
+ threads.each(&:join)
312
+ puts 'All files uploaded.'
313
+ end
314
+ end
315
+
316
+ def upload
317
+ display_upload_help_and_exit if @subargs.empty?
318
+
319
+ loop do
320
+ case @subargs[0]
321
+ when /^-/
322
+ puts @pastel.red.bold("Unknown option: #{@subargs[0].inspect}")
323
+ display_upload_help_and_exit
324
+ else
325
+ break
326
+ end
327
+ end
328
+
329
+ FileUploader.new(@client, @subargs[0], @subargs[1]).upload
330
+ end
331
+
332
+ def pull
333
+ quiet = ['--quiet', '-q'].include?(@subargs[0])
334
+ file = File.read(@app_config_path)
335
+ data = JSON.parse(file)
336
+ last_pull_time = data['LAST_PULL']['time']
337
+ last_pull_loc = data['LAST_PULL']['loc']
338
+
339
+ SiteExporter.new(@client, @sitename, data, @app_config_path)
340
+ .export(quiet, last_pull_time, last_pull_loc)
341
+ end
342
+
343
+ def pizza
344
+ display_pizza_help_and_exit
345
+ end
346
+
347
+ def display_pizza_help_and_exit
348
+ puts Pizza.new.make_order
349
+ end
350
+
351
+ def display_list_help_and_exit
352
+ display_banner
353
+
354
+ puts <<HERE
355
+ #{@pastel.green.bold 'list'} - List files on your Neocities site
356
+
357
+ #{@pastel.dim 'Examples:'}
358
+
359
+ #{@pastel.green '$ neocities list /'} List files in your root directory
360
+
361
+ #{@pastel.green '$ neocities list -a'} Recursively display all files and directories
362
+
363
+ #{@pastel.green '$ neocities list -d /mydir'} Show detailed information on /mydir
364
+
365
+ HERE
366
+ exit
367
+ end
368
+
369
+ def display_delete_help_and_exit
370
+ display_banner
371
+
372
+ puts <<HERE
373
+ #{@pastel.green.bold 'delete'} - Delete files on your Neocities site
374
+
375
+ #{@pastel.dim 'Examples:'}
376
+
377
+ #{@pastel.green '$ neocities delete myfile.jpg'} Delete myfile.jpg
378
+
379
+ #{@pastel.green '$ neocities delete myfile.jpg myfile2.jpg'} Delete myfile.jpg and myfile2.jpg
380
+
381
+ #{@pastel.green '$ neocities delete mydir'} Deletes mydir and everything inside it (be careful!)
382
+
383
+ HERE
384
+ exit
385
+ end
386
+
387
+ def display_upload_help_and_exit
388
+ display_banner
389
+
390
+ puts <<HERE
391
+ #{@pastel.green.bold 'upload'} - Upload file to your Neocities site to the specific path
392
+
393
+ #{@pastel.dim 'Examples:'}
394
+
395
+ #{@pastel.green '$ neocities upload /img.jpg /images/img2.jpg'} Upload img.jpg to /images folder and with img2.jpg name
396
+ HERE
397
+ exit
398
+ end
399
+
400
+ def display_pull_help_and_exit
401
+ display_banner
402
+
403
+ puts <<HERE
404
+ #{@pastel.magenta.bold 'pull'} - Get the most recent version of files from your site, does not download if files haven't changed
405
+
406
+ HERE
407
+ exit
408
+ end
409
+
410
+ def display_push_help_and_exit
411
+ display_banner
412
+
413
+ puts <<HERE
414
+ #{@pastel.green.bold 'push'} - Recursively upload a local directory to your Neocities site
415
+
416
+ #{@pastel.dim 'Examples:'}
417
+
418
+ #{@pastel.green '$ neocities push .'} Recursively upload current directory.
419
+
420
+ #{@pastel.green '$ neocities push -e node_modules -e secret.txt .'} Exclude certain files from push
421
+
422
+ #{@pastel.green '$ neocities push --no-gitignore .'} Don't use .gitignore to exclude files
423
+
424
+ #{@pastel.green '$ neocities push --ignore-dotfiles .'} Ignore files with '.' at the beginning (for example, '.git/')
425
+
426
+ #{@pastel.green '$ neocities push --dry-run .'} Just show what would be uploaded
427
+
428
+ #{@pastel.green '$ neocities push --optimized .'} Do not upload unchanged files.#{' '}
429
+
430
+ #{@pastel.green '$ neocities push --prune .'} Delete site files not in dir (be careful!)
431
+
432
+ HERE
433
+ exit
434
+ end
435
+
436
+ def display_info_help_and_exit
437
+ display_banner
438
+
439
+ puts <<HERE
440
+ #{@pastel.green.bold 'info'} - Get site info
441
+
442
+ #{@pastel.dim 'Examples:'}
443
+
444
+ #{@pastel.green '$ neocities info fauux'} Gets info for 'fauux' site
445
+
446
+ HERE
447
+ exit
448
+ end
449
+
450
+ def display_logout_help_and_exit
451
+ display_banner
452
+
453
+ puts <<HERE
454
+ #{@pastel.green.bold 'logout'} - Remove the site api key from the config
455
+
456
+ #{@pastel.dim 'Examples:'}
457
+
458
+ #{@pastel.green '$ neocities logout -y'}
459
+
460
+ HERE
461
+ exit
462
+ end
463
+
464
+ def display_banner
465
+ puts <<HERE
466
+
467
+ |\\---/|
468
+ | #{PENELOPE_EYES.sample}_#{PENELOPE_EYES.sample} | #{@pastel.on_red.bold ' Neocities red '}
469
+ \\_#{PENELOPE_MOUTHS.sample}_/
470
+
471
+ HERE
472
+ end
473
+
474
+ def display_help_and_exit
475
+ display_banner
476
+ puts <<HERE
477
+ #{@pastel.dim 'Subcommands:'}
478
+ push Recursively upload a local directory to your site
479
+ upload Upload individual files to your Neocities site
480
+ delete Delete files from your Neocities site
481
+ list List files from your Neocities site
482
+ info Information and stats for your site
483
+ logout Remove the site api key from the config
484
+ version Unceremoniously display version and self destruct
485
+ pull Get the most recent version of files from your site
486
+ pizza Order a free pizza
487
+
488
+ HERE
489
+ exit
490
+ end
491
+
492
+ def self.app_config_path(name)
493
+ platform = case RUBY_PLATFORM
494
+ when /win32/
495
+ :win32
496
+ when /darwin/
497
+ :darwin
498
+ when /linux/
499
+ :linux
500
+ else
501
+ :unknown
502
+ end
503
+
504
+ case platform
505
+ when :linux
506
+ return File.join(ENV['XDG_CONFIG_HOME'], name) if ENV['XDG_CONFIG_HOME']
507
+
508
+ File.join(ENV['HOME'], '.config', name) if ENV['HOME']
509
+ when :darwin
510
+ File.join(ENV['HOME'], 'Library', 'Application Support', name)
511
+ else
512
+ # Windows platform detection is weird, just look for the env variables
513
+ return File.join(ENV['LOCALAPPDATA'], name) if ENV['LOCALAPPDATA']
514
+
515
+ return File.join(ENV['USERPROFILE'], 'Local Settings', 'Application Data', name) if ENV['USERPROFILE']
516
+
517
+ # Should work for the BSDs
518
+ File.join(ENV['HOME'], ".#{name}") if ENV['HOME']
519
+ end
520
+ end
521
+ end
522
+ end