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