terrestrial-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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