1: <?php
2:
3: /**
4: * Structures to parse/build vCards (RFC-6350) and Ical events (RFC-5545)
5: * @package framework
6: * @subpackage webdav
7: */
8:
9: /**
10: * Trait with line parsing logic
11: */
12: trait Hm_Card_Line_Parse {
13:
14: /**
15: * Split a value skipping escaped characters
16: * @param string $line value to split
17: * @param string $delim value to split on
18: * @param integer $limit max number of splits (-1 for all)
19: * @return array
20: */
21: protected function split_value($line, $delim, $limit) {
22: $res = preg_split("/\\\\.(*SKIP)(*FAIL)|$delim/s", $line, $limit);
23: return $res;
24: }
25:
26: /**
27: * Normalize End-Of-Line chars
28: * @param string $str string input
29: * @return string
30: */
31: private function standard_eol($str) {
32: return rtrim(str_replace([ "\r\n", "\n\r", "\r"], "\n", $str));
33: }
34:
35: /**
36: * Unfold values that span > 1 line
37: * @param string $str string input
38: * @return string
39: */
40: private function unfold($str) {
41: return preg_replace("/\n\s{1}/m", '', $str);
42: }
43:
44: /**
45: * Process the value portion of an input line
46: * @param string $value value to process
47: * @param string $type property type
48: * @return array
49: */
50: private function process_value($value, $type = false) {
51: $res = [];
52: foreach ($this->split_value($value, ',', -1) as $val) {
53: $res[] = str_replace(['\n', '\,'], ["\n", ','], $val);
54: }
55: return $res;
56: }
57:
58: /**
59: * Validate a property
60: * @param string $prop property name
61: * @return boolean
62: */
63: private function invalid_prop($prop) {
64: if (mb_strtolower(mb_substr($prop, 0, 2)) == 'x-') {
65: return false;
66: }
67: foreach ($this->properties as $name => $value) {
68: if (mb_strtolower($prop) == mb_strtolower($value)) {
69: return false;
70: }
71: }
72: Hm_Debug::add(sprintf("%s invalid prop found: %s", $this->format, $prop));
73: return true;
74: }
75:
76: /**
77: * Validate a paramater
78: * @param string $param parameter to validate
79: * @return boolean
80: */
81: private function invalid_param($param) {
82: foreach ($this->parameters as $val) {
83: if (mb_strtolower($param) == mb_strtolower($val)) {
84: return false;
85: }
86: }
87: Hm_Debug::add(sprintf("%s invalid param found: %s",
88: $this->format, $param));
89: return true;
90: }
91:
92: /**
93: * Parse a property value
94: * @param string $prop property to parse
95: * @return array
96: */
97: private function parse_prop($prop) {
98: $vals = $this->split_value($prop, ';', -1);
99: $res = [
100: 'prop' => $vals[0],
101: 'params' => []
102: ];
103: if (count($vals) > 1) {
104: $res['params'] = $this->parse_prop_params($vals);
105: }
106: return $res;
107: }
108:
109: /**
110: * Parse parameters in a property field
111: * @param array $vals list of parameters
112: * @return array
113: */
114: private function parse_prop_params($vals) {
115: $res = [];
116: array_shift($vals);
117: foreach ($vals as $val) {
118: $pair = $this->split_value($val, '=', 2);
119: if (count($pair) > 1) {
120: if ($this->invalid_param($pair[0])) {
121: continue;
122: }
123: $res[mb_strtolower($pair[0])] = $this->flatten(
124: $this->process_value($pair[1]));
125: }
126: }
127: return $res;
128: }
129: }
130:
131: /**
132: * Class for parsing vCard/iCal data
133: */
134: class Hm_Card_Parse {
135:
136: use Hm_Card_Line_Parse;
137:
138: /* input format version */
139: protected $version = '';
140:
141: /* original input data unchanged */
142: protected $raw_card = '';
143:
144: /* placeholder for parsed data */
145: protected $data = [];
146:
147: /* list of valid parameters for the file type */
148: protected $parameters = [];
149:
150: /* list of valid properties for the file type */
151: protected $properties = [];
152:
153: /**
154: * init
155: */
156: public function __construct() {
157: }
158:
159: /**
160: * Import a single set of values
161: * @param string $str string data from the input file
162: * @return boolean
163: */
164: public function import($str) {
165: $this->data = [];
166: $this->raw_card = $str;
167: $lines = explode("\n", $this->unfold($this->standard_eol($str)));
168: if ($this->is_valid($lines)) {
169: return $this->parse($lines);
170: }
171: return false;
172: }
173:
174: /**
175: * Load an already parsed card
176: * @param array $data parsed card data
177: * @return void
178: */
179: public function import_parsed($data) {
180: if (array_key_exists('raw', $data)) {
181: $this->raw_card = $data['raw'];
182: unset($data['raw']);
183: }
184: $this->data = $data;
185: }
186:
187: /**
188: * Return parsed data for an input
189: * @return array
190: */
191: public function raw_data() {
192: return $this->raw_card;
193: }
194:
195: /**
196: * Format as vcard
197: */
198: public function build_card() {
199: $new_card = [];
200: foreach ($this->data as $name => $val) {
201: if (method_exists($this, 'format_vcard_'.$name)) {
202: $res = $this->{'format_vcard_'.$name}();
203: } else {
204: $res = $this->format_vcard_generic($name);
205: }
206: if (is_array($res) && $res) {
207: $new_card = array_merge($new_card, $res);
208: } elseif ($res) {
209: $new_card[] = $res;
210: }
211: }
212: return implode("\n", $new_card);
213: }
214:
215: /**
216: * Return parsed data for an input
217: * @return array
218: */
219: public function parsed_data() {
220: return $this->data;
221: }
222:
223: /**
224: * Get the value for a field
225: */
226: public function fld_val($name, $type = false, $default = false, $all = false) {
227: if (!array_key_exists($name, $this->data)) {
228: return $default;
229: }
230: $fld = $this->data[$name];
231: if ($all) {
232: return $fld;
233: }
234: foreach ($fld as $vals) {
235: if ($this->is_type($type, $vals)) {
236: if (array_key_exists('formatted', $vals)) {
237: return $vals['formatted']['values'];
238: }
239: return $vals['values'];
240: }
241: }
242: if (array_key_exists('formatted', $fld[0])) {
243: return $fld[0]['formatted']['values'];
244: } else {
245: return $fld[0]['values'];
246: }
247: }
248:
249: /**
250: * Look for a sepcific type
251: */
252: private function is_type($type, $vals) {
253: if (!$type) {
254: return false;
255: }
256: if (!array_key_exists('type', $vals)) {
257: return false;
258: }
259: if (is_array($vals['type']) && in_array($type, $vals['type'])) {
260: return true;
261: } elseif (mb_strtolower($type) == mb_strtolower($vals['type'])) {
262: return true;
263: }
264: return false;
265: }
266:
267: /**
268: * Flatten a list with 1 value to a scaler
269: * @param array $arr list to flatten
270: * @return array
271: */
272: private function flatten($arr) {
273: if (is_array($arr) && count($arr) == 1) {
274: return array_pop($arr);
275: }
276: return $arr;
277: }
278:
279: /**
280: * Parse input file
281: * @param array $lines lines of input to parse
282: * @return boolean
283: */
284: private function parse($lines) {
285: foreach ($lines as $line) {
286: $id = md5($line);
287: $vals = $this->split_value($line, ':', 2);
288: $prop = $this->parse_prop($vals[0]);
289: if ($this->invalid_prop($prop['prop'])) {
290: continue;
291: }
292: $data = $prop['params'];
293: $data['values'] = $this->flatten(
294: $this->process_value($vals[1], $prop['prop']));
295: $data['id'] = $id;
296: if (array_key_exists(mb_strtolower($prop['prop']), $this->data)) {
297: $this->data[mb_strtolower($prop['prop'])][] = $data;
298: } else {
299: $this->data[mb_strtolower($prop['prop'])] = [$data];
300: }
301: }
302: $this->data['raw'] = $this->raw_card;
303: $this->parse_values();
304: return count($this->data) > 0;
305: }
306:
307: /**
308: * Top level validation of input data
309: * @param array $lines input data by line
310: * @return boolean
311: */
312: private function is_valid($lines) {
313: $res = true;
314: if (count($lines) < 4) {
315: $res = false;
316: }
317: if (count($lines) > 0 && mb_strtolower(mb_substr($lines[0], 0, 5)) != 'begin') {
318: $res = false;
319: }
320: if (count($lines) > 1 && mb_strtolower(mb_substr($lines[1], 0, 7)) != 'version') {
321: $res = false;
322: }
323: if (count($lines) && mb_strtolower(mb_substr($lines[(count($lines) - 1)], 0, 3)) != 'end') {
324: $res = false;
325: }
326: if (!$res) {
327: Hm_Debug::add(sprintf('Invalid %s format', $this->format));
328: return false;
329: }
330: $version = $this->split_value($lines[1], ':', 2);
331: if (count($version) > 1) {
332: $this->version = $version[1];
333: }
334: return true;
335: }
336:
337: /**
338: * Parse values that require it
339: * @return void
340: */
341: private function parse_values() {
342: foreach ($this->data as $prop => $values) {
343: $method = sprintf('parse_%s', $prop);
344: if (method_exists($this, $method)) {
345: $this->data[$prop] = $this->$method($values);
346: }
347: }
348: }
349:
350: /**
351: * Catch-all for formatting vcard fields that don't need specific formatting
352: * @param string $name the field name
353: * @return array
354: */
355: protected function format_vcard_generic($name) {
356: $res = [];
357: if (in_array($name, ['raw'], true)) {
358: return;
359: }
360: $vals = $this->fld_val($name, false, [], true);
361: if (count($vals) == 0) {
362: $res;
363: }
364: foreach ($vals as $val) {
365: $name = mb_substr($name, 0, 2) == 'x-' ? $name : mb_strtoupper($name);
366: $params = array_merge([$name], $this->build_vcard_params($val));
367: $res[] = sprintf("%s:%s", implode(';', $params), $val['values']);
368: }
369: return $res;
370: }
371:
372: /**
373: * Build the vcard entry paramater string
374: * @param array field values
375: * @return array
376: */
377: protected function build_vcard_params($fld_val) {
378: $props = [];
379: foreach ($this->parameters as $param) {
380: if (array_key_exists(mb_strtolower($param), $fld_val)) {
381: $props[] = sprintf('%s=%s', mb_strtoupper($param),
382: $this->combine($fld_val[mb_strtolower($param)]));
383: }
384: }
385: return $props;
386: }
387:
388: /**
389: * Combine an array value if needed, return formatted value
390: * @param mixed $val the value to combine
391: * @return string
392: */
393: protected function combine($val) {
394: if (is_array($val)) {
395: return implode(',', array_map([$this, 'vcard_format'], $val));
396: }
397: return $this->vcard_format($val);
398: }
399:
400: /**
401: * Clean a vcard value
402: * TODO: make escaping more robust
403: * @param string $val the value to format
404: * @return string
405: */
406: protected function vcard_format($val) {
407: return str_replace([',', "\n"], ['\,', '\n'], $val);
408: }
409: }
410:
411: /**
412: * Class for parsing vCard data
413: */
414: class Hm_VCard extends Hm_Card_Parse {
415: protected $format = 'vCard';
416: protected $raw_card = '';
417: protected $data = [];
418: protected $parameters = [
419: 'TYPE', 'PREF', 'LABEL', 'VALUE', 'LANGUAGE',
420: 'MEDIATYPE', 'ALTID', 'PID', 'CALSCALE',
421: 'SORT-AS', 'GEO', 'TZ'
422: ];
423: protected $properties = [
424: 'BEGIN', 'VERSION', 'END', 'FN', 'N',
425: 'KIND', 'BDAY', 'ANNIVERSARY', 'GENDER',
426: 'PRODID', 'REV', 'UID', 'SOURCE', 'XML',
427: 'NICKNAME', 'PROTO', 'ADR', 'TEL',
428: 'EMAIL', 'IMPP', 'LANG', 'TZ', 'GEO',
429: 'TITLE', 'ROLE', 'LOGO', 'ORG', 'MEMBER',
430: 'RELATED', 'CATEGORIES', 'NOTE', 'SOUND',
431: 'CLIENTPIDMAP', 'PHOTO', 'URL', 'KEY',
432: 'FBURL', 'CALADRURI', 'CALURI'
433: ];
434:
435: /* CONVERT VCARD INPUT */
436:
437: /**
438: * Parse the name field
439: * @param array $vals name field values
440: * @return array
441: */
442: protected function parse_n($vals) {
443: foreach ($vals as $index => $name) {
444: $flds = $this->split_value($name['values'], ';', 5);
445: $vals[$index]['values'] = [
446: 'lastname' => $flds[0],
447: 'firstname' => $flds[1],
448: 'additional' => $flds[2],
449: 'prefixes' => $flds[3],
450: 'suffixes' => $flds[4]
451: ];
452: }
453: return $vals;
454: }
455:
456: /**
457: * Convert an address from vcard to an internal struct
458: * @param array $vals address values
459: * @return array
460: */
461: protected function format_addr($vals) {
462: $name = 'address';
463: if (array_key_exists('type', $vals)) {
464: $name = sprintf('%s_address', mb_strtolower($vals['type']));
465: }
466: $vals = $vals['values'];
467: $street = $vals['street'];
468: if (!$street && $vals['po']) {
469: $steet = $vals['po'];
470: }
471: $value = sprintf('%s, %s, %s, %s, %s', $street, $vals['locality'], $vals['region'],
472: $vals['country'], $vals['postal_code']);
473: return ['name' => $name, 'values' => $value];
474: }
475:
476: /**
477: * Parse an address field value
478: * @param array $vals address values
479: * @return array
480: */
481: protected function parse_adr($vals) {
482: foreach ($vals as $index => $addr) {
483: $flds = $this->split_value($addr['values'], ';', 7);
484: $vals[$index]['values'] = [
485: 'po' => $flds[0],
486: 'apartment' => $flds[1],
487: 'street' => $flds[2],
488: 'locality' => $flds[3],
489: 'region' => $flds[4],
490: 'postal_code' => $flds[5],
491: 'country' => $flds[6]
492: ];
493: $vals[$index]['formatted'] = $this->format_addr($vals[$index]);
494: }
495: return $vals;
496: }
497:
498: /* CONVERT TO VCARD OUTPUT */
499:
500: /**
501: * Format a name field for vcard output
502: * @return string
503: */
504: protected function format_vcard_n() {
505: $n = $this->fld_val('n');
506: return sprintf("N:%s;%s;%s;%s;%s", $n['lastname'], $n['firstname'],
507: $n['additional'], $n['prefixes'], $n['suffixes']);
508: }
509:
510: /**
511: * Format addresses to vcard
512: * @return array
513: */
514: protected function format_vcard_adr() {
515: $res = [];
516: foreach ($this->fld_val('adr', [], false, true) as $adr) {
517: $parts = $adr['values'];
518: $params = array_merge(['ADR'], $this->build_vcard_params($adr));
519: $res[] = sprintf('%s:%s;%s;%s;%s;%s;%s;%s', implode(';', $params),
520: $parts['po'], $parts['apartment'], $parts['street'], $parts['locality'],
521: $parts['region'], $parts['postal_code'], $parts['country']);
522: }
523: return $res;
524: }
525: }
526:
527: /**
528: * Class for parsing iCal data
529: */
530: class Hm_ICal extends Hm_Card_Parse {
531: protected $format = 'iCal';
532: protected $raw_card = '';
533: protected $data = [];
534: protected $parameters = [
535: 'ALTREP', 'CN', 'CUTYPE', 'DELEGATED-FROM',
536: 'DELEGATED-TO', 'DIR', 'ENCODING', 'FMTTYPE',
537: 'FBTYPE', 'LANGUAGE', 'MEMBER', 'PARTSTAT',
538: 'RANGE', 'RELATED', 'RELTYPE', 'ROLE', 'RSVP',
539: 'SENT-BY', 'TZID', 'VALUE'
540: ];
541: protected $properties = [
542: 'BEGIN', 'VERSION', 'END', 'CALSCALE',
543: 'METHOD', 'PRODID', 'ATTACH', 'CATEGORIES',
544: 'CLASS', 'COMMENT', 'DESCRIPTION', 'GEO',
545: 'LOCATION', 'PERCENT-COMPLETE', 'PRIORITY',
546: 'RESOURCES', 'STATUS', 'SUMMARY', 'COMPLETED',
547: 'DTEND', 'DUE', 'DTSTART', 'DURATION', 'FREEBUSY',
548: 'TRANSP', 'TZID', 'TZNAME', 'TZOFFSETFROM',
549: 'TZOFFSETTO', 'TZURL', 'ATTENDEE', 'CONTACT',
550: 'ORGANIZER', 'RECURRENCE-ID', 'RELATED-TO',
551: 'URL', 'UID', 'EXDATE', 'EXRULE', 'RDATE',
552: 'RRULE', 'ACTION', 'REPEAT', 'TRIGGER',
553: 'CREATED', 'DTSTAMP', 'LAST-MODIFIED',
554: 'SEQUENCE', 'REQUEST-STATUS'
555: ];
556:
557: protected function parse_due($vals) {
558: return $this->parse_dt($vals);
559: }
560:
561: protected function parse_dtstamp($vals) {
562: return $this->parse_dt($vals);
563: }
564:
565: protected function parse_dtend($vals) {
566: return $this->parse_dt($vals);
567: }
568: protected function parse_dtstart($vals) {
569: return $this->parse_dt($vals);
570: }
571: protected function parse_trigger($vals) {
572: return $this->parse_dt($vals);
573: }
574:
575: protected function parse_dt($vals) {
576: foreach ($vals as $index => $dates) {
577: $dt = $vals[0]['values'];
578: if (mb_substr($dt, -1, 1) == 'Z') {
579: $vals[0]['tzid'] = 'UTC';
580: $dt = mb_substr($dt, 0, -1);
581: }
582: $vals[$index]['values'] = date_parse_from_format('Ymd\THis', $dt);
583: }
584: return $vals;
585: }
586: }
587: