2be164fb4cc4b696e5fc9e1d37b34d825ab3c73f
[platform/upstream/syncevolution.git] / test / synccompare.pl
1 #! /usr/bin/env perl
2 #
3 # Copyright (C) 2008 Funambol, Inc.
4 # Copyright (C) 2008-2009 Patrick Ohly <patrick.ohly@gmx.de>
5 # Copyright (C) 2009 Intel Corporation
6 #
7 # Usage: <file>
8 #        <left file> <right file>
9 # Either normalizes a file or compares two of them in a side-by-side
10 # diff.
11 #
12 # Checks environment variables:
13 #
14 # CLIENT_TEST_SERVER=funambol|scheduleworld|egroupware|synthesis
15 #       Enables code which simplifies the text files just like
16 #       certain well-known servers do. This is useful for testing
17 #       to ignore the data loss introduced by these servers or (for
18 #       users) to simulate the effect of these servers on their data.
19 #
20 # CLIENT_TEST_CLIENT=evolution|addressbook (Mac OS X/iPhone)
21 #       Same as for servers this replicates the effect of storing
22 #       data in the clients.
23 #
24 # CLIENT_TEST_LEFT_NAME="before sync"
25 # CLIENT_TEST_RIGHT_NAME="after sync"
26 # CLIENT_TEST_REMOVED="removed during sync"
27 # CLIENT_TEST_ADDED="added during sync"
28 #       Setting these variables changes the default legend
29 #       print above the left and right file during a
30 #       comparison.
31 #
32 # CLIENT_TEST_COMPARISON_FAILED=1
33 #       Overrides the default error code when changes are found.
34 #
35 # This program is free software; you can redistribute it and/or
36 # modify it under the terms of the GNU Lesser General Public
37 # License as published by the Free Software Foundation; either
38 # version 2.1 of the License, or (at your option) version 3.
39 #
40 # This program is distributed in the hope that it will be useful,
41 # but WITHOUT ANY WARRANTY; without even the implied warranty of
42 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
43 # Lesser General Public License for more details.
44 #
45 # You should have received a copy of the GNU Lesser General Public
46 # License along with this library; if not, write to the Free Software
47 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
48 # 02110-1301  USA
49
50
51 use strict;
52
53 # Various crashes have been encountered in the Perl interpreter
54 # executable when enabling UTF-8. It is only needed for nicer
55 # side-by-side comparison of changes (correct column width),
56 # so not much functionality is lost by disabling this.
57 # use encoding 'utf8';
58
59 # Instead enable writing the result as UTF-8. Input
60 # files are read as UTF-8 via PerlIO parameters in open().
61 binmode(STDOUT, ":utf8");
62
63 use Algorithm::Diff;
64 use MIME::Base64;
65 use Digest::MD5 qw(md5 md5_hex md5_base64);
66
67 # ignore differences caused by specific servers or local backends?
68 my $server = $ENV{CLIENT_TEST_SERVER};
69 my $client = $ENV{CLIENT_TEST_CLIENT} || "evolution";
70 my $scheduleworld = $server =~ /scheduleworld/;
71 my $synthesis = $server =~ /synthesis/;
72 my $zyb = $server =~ /zyb/;
73 my $mobical = $server =~ /mobical/;
74 my $memotoo = $server =~ /memotoo/;
75 my $nokia_7210c = $server =~ /nokia_7210c/;
76 my $ovi = $server =~ /Ovi/;
77 my $unique_uid = $ENV{CLIENT_TEST_UNIQUE_UID};
78 my $full_timezones = $ENV{CLIENT_TEST_FULL_TIMEZONES}; # do not simplify VTIMEZONE definitions
79
80 # TODO: this hack ensures that any synchronization is limited to
81 # properties supported by Synthesis. Remove this again.
82 # $synthesis = 1;
83
84 my $exchange = $server =~ /exchange/; # Exchange via ActiveSync
85 my $egroupware = $server =~ /egroupware/;
86 my $funambol = $server =~ /funambol/;
87 my $googlesyncml = $server eq "google";
88 my $googlecaldav = $server eq "googlecalendar";
89 my $googlecarddav = $server eq "googlecontacts";
90 my $googleeas = $server eq "googleeas";
91 my $google_valarm = $ENV{CLIENT_TEST_GOOGLE_VALARM};
92 my $yahoo = $server =~ /yahoo/;
93 my $davical = $server =~ /davical/;
94 my $apple = $server =~ /apple/;
95 my $oracle = $server =~ /oracle/;
96 my $radicale = $server =~ /radicale/;
97 my $zimbra = $server =~ /zimbra/;
98 my $evolution = $client =~ /evolution/;
99 my $addressbook = $client =~ /addressbook/;
100
101 sub Usage {
102   print "$0 <vcards.vcf\n";
103   print "   normalizes one file (stdin or single argument), prints to stdout\n";
104   print "$0 vcards1.vcf vcards2.vcf\n";
105   print "   compares the two files\n";
106   print "Also works for iCalendar files.\n";
107 }
108
109 sub uppercase {
110   my $text = shift;
111   $text =~ tr/a-z/A-Z/;
112   return $text;
113 }
114
115 sub sortlist {
116   my $list = shift;
117   return join(",", sort(split(/,/, $list)));
118 }
119
120 sub splitvalue {
121   my $prop = shift;
122   my $values = shift;
123   my $eol = shift;
124
125   my @res = ();
126   foreach my $val (split (/;/, $values)) {
127       push(@res, $prop, ":", $val, $eol);
128   }
129   return join("", @res);
130 }
131
132 # normalize the DATE-TIME duration unless the VALUE isn't a duration
133 sub NormalizeTrigger {
134     my $value = shift;
135     $value =~ /([+-]?)P(?:(\d*)D)?T(?:(\d*)H)?(?:(\d*)M)?(?:(\d*)S)?/;
136     my ($sign, $days, $hours, $minutes, $seconds) = ($1, int($2), int($3), int($4), int($5));
137     while ($seconds >= 60) {
138         $minutes++;
139         $seconds -= 60;
140     }
141     while ($minutes >= 60) {
142         $hours++;
143         $minutes -= 60;
144     }
145     while ($hours >= 24) {
146         $days++;
147         $hours -= 24;
148     }
149     $value = $sign;
150     $value .= ($days . "D") if $days;
151     $value .= ($hours . "H") if $hours;
152     $value .= ($minutes . "M") if $minutes;
153     $value .= ($seconds . "S") if $seconds;
154     return $value;
155 }
156
157 # decode base64 string, return size and hash
158 sub describeBase64 {
159     my $data = decode_base64($1);
160     return sprintf("%d b64 characters = %d bytes, %s md5sum", length($1), length($data), md5_hex($data));
161 }
162
163 # called for one VCALENDAR (with single VEVENT/VTODO/VJOURNAL) or VCARD,
164 # returns normalized one
165 sub NormalizeItem {
166     my $width = shift;
167     $_ = shift;
168
169     # Reduce \N to \n (both are allowed in vCard 3.0).
170     # Using a regular expression is a bit too broad
171     # because it also matches \\N, which must not be
172     # changed.
173     s/\\N/\\n/g;
174
175     # undo line continuation
176     s/\n\s//gs;
177     # ignore charset specifications, assume UTF-8
178     s/;CHARSET="?UTF-8"?//g;
179
180     # UID may differ, but only in vCards and journal entries:
181     # in calendar events the UID needs to be preserved to handle
182     # meeting invitations/replies correctly
183     s/((VCARD|VJOURNAL).*)^UID:[^\n]*\n/$1/msg;
184
185     # intentional changes to UID are acceptable when running with CLIENT_TEST_UNIQUE_UID
186     if ($unique_uid) {
187         s/UID:UNIQUE-UID-\d+-/UID:/g;
188     }
189
190     # merge all CATEGORIES properties into one comma-separated one
191     while ( s/^CATEGORIES:([^\n]*)\n(.*)^CATEGORIES:([^\n]*)\n/CATEGORIES:$1,$3\n$2/ms ) {}
192
193     # exact order of categories is irrelevant
194     s/^CATEGORIES:(\S+)/"CATEGORIES:" . sortlist($1)/mge;
195
196     # expand <foo> shortcuts to TYPE=<foo>
197     while (s/^(ADR|EMAIL|TEL)([^:\n]*);(HOME|OTHER|WORK|PARCEL|INTERNET|CAR|VOICE|CELL|PAGER)/$1;TYPE=$3/mg) {}
198
199     # the distinction between an empty and a missing property
200     # is vague and handled differently, so ignore empty properties
201     s/^[^:\n]*:;*\n//mg;
202
203     # use separate TYPE= fields
204     while( s/^(\w*[^:\n]*);TYPE=(\w*),(\w*)/$1;TYPE=$2;TYPE=$3/mg ) {}
205
206     # make TYPE uppercase (in vCard 3.0 at least those parameters are case-insensitive)
207     while( s/^(\w*[^:\n]*);TYPE=(\w*?[a-z]\w*?)([;:])/ $1 . ";TYPE=" . uppercase($2) . $3 /mge ) {}
208
209     # replace parameters with a sorted parameter list
210     s!^([^;:\n]*);(.*?):!$1 . ";" . join(';',sort(split(/;/, $2))) . ":"!meg;
211
212     # VALUE=DATE is the default, no need to show it
213     s/^(EXDATE|BDAY);VALUE=DATE:/\1:/mg;
214
215     # default opacity is OPAQUE
216     s/^TRANSP:OPAQUE\r?\n?//gm;
217
218     # multiple EXDATEs may be joined into one, use separate properties as normal form
219     s/^(EXDATE[^:]*):(.*)(\r?\n)/splitvalue($1, $2, $3)/mge;
220
221     # sort value lists of specific properties
222     s!^(RRULE.*):(.*)!$1 . ":" . join(';',sort(split(/;/, $2)))!meg;
223
224     # INTERVAL=1 is the default and thus can be removed
225     s/^RRULE(.*?);INTERVAL=1(;|$)/RRULE$1$2/mg;
226
227     # Ignore remaining "other" email, address and telephone type - this is
228     # an Evolution specific extension which might not be preserved.
229     s/^(ADR|EMAIL|TEL)([^:\n]*);TYPE=OTHER/$1$2/mg;
230     # TYPE=PREF on the other hand is not used by Evolution, but
231     # might be sent back.
232     s/^(ADR|EMAIL)([^:\n]*);TYPE=PREF/$1$2/mg;
233     # Evolution does not need TYPE=INTERNET for email
234     s/^(EMAIL)([^:\n]*);TYPE=INTERNET/$1$2/mg;
235     # ignore TYPE=PREF in address, does not matter in Evolution
236     s/^((ADR|LABEL)[^:\n]*);TYPE=PREF/$1/mg;
237     # ignore extra separators in multi-value fields
238     s/^((ORG|N|(ADR[^:\n]*?)):.*?);*$/$1/mg;
239     # the type of certain fields is ignore by Evolution
240     s/^X-(AIM|GROUPWISE|ICQ|YAHOO);TYPE=HOME/X-$1/gm;
241     # Evolution ignores an additional pager type
242     s/^TEL;TYPE=PAGER;TYPE=WORK/TEL;TYPE=PAGER/gm;
243     # PAGER property is sent by Evolution, but otherwise ignored
244     s/^LABEL[;:].*\n//mg;
245     # TYPE=VOICE is the default in Evolution and may or may not appear in the vcard;
246     # this simplification is a bit too agressive and hides the problematic
247     # TYPE=PREF,VOICE combination which Evolution does not handle :-/
248     s/^TEL([^:\n]*);TYPE=VOICE,([^:\n]*):/TEL$1;TYPE=$2:/mg;
249     s/^TEL([^:\n]*);TYPE=([^;:\n]*),VOICE([^:\n]*):/TEL$1;TYPE=$2$3:/mg;
250     s/^TEL([^:\n]*);TYPE=VOICE([^:\n]*):/TEL$1$2:/mg;
251     # don't care about the TYPE property of PHOTOs
252     s/^PHOTO;(.*)TYPE=[A-Z]*/PHOTO;$1/mg;
253     # encoding is not case sensitive, skip white space in the middle of binary data
254     if (s/^PHOTO;.*?ENCODING=(b|B|BASE64).*?:\s*/PHOTO;ENCODING=B: /mgi) {
255         if ($memotoo) {
256             # transcodes image data, can't compare it
257             s/(^PHOTO.*:).*/$1<stripped by synccompare>/mg;
258         } else {
259             while (s/^PHOTO(.*?): (\S+)[\t ]+(\S+)/PHOTO$1: $2$3/mg) {}
260         }
261     }
262     # Don't show base64 encoded PHOTO data (makes diff very long). Instead
263     # decode and show size + hash.
264     s/^PHOTO;ENCODING=B: (.*)$/"PHOTO: " . describeBase64($1)/mge;
265     # special case for the inlining of the local test case PHOTO
266     s!^PHOTO;;VALUE=uri:file://testcases/local.png$!PHOTO;;VALUE=uri:<local.png>!m;
267     s!^PHOTO;ENCODING=B: iVBORw0KGgoAAAANSUh.*UQOVkeH/aKBSLM04QlMqAAFNBTl\+CjN9AAAAAElFTkSuQmCC$!PHOTO;;VALUE=uri:<local.png>!m;
268     # ignore extra day factor in front of weekday
269     s/^RRULE:(.*)BYDAY=\+?1(\D)/RRULE:$1BYDAY=$2/mg;
270     # remove default VALUE=DATE-TIME
271     s/^(DTSTART|DTEND)([^:\n]*);VALUE=DATE-TIME/$1$2/mg;
272
273     # remove default LANGUAGE=en-US
274     s/^([^:\n]*);LANGUAGE=en-US/$1/mg;
275
276     # normalize values which look like a date to YYYYMMDD because the hyphen is optional
277     s/:(\d{4})-(\d{2})-(\d{2})/:$1$2$3/g;
278
279     # mailto is case insensitive
280     s/^((ATTENDEE|ORGANIZER).*):[Mm][Aa][Ii][Ll][Tt][Oo]:/$1:mailto:/mg;
281
282     # remove fields which may differ
283     s/^(PRODID|CREATED|DTSTAMP|LAST-MODIFIED|REV)(;X-VOBJ-FLOATINGTIME-ALLOWED=(TRUE|FALSE))?:.*\r?\n?//gm;
284     # remove optional fields
285     s/^(METHOD|X-WSS-[A-Z]*|X-WR-[A-Z]*|CALSCALE):.*\r?\n?//gm;
286
287     # trailing line break(s) in a DESCRIPTION may or may not be
288     # removed or added by servers
289     s/^DESCRIPTION:(.*?)(\\n)+$/DESCRIPTION:$1/gm;
290
291     # use the shorter property name when there are alternatives,
292     # but avoid duplicates
293     foreach my $i ("SPOUSE", "MANAGER", "ASSISTANT", "ANNIVERSARY") {
294         if (/^X-\Q$i\E:(.*?)$/m) {
295             s/^X-EVOLUTION-\Q$i\E:\Q$1\E\n//m;
296         }
297     }
298     s/^X-EVOLUTION-(SPOUSE|MANAGER|ASSISTANT|ANNIVERSARY)/X-$1/gm;
299
300     # some properties are always lost because we don't transmit them
301     if ($ENV{CLIENT_TEST_SERVER}) {
302         s/^(X-FOOBAR-EXTENSION|X-TEST)(;[^:;\n]*)*:.*\r?\n?//gm;
303     }
304
305     # if there is no DESCRIPTION in a VJOURNAL, then use the
306     # summary: that's what is done when exchanging such a
307     # VJOURNAL as plain text
308     if (/^BEGIN:VJOURNAL$/m && !/^DESCRIPTION/m) {
309         s/^SUMMARY:(.*)$/SUMMARY:$1\nDESCRIPTION:$1/m;
310     }
311
312     # strip configurable X- parameters or properties
313     my $strip = $ENV{CLIENT_TEST_STRIP_PROPERTIES};
314     if ($strip) {
315         s/^$strip(;[^:;\n]*)*:.*\r?\n?//gm;
316     }
317     $strip = $ENV{CLIENT_TEST_STRIP_PARAMETERS};
318     if ($strip) {
319         while (s/^(\w+)([^:\n]*);$strip=\d+/$1$2/mg) {}
320     }
321
322     # strip redundant VTIMEZONE definitions (happen to be
323     # added by Google CalDAV server when storing an all-day event
324     # which doesn't need any time zone definition)
325     # http://code.google.com/p/google-caldav-issues/issues/detail?id=63
326     while (m/(BEGIN:VTIMEZONE.*?TZID:([^\n]*)\n.*?END:VTIMEZONE\n)/gs) {
327         my $def = $1;
328         my $tzid = $2;
329         # used as parameter?
330         if (! m/;TZID="?\Q$tzid\E"?/) {
331             # no, remove definition
332             s!\Q$def\E!!s;
333         }
334     }
335
336     if (!$full_timezones) {
337         # Strip trailing digits from TZID. They are appended by
338         # Evolution and SyncEvolution to distinguish VTIMEZONE
339         # definitions which have the same TZID, but different rules.
340         # This appending of digits may even get repeated, leading to:
341         # TZID=EST/EDT 1 1
342         s/(^TZID:|;TZID=)([^;:]*?)( \d+)+/$1$2/gm;
343
344         # Strip trailing -(Standard) from TZID. Evolution 2.24.5 adds
345         # that (not sure exactly where that comes from).
346         s/(^TZID:|;TZID=)([^;:]*?)-\(Standard\)/$1$2/gm;
347
348         # VTIMEZONE and TZID do not have to be preserved verbatim as long
349         # as the replacement is still representing the same timezone.
350         # Reduce TZIDs which specify a proper location
351         # to their location part and strip the VTIMEZONE - makes the
352         # diff shorter, too.
353         my $location = "[^\n]*((?:Africa|America|Antarctica|Arctic|Asia|Atlantic|Australia|Brazil|Canada|Chile|Egypt|Eire|Europe|Hongkong|Iceland|India|Iran|Israel|Jamaica|Japan|Kwajalein|Libya|Mexico|Mideast|Navajo|Pacific|Poland|Portugal|Singapore|Turkey|Zulu)[-a-zA-Z0-9_/]*)";
354         s;^BEGIN:VTIMEZONE.*?^TZID:$location.*^END:VTIMEZONE;BEGIN:VTIMEZONE\n  TZID:$1 [...]\nEND:VTIMEZONE;gms;
355         s;TZID="?$location"?;TZID=$1;gm;
356     }
357
358     # normalize iCalendar 2.0
359     if (/^BEGIN:(VEVENT|VTODO|VJOURNAL)$/m) {
360         # CLASS=PUBLIC is the default, no need to show it
361         s/^CLASS:PUBLIC\r?\n//m;
362         # RELATED=START is the default behavior
363         s/^TRIGGER([^\n:]*);RELATED=START/TRIGGER$1/mg;
364         # VALUE=DURATION is the default behavior
365         s/^TRIGGER([^\n:]*);VALUE=DURATION/TRIGGER$1/mg;
366         s/^(TRIGGER.*):(\S*)/$1 . ":" . NormalizeTrigger($2)/mge;
367     }
368
369     # Added by EDS >= 2.32, presumably to cache some internal computation.
370     # Because it can be recreated, it doesn't have to be preserved during
371     # sync and such changes can be ignored:
372     #
373     # RRULE:BYDAY=SU;COUNT=10;FREQ=WEEKLY  |   RRULE;X-EVOLUTION-ENDDATE=20080608T 
374     #                                      >    070000Z:BYDAY=SU;COUNT=10;FREQ=WEEK
375     #                                      >    LY                                 
376     s/^(\w+)([^:\n]*);X-EVOLUTION-ENDDATE=[0-9TZ]*/$1$2/mg;
377
378     if ($scheduleworld || $egroupware || $synthesis || $addressbook || $funambol ||$googlesyncml || $googleeas || $googlecarddav || $mobical || $memotoo || $zimbra) {
379       # does not preserve X-EVOLUTION-UI-SLOT=
380       s/^(\w+)([^:\n]*);X-EVOLUTION-UI-SLOT=\d+/$1$2/mg;
381     }
382
383     if ($scheduleworld) {
384       # cannot distinguish EMAIL types
385       s/^EMAIL;TYPE=\w*/EMAIL/mg;
386       # replaces certain TZIDs with more up-to-date ones
387       s;TZID(=|:)/(scheduleworld.com|softwarestudio.org)/Olson_\d+_\d+/;TZID$1/foo.com/Olson_20000101_1/;mg;
388     }
389
390     if ($synthesis || $mobical) {
391       # only preserves ORG "Company", but loses "Department" and "Office"
392       s/^ORG:([^;:\n]+)(;[^\n]*)/ORG:$1/mg;
393     }
394
395     if ($funambol) {
396       # only preserves ORG "Company";"Department", but loses "Office"
397       s/^ORG:([^;:\n]+)(;[^;:\n]*)(;[^\n]*)/ORG:$1$2/mg;
398       # drops the second address line
399       s/^ADR(.*?):([^;]*?);[^;]*?;/ADR$1:$2;;/mg;
400       # has no concept of "preferred" phone number
401       s/^(TEL.*);TYPE=PREF/$1/mg;
402     }
403
404    if($googlesyncml || $googleeas || $googlecarddav) {
405       # ignore the PHOTO encoding data
406       s/^PHOTO(.*?): .*\n/PHOTO$1: [...]\n/mg;
407    }
408
409    if($googlesyncml || $googlecarddav) {
410       # FN property gets synthesized by Google.
411       s/^FN:.*\n/FN$1: [...]\n/mg;
412    }
413
414    if ($googlecarddav) {
415        # Adds .X-ABLabel to URL, TEL, etc. to represent custom
416        # labels. TODO: support that in SyncEvolution
417        s/^.*\.X-ABLabel(;[^:;\n]*)*:.*\r?\n?//mg;
418
419        # Ignore groups. TODO: support that in SyncEvolution.
420        s/^[a-zA-Z0-9]+\.//mg;
421    }
422
423    if ($googlesyncml) {
424       # Not support car type in telephone
425       s!^TEL\;TYPE=CAR(.*)\n!TEL$1\n!mg;
426       # some properties are lost
427       s/^(X-EVOLUTION-FILE-AS|NICKNAME|BDAY|CATEGORIES|CALURI|FBURL|GEO|ROLE|URL|X-AIM|X-EVOLUTION-UI-SLOT|X-ANNIVERSARY|X-ASSISTANT|X-EVOLUTION-BLOG-URL|X-EVOLUTION-VIDEO-URL|X-GROUPWISE|X-ICQ|X-GADUGADU|X-JABBER|X-MSN|X-SIP|X-SKYPE|X-MANAGER|X-SPOUSE|X-MOZILLA-HTML|X-YAHOO)(;[^:;\n]*)*:.*\r?\n?//gm;
428    }
429
430    if ($googlecaldav) {
431       #several properties are not preserved by Google in icalendar2.0 format
432       s/^(SEQUENCE|X-EVOLUTION-ALARM-UID|TRANSP)(;[^:;\n]*)*:.*\r?\n?//gm;
433
434       # Google adds calendar owner as attendee of meetings, regardless
435       # whether it was on the original attendee list. Ignore this
436       # during testing by removing all attendees with @googlemail.com
437       # email address.
438       s/^ATTENDEE.*googlemail.com\r?\n//gm;
439     }
440
441     if ($apple) {
442         # remove some parameters added by Apple Calendar server in CalDAV
443         s/^(ORGANIZER[^:]*);SCHEDULE-AGENT=NONE/$1/gm;
444         s/^(ORGANIZER[^:]*);SCHEDULE-STATUS=5.3/$1/gm;
445         # seems to require a fixed number of recurrences; hmm, okay...
446         s/^RRULE:COUNT=400;FREQ=DAILY/RRULE:FREQ=DAILY/gm;
447     }
448
449     if ($oracle) {
450         # remove extensions added by server
451         s/^(X-S1CS-RECURRENCE-COUNT)(;[^:;\n]*)*:.*\r?\n?//gm;
452         # ignore loss of LANGUAGE=xxx property in ATTENDEE
453         s/^ATTENDEE([^\n:]*);LANGUAGE=([^\n;:]*)/ATTENDEE$1/mg;
454     }
455
456     if ($radicale) {
457         # remove extensions added by server
458         s/^(X-RADICALE-NAME)(;[^:;\n]*)*:.*\r?\n?//gm;
459     }
460
461     if ($googlecaldav || $yahoo) {
462       # default status is CONFIRMED
463       s/^STATUS:CONFIRMED\r?\n?//gm;
464     }
465
466     # Google randomly (?!) adds a standard alarm to events.
467     if ($google_valarm) {
468         s/BEGIN:VALARM\nDESCRIPTION:This is an event reminder\nACTION:DISPLAY\nTRIGGER:-PT10M\n(X-KDE-KCALCORE-ENABLED:TRUE\n)END:VALARM\n//s;
469     }
470
471     if ($yahoo) {
472         s/^(X-MICROSOFT-[-A-Z0-9]*)(;[^:;\n]*)*:.*\r?\n?//gm;
473         # some properties cannot be stored
474         s/^(FN)(;[^:;\n]*)*:.*\r?\n?//gm;
475     }
476
477     if ($addressbook) {
478       # some properties cannot be stored
479       s/^(X-MOZILLA-HTML|X-EVOLUTION-FILE-AS|X-EVOLUTION-ANNIVERSARY|X-EVOLUTION-BLOG-URL|X-EVOLUTION-VIDEO-URL|X-GROUPWISE|ROLE|CATEGORIES|FBURL|CALURI|FN)(;[^:;\n]*)*:.*\r?\n?//gm;
480       # only some parts of ADR are preserved
481       my $type;
482       s/^ADR(.*?)\:(.*)/$type=($1 || ""); @_ = split(\/(?<!\\);\/, $2); "ADR:;;" . ($_[2] || "") . ";" . ($_[3] || "") . ";" . ($_[4] || "") . ";" . ($_[5] || "") . ";" . ($_[6] || "")/gme;
483       # TYPE=CAR not supported
484       s/;TYPE=CAR//g;
485     }
486
487     if ($synthesis) {
488       # does not preserve certain properties
489       s/^(FN|GEO|BDAY|X-MOZILLA-HTML|X-EVOLUTION-FILE-AS|X-AIM|NICKNAME|UID|PHOTO|CALURI|SEQUENCE|TRANSP|ORGANIZER|ROLE|FBURL|X-ANNIVERSARY|X-ASSISTANT|X-EVOLUTION-BLOG-URL|X-EVOLUTION-VIDEO-URL|X-GADUGADU|X-GROUPWISE|X-ICQ|X-JABBER|X-MANAGER|X-MSN|X-SIP|X-SKYPE|X-SPOUSE|X-YAHOO)(;[^:;\n]*)*:.*\r?\n?//gm;
490       # default ADR is HOME
491       s/^ADR;TYPE=HOME/ADR/gm;
492       # only some parts of N are preserved
493       s/^N((?:;[^;:]*)*)\:(.*)/@_ = split(\/(?<!\\);\/, $2); "N$1:$_[0];" . ($_[1] || "") . ";;" . ($_[3] || "")/gme;
494       # breaks lines at semicolons, which adds white space
495       while( s/^ADR:(.*); +/ADR:$1;/gm ) {}
496       # no attributes stored for ATTENDEEs
497       s/^ATTENDEE;.*?:/ATTENDEE:/msg;
498     }
499
500     if ($synthesis) {
501       # VALARM not supported
502       s/^BEGIN:VALARM.*?END:VALARM\r?\n?//msg;
503     }
504
505     if ($egroupware) {
506       # CLASS:PUBLIC is added if none exists (as in our test cases),
507       # several properties not preserved
508       s/^(BDAY|CATEGORIES|FBURL|PHOTO|FN|X-[A-Z-]*|CALURI|CLASS|NICKNAME|UID|TRANSP|PRIORITY|SEQUENCE)(;[^:;\n]*)*:.*\r?\n?//gm;
509       # org gets truncated
510       s/^ORG:([^;:\n]*);.*/ORG:$1/gm;
511     }
512
513     if ($funambol) {
514       # several properties are not preserved
515       s/^(CALURI|FBURL|GEO|X-MOZILLA-HTML|X-EVOLUTION-FILE-AS|X-AIM|X-EVOLUTION-BLOG-URL|X-EVOLUTION-VIDEO-URL|X-GROUPWISE|X-ICQ|X-YAHOO|X-GADUGADU|X-JABBER|X-MSN|X-SIP|X-SKYPE|X-ASSISTANT)(;[^:;\n]*)*:.*\r?\n?//gm;
516
517       # quoted-printable line breaks are =0D=0A, not just single =0A
518       s/(?<!=0D)=0A/=0D=0A/g;
519       # only three email addresses, fourth one from test case gets lost
520       s/^EMAIL:john.doe\@yet.another.world\n\r?//mg;
521       # this particular type is not preserved
522       s/ADR;TYPE=PARCEL:Test Box #3/ADR;TYPE=HOME:Test Box #3/;
523     }
524     if ($funambol) {
525       #several properties are not preserved by funambol server in icalendar2.0 format
526       s/^(UID|SEQUENCE|TRANSP|LAST-MODIFIED|X-EVOLUTION-ALARM-UID)(;[^:;\n]*)*:.*\r?\n?//gm;
527       if (/^BEGIN:VEVENT/m ) {
528         #several properties are not preserved by funambol server in itodo2.0 format and
529         s/^(RECURRENCE-ID|ATTENDEE)(;[^:;\n]*)*:.*\r?\n?//gm;
530         #REPEAT:0 is added by funambol server so ignore it
531         s/^(REPEAT:0).*\r?\n?//gm;
532         #CN parameter is lost by funambol server
533         s/^ORGANIZER([^:\n]*);CN=([^:\n]*)(;[^:\n])*:(.*\r?\n?)/ORGANIZER$1$3:$4/mg;
534       }
535
536       if (/^BEGIN:VTODO/m ) {
537         #several properties are not preserved by funambol server in itodo2.0 format and
538         s/^(STATUS|URL)(;[^:;\n]*)*:.*\r?\n?//gm;
539
540         #some new properties are added by funambol server
541         s/^(CLASS:PUBLIC|PERCENT-COMPLETE:0).*\r?\n?//gm;
542       }
543     }
544
545     if($nokia_7210c) {
546         if (/BEGIN:VCARD/m) {
547             #ignore PREF, as it will added by default
548             s/^TEL([^:\n]*);TYPE=PREF/TEL$1/mg;
549             #remove non-digit prefix in TEL
550             s/^TEL([^:\n]*):(\D*)/TEL$1:/mg;
551             #properties N mismatch, sometimes lost part of components
552             s/^(N|X-EVOLUTION-FILE-AS):.*\r?\n?/$1:[...]\n/gm;
553             #strip spaces in 'NOTE'
554             while (s/^(NOTE|DESCRIPTION):(\S+)[\t ]+(\S+)/$1:$2$3/mg) {}
555             #preserve 80 chars in NOTE
556             s/^NOTE:(.{70}).*\r?\n?/NOTE:$1\n/mg;
557             #preserve one ADDR
558
559             # ignore the PHOTO encoding data, sometimes it add a default photo
560             s/^PHOTO(.*?): .*\n//mg; 
561             #s/^(ADR)([^:;\n]*)(;TYPE=[^:\n]*)?:.*\r?\n?/$1:$4\n/mg;
562
563             #lost properties
564             s/^(NICKNAME|CATEGORIES|CALURI|FBURL|ROLE|X-AIM|X-ANNIVERSARY|X-ASSISTANT|X-EVOLUTION-BLOG-URL|X-EVOLUTION-VIDEO-URL|X-GROUPWISE|X-ICQ|X-MANAGER|X-SPOUSE|X-MOZILLA-HTML|X-YAHOO)(;[^:;\n]*)*:.*\r?\n?//gm;
565         }
566
567         if (/^BEGIN:VEVENT/m ) {
568             #The properties phones add by default
569             s/^(PRIORITY|CATEGORIES)(;[^:;\n]*)*:.*\r?\n?//gm;
570             #strip spaces in 'DESCRIPTION'
571             while (s/^DESCRIPTION:(\S+)[\t ]+(\S+)/DESCRIPTION:$1$2/mg) {}
572
573         }
574
575         if (/^BEGIN:VTODO/m) {
576             #mismatch properties
577             s/^(PRIORITY)(;[^:;\n]*)*:.*\r?\n?/$1:[...]\n/gm;
578             #lost properties
579             s/^(STATUS|DTSTART|CATEGORIES)(;[^:;\n]*)*:.*\r?\n?//gm;
580         }
581
582         #Testing with phones using vcalendar, do not support UID
583         s/^(UID|CLASS|SEQUENCE|TRANSP)(;[^:;\n]*)*:.*\r?\n?//gm;
584     }
585
586     if ($ovi) {
587         if (/^BEGIN:VCARD/m) {
588             #lost properties
589             s/^(X-AIM|CALURI|URL|FBURL|PHOTO|EMAIL)(;[^:;\n]*)*:.*\r?\n?//gm;
590             #FN value mismatch (reordring and adding , by the server)
591             s/^FN:.*\r?\n?/FN:[...]\n/gm;
592             #X-EVOLUTION-FILE-AS adding '\' by the server
593             while (s/^X-EVOLUTION-FILE-AS:(.*)\\(.*)/X-EVOLUTION-FILE-AS:$1$2/gm) {}
594
595             # does not preserve X-EVOLUTION-UI-SLOT=
596             s/^(\w+)([^:\n]*);X-EVOLUTION-UI-SLOT=\d+/$1$2/mg;
597
598             # does not preserve third ADR
599             s/^ADR:Test Box #3.*\n\r?//mg;
600         }
601
602         if (/^BEGIN:VEVENT/m) {
603             #Testing with vcalendar, do not support UID
604             s/^(UID|SEQUENCE|TRANSP)(;[^:;\n]*)*:.*\r?\n?//gm;
605             #Add PRORITY by default
606             s/^(PRIORITY)(;[^:;\n]*)*:.*\r?\n?//gm;
607             # VALARM not supported
608             s/^BEGIN:VALARM.*?END:VALARM\r?\n?//msg;
609         }
610
611         if (/^BEGIN:VTODO/m) {
612             #Testing with vcalendar, do not support UID
613             s/^(UID|SEQUENCE|PERCENT-COMPLETE)(;[^:;\n]*)*:.*\r?\n?//gm;
614             #Mismatch DTSTART, COMPLETED
615             s/^(DTSTART|COMPLETED)(;[^:;\n]*)*:.*\r?\n?/$1:[...]\n/gm;
616         }
617     }
618
619     if ($funambol || $egroupware || $nokia_7210c) {
620       # NOTE may be truncated due to length resistrictions
621       s/^(NOTE(;[^:;\n]*)*:.{0,160}).*(\r?\n?)/$1$3/gm;
622     }
623     if ($memotoo) {
624       if (/^BEGIN:VCARD/m ) {
625         s/^(FN|FBURL|GEO|CALURI|ROLE|X-MOZILLA-HTML|X-EVOLUTION-BLOG-URL|X-EVOLUTION-VIDEO-URL|X-GADUGADU|X-JABBER|X-MSN|X-SIP|X-SKYPE|X-GROUPWISE)(;[^:;\n]*)*:.*\r?\n?//gm;
626         # s/^(FN|FBURL|CALURI|CATEGORIES|ROLE|X-MOZILLA-HTML|X-EVOLUTION-FILE-AS|X-EVOLUTION-BLOG-URL|X-EVOLUTION-VIDEO-URL|X-GADUGADU|X-JABBER|X-MSN|X-SIP|X-SKYPE|X-GROUPWISE)(;[^:;\n]*)*:.*\r?\n?//gm;
627         # strip 'TYPE=HOME' 
628         s/^URL([^\n:]*);TYPE=HOME/URL$1/mg;
629         s/^EMAIL([^\n:]*);TYPE=HOME/EMAIL$1/mg;
630       }
631       if (/^BEGIN:VEVENT/m ) {
632         s/^(UID|SEQUENCE|TRANSP|RECURRENCE-ID|X-EVOLUTION-ALARM-UID|ORGANIZER)(;[^:;\n]*)*:.*\r?\n?//gm;
633         # some parameters of 'ATTENDEE' will be lost by server
634         s/^ATTENDEE([^\n:]*);CUTYPE=([^\n;:]*)/ATTENDEE$1/mg;
635         s/^ATTENDEE([^\n:]*);LANGUAGE=([^\n;:]*)/ATTENDEE$1/mg;
636         s/^ATTENDEE([^\n:]*);ROLE=([^\n;:]*)/ATTENDEE$1/mg;
637         s/^ATTENDEE([^\n:]*);RSVP=([^\n;:]*)/ATTENDEE$1/mg;
638         s/^ATTENDEE([^\n:]*);CN=([^\n;:]*)/ATTENDEE$1/mg;
639         s/^ATTENDEE([^\n:]*);PARTSTAT=([^\n;:]*)/ATTENDEE$1/mg;
640         if (/^BEGIN:VALARM/m ) {
641             s/^(DESCRIPTION)(;[^:;\n]*)*:.*\r?\n?//mg;
642         }
643       }
644       if (/^BEGIN:VTODO/m ) {
645         s/^(UID|SEQUENCE|URL|CLASS|PRIORITY)(;[^:;\n]*)*:.*\r?\n?//gm;
646         s/^PERCENT-COMPLETE:0\r?\n?//gm;
647       }
648     }
649     if ($mobical) {
650       s/^(CALURI|CATEGORIES|FBURL|GEO|NICKNAME|X-MOZILLA-HTML|X-EVOLUTION-FILE-AS|X-ANNIVERSARY|X-ASSISTANT|X-EVOLUTION-BLOG-URL|X-EVOLUTION-VIDEO-URL|X-GROUPWISE|X-ICQ|X-GADUGADU|X-JABBER|X-MSN|X-SIP|X-SKYPE|X-MANAGER|X-SPOUSE|X-YAHOO|X-AIM)(;[^:;\n]*)*:.*\r?\n?//gm;
651
652       # some workrounds here for mobical's bug 
653       s/^(FN|BDAY)(;[^:;\n]*)*:.*\r?\n?//gm;
654
655       if (/^BEGIN:VEVENT/m ) {
656         s/^(UID|SEQUENCE|CLASS|TRANSP|RECURRENCE-ID|ATTENDEE|ORGANIZER|AALARM|DALARM)(;[^:;\n]*)*:.*\r?\n?//gm;
657       }
658
659       if (/^BEGIN:VTODO/m ) {
660         s/^(UID|SEQUENCE|DTSTART|URL|PERCENT-COMPLETE|CLASS)(;[^:;\n]*)*:.*\r?\n?//gm;
661         s/^PRIORITY:0\r?\n?//gm;
662       }
663     }
664
665     if ($zyb) {
666         s/^(CALURI|CATEGORIES|FBURL|NICKNAME|X-MOZILLA-HTML|PHOTO|X-EVOLUTION-FILE-AS|X-ANNIVERSARY|X-ASSISTANT|X-EVOLUTION-BLOG-URL|X-EVOLUTION-VIDEO-URL|X-GROUPWISE|X-ICQ|X-MANAGER|X-SPOUSE|X-YAHOO|X-AIM)(;[^:;\n]*)*:.*\r?\n?//gm;
667     }
668
669     if ($exchange) {
670         # unsupported properties
671         s/^(SEQUENCE|X-EVOLUTION-ALARM-UID)(;[^:;\n]*)*:.*\r?\n?//gm;
672         # added properties which can be ignored (?)
673         s/^(X-MEEGO-ACTIVESYNCD-[a-zA-Z]*)(;[^:;\n]*)*:.*\r?\n?//gm;
674         # ORGANIZER added - remove and thus ignore if we have no ATTENDEEs
675         if (!/^ATTENDEE/m) {
676             s/^(ORGANIZER)(;[^:;\n]*)*:.*\r?\n?//gm;
677         }
678         # ignore added VALARM DESCRIPTION
679         s/^DESCRIPTION:Reminder\n//m;
680     }
681
682     if ($googleeas) {
683         # properties not supported by Google
684         s/^(X-EVOLUTION-FILE-AS|CATEGORIES)(;[^:;\n]*)*:.*\r?\n?//gm;
685     }
686
687     if ($googleeas || $exchange) {
688         # properties not supported by ActiveSync
689         s/^(FN)(;[^:;\n]*)*:.*\r?\n?//gm;
690     }
691
692     if ($googleeas || $exchange) {
693         # properties not supported by ActiveSync and/or activesyncd
694         s/^(GEO)(;[^:;\n]*)*:.*\r?\n?//gm;
695     }
696
697     if ($googleeas || $exchange) {
698         # temporarily ignore modified properties
699         s/^(BDAY|X-ANNIVERSARY)(;[^:;\n]*)*:.*\r?\n?//gm;
700     }
701
702     # treat X-MOZILLA-HTML=FALSE as if the property didn't exist
703     s/^X-MOZILLA-HTML:FALSE\r?\n?//gm;
704
705     my @formatted = ();
706
707     # Modify lines to cover not more than
708     # $width characters by folding lines (as done for the N or SUMMARY above),
709     # but also indent each inner BEGIN/END block by 2 spaces
710     # and finally sort the lines.
711     # We need to keep a stack of open blocks in @formatted:
712     # - BEGIN creates another open block
713     # - END closes it, sorts it, and adds as single string to the parent block
714     push @formatted, [];
715     foreach $_ (split /\n/, $_) {
716       if (/^BEGIN:/) {
717         # start a new block
718         push @formatted, [];
719       }
720
721       my $spaces = "  " x ($#formatted - 1);
722       my $thiswidth = $width -1 - length($spaces);
723       $thiswidth = 1 if $thiswidth <= 0;
724       s/(.{$thiswidth})(?!$)/$1\n /g;
725       s/^(.*)$/$spaces$1/mg;
726       push @{$formatted[$#formatted]}, $_;
727
728       if (/^\s*END:/) {
729         my $block = pop @formatted;
730         my $begin = shift @{$block};
731         my $end = pop @{$block};
732
733         # Keep begin/end as first/last line,
734         # inbetween sort, but so that N or SUMMARY are
735         # at the top. This ensures that the order of items
736         # is the same, even if individual properties differ.
737         # Also put indented blocks at the end, not the top.
738         sub numspaces {
739           my $str = shift;
740           $str =~ /^(\s*)/;
741           return length($1);
742         }
743         $_ = join("\n",
744                   $begin,
745                   sort( { $a =~ /^\s*(N|SUMMARY):/ ? -1 :
746                           $b =~ /^\s*(N|SUMMARY):/ ? 1 :
747                           ($a =~ /^\s/ && $b =~ /^\S/) ? 1 :
748                           numspaces($a) == numspaces($b) ? $a cmp $b :
749                           numspaces($a) - numspaces($b) }
750                         @{$block} ),
751                   $end);
752         push @{$formatted[$#formatted]}, $_;
753       }
754     }
755
756     return ${$formatted[0]}[0];
757 }
758
759 # parameters: text, width to use for reformatted lines
760 # returns list of lines without line breaks
761 sub Normalize {
762     $_ = shift;
763     my $width = shift;
764
765     s/\r//g;
766
767     my @items = ();
768
769     # split into individual items
770     foreach $_ ( split( /(?:(?<=\nEND:VCARD)|(?<=\nEND:VCALENDAR))\n*/ ) ) {
771         if (/END:VEVENT\s+BEGIN:VEVENT/s) {
772             # remove multiple events from calendar item
773             s/(BEGIN:VEVENT.*END:VEVENT\n)//s;
774             my $events = $1;
775             my $calendar = $_;
776             my $event;
777             # inject every single one back into the calendar and process the result
778             foreach $event ( split ( /(?:(?<=\nEND:VEVENT))\n*/, $events ) ) {
779                 $_ = $calendar;
780                 s/\nEND:VCALENDAR/\n$event\nEND:VCALENDAR/;
781                 push @items, NormalizeItem($width, $_);
782             }
783         } else {
784             # already a single item
785             push @items, NormalizeItem($width, $_);
786         }
787     }
788
789     return split( /\n/, join( "\n\n", sort @items ));
790 }
791
792 # number of columns available for output:
793 # try tput without printing the shells error if not found,
794 # default to 80
795 my $columns = `which tput >/dev/null 2>/dev/null && tput 2>/dev/null && tput cols`;
796 if ($? || !$columns) {
797   $columns = 80;
798 }
799
800 if($#ARGV > 1) {
801   # error
802   Usage();
803   exit 1;
804 } elsif($#ARGV == 1) {
805   # comparison
806
807   my ($file1, $file2) = ($ARGV[0], $ARGV[1]);
808
809   my $singlewidth = int(($columns - 3) / 2);
810   $columns = $singlewidth * 2 + 3;
811   my @normal1;
812   my @normal2;
813
814   if (-d $file1 && -d $file2) {
815       # Both "files" are really directories of individual files.
816       # Don't include files in the comparison which are known
817       # to be identical because the refer to the same inode.
818       # - build map from inode to filename(s) (each inode might be used more than once!)
819       my %files1;
820       my %files2;
821       my @content1;
822       my @content2;
823       my $inode;
824       my $fullname;
825       my $entry;
826       opendir(my $dh, $file1) || die "cannot read $file1: $!";
827       foreach $entry (grep { -f "$file1/$_" } readdir($dh)) {
828           $fullname = "$file1/$entry";
829           $inode = (stat($fullname))[1];
830           if (!$files1{$inode}) {
831               $files1{$inode} = [];
832           }
833           push(@{$files1{$inode}}, $entry);
834       }
835       closedir($dh);
836       # - remove common files, read others
837       opendir(my $dh, $file2) || die "cannot read $file2: $!";
838       foreach $entry (grep { -f "$file2/$_" } readdir($dh)) {
839           $fullname = "$file2/$entry";
840           $inode = (stat($fullname))[1];
841           if (@{$files1{$inode}}) {
842               # randomly match against the last file
843               pop @{$files1{$inode}};
844           } else {
845               open(IN, "<:utf8", "$fullname") || die "$fullname: $!";
846               push @content2, <IN>;
847           }
848       }
849       # - read remaining entries from first dir
850       foreach my $array (values %files1) {
851           foreach $entry (@{$array}) {
852               $fullname = "$file1/$entry";
853               open(IN, "<:utf8", "$fullname") || die "$fullname: $!";
854               push @content1, <IN>;
855           }
856       }
857       my $content1 = join("", @content1);
858       my $content2 = join("", @content2); 
859       @normal1 = Normalize($content1, $singlewidth);
860       @normal2 = Normalize($content2, $singlewidth);
861   } else {
862       if (-d $file1) {
863           open(IN1, "-|:utf8", "find $file1 -type f -print0 | xargs -0 cat") || die "$file1: $!";
864       } else {
865           open(IN1, "<:utf8", $file1) || die "$file1: $!";
866       }
867       if (-d $file2) {
868           open(IN2, "-|:utf8", "find $file2 -type f -print0 | xargs -0 cat") || die "$file2: $!";
869       } else {
870           open(IN2, "<:utf8", $file2) || die "$file2: $!";
871       }
872       my $buf1 = join("", <IN1>);
873       my $buf2 = join("", <IN2>);
874       @normal1 = Normalize($buf1, $singlewidth);
875       @normal2 = Normalize($buf2, $singlewidth);
876       close(IN1);
877       close(IN2);
878   }
879
880   # Produce output where each line is marked as old (aka remove) with o,
881   # as new (aka added) with n, and as unchanged with u at the beginning.
882   # This allows simpler processing below.
883   my $res = 0;
884   if (0) {
885     # $_ = `diff "--old-line-format=o %L" "--new-line-format=n %L" "--unchanged-line-format=u %L" "$normal1" "$normal2"`;
886     # $res = $?;
887   } else {
888     # convert into same format as diff above - this allows reusing the
889     # existing output formatting code
890     my $diffs_ref = Algorithm::Diff::sdiff(\@normal1, \@normal2);
891     @_ = ();
892     my $hunk;
893     foreach $hunk ( @{$diffs_ref} ) {
894       my ($type, $left, $right) = @{$hunk};
895       if ($type eq "-") {
896         push @_, "o $left";
897         $res = 1;
898       } elsif ($type eq "+") {
899         push @_, "n $right";
900         $res = 1;
901       } elsif ($type eq "c") {
902         push @_, "o $left";
903         push @_, "n $right";
904         $res = 1;
905       } else {
906         push @_, "u $left";
907       }
908     }
909
910     $_ = join("\n", @_);
911   }
912
913   if ($res) {
914     print $ENV{CLIENT_TEST_HEADER};
915     printf "%*s | %s\n", $singlewidth,
916            ($ENV{CLIENT_TEST_LEFT_NAME} || "before sync"),
917            ($ENV{CLIENT_TEST_RIGHT_NAME} || "after sync");
918     printf "%*s <\n", $singlewidth,
919            ($ENV{CLIENT_TEST_REMOVED} || "removed during sync");
920     printf "%*s > %s\n", $singlewidth, "",
921            ($ENV{CLIENT_TEST_ADDED} || "added during sync");
922     print "-" x $columns, "\n";
923
924     # fix confusing output like:
925     # BEGIN:VCARD                             BEGIN:VCARD
926     #                                      >  N:new;entry
927     #                                      >  FN:new
928     #                                      >  END:VCARD
929     #                                      >
930     #                                      >  BEGIN:VCARD
931     # and replace it with:
932     #                                      >  BEGIN:VCARD
933     #                                      >  N:new;entry
934     #                                      >  FN:new
935     #                                      >  END:VCARD
936     #
937     # BEGIN:VCARD                             BEGIN:VCARD
938     #
939     # With the o/n/u markup this presents itself as:
940     # u BEGIN:VCARD
941     # n N:new;entry
942     # n FN:new
943     # n END:VCARD
944     # n
945     # n BEGIN:VCARD
946     #
947     # The alternative case is also possible:
948     # o END:VCARD
949     # o 
950     # o BEGIN:VCARD
951     # o N:old;entry
952     # u END:VCARD
953
954     # case one above
955     while( s/^u BEGIN:(VCARD|VCALENDAR)\n((?:^n .*\n)+?)^n BEGIN:/n BEGIN:$1\n$2u BEGIN:/m) {}
956     # same for the other direction
957     while( s/^u BEGIN:(VCARD|VCALENDAR)\n((?:^o .*\n)+?)^o BEGIN:/o BEGIN:$1\n$2u BEGIN:/m) {}
958
959     # case two
960     while( s/^o END:(VCARD|VCALENDAR)\n((?:^o .*\n)+?)^u END:/u END:$1\n$2o END:/m) {}
961     while( s/^n END:(VCARD|VCALENDAR)\n((?:^n .*\n)+?)^u END:/u END:$1\n$2n END:/m) {}
962
963     # split at end of each record
964     my $spaces = " " x $singlewidth;
965     foreach $_ (split /(?:(?<=. END:VCARD\n)|(?<=. END:VCALENDAR\n))(?:^. \n)*/m, $_) {
966       # ignore unchanged records
967       if (!length($_) || /^((u [^\n]*\n)*(u [^\n]*?))$/s) {
968         next;
969       }
970
971       # make all lines equally long in terms of printable characters
972       s/^(.*)$/$1 . (" " x ($singlewidth + 2 - length($1)))/gme;
973
974       # convert into side-by-side output
975       my @buffer = ();
976       foreach $_ (split /\n/, $_) {
977         if (/^u (.*)/) {
978           print join(" <\n", @buffer), " <\n" if $#buffer >= 0;
979           @buffer = ();
980           print $1, "   ", $1, "\n";
981         } elsif (/^o (.*)/) {
982           # preserve in buffer for potential merging with "n "
983           push @buffer, $1;
984         } else {
985           /^n (.*)/;
986           # have line to be merged with?
987           if ($#buffer >= 0) {
988             print shift @buffer, " | ", $1, "\n";
989           } else {
990             print join(" <\n", @buffer), " <\n" if $#buffer >= 0;
991             print $spaces, " > ", $1, "\n";
992           }
993         }
994       }
995       print join(" <\n", @buffer), " <\n" if $#buffer >= 0;
996       @buffer = ();
997
998       print "-" x $columns, "\n";
999     }
1000   }
1001
1002   # unlink($normal1);
1003   # unlink($normal2);
1004   exit($res ? ((defined $ENV{CLIENT_TEST_COMPARISON_FAILED}) ? int($ENV{CLIENT_TEST_COMPARISON_FAILED}) : 1) : 0);
1005 } else {
1006   # normalize
1007   my $in;
1008   if( $#ARGV >= 0 ) {
1009     my $file1 = $ARGV[0];
1010     if (-d $file1) {
1011         open(IN, "-|:utf8", "find $file1 -type f -print0 | xargs -0 cat") || die "$file1: $!";
1012     } else {
1013         open(IN, "<:utf8", $file1) || die "$file1: $!";
1014     }
1015     $in = *IN{IO};
1016   } else {
1017     $in = *STDIN{IO};
1018   }
1019
1020   my $buf = join("", <$in>);
1021   print STDOUT join("\n", Normalize($buf, $columns)), "\n";
1022 }