diff --git a/Gemfile.lock b/Gemfile.lock index 6d3bcf5..858b0e5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,26 +1,31 @@ PATH remote: . specs: - vcardigan (0.0.8) + vcardigan (0.0.9) GEM remote: https://rubygems.org/ specs: coderay (1.1.0) - diff-lcs (1.1.3) + diff-lcs (1.5.1) method_source (0.8.2) pry (0.9.12.6) coderay (~> 1.0) method_source (~> 0.8) slop (~> 3.4) - rspec (2.12.0) - rspec-core (~> 2.12.0) - rspec-expectations (~> 2.12.0) - rspec-mocks (~> 2.12.0) - rspec-core (2.12.2) - rspec-expectations (2.12.1) - diff-lcs (~> 1.1.3) - rspec-mocks (2.12.1) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.1) slop (3.4.7) PLATFORMS @@ -28,5 +33,8 @@ PLATFORMS DEPENDENCIES pry - rspec (~> 2.0) + rspec (~> 3.0) vcardigan! + +BUNDLED WITH + 2.5.7 diff --git a/lib/vcardigan.rb b/lib/vcardigan.rb index 0cfd4b1..1baa9c3 100644 --- a/lib/vcardigan.rb +++ b/lib/vcardigan.rb @@ -16,6 +16,14 @@ def parse(*args) VCardigan::VCard.new.parse(*args) end + def parse!(*args) + VCardigan::VCard.new.parse(*args, strict: true) + end + + def parse_all!(io, skip_invalid: false, &block) + VCardigan::VCard.parse_all(io, skip_invalid: skip_invalid, &block) + end + end end diff --git a/lib/vcardigan/errors.rb b/lib/vcardigan/errors.rb index 5ebf7cc..d19e94c 100644 --- a/lib/vcardigan/errors.rb +++ b/lib/vcardigan/errors.rb @@ -1,3 +1,29 @@ module VCardigan - EncodingError = Class.new(StandardError) + class Error < StandardError + class << self + attr_accessor :default_message + end + + def initialize(message = nil) + super(message || self.class.default_message) + end + end + + class EncodingError < Error; end + + class MissingEndError < EncodingError + self.default_message = "vCards must end with an END:VCARD line" + end + + class MissingVersionError < EncodingError + self.default_message = "vCards must include a VERSION field" + end + + class MissingFullNameError < EncodingError + self.default_message = "vCards must include an FN field" + end + + class UnexpectedBeginError < EncodingError + self.default_message = "vCard has more than one BEGIN:VCARD line" + end end diff --git a/lib/vcardigan/vcard.rb b/lib/vcardigan/vcard.rb index 8fd6df6..ff4327c 100644 --- a/lib/vcardigan/vcard.rb +++ b/lib/vcardigan/vcard.rb @@ -1,3 +1,5 @@ +require 'stringio' + module VCardigan class VCard @@ -6,6 +8,37 @@ class VCard # it's not terminated UNTERMINATED_QUOTED_PRINTABLE = /ENCODING=QUOTED-PRINTABLE:.*=$/ + class << self + def parse_all(io, skip_invalid: false, &block) + io = StringIO.new(io.to_s) unless io_like?(io) + + enumerator = Enumerator.new do |yielder| + loop do + break if io.eof? + + begin + vcard = new.parse(io, strict: true) + yielder << vcard if vcard + rescue VCardigan::EncodingError => e + raise e unless skip_invalid + end + end + end + + if block_given? + enumerator.each(&block) + else + enumerator + end + end + + private + + def io_like?(obj) + obj.respond_to?(:each_line) && obj.respond_to?(:eof?) + end + end + attr_accessor :version attr_accessor :chars @@ -24,13 +57,15 @@ def initialize(options = {}) @group = nil end - def parse(data) - lines = unfold(data) + def parse(data, strict: false) + self.version = nil if strict + lines = unfold(data, strict: strict) + return nil if lines.empty? && strict # Add the parsed properties to this vCard lines.each do |line| if line =~ /^VERSION:(.+)/ - @version = $1 + self.version = $1 next end @@ -38,6 +73,9 @@ def parse(data) add_prop(property) end + raise VCardigan::MissingVersionError if strict && self.version.nil? + raise VCardigan::MissingFullNameError if strict && !@fields.has_key?('fn') + self end @@ -170,27 +208,42 @@ def fullname(*args) # lines to be inserted for readability - it does this by dropping zero-length # lines. # Borrowed from https://github.com/qoobaa/vcard - def unfold(card) + def unfold(card, strict: true) unfolded = [] + passed_begining = 0 + passed_ending = 0 + prior_line = nil - card.lines do |line| + card.each_line do |line| line.chomp! # If it's a continuation line, add it to the last. # If it's an empty line, drop it from the input. if line =~ /^[ \t]/ + next if strict && passed_begining.zero? unfolded[-1] << line[1, line.size-1] - elsif line =~ /(^BEGIN:VCARD$)|(^END:VCARD$)/ + elsif line =~ /^BEGIN:VCARD$/ + passed_begining += 1 + raise VCardigan::UnexpectedBeginError if strict && passed_begining > 1 + elsif line =~ /^END:VCARD$/ + passed_ending += 1 + break if strict elsif prior_line && (prior_line =~ UNTERMINATED_QUOTED_PRINTABLE) + next if strict && passed_begining.zero? # Strip the trailing = off prior line, then append current line unfolded[-1] = prior_line[0, prior_line.length-1] + line elsif line =~ /^$/ else + next if strict && passed_begining.zero? unfolded << line end prior_line = unfolded[-1] end + if strict && passed_begining.positive? && passed_ending.zero? + raise VCardigan::MissingEndError + end + unfolded end @@ -225,8 +278,7 @@ def add_prop(property) def validate unless @fields['fn'] - raise VCardigan::EncodingError, - "vCards must include an FN field" + raise VCardigan::MissingFullNameError end end diff --git a/spec/examples/vcard_spec.rb b/spec/examples/vcard_spec.rb index 767e640..af70976 100644 --- a/spec/examples/vcard_spec.rb +++ b/spec/examples/vcard_spec.rb @@ -357,4 +357,167 @@ end end end + + describe '#parse!' do + context 'invalid vCard' do + let(:data) { File.read(File.dirname(__FILE__) + '/../helpers/joe.vcf') } + let(:vcard) { VCardigan.parse!(data) } + let(:fields) { vcard.instance_variable_get(:@fields) } + + context 'when the fields are out of order' do + let(:data) { File.read(File.dirname(__FILE__) + '/../helpers/scrambeled_joe.vcf') } + + it 'ignores all fields before the begin line and after the end line' do + expect(fields).not_to have_key('n') + expect(fields).not_to have_key('email') + expect(fields).to have_key('fn') + expect(vcard.fullname.first.value).to eq('Joe Strummer') + expect(vcard.email).to be_nil + end + end + + context 'when the version is missing' do + let(:data) { File.read(File.dirname(__FILE__) + '/../helpers/no_version.vcf') } + + it 'raises an error' do + expect { vcard }.to raise_error(VCardigan::MissingVersionError) + end + end + + context 'when the end line is missing' do + let(:data) { File.read(File.dirname(__FILE__) + '/../helpers/no_end.vcf') } + + it 'raises an error' do + expect { vcard }.to raise_error(VCardigan::MissingEndError) + end + end + + context 'when the full name is missing' do + let(:data) { File.read(File.dirname(__FILE__) + '/../helpers/no_fullname.vcf') } + + it 'raises an error' do + expect { vcard }.to raise_error(VCardigan::MissingFullNameError) + end + end + + context 'when there are multiple begin lines' do + let(:data) { File.read(File.dirname(__FILE__) + '/../helpers/multiple_begin.vcf') } + + it 'raises an error' do + expect { vcard }.to raise_error(VCardigan::UnexpectedBeginError) + end + end + end + + context 'valid 4.0 vCard' do + let(:data) { File.read(File.dirname(__FILE__) + '/../helpers/joe.vcf') } + let(:vcard) { VCardigan.parse!(data) } + let(:fields) { vcard.instance_variable_get(:@fields) } + + it 'should set the version' do + vcard.version.should == '4.0' + end + + it 'should only have one version property' do + vcard.to_s.lines.count {|l| l =~ /^VERSION:/ }.should == 1 + end + + it 'should add the properties to the fields array' do + fields.should have_key('n') + fields.should have_key('fn') + end + end + + context 'google 3.0 vCard' do + let(:data) { File.read(File.dirname(__FILE__) + '/../helpers/google.vcf') } + let(:vcard) { VCardigan.parse!(data) } + let(:fields) { vcard.instance_variable_get(:@fields) } + + it 'should set the version' do + vcard.version.should == '3.0' + end + + it 'should add the properties to the fields array' do + fields.should have_key('n') + fields.should have_key('fn') + fields.should have_key('photo') + fields.should have_key('x-socialprofile') + end + end + end + + describe '.parse_all!' do + context 'with a valid 4.0 vCard string' do + it 'should return an enumerator of vCards' do + data = File.read(File.dirname(__FILE__) + '/../helpers/doe_family.vcf') + vcards = VCardigan.parse_all!(data) + + expect(vcards).to be_an_instance_of(Enumerator) + + vcards = vcards.to_a + expect(vcards.size).to eq(2) + expect(vcards).to all(be_an_instance_of(VCardigan::VCard)) + + vcards = [] + VCardigan.parse_all!(data) do |vcard| + vcards << vcard + end + + expect(vcards.size).to eq(2) + expect(vcards).to all(be_an_instance_of(VCardigan::VCard)) + end + end + + context 'with a valid 4.0 vCard io' do + it 'should return an enumerator of vCards' do + data = File.open(File.dirname(__FILE__) + '/../helpers/doe_family.vcf', 'r') + vcards = VCardigan.parse_all!(data) + + expect(vcards).to be_an_instance_of(Enumerator) + + vcards = vcards.to_a + expect(vcards.size).to eq(2) + expect(vcards).to all(be_an_instance_of(VCardigan::VCard)) + + vcards = [] + data.rewind + VCardigan.parse_all!(data) do |vcard| + vcards << vcard + end + + expect(vcards.size).to eq(2) + expect(vcards).to all(be_an_instance_of(VCardigan::VCard)) + end + end + + context 'with an invalid vCard' do + it 'parses what it can and then rasies an error' do + data = File.open(File.dirname(__FILE__) + '/../helpers/scrambeled_doe_family.vcf', 'r') + + vcards = [] + + expect do + VCardigan.parse_all!(data) do |vcard| + vcards << vcard + end + end.to raise_error(VCardigan::EncodingError) + + expect(vcards.size).to eq(1) + expect(vcards.first).to be_an_instance_of(VCardigan::VCard) + end + + it 'parses what it can when passed skip_invalid: true' do + data = File.open(File.dirname(__FILE__) + '/../helpers/scrambeled_doe_family.vcf', 'r') + + vcards = [] + + VCardigan.parse_all!(data, skip_invalid: true) do |vcard| + vcards << vcard + end + + expect(vcards.size).to eq(1) + expect(vcards.first).to be_an_instance_of(VCardigan::VCard) + end + end + end end diff --git a/spec/helpers/doe_family.vcf b/spec/helpers/doe_family.vcf new file mode 100644 index 0000000..c68993b --- /dev/null +++ b/spec/helpers/doe_family.vcf @@ -0,0 +1,15 @@ +BEGIN:VCARD +VERSION:4.0 +N:Doe;John;;; +FN:John Doe +END:VCARD +BEGIN:VCARD +VERSION:4.0 +N:Doe;Jane;;; +FN:Jane Doe +END:VCARD + + + + + diff --git a/spec/helpers/multiple_begin.vcf b/spec/helpers/multiple_begin.vcf new file mode 100644 index 0000000..83d4f0d --- /dev/null +++ b/spec/helpers/multiple_begin.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:4.0 +FN:Joe Strummer +N:Strummer;Joe;;; +BEGIN:VCARD +EMAIL;type=INTERNET;type=HOME:joe@example.com +END:VCARD diff --git a/spec/helpers/no_begin.vcf b/spec/helpers/no_begin.vcf new file mode 100644 index 0000000..351ab88 --- /dev/null +++ b/spec/helpers/no_begin.vcf @@ -0,0 +1,5 @@ +VERSION:4.0 +FN:Joe Strummer +N:Strummer;Joe;;; +EMAIL;type=INTERNET;type=HOME:joe@example.com +END:VCARD diff --git a/spec/helpers/no_end.vcf b/spec/helpers/no_end.vcf new file mode 100644 index 0000000..bfc1636 --- /dev/null +++ b/spec/helpers/no_end.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:4.0 +FN:Joe Strummer +N:Strummer;Joe;;; +EMAIL;type=INTERNET;type=HOME:joe@example.com diff --git a/spec/helpers/no_fullname.vcf b/spec/helpers/no_fullname.vcf new file mode 100644 index 0000000..ff25f9b --- /dev/null +++ b/spec/helpers/no_fullname.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:4.0 +N:Strummer;Joe;;; +EMAIL;type=INTERNET;type=HOME:joe@example.com +END:VCARD diff --git a/spec/helpers/no_version.vcf b/spec/helpers/no_version.vcf new file mode 100644 index 0000000..0a88060 --- /dev/null +++ b/spec/helpers/no_version.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +FN:Joe Strummer +N:Strummer;Joe;;; +EMAIL;type=INTERNET;type=HOME:joe@example.com +END:VCARD diff --git a/spec/helpers/scrambeled_doe_family.vcf b/spec/helpers/scrambeled_doe_family.vcf new file mode 100644 index 0000000..c96a17f --- /dev/null +++ b/spec/helpers/scrambeled_doe_family.vcf @@ -0,0 +1,10 @@ +VERSION:4.0 +N:Doe;John;;; +FN:John Doe +BEGIN:VCARD +VERSION:4.0 +N:Doe;Jane;;; +FN:Jane Doe +END:VCARD +BEGIN:VCARD +BEGIN:VCARD diff --git a/spec/helpers/scrambeled_joe.vcf b/spec/helpers/scrambeled_joe.vcf new file mode 100644 index 0000000..0b2584d --- /dev/null +++ b/spec/helpers/scrambeled_joe.vcf @@ -0,0 +1,6 @@ +EMAIL;type=INTERNET;type=HOME:joe@example.com +BEGIN:VCARD +VERSION:4.0 +FN:Joe Strummer +END:VCARD +N:Strummer;Joe;;; diff --git a/vcardigan.gemspec b/vcardigan.gemspec index 9a62b89..fae6e12 100644 --- a/vcardigan.gemspec +++ b/vcardigan.gemspec @@ -12,6 +12,6 @@ spec = Gem::Specification.new do |s| s.files = Dir['lib/**/*.rb'] s.test_files = Dir['spec/**/*.rb'] s.has_rdoc = false - s.add_development_dependency 'rspec', '~> 2.0' + s.add_development_dependency 'rspec', '~> 3.0' s.add_development_dependency 'pry' end