diff --git a/appinfo/routes.php b/appinfo/routes.php index b92ee2f831..9e19e1d604 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -156,8 +156,6 @@ ['name' => 'Context#transfer', 'url' => '/api/2/contexts/{contextId}/transfer', 'verb' => 'PUT'], ['name' => 'Context#updateContentOrder', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'PUT'], - ['name' => 'RowOCS#createRow', 'url' => '/api/2/{nodeCollection}/{nodeId}/rows', 'verb' => 'POST', 'requirements' => ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)']], - ['name' => 'Config#getTableConfig', 'url' => '/api/2/config/table/{id}', 'verb' => 'GET'], ['name' => 'Config#getViewConfig', 'url' => '/api/2/config/view/{id}', 'verb' => 'GET'], ['name' => 'Config#setValue', 'url' => '/api/2/config/{key}', 'verb' => 'POST'], diff --git a/lib/Constants/FilterOperator.php b/lib/Constants/FilterOperator.php index 1bf49d8228..5b7665fea4 100644 --- a/lib/Constants/FilterOperator.php +++ b/lib/Constants/FilterOperator.php @@ -12,6 +12,7 @@ enum FilterOperator: string { case BEGINS_WITH = 'begins-with'; case ENDS_WITH = 'ends-with'; case CONTAINS = 'contains'; + case CONTAINS_ITEM = 'contains-item'; case DOES_NOT_CONTAIN = 'does-not-contain'; case IS_EQUAL = 'is-equal'; case IS_NOT_EQUAL = 'is-not-equal'; diff --git a/lib/Controller/PublicRowOCSController.php b/lib/Controller/PublicRowOCSController.php index 9ac88b6768..1ebca70b8f 100644 --- a/lib/Controller/PublicRowOCSController.php +++ b/lib/Controller/PublicRowOCSController.php @@ -8,13 +8,12 @@ namespace OCA\Tables\Controller; -use OCA\Tables\AppInfo\Application; use OCA\Tables\Db\Row2Mapper; +use OCA\Tables\Db\RowQuery; use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; -use OCA\Tables\Helper\ConversionHelper; use OCA\Tables\Middleware\Attribute\AssertShareAccessIsAccessible; use OCA\Tables\Model\RowDataInput; use OCA\Tables\ResponseDefinitions; @@ -26,6 +25,7 @@ use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\DataResponse; use OCP\IL10N; use OCP\IRequest; @@ -52,8 +52,12 @@ public function __construct( * [api v2] Fetch all rows from a link share * * @param string $token The share token - * @param int|null $limit Optional: maximum number of results, capped at 500 - * @param int|null $offset Optional: the offset for this operation + * @psalm-param ?int<1,500> $limit Number of rows to return between 1 and 500, fetches all by default (optional) + * @psalm-param ?int<0,max> $offset Offset of the rows to be returned (optional) + * @param string|null $filter Optional: a JSON encoded filter parameter + * @param string|null $sort Optional: a JSON encoded sort parameter + * @param string|null $search Optional: a search string + * @param string|null $rowIds Optional: a JSON encoded list of row IDs * @return DataResponse, array{}>|DataResponse * * 200: Rows are returned @@ -67,7 +71,7 @@ public function __construct( #[ApiRoute(verb: 'GET', url: '/api/2/public/{token}/rows', requirements: ['token' => '[a-zA-Z0-9]{16}'])] #[OpenAPI] #[AnonRateLimit(limit: 20, period: 30)] - public function getRows(string $token, ?int $limit, ?int $offset): DataResponse { + public function getRows(string $token, ?int $limit, ?int $offset, ?string $filter = null, ?string $sort = null, ?string $search = null, ?string $rowIds = null): DataResponse { try { $shareToken = new ShareToken($token); $share = $this->shareService->findByToken($shareToken); @@ -76,16 +80,19 @@ public function getRows(string $token, ?int $limit, ?int $offset): DataResponse return $this->handlePermissionError(new PermissionError('No read permission on this share')); } - $limit = $limit !== null ? max(0, min(500, $limit)) : null; - $offset = $offset !== null ? max(0, $offset) : null; - - $nodeType = ConversionHelper::stringNodeType2Const($share->getNodeType()); - if ($nodeType === Application::NODE_TYPE_TABLE) { - $rows = $this->rowService->findAllByTable($share->getNodeId(), '', $limit, $offset); - } elseif ($nodeType === Application::NODE_TYPE_VIEW) { - $rows = $this->rowService->findAllByView($share->getNodeId(), '', $limit, $offset); - } + $queryData = RowQuery::buildFromInput( + nodeType: $share->getNodeType(), + nodeId: $share->getNodeId(), + limit: $limit, + offset: $offset, + filter: $filter, + sort: $sort, + search: $search, + rowIds: $rowIds, + normalizePagination: true, + ); + $rows = $this->rowService->findAllByQuery($queryData); $formattedRows = $this->rowService->formatRowsForPublicShare($rows); return new DataResponse($formattedRows); } catch (PermissionError $e) { @@ -99,6 +106,108 @@ public function getRows(string $token, ?int $limit, ?int $offset): DataResponse } } + /** + * [api v2] Count rows from a link share + * + * @param string $token The share token + * @param string|null $filter Optional: a JSON encoded filter parameter + * @param string|null $sort Optional: a JSON encoded sort parameter + * @param string|null $search Optional: a search string + * @return DataResponse|DataResponse + * + * 200: Count is returned + * 400: Invalid request parameters + * 403: No permissions + * 404: Not found + * 500: Internal error + */ + #[PublicPage] + #[AssertShareAccessIsAccessible] + #[ApiRoute(verb: 'GET', url: '/api/2/public/{token}/rows/count', requirements: ['token' => '[a-zA-Z0-9]{16}'])] + #[OpenAPI] + #[AnonRateLimit(limit: 20, period: 30)] + public function countRows(string $token, ?string $filter = null, ?string $sort = null, ?string $search = null): DataResponse { + try { + $shareToken = new ShareToken($token); + $share = $this->shareService->findByToken($shareToken); + + if (!$share->getPermissionRead()) { + return $this->handlePermissionError(new PermissionError('No read permission on this share')); + } + + $queryData = RowQuery::buildFromInput( + nodeType: $share->getNodeType(), + nodeId: $share->getNodeId(), + filter: $filter, + sort: $sort, + search: $search, + ); + + $count = $this->rowService->countByQuery($queryData); + return new DataResponse(['count' => $count]); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (InternalError $e) { + return $this->handleError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } + } + + /** + * [api v2] Export all rows from a link share as a CSV file + * + * @param string $token The share token + * @param ?string $filter Optional: a JSON encoded filter parameter + * @param ?string $sort Optional: a JSON encoded sort parameter + * @param ?string $search Optional: a search string + * @param ?string $rowIds Optional: a JSON encoded list of row IDs to export + * @return DataDownloadResponse|DataResponse + * + * 200: CSV file is returned + * 400: Invalid request parameters + * 403: No permissions + * 404: Not found + * 500: Internal error + */ + #[PublicPage] + #[AssertShareAccessIsAccessible] + #[ApiRoute(verb: 'GET', url: '/api/2/public/{token}/rows/export', requirements: ['token' => '[a-zA-Z0-9]{16}'])] + #[OpenAPI] + #[AnonRateLimit(limit: 20, period: 30)] + public function exportRows(string $token, ?string $filter = null, ?string $sort = null, ?string $search = null, ?string $rowIds = null): DataDownloadResponse|DataResponse { + try { + $shareToken = new ShareToken($token); + $share = $this->shareService->findByToken($shareToken); + + if (!$share->getPermissionRead()) { + return $this->handlePermissionError(new PermissionError('No read permission on this share')); + } + + $queryData = RowQuery::buildFromInput( + nodeType: $share->getNodeType(), + nodeId: $share->getNodeId(), + filter: $filter, + sort: $sort, + search: $search, + rowIds: $rowIds, + ); + + $csv = $this->rowService->exportCsv($queryData); + return new DataDownloadResponse($csv, 'export.csv', 'text/csv'); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (InternalError $e) { + return $this->handleError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } + } + /** * [api v2] Create a row in a link share * @@ -259,4 +368,5 @@ public function deleteRow(string $token, int $rowId): DataResponse { return $this->handleError($e); } } + } diff --git a/lib/Controller/RowOCSController.php b/lib/Controller/RowOCSController.php index 60ef07efaf..7a8be067c6 100644 --- a/lib/Controller/RowOCSController.php +++ b/lib/Controller/RowOCSController.php @@ -1,5 +1,7 @@ $data An array containing the column identifiers and their values - * @return DataResponse|DataResponse + * @param string|array $data An array containing the column + * identifiers and their values + * @return DataResponse|DataResponse * * 200: Row returned * 400: Invalid request parameters @@ -55,6 +68,7 @@ public function __construct( */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_CREATE, typeParam: 'nodeCollection')] + #[ApiRoute(verb: 'POST', url: '/api/2/{nodeCollection}/{nodeId}/rows', requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)'])] public function createRow(string $nodeCollection, int $nodeId, mixed $data): DataResponse { if (is_string($data)) { $data = json_decode($data, true); @@ -88,4 +102,162 @@ public function createRow(string $nodeCollection, int $nodeId, mixed $data): Dat return $this->handleError($e); } } + + /** + * [api v2] Get a number of rows from a table or view + * + * Both `filter` and `sort` are passed as JSON encoded strings. + * + * The filter is a list of filter groups, each group being a list of single + * filter definitions. Definitions within a group are AND-connected, while + * the groups themselves are OR-connected. + * + * When reading from a view, the provided filter is added to each of the + * view's existing filter groups, so the view's base rules are always + * enforced. + * + * A provided sort order overrides the view's default sort order. The view's + * default sort order is only used when no sort order is provided. + * + * @param 'tables'|'views' $nodeCollection Indicates whether to read from a table or a view + * @psalm-param int<0,max> $nodeId The ID of the table or view + * @psalm-param ?int<1,500> $limit Number of rows to return between 1 and 500, fetches all by default (optional) + * @psalm-param ?int<0,max> $offset Offset of the rows to be returned (optional) + * @param ?string $filter JSON encoded list of filter groups. Definitions within a group are AND-connected, groups are OR-connected, e.g. `[[{"columnId":1,"operator":"contains","value":"foo"}]]` (optional) + * @param ?string $sort JSON encoded list of sort rules, e.g. `[{"columnId":1,"mode":"ASC"}]` (optional) + * @param ?string $search Search string (optional) + * @param ?string $rowIds JSON encoded list of row IDs (optional) + * @return DataResponse, array{}>|DataResponse + * + * 200: Rows returned + * 400: Invalid request parameters + * 403: No permissions + * 404: Not found + * 500: Internal error + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'nodeCollection')] + #[ApiRoute( + verb: 'GET', + url: '/api/2/{nodeCollection}/{nodeId}/rows', + requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\\d+)'] + )] + public function getRows(string $nodeCollection, int $nodeId, ?int $limit = null, ?int $offset = null, ?string $filter = null, ?string $sort = null, ?string $search = null, ?string $rowIds = null): DataResponse { + try { + $queryData = RowQuery::buildFromInput( + nodeType: $nodeCollection, + nodeId: $nodeId, + limit: $limit, + offset: $offset, + filter: $filter, + sort: $sort, + search: $search, + rowIds: $rowIds, + normalizePagination: true, + userId: $this->userId, + ); + + $rows = $this->rowService->findAllByQuery($queryData); + return new DataResponse($this->rowService->formatRows($rows)); + } catch (DoesNotExistException $e) { + return $this->handleNotFoundError(new NotFoundError($e->getMessage(), $e->getCode(), $e)); + } catch (MultipleObjectsReturnedException|InvalidArgumentException $e) { + return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e)); + } catch (InternalError|Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Count rows from a table or view + * + * @param 'tables'|'views' $nodeCollection Indicates whether to read from a table or a view + * @psalm-param int<0,max> $nodeId The ID of the table or view + * @param string|null $filter Optional: a JSON encoded filter parameter + * @param string|null $sort Optional: a JSON encoded sort parameter + * @param string|null $search Optional: a search string + * @return DataResponse|DataResponse + * + * 200: Count is returned + * 400: Invalid request parameters + * 403: No permissions + * 404: Not found + * 500: Internal error + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'nodeCollection')] + #[ApiRoute( + verb: 'GET', + url: '/api/2/{nodeCollection}/{nodeId}/rows/count', + requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\\d+)'] + )] + public function countRows(string $nodeCollection, int $nodeId, ?string $filter = null, ?string $sort = null, ?string $search = null): DataResponse { + try { + $queryData = RowQuery::buildFromInput( + nodeType: $nodeCollection, + nodeId: $nodeId, + filter: $filter, + sort: $sort, + search: $search, + userId: $this->userId, + ); + + $count = $this->rowService->countByQuery($queryData); + return new DataResponse(['count' => $count]); + } catch (DoesNotExistException $e) { + return $this->handleNotFoundError(new NotFoundError($e->getMessage(), $e->getCode(), $e)); + } catch (MultipleObjectsReturnedException|InvalidArgumentException $e) { + return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e)); + } catch (InternalError|\Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Export all rows from a table or view as a CSV file + * + * @param string $nodeCollection 'tables' or 'views' + * @param int $nodeId The table or view ID + * @param ?string $filter JSON encoded list of filter groups (optional) + * @param ?string $sort JSON encoded list of sort rules (optional) + * @param ?string $search Search string (optional) + * @param ?string $rowIds JSON encoded list of row IDs to export (optional) + * @return DataDownloadResponse|DataResponse + * + * 200: CSV file is returned + * 400: Invalid request parameters + * 403: No permissions + * 404: Not found + * 500: Internal error + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'nodeCollection')] + #[ApiRoute( + verb: 'GET', + url: '/api/2/{nodeCollection}/{nodeId}/rows/export', + requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)'] + )] + public function exportRows(string $nodeCollection, int $nodeId, ?string $filter = null, ?string $sort = null, ?string $search = null, ?string $rowIds = null): DataDownloadResponse|DataResponse { + try { + $queryData = RowQuery::buildFromInput( + nodeType: $nodeCollection, + nodeId: $nodeId, + filter: $filter, + sort: $sort, + search: $search, + rowIds: $rowIds, + userId: $this->userId, + ); + + $csv = $this->rowService->exportCsv($queryData); + return new DataDownloadResponse($csv, 'export.csv', 'text/csv'); + } catch (DoesNotExistException $e) { + return $this->handleNotFoundError(new NotFoundError($e->getMessage(), $e->getCode(), $e)); + } catch (MultipleObjectsReturnedException|InvalidArgumentException $e) { + return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e)); + } catch (InternalError|Exception $e) { + return $this->handleError($e); + } + } + } diff --git a/lib/Db/LegacyRowMapper.php b/lib/Db/LegacyRowMapper.php index 95f04a2bb7..b39859ca07 100644 --- a/lib/Db/LegacyRowMapper.php +++ b/lib/Db/LegacyRowMapper.php @@ -82,7 +82,7 @@ public function find(int $id): LegacyRow { return $this->findEntity($qb); } - private function buildFilterByColumnType($qb, array $filter, string $filterId): ?IQueryFunction { + private function buildFilterByColumnType(IQueryBuilder $qb, array $filter, string $filterId): ?IQueryFunction { try { $columnQbClassName = 'OCA\Tables\Db\ColumnTypes\\'; $type = explode('-', $filter['columnType'])[0]; diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index 7ec48500f9..194092505d 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -132,7 +132,7 @@ public function setUserId(string $userId): void { * @return int[] * @throws InternalError */ - private function getWantedRowIds(string $userId, int $tableId, ?array $filter = null, ?array $sort = null, ?int $limit = null, ?int $offset = null): array { + private function getWantedRowIds(string $userId, int $tableId, ?array $filter = null, ?array $sort = null, ?int $limit = null, ?int $offset = null, ?array $showColumnIds = null, ?string $search = null, ?array $rowIds = null): array { $qb = $this->db->getQueryBuilder(); $qb->select('sleeves.id') @@ -143,6 +143,14 @@ private function getWantedRowIds(string $userId, int $tableId, ?array $filter = $this->addFilterToQuery($qb, $filter, $userId); } + if ($search !== null && $search !== '' && $showColumnIds) { + $this->addSearchToQuery($qb, $search, $showColumnIds); + } + + if ($rowIds !== null && $rowIds !== []) { + $qb->andWhere($qb->expr()->in('sleeves.id', $qb->createNamedParameter($rowIds, IQueryBuilder::PARAM_INT_ARRAY))); + } + $this->addSortQueryForMultipleSleeveFinder($qb, 'sleeves', $sort); $qb->groupBy('sleeves.id'); @@ -161,7 +169,7 @@ private function getWantedRowIds(string $userId, int $tableId, ?array $filter = throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), ); } - return array_map(fn (array $item) => $item['id'], $result->fetchAll()); + return array_map(static fn (array $item) => $item['id'], $result->fetchAll()); } /** @@ -172,14 +180,15 @@ private function getWantedRowIds(string $userId, int $tableId, ?array $filter = * @param array|null $filter * @param array|null $sort * @param string|null $userId + * @param string|null $search * @return Row2[] * @throws InternalError */ - public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, ?int $offset = null, ?array $filter = null, ?array $sort = null, ?string $userId = null): array { + public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, ?int $offset = null, ?array $filter = null, ?array $sort = null, ?string $userId = null, ?string $search = null, ?array $rowIds = null): array { try { $this->columnMapper->preloadColumns($showColumnIds, $filter, $sort); - $wantedRowIdsArray = $this->getWantedRowIds($userId, $tableId, $filter, $sort, $limit, $offset); + $wantedRowIdsArray = $this->getWantedRowIds($userId ?? '', $tableId, $filter, $sort, $limit, $offset, $showColumnIds, $search, $rowIds); // Get rows without SQL sorting $rows = $this->getRows($wantedRowIdsArray, $showColumnIds); @@ -192,6 +201,29 @@ public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, } } + /** + * @param int[] $showColumnIds + * @param int $tableId + * @param array|null $filter + * @param array|null $sort + * @param string|null $search + * @param string|null $userId + * @return int + * @throws InternalError + */ + public function count(array $showColumnIds, int $tableId, ?array $filter = null, ?array $sort = null, ?string $search = null, ?string $userId = null, ?array $rowIds = null): int { + try { + $this->columnMapper->preloadColumns($showColumnIds, $filter, $sort); + + $wantedRowIdsArray = $this->getWantedRowIds($userId ?? '', $tableId, $filter, $sort, null, null, $showColumnIds, $search, $rowIds); + + return count($wantedRowIdsArray); + } catch (DoesNotExistException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + } + /** * @param array $rowIds * @param array $columnIds @@ -486,6 +518,23 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ } $filterExpression = $qb->expr()->like('value', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%', $paramType)); break; + case 'contains-item': + $values = is_array($value) ? $value : [$value]; + if ($column->getType() === 'selection' && $column->getSubtype() === 'multi') { + $filterExpressions = []; + foreach ($values as $singleValue) { + $singleValue = (string)$singleValue; + $filterExpressions[] = $qb2->expr()->like('value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($singleValue) . ']', IQueryBuilder::PARAM_STR)); + $filterExpressions[] = $qb2->expr()->like('value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($singleValue) . ',%')); + $filterExpressions[] = $qb2->expr()->like('value', $qb->createNamedParameter('%,' . $this->db->escapeLikeParameter($singleValue) . ']%')); + $filterExpressions[] = $qb2->expr()->like('value', $qb->createNamedParameter('%,' . $this->db->escapeLikeParameter($singleValue) . ',%')); + } + $filterExpression = $qb2->expr()->orX(...$filterExpressions); + } else { + $filterExpression = $qb2->expr()->in('value', $qb->createNamedParameter($values, IQueryBuilder::PARAM_STR_ARRAY)); + } + $includeDefault = !empty(array_intersect((array)($defaultValue ?? []), $values)); + break; case 'does-not-contain': if (is_array($value) && $column->getType() === Column::TYPE_USERGROUP) { $filterExpressions = []; @@ -593,7 +642,7 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ /** * @throws InternalError */ - private function getMetaFilterExpression(IQueryBuilder $qb, int $columnId, string $operator, string $value): IQueryBuilder { + private function getMetaFilterExpression(IQueryBuilder $qb, int $columnId, string $operator, string|array $value): IQueryBuilder { $qb2 = $this->db->getQueryBuilder(); $qb2->select('id'); $qb2->from('tables_row_sleeves'); @@ -1029,4 +1078,45 @@ private function sortRowsByIds(array $rows, array $wantedRowIds): array { return $sortedRows; } + + private function addSearchToQuery(IQueryBuilder $qb, string $search, array $showColumnIds): void { + $qb->andWhere( + $qb->expr()->in( + 'sleeves.id', + $qb->createFunction($this->getSearchSubquery($qb, $search, $showColumnIds)->getSQL()) + ) + ); + } + + private function getSearchSubquery(IQueryBuilder $qb, string $search, array $showColumnIds): IQueryBuilder { + $searchParam = $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($search) . '%', IQueryBuilder::PARAM_STR, ':search'); + $columnIdsParam = $qb->createNamedParameter($showColumnIds, IQueryBuilder::PARAM_INT_ARRAY, ':searchColumnIds'); + + $qbSqlForColumnTypes = null; + foreach ($this->columnsHelper->columns as $columnType) { + $innerQb = $this->db->getQueryBuilder(); + $innerQb->select('row_id') + ->selectAlias($innerQb->expr()->castColumn('value', IQueryBuilder::PARAM_STR), 'search_value') + ->from('tables_row_cells_' . $columnType) + ->where($innerQb->expr()->in('column_id', $columnIdsParam)); + + $qbTmp = $this->db->getQueryBuilder(); + $qbTmp->select('row_id') + ->from($qbTmp->createFunction('(' . $innerQb->getSQL() . ')'), 't') + ->where($qbTmp->expr()->iLike('t.search_value', $searchParam)); + + if ($qbSqlForColumnTypes) { + $qbSqlForColumnTypes .= ' UNION ALL ' . $qbTmp->getSQL() . ' '; + } else { + $qbSqlForColumnTypes = '(' . $qbTmp->getSQL(); + } + } + $qbSqlForColumnTypes .= ')'; + + $searchQb = $this->db->getQueryBuilder(); + $searchQb->select('row_id') + ->from($qb->createFunction($qbSqlForColumnTypes), 't_search'); + + return $searchQb; + } } diff --git a/lib/Db/RowCellSelectionMapper.php b/lib/Db/RowCellSelectionMapper.php index eba5332f1e..9c7e47045c 100644 --- a/lib/Db/RowCellSelectionMapper.php +++ b/lib/Db/RowCellSelectionMapper.php @@ -22,6 +22,9 @@ public function __construct(IDBConnection $db) { } public function filterValueToQueryParam(Column $column, mixed $value): mixed { + if (is_array($value)) { + return $value; + } return $this->valueToJsonDbValue($column, $value); } diff --git a/lib/Db/RowQuery.php b/lib/Db/RowQuery.php new file mode 100644 index 0000000000..4fe3f73474 --- /dev/null +++ b/lib/Db/RowQuery.php @@ -0,0 +1,177 @@ +nodeType; + } + + public function getNodeId(): int { + return $this->nodeId; + } + + public function getUserId(): ?string { + return $this->userId; + } + + public function setUserId(?string $userId): self { + $this->userId = $userId; + return $this; + } + + public function getLimit(): ?int { + return $this->limit; + } + + public function setLimit(?int $limit): self { + $this->limit = $limit; + return $this; + } + + public function getOffset(): ?int { + return $this->offset; + } + + public function setOffset(?int $offset): self { + $this->offset = $offset; + return $this; + } + + public function getFilter(): ?array { + return $this->filter; + } + + public function setFilter(?array $filter): self { + $this->filter = $filter; + return $this; + } + + public function getSort(): ?array { + return $this->sort; + } + + public function setSort(?array $sort): self { + $this->sort = $sort; + return $this; + } + + public function getSearch(): ?string { + return $this->search; + } + + public function setSearch(?string $search): self { + $this->search = $search; + return $this; + } + + public function getRowIds(): ?array { + return $this->rowIds; + } + + public function setRowIds(?array $rowIds): self { + $this->rowIds = $rowIds; + return $this; + } + + /** + * Build a RowQuery from request parameters. + * + * @param bool $normalizePagination Clamp limit and offset to valid ranges instead of throwing. + * @return self + * @throws InvalidArgumentException + */ + public static function buildFromInput(string $nodeType, int $nodeId, ?int $limit = null, ?int $offset = null, ?string $filter = null, ?string $sort = null, ?string $search = null, ?string $rowIds = null, bool $normalizePagination = false, string $userId = ''): self { + if ($normalizePagination) { + $limit = $limit !== null ? max(0, min(500, $limit)) : null; + $offset = $offset !== null ? max(0, $offset) : null; + } + + $rowQuery = new self(ConversionHelper::stringNodeType2Const($nodeType), $nodeId); + $rowQuery->setLimit($limit) + ->setOffset($offset) + ->setFilter(self::parseFilter($filter)) + ->setSort(self::parseSort($sort)) + ->setSearch($search !== '' && $search !== null ? $search : null) + ->setRowIds(self::parseRowIds($rowIds)) + ->setUserId($userId); + return $rowQuery; + } + + /** + * Decode and validate the JSON encoded row ID list. + * + * @return list|null + * @throws InvalidArgumentException + */ + private static function parseRowIds(?string $rowIds): ?array { + if ($rowIds === null || $rowIds === '') { + return null; + } + $decoded = json_decode($rowIds, true); + if (!is_array($decoded) || array_filter($decoded, 'is_int') !== $decoded) { + throw new InvalidArgumentException('Invalid row IDs supplied'); + } + return array_map('intval', $decoded); + } + + /** + * Decode and validate the JSON encoded filter parameter. + * + * @return list}>>|null + * @throws InvalidArgumentException + */ + private static function parseFilter(?string $filter): ?array { + if ($filter === null || $filter === '') { + return null; + } + $decoded = json_decode($filter, true); + if (!is_array($decoded)) { + throw new InvalidArgumentException('Invalid filter supplied'); + } + return FilterSet::createFromInputArray($decoded)->jsonSerialize(); + } + + /** + * Decode and validate the JSON encoded sort parameter. + * + * @return list|null + * @throws InvalidArgumentException + */ + private static function parseSort(?string $sort): ?array { + if ($sort === null || $sort === '') { + return null; + } + $decoded = json_decode($sort, true); + if (!is_array($decoded)) { + throw new InvalidArgumentException('Invalid sort data supplied'); + } + return SortRuleSet::createFromInputArray($decoded)->jsonSerialize(); + } +} diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 252e428118..7130edb1b6 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -8,10 +8,12 @@ namespace OCA\Tables\Service; use OCA\Tables\Activity\ActivityManager; +use OCA\Tables\AppInfo\Application; use OCA\Tables\Db\Column; use OCA\Tables\Db\ColumnMapper; use OCA\Tables\Db\Row2; use OCA\Tables\Db\Row2Mapper; +use OCA\Tables\Db\RowQuery; use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; use OCA\Tables\Db\View; @@ -85,6 +87,212 @@ public function formatRowsForPublicShare(array $rows): array { }, $rows); } + /** + * Export all matching rows as a CSV string. + * + * @param RowQuery $rowQuery + * @return string + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws InternalError + * @throws NotFoundError + */ + public function exportCsv(RowQuery $rowQuery): string { + $tableId = $rowQuery->getNodeId(); + $showColumnIds = []; + $filter = $rowQuery->getFilter(); + $sort = $rowQuery->getSort(); + $search = $rowQuery->getSearch(); + + if ($rowQuery->getNodeType() === Application::NODE_TYPE_VIEW) { + $view = $this->viewMapper->find($rowQuery->getNodeId()); + $tableId = $view->getTableId(); + $showColumnIds = $view->getColumnIds(); + $filter = $this->mergeFilterWithViewFilter($filter, $view->getFilterArray()); + if ($sort === null) { + $sort = $view->getSortArray(); + } + } else { + $showColumnIds = array_map(static fn (Column $column) => $column->getId(), $this->columnMapper->findAllByTable($tableId)); + } + + $columns = $this->columnMapper->findAll($showColumnIds); + $columnsById = []; + foreach ($columns as $column) { + $columnsById[$column->getId()] = $column; + } + + $orderedColumns = array_map(static fn (int $columnId) => $columnsById[$columnId], $showColumnIds); + + $rows = $this->row2Mapper->findAll( + $showColumnIds, + $tableId, + null, + null, + $filter, + $sort, + $rowQuery->getUserId() ?? '', + $search, + $rowQuery->getRowIds(), + ); + + return $this->buildCsv($orderedColumns, $rows); + } + + /** + * @param Column[] $columns + * @param Row2[] $rows + */ + private function buildCsv(array $columns, array $rows): string { + $handle = fopen('php://temp', 'r+'); + if ($handle === false) { + throw new InternalError('Could not create CSV buffer.'); + } + + $headers = ['ID']; + foreach ($columns as $column) { + $headers[] = $column->getTitle(); + } + fputcsv($handle, $headers); + + foreach ($rows as $row) { + $cellValues = []; + foreach ($row->getData() as $cell) { + $cellValues[(int)$cell['columnId']] = $cell['value']; + } + + $line = [(string)$row->getId()]; + foreach ($columns as $column) { + $value = $cellValues[$column->getId()] ?? ''; + if ($value === null) { + $value = ''; + } elseif (!is_string($value)) { + $value = json_encode($value); + } + $line[] = $value; + } + fputcsv($handle, $line); + } + + rewind($handle); + $csv = stream_get_contents($handle); + fclose($handle); + return $csv ?: ''; + } + + /** + * Fetch rows for a table or view, applying the given filter and sort rules. + * + * When reading from a view, the provided filter is added to each of the + * view's filter groups so that the view's base rules are always enforced. + * A provided sort order overrides the view's default sort order; the view + * default is only used when no sort order is given. + * + * @return Row2[] + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws InternalError + */ + public function findAllByQuery(RowQuery $rowQuery): array { + $tableId = $rowQuery->getNodeId(); + $userId = $rowQuery->getUserId() ?? ''; + $filter = $rowQuery->getFilter(); + $sort = $rowQuery->getSort(); + $search = $rowQuery->getSearch(); + + if ($rowQuery->getNodeType() === Application::NODE_TYPE_VIEW) { + $view = $this->viewMapper->find($rowQuery->getNodeId()); + $tableId = $view->getTableId(); + $showColumnIds = $view->getColumnIds(); + + $filter = $this->mergeFilterWithViewFilter($filter, $view->getFilterArray()); + + if ($sort === null) { + $sort = $view->getSortArray(); + } + + $userId = $this->resolveFilterUserId($userId, $view); + } else { + $tableColumns = $this->columnMapper->findAllByTable($tableId); + $showColumnIds = array_map(static fn (Column $column) => $column->getId(), $tableColumns); + } + + return $this->row2Mapper->findAll( + $showColumnIds, + $tableId, + $rowQuery->getLimit(), + $rowQuery->getOffset(), + $filter, + $sort, + $userId, + $search, + $rowQuery->getRowIds(), + ); + } + + public function countByQuery(RowQuery $rowQuery): int { + $tableId = $rowQuery->getNodeId(); + $userId = $rowQuery->getUserId() ?? ''; + $filter = $rowQuery->getFilter(); + $sort = $rowQuery->getSort(); + $search = $rowQuery->getSearch(); + + if ($rowQuery->getNodeType() === Application::NODE_TYPE_VIEW) { + $view = $this->viewMapper->find($rowQuery->getNodeId()); + $tableId = $view->getTableId(); + $showColumnIds = $view->getColumnIds(); + + $filter = $this->mergeFilterWithViewFilter($filter, $view->getFilterArray()); + + if ($sort === null) { + $sort = $view->getSortArray(); + } + + $userId = $this->resolveFilterUserId($userId, $view); + } else { + $tableColumns = $this->columnMapper->findAllByTable($tableId); + $showColumnIds = array_map(static fn (Column $column) => $column->getId(), $tableColumns); + } + + return $this->row2Mapper->count( + $showColumnIds, + $tableId, + $filter, + $sort, + $search, + $userId, + $rowQuery->getRowIds(), + ); + } + + /** + * Combine a user supplied filter with a view's base filter. + * + * A filter is a list of OR-connected groups, each group being a list of + * AND-connected conditions. To enforce the view's rules while also applying + * the user's filter, the user's conditions are appended to every base + * group, resulting in (group AND userFilter) OR ... When the view has no + * base filter, the user's filter is used as-is. + * + * @param list>|null $filter + * @param list> $viewFilter + * @return list> + */ + private function mergeFilterWithViewFilter(?array $filter, array $viewFilter): array { + if ($filter === null || $filter === []) { + return $viewFilter; + } + if ($viewFilter === []) { + return $filter; + } + $userConditions = array_merge(...$filter); + $merged = []; + foreach ($viewFilter as $group) { + $merged[] = array_merge($group, $userConditions); + } + return $merged; + } + /** * @param int $tableId * @param string $userId diff --git a/lib/Service/ValueObject/Filter.php b/lib/Service/ValueObject/Filter.php index 08c476241e..9194c90471 100644 --- a/lib/Service/ValueObject/Filter.php +++ b/lib/Service/ValueObject/Filter.php @@ -16,7 +16,7 @@ class Filter implements JsonSerializable { public function __construct( protected readonly int $columnId, protected readonly FilterOperator $operator, - protected readonly string $value, + protected readonly string|array $value, ) { } diff --git a/openapi.json b/openapi.json index 0234b5c0f6..4dd6e87176 100644 --- a/openapi.json +++ b/openapi.json @@ -12695,14 +12695,922 @@ } } }, - "/ocs/v2.php/apps/tables/api/2/{nodeCollection}/{nodeId}/rows": { + "/ocs/v2.php/apps/tables/api/2/config/table/{id}": { + "get": { + "operationId": "config-get-table-config", + "summary": "Gets the config for a specific table", + "tags": [ + "config" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Table id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Table config returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/NotifyConfig" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/config/view/{id}": { + "get": { + "operationId": "config-get-view-config", + "summary": "Gets the config for a specific view", + "tags": [ + "config" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "View id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "View config returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/NotifyConfig" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/config/{key}": { + "post": { + "operationId": "config-set-value", + "summary": "Sets a config value for a specific key", + "tags": [ + "config" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "object", + "description": "Config value" + } + } + } + } + } + }, + "parameters": [ + { + "name": "key", + "in": "path", + "description": "Config key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Config updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "400": { + "description": "bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/public/{token}/columns": { + "get": { + "operationId": "api_public_columns-index-by-public-link", + "summary": "[api v2] Get all columns for a table or a view shared by link", + "description": "Return an empty array if no columns were found", + "tags": [ + "api_public_columns" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "token", + "in": "path", + "description": "The share token", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-zA-Z0-9]{16}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Columns are returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicColumn" + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/public/{token}/rows": { + "get": { + "operationId": "public_rowocs-get-rows", + "summary": "[api v2] Fetch all rows from a link share", + "tags": [ + "public_rowocs" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "token", + "in": "path", + "description": "The share token", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-zA-Z0-9]{16}$" + } + }, + { + "name": "limit", + "in": "query", + "description": "Number of rows to return between 1 and 500, fetches all by default (optional)", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 1, + "maximum": 500 + } + }, + { + "name": "offset", + "in": "query", + "description": "Offset of the rows to be returned (optional)", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + } + }, + { + "name": "filter", + "in": "query", + "description": "Optional: a JSON encoded filter parameter", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "sort", + "in": "query", + "description": "Optional: a JSON encoded sort parameter", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "search", + "in": "query", + "description": "Optional: a search string", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "rowIds", + "in": "query", + "description": "Optional: a JSON encoded list of row IDs", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Rows are returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicRow" + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, "post": { - "operationId": "rowocs-create-row", - "summary": "[api v2] Create a new row in a table or a view", + "operationId": "public_rowocs-create-row", + "summary": "[api v2] Create a row in a link share", "tags": [ - "rowocs" + "public_rowocs" ], "security": [ + {}, { "bearer_auth": [] }, @@ -12741,27 +13649,13 @@ }, "parameters": [ { - "name": "nodeCollection", + "name": "token", "in": "path", - "description": "Indicates whether to create a row on a table or view", + "description": "The share token", "required": true, "schema": { "type": "string", - "enum": [ - "tables", - "views" - ], - "pattern": "^(tables|views)$" - } - }, - { - "name": "nodeId", - "in": "path", - "description": "The identifier of the targeted table or view", - "required": true, - "schema": { - "type": "integer", - "format": "int64" + "pattern": "^[a-zA-Z0-9]{16}$" } }, { @@ -12777,7 +13671,7 @@ ], "responses": { "200": { - "description": "Row returned", + "description": "Row created", "content": { "application/json": { "schema": { @@ -12797,7 +13691,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/Row" + "$ref": "#/components/schemas/PublicRow" } } } @@ -12957,9 +13851,81 @@ } } } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/public/{token}/rows/count": { + "get": { + "operationId": "public_rowocs-count-rows", + "summary": "[api v2] Count rows from a link share", + "tags": [ + "public_rowocs" + ], + "security": [ + {}, + { + "bearer_auth": [] }, - "401": { - "description": "Current user is not logged in", + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "token", + "in": "path", + "description": "The share token", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-zA-Z0-9]{16}$" + } + }, + { + "name": "filter", + "in": "query", + "description": "Optional: a JSON encoded filter parameter", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "sort", + "in": "query", + "description": "Optional: a JSON encoded sort parameter", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "search", + "in": "query", + "description": "Optional: a search string", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Count is returned", "content": { "application/json": { "schema": { @@ -12978,7 +13944,170 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } } } } @@ -12989,14 +14118,15 @@ } } }, - "/ocs/v2.php/apps/tables/api/2/config/table/{id}": { + "/ocs/v2.php/apps/tables/api/2/public/{token}/rows/export": { "get": { - "operationId": "config-get-table-config", - "summary": "Gets the config for a specific table", + "operationId": "public_rowocs-export-rows", + "summary": "[api v2] Export all rows from a link share as a CSV file", "tags": [ - "config" + "public_rowocs" ], "security": [ + {}, { "bearer_auth": [] }, @@ -13006,13 +14136,53 @@ ], "parameters": [ { - "name": "id", + "name": "token", "in": "path", - "description": "Table id", + "description": "The share token", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "pattern": "^[a-zA-Z0-9]{16}$" + } + }, + { + "name": "filter", + "in": "query", + "description": "Optional: a JSON encoded filter parameter", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "sort", + "in": "query", + "description": "Optional: a JSON encoded sort parameter", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "search", + "in": "query", + "description": "Optional: a search string", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "rowIds", + "in": "query", + "description": "Optional: a JSON encoded list of row IDs to export", + "schema": { + "type": "string", + "nullable": true, + "default": null } }, { @@ -13028,7 +14198,18 @@ ], "responses": { "200": { - "description": "Table config returned", + "description": "CSV file is returned", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "403": { + "description": "No permissions", "content": { "application/json": { "schema": { @@ -13048,7 +14229,15 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/NotifyConfig" + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } } } } @@ -13057,8 +14246,8 @@ } } }, - "401": { - "description": "Current user is not logged in", + "400": { + "description": "Invalid request parameters", "content": { "application/json": { "schema": { @@ -13077,57 +14266,26 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } } } } } } } - } - } - } - }, - "/ocs/v2.php/apps/tables/api/2/config/view/{id}": { - "get": { - "operationId": "config-get-view-config", - "summary": "Gets the config for a specific view", - "tags": [ - "config" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "id", - "in": "path", - "description": "View id", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } }, - { - "name": "OCS-APIRequest", - "in": "header", - "description": "Required to be true for the API request to pass", - "required": true, - "schema": { - "type": "boolean", - "default": true - } - } - ], - "responses": { - "200": { - "description": "View config returned", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -13147,7 +14305,15 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/NotifyConfig" + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } } } } @@ -13156,8 +14322,8 @@ } } }, - "401": { - "description": "Current user is not logged in", + "500": { + "description": "Internal error", "content": { "application/json": { "schema": { @@ -13176,7 +14342,17 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } } } } @@ -13187,14 +14363,15 @@ } } }, - "/ocs/v2.php/apps/tables/api/2/config/{key}": { - "post": { - "operationId": "config-set-value", - "summary": "Sets a config value for a specific key", + "/ocs/v2.php/apps/tables/api/2/public/{token}/rows/{rowId}": { + "put": { + "operationId": "public_rowocs-update-row", + "summary": "[api v2] Update a row in a link share", "tags": [ - "config" + "public_rowocs" ], "security": [ + {}, { "bearer_auth": [] }, @@ -13209,12 +14386,22 @@ "schema": { "type": "object", "required": [ - "value" + "data" ], "properties": { - "value": { - "type": "object", - "description": "Config value" + "data": { + "description": "An array containing the column identifiers and their values", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] } } } @@ -13223,12 +14410,23 @@ }, "parameters": [ { - "name": "key", + "name": "token", "in": "path", - "description": "Config key", + "description": "The share token", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[a-zA-Z0-9]{16}$" + } + }, + { + "name": "rowId", + "in": "path", + "description": "The row identifier", + "required": true, + "schema": { + "type": "integer", + "format": "int64" } }, { @@ -13244,7 +14442,7 @@ ], "responses": { "200": { - "description": "Config updated", + "description": "Row updated", "content": { "application/json": { "schema": { @@ -13264,7 +14462,45 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "boolean" + "$ref": "#/components/schemas/PublicRow" + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } } } } @@ -13274,7 +14510,7 @@ } }, "400": { - "description": "bad request", + "description": "Invalid request parameters", "content": { "application/json": { "schema": { @@ -13311,8 +14547,8 @@ } } }, - "403": { - "description": "No permissions", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -13349,8 +14585,8 @@ } } }, - "401": { - "description": "Current user is not logged in", + "500": { + "description": "Internal error", "content": { "application/json": { "schema": { @@ -13369,7 +14605,17 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } } } } @@ -13378,15 +14624,12 @@ } } } - } - }, - "/ocs/v2.php/apps/tables/api/2/public/{token}/columns": { - "get": { - "operationId": "api_public_columns-index-by-public-link", - "summary": "[api v2] Get all columns for a table or a view shared by link", - "description": "Return an empty array if no columns were found", + }, + "delete": { + "operationId": "public_rowocs-delete-row", + "summary": "[api v2] Delete a row in a link share", "tags": [ - "api_public_columns" + "public_rowocs" ], "security": [ {}, @@ -13408,6 +14651,16 @@ "pattern": "^[a-zA-Z0-9]{16}$" } }, + { + "name": "rowId", + "in": "path", + "description": "The row identifier", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -13421,7 +14674,7 @@ ], "responses": { "200": { - "description": "Columns are returned", + "description": "Row deleted", "content": { "application/json": { "schema": { @@ -13441,10 +14694,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicColumn" - } + "$ref": "#/components/schemas/PublicRow" } } } @@ -13491,44 +14741,6 @@ } } }, - "400": { - "description": "Invalid request parameters", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { - "type": "object", - "required": [ - "meta", - "data" - ], - "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string" - } - } - } - } - } - } - } - } - } - }, "404": { "description": "Not found", "content": { @@ -13608,15 +14820,14 @@ } } }, - "/ocs/v2.php/apps/tables/api/2/public/{token}/rows": { - "get": { - "operationId": "public_rowocs-get-rows", - "summary": "[api v2] Fetch all rows from a link share", + "/ocs/v2.php/apps/tables/api/2/{nodeCollection}/{nodeId}/rows": { + "post": { + "operationId": "rowocs-create-row", + "summary": "[api v2] Create a new row in a table or a view", "tags": [ - "public_rowocs" + "rowocs" ], "security": [ - {}, { "bearer_auth": [] }, @@ -13624,35 +14835,58 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "description": "An array containing the column identifiers and their values", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + } + } + } + } + } + }, "parameters": [ { - "name": "token", + "name": "nodeCollection", "in": "path", - "description": "The share token", + "description": "Indicates whether to create a row on a table or view", "required": true, "schema": { "type": "string", - "pattern": "^[a-zA-Z0-9]{16}$" - } - }, - { - "name": "limit", - "in": "query", - "description": "Optional: maximum number of results, capped at 500", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true + "enum": [ + "tables", + "views" + ], + "pattern": "^(tables|views)$" } }, { - "name": "offset", - "in": "query", - "description": "Optional: the offset for this operation", + "name": "nodeId", + "in": "path", + "description": "The identifier of the targeted table or view", + "required": true, "schema": { "type": "integer", - "format": "int64", - "nullable": true + "format": "int64" } }, { @@ -13668,7 +14902,7 @@ ], "responses": { "200": { - "description": "Rows are returned", + "description": "Row returned", "content": { "application/json": { "schema": { @@ -13688,10 +14922,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicRow" - } + "$ref": "#/components/schemas/Row" } } } @@ -13851,17 +15082,45 @@ } } } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } } } }, - "post": { - "operationId": "public_rowocs-create-row", - "summary": "[api v2] Create a row in a link share", + "get": { + "operationId": "rowocs-get-rows", + "summary": "[api v2] Get a number of rows from a table or view", + "description": "Both `filter` and `sort` are passed as JSON encoded strings.\nThe filter is a list of filter groups, each group being a list of single filter definitions. Definitions within a group are AND-connected, while the groups themselves are OR-connected.\nWhen reading from a view, the provided filter is added to each of the view's existing filter groups, so the view's base rules are always enforced.\nA provided sort order overrides the view's default sort order. The view's default sort order is only used when no sort order is provided.", "tags": [ - "public_rowocs" + "rowocs" ], "security": [ - {}, { "bearer_auth": [] }, @@ -13869,44 +15128,95 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "description": "An array containing the column identifiers and their values", - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": { - "type": "object" - } - } - ] - } - } - } + "parameters": [ + { + "name": "nodeCollection", + "in": "path", + "description": "Indicates whether to read from a table or a view", + "required": true, + "schema": { + "type": "string", + "enum": [ + "tables", + "views" + ], + "pattern": "^(tables|views)$" + } + }, + { + "name": "nodeId", + "in": "path", + "description": "The ID of the table or view", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Number of rows to return between 1 and 500, fetches all by default (optional)", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "minimum": 1, + "maximum": 500 + } + }, + { + "name": "offset", + "in": "query", + "description": "Offset of the rows to be returned (optional)", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "minimum": 0 + } + }, + { + "name": "filter", + "in": "query", + "description": "JSON encoded list of filter groups. Definitions within a group are AND-connected, groups are OR-connected, e.g. `[[{\"columnId\":1,\"operator\":\"contains\",\"value\":\"foo\"}]]` (optional)", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "sort", + "in": "query", + "description": "JSON encoded list of sort rules, e.g. `[{\"columnId\":1,\"mode\":\"ASC\"}]` (optional)", + "schema": { + "type": "string", + "nullable": true, + "default": null } - } - }, - "parameters": [ + }, { - "name": "token", - "in": "path", - "description": "The share token", - "required": true, + "name": "search", + "in": "query", + "description": "Search string (optional)", "schema": { "type": "string", - "pattern": "^[a-zA-Z0-9]{16}$" + "nullable": true, + "default": null + } + }, + { + "name": "rowIds", + "in": "query", + "description": "JSON encoded list of row IDs (optional)", + "schema": { + "type": "string", + "nullable": true, + "default": null } }, { @@ -13922,7 +15232,7 @@ ], "responses": { "200": { - "description": "Row created", + "description": "Rows returned", "content": { "application/json": { "schema": { @@ -13942,7 +15252,10 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/PublicRow" + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } } } } @@ -14102,19 +15415,46 @@ } } } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } } } } }, - "/ocs/v2.php/apps/tables/api/2/public/{token}/rows/{rowId}": { - "put": { - "operationId": "public_rowocs-update-row", - "summary": "[api v2] Update a row in a link share", + "/ocs/v2.php/apps/tables/api/2/{nodeCollection}/{nodeId}/rows/count": { + "get": { + "operationId": "rowocs-count-rows", + "summary": "[api v2] Count rows from a table or view", "tags": [ - "public_rowocs" + "rowocs" ], "security": [ - {}, { "bearer_auth": [] }, @@ -14122,54 +15462,60 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "description": "An array containing the column identifiers and their values", - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": { - "type": "object" - } - } - ] - } - } - } - } - } - }, "parameters": [ { - "name": "token", + "name": "nodeCollection", "in": "path", - "description": "The share token", + "description": "Indicates whether to read from a table or a view", "required": true, "schema": { "type": "string", - "pattern": "^[a-zA-Z0-9]{16}$" + "enum": [ + "tables", + "views" + ], + "pattern": "^(tables|views)$" } }, { - "name": "rowId", + "name": "nodeId", "in": "path", - "description": "The row identifier", + "description": "The ID of the table or view", "required": true, "schema": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 0 + } + }, + { + "name": "filter", + "in": "query", + "description": "Optional: a JSON encoded filter parameter", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "sort", + "in": "query", + "description": "Optional: a JSON encoded sort parameter", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "search", + "in": "query", + "description": "Optional: a search string", + "schema": { + "type": "string", + "nullable": true, + "default": null } }, { @@ -14185,7 +15531,7 @@ ], "responses": { "200": { - "description": "Row updated", + "description": "Count is returned", "content": { "application/json": { "schema": { @@ -14205,7 +15551,16 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/PublicRow" + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "format": "int64" + } + } } } } @@ -14365,17 +15720,46 @@ } } } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } } } - }, - "delete": { - "operationId": "public_rowocs-delete-row", - "summary": "[api v2] Delete a row in a link share", + } + }, + "/ocs/v2.php/apps/tables/api/2/{nodeCollection}/{nodeId}/rows/export": { + "get": { + "operationId": "rowocs-export-rows", + "summary": "[api v2] Export all rows from a table or view as a CSV file", "tags": [ - "public_rowocs" + "rowocs" ], "security": [ - {}, { "bearer_auth": [] }, @@ -14385,25 +15769,65 @@ ], "parameters": [ { - "name": "token", + "name": "nodeCollection", "in": "path", - "description": "The share token", + "description": "'tables' or 'views'", "required": true, "schema": { "type": "string", - "pattern": "^[a-zA-Z0-9]{16}$" + "pattern": "^(tables|views)$" } }, { - "name": "rowId", + "name": "nodeId", "in": "path", - "description": "The row identifier", + "description": "The table or view ID", "required": true, "schema": { "type": "integer", "format": "int64" } }, + { + "name": "filter", + "in": "query", + "description": "JSON encoded list of filter groups (optional)", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "sort", + "in": "query", + "description": "JSON encoded list of sort rules (optional)", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "search", + "in": "query", + "description": "Search string (optional)", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "rowIds", + "in": "query", + "description": "JSON encoded list of row IDs to export (optional)", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -14417,7 +15841,18 @@ ], "responses": { "200": { - "description": "Row deleted", + "description": "CSV file is returned", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "403": { + "description": "No permissions", "content": { "application/json": { "schema": { @@ -14437,7 +15872,15 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/PublicRow" + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } } } } @@ -14446,8 +15889,8 @@ } } }, - "403": { - "description": "No permissions", + "400": { + "description": "Invalid request parameters", "content": { "application/json": { "schema": { @@ -14559,6 +16002,34 @@ } } } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } } } } diff --git a/package.json b/package.json index 40ba34f4e3..6bbf233287 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "@vueuse/core": "^12.8.2", "debounce": "^3.0.0", "dompurify": "^3.4.13", - "papaparse": "^5.6.0", "pinia": "^2.3.1", "vue": "^3.5.13", "vue-material-design-icons": "^5.3.1", diff --git a/src/modules/main/partials/TableView.vue b/src/modules/main/partials/TableView.vue index 3b99b004e3..87fe886a8e 100644 --- a/src/modules/main/partials/TableView.vue +++ b/src/modules/main/partials/TableView.vue @@ -6,6 +6,7 @@ [], }, + rowsCount: { + type: Number, + default: null, + }, columns: { type: Array, default: () => [], diff --git a/src/modules/main/sections/DataTable.vue b/src/modules/main/sections/DataTable.vue index c72f8d7734..57db538f9b 100644 --- a/src/modules/main/sections/DataTable.vue +++ b/src/modules/main/sections/DataTable.vue @@ -77,6 +77,7 @@ v-model:view-setting="localViewSetting" v-model:selected-rows="localSelectedRows" :rows="rows" + :rows-count="rowsCount" :columns="columns" :element="table" :is-view="false" @@ -212,6 +213,10 @@ export default { type: Array, default: null, }, + rowsCount: { + type: Number, + default: null, + }, viewSetting: { type: Object, default: null, @@ -250,7 +255,22 @@ export default { return this.views.some(v => v.tableId === this.table.id) }, isViewSettingSet() { - return !(!this.localViewSetting || ((!this.localViewSetting.hiddenColumns || this.localViewSetting.hiddenColumns.length === 0) && (!this.localViewSetting.sorting) && (!this.localViewSetting.filter || this.localViewSetting.filter.length === 0))) + if (!this.localViewSetting) { + return false + } + if (this.localViewSetting.hiddenColumns?.length > 0) { + return true + } + if (this.localViewSetting.searchString) { + return true + } + if (this.localViewSetting.sorting?.some(rule => rule?.columnId)) { + return true + } + if (this.localViewSetting.filter?.some(rule => rule?.columnId)) { + return true + } + return false }, }, diff --git a/src/modules/main/sections/ElementTitle.vue b/src/modules/main/sections/ElementTitle.vue index e8c30e9334..62f67391a7 100644 --- a/src/modules/main/sections/ElementTitle.vue +++ b/src/modules/main/sections/ElementTitle.vue @@ -74,7 +74,22 @@ export default { }, isViewSettingSet() { - return !(!this.viewSetting || ((!this.viewSetting.hiddenColumns || this.viewSetting.hiddenColumns.length === 0) && (!this.viewSetting.sorting) && (!this.viewSetting.filter || this.viewSetting.filter.length === 0))) + if (!this.viewSetting) { + return false + } + if (this.viewSetting.hiddenColumns?.length > 0) { + return true + } + if (this.viewSetting.searchString) { + return true + } + if (this.viewSetting.sorting?.some(rule => rule?.columnId)) { + return true + } + if (this.viewSetting.filter?.some(rule => rule?.columnId)) { + return true + } + return false }, }, diff --git a/src/modules/main/sections/MainWrapper.vue b/src/modules/main/sections/MainWrapper.vue index a0d60f6763..f91eeb75f2 100644 --- a/src/modules/main/sections/MainWrapper.vue +++ b/src/modules/main/sections/MainWrapper.vue @@ -11,7 +11,9 @@ :view="element" :columns="columns" :rows="rows" + :rows-count="rowsCount" :view-setting="viewSetting" + @update:viewSetting="viewSetting = $event" @create-column="createColumn" @import="openImportModal" @download-csv="downloadCSV" @@ -22,7 +24,9 @@ :table="element" :columns="columns" :rows="rows" + :rows-count="rowsCount" :view-setting="viewSetting" + @update:viewSetting="viewSetting = $event" @create-column="createColumn" @import="openImportModal" @download-csv="downloadCSV" @@ -36,7 +40,7 @@ diff --git a/src/modules/main/sections/PublicMainWrapper.vue b/src/modules/main/sections/PublicMainWrapper.vue index 53bd3cc764..b930d543b4 100644 --- a/src/modules/main/sections/PublicMainWrapper.vue +++ b/src/modules/main/sections/PublicMainWrapper.vue @@ -7,7 +7,7 @@
- +
@@ -19,7 +19,9 @@ import exportTableMixin from '../../../shared/components/ncTable/mixins/exportTa import { useDataStore } from '../../../store/data.js' import { useTablesStore } from '../../../store/store.js' import { computed } from 'vue' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' +import { buildUrlQuery, parseUrlQuery } from '../../../shared/utils/urlState.js' import { showError } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' @@ -44,18 +46,30 @@ export default { setup(props) { const store = useDataStore() - const { getColumns, getRows } = storeToRefs(store) + const { getColumns, getRows, getRowsCount } = storeToRefs(store) const stateKey = 'public-' + props.token const rows = computed(() => getRows.value(false, stateKey)) const columns = computed(() => getColumns.value(false, stateKey)) + const rowsCount = computed(() => getRowsCount.value(false, stateKey)) - return { rows, columns } + return { rows, columns, rowsCount, dataStore: store } }, data() { return { loading: false, + viewSetting: {}, + lastViewSettingFilter: null, + lastViewSettingSorting: null, + lastViewSettingSearchString: null, + rowsPerPage: 100, + pageNumber: 1, + paginationOffset: 0, + rowsLoading: false, + viewSettingInProgress: false, + applyUrlStateOnReload: false, + urlRowIds: null, publicElement: { id: 'public', emoji: nodeData.emoji, @@ -73,26 +87,197 @@ export default { } }, + watch: { + viewSetting: { + handler() { + const newFilter = this.viewSetting?.filter ? JSON.stringify(this.viewSetting.filter) : null + const newSorting = this.viewSetting?.sorting ? JSON.stringify(this.viewSetting.sorting) : null + const newSearchString = this.viewSetting?.searchString || null + if (newFilter === this.lastViewSettingFilter && newSorting === this.lastViewSettingSorting && newSearchString === this.lastViewSettingSearchString) { + return + } + const oldFilter = this.lastViewSettingFilter + const oldSorting = this.lastViewSettingSorting + const oldSearchString = this.lastViewSettingSearchString + this.lastViewSettingFilter = newFilter + this.lastViewSettingSorting = newSorting + this.lastViewSettingSearchString = newSearchString + this.onViewSettingChanged(oldFilter, oldSorting, oldSearchString) + }, + deep: true, + }, + }, + beforeMount() { this.setPublicToken(this.token) - this.loadData() + this.applyUrlStateOnReload = true + this.reload() + }, + + mounted() { + subscribe('tables:pagination-changed', this.onPaginationChanged) + }, + + beforeUnmount() { + unsubscribe('tables:pagination-changed', this.onPaginationChanged) }, methods: { - ...mapActions(useDataStore, ['loadPublicColumnsFromBE', 'loadPublicRowsFromBE', 'setPublicToken']), + ...mapActions(useDataStore, ['loadPublicColumnsFromBE', 'loadPublicRowsFromBE', 'loadPublicRowsCountFromBE', 'loadPublicRowsForExportFromBE', 'setPublicToken']), ...mapActions(useTablesStore, ['validatePublicExportAccess']), - async loadData() { + async reload() { + if (!this.token) { + return + } + this.loading = true + + if (this.applyUrlStateOnReload) { + this.applyUrlStateOnReload = false + this.applyUrlState() + } else { + this.viewSetting = {} + this.pageNumber = 1 + this.paginationOffset = 0 + } + + await this.loadPublicColumnsFromBE({ token: this.token }) + + this.rowsLoading = true try { - await Promise.all([ - this.loadPublicColumnsFromBE({ token: this.token }), - this.loadPublicRowsFromBE({ token: this.token }), - ]) - } catch (e) { - console.error('Error loading public data', e) + await this.loadPublicRowsCountFromBE({ + token: this.token, + filter: this.viewSetting?.filter, + sort: this.viewSetting?.sorting, + search: this.viewSetting?.searchString, + rowIds: this.urlRowIds, + }) + await this.loadPublicRowsFromBE({ + token: this.token, + filter: this.viewSetting?.filter, + sort: this.viewSetting?.sorting, + search: this.viewSetting?.searchString, + limit: this.rowsPerPage, + offset: this.paginationOffset, + rowIds: this.urlRowIds, + }) } finally { - this.loading = false + this.rowsLoading = false + } + + this.loading = false + this.$nextTick(() => { + emit('tables:pagination-changed', { pageNumber: this.pageNumber, rowsPerPage: this.rowsPerPage }) + }) + }, + + applyUrlState() { + const { filter, sorting, searchString, pageNumber, rowsPerPage, rowIds } = parseUrlQuery(this.$route.query) + this.pageNumber = pageNumber + this.rowsPerPage = rowsPerPage + this.paginationOffset = (this.pageNumber - 1) * this.rowsPerPage + this.urlRowIds = rowIds + const viewSetting = { + filter, + sorting, + searchString, + } + this.lastViewSettingFilter = viewSetting?.filter ? JSON.stringify(viewSetting.filter) : null + this.lastViewSettingSorting = viewSetting?.sorting ? JSON.stringify(viewSetting.sorting) : null + this.lastViewSettingSearchString = viewSetting?.searchString || null + this.viewSetting = viewSetting + }, + + updateUrlFromState() { + const query = buildUrlQuery(this.viewSetting, this.pageNumber, this.rowsPerPage, this.urlRowIds) + this.$router.replace({ query }).catch(() => {}) + }, + + async onViewSettingChanged(oldFilter, oldSorting, oldSearchString) { + if (this.loading || this.rowsLoading) { + return + } + const filterChanged = this.lastViewSettingFilter !== oldFilter + const sortingChanged = this.lastViewSettingSorting !== oldSorting + const searchStringChanged = this.lastViewSettingSearchString !== oldSearchString + if (!filterChanged && !sortingChanged && !searchStringChanged) { + return + } + this.viewSettingInProgress = filterChanged || searchStringChanged + + if (!this.viewSetting?.filter?.length && !this.viewSetting?.sorting?.length && !this.viewSetting?.searchString) { + this.urlRowIds = null + } + + this.rowsLoading = true + try { + if (filterChanged || searchStringChanged) { + this.pageNumber = 1 + this.paginationOffset = 0 + emit('tables:pagination-changed', { pageNumber: 1, rowsPerPage: this.rowsPerPage }) + await this.loadPublicRowsCountFromBE({ + token: this.token, + filter: this.viewSetting?.filter, + sort: this.viewSetting?.sorting, + search: this.viewSetting?.searchString, + rowIds: this.urlRowIds, + }) + await this.loadPublicRowsFromBE({ + token: this.token, + filter: this.viewSetting?.filter, + sort: this.viewSetting?.sorting, + search: this.viewSetting?.searchString, + limit: this.rowsPerPage, + offset: this.paginationOffset, + rowIds: this.urlRowIds, + }) + } else if (sortingChanged) { + this.paginationOffset = (this.pageNumber - 1) * this.rowsPerPage + await this.loadPublicRowsFromBE({ + token: this.token, + filter: this.viewSetting?.filter, + sort: this.viewSetting?.sorting, + search: this.viewSetting?.searchString, + limit: this.rowsPerPage, + offset: this.paginationOffset, + rowIds: this.urlRowIds, + }) + } + } finally { + this.rowsLoading = false + this.viewSettingInProgress = false + this.updateUrlFromState() + } + }, + + async onPaginationChanged({ pageNumber, rowsPerPage }) { + if (this.loading || this.viewSettingInProgress || this.rowsLoading) { + return + } + if (this.pageNumber === pageNumber && this.rowsPerPage === rowsPerPage) { + return + } + this.pageNumber = pageNumber + if (rowsPerPage) { + this.rowsPerPage = rowsPerPage + } + this.paginationOffset = (this.pageNumber - 1) * this.rowsPerPage + + this.rowsLoading = true + try { + await this.loadPublicRowsFromBE({ + token: this.token, + filter: this.viewSetting?.filter, + sort: this.viewSetting?.sorting, + search: this.viewSetting?.searchString, + limit: this.rowsPerPage, + offset: this.paginationOffset, + rowIds: this.urlRowIds, + }) + } finally { + this.rowsLoading = false + this.updateUrlFromState() } }, @@ -104,7 +289,12 @@ export default { } return } - this.downloadCsv(this.rows, this.columns, 'public-export') + const csv = await this.loadPublicRowsForExportFromBE({ + token: this.token, + }) + if (csv) { + this.downloadFile(csv, 'public-export.csv') + } }, async downloadFilteredCSV(rows) { const access = await this.validatePublicExportAccess(this.token) @@ -114,7 +304,27 @@ export default { } return } - this.downloadCsv(rows, this.columns, 'public-export') + + if (rows !== this.rows) { + const csv = await this.loadPublicRowsForExportFromBE({ + token: this.token, + rowIds: rows.map(row => row.id), + }) + if (csv) { + this.downloadFile(csv, 'public-export.csv') + } + return + } + + const csv = await this.loadPublicRowsForExportFromBE({ + token: this.token, + filter: this.viewSetting?.filter, + sort: this.viewSetting?.sorting, + search: this.viewSetting?.searchString, + }) + if (csv) { + this.downloadFile(csv, 'public-export.csv') + } }, }, } diff --git a/src/modules/main/sections/Table.vue b/src/modules/main/sections/Table.vue index 20da815562..061a5f5099 100644 --- a/src/modules/main/sections/Table.vue +++ b/src/modules/main/sections/Table.vue @@ -13,7 +13,7 @@ @toggle-share="$emit('toggle-share')" @show-integration="$emit('show-integration')" @create-view="createView" /> - 0) { + return true + } + if (this.localViewSetting.searchString) { + return true + } + if (this.localViewSetting.sorting?.some(rule => rule?.columnId)) { + return true + } + if (this.localViewSetting.filter?.some(rule => rule?.columnId)) { + return true + } + return false }, }, watch: { diff --git a/src/pages/Context.vue b/src/pages/Context.vue index f4d8a6f412..5c33b7fbb5 100644 --- a/src/pages/Context.vue +++ b/src/pages/Context.vue @@ -158,7 +158,7 @@ export default { methods: { ...mapActions(useTablesStore, ['loadContext', 'validateExportAccess', 'loadContextTable', 'loadContextView']), - ...mapActions(useDataStore, ['loadColumnsFromBE', 'loadRowsFromBE']), + ...mapActions(useDataStore, ['loadColumnsFromBE', 'loadRowsFromBE', 'loadRowsForExportFromBE']), contextSignature() { const ctx = this.activeContext return ctx ? `${ctx.id}:${Object.keys(ctx.nodes || {}).sort().join(',')}` : null @@ -277,9 +277,13 @@ export default { return } - const rowId = this.getKey(isView, element.id) - const colId = this.getKey(isView, element.id) - this.downloadCsv(this.rows[rowId], this.columns[colId], element.title) + const csv = await this.loadRowsForExportFromBE({ + tableId: isView ? null : element.id, + viewId: isView ? element.id : null, + }) + if (csv) { + this.downloadFile(csv, element.title + '.csv') + } }, async downloadFilteredCSV(rows, element, isView) { const access = await this.validateExportAccess({ @@ -294,8 +298,14 @@ export default { return } - const colId = this.getKey(isView, element.id) - this.downloadCsv(rows, this.columns[colId], element.title) + const csv = await this.loadRowsForExportFromBE({ + tableId: isView ? null : element.id, + viewId: isView ? element.id : null, + rowIds: rows.map(row => row.id), + }) + if (csv) { + this.downloadFile(csv, element.title + '.csv') + } }, getKey(isView, id) { return isView ? 'view-' + id : id diff --git a/src/shared/components/ncTable/NcTable.vue b/src/shared/components/ncTable/NcTable.vue index 9608c9f3f6..5dcfdda1a6 100644 --- a/src/shared/components/ncTable/NcTable.vue +++ b/src/shared/components/ncTable/NcTable.vue @@ -43,7 +43,7 @@ deselect-all-rows -> unselect all rows, e.g. after deleting selected rows