From b181ce326d5dd3a564d0548b220c7701fdb27efe Mon Sep 17 00:00:00 2001 From: Leon Oltmanns <55587275+oltmanns-leuchtfeuer@users.noreply.github.com> Date: Tue, 12 Aug 2025 22:56:59 +0200 Subject: [PATCH 1/4] Add files via upload --- Command/ImportFromDirectoryCommand.php | 18 +-- Command/ParallelImportCommand.php | 11 +- Command/RemoveTagsCommand.php | 16 +- Config/config.php | 1 + Form/Type/ImportListType.php | 2 +- Import/ImportFromDirectory.php | 202 ++++++++++++++++++++++-- Integration/CustomImportIntegration.php | 46 +++++- Translations/en_US/messages.ini | 4 +- composer.json | 6 +- 9 files changed, 256 insertions(+), 50 deletions(-) diff --git a/Command/ImportFromDirectoryCommand.php b/Command/ImportFromDirectoryCommand.php index dae6af1..7a8ad30 100644 --- a/Command/ImportFromDirectoryCommand.php +++ b/Command/ImportFromDirectoryCommand.php @@ -11,14 +11,14 @@ namespace MauticPlugin\MauticCustomImportBundle\Command; -use Mautic\CoreBundle\Command\ModeratedCommand; +use Symfony\Component\Console\Command\Command; use MauticPlugin\MauticCustomImportBundle\Exception\InvalidImportException; use MauticPlugin\MauticCustomImportBundle\Import\CustomImportFactory; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Translation\TranslatorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; -class ImportFromDirectoryCommand extends ModeratedCommand +class ImportFromDirectoryCommand extends Command { /** * @var CustomImportFactory @@ -38,10 +38,11 @@ class ImportFromDirectoryCommand extends ModeratedCommand */ public function __construct(CustomImportFactory $customImportFactory, TranslatorInterface $translator) { + parent::__construct(); $this->customImportFactory = $customImportFactory; $this->translator = $translator; - parent::__construct(); - } + // parent constructor removed for Mautic 5; locking deps are DI'd into parent in core +} /** * {@inheritdoc} @@ -60,11 +61,6 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output) { - $key = __CLASS__; - if (!$this->checkRunStatus($input, $output, $key)) { - return 0; - } - try { $files = $this->customImportFactory->createImportFromDirectory(); $output->writeln( @@ -76,8 +72,6 @@ protected function execute(InputInterface $input, OutputInterface $output) } catch (InvalidImportException $importException) { $output->writeln(sprintf("%s", $importException->getMessage())); } - $this->completeRun(); - return 0; } } diff --git a/Command/ParallelImportCommand.php b/Command/ParallelImportCommand.php index 4ab6d79..2e4b9c3 100644 --- a/Command/ParallelImportCommand.php +++ b/Command/ParallelImportCommand.php @@ -11,7 +11,7 @@ namespace MauticPlugin\MauticCustomImportBundle\Command; -use Mautic\CoreBundle\Command\ModeratedCommand; +use Symfony\Component\Console\Command\Command; use Mautic\CoreBundle\Helper\ProgressBarHelper; use MauticPlugin\MauticCustomImportBundle\Exception\InvalidImportException; use MauticPlugin\MauticCustomImportBundle\Import\CustomImportFactory; @@ -20,9 +20,9 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\Output; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Translation\TranslatorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; -class ParallelImportCommand extends ModeratedCommand +class ParallelImportCommand extends Command { /** @@ -43,10 +43,11 @@ class ParallelImportCommand extends ModeratedCommand */ public function __construct(CustomImportFactory $customImportFactory, TranslatorInterface $translator) { + parent::__construct(); $this->translator = $translator; $this->customImportFactory = $customImportFactory; - parent::__construct(); - } + // parent constructor removed for Mautic 5; locking deps are DI'd into parent in core +} /** * {@inheritdoc} diff --git a/Command/RemoveTagsCommand.php b/Command/RemoveTagsCommand.php index 0b225da..2cba6fe 100644 --- a/Command/RemoveTagsCommand.php +++ b/Command/RemoveTagsCommand.php @@ -11,14 +11,14 @@ namespace MauticPlugin\MauticCustomImportBundle\Command; -use Mautic\CoreBundle\Command\ModeratedCommand; +use Symfony\Component\Console\Command\Command; use MauticPlugin\MauticCustomImportBundle\Exception\InvalidImportException; use MauticPlugin\MauticCustomImportBundle\Import\CustomImportFactory; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Translation\TranslatorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; -class RemoveTagsCommand extends ModeratedCommand +class RemoveTagsCommand extends Command { /** * @var CustomImportFactory @@ -38,10 +38,11 @@ class RemoveTagsCommand extends ModeratedCommand */ public function __construct(CustomImportFactory $customImportFactory, TranslatorInterface $translator) { + parent::__construct(); $this->customImportFactory = $customImportFactory; $this->translator = $translator; - parent::__construct(); - } + // parent constructor removed for Mautic 5; locking deps are DI'd into parent in core +} /** * {@inheritdoc} @@ -60,11 +61,6 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output) { - $key = __CLASS__; - if (!$this->checkRunStatus($input, $output, $key)) { - return 0; - } - try { $removedTags = $this->customImportFactory->removeContactsTags(); if (!empty($removedTags)) { diff --git a/Config/config.php b/Config/config.php index ccdb6c3..0f271f3 100644 --- a/Config/config.php +++ b/Config/config.php @@ -51,6 +51,7 @@ 'class' => \MauticPlugin\MauticCustomImportBundle\Import\ImportFromDirectory::class, 'arguments' => [ 'mautic.lead.model.import', + 'doctrine.orm.entity_manager', ], ], 'mautic.custom.import.parallel' => [ diff --git a/Form/Type/ImportListType.php b/Form/Type/ImportListType.php index c3c545d..5b445f3 100644 --- a/Form/Type/ImportListType.php +++ b/Form/Type/ImportListType.php @@ -69,7 +69,7 @@ public function configureOptions(OptionsResolver $resolver) 'expanded' => false, 'multiple' => false, 'required' => false, - 'empty_value' => '', + 'placeholder' => '', ]); } diff --git a/Import/ImportFromDirectory.php b/Import/ImportFromDirectory.php index be68961..e49f71d 100644 --- a/Import/ImportFromDirectory.php +++ b/Import/ImportFromDirectory.php @@ -8,6 +8,8 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\SplFileInfo; +use Doctrine\ORM\EntityManagerInterface; +use Mautic\UserBundle\Entity\User; class ImportFromDirectory { @@ -26,15 +28,21 @@ class ImportFromDirectory */ private $fileSystem; + /** @var EntityManagerInterface */ + private $em; + /** * CreateImportFromDirectory constructor. * * @param ImportModel $importModel */ public function __construct( - ImportModel $importModel + ImportModel $importModel, + EntityManagerInterface $em ) { $this->importModel = $importModel; + $this->em = $em; + $this->em = $em; } /** @@ -45,8 +53,10 @@ public function __construct( */ public function importFromFiles(array $options) { - $files = $this->loadCsvFilesFromPath($options['path_to_directory_csv']); - $importTemplate = $this->importModel->getEntity($options['template_from_import']); + $this->integrationOptions = $options; + + $files = $this->loadCsvFilesFromPath($this->integrationOptions['path_to_directory_csv']); + $importTemplate = $this->importModel->getEntity($this->integrationOptions['template_from_import']); if (!$importTemplate) { throw new InvalidImportException('Import template entity doesn\'t exists'); } @@ -76,24 +86,163 @@ private function importFromFile(SplFileInfo $file, Import $importTemplate) // move csv file to import directory $this->fileSystem->rename($file->getRealPath(), $newFilePath); - // Create an import object - $import = new Import(); - $properties = $importTemplate->getProperties(); - if (isset($properties['line'])) { + +// Create an import object +$import = new Import(); +$properties = $importTemplate->getProperties(); + +// Parser settings from template +$parserConfig = $importTemplate->getParserConfig() ?? []; +$delimiter = $parserConfig['delimiter'] ?? ','; +$enclosure = $parserConfig['enclosure'] ?? '"'; +$escape = $parserConfig['escape'] ?? '\\'; + +// Auto-detect delimiter from the first non-empty line and persist override +$__detected = $this->detectCsvDelimiter($newFilePath, [',',';'," ",'|']); +if ($__detected && $__detected !== $delimiter) { + $delimiter = $__detected; + $parserConfig['delimiter'] = $__detected; +} + + +// Read actual CSV headers +$fp = new \SplFileObject($newFilePath); +$fp->setFlags(\SplFileObject::READ_CSV); +$fp->setCsvControl($delimiter, $enclosure, $escape); +$fp->rewind(); +$headersFromFile = $fp->fgetcsv(); +if (!is_array($headersFromFile)) { + $headersFromFile = []; +} +if (isset($headersFromFile[0]) && is_string($headersFromFile[0])) { + $headersFromFile[0] = preg_replace('/^\xEF\xBB\xBF/u', '', $headersFromFile[0]); +} + + +// Align mapping: template fields -> actual header row (robust normalization) +$templateFields = $properties['fields'] ?? []; +$normalize = static function($v) { + $v = is_string($v) ? mb_strtolower(trim($v)) : (string) $v; + $v = preg_replace('/\s+/', '', $v); + $v = str_replace(['-', '_'], '', $v); + return $v; +}; +$mapNormalizedToOriginal = []; +foreach ((array) $headersFromFile as $h) { + $mapNormalizedToOriginal[$normalize((string) $h)] = (string) $h; +} +$aligned = []; +foreach ($templateFields as $src => $dst) { + $key = $normalize((string) $src); + if (isset($mapNormalizedToOriginal[$key])) { + $aligned[$mapNormalizedToOriginal[$key]] = $dst; + } +} +// Persist aligned props +$properties['fields'] = $aligned; +$properties['headers'] = $headersFromFile; +$properties['parser'] = $parserConfig; +if (isset($properties['line'])) { unset($properties['line']); } $import ->setProperties($properties) - ->setDefault('owner', null) - ->setHeaders($importTemplate->getHeaders()) - ->setParserConfig($importTemplate->getParserConfig()) + ->setDefault('owner', isset($this->integrationOptions['owner_id']) && ctype_digit((string)$this->integrationOptions['owner_id']) ? (int) $this->integrationOptions['owner_id'] : 1) + ->setHeaders($headersFromFile) + ->setParserConfig($parserConfig) ->setDir($importDir) ->setLineCount($this->getLinesCountFromPath($newFilePath)) ->setFile($fileName) ->setOriginalFile($file->getFilename()) ->setStatus($import::QUEUED); - $this->importModel->saveEntity($import); + +// Resolve a valid user for context and ownership +$ownerId = (int) ($this->integrationOptions['owner_id'] ?? ($this->integrationOptions['owner'] ?? 0)); +if (!$ownerId) { $ownerId = (int) ($properties['defaults']['owner'] ?? 0); } + +$userRepo = $this->em->getRepository(User::class); +$user = $ownerId ? $userRepo->find($ownerId) : null; +if (!$user && 0 === (int)($this->integrationOptions['owner_id'] ?? 0)) { + // Fallback: pick the lowest-ID user + $user = $userRepo->createQueryBuilder('u') + ->orderBy('u.id', 'ASC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); +} +if (!$user) { + throw new InvalidImportException('No Mautic user found. Please create a user or set an Owner ID in the Custom Import integration settings.'); +} + +// Ensure defaults.owner matches the actual user ID +$properties['defaults']['owner'] = $user->getId(); +$import->setProperties($properties); + +// Keep defaults.owner aligned with the actually resolved user ID +$properties['defaults']['owner'] = (int) $user->getId(); +$import->setProperties($properties); + +// Set creator and owner for the Import entity +$import->setCreatedBy($user); +// Resolve selected owner and set as creator to fill created_by and created_by_user +$ownerId = 0; +if (isset($this->integrationOptions['owner_id']) && (string) $this->integrationOptions['owner_id'] !== '') { + $ownerId = (int) $this->integrationOptions['owner_id']; +} elseif (isset($properties['defaults']['owner'])) { + $ownerId = (int) $properties['defaults']['owner']; +} + +$userRepo = $this->em->getRepository(User::class); +$user = $ownerId ? $userRepo->find($ownerId) : null; +if (!$user) { + $user = $userRepo->createQueryBuilder('u')->orderBy('u.id', 'ASC')->setMaxResults(1)->getQuery()->getOneOrNullResult(); +} +if (!$user) { + throw new \RuntimeException('No Mautic user found to assign as Import owner.'); +} + +// Keep defaults.owner accurate +if (!isset($properties['defaults'])) { $properties['defaults'] = []; } +$properties['defaults']['owner'] = (int) $user->getId(); +if (method_exists($import, 'setProperties')) { + $import->setProperties($properties); +} + +if (method_exists($import, 'setCreatedBy')) { + $import->setCreatedBy($user); +} +if (method_exists($import, 'setCreatedByUser')) { + $display = trim(($user->getFirstName() ?: '') . ' ' . ($user->getLastName() ?: '')); + if ($display === '') { + $display = $user->getUsername() ?: $user->getEmail() ?: ('User #'.$user->getId()); + } + $import->setCreatedByUser($display); +} +$this->importModel->saveEntity($import); + +// Force created_by and created_by_user to the configured owner in case listeners override it +try { + $finalOwnerId = 0; + if (isset($this->integrationOptions['owner_id']) && (string) $this->integrationOptions['owner_id'] !== '') { + $finalOwnerId = (int) $this->integrationOptions['owner_id']; + } elseif (isset($properties['defaults']['owner'])) { + $finalOwnerId = (int) $properties['defaults']['owner']; + } + if ($finalOwnerId > 0) { + $conn = $this->em->getConnection(); + $creatorDisplay = isset($display) && $display ? $display : ''; + if ($creatorDisplay === '' && isset($user)) { + $creatorDisplay = trim(($user->getFirstName() ?: '') . ' ' . ($user->getLastName() ?: '')); + if ($creatorDisplay === '') { + $creatorDisplay = $user->getUsername() ?: $user->getEmail() ?: ('User #'.$user->getId()); + } + } + $conn->update('imports', ['created_by' => $finalOwnerId, 'created_by_user' => $creatorDisplay], ['id' => $import->getId()]); } +} catch (\Throwable $e) { + // Swallow to avoid breaking the import if DBAL update fails +} +} /** * @param $path @@ -125,7 +274,36 @@ private function getLinesCountFromPath($path) $fileData = new \SplFileObject($path); $fileData->seek(PHP_INT_MAX); return $fileData->key(); + } - + /** + * Detect the most likely CSV delimiter by counting candidate occurrences + * on the first non-empty, non-BOM line. + */ + private function detectCsvDelimiter(string $filePath, array $candidates = [',',';', "\t", '|']): ?string + { + $fh = new \SplFileObject($filePath, 'r'); + $line = ''; + // Find first non-empty trimmed line + while (!$fh->eof() && $line === '') { + $line = (string) $fh->fgets(); + $line = trim($line); + } + if ($line === '') { + return null; + } + // Strip UTF-8 BOM if present + $line = preg_replace('/^\xEF\xBB\xBF/u', '', $line); + $best = null; + $bestCount = 0; + foreach ($candidates as $c) { + $cnt = substr_count($line, $c); + if ($cnt > $bestCount) { + $best = $c; + $bestCount = $cnt; + } + } + return $bestCount > 0 ? $best : null; +} } diff --git a/Integration/CustomImportIntegration.php b/Integration/CustomImportIntegration.php index 7590048..481fce4 100644 --- a/Integration/CustomImportIntegration.php +++ b/Integration/CustomImportIntegration.php @@ -9,19 +9,21 @@ use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilder; use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Mautic\UserBundle\Entity\User; class CustomImportIntegration extends AbstractIntegration { /** * @return string */ - public function getName() + public function getName(): string { // should be the name of the integration return 'CustomImport'; } - public function getDisplayName() + public function getDisplayName(): string { return 'Custom Import'; } @@ -29,7 +31,7 @@ public function getDisplayName() /** * @return string */ - public function getAuthenticationType() + public function getAuthenticationType(): string { /* @see \Mautic\PluginBundle\Integration\AbstractIntegration::getAuthenticationType */ return 'none'; @@ -38,7 +40,7 @@ public function getAuthenticationType() /** * @return string */ - public function getIcon() + public function getIcon(): string { return 'plugins/MauticCustomImportBundle/Assets/img/icon.png'; } @@ -48,7 +50,7 @@ public function getIcon() * @param array $data * @param string $formArea */ - public function appendToForm(&$builder, $data, $formArea) + public function appendToForm(&$builder, $data, $formArea): void { if ($formArea == 'features') { $builder->add( @@ -67,8 +69,40 @@ public function appendToForm(&$builder, $data, $formArea) ], ] ); + +// Build user choices for the dropdown +$users = $this->em->getRepository(User::class) + ->createQueryBuilder('u') + ->select('u.id, u.firstName, u.lastName, u.username, u.email') + ->orderBy('u.firstName', 'ASC') + ->addOrderBy('u.lastName', 'ASC') + ->getQuery() + ->getArrayResult(); - $builder->add( +$ownerChoices = []; +foreach ($users as $u) { + $name = trim(($u['firstName'] ?? '') . ' ' . ($u['lastName'] ?? '')); + $label = $name !== '' ? $name : ($u['username'] ?? ($u['email'] ?? ('User #'.$u['id']))); + $ownerChoices[sprintf('%s (#%d)', $label, (int) $u['id'])] = (int) $u['id']; +} + + +$builder->add( + 'owner_id', + ChoiceType::class, + [ + 'label' => 'mautic.custom.import.owner_id', + 'label_attr' => ['class' => 'control-label'], + 'choices' => $ownerChoices, + 'placeholder' => 'mautic.custom.import.owner_id.placeholder', + 'required' => true, + 'attr' => [ + 'class' => 'form-control', + 'data-placeholder' => $this->translator->trans('mautic.custom.import.owner_id.placeholder'), + ], + ] +); +$builder->add( 'path_to_directory_csv', TextType::class, [ diff --git a/Translations/en_US/messages.ini b/Translations/en_US/messages.ini index f026900..23672f1 100644 --- a/Translations/en_US/messages.ini +++ b/Translations/en_US/messages.ini @@ -7,4 +7,6 @@ mautic.custom.import.csv.import.parallel.sucess="Parallel import process n. %s s mautic.custom.import.parallel.records.limit="Import records limit" mautic.custom.import.parallel.records.limit.tooltip="Maximum number of records to import for this script execution." mautic.custom.import.remove.tags="Remove tags from contacts" -mautic.custom.import.tags.removed="Tags %s removed" \ No newline at end of file +mautic.custom.import.tags.removed="Tags %s removed" +mautic.custom.import.owner_id="Select Import Owner" +mautic.custom.import.owner_id.placeholder="Select Import Owner" diff --git a/composer.json b/composer.json index 0bb7030..0c99265 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "mtcextendee/mautic-custom-import-bundle", "type": "mautic-plugin", "require": { - "mautic/composer-plugin": "^1.0" + "mautic/composer-plugin": "^1.0", + "php": ">=8.1" } -} - +} \ No newline at end of file From 619ecf74db57b547e143c164d524ec6ed0a04be7 Mon Sep 17 00:00:00 2001 From: Leon Oltmanns <55587275+oltmanns-leuchtfeuer@users.noreply.github.com> Date: Wed, 13 Aug 2025 08:00:04 +0200 Subject: [PATCH 2/4] Update ParallelImport.php --- Import/ParallelImport.php | 86 +++++++++++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 13 deletions(-) diff --git a/Import/ParallelImport.php b/Import/ParallelImport.php index e1f6ef5..2486d1e 100644 --- a/Import/ParallelImport.php +++ b/Import/ParallelImport.php @@ -4,7 +4,7 @@ use Mautic\CoreBundle\Helper\PathsHelper; use Mautic\LeadBundle\Model\ImportModel; -use Symfony\Component\Process\ProcessBuilder; +use Symfony\Component\Process\Process; class ParallelImport { @@ -39,18 +39,78 @@ public function parallelImport(array $options) if (!$this->importModel->getImportToProcess() || !$this->importModel->checkParallelImportLimit()) { continue; } - $builder = (new ProcessBuilder()) - ->setPrefix('php') - ->setTimeout(9999) - ->add($this->pathsHelper->getSystemPath('app', true).'/console') - ->add('mautic:import') - ->add('--limit='.$options['limit']) - ->add('--env='.MAUTIC_ENV);; - - $process = $builder->getProcess(); - $process->start(); - $processSet[] = $process; - sleep(5); + + +// Determine PHP CLI executable in both default and php-fpm environments +$php = null; +try { + // Prefer Symfony's PhpExecutableFinder (handles php, phpdbg, versioned binaries) + $phpFinder = new PhpExecutableFinder(); + $php = $phpFinder->find(false); +} catch (\Throwable $e) { + $php = null; +} + +// If PhpExecutableFinder didn't find anything, try configured PathsHelper key +if (!$php) { + try { + $php = $this->pathsHelper->getSystemPath('php'); + } catch (\Throwable $e) { + $php = null; + } +} + +// If PHP_BINARY exists and is a CLI (not php-fpm), use it +if (!$php && defined('PHP_BINARY') && @is_executable(PHP_BINARY) && stripos(basename(PHP_BINARY), 'php-fpm') === false) { + $php = PHP_BINARY; +} + +// Allow env overrides +if (!$php) { + $envPhp = getenv('MAUTIC_PHP_BIN') ?: getenv('PHP_CLI_BINARY'); + if ($envPhp && @is_executable($envPhp)) { + $php = $envPhp; + } +} + +// Common fallbacks +if (!$php) { + foreach (['/usr/bin/php', '/usr/local/bin/php', '/usr/bin/php8.3', '/usr/bin/php8.2', '/usr/bin/php8.1'] as $bin) { + if (@is_executable($bin)) { $php = $bin; break; } + } +} + +// Last resort: rely on PATH +if (!$php) { $php = 'php'; } + +// Resolve console path using Mautic root; fall back to app/console for older installs +$rootReal = null; +try { + $rootReal = rtrim($this->pathsHelper->getSystemPath('root', true), '/'); +} catch (\Throwable $e) { + $rootReal = getcwd(); +} + +$console = $rootReal.'/bin/console'; +if (!file_exists($console)) { + $alt = $rootReal.'/app/console'; + if (file_exists($alt)) { $console = $alt; } +} + +// Build command +$cmd = [$php, $console, 'mautic:import', '--limit='.$options['limit']]; +if (defined('MAUTIC_ENV')) { + $cmd[] = '--env='.MAUTIC_ENV; +} + +// Working dir: use root if available +$workdir = $rootReal ?: getcwd(); + +$process = new Process($cmd, $workdir); +$process->setTimeout(9999); +$process->start(); +$processSet[] = $process; +sleep(5); } return $processSet; From a5fab6934a073fc28ad79ab8ca6effa7bd921b2a Mon Sep 17 00:00:00 2001 From: Leon Oltmanns <55587275+oltmanns-leuchtfeuer@users.noreply.github.com> Date: Wed, 13 Aug 2025 08:15:02 +0200 Subject: [PATCH 3/4] Update Readme --- README.md | 117 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 86 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index ae3e6b8..769c9fb 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,113 @@ # MauticCustomImportBundle -This plugin allow +Import contacts from a filesystem directory using Mautic’s native importer, optionally run imports in parallel from the CLI, and bulk-remove tags on demand. + +## Features + +- Create imports from CSV files located in a server directory +- Run imports **in parallel** via CLI (robust PHP CLI discovery; works on php-fpm hosts) +- Remove tags from contacts via CLI +- **New:** Select an **Import Owner** in the integration settings + +## Compatibility + +- Mautic 5.x -- create imports from CSV files from directory -- run parallel import process by command -- remove tags by command ## Support -https://mtcextendee.com/plugins + ## Installation -### Command line -- `composer require mtcextendee/mautic-custom-import-bundle` -- `php bin/console mautic:plugins:reload` -- Go to /s/plugins and setup CustomImport integration +### Composer (recommended) + +```bash +composer require mtcextendee/mautic-custom-import-bundle +php bin/console mautic:plugins:reload +``` +Then open **Settings → Plugins → Custom Import** and configure the integration. + +### Manual + +1. Download the latest release from GitHub Releases. +2. Unzip into: `plugins/MauticCustomImportBundle` +3. Clear cache: + ```bash + php bin/console cache:clear + ``` +4. In Mautic, go to **Settings → Plugins → Reload Plugins**, then open **Custom Import** and configure. + +## Configuration -### Manual -- Download last version https://github.com/mtcextendee/mautic-custom-import-bundle/releases -- Unzip files to plugins/MauticCustomImportBundle -- Clear cache (app/cache/prod/) -- Go to /s/plugins/reload -- Setup CustomImport integration +Open **Settings → Plugins → Custom Import** and review: + +- **Template from existing import** + Choose a previous (successful) Mautic import to reuse its parser settings and field mapping. + +- **Path to directory with CSV files** + Absolute server path where your CSV files are placed (e.g. `/var/www/example.com/htdocs/var/import`). + The command scans this folder and creates a standard Mautic import per CSV. + +- **Import records limit** + Batch size used by the CLI importer for each worker. + +- **Select Import Owner (new)** + Choose a Mautic user. Each created import row will have: + - `created_by` = selected user’s ID + - `created_by_user` = the selected user’s display name + (Also applied to `properties.defaults.owner`.) + +- **Tags to Remove (for the remove-tags command)** + Select tags to strip from contacts before your next import run. ## Usage -### Create imports from directory +### Create imports from a directory + +```bash +php bin/console mautic:import:directory +``` + +Reads the configured folder and creates one queued Mautic import per CSV using the template’s mapping and parser config. + +### Run parallel import -Command: `php bin/console mautic:import:directory` +```bash +php bin/console mautic:import:parallel +``` -Setup in plugin settings -- **Template from existed import** - it's import from history which is used as template. Especially config (especially parsers config and matched contact fields) -- **Path to directory with CSV files**. It's server path where you can store unlimited CSV files +Notes: -Command read this directory and create standard import from CSV with settings from template. +- The plugin spawns multiple workers up to Mautic’s **parallel import limit** (configure in `app/config/local.php` / `config/local.php` depending on your setup). +- Works on servers that primarily run **php-fpm**. You can force a specific PHP binary by setting: + ```bash + export MAUTIC_PHP_BIN=/usr/bin/php + ``` + The plugin otherwise auto-detects (`PhpExecutableFinder`, `PHP_BINARY`, common paths, then `php` from `PATH`). -### Run parallel import process +### Remove tags via CLI -Command: `php bin/console mautic:import:parallel` +```bash +php bin/console mautic:remove:tags +``` -First, increase **parallel_import_limit** in app/config/local.php. Don't forgot clear cache (app/cache/prod/) -This option allow run parallel import process by command +Removes the tags you configured in the integration. Helpful when your next CSV import needs a clean slate. -Each command in parallel processes import 1000 contacts by default. You can change it in plugin settings (Import records limit) +## Troubleshooting -### Remove tags by command +- **“php does not exist” (parallel command):** + The plugin now tries several strategies to find PHP. If needed, set `MAUTIC_PHP_BIN=/path/to/php` for the web/CLI user. -Command: `php bin/console mautic:remove:tags` +- **Owner not applied:** + Ensure **Select Import Owner** is saved in the integration. New `imports` rows should show the selected owner in both `created_by` and `created_by_user`. Historical rows are unchanged. -If you want tag your contacts by import, then before import you are able to remove tag. -Just setup tags in plugin settings and call command +- **Permissions:** + The web/CLI user must have read/write access to the CSV directory and Mautic’s `var/` (cache/logs/import dir). +- **Logs:** + Check `var/logs/` for import-related errors. ## Credits -
Icons made by Chanut from www.flaticon.com +Icons by [Chanut](https://www.flaticon.com/authors/chanut) on [Flaticon](https://www.flaticon.com/). From 24a7b03eb811a564574623c487c4af69c2f7227a Mon Sep 17 00:00:00 2001 From: Leon Oltmanns <55587275+oltmanns-leuchtfeuer@users.noreply.github.com> Date: Wed, 13 Aug 2025 08:21:48 +0200 Subject: [PATCH 4/4] Update composer.json --- composer.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 0c99265..ce0a495 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,10 @@ "name": "mtcextendee/mautic-custom-import-bundle", "type": "mautic-plugin", "require": { - "mautic/composer-plugin": "^1.0", - "php": ">=8.1" + "php": "^8.0", + "mautic/core-lib": "^5.0" + }, + "extra": { + "install-directory-name": "MauticCustomImportBundle" } -} \ No newline at end of file +}