loc_mods 0.2.2 → 0.2.4

Sign up to get free protection for your applications and to get access to all the features.
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