diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f0b72ddc0..0bfdffb8fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,17 @@ All notable changes to this project will be documented in this file. ## [unreleased] -### changes +### New + - Add option to delete orphaned votes + +### Changes - Make vote cell focusable - Make shadow of sticky items transparent + - Changed experimental comments layout + +### Fixes + - Force list view mode initially on mobile viewports + - Fix some viual issues of the vote page ## [8.1.4] - 2025-07-15 ### Fixes @@ -19,7 +27,7 @@ All notable changes to this project will be documented in this file. - Fixed visual bug when scrolling in list view - Fixed exception on notifications which may cause resending notification mails -### changes +### Changes - Center poll table ## [8.1.0] - 2025-07-13 diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index d92633f54e..1d99925008 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -42,6 +42,7 @@ use OCA\Polls\Event\ShareLockedEvent; use OCA\Polls\Event\ShareRegistrationEvent; use OCA\Polls\Event\ShareTypeChangedEvent; +use OCA\Polls\Event\VoteDeletedOrphanedEvent; use OCA\Polls\Event\VoteEvent; use OCA\Polls\Event\VoteSetEvent; use OCA\Polls\Listener\CommentListener; @@ -126,6 +127,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(VoteEvent::class, VoteListener::class); $context->registerEventListener(VoteSetEvent::class, VoteListener::class); + $context->registerEventListener(VoteDeletedOrphanedEvent::class, VoteListener::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); $context->registerEventListener(GroupDeletedEvent::class, GroupDeletedListener::class); diff --git a/lib/Controller/PollApiController.php b/lib/Controller/PollApiController.php index 66ef7e9bbb..6469d4dd0a 100644 --- a/lib/Controller/PollApiController.php +++ b/lib/Controller/PollApiController.php @@ -67,6 +67,7 @@ public function get(int $pollId): DataResponse { 'poll' => $this->pollService->get($pollId), 'options' => $this->optionService->list($pollId), 'votes' => $this->voteService->list($pollId), + 'orphaned' => count($this->voteService->getOprhanedVotes($pollId)), 'comments' => $this->commentService->list($pollId), 'shares' => $this->shareService->list($pollId), 'subscribed' => $this->subscriptionService->get($pollId), @@ -196,8 +197,19 @@ public function getParticipantsEmailAddresses(int $pollId): DataResponse { } /** - * Get valid values for configuration options + * Delete orphaned votes from pollId + * @param int $pollId poll id */ + #[CORS] + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/v1.0/poll/{pollId}/votes/orphaned/all', requirements: ['apiVersion' => '(v2)'])] + public function deleteOrphaned(int $pollId): DataResponse { + return $this->response(fn () => [ + 'deleted' => $this->voteService->deleteOrphanedVotes($pollId) + ]); + } + #[CORS] #[NoAdminRequired] #[NoCSRFRequired] diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index 3686cd60e5..0a7ce87c29 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -22,6 +22,7 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; @@ -56,6 +57,7 @@ public function __construct( * }> */ #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'GET', url: '/polls')] public function listPolls(): JSONResponse { return $this->response(function () { @@ -78,6 +80,7 @@ public function listPolls(): JSONResponse { * psalm-return JSONResponse */ #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'GET', url: '/poll/{pollId}/poll')] public function get(int $pollId): JSONResponse { return $this->response(fn () => [ @@ -100,6 +103,7 @@ public function get(int $pollId): JSONResponse { * */ #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'GET', url: '/poll/{pollId}')] public function getFull(int $pollId): JSONResponse { return $this->response(fn () => $this->getFullPoll($pollId, true), Http::STATUS_OK); @@ -117,6 +121,9 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array { $votes = $this->voteService->list($pollId); $timerMicro['votes'] = microtime(true); + $orphaned = $this->voteService->getOprhanedVotes($pollId); + $timerMicro['orphaned'] = microtime(true); + $comments = $this->commentService->list($pollId); $timerMicro['comments'] = microtime(true); @@ -130,7 +137,8 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array { $diffMicro['poll'] = $timerMicro['poll'] - $timerMicro['start']; $diffMicro['options'] = $timerMicro['options'] - $timerMicro['poll']; $diffMicro['votes'] = $timerMicro['votes'] - $timerMicro['options']; - $diffMicro['comments'] = $timerMicro['comments'] - $timerMicro['votes']; + $diffMicro['orphaned'] = $timerMicro['orphaned'] - $timerMicro['votes']; + $diffMicro['comments'] = $timerMicro['comments'] - $timerMicro['orphaned']; $diffMicro['shares'] = $timerMicro['shares'] - $timerMicro['comments']; $diffMicro['subscribed'] = $timerMicro['subscribed'] - $timerMicro['shares']; @@ -139,6 +147,7 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array { 'poll' => $poll, 'options' => $options, 'votes' => $votes, + 'orphaned' => count($orphaned), 'comments' => $comments, 'shares' => $shares, 'subscribed' => $subscribed, @@ -149,6 +158,7 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array { 'poll' => $poll, 'options' => $options, 'votes' => $votes, + 'orphaned' => count($orphaned), 'comments' => $comments, 'shares' => $shares, 'subscribed' => $subscribed, @@ -164,6 +174,7 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array { * psalm-return JSONResponse */ #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'POST', url: '/poll/add')] public function add(string $type, string $title, string $votingVariant = Poll::VARIANT_SIMPLE): JSONResponse { return $this->response( @@ -182,6 +193,7 @@ public function add(string $type, string $title, string $votingVariant = Poll::V * psalm-return JSONResponse */ #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}')] public function update(int $pollId, array $poll): JSONResponse { return $this->response(fn () => [ @@ -194,6 +206,7 @@ public function update(int $pollId, array $poll): JSONResponse { * @param int $pollId Poll id */ #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/lockAnonymous')] public function lockAnonymous(int $pollId): JSONResponse { return $this->response(fn () => [ @@ -206,6 +219,7 @@ public function lockAnonymous(int $pollId): JSONResponse { * @param int $pollId Poll id */ #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'POST', url: '/poll/{pollId}/confirmation')] public function sendConfirmation(int $pollId): JSONResponse { return $this->response(fn () => [ @@ -218,6 +232,7 @@ public function sendConfirmation(int $pollId): JSONResponse { * @param int $pollId Poll id */ #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/toggleArchive')] public function toggleArchive(int $pollId): JSONResponse { return $this->response(fn () => [ @@ -230,6 +245,7 @@ public function toggleArchive(int $pollId): JSONResponse { * @param int $pollId Poll id */ #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'DELETE', url: '/poll/{pollId}')] public function delete(int $pollId): JSONResponse { return $this->response(fn () => [ @@ -242,6 +258,7 @@ public function delete(int $pollId): JSONResponse { * @param int $pollId Poll id */ #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/close')] public function close(int $pollId): JSONResponse { return $this->response(fn () => [ @@ -254,6 +271,7 @@ public function close(int $pollId): JSONResponse { * @param int $pollId Poll id */ #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/reopen')] public function reopen(int $pollId): JSONResponse { return $this->response(fn () => [ @@ -284,6 +302,7 @@ private function clonePoll(int $pollId): Poll { * @param string $sourceUserId User id to transfer polls from * @param string $targetUserId User id to transfer polls to */ + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'PUT', url: '/poll/transfer/{sourceUserId}/{targetUserId}')] public function transferPolls(string $sourceUserId, string $targetUserId): JSONResponse { return $this->response(fn () => $this->pollService->transferPolls($sourceUserId, $targetUserId)); @@ -295,6 +314,7 @@ public function transferPolls(string $sourceUserId, string $targetUserId): JSONR * @param string $targetUserId User to transfer polls to */ #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/changeowner/{targetUserId}')] public function changeOwner(int $pollId, string $targetUserId): JSONResponse { return $this->response(fn () => $this->pollService->transferPoll($pollId, $targetUserId)); @@ -305,8 +325,23 @@ public function changeOwner(int $pollId, string $targetUserId): JSONResponse { * @param int $pollId Poll id */ #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'GET', url: '/poll/{pollId}/addresses')] public function getParticipantsEmailAddresses(int $pollId): JSONResponse { return $this->response(fn () => $this->pollService->getParticipantsEmailAddresses($pollId)); } + + /** + * Delete orphaned votes + * @param int $pollId poll id + */ + #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] + #[FrontpageRoute(verb: 'DELETE', url: '/poll/{pollId}/votes/orphaned/all')] + public function deleteOrphaned(int $pollId): JSONResponse { + return $this->response(fn () => [ + 'deleted' => $this->voteService->deleteOrphanedVotes($pollId) + ]); + } + } diff --git a/lib/Controller/PublicController.php b/lib/Controller/PublicController.php index a828171d3c..fc3ceb6be8 100644 --- a/lib/Controller/PublicController.php +++ b/lib/Controller/PublicController.php @@ -94,6 +94,7 @@ public function getPoll(): JSONResponse { 'poll' => $this->pollService->get($this->userSession->getShare()->getPollId()), 'options' => $this->optionService->list($this->userSession->getShare()->getPollId()), 'votes' => $this->voteService->list($this->userSession->getShare()->getPollId()), + 'orphaned' => 0, 'comments' => $this->commentService->list($this->userSession->getShare()->getPollId()), 'shares' => $this->shareService->list($this->userSession->getShare()->getPollId()), 'subscribed' => $this->subscriptionService->get($this->userSession->getShare()->getPollId()), diff --git a/lib/Db/VoteMapper.php b/lib/Db/VoteMapper.php index 00a52520dc..f669a02c45 100644 --- a/lib/Db/VoteMapper.php +++ b/lib/Db/VoteMapper.php @@ -154,6 +154,26 @@ public function findOrphanedByPollandUser(int $pollId, string $userId): array { return $this->findEntities($qb); } + /** + * @throws \OCP\AppFramework\Db\DoesNotExistException if not found + * @return Vote[] + * @psalm-return array + */ + public function findOrphanedByPoll(int $pollId): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select(self::TABLE . '.*') + ->where($qb->expr()->isNotNull(self::TABLE . '.poll_id')) + ->from($this->getTableName(), self::TABLE) + ->groupBy(self::TABLE . '.id'); + + $optionAlias = $this->joinOption($qb, self::TABLE); + + $qb->andWhere($qb->expr()->isNull($optionAlias . '.id')); + $qb->andWhere($qb->expr()->eq(self::TABLE . '.poll_id', $qb->createNamedParameter($pollId, IQueryBuilder::PARAM_INT))); + return $this->findEntities($qb); + } + /** * Build the enhanced query with joined tables */ diff --git a/lib/Event/VoteDeletedOrphanedEvent.php b/lib/Event/VoteDeletedOrphanedEvent.php new file mode 100644 index 0000000000..751e2f294a --- /dev/null +++ b/lib/Event/VoteDeletedOrphanedEvent.php @@ -0,0 +1,24 @@ +log = $log; + $this->eventId = self::DELETED_ORPHANED; + } +} diff --git a/lib/Event/VoteEvent.php b/lib/Event/VoteEvent.php index 9065f6b3ec..a1c8f7a34b 100644 --- a/lib/Event/VoteEvent.php +++ b/lib/Event/VoteEvent.php @@ -14,6 +14,7 @@ */ abstract class VoteEvent extends BaseEvent { public const SET = 'vote_set'; + public const DELETED_ORPHANED = 'vote_deleted_orphaned'; public function __construct( protected Vote $vote, diff --git a/lib/Service/ActivityService.php b/lib/Service/ActivityService.php index 2c1c6bec29..acbdf8bb44 100644 --- a/lib/Service/ActivityService.php +++ b/lib/Service/ActivityService.php @@ -242,6 +242,12 @@ private function getMatchedMessages(): array { self::FIRST_PERSON_FILTERED => $this->l10n->t('You have voted'), self::THIRD_PERSON_FILTERED => $this->l10n->t('{actor} has voted'), ], + VoteEvent::DELETED_ORPHANED => [ + self::FIRST_PERSON_FULL => $this->l10n->t('One or more orphaned votes heve been deleted from {pollTitle}'), + self::THIRD_PERSON_FULL => $this->l10n->t('{actor} has deleted your orphaned polls from poll {pollTitle}'), + self::FIRST_PERSON_FILTERED => $this->l10n->t('Orphaned votes deleted'), + self::THIRD_PERSON_FILTERED => $this->l10n->t('{actor} deleted orphaned votes'), + ], ShareEvent::LOCKED => [ self::FIRST_PERSON_FULL => $this->l10n->t('You have locked the share of {sharee}'), self::THIRD_PERSON_FULL => $this->l10n->t('{actor} has locked the share of {sharee}'), diff --git a/lib/Service/VoteService.php b/lib/Service/VoteService.php index 38b4f19bb8..b40a051a02 100644 --- a/lib/Service/VoteService.php +++ b/lib/Service/VoteService.php @@ -14,7 +14,9 @@ use OCA\Polls\Db\PollMapper; use OCA\Polls\Db\Vote; use OCA\Polls\Db\VoteMapper; +use OCA\Polls\Event\VoteDeletedOrphanedEvent; use OCA\Polls\Event\VoteSetEvent; +use OCA\Polls\Exceptions\ForbiddenException; use OCA\Polls\Exceptions\NotFoundException; use OCA\Polls\Exceptions\VoteLimitExceededException; use OCA\Polls\UserSession; @@ -130,6 +132,43 @@ public function set(Option|int $optionOrOptionIdoptionId, string $setTo): ?Vote return $this->vote; } + /** + * Get all votes of a poll, which are not assigned to an option + * + * @param int $pollId poll id of the poll the votes get deleted from + * @return Vote[] + */ + public function getOprhanedVotes(int $pollId): array { + try { + $poll = $this->pollMapper->get($pollId, withRoles: true); + $poll->request(Poll::PERMISSION_POLL_EDIT); + return $this->voteMapper->findOrphanedByPoll($pollId); + } catch (ForbiddenException $e) { + return []; + } + } + + /** + * Delete all votes of a poll, which are not assigned to an option + * + * @param int $pollId poll id of the poll the votes get deleted from + * @return Vote[] + */ + public function deleteOrphanedVotes(int $pollId): array { + $poll = $this->pollMapper->get($pollId, withRoles: true); + $poll->request(Poll::PERMISSION_VOTE_FOREIGN_CHANGE); + + // delete all votes of the poll, which are not assigned to an option + $votes = $this->voteMapper->findOrphanedByPoll($pollId); + foreach ($votes as $vote) { + $this->voteMapper->delete($vote); + // TODO: rework notification methods + // keep this dispatch as reminder + // $this->eventDispatcher->dispatchTyped(new VoteDeletedOrphanedEvent($this->vote, false)); + } + return $votes; + } + /** * Remove user from poll * diff --git a/src/Api/modules/api.types.ts b/src/Api/modules/api.types.ts new file mode 100644 index 0000000000..39db149188 --- /dev/null +++ b/src/Api/modules/api.types.ts @@ -0,0 +1,36 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Comment, Option, Poll, Share, Vote } from '../../Types' + +export type FullPollResponse = { + poll: Poll + options: Option[] + votes: Vote[] + orphaned: number + comments: Comment[] + shares: Share[] + subscribed: boolean +} + +export type AddOptionResponse = { + option: Option + repetitions: Option[] + options: Option[] + votes: Vote[] +} + +export type setVoteResponse = { + vote: Vote + poll: Poll + options: Option[] + votes: Vote[] +} + +export type RemoveVotesResponse = { + poll: Poll + options: Option[] + votes: Vote[] +} diff --git a/src/Api/modules/polls.ts b/src/Api/modules/polls.ts index 34ea73ff92..ea32f171ee 100644 --- a/src/Api/modules/polls.ts +++ b/src/Api/modules/polls.ts @@ -5,11 +5,9 @@ import { Poll, PollConfiguration, PollType } from '../../stores/poll.ts' import { AxiosResponse } from '@nextcloud/axios' import { httpInstance, createCancelTokenHandler } from './HttpApi.js' -import { Option } from '../../stores/options.ts' -import { Vote } from '../../stores/votes.ts' -import { Share } from '../../stores/shares.ts' -import { ApiEmailAdressList, Comment } from '../../Types/index.ts' +import { ApiEmailAdressList, Vote } from '../../Types/index.ts' import { PollGroup } from '../../stores/pollGroups.types.ts' +import { FullPollResponse } from './api.types.ts' export type Confirmations = { sentMails: { emailAddress: string; displayName: string }[] @@ -52,16 +50,7 @@ const polls = { }) }, - getFullPoll(pollId: number): Promise< - AxiosResponse<{ - poll: Poll - options: Option[] - votes: Vote[] - comments: Comment[] - shares: Share[] - subscribed: boolean - }> - > { + getFullPoll(pollId: number): Promise> { return httpInstance.request({ method: 'GET', url: `poll/${pollId}`, @@ -150,6 +139,19 @@ const polls = { }) }, + removeOrphanedVotes( + pollId: number, + ): Promise> { + return httpInstance.request({ + method: 'DELETE', + url: `poll/${pollId}/votes/orphaned/all`, + cancelToken: + cancelTokenHandlerObject[ + this.removeOrphanedVotes.name + ].handleRequestCancellation().token, + }) + }, + closePoll(pollId: number): Promise> { return httpInstance.request({ method: 'PUT', diff --git a/src/Api/modules/public.ts b/src/Api/modules/public.ts index 85c0bde2b3..1e7d02e710 100644 --- a/src/Api/modules/public.ts +++ b/src/Api/modules/public.ts @@ -8,21 +8,17 @@ import { Session } from '../../stores/session.js' import { Answer, Vote } from '../../stores/votes.js' import { httpInstance, createCancelTokenHandler } from './HttpApi.js' import { Comment } from '../../stores/comments.js' -import { Poll } from '../../stores/poll.js' import { Share } from '../../stores/shares.js' import { SentResults } from './shares.js' +import { + AddOptionResponse, + FullPollResponse, + RemoveVotesResponse, + setVoteResponse, +} from './api.types.js' const publicPoll = { - getPoll(shareToken: string): Promise< - AxiosResponse<{ - poll: Poll - options: Option[] - votes: Vote[] - comments: Comment[] - shares: Share[] - subscribed: boolean - }> - > { + getPoll(shareToken: string): Promise> { return httpInstance.request({ method: 'GET', url: `/s/${shareToken}/poll`, @@ -63,14 +59,7 @@ const publicPoll = { option: SimpleOption, sequence: Sequence | null, voteYes: boolean = false, - ): Promise< - AxiosResponse<{ - option: Option - repetitions: Option[] - options: Option[] - votes: Vote[] - }> - > { + ): Promise> { return httpInstance.request({ method: 'POST', url: `/s/${shareToken}/option`, @@ -129,9 +118,7 @@ const publicPoll = { shareToken: string, optionId: number, setTo: Answer, - ): Promise< - AxiosResponse<{ vote: Vote; poll: Poll; options: Option[]; votes: Vote[] }> - > { + ): Promise> { return httpInstance.request({ method: 'PUT', url: `s/${shareToken}/vote`, @@ -146,9 +133,7 @@ const publicPoll = { }) }, - resetVotes( - shareToken: string, - ): Promise> { + resetVotes(shareToken: string): Promise> { return httpInstance.request({ method: 'DELETE', url: `s/${shareToken}/user`, @@ -161,7 +146,7 @@ const publicPoll = { removeOrphanedVotes( shareToken: string, - ): Promise> { + ): Promise> { return httpInstance.request({ method: 'DELETE', url: `s/${shareToken}/votes/orphaned`, diff --git a/src/Api/modules/votes.ts b/src/Api/modules/votes.ts index e408996031..0861930add 100644 --- a/src/Api/modules/votes.ts +++ b/src/Api/modules/votes.ts @@ -5,8 +5,7 @@ import { AxiosResponse } from '@nextcloud/axios' import { Answer, Vote } from '../../stores/votes.js' import { httpInstance, createCancelTokenHandler } from './HttpApi.js' -import { Option } from '../../stores/options.js' -import { Poll } from '../../stores/poll.js' +import { RemoveVotesResponse, setVoteResponse } from './api.types.js' const votes = { getVotes(pollId: number): Promise> { @@ -24,9 +23,7 @@ const votes = { setVote( optionId: number, setTo: Answer, - ): Promise< - AxiosResponse<{ vote: Vote; poll: Poll; options: Option[]; votes: Vote[] }> - > { + ): Promise> { return httpInstance.request({ method: 'PUT', url: 'vote', @@ -44,7 +41,7 @@ const votes = { resetVotes( pollId: number, userId: string | null = null, - ): Promise> { + ): Promise> { return httpInstance.request({ method: 'DELETE', url: userId ? `poll/${pollId}/user/${userId}` : `poll/${pollId}/user`, @@ -57,7 +54,7 @@ const votes = { removeOrphanedVotes( pollId: number, - ): Promise> { + ): Promise> { return httpInstance.request({ method: 'DELETE', url: `poll/${pollId}/votes/orphaned`, diff --git a/src/components/Configuration/ConfigDangerArea.vue b/src/components/Configuration/ConfigDangerArea.vue index c2ae5dbe42..a1b17d3ba8 100644 --- a/src/components/Configuration/ConfigDangerArea.vue +++ b/src/components/Configuration/ConfigDangerArea.vue @@ -4,10 +4,10 @@ -->