loc_mods 0.2.2 → 0.2.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66939abfdba314e93de07afbe306be58394fb4466de3d9203318e400b2a0f017
4
- data.tar.gz: 5e9ffbdd0c405ac026e77138c5287c34d21cf93c84a7d98e94e5d6e987b015a0
3
+ metadata.gz: c4e669e85342e73695662fb8685879a283bd8b166c44a180de849a1a868d5db3
4
+ data.tar.gz: 93bd3bce5771da37094b7b2f4431c733fd5024be93fbde51900ea3e76a95af1b
5
5
  SHA512:
6
- metadata.gz: 9e0cec87a093a33c3785376aaba4cbe7b05b3ee8e916c1fa238faa659668e01468b56ec08afc9478123a01adb0076e497e1f09a8dcdc4f330965aa6f82fddcc2
7
- data.tar.gz: f5ed1dc6d38e5c6afec2edd7872963433e1cc699502686acd6045198b4b37fec1d27720f7777c2f08a1a5f2234c15d98ccf2beefe2babfca3cf897007b7ee6c5
6
+ metadata.gz: 3597a8790c984fb0e4666d334d44727952e46bed0822b8c999dc4bfa3a32669ebfdf6150cc6ab4c37762b7483dfb376f716b9437e27634d594852dc10732de65
7
+ data.tar.gz: 3d44d27786cea8ddd7024065b9ea8f8c5a0b08eebf6aa93d35dc24c9575c67f21a9a3ed69887b667332fa9e20af84dd003739b6853ec41b92960bd587132a8fc
data/README.adoc CHANGED
@@ -28,15 +28,44 @@ LocMods::Collection.from_xml(File.read("reference/allrecords-MODS.xml"))
28
28
 
29
29
  === Command line interface
30
30
 
31
- LocMods provides a command-line interface (CLI) for various operations. The main
32
- executable is `loc-mods`.
31
+ LocMods provides a command-line interface (CLI) for various operations.
32
+
33
+ The main executable is `loc-mods`.
34
+
35
+ [source,shell]
36
+ ----
37
+ Commands:
38
+ loc-mods detect-duplicates PATH... # Detect duplicate records in MODS XML files or directories
39
+ loc-mods help [COMMAND] # Describe available commands or one specific command
40
+ ----
41
+
42
+
43
+
33
44
 
34
45
  ==== Detect duplicates
35
46
 
36
47
  The `detect-duplicates` command allows you to find duplicate MODS records based
37
48
  on using a "primary ID" that is their DOI (Digital Object Identifier).
38
49
 
50
+ NOTE: The library assumes that every record has a DOI. If that is not the case,
51
+ another way to setting the primary key needs to be defined.
52
+
53
+ Usage:
54
+
55
+ [source,shell]
56
+ ----
39
57
  Usage:
58
+ loc-mods detect-duplicates PATH...
59
+
60
+ Options:
61
+ [--show-unchanged], [--no-show-unchanged] # Show unchanged attributes in the diff output
62
+ # Default: false
63
+ [--highlight-diff], [--no-highlight-diff] # Highlight only the differences
64
+ # Default: false
65
+ [--color=COLOR] # Use colors in the diff output (auto, on, off)
66
+ # Default: auto
67
+ # Possible values: auto, on, off
68
+ ----
40
69
 
41
70
  [source,shell]
42
71
  ----
@@ -45,15 +74,28 @@ $ loc-mods detect-duplicates [OPTIONS] <file_or_directory_path>
45
74
 
46
75
  Options:
47
76
 
48
- * `-r, --recursive`: Search for MODS files recursively in subdirectories.
49
- * `-v, --verbose`: Display more detailed output.
50
- * `-o, --output FILE`: Write the results to a file instead of stdout.
77
+ `--show-unchanged`::
78
+ (default: `false`)
79
+ Show attributes of both objects even when they were not changed.
80
+
81
+ `--highlight-diff`::
82
+ (default: `false`)
83
+ Highlight values only when they differ between two records.
84
+
85
+ `--color=COLOR`::
86
+ (default: `auto`) Use colors in the diff output. Values:
87
+
88
+ `auto`::: the CLI will detect whether the terminal supports colors and display
89
+ with colors if it does.
90
+ `on`::: the CLI will always display with colors.
91
+ `off`::: the CLI will never display with colors.
92
+
51
93
 
