From 03688cda60a094128f18a8df2d11f9661015673e Mon Sep 17 00:00:00 2001 From: Konstantin Myakshin Date: Sun, 18 Aug 2024 17:47:16 +0200 Subject: [PATCH 1/4] Slugify Collectives and Pages Signed-off-by: Kostiantyn Miakshyn --- composer.json | 2 + composer.lock | 483 +++++++++++++++++- cypress/e2e/page-landingpage.spec.js | 2 +- lib/AppInfo/Application.php | 9 + lib/Command/CreateCollective.php | 3 +- lib/Controller/CollectiveController.php | 4 +- lib/Db/Collective.php | 5 +- lib/Db/Page.php | 4 + lib/Listeners/CircleEditingEventListener.php | 55 ++ .../Version021500Date20240820000000.php | 76 +++ .../Version021500Date20240820000001.php | 95 ++++ lib/Model/PageInfo.php | 15 +- .../SearchablePageReferenceProvider.php | 21 +- lib/Service/CollectiveService.php | 15 +- lib/Service/PageService.php | 43 +- lib/Service/SlugService.php | 27 + src/Collectives.vue | 2 + src/components/Collective.vue | 19 +- src/components/Nav/CollectiveSettings.vue | 12 +- src/components/Page.vue | 2 + src/components/PageList/SubpageList.vue | 5 +- src/router.js | 28 + src/stores/collectives.js | 17 +- src/stores/pages.js | 51 +- src/stores/root.js | 2 + tests/Unit/Service/CollectiveServiceTest.php | 18 +- tests/Unit/Service/PageServiceTest.php | 5 +- tests/stub.phpstub | 34 +- 28 files changed, 993 insertions(+), 61 deletions(-) create mode 100644 lib/Listeners/CircleEditingEventListener.php create mode 100644 lib/Migration/Version021500Date20240820000000.php create mode 100644 lib/Migration/Version021500Date20240820000001.php create mode 100644 lib/Service/SlugService.php diff --git a/composer.json b/composer.json index 9f93e072c..2a66d0211 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 e1735880f..69d346224 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": "fce54c5e91a882fe3a5f9e86206e00e2", + "content-hash": "35397f4395d9cf38e3f543d4fd91a0ee", "packages": [ { "name": "predis/predis", @@ -67,6 +67,487 @@ ], "time": "2024-11-21T20:00:02+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": { + "url": "https://github.com/symfony/polyfill", + "name": "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": { + "url": "https://github.com/symfony/polyfill", + "name": "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": { + "url": "https://github.com/symfony/polyfill", + "name": "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": { + "url": "https://github.com/symfony/polyfill", + "name": "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.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "450d4172653f38818657022252f9d81be89ee9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/450d4172653f38818657022252f9d81be89ee9a8", + "reference": "450d4172653f38818657022252f9d81be89ee9a8", + "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.4" + }, + "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-25T14:11:13+00:00" + }, { "name": "teamtnt/tntsearch", "version": "v4.4.0", diff --git a/cypress/e2e/page-landingpage.spec.js b/cypress/e2e/page-landingpage.spec.js index cb4a72a05..056ede9bf 100644 --- a/cypress/e2e/page-landingpage.spec.js +++ b/cypress/e2e/page-landingpage.spec.js @@ -53,7 +53,7 @@ describe('Page landing page', function() { cy.get('.recent-pages-widget .recent-page-tile') .contains('Page 3') .click() - cy.url().should('include', `/apps/collectives/${encodeURIComponent(collective)}/${encodeURIComponent('Page 3')}`) + cy.url().should('match', /\/apps\/collectives\/Landingpage-Collective-\d+\/page-\d+-Page-3/) }) }) 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..fc33c805b --- /dev/null +++ b/lib/Migration/Version021500Date20240820000001.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') + ->where('trash_timestamp IS NULL'); + $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/Reference/SearchablePageReferenceProvider.php b/lib/Reference/SearchablePageReferenceProvider.php index b46fc4cb2..593e8282e 100644 --- a/lib/Reference/SearchablePageReferenceProvider.php +++ b/lib/Reference/SearchablePageReferenceProvider.php @@ -77,6 +77,17 @@ private static function pagePathFromMatches(string $url, array $matches): array if ($matches && count($matches) > 1) { $pagePath['fileId'] = (int)$matches[1]; } + + if (preg_match('/page-(\d+)-(.+)$/i', $pagePath['pagePath'], $matches)) { + $pagePath['fileId'] = (int)$matches[1]; + $pagePath['pagePath'] = urldecode($matches[2]); + } + + if (preg_match('/(.+?)-(\d+)$/i', $pagePath['collectiveName'], $matches)) { + $pagePath['collectiveName'] = $matches[1]; + $pagePath['collectiveId'] = (int)$matches[2]; + } + return $pagePath; } @@ -99,6 +110,7 @@ public function matchUrl(string $url): ?array { } // link examples: + // https://nextcloud.local/apps/collectives/supacollective-123/page-14457-spectre-slug // https://nextcloud.local/apps/collectives/supacollective/Tutos/Hacking/Spectre?fileId=14457 // https://nextcloud.local/apps/collectives/supacollective/Tutos/Hacking/Spectre $startRegexes = [ @@ -123,12 +135,16 @@ public function matchReference(string $referenceText): bool { /** * @throws NotFoundException */ - private function getCollective(string $collectiveName, ?string $sharingToken): Collective { + private function getCollective(?int $collectiveId, string $collectiveName, ?string $sharingToken): Collective { if ($sharingToken) { // TODO: Check if share is password protected; if yes, then check in session if authenticated return $this->collectiveService->findCollectiveByShare($sharingToken); } + if ($collectiveId) { + return $this->collectiveService->getCollective($collectiveId, $this->userId); + } + return $this->collectiveService->findCollectiveByName($this->userId, $collectiveName); } @@ -186,6 +202,7 @@ private function resolve(string $referenceText, bool $public = false, string $sh return $this->linkReferenceProvider->resolveReference($referenceText); } + $collectiveId = $pageReferenceInfo['collectiveId'] ?? null; $collectiveName = $pageReferenceInfo['collectiveName']; if ($public && !$sharingToken) { @@ -193,7 +210,7 @@ private function resolve(string $referenceText, bool $public = false, string $sh return $this->linkReferenceProvider->resolveReference($referenceText); } try { - $collective = $this->getCollective($collectiveName, $sharingToken); + $collective = $this->getCollective($collectiveId, $collectiveName, $sharingToken); $page = $this->getPage($collective, $pageReferenceInfo, $public); } catch (Exception|Throwable) { // fallback to opengraph if it matches, but somehow we can't resolve 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 ba342f9ae..2c23cf571 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); } @@ -421,11 +431,12 @@ public function getPagesFromFolder(int $collectiveId, Folder $folder, string $us if (!isset($indexPage)) { if ($hasPages || $forceIndex) { // Create missing index page if folder or subfolders have page files (or forceIndex) - $indexPage = $this->newPage($collectiveId, $folder, PageInfo::INDEX_PAGE_TITLE, $userId); + $indexPage = $this->newPage($collectiveId, $folder, PageInfo::INDEX_PAGE_TITLE, $userId, null); } else { // Ignore folders without an index page return []; } + $indexPage = $this->newPage($collectiveId, $folder, PageInfo::INDEX_PAGE_TITLE, $userId, null); } // Add markdown files from this folder @@ -472,6 +483,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); @@ -629,7 +642,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; } @@ -771,8 +784,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); } @@ -798,9 +812,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); } @@ -1110,4 +1125,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 424b751f2..71cf658e0 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 0b0f0ab76..686f641c3 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 d90b8d2d7..37d538a8c 100644 --- a/src/components/Page.vue +++ b/src/components/Page.vue @@ -173,6 +173,7 @@ export default { ]), ...mapState(usePagesStore, [ 'currentPage', + 'pagePath', 'isIndexPage', 'isFullWidthView', 'isTemplatePage', @@ -318,6 +319,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..d46b578d1 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,30 @@ 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 + if (!page.slug) { + const { filePath, fileName, title, id } = page + const titlePart = fileName !== INDEX_PAGE + '.md' && title + + const pagePath = [collective, ...filePath.split('/'), titlePart] + .filter(Boolean).map(encodeURIComponent).join('/') + + return `${pagePath}?fileId=${id}` + } + + 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/Unit/Service/CollectiveServiceTest.php b/tests/Unit/Service/CollectiveServiceTest.php index 8c70bc9b3..19e0ed3c6 100644 --- a/tests/Unit/Service/CollectiveServiceTest.php +++ b/tests/Unit/Service/CollectiveServiceTest.php @@ -17,6 +17,7 @@ use OCA\Collectives\Db\CollectiveMapper; use OCA\Collectives\Db\CollectiveUserSettingsMapper; use OCA\Collectives\Db\PageMapper; +use OCA\Collectives\Fs\NodeHelper; use OCA\Collectives\Mount\CollectiveFolderManager; use OCA\Collectives\Service\CircleExistsException; use OCA\Collectives\Service\CircleHelper; @@ -24,6 +25,7 @@ use OCA\Collectives\Service\CollectiveService; use OCA\Collectives\Service\CollectiveShareService; use OCA\Collectives\Service\NotFoundException; +use OCA\Collectives\Service\SlugService; use OCA\Collectives\Service\UnprocessableEntityException; use OCP\App\IAppManager; use OCP\EventDispatcher\IEventDispatcher; @@ -90,6 +92,15 @@ protected function setUp(): void { ->disableOriginalConstructor() ->getMock(); + $nodeHelper = $this->createMock(NodeHelper::class); + $nodeHelper->method('sanitiseFilename') + ->willReturnCallback(function (string $name, string $default = 'New File') { + return $name; + }); + + $slugService = $this->createMock(SlugService::class); + $slugService->method('generateCollectiveSlug')->willReturn('free-123'); + $this->service = new CollectiveService( $appManager, $this->collectiveMapper, @@ -100,7 +111,9 @@ protected function setUp(): void { $collectiveUserSettingsMapper, $pageMapper, $this->l10n, - $eventDispatcher + $eventDispatcher, + $nodeHelper, + $slugService, ); } @@ -203,8 +216,9 @@ public function testCreate(): void { ->willReturn($collective); [$collective, $info] = $this->service->createCollective($this->userId, 'de', 'free'); self::assertIsCallable([$collective, 'jsonSerialize']); - self::assertEqualsCanonicalizing([ + self::assertEquals([ 'id' => 123, + 'slug' => 'free-123', 'circleId' => null, 'emoji' => null, 'trashTimestamp' => null, diff --git a/tests/Unit/Service/PageServiceTest.php b/tests/Unit/Service/PageServiceTest.php index f12b05c87..57d648c2b 100644 --- a/tests/Unit/Service/PageServiceTest.php +++ b/tests/Unit/Service/PageServiceTest.php @@ -25,6 +25,7 @@ use OCA\Collectives\Service\NotPermittedException; use OCA\Collectives\Service\PageService; use OCA\Collectives\Service\SessionService; +use OCA\Collectives\Service\SlugService; use OCP\IConfig; use OCP\IUserManager; use PHPUnit\Framework\TestCase; @@ -100,7 +101,9 @@ protected function setUp(): void { $userManager, $this->config, $container, - $sessionService); + $sessionService, + $this->createMock(SlugService::class), + ); } public function testGetPageFile(): void { diff --git a/tests/stub.phpstub b/tests/stub.phpstub index a88933a48..e2ab7d82c 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -67,7 +67,10 @@ namespace OC\Core\Command { public function __construct() {} protected function configure() {} public function run(InputInterface $input, OutputInterface $output) {} - public function setName(string $name) {} + public function setName(string $name): self {} + public function setDescription(string $description): self {} + public function addArgument(string $name, ?int $mode = null, string $description = '', mixed $default = null /* array|\Closure $suggestedValues = null */): self {} + public function addOption(string $name, string|array|null $shortcut = null, ?int $mode = null, string $description = '', mixed $default = null /* array|\Closure $suggestedValues = [] */): self {} public function getHelper(string $name) {} protected function writeArrayInOutputFormat(InputInterface $input, OutputInterface $output, $items, $prefix = ' - ') { } @@ -630,23 +633,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 {} @@ -667,6 +680,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 { @@ -689,6 +707,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; From 893a5c8f3da88a7d784888936f3b22bb4839580e Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Fri, 20 Dec 2024 12:38:57 +0100 Subject: [PATCH 2/4] Slugify Collectives and Pages (move slug generation into a separate console command) Signed-off-by: Kostiantyn Miakshyn --- appinfo/info.xml | 3 +- lib/Command/GenerateSlugs.php | 106 ++++++++++++++++++ .../Version021500Date20240820000000.php | 76 ------------- .../Version021500Date20240820000001.php | 95 ---------------- .../Version021600Date20240820000000.php | 37 ++++++ .../Version021600Date20240820000001.php | 36 ++++++ 6 files changed, 181 insertions(+), 172 deletions(-) create mode 100644 lib/Command/GenerateSlugs.php delete mode 100644 lib/Migration/Version021500Date20240820000000.php delete mode 100644 lib/Migration/Version021500Date20240820000001.php create mode 100644 lib/Migration/Version021600Date20240820000000.php create mode 100644 lib/Migration/Version021600Date20240820000001.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 291fab648..bab6aedbe 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.15.2 + 2.16.0 agpl CollectiveCloud Team Collectives @@ -59,6 +59,7 @@ In your Nextcloud instance, simply navigate to **»Apps«**, find the OCA\Collectives\Command\CreateCollective OCA\Collectives\Command\ExpirePageVersions + OCA\Collectives\Command\GenerateSlugs OCA\Collectives\Command\IndexCollectives OCA\Collectives\Command\PageTrashCleanup OCA\Collectives\Command\PurgeObsoletePages diff --git a/lib/Command/GenerateSlugs.php b/lib/Command/GenerateSlugs.php new file mode 100644 index 000000000..7a49d6538 --- /dev/null +++ b/lib/Command/GenerateSlugs.php @@ -0,0 +1,106 @@ +setName('collectives:generate-slugs') + ->setDescription('Generate slugs for collectives and pages'); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $output->write('Generating slugs for collectives ... '); + $this->generateCollectiveSlugs(); + $output->writeln('done'); + + $output->write('Generating slugs for pages ... '); + $this->generatePageSlugs(); + $output->writeln('done'); + + return 0; + } + + private function generateCollectiveSlugs(): void { + $query = $this->connection->getQueryBuilder(); + $query->select(['id', 'circle_unique_id']) + ->from('collectives') + ->where('(slug IS NULL OR slug = \'\')'); + $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(); + } + + private function generatePageSlugs(): void { + $queryCollectives = $this->connection->getQueryBuilder(); + $queryCollectives->select(['id', 'circle_unique_id']) + ->from('collectives') + ->where('trash_timestamp IS NULL'); + $resultCollectives = $queryCollectives->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(); + } +} diff --git a/lib/Migration/Version021500Date20240820000000.php b/lib/Migration/Version021500Date20240820000000.php deleted file mode 100644 index 1dfeec72e..000000000 --- a/lib/Migration/Version021500Date20240820000000.php +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index fc33c805b..000000000 --- a/lib/Migration/Version021500Date20240820000001.php +++ /dev/null @@ -1,95 +0,0 @@ -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') - ->where('trash_timestamp IS NULL'); - $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/Migration/Version021600Date20240820000000.php b/lib/Migration/Version021600Date20240820000000.php new file mode 100644 index 000000000..67a9ac944 --- /dev/null +++ b/lib/Migration/Version021600Date20240820000000.php @@ -0,0 +1,37 @@ +getTable('collectives'); + if (!$table->hasColumn('slug')) { + $table->addColumn('slug', Types::STRING, [ + 'notnull' => false, + 'default' => false, + 'length' => 255, + ]); + + return $schema; + } + + return null; + } + +} diff --git a/lib/Migration/Version021600Date20240820000001.php b/lib/Migration/Version021600Date20240820000001.php new file mode 100644 index 000000000..7d4349e7a --- /dev/null +++ b/lib/Migration/Version021600Date20240820000001.php @@ -0,0 +1,36 @@ +getTable('collectives_pages'); + if (!$table->hasColumn('slug')) { + $table->addColumn('slug', Types::STRING, [ + 'notnull' => false, + 'default' => false, + 'length' => 255, + ]); + + return $schema; + } + + return null; + } +} From 3f6dcdc7392d8beb64c7249f8fca24e1212cb017 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sat, 4 Jan 2025 02:08:47 +0100 Subject: [PATCH 3/4] Slugify Collectives and Pages (try fix cypress) Signed-off-by: Kostiantyn Miakshyn --- cypress/e2e/pages-links.spec.js | 7 ++++--- cypress/support/commands.js | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cypress/e2e/pages-links.spec.js b/cypress/e2e/pages-links.spec.js index 4f5e3dfaa..c79d855b8 100644 --- a/cypress/e2e/pages-links.spec.js +++ b/cypress/e2e/pages-links.spec.js @@ -4,8 +4,7 @@ */ const baseUrl = Cypress.env('baseUrl') -const sourceUrl = new URL(`${baseUrl}/index.php/apps/collectives/Link%20Testing/Link%20Source`) -let imageId, pdfId, textId +let imageId, pdfId, textId, sourceUrl let anotherCollectiveFirstPageId, linkTargetPageId describe('Page link handling', function() { @@ -21,7 +20,9 @@ describe('Page link handling', function() { .seedPage('Link Target', '', 'Readme.md').then(({ pageId }) => { linkTargetPageId = pageId }) - .seedPage('Link Source', '', 'Readme.md') + .seedPage('Link Source', '', 'Readme.md').then(({ collectiveId, pageId }) => { + sourceUrl = new URL(`${baseUrl}/index.php/apps/collectives/Link-Testing-${collectiveId}/page-${pageId}-Link-Source`) + }) cy.seedPageContent('Link%20Testing/Link%20Target.md', 'Some content') cy.uploadFile('test.md', 'text/markdown').then((id) => { textId = id diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 6e65676c0..e8d154ddb 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -314,7 +314,7 @@ Cypress.Commands.add('seedPage', ) }) .its('data.data.id') - .then(pageId => ({ ...subject, pageId })) + .then(pageId => ({ ...subject, collectiveId: collectiveContext(subject).collectiveId, pageId })) }) /** From dffe309d12868b57d0847dff9e72d6ba2eb342ec Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sat, 4 Jan 2025 13:45:56 +0100 Subject: [PATCH 4/4] Slugify Collectives and Pages (try fix cypress) Signed-off-by: Kostiantyn Miakshyn --- cypress/e2e/pages-links.spec.js | 104 +++++++++++--------------------- 1 file changed, 34 insertions(+), 70 deletions(-) diff --git a/cypress/e2e/pages-links.spec.js b/cypress/e2e/pages-links.spec.js index c79d855b8..20d879649 100644 --- a/cypress/e2e/pages-links.spec.js +++ b/cypress/e2e/pages-links.spec.js @@ -5,13 +5,14 @@ const baseUrl = Cypress.env('baseUrl') let imageId, pdfId, textId, sourceUrl -let anotherCollectiveFirstPageId, linkTargetPageId +let anotherCollectiveFirstPageId, anotherCollectiveId, linkTestingCollectiveId, linkTargetPageId describe('Page link handling', function() { before(function() { cy.loginAs('bob') cy.deleteAndSeedCollective('Another Collective') - .seedPage('First Page', '', 'Readme.md').then(({ pageId }) => { + .seedPage('First Page', '', 'Readme.md').then(({ collectiveId, pageId }) => { + anotherCollectiveId = collectiveId anotherCollectiveFirstPageId = pageId }) cy.deleteAndSeedCollective('Link Testing') @@ -21,6 +22,7 @@ describe('Page link handling', function() { linkTargetPageId = pageId }) .seedPage('Link Source', '', 'Readme.md').then(({ collectiveId, pageId }) => { + linkTestingCollectiveId = collectiveId sourceUrl = new URL(`${baseUrl}/index.php/apps/collectives/Link-Testing-${collectiveId}/page-${pageId}-Link-Source`) }) cy.seedPageContent('Link%20Testing/Link%20Target.md', 'Some content') @@ -56,15 +58,15 @@ describe('Page link handling', function() { ## Links supposed to open in same window -* URL to page in this collective: [Link Target](${baseUrl}/index.php/apps/collectives/Link%20Testing/Link%20Target) -* Absolute path to page in this collective: [Link Target](/index.php/apps/collectives/Link%20Testing/Link%20Target) +* URL to page in this collective: [Link Target](${baseUrl}/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target) +* Absolute path to page in this collective: [Link Target](/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target) * Relative path to page in this collective with fileId: [Link Target](./Link%20Target?fileId=${linkTargetPageId}) * Relative path to page in this collective with fileId and outdated path: [Link Target](./Link%20Target%20Outdated?fileId=${linkTargetPageId}) * Relative path to page in this collective without fileId: [Link Target](./Link%20Target) * Relative path to markdown file in this collective: [Link Target](./Link%20Target.md) -* URL to page in other collective with fileId: [Another Collective/First Page](${baseUrl}/index.php/apps/collectives/Another%20Collective/First%20Page?fileId=${anotherCollectiveFirstPageId}) -* Absolute path to page in other collective without fileId: [Another Collective/First Page](/index.php/apps/collectives/Another%20Collective/First%20Page) +* URL to page in other collective with fileId: [Another Collective/First Page](${baseUrl}/index.php/apps/collectives/Another-Collective-${anotherCollectiveId}/First%20Page?fileId=${anotherCollectiveFirstPageId}) +* Absolute path to page in other collective without fileId: [Another Collective/First Page](/index.php/apps/collectives/Another-Collective-${anotherCollectiveId}/page-${anotherCollectiveFirstPageId}-First-Page) ## Links supposed to open in new window @@ -124,9 +126,8 @@ describe('Page link handling', function() { cy.url().then((newBaseUrl) => { const url = new URL(href, newBaseUrl) - const encodedCollectiveName = encodeURIComponent('Link Testing') const pathname = isPublic - ? url.pathname.replace(`/${encodedCollectiveName}`, `/p/\\w+/${encodedCollectiveName}`) + ? url.pathname.replace('/Link-Testing', '/p/\\w+/Link-Testing') : url.pathname cy.location().should((loc) => { expect(loc.pathname).to.match(new RegExp(`^${expectedPathname || pathname}$`)) @@ -239,24 +240,12 @@ describe('Page link handling', function() { describe('Link handling to collectives in view mode', function() { it('Opens link with URL to page in this collective in same tab', function() { - const href = `${baseUrl}/index.php/apps/collectives/Link%20Testing/Link%20Target` - if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { - testLinkToSameTab(href, { - expectedSearch: `?fileId=${linkTargetPageId}`, - }) - } else { - testLinkToSameTab(href) - } + const href = `${baseUrl}/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target` + testLinkToSameTab(href) }) it('Opens link with absolute path to page in this collective in same tab', function() { - const href = '/index.php/apps/collectives/Link%20Testing/Link%20Target' - if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { - testLinkToSameTab(href, { - expectedSearch: `?fileId=${linkTargetPageId}`, - }) - } else { - testLinkToSameTab(href) - } + const href = `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target` + testLinkToSameTab(href) }) it('Opens link with relative path to page in this collective with fileId in same tab', function() { // Starting with Nextcloud 29, internal links will always open in same tab (also in edit mode) @@ -268,7 +257,7 @@ describe('Page link handling', function() { // const href = `./Link%20Target?fileId=${linkTargetPageId}` const href = `/index.php/apps/files/?dir=/&openfile=${linkTargetPageId}#relPath=./Link%20Target` testLinkToSameTab(href, { - expectedPathname: '/index.php/apps/collectives/Link%20Testing/Link%20Target', + expectedPathname: `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target`, expectedSearch: `?fileId=${linkTargetPageId}`, }) } @@ -278,16 +267,14 @@ describe('Page link handling', function() { if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { const href = `./Link%20Target%20Outdated?fileId=${linkTargetPageId}` testLinkToSameTab(href, { - expectedPathname: '/index.php/apps/collectives/Link%20Testing/Link%20Target', - expectedSearch: `?fileId=${linkTargetPageId}`, + expectedPathname: `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target`, }) } else { // Link without origin and containing `fileId` param gets rewritten by editor rendering // const href = `./Link%20Target%20Outdated?fileId=${linkTargetPageId}` const href = `/index.php/apps/files/?dir=/&openfile=${linkTargetPageId}#relPath=./Link%20Target%20Outdated` testLinkToSameTab(href, { - expectedPathname: '/index.php/apps/collectives/Link%20Testing/Link%20Target', - expectedSearch: `?fileId=${linkTargetPageId}`, + expectedPathname: `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target`, }) } }) @@ -295,8 +282,7 @@ describe('Page link handling', function() { const href = './Link%20Target' if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { testLinkToSameTab(href, { - expectedPathname: '/index.php/apps/collectives/Link%20Testing/Link%20Target', - expectedSearch: `?fileId=${linkTargetPageId}`, + expectedPathname: `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target`, }) } else { testLinkToSameTab(href) @@ -307,27 +293,21 @@ describe('Page link handling', function() { const href = './Link%20Target.md' if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { testLinkToSameTab(href, { - expectedPathname: '/index.php/apps/collectives/Link%20Testing/Link%20Target', + expectedPathname: `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target`, expectedSearch: `?fileId=${linkTargetPageId}`, }) } else { - testLinkToSameTab(href, { expectedPathname: '/index.php/apps/collectives/Link%20Testing/Link%20Target' }) + testLinkToSameTab(href, { expectedPathname: `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target` }) } }) it('Opens link with URL to page in other collective with fileId in same tab', function() { - const href = `${baseUrl}/index.php/apps/collectives/Another%20Collective/First%20Page?fileId=${anotherCollectiveFirstPageId}` + const href = `${baseUrl}/index.php/apps/collectives/Another-Collective-${anotherCollectiveId}/page-${anotherCollectiveFirstPageId}-First-Page` testLinkToSameTab(href) }) it('Opens link with absolute path to page in other collective without fileId in same tab', function() { - const href = '/index.php/apps/collectives/Another%20Collective/First%20Page' - if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { - testLinkToSameTab(href, { - expectedSearch: `?fileId=${anotherCollectiveFirstPageId}`, - }) - } else { - testLinkToSameTab(href) - } + const href = `/index.php/apps/collectives/Another-Collective-${anotherCollectiveId}/page-${anotherCollectiveFirstPageId}-First-Page` + testLinkToSameTab(href) }) it('Opens link with relative path from index page to page in this collective with fileId in same tab', function() { // Starting with Nextcloud 29, internal links will always open in same tab (also in edit mode) @@ -337,7 +317,7 @@ describe('Page link handling', function() { // const href = `../Link%20Target.md?fileId=${linkTargetPageId}` const href = `/index.php/apps/files/?dir=&openfile=${linkTargetPageId}#relPath=../Link%20Target.md` testLinkToSameTab(href, { - expectedPathname: '/index.php/apps/collectives/Link%20Testing/Link%20Target', + expectedPathname: `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target`, expectedSearch: `?fileId=${linkTargetPageId}`, }) } @@ -350,7 +330,7 @@ describe('Page link handling', function() { // const href = `./Link%20Target?fileId=${linkTargetPageId}` const href = `/index.php/apps/files/?dir=/&openfile=${linkTargetPageId}#relPath=./Link%20Target` testLinkToSameTab(href, { - expectedPathname: '/index.php/apps/collectives/Link%20Testing/Link%20Target', + expectedPathname: `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target`, expectedSearch: `?fileId=${linkTargetPageId}`, }) } @@ -359,7 +339,7 @@ describe('Page link handling', function() { describe('Link handling to collectives in edit mode', function() { it('Opens link with URL to page in this collective in same tab', function() { - const href = `${baseUrl}/index.php/apps/collectives/Link%20Testing/Link%20Target` + const href = `${baseUrl}/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target` cy.switchToEditMode() // Starting with Nextcloud 29, internal links will always open in same tab (also in edit mode) if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { @@ -372,17 +352,10 @@ describe('Page link handling', function() { } }) it('Opens link with absolute path to page in this collective in same tab', function() { - const href = '/index.php/apps/collectives/Link%20Testing/Link%20Target' + const href = `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target` cy.switchToEditMode() // Starting with Nextcloud 29, internal links will always open in same tab (also in edit mode) - if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { - testLinkToSameTab(href, { - edit: true, - expectedSearch: `?fileId=${linkTargetPageId}`, - }) - } else { - testLinkToNewTab(href, { edit: true }) - } + testLinkToNewTab(href, { edit: true }) }) it('Opens link with relative path to page in this collective with fileId in same tab', function() { const href = `./Link%20Target?fileId=${linkTargetPageId}` @@ -401,8 +374,7 @@ describe('Page link handling', function() { if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { testLinkToSameTab(href, { edit: true, - expectedPathname: '/index.php/apps/collectives/Link%20Testing/Link%20Target', - expectedSearch: `?fileId=${linkTargetPageId}`, + expectedPathname: `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target`, }) } else { // Link without origin and containing `fileId` param gets rewritten by editor rendering @@ -412,14 +384,7 @@ describe('Page link handling', function() { const href = './Link%20Target' cy.switchToEditMode() // Starting with Nextcloud 29, internal links will always open in same tab (also in edit mode) - if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { - testLinkToSameTab(href, { - edit: true, - expectedSearch: `?fileId=${linkTargetPageId}`, - }) - } else { - testLinkToNewTab(href, { edit: true }) - } + testLinkToNewTab(href, { edit: true }) }) it('Opens link with relative path to markdown file in this collective without fileId in same tab', function() { // TODO: We want '.md' to be stripped when opening the link @@ -429,8 +394,7 @@ describe('Page link handling', function() { if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { testLinkToSameTab(href, { edit: true, - expectedPathname: '/index.php/apps/collectives/Link%20Testing/Link%20Target', - expectedSearch: `?fileId=${linkTargetPageId}`, + expectedPathname: `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target`, }) } else { // Special handling of links to markdown files is only in Collectives link handler @@ -438,7 +402,7 @@ describe('Page link handling', function() { }) it('Opens link with URL to page in other collective with fileId in same tab', function() { - const href = `${baseUrl}/index.php/apps/collectives/Another%20Collective/First%20Page?fileId=${anotherCollectiveFirstPageId}` + const href = `${baseUrl}/index.php/apps/collectives/Another-Collective-${anotherCollectiveId}/First%20Page?fileId=${anotherCollectiveFirstPageId}` cy.switchToEditMode() // Starting with Nextcloud 29, internal links will always open in same tab (also in edit mode) if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { @@ -448,7 +412,7 @@ describe('Page link handling', function() { } }) it('Opens link with absolute path to page in other collective without fileId in same tab', function() { - const href = '/index.php/apps/collectives/Another%20Collective/First%20Page' + const href = `/index.php/apps/collectives/Another-Collective-${anotherCollectiveId}/page-${anotherCollectiveFirstPageId}-First-Page` cy.switchToEditMode() // Starting with Nextcloud 29, internal links will always open in same tab (also in edit mode) if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { @@ -552,13 +516,13 @@ describe('Page link handling', function() { it('Public share in view mode: opens link with absolute path to page in this collective in same tab', function() { cy.logout() cy.visit(`${shareUrl}/Link Source`) - const href = '/index.php/apps/collectives/Link%20Testing/Link%20Target' + const href = `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target` testLinkToSameTab(href, { isPublic: true }) }) it('Public share in edit mode: opens link with absolute path to page in this collective in same tab', function() { cy.logout() cy.visit(`${shareUrl}/Link Source`) - const href = '/index.php/apps/collectives/Link%20Testing/Link%20Target' + const href = `/index.php/apps/collectives/Link-Testing-${linkTestingCollectiveId}/page-${linkTargetPageId}-Link-Target` cy.switchToEditMode() if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { testLinkToSameTab(href, { edit: true, isPublic: true })