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
@@ -4,8 +4,18 @@ require "pastel"
4
4
 
5
5
  module NeocitiesRed
6
6
  module Services
7
+ # Shared service utilities used across file and site operations.
7
8
  module Common
9
+ # Easter egg — returns a random humorous excuse for why pizza
10
+ # is unavailable.
11
+ #
12
+ # Invoked via the +neocities-red pizza+ CLI command.
13
+ #
14
+ # @example
15
+ # excuse = Pizza.new.make_order
16
+ # puts excuse
8
17
  class Pizza
18
+ # @return [Array<String>] pool of humorous pizza excuses
9
19
  EXCUSES = [
10
20
  "Sorry, we're fresh out of pineapple today.",
11
21
  "All the toppings just went rogue and are currently answering to no god.",
@@ -24,12 +34,12 @@ module NeocitiesRed
24
34
  <<~TEXT
25
35
  WAR AND PEACE, BY LEO TOLSTOY, BOOK ONE: 1805, CHAPTER I
26
36
 
27
- “Well, Prince, so Genoa and Lucca are now just family estates of the Buonapartes.
28
- But I warn you, if you don’t tell me that this means war, if you still try to defend
37
+ "Well, Prince, so Genoa and Lucca are now just family estates of the Buonapartes.
38
+ But I warn you, if you don't tell me that this means war, if you still try to defend
29
39
  the infamies and horrors perpetrated by that Antichrist—I really believe he is
30
40
  Antichrist—I will have nothing more to do with you and you are no longer my friend,
31
- no longer my ‘faithful slave,’ as you call yourself! But how do you do? I see I have
32
- frightened you—sit down and tell me all the news.”
41
+ no longer my 'faithful slave,' as you call yourself! But how do you do? I see I have
42
+ frightened you—sit down and tell me all the news."
33
43
 
34
44
  It was in July, 1805, and the speaker was the well-known Anna Pávlovna Schérer,
35
45
  maid of honor and favorite of the Empress Márya Fëdorovna. With these words she
@@ -42,11 +52,11 @@ module NeocitiesRed
42
52
  All her invitations without exception, written in French, and delivered by a
43
53
  scarlet-liveried footman that morning, ran as follows:
44
54
 
45
- “If you have nothing better to do, Count (or Prince), and if the prospect of
55
+ "If you have nothing better to do, Count (or Prince), and if the prospect of
46
56
  spending an evening with a poor invalid is not too terrible, I shall be very
47
- charmed to see you tonight between 7 and 10—Annette Schérer.”
57
+ charmed to see you tonight between 7 and 10—Annette Schérer."
48
58
 
49
- “Heavens! what a virulent attack!” replied the prince, not in the least disconcerted
59
+ "Heavens! what a virulent attack!" replied the prince, not in the least disconcerted
50
60
  by this reception.
51
61
 
52
62
  He had just entered, wearing an embroidered court uniform, knee breeches, and
@@ -59,32 +69,32 @@ module NeocitiesRed
59
69
  He went up to Anna Pávlovna, kissed her hand, presenting to her his bald, scented,
60
70
  and shining head, and complacently seated himself on the sofa.
61
71
 
62
- “First of all, dear friend, tell me how you are. Set your friend’s mind at rest,”
72
+ "First of all, dear friend, tell me how you are. Set your friend's mind at rest,"
63
73
  said he without altering his tone, beneath the politeness and affected sympathy of
64
74
  which indifference and even irony could be discerned.
65
75
 
66
- “Can one be well while suffering morally? Can one be calm in times like these if
67
- one has any feeling?” said Anna Pávlovna. “You are staying the whole evening, I hope?”
76
+ "Can one be well while suffering morally? Can one be calm in times like these if
77
+ one has any feeling?" said Anna Pávlovna. "You are staying the whole evening, I hope?"
68
78
 
69
- “And the fete at the English ambassador’s? Today is Wednesday. I must put in an
70
- appearance there,” said the prince. “My daughter is coming for me to take me there.”
79
+ "And the fete at the English ambassador's? Today is Wednesday. I must put in an
80
+ appearance there," said the prince. "My daughter is coming for me to take me there."
71
81
 
72
- “I thought today’s fete had been canceled. I confess all these festivities and
73
- fireworks are becoming wearisome.”
82
+ "I thought today's fete had been canceled. I confess all these festivities and
83
+ fireworks are becoming wearisome."
74
84
 
75
- “If they had known that you wished it, the entertainment would have been put off,”
85
+ "If they had known that you wished it, the entertainment would have been put off,"
76
86
  said the prince, who, like a wound-up clock, by force of habit said things he did
77
87
  not even wish to be believed.
78
88
 
79
- “Don’t tease! Well, and what has been decided about Novosíltsev’s dispatch?
80
- You know everything.”
89
+ "Don't tease! Well, and what has been decided about Novosíltsev's dispatch?
90
+ You know everything."
81
91
 
82
- “What can one say about it?” replied the prince in a cold, listless tone.
83
- “What has been decided? They have decided that Buonaparte has burnt his boats,
84
- and I believe that we are ready to burn ours.”
92
+ "What can one say about it?" replied the prince in a cold, listless tone.
93
+ "What has been decided? They have decided that Buonaparte has burnt his boats,
94
+ and I believe that we are ready to burn ours."
85
95
 
86
96
  Prince Vasíli always spoke languidly, like an actor repeating a stale part.
87
- Anna Pávlovna Schérer on the contrary, despite her forty years, overflowed with
97
+ Anna Pávlovna Schérer on the contrary, despite of her forty years, overflowed with
88
98
  animation and impulsiveness.
89
99
 
90
100
  To be an enthusiast had become her social vocation and, sometimes even when she
@@ -98,6 +108,9 @@ module NeocitiesRed
98
108
  TEXT
99
109
  ].freeze