52
94
  Example:
53
95
 
54
96
  [source,shell]
55
97
  ----
56
- $ loc-mods detect-duplicates -r -v /path/to/mods/files
98
+ $ loc-mods detect-duplicates /path/to/mods/files
57
99
  ----
58
100
 
59
101
  This command will:
data/exe/loc-mods CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "bundler/setup"
4
5
  require_relative "../lib/loc_mods/cli"
@@ -1,9 +1,26 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/loc_mods/base_mapper.rb
2
4
  require "shale"
3
5
  require_relative "comparable_mapper"
4
6
 
5
7
  module LocMods
8
+ # Base class for all object definitions
6
9
  class BaseMapper < Shale::Mapper
7
10
  include ComparableMapper
8
11
  end
12
+
13
+ # Nil class substitute for comparison
14
+ class ComparableNil < BaseMapper
15
+ end
16
+
17
+ # Comparison of two values for ComparableMapper
18
+ class Comparison
19
+ attr_accessor :original, :updated
20
+
21
+ def initialize(original:, updated:)
22
+ @original = original
23
+ @updated = updated
24
+ end
25
+ end
9
26
  end
data/lib/loc_mods/cli.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/loc_mods/cli.rb
2
4
  require "thor"
3
5
  require "loc_mods"
@@ -5,6 +7,10 @@ require "loc_mods"
5
7
  module LocMods
6
8
  class Cli < Thor
7
9
  desc "detect-duplicates PATH...", "Detect duplicate records in MODS XML files or directories"
10
+ method_option :show_unchanged, type: :boolean, default: false, desc: "Show unchanged attributes in the diff output"
11
+ method_option :highlight_diff, type: :boolean, default: false, desc: "Highlight only the differences"
12
+ method_option :color, type: :string, enum: %w[auto on off], default: "auto",
13
+ desc: "Use colors in the diff output (auto, on, off)"
8
14
 
9
15
  def detect_duplicates(*paths)
10
16
  all_records = []
@@ -23,33 +29,35 @@ module LocMods
23
29
  records_by_url = {}
24
30
  all_records.each do |record|
25
31
  urls = record[:record].location.flat_map { |loc| loc.url.map(&:content) }.compact
26
- if urls.any?
27
- urls.each do |url|
28
- records_by_url[url] ||= []
29
- records_by_url[url] << record
30
- end
31
- else
32
+ unless urls.any?
32
33
  puts "Warning: Record without URL found in file: #{record[:file]}"
34
+ next
35
+ end
36
+
37
+ urls.each do |url|
38
+ records_by_url[url] ||= []
39
+ records_by_url[url] << record
33
40
  end
34
41
  end
35
42
 
36
43
  duplicate_count = 0
37
44
  records_by_url.each do |url, records|
38
- if records.size > 1
39
- duplicate_count += 1
40
- puts "Duplicate set ##{duplicate_count} found for URL: #{url}"
41
- records.combination(2).each_with_index do |(record1, record2), index|
42
- puts " Comparison #{index + 1}:"
43
- puts " File 1: #{record1[:file]}"
44
- puts " File 2: #{record2[:file]}"
45
- differences = record1[:record].compare(record2[:record])
46
- if differences
47
- puts " ----"
48
- print_differences(differences)
49
- puts " ----"
50
- end
51
- puts "\n"
52
- end
45
+ next unless records.size > 1
46
+
47
+ duplicate_count += 1
48
+ puts "Duplicate set ##{duplicate_count} found for URL: #{url}"
49
+ records.combination(2).each_with_index do |(record1, record2), index|
50
+ puts " Comparison #{index + 1}:"
51
+ puts " File 1: #{record1[:file]}"
52
+ puts " File 2: #{record2[:file]}"
53
+ print_differences(
54
+ record1[:record],
55
+ record2[:record],
56
+ options[:show_unchanged],
57
+ options[:highlight_diff],
58
+ color_enabled?
59
+ )
60
+ puts "\n"
53
61
  end
54
62
  end
55
63
  end
@@ -64,38 +72,51 @@ module LocMods
64
72
  end
65
73
  end
66
74
 
67
- def print_differences(differences, prefix = "", path = [])
68
- differences.each do |key, value|
69
- current_path = path + [key]
70
- if value.is_a?(Hash) && value.keys.all? { |k| k.is_a?(Integer) }
71
- value.each do |index, sub_value|
72
- print_differences(sub_value, prefix, current_path + [index])
73
- end
74
- elsif value.is_a?(Hash) && (value[:self] || value[:other])
75
- puts " #{format_path(current_path)}:"
76
- puts " #{prefix} Record 1: #{format_value(value[:self])}"
77
- puts " #{prefix} Record 2: #{format_value(value[:other])}"
78
- puts
79
- elsif value.is_a?(Hash)
80
- print_differences(value, prefix, current_path)
81
- end
82
- end
75
+ def print_differences(record1, record2, show_unchanged, highlight_diff, use_colors)
76
+ diff_score, diff_tree = LocMods::BaseMapper.diff_with_score(
77
+ record1,
78
+ record2,
79
+ show_unchanged: show_unchanged,
80
+ highlight_diff: highlight_diff,
81
+ use_colors: use_colors,
82
+ indent: " ",
83
+ )
84
+ similarity_percentage = (1 - diff_score) * 100
85
+
86
+ puts " Differences:"
87
+ puts diff_tree
88
+ puts " Similarity score: #{similarity_percentage.round(2)}%"
83
89
  end
84
90
 
85
- def format_path(path)
86
- path.map.with_index do |part, index|
87
- if index == 0
88
- part.to_s
89
- elsif part.is_a?(Integer)
90
- "[#{part}]"
91
- else
92
- ".#{part}"
93
- end
94
- end.join
91
+ def color_enabled?
92
+ case options[:color]
93
+ when "on"
94
+ true
95
+ when "off"
96
+ false
97
+ else
98
+ supports_color?
99
+ end
95
100
  end
96
101
 
97
- def format_value(value)
98
- value.nil? ? "(nil)" : "\"#{value}\""
102
+ def supports_color?
103
+ return false unless $stdout.tty?
104
+
105
+ if RbConfig::CONFIG["host_os"] =~ /mswin|mingw|cygwin/
106
+ return true if ENV["ANSICON"]
107
+ return true if ENV["ConEmuANSI"] == "ON"
108
+ return true if ENV["TERM"] == "xterm"
109
+ end
110
+
111
+ return true if ENV["COLORTERM"]
112
+
113
+ term = ENV["TERM"]
114
+ return false if term.nil? || term.empty?
115
+
116
+ color_terms = %w[ansi color console cygwin gnome konsole kterm
117
+ linux msys putty rxvt screen tmux vt100 xterm]
118
+
119
+ color_terms.any? { |ct| term.include?(ct) }
99
120
  end
100
121
  end
101
122
  end
@@ -1,96 +1,501 @@
1
- # lib/loc_mods/comparable_mapper.rb
1
+ # frozen_string_literal: true
2
2
 
3
3
  module LocMods
4
+ # ComparableMapper module provides functionality to compare and diff two objects
5
+ # of the same class, based on their attribute values.
4
6
  module ComparableMapper
5
- # def self.included(base)
6
- # base.extend(ClassMethods)
7
- # end
7
+ def self.included(base)
8
+ base.extend(ClassMethods)
9
+ end
8
10
 
9
- # module ClassMethods
10
- # def attribute_mapping
11
- # @attribute_mapping ||= attributes.to_h do |name, attribute|
12
- # [name, attribute.type]
13
- # end
14
- # end
15
- # end
11
+ # Checks if two objects are equal based on their attributes
12
+ # @param other [Object] The object to compare with
13
+ # @return [Boolean] True if objects are equal, false otherwise
14
+ def eql?(other)
15
+ other.class == self.class &&
16
+ self.class.attributes.all? { |attr, _| send(attr) == other.send(attr) }
17
+ end
16
18
 
17
- def compare(other)
18
- return {} unless other.is_a?(self.class)
19
+ alias == eql?
19
20
 
20
- differences = {}
21
+ # Generates a hash value for the object
22
+ # @return [Integer] The hash value
23
+ def hash
24
+ ([self.class] + self.class.attributes.map { |attr, _| send(attr).hash }).hash
25
+ end
21
26
 
22
- # puts "Debugging: Attributes for #{self.class.name}"
23
- # pp self.class.attributes.keys
27
+ # Class methods added to the class that includes ComparableMapper
28
+ module ClassMethods
29
+ # Generates a diff tree between two objects of the same class
30
+ # @param obj1 [Object] The first object to compare
31
+ # @param obj2 [Object] The second object to compare
32
+ # @param options [Hash] Options for diff generation
33
+ # @return [String] A string representation of the diff tree
34
+ def diff_tree
35
+ if @obj1.nil? && @obj2.nil?
36
+ @root_tree = Tree.new("Both objects are nil")
37
+ elsif @obj1.nil?
38
+ @root_tree = Tree.new("First object is nil")
39
+ format_single_value(@obj2, @root_tree, @obj2.class.to_s)
40
+ elsif @obj2.nil?
41
+ @root_tree = Tree.new("Second object is nil")
42
+ format_single_value(@obj1, @root_tree, @obj1.class.to_s)
43
+ else
44
+ traverse_diff do |name, type, value1, value2, is_last|
45
+ format_attribute_diff(name, type, value1, value2, is_last)
46
+ end
47
+ end
24
48
 
25
- self.class.attributes.each_key do |attr|
26
- self_value = self.send(attr)
27
- other_value = other.send(attr)
49
+ @root_tree.to_s
50
+ end
28
51
 
29
- # puts "Debugging: Comparing attribute '#{attr}'"
30
- # puts " Self value: #{self_value.inspect}"
31
- # puts " Other value: #{other_value.inspect}"
52
+ # Generates a diff tree and calculates a diff score between two objects of the same class
53
+ # @param obj1 [Object] The first object to compare
54
+ # @param obj2 [Object] The second object to compare
55
+ # @param options [Hash] Options for diff generation
56
+ # @return [Array<Float, String>] An array containing the normalized diff score and the diff tree
57
+ def diff_with_score(obj1, obj2, **options)
58
+ context = DiffContext.new(obj1, obj2, **options)
59
+ indent = options[:indent] || ""
60
+ [context.calculate_diff_score, context.diff_tree(indent)]
61
+ end
62
+ end
63
+
64
+ class Tree
65
+ attr_accessor :content, :children, :color
66
+
67
+ def initialize(content, color: nil)
68
+ @content = content
69
+ @children = []
70
+ @color = color
71
+ end
72
+
73
+ def add_child(child)
74
+ @children << child
75
+ end
32
76
 
33
- compared = compare_values(self_value, other_value)
34
- if compared
35
- # puts "DETECTED DIFFERENCE! #{compared.inspect}"
36
- differences[attr] = compared
77
+ def to_s(indent = "", is_last = true)
78
+ prefix = is_last ? "└── " : "├── "
79
+ result = "#{indent}#{colorize(prefix + @content, @color)}\n"
80
+ @children.each_with_index do |child, index|
81
+ is_last_child = index == @children.size - 1
82
+ child_indent = indent + (is_last ? " " : "#{colorize("│", @color)} ")
83
+ result += child.to_s(child_indent, is_last_child)
37
84
  end
85
+ result
38
86
  end
39
87
 
40
- # unless differences.empty?
41
- # puts "DIFFERENCES ARE"
42
- # pp differences
43
- # end
88
+ private
44
89
 
45
- differences.empty? ? nil : differences
90
+ def colorize(text, color)
91
+ return text unless color
92
+
93
+ color_codes = { red: 31, green: 32, blue: 34 }
94
+ "\e[#{color_codes[color]}m#{text}\e[0m"
95
+ end
46
96
  end
47
97
 
48
- private
98
+ # DiffContext handles the comparison between two objects
99
+ class DiffContext
100
+ attr_reader :obj1, :obj2, :show_unchanged, :highlight_diff, :use_colors
101
+ attr_accessor :level, :tree_lines, :root_tree
102
+
103
+ # Initializes a new DiffContext
104
+ # @param obj1 [Object] The first object to compare
105
+ # @param obj2 [Object] The second object to compare
106
+ # @param options [Hash] Options for diff generation
107
+ def initialize(obj1, obj2, **options)
108
+ @obj1 = obj1
109
+ @obj2 = obj2
110
+ @show_unchanged = options.fetch(:show_unchanged, false)
111
+ @highlight_diff = options.fetch(:highlight_diff, false)
112
+ @use_colors = options.fetch(:use_colors, true)
113
+ @level = 0
114
+ @tree_lines = []
115
+ @root_tree = Tree.new(obj1.class.to_s)
116
+ end
49
117
 
50
- def compare_values(self_value, other_value)
51
- # puts "compare_values (self_value: #{self_value}, other_value: #{other_value})"
52
- case self_value
53
- when Array
54
- # puts "compare_values case 1"
55
- compare_arrays(self_value, other_value)
56
- when Shale::Mapper
57
- # puts "compare_values case 2"
58
- self_value.compare(other_value)
59
- else
60
- if self_value != other_value
61
- # puts "compare_values case 3"
62
- { self: self_value, other: other_value }
118
+ # Generates a diff tree between the two objects
119
+ # @return [String] A string representation of the diff tree
120
+ def diff_tree(indent = "")
121
+ traverse_diff do |name, type, value1, value2, is_last|
122
+ format_attribute_diff(name, type, value1, value2, is_last)
63
123
  end
124
+ @root_tree.to_s(indent)
64
125
  end
65
- end
66
126
 
67
- def compare_arrays(self_array, other_array)
68
- differences = {}
69
- max_length = [self_array.size, other_array.size].max
127
+ # Calculates the normalized diff score
128
+ # @return [Float] The normalized diff score
129
+ def calculate_diff_score
130
+ total_score = 0
131
+ total_attributes = 0
132
+ traverse_diff do |_, _, value1, value2, _|
133
+ total_score += calculate_attribute_score(value1, value2)
134
+ total_attributes += 1
135
+ end
136
+ total_attributes.positive? ? total_score / total_attributes : 0
137
+ end
138
+
139
+ private
140
+
141
+ # Applies color to text if colors are enabled
142
+ # @param text [String] The text to color
143
+ # @param color [Symbol] The color to apply
144
+ # @return [String] The colored text
145
+ def colorize(text, color)
146
+ return text unless @use_colors
147
+
148
+ color_codes = { red: 31, green: 32, blue: 34 }
149
+ "\e[#{color_codes[color]}m#{text}\e[0m"
150
+ end
151
+
152
+ # Traverses the attributes of the objects and yields each attribute's details
153
+ # @yield [String, Symbol, Object, Object, Boolean] Yields the name, type, value1, value2, and is_last for each attribute
154
+ def traverse_diff
155
+ return yield nil, nil, obj1, obj2, true if obj1.class != obj2.class
156
+
157
+ obj1.class.attributes.each_with_index do |(name, type), index|
158
+ yield name, type, obj1.send(name), obj2.send(name), index == obj1.class.attributes.length - 1
159
+ end
160
+ end
161
+
162
+ # Generates the prefix for tree lines
163
+ # @return [String] Prefix for tree lines
164
+ def tree_prefix
165
+ @tree_lines.map { |enabled| enabled ? "│ " : " " }.join
166
+ end
167
+
168
+ # Formats a line in the tree structure
169
+ # @param is_last [Boolean] Whether this is the last item in the current level
170
+ # @param content [String] The content to be displayed in the line
171
+ # @return [String] Formatted tree line
172
+ def tree_line(is_last, content)
173
+ "#{tree_prefix}#{is_last ? "└── " : "├── "}#{content}\n"
174
+ end
175
+
176
+ # Calculates the diff score for a single attribute
177
+ # @param value1 [Object] The value of the attribute in the first object
178
+ # @param value2 [Object] The value of the attribute in the second object
179
+ # @return [Float] The diff score for the attribute
180
+ def calculate_attribute_score(value1, value2)
181
+ if value1 == value2
182
+ 0
183
+ elsif value1.is_a?(Array) && value2.is_a?(Array)
184
+ calculate_array_score(value1, value2)
185
+ else
186
+ value1.instance_of?(value2.class) ? 0.5 : 1
187
+ end
188
+ end
189
+
190
+ # Calculates the diff score for array attributes
191
+ # @param arr1 [Array] The array from the first object
192
+ # @param arr2 [Array] The array from the second object
193
+ # @return [Float] The diff score for the arrays
194
+ def calculate_array_score(arr1, arr2)
195
+ max_length = [arr1.length, arr2.length].max
196
+ return 0.0 if max_length.zero?
197
+
198
+ total_score = max_length.times.sum do |i|
199
+ if i < arr1.length && i < arr2.length
200
+ if arr1[i] == arr2[i]
201
+ 0.0
202
+ elsif arr1[i].is_a?(ComparableMapper) && arr2[i].is_a?(ComparableMapper)
203
+ DiffContext.new(arr1[i], arr2[i], show_unchanged: @show_unchanged).calculate_diff_score
204
+ else
205
+ calculate_attribute_score(arr1[i], arr2[i])
206
+ end
207
+ else
208
+ 1.0
209
+ end
210
+ end
211
+
212
+ total_score / max_length
213
+ end
214
+
215
+ # Formats a value for display in the diff output
216
+ # @param value [Object] The value to format
217
+ # @return [String] Formatted value
218
+ def format_value(value)
219
+ case value
220
+ when nil
221
+ "(nil)"
222
+ when String
223
+ "(String) \"#{value}\""
224
+ when Array
225
+ if value.empty?
226
+ "(Array) 0 items"
227
+ else
228
+ items = value.map { |item| format_value(item) }.join(", ")
229
+ "(Array) [#{items}]"
230
+ end
231
+ when Hash
232
+ "(Hash) #{value.keys.length} keys"
233
+ when ComparableMapper
234
+ "(#{value.class})"
235
+ else
236
+ "(#{value.class}) #{value}"
237
+ end
238
+ end
239
+
240
+ # Formats the diff output for a single attribute
241
+ # @param name [String] The name of the attribute
242
+ # @param type [Symbol] The type of the attribute
243
+ # @param value1 [Object] The value of the attribute in the first object
244
+ # @param value2 [Object] The value of the attribute in the second object
245
+ # @param is_last [Boolean] Whether this is the last attribute in the list
246
+ # @return [String] Formatted diff output for the attribute
247
+ def format_attribute_diff(name, type, value1, value2, _is_last)
248
+ return if value1 == value2 && !@show_unchanged
249
+
250
+ node = Tree.new("#{name} (#{obj1.class.attributes[name].collection? ? "collection" : type_name(type)}):")
251
+ @root_tree.add_child(node)
252
+
253
+ if obj1.class.attributes[name].collection?
254
+ format_collection(value1, value2, node)
255
+ elsif value1 == value2
256
+ format_single_value(value1, node, "")
257
+ else
258
+ format_value_tree(value1, value2, node, "", type_name(type))
259
+ end
260
+ end
261
+
262
+ # Formats a collection (array) for diff output
263
+ # @param array1 [Array] The first array to compare
264
+ # @param array2 [Array] The second array to compare
265
+ # @return [String] Formatted diff output for the collection
266
+ def format_collection(array1, array2, parent_node)
267
+ array2 = [] if array2.nil?
268
+ max_length = [array1.length, array2.length].max
269
+
270
+ if max_length.zero?
271
+ parent_node.content += " (nil)"
272
+ return
273
+ end
274
+
275
+ max_length.times do |index|
276
+ item1 = array1[index]
277
+ item2 = array2[index]
278
+
279
+ next if item1 == item2 && !@show_unchanged
70
280
 
71
- max_length.times do |index|
72
- self_item = self_array[index]
73
- other_item = other_array[index]
281
+ prefix = item2.nil? ? "- " : (item1.nil? ? "+ " : "")
282
+ color = item2.nil? ? :red : (item1.nil? ? :green : nil)
283
+ type = item1&.class || item2&.class
284
+
285
+ node = Tree.new("#{prefix}[#{index + 1}] (#{type_name(type)})", color: color)
286
+ parent_node.add_child(node)
287
+
288
+ if item1.nil?
289
+ format_diff_item(item2, :green, node)
290
+ elsif item2.nil?
291
+ format_diff_item(item1, :red, node)
292
+ else
293
+ format_value_tree(item1, item2, node, "")
294
+ end
295
+ end
296
+ end
297
+
298
+ # Formats a removed item in the diff output
299
+ # @param item [Object] The removed item
300
+ # @param is_last [Boolean] Whether this is the last item in the current level
301
+ # @param index [Integer] The index of the removed item
302
+ # @return [String] Formatted output for the removed item
303
+ def format_removed_item(item, _parent_node)
304
+ format_diff_item(item, :red)
305
+ end
74
306
 
75
- if index >= self_array.size
76
- compared = compare_values(other_item, nil)
77
- differences[index] = { self: compared[:other], other: compared[:self] }
78
- elsif index >= other_array.size
79
- differences[index] = compare_values(self_item, nil)
307
+ # Formats an added item in the diff output
308
+ # @param item [Object] The added item
309
+ # @param is_last [Boolean] Whether this is the last item in the current level
310
+ # @param index [Integer] The index of the added item
311
+ # @return [String] Formatted output for the added item
312
+ def format_added_item(item, _parent_node)
313
+ format_diff_item(item, :green)
314
+ end
315
+
316
+ # Formats a diff item (added or removed)
317
+ # @param item [Object] The item to format
318
+ # @param is_last [Boolean] Whether this is the last item in the current level
319
+ # @param index [Integer] The index of the item
320
+ # @param color [Symbol] The color to use for the item
321
+ # @param prefix [String] The prefix to use for the item (+ or -)
322
+ # @return [String] Formatted output for the diff item
323
+ def format_diff_item(item, color, parent_node)
324
+ if item.is_a?(ComparableMapper)
325
+ return format_comparable_mapper(item, parent_node, color)
326
+ end
327
+
328
+ parent_node.add_child(Tree.new(format_value(item), color: color))
329
+ end
330
+
331
+ # Formats the content of an object for diff output
332
+ # @param obj [Object] The object to format
333
+ # @return [String] Formatted content of the object
334
+ def format_object_content(obj)
335
+ return format_value(obj) unless obj.is_a?(ComparableMapper)
336
+
337
+ obj.class.attributes.map do |attr, _|
338
+ "#{attr}: #{format_value(obj.send(attr))}"
339
+ end.join("\n")
340
+ end
341
+
342
+ # Formats and colors the content for diff output
343
+ # @param content [String] The content to format and color
344
+ # @param color [Symbol] The color to apply
345
+ # @param is_last [Boolean] Whether this is the last item in the current level
346
+ # @return [String] Formatted and colored content
347
+ def format_colored_content(content, color, is_last)
348
+ lines = content.split("\n")
349
+ lines.map.with_index do |line, index|
350
+ if index.zero?
351
+ "" # Skip the first line as it's already been output
352
+ else
353
+ prefix = index == lines.length - 1 && is_last ? "└── " : "├── "
354
+ tree_line(index == lines.length - 1 && is_last, colorize("#{prefix}#{line}", color))
355
+ end
356
+ end.join
357
+ end
358
+
359
+ # Gets the name of a type
360
+ # @param type [Class, Object] The type to get the name for
361
+ # @return [String] The name of the type
362
+ def type_name(type)
363
+ if type.is_a?(Class)
364
+ type.name
365
+ elsif type.respond_to?(:type)
366
+ type.type.name
80
367
  else
81
- compared = compare_values(self_item, other_item)
82
- differences[index] = compared if compared
368
+ type.class.name
83
369
  end
84
370
  end
85
371
 
86
- if self_array.size != other_array.size
87
- differences[:array_size_difference] = {
88
- self: self_array.size,
89
- other: other_array.size,
90
- }
372
+ # Formats the attributes of an object for diff output
373
+ # @param obj1 [Object] The first object
374
+ # @param obj2 [Object] The second object
375
+ # @return [String] Formatted attributes of the objects
376
+ def format_object_attributes(obj1, obj2, parent_node)
377
+ obj1.class.attributes.each_key do |attr|
378
+ value1 = obj1.send(attr)
379
+ value2 = obj2&.send(attr)
380
+
381
+ attr_type = obj1.class.attributes[attr].collection? ? "collection" : type_name(obj1.class.attributes[attr])
382
+
383
+ if value1 == value2
384
+ format_single_value(value1, parent_node, "#{attr} (#{attr_type})") if @show_unchanged
385
+ else
386
+ format_value_tree(value1, value2, parent_node, attr, attr_type)
387
+ end
388
+ end
389
+ end
390
+
391
+ # Formats the value tree for diff output
392
+ # @param value1 [Object] The first value
393
+ # @param value2 [Object] The second value
394
+ # @param is_last [Boolean] Whether this is the last item in the current level
395
+ # @param label [String] The label for the value
396
+ # @param type_info [String, nil] Additional type information
397
+ # @return [String] Formatted value tree
398
+ def format_value_tree(value1, value2, parent_node, label, type_info = nil)
399
+ return if value1 == value2 && !@show_unchanged
400
+
401
+ if value1 == value2
402
+ if @show_unchanged
403
+ return format_single_value(
404
+ value1,
405
+ parent_node,
406
+ "#{label}#{type_info ? " (#{type_info})" : ""}"
407
+ )
408
+ end
409
+
410
+ return if @highlight_diff
411
+ end
412
+
413
+ case value1
414
+ when Array
415
+ format_collection(value1, value2, parent_node)
416
+ when Hash
417
+ format_hash_tree(value1, value2, parent_node)
418
+ when ComparableMapper
419
+ format_object_attributes(value1, value2, parent_node)
420
+ else
421
+ node = Tree.new("#{label}#{type_info ? " (#{type_info})" : ""}:")
422
+ parent_node.add_child(node)
423
+ node.add_child(Tree.new("- #{format_value(value1)}", color: :red))
424
+ node.add_child(Tree.new("+ #{format_value(value2)}", color: :green))
425
+ end
426
+ end
427
+
428
+ # Formats a single value for diff output
429
+ # @param value [Object] The value to format
430
+ # @param is_last [Boolean] Whether this is the last item in the current level
431
+ # @param label [String] The label for the value
432
+ # @return [String] Formatted single value
433
+ def format_single_value(value, parent_node, label, color = nil)
434
+ node = Tree.new("#{label}#{label.empty? ? "" : ":"}", color: color)
435
+ parent_node.add_child(node)
436
+
437
+ case value
438
+ when ComparableMapper
439
+ format_comparable_mapper(value, node, color)
440
+ when Array
441
+ if value.empty?
442
+ node.add_child(Tree.new("(nil)", color: color))
443
+ else
444
+ format_collection(value, value, node)
445
+ end
446
+ else
447
+ node.content += " #{format_value(value)}"
448
+ end
449
+ end
450
+
451
+ # Formats a ComparableMapper object for diff output
452
+ # @param obj [ComparableMapper] The object to format
453
+ # @return [String] Formatted ComparableMapper object
454
+ def format_comparable_mapper(obj, parent_node, color = nil)
455
+ obj.class.attributes.each do |attr_name, attr_type|
456
+ attr_value = obj.send(attr_name)
457
+ attr_node = Tree.new("#{attr_name} (#{type_name(attr_type)}):", color: color)
458
+ parent_node.add_child(attr_node)
459
+ if attr_value.is_a?(ComparableMapper)
460
+ format_comparable_mapper(attr_value, attr_node, color)
461
+ else
462
+ value_node = Tree.new(format_value(attr_value), color: color)
463
+ attr_node.add_child(value_node)
464
+ end
465
+ end
91
466
  end
92
467
 
93
- differences.empty? ? nil : differences
468
+ # Formats a hash tree for diff output
469
+ # @param hash1 [Hash] The first hash to compare
470
+ # @param hash2 [Hash] The second hash to compare
471
+ # @return [String] Formatted hash tree
472
+ def format_hash_tree(hash1, hash2, parent_node)
473
+ keys = (hash1.keys + hash2.keys).uniq
474
+ keys.each do |key|
475
+ value1 = hash1[key]
476
+ value2 = hash2[key]
477
+
478
+ if value1 == value2
479
+ format_single_value(value1, parent_node, key) if @show_unchanged
480
+ else
481
+ format_value_tree(value1, value2, parent_node, key)
482
+ end
483
+ end
484
+ end
485
+ end
486
+ end
487
+
488
+ # Generates a tree representation of the object
489
+ # @return [String] A string representation of the object's attribute tree
490
+ def to_tree
491
+ output = "#{self.class}\n"
492
+ self.class.attributes.each_with_index do |(name, type), index|
493
+ value = send(name)
494
+ is_last = index == self.class.attributes.length - 1
495
+ context = DiffContext.new(nil, nil, show_unchanged: false)
496
+ formatted = context.format_value(value)
497
+ output << context.tree_line(is_last, "#{name} (#{type}): #{formatted}")
94
498
  end
499
+ output
95
500
  end
96
501
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LocMods
4
- VERSION = "0.2.2"
4
+ VERSION = "0.2.4"
5
5
  end
@@ -1863916,4 +1863916,4 @@
1863916
1863916
  </languageOfCataloging>
1863917
1863917
  </recordInfo>
1863918
1863918
  </mods>
1863919
- </modsCollection>
1863919
+ </modsCollection>
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: loc_mods
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-07-13 00:00:00.000000000 Z
11
+ date: 2024-08-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri