targetParentPageId < 1) { throw new \InvalidArgumentException('Eine gueltige Ziel-Elternseite ist erforderlich.'); } $result = new DummyCopyResult(); if ($options->dryRun) { $result->copiedPages = $this->countRows('tl_page', $options->sourcePageIds); $result->copiedModules = $options->copyModules ? $this->countRows('tl_module', $options->sourceModuleIds) : 0; $result->copiedContent = $this->estimateContentCount($options); $result->copiedDirectories = $options->copyDirectories ? \count($options->sourceDirectories) : 0; $result->copiedNewsArchives = $this->countRows('tl_news_archive', $options->sourceNewsArchiveIds); $result->copiedNewsItems = $this->countByPid('tl_news', 'pid', $options->sourceNewsArchiveIds); $result->copiedCalendars = $this->countRows('tl_calendar', $options->sourceCalendarIds); $result->copiedEvents = $this->countByPid('tl_calendar_events', 'pid', $options->sourceCalendarIds); $result->addNote('Dry-Run aktiv: Es wurden keine Daten geschrieben.'); return $result; } $this->connection->beginTransaction(); try { if ($options->copyModules) { $result->moduleMap = $this->copyModules($options->sourceModuleIds, $options->namePrefix, $result); } foreach ($options->sourcePageIds as $sourcePageId) { $newPageId = $this->copyPageTree($sourcePageId, $options->targetParentPageId, $options->namePrefix, $result); if ($options->includeContent) { $this->copyArticlesAndContent($sourcePageId, $newPageId, $result->moduleMap, $result); } } if ($options->sourceNewsArchiveIds !== []) { $result->newsArchiveMap = $this->copyNewsArchives($options->sourceNewsArchiveIds, $options->namePrefix, $result); } if ($options->sourceCalendarIds !== []) { $result->calendarMap = $this->copyCalendars($options->sourceCalendarIds, $options->namePrefix, $result); } if ($options->targetArticleId > 0) { foreach ($options->sourceContentIds as $sourceContentId) { $this->copySingleContent($sourceContentId, $options->targetArticleId, $result->moduleMap, $result); } } $this->rewriteReferences($result); if ($options->copyModules) { $this->rewriteModuleArchiveReferences($result); } if ($options->copyDirectories) { $this->copyDirectories($options, $result); } $this->connection->commit(); } catch (\Throwable $exception) { $this->connection->rollBack(); throw $exception; } return $result; } /** * @return array */ private function copyModules(array $sourceModuleIds, string $prefix, DummyCopyResult $result): array { $map = []; foreach ($sourceModuleIds as $sourceId) { $row = $this->fetchRow('tl_module', $sourceId); if ($row === null) { $result->addNote(sprintf('Modul %d nicht gefunden, wurde uebersprungen.', $sourceId)); continue; } unset($row['id']); $row['tstamp'] = time(); $row['name'] = $this->prefixed((string) ($row['name'] ?? ('module-' . $sourceId)), $prefix); $newId = $this->insertRow('tl_module', $row); $map[$sourceId] = $newId; $result->copiedModules++; } return $map; } private function copyPageTree(int $sourcePageId, int $newParentId, string $prefix, DummyCopyResult $result): int { $source = $this->fetchRow('tl_page', $sourcePageId); if ($source === null) { throw new \RuntimeException(sprintf('Seite %d wurde nicht gefunden.', $sourcePageId)); } unset($source['id']); $source['pid'] = $newParentId; $source['tstamp'] = time(); $source['title'] = $this->prefixed((string) ($source['title'] ?? ('page-' . $sourcePageId)), $prefix); $source['alias'] = $this->makeUniqueAlias($this->prefixed((string) ($source['alias'] ?? ('page-' . $sourcePageId)), $prefix)); $source['sorting'] = $this->nextSorting('tl_page', 'pid', $newParentId); $newPageId = $this->insertRow('tl_page', $source); $result->pageMap[$sourcePageId] = $newPageId; $result->copiedPages++; $children = $this->connection->fetchFirstColumn('SELECT id FROM tl_page WHERE pid = ? ORDER BY sorting', [$sourcePageId]); foreach ($children as $childId) { $this->copyPageTree((int) $childId, $newPageId, $prefix, $result); } return $newPageId; } /** * Copies articles and their content from one page to another. * * @param array $moduleMap */ private function copyArticlesAndContent(int $sourcePageId, int $targetPageId, array $moduleMap, DummyCopyResult $result): void { $articleIds = $this->connection->fetchFirstColumn('SELECT id FROM tl_article WHERE pid = ? ORDER BY sorting', [$sourcePageId]); foreach ($articleIds as $articleId) { $articleRow = $this->fetchRow('tl_article', (int) $articleId); if ($articleRow === null) { continue; } unset($articleRow['id']); $articleRow['pid'] = $targetPageId; $articleRow['tstamp'] = time(); $articleRow['sorting'] = $this->nextSorting('tl_article', 'pid', $targetPageId); $newArticleId = $this->insertRow('tl_article', $articleRow); $this->copyArticleContentTree((int) $articleId, $newArticleId, $moduleMap, $result); } } /** * Copies top-level article content and all nested child elements recursively. * * @param array $moduleMap */ private function copyArticleContentTree(int $sourceArticleId, int $targetArticleId, array $moduleMap, DummyCopyResult $result): void { $visited = []; $contentIds = $this->connection->fetchFirstColumn( 'SELECT id FROM tl_content WHERE ptable = ? AND pid = ? ORDER BY sorting', ['tl_article', $sourceArticleId] ); foreach ($contentIds as $contentId) { $this->copyContentRecursive((int) $contentId, 'tl_article', $targetArticleId, $moduleMap, $result, $visited); } } /** * @param array $moduleMap */ private function copySingleContent(int $sourceContentId, int $targetArticleId, array $moduleMap, DummyCopyResult $result): void { $visited = []; $this->copyContentRecursive($sourceContentId, 'tl_article', $targetArticleId, $moduleMap, $result, $visited); } /** * Recursively copies one content element and its children. * * @param array $moduleMap */ private function copyContentRecursive(int $sourceContentId, string $targetPtable, int $targetPid, array $moduleMap, DummyCopyResult $result, array &$visited): int { if (isset($visited[$sourceContentId])) { return 0; } $visited[$sourceContentId] = true; $contentRow = $this->fetchRow('tl_content', $sourceContentId); if ($contentRow === null) { $result->addNote(sprintf('Content %d nicht gefunden, wurde uebersprungen.', $sourceContentId)); return 0; } unset($contentRow['id']); $contentRow['pid'] = $targetPid; $contentRow['ptable'] = $targetPtable; $contentRow['tstamp'] = time(); $contentRow['sorting'] = $this->nextSorting('tl_content', 'pid', $targetPid, 'ptable', $targetPtable); if (($contentRow['type'] ?? '') === 'module') { $oldModule = (int) ($contentRow['module'] ?? 0); if (isset($moduleMap[$oldModule])) { $contentRow['module'] = $moduleMap[$oldModule]; } } $newContentId = $this->insertRow('tl_content', $contentRow); $result->contentMap[$sourceContentId] = $newContentId; $result->copiedContent++; $result->copiedContentIds[] = $newContentId; if (isset($contentRow['jumpTo'], $result->pageMap[(int) $contentRow['jumpTo']])) { $this->connection->update('tl_content', ['jumpTo' => $result->pageMap[(int) $contentRow['jumpTo']]], ['id' => $newContentId]); } $childIds = $this->connection->fetchFirstColumn( 'SELECT id FROM tl_content WHERE ptable = ? AND pid = ? ORDER BY sorting', ['tl_content', $sourceContentId] ); foreach ($childIds as $childId) { $this->copyContentRecursive((int) $childId, 'tl_content', $newContentId, $moduleMap, $result, $visited); } return $newContentId; } private function rewriteReferences(DummyCopyResult $result): void { foreach ($result->pageMap as $oldPageId => $newPageId) { if (isset($result->pageMap[$oldPageId])) { $this->connection->update( 'tl_page', ['jumpTo' => $result->pageMap[$oldPageId]], ['id' => $newPageId, 'jumpTo' => $oldPageId] ); } } foreach ($result->moduleMap as $oldModuleId => $newModuleId) { foreach ($result->pageMap as $oldPageId => $newPageId) { $this->connection->update( 'tl_module', ['jumpTo' => $newPageId], ['id' => $newModuleId, 'jumpTo' => $oldPageId] ); } if ($result->copiedContentIds === []) { continue; } $placeholders = implode(',', array_fill(0, \count($result->copiedContentIds), '?')); $params = array_merge([$newModuleId, 'module', $oldModuleId], $result->copiedContentIds); // Only copied content elements are switched to their cloned modules. $this->connection->executeStatement( sprintf('UPDATE tl_content SET module = ? WHERE type = ? AND module = ? AND id IN (%s)', $placeholders), $params ); } foreach ($result->contentMap as $oldContentId => $newContentId) { $sourceAliasTarget = (int) $this->connection->fetchOne( 'SELECT cteAlias FROM tl_content WHERE id = ? AND type = ?', [$oldContentId, 'alias'] ); if ($sourceAliasTarget < 1 || !isset($result->contentMap[$sourceAliasTarget])) { continue; } $this->connection->update( 'tl_content', ['cteAlias' => $result->contentMap[$sourceAliasTarget]], ['id' => $newContentId, 'type' => 'alias'] ); } } private function rewriteModuleArchiveReferences(DummyCopyResult $result): void { if ($result->moduleMap === []) { return; } foreach ($result->moduleMap as $newModuleId) { $row = $this->fetchRow('tl_module', $newModuleId); if ($row === null) { continue; } $updates = []; if (array_key_exists('news_archives', $row) && $result->newsArchiveMap !== []) { $updates['news_archives'] = $this->remapSerializedIds((string) ($row['news_archives'] ?? ''), $result->newsArchiveMap); } if (array_key_exists('cal_calendar', $row) && $result->calendarMap !== []) { $updates['cal_calendar'] = $this->remapSerializedIds((string) ($row['cal_calendar'] ?? ''), $result->calendarMap); } if (array_key_exists('news_readerModule', $row)) { $oldReaderModuleId = (int) ($row['news_readerModule'] ?? 0); if ($oldReaderModuleId > 0 && isset($result->moduleMap[$oldReaderModuleId])) { $updates['news_readerModule'] = $result->moduleMap[$oldReaderModuleId]; } } if (array_key_exists('cal_readerModule', $row)) { $oldReaderModuleId = (int) ($row['cal_readerModule'] ?? 0); if ($oldReaderModuleId > 0 && isset($result->moduleMap[$oldReaderModuleId])) { $updates['cal_readerModule'] = $result->moduleMap[$oldReaderModuleId]; } } if ($updates !== []) { $updates['tstamp'] = time(); $this->connection->update('tl_module', $updates, ['id' => $newModuleId]); } } $this->rewriteNewsItemReferences($result); } private function rewriteNewsItemReferences(DummyCopyResult $result): void { if ($result->newsItemMap === []) { return; } foreach ($result->newsItemMap as $oldNewsId => $newNewsId) { $sourceRow = $this->fetchRow('tl_news', $oldNewsId); if ($sourceRow === null || !array_key_exists('related', $sourceRow)) { continue; } $related = StringUtil::deserialize((string) ($sourceRow['related'] ?? ''), true); if ($related === []) { continue; } $mappedRelated = []; foreach ($related as $relatedIdValue) { $relatedId = (int) $relatedIdValue; if ($relatedId < 1) { continue; } $mappedRelated[] = (string) ($result->newsItemMap[$relatedId] ?? $relatedId); } $this->connection->update( 'tl_news', [ 'related' => serialize($mappedRelated), 'tstamp' => time(), ], ['id' => $newNewsId] ); } } /** * @param array $sourceArchiveIds * * @return array */ private function copyNewsArchives(array $sourceArchiveIds, string $prefix, DummyCopyResult $result): array { $map = []; foreach ($sourceArchiveIds as $sourceArchiveId) { $archiveRow = $this->fetchRow('tl_news_archive', (int) $sourceArchiveId); if ($archiveRow === null) { $result->addNote(sprintf('Newsarchiv %d nicht gefunden, wurde uebersprungen.', $sourceArchiveId)); continue; } unset($archiveRow['id']); $archiveRow['tstamp'] = time(); if (isset($archiveRow['title'])) { $archiveRow['title'] = $this->prefixed((string) $archiveRow['title'], $prefix); } if (isset($archiveRow['jumpTo']) && isset($result->pageMap[(int) $archiveRow['jumpTo']])) { $archiveRow['jumpTo'] = $result->pageMap[(int) $archiveRow['jumpTo']]; } $newArchiveId = $this->insertRow('tl_news_archive', $archiveRow); $map[(int) $sourceArchiveId] = $newArchiveId; $result->copiedNewsArchives++; $newsIds = $this->connection->fetchFirstColumn('SELECT id FROM tl_news WHERE pid = ? ORDER BY date, id', [(int) $sourceArchiveId]); foreach ($newsIds as $newsId) { $newsRow = $this->fetchRow('tl_news', (int) $newsId); if ($newsRow === null) { continue; } unset($newsRow['id']); $newsRow['pid'] = $newArchiveId; $newsRow['tstamp'] = time(); if (isset($newsRow['headline'])) { $newsRow['headline'] = $this->prefixed((string) $newsRow['headline'], $prefix); } if (isset($newsRow['alias']) && trim((string) $newsRow['alias']) !== '') { $newsRow['alias'] = $this->makeUniqueAliasInTable('tl_news', $this->prefixed((string) $newsRow['alias'], $prefix)); } if (isset($newsRow['jumpTo']) && isset($result->pageMap[(int) $newsRow['jumpTo']])) { $newsRow['jumpTo'] = $result->pageMap[(int) $newsRow['jumpTo']]; } $newNewsId = $this->insertRow('tl_news', $newsRow); $result->newsItemMap[(int) $newsId] = $newNewsId; $result->copiedNewsItems++; } } return $map; } /** * @param array $sourceCalendarIds * * @return array */ private function copyCalendars(array $sourceCalendarIds, string $prefix, DummyCopyResult $result): array { $map = []; foreach ($sourceCalendarIds as $sourceCalendarId) { $calendarRow = $this->fetchRow('tl_calendar', (int) $sourceCalendarId); if ($calendarRow === null) { $result->addNote(sprintf('Kalender %d nicht gefunden, wurde uebersprungen.', $sourceCalendarId)); continue; } unset($calendarRow['id']); $calendarRow['tstamp'] = time(); if (isset($calendarRow['title'])) { $calendarRow['title'] = $this->prefixed((string) $calendarRow['title'], $prefix); } if (isset($calendarRow['jumpTo']) && isset($result->pageMap[(int) $calendarRow['jumpTo']])) { $calendarRow['jumpTo'] = $result->pageMap[(int) $calendarRow['jumpTo']]; } $newCalendarId = $this->insertRow('tl_calendar', $calendarRow); $map[(int) $sourceCalendarId] = $newCalendarId; $result->copiedCalendars++; $eventIds = $this->connection->fetchFirstColumn('SELECT id FROM tl_calendar_events WHERE pid = ? ORDER BY startTime, id', [(int) $sourceCalendarId]); foreach ($eventIds as $eventId) { $eventRow = $this->fetchRow('tl_calendar_events', (int) $eventId); if ($eventRow === null) { continue; } unset($eventRow['id']); $eventRow['pid'] = $newCalendarId; $eventRow['tstamp'] = time(); if (isset($eventRow['title'])) { $eventRow['title'] = $this->prefixed((string) $eventRow['title'], $prefix); } if (isset($eventRow['alias']) && trim((string) $eventRow['alias']) !== '') { $eventRow['alias'] = $this->makeUniqueAliasInTable('tl_calendar_events', $this->prefixed((string) $eventRow['alias'], $prefix)); } if (isset($eventRow['jumpTo']) && isset($result->pageMap[(int) $eventRow['jumpTo']])) { $eventRow['jumpTo'] = $result->pageMap[(int) $eventRow['jumpTo']]; } $newEventId = $this->insertRow('tl_calendar_events', $eventRow); $result->eventMap[(int) $eventId] = $newEventId; $result->copiedEvents++; } } return $map; } private function copyDirectories(DummyCopyOptions $options, DummyCopyResult $result): void { if ($options->targetDirectory === '') { $result->addNote('Verzeichnisse wurden nicht kopiert: targetDirectory ist leer.'); return; } $targetBase = $this->projectDir . '/' . ltrim($options->targetDirectory, '/'); foreach ($options->sourceDirectories as $relativePath) { $sourcePath = $this->projectDir . '/' . ltrim($relativePath, '/'); if (!is_dir($sourcePath)) { $result->addNote(sprintf('Verzeichnis %s nicht gefunden, wurde uebersprungen.', $relativePath)); continue; } $folderName = basename($sourcePath); $targetPath = rtrim($targetBase, '/') . '/' . $this->prefixed($folderName, $options->namePrefix); $this->filesystem->mkdir(dirname($targetPath)); $this->filesystem->mirror($sourcePath, $targetPath, null, ['override' => true]); $result->copiedDirectories++; } $result->addNote('Hinweis: Nach Dateikopien ggf. DBAFS per contao:filesync synchronisieren.'); } private function nextSorting(string $table, string $pidField, int $pidValue, ?string $extraField = null, ?string $extraValue = null): int { $sql = sprintf('SELECT COALESCE(MAX(sorting), 0) FROM %s WHERE %s = ?', $table, $pidField); $params = [$pidValue]; if ($extraField !== null) { $sql .= sprintf(' AND %s = ?', $extraField); $params[] = $extraValue; } $max = (int) $this->connection->fetchOne($sql, $params); return $max + 128; } private function makeUniqueAlias(string $baseAlias): string { return $this->makeUniqueAliasInTable('tl_page', $baseAlias); } private function makeUniqueAliasInTable(string $table, string $baseAlias): string { $alias = $this->slugify($baseAlias); $counter = 1; while ((int) $this->connection->fetchOne(sprintf('SELECT COUNT(*) FROM %s WHERE alias = ?', $table), [$alias]) > 0) { $alias = $this->slugify($baseAlias) . '-' . $counter; $counter++; } return $alias; } private function slugify(string $value): string { $value = strtolower(trim($value)); $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? 'page'; return trim($value, '-') ?: 'page'; } private function prefixed(string $value, string $prefix): string { if ($prefix === '') { return $value; } return $prefix . $value; } private function insertRow(string $table, array $row): int { $this->connection->insert($table, $row); return (int) $this->connection->lastInsertId(); } private function fetchRow(string $table, int $id): ?array { $row = $this->connection->fetchAssociative(sprintf('SELECT * FROM %s WHERE id = ?', $table), [$id]); return $row === false ? null : $row; } private function countRows(string $table, array $ids): int { if ($ids === []) { return 0; } $placeholders = implode(',', array_fill(0, \count($ids), '?')); return (int) $this->connection->fetchOne(sprintf('SELECT COUNT(*) FROM %s WHERE id IN (%s)', $table, $placeholders), $ids); } private function countByPid(string $table, string $pidField, array $pidValues): int { if ($pidValues === []) { return 0; } $placeholders = implode(',', array_fill(0, \count($pidValues), '?')); return (int) $this->connection->fetchOne(sprintf('SELECT COUNT(*) FROM %s WHERE %s IN (%s)', $table, $pidField, $placeholders), $pidValues); } /** * @param array $idMap */ private function remapSerializedIds(string $serialized, array $idMap): string { $values = StringUtil::deserialize($serialized, true); $mapped = []; foreach ($values as $value) { $id = (int) $value; if ($id < 1) { continue; } $mapped[] = (string) ($idMap[$id] ?? $id); } return serialize($mapped); } private function estimateContentCount(DummyCopyOptions $options): int { $count = $this->countContentTree(array_map('intval', $options->sourceContentIds)); if (!$options->includeContent || $options->sourcePageIds === []) { return $count; } $placeholders = implode(',', array_fill(0, \count($options->sourcePageIds), '?')); $articleIds = $this->connection->fetchFirstColumn(sprintf('SELECT id FROM tl_article WHERE pid IN (%s)', $placeholders), $options->sourcePageIds); if ($articleIds === []) { return $count; } $articlePlaceholders = implode(',', array_fill(0, \count($articleIds), '?')); $rootContentIds = $this->connection->fetchFirstColumn( sprintf('SELECT id FROM tl_content WHERE ptable = ? AND pid IN (%s)', $articlePlaceholders), array_merge(['tl_article'], array_map('intval', $articleIds)) ); if ($rootContentIds === []) { return $count; } return $count + $this->countContentTree(array_map('intval', $rootContentIds)); } /** * Counts content roots and all nested descendants. * * @param array $rootIds */ private function countContentTree(array $rootIds): int { if ($rootIds === []) { return 0; } $seen = []; $queue = array_values(array_filter($rootIds, static fn (int $id): bool => $id > 0)); while ($queue !== []) { $currentBatch = []; foreach ($queue as $id) { if (!isset($seen[$id])) { $seen[$id] = true; $currentBatch[] = $id; } } if ($currentBatch === []) { break; } $placeholders = implode(',', array_fill(0, \count($currentBatch), '?')); $children = $this->connection->fetchFirstColumn( sprintf('SELECT id FROM tl_content WHERE ptable = ? AND pid IN (%s)', $placeholders), array_merge(['tl_content'], $currentBatch) ); $queue = array_map('intval', $children); } return \count($seen); } }