virginity 0.3.31
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.
- 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
|