*/ public function listSourceForms(string $sourceType, ?string $formIdentifier = null): array { $sourceAdapter = $this->storageAdapterFactory->getAdapterByType($sourceType); $forms = $sourceAdapter->findAll(new SearchCriteria()); if ($formIdentifier !== null) { $forms = array_values(array_filter( $forms, static fn(FormMetadata $form) => $form->identifier === $formIdentifier, )); } return $forms; } /** * Read a form definition from a source storage */ public function readForm(string $sourceType, string $persistenceIdentifier): FormData { return $this->storageAdapterFactory->getAdapterByType($sourceType)->read(FormIdentifier::fromString($persistenceIdentifier)); } /** * Delete a form from a storage */ public function deleteForm(string $storageType, string $persistenceIdentifier): void { $adapter = $this->storageAdapterFactory->getAdapterByType($storageType); $adapter->delete(FormIdentifier::fromString($persistenceIdentifier)); } /** * Transfer a single form from source to target storage * * @return FormTransferResult */ public function transferForm( FormMetadata $sourceForm, string $sourceType, string $targetType, string $targetLocation, bool $deleteSource = false, ): FormTransferResult { $sourceAdapter = $this->storageAdapterFactory->getAdapterByType($sourceType); $targetAdapter = $this->storageAdapterFactory->getAdapterByType($targetType); // Read from source $sourcePersistenceIdentifier = $sourceForm->persistenceIdentifier ?? $sourceForm->identifier; $formData = $sourceAdapter->read(FormIdentifier::fromString($sourcePersistenceIdentifier)); // Ensure unique identifier in target. // For a move operation the source form will be deleted afterwards, so it must not be // counted as a duplicate. Therefore only the target adapter is checked for conflicts. // For a copy operation all adapters are checked to avoid the same logical identifier // existing in multiple storages simultaneously. if ($deleteSource) { $uniqueIdentifier = $this->getUniqueIdentifierInAdapter($targetAdapter, $formData->identifier); } else { $uniqueIdentifier = $this->formPersistenceManager->getUniqueIdentifier($formData->identifier); } // Build FormData with potentially updated identifier $targetFormData = $uniqueIdentifier !== $formData->identifier ? FormData::fromArray(array_merge($formData->toArray(), ['identifier' => $uniqueIdentifier])) : $formData; // Get unique persistence identifier in target storage $targetPersistenceIdentifier = $targetAdapter->getUniquePersistenceIdentifier( $uniqueIdentifier, $targetLocation, ); // Write to target $context = $this->buildStorageContext($targetLocation); $savedIdentifier = $targetAdapter->write( FormIdentifier::fromString($targetPersistenceIdentifier), $targetFormData, $context, ); // Optionally delete from source $sourceDeleted = false; $deletionError = null; if ($deleteSource) { try { $sourceAdapter->delete(FormIdentifier::fromString($sourcePersistenceIdentifier)); $sourceDeleted = true; } catch (\Exception $e) { $deletionError = $e->getMessage(); } } return new FormTransferResult( sourceIdentifier: $sourcePersistenceIdentifier, targetIdentifier: $savedIdentifier->identifier, formIdentifier: $uniqueIdentifier, formName: $sourceForm->name, sourceDeleted: $sourceDeleted, deletionError: $deletionError, ); } /** * Get all registered storage type identifiers * * @return list */ public function getAvailableStorageTypes(): array { return $this->storageAdapterFactory->getRegisteredTypeIdentifiers(); } /** * Check if a storage type exists */ public function hasStorageType(string $typeIdentifier): bool { return $this->storageAdapterFactory->hasAdapterType($typeIdentifier); } /** * Get adapter for a storage type (for validation purposes) */ public function getAdapter(string $typeIdentifier): StorageAdapterInterface { return $this->storageAdapterFactory->getAdapterByType($typeIdentifier); } /** * Get a unique form identifier by checking only the given storage adapter for conflicts. * * Used for move operations: since the source form is deleted after transfer, only the * target storage needs to be free of the identifier — not all storages globally. * * @throws \RuntimeException if no unique identifier can be found */ private function getUniqueIdentifierInAdapter(StorageAdapterInterface $adapter, string $identifier): string { $originalIdentifier = $identifier; if (!$adapter->existsByFormIdentifier($identifier)) { return $identifier; } for ($attempts = 1; $attempts < 100; $attempts++) { $identifier = sprintf('%s_%d', $originalIdentifier, $attempts); if (!$adapter->existsByFormIdentifier($identifier)) { return $identifier; } } $identifier = $originalIdentifier . '_' . time(); if (!$adapter->existsByFormIdentifier($identifier)) { return $identifier; } throw new \RuntimeException( sprintf('Could not find a unique identifier for form identifier "%s" after %d attempts', $originalIdentifier, $attempts), 1742477400 ); } private function buildStorageContext(string $targetLocation): ?StorageContext { if (ctype_digit($targetLocation)) { return StorageContext::create((int)$targetLocation); } return null; } /** * Update tt_content FlexForm references from old persistence identifiers * to new ones. * * Uses DOM/XPath parsing to precisely target only the * `settings.persistenceIdentifier` field in FlexForm XML, avoiding * false replacements in other fields. * * @param array $migrationMap Old persistenceIdentifier => new persistenceIdentifier * @return int Number of updated content element references */ public function updateContentElementReferences(array $migrationMap): int { $updatedCount = 0; $connection = $this->connectionPool->getConnectionForTable('tt_content'); $queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content'); $queryBuilder->getRestrictions()->removeAll()->add( GeneralUtility::makeInstance(DeletedRestriction::class) ); $rows = $queryBuilder ->select('uid', 'pi_flexform') ->from('tt_content') ->where( $queryBuilder->expr()->eq( 'CType', $queryBuilder->createNamedParameter('form_formframework') ), $queryBuilder->expr()->isNotNull('pi_flexform'), $queryBuilder->expr()->neq( 'pi_flexform', $queryBuilder->createNamedParameter('') ) ) ->executeQuery() ->fetchAllAssociative(); foreach ($rows as $row) { $flexForm = $row['pi_flexform']; $newValue = $this->replacePersistenceIdentifierInFlexForm($flexForm, $migrationMap); if ($newValue !== null && $newValue !== $flexForm) { $connection->update( 'tt_content', ['pi_flexform' => $newValue], ['uid' => (int)$row['uid']] ); $updatedCount++; } } return $updatedCount; } /** * Replace the persistenceIdentifier value in FlexForm XML using DOM parsing. * * Specifically targets only the * element to avoid replacing values in other FlexForm fields. * * @param string $flexFormXml The raw FlexForm XML string * @param array $migrationMap Old persistenceIdentifier => new persistenceIdentifier * @return string|null The modified XML string, or null if parsing failed or no changes were made */ private function replacePersistenceIdentifierInFlexForm(string $flexFormXml, array $migrationMap): ?string { $document = new \DOMDocument(); $previousErrorHandling = libxml_use_internal_errors(true); if (!$document->loadXML($flexFormXml)) { libxml_clear_errors(); libxml_use_internal_errors($previousErrorHandling); $this->logger->warning('Could not parse FlexForm XML.'); return null; } libxml_clear_errors(); libxml_use_internal_errors($previousErrorHandling); $xpath = new \DOMXPath($document); $nodes = $xpath->query('//field[@index="settings.persistenceIdentifier"]/value[@index="vDEF"]'); if ($nodes === false || $nodes->length === 0) { return null; } $modified = false; foreach ($nodes as $node) { $currentValue = $node->nodeValue; if (isset($migrationMap[$currentValue])) { $node->nodeValue = (string)$migrationMap[$currentValue]; $modified = true; } } if (!$modified) { return null; } return $document->saveXML(); } }