commit 80fd71b92239b7d1e061266dd0303b0e73beeea0 Author: webfarben Date: Wed Mar 11 20:01:53 2026 +0100 Initial: Webfarben DummyCopier Bundle diff --git a/README.md b/README.md new file mode 100644 index 0000000..0258e38 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Contao Dummy Copier (Scaffold) + +Dieses Bundle stellt ein Backend-Modul `Dummy Copier` bereit, um bestehende Dummyseiten, Inhalte, Module und Verzeichnisse zu kopieren und Referenzen automatisiert umzubiegen. + +## Enthaltene Funktionen + +- Rekursives Kopieren von Seitenbaeumen (`tl_page`) +- Optionales Kopieren von Artikeln und Content (`tl_article`, `tl_content`) +- Optionales Kopieren von Modulen (`tl_module`) +- Automatisches Umstellen von: + - Content-Elementen vom Typ `module` auf kopierte Modul-IDs + - `jumpTo` in kopierten Seiten/Modulen/Content auf kopierte Seiten, falls vorhanden +- Optionales Kopieren von Verzeichnissen (Dateisystem-Mirror) +- Dry-Run Modus ohne Schreibzugriff + +## Installation + +1. Bundle in dein Contao-Projekt legen (oder als VCS-Paket einbinden). +2. `composer install` oder `composer update acme/contao-dummy-copier` +3. Cache leeren. +4. Backend-Modul `Dummy Copier` unter `System` oeffnen. + +## Bedienung (aktueller Stand) + +- Quellobjekte werden ueber Mehrfachauswahlfelder ausgewaehlt (Seiten, Module, Content, Verzeichnisse). +- Seiten und Verzeichnisse werden in Baumdarstellung (Einrueckung nach Hierarchie) angezeigt. +- Alle Mehrfachauswahlfelder haben Live-Filter sowie `Alle`/`Keine` Buttons. +- Ziel-Elternseite wird per Auswahlfeld gesetzt. + +Bei kompatibler Contao-Umgebung nutzt das Modul native `pageTree`/`fileTree` Widgets fuer Seiten und Verzeichnisse. +Falls die Widget-Initialisierung versionsbedingt fehlschlaegt, wird automatisch auf die Select-Fallbacks gewechselt. +- Setze optional Zielverzeichnis, Zielartikel-ID und Praefix. +- Aktiviere Optionen nach Bedarf (`inkl. Content`, `Module kopieren`, `Verzeichnisse kopieren`, `Dry-Run`). + +Hinweis: Das Modul akzeptiert weiterhin CSV-Werte als Fallback, falls du Felder per POST automatisiert befuellst. + +## Wichtige Hinweise + +- Nach Verzeichnis-Kopien ggf. `contao:filesync` ausfuehren, damit DBAFS konsistent ist. +- Dieses Grundgeruest ist bewusst pragmatisch und kann erweitert werden um: + - PageTree/FileTree Picker statt CSV + - Feldspezifisches Mapping fuer News/Event/Archive-Felder in `tl_module` + - Job-Queue via Messenger bei sehr grossen Kopierlaeufen diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ae4b02b --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "webfarben/contao-dummy-copier", + "description": "Contao backend module to clone dummy pages, content elements, modules and rewire references.", + "type": "contao-bundle", + "license": "proprietary", + "require": { + "php": "^8.1", + "contao/core-bundle": "^4.13 || ^5.0" + }, + "autoload": { + "psr-4": { + "Webfarben\\DummyCopier\\": "src/" + } + }, + "extra": { + "contao-manager-plugin": "Webfarben\\DummyCopier\\ContaoManager\\Plugin" + } +} diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..6403440 --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,9 @@ +services: + _defaults: + autowire: true + autoconfigure: true + bind: + string $projectDir: '%kernel.project_dir%' + + Acme\DummyCopier\: + resource: '../src/' diff --git a/contao/config/config.php b/contao/config/config.php new file mode 100644 index 0000000..b71780e --- /dev/null +++ b/contao/config/config.php @@ -0,0 +1,6 @@ + Webfarben\DummyCopier\Backend\DummyCopierModule::class, + 'icon' => 'bundles/acmedummycopier/icon.svg', +]; diff --git a/contao/languages/de/modules.php b/contao/languages/de/modules.php new file mode 100644 index 0000000..2a294cc --- /dev/null +++ b/contao/languages/de/modules.php @@ -0,0 +1,3 @@ +extend('be_main'); ?> + +block('main'); ?> +
+ + + +

Dummy Copier

+ + + +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+

+

+ +

+

+

+

+ +

+ + result) && \is_object($this->result)): ?> +

Ergebnis

+
 $this->result->copiedPages,
+          'copiedModules' => $this->result->copiedModules,
+          'copiedContent' => $this->result->copiedContent,
+          'copiedDirectories' => $this->result->copiedDirectories,
+          'pageMap' => $this->result->pageMap,
+          'moduleMap' => $this->result->moduleMap,
+          'notes' => $this->result->notes,
+      ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); ?>
+ + + +
+endblock(); ?> diff --git a/src/Backend/DummyCopierModule.php b/src/Backend/DummyCopierModule.php new file mode 100644 index 0000000..e8ad6af --- /dev/null +++ b/src/Backend/DummyCopierModule.php @@ -0,0 +1,380 @@ +get(Connection::class); + + $this->Template->action = Environment::get('request'); + $this->Template->requestToken = \defined('REQUEST_TOKEN') ? REQUEST_TOKEN : ''; + $this->Template->pageChoices = $this->getPageChoices($connection); + $this->Template->moduleChoices = $this->getModuleChoices($connection); + $this->Template->contentChoices = $this->getContentChoices($connection); + $this->Template->directoryChoices = $this->getDirectoryChoices(); + $this->Template->sourcePagesWidget = ''; + $this->Template->targetParentPageWidget = ''; + $this->Template->sourceDirectoriesWidget = ''; + $targetParentPageId = $this->parseSingleIdInput(Input::postRaw('targetParentPage')); + + $this->Template->selected = [ + 'sourcePages' => $this->parseIdInput(Input::postRaw('sourcePages')), + 'sourceModules' => $this->parseIdInput(Input::postRaw('sourceModules')), + 'sourceContent' => $this->parseIdInput(Input::postRaw('sourceContent')), + 'sourceDirectories' => $this->parsePathInput(Input::postRaw('sourceDirectories')), + 'targetParentPage' => $targetParentPageId, + 'targetArticle' => (int) Input::post('targetArticle'), + 'targetDirectory' => trim((string) Input::post('targetDirectory')), + 'namePrefix' => trim((string) Input::post('namePrefix')), + ]; + + $this->prepareTreeWidgets(); + + if (Input::post('FORM_SUBMIT') !== 'tl_dummy_copier') { + return; + } + + $options = new DummyCopyOptions( + $this->parseIdInput(Input::postRaw('sourcePages')), + $this->parseIdInput(Input::postRaw('sourceModules')), + $this->parseIdInput(Input::postRaw('sourceContent')), + $this->parsePathInput(Input::postRaw('sourceDirectories')), + $targetParentPageId, + (int) Input::post('targetArticle'), + trim((string) Input::post('targetDirectory')), + trim((string) Input::post('namePrefix')), + (bool) Input::post('includeContent'), + (bool) Input::post('copyModules'), + (bool) Input::post('copyDirectories'), + (bool) Input::post('dryRun') + ); + + try { + /** @var DummyCopier $copier */ + $copier = System::getContainer()->get(DummyCopier::class); + $result = $copier->execute($options); + + Message::addConfirmation(sprintf( + 'Fertig. Seiten: %d, Module: %d, Content: %d, Verzeichnisse: %d', + $result->copiedPages, + $result->copiedModules, + $result->copiedContent, + $result->copiedDirectories + )); + + $this->Template->result = $result; + } catch (\Throwable $exception) { + Message::addError($exception->getMessage()); + } + } + + private function parseIdInput($input): array + { + if (\is_array($input)) { + return array_values(array_filter(array_map('intval', $input), static fn (int $id): bool => $id > 0)); + } + + $csv = trim((string) $input); + + if ($csv === '') { + return []; + } + + $deserialized = StringUtil::deserialize($csv, true); + + if ($deserialized !== [] && $deserialized !== [$csv]) { + return array_values(array_filter(array_map('intval', $deserialized), static fn (int $id): bool => $id > 0)); + } + + $parts = array_filter(array_map('trim', explode(',', $csv)), static fn (string $value): bool => $value !== ''); + + return array_values(array_filter(array_map('intval', $parts), static fn (int $id): bool => $id > 0)); + } + + private function parsePathInput($input): array + { + if (\is_array($input)) { + return array_values(array_filter(array_map('trim', $input), static fn (string $value): bool => $value !== '')); + } + + $csv = trim((string) $input); + + if ($csv === '') { + return []; + } + + $deserialized = StringUtil::deserialize($csv, true); + + if ($deserialized !== [] && $deserialized !== [$csv]) { + return array_values(array_filter(array_map('trim', $deserialized), static fn (string $value): bool => $value !== '')); + } + + return array_values(array_filter(array_map('trim', explode(',', $csv)), static fn (string $value): bool => $value !== '')); + } + + private function parseSingleIdInput($input): int + { + $ids = $this->parseIdInput($input); + + return $ids[0] ?? 0; + } + + private function prepareTreeWidgets(): void + { + if (!class_exists(PageTree::class) || !class_exists(FileTree::class) || !class_exists(Widget::class)) { + return; + } + + try { + $selectedPages = $this->parseIdInput(Input::postRaw('sourcePages')); + $selectedParent = (int) Input::post('targetParentPage'); + $selectedDirectories = $this->parsePathInput(Input::postRaw('sourceDirectories')); + + $this->Template->sourcePagesWidget = $this->renderPageTreeWidget( + 'sourcePages', + 'Quell-Seiten (pageTree)', + $selectedPages, + true + ); + + $this->Template->targetParentPageWidget = $this->renderPageTreeWidget( + 'targetParentPage', + 'Ziel-Elternseite (pageTree)', + $selectedParent > 0 ? [$selectedParent] : [], + false + ); + + $this->Template->sourceDirectoriesWidget = $this->renderFileTreeWidget( + 'sourceDirectories', + 'Quell-Verzeichnisse (fileTree)', + $selectedDirectories + ); + } catch (\Throwable $exception) { + // If widget rendering differs by Contao version, the module falls back to select boxes. + $this->Template->sourcePagesWidget = ''; + $this->Template->targetParentPageWidget = ''; + $this->Template->sourceDirectoriesWidget = ''; + Message::addInfo('Tree-Widgets konnten nicht initialisiert werden, Fallback-Auswahl wird verwendet.'); + } + } + + private function renderPageTreeWidget(string $name, string $label, array $value, bool $multiple): string + { + $attributes = Widget::getAttributesFromDca([ + 'inputType' => 'pageTree', + 'label' => [$label, ''], + 'eval' => [ + 'fieldType' => $multiple ? 'checkbox' : 'radio', + 'multiple' => $multiple, + 'tl_class' => 'clr', + ], + ], $name, $value, $name, 'tl_dummy_copier'); + + $attributes['id'] = $name; + $attributes['name'] = $name; + + $widget = new PageTree($attributes); + + return $widget->generate(); + } + + private function renderFileTreeWidget(string $name, string $label, array $value): string + { + $attributes = Widget::getAttributesFromDca([ + 'inputType' => 'fileTree', + 'label' => [$label, ''], + 'eval' => [ + 'fieldType' => 'checkbox', + 'filesOnly' => false, + 'files' => false, + 'multiple' => true, + 'tl_class' => 'clr', + ], + ], $name, $value, $name, 'tl_dummy_copier'); + + $attributes['id'] = $name; + $attributes['name'] = $name; + + $widget = new FileTree($attributes); + + return $widget->generate(); + } + + /** + * @return array + */ + private function getPageChoices(Connection $connection): array + { + $rows = $connection->fetchAllAssociative('SELECT id, pid, title, alias FROM tl_page ORDER BY sorting, id'); + $rowsByParent = []; + + foreach ($rows as $row) { + $pid = (int) ($row['pid'] ?? 0); + $rowsByParent[$pid][] = $row; + } + + $choices = []; + + $build = function (int $pid, int $depth) use (&$build, &$choices, $rowsByParent): void { + foreach ($rowsByParent[$pid] ?? [] as $row) { + $id = (int) ($row['id'] ?? 0); + + if ($id < 1) { + continue; + } + + $title = trim((string) ($row['title'] ?? '')); + $alias = trim((string) ($row['alias'] ?? '')); + $label = $title !== '' ? $title : 'Seite ' . $id; + + if ($alias !== '') { + $label .= ' (' . $alias . ')'; + } + + $indent = str_repeat(' ', max(0, $depth)); + $choices[$id] = sprintf('%s%s [ID %d]', $indent, $label, $id); + $build($id, $depth + 1); + } + }; + + $build(0, 0); + + // Fallback for non-rooted records that were not visited from pid=0. + foreach ($rows as $row) { + $id = (int) ($row['id'] ?? 0); + + if ($id < 1 || isset($choices[$id])) { + continue; + } + + $title = trim((string) ($row['title'] ?? '')); + $alias = trim((string) ($row['alias'] ?? '')); + $label = $title !== '' ? $title : 'Seite ' . $id; + + if ($alias !== '') { + $label .= ' (' . $alias . ')'; + } + + $choices[$id] = sprintf('%s [ID %d]', $label, $id); + } + + return $choices; + } + + /** + * @return array + */ + private function getModuleChoices(Connection $connection): array + { + $rows = $connection->fetchAllAssociative('SELECT id, name, type FROM tl_module ORDER BY id'); + $choices = []; + + foreach ($rows as $row) { + $id = (int) ($row['id'] ?? 0); + + if ($id < 1) { + continue; + } + + $name = trim((string) ($row['name'] ?? 'Modul ' . $id)); + $type = trim((string) ($row['type'] ?? '')); + $label = $type !== '' ? sprintf('%s (%s)', $name, $type) : $name; + $choices[$id] = sprintf('%s [ID %d]', $label, $id); + } + + return $choices; + } + + /** + * @return array + */ + private function getContentChoices(Connection $connection): array + { + $rows = $connection->fetchAllAssociative('SELECT id, type, pid, headline FROM tl_content ORDER BY id'); + $choices = []; + + foreach ($rows as $row) { + $id = (int) ($row['id'] ?? 0); + + if ($id < 1) { + continue; + } + + $type = trim((string) ($row['type'] ?? 'content')); + $pid = (int) ($row['pid'] ?? 0); + $headline = $this->normalizeHeadline($row['headline'] ?? null); + $label = $headline !== '' ? sprintf('%s: %s', $type, $headline) : $type; + $choices[$id] = sprintf('%s [ID %d, Artikel %d]', $label, $id, $pid); + } + + return $choices; + } + + /** + * @return array + */ + private function getDirectoryChoices(): array + { + $projectDir = (string) System::getContainer()->getParameter('kernel.project_dir'); + $filesDir = $projectDir . '/files'; + + if (!is_dir($filesDir)) { + return []; + } + + $choices = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($filesDir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + if (!$item->isDir()) { + continue; + } + + $fullPath = $item->getPathname(); + $relative = str_replace($projectDir . '/', '', $fullPath); + $trimmed = trim((string) str_replace('files/', '', $relative), '/'); + $depth = $trimmed === '' ? 0 : substr_count($trimmed, '/'); + $indent = str_repeat(' ', max(0, $depth)); + $choices[$relative] = $indent . $relative; + } + + ksort($choices); + + return $choices; + } + + private function normalizeHeadline($headline): string + { + if (\is_string($headline)) { + return trim($headline); + } + + if (!\is_array($headline) || !isset($headline['value']) || !\is_string($headline['value'])) { + return ''; + } + + return trim($headline['value']); + } +} diff --git a/src/ContaoManager/Plugin.php b/src/ContaoManager/Plugin.php new file mode 100644 index 0000000..fe5a810 --- /dev/null +++ b/src/ContaoManager/Plugin.php @@ -0,0 +1,22 @@ +setLoadAfter([ContaoCoreBundle::class]), + ]; + } +} diff --git a/src/DummyCopierBundle.php b/src/DummyCopierBundle.php new file mode 100644 index 0000000..d760c40 --- /dev/null +++ b/src/DummyCopierBundle.php @@ -0,0 +1,11 @@ +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->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->targetArticleId > 0) { + foreach ($options->sourceContentIds as $sourceContentId) { + $this->copySingleContent($sourceContentId, $options->targetArticleId, $result->moduleMap, $result); + } + } + + $this->rewriteReferences($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); + + $contentIds = $this->connection->fetchFirstColumn('SELECT id FROM tl_content WHERE ptable = ? AND pid = ? ORDER BY sorting', ['tl_article', (int) $articleId]); + + foreach ($contentIds as $contentId) { + $this->copySingleContent((int) $contentId, $newArticleId, $moduleMap, $result); + } + } + } + + /** + * @param array $moduleMap + */ + private function copySingleContent(int $sourceContentId, int $targetArticleId, array $moduleMap, DummyCopyResult $result): void + { + $contentRow = $this->fetchRow('tl_content', $sourceContentId); + + if ($contentRow === null) { + $result->addNote(sprintf('Content %d nicht gefunden, wurde uebersprungen.', $sourceContentId)); + return; + } + + unset($contentRow['id']); + $contentRow['pid'] = $targetArticleId; + $contentRow['ptable'] = 'tl_article'; + $contentRow['tstamp'] = time(); + $contentRow['sorting'] = $this->nextSorting('tl_content', 'pid', $targetArticleId, 'ptable', 'tl_article'); + + if (($contentRow['type'] ?? '') === 'module') { + $oldModule = (int) ($contentRow['module'] ?? 0); + + if (isset($moduleMap[$oldModule])) { + $contentRow['module'] = $moduleMap[$oldModule]; + } + } + + $newContentId = $this->insertRow('tl_content', $contentRow); + $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]); + } + } + + 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 + ); + } + } + + 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 + { + $alias = $this->slugify($baseAlias); + $counter = 1; + + while ((int) $this->connection->fetchOne('SELECT COUNT(*) FROM tl_page WHERE alias = ?', [$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 estimateContentCount(DummyCopyOptions $options): int + { + $count = \count($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), '?')); + + return $count + (int) $this->connection->fetchOne( + sprintf('SELECT COUNT(*) FROM tl_content WHERE ptable = ? AND pid IN (%s)', $articlePlaceholders), + array_merge(['tl_article'], array_map('intval', $articleIds)) + ); + } +} diff --git a/src/Service/DummyCopyOptions.php b/src/Service/DummyCopyOptions.php new file mode 100644 index 0000000..52c761d --- /dev/null +++ b/src/Service/DummyCopyOptions.php @@ -0,0 +1,24 @@ + */ + public array $pageMap = []; + + /** @var array */ + public array $moduleMap = []; + + /** @var array */ + public array $notes = []; + + /** @var array */ + public array $copiedContentIds = []; + + public function addNote(string $note): void + { + $this->notes[] = $note; + } +}