From 122a2b36503523be295f8ab875a4c2fa3bf7fc8d Mon Sep 17 00:00:00 2001 From: Konstantin Myakshin Date: Sun, 18 Aug 2024 17:47:16 +0200 Subject: [PATCH] Slugify Collectives and Pages Signed-off-by: Konstantin Myakshin --- composer.json | 2 + composer.lock | 485 +++++++++++++++++- lib/AppInfo/Application.php | 6 + lib/Command/CreateCollective.php | 3 +- lib/Controller/CollectiveController.php | 5 +- lib/Db/Collective.php | 5 +- lib/Db/Page.php | 3 + .../Version021200Date20240820000000.php | 76 +++ .../Version021200Date20240820000001.php | 95 ++++ lib/Model/PageInfo.php | 15 +- lib/Service/CollectiveService.php | 16 +- lib/Service/PageService.php | 40 +- lib/Service/SlugGeneratorService.php | 20 + psalm.xml | 5 + src/Collectives.vue | 2 + src/components/Collective.vue | 19 +- src/components/Nav/CollectiveSettings.vue | 2 + src/components/Page.vue | 2 + src/components/PageList/SubpageList.vue | 5 +- src/router.js | 28 + src/stores/collectives.js | 17 +- src/stores/pages.js | 42 +- src/stores/root.js | 2 + 23 files changed, 850 insertions(+), 45 deletions(-) create mode 100644 lib/Migration/Version021200Date20240820000000.php create mode 100644 lib/Migration/Version021200Date20240820000001.php create mode 100644 lib/Service/SlugGeneratorService.php diff --git a/composer.json b/composer.json index 9acce8255..fe656a294 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 755d5d5e7..77543ff7b 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": "9b344fa99c6c4689dcac79184736e09c", + "content-hash": "03965594e8e2d0b06d40acf0448daf7c", "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", @@ -1175,5 +1656,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 0083793c1..789034433 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -52,6 +52,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'; @@ -130,6 +132,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..bfd7fefbb 100644 --- a/lib/Controller/CollectiveController.php +++ b/lib/Controller/CollectiveController.php @@ -10,9 +10,7 @@ namespace OCA\Collectives\Controller; use Closure; - use OCA\Collectives\Db\Collective; -use OCA\Collectives\Fs\NodeHelper; use OCA\Collectives\Service\CollectiveService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -60,11 +58,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 fb50a1812..c7ada415c 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; @@ -258,6 +260,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..d3c169b0f 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; diff --git a/lib/Migration/Version021200Date20240820000000.php b/lib/Migration/Version021200Date20240820000000.php new file mode 100644 index 000000000..2af5b3ef0 --- /dev/null +++ b/lib/Migration/Version021200Date20240820000000.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->slugGeneratorService->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/Version021200Date20240820000001.php b/lib/Migration/Version021200Date20240820000001.php new file mode 100644 index 000000000..fb7d8a9ef --- /dev/null +++ b/lib/Migration/Version021200Date20240820000001.php @@ -0,0 +1,95 @@ +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); + /** @var PageInfo[] $pageInfos */ + $pageInfos = $this->service->findAll($rowCollective['id'], $circle->getOwner()->getUserId()); + + foreach ($pageInfos as $pageInfo) { + if ($pageInfo->getFileName() === PageInfo::INDEX_PAGE_TITLE . PageInfo::SUFFIX) { + continue; + } + + $slug = $this->slugGeneratorService->generatePageSlug($pageInfo->getId(), $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..e2557a02f 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 SlugGeneratorService $slugGeneratorService, ) { 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->slugGeneratorService->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)); @@ -248,6 +257,7 @@ public function createCollective(string $userId, public function updateCollective(int $id, string $userId, ?string $emoji = null): Collective { + //fixme: should we update slug? $collective = $this->getCollective($id, $userId); if (!$this->circleHelper->isAdmin($collective->getCircleId(), $userId)) { diff --git a/lib/Service/PageService.php b/lib/Service/PageService.php index 2f5da770c..f2da979bb 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 SlugGeneratorService $slugGeneratorService, ) { 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, slug: $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); @@ -603,7 +613,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 +755,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 = $title ? $this->generateSlugForPage($title, $file) : $this->generateSlugForPage($page->getTitle(), $file); try { - $this->updatePage($collectiveId, $file->getId(), $userId, $page->getEmoji()); + $this->updatePage($collectiveId, $file->getId(), $userId, $page->getEmoji(), slug: $slug); } catch (InvalidPathException|FilesNotFoundException $e) { throw new NotFoundException($e->getMessage(), 0, $e); } @@ -772,9 +783,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, slug: $slug); } catch (InvalidPathException|FilesNotFoundException $e) { throw new NotFoundException($e->getMessage(), 0, $e); } @@ -1084,4 +1096,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->slugGeneratorService->generatePageSlug($file->getId(), $title); + } } diff --git a/lib/Service/SlugGeneratorService.php b/lib/Service/SlugGeneratorService.php new file mode 100644 index 000000000..ef394d1aa --- /dev/null +++ b/lib/Service/SlugGeneratorService.php @@ -0,0 +1,20 @@ +slugger->slug($name)->toString() . '-' . $collectiveId; + } + + public function generatePageSlug(int $fileId, string $title): string { + return $this->slugger->slug($title)->toString() . '-' . $fileId; + } +} diff --git a/psalm.xml b/psalm.xml index b8448a28b..0fc04d86c 100644 --- a/psalm.xml +++ b/psalm.xml @@ -31,6 +31,11 @@ + + + + + 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..9e4bcf2f8 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}/${routerParams.pageSlugPart}-${routerParams.pageId}` + const expectedUrl = this.pageSlugPath(this.currentPage) + + if (actualUrl !== expectedUrl) { + this.$router.replace(this.pagePath(this.currentPage)) + } + } 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..7b55909df 100644 --- a/src/components/Nav/CollectiveSettings.vue +++ b/src/components/Nav/CollectiveSettings.vue @@ -192,6 +192,7 @@ export default { 'collectiveParam', 'loading', 'pageParam', + 'pageId', ]), ...mapState(useCollectivesStore, ['isCollectiveOwner']), ...mapState(usePagesStore, ['pages']), @@ -339,6 +340,7 @@ export default { // Push new router path if currentCollective was renamed if (redirect) { + // fixme: adjust this.$router.push( '/' + encodeURIComponent(this.newCollectiveName) + (this.pageParam ? '/' + this.pageParam : ''), 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 fbe906ba0..450c80817 100644 --- a/src/components/PageList/SubpageList.vue +++ b/src/components/PageList/SubpageList.vue @@ -80,7 +80,7 @@ export default { }, computed: { - ...mapState(useRootStore, ['pageParam']), + ...mapState(useRootStore, ['pageParam', 'pageId']), ...mapState(useCollectivesStore, ['currentCollectiveCanEdit']), ...mapState(usePagesStore, [ 'pagePath', @@ -134,6 +134,9 @@ export default { 'pageParam'() { this.initCollapsed() }, + 'pageId'() { + this.initCollapsed() + }, }, mounted() { diff --git a/src/router.js b/src/router.js index 2c6bc3616..9d90f2283 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: ':pageSlugPart+-:pageId(\\d+)' }, + { 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: ':pageSlugPart+-:pageId(\\d+)' }, + { path: ':page*' }, + ], + }, { path: '/:collective', component: CollectiveView, diff --git a/src/stores/collectives.js b/src/stores/collectives.js index 29ad49b1c..d44225f50 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 2d0f01f30..2e9c0a401 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,21 @@ 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 + const slugsPath = pageParents(state)(page.id).map(p => p.slug) + return [collective, ...slugsPath].filter(Boolean).map(encodeURIComponent).join('/') }, pagePathTitle: () => (page) => { diff --git a/src/stores/root.js b/src/stores/root.js index 8d879fbbe..3af8f5a5f 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: '', }),