1: <?php
2: declare(strict_types=1);
3:
4: namespace om\ICal;
5:
6: use Stringable;
7:
8: /**
9: * One unfolded content line (RFC 5545, section 3.1):
10: *
11: * name *(";" param) ":" value
12: *
13: * BEGIN and END lines are content lines too, their value is the component name.
14: *
15: * $line = ContentLine::parse('ATTENDEE;CN="Doe, John";ROLE=REQ-PARTICIPANT:mailto:john@example.com');
16: * $line->name; // "ATTENDEE"
17: * $line->parameters->get('CN'); // "Doe, John"
18: * $line->value; // "mailto:john@example.com"
19: */
20: final class ContentLine implements Stringable {
21: private const string NAME_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
22:
23: /** Parameters are parsed on first access. */
24: public Parameters $parameters {
25: get => $this->parsed ??= Parameters::parse($this->rawParameters);
26: }
27:
28: private ?Parameters $parsed = null;
29:
30: /**
31: * @param string $name upper-cased property name
32: * @param string $rawParameters parameters without the leading semicolon
33: * @param string $value raw (still escaped) value
34: * @param int $line number of the first physical line, 0 when unknown
35: */
36: public function __construct(
37: public readonly string $name,
38: public readonly string $rawParameters,
39: public readonly string $value,
40: public readonly int $line = 0,
41: ) {
42: }
43:
44: /**
45: * @return ?self null when the line is not a content line
46: */
47: public static function parse(string $line, int $number = 0): ?self {
48: $parts = self::split($line);
49: return $parts === null ? null : new self($parts[0], $parts[1], $parts[2], $number);
50: }
51:
52: public function isBegin(): bool {
53: return $this->name === 'BEGIN' && $this->rawParameters === '';
54: }
55:
56: public function isEnd(): bool {
57: return $this->name === 'END' && $this->rawParameters === '';
58: }
59:
60: /**
61: * Component name of a BEGIN or END line.
62: */
63: public function componentName(): string {
64: return strtoupper(trim($this->value));
65: }
66:
67: public function __toString(): string {
68: return $this->name . ($this->rawParameters === '' ? '' : ';' . $this->rawParameters) . ':' . $this->value;
69: }
70:
71: /**
72: * Split a line into its upper-cased name, raw parameters and value without creating objects.
73: *
74: * @internal
75: * @return array{string, string, string}|null
76: */
77: public static function split(string $line): ?array {
78: $nameLength = strcspn($line, ';:');
79: if ($nameLength === 0 || $nameLength === strlen($line) || strspn($line, self::NAME_CHARACTERS, 0, $nameLength) !== $nameLength) {
80: return null;
81: }
82:
83: $parameters = '';
84: $valueStart = $nameLength + 1;
85: if ($line[$nameLength] === ';') {
86: $colon = self::valueSeparator($line, $nameLength);
87: if ($colon === null) {
88: return null;
89: }
90: $parameters = substr($line, $nameLength + 1, $colon - $nameLength - 1);
91: $valueStart = $colon + 1;
92: }
93: return [strtoupper(substr($line, 0, $nameLength)), $parameters, substr($line, $valueStart)];
94: }
95:
96: /**
97: * Parse parameters into [NAME => value] with several values joined by a comma.
98: *
99: * @internal used by the array based IcalParser
100: * @return array<string, string>
101: */
102: public static function parameters(string $parameters): array {
103: $result = [];
104: foreach (Parameters::parse($parameters) as $name => $values) {
105: $result[$name] = implode(',', $values);
106: }
107: return $result;
108: }
109:
110: /**
111: * Position of the colon that separates parameters from the value; colons inside quoted
112: * parameter values (e.g. ALTREP="http://...") are skipped.
113: */
114: private static function valueSeparator(string $line, int $offset): ?int {
115: $colon = strpos($line, ':', $offset);
116: if ($colon === false) {
117: return null;
118: }
119: $quote = strpos($line, '"', $offset);
120: if ($quote === false || $quote > $colon) {
121: return $colon;
122: }
123: $length = strlen($line);
124: $quoted = false;
125: for ($i = $quote; $i < $length; $i++) {
126: if ($line[$i] === '"') {
127: $quoted = !$quoted;
128: } elseif ($line[$i] === ':' && !$quoted) {
129: return $i;
130: }
131: }
132: return null;
133: }
134: }
135: