diff --git a/appinfo/info.xml b/appinfo/info.xml index c6e655aa5..bb977b169 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -26,7 +26,7 @@ In your Nextcloud instance, simply navigate to **»Apps«**, find the **»Teams«** and **»Collectives«** apps and enable them. ]]> - 2.14.4 + 2.15.0 agpl CollectiveCloud Team Collectives diff --git a/composer.json b/composer.json index 02a4487a3..4161f3003 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,8 @@ "ext-json": "*", "ext-pdo": "*", "ext-pdo_sqlite": "*", + "symfony/string": "^6.0", + "symfony/translation-contracts": "^2.5", "teamtnt/tntsearch": "^4.2" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 3fefc171e..5d31ea07a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fdf3c7f31d0b558bade1d751baaa4e7e", + "content-hash": "c52d7e2779851295d90eb9565eab5330", "packages": [ { "name": "predis/predis", @@ -67,6 +67,487 @@ ], "time": "2023-09-13T16:42:03+00:00" }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/string", + "version": "v6.0.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "d9e72497367c23e08bf94176d2be45b00a9d232a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/d9e72497367c23e08bf94176d2be45b00a9d232a", + "reference": "d9e72497367c23e08bf94176d2be45b00a9d232a", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.0" + }, + "require-dev": { + "symfony/error-handler": "^5.4|^6.0", + "symfony/http-client": "^5.4|^6.0", + "symfony/translation-contracts": "^2.0|^3.0", + "symfony/var-exporter": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v6.0.19" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-01-01T08:36:10+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v2.5.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "b0073a77ac0b7ea55131020e87b1e3af540f4664" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/b0073a77ac0b7ea55131020e87b1e3af540f4664", + "reference": "b0073a77ac0b7ea55131020e87b1e3af540f4664", + "shasum": "" + }, + "require": { + "php": ">=7.2.5" + }, + "suggest": { + "symfony/translation-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v2.5.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-01-23T13:51:25+00:00" + }, { "name": "teamtnt/tntsearch", "version": "v4.3.0", @@ -913,7 +1394,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -928,5 +1409,5 @@ "platform-overrides": { "php": "8.0.2" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 4aac2795e..e158f0e7e 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -11,6 +11,7 @@ use Closure; use OCA\Circles\Events\CircleDestroyedEvent; +use OCA\Circles\Events\EditingCircleEvent; use OCA\Collectives\CacheListener; use OCA\Collectives\Dashboard\RecentPagesWidget; use OCA\Collectives\Db\CollectiveMapper; @@ -18,6 +19,7 @@ use OCA\Collectives\Fs\UserFolderHelper; use OCA\Collectives\Listeners\BeforeTemplateRenderedListener; use OCA\Collectives\Listeners\CircleDestroyedListener; +use OCA\Collectives\Listeners\CircleEditingEventListener; use OCA\Collectives\Listeners\CollectivesReferenceListener; use OCA\Collectives\Listeners\ShareDeletedListener; use OCA\Collectives\Mount\CollectiveFolderManager; @@ -52,6 +54,8 @@ use OCP\Share\Events\ShareDeletedEvent; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +use Symfony\Component\String\Slugger\AsciiSlugger; +use Symfony\Component\String\Slugger\SluggerInterface; class Application extends App implements IBootstrap { public const APP_NAME = 'collectives'; @@ -64,6 +68,7 @@ public function register(IRegistrationContext $context): void { require_once(__DIR__ . '/../../vendor/autoload.php'); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); $context->registerEventListener(CircleDestroyedEvent::class, CircleDestroyedListener::class); + $context->registerEventListener(EditingCircleEvent::class, CircleEditingEventListener::class); $context->registerEventListener(ShareDeletedEvent::class, ShareDeletedListener::class); $context->registerEventListener(RenderReferenceEvent::class, CollectivesReferenceListener::class); @@ -130,6 +135,10 @@ public function register(IRegistrationContext $context): void { /** @psalm-suppress MissingDependency */ $context->registerSetupCheck(CirclesAppIsEnableCheck::class); } + + $context->registerService(SluggerInterface::class, function (ContainerInterface $c) { + return new AsciiSlugger(); + }); } public function boot(IBootcontext $context): void { diff --git a/lib/Command/CreateCollective.php b/lib/Command/CreateCollective.php index 03973df6c..9377e31ef 100644 --- a/lib/Command/CreateCollective.php +++ b/lib/Command/CreateCollective.php @@ -52,11 +52,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $user = $this->userManager->get($userId); $this->userSession->setUser($user); $lang = $this->l10nFactory->getUserLanguage($this->userSession->getUser()); - $safeName = $this->nodeHelper->sanitiseFilename($name); $output->write('Creating new collective ' . $name . ' ... '); - [$collective, $info] = $this->collectiveService->createCollective($userId, $lang, $safeName); + [, $info] = $this->collectiveService->createCollective($userId, $lang, $name); $output->writeln('' . $info ?: 'done.' . ''); return 0; diff --git a/lib/Controller/CollectiveController.php b/lib/Controller/CollectiveController.php index b45006e90..02e43f44d 100644 --- a/lib/Controller/CollectiveController.php +++ b/lib/Controller/CollectiveController.php @@ -10,7 +10,6 @@ namespace OCA\Collectives\Controller; use Closure; - use OCA\Collectives\Db\Collective; use OCA\Collectives\Fs\NodeHelper; use OCA\Collectives\Service\CollectiveService; @@ -60,11 +59,10 @@ public function index(): DataResponse { #[NoAdminRequired] public function create(string $name, ?string $emoji = null): DataResponse { return $this->prepareResponse(function () use ($name, $emoji): array { - $safeName = $this->nodeHelper->sanitiseFilename($name); [$collective, $info] = $this->service->createCollective( $this->getUserId(), $this->getUserLang(), - $safeName, + $name, $emoji, ); return [ diff --git a/lib/Db/Collective.php b/lib/Db/Collective.php index a2ac2aecc..3fd90b51f 100644 --- a/lib/Db/Collective.php +++ b/lib/Db/Collective.php @@ -17,9 +17,10 @@ use RuntimeException; /** - * Class Collective * @method int getId() * @method void setId(int $value) + * @method string getSlug() + * @method void setSlug(?string $value) * @method string getCircleUniqueId() * @method void setCircleUniqueId(string $circleUniqueId) * @method int getPermissions() @@ -59,6 +60,7 @@ class Collective extends Entity implements JsonSerializable { protected ?string $circleUniqueId = null; protected int $permissions = self::defaultPermissions; + protected ?string $slug = null; protected ?string $emoji = null; protected ?int $trashTimestamp = null; protected int $pageMode = self::defaultPageMode; @@ -268,6 +270,7 @@ public function canShare(): bool { public function jsonSerialize(): array { return [ 'id' => $this->id, + 'slug' => $this->slug, 'circleId' => $this->circleUniqueId, 'emoji' => $this->emoji, 'trashTimestamp' => $this->trashTimestamp, diff --git a/lib/Db/Page.php b/lib/Db/Page.php index b0279912f..58c739955 100644 --- a/lib/Db/Page.php +++ b/lib/Db/Page.php @@ -18,6 +18,8 @@ * @method void setId(int $value) * @method int getFileId() * @method void setFileId(int $value) + * @method string getSlug() + * @method void setSlug(?string $value) * @method string getLastUserId() * @method void setLastUserId(string $value) * @method string getEmoji() @@ -31,6 +33,7 @@ */ class Page extends Entity implements JsonSerializable { protected ?int $fileId = null; + protected ?string $slug = null; protected ?string $lastUserId = null; protected ?string $emoji = null; protected ?string $subpageOrder = null; @@ -45,6 +48,7 @@ public function jsonSerialize(): array { return [ 'id' => $this->id, 'fileId' => $this->fileId, + 'slug' => $this->slug, 'lastUserId' => $this->lastUserId, 'emoji' => $this->emoji, 'subpageOrder' => json_decode($this->getSubpageOrder() ?? '[]', true, 512, JSON_THROW_ON_ERROR), diff --git a/lib/Listeners/CircleEditingEventListener.php b/lib/Listeners/CircleEditingEventListener.php new file mode 100644 index 000000000..a0bd54d7e --- /dev/null +++ b/lib/Listeners/CircleEditingEventListener.php @@ -0,0 +1,55 @@ + */ +class CircleEditingEventListener implements IEventListener { + public function __construct( + private CollectiveMapper $collectiveMapper, + private SlugService $slugService, + ) { + } + /** + * @throws FilesNotPermittedException + */ + public function handle(Event $event): void { + if (!($event instanceof EditingCircleEvent)) { + return; + } + + try { + $collective = $this->collectiveMapper->findByCircleId($event->getCircle()->getSingleId()); + } catch (NotFoundException) { + return; + } + + if (!$collective) { + return; + } + + $name = $event->getFederatedEvent()->getParams()->g('name'); + if (!$name) { + return; + } + + // Update slug if name has changed + $slug = $this->slugService->generateCollectiveSlug($collective->getId(), $name); + $collective->setSlug($slug); + $this->collectiveMapper->update($collective); + } +} diff --git a/lib/Migration/Version021500Date20240820000000.php b/lib/Migration/Version021500Date20240820000000.php new file mode 100644 index 000000000..1dfeec72e --- /dev/null +++ b/lib/Migration/Version021500Date20240820000000.php @@ -0,0 +1,76 @@ +getTable('collectives'); + if (!$table->hasColumn('slug')) { + $this->runSlugGeneration = true; + $table->addColumn('slug', Types::STRING, [ + 'notnull' => false, + 'default' => false, + 'length' => 255, + ]); + + return $schema; + } + + return null; + } + + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + if (!$this->runSlugGeneration) { + return; + } + + $query = $this->connection->getQueryBuilder(); + $query->select(['id', 'circle_unique_id'])->from('collectives'); + $result = $query->executeQuery(); + + $update = $this->connection->getQueryBuilder(); + $update->update('collectives') + ->set('slug', $update->createParameter('slug')) + ->where($update->expr()->eq('id', $update->createParameter('id'))); + + while ($row = $result->fetch()) { + $circle = $this->circleHelper->getCircle($row['circle_unique_id'], null, true); + $slug = $this->slugService->generateCollectiveSlug($row['id'], $circle->getSanitizedName()); + + $update + ->setParameter('id', (int)$row['id'], IQueryBuilder::PARAM_INT) + ->setParameter('slug', $slug, IQueryBuilder::PARAM_STR) + ->executeStatement(); + } + $result->closeCursor(); + } +} diff --git a/lib/Migration/Version021500Date20240820000001.php b/lib/Migration/Version021500Date20240820000001.php new file mode 100644 index 000000000..3e17c2a81 --- /dev/null +++ b/lib/Migration/Version021500Date20240820000001.php @@ -0,0 +1,93 @@ +getTable('collectives_pages'); + if (!$table->hasColumn('slug')) { + $this->runSlugGeneration = true; + $table->addColumn('slug', Types::STRING, [ + 'notnull' => false, + 'default' => false, + 'length' => 255, + ]); + + return $schema; + } + + return null; + } + + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + if (!$this->runSlugGeneration) { + return; + } + + $queryCollectives = $this->connection->getQueryBuilder(); + $queryCollectives->select(['id', 'circle_unique_id'])->from('collectives'); + $resultCollectives = $queryCollectives->executeQuery(); + + $queryPages = $this->connection->getQueryBuilder(); + $queryPages->select(['id']) + ->from('collectives_pages'); + $resultPages = $queryPages->executeQuery(); + + $update = $this->connection->getQueryBuilder(); + $update->update('collectives_pages') + ->set('slug', $update->createParameter('slug')) + ->where($update->expr()->eq('file_id', $update->createParameter('file_id'))); + + while ($rowCollective = $resultCollectives->fetch()) { + $circle = $this->circleHelper->getCircle($rowCollective['circle_unique_id'], null, true); + $pageInfos = $this->pageService->findAll($rowCollective['id'], $circle->getOwner()->getUserId()); + + foreach ($pageInfos as $pageInfo) { + if ($pageInfo->getFileName() === PageInfo::INDEX_PAGE_TITLE . PageInfo::SUFFIX) { + continue; + } + + $slug = $this->slugService->generatePageSlug($pageInfo->getTitle()); + $update + ->setParameter('file_id', $pageInfo->getId(), IQueryBuilder::PARAM_INT) + ->setParameter('slug', $slug, IQueryBuilder::PARAM_STR) + ->executeStatement(); + } + } + + $resultCollectives->closeCursor(); + $resultPages->closeCursor(); + } +} diff --git a/lib/Model/PageInfo.php b/lib/Model/PageInfo.php index a4b2cfeee..044d41372 100644 --- a/lib/Model/PageInfo.php +++ b/lib/Model/PageInfo.php @@ -21,6 +21,7 @@ class PageInfo implements JsonSerializable { public const SUFFIX = '.md'; private int $id; + private ?string $slug = null; private ?string $lastUserId = null; private ?string $lastUserDisplayName = null; private ?string $emoji = null; @@ -44,6 +45,14 @@ public function setId(int $id): void { $this->id = $id; } + public function getSlug(): ?string { + return $this->slug; + } + + public function setSlug(?string $slug): void { + $this->slug = $slug; + } + public function getLastUserId(): ?string { return $this->lastUserId; } @@ -159,6 +168,7 @@ public function setShareToken(string $shareToken): void { public function jsonSerialize(): array { return [ 'id' => $this->id, + 'slug' => $this->slug, 'lastUserId' => $this->lastUserId, 'lastUserDisplayName' => $this->lastUserDisplayName, 'emoji' => $this->emoji, @@ -180,7 +190,7 @@ public function jsonSerialize(): array { * @throws InvalidPathException * @throws NotFoundException */ - public function fromFile(File $file, int $parentId, ?string $lastUserId = null, ?string $lastUserDisplayName = null, ?string $emoji = null, ?string $subpageOrder = null, bool $fullWidth = false): void { + public function fromFile(File $file, int $parentId, ?string $lastUserId = null, ?string $lastUserDisplayName = null, ?string $emoji = null, ?string $subpageOrder = null, bool $fullWidth = false, ?string $slug = null): void { $this->setId($file->getId()); // Set folder name as title for all index pages except the collective landing page $dirName = dirname($file->getInternalPath()); @@ -213,6 +223,9 @@ public function fromFile(File $file, int $parentId, ?string $lastUserId = null, if ($subpageOrder !== null) { $this->setSubpageOrder($subpageOrder); } + if ($slug !== null) { + $this->setSlug($slug); + } $this->setParentId($parentId); } } diff --git a/lib/Service/CollectiveService.php b/lib/Service/CollectiveService.php index dc446b418..18a616431 100644 --- a/lib/Service/CollectiveService.php +++ b/lib/Service/CollectiveService.php @@ -17,6 +17,7 @@ use OCA\Collectives\Db\CollectiveUserSettingsMapper; use OCA\Collectives\Db\Page; use OCA\Collectives\Db\PageMapper; +use OCA\Collectives\Fs\NodeHelper; use OCA\Collectives\Model\PageInfo; use OCA\Collectives\Mount\CollectiveFolderManager; use OCA\Collectives\Trash\PageTrashBackend; @@ -44,6 +45,8 @@ public function __construct( private PageMapper $pageMapper, private IL10N $l10n, private IEventDispatcher $eventDispatcher, + private NodeHelper $nodeHelper, + private SlugService $slugService, ) { parent::__construct($collectiveMapper, $circleHelper); } @@ -169,8 +172,10 @@ public function getCollectiveNameWithEmoji(Collective $collective): string { */ public function createCollective(string $userId, string $userLang, - string $safeName, + string $name, ?string $emoji = null): array { + $safeName = $this->nodeHelper->sanitiseFilename($name); + if ($safeName === '') { throw new UnprocessableEntityException('Empty collective name is not allowed'); } @@ -202,7 +207,7 @@ public function createCollective(string $userId, $this->eventDispatcher->dispatchTyped(new InvalidateMountCacheEvent(null)); } - // Create collective object + // Create a collective object $collective = new Collective(); $collective->setCircleId($circle->getSingleId()); $collective->setPermissions(Collective::defaultPermissions); @@ -211,7 +216,11 @@ public function createCollective(string $userId, } $collective = $this->collectiveMapper->insert($collective); - // Decorate collective object + $slug = $this->slugService->generateCollectiveSlug($collective->getId(), $name); + $collective->setSlug($slug); + $this->collectiveMapper->update($collective); + + // Decorate a collective object $collective->setName($circle->getSanitizedName()); $collective->setLevel($this->circleHelper->getLevel($circle->getSingleId(), $userId)); diff --git a/lib/Service/PageService.php b/lib/Service/PageService.php index 2f5da770c..2cb96ea85 100644 --- a/lib/Service/PageService.php +++ b/lib/Service/PageService.php @@ -49,6 +49,7 @@ public function __construct( private IConfig $config, ContainerInterface $container, private SessionService $sessionService, + private SlugService $slugService, ) { try { $this->pushQueue = $container->get(IQueue::class); @@ -200,7 +201,8 @@ private function getPageByFile(File $file, ?Node $parent = null): PageInfo { $lastUserId = ($page !== null) ? $page->getLastUserId() : null; $emoji = ($page !== null) ? $page->getEmoji() : null; $subpageOrder = ($page !== null) ? $page->getSubpageOrder() : null; - $fullWidth = $page !== null && $page->getFullWidth(); + $fullWidth = ($page !== null) ? $page->getFullWidth() : false; + $slug = ($page !== null) ? $page->getSlug() : null; $pageInfo = new PageInfo(); try { $pageInfo->fromFile($file, @@ -209,7 +211,8 @@ private function getPageByFile(File $file, ?Node $parent = null): PageInfo { $lastUserId ? $this->userManager->getDisplayName($lastUserId) : null, $emoji, $subpageOrder, - $fullWidth); + $fullWidth, + $slug); } catch (FilesNotFoundException|InvalidPathException $e) { throw new NotFoundException($e->getMessage(), 0, $e); } @@ -230,6 +233,7 @@ private function getTrashPageByFile(File $file, string $filename, string $timest $emoji = ($page !== null) ? $page->getEmoji() : null; $subpageOrder = ($page !== null) ? $page->getSubpageOrder() : null; $trashTimestamp = ($page !== null) ? $page->getTrashTimestamp(): (int)$timestamp; + $slug = ($page !== null) ? $page->getSlug() : null; $pageInfo = new PageInfo(); try { $pageInfo->fromFile($file, @@ -238,7 +242,8 @@ private function getTrashPageByFile(File $file, string $filename, string $timest $lastUserId ? $this->userManager->getDisplayName($lastUserId) : null, $emoji, $subpageOrder, - $page && $page->getFullWidth()); + $page->getFullWidth(), + $slug); $pageInfo->setTrashTimestamp($trashTimestamp); $pageInfo->setFilePath(''); $pageInfo->setTitle(basename($filename, PageInfo::SUFFIX)); @@ -263,7 +268,7 @@ private function notifyPush(int $collectiveId): void { } } - private function updatePage(int $collectiveId, int $fileId, string $userId, ?string $emoji = null, ?bool $fullWidth = null): void { + private function updatePage(int $collectiveId, int $fileId, string $userId, ?string $emoji = null, ?bool $fullWidth = null, ?string $slug = null): void { $page = new Page(); $page->setFileId($fileId); $page->setLastUserId($userId); @@ -273,6 +278,9 @@ private function updatePage(int $collectiveId, int $fileId, string $userId, ?str if ($fullWidth !== null) { $page->setFullWidth($fullWidth); } + if ($slug !== null) { + $page->setSlug($slug); + } $this->pageMapper->updateOrInsert($page); $this->notifyPush($collectiveId); } @@ -296,7 +304,7 @@ private function updateSubpageOrder(int $collectiveId, int $fileId, string $user * @throws NotFoundException * @throws NotPermittedException */ - private function newPage(int $collectiveId, Folder $folder, string $filename, string $userId): PageInfo { + private function newPage(int $collectiveId, Folder $folder, string $filename, string $userId, ?string $title): PageInfo { $hasTemplate = NodeHelper::folderHasSubPage($folder, PageInfo::TEMPLATE_PAGE_TITLE); try { if ($hasTemplate === 1) { @@ -325,7 +333,9 @@ private function newPage(int $collectiveId, Folder $folder, string $filename, st $this->getParentPageId($newFile), $userId, $this->userManager->getDisplayName($userId)); - $this->updatePage($collectiveId, $newFile->getId(), $userId); + $slug = $title ? $this->generateSlugForPage($title, $newFile) : null; + $this->updatePage($collectiveId, $newFile->getId(), $userId, null, null, $slug); + $pageInfo->setSlug($slug); } catch (FilesNotFoundException|InvalidPathException $e) { throw new NotFoundException($e->getMessage(), 0, $e); } @@ -416,7 +426,7 @@ public function getPagesFromFolder(int $collectiveId, Folder $folder, string $us if (!$forceIndex && count($pageInfos) === 0) { return []; } - $indexPage = $this->newPage($collectiveId, $folder, PageInfo::INDEX_PAGE_TITLE, $userId); + $indexPage = $this->newPage($collectiveId, $folder, PageInfo::INDEX_PAGE_TITLE, $userId, null); } return array_merge([$indexPage], $pageInfos); @@ -446,6 +456,8 @@ public function findChildren(int $collectiveId, int $parentId, string $userId): * @throws MissingDependencyException * @throws NotFoundException * @throws NotPermittedException + * + * @return PageInfo[] */ public function findAll(int $collectiveId, string $userId): array { $folder = $this->getCollectiveFolder($collectiveId, $userId); @@ -603,7 +615,7 @@ public function create(int $collectiveId, int $parentId, string $title, string $ $safeTitle = $this->nodeHelper->sanitiseFilename($title, self::DEFAULT_PAGE_TITLE); $filename = NodeHelper::generateFilename($folder, $safeTitle, PageInfo::SUFFIX); - $pageInfo = $this->newPage($collectiveId, $folder, $filename, $userId); + $pageInfo = $this->newPage($collectiveId, $folder, $filename, $userId, $title); $this->addToSubpageOrder($collectiveId, $parentId, $pageInfo->getId(), 0, $userId); return $pageInfo; } @@ -745,8 +757,9 @@ public function copy(int $collectiveId, int $id, ?int $parentId, ?string $title, if (null !== $newFile = $this->moveOrCopyPage($collectiveFolder, $file, $parentId, $title, true)) { $file = $newFile; } + $slug = $this->generateSlugForPage($title ?: $page->getTitle(), $file); try { - $this->updatePage($collectiveId, $file->getId(), $userId, $page->getEmoji()); + $this->updatePage($collectiveId, $file->getId(), $userId, $page->getEmoji(), null, $slug); } catch (InvalidPathException|FilesNotFoundException $e) { throw new NotFoundException($e->getMessage(), 0, $e); } @@ -772,9 +785,10 @@ public function move(int $collectiveId, int $id, ?int $parentId, ?string $title, if (null !== $newFile = $this->moveOrCopyPage($collectiveFolder, $file, $parentId, $title, false)) { $file = $newFile; } + $slug = $title ? $this->generateSlugForPage($title, $file) : null; try { - $this->updatePage($collectiveId, $file->getId(), $userId); + $this->updatePage($collectiveId, $file->getId(), $userId, null, null, $slug); } catch (InvalidPathException|FilesNotFoundException $e) { throw new NotFoundException($e->getMessage(), 0, $e); } @@ -1084,4 +1098,12 @@ public function getBacklinks(int $collectiveId, int $id, string $userId): array return $backlinks; } + + private function generateSlugForPage(string $title, ?File $file): ?string { + if (!$file) { + return null; + } + + return $this->slugService->generatePageSlug($title); + } } diff --git a/lib/Service/SlugService.php b/lib/Service/SlugService.php new file mode 100644 index 000000000..40ce07afb --- /dev/null +++ b/lib/Service/SlugService.php @@ -0,0 +1,27 @@ +slugger->slug($name)->toString() . '-' . $collectiveId; + } + + public function generatePageSlug(string $title): string { + return $this->slugger->slug($title)->toString(); + } +} diff --git a/src/Collectives.vue b/src/Collectives.vue index d65b06b15..c2cf33d94 100644 --- a/src/Collectives.vue +++ b/src/Collectives.vue @@ -71,7 +71,9 @@ export default { $route: { handler(val) { this.rootStore.collectiveParam = val.params.collective + this.rootStore.collectiveId = val.params.collectiveId ? parseInt(val.params.collectiveId) : null this.rootStore.pageParam = val.params.page + this.rootStore.pageId = val.params.pageId ? parseInt(val.params.pageId) : null this.rootStore.shareTokenParam = val.params.token this.rootStore.fileIdQuery = val.query.fileId }, diff --git a/src/components/Collective.vue b/src/components/Collective.vue index f9739731c..0fe51b17a 100644 --- a/src/components/Collective.vue +++ b/src/components/Collective.vue @@ -59,8 +59,9 @@ export default { }, computed: { - ...mapState(useRootStore, ['isPublic', 'loading', 'pageParam']), + ...mapState(useRootStore, ['isPublic', 'loading', 'pageParam', 'pageId']), ...mapState(useCollectivesStore, [ + 'collectivePath', 'currentCollective', 'currentCollectiveCanEdit', 'currentCollectiveIsPageShare', @@ -69,7 +70,9 @@ export default { ...mapState(usePagesStore, [ 'currentFileIdPage', 'currentPage', + 'isIndexPage', 'pagePath', + 'pageSlugPath', ]), ...mapState(useVersionsStore, ['version']), @@ -91,6 +94,20 @@ export default { }, 'currentPage.id'() { this.selectVersion(null) + + const routerParams = this.$router.currentRoute.params + // If the current page is not the one we are supposed to be on, redirect + if (this.currentPage && !this.isIndexPage) { + const actualUrl = `${routerParams.collectiveSlugPart}-${routerParams.collectiveId}/page-${routerParams.pageId}-${routerParams.pageSlug}` + const expectedUrl = this.pageSlugPath(this.currentPage) + + if (actualUrl !== expectedUrl) { + this.$router.replace({path: this.pagePath(this.currentPage), hash: document.location.hash}) + } + } else if (this.currentCollective + && `${routerParams.collectiveSlugPart}-${routerParams.collectiveId}` !== this.currentCollective.slug) { + this.$router.replace(this.collectivePath(this.currentCollective)) + } }, 'notFound'(current) { if (current && this.currentFileIdPage) { diff --git a/src/components/Nav/CollectiveSettings.vue b/src/components/Nav/CollectiveSettings.vue index 9766e860e..ef97a2d54 100644 --- a/src/components/Nav/CollectiveSettings.vue +++ b/src/components/Nav/CollectiveSettings.vue @@ -189,9 +189,10 @@ export default { computed: { ...mapState(useRootStore, [ - 'collectiveParam', + 'collectiveId', 'loading', 'pageParam', + 'pageId', ]), ...mapState(useCollectivesStore, ['isCollectiveOwner']), ...mapState(usePagesStore, ['pages']), @@ -321,7 +322,7 @@ export default { this.load('renameCollective') // If currentCollective is renamed, we need to update the router path later - const redirect = this.collectiveParam === this.collective.name + const redirect = this.collectiveId === this.collective.id // Wait for team rename (also patches store with updated collective and pages) const collective = { ...this.collective } @@ -339,10 +340,7 @@ export default { // Push new router path if currentCollective was renamed if (redirect) { - this.$router.push( - '/' + encodeURIComponent(this.newCollectiveName) - + (this.pageParam ? '/' + this.pageParam : ''), - ) + this.$router.go(0) } this.done('renameCollective') @@ -352,7 +350,7 @@ export default { * Trash a collective with the given name */ onTrashCollective() { - if (this.collectiveParam === this.collective.name) { + if (this.collectiveId === this.collective.id) { this.$router.push('/') emit('toggle-navigation', { open: true }) } diff --git a/src/components/Page.vue b/src/components/Page.vue index 75a0b17ac..2f24648c9 100644 --- a/src/components/Page.vue +++ b/src/components/Page.vue @@ -171,6 +171,7 @@ export default { ]), ...mapState(usePagesStore, [ 'currentPage', + 'pagePath', 'isIndexPage', 'isFullWidthView', 'isTemplatePage', @@ -316,6 +317,7 @@ export default { // The resulting title may be different due to sanitizing this.newTitle = this.currentPage.title this.getPages(false) + this.$router.replace(this.pagePath(this.currentPage)) } catch (e) { console.error(e) showError(t('collectives', 'Could not rename the page')) diff --git a/src/components/PageList/SubpageList.vue b/src/components/PageList/SubpageList.vue index 58496c1a4..70450d1eb 100644 --- a/src/components/PageList/SubpageList.vue +++ b/src/components/PageList/SubpageList.vue @@ -79,7 +79,7 @@ export default { }, computed: { - ...mapState(useRootStore, ['pageParam']), + ...mapState(useRootStore, ['pageParam', 'pageId']), ...mapState(useCollectivesStore, ['currentCollectiveCanEdit']), ...mapState(usePagesStore, [ 'pagePath', @@ -133,6 +133,9 @@ export default { 'pageParam'() { this.initCollapsed() }, + 'pageId'() { + this.initCollapsed() + }, }, mounted() { diff --git a/src/router.js b/src/router.js index 2c6bc3616..b67403b97 100644 --- a/src/router.js +++ b/src/router.js @@ -16,22 +16,50 @@ const routes = [ path: '/', component: Home, }, + { + path: '/_/print/:collectiveSlugPart-:collectiveId(\\d+)', + component: CollectivePrintView, + props: (route) => route.params, + }, { path: '/_/print/:collective', component: CollectivePrintView, props: (route) => route.params, }, + { + path: '/p/:token/print/:collectiveSlugPart-:collectiveId(\\d+)', + component: CollectivePrintView, + props: (route) => route.params, + }, { path: '/p/:token/print/:collective', component: CollectivePrintView, props: (route) => route.params, }, + { + path: '/p/:token/:collectiveSlugPart-:collectiveId(\\d+)', + component: CollectiveView, + props: (route) => route.params, + children: [ + { path: 'page-:pageId(\\d+)-:pageSlug' }, + { path: ':page*' }, + ], + }, { path: '/p/:token/:collective', component: CollectiveView, props: (route) => route.params, children: [{ path: ':page*' }], }, + { + path: '/:collectiveSlugPart-:collectiveId(\\d+)', + component: CollectiveView, + props: (route) => route.params, + children: [ + { path: 'page-:pageId(\\d+)-:pageSlug' }, + { path: ':page*' }, + ], + }, { path: '/:collective', component: CollectiveView, diff --git a/src/stores/collectives.js b/src/stores/collectives.js index 59f77132d..c9a96787b 100644 --- a/src/stores/collectives.js +++ b/src/stores/collectives.js @@ -32,6 +32,11 @@ export const useCollectivesStore = defineStore('collectives', { currentCollective(state) { const rootStore = useRootStore() + if (rootStore.collectiveId) { + return state.collectives.find( + (collective) => collective.id === rootStore.collectiveId, + ) + } return state.collectives.find( (collective) => collective.name === rootStore.collectiveParam, ) @@ -40,11 +45,11 @@ export const useCollectivesStore = defineStore('collectives', { collectivePath() { return (collective) => { const rootStore = useRootStore() + const slug = collective.slug ? collective.slug : encodeURIComponent(collective.name) if (rootStore.isPublic) { - return `/p/${rootStore.shareTokenParam}/${encodeURIComponent(collective.name)}` - } else { - return `/${encodeURIComponent(collective.name)}` + return `/p/${rootStore.shareTokenParam}/${slug}` } + return `/${slug}` } }, @@ -75,7 +80,11 @@ export const useCollectivesStore = defineStore('collectives', { updatedCollectivePath(state) { const collective = state.updatedCollective - return collective?.name && `/${encodeURIComponent(collective.name)}` + if (!collective) { + return false + } + const slug = collective.slug ? collective.slug : encodeURIComponent(collective.name) + return `/${slug}` }, collectiveChanged(state) { diff --git a/src/stores/pages.js b/src/stores/pages.js index 7cd403c2d..08c9c7ea0 100644 --- a/src/stores/pages.js +++ b/src/stores/pages.js @@ -48,7 +48,7 @@ export const usePagesStore = defineStore('pages', { const collectivesStore = useCollectivesStore() return collectivesStore.currentCollectiveIsPageShare ? false - : !rootStore.pageParam || rootStore.pageParam === INDEX_PAGE + : (!rootStore.pageId && !rootStore.pageParam) || rootStore.pageParam === INDEX_PAGE }, isIndexPage: (state) => state.currentPage.fileName === INDEX_PAGE + '.md', isTemplatePage: (state) => state.currentPage.title === TEMPLATE_PAGE, @@ -67,13 +67,23 @@ export const usePagesStore = defineStore('pages', { currentPageIds(state) { const rootStore = useRootStore() // Return root page - if (!rootStore.pageParam + if ((!rootStore.pageId && !rootStore.pageParam) || rootStore.pageParam === INDEX_PAGE) { return [state.rootPage.id] } - // Iterate through all path levels to find the correct page const pageIds = [] + if (rootStore.pageId) { + let pageId = rootStore.pageId + do { + const page = state.pageById(pageId) + pageIds.unshift(page.id) + pageId = page.parentId + } while (pageId) + return pageIds + } + + // Iterate through all path levels to find the correct page const parts = rootStore.pageParam.split('/').filter(Boolean) let page = state.rootPage for (const i in parts) { @@ -97,21 +107,20 @@ export const usePagesStore = defineStore('pages', { } }, - pagePath: () => (page) => { + pagePath: (state) => (page) => { const rootStore = useRootStore() - const collectivesStore = useCollectivesStore() - const collective = collectivesStore.currentCollective.name - const { filePath, fileName, title, id } = page - const titlePart = fileName !== INDEX_PAGE + '.md' && title // For public collectives, prepend `/p/{shareToken}` - const pagePath = [ - rootStore.isPublic ? 'p' : null, - rootStore.isPublic ? rootStore.shareTokenParam : null, - collective, - ...filePath.split('/'), - titlePart, - ].filter(Boolean).map(encodeURIComponent).join('/') - return `/${pagePath}?fileId=${id}` + let prefix = '' + if (rootStore.isPublic) { + prefix = `/p/${encodeURIComponent(rootStore.shareTokenParam)}` + } + return `${prefix}/${state.pageSlugPath(page)}` + }, + + pageSlugPath: (state) => (page) => { + const collectivesStore = useCollectivesStore() + const collective = collectivesStore.currentCollective.slug || collectivesStore.currentCollective.name + return [collective, `page-${page.id}-${page.slug}`].join('/') }, pagePathTitle: () => (page) => { diff --git a/src/stores/root.js b/src/stores/root.js index 4b8494ef4..987fbc0d1 100644 --- a/src/stores/root.js +++ b/src/stores/root.js @@ -15,7 +15,9 @@ export const useRootStore = defineStore('root', { printView: false, activeSidebarTab: 'attachments', collectiveParam: '', + collectiveId: null, pageParam: '', + pageId: null, shareTokenParam: '', fileIdQuery: '', }), diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 00ab9e67e..8e24c0425 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -630,23 +630,33 @@ namespace OCA\Circles\Model { public const APP_OCC = 10002; public const APP_DEFAULT = 11000; + public function getLevel(): int {} public function getSingleId(): string {} + public function getUserId(): string {} public function getUserType(): int {} - public function getLevel(): int {} } class Circle { + public function getInitiator(): Member {} + public function getMembers(): array {} public function getName(): string {} - public function getSingleId(): string {} + public function getOwner(): Member {} public function getSanitizedName(): string {} - public function getMembers(): array {} - public function getInitiator(): Member {} + public function getSingleId(): string {} } class FederatedUser { } } +namespace OCA\Circles\Model\Federated { + use OCA\Circles\Tools\Model\SimpleDataStore; + + class FederatedEvent { + public function getParams(): SimpleDataStore {} + } +} + namespace OCA\Circles\Model\Probes { class CircleProbe { public function mustBeMember(bool $must = true): self {} @@ -663,6 +673,11 @@ namespace OCA\Circles\Events { abstract class CircleDestroyedEvent extends CircleResultGenericEvent { public function __construct(FederatedEvent $federatedEvent, array $results) {} } + abstract class EditingCircleEvent extends CircleResultGenericEvent { + public function __construct(FederatedEvent $federatedEvent, array $results) {} + + public function getFederatedEvent(): FederatedEvent; + } } namespace OCA\Circles\Exceptions { @@ -685,6 +700,12 @@ namespace OCA\Circles\Tools\Exceptions { class InvalidItemException extends Exception {} } +namespace OCA\Circles\Tools\Model { + class SimpleDataStore { + public function g(string $key): string {} + } +} + namespace OCA\Circles { use OCA\Circles\Model\Circle; use OCA\Circles\Model\FederatedUser;