1: <?php
2: declare(strict_types=1);
3:
4: namespace om\ICal;
5:
6: use Countable;
7: use InvalidArgumentException;
8: use IteratorAggregate;
9: use Traversable;
10:
11: /**
12: * Property parameters (RFC 5545, section 3.2). Names are case-insensitive,
13: * a parameter may have several values (e.g. MEMBER="mailto:a","mailto:b").
14: *
15: * @implements IteratorAggregate<string, list<string>>
16: */
17: final class Parameters implements IteratorAggregate, Countable {
18: /**
19: * @param array<string, list<string>> $values
20: */
21: private function __construct(private array $values = []) {
22: }
23:
24: /**
25: * @param array<string, string|list<string>> $parameters
26: */
27: public static function from(array $parameters): self {
28: $values = [];
29: foreach ($parameters as $name => $value) {
30: $values[strtoupper(self::name($name))] = array_map('strval', (array) $value);
31: }
32: return new self($values);
33: }
34:
35: /**
36: * Parse the parameter part of a content line, e.g. 'CN="Doe, John";ROLE=CHAIR'.
37: * Parts without "=" are ignored.
38: */
39: public static function parse(string $raw): self {
40: $values = [];
41: $length = strlen($raw);
42: $name = '';
43: $value = '';
44: $list = [];
45: $inName = true;
46: $quoted = false;
47: for ($i = 0; $i <= $length; $i++) {
48: $char = $i < $length ? $raw[$i] : ';';
49: if ($quoted) {
50: if ($char === '"') {
51: $quoted = false;
52: } else {
53: $value .= $char;
54: }
55: continue;
56: }
57: if ($char === ';') {
58: if (!$inName && trim($name) !== '') {
59: $list[] = $value;
60: $values[strtoupper(trim($name))] = array_map(self::decode(...), $list);
61: }
62: [$name, $value, $list, $inName] = ['', '', [], true];
63: } elseif ($inName) {
64: if ($char === '=') {
65: $inName = false;
66: } else {
67: $name .= $char;
68: }
69: } elseif ($char === '"') {
70: $quoted = true;
71: } elseif ($char === ',') {
72: $list[] = $value;
73: $value = '';
74: } else {
75: $value .= $char;
76: }
77: }
78: return new self($values);
79: }
80:
81: /**
82: * Value of a parameter; several values are joined by a comma.
83: */
84: public function get(string $name): ?string {
85: $values = $this->values[strtoupper($name)] ?? null;
86: return $values === null ? null : implode(',', $values);
87: }
88:
89: /**
90: * @return list<string>
91: */
92: public function values(string $name): array {
93: return $this->values[strtoupper($name)] ?? [];
94: }
95:
96: public function has(string $name): bool {
97: return isset($this->values[strtoupper($name)]);
98: }
99:
100: /**
101: * @param string|list<string>|null $value null removes the parameter
102: */
103: public function with(string $name, string|array|null $value): self {
104: $values = $this->values;
105: if ($value === null) {
106: unset($values[strtoupper($name)]);
107: } else {
108: $values[strtoupper(self::name($name))] = (array) $value;
109: }
110: return new self($values);
111: }
112:
113: /**
114: * @return array<string, list<string>>
115: */
116: public function all(): array {
117: return $this->values;
118: }
119:
120: public function count(): int {
121: return count($this->values);
122: }
123:
124: public function getIterator(): Traversable {
125: yield from $this->values;
126: }
127:
128: /**
129: * Serialized form without the leading semicolon. Values with ":", ";" or "," are quoted,
130: * DQUOTE, newlines and "^" are encoded as RFC 6868 requires, other control characters are removed.
131: */
132: public function __toString(): string {
133: $parts = [];
134: foreach ($this->values as $name => $values) {
135: $parts[] = $name . '=' . implode(',', array_map(self::encode(...), $values));
136: }
137: return implode(';', $parts);
138: }
139:
140: private static function encode(string $value): string {
141: $value = strtr($value, ['^' => '^^', "\r\n" => '^n', "\n" => '^n', "\r" => '^n', '"' => "^'"]);
142: $value = (string) preg_replace('/[\x00-\x08\x0A-\x1F\x7F]/', '', $value);
143: return strpbrk($value, ':;,') === false ? $value : '"' . $value . '"';
144: }
145:
146: /**
147: * RFC 6868: ^n is a newline, ^' a DQUOTE and ^^ a caret.
148: */
149: private static function decode(string $value): string {
150: return str_contains($value, '^') ? strtr($value, ['^^' => '^', '^n' => "\n", '^N' => "\n", "^'" => '"']) : $value;
151: }
152:
153: private static function name(string $name): string {
154: if (!preg_match('/^[A-Za-z0-9-]+$/D', $name)) {
155: throw new InvalidArgumentException("Invalid parameter name: $name");
156: }
157: return $name;
158: }
159: }
160: