terrestrial-cli 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rspec +1 -0
  4. data/.ruby-gemset +1 -0
  5. data/.ruby-version +1 -0
  6. data/Gemfile +4 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +134 -0
  9. data/Rakefile +6 -0
  10. data/bin/console +14 -0
  11. data/bin/setup +7 -0
  12. data/bin/terrestrial +44 -0
  13. data/circle.yml +11 -0
  14. data/lib/terrestrial/cli/android_xml_formatter.rb +43 -0
  15. data/lib/terrestrial/cli/android_xml_parser.rb +49 -0
  16. data/lib/terrestrial/cli/bootstrapper.rb +184 -0
  17. data/lib/terrestrial/cli/command.rb +20 -0
  18. data/lib/terrestrial/cli/detects_project_type.rb +16 -0
  19. data/lib/terrestrial/cli/dot_strings_formatter.rb +53 -0
  20. data/lib/terrestrial/cli/dot_strings_parser.rb +139 -0
  21. data/lib/terrestrial/cli/editor/android_xml.rb +64 -0
  22. data/lib/terrestrial/cli/editor/base_editor.rb +36 -0
  23. data/lib/terrestrial/cli/editor/objc.rb +66 -0
  24. data/lib/terrestrial/cli/editor/printer.rb +47 -0
  25. data/lib/terrestrial/cli/editor/storyboard.rb +98 -0
  26. data/lib/terrestrial/cli/editor/swift.rb +92 -0
  27. data/lib/terrestrial/cli/editor.rb +42 -0
  28. data/lib/terrestrial/cli/engine_mapper.rb +30 -0
  29. data/lib/terrestrial/cli/entry_collection_differ.rb +22 -0
  30. data/lib/terrestrial/cli/file_finder.rb +65 -0
  31. data/lib/terrestrial/cli/file_picker.rb +58 -0
  32. data/lib/terrestrial/cli/flight/ios_workflow.rb +81 -0
  33. data/lib/terrestrial/cli/flight/table_workflow.rb +77 -0
  34. data/lib/terrestrial/cli/flight.rb +93 -0
  35. data/lib/terrestrial/cli/ignite.rb +73 -0
  36. data/lib/terrestrial/cli/init.rb +133 -0
  37. data/lib/terrestrial/cli/mixpanel_client.rb +56 -0
  38. data/lib/terrestrial/cli/parser/android_xml.rb +82 -0
  39. data/lib/terrestrial/cli/parser/base_parser.rb +42 -0
  40. data/lib/terrestrial/cli/parser/objc.rb +127 -0
  41. data/lib/terrestrial/cli/parser/storyboard.rb +166 -0
  42. data/lib/terrestrial/cli/parser/string_analyser.rb +115 -0
  43. data/lib/terrestrial/cli/parser/swift.rb +102 -0
  44. data/lib/terrestrial/cli/parser.rb +25 -0
  45. data/lib/terrestrial/cli/photoshoot.rb +65 -0
  46. data/lib/terrestrial/cli/pull.rb +110 -0
  47. data/lib/terrestrial/cli/push.rb +40 -0
  48. data/lib/terrestrial/cli/scan.rb +72 -0
  49. data/lib/terrestrial/cli/string_registry.rb +30 -0
  50. data/lib/terrestrial/cli/terminal_ui.rb +25 -0
  51. data/lib/terrestrial/cli/variable_normalizer.rb +34 -0
  52. data/lib/terrestrial/cli/version.rb +5 -0
  53. data/lib/terrestrial/cli.rb +82 -0
  54. data/lib/terrestrial/config.rb +99 -0
  55. data/lib/terrestrial/creates_terrestrial_yml.rb +9 -0
  56. data/lib/terrestrial/web/response.rb +17 -0
  57. data/lib/terrestrial/web.rb +78 -0
  58. data/lib/terrestrial/yaml_helper.rb +48 -0
  59. data/lib/terrestrial.rb +7 -0
  60. data/terrestrial-cli.gemspec +29 -0
  61. metadata +188 -0
@@ -0,0 +1,139 @@
1
+ module Terrestrial
2
+ module Cli
3
+ module DotStringsParser
4
+ class << self
5
+
6
+ def parse_file(path)
7
+ entries = do_parse_file read_file_with_correct_encoding(path)
8
+ entries.each do |entry|
9
+ entry["type"] = "localizable.strings"
10
+ entry["file"] = path
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def do_parse_file(contents)
17
+ results = []
18
+
19
+ multiline_comment = false
20
+ expecting_string = false
21
+ multiline_string = false
22
+ current = {}
23
+
24
+ contents.split("\n").each do |line|
25
+ line = line.rstrip
26
+ line = remove_comments(line) unless multiline_string
27
+
28
+ if !multiline_string && !multiline_comment && line == ""
29
+ # Just empty line between entries
30
+ next
31
+ elsif line.start_with?("\"") && !line.end_with?(";")
32
+ # Start multiline string"
33
+ current_id = line.split("=").map(&:strip)[0][1..-1][0..-2]
34
+ current_string = line.split("=").map(&:strip)[1][1..-1]
35
+
36
+ current["identifier"] = current_id
37
+ current["string"] = current_string unless current_string.empty?
38
+ multiline_string = true
39
+ elsif multiline_string && !line.end_with?(";")
40
+ # Continuing multiline string
41
+ if current["string"].nil?
42
+ current["string"] = line.lstrip
43
+ else
44
+ current["string"] << "\n" + line
45
+ end
46
+ elsif multiline_string && line.end_with?(";")
47
+ # Ending multiline string
48
+ current["string"] << "\n#{line[0..-3]}"
49
+ multiline_string = false
50
+ results << current
51
+ current = {}
52
+ elsif !expecting_string && line.lstrip.start_with?("/*") && !line.end_with?("*/")
53
+ # Start multline comment
54
+ tmp_content = line[2..-1].strip
55
+ current["context"] = "\n" + tmp_content unless tmp_content.empty?
56
+ multiline_comment = true
57
+ elsif multiline_comment && !line.end_with?("*/")
58
+ # Continuing multline comment
59
+ if current["context"].nil?
60
+ current["context"] = line.lstrip
61
+ else
62
+ current["context"] << "\n" + line
63
+ end
64
+ elsif multiline_comment && line.end_with?("*/")
65
+ # Ending multline comment
66
+ tmp_content = line[0..-3].strip
67
+ current["context"] << (tmp_content.empty? ? "" : "\n#{tmp_content}")
68
+ multiline_comment = false
69
+ expecting_string = true
70
+ elsif !expecting_string && line.start_with?("/*") && line.end_with?("*/")
71
+ # Single line comment
72
+ current["context"] = line[2..-1][0..-3].strip
73
+ expecting_string = true
74
+ elsif expecting_string && line.end_with?(";")
75
+ # Single line id/string pair after a comment
76
+ current_id, current_string = get_string_and_id(line)
77
+ current["identifier"] = current_id
78
+ current["string"] = current_string
79
+
80
+ expecting_string = false
81
+ results << current
82
+ current = {}
83
+ elsif !expecting_string && line.end_with?(";")
84
+ # id/string without comment first
85
+ current_id, current_string = get_string_and_id(line)
86
+ current["identifier"] = current_id
87
+ current["string"] = current_string
88
+
89
+ results << current
90
+ current = {}
91
+ else
92
+ raise "Don't know what to do with '#{line.inspect}'"
93
+ end
94
+ end
95
+ results
96
+ end
97
+
98
+ def get_string_and_id(line)
99
+ id = line.split("=").map(&:strip)[0].gsub("\"", "")
100
+ string = line.split("=").map(&:strip)[1].gsub("\"", "")[0..-2]
101
+ [id, string]
102
+ end
103
+
104
+ def read_file_with_correct_encoding(path)
105
+ # Genstrings creates files with BOM UTF-16LE encoding.
106
+ # If we realise that we cannot operate on the content
107
+ # of the file assumin UTF-8, we try UTF-16!
108
+
109
+ content = File.read path
110
+ begin
111
+ # Try performing an operation on the content
112
+ content.split("\n")
113
+ rescue ArgumentError
114
+ # Failure! We think this is a UTF-16 file
115
+
116
+ # Remove the byte order marker from the beginning
117
+ # of the file. We tried doing this with a simple
118
+ # sub! of \xFF\xFE, but we kept running into
119
+ # more issues. We instead do it manually.
120
+ content = content.bytes[2..-1].pack('c*')
121
+
122
+ # Force UTF-16LE encoding as a setting (not actually
123
+ # changing any representations yet!), then encode from
124
+ # that to UTF-8 again
125
+ content = content
126
+ .force_encoding(Encoding::UTF_16LE)
127
+ .encode!(Encoding::UTF_8)
128
+ end
129
+ content
130
+ end
131
+
132
+ def remove_comments(line)
133
+ line = line.split("//")[0] || ""
134
+ line.rstrip
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,64 @@
1
+ module Terrestrial
2
+ module Cli
3
+ module Editor
4
+ class AndroidXML < BaseEditor
5
+
6
+ def self.find_and_edit_line(string_entry)
7
+ self.new(string_entry).add_attributes
8
+ end
9
+
10
+ def self.add_import(file)
11
+ # Not needed
12
+ end
13
+
14
+ def initialize(entry)
15
+ @path = entry.file
16
+ @type = entry.type
17
+ @string = entry.string
18
+ @identifier = entry.identifier
19
+
20
+ @document = REXML::Document.new(File.new(@path))
21
+ @document.context[:attribute_quote] = :quote
22
+ end
23
+
24
+ def add_attributes
25
+ node = find_node(@identifier)
26
+ node.add_attribute("terrestrial", true)
27
+ refresh_document(node)
28
+ save_document
29
+ end
30
+
31
+ def find_node(name)
32
+ REXML::XPath.first(@document, "//resources/string[@name=\"#{name}\"]")
33
+ end
34
+
35
+ def refresh_document(node)
36
+ # This is a bit ridiculous, but necessary because
37
+ # &*£(*$)£@*$!£ REXML
38
+
39
+ @document = REXML::Document.new(node.document.to_s)
40
+ end
41
+
42
+ def save_document
43
+ # AAAAAAAARARARARARARRARAAAGH REXML STAAAAHP
44
+ # You can't make REXML print attributes inside double
45
+ # quotes without monkey patching >.<
46
+ #
47
+ # ...seriously?
48
+
49
+ REXML::Attribute.class_eval( %q^
50
+ def to_string
51
+ %Q[#@expanded_name="#{to_s().gsub(/"/, '&quot;')}"]
52
+ end
53
+ ^)
54
+ File.open(@path, "w") do |f|
55
+ printer = Printer.new(2)
56
+ printer.compact = true
57
+ printer.width = 1000000
58
+ printer.write(@document, f)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,36 @@
1
+ require 'tempfile'
2
+
3
+ module Terrestrial
4
+ module Cli
5
+ module Editor
6
+ class BaseEditor
7
+
8
+ def self.find_and_edit_line(string_entry)
9
+ raise "Not implemented"
10
+ end
11
+
12
+ def self.add_import(file)
13
+ raise "Not implemented"
14
+ end
15
+
16
+ def self.edit_file(path)
17
+ temp_file = Tempfile.new(File.basename(path))
18
+ begin
19
+ line_number = 1
20
+ File.open(path, 'r') do |file|
21
+ file.each_line do |line|
22
+ yield line, line_number, temp_file
23
+ line_number += 1
24
+ end
25
+ end
26
+ temp_file.close
27
+ FileUtils.mv(temp_file.path, path)
28
+ ensure
29
+ temp_file.close
30
+ temp_file.unlink
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,66 @@
1
+ module Terrestrial
2
+ module Cli
3
+ module Editor
4
+ class ObjC < BaseEditor
5
+
6
+ def self.find_and_edit_line(new_string)
7
+ edit_file(new_string.file) do |line, line_number, file|
8
+ if line_number == new_string.line_number
9
+ file.puts do_edit_string(line, new_string)
10
+ else
11
+ file.puts line
12
+ end
13
+ end
14
+ end
15
+
16
+ def self.do_edit_string(line, entry)
17
+ line.gsub(a_string_not_followed_by_translated(entry.string), "@\"#{entry.identifier}\".translated")
18
+ end
19
+
20
+ def self.add_import(file)
21
+ # Adds #import "Terrestrial.h" as the last import
22
+ # statement at the top of the file.
23
+ #
24
+ # It goes through the file from top to bottom looking for the first import
25
+ # statement. After it finds it, it will look for the first line without
26
+ # an import statement. When it finds it, it will write the import line,
27
+ # and all following lines are just copied over.
28
+
29
+ found_first_import = false
30
+ imported = false
31
+
32
+ edit_file(file) do |line, line_number, file|
33
+ if !found_first_import && line.start_with?("#import ")
34
+ found_first_import = true
35
+ file.puts line
36
+ elsif line.start_with?("#import <Terrestrial/Terrestrial.h>")
37
+ imported = true
38
+ file.puts line
39
+ elsif !imported && found_first_import && !line.start_with?("#import ")
40
+ file.puts "#import <Terrestrial/Terrestrial.h>"
41
+ file.puts ""
42
+ imported = true
43
+ else
44
+ file.puts line
45
+ end
46
+ end
47
+ end
48
+
49
+ def self.a_string_not_followed_by_translated(string)
50
+ # Does not match:
51
+ #
52
+ # @"foo".translated
53
+ # or
54
+ # @"foo" translatedWithContext
55
+ #
56
+ # (?!(\.|\s)translated) means don't match either
57
+ # period or single whitepspace character,
58
+ # followed by translated
59
+
60
+
61
+ /@"#{string}"(?!(\.|\s)translated)/
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,47 @@
1
+ # Common fixes for editors that use
2
+ # REXML
3
+ class Printer < REXML::Formatters::Pretty
4
+ # The point of this little thing is to keep
5
+ # the order of attributes when we flush the
6
+ # storyboard back to disk.
7
+ # We do this to avoid massive git diffs.
8
+ # Turns out that xcode is quite good at updating
9
+ # and reverting our formating changes though.
10
+ #
11
+ # Source:
12
+ # http://stackoverflow.com/questions/574724/rexml-preserve-attributes-order
13
+ #
14
+ # fmt = OrderedAttributes.new
15
+ # fmt.write(xmldoc, $stdout)
16
+ #
17
+ def write_element(elm, out)
18
+ att = elm.attributes
19
+ class <<att
20
+ alias _each_attribute each_attribute
21
+
22
+ def each_attribute(&b)
23
+ to_enum(:_each_attribute).sort_by {|x| x.name}.each(&b)
24
+ end
25
+ end
26
+ super(elm, out)
27
+ end
28
+
29
+ # This genious is here to stop the damn thing from wrapping
30
+ # lines over 80 chars long >.<
31
+ #
32
+ # Source:
33
+ # http://stackoverflow.com/questions/4203180/rexml-is-wrapping-long-lines-how-do-i-switch-that-off
34
+ #
35
+ def write_text( node, output )
36
+ s = node.to_s()
37
+ s.gsub!(/\s/,' ')
38
+ s.squeeze!(" ")
39
+
40
+ #The Pretty formatter code mistakenly used 80 instead of the @width variable
41
+ #s = wrap(s, 80-@level)
42
+ s = wrap(s, @width-@level)
43
+
44
+ s = indent_text(s, @level, " ", true)
45
+ output << (' '*@level + s)
46
+ end
47
+ end
@@ -0,0 +1,98 @@
1
+ module Terrestrial
2
+ module Cli
3
+ module Editor
4
+ class Storyboard < BaseEditor
5
+
6
+ QUERIES = {
7
+ "storyboard-label" => "//label",
8
+ "storyboard-text-field" => "//textField",
9
+ "storyboard-button" => "//button",
10
+ "storyboard-bar-button-item" => "//barButtonItem",
11
+ "storyboard-navbar-item" => "//navigationItem",
12
+ "storyboard-text-view" => "//textView"
13
+ }
14
+
15
+ def self.find_and_edit_line(approved_string)
16
+ insert_runtime_attribute(approved_string)
17
+ end
18
+
19
+
20
+ def self.add_import(file)
21
+ # Not needed
22
+ # Override parent class implementation
23
+ end
24
+
25
+ def initialize(entry)
26
+ @path = entry.file
27
+ @type = entry.type
28
+ @string = entry.string
29
+ @storyboard_id = entry.metadata["storyboard_element_id"]
30
+ @identifier = entry.identifier
31
+
32
+ @document = REXML::Document.new(File.new(@path))
33
+ end
34
+
35
+ def self.insert_runtime_attribute(entry)
36
+ self.new(entry).insert_attribute
37
+ end
38
+
39
+ def insert_attribute
40
+ node = find_node
41
+
42
+ # TODO, There was a case when "node" was nil in this point, after
43
+ # trying to find it by type + ID.
44
+ #
45
+ # Keep an eye out for it to see if reproducible
46
+
47
+ node.add(create_element)
48
+ refresh_document(node)
49
+ save_document
50
+ end
51
+
52
+ private
53
+
54
+ def find_node
55
+ REXML::XPath.first(@document, query_for(@type, storyboard_id: @storyboard_id))
56
+ end
57
+
58
+ def refresh_document(node)
59
+ # This is a bit ridiculous, but necessary because
60
+ # &*£(*$)£@*$!£ REXML
61
+
62
+ @document = REXML::Document.new(node.document.to_s)
63
+ end
64
+
65
+ def save_document
66
+ File.open(@path, "w") do |f|
67
+ printer = Printer.new(2)
68
+ printer.width = 1000
69
+ printer.write(@document, f)
70
+ end
71
+ end
72
+
73
+ def query_for(type, storyboard_id: "")
74
+ # Find element of said type, with the ID, that does not
75
+ # have a userDefinedRuntimeAttribute as a child
76
+
77
+ QUERIES[type] + "[@id=\"#{storyboard_id}\" and not(userDefinedRuntimeAttributes)]]"
78
+ end
79
+
80
+ def text_attribute(type)
81
+ Parser::Storyboard::Engine::TEXT_ATTRIBUTE[type]
82
+ end
83
+
84
+ def create_element
85
+ REXML::Element.new("userDefinedRuntimeAttributes")
86
+ .add_element("userDefinedRuntimeAttribute",
87
+ {"type" => "boolean",
88
+ "keyPath" => "Terrestrial",
89
+ "value" => "YES" }).parent
90
+ .add_element("userDefinedRuntimeAttribute",
91
+ {"type" => "string",
92
+ "keyPath" => "Identifier",
93
+ "value" => @identifier }).parent
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,92 @@
1
+ module Terrestrial
2
+ module Cli
3
+ module Editor
4
+ class Swift < BaseEditor
5
+
6
+ def self.find_and_edit_line(new_string)
7
+ edit_file(new_string.file) do |line, line_number, file|
8
+ if line_number == new_string.line_number
9
+ file.puts do_edit_string(line, new_string)
10
+ else
11
+ file.puts line
12
+ end
13
+ line_number += 1
14
+ end
15
+ end
16
+
17
+ def self.do_edit_string(line, entry)
18
+ if has_swift_variables? entry.string
19
+ edit_with_variables(line, entry)
20
+ else
21
+ line.gsub(a_string_not_followed_by_translated(entry.string), "\"#{entry.identifier}\".translated")
22
+ end
23
+ end
24
+
25
+ def self.edit_with_variables(line, entry)
26
+ line.gsub(a_string_not_followed_by_translated(entry.string), build_string_with_variables(entry))
27
+ end
28
+
29
+ def self.build_string_with_variables(entry)
30
+ "NSString(format: \"#{entry.identifier}\".translated, #{swift_variables(entry.string).join(", ")})"
31
+ end
32
+
33
+ def self.add_import(file)
34
+ # Adds import Terrestrial as the last import
35
+ # statement at the top of the file.
36
+ #
37
+ # It goes through the file from top to bottom looking for the first import
38
+ # statement. After it finds it, it will look for the first line without
39
+ # an import statement. When it finds it, it will write the import line,
40
+ # and all following lines are just copied over.
41
+
42
+ found_first_import = false
43
+ imported = false
44
+
45
+ edit_file(file) do |line, line_number, file|
46
+ # Detect first import statement
47
+ if !found_first_import && line.start_with?("import ")
48
+ found_first_import = true
49
+ file.puts line
50
+ # Terrestrial had already been imported
51
+ elsif line.start_with?("import Terrestrial")
52
+ imported = true
53
+ file.puts line
54
+ # Not imported, had found first import, and doesn't start with "import"
55
+ # -> import
56
+ elsif !imported && found_first_import && !line.start_with?("import ")
57
+ file.puts "import Terrestrial"
58
+ file.puts ""
59
+ imported = true
60
+ # Copy over as normal
61
+ else
62
+ file.puts line
63
+ end
64
+ end
65
+ end
66
+
67
+ def self.has_swift_variables?(string)
68
+ swift_variables(string).any?
69
+ end
70
+
71
+ def self.swift_variables(string)
72
+ string.scan(Parser::Swift::VARIABLE_REGEX).map(&:first)
73
+ end
74
+
75
+ def self.a_string_not_followed_by_translated(string)
76
+ # Does not match:
77
+ #
78
+ # @"foo".translated
79
+ # or
80
+ # @"foo" translatedWithContext
81
+ #
82
+ # (?!(\.|\s)translated) means don't match either
83
+ # period or single whitepspace character,
84
+ # followed by translated
85
+
86
+
87
+ /"#{Regexp.quote(string)}"(?!\.translated)/
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,42 @@
1
+ require 'terrestrial/cli/editor/printer'
2
+ require 'terrestrial/cli/editor/base_editor'
3
+ require 'terrestrial/cli/editor/objc'
4
+ require 'terrestrial/cli/editor/swift'
5
+ require 'terrestrial/cli/editor/storyboard'
6
+ require 'terrestrial/cli/editor/android_xml'
7
+
8
+ module Terrestrial
9
+ module Cli
10
+ module Editor
11
+ class << self
12
+ def prepare_files(new_strings)
13
+ @new_strings = new_strings
14
+ run
15
+ end
16
+
17
+ private
18
+
19
+ def run
20
+ wrap_string_with_sdk_functions
21
+ add_imports
22
+ end
23
+
24
+ def add_imports
25
+ @new_strings
26
+ .uniq {|string| string.file}
27
+ .each {|string| editor_for_type(string.file).add_import(string.file)}
28
+ end
29
+
30
+ def wrap_string_with_sdk_functions
31
+ @new_strings.each do |string|
32
+ editor_for_type(string.file).find_and_edit_line(string)
33
+ end
34
+ end
35
+
36
+ def editor_for_type(file)
37
+ EngineMapper.editor_for(File.extname(file))
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,30 @@
1
+ module Terrestrial
2
+ module Cli
3
+ class EngineMapper
4
+
5
+ PARSERS = {
6
+ ".m" => Terrestrial::Cli::Parser::ObjC,
7
+ ".h" => Terrestrial::Cli::Parser::ObjC,
8
+ ".swift" => Terrestrial::Cli::Parser::Swift,
9
+ ".storyboard" => Terrestrial::Cli::Parser::Storyboard,
10
+ ".xml" => Terrestrial::Cli::Parser::AndroidXML
11
+ }
12
+
13
+ EDITORS = {
14
+ ".m" => Terrestrial::Cli::Editor::ObjC,
15
+ ".h" => Terrestrial::Cli::Editor::ObjC,
16
+ ".swift" => Terrestrial::Cli::Editor::Swift,
17
+ ".storyboard" => Terrestrial::Cli::Editor::Storyboard,
18
+ ".xml" => Terrestrial::Cli::Editor::AndroidXML
19
+ }
20
+
21
+ def self.parser_for(extension)
22
+ PARSERS[extension]
23
+ end
24
+
25
+ def self.editor_for(extension)
26
+ EDITORS[extension]
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,22 @@
1
+ module Terrestrial
2
+ module Cli
3
+ class EntryCollectionDiffer
4
+
5
+ def self.omissions(first, second)
6
+ first.select do |a|
7
+ !second.any? {|b| match?(a, b)}
8
+ end
9
+ end
10
+
11
+ def self.additions(first, second)
12
+ second.reject do |b|
13
+ first.any? {|a| match?(a, b) }
14
+ end
15
+ end
16
+
17
+ def self.match?(a, b)
18
+ a.fetch("identifier") == b.fetch("identifier")
19
+ end
20
+ end
21
+ end
22
+ end