Skip to content

Commit

Permalink
Initial Version
Browse files Browse the repository at this point in the history
  • Loading branch information
gradinarufelix committed Jun 25, 2024
1 parent 0e49061 commit c257caa
Show file tree
Hide file tree
Showing 15 changed files with 480 additions and 0 deletions.
87 changes: 87 additions & 0 deletions Classes/Controller/ImportModuleController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

namespace CodeQ\BulkTitleImporter\Controller;

/*
* This file is part of the CodeQ.BulkTitleImporter package.
*/

use CodeQ\BulkTitleImporter\Service\ImportService;
use InvalidArgumentException;
use Neos\ContentRepository\Domain\Model\Workspace;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Mvc\View\ViewInterface;
use Neos\Flow\ResourceManagement\Exception;
use Neos\Flow\ResourceManagement\PersistentResource;
use Neos\Fusion\View\FusionView;
use Neos\Neos\Controller\Module\AbstractModuleController;
use Neos\Neos\Service\UserService;
use Neos\Neos\Ui\ContentRepository\Service\WorkspaceService;

class ImportModuleController extends AbstractModuleController
{
/**
* @var string
*/
protected $defaultViewObjectName = FusionView::class;

/**
* @Flow\Inject
* @var ImportService
*/
protected $importService;

/**
* @Flow\Inject
* @var WorkspaceService
*/
protected $workspaceService;

/**
* @Flow\Inject
* @var UserService
*/
protected $userService;

public function indexAction(): void
{
$this->view->assign('workspaces', [
[
'name' => $this->userService->getPersonalWorkspaceName(),
'title' => 'Persönlicher Arbeitsbereich'
],
...array_filter($this->workspaceService->getAllowedTargetWorkspaces(), static fn (array $workspace) => $workspace['name'] !== 'live')
]);
}

/**
* @param PersistentResource $file
* @param string $targetWorkspaceName
*
* @return void
* @throws \Neos\Flow\Mvc\Exception\StopActionException
*/
public function importAction(PersistentResource $file, string $targetWorkspaceName): void
{
try {
$importResult = $this->importService->importFromPersistentResource($file, $targetWorkspaceName);
} catch (Exception | InvalidArgumentException $exception) {
$this->addFlashMessage('The file could not be imported: ' . $exception->getMessage(), 'Error', \Neos\Error\Messages\Message::SEVERITY_ERROR);
$this->redirect('index');
return;
}
$this->view->assign('importResult', $importResult);
$this->view->assign('targetWorkspaceName', $targetWorkspaceName);
}

/**
* @param FusionView $view
*
* @return void
*/
protected function initializeView(ViewInterface $view): void
{
parent::initializeView($view);
$view->setFusionPathPattern('resource://CodeQ.BulkTitleImporter/Private/Fusion/Module');
}
}
37 changes: 37 additions & 0 deletions Classes/Dto/ImportResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace CodeQ\BulkTitleImporter\Dto;

use Neos\Flow\Annotations as Flow;

/**
* @Flow\ValueObject
*/
class ImportResult
{
/**
* @var array<string> Error messages that occurred during the import
*/
protected array $errors = [];

/**
* @var int Number of nodes that have been imported
*/
protected int $importedNodes = 0;

public function __construct(array $errors = [], int $importedNodes = 0)
{
$this->errors = $errors;
$this->importedNodes = $importedNodes - count($errors);
}

public function getErrors(): array
{
return $this->errors;
}

public function getImportedNodes(): int
{
return $this->importedNodes;
}
}
54 changes: 54 additions & 0 deletions Classes/Service/FileService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace CodeQ\BulkTitleImporter\Service;

use InvalidArgumentException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Reader\IReader;
use PhpOffice\PhpSpreadsheet\Reader\Xlsx;

class FileService
{
/**
* @var Xlsx
*/
protected IReader $reader;

public function __construct()
{
$this->reader = IOFactory::createReader(IOFactory::READER_XLSX);
}

/**
* @param string $filePath
*
* @return array
* @throws InvalidArgumentException
*/
public function read(string $filePath): array
{
$spreadsheet = $this->reader->load($filePath, IReader::READ_DATA_ONLY);
$worksheet = $spreadsheet->getAllSheets()[0];
$rows = $worksheet->toArray();
$this->validateFileContents($rows);
// Remove the header row
array_shift($rows);
return $rows;
}

/**
* @param array $rows
*
* @return void
* @throws InvalidArgumentException
*/
protected function validateFileContents(array $rows): void
{
if (count($rows) < 2) {
throw new InvalidArgumentException('The file must contain at least two rows.');
}
if ($rows[0] !== ['URL', 'Title']) {
throw new InvalidArgumentException('The first row must contain the headers "URL" and "Title".');
}
}
}
74 changes: 74 additions & 0 deletions Classes/Service/ImportService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace CodeQ\BulkTitleImporter\Service;

use CodeQ\BulkTitleImporter\Dto\ImportResult;
use InvalidArgumentException;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\ResourceManagement\Exception;
use Neos\Flow\ResourceManagement\PersistentResource;

