Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Share your tables and views with users and groups within your cloud.
Have a good time and manage whatever you want.

]]></description>
<version>2.4.0-dev.5</version>
<version>2.4.0-dev.6</version>
<licence>AGPL-3.0-or-later</licence>
<author>Nextcloud GmbH and Nextcloud contributors</author>
<namespace>Tables</namespace>
Expand Down
5 changes: 3 additions & 2 deletions lib/Controller/ContextController.php
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ public function previewSchemeChanges(int $contextId, array $updateScheme): DataR
* @param string $description Description of the context
* @param list<array{node_type: int, node_uuid: string, permissions: int}> $nodes Ordered meta data of the related nodes
* @psalm-param array{addTables: list<TablesTable>, modifyTables: list<TablesTable>} $tables Tables to be added or modified
* @param list<array<string, mixed>>|null $pages Pages of the scheme; each updates the page with the same technical name or is added, with its widgets
*
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND|Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
*
Expand All @@ -364,10 +365,10 @@ public function previewSchemeChanges(int $contextId, array $updateScheme): DataR
*/
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')]
public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables): DataResponse {
public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $pages = null): DataResponse {
try {
$this->db->beginTransaction();
$context = $this->contextService->importScheme($contextId, $name, $iconName, $description, $nodes, $tables, $this->userId);
$context = $this->contextService->importScheme($contextId, $name, $iconName, $description, $nodes, $tables, $this->userId, $pages === null ? null : array_values($pages));
$this->db->commit();
return new DataResponse($context->jsonSerialize());
} catch (\InvalidArgumentException $e) {
Expand Down
14 changes: 8 additions & 6 deletions lib/Controller/ContextPageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,20 +72,21 @@ public function show(int $contextId, int $pageId): DataResponse {
* @param string $title Title of the page
* @param string|null $emoji Emoji shown with the title
* @param string|null $description Description of the page
* @param string|null $technicalName Name to address the page by, unique within the application; left out makes one from the title
* @return DataResponse<Http::STATUS_OK, TablesPage, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 200: Page created
* 400: Invalid title, emoji or description
* 400: Invalid title, emoji, description or technical name
* 403: No permissions
* 404: Not found
*
* @CanManageContext
*/
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')]
public function create(int $contextId, string $title, ?string $emoji = null, ?string $description = null): DataResponse {
public function create(int $contextId, string $title, ?string $emoji = null, ?string $description = null, ?string $technicalName = null): DataResponse {
try {
return new DataResponse($this->pageService->create($contextId, $title, $emoji, $description, $this->userId));
return new DataResponse($this->pageService->create($contextId, $title, $emoji, $description, $technicalName, $this->userId));
} catch (BadRequestError $e) {
return $this->handleBadRequestError($e);
} catch (PermissionError $e) {
Expand Down Expand Up @@ -117,20 +118,21 @@ public function create(int $contextId, string $title, ?string $emoji = null, ?st
* @param string|null $emoji New emoji, empty to remove it
* @param string|null $description New description, empty to remove it
* @param list<array<string, mixed>>|null $widgets All widgets of the page
* @param string|null $technicalName New technical name, unique within the application
* @return DataResponse<Http::STATUS_OK, TablesPage, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 200: Page updated
* 400: Invalid page field or widget
* 400: Invalid page field, technical name or widget
* 403: No permissions on the application or on a table or view to add
* 404: Not found
*
* @CanManageContext
*/
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')]
public function update(int $contextId, int $pageId, ?string $title = null, ?string $emoji = null, ?string $description = null, ?array $widgets = null): DataResponse {
public function update(int $contextId, int $pageId, ?string $title = null, ?string $emoji = null, ?string $description = null, ?array $widgets = null, ?string $technicalName = null): DataResponse {
try {
return new DataResponse($this->pageService->update($contextId, $pageId, $title, $emoji, $description, $widgets, $this->userId));
return new DataResponse($this->pageService->update($contextId, $pageId, $title, $emoji, $description, $widgets, $this->userId, $technicalName));
} catch (BadRequestError $e) {
return $this->handleBadRequestError($e);
} catch (PermissionError $e) {
Expand Down
3 changes: 2 additions & 1 deletion lib/Db/ContextMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ protected function getFindContextBaseQuery(?string $userId): IQueryBuilder {
$qb->select(
'c.*',
'r.id as node_rel_id', 'r.node_id', 'r.node_type', 'r.permissions',
'p.id as page_id', 'p.page_type', 'p.title as page_title', 'p.emoji as page_emoji', 'p.description as page_description',
'p.id as page_id', 'p.page_type', 'p.title as page_title', 'p.emoji as page_emoji', 'p.description as page_description', 'p.technical_name as page_technical_name',
'pc.id as content_id', 'pc.order',
'n.display_mode as display_mode_default',
's.id as share_id', 's.receiver', 's.receiver_type'
Expand Down Expand Up @@ -134,6 +134,7 @@ protected function formatResultRows(array $rows, ?string $userId) {
$carry[$item['page_id']]['title'] = $item['page_title'];
$carry[$item['page_id']]['emoji'] = $item['page_emoji'];
$carry[$item['page_id']]['description'] = $item['page_description'];
$carry[$item['page_id']]['technical_name'] = $item['page_technical_name'];
if ($item['node_rel_id'] !== null && $item['content_id'] !== null) {
$carry[$item['page_id']]['content'][$item['content_id']] = [
'order' => (int)$item['order'],
Expand Down
10 changes: 10 additions & 0 deletions lib/Db/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

namespace OCA\Tables\Db;

use OCA\Tables\Helper\TechnicalNameHelper;
use OCP\AppFramework\Db\Entity;

/**
Expand All @@ -21,21 +22,29 @@
* @method setEmoji(?string $value): void
* @method getDescription(): ?string
* @method setDescription(?string $value): void
* @method getTechnicalName(): ?string
* @method setTechnicalName(?string $value): void
*/
class Page extends Entity implements \JsonSerializable {
public const TYPE_STARTPAGE = 'startpage';
public const TYPE_GRID = 'grid';
public const TECHNICAL_NAME_MAX_LENGTH = TechnicalNameHelper::MAX_LENGTH;

protected ?int $contextId = null;
protected ?string $pageType = null;
protected ?string $title = null;
protected ?string $emoji = null;
protected ?string $description = null;
protected ?string $technicalName = null;

public function __construct() {
$this->addType('id', 'integer');
}

public static function isValidTechnicalName(string $technicalName): bool {
return TechnicalNameHelper::isValid($technicalName);
}

public function jsonSerialize(): array {
return [
'id' => $this->getId(),
Expand All @@ -44,6 +53,7 @@ public function jsonSerialize(): array {
'title' => $this->getTitle(),
'emoji' => $this->getEmoji(),
'description' => $this->getDescription(),
'technicalName' => $this->getTechnicalName(),
];
}
}
100 changes: 100 additions & 0 deletions lib/Helper/TechnicalNameHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Tables\Helper;

/**
* Makes technical names: lower case, letters, digits and underscores, starting
* with a letter, unique within their scope.
*/
class TechnicalNameHelper {
public const PATTERN = '/^[a-z][a-z0-9_]*$/';
public const MAX_LENGTH = 200;
public const START_PAGE_NAME = 'dashboard';

/**
* A technical name made from free text. Every run of characters outside
* `[a-z0-9]` becomes one underscore; a name that does not start with a letter
* gets the prefix, and an empty one is the prefix itself.
*/
public static function fromText(string $text, string $prefix = 'page', int $maxLength = self::MAX_LENGTH): string {
$name = trim(preg_replace('/[^a-z0-9]+/', '_', strtolower($text)) ?? '', '_');
if ($name === '') {
return $prefix;
}
if (!ctype_alpha($name[0])) {
$name = $prefix . '_' . $name;
}
return rtrim(substr($name, 0, $maxLength), '_');
}

/**
* The base name, or the base with the lowest free suffix `_2`, `_3`, ...
*
* @param array<string, true> $taken
*/
public static function unique(string $base, array $taken, int $maxLength = self::MAX_LENGTH): string {
$technicalName = $base;
for ($suffix = 2; isset($taken[$technicalName]); $suffix++) {
$ending = '_' . $suffix;
$technicalName = rtrim(substr($base, 0, $maxLength - strlen($ending)), '_') . $ending;
}
return $technicalName;
}

/**
* Names a set of pages within one application. Pages whose given name is free
* keep it, in the order given; every other page gets a unique name made from its
* given name, else its fallback.
*
* @param array<array-key, array{name: ?string, fallback: string}> $pages
* @param array<string, true> $taken names already used in the application
* @return array<array-key, string> the name of every page, by its key
*/
public static function assign(array $pages, array $taken = []): array {
$names = [];
foreach ($pages as $key => $page) {
$given = $page['name'];
if ($given !== null && $given !== '' && self::isValid($given) && !isset($taken[$given])) {
$names[$key] = $given;
$taken[$given] = true;
}
}
foreach ($pages as $key => $page) {
if (isset($names[$key])) {
continue;
}
$given = $page['name'];
$base = $given !== null && $given !== '' ? self::fromText($given) : $page['fallback'];
$names[$key] = self::unique($base, $taken);
$taken[$names[$key]] = true;
}
$ordered = [];
foreach (array_keys($pages) as $key) {
$ordered[$key] = $names[$key];
}
return $ordered;
}

/**
* The name a page gets when none is given: `dashboard` for a start page, else
* one made from its title.
*/
public static function pageFallback(bool $isStartPage, ?string $title): string {
if ($isStartPage) {
return self::START_PAGE_NAME;
}
return self::fromText($title ?? '');
}

public static function isValid(string $technicalName, int $maxLength = self::MAX_LENGTH): bool {
return strlen($technicalName) <= $maxLength
&& preg_match(self::PATTERN, $technicalName) === 1;
}
}
120 changes: 120 additions & 0 deletions lib/Migration/Version2400Date20261016000002.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Tables\Migration;

use Closure;
use OCA\Tables\Db\Page;
use OCA\Tables\Helper\TechnicalNameHelper;
use OCP\DB\ISchemaWrapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\Types;
use OCP\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;

/**
* Adds a technical name to application pages and gives every page one that is
* unique within its application: start pages get `dashboard`, other pages a name
* made from their title, else `page_<id>`. Valid names that are free keep their value.
*/
class Version2400Date20261016000002 extends SimpleMigrationStep {
private const TABLE = 'tables_contexts_page';

public function __construct(
private readonly IDBConnection $db,
) {
}

#[Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable(self::TABLE)) {
return null;
}
$table = $schema->getTable(self::TABLE);
if ($table->hasColumn('technical_name')) {
return null;
}
$table->addColumn('technical_name', Types::STRING, ['notnull' => false, 'length' => TechnicalNameHelper::MAX_LENGTH]);
return $schema;
}

/**
* @param Closure(): ISchemaWrapper $schemaClosure
*/
#[Override]
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$select = $this->db->getQueryBuilder();
$select->select('id', 'context_id', 'page_type', 'title', 'technical_name')
->from(self::TABLE)
->orderBy('context_id')
->addOrderBy('id');
$result = $select->executeQuery();
$rows = $result->fetchAll();
$result->closeCursor();

$names = self::backfillNames($rows);
if ($names === []) {
return;
}

$update = $this->db->getQueryBuilder();
$update->update(self::TABLE)
->set('technical_name', $update->createParameter('technicalName'))
->where($update->expr()->eq('id', $update->createParameter('pageId')));

$this->db->beginTransaction();
try {
foreach ($names as $pageId => $technicalName) {
$update->setParameter('technicalName', $technicalName);
$update->setParameter('pageId', $pageId, IQueryBuilder::PARAM_INT);
$update->executeStatement();
}
$this->db->commit();
} catch (\Exception $e) {
$this->db->rollBack();
throw $e;
}
}

/**
* The new technical name of every page row that needs one, by page id.
*
* @param list<array<string, mixed>> $rows page rows ordered by id
* @return array<int, string>
*/
public static function backfillNames(array $rows): array {
$pagesByContext = [];
foreach ($rows as $row) {
$pageId = (int)$row['id'];
$title = is_string($row['title'] ?? null) ? $row['title'] : '';
$isStartPage = $row['page_type'] === Page::TYPE_STARTPAGE;
$fallback = $isStartPage || preg_match('/[a-z0-9]/i', $title) === 1
? TechnicalNameHelper::pageFallback($isStartPage, $title)
: 'page_' . $pageId;
$pagesByContext[(int)$row['context_id']][$pageId] = [
'name' => is_string($row['technical_name'] ?? null) ? $row['technical_name'] : null,
'fallback' => $fallback,
];
}

$changes = [];
foreach ($pagesByContext as $pages) {
foreach (TechnicalNameHelper::assign($pages) as $pageId => $technicalName) {
if ($pages[$pageId]['name'] !== $technicalName) {
$changes[$pageId] = $technicalName;
}
}
}
return $changes;
}
}
Loading
Loading