diff --git a/lib/Component.php b/lib/Component.php index 0849c5a8e..25b540abc 100644 --- a/lib/Component.php +++ b/lib/Component.php @@ -475,6 +475,9 @@ public function __unset(string $name): void */ public function __clone() { + if (null !== self::$cloneRoot) { + $this->root = self::$cloneRoot; + } foreach ($this->children as $childName => $childGroup) { foreach ($childGroup as $key => $child) { $clonedChild = clone $child; diff --git a/lib/Document.php b/lib/Document.php index 8319b49be..9ea976dbf 100644 --- a/lib/Document.php +++ b/lib/Document.php @@ -101,6 +101,23 @@ public function __construct() parent::__construct($this, $name, $children, $defaults); } + /** + * This method is automatically called when the object is cloned. + * + * The copy becomes the root of all its cloned nodes, so it does not depend + * on the original document (e.g. to resolve a TZID against its VTIMEZONE). + */ + public function __clone() + { + $previousCloneRoot = self::$cloneRoot; + self::$cloneRoot = $this; + try { + parent::__clone(); + } finally { + self::$cloneRoot = $previousCloneRoot; + } + } + /** * Returns the current document type. */ diff --git a/lib/Node.php b/lib/Node.php index 47889a2ab..de8821654 100644 --- a/lib/Node.php +++ b/lib/Node.php @@ -54,6 +54,14 @@ abstract class Node implements \IteratorAggregate, \ArrayAccess, \Countable, \Js */ protected ?Component $root = null; + /** + * The copy under construction while a Document is being cloned. + * + * Every node cloned as part of that copy takes it as its root, instead of + * keeping a reference to the original document. + */ + protected static ?Document $cloneRoot = null; + /** * Serializes the node into a mimedir format. */ diff --git a/lib/Property.php b/lib/Property.php index 7432e5064..5d716f4b8 100644 --- a/lib/Property.php +++ b/lib/Property.php @@ -453,9 +453,13 @@ public function offsetUnset($offset): void */ public function __clone() { + if (null !== self::$cloneRoot) { + $this->root = self::$cloneRoot; + } foreach ($this->parameters as $key => $child) { $this->parameters[$key] = clone $child; $this->parameters[$key]->parent = $this; + $this->parameters[$key]->root = $this->root; } } diff --git a/tests/VObject/DocumentCloneTest.php b/tests/VObject/DocumentCloneTest.php new file mode 100644 index 000000000..9b362e4ff --- /dev/null +++ b/tests/VObject/DocumentCloneTest.php @@ -0,0 +1,118 @@ +collectNodes($copy); + self::assertContains($copy->VEVENT->VALARM, $nodes); + self::assertContains($copy->VEVENT->VALARM->TRIGGER['RELATED'], $nodes); + foreach ($nodes as $node) { + self::assertSame($copy, $this->getRoot($node), $node->name.' should belong to the copy'); + } + } + + public function testClonedDocumentDoesNotChangeTheOriginal(): void + { + $doc = Reader::read(self::ICS); + + clone $doc; + + foreach ($this->collectNodes($doc) as $node) { + self::assertSame($doc, $this->getRoot($node), $node->name.' should belong to the original'); + } + } + + public function testClonedDocumentResolvesCustomTimezoneAfterOriginalIsDestroyed(): void + { + $doc = Reader::read(self::ICS); + + $copy = clone $doc; + $doc->destroy(); + + self::assertEquals( + new \DateTimeImmutable('2026-03-22T13:00:00Z'), + $copy->VEVENT->DTSTART->getDateTime() + ); + } + + public function testClonedComponentKeepsTheOriginalRoot(): void + { + $doc = Reader::read(self::ICS); + + $event = clone $doc->VEVENT; + + foreach ($this->collectNodes($event) as $node) { + self::assertSame($doc, $this->getRoot($node), $node->name.' should belong to the original document'); + } + } + + public function testClonedPropertyKeepsTheOriginalRoot(): void + { + $doc = Reader::read(self::ICS); + + $dtStart = clone $doc->VEVENT->DTSTART; + + self::assertSame($doc, $this->getRoot($dtStart)); + self::assertSame($doc, $this->getRoot($dtStart['TZID'])); + self::assertSame($dtStart, $dtStart['TZID']->parent); + } + + /** + * @return Node[] + */ + private function collectNodes(Node $node): array + { + $nodes = [$node]; + if ($node instanceof Component) { + foreach ($node->children() as $child) { + array_push($nodes, ...$this->collectNodes($child)); + } + } elseif ($node instanceof Property) { + array_push($nodes, ...array_values($node->parameters())); + } + + return $nodes; + } + + private function getRoot(Node $node): ?Component + { + return (new \ReflectionProperty(Node::class, 'root'))->getValue($node); + } +}