100
110
 
111
+ # Returns a random pizza excuse.
112
+ #
113
+ # @return [String] a humorous excuse rendered in bright red via Pastel
101
114
  def make_order
102
115
  Pastel.new(enabled: true).bright_red(EXCUSES.sample)
103
116
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NeocitiesRed
4
+ module Services
5
+ module Common
6
+ # A simple thread pool for parallel task execution.
7
+ #
8
+ # Processes a list of items concurrently using a fixed number of
9
+ # worker threads. Each item is passed to the block provided at
10
+ # construction time.
11
+ #
12
+ # @example
13
+ # pool = WorkerPool.new(5) { |item| puts item }
14
+ # pool.process([1, 2, 3, 4, 5])
15
+ #
16
+ # @see NeocitiesRed::Services::File::FolderUploader Uses for parallel uploads
17
+ # @see NeocitiesRed::Services::Site::Pusher Uses for parallel uploads
18
+ class WorkerPool
19
+ # @param size [Integer] number of concurrent worker threads
20
+ # @yield [item] block to execute for each item
21
+ # @yieldparam item [Object] an item from the processing queue
22
+ def initialize(size, &block)
23
+ @size = size
24
+ @block = block
25
+ end
26
+
27
+ # Processes all items in the pool using worker threads.
28
+ #
29
+ # Items are placed in a thread-safe queue and consumed by workers.
30
+ # The method blocks until all items have been processed.
31
+ #
32
+ # @param items [Array<Object>] items to process
33
+ # @return [void]
34
+ def process(items)
35
+ queue = Queue.new
36
+ items.each { |item| queue << item }
37
+
38
+ workers = Array.new(@size) do
39
+ Thread.new do
40
+ loop do
41
+ item = queue.pop(true)
42
+ @block.call(item)
43
+ rescue ThreadError
44
+ break
45
+ end
46
+ end
47
+ end
48
+
49
+ workers.each(&:join)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,30 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
- require "pastel"
5
4
 
6
5
  module NeocitiesRed
7
6
  module Services
8
7
  module File
9
- # warning - the big quantity of working threads could be considered like-a DDOS.
10
- # Your ip-address could get banned for a few days.
8
+ # Maximum number of concurrent upload threads.
9
+ #
10
+ # Warning: a high thread count may be flagged as DDOS-like traffic
11
+ # by Neocities, potentially resulting in a temporary IP ban.
11
12
  MAX_THREADS = 5
12
13
 
14
+ # Uploads all files in a local directory to the remote site in parallel.
15
+ #
16
+ # Uses a {Services::Common::WorkerPool} to upload files concurrently
17
+ # up to {MAX_THREADS} threads. Each file is uploaded via {Uploader}.
18
+ #
19
+ # @example
20
+ # folder = NeocitiesRed::Services::File::FolderUploader.new(client, "./dist", "assets", display: display)
21
+ # files = folder.files
22
+ # folder.upload(files)
23
+ #
24
+ # @see NeocitiesRed::Services::File::Uploader Individual file upload
25
+ # @see NeocitiesRed::Services::Common::WorkerPool Thread pool
13
26
  class FolderUploader
14
- def initialize(client, filepath, remote_path)
27
+ # @param client [NeocitiesRed::Client] authenticated API client
28
+ # @param filepath [String] local directory path to upload
29
+ # @param remote_path [String] remote destination directory
30
+ # @param display [NeocitiesRed::CliDisplay] output helper
31
+ def initialize(client, filepath, remote_path, display: NeocitiesRed::CliDisplay.new)
15
32
  @client = client
16
33
  @filepath = filepath
17
34
  @remote_path = remote_path
18
- @pastel = Pastel.new(eachline: "\n")
35
+ @display = display
19
36
  end
20
37
 
38
+ # Recursively lists all files in the local directory.
39
+ #
40
+ # @return [Array<String>] relative file paths within the directory
41
+ # @raise [NeocitiesRed::FileNotFoundError] if the directory does not exist
42
+ # @return [nil] if the path is a file (not a directory)
21
43
  def files
22
44
  path = Pathname.new(::File.expand_path(@filepath))
23
45
 
24
- raise FileIsNotExists, "#{path} does not exist locally." unless path.exist?
46
+ raise NeocitiesRed::FileNotFoundError, "#{path} does not exist locally." unless path.exist?
25
47
 
26
48
  if path.file?
27
- puts @pastel.bold("#{path} is not a directory, skipping")
49
+ @display.display_skip_file(path)
28
50
  return
29
51
  end
30
52
 
@@ -34,30 +56,22 @@ module NeocitiesRed
34
56
  end
35
57
  end
36
58
 
59
+ # Uploads all files in the list concurrently.
60
+ #
61
+ # @param files_list [Array<String>] relative file paths to upload
62
+ # @param threads [Integer] maximum concurrent upload threads (default: {MAX_THREADS})
63
+ # @return [void]
37
64
  def upload(files_list, threads = MAX_THREADS)
38
65
  base = ::File.expand_path(@filepath)
39
66
 
40
- queue = Queue.new
41
- files_list.each { |file| queue << file }
67
+ worker_pool = Services::Common::WorkerPool.new(threads) do |file|
68
+ local_path = ::File.join(base, file)
69
+ remote_path = ::File.join(@remote_path, file)
42
70
 
43
- workers = Array.new(threads) do
44
- Thread.new do
45
- loop do
46
- begin
47
- file = queue.pop(true)
48
- rescue ThreadError
49
- break
50
- end
51
-
52
- local_path = ::File.join(base, file)
53
- remote_path = ::File.join(@remote_path, file)
54
-
55
- Uploader.new(@client, local_path, remote_path).upload
56
- end
57
- end
71
+ Uploader.new(@client, local_path, remote_path, display: @display).upload
58
72
  end
59
73
 
60
- workers.each(&:join)
74
+ worker_pool.process(files_list)
61
75
  end
62
76
  end
63
77
  end
@@ -6,58 +6,88 @@ require "tty/table"
6
6
 
7
7
  module NeocitiesRed
8
8
  module Services
9
+ # File-level operations: upload, delete, and list.
9
10
  module File
11
+ # Lists files on the remote Neocities site.
12
+ #
13
+ # Supports both a simple list mode and a detailed table mode
14
+ # with file size, SHA1 hash, and last-updated timestamp.
15
+ #
16
+ # @example Simple listing
17
+ # list = NeocitiesRed::Services::File::List.new(client, nil, false, display: display)
18
+ # files = list.show
19
+ #
20
+ # @example Detailed table
21
+ # list = NeocitiesRed::Services::File::List.new(client, nil, true, display: display)
22
+ # list.show # renders TTY::Table
23
+ #
24
+ # @see NeocitiesRed::Client#list Underlying API call
10
25
  class List
11
- def initialize(client, path, detail)
26
+ # @param client [NeocitiesRed::Client] authenticated API client
27
+ # @param path [String, nil] remote directory path to list (nil for root)
28
+ # @param detail [Boolean] when true, renders a detailed table with
29
+ # size, hash, and timestamp columns
30
+ # @param display [NeocitiesRed::CliDisplay] output helper
31
+ def initialize(client, path, detail, display: NeocitiesRed::CliDisplay.new)
12
32
  @client = client
13
33
  @path = path
14
34
  @detail = detail || false
35
+ @display = display
15
36
  @pastel = Pastel.new(eachline: "\n")
16
37
  end
17
38
 
39
+ # Returns the raw list of remote files.
40
+ #
41
+ # @return [Array<Hash>] array of file hashes with +:path+, +:is_directory+,
42
+ # +:size+, +:sha1_hash+, and +:updated_at+ keys
43
+ # @raise [NeocitiesRed::APIError] if the API returns an error
18
44
  def list
19
- resp = @client.list(@path)
20
-
21
- display_error_and_exit(resp) if resp[:result] == "error"
22
-
23
- resp[:files]
45
+ files_from_response
24
46
  end
25
47
 
48
+ # Returns the file list, optionally rendered as a detailed table.
49
+ #
50
+ # In detail mode, displays a colored table with Path, Size, SHA1 Hash,
51
+ # and Updated columns. Directories are shown in blue, files in green.
52
+ #
53
+ # @return [Array<Hash>] the file list hashes
54
+ # @raise [NeocitiesRed::APIError] if the API returns an error
26
55
  def show
27
- resp = @client.list(@path)
28
-
29
- display_error_and_exit(resp) if resp[:result] == "error"
56
+ files = files_from_response
30
57
 
31
- if @detail
32
- out = [
33
- [@pastel.bold("Path"), @pastel.bold("Size"), @pastel.bold("sha1_Hash"),
34
- @pastel.bold("Updated")]
35
- ]
58
+ return files unless @detail
36
59
 
37
- resp[:files].each do |file|
38
- out.push([
39
- @pastel.send(file[:is_directory] ? :blue : :green).bold(file[:path]),
40
- file[:size] || "",
41
- file[:sha1_hash],
42
- Time.parse(file[:updated_at]).localtime
43
- ])
44
- end
60
+ out = [
61
+ [@pastel.bold("Path"), @pastel.bold("Size"), @pastel.bold("sha1_Hash"),
62
+ @pastel.bold("Updated")]
63
+ ]
45
64
 
46
- puts TTY::Table.new(out)
65
+ files.each do |file|
66
+ out.push([
67
+ @pastel.send(file[:is_directory] ? :blue : :green).bold(file[:path]),
68
+ file[:size] || "",
69
+ file[:sha1_hash],
70
+ Time.parse(file[:updated_at]).localtime
71
+ ])
47
72
  end
48
73
 
49
- resp[:files].map do |file|
50
- @pastel.send(file[:is_directory] ? :blue : :green).bold(file[:path])
51
- end
74
+ @display.display_list_table(TTY::Table.new(out))
52
75
 
53
- resp[:files]
76
+ files
54
77
  end
55
78
 
56
79
  private
57
80
 
58
- def display_error_and_exit(resp)
59
- puts(resp[:message] || resp[:error_type] || resp.inspect)
60
- exit
81
+ # Fetches and validates the file list from the API.
82
+ #
83
+ # @return [Array<Hash>] file hashes from the API response
84
+ # @raise [NeocitiesRed::APIError] if the response indicates an error
85
+ def files_from_response
86
+ resp = @client.list(@path)
87
+
88
+ raise NeocitiesRed::APIError, resp[:message] || resp[:error_type] || resp.inspect if resp[:result] == "error"
89
+
90
+ resp[:files]
61
91
  end
62
92
  end
63
93
  end
@@ -1,30 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pastel"
4
-
5
3
  module NeocitiesRed
6
4
  module Services
7
5
  module File
6
+ # Deletes a single file from the remote Neocities site.
7
+ #
8
+ # Displays progress and result feedback via {CliDisplay}.
9
+ #
10
+ # @example
11
+ # remover = NeocitiesRed::Services::File::Remover.new(client, "old-file.html", display: display)
12
+ # remover.remove
13
+ #
14
+ # @see NeocitiesRed::Client#delete Underlying delete API call
8
15
  class Remover
9
- attr_accessor :client, :filepath
16
+ # @return [NeocitiesRed::Client] the authenticated API client
17
+ attr_reader :client
18
+
19
+ # @return [String] the remote file path to delete
20
+ attr_accessor :filepath
10
21
 
11
- def initialize(client, filepath)
22
+ # @param client [NeocitiesRed::Client] authenticated API client
23
+ # @param filepath [String] remote file path to delete
24
+ # @param display [NeocitiesRed::CliDisplay] output helper
25
+ def initialize(client, filepath, display: NeocitiesRed::CliDisplay.new)
12
26
  @client = client
13
27
  @filepath = filepath
14
- @pastel = Pastel.new(eachline: "\n")
28
+ @display = display
15
29
  end
16
30
 
31
+ # Deletes the file from the remote site.
32
+ #
33
+ # @return [Hash] API response with +:result+ key ("success" or "error")
17
34
  def remove
18
- puts @pastel.bold("Deleting #{filepath} ...")
35
+ @display.display_delete_progress(filepath)
19
36
 
20
37
  response = @client.delete(filepath)
21
38
 
22
- puts @pastel.bold(response[:message]) if response[:result] == "error"
23
-
24
- if response[:result] == "error" && response[:error_type] == "file_exists"
25
- print @pastel.yellow.bold("EXISTS")
26
- elsif response[:result] == "success"
27
- print @pastel.green.bold("SUCCESS")
39
+ if response[:result] == "success"
40
+ @display.display_delete_success
41
+ else
42
+ @display.display_delete_error(response)
28
43
  end
29
44
 
30
45
  response
@@ -1,40 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
- require "pastel"
5
4
 
6
5
  module NeocitiesRed
7
6
  module Services
8
7
  module File
9
- class FileIsNotExists < StandardError; end
10
-
8
+ # Uploads a single file to the Neocities site.
9
+ #
10
+ # Handles display feedback for the upload lifecycle: progress,
11
+ # success, already-exists, and directory-skip scenarios.
12
+ #
13
+ # @example
14
+ # uploader = NeocitiesRed::Services::File::Uploader.new(client, "index.html", display: display)
15
+ # uploader.upload
16
+ #
17
+ # @see NeocitiesRed::Client#upload Underlying upload method
11
18
  class Uploader
12
- def initialize(client, filepath, remote_path = nil)
19
+ # @param client [NeocitiesRed::Client] authenticated API client
20
+ # @param filepath [String] local file path to upload
21
+ # @param remote_path [String, nil] remote destination path;
22
+ # defaults to the file's basename
23
+ # @param display [NeocitiesRed::CliDisplay] output helper
24
+ def initialize(client, filepath, remote_path = nil, display: NeocitiesRed::CliDisplay.new)
13
25
  @client = client
14
26
  @filepath = filepath
15
27
  @remote_path = remote_path
16
- @pastel = Pastel.new(eachline: "\n")
28
+ @display = display
17
29
  end
18
30
 
31
+ # Uploads the file to the remote site.
32
+ #
33
+ # Skips directories with a display message. Computes the SHA1 hash
34
+ # and skips the upload if the remote file is identical.
35
+ #
36
+ # @return [Hash] API response with +:result+ key
37
+ # @raise [NeocitiesRed::FileNotFoundError] if the local file does not exist
19
38
  def upload
20
39
  path = Pathname.new(@filepath)
21
40
 
22
- raise FileIsNotExists, "#{path} does not exist locally." unless path.exist?
41
+ raise NeocitiesRed::FileNotFoundError, "#{path} does not exist locally." unless path.exist?
23
42
 
24
43
  if path.directory?
25
- puts @pastel.bold("#{path} is a directory, skipping")
44
+ @display.display_skip_directory(path)
26
45
  return
27
46
  end
28
47
 
29
- puts @pastel.bold("Uploading #{path} to #{@remote_path} ...")
48
+ @display.display_upload_progress(path, @remote_path)
30
49
 
31
50
  response = @client.upload(path, @remote_path)
32
- puts response if response[:result] == "error"
33
51
 
34
- if response[:result] == "error" && response[:error_type] == "file_exists"
35
- puts @pastel.yellow.bold("EXISTS")
36
- elsif response[:result] == "success"
37
- puts @pastel.green.bold("SUCCESS")
52
+ if response[:result] == "success"
53
+ @display.display_upload_success
54
+ elsif response[:result] == "error" && response[:error_type] == "file_exists"
55
+ @display.display_upload_exists
56
+ else
57
+ @display.display_response(response)
38
58
  end
39
59
 
40
60
  response
@@ -6,7 +6,29 @@ require "tty/table"
6
6
  module NeocitiesRed
7
7
  module Services
8
8
  module Site
9
+ # Compares local files against the remote Neocities site.
10
+ #
11
+ # Identifies three categories of differences:
12
+ # - **Added** — files present locally but not on the server
13
+ # - **Modified** — files present in both but with different SHA1 hashes
14
+ # - **Removed** — files present on the server but not locally
15
+ #
16
+ # Results are color-coded: green for added, yellow for modified,
17
+ # red for removed.
18
+ #
19
+ # @example
20
+ # differencer = NeocitiesRed::Services::Site::Differencer.new(
21
+ # client, path: ".", detail: false, ignore_dotfiles: false, exclude: []
22
+ # )
23
+ # added, modified, removed = differencer.show
24
+ #
25
+ # @see NeocitiesRed::Services::File::List Fetches the remote file list
9
26
  class Differencer
27
+ # @param client [NeocitiesRed::Client] authenticated API client
28
+ # @param path [String] local directory path to compare (default: ".")
29
+ # @param detail [Boolean] whether to show detailed output (default: false)
30
+ # @param ignore_dotfiles [Boolean] when true, ignores files starting with "."
31
+ # @param exclude [Array<String>] local paths to exclude from comparison
10
32
  def initialize(client, path: ".", detail: false, ignore_dotfiles: false, exclude: [])
11
33
  @client = client
12
34
  @path = path
@@ -16,6 +38,11 @@ module NeocitiesRed
16
38
  @pastel = Pastel.new(eachline: "\n")
17
39
  end
18
40
 
41
+ # Computes the diff between local and remote files.
42
+ #
43
+ # @return [Array(Array<String>, Array<String>, Array<String>)]
44
+ # a tuple of +[added, modified, removed]+, where each element
45
+ # is an array of color-coded file path strings
19
46
  def show
20
47
  server_files = Services::File::List.new(@client, nil, @detail).show
21
48
 
@@ -28,27 +55,30 @@ module NeocitiesRed
28
55
  Dir.chdir(root_path) do
29
56
  paths = Dir.glob(::File.join("**", "*"), ::File::FNM_DOTMATCH)
30
57
 
31
- local_paths = paths.reject { |path| path.start_with?(".") }
32
- local_files = local_paths.select { |path| ::File.file?(path) }.map do |path|
33
- {
34
- path: path,
35
- sha1_hash: Digest::SHA1.file(path).hexdigest
36
- }
37
- end
38
- server_paths = server_files.map { |n| n[:path] }
39
-
40
- server_file_map = server_files.to_h do |file|
58
+ server_file_entries = server_files.reject { |file| file[:is_directory] }
59
+ server_paths = server_file_entries.map { |file| file[:path] }
60
+ server_file_map = server_file_entries.to_h do |file|
41
61
  [file[:path], file[:sha1_hash]]
42
62
  end
43
63
 
64
+ local_paths = paths
65
+
44
66
  if @ignore_dotfiles
45
67
  server_paths = server_paths.reject { |path| path.start_with?(".") }
46
68
  local_paths = local_paths.reject { |path| path.start_with?(".") }
47
69
  end
48
70
 
71
+ local_files = local_paths.select { |path| ::File.file?(path) }.map do |path|
72
+ {
73
+ path: path,
74
+ sha1_hash: Digest::SHA1.file(path).hexdigest
75
+ }
76
+ end
77
+
49
78
  if @exclude.any?
50
- server_paths -= @exclude
51
- local_paths -= @exclude
79
+ normalized_exclude = @exclude.map { |path| Pathname.new(path).cleanpath.to_s }
80
+ server_paths -= normalized_exclude
81
+ local_paths -= normalized_exclude
52
82
  end
53
83
 
54
84
  removed_paths = server_paths - local_paths