class ImportService
{
/**
* @Flow\Inject
* @var FileService
*/
protected FileService $fileService;

/**
* @Flow\Inject
* @var NodeFindingService
*/
protected NodeFindingService $nodeFindingService;

/**
* @Flow\InjectConfiguration(path="nodeProperty")
* @var string
*/
protected string $nodeProperty;

/**
* @param PersistentResource $file
* @param string $targetWorkspaceName
*
* @return ImportResult
* @throws Exception
* @throws InvalidArgumentException
*/
public function importFromPersistentResource(PersistentResource $file, string $targetWorkspaceName): ImportResult
{
$errorBuffer = [];
$temporaryFilePath = $file->createTemporaryLocalCopy();
$rows = $this->fileService->read($temporaryFilePath);
$this->import($rows, $targetWorkspaceName, $errorBuffer);
return new ImportResult(
$errorBuffer,
count($rows)
);
}

/**
* @param array $rows
* @param string $targetWorkspaceName
* @param array $errorBuffer
*
* @return void
*/
protected function import(array $rows, string $targetWorkspaceName, array &$errorBuffer): void
{
foreach ($rows as [$url, $title]) {
$node = $this->nodeFindingService->tryToResolvePublicUriToNode($url, $targetWorkspaceName);
if ($node === null) {
$errorBuffer[] = $url;
continue;
}
$node->setProperty($this->nodeProperty, self::sanitizeTitle($title));
}
}

protected static function sanitizeTitle(string $title): string
{
return trim(strip_tags($title));
}
}
79 changes: 79 additions & 0 deletions Classes/Service/NodeFindingService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

namespace CodeQ\BulkTitleImporter\Service;

use GuzzleHttp\Psr7\Uri;
use Neos\ContentRepository\Domain\Model\NodeInterface;
use Neos\ContentRepository\Domain\Utility\NodePaths;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Mvc\Routing\Dto\RouteParameters;
use Neos\Flow\ObjectManagement\ObjectManagerInterface;
use Neos\Neos\Controller\CreateContentContextTrait;
use Neos\Neos\Routing\FrontendNodeRoutePartHandlerInterface;

class NodeFindingService
{
use CreateContentContextTrait;

/**
* @Flow\InjectConfiguration(package="Neos.Flow", path="mvc.routes")
* @var array
*/
protected array $routesConfiguration;

/**
* @Flow\InjectConfiguration(path="overrideNodeUriPathSuffix")
* @var string|null
*/
protected ?string $overrideNodeUriPathSuffix;

/**
* @Flow\Inject
* @var ObjectManagerInterface
*/
protected $objectManager;

/**
* @param mixed $term
* @param string $targetWorkspaceName
*
* @return NodeInterface|null
*/
public function tryToResolvePublicUriToNode(mixed $term, string $targetWorkspaceName): ?NodeInterface
{
if (!preg_match('/(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/', $term)) {
return null;
}

$uri = new Uri($term);
// Remove the starting slash.
$path = str_starts_with($uri->getPath(), '/') ? substr($uri->getPath(), 1) : $uri->getPath();

$routeHandler = $this->objectManager->get(FrontendNodeRoutePartHandlerInterface::class);
$routeHandler->setName('node');

$uriPathSuffix = !empty($this->overrideNodeUriPathSuffix) ? $this->overrideNodeUriPathSuffix : $this->routesConfiguration['Neos.Neos']['variables']['defaultUriSuffix'];
$routeHandler->setOptions(['uriPathSuffix' => $uriPathSuffix]);

$routeParameters = RouteParameters::createEmpty();
// This is needed for the FrontendNodeRoutePartHandler to correctly identify the current site
$routeParameters = $routeParameters->withParameter('requestUriHost', $uri->getHost());
$matchResult = $routeHandler->matchWithParameters($path, $routeParameters);

if (!$matchResult || !$matchResult->getMatchedValue()) {
return null;
}

$nodeContextPath = $matchResult->getMatchedValue();
$nodeContextPathSegments = NodePaths::explodeContextPath($nodeContextPath);
$nodePath = $nodeContextPathSegments['nodePath'];
$context = $this->createContentContext($targetWorkspaceName, $nodeContextPathSegments['dimensions']);
$matchingNode = $context->getNode($nodePath);

if (!$matchingNode) {
return null;
}

return $matchingNode;
}
}
14 changes: 14 additions & 0 deletions Configuration/Policy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
privilegeTargets:
'Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege':
'CodeQ.BulkTitleImporter:CanImportTitles':
matcher: 'within(CodeQ\BulkTitleImporter\Controller\ImportModuleController)'

roles:
'CodeQ.BulkTitleImporter:TitleImporter':
privileges:
- privilegeTarget: 'CodeQ.BulkTitleImporter:CanImportTitles'
permission: GRANT
'Neos.Neos:Administrator':
privileges:
- privilegeTarget: 'CodeQ.BulkTitleImporter:CanImportTitles'
permission: GRANT
16 changes: 16 additions & 0 deletions Configuration/Settings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
Neos:
Neos:
modules:
management:
submodules:
bulkTitleImporter:
label: 'SEO-Titel importieren'
description: ''
icon: 'fa fa-file-import'
controller: 'CodeQ\BulkTitleImporter\Controller\ImportModuleController'
privilegeTarget: 'CodeQ.BulkTitleImporter:CanImportTitles'

CodeQ:
BulkTitleImporter:
overrideNodeUriPathSuffix: null
nodeProperty: 'titleOverride'
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# CodeQ.BulkTitleImporter
Loading

0 comments on commit c257caa

Please sign in to comment.