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
17 changes: 14 additions & 3 deletions lib/Controller/ContextController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use OCA\Tables\Errors\PermissionError;
use OCA\Tables\Helper\IconHelper;
use OCA\Tables\Middleware\Attribute\RequirePermission;
use OCA\Tables\Model\MenuItemInput;
use OCA\Tables\ResponseDefinitions;
use OCA\Tables\Service\ColumnService;
use OCA\Tables\Service\ContextService;
Expand Down Expand Up @@ -101,6 +102,7 @@ public function show(int $contextId): DataResponse {
* @param string $iconName Material design icon name of the context
* @param string $description Descriptive text of the context
* @psalm-param list<array{id: int, type: int, permissions?: int}> $nodes optional nodes to be connected to this context
* @psalm-param ?list<array{label: string, icon?: string, description?: string, targetType?: 'view'|'table'|'url'|'page', targetId?: int, url?: string, technicalName?: string}> $menuItems optional menu of the context, in order; the views and tables it opens become nodes of the context and a page item without a targetId opens the start page; without a menu the context gets one item opening the start page
*
* @return DataResponse<Http::STATUS_OK, TablesContext, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN, array{message: string}, array{}>
*
Expand All @@ -109,7 +111,7 @@ public function show(int $contextId): DataResponse {
* 403: lacking permissions on a resource
*/
#[NoAdminRequired]
public function create(string $name, string $iconName, string $description = '', array $nodes = []): DataResponse {
public function create(string $name, string $iconName, string $description = '', array $nodes = [], ?array $menuItems = null): DataResponse {
try {
if (!$this->isValidIcon($iconName)) {
return new DataResponse(['message' => 'Invalid icon name'], Http::STATUS_BAD_REQUEST);
Expand All @@ -121,13 +123,16 @@ public function create(string $name, string $iconName, string $description = '',
$this->sanitizeInputNodes($nodes),
$this->userId,
0,
$menuItems !== null ? MenuItemInput::createListFromInputArray($menuItems, allowNewStartPage: true) : null,
)->jsonSerialize());
} catch (Exception $e) {
return $this->handleError($e);
} catch (PermissionError $e) {
return $this->handlePermissionError($e);
} catch (BadRequestError $e) {
return $this->handleBadRequestError($e);
} catch (InvalidArgumentException $e) {
return $this->handleError(new InternalError($e->getMessage(), $e->getCode(), $e));
return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e));
}
}

Expand All @@ -139,6 +144,7 @@ public function create(string $name, string $iconName, string $description = '',
* @param ?string $iconName provide this parameter to set a new icon
* @param ?string $description provide this parameter to set a new description
* @param ?array{id: int, type: int, permissions: int, order: int} $nodes provide this parameter to set a new list of nodes.
* @psalm-param ?list<array{label: string, icon?: string, description?: string, targetType?: 'view'|'table'|'url'|'page', targetId?: int, url?: string, technicalName?: string}> $menuItems provide this parameter to replace the menu; the views and tables it opens become nodes of the context, and an item without a technicalName keeps the one stored for the same target
*
* @return DataResponse<Http::STATUS_OK, TablesContext, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND|Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
*
Expand All @@ -150,7 +156,7 @@ public function create(string $name, string $iconName, string $description = '',
* @CanManageContext
*/
#[NoAdminRequired]
public function update(int $contextId, ?string $name, ?string $iconName, ?string $description, ?array $nodes): DataResponse {
public function update(int $contextId, ?string $name, ?string $iconName, ?string $description, ?array $nodes, ?array $menuItems = null): DataResponse {
try {
if ($iconName !== null && !$this->isValidIcon($iconName)) {
return new DataResponse(['message' => 'Invalid icon name'], Http::STATUS_BAD_REQUEST);
Expand All @@ -163,13 +169,18 @@ public function update(int $contextId, ?string $name, ?string $iconName, ?string
$iconName,
$description,
$nodes,
$menuItems !== null ? MenuItemInput::createListFromInputArray($menuItems) : null,
)->jsonSerialize());
} catch (Exception|MultipleObjectsReturnedException $e) {
return $this->handleError($e);
} catch (PermissionError $e) {
return $this->handlePermissionError($e);
} catch (DoesNotExistException $e) {
return $this->handleNotFoundError(new NotFoundError($e->getMessage(), $e->getCode(), $e));
} catch (BadRequestError $e) {
return $this->handleBadRequestError($e);
} catch (InvalidArgumentException $e) {
return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e));
}
}

Expand Down
15 changes: 15 additions & 0 deletions lib/Db/ContextNodeRelation.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ public function __construct() {
$this->addType('permissions', 'integer');
}

/**
* The relation as an application carries it among its nodes, in the shape the
* context mapper reads it.
*
* @return array{id: int, node_id: int, node_type: int, permissions: int}
*/
public function toContextNode(): array {
return [
'id' => (int)$this->getId(),
'node_id' => (int)$this->getNodeId(),
'node_type' => (int)$this->getNodeType(),
'permissions' => (int)$this->getPermissions(),
];
}

public function jsonSerialize(): array {
return [
'id' => $this->getId(),
Expand Down
10 changes: 10 additions & 0 deletions lib/Db/MenuItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class MenuItem extends EntitySuper implements JsonSerializable {
public const TARGET_URL = 'url';
public const TARGET_PAGE = 'page';
public const TARGET_TYPES = [self::TARGET_VIEW, self::TARGET_TABLE, self::TARGET_URL, self::TARGET_PAGE];
public const DEFAULT_NAME_PREFIX = 'item_';
public const MAX_ITEMS_PER_APPLICATION = 200;

protected ?int $contextId = null;
protected ?string $label = null;
Expand All @@ -70,6 +72,14 @@ public function __construct() {
$this->addType('broken', 'boolean');
}

/**
* Whether a technical name has the form of the automatic `item_<id>`. Such names are
* reserved, so a given name never collides with the name a later item gets.
*/
public static function isDefaultName(string $technicalName): bool {
return preg_match('/^' . self::DEFAULT_NAME_PREFIX . '[0-9]+$/', $technicalName) === 1;
}

/**
* @return array{id: int, contextId: int, label: string, icon: string|null, description: string|null, targetType: 'view'|'table'|'url'|'page', targetId: int|null, url: string|null, technicalName: string, order: int, parentId: int|null, broken: bool}
*/
Expand Down
2 changes: 1 addition & 1 deletion lib/Db/MenuItemMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function __construct(IDBConnection $db) {
public function insertWithDefaultName(MenuItem $menuItem): MenuItem {
return $this->atomic(function () use ($menuItem): MenuItem {
$menuItem = $this->insert($menuItem);
$menuItem->setTechnicalName('item_' . $menuItem->getId());
$menuItem->setTechnicalName(MenuItem::DEFAULT_NAME_PREFIX . $menuItem->getId());
return $this->update($menuItem);
}, $this->db);
}
Expand Down
13 changes: 13 additions & 0 deletions lib/Db/PageContent.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ public function __construct() {
$this->addType('order', 'integer');
}

/**
* The content as a page of an application carries it, in the shape the context
* mapper reads it.
*
* @return array{order: int, node_rel_id: int}
*/
public function toPageContent(): array {
return [
'order' => (int)$this->getOrder(),
'node_rel_id' => (int)$this->getNodeRelId(),
];
}

public function jsonSerialize(): array {
return [
'id' => $this->getId(),
Expand Down
178 changes: 178 additions & 0 deletions lib/Model/MenuItemInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<?php

declare(strict_types=1);

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

namespace OCA\Tables\Model;

use InvalidArgumentException;
use OCA\Tables\Db\MenuItem;
use OCA\Tables\Helper\IconHelper;
use OCA\Tables\Validation\TechnicalNameValidator;

/**
* A submitted menu item, checked on its own: its label, icon, description, target and
* technical name. Whether a target may be used is up to the application it is saved in.
*/
class MenuItemInput {
public const MAX_LABEL_LENGTH = 200;
public const MAX_DESCRIPTION_LENGTH = 2000;
public const MAX_URL_LENGTH = 2000;
private const URL_SCHEMES = ['http', 'https'];

/**
* @throws InvalidArgumentException
*/
public function __construct(
private readonly string $label,
private readonly ?string $icon,
private readonly ?string $description,
private readonly string $targetType,
private readonly ?int $targetId,
private readonly ?string $url,
private readonly ?string $technicalName,
private readonly bool $allowNewStartPage = false,
) {
if (trim($this->label) === '' || mb_strlen($this->label) > self::MAX_LABEL_LENGTH) {
throw new InvalidArgumentException('A menu item needs a label of at most ' . self::MAX_LABEL_LENGTH . ' characters.');
}
if ($this->icon !== null && !IconHelper::isValid($this->icon)) {
throw new InvalidArgumentException('A menu item has an unknown icon: ' . $this->icon);
}
if ($this->description !== null && mb_strlen($this->description) > self::MAX_DESCRIPTION_LENGTH) {
throw new InvalidArgumentException('A menu item description has at most ' . self::MAX_DESCRIPTION_LENGTH . ' characters.');
}
if (!in_array($this->targetType, MenuItem::TARGET_TYPES, true)) {
throw new InvalidArgumentException('Unexpected menu item target type, expected one of: ' . implode(', ', MenuItem::TARGET_TYPES) . '.');
}
if ($this->targetType === MenuItem::TARGET_URL) {
self::assertUrlValid($this->url);
} elseif (!$this->pointsAtNewStartPage() && ($this->targetId === null || $this->targetId <= 0)) {
throw new InvalidArgumentException('A menu item pointing at a ' . $this->targetType . ' needs its id.');
}
if ($this->technicalName !== null && !TechnicalNameValidator::isValid($this->technicalName)) {
throw new InvalidArgumentException('A technical name has 1 to ' . TechnicalNameValidator::MAX_LENGTH . ' lower case letters, digits and underscores and starts with a letter.');
}
}

/**
* @param bool $allowNewStartPage accept a page item without an id, meaning the start page
* of an application that is being created
* @throws InvalidArgumentException
*/
public static function createFromInputArray(mixed $data, bool $allowNewStartPage = false): self {
if (!is_array($data)) {
throw new InvalidArgumentException('A menu item must be an object.');
}
if (!is_string($data['label'] ?? null)) {
throw new InvalidArgumentException('A menu item needs a label.');
}
$targetType = $data['targetType'] ?? MenuItem::TARGET_URL;
if (!is_string($targetType)) {
throw new InvalidArgumentException('The target type of a menu item must be a string.');
}
$isUrl = $targetType === MenuItem::TARGET_URL;
$targetId = $data['targetId'] ?? null;
$isNewStartPage = $allowNewStartPage && $targetType === MenuItem::TARGET_PAGE && $targetId === null;
if (!$isUrl && !$isNewStartPage && !is_int($targetId)) {
throw new InvalidArgumentException('A menu item pointing at a ' . $targetType . ' needs its id.');
}
foreach (['icon', 'description', 'url', 'technicalName'] as $optional) {
if (isset($data[$optional]) && !is_string($data[$optional])) {
throw new InvalidArgumentException('The menu item property "' . $optional . '" must be a string.');
}
}

return new self(
label: trim($data['label']),
icon: self::emptyToNull($data['icon'] ?? null),
description: self::emptyToNull($data['description'] ?? null),
targetType: $targetType,
targetId: $isUrl ? null : $targetId,
url: $isUrl ? trim((string)($data['url'] ?? '')) : null,
technicalName: self::emptyToNull($data['technicalName'] ?? null),
allowNewStartPage: $allowNewStartPage,
);
}

/**
* @param bool $allowNewStartPage see createFromInputArray()
* @return list<self>
* @throws InvalidArgumentException
*/
public static function createListFromInputArray(mixed $data, bool $allowNewStartPage = false): array {
if (!is_array($data) || !array_is_list($data)) {
throw new InvalidArgumentException('The menu items must be a list.');
}
return array_map(static fn (mixed $item): self => self::createFromInputArray($item, $allowNewStartPage), $data);
}

/**
* True for a page item without an id on the menu of an application that is being created.
*/
public function pointsAtNewStartPage(): bool {
return $this->allowNewStartPage && $this->targetType === MenuItem::TARGET_PAGE && $this->targetId === null;
}

/**
* This item pointing at the given page.
*
* @throws InvalidArgumentException
*/
public function withPageId(int $pageId): self {
return new self($this->label, $this->icon, $this->description, MenuItem::TARGET_PAGE, $pageId, null, $this->technicalName);
}

/**
* @throws InvalidArgumentException
*/
private static function assertUrlValid(?string $url): void {
$scheme = $url === null ? null : parse_url($url, PHP_URL_SCHEME);
// parse_url accepts spaces and control characters anywhere in a link
if ($url === null || mb_strlen($url) > self::MAX_URL_LENGTH || preg_match('/[\s\p{Z}\p{Cc}]/u', $url) !== 0
|| !is_string($scheme) || !in_array(strtolower($scheme), self::URL_SCHEMES, true)
|| parse_url($url, PHP_URL_HOST) === null) {
throw new InvalidArgumentException('A menu item link must be an http or https address of at most ' . self::MAX_URL_LENGTH . ' characters.');
}
}

private static function emptyToNull(?string $value): ?string {
if ($value === null) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}

public function getLabel(): string {
return $this->label;
}

public function getIcon(): ?string {
return $this->icon;
}

public function getDescription(): ?string {
return $this->description;
}

public function getTargetType(): string {
return $this->targetType;
}

public function getTargetId(): ?int {
return $this->targetId;
}

public function getUrl(): ?string {
return $this->url;
}

public function getTechnicalName(): ?string {
return $this->technicalName;
}
}
Loading
Loading