loc_mods 0.2.3 → 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: 2b206d76220e77a18fd6609c3f7646eaf8fb836ac2d69a7d6da019db5820f24d
4
- data.tar.gz: ffd23bb7508ad1fef1e0b165eb07620fc0a2420b6c436ac34cec8208affb6463
3
+ metadata.gz: c4e669e85342e73695662fb8685879a283bd8b166c44a180de849a1a868d5db3
4
+ data.tar.gz: 93bd3bce5771da37094b7b2f4431c733fd5024be93fbde51900ea3e76a95af1b
5
5
  SHA512:
6
- metadata.gz: 9f56bbb4bfdab2dd2820ef67d94461ac5caa47d62937762a5cbeacbea8e70497c75968d92b3ce23f5d7e79f6a737c98c43058d6e11b774d0777340f040808fea
7
- data.tar.gz: 0ac7feedcebe5df178f6ad313ba811004de126c82adc0f5289f44b43212a26ef2acfa3f09a45afa9cd4b158b7b4ef97976413ac357b4eb11749ab227c801a168
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/lib/loc_mods/cli.rb CHANGED
@@ -7,6 +7,10 @@ require "loc_mods"
7
7
  module LocMods
8
8
  class Cli < Thor
9
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)"
10
14
 
11
15
  def detect_duplicates(*paths)
12
16
  all_records = []
@@ -46,12 +50,13 @@ module LocMods
46
50
  puts " Comparison #{index + 1}:"
47
51
  puts " File 1: #{record1[:file]}"
48
52
  puts " File 2: #{record2[:file]}"
49
- differences = record1[:record].compare(record2[:record])
50
- if differences
51
- puts " ----"
52
- print_differences(differences)
53
- puts " ----"
54
- end
53
+ print_differences(
54
+ record1[:record],
55
+ record2[:record],
56
+ options[:show_unchanged],
57
+ options[:highlight_diff],
58
+ color_enabled?
59
+ )
55
60
  puts "\n"
56
61
  end
57
62
  end
@@ -67,87 +72,51 @@ module LocMods
67
72
  end
68
73
  end
69
74
 
70
- def print_differences(differences, prefix = "", path = [])
71
- if differences.is_a?(Comparison)
72
- print_difference(differences, path)
73
- return
74
- end
75
-
76
- # Differences is a Hash here
77
- differences.each do |key, value|
78
- # puts "key #{key}, value #{value}"
79
- current_path = path + [key]
80
- # This is a comparison
81
- if value.is_a?(Comparison)
82
- print_difference(value, path)
83
- next
84
- 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)}%"
89
+ end
85
90
 
86
- raise "Differences must be in form of a Hash" unless value.is_a?(Hash)
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
100
+ end
87
101
 
88
- # This is not array, end here
89
- next unless value.keys.any? do |k|
90
- k.is_a?(Integer) || k == :_array_size_difference
91
- end
102
+ def supports_color?
103
+ return false unless $stdout.tty?
92
104
 
93
- # if value[:_array_size_difference]
94
- # puts " #{format_path(current_path)} [_array_size_difference]:"
95
- # puts " Record 1: #{format_value(value[:_array_size_difference].original)}"
96
- # puts " Record 2: #{format_value(value[:_array_size_difference].updated)}"
97
- # puts
98
- # end
99
-
100
- value.each do |subkey, subvalue|
101
- # if [:self, :other].include?(subkey)
102
- # raise "Subkey is self or other"
103
- # end
104
- # puts "subkey (#{subkey})"
105
- # puts "subvalue #{subvalue}, prefix #{prefix}, current_path + [subkey] #{current_path + [subkey]}"
106
-
107
- if subkey.is_a?(Integer)
108
- print_differences(subvalue, prefix, current_path + [subkey])
109
- else
110
- if subvalue.is_a?(Comparison)
111
- print_difference(subvalue, current_path + [subkey])
112
- next
113
- end
114
-
115
- raise "In an array diff but not an Integer!"
116
- end
117
- end
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"
118
109
  end
119
- end
120
110
 
121
- def print_difference(value, current_path)
122
- return unless value.original || value.updated
111
+ return true if ENV["COLORTERM"]
123
112
 
124
- puts " #{format_path(current_path)}:"
125
- puts " Record 1: #{format_value(value.original)}"
126
- puts " Record 2: #{format_value(value.updated)}"
127
- puts
128
- end
113
+ term = ENV["TERM"]
114
+ return false if term.nil? || term.empty?
129
115
 
130
- def format_path(path)
131
- path.map.with_index do |part, index|
132
- if index.zero?
133
- part.to_s
134
- elsif part.is_a?(Integer)
135
- "[#{part}]"
136
- else
137
- ".#{part}"
138
- end
139
- end.join
140
- end
116
+ color_terms = %w[ansi color console cygwin gnome konsole kterm
117
+ linux msys putty rxvt screen tmux vt100 xterm]
141
118
 
142
- def format_value(value)
143
- case value
144
- when nil, ComparableNil
145
- "(nil)"
146
- when String
147
- "\"#{value}\""
148
- else
149
- value.to_s
150
- end
119
+ color_terms.any? { |ct| term.include?(ct) }
151
120
  end
152
121
  end
153
122
  end
@@ -1,110 +1,501 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/loc_mods/comparable_mapper.rb
4
-
5
3
  module LocMods
6
- # Enable comparison of two class models solely based on attribute values in
7
- # a recursive manner.
4
+ # ComparableMapper module provides functionality to compare and diff two objects
5
+ # of the same class, based on their attribute values.
8
6
  module ComparableMapper
9
- # TODO: Implement Comparable
10
- # include Comparable
11
- # def <=>(other)
12
- # attributes.foo <=> other.attributes.foo
13
- # end
14
- # def inspect
15
- # @foo
16
- # end
7
+ def self.included(base)
8
+ base.extend(ClassMethods)
9
+ end
17
10
 
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
18
14
  def eql?(other)
19
15
  other.class == self.class &&
20
- self.class.attributes.all? do |attr|
21
- send(attr) == other.send(attr)
22
- end
16
+ self.class.attributes.all? { |attr, _| send(attr) == other.send(attr) }
23
17
  end
24
18
 
25
19
  alias == eql?
26
20
 
21
+ # Generates a hash value for the object
22
+ # @return [Integer] The hash value
27
23
  def hash
28
- ([self.class] + self.class.attributes.map(&:hash)).hash
24
+ ([self.class] + self.class.attributes.map { |attr, _| send(attr).hash }).hash
29
25
  end
30
26
 
31
- def compare(other)
32
- differences = {}
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
48
+
49
+ @root_tree.to_s
50
+ end
51
+
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
33
63
 
34
- # puts "Debugging: Attributes for #{self.class.name}"
35
- # pp self.class.attributes.keys
64
+ class Tree
65
+ attr_accessor :content, :children, :color
36
66
 
37
- self.class.attributes.each_key do |attr|
38
- self_value = respond_to?(attr) ? send(attr) : nil
39
- other_value = other.respond_to?(attr) ? other.send(attr) : nil
67
+ def initialize(content, color: nil)
68
+ @content = content
69
+ @children = []
70
+ @color = color
71
+ end
40
72
 
41
- # puts "Debugging: Comparing attribute '#{attr}'"
42
- # puts " Self value: #{self_value.inspect}"
43
- # puts " Other value: #{other_value.inspect}"
73
+ def add_child(child)
74
+ @children << child
75
+ end
44
76
 
45
- compared = compare_values(self_value, other_value)
46
- if compared
47
- # puts "DETECTED DIFFERENCE! #{compared.inspect}"
48
- 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)
49
84
  end
85
+ result
50
86
  end
51
87
 
52
- # unless differences.empty?
53
- # puts "DIFFERENCES ARE"
54
- # pp differences
55
- # end
88
+ private
89
+
90
+ def colorize(text, color)
91
+ return text unless color
56
92
 
57
- differences.empty? ? nil : differences
93
+ color_codes = { red: 31, green: 32, blue: 34 }
94
+ "\e[#{color_codes[color]}m#{text}\e[0m"
95
+ end
58
96
  end
59
97
 
60
- 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
61
117
 
62
- def compare_values(self_value, other_value)
63
- # puts "compare_values (self_value: #{self_value}, other_value: #{other_value})"
64
- case self_value
65
- when Array
66
- # puts "compare_values case 1"
67
- compare_arrays(self_value, other_value || [])
68
- when ComparableMapper
69
- # puts "compare_values case 2"
70
- self_value.compare(other_value)
71
- else
72
- if self_value != other_value
73
- # puts "compare_values case 3"
74
- Comparison.new(original: self_value, updated: 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)
75
123
  end
124
+ @root_tree.to_s(indent)
125
+ end
126
+
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"
76
150
  end
77
- end
78
151
 
79
- def compare_arrays(self_array, other_array)
80
- differences = {}
81
- max_length = [self_array.size, other_array.size].max
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
82
156
 
83
- max_length.times do |index|
84
- self_item = self_array[index] || ComparableNil.new
85
- other_item = other_array[index] || ComparableNil.new
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
86
175
 
87
- compared = compare_values(self_item, other_item)
88
- differences[index] = compared if compared
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?
89
197
 
90
- # if index >= self_array.size
91
- # puts "case 1 #{compared}"
92
- # # differences[index] = compared
93
- # elsif index >= other_array.size
94
- # puts "case 2.1 #{self_item}"
95
- # puts "case 2.2 #{compared}"
96
- # # differences[index] = compared
97
- # end
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
98
213
  end
99
214
 
100
- if self_array.size != other_array.size
101
- differences[:_array_size_difference] = Comparison.new(
102
- original: self_array.size,
103
- updated: other_array.size
104
- )
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
105
238
  end
106
239
 
107
- differences.empty? ? nil : differences
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
280
+
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
306
+
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
367
+ else
368
+ type.class.name
369
+ end
370
+ end
371
+
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
466
+ end
467
+
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}")
108
498
  end
499
+ output
109
500
  end
110
501
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LocMods
4
- VERSION = "0.2.3"
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.3
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-14 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