virginity 0.3.31
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/virginity.rb +6 -0
- data/lib/virginity/api_extensions.rb +87 -0
- data/lib/virginity/api_extensions/fields_to_json.rb +82 -0
- data/lib/virginity/api_extensions/fields_to_xml.rb +151 -0
- data/lib/virginity/bnf.rb +84 -0
- data/lib/virginity/dir_info.rb +93 -0
- data/lib/virginity/dir_info/content_line.rb +146 -0
- data/lib/virginity/dir_info/line_folding.rb +60 -0
- data/lib/virginity/dir_info/param.rb +208 -0
- data/lib/virginity/dir_info/query.rb +144 -0
- data/lib/virginity/encoding_decoding.rb +177 -0
- data/lib/virginity/encodings.rb +36 -0
- data/lib/virginity/fixes.rb +230 -0
- data/lib/virginity/vcard.rb +244 -0
- data/lib/virginity/vcard/base_field.rb +126 -0
- data/lib/virginity/vcard/categories.rb +57 -0
- data/lib/virginity/vcard/cleaning.rb +364 -0
- data/lib/virginity/vcard/field.rb +22 -0
- data/lib/virginity/vcard/field/params.rb +93 -0
- data/lib/virginity/vcard/field_values.rb +10 -0
- data/lib/virginity/vcard/field_values/binary.rb +22 -0
- data/lib/virginity/vcard/field_values/boolean.rb +14 -0
- data/lib/virginity/vcard/field_values/case_insensitive_value.rb +13 -0
- data/lib/virginity/vcard/field_values/date.rb +16 -0
- data/lib/virginity/vcard/field_values/integer.rb +15 -0
- data/lib/virginity/vcard/field_values/optional_structured_text.rb +35 -0
- data/lib/virginity/vcard/field_values/separated_text.rb +59 -0
- data/lib/virginity/vcard/field_values/structured_text.rb +71 -0
- data/lib/virginity/vcard/field_values/text.rb +23 -0
- data/lib/virginity/vcard/field_values/uri.rb +15 -0
- data/lib/virginity/vcard/fields.rb +284 -0
- data/lib/virginity/vcard/fields_osx.rb +95 -0
- data/lib/virginity/vcard/fields_soocial.rb +45 -0
- data/lib/virginity/vcard/name_handler.rb +151 -0
- data/lib/virginity/vcard/patching.rb +262 -0
- data/lib/virginity/vcard21.rb +2 -0
- data/lib/virginity/vcard21/base.rb +30 -0
- data/lib/virginity/vcard21/parser.rb +359 -0
- data/lib/virginity/vcard21/reader.rb +103 -0
- data/lib/virginity/vcard21/writer.rb +139 -0
- metadata +111 -0
@@ -0,0 +1,177 @@
|
|
1
|
+
module Virginity
|
2
|
+
|
3
|
+
class Error < StandardError; end
|
4
|
+
class InvalidEncoding < Error; end
|
5
|
+
|
6
|
+
module EncodingDecoding
|
7
|
+
extend Encodings
|
8
|
+
|
9
|
+
# VALUE-CHAR = WSP / VCHAR / NON-ASCII
|
10
|
+
WSP = [0x20, 0x09] # WSP = SP / HTAB
|
11
|
+
VCHAR = 0x21..0x7E # VCHAR = %x21-7E ; visible (printing) characters
|
12
|
+
NONASCII = 0x80..0xFF # NON-ASCII = %x80-FF
|
13
|
+
|
14
|
+
CR_AND_LF = /\r\n/
|
15
|
+
CR = "\r"
|
16
|
+
LF = "\n"
|
17
|
+
def self.decode_quoted_printable(text)
|
18
|
+
text.gsub(CR_AND_LF, LF).gsub(/\=([0-9a-fA-F])?\n\s+([0-9a-fA-F])/, "=\\1\\2").unpack('M*').first
|
19
|
+
end
|
20
|
+
|
21
|
+
QP_ALSO_ENCODE = "\x0A\x20"
|
22
|
+
def self.encode_quoted_printable(text, options = {})
|
23
|
+
options[:also_encode] ||= QP_ALSO_ENCODE
|
24
|
+
# special_chars = /[\t ](?:[\v\t ]|$)|[=\x00-\x08\x0B-\x1F\x7F-\xFF#{options[:also_encode]}]/
|
25
|
+
special_chars = /[=\x00-\x08\x0B-\x1F\x7F-\xFF#{options[:also_encode]}]/n
|
26
|
+
encoded = to_binary(text).gsub(special_chars) do |char|
|
27
|
+
char[0 ... -1] + "=%02X" % char[-1].ord
|
28
|
+
end
|
29
|
+
fold_quoted_printable(encoded, options[:width] || 76, options[:initial_position])
|
30
|
+
end
|
31
|
+
|
32
|
+
QPCHAR = /[^=]|=[\dABCDEF]{2}/
|
33
|
+
QPFOLD = "=\r\n" # only vCard 2.1 uses encode_quoted_printable so we always use windows line endings
|
34
|
+
def self.fold_quoted_printable(qp_text, width = 76, initial_position = 0)
|
35
|
+
return qp_text unless width > 5
|
36
|
+
pos = initial_position.to_i
|
37
|
+
scanner = StringScanner.new(qp_text)
|
38
|
+
folded = ""
|
39
|
+
while !scanner.eos?
|
40
|
+
char = scanner.scan(QPCHAR)
|
41
|
+
charsize = char.size
|
42
|
+
if pos + charsize > width - 3
|
43
|
+
folded << QPFOLD
|
44
|
+
pos = 0
|
45
|
+
end
|
46
|
+
folded << char
|
47
|
+
pos += charsize
|
48
|
+
end
|
49
|
+
folded
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
def self.normalize_newlines!(text)
|
54
|
+
text.gsub!(/\r?\n|\r/, "\n")
|
55
|
+
end
|
56
|
+
|
57
|
+
# "text": The "text" value type should be used to identify values that
|
58
|
+
# contain human-readable text. The character set and language in which
|
59
|
+
# the text is represented is controlled by the charset content-header
|
60
|
+
# and the language type parameter and content-header.
|
61
|
+
#
|
62
|
+
# A formatted text line break in a text value type MUST be represented
|
63
|
+
# as the character sequence backslash (ASCII decimal 92) followed by a
|
64
|
+
# Latin small letter n (ASCII decimal 110) or a Latin capital letter N
|
65
|
+
# (ASCII decimal 78), that is "\n" or "\N".
|
66
|
+
#
|
67
|
+
# TODO options for saving to ascii (convert to quoted printable) or storing plain utf-8
|
68
|
+
ENCODED_LF = "\\n"
|
69
|
+
CRLF = CR + LF
|
70
|
+
BACKSLASH = "\\"
|
71
|
+
COMMA = ","
|
72
|
+
SEMICOLON = ";"
|
73
|
+
STUFF_TO_ENCODE = /[\n\\\,\;]/
|
74
|
+
STUFF_NOT_TO_ENCODE = %r{[^\n\\\,\;]*}
|
75
|
+
def self.encode_text(text)
|
76
|
+
raise "#{text.inspect} must be a String" unless text.is_a? String
|
77
|
+
normalize_newlines!(text)
|
78
|
+
encoded = ""
|
79
|
+
s = StringScanner.new(text)
|
80
|
+
while !s.eos?
|
81
|
+
encoded << s.scan(STUFF_NOT_TO_ENCODE)
|
82
|
+
# 5.8.4 Backslashes, newlines, and commas must be encoded.
|
83
|
+
case x = s.scan(STUFF_TO_ENCODE)
|
84
|
+
when LF
|
85
|
+
encoded << ENCODED_LF
|
86
|
+
when BACKSLASH, COMMA, SEMICOLON
|
87
|
+
# RFC2426 tells us to encode ":" too, which is needed for structured text fields
|
88
|
+
encoded << BACKSLASH << x
|
89
|
+
end
|
90
|
+
end
|
91
|
+
encoded
|
92
|
+
end
|
93
|
+
|
94
|
+
def self.decode_text(text)
|
95
|
+
text.gsub(/\\(.)/) { $1.casecmp('n') == 0 ? LF : $1 }
|
96
|
+
end
|
97
|
+
|
98
|
+
def self.encode_text_list(list, separator = COMMA)
|
99
|
+
list.map { |value| encode_text(value) }.join(separator)
|
100
|
+
end
|
101
|
+
|
102
|
+
# # TODO: port to C someday
|
103
|
+
# This is the old simple implementation that is easy to port to another language
|
104
|
+
# this can be a lot faster if we don't create a new string object for each char
|
105
|
+
# def self.decode_text_list(text_list, separator = COMMA)
|
106
|
+
# strings = []
|
107
|
+
# state = :normal # there are two states: :normal and :escaped
|
108
|
+
# string = ""
|
109
|
+
# text_list.each_char do |char|
|
110
|
+
# if state == :escaped
|
111
|
+
# string << (%w(n N).include?(char) ? LF : char)
|
112
|
+
# state = :normal
|
113
|
+
# else
|
114
|
+
# case char
|
115
|
+
# when BACKSLASH
|
116
|
+
# state = :escaped
|
117
|
+
# when separator
|
118
|
+
# strings << string
|
119
|
+
# string = ""
|
120
|
+
# else
|
121
|
+
# string << char
|
122
|
+
# end
|
123
|
+
# end
|
124
|
+
# end
|
125
|
+
# strings << string
|
126
|
+
# strings
|
127
|
+
# end
|
128
|
+
|
129
|
+
|
130
|
+
NON_ESCAPE_OR_SEPARATOR_REGEXP = {}
|
131
|
+
def self.non_escape_or_separator_regexp(separator)
|
132
|
+
NON_ESCAPE_OR_SEPARATOR_REGEXP[separator] ||= %r{[^\\#{separator}\\\\]*}
|
133
|
+
end
|
134
|
+
|
135
|
+
def self.decode_text_list(text_list, separator = COMMA)
|
136
|
+
not_special = non_escape_or_separator_regexp(separator)
|
137
|
+
list = []
|
138
|
+
text = ""
|
139
|
+
s = StringScanner.new(text_list)
|
140
|
+
while !s.eos?
|
141
|
+
text << s.scan(not_special)
|
142
|
+
break if s.eos?
|
143
|
+
case s.getch
|
144
|
+
when BACKSLASH
|
145
|
+
char = s.getch
|
146
|
+
# what do I do when char is nil? ignore the backslash too? I don't know...
|
147
|
+
raise InvalidEncoding, "text list \"#{text_list}\" ends after escape char" if char.nil?
|
148
|
+
text << (char.casecmp('n') == 0 ? LF : char)
|
149
|
+
when separator
|
150
|
+
list << text
|
151
|
+
text = ""
|
152
|
+
else
|
153
|
+
raise InvalidEncoding, "read #{s.matched.inspect} at #{s.pos} in #{s.string.inspect} (#{s.string.size}) using #{not_special.inspect}"
|
154
|
+
end
|
155
|
+
end
|
156
|
+
list << text
|
157
|
+
list
|
158
|
+
end
|
159
|
+
|
160
|
+
# Compound type values are delimited by a field delimiter, specified by the SEMI-COLON character (ASCII decimal 59). A SEMI-COLON in a component of a compound property value MUST be escaped with a BACKSLASH character (ASCII decimal 92).
|
161
|
+
#
|
162
|
+
# Lists of values are delimited by a list delimiter, specified by the COMMA character (ASCII decimal 44). A COMMA character in a value MUST be escaped with a BACKSLASH character (ASCII decimal 92).
|
163
|
+
#
|
164
|
+
# This profile supports the type grouping mechanism defined in [MIME-DIR]. Grouping of related types is a useful technique to communicate common semantics concerning the properties of a vCard.
|
165
|
+
def self.decode_structured_text(value, size, separator = SEMICOLON)
|
166
|
+
list = decode_text_list(value, separator)
|
167
|
+
list << "" while list.size < size
|
168
|
+
list.pop while list.size > size
|
169
|
+
list
|
170
|
+
end
|
171
|
+
|
172
|
+
def self.encode_structured_text(list, separator = SEMICOLON)
|
173
|
+
encode_text_list(list, separator)
|
174
|
+
end
|
175
|
+
|
176
|
+
end
|
177
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Virginity
|
2
|
+
module Encodings
|
3
|
+
|
4
|
+
def binary?(s)
|
5
|
+
s.encoding == Encoding::BINARY
|
6
|
+
end
|
7
|
+
|
8
|
+
def to_binary(s)
|
9
|
+
s.dup.force_encoding(Encoding::BINARY)
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_ascii(s)
|
13
|
+
s.dup.force_encoding(Encoding::ASCII)
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_default(s)
|
17
|
+
s.encode
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_default!(s)
|
21
|
+
s.encode!
|
22
|
+
end
|
23
|
+
|
24
|
+
def verify_utf8ness(string)
|
25
|
+
if string.encoding == Encoding::UTF_8 || string.encoding == Encoding::US_ASCII
|
26
|
+
unless string.valid_encoding?
|
27
|
+
# puts "*"*100, "incorrectly encoded String", string, "*"*100
|
28
|
+
raise InvalidEncoding, "incorrectly encoded String"
|
29
|
+
end
|
30
|
+
else
|
31
|
+
raise InvalidEncoding, "expected UTF-8 or ASCII"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,230 @@
|
|
1
|
+
module Virginity
|
2
|
+
module Fixes
|
3
|
+
def unfold_faulty_qp_lines(faulty_lines)
|
4
|
+
lines = faulty_lines.dup
|
5
|
+
loop do # unfold line that do not begin with " " but are encoded as QP
|
6
|
+
changed = false
|
7
|
+
lines.each_with_index do |line, i|
|
8
|
+
# if line is QP, ends with equals and is not the last line
|
9
|
+
if line =~ /ENCODING=QUOTED-PRINTABLE/i and line =~ /=$/ and lines.length > i+1
|
10
|
+
lines[i] = lines[i].chomp('=') # remove the soft-line break, the last character, (=)...
|
11
|
+
lines[i] += lines.delete_at(i+1) # ...and add the next line to the failing line.
|
12
|
+
changed = true
|
13
|
+
end
|
14
|
+
end
|
15
|
+
break unless changed
|
16
|
+
end
|
17
|
+
lines
|
18
|
+
end
|
19
|
+
|
20
|
+
def non_empty_lines(faulty_lines) # (needed for simon3.vcf)
|
21
|
+
faulty_lines.reject {|line| line.empty? }
|
22
|
+
end
|
23
|
+
|
24
|
+
# for osx
|
25
|
+
# more info on this Base64-line: http://www.imc.org/imc-vcard/mail-archive/msg00555.html
|
26
|
+
def remove_spaces_from_base64_lines(faulty_lines)
|
27
|
+
faulty_lines.map do |line|
|
28
|
+
if line =~ /BASE64/ # FIXME, this will break if the value contains this string. --> so let's do this in the field-class?
|
29
|
+
line.gsub(/\s/, "")
|
30
|
+
else
|
31
|
+
line
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.line_parts(line)
|
37
|
+
ContentLine::line_parts(line)
|
38
|
+
rescue InvalidEncoding
|
39
|
+
# so, it's invalid 3.0 encoded, could it be a 2.1-encoding?
|
40
|
+
DirectoryInformation::line21_parts(line)
|
41
|
+
end
|
42
|
+
|
43
|
+
# FIXME --> width can't be greater than width in the normal folding method... but it is now :-(
|
44
|
+
def self.photo_folding_like_apple(value, options = {})
|
45
|
+
width = options[:width] || 78
|
46
|
+
line_ending = (options[:windows_line_endings] ? "\r\n" : "\n")
|
47
|
+
s = line_ending + " " + value.gsub(/.{#{width-2},#{width-2}}/) {|x| x + line_ending + " "}
|
48
|
+
s.sub(/#{line_ending} $/,"") # remove the last line ending if if it so happens to be that the last line is 'empty'
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.sane_line_endings(s)
|
52
|
+
s.gsub(LineFolding::LINE_ENDING, "\n")
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.should_be_folded?(line)
|
56
|
+
return false if line =~ /\A $/ # TODO: clarify this, it was line.first, which takes the first string.
|
57
|
+
if line.include?(":") # probably a field
|
58
|
+
unless line[0] == ":" or line.split(":").first.match(/[\(\)]/)
|
59
|
+
# it is a name then, so it's a new field
|
60
|
+
return false
|
61
|
+
end
|
62
|
+
end
|
63
|
+
true
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.unfold_wrongly_folded_lines(s)
|
67
|
+
x = ""
|
68
|
+
sane_line_endings(s).split("\n").each do |line|
|
69
|
+
if should_be_folded?(line.dup)
|
70
|
+
x << " " + line
|
71
|
+
else
|
72
|
+
x << line
|
73
|
+
end
|
74
|
+
x << "\n"
|
75
|
+
end
|
76
|
+
x
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.remove_ascii_ctl_chars(s)
|
80
|
+
ctl = (0..31).to_a + [127]
|
81
|
+
v = ""
|
82
|
+
s.each_byte do |c|
|
83
|
+
v << c unless ctl.include? c
|
84
|
+
end
|
85
|
+
v
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.reencode_qp(qp, type=:value)
|
89
|
+
decoded = EncodingDecoding::decode_quoted_printable(qp)
|
90
|
+
case type
|
91
|
+
when nil
|
92
|
+
@value = EncodingDecoding::encode_text decoded
|
93
|
+
when :separated
|
94
|
+
@value = EncodingDecoding::encode_text_list [decoded]
|
95
|
+
when :structured
|
96
|
+
@value = EncodingDecoding::encode_structured_text [decoded]
|
97
|
+
else
|
98
|
+
raise TypeError, type
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
EQUALS_SIGN_WITHOUT_HEX = /=(([^0-9^A-F])|(.[^0-9^A-F]))/
|
103
|
+
|
104
|
+
def self.fix_faulty_qp_chars(s)
|
105
|
+
x = Virginity::LineFolding::unfold_and_split(s)
|
106
|
+
y = x.map do |l|
|
107
|
+
if l =~ /QUOTED\-PRINTABLE/
|
108
|
+
l =~ /(.*):(.*)/
|
109
|
+
prevalue, value = $1, $2
|
110
|
+
value = value.gsub(EQUALS_SIGN_WITHOUT_HEX) { |s| " #{$1}" }
|
111
|
+
"#{prevalue}:#{value}"
|
112
|
+
else
|
113
|
+
l
|
114
|
+
end
|
115
|
+
end
|
116
|
+
y.join("\n")
|
117
|
+
end
|
118
|
+
|
119
|
+
def self.guess_latin1(s)
|
120
|
+
x = Vcard.new(s).super_clean!
|
121
|
+
x.fields.each do |f|
|
122
|
+
begin
|
123
|
+
f.to_s.encode('UTF-8//TRANSLIT', Encoding::UTF_8)
|
124
|
+
f.to_s.force_encoding(LATIN1).encode
|
125
|
+
rescue EncodingError
|
126
|
+
print "\tguessing Latin1 for #{f.to_s.inspect}"
|
127
|
+
f.params << Param.new("CHARSET", "Latin1")
|
128
|
+
f.clean_charsets!
|
129
|
+
raise "GAAAAAAAAH!" unless f.to_s.is_utf8?
|
130
|
+
puts "\t-->\t" + f.to_s.inspect
|
131
|
+
end
|
132
|
+
end
|
133
|
+
x.super_clean!.to_s
|
134
|
+
end
|
135
|
+
|
136
|
+
# def faulty?(lns=lines)
|
137
|
+
# lns.any? { |line| not line =~ %r{#{Bnf::LINE}}i }
|
138
|
+
# end
|
139
|
+
|
140
|
+
# def fix!(lines)
|
141
|
+
# ls = lines.dup
|
142
|
+
# ls = unfold_faulty_qp_lines(ls)
|
143
|
+
# ls = non_empty_lines(ls)
|
144
|
+
# raise InvalidEncoding.new("I do not know how to repair this vcard:\n" + inspect) if faulty?(ls)
|
145
|
+
# ls
|
146
|
+
# end
|
147
|
+
|
148
|
+
# def fixed!
|
149
|
+
# original = lines.join("\n")
|
150
|
+
# ls = lines.dup
|
151
|
+
# before = original.dup
|
152
|
+
# after = ""
|
153
|
+
# while before != after and faulty_lines?(lines)
|
154
|
+
# before = ls.join("\n")
|
155
|
+
# ls = unfold_faulty_qp_lines(ls)
|
156
|
+
# ls = non_empty_lines(ls)
|
157
|
+
# after = ls.join("\n")
|
158
|
+
# end
|
159
|
+
# raise InvalidEncoding.new("I do not know how to repair this vcard:\n" + original) if faulty_lines?(lines)
|
160
|
+
# lines
|
161
|
+
# end
|
162
|
+
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
|
167
|
+
class Virginity::Vcard < Virginity::DirectoryInformation
|
168
|
+
|
169
|
+
def self.fields_from_broken_vcard(vcard_as_string)
|
170
|
+
lines = Virginity::LineFolding::unfold_and_split(vcard_as_string.lstrip).map do |line|
|
171
|
+
begin
|
172
|
+
# if it is not valid 3.0, could it be a 2.1-line?
|
173
|
+
Virginity::Field.parse(line)
|
174
|
+
rescue
|
175
|
+
group, name, params, value = DirectoryInformation::line21_parts(line)
|
176
|
+
Virginity::Field[name].new(name, value, params, group, :no_deep_copy => true)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def self.valid_utf8?(v)
|
182
|
+
if v.to_s.dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
183
|
+
true
|
184
|
+
else
|
185
|
+
false
|
186
|
+
end
|
187
|
+
rescue EncodingError
|
188
|
+
return false
|
189
|
+
end
|
190
|
+
|
191
|
+
# NB. this only works for vCard 3.0, not for 2.1!
|
192
|
+
def self.fix_and_clean(vcard_as_string)
|
193
|
+
fixes ||= []
|
194
|
+
# puts vcard_as_string
|
195
|
+
# puts "fixes => #{fixes.inspect}"
|
196
|
+
lines = fields_from_broken_vcard(vcard_as_string)
|
197
|
+
v = Virginity::Vcard.new(lines)
|
198
|
+
v.super_clean!
|
199
|
+
valid_utf8?(v)
|
200
|
+
v
|
201
|
+
rescue EncodingError
|
202
|
+
#puts e.class, e
|
203
|
+
if !fixes.include?(:faulty_qp)
|
204
|
+
newcard = Fixes::fix_faulty_qp_chars(vcard_as_string)
|
205
|
+
fixes << :faulty_qp
|
206
|
+
elsif !fixes.include?(:guess_latin1)
|
207
|
+
newcard = Fixes::guess_latin1(lines)
|
208
|
+
fixes << :guess_latin1
|
209
|
+
else
|
210
|
+
File.open("illegal_sequence_#{vcard_as_string.hash}.vcf", "wb") do |f|
|
211
|
+
f.puts Virginity::Vcard.new(vcard_as_string).super_clean!
|
212
|
+
end
|
213
|
+
raise
|
214
|
+
end
|
215
|
+
vcard_as_string = newcard
|
216
|
+
retry
|
217
|
+
rescue Virginity::InvalidEncoding, Virginity::Vcard21::ParseError => e
|
218
|
+
if !fixes.include?(:fix_folding)
|
219
|
+
vcard_as_string = Fixes::unfold_wrongly_folded_lines(vcard_as_string)
|
220
|
+
fixes << :fix_folding
|
221
|
+
retry
|
222
|
+
elsif !fixes.include?(:weird_mac_16bit_encoding_that_is_not_utf16) && vcard_as_string.include?("\000")
|
223
|
+
vcard_as_string.gsub!("\000", "")
|
224
|
+
fixes << :weird_mac_16bit_encoding_that_is_not_utf16
|
225
|
+
retry
|
226
|
+
else
|
227
|
+
raise
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
@@ -0,0 +1,244 @@
|
|
1
|
+
require 'digest'
|
2
|
+
require 'virginity/encodings'
|
3
|
+
require 'virginity/dir_info'
|
4
|
+
require 'virginity/vcard/cleaning'
|
5
|
+
require 'virginity/vcard/categories'
|
6
|
+
require 'virginity/vcard/name_handler'
|
7
|
+
require 'virginity/vcard/fields'
|
8
|
+
require 'virginity/vcard/fields_osx'
|
9
|
+
require 'virginity/vcard/fields_soocial'
|
10
|
+
require 'virginity/vcard/patching'
|
11
|
+
|
12
|
+
module Virginity
|
13
|
+
|
14
|
+
class InvalidVcard < Error
|
15
|
+
attr_reader :original
|
16
|
+
|
17
|
+
def initialize(msg, original = $!)
|
18
|
+
super(msg)
|
19
|
+
@original = original
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# rfc 2426, vCard MIME Directory Profile
|
24
|
+
class Vcard < DirectoryInformation
|
25
|
+
include VcardCleaning
|
26
|
+
include VcardCategories
|
27
|
+
include Patching
|
28
|
+
include Encodings
|
29
|
+
|
30
|
+
def self.diff(old, new)
|
31
|
+
Patching::diff(old, new)
|
32
|
+
end
|
33
|
+
|
34
|
+
EMPTY = "BEGIN:VCARD\nN:;;;;\nVERSION:3.0\nEND:VCARD\n"
|
35
|
+
|
36
|
+
VCARD_REGEX = /^[\ \t]*VCARD[\ \t]*$/i
|
37
|
+
def initialize(lines = EMPTY, options = {})
|
38
|
+
if lines.is_a? Array
|
39
|
+
@lines = lines.map {|line| line.to_field }
|
40
|
+
else # it's expected to be a String
|
41
|
+
verify_utf8ness lines
|
42
|
+
@lines = LineFolding::unfold_and_split(lines.lstrip).map { |line| Field.parse(line) }
|
43
|
+
end
|
44
|
+
raise InvalidVcard, "missing BEGIN:VCARD" unless @lines.first.name =~ Field::BEGIN_REGEX
|
45
|
+
raise InvalidVcard, "missing END:VCARD" unless @lines.last.name =~ Field::END_REGEX
|
46
|
+
raise InvalidVcard, "missing BEGIN:VCARD" unless @lines.first.raw_value =~ VCARD_REGEX
|
47
|
+
raise InvalidVcard, "missing END:VCARD" unless @lines.last.raw_value =~ VCARD_REGEX
|
48
|
+
rescue => error
|
49
|
+
raise InvalidVcard, error.message
|
50
|
+
end
|
51
|
+
|
52
|
+
# the empty vcard
|
53
|
+
def self.empty
|
54
|
+
new(LineFolding::unfold_and_split(EMPTY.lstrip).map { |line| Field.parse(line) })
|
55
|
+
end
|
56
|
+
|
57
|
+
# import vcard21 and convert it to 3.0
|
58
|
+
def self.from_vcard21(vcf, options = {})
|
59
|
+
verify_utf8ness vcf
|
60
|
+
vcard = new(lines_from_vcard21(vcf, options), options)
|
61
|
+
vcard.clean_version!
|
62
|
+
rescue => error
|
63
|
+
raise InvalidVcard, error.message
|
64
|
+
end
|
65
|
+
|
66
|
+
VERSION21 = /^VERSION\:2\.1(\s*)$/i
|
67
|
+
def self.parse(vcf, options = {})
|
68
|
+
vcf = vcf.to_s
|
69
|
+
verify_utf8ness vcf
|
70
|
+
if vcf =~ VERSION21
|
71
|
+
from_vcard21(vcf, options)
|
72
|
+
else
|
73
|
+
new(vcf, options)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.from_vcard(vcf, options = {})
|
78
|
+
parse(vcf, options)
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.load_all_from(filename, options = {})
|
82
|
+
list(File.read(filename), options)
|
83
|
+
end
|
84
|
+
|
85
|
+
END_VCARD = /^END\:VCARD\s*$/i
|
86
|
+
# split a given string of concatenated vcards to an array containing one vcard per element
|
87
|
+
def self.vcards_in_list(vcf)
|
88
|
+
s = StringScanner.new(vcf)
|
89
|
+
array = []
|
90
|
+
while !s.eos?
|
91
|
+
if v = s.scan_until(END_VCARD)
|
92
|
+
v.lstrip!
|
93
|
+
array << v
|
94
|
+
else
|
95
|
+
puts s.peek(100)
|
96
|
+
break
|
97
|
+
end
|
98
|
+
end
|
99
|
+
array
|
100
|
+
end
|
101
|
+
|
102
|
+
# returns an array of Vcards for a string of concatenated vcards
|
103
|
+
def self.list(vcf, options = {})
|
104
|
+
vcards_in_list(vcf).map do |v|
|
105
|
+
from_vcard(v, options)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def inspect
|
110
|
+
super.chomp(">") + " name=" + name.to_s.inspect + ">"
|
111
|
+
end
|
112
|
+
|
113
|
+
def dir_info
|
114
|
+
DirectoryInformation.new(to_s)
|
115
|
+
end
|
116
|
+
|
117
|
+
def deep_copy
|
118
|
+
Marshal::load(Marshal::dump(self))
|
119
|
+
end
|
120
|
+
|
121
|
+
alias_method :fields, :lines
|
122
|
+
alias_method :delete_field, :delete_content_line
|
123
|
+
|
124
|
+
# add a field and return it
|
125
|
+
# if a block is given, then the new field is yielded so it can be changed
|
126
|
+
def add_field(line)
|
127
|
+
end_vcard = @lines.pop
|
128
|
+
raise InvalidVcard, "there is no last line? ('END:VCARD')" if end_vcard.nil?
|
129
|
+
@lines << (field = Field.parse(line))
|
130
|
+
@lines << end_vcard
|
131
|
+
yield field if block_given?
|
132
|
+
field
|
133
|
+
end
|
134
|
+
|
135
|
+
# add a field, returns the vcard
|
136
|
+
def <<(line)
|
137
|
+
add_field(line)
|
138
|
+
self
|
139
|
+
end
|
140
|
+
alias_method :push, :<<
|
141
|
+
|
142
|
+
# a vCard 2.1 string representation of this vCard
|
143
|
+
# if :windows_line_endings => true then lines end with \r\n otherwise with \n
|
144
|
+
CRLF = "\r\n"
|
145
|
+
def to_vcard21(options = {})
|
146
|
+
line_ending = options[:windows_line_endings] ? CRLF : LF
|
147
|
+
fields.map { |field| field.encode21(options) }.join(line_ending)
|
148
|
+
end
|
149
|
+
|
150
|
+
# are all fields also present or supersets in other?
|
151
|
+
IGNORE_IN_SUBSET_COMPARISON = %w(BEGIN END FN)
|
152
|
+
def subset_of?(other)
|
153
|
+
fields.all? do |field|
|
154
|
+
if IGNORE_IN_SUBSET_COMPARISON.include?(field.name)
|
155
|
+
true
|
156
|
+
else
|
157
|
+
# puts "-----\n"
|
158
|
+
# puts "#{field} in #{other}?\n"
|
159
|
+
other.lines_with_name(field.name).any? do |f|
|
160
|
+
begin
|
161
|
+
# puts "considering #{f} ==> #{f.raw_value == field.raw_value or field.subset_of?(f)}"
|
162
|
+
f.raw_value == field.raw_value or field.subset_of?(f)
|
163
|
+
rescue NoMethodError
|
164
|
+
false
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
SINGLETON_FIELDS = %w(N FN BEGIN END VERSION)
|
172
|
+
# import all fields except N, FN, and VERSION from other (another Vcard)
|
173
|
+
# duplicate fields are deduped
|
174
|
+
def assimilate_fields_from!(other)
|
175
|
+
other.fields.each do |field|
|
176
|
+
next if SINGLETON_FIELDS.include? field.name.upcase
|
177
|
+
push(field)
|
178
|
+
end
|
179
|
+
clean_same_value_fields!
|
180
|
+
self
|
181
|
+
end
|
182
|
+
|
183
|
+
def add(name)
|
184
|
+
add_field(name + ":") do |field|
|
185
|
+
yield field if block_given?
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def add_email(address = nil)
|
190
|
+
add(EMAIL) do |email|
|
191
|
+
email.address = address.to_s
|
192
|
+
yield email if block_given?
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
def add_telephone(number = nil)
|
197
|
+
add(TEL) do |tel|
|
198
|
+
tel.number = number.to_s
|
199
|
+
yield tel if block_given?
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def name
|
204
|
+
@name ||= NameHandler.new(self)
|
205
|
+
end
|
206
|
+
|
207
|
+
ADR = "ADR"
|
208
|
+
BDAY = "BDAY"
|
209
|
+
CATEGORIES = "CATEGORIES"
|
210
|
+
EMAIL = "EMAIL"
|
211
|
+
IMPP = "IMPP"
|
212
|
+
LOGO = "LOGO"
|
213
|
+
NICKNAME = "NICKNAME"
|
214
|
+
TEL = "TEL"
|
215
|
+
NOTE = "NOTE"
|
216
|
+
ORG = "ORG"
|
217
|
+
PHOTO = "PHOTO"
|
218
|
+
TITLE = "TITLE"
|
219
|
+
URL = "URL"
|
220
|
+
def addresses; lines_with_name(ADR); end
|
221
|
+
def birthdays; lines_with_name(BDAY); end
|
222
|
+
def categories; lines_with_name(CATEGORIES); end
|
223
|
+
def emails; lines_with_name(EMAIL); end
|
224
|
+
def impps; lines_with_name(IMPP); end
|
225
|
+
def logos; lines_with_name(LOGO); end
|
226
|
+
def nicknames; lines_with_name(NICKNAME); end
|
227
|
+
def telephones; lines_with_name(TEL); end
|
228
|
+
def notes; lines_with_name(NOTE); end
|
229
|
+
def organizations; lines_with_name(ORG); end
|
230
|
+
alias_method :organisations, :organizations # Britania rules the waves!
|
231
|
+
def photos; lines_with_name(PHOTO); end
|
232
|
+
def titles; lines_with_name(TITLE); end
|
233
|
+
def urls; lines_with_name(URL); end
|
234
|
+
def custom_im_fields; @lines.select{|line| line.is_a? Virginity::Vcard::CustomImField}; end
|
235
|
+
|
236
|
+
XABRELATEDNAMES = "X-ABRELATEDNAMES"
|
237
|
+
def related_names; lines_with_name(XABRELATEDNAMES); end
|
238
|
+
XABDATE = "X-ABDATE"
|
239
|
+
def dates; lines_with_name(XABDATE); end
|
240
|
+
XANNIVERSARY = "X-ANNIVERSARY"
|
241
|
+
def anniversaries; lines_with_name(XANNIVERSARY); end
|
242
|
+
end
|
243
|
+
|
244
|
+
end
|