1

I have slug field in my TCA and in general it works, when adding via Backend > List module, even if I won't input any value the unique eval ensures that slug will be unique, so when I'll create many rows with the same name Foo TYPO3 backend will enshure that it will resolve to unique slugs like foo, foo-1, foo-2, etc. Kudos!:

'slug'       => [
    'exclude'     => true,
    'label'       => 'Slug',
    'displayCond' => 'VERSION:IS:false',
    'config'      => [
        'type'              => 'slug',
        'generatorOptions'  => [
            'fields'         => ['name'],
            'fieldSeparator' => '/',
            'replacements'   => [
                '/' => '',
            ],
        ],
        'fallbackCharacter' => '-',
        'eval'              => 'unique',
        'default'           => '',
        'appearance'        => [
            'prefix' => \BIESIOR\Garage\UserFunctions\SlugPrefix::class . '->getPrefix'
        ],
    ],
],

However when creating a new object from my form within new/create actions (typical Extbase CRUD from extension_builder as you can see) like:

public function createAction(Car $newCar)
{
    $this->addFlashMessage(
        'The object was created. Please be aware that this action is publicly accessible unless you implement an access check. See https://docs.typo3.org/typo3cms/extensions/extension_builder/User/Index.html', 
        '', 
        \TYPO3\CMS\Core\Messaging\AbstractMessage::WARNING);
    $this->carRepository->add($newCar);
    $this->redirect('list');
}

of course slug is note set.

My first idea is to duplicate the logic of TCA type='slug' and just add this functionality with some own JS, AJAX and PHP, however that sounds as overload and time consumption. Especially that I don't want the user to care about slug part at all. Is there any simple API for lookup for a unique slug of the given table that can be used in custom action instead?

Note this question is not about how to handle it with JS, that's just concept. I would like to skip this part for FE user at all, he doesn't need to know what the slug is. Just during creating a new object, I want to get unique value like foo-123 instead.

biesior
  • 55,576
  • 10
  • 125
  • 182

3 Answers3

9

In addition to Jonas Eberles answer here's another example which also respects the eval configuration of the slug field (can be uniqueInSite, uniqueInPid or simply unique).

use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
use TYPO3\CMS\Core\DataHandling\SlugHelper;
use TYPO3\CMS\Core\Utility\GeneralUtility;

...

public function createAction(Car $newCar)
{
    $this->carRepository->add($newCar);
    GeneralUtility::makeInstance(\TYPO3\CMS\Extbase\Persistence\Generic\PersistenceManager::class)->persistAll();
    $record = $this->carRepository->findByUidAssoc($newCar->getUid())[0];

    $tableName = 'tx_garage_domain_model_car';
    $slugFieldName = 'slug';

//      Get field configuration
    $fieldConfig = $GLOBALS['TCA'][$tableName]['columns'][$slugFieldName]['config'];
    $evalInfo = GeneralUtility::trimExplode(',', $fieldConfig['eval'], true);

//      Initialize Slug helper
    /** @var SlugHelper $slugHelper */
    $slugHelper = GeneralUtility::makeInstance(
        SlugHelper::class,
        $tableName,
        $slugFieldName,
        $fieldConfig
    );

//      Generate slug

    $slug = $slugHelper->generate($record, $record['pid']);
    $state = RecordStateFactory::forName($tableName)
        ->fromArray($record, $record['pid'], $record['uid']);

//      Build slug depending on eval configuration
    if (in_array('uniqueInSite', $evalInfo)) {
        $slug = $slugHelper->buildSlugForUniqueInSite($slug, $state);
    } else if (in_array('uniqueInPid', $evalInfo)) {
        $slug = $slugHelper->buildSlugForUniqueInPid($slug, $state);
    } else if (in_array('unique', $evalInfo)) {
        $slug = $slugHelper->buildSlugForUniqueInTable($slug, $state);
    }
    $newCar->setSlug($slug);
    $this->carRepository->update($newCar);

}

with custom finder in the repository to fetch assoc array instead of the mapped object for $racord argument

public function findByUidAssoc($uid)
{
    $query = $this->createQuery();
    $query->matching(
        $query->equals('uid', $uid)
    );

    return $query->execute(true)[0];
}

Note that the record needs to be persisted before executing above code.

References:

biesior
  • 55,576
  • 10
  • 125
  • 182
  • Thanx, I updated your sample with working code. Does its job. In my free time I'll add usage for Scheduler task, using this approach. – biesior Aug 07 '20 at 16:47
5

According to answers from Elias and Jonas, I created a class which simplifies things especially when you have more models to handle

typo3conf/ext/sitepackage/Classes/Utility/SlugUtility.php

<?php
namespace VENDOR\Sitepackage\Utility; // <- to be replaced with your namespace

use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
use TYPO3\CMS\Core\DataHandling\SlugHelper;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/***
 *
 * This file is part of the "Sitepackage" Extension for TYPO3 CMS.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 *  (c) 2020 Marcus Biesioroff <biesior@gmail.com>
 *  Concept by:  Elias Häußler
 *               Jonas Eberle
 *
 ***/
class SlugUtility
{
    /**
     * @param int    $uid UID of record saved in DB
     * @param string $tableName Name of the table to lookup for uniques
     * @param string $slugFieldName Name of the slug field
     *
     * @return string Resolved unique slug
     * @throws \TYPO3\CMS\Core\Exception\SiteNotFoundException
     */
    public static function generateUniqueSlug(int $uid, string $tableName, string $slugFieldName): string
    {

        /** @var Connection $connection */
        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($tableName);
        $queryBuilder = $connection->createQueryBuilder();

        $record = $queryBuilder
            ->select('*')
            ->from($tableName)
            ->where('uid=:uid')
            ->setParameter(':uid', $uid)
            ->execute()
            ->fetch();
        if (!$record) return false;

//      Get field configuration
        $fieldConfig = $GLOBALS['TCA'][$tableName]['columns'][$slugFieldName]['config'];
        $evalInfo = GeneralUtility::trimExplode(',', $fieldConfig['eval'], true);

//      Initialize Slug helper
        /** @var SlugHelper $slugHelper */
        $slugHelper = GeneralUtility::makeInstance(
            SlugHelper::class,
            $tableName,
            $slugFieldName,
            $fieldConfig
        );
//      Generate slug
        $slug = $slugHelper->generate($record, $record['pid']);
        $state = RecordStateFactory::forName($tableName)
            ->fromArray($record, $record['pid'], $record['uid']);

//      Build slug depending on eval configuration
        if (in_array('uniqueInSite', $evalInfo)) {
            $slug = $slugHelper->buildSlugForUniqueInSite($slug, $state);
        } else if (in_array('uniqueInPid', $evalInfo)) {
            $slug = $slugHelper->buildSlugForUniqueInPid($slug, $state);
        } else if (in_array('unique', $evalInfo)) {
            $slug = $slugHelper->buildSlugForUniqueInTable($slug, $state);
        }
        return $slug;
    }
}

Usage in any place, like controller. Scheduler task, repository, etc. Keep in mind that record should be saved before (it may be created by Extbase, or just with plain SQL), just need to have created uid and be valid TYPO3 record.

use VENDOR\Sitepackage\Utility\SlugUtility;
use \TYPO3\CMS\Extbase\Persistence\Generic\PersistenceManager;

...

$pageSlug = SlugUtility::generateUniqueSlug(
    5,        // int     $uid            UID of record saved in DB
    'pages',  // string  $tableName      Name of the table to lookup for uniques
    'slug'    // string  $slugFieldName  Name of the slug field
)

// or

$uniqueSlug = SlugUtility::generateUniqueSlug(
    123,
    'tx_garage_domain_model_car',
    'slug'
);

// or according to the original question, 
// if you created new model object with Extbase, 
// persist it, create unique slug with SlugUtility 
// set the slug property to the created model object and finally update

public function createAction(Car $newCar)
{
    $this->carRepository->add($newCar);
    GeneralUtility::makeInstance(PersistenceManager::class)->persistAll();
    $uniqueSlug = SlugUtility::generateUniqueSlug(
        $newCar->getUid(),
        'tx_garage_domain_model_car',
        'slug'
    );
    if($uniqueSlug) {
        $newCar->setSlug($uniqueSlug);
        $this->carRepository->update($newCar);
    }
    $this->redirect('list');
}

// no need for second call to persistAll() 
// as Extbase will call it at action's finalizing.

// etc.
biesior
  • 55,576
  • 10
  • 125
  • 182
  • Many thanks for the concept and the class. But be aware of the `DefaultRestrictionContainer` if your records make use of restrictions. E.g. a record should be shown in the future and has, therefore, a starttime which is in the future, then `if (!$record) return false;` in `SlugUtility::generateUniqueSlug()` will match. So, your record won't have a filled slug field at the time of publishing. – Julian Hofmann May 07 '21 at 16:34
  • Thx Marcus, since TYPO3 10 use `->fetchAssociative()` instead of `->fetch()` in SlugUtility – jokumer Sep 07 '21 at 08:35
2

You can use the SlugHelper directly. The API was obviously not made very fluent for that use case but it works...

$this->carRepository->add($newCar);

// probably you need to persist first - I am not sure if this is really necessary
$this->objectManager()->get(
  \TYPO3\CMS\Extbase\Persistence\Generic\PersistenceManager::class
)->persistAll();

$table = 'tx_garage_domain_model_car';
$field = 'slug';

// a stripped down record with just the necessary fields is enough
$record = ['name' => $newCar->getName()];
$pid = $this->settings->persistence->... 

$slugHelper = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(
  \TYPO3\CMS\Core\DataHandling\SlugHelper::class,
  $table,
  $field,
  $GLOBALS['TCA'][$table]['columns'][$field]['config']
);

$newCar->slug = $slugHelper->generate($record, $pid);
biesior
  • 55,576
  • 10
  • 125
  • 182
Jonas Eberle
  • 2,835
  • 1
  • 15
  • 25
  • Thanx for your effort, that probably after some fixed would work. Unfortunately, I cannot accept two answers. I'll go with @eliash solution as it checks TCA config additionally, however, I appreciate your contribution. – biesior Aug 07 '20 at 16:41
  • I think that good idea is to persist object and then re-fetch it a record in an associative array. Apparently `SlugHelper::generate()` method cheks additional fields except of `name` i.e. for `languageField` or `is_siteroot` for pages table. It's quite possible that in future authors may handle also additional fields so for now it most secure way IMO. – biesior Aug 08 '20 at 01:34
  • 1
    Great. I took that from a v9 project and it's not very polished. I'll bookmark your SlugUtility :) – Jonas Eberle Aug 08 '20 at 10:32
  • I'm afraid that it was primary considered to be used as an @internal class, so as you pointed it really can/should be polished, especially that slugs/routes are mandatory since 10.x – biesior Aug 08 '20 at 10:36