Completed
Pull Request — master (#90)
by
unknown
03:55
created

VCard::chunk_split_unicode()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 10
rs 9.4285
cc 2
eloc 7
nc 2
nop 3
1
<?php
2
3
namespace JeroenDesloovere\VCard;
4
5
/*
6
 * This file is part of the VCard PHP Class from Jeroen Desloovere.
7
 *
8
 * For the full copyright and license information, please view the license
9
 * file that was distributed with this source code.
10
 */
11
12
use Behat\Transliterator\Transliterator;
13
14
/**
15
 * VCard PHP Class to generate .vcard files and save them to a file or output as a download.
16
 *
17
 * @author Jeroen Desloovere <[email protected]>
18
 */
19
class VCard
20
{
21
    /**
22
     * definedElements
23
     *
24
     * @var array
25
     */
26
    private $definedElements;
27
28
    /**
29
     * Filename
30
     *
31
     * @var string
32
     */
33
    private $filename;
34
35
    /**
36
     * Save Path
37
     *
38
     * @var string
39
     */
40
    private $savePath = null;
41
42
    /**
43
     * Multiple properties for element allowed
44
     *
45
     * @var array
46
     */
47
    private $multiplePropertiesForElementAllowed = array(
48
        'email',
49
        'address',
50
        'phoneNumber',
51
        'url'
52
    );
53
54
    /**
55
     * Properties
56
     *
57
     * @var array
58
     */
59
    private $properties;
60
61
    /**
62
     * Default Charset
63
     *
64
     * @var string
65
     */
66
    public $charset = 'utf-8';
67
68
    /**
69
     * Add address
70
     *
71
     * @param  string [optional] $name
72
     * @param  string [optional] $extended
73
     * @param  string [optional] $street
74
     * @param  string [optional] $city
75
     * @param  string [optional] $region
76
     * @param  string [optional] $zip
77
     * @param  string [optional] $country
78
     * @param  string [optional] $type
79
     *                                     $type may be DOM | INTL | POSTAL | PARCEL | HOME | WORK
80
     *                                     or any combination of these: e.g. "WORK;PARCEL;POSTAL"
81
     * @return $this
82
     */
83
    public function addAddress(
84
        $name = '',
85
        $extended = '',
86
        $street = '',
87
        $city = '',
88
        $region = '',
89
        $zip = '',
90
        $country = '',
91
        $type = 'WORK;POSTAL'
92
    ) {
93
        // init value
94
        $value = $name . ';' . $extended . ';' . $street . ';' . $city . ';' . $region . ';' . $zip . ';' . $country;
95
96
        // set property
97
        $this->setProperty(
98
            'address',
99
            'ADR' . (($type != '') ? ';' . $type : '') . $this->getCharsetString(),
100
            $value
101
        );
102
103
        return $this;
104
    }
105
106
    /**
107
     * Add birthday
108
     *
109
     * @param  string $date Format is YYYY-MM-DD
110
     * @return $this
111
     */
112
    public function addBirthday($date)
113
    {
114
        $this->setProperty(
115
            'birthday',
116
            'BDAY',
117
            $date
118
        );
119
120
        return $this;
121
    }
122
123
    /**
124
     * Add company
125
     *
126
     * @param  string $company
127
     * @return $this
128
     */
129
    public function addCompany($company, $department = '')
130
    {
131
        $this->setProperty(
132
            'company',
133
            'ORG' . $this->getCharsetString(),
134
            $company
135
            . ($department != '' ? ';' . $department : '')
136
        );
137
138
        // if filename is empty, add to filename
139
        if ($this->filename === null) {
140
            $this->setFilename($company);
141
        }
142
143
        return $this;
144
    }
145
146
    /**
147
     * Add email
148
     *
149
     * @param  string $address The e-mail address
150
     * @param  string [optional] $type    The type of the email address
151
     *                                    $type may be  PREF | WORK | HOME
152
     *                                    or any combination of these: e.g. "PREF;WORK"
153
     * @return $this
154
     */
155
    public function addEmail($address, $type = '')
156
    {
157
        $this->setProperty(
158
            'email',
159
            'EMAIL;INTERNET' . (($type != '') ? ';' . $type : ''),
160
            $address
161
        );
162
163
        return $this;
164
    }
165
166
    /**
167
     * Add jobtitle
168
     *
169
     * @param  string $jobtitle The jobtitle for the person.
170
     * @return $this
171
     */
172
    public function addJobtitle($jobtitle)
173
    {
174
        $this->setProperty(
175
            'jobtitle',
176
            'TITLE' . $this->getCharsetString(),
177
            $jobtitle
178
        );
179
180
        return $this;
181
    }
182
183
    /**
184
     * Add role
185
     *
186
     * @param  string $role The role for the person.
187
     * @return $this
188
     */
189
    public function addRole($role)
190
    {
191
        $this->setProperty(
192
            'role',
193
            'ROLE' . $this->getCharsetString(),
194
            $role
195
        );
196
197
        return $this;
198
    }
199
200
    /**
201
     * Add a photo or logo (depending on property name)
202
     *
203
     * @param  string $property LOGO|PHOTO
204
     * @param  string $url image url or filename
205
     * @param  bool $include Do we include the image in our vcard or not?
206
     * @throws VCardMediaException if file is empty or not an image file
207
     */
208
    private function addMedia($property, $url, $include = true, $element)
209
    {
210
        if ($include) {
211
            $value = file_get_contents($url);
212
213
            if (!$value) {
214
                throw new VCardMediaException('Nothing returned from URL.');
215
            }
216
217
            $value = base64_encode($value);
218
            $mimetype = mime_content_type($url);
219
220
            if (preg_match('/^image\//', $mimetype) !== 1) {
221
                throw new VCardMediaException('Returned data aren\'t an image.');
222
            }
223
224
            $type = strtoupper(str_replace('image/', '', $mimetype));
225
226
            $property .= ";ENCODING=b;TYPE=" . $type;
227
        } else {
228
            if (filter_var($url, FILTER_VALIDATE_URL) !== false) {
229
                $propertySuffix = ';VALUE=URL';
230
231
                $headers = get_headers($url);
232
233
                $imageTypeMatched = false;
234
                $fileType = null;
235
236
                foreach ($headers as $header) {
237
                    if (preg_match('/Content-Type:\simage\/([a-z]+)/i', $header, $m)) {
238
                        $fileType = $m[1];
239
                        $imageTypeMatched = true;
240
                    }
241
                }
242
243
                if (!$imageTypeMatched) {
244
                    throw new VCardMediaException('Returned data isn\'t an image.');
245
                }
246
247
                $propertySuffix .= ';TYPE=' . strtoupper($fileType);
248
249
                $property = $property . $propertySuffix;
250
                $value = $url;
251
            } else {
252
                $value = $url;
253
            }
254
        }
255
256
        $this->setProperty(
257
            $element,
258
            $property,
259
            $value
260
        );
261
    }
262
263
    /**
264
     * Add name
265
     *
266
     * @param  string [optional] $lastName
267
     * @param  string [optional] $firstName
268
     * @param  string [optional] $additional
269
     * @param  string [optional] $prefix
270
     * @param  string [optional] $suffix
271
     * @return $this
272
     */
273
    public function addName(
274
        $lastName = '',
275
        $firstName = '',
276
        $additional = '',
277
        $prefix = '',
278
        $suffix = ''
279
    ) {
280
        // define values with non-empty values
281
        $values = array_filter(array(
282
            $prefix,
283
            $firstName,
284
            $additional,
285
            $lastName,
286
            $suffix,
287
        ));
288
289
        // define filename
290
        $this->setFilename($values);
291
292
        // set property
293
        $property = $lastName . ';' . $firstName . ';' . $additional . ';' . $prefix . ';' . $suffix;
294
        $this->setProperty(
295
            'name',
296
            'N' . $this->getCharsetString(),
297
            $property
298
        );
299
300
        // is property FN set?
301
        if (!$this->hasProperty('FN')) {
302
            // set property
303
            $this->setProperty(
304
                'fullname',
305
                'FN' . $this->getCharsetString(),
306
                trim(implode(' ', $values))
307
            );
308
        }
309
310
        return $this;
311
    }
312
313
    /**
314
     * Add note
315
     *
316
     * @param  string $note
317
     * @return $this
318
     */
319
    public function addNote($note)
320
    {
321
        $this->setProperty(
322
            'note',
323
            'NOTE' . $this->getCharsetString(),
324
            $note
325
        );
326
327
        return $this;
328
    }
329
330
    /**
331
     * Add categories
332
     *
333
     * @param array $categories
334
     * @return $this
335
     */
336
    public function addCategories($categories)
337
    {
338
        $this->setProperty(
339
            'categories',
340
            'CATEGORIES' . $this->getCharsetString(),
341
            trim(implode(',', $categories))
342
        );
343
344
        return $this;
345
    }
346
347
    /**
348
     * Add phone number
349
     *
350
     * @param  string $number
351
     * @param  string [optional] $type
352
     *                                   Type may be PREF | WORK | HOME | VOICE | FAX | MSG |
353
     *                                   CELL | PAGER | BBS | CAR | MODEM | ISDN | VIDEO
354
     *                                   or any senseful combination, e.g. "PREF;WORK;VOICE"
355
     * @return $this
356
     */
357
    public function addPhoneNumber($number, $type = '')
358
    {
359
        $this->setProperty(
360
            'phoneNumber',
361
            'TEL' . (($type != '') ? ';' . $type : ''),
362
            $number
363
        );
364
365
        return $this;
366
    }
367
368
    /**
369
     * Add Logo
370
     *
371
     * @param  string $url image url or filename
372
     * @param  bool $include Include the image in our vcard?
373
     * @return $this
374
     */
375
    public function addLogo($url, $include = true)
376
    {
377
        $this->addMedia(
378
            'LOGO',
379
            $url,
380
            $include,
381
            'logo'
382
        );
383
384
        return $this;
385
    }
386
387
    /**
388
     * Add Photo
389
     *
390
     * @param  string $url image url or filename
391
     * @param  bool $include Include the image in our vcard?
392
     * @return $this
393
     */
394
    public function addPhoto($url, $include = true)
395
    {
396
        $this->addMedia(
397
            'PHOTO',
398
            $url,
399
            $include,
400
            'photo'
401
        );
402
403
        return $this;
404
    }
405
406
    /**
407
     * Add URL
408
     *
409
     * @param  string $url
410
     * @param  string [optional] $type Type may be WORK | HOME
411
     * @return $this
412
     */
413
    public function addURL($url, $type = '')
414
    {
415
        $this->setProperty(
416
            'url',
417
            'URL' . (($type != '') ? ';' . $type : ''),
418
            $url
419
        );
420
421
        return $this;
422
    }
423
424
    /**
425
     * Build VCard (.vcf)
426
     *
427
     * @return string
428
     */
429
    public function buildVCard()
430
    {
431
        // init string
432
        $string = "BEGIN:VCARD\r\n";
433
        $string .= "VERSION:3.0\r\n";
434
        $string .= "REV:" . date("Y-m-d") . "T" . date("H:i:s") . "Z\r\n";
435
436
        // loop all properties
437
        $properties = $this->getProperties();
438
        foreach ($properties as $property) {
439
            // add to string
440
            $string .= $this->fold($property['key'] . ':' . $this->escape($property['value']) . "\r\n");
441
        }
442
443
        // add to string
444
        $string .= "END:VCARD\r\n";
445
446
        // return
447
        return $string;
448
    }
449
450
    /**
451
     * Build VCalender (.ics) - Safari (< iOS 8) can not open .vcf files, so we have build a workaround.
452
     *
453
     * @return string
454
     */
455
    public function buildVCalendar()
456
    {
457
        // init dates
458
        $dtstart = date("Ymd") . "T" . date("Hi") . "00";
459
        $dtend = date("Ymd") . "T" . date("Hi") . "01";
460
461
        // init string
462
        $string = "BEGIN:VCALENDAR\n";
463
        $string .= "VERSION:2.0\n";
464
        $string .= "BEGIN:VEVENT\n";
465
        $string .= "DTSTART;TZID=Europe/London:" . $dtstart . "\n";
466
        $string .= "DTEND;TZID=Europe/London:" . $dtend . "\n";
467
        $string .= "SUMMARY:Click attached contact below to save to your contacts\n";
468
        $string .= "DTSTAMP:" . $dtstart . "Z\n";
469
        $string .= "ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE=text/directory;\n";
470
        $string .= " X-APPLE-FILENAME=" . $this->getFilename() . "." . $this->getFileExtension() . ":\n";
471
472
        // base64 encode it so that it can be used as an attachemnt to the "dummy" calendar appointment
473
        $b64vcard = base64_encode($this->buildVCard());
474
475
        // chunk the single long line of b64 text in accordance with RFC2045
476
        // (and the exact line length determined from the original .ics file exported from Apple calendar
477
        $b64mline = chunk_split($b64vcard, 74, "\n");
478
479
        // need to indent all the lines by 1 space for the iphone (yes really?!!)
480
        $b64final = preg_replace('/(.+)/', ' $1', $b64mline);
481
        $string .= $b64final;
482
483
        // output the correctly formatted encoded text
484
        $string .= "END:VEVENT\n";
485
        $string .= "END:VCALENDAR\n";
486
487
        // return
488
        return $string;
489
    }
490
491
    /**
492
     * Returns the browser user agent string.
493
     *
494
     * @return string
495
     */
496
    protected function getUserAgent()
0 ignored issues
show
Coding Style introduced by
getUserAgent uses the super-global variable $_SERVER which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
497
    {
498
        if (array_key_exists('HTTP_USER_AGENT', $_SERVER)) {
499
            $browser = strtolower($_SERVER['HTTP_USER_AGENT']);
500
        } else {
501
            $browser = 'unknown';
502
        }
503
504
        return $browser;
505
    }
506
507
    /**
508
     * Decode
509
     *
510
     * @param  string $value The value to decode
511
     * @return string decoded
512
     */
513
    private function decode($value)
514
    {
515
        // convert cyrlic, greek or other caracters to ASCII characters
516
        return Transliterator::transliterate($value);
517
    }
518
519
    /**
520
     * Download a vcard or vcal file to the browser.
521
     */
522
    public function download()
523
    {
524
        // define output
525
        $output = $this->getOutput();
526
527
        foreach ($this->getHeaders(false) as $header) {
528
            header($header);
529
        }
530
531
        // echo the output and it will be a download
532
        echo $output;
533
    }
534
535
    /**
536
     * Fold a line according to RFC2425 section 5.8.1.
537
     *
538
     * @link http://tools.ietf.org/html/rfc2425#section-5.8.1
539
     * @param  string $text
540
     * @return mixed
541
     */
542
    protected function fold($text)
543
    {
544
        if (strlen($text) <= 75) {
545
            return $text;
546
        }
547
548
        // split, wrap and trim trailing separator
549
        return substr($this->chunk_split_unicode($text, 73, "\r\n "), 0, -3);
550
    }
551
552
    /**
553
     * multibyte word chunk split
554
     * @link http://php.net/manual/en/function.chunk-split.php#107711
555
     * 
556
     * @param  string  $body     The string to be chunked.
557
     * @param  integer $chunklen The chunk length.
558
     * @param  string  $end      The line ending sequence.
559
     * @return string            Chunked string
560
     */
561
    protected function chunk_split_unicode($body, $chunklen = 76, $end = "\r\n")
562
    {
563
        $array = array_chunk(
564
            preg_split("//u", $body, -1, PREG_SPLIT_NO_EMPTY), $chunklen);
565
        $body = "";
566
        foreach ($array as $item) {
567
            $body .= join("", $item) . $end;
568
        }
569
        return $body;
570
    }
571
572
    /**
573
     * Escape newline characters according to RFC2425 section 5.8.4.
574
     *
575
     * @link http://tools.ietf.org/html/rfc2425#section-5.8.4
576
     * @param  string $text
577
     * @return string
578
     */
579
    protected function escape($text)
580
    {
581
        $text = str_replace("\r\n", "\\n", $text);
582
        $text = str_replace("\n", "\\n", $text);
583
584
        return $text;
585
    }
586
587
    /**
588
     * Get output as string
589
     * @deprecated in the future
590
     *
591
     * @return string
592
     */
593
    public function get()
594
    {
595
        return $this->getOutput();
596
    }
597
598
    /**
599
     * Get charset
600
     *
601
     * @return string
602
     */
603
    public function getCharset()
604
    {
605
        return $this->charset;
606
    }
607
608
    /**
609
     * Get charset string
610
     *
611
     * @return string
612
     */
613
    public function getCharsetString()
614
    {
615
        $charsetString = '';
616
        if ($this->charset == 'utf-8') {
617
            $charsetString = ';CHARSET=' . $this->charset;
618
        }
619
620
        return $charsetString;
621
    }
622
623
    /**
624
     * Get content type
625
     *
626
     * @return string
627
     */
628
    public function getContentType()
629
    {
630
        return ($this->isIOS7()) ?
631
            'text/x-vcalendar' : 'text/x-vcard';
632
    }
633
634
    /**
635
     * Get filename
636
     *
637
     * @return string
638
     */
639
    public function getFilename()
640
    {
641
        if (!$this->filename) {
642
            return 'unknown';
643
        }
644
645
        return $this->filename;
646
    }
647
648
    /**
649
     * Get file extension
650
     *
651
     * @return string
652
     */
653
    public function getFileExtension()
654
    {
655
        return ($this->isIOS7()) ?
656
            'ics' : 'vcf';
657
    }
658
659
    /**
660
     * Get headers
661
     *
662
     * @param  bool $asAssociative
663
     * @return array
664
     */
665
    public function getHeaders($asAssociative)
666
    {
667
        $contentType = $this->getContentType() . '; charset=' . $this->getCharset();
668
        $contentDisposition = 'attachment; filename=' . $this->getFilename() . '.' . $this->getFileExtension();
669
        $contentLength = mb_strlen($this->getOutput(), $this->getCharset());
670
        $connection = 'close';
671
672
        if ((bool)$asAssociative) {
673
            return array(
674
                'Content-type' => $contentType,
675
                'Content-Disposition' => $contentDisposition,
676
                'Content-Length' => $contentLength,
677
                'Connection' => $connection,
678
            );
679
        }
680
681
        return array(
682
            'Content-type: ' . $contentType,
683
            'Content-Disposition: ' . $contentDisposition,
684
            'Content-Length: ' . $contentLength,
685
            'Connection: ' . $connection,
686
        );
687
    }
688
689
    /**
690
     * Get output as string
691
     * iOS devices (and safari < iOS 8 in particular) can not read .vcf (= vcard) files.
692
     * So I build a workaround to build a .ics (= vcalender) file.
693
     *
694
     * @return string
695
     */
696
    public function getOutput()
697
    {
698
        $output = ($this->isIOS7()) ?
699
            $this->buildVCalendar() : $this->buildVCard();
700
701
        return $output;
702
    }
703
704
    /**
705
     * Get properties
706
     *
707
     * @return array
708
     */
709
    public function getProperties()
710
    {
711
        return $this->properties;
712
    }
713
714
    /**
715
     * Has property
716
     *
717
     * @param  string $key
718
     * @return bool
719
     */
720
    public function hasProperty($key)
721
    {
722
        $properties = $this->getProperties();
723
724
        foreach ($properties as $property) {
725
            if ($property['key'] === $key && $property['value'] !== '') {
726
                return true;
727
            }
728
        }
729
730
        return false;
731
    }
732
733
    /**
734
     * Is iOS - Check if the user is using an iOS-device
735
     *
736
     * @return bool
737
     */
738
    public function isIOS()
739
    {
740
        // get user agent
741
        $browser = $this->getUserAgent();
742
743
        return (strpos($browser, 'iphone') || strpos($browser, 'ipod') || strpos($browser, 'ipad'));
744
    }
745
746
    /**
747
     * Is iOS less than 7 (should cal wrapper be returned)
748
     *
749
     * @return bool
750
     */
751
    public function isIOS7()
752
    {
753
        return ($this->isIOS() && $this->shouldAttachmentBeCal());
754
    }
755
756
    /**
757
     * Save to a file
758
     *
759
     * @return void
760
     */
761
    public function save()
762
    {
763
        $file = $this->getFilename() . '.' . $this->getFileExtension();
764
765
        // Add save path if given
766
        if (null !== $this->savePath) {
767
            $file = $this->savePath . $file;
768
        }
769
770
        file_put_contents(
771
            $file,
772
            $this->getOutput()
773
        );
774
    }
775
776
    /**
777
     * Set charset
778
     *
779
     * @param  mixed $charset
780
     * @return void
781
     */
782
    public function setCharset($charset)
783
    {
784
        $this->charset = $charset;
785
    }
786
787
    /**
788
     * Set filename
789
     *
790
     * @param  mixed $value
791
     * @param  bool $overwrite [optional] Default overwrite is true
792
     * @param  string $separator [optional] Default separator is an underscore '_'
793
     * @return void
794
     */
795
    public function setFilename($value, $overwrite = true, $separator = '_')
796
    {
797
        // recast to string if $value is array
798
        if (is_array($value)) {
799
            $value = implode($separator, $value);
800
        }
801
802
        // trim unneeded values
803
        $value = trim($value, $separator);
804
805
        // remove all spaces
806
        $value = preg_replace('/\s+/', $separator, $value);
807
808
        // if value is empty, stop here
809
        if (empty($value)) {
810
            return;
811
        }
812
813
        // decode value + lowercase the string
814
        $value = strtolower($this->decode($value));
815
816
        // urlize this part
817
        $value = Transliterator::urlize($value);
818
819
        // overwrite filename or add to filename using a prefix in between
820
        $this->filename = ($overwrite) ?
821
            $value : $this->filename . $separator . $value;
822
    }
823
824
    /**
825
     * Set the save path directory
826
     *
827
     * @param  string $savePath Save Path
828
     * @throws Exception
829
     */
830
    public function setSavePath($savePath)
831
    {
832
        if (!is_dir($savePath)) {
833
            throw new Exception('Output directory does not exist.');
834
        }
835
836
        // Add trailing directory separator the save path
837
        if (substr($savePath, -1) != DIRECTORY_SEPARATOR) {
838
            $savePath .= DIRECTORY_SEPARATOR;
839
        }
840
841
        $this->savePath = $savePath;
842
    }
843
844
    /**
845
     * Set property
846
     *
847
     * @param  string $element The element name you want to set, f.e.: name, email, phoneNumber, ...
848
     * @param  string $key
849
     * @param  string $value
850
     * @throws Exception
851
     */
852
    private function setProperty($element, $key, $value)
853
    {
854
        if (!in_array($element, $this->multiplePropertiesForElementAllowed)
855
            && isset($this->definedElements[$element])
856
        ) {
857
            throw new Exception('You can only set "' . $element . '" once.');
858
        }
859
860
        // we define that we set this element
861
        $this->definedElements[$element] = true;
862
863
        // adding property
864
        $this->properties[] = array(
865
            'key' => $key,
866
            'value' => $value
867
        );
868
    }
869
870
    /**
871
     * Checks if we should return vcard in cal wrapper
872
     *
873
     * @return bool
874
     */
875
    protected function shouldAttachmentBeCal()
876
    {
877
        $browser = $this->getUserAgent();
878
879
        $matches = array();
880
        preg_match('/os (\d+)_(\d+)\s+/', $browser, $matches);
881
        $version = isset($matches[1]) ? ((int)$matches[1]) : 999;
882
883
        return ($version < 8);
884
    }
885
}
886