diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml index d007104641..afc75cc25d 100644 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml @@ -172,12 +172,9 @@ body: description: | Which Nextcloud Server version are you running. options: - - 'Nextcloud 27' - - 'Nextcloud 28' - - 'Nextcloud 29' - - 'Nextcloud 30' - - 'Nextcloud 31' - - 'Nextcloud 32' + - 'Nextcloud 31 (Hub 10)' + - 'Nextcloud 32 (Hub 25 Autumn)' + - 'Nextcloud 33 (Hub 26 Winter)' - "Other/Don't know" validations: required: true @@ -185,7 +182,7 @@ body: id: server-nc-version-other attributes: label: Other Nextcloud version - placeholder: i.e. Nextcloud 21.1.1 + placeholder: i.e. Nextcloud 29 or Hub 8 - type: dropdown id: server-php diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d31c55e3..dc43cd3d60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ # Changelog All notable changes to this project will be documented in this file. +## [unreleased] - tbd +### Added + - Timezone support for polls and options + - Set default timezone for any poll + - Choose timezone to display, if the poll's original timezone differs from the user's one + - Choose timezone, when adding options in case of different timezones between poll and user + +### Changed + - Use ISO formatted timestamps and durations + - Drop support for Nexcloud Hub 10 + - Drop support for PHP 8.1 + ## [8.6.3] - 2026-01-18 ### Fixed - Fixed missing user menu items, when table view is active diff --git a/appinfo/info.xml b/appinfo/info.xml index 5ace9ad607..f5edd01e00 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -4,7 +4,7 @@ Polls A polls app, similar to Doodle/DuD-Poll with the possibility to restrict access. A polls app, similar to Doodle/DuD-Poll with the possibility to restrict access (members, certain groups/users, hidden and public). - 8.6.3 + 9.0.0-beta.2 agpl Vinzenz Rosenkranz René Gieling @@ -25,7 +25,7 @@ https://buymeacoffee.com/dartcafe - + OCA\Polls\Cron\NotificationCron diff --git a/composer.json b/composer.json index 70849a3db5..3af07f4d2e 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "doctrine/dbal": "3.8.6", "league/factory-muffin": "^3.0", "league/factory-muffin-faker": "^2.0", - "nextcloud/ocp": "dev-stable30" + "nextcloud/ocp": "dev-stable32" }, "scripts": { "lint": "find . -name \\*.php -not -path './vendor*/*' -print0 | xargs -0 -n1 php -l", @@ -67,4 +67,4 @@ "league/commonmark": "^2.1", "rlanvin/php-rrule": "^2.3" } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 20c971b278..6a786469f5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "322cad1dfd0f57d76c5e374aeb123b71", + "content-hash": "ec4f23e3e4d084e0631510e986667497", "packages": [ { "name": "dflydev/dot-access-data", @@ -1286,29 +1286,29 @@ }, { "name": "nextcloud/ocp", - "version": "dev-stable30", + "version": "dev-stable32", "source": { "type": "git", "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "f9705f567a8590b0f84a7d6dec0663b3155f2046" + "reference": "aa9d5a01d17cb9c19ca32532e08e999d693679fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/f9705f567a8590b0f84a7d6dec0663b3155f2046", - "reference": "f9705f567a8590b0f84a7d6dec0663b3155f2046", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/aa9d5a01d17cb9c19ca32532e08e999d693679fc", + "reference": "aa9d5a01d17cb9c19ca32532e08e999d693679fc", "shasum": "" }, "require": { - "php": "~8.0 || ~8.1 || ~8.2 || ~8.3", + "php": "~8.1 || ~8.2 || ~8.3 || ~8.4", "psr/clock": "^1.0", "psr/container": "^2.0.2", "psr/event-dispatcher": "^1.0", - "psr/log": "^2.0.0" + "psr/log": "^3.0.2" }, "type": "library", "extra": { "branch-alias": { - "dev-stable30": "30.0.0-dev" + "dev-stable32": "32.0.0-dev" } }, "notification-url": "https://packagist.org/downloads/", @@ -1319,14 +1319,18 @@ { "name": "Christoph Wurst", "email": "christoph@winzerhof-wurst.at" + }, + { + "name": "Joas Schilling", + "email": "coding@schilljs.com" } ], - "description": "Composer package containing Nextcloud's public API (classes, interfaces)", + "description": "Composer package containing Nextcloud's public OCP API and the unstable NCU API", "support": { "issues": "https://github.com/nextcloud-deps/ocp/issues", - "source": "https://github.com/nextcloud-deps/ocp/tree/stable30" + "source": "https://github.com/nextcloud-deps/ocp/tree/stable32" }, - "time": "2025-05-16T00:52:15+00:00" + "time": "2026-02-24T01:08:25+00:00" }, { "name": "psr/cache", @@ -1480,16 +1484,16 @@ }, { "name": "psr/log", - "version": "2.0.0", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "ef29f6d262798707a9edd554e2b82517ef3a9376" + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/ef29f6d262798707a9edd554e2b82517ef3a9376", - "reference": "ef29f6d262798707a9edd554e2b82517ef3a9376", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { @@ -1498,7 +1502,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "3.x-dev" } }, "autoload": { @@ -1524,9 +1528,9 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/2.0.0" + "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2021-07-14T16:41:46+00:00" + "time": "2024-09-11T13:17:53+00:00" } ], "aliases": [], @@ -1536,8 +1540,8 @@ }, "prefer-stable": false, "prefer-lowest": false, - "platform": {}, - "platform-dev": {}, + "platform": [], + "platform-dev": [], "platform-overrides": { "php": "8.1" }, diff --git a/lib/Command/Db/CleanMigrations.php b/lib/Command/Db/CleanMigrations.php index c5fefcea08..f590d9021f 100644 --- a/lib/Command/Db/CleanMigrations.php +++ b/lib/Command/Db/CleanMigrations.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Command\Command; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCP\IDBConnection; /** diff --git a/lib/Command/Db/CreateIndices.php b/lib/Command/Db/CreateIndices.php index 56d267f358..809f1e0971 100644 --- a/lib/Command/Db/CreateIndices.php +++ b/lib/Command/Db/CreateIndices.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Command\Command; -use OCA\Polls\Db\V5\IndexManager; +use OCA\Polls\Db\V6\IndexManager; use OCP\IDBConnection; /** diff --git a/lib/Command/Db/FixDB.php b/lib/Command/Db/FixDB.php index baf7c169a7..9974936b18 100644 --- a/lib/Command/Db/FixDB.php +++ b/lib/Command/Db/FixDB.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Command\Command; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCP\IDBConnection; /** diff --git a/lib/Command/Db/Purge.php b/lib/Command/Db/Purge.php index 9828b8a04f..52a9e73083 100644 --- a/lib/Command/Db/Purge.php +++ b/lib/Command/Db/Purge.php @@ -9,7 +9,7 @@ namespace OCA\Polls\Command\Db; use OCA\Polls\Command\Command; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCP\IDBConnection; /** diff --git a/lib/Command/Db/Rebuild.php b/lib/Command/Db/Rebuild.php index 18df1dfc6c..de67769706 100644 --- a/lib/Command/Db/Rebuild.php +++ b/lib/Command/Db/Rebuild.php @@ -9,8 +9,8 @@ namespace OCA\Polls\Command\Db; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\V5\TableManager; -use OCA\Polls\Db\V5\IndexManager; +use OCA\Polls\Db\V6\TableManager; +use OCA\Polls\Db\V6\IndexManager; use OCA\Polls\Command\Command; use OCP\IDBConnection; diff --git a/lib/Command/Db/RemoveFKConstraints.php b/lib/Command/Db/RemoveFKConstraints.php index cdd52a56ea..f276543bba 100644 --- a/lib/Command/Db/RemoveFKConstraints.php +++ b/lib/Command/Db/RemoveFKConstraints.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Command\Command; -use OCA\Polls\Db\V5\IndexManager; +use OCA\Polls\Db\V6\IndexManager; use OCP\IDBConnection; /** diff --git a/lib/Command/Db/RemoveOptionalIndices.php b/lib/Command/Db/RemoveOptionalIndices.php index 00b7b74c1b..080a47cf8a 100644 --- a/lib/Command/Db/RemoveOptionalIndices.php +++ b/lib/Command/Db/RemoveOptionalIndices.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Command\Command; -use OCA\Polls\Db\V5\IndexManager; +use OCA\Polls\Db\V6\IndexManager; use OCP\IDBConnection; /** diff --git a/lib/Command/Db/RemoveUniqueIndices.php b/lib/Command/Db/RemoveUniqueIndices.php index 67317713de..fd3cc4274b 100644 --- a/lib/Command/Db/RemoveUniqueIndices.php +++ b/lib/Command/Db/RemoveUniqueIndices.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Command\Command; -use OCA\Polls\Db\V5\IndexManager; +use OCA\Polls\Db\V6\IndexManager; use OCP\IDBConnection; /** diff --git a/lib/Command/Db/ResetWatch.php b/lib/Command/Db/ResetWatch.php index b216e19aaa..a5a23aa376 100644 --- a/lib/Command/Db/ResetWatch.php +++ b/lib/Command/Db/ResetWatch.php @@ -10,10 +10,10 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Command\Command; -use OCA\Polls\Db\V5\IndexManager; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\IndexManager; +use OCA\Polls\Db\V6\TableManager; use OCA\Polls\Db\Watch; -use OCA\Polls\Migration\V5\TableSchema; +use OCA\Polls\Migration\V6\TableSchema; use OCP\IDBConnection; /** diff --git a/lib/Controller/OptionApiController.php b/lib/Controller/OptionApiController.php index bd55c0d45c..b9932c081e 100644 --- a/lib/Controller/OptionApiController.php +++ b/lib/Controller/OptionApiController.php @@ -8,6 +8,8 @@ namespace OCA\Polls\Controller; +use OCA\Polls\Model\DateInterval; +use OCA\Polls\Model\DateTimeImmutable; use OCA\Polls\Model\Sequence; use OCA\Polls\Model\SimpleOption; use OCA\Polls\Service\OptionService; @@ -22,6 +24,8 @@ /** * @psalm-api + * @psalm-import-type SimpleOptionsArray from SimpleOption + * @psalm-import-type SequenceArray from Sequence */ class OptionApiController extends BaseApiV2Controller { public function __construct( @@ -47,10 +51,11 @@ public function list(int $pollId): DataResponse { /** * Add a new option + * * @param int $pollId poll id - * @param array $option Options text for text poll - * @param array $sequence Sequence of the option - * @param bool $voteYes Vote yes + * @param SimpleOptionsArray $option The array containing the option data + * @param bool $voteYes Vote yes for this option and for all generated sequence + * @param SequenceArray $sequence Sequence of the option */ #[CORS] #[NoAdminRequired] @@ -99,8 +104,13 @@ public function addBulk(int $pollId, string $text = ''): DataResponse { #[NoAdminRequired] #[NoCSRFRequired] #[ApiRoute(verb: 'PUT', url: '/api/v1.0/option/{optionId}', requirements: ['apiVersion' => '(v2)'])] - public function update(int $optionId, int $timestamp = 0, string $text = '', int $duration = 0): DataResponse { - return $this->response(fn () => ['option' => $this->optionService->update($optionId, $timestamp, $text, $duration)]); + public function update(int $optionId, int $timestamp = 0, string $text = '', int $duration = 0, ?string $isoTimestamo = null, ?string $isoDuration = null): DataResponse { + return $this->response(fn () => ['option' => $this->optionService->update( + $optionId, + $text, + new DateTimeImmutable($isoTimestamo ?? $timestamp), + new DateInterval($isoDuration ?? $duration), + )]); } /** diff --git a/lib/Controller/OptionController.php b/lib/Controller/OptionController.php index 1a2aca9d22..88e82c187a 100644 --- a/lib/Controller/OptionController.php +++ b/lib/Controller/OptionController.php @@ -8,6 +8,8 @@ namespace OCA\Polls\Controller; +use OCA\Polls\Model\DateInterval; +use OCA\Polls\Model\DateTimeImmutable; use OCA\Polls\Model\Sequence; use OCA\Polls\Model\SimpleOption; use OCA\Polls\Service\CalendarService; @@ -21,6 +23,8 @@ /** * @psalm-api + * @psalm-import-type SimpleOptionsArray from SimpleOption + * @psalm-import-type SequenceArray from Sequence */ class OptionController extends BaseController { public function __construct( @@ -48,9 +52,9 @@ public function list(int $pollId): JSONResponse { /** * Add a new option * @param int $pollId poll id - * @param array $option Options text for text poll - * @param array $sequence sequence of new options + * @param SimpleOptionsArray $option Options text for text poll * @param bool $voteYes vote yes + * @param SequenceArray $sequence sequence of new options * @return JSONResponse */ #[NoAdminRequired] @@ -88,14 +92,19 @@ public function addBulk(int $pollId, string $text = ''): JSONResponse { /** * Update option * @param int $optionId option id - * @param int $timestamp timestamp for datepoll + * @param string $isoTimestamp ISO 8601 timestamp for date poll * @param string $text Option text for text poll - * @param int duration duration of option + * @param string $isoDuration ISO 8601 duration for date poll */ #[NoAdminRequired] #[FrontpageRoute(verb: 'PUT', url: '/option/{optionId}')] - public function update(int $optionId, int $timestamp, string $text, int $duration): JSONResponse { - return $this->response(fn () => ['option' => $this->optionService->update($optionId, $timestamp, $text, $duration)]); + public function update(int $optionId, string $isoTimestamp, string $text, string $isoDuration): JSONResponse { + return $this->response(fn () => ['option' => $this->optionService->update( + $optionId, + $text, + new DateTimeImmutable($isoTimestamp), + new DateInterval($isoDuration), + )]); } /** diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index b61ff27b3d..b0a37713c2 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -106,49 +106,17 @@ public function get(int $pollId): JSONResponse { #[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); + return $this->response(fn () => $this->getFullPoll($pollId), Http::STATUS_OK); } - private function getFullPoll(int $pollId, bool $withTimings = false): array { - $timerMicro['start'] = microtime(true); - + private function getFullPoll(int $pollId): array { $poll = $this->pollService->get($pollId); - $timerMicro['poll'] = microtime(true); - $options = $this->optionService->list($pollId); - $timerMicro['options'] = microtime(true); - $votes = $this->voteService->list($pollId); - $timerMicro['votes'] = microtime(true); - $comments = $this->commentService->list($pollId); - $timerMicro['comments'] = microtime(true); - $shares = $this->shareService->list($pollId); - $timerMicro['shares'] = microtime(true); - $subscribed = $this->subscriptionService->get($pollId); - $timerMicro['subscribed'] = microtime(true); - - $diffMicro['total'] = microtime(true) - $timerMicro['start']; - $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['shares'] = $timerMicro['shares'] - $timerMicro['comments']; - $diffMicro['subscribed'] = $timerMicro['subscribed'] - $timerMicro['shares']; - if ($withTimings) { - return [ - 'poll' => $poll, - 'options' => $options, - 'votes' => $votes, - 'comments' => $comments, - 'shares' => $shares, - 'subscribed' => $subscribed, - 'diffMicro' => $diffMicro, - ]; - } return [ 'poll' => $poll, 'options' => $options, @@ -170,10 +138,10 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array { #[NoAdminRequired] #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'POST', url: '/poll/add')] - public function add(string $type, string $title, string $votingVariant = Poll::VARIANT_SIMPLE): JSONResponse { + public function add(string $type, string $title, ?string $timezoneName = null, string $votingVariant = Poll::VARIANT_SIMPLE): JSONResponse { return $this->response( fn () => [ - 'poll' => $this->pollService->add($type, $title, $votingVariant) + 'poll' => $this->pollService->add($type, $title, $timezoneName, $votingVariant) ], Http::STATUS_CREATED ); diff --git a/lib/Controller/PublicController.php b/lib/Controller/PublicController.php index f7b76a3f0b..eb0ab601dd 100644 --- a/lib/Controller/PublicController.php +++ b/lib/Controller/PublicController.php @@ -10,6 +10,7 @@ use OCA\Polls\AppConstants; use OCA\Polls\Attributes\ShareTokenRequired; +use OCA\Polls\Exceptions\InsufficientAttributesException; use OCA\Polls\Model\SentResult; use OCA\Polls\Model\Sequence; use OCA\Polls\Model\Settings\AppSettings; @@ -42,6 +43,8 @@ * Requesting the token inside the controller is not possible, because the token is submitted * as a paramter and not known while contruction time * @psalm-api + * @psalm-import-type SimpleOptionsArray from SimpleOption + * @psalm-import-type SequenceArray from Sequence */ class PublicController extends BaseController { public function __construct( @@ -210,8 +213,8 @@ public function getOptions(): JSONResponse { /** * Add options - * @param array $option Options text for text poll - * @param array $sequence Sequence of the option + * @param SimpleOptionsArray $option Options text for text poll + * @param SequenceArray $sequence Sequence of the option * @param bool $voteYes Vote yes */ #[PublicPage] @@ -224,6 +227,12 @@ public function addOption( ?array $sequence = null, ): JSONResponse { $pollId = $this->userSession->getShare()->getPollId(); + + /** @psalm-suppress RiskyTruthyFalsyComparison */ + if (!$pollId) { + throw new InsufficientAttributesException('PollId of share is missing or invalid'); + } + return $this->response(fn () => array_merge( $this->optionService->addWithSequenceAndAutoVote( $pollId, diff --git a/lib/Controller/SystemController.php b/lib/Controller/SystemController.php index 6afdc02df0..7a0a3f3b4c 100644 --- a/lib/Controller/SystemController.php +++ b/lib/Controller/SystemController.php @@ -37,7 +37,7 @@ public function __construct( #[FrontpageRoute(verb: 'GET', url: '/search/users/{query}')] public function userSearch(string $query, string $types): JSONResponse { $types = array_map('intval', explode(',', $types)); - // $types = explode(',', $types); + return new JSONResponse([ 'siteusers' => $this->systemService->getSiteUsersAndGroups($query, $types), 'types' => $types diff --git a/lib/Controller/VoteController.php b/lib/Controller/VoteController.php index 74b5c6e2fe..a7daf611a4 100644 --- a/lib/Controller/VoteController.php +++ b/lib/Controller/VoteController.php @@ -59,7 +59,6 @@ public function set(int $optionId, string $setTo): JSONResponse { $vote = $this->voteService->set($optionId, $setTo); return [ 'vote' => $vote, - // 'poll' => $this->pollService->get($option->getPollId()), 'options' => $this->optionService->list($option->getPollId()), 'votes' => $this->voteService->list($option->getPollId()) ]; @@ -78,7 +77,6 @@ public function set(int $optionId, string $setTo): JSONResponse { public function delete(int $pollId, string $userId = ''): JSONResponse { $this->voteService->deleteUserFromPoll($pollId, $userId); return $this->response(fn () => [ - // 'poll' => $this->pollService->get($pollId), 'options' => $this->optionService->list($pollId), 'votes' => $this->voteService->list($pollId) ]); diff --git a/lib/Cron/JanitorCron.php b/lib/Cron/JanitorCron.php index 181f844c7c..57a6476bb9 100644 --- a/lib/Cron/JanitorCron.php +++ b/lib/Cron/JanitorCron.php @@ -16,7 +16,7 @@ use OCA\Polls\Db\OptionMapper; use OCA\Polls\Db\PollMapper; use OCA\Polls\Db\ShareMapper; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCA\Polls\Db\VoteMapper; use OCA\Polls\Model\Settings\AppSettings; use OCP\AppFramework\Utility\ITimeFactory; diff --git a/lib/Db/Option.php b/lib/Db/Option.php index 0f97a428c8..0cb0bf785e 100644 --- a/lib/Db/Option.php +++ b/lib/Db/Option.php @@ -8,16 +8,14 @@ namespace OCA\Polls\Db; -use DateInterval; -use DateTime; -use DateTimeImmutable; use DateTimeZone; use JsonSerializable; use OCA\Polls\Exceptions\InsufficientAttributesException; use OCA\Polls\Helper\Hash; +use OCA\Polls\Model\DateInterval; +use OCA\Polls\Model\DateTimeImmutable; use OCA\Polls\Model\SimpleOption; use OCA\Polls\Model\UserBase; -use OCP\IL10N; /** * @psalm-suppress UnusedProperty @@ -25,25 +23,17 @@ * @method void setId(int $value) * @method int getConfirmed() * @method void setConfirmed(int $value) - * @method int getDuration() - * @method void setDuration(int $value) - * @method int getOrder() - * @method void setOrder(int $value) * @method string getOwner() * @method void setOwner(string $value) * @method int getPollId() - * @method void setPollId(int $value) - * @method string getPollOptionText() - * @method void setPollOptionText(string $value) - * @method string getPollOptionHash() - * @method void setPollOptionHash(string $value) * @method int getReleased() * @method void setReleased(int $value) - * @method int getTimestamp() - * @method void setTimestamp(int $value) * @method int getDeleted() * @method void setDeleted(int $value) * + * No magic getters, getters are overwritten for special handling of timestamp and option text + * @method void setOrder(int $value) + * * Joined Attributes * @method string getUserVoteAnswer() * @method int getOptionLimit() @@ -54,6 +44,21 @@ * @method int getVotesNo() * @method int getVotesMaybe() * @method int getShowResults() + * + * Protected setters for pollId, pollOptionText, setPollOptionHash, timestamp + * and duration, as they should not be set directly, but through setPoll(), + * setText(), setDateTime() and setInterval() + * + * protected setPollId(int $value) + * protected setTimestamp(int $value) + * protected setDuration(int $value) + * protected setIsoTimestamp(?string $value) + * protected setIsoDuration(?string $value) + * protected setPollOptionText(string $value) + * protected setPollOptionHash(string $value) + * + * Nextcloud generates them automagically through the entity class. + * */ class Option extends EntityWithUser implements JsonSerializable { public const TABLE = 'polls_options'; @@ -66,6 +71,8 @@ class Option extends EntityWithUser implements JsonSerializable { protected int $timestamp = 0; protected int $duration = 0; protected int $order = 0; + protected ?string $isoTimestamp = null; + protected ?string $isoDuration = null; protected int $confirmed = 0; protected string $owner = ''; protected int $released = 0; @@ -82,13 +89,18 @@ class Option extends EntityWithUser implements JsonSerializable { protected int $votesMaybe = 0; protected int $showResults = 0; + protected DateInterval $optionInterval; + protected DateTimeImmutable $optionDateTimeImmutable; + + // deprecated columns + public function __construct() { $this->addType('released', 'integer'); $this->addType('pollId', 'integer'); - $this->addType('timestamp', 'integer'); $this->addType('order', 'integer'); - $this->addType('confirmed', 'integer'); + $this->addType('timestamp', 'integer'); $this->addType('duration', 'integer'); + $this->addType('confirmed', 'integer'); $this->addType('deleted', 'integer'); // joined Attributes @@ -103,6 +115,22 @@ public function __construct() { } /** + * Clone this option and reset the id, pollId, deleted + * and confirmed status, as well as the owner and the hash. + * + * @return void + */ + public function __clone() { + $this->setDeleted(0); + $this->setConfirmed(0); + $this->setOwner(''); + /** @psalm-suppress UndefinedMagicMethod */ + $this->setPollId(0); + } + + /** + * Return the option data as an associative array for JSON serialization. + * * @return array * * @psalm-suppress PossiblyUnusedMethod @@ -113,10 +141,12 @@ public function jsonSerialize(): array { 'pollId' => $this->getPollId(), 'text' => $this->getPollOptionText(), 'timestamp' => $this->getTimestamp(), + 'duration' => $this->getDuration(), + 'isoTimestamp' => $this->getIsoTimestamp(), + 'isoDuration' => $this->getIsoDuration(), 'deleted' => $this->getDeleted(), 'order' => $this->getOrder(), 'confirmed' => $this->getConfirmed(), - 'duration' => $this->getDuration(), 'locked' => $this->getIsLocked(), 'hash' => $this->getPollOptionHash(), 'isOwner' => $this->getCurrentUserIsEntityUser(), @@ -125,6 +155,11 @@ public function jsonSerialize(): array { ]; } + /** + * Get the vote status for this option + * + * @return array An associative array containing the counts of 'yes', 'no', 'maybe' votes, the total count of votes and the current user's vote answer. + */ private function getVotes(): array { return [ 'no' => $this->getVotesNo() * $this->getShowResults(), @@ -135,127 +170,273 @@ private function getVotes(): array { ]; } + /** + * alias of getOwner() + * + * @return string The user ID of the option's owner + */ + public function getUserId(): string { + return $this->getOwner(); + } + + /*************************************************** + * special getters + ***************************************************/ + /** + * Get the owner user object. Returns null if the owner is not set or if the owner does not exist. + * + * @return UserBase|null The owner user object or null if not set or does not exist + */ public function getOwnerUser(): ?UserBase { - if ($this->getOwner() === '') { + if ($this->getUserId() === '') { return null; } return parent::getUser(); } - public function setFromSimpleOption(SimpleOption $option): void { - $this->setOption( - $option->getTimestamp(), - $option->getDuration(), - $option->getText(), - $option->getOrder(), + public function getPollOptionHash(): string { + return Hash::getOptionHash( + $this->getPollId(), + $this->getPollOptionText(), ); - $this->setDeleted(0); - $this->syncOption(); + } + /** + * Get the order of the option. If the option has a valid timestamp, + * the order will be determined by the timestamp to ensure correct + * ordering of options based on their date and time. + * + * @return int The order of the option, determined by the timestamp if valid, otherwise the order value from the database + */ + public function getOrder(): int { + return $this->getTimestamp() !== 0 + ? $this->getTimestamp() + : $this->order; + } + + /** + * Get the text of the option. If the option has a valid timestamp, + * the text will be generated from the timestamp and duration + */ + public function getPollOptionText(): string { + return $this->getTimestamp() !== 0 + ? $this->getDateTimePollOptionText() + : htmlspecialchars_decode($this->pollOptionText); + } + + /*************************************************** + * special setters + ***************************************************/ + + /** + * Set the poll ID for the option. Also updates the poll option hash to ensure + */ + public function setPoll(int $pollId): void { + /** @psalm-suppress UndefinedMagicMethod */ + $this->setPollId($pollId); + /** @psalm-suppress UndefinedMagicMethod */ + $this->setPollOptionHash($this->getPollOptionHash()); } /** - * cumulative Set option entities cumulative and validated - * if timestamp is given, the pollOptionText will be synced according to the timestamp and duration + * Set the text of the option. Also updates the poll option hash to ensure + * it is always in sync with the text and poll ID. * - * @param int $timestamp Timestamp to set - * @param int $duration Set duration of option in seconds and used together with timestamp, defaults to 0 - * @param string $pollOptionText Option text, ignored if $timestamp is set - * @param int $order Set order of this option inside the poll, defaults to 0, ignored if timestap is set - * @return void + * @param string $text The text to set for the option */ - public function setOption( - int $timestamp = 0, - int $duration = 0, - string $pollOptionText = '', - int $order = 0, - ): void { - - if ($timestamp) { - $this->setTimestamp($timestamp); - $this->setDuration($duration); - } elseif ($pollOptionText) { - $this->setPollOptionText($pollOptionText); - if ($order > 0) { - $this->setOrder($order); - } - } else { - throw new InsufficientAttributesException('Option must have a value'); + public function setText(string $text): void { + /** @psalm-suppress UndefinedMagicMethod */ + $this->setPollOptionText($text); + /** @psalm-suppress UndefinedMagicMethod */ + $this->setPollOptionHash($this->getPollOptionHash()); + } + /*************************************************** + * date and time getters + ***************************************************/ + + /** + * Get the option's date and time as a DateTimeImmutable object. + * Returns the date and time based on the isoTimestamp if it is set, + * otherwise it falls back to the timestamp. + * + * @return DateTimeImmutable + */ + public function getDateTime(): DateTimeImmutable { + if ($this->isoTimestamp !== null && $this->isoTimestamp !== '') { + return new DateTimeImmutable($this->isoTimestamp); } + return new DateTimeImmutable($this->timestamp); + } - $this->syncOption(); + /** + * Get the option's timestamp as an integer. + * Always returns the timestamp from getDateTime() + * + * @return int The timestamp of the option as Unix timestamp in seconds + */ + public function getTimestamp(): int { + return $this->getDateTime()->getTimestamp(); } - public function shiftOption(DateTimeZone $timeZone, int $step, string $unit): void { - $from = (new DateTime()) - ->setTimestamp($this->getTimestamp()) - ->setTimezone($timeZone) - ->modify($step . ' ' . $unit); - $to = (new DateTime()) - ->setTimestamp($this->getTimestamp() + $this->getDuration()) - ->setTimezone($timeZone) - ->modify($step . ' ' . $unit); - $this->setTimestamp($from->getTimestamp()); - $this->setDuration($to->getTimestamp() - $from->getTimestamp()); - $this->syncOption(); + /** + * Get the option's date and time as an ISO 8601 string. + * Always returns the ISO timestamp from getDateTime() + * + * @return string|null The ISO 8601 timestamp string of the option, or null if no valid date and time is set + */ + public function getIsoTimestamp(): ?string { + return $this->getDateTime()->getISO(); } + /*************************************************** + * date and time setters + ***************************************************/ + /** - * Syncs pollOptionText and order according to timestamp and duration if timestamp > 0 and - * updates hash based on synced pollId and pollOptionText + * Set the option's date and time from a DateTimeImmutable object. + * Also updates the timestamp, isoTimestamp, order, and pollOptionText + * based on the new date and time. + * + * Prefer this method over setTimestamp() and setIsoTimestamp() + * + * @param DateTimeImmutable $dateTime The DateTimeImmutable object to set the option's date and time from + * @return void + */ + public function setDateTime(DateTimeImmutable $dateTime): void { + /** @psalm-suppress UndefinedMagicMethod */ + $this->setTimestamp($dateTime->getTimestamp()); + $this->setOrder($dateTime->getTimestamp()); + /** @psalm-suppress UndefinedMagicMethod */ + $this->setIsoTimestamp($dateTime->getISO()); + $this->setText($this->getDateTimePollOptionText()); + } + + /*************************************************** + * Duration getters + ***************************************************/ + + /** + * Get the option's duration as a DateInterval object. + * Returns the duration based on the isoDuration if it is set, + * otherwise it falls back to the duration in seconds. + * + * @return DateInterval The duration of the option as a DateInterval object, or null if no valid duration is set */ - public function syncOption(): void { - // make sure, pollOptionText matches timestamp and duration - // timestamp gets precedence over pollOptionText - if ($this->getTimestamp()) { - $this->setOrder($this->getTimestamp()); - - if ($this->duration) { - $this->setPollOptionText(date('c', $this->getTimestamp()) . ' - ' . date('c', $this->getTimestamp() + $this->getDuration())); - } else { - $this->setPollOptionText(date('c', $this->timestamp)); - } + public function getInterval(): DateInterval { + if ($this->isoDuration !== null && $this->isoDuration !== '') { + return new DateInterval($this->isoDuration); } + return new DateInterval($this->duration); + } - // update hash - $this->updateHash(); + /** + * Get the option's duration in seconds. + * Always returns the duration in seconds from getInterval() + * + * @return int The duration of the option in seconds + * @psalm-suppress PossiblyUnusedMethod + */ + public function getDuration(): int { + return $this->getInterval()->getSeconds(); } - private function updateHash(): void { - $this->setPollOptionHash(Hash::getOptionHash( - $this->getPollId(), - $this->getPollOptionText() - )); + /** + * Get the option's duration as an ISO 8601 string. + * Always returns the ISO duration from getInterval() + * + * @return string|null The ISO 8601 duration string of the option, or null if no valid duration is set + * @psalm-suppress PossiblyUnusedMethod + */ + public function getIsoDuration(): ?string { + return $this->getInterval()->getISO(); } - public function getPollOptionText(): string { - if ($this->getTimestamp() === 0) { + /*************************************************** + * Duration setters + ***************************************************/ + + /** + * Set the option's duration from a DateInterval object. + * Also updates the duration in seconds, isoDuration, and pollOptionText + * based on the new duration. + * + * Prefer this method over setDuration() and setIsoDuration() + * + * @param DateInterval $optionInterval The DateInterval object to set the option's duration from + * @return void + */ + public function setInterval(DateInterval $optionInterval): void { + /** @psalm-suppress UndefinedMagicMethod */ + $this->setDuration($optionInterval->getSeconds()); + /** @psalm-suppress UndefinedMagicMethod */ + $this->setIsoDuration($optionInterval->getISO()); + $this->setText($this->getDateTimePollOptionText()); + } + + /*************************************************** + * misc private and public methods and helpers + ***************************************************/ + + private function getDateTimePollOptionText(): string { + $isoTimestamp = $this->getIsoTimestamp(); + if ($isoTimestamp === null) { return htmlspecialchars_decode($this->pollOptionText); } - // return timespan, if duration is set - if ($this->getDuration()) { - return date('c', $this->getTimestamp()) . ' - ' . date('c', $this->getTimestamp() + $this->getDuration()); + if ($this->getInterval()->isZeroDuration()) { + return $isoTimestamp; } - // else return formatted timestamp - return date('c', $this->getTimestamp()); + return $isoTimestamp + . ' - ' + . ($this->getDateTime()->add($this->getInterval())->getISO() ?? ''); } - /** @psalm-suppress PossiblyUnusedMethod */ - public function getPollOptionTextEnd(): string { - if ($this->getTimestamp()) { - return date('c', $this->getTimestamp() + $this->getDuration()); + /** + * Shift the option's date and time by a specified amount of time. + * + * @param int $step The amount of time to shift the option by + * @param string $unit The unit of time to shift by (e.g. 'day', 'hour', 'minute') + * @param DateTimeZone $timeZone The time zone to use for the date and time calculations + * @return Option Returns the Option instance for method chaining + */ + public function shiftOption(int $step, string $unit, DateTimeZone $timeZone): self { + $shifted = $this->getDateTime() + ->setTimeZone($timeZone) + ->modify($step . ' ' . $unit); + if (!$shifted) { + throw new InsufficientAttributesException('Failed to shift option, invalid step or unit'); } - return htmlspecialchars_decode($this->pollOptionText); + $this->setDateTime($shifted); + + return $this; } + /** + * Get the option's text for old options that only have a timestamp. If + * the option has a valid timestamp, the text will be returned as an + * ISO string of the timestamp. Otherwise, the regular option text will be + * returned. + * + * This is used in the migration to set the option text for old options that + * only have a timestamp, as the option text is now required and cannot be + * empty. This ensures that old options will have a valid option text after + * the migration, even if they only had a timestamp before. The option text + * will be set to the ISO string of the timestamp to ensure it is unique and + * does not conflict with any existing option texts. + * + * @return string The text of the option, either as an ISO string of the timestamp or the regular option text + */ public function getPollOptionTextStart(): string { - if ($this->getTimestamp()) { - return date('c', $this->getTimestamp()); - } - return htmlspecialchars_decode($this->pollOptionText); + return $this->getIsoTimestamp() ?? htmlspecialchars_decode($this->pollOptionText); } + /** + * Check if the option is locked for the current user. An option is considered locked if: + * - It is marked as deleted, or + * - The user did not vote yes for this option, and either the option limit or the vote limit has been reached. + * + * @return bool Returns true if the option is locked for the current user, false otherwise + */ public function getIsLocked(): bool { return $this->getDeleted() || ($this->getUserVoteAnswer() !== Vote::VOTE_YES @@ -264,90 +445,51 @@ public function getIsLocked(): bool { } /** + * Check if this option is locked by the optionLimit: + * If an option limit is set + * and the user did not vote yes for this option + * and the count of yes votes for this option is EQUAL OR GREATER THAN the option limit, + * the option is locked for the current user + * * @return bool Returns true, if this option is locked by the optionLimit and the user has not voted yes */ public function getIsLockedByOptionLimit(): bool { return $this->getOptionLimit() && $this->getVotesYes() >= $this->getOptionLimit() && $this->getUserVoteAnswer() !== Vote::VOTE_YES; } + /** + * Check if this option is locked by the voteLimit: + * If a vote limit is set + * and the user did not vote yes for this option + * and the count of yes votes of the current user is EQUAL OR GREATER THAN the vote limit, + * the option is locked for the current user + * + * @return bool Returns true, if this option is locked by the voteLimit + */ public function getIsLockedByVotesLimit(): bool { - // IF a vote limit is set - // AND the user did not vote yes for this option - // AND the count of yes votes of the current user is EQUAL OR GREATER THAN the vote limit - // return true (locked option for current user) return $this->getVoteLimit() && $this->getUserCountYesVotes() >= $this->getVoteLimit(); } - public function getOrder(): int { - if ($this->timestamp) { - return $this->getTimestamp(); - } - return $this->order; - } - - // alias of getOwner() - public function getUserId(): string { - return $this->getOwner(); - } - - public function getDateStringLocalized(DateTimeZone $timeZone, IL10N $l10n): string { - $mutableFrom = DateTime::createFromImmutable($this->getDateObjectFrom($timeZone)); - $mutableTo = DateTime::createFromImmutable($this->getDateObjectTo($timeZone)); - $dayLongSecond = new DateInterval('PT1S'); - $sameDay = $this->getDateObjectFrom($timeZone)->format('Y-m-d') === $this->getDateObjectTo($timeZone)->format('Y-m-d'); - - // If duration is zero, the option represents a moment with day and time - if ($this->getDuration() === 0) { - return (string)$l10n->l('datetime', $mutableFrom); - } - - $dateTimeFrom = $l10n->l('datetime', $mutableFrom); - $dateTimeTo = $l10n->l('datetime', $mutableTo); - - // If the option spans over on or more whole days, the option represents only the days without time - // adjust the end by substracting a second, to represent the last moment at the day and not the first moment of the following day - // which is calculated by adding the duration - if ($this->getDaylong($timeZone)) { - $dateTimeFrom = $l10n->l('date', $mutableFrom); - $dateTimeTo = $l10n->l('date', $mutableTo->sub($dayLongSecond)); - // if start and end day are identiacal, just return the start day - if ($dateTimeFrom === $dateTimeTo) { - return (string)$dateTimeFrom; - } - } - - if ($sameDay) { - $dateTimeTo = $l10n->l('time', $mutableTo); - } - - return (string)$dateTimeFrom . ' - ' . (string)$dateTimeTo; - } - /** - * Check, if the date option spans one or more whole days (from 00:00 to 24:00) + * Set option's text, date and time, and duration from a SimpleOption instance. + * This will also sync the option to update the order and hash. + * + * @param SimpleOption $simpleOption The SimpleOption instance to set the option's attributes from + * @param string $pollTypeHint The type of the poll to determine which attributes to set. If empty, all attributes will be set. + * @return Option Returns the Option instance for method chaining */ - private function getDaylong(DateTimeZone $timeZone): bool { - $from = $this->getDateObjectFrom($timeZone); - $to = $this->getDateObjectTo($timeZone); - $dateInterval = $from->diff($to); - - if ( - $this->getDuration() > 0 - && $from->format('H') === '00' - && $dateInterval->h + $dateInterval->i + $dateInterval->h === 0 - ) { - return true; + public function setFromSimpleOption(SimpleOption $simpleOption, string $pollTypeHint = ''): self { + if ($pollTypeHint === Poll::TYPE_DATE) { + $this->setDateTime($simpleOption->getDateTime()); + $this->setInterval($simpleOption->getInterval()); + } elseif ($pollTypeHint === Poll::TYPE_TEXT) { + $this->setText($simpleOption->getText() ?? ''); + } else { + // Without poll type hint, set all properties + $this->setText($simpleOption->getText() ?? ''); + $this->setDateTime($simpleOption->getDateTime()); + $this->setInterval($simpleOption->getInterval()); } - return false; - } - - private function getDateObjectFrom(DateTimeZone $timeZone): DateTimeImmutable { - $dateTime = (new DateTimeImmutable())->setTimestamp($this->getTimestamp()); - return $dateTime->setTimezone($timeZone); - } - - private function getDateObjectTo(DateTimeZone $timeZone): DateTimeImmutable { - $dateTime = (new DateTimeImmutable())->setTimestamp($this->getTimestamp() + $this->getDuration()); - return $dateTime->setTimezone($timeZone); + return $this; } } diff --git a/lib/Db/Poll.php b/lib/Db/Poll.php index 61f2d29949..5a5418b8e9 100644 --- a/lib/Db/Poll.php +++ b/lib/Db/Poll.php @@ -14,7 +14,6 @@ use OCA\Polls\Helper\Container; use OCA\Polls\Model\Settings\AppSettings; use OCA\Polls\Model\Settings\SystemSettings; -use OCA\Polls\Model\User\User; use OCA\Polls\Model\UserBase; use OCA\Polls\UserSession; use OCP\IURLGenerator; @@ -65,6 +64,7 @@ * @method void setMiscSettings(string $value) * @method string getVotingVariant() * @method void setVotingVariant(string $value) + * @method void setTimezoneName(?string $value) * * Magic functions for joined columns * @method string getShareToken() @@ -162,6 +162,7 @@ class Poll extends EntityWithUser implements JsonSerializable { protected int $lastInteraction = 0; protected ?string $miscSettings = ''; protected string $votingVariant = ''; + protected ?string $timezoneName = null; // joined columns protected ?int $isCurrentUserLocked = 0; @@ -209,6 +210,16 @@ public function __construct() { $this->userSession = Container::queryClass(UserSession::class); } + public function __clone() { + $this->setId(0); + $this->setTitle('Clone of ' . $this->getTitle()); + $this->setDeleted(0); + $this->setCreated(time()); + $this->setAccess(self::ACCESS_PRIVATE); + // deanonymize cloned polls by default, to avoid locked anonymous polls + $this->setAnonymous(0); + } + /** * @return array * @@ -237,7 +248,7 @@ public function getStatusArray(): array { 'created' => $this->getCreated(), 'isAnonymous' => boolval($this->getAnonymous()), 'isArchived' => boolval($this->getDeleted()), - 'isExpired' => $this->getExpired(), + 'isExpired' => $this->getIsExpired(), 'isRealAnonymous' => $this->getAnonymous() < 0, 'relevantThreshold' => $this->getRelevantThreshold(), 'deletionDate' => $this->getDeletionDate(), @@ -268,6 +279,7 @@ public function getConfigurationArray(): array { 'showResults' => $this->getShowResults(), 'title' => $this->getTitle(), 'useNo' => boolval($this->getUseNo()), + 'timezoneName' => $this->getTimezoneName(), ]; } @@ -336,10 +348,11 @@ public function deserializeArray(array $pollConfiguration): self { $this->setUseNo($pollConfiguration['useNo'] ?? $this->getUseNo()); $this->setOptionLimit($pollConfiguration['maxVotesPerOption'] ?? $this->getOptionLimit()); $this->setVoteLimit($pollConfiguration['maxVotesPerUser'] ?? $this->getVoteLimit()); + $this->setTimezoneName($pollConfiguration['timezoneName'] ?? $this->getTimezoneName()); return $this; } - public function getExpired(): bool { + public function getIsExpired(): bool { $compareTime = time(); $expiry = $this->getExpire(); @@ -353,6 +366,16 @@ public function getPollOwnerId() { return $this->getOwner(); } + /** + * @psalm-return non-empty-string|null + */ + public function getTimezoneName(): ?string { + if ($this->timezoneName === '') { + return null; + } + return $this->timezoneName; + } + public function getUserRole(): string { if ($this->getCurrentUserIsEntityUser()) { return self::ROLE_OWNER; @@ -473,6 +496,7 @@ private function getGroupShares(): array { * @psalm-return list */ public function getPollGroups(): array { + /** @psalm-suppress RiskyTruthyFalsyComparison */ if (!$this->pollGroups) { return []; } @@ -487,6 +511,7 @@ public function getPollGroups(): array { * @psalm-return list */ public function getPollGroupUserShares(): array { + /** @psalm-suppress RiskyTruthyFalsyComparison */ if (!$this->pollGroupUserShares) { return []; } @@ -503,7 +528,7 @@ private function getAccess(): string { return $this->access; } - private function getProposalsExpired(): bool { + private function getProposalPeriodIsExpired(): bool { return ( $this->getProposalsExpire() > 0 && $this->getProposalsExpire() < time() @@ -798,7 +823,7 @@ private function getAllowAddOptions(): bool { } // Request for option proposals is expired, deny - if ($this->getProposalsExpired()) { + if ($this->getProposalPeriodIsExpired()) { return false; } @@ -826,21 +851,14 @@ private function getAllowDeleteOption(): bool { * Is current user allowed to confirm options */ private function getAllowConfirmOption(): bool { - return $this->getAllowEditPoll() && $this->getExpired(); + return $this->getAllowEditPoll() && $this->getIsExpired(); } /** * Is current user allowed to confirm options */ private function getAllowReorderOptions(): bool { - return $this->getAllowEditPoll() && !$this->getExpired() && $this->getType() === Poll::TYPE_TEXT; - } - - /** - * Compare $userId with current user's id - */ - public function matchUser(string $userId): bool { - return (bool)$this->userSession->getCurrentUser()->getId() && $this->userSession->getCurrentUser()->getId() === $userId; + return $this->getAllowEditPoll() && !$this->getIsExpired() && $this->getType() === Poll::TYPE_TEXT; } /** @@ -931,7 +949,7 @@ private function getAllowVote(): bool { } // deny votes, if poll is expired - return !$this->getExpired(); + return !$this->getIsExpired(); } /** @@ -961,7 +979,7 @@ private function getAllowShowResults(): bool { } // show results, when poll is closed - if ($this->getShowResults() === Poll::SHOW_RESULTS_CLOSED && $this->getExpired()) { + if ($this->getShowResults() === Poll::SHOW_RESULTS_CLOSED && $this->getIsExpired()) { return true; } // return poll settings diff --git a/lib/Db/V5/DbManager.php b/lib/Db/V6/DbManager.php similarity index 95% rename from lib/Db/V5/DbManager.php rename to lib/Db/V6/DbManager.php index bef8bc3ad2..37444731d8 100644 --- a/lib/Db/V5/DbManager.php +++ b/lib/Db/V6/DbManager.php @@ -6,7 +6,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Polls\Db\V5; +namespace OCA\Polls\Db\V6; use Doctrine\DBAL\Schema\Schema; use Exception; @@ -170,6 +170,12 @@ protected function checkPrecondition(string $tableName, array|string $columnName return; } + /** + * Format the caller of a method for logging purposes. + * + * @param int $skip number of stack frames to skip (default is 1, which means the direct caller) + * @return string formatted caller information in the format "ClassName->methodName@file:line" + */ private static function formatCaller(int $skip = 1): string { $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, $skip + 2); $f = $bt[$skip + 0] ?? null; // Frame of this method (0) diff --git a/lib/Db/V5/IndexManager.php b/lib/Db/V6/IndexManager.php similarity index 99% rename from lib/Db/V5/IndexManager.php rename to lib/Db/V6/IndexManager.php index 1e5be67f71..597e103800 100644 --- a/lib/Db/V5/IndexManager.php +++ b/lib/Db/V6/IndexManager.php @@ -6,11 +6,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Polls\Db\V5; +namespace OCA\Polls\Db\V6; use Doctrine\DBAL\Schema\Exception\IndexDoesNotExist; use Exception; -use OCA\Polls\Migration\V5\TableSchema; +use OCA\Polls\Migration\V6\TableSchema; use OCP\IConfig; use OCP\IDBConnection; use Psr\Log\LoggerInterface; diff --git a/lib/Db/V5/TableManager.php b/lib/Db/V6/TableManager.php similarity index 99% rename from lib/Db/V5/TableManager.php rename to lib/Db/V6/TableManager.php index b05972f94b..47b7905a56 100644 --- a/lib/Db/V5/TableManager.php +++ b/lib/Db/V6/TableManager.php @@ -6,7 +6,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Polls\Db\V5; +namespace OCA\Polls\Db\V6; use Doctrine\DBAL\Types\Type; use Exception; @@ -20,7 +20,7 @@ use OCA\Polls\Db\Watch; use OCA\Polls\Exceptions\PreconditionException; use OCA\Polls\Helper\Hash; -use OCA\Polls\Migration\V5\TableSchema; +use OCA\Polls\Migration\V6\TableSchema; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IConfig; use OCP\IDBConnection; @@ -691,7 +691,7 @@ private function updateOptionHashes(): array { try { // if the option's hash differs from $actualHash update the option if ($option->getPollOptionHash() !== Hash::getOptionHash($option->getPollId(), $option->getPollOptionText())) { - $option->setPollOptionHash(Hash::getOptionHash($option->getPollId(), $option->getPollOptionText())); + $option->setText($option->getPollOptionText()); $option = $this->optionMapper->update($option); $updated++; } diff --git a/lib/Exceptions/InsufficientAttributesException.php b/lib/Exceptions/InsufficientAttributesException.php index 98eb17d8e7..5c61bb61e1 100644 --- a/lib/Exceptions/InsufficientAttributesException.php +++ b/lib/Exceptions/InsufficientAttributesException.php @@ -14,6 +14,6 @@ class InsufficientAttributesException extends Exception { public function __construct( string $e = 'Attribut constraints not met', ) { - parent::__construct($e, Http::STATUS_CONFLICT); + parent::__construct($e, Http::STATUS_BAD_REQUEST); } } diff --git a/lib/Listener/AddMissingIndicesListener.php b/lib/Listener/AddMissingIndicesListener.php index a1a9ad3b84..15f6c003a9 100644 --- a/lib/Listener/AddMissingIndicesListener.php +++ b/lib/Listener/AddMissingIndicesListener.php @@ -8,7 +8,7 @@ namespace OCA\Polls\Listener; -use OCA\Polls\Migration\V5\TableSchema; +use OCA\Polls\Migration\V6\TableSchema; use OCP\DB\Events\AddMissingIndicesEvent; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; diff --git a/lib/Migration/FixVotes.php b/lib/Migration/FixVotes.php index 65c05f6a69..55155cdb6f 100644 --- a/lib/Migration/FixVotes.php +++ b/lib/Migration/FixVotes.php @@ -10,7 +10,7 @@ namespace OCA\Polls\Migration; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/RepairSteps/CleanTables.php b/lib/Migration/RepairSteps/CleanTables.php index 5c49bcccdf..f8ed071318 100644 --- a/lib/Migration/RepairSteps/CleanTables.php +++ b/lib/Migration/RepairSteps/CleanTables.php @@ -12,7 +12,7 @@ use Doctrine\DBAL\Schema\Schema; use Exception; use OCA\Polls\Db\Poll; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/RepairSteps/CreateIndices.php b/lib/Migration/RepairSteps/CreateIndices.php index 33a708b8aa..5bb00368df 100644 --- a/lib/Migration/RepairSteps/CreateIndices.php +++ b/lib/Migration/RepairSteps/CreateIndices.php @@ -11,7 +11,7 @@ use Doctrine\DBAL\Schema\Schema; use OCA\Polls\Db\Share; -use OCA\Polls\Db\V5\IndexManager; +use OCA\Polls\Db\V6\IndexManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/RepairSteps/CreateTables.php b/lib/Migration/RepairSteps/CreateTables.php index cd01e6512a..2623c882df 100644 --- a/lib/Migration/RepairSteps/CreateTables.php +++ b/lib/Migration/RepairSteps/CreateTables.php @@ -10,7 +10,7 @@ namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/RepairSteps/DropOrphanedColumns.php b/lib/Migration/RepairSteps/DropOrphanedColumns.php index b8722bd8c0..1960d2ea11 100644 --- a/lib/Migration/RepairSteps/DropOrphanedColumns.php +++ b/lib/Migration/RepairSteps/DropOrphanedColumns.php @@ -10,7 +10,7 @@ namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/RepairSteps/DropOrphanedTables.php b/lib/Migration/RepairSteps/DropOrphanedTables.php index f1947a0e58..cb1d8f502d 100644 --- a/lib/Migration/RepairSteps/DropOrphanedTables.php +++ b/lib/Migration/RepairSteps/DropOrphanedTables.php @@ -10,7 +10,7 @@ namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/RepairSteps/FixNullish.php b/lib/Migration/RepairSteps/FixNullish.php index 2964051471..c02c3172fd 100644 --- a/lib/Migration/RepairSteps/FixNullish.php +++ b/lib/Migration/RepairSteps/FixNullish.php @@ -9,7 +9,7 @@ namespace OCA\Polls\Migration\RepairSteps; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/RepairSteps/Install.php b/lib/Migration/RepairSteps/Install.php index e4a60b5afe..322fea6ce8 100644 --- a/lib/Migration/RepairSteps/Install.php +++ b/lib/Migration/RepairSteps/Install.php @@ -10,7 +10,7 @@ namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\V5\IndexManager; +use OCA\Polls\Db\V6\IndexManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/RepairSteps/MigratePublicToOpen.php b/lib/Migration/RepairSteps/MigratePublicToOpen.php index 2c1115cc18..d0a9f510a0 100644 --- a/lib/Migration/RepairSteps/MigratePublicToOpen.php +++ b/lib/Migration/RepairSteps/MigratePublicToOpen.php @@ -9,7 +9,7 @@ namespace OCA\Polls\Migration\RepairSteps; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/RepairSteps/RemoveIndices.php b/lib/Migration/RepairSteps/RemoveIndices.php index 381d94d2fd..2eed694796 100644 --- a/lib/Migration/RepairSteps/RemoveIndices.php +++ b/lib/Migration/RepairSteps/RemoveIndices.php @@ -9,7 +9,7 @@ namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\V5\IndexManager; +use OCA\Polls\Db\V6\IndexManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/RepairSteps/RemoveObsoleteMigrations.php b/lib/Migration/RepairSteps/RemoveObsoleteMigrations.php index 0844f97e85..1a1d821dea 100644 --- a/lib/Migration/RepairSteps/RemoveObsoleteMigrations.php +++ b/lib/Migration/RepairSteps/RemoveObsoleteMigrations.php @@ -9,7 +9,7 @@ namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/RepairSteps/SetLastInteraction.php b/lib/Migration/RepairSteps/SetLastInteraction.php index 29a7b3d8cf..5a407ce29d 100644 --- a/lib/Migration/RepairSteps/SetLastInteraction.php +++ b/lib/Migration/RepairSteps/SetLastInteraction.php @@ -9,7 +9,7 @@ namespace OCA\Polls\Migration\RepairSteps; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCA\Polls\Db\WatchMapper; use OCP\IDBConnection; use OCP\Migration\IOutput; diff --git a/lib/Migration/RepairSteps/UpdateHashes.php b/lib/Migration/RepairSteps/UpdateHashes.php index 74beaefe43..6ead8b3346 100644 --- a/lib/Migration/RepairSteps/UpdateHashes.php +++ b/lib/Migration/RepairSteps/UpdateHashes.php @@ -9,7 +9,7 @@ namespace OCA\Polls\Migration\RepairSteps; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/RepairSteps/UpdateInteraction.php b/lib/Migration/RepairSteps/UpdateInteraction.php index 7df0ef9591..d63adb2c2b 100644 --- a/lib/Migration/RepairSteps/UpdateInteraction.php +++ b/lib/Migration/RepairSteps/UpdateInteraction.php @@ -9,7 +9,7 @@ namespace OCA\Polls\Migration\RepairSteps; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\TableManager; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; diff --git a/lib/Migration/V5/TableSchema.php b/lib/Migration/V6/TableSchema.php similarity index 97% rename from lib/Migration/V5/TableSchema.php rename to lib/Migration/V6/TableSchema.php index fefea712dc..ac21f363f0 100644 --- a/lib/Migration/V5/TableSchema.php +++ b/lib/Migration/V6/TableSchema.php @@ -6,7 +6,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Polls\Migration\V5; +namespace OCA\Polls\Migration\V6; use OCA\Polls\Db\Comment; use OCA\Polls\Db\Log; @@ -254,15 +254,18 @@ abstract class TableSchema { 'last_interaction' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'misc_settings' => ['type' => Types::TEXT, 'options' => ['notnull' => false, 'default' => null, 'length' => 65535]], 'voting_variant' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => 'simple', 'length' => 64]], + 'timezone_name' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => null, 'length' => 64]], ], Option::TABLE => [ 'id' => ['type' => Types::BIGINT, 'options' => ['autoincrement' => true, 'notnull' => true, 'length' => 20]], 'poll_id' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'poll_option_text' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 1024]], 'poll_option_hash' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => '', 'length' => 32]], + 'order' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'timestamp' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'duration' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], - 'order' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], + 'iso_timestamp' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => null, 'length' => 32]], + 'iso_duration' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => null, 'length' => 32]], 'confirmed' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'owner' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 256]], 'released' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], diff --git a/lib/Migration/Version080301Date20250822153002.php b/lib/Migration/Version080301Date20250822153002.php index 9d75d1203d..abaa299611 100644 --- a/lib/Migration/Version080301Date20250822153002.php +++ b/lib/Migration/Version080301Date20250822153002.php @@ -8,8 +8,8 @@ namespace OCA\Polls\Migration; -use OCA\Polls\Db\V5\IndexManager; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\IndexManager; +use OCA\Polls\Db\V6\TableManager; use OCP\DB\ISchemaWrapper; use OCP\IDBConnection; use OCP\Migration\IOutput; diff --git a/lib/Migration/Version080307Date20250826231102.php b/lib/Migration/Version080307Date20250826231102.php index 672fde07b2..b10300348c 100644 --- a/lib/Migration/Version080307Date20250826231102.php +++ b/lib/Migration/Version080307Date20250826231102.php @@ -9,8 +9,8 @@ namespace OCA\Polls\Migration; use OCA\Polls\Db\Share; -use OCA\Polls\Db\V5\IndexManager; -use OCA\Polls\Db\V5\TableManager; +use OCA\Polls\Db\V6\IndexManager; +use OCA\Polls\Db\V6\TableManager; use OCP\DB\ISchemaWrapper; use OCP\IDBConnection; use OCP\Migration\IOutput; diff --git a/lib/Migration/Version090000Date20260302211900.php b/lib/Migration/Version090000Date20260302211900.php new file mode 100644 index 0000000000..cc73aa91b7 --- /dev/null +++ b/lib/Migration/Version090000Date20260302211900.php @@ -0,0 +1,118 @@ +output = $output; + $this->logInfo('Prepare migration- no operation needed'); + } + + /** + * This method is executing the actual schema change based on the definition of TableSchema + * $schemaClosure The `\Closure` returns an `ISchemaWrapper` + * @param IOutput $output + * @param \Closure $schemaClosure + * @param array $options + * @return ISchemaWrapper|null + */ + public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options): ?ISchemaWrapper { + $this->output = $output; + $this->schema = $schemaClosure(); + $this->tableManager->setConnection($this->connection); + $this->tableManager->setSchema($this->schema); + + $message = $this->tableManager->createTables(); + $this->logInfo($message, 'runMigration: '); + + if (!($this->schema instanceof ISchemaWrapper)) { + return null; + } + + return $this->schema; + } + + /** + * This method is called after the schema change. + * It is used to perform any post-migration steps, such as migrating options to a hash. + * Main steps: + * 1. Ensure that option hashes are created correctly for options and votes + * + * @param IOutput $output + * @param \Closure $schemaClosure + * @param array $options + * @return void + */ + public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void { + $this->output = $output; + $this->logInfo('Post migration steps- no operation needed'); + + // Clean up tables before creating indices and foreign keys + + } + + /** + * Logs the given message to the output. + * + * @param string|array $message The message to log, can be a string or an array of strings. + * @param string $prefix Optional prefix for the message, defaults to an empty string. + * @return void + */ + private function logInfo(string|array $message, string $prefix = ''): void { + if ($this->output) { + if (is_array($message)) { + foreach ($message as $msg) { + $this->output->info($prefix . 'Polls - ' . $msg); + } + } else { + $this->output->info($prefix . 'Polls - ' . $message); + } + } + } + +} diff --git a/lib/Model/DateInterval.php b/lib/Model/DateInterval.php new file mode 100644 index 0000000000..d0c7716cb6 --- /dev/null +++ b/lib/Model/DateInterval.php @@ -0,0 +1,86 @@ +format('P%yY%mM%dDT%hH%iM%sS')); + } else { + parent::__construct('PT0S'); + } + } + + public function isZeroDuration(): bool { + return $this->getSeconds() === 0; + } + + /** + * Get the ISO 8601 duration string representation of this DateInterval. + * If the interval is zero (all components are zero), this method returns null. + * + * @return string|null The ISO 8601 duration string or null if the interval is zero. + */ + public function getISO(): ?string { + if ( + $this->y === 0 + && $this->m === 0 + && $this->d === 0 + && $this->h === 0 + && $this->i === 0 + && $this->s === 0 + ) { + return null; + } + return $this->format('P%yY%mM%dDT%hH%iM%sS'); + } + + /** + * Determine if this DateInterval represents one or more full days. + * + * @return bool True if the interval represents a full day or more, false otherwise. + */ + public function isDayInterval(): bool { + return + ($this->y + $this->m + $this->d) !== 0 + && $this->h === 0 + && $this->i === 0 + && $this->s === 0; + } + + /** + * Calculate the total number of seconds represented by this DateInterval, + * using a base date to account for months and years which can vary in length. + * If no base date is provided, the current date and time will be used as the base. + * + * @param DateTimeImmutable $baseDate The date from which to calculate the interval in seconds. + * @return int The total number of seconds represented by this DateInterval. + */ + public function getSeconds(DateTimeImmutable $baseDate = new DateTimeImmutable()): int { + return ($baseDate->add($this)->getTimestamp() - $baseDate->getTimestamp()); + } +} diff --git a/lib/Model/DateTimeImmutable.php b/lib/Model/DateTimeImmutable.php new file mode 100644 index 0000000000..bb91ca72db --- /dev/null +++ b/lib/Model/DateTimeImmutable.php @@ -0,0 +1,61 @@ +format(self::DEFAULT_DATE_TIME_FORMAT)); + } elseif (is_int($dateValue) && $dateValue > 0) { + // Treat as Unix timestamp (in seconds) and create DateTimeImmutable + parent::__construct('@' . strval($dateValue)); + } elseif (is_string($dateValue) && !empty($dateValue)) { + // Treat as ISO 8601 date string and create DateTimeImmutable + parent::__construct($dateValue); + } else { + // Default to Unix epoch if input is null, empty string, or invalid + parent::__construct('@0'); + } + } + + public function isStartOfDay(): bool { + return $this->format('H:i:s') === '00:00:00'; + } + + /** + * Get the ISO 8601 timestamp string representation of this DateTimeImmutable. + * If the date is the Unix epoch (1970-01-01T00:00:00+00:00), this method returns null, + * + * @return string|null The ISO 8601 duration string or null if the interval is zero. + */ + public function getISO(): ?string { + if ($this->getTimestamp() === 0) { + return null; + } + return $this->format(self::DEFAULT_DATE_TIME_FORMAT); + } + + public function getISODate(): ?string { + if ($this->getTimestamp() === 0) { + return null; + } + return $this->format('Y-m-d'); + } +} diff --git a/lib/Model/DateTimeIntervalText.php b/lib/Model/DateTimeIntervalText.php new file mode 100644 index 0000000000..cfde184c2b --- /dev/null +++ b/lib/Model/DateTimeIntervalText.php @@ -0,0 +1,114 @@ +start = new DateTimeImmutable(); + $this->interval = new DateInterval('PT0S'); + } + + public function setStartDateTime(DateTimeImmutable $start): self { + $this->start = $start; + return $this; + } + + public function setInterval(DateInterval $interval): self { + $this->interval = $interval; + return $this; + } + + public function getDateTime(): DateTimeImmutable { + return $this->start; + } + + public function getInterval(): DateInterval { + return $this->interval; + } + + public function getEndDateTime(): DateTimeImmutable { + return $this->start->add($this->interval); + } + + public function getLocalizedDateTimeString(): string { + $dateTimeStart = $this->getDateTime(); + + if ($this->getInterval()->isZeroDuration()) { + return $this->getDateTimeString($dateTimeStart, $dateTimeStart->isStartOfDay() ? self::FORMAT_DATE : self::FORMAT_DATE_TIME); + } + + $dateTimeEnd = $this->getEndDateTime(); + + // Daylong events (start at 00:00:00 and lasts one or more days) + if ($this->getInterval()->isDayInterval() + && $dateTimeStart->isStartOfDay() + ) { + + // Adjust the end date by minus one day + $dateTimeEnd = $dateTimeEnd->sub(new DateInterval('P1D')); + + if ( + // start and end are on the same day (Interval is set to one day) + $dateTimeEnd->getISODate() === $dateTimeStart->getISODate()) { + // start and end are on the same day + return $this->getDateTimeString($dateTimeStart, self::FORMAT_DATE); + } + + return $this->getDateTimeString($dateTimeStart, self::FORMAT_DATE) + . ' – ' + . $this->getDateTimeString($dateTimeEnd, self::FORMAT_DATE); + } + + // From here on we have a time range that does not span over whole days + // use initial end date and add the time information + + if ($dateTimeEnd->getISODate() === $dateTimeStart->getISODate()) { + // If the end date is the same day as the start date, we can omit the date in the end date and only show the time + return $this->getDateTimeString($dateTimeStart) + . ' – ' + . $this->getDateTimeString($dateTimeEnd, self::FORMAT_TIME); + } + + return $this->getDateTimeString($dateTimeStart) + . ' – ' + . $this->getDateTimeString($dateTimeEnd); + } + + /** + * Helper method to get a date string for a given DateTimeImmutable and format, + * using the provided localization service if available. + * If no localization service is provided, it returns an ISO 8601 formatted date string in UTC. + * + * @param DateTimeImmutable $dateTime The date and time to format + * @param string $format Format key to use for localization (e.g., 'datetime', 'date', 'time') + * @return string Localized date string + */ + private function getDateTimeString( + DateTimeImmutable $dateTime, + string $format = self::FORMAT_DATE_TIME, + ): string { + return (string)$this->l10n->l( + $format, + DateTime::createFromImmutable($dateTime) + ); + } + +} diff --git a/lib/Model/Mail/ConfirmationMail.php b/lib/Model/Mail/ConfirmationMail.php index ad5d326ff7..a06ef65ee4 100644 --- a/lib/Model/Mail/ConfirmationMail.php +++ b/lib/Model/Mail/ConfirmationMail.php @@ -12,6 +12,7 @@ use OCA\Polls\AppConstants; use OCA\Polls\Db\Option; use OCA\Polls\Db\Poll; +use OCA\Polls\Model\DateTimeIntervalText; class ConfirmationMail extends MailBase { protected const TEMPLATE_CLASS = AppConstants::APP_ID . '.Confirmation'; @@ -46,9 +47,24 @@ protected function buildBody(): void { $this->l10n->n('Confirmed option:', 'Confirmed options:', count($this->confirmedOptions)) ); + $localizedDateSpan = new DateTimeIntervalText($this->l10n); + foreach ($this->confirmedOptions as $option) { if ($this->poll->getType() === Poll::TYPE_DATE) { - $this->emailTemplate->addBodyListItem($option->getDateStringLocalized($this->recipient->getTimeZone(), $this->l10n)); + $dateTime = $option->getDateTime(); + + if ($dateTime->getISO() === null) { + continue; + } + + $localizedDateSpan + ->setStartDateTime($option->getDateTime()) + ->setInterval($option->getInterval()); + + $this->emailTemplate->addBodyListItem( + $localizedDateSpan->getLocalizedDateTimeString() + ); + } else { $this->emailTemplate->addBodyListItem($option->getPollOptionText()); } diff --git a/lib/Model/Sequence.php b/lib/Model/Sequence.php index f5054247e2..e1ef31c9e8 100644 --- a/lib/Model/Sequence.php +++ b/lib/Model/Sequence.php @@ -8,10 +8,22 @@ namespace OCA\Polls\Model; -use DateTime; -use DateTimeZone; use JsonSerializable; +use OCA\Polls\Exceptions\InsufficientAttributesException; +/** + * @psalm-type SequenceUnitArray = array{ + * id: string, + * name: string, + * timeOption: bool, + * } + * + * @psalm-type SequenceArray = array{ + * repetitions: int, + * stepWidth: int, + * unit: SequenceUnitArray, + * } + */ class Sequence implements JsonSerializable { public const REPETITION_YEAR = 'year'; public const REPETITION_MONTH = 'month'; @@ -20,8 +32,7 @@ class Sequence implements JsonSerializable { public const REPETITION_HOUR = 'hour'; public const REPETITION_MINUTE = 'minute'; - protected DateTimeZone $timeZone; - protected ?int $baseTimeStamp = null; + protected DateTimeImmutable $baseDateTimeImmutable; /** * @param SequenceUnit $unit The unit of time for the sequence * @param int $stepWidth The step width for the sequence according to the unit @@ -32,7 +43,6 @@ public function __construct( protected int $stepWidth, protected int $repetitions, ) { - $this->timeZone = new DateTimeZone(date_default_timezone_get()); } public function jsonSerialize(): array { @@ -55,35 +65,21 @@ public function getRepetitions(): int { return $this->repetitions; } - public function setTimeZone(DateTimeZone $timeZone): void { - $this->timeZone = $timeZone; - } - - private function getTimeZone(): DateTimeZone { - return $this->timeZone; + public function setBaseDateTime(DateTimeImmutable $baseDateTime): void { + $this->baseDateTimeImmutable = $baseDateTime; } - public function setBaseTimeStamp(int $baseTimeStamp): void { - $this->baseTimeStamp = $baseTimeStamp; - } - - private function getBaseTimeStamp(): int { - if ($this->baseTimeStamp === null) { - throw new \Exception('Base timestamp not set'); + public function getOccurence(int $index): DateTimeImmutable { + $occurence = $this->baseDateTimeImmutable->modify($this->getStepWidth() * $index . ' ' . $this->getUnit()->getId()); + if (!$occurence) { + throw new InsufficientAttributesException('Failed to calculate occurrence for index ' . $index); } - return $this->baseTimeStamp; - } - - public function getOccurence(int $index): int { - return ((new DateTime()) - ->setTimestamp($this->getBaseTimeStamp()) - ->setTimezone($this->getTimeZone()) - ->modify($this->getStepWidth() * $index . ' ' . $this->getUnit()->getId())) - ->getTimestamp(); + return $occurence; } /** - * @param array $sequence The sequence to create + * Create a Sequence instance from an array + * @param SequenceArray $sequence The sequence to create */ public static function fromArray(?array $sequence): Sequence { if ($sequence === null) { diff --git a/lib/Model/SimpleOption.php b/lib/Model/SimpleOption.php index 9810c6fa7a..9a61c6c012 100644 --- a/lib/Model/SimpleOption.php +++ b/lib/Model/SimpleOption.php @@ -10,56 +10,122 @@ use JsonSerializable; +/** + * @psalm-type SimpleOptionsArray = array{ + * text?: string, + * timestamp?: int, + * isoDuration?:? string, + * duration?: int, + * isoTimestamp?: ?string, + * } + */ class SimpleOption implements JsonSerializable { - /** - * @param string $text The text of the option - * @param int $timestamp The timestamp for the datepoll - * @param int $duration The duration of the option - * @param int $order The order of the option - */ - public function __construct( - protected string $text, - protected ?int $timestamp, - protected ?int $duration = 0, - protected ?int $order = 0, - ) { + protected ?string $text = null; + protected DateTimeImmutable $dateTime; + protected DateInterval $interval; + + public function __construct() { + $this->dateTime = new DateTimeImmutable(null); + $this->interval = new DateInterval(null); } public function jsonSerialize(): array { return [ - 'text' => $this->text, - 'timestamp' => $this->timestamp ?? 0, - 'duration' => $this->duration ?? 0, - 'order' => $this->order ?? 0, + 'text' => $this->getText(), + 'isoDuration' => $this->getIsoDuration(), + 'timestamp' => $this->getDateTime()->getTimestamp(), ]; } - public function getText(): string { - return $this->text; + /** + * Set the timestamp of the option as DateTimeImmutable, int, string or null + * + * @param null|int|string|DateTimeImmutable $dateTime The timestamp for the datepoll + * @return self Return the SimpleOption instance for method chaining + */ + public function setDateTime(null|int|string|DateTimeImmutable $dateTime): self { + $this->dateTime = new DateTimeImmutable($dateTime); + return $this; + } + + /** + * Set the duration of the option as DateInterval, int, string or null + * + * @param null|int|string|DateInterval $duration The duration of the option + * @return self Return the SimpleOption instance for method chaining + */ + public function setInterval(null|int|string|DateInterval $duration): self { + $this->interval = new DateInterval($duration); + return $this; + } + + /** + * Set the text of the option + * @param string $text The text of the option + * @return self Return the SimpleOption instance for method chaining + */ + public function setText(string $text): self { + $this->text = $text; + return $this; } - public function getTimestamp(): int { - return $this->timestamp ?? 0; + /** + * Get the timestamp of the option as DateTimeImmutable + * @return DateTimeImmutable + */ + public function getDateTime(): DateTimeImmutable { + return $this->dateTime; } - public function getDuration(): int { - return $this->duration ?? 0; + /** + * Get the duration of the option as DateInterval + * @return DateInterval + */ + public function getInterval(): DateInterval { + return $this->interval; } - public function getOrder(): int { - return $this->order ?? 0; + /** + * Get the duration of the option as ISO 8601 string or null if not set + * @return string|null + */ + public function getIsoDuration(): ?string { + return $this->interval->getISO(); } - public function setOrder(int $order): void { - $this->order = $order; + /** + * Get the text of the option + */ + public function getText(): ?string { + return $this->text; } - public static function fromArray(array $option): SimpleOption { - return new SimpleOption( - $option['text'], - $option['timestamp'] ?? 0, - $option['duration'] ?? 0, - $option['order'] ?? 0, - ); + /** + * Create a SimpleOption from an array + * Note: timestamp takes precedence over isoTimestamp, + * isoDuration takes precedence over duration + * + * @param SimpleOptionsArray $option The array containing the option data + * @return SimpleOption The created SimpleOption + */ + public static function fromArray(array $option): self { + $simpleOption = new self(); + + if (isset($option['text'])) { + $simpleOption->text = $option['text']; + } + if (isset($option['timestamp'])) { + $simpleOption->setDateTime($option['timestamp']); + } elseif (isset($option['isoTimestamp'])) { + $simpleOption->setDateTime($option['isoTimestamp']); + } + + if (isset($option['isoDuration'])) { + $simpleOption->setInterval($option['isoDuration']); + } elseif (isset($option['duration'])) { + $simpleOption->setInterval($option['duration']); + } + + return $simpleOption; } } diff --git a/lib/Provider/ReferenceProvider.php b/lib/Provider/ReferenceProvider.php index dfe27afe21..9560e58c66 100644 --- a/lib/Provider/ReferenceProvider.php +++ b/lib/Provider/ReferenceProvider.php @@ -74,7 +74,7 @@ public function resolveReference(string $referenceText): ?IReference { $ownerId = $poll->getUser()->getId(); $ownerDisplayName = $poll->getUser()->getDisplayName(); $url = $poll->getVoteUrl(); - $expired = $poll->getExpired(); + $expired = $poll->getIsExpired(); $expiry = $poll->getExpire(); $participated = $poll->getCurrentUserVotes() ? true : false; diff --git a/lib/Service/CalendarService.php b/lib/Service/CalendarService.php index 9bad776092..58fbc4d5d4 100644 --- a/lib/Service/CalendarService.php +++ b/lib/Service/CalendarService.php @@ -9,13 +9,11 @@ namespace OCA\Polls\Service; -use DateInterval; -use DateTime; -use DateTimeImmutable; -use DateTimeZone; use OCA\Polls\Db\OptionMapper; use OCA\Polls\Db\Preferences; use OCA\Polls\Model\CalendarEvent; +use OCA\Polls\Model\DateInterval; +use OCA\Polls\Model\DateTimeImmutable; use OCA\Polls\UserSession; use OCP\Calendar\ICalendar; use OCP\Calendar\IManager as CalendarManager; @@ -69,23 +67,20 @@ private function getCalendarsForPrincipal(): void { * * @psalm-return array{from: DateTimeImmutable, to: DateTimeImmutable} */ - private function getTimerange(int $optionId, DateTimeZone $timezone): array { + private function getTimerange(int $optionId): array { $option = $this->optionMapper->find($optionId); + $optionDateTime = $option->getDateTime(); + $optionInterval = $option->getInterval(); + $searchIntervalBefore = new DateInterval("PT{$this->preferences->getCheckCalendarsHoursBefore()}H"); $searchIntervalAfter = new DateInterval("PT{$this->preferences->getCheckCalendarsHoursAfter()}H"); - $from = (new DateTime()) - ->setTimeZone($timezone) - ->setTimestamp($option->getTimestamp()) - ->sub($searchIntervalBefore); - $to = (new DateTime()) - ->setTimeZone($timezone) - ->setTimestamp($option->getTimestamp() + $option->getDuration()) - ->add($searchIntervalAfter); - return [ - 'from' => DateTimeImmutable::createFromMutable($from), - 'to' => DateTimeImmutable::createFromMutable($to), + 'from' => $optionDateTime + ->sub($searchIntervalBefore), + 'to' => $optionDateTime + ->add($optionInterval) + ->add($searchIntervalAfter) ]; } @@ -114,8 +109,7 @@ private function searchEventsByTimeRange(DateTimeImmutable $from, DateTimeImmuta * @psalm-return list */ public function getEvents(int $optionId): array { - $timezone = new DateTimeZone($this->userSession->getClientTimeZone()); - $timerange = $this->getTimerange($optionId, $timezone); + $timerange = $this->getTimerange($optionId); $events = []; $foundEvents = $this->searchEventsByTimeRange($timerange['from'], $timerange['to']); @@ -131,7 +125,7 @@ public function getEvents(int $optionId): array { continue; } - $calendarEvent = new CalendarEvent($event, $calendar, $timerange['from'], $timerange['to'], $timezone); + $calendarEvent = new CalendarEvent($event, $calendar, $timerange['from'], $timerange['to']); if ($calendarEvent->getOccurrences()) { for ($index = 0; $index < count($calendarEvent->getOccurrences()); $index++) { diff --git a/lib/Service/OptionService.php b/lib/Service/OptionService.php index f5572c367d..4a2a4c5f4c 100644 --- a/lib/Service/OptionService.php +++ b/lib/Service/OptionService.php @@ -8,7 +8,7 @@ namespace OCA\Polls\Service; -use DateTimeZone; +use InvalidArgumentException; use OCA\Polls\Db\Option; use OCA\Polls\Db\OptionMapper; use OCA\Polls\Db\Poll; @@ -21,7 +21,10 @@ use OCA\Polls\Event\OptionUpdatedEvent; use OCA\Polls\Event\PollOptionReorderedEvent; use OCA\Polls\Exceptions\ForbiddenException; +use OCA\Polls\Exceptions\InsufficientAttributesException; use OCA\Polls\Exceptions\InvalidPollTypeException; +use OCA\Polls\Model\DateInterval; +use OCA\Polls\Model\DateTimeImmutable; use OCA\Polls\Model\Sequence; use OCA\Polls\Model\SimpleOption; use OCA\Polls\UserSession; @@ -59,7 +62,8 @@ public function get(int $optionId): Option { * @psalm-return array */ public function list(int $pollId): array { - $this->getPoll($pollId, Poll::PERMISSION_POLL_ACCESS); + $this->getPoll($pollId) + ->request(Poll::PERMISSION_POLL_ACCESS); try { $this->options = $this->optionMapper->findByPoll($pollId, !$this->poll->getIsAllowed(Poll::PERMISSION_POLL_RESULTS_VIEW)); @@ -77,13 +81,11 @@ public function list(int $pollId): array { */ public function addWithSequenceAndAutoVote( int $pollId, - SimpleOption $option, + SimpleOption $simpleOption, bool $voteYes = false, ?Sequence $sequence = null, ): array { - - $newOption = $this->add($pollId, $option, $voteYes); - + $newOption = $this->add($pollId, $simpleOption, $voteYes); if ($sequence) { $repetitions = $this->sequence($newOption, $sequence, $voteYes); @@ -105,31 +107,37 @@ public function addWithSequenceAndAutoVote( * @param bool $voteYes Directly vote 'yes' for the new option * @return Option */ - public function add(int $pollId, SimpleOption $simpleOption, bool $voteYes = false): Option { - $this->getPoll($pollId, Poll::PERMISSION_OPTION_ADD); - - if ($this->poll->getType() === Poll::TYPE_TEXT) { - $simpleOption->setOrder($this->getHighestOrder($pollId) + 1); - } + public function add( + int $pollId, + SimpleOption $simpleOption, + bool $voteYes = false, + ): Option { + $this->getPoll($pollId)->request(Poll::PERMISSION_OPTION_ADD); // Build the new option $newOption = new Option(); - $newOption->setPollId($pollId); - $newOption->setFromSimpleOption($simpleOption); + $newOption->setPoll($this->poll->getId()); + // A new option initially is positioned at the end of the list + $newOption->setOrder($this->getHighestOrder($pollId) + 1); + $newOption->setFromSimpleOption($simpleOption, $this->poll->getType()); if (!$this->poll->getIsPollOwner()) { $newOption->setOwner($this->userSession->getCurrentUserId()); } - try { // Insert the new option $newOption = $this->optionMapper->insert($newOption); } catch (Exception $e) { + $this->logger->error('Error while adding option', [ + 'optionText' => json_encode($newOption), + 'pollId' => $pollId, + 'exceptionMessage' => $e->getMessage(), + ]); // TODO: Change exception catch to actual exception // Currently OC\DB\Exceptions\DbalException is thrown instead of // UniqueConstraintViolationException // since the exception is from private namespace, we check the type string - if (get_class($e) === 'OC\DB\Exceptions\DbalException' || $e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + if (get_class($e) === 'OC\DB\Exceptions\DbalException' && $e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { // Option already exists, so we need to update the existing one // and remove deleted setting @@ -160,13 +168,21 @@ public function add(int $pollId, SimpleOption $simpleOption, bool $voteYes = fal * @return Option[] */ public function addBulk(int $pollId, string $bulkText = ''): array { - $this->getPoll($pollId, Poll::PERMISSION_OPTION_ADD); + $this->getPoll($pollId) + ->request(Poll::PERMISSION_OPTION_ADD); $newOptionsTexts = array_unique(explode(PHP_EOL, $bulkText)); foreach ($newOptionsTexts as $pollOptionText) { if ($pollOptionText) { - $this->add($pollId, new SimpleOption($pollOptionText, 0)); + try { + $this->add( + $pollId, + (new SimpleOption())->setText($pollOptionText) + ); + } catch (InvalidArgumentException) { + $this->logger->warning('Skipping bulk operation because of invalid option text: {optionText}', ['optionText' => $pollOptionText]); + } } } @@ -178,11 +194,29 @@ public function addBulk(int $pollId, string $bulkText = ''): array { * * @return Option */ - public function update(int $optionId, int $timestamp = 0, string $pollOptionText = '', int $duration = 0): Option { + public function update( + int $optionId, + string $pollOptionText = '', + ?DateTimeImmutable $dateTime = null, + DateInterval $interval = new DateInterval('PT0S'), + ): Option { $option = $this->optionMapper->find($optionId); - $this->getPoll($option->getPollId(), Poll::PERMISSION_POLL_EDIT); + $this->getPoll($option->getPollId())->request(Poll::PERMISSION_POLL_EDIT); + + if ($this->poll->getType() === Poll::TYPE_TEXT) { + if (!$pollOptionText) { + throw new InsufficientAttributesException('Text is required for text polls'); + } + $option->setText($pollOptionText); + } - $option->setOption($timestamp, $duration, $pollOptionText); + if ($this->poll->getType() === Poll::TYPE_DATE) { + if ($dateTime === null) { + throw new InsufficientAttributesException('Date time is required for date polls'); + } + $option->setDateTime($dateTime); + $option->setInterval($interval); + } $option = $this->optionMapper->update($option); $this->eventDispatcher->dispatchTyped(new OptionUpdatedEvent($option)); @@ -217,7 +251,8 @@ public function delete(int $optionId, bool $restore = false): Option { */ public function confirm(int $optionId): Option { $option = $this->optionMapper->find($optionId); - $this->getPoll($option->getPollId(), Poll::PERMISSION_OPTION_CONFIRM); + $this->getPoll($option->getPollId()) + ->request(Poll::PERMISSION_OPTION_CONFIRM); $option->setConfirmed($option->getConfirmed() ? 0 : time()); $option = $this->optionMapper->update($option); @@ -234,10 +269,10 @@ public function confirm(int $optionId): Option { /** * Make a sequence of date poll options * - * @param int | Option $optionOrOptionId Option od optionId of the option to clone + * @param int|Option $optionOrOptionId Option or optionId of the option to clone * @param Sequence $sequence Sequence object * @param bool $voteYes Directly vote 'yes' for the new options - * @return Option[] + * @return Option[] Returns all options of the poll * * @psalm-return array */ @@ -252,25 +287,25 @@ public function sequence(int|Option $optionOrOptionId, Sequence $sequence, bool $baseOption = $this->optionMapper->find($optionOrOptionId); } - $this->getPoll($baseOption->getPollId(), Poll::PERMISSION_OPTION_ADD); + $this->getPoll($baseOption->getPollId()) + ->request(Poll::PERMISSION_OPTION_ADD); if ($this->poll->getType() !== Poll::TYPE_DATE) { throw new InvalidPollTypeException('Sequences are only available in date polls'); } - $sequence->setTimeZone(new DateTimeZone($this->userSession->getClientTimeZone())); - $sequence->setBaseTimeStamp($baseOption->getTimestamp()); + $baseDate = $baseOption->getDateTime(); + + $sequence->setBaseDateTime($baseDate->setTimezone($this->userSession->getClientTimeZone())); // iterate over the amount of options to create for ($i = 1; $i <= ($sequence->getRepetitions()); $i++) { // build a new option $this->add( $baseOption->getPollId(), - new SimpleOption( - '', - $sequence->getOccurence($i), - $baseOption->getDuration(), - ), + (new SimpleOption) + ->setDateTime($sequence->getOccurence($i)) + ->setInterval($baseOption->getInterval()), $voteYes ); } @@ -308,10 +343,10 @@ public function shift(int $pollId, int $step, string $unit): array { $options = $this->optionMapper->findByPoll($pollId); if ($this->countProposals($options) > 0) { - throw new ForbiddenException('dates is not allowed'); + throw new ForbiddenException('Shifting dates is not allowed, when proposals exist'); } - $timezone = new DateTimeZone($this->userSession->getClientTimeZone()); + $timezone = $this->userSession->getClientTimeZone(); if ($step > 0) { // start from last item if moving option into the future @@ -320,7 +355,7 @@ public function shift(int $pollId, int $step, string $unit): array { } foreach ($options as $option) { - $option->shiftOption($timezone, $step, $unit); + $option->shiftOption($step, $unit, $timezone); $this->optionMapper->update($option); } @@ -338,15 +373,8 @@ public function clone(int $fromPollId, int $toPollId): void { ->request(Poll::PERMISSION_OPTION_ADD); foreach ($this->optionMapper->findByPoll($fromPollId) as $origin) { - $option = new Option(); - $option->setPollId($toPollId); - $option->setConfirmed(0); - $option->setOption( - $origin->getTimestamp(), - $origin->getDuration(), - $origin->getPollOptionText(), - ); - $option->setOrder($origin->getOrder()); + $option = clone $origin; + $option->setPoll($toPollId); $option = $this->optionMapper->insert($option); $this->eventDispatcher->dispatchTyped(new OptionCreatedEvent($option)); } @@ -360,11 +388,8 @@ public function clone(int $fromPollId, int $toPollId): void { * @psalm-return array */ public function reorder(int $pollId, array $options): array { - $this->getPoll($pollId, Poll::PERMISSION_POLL_EDIT); - - if ($this->poll->getType() === Poll::TYPE_DATE) { - throw new InvalidPollTypeException('Not allowed in date polls'); - } + $this->getPoll($pollId) + ->request(Poll::PERMISSION_OPTIONS_REORDER); $i = 0; foreach ($options as $option) { @@ -397,11 +422,8 @@ public function reorder(int $pollId, array $options): array { */ public function setOrder(int $optionId, int $newOrder): array { $option = $this->optionMapper->find($optionId); - $this->getPoll($option->getPollId(), Poll::PERMISSION_POLL_EDIT); - - if ($this->poll->getType() === Poll::TYPE_DATE) { - throw new InvalidPollTypeException('Not allowed in date polls'); - } + $this->getPoll($option->getPollId()) + ->request(Poll::PERMISSION_OPTIONS_REORDER); if ($newOrder < 1) { $newOrder = 1; @@ -440,15 +462,15 @@ private function moveModifier(int $moveFrom, int $moveTo, int $currentPosition): } /** - * Load the poll and check permissions + * Load the poll if not already loaded * - * @return void + * @return Poll */ - private function getPoll(int $pollId, string $permission = Poll::PERMISSION_POLL_ACCESS): void { + private function getPoll(int $pollId): Poll { if ($this->poll->getId() !== $pollId) { $this->poll = $this->pollMapper->get($pollId); } - $this->poll->request($permission); + return $this->poll; } /** @@ -472,7 +494,7 @@ private function filterBookedUp() { * * @return int */ - public function getHighestOrder(int $pollId): int { + private function getHighestOrder(int $pollId): int { $result = intval($this->optionMapper->getOrderBoundaries($pollId)['max']); return $result; } diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index d3975b2880..4f179fa0d8 100644 --- a/lib/Service/PollService.php +++ b/lib/Service/PollService.php @@ -52,6 +52,7 @@ public function __construct( /** * Get list of polls including Threshold for "relevant polls" + * @return Poll[] */ public function listPolls(): array { $pollList = $this->pollMapper->findForMe($this->userSession->getCurrentUserId()); @@ -66,6 +67,7 @@ public function listPolls(): array { /** * Get list of polls + * @return Poll[] */ public function search(ISearchQuery $query): array { $pollList = []; @@ -103,6 +105,7 @@ public function listForAdmin(): array { } /** + * Transfer all polls from one user to another * @return Poll[] * @psalm-return array */ @@ -166,9 +169,8 @@ public function transferPoll(int|Poll $poll, string|UserBase $targetUser): Poll /** * get poll configuration - * @return Poll */ - public function get(int $pollId) { + public function get(int $pollId): Poll { try { $this->poll = $this->pollMapper->get($pollId); $this->poll->request(Poll::PERMISSION_POLL_ACCESS); @@ -189,7 +191,7 @@ public function getPollOwnerFromDB(int $pollId): UserBase { /** * Add poll */ - public function add(string $type, string $title, string $votingVariant = Poll::VARIANT_SIMPLE): Poll { + public function add(string $type, string $title, ?string $timezoneName = null, string $votingVariant = Poll::VARIANT_SIMPLE): Poll { if (!$this->appSettings->getPollCreationAllowed()) { throw new ForbiddenException('Poll creation is disabled'); } @@ -211,6 +213,7 @@ public function add(string $type, string $title, string $votingVariant = Poll::V $this->poll->setCreated($timestamp); $this->poll->setLastInteraction($timestamp); $this->poll->setOwner($this->userSession->getCurrentUserId()); + $this->poll->setTimezoneName($timezoneName); // create new poll before resetting all values to // ensure that the poll has all required values and an id @@ -239,7 +242,6 @@ public function add(string $type, string $title, string $votingVariant = Poll::V * * @param int $pollId Poll id * @param array $pollConfiguration Poll configuration - * @return array * * @psalm-return array{poll: Poll, diff: array, changes: array} */ @@ -296,7 +298,6 @@ public function update(int $pollId, array $pollConfiguration): array { /** * Manually lock anonymization - * @return Poll */ public function lockAnonymous(int $pollId): Poll { $this->poll = $this->pollMapper->get($pollId); @@ -329,7 +330,6 @@ public function setLastInteraction(int $pollId): void { /** * Move to archive or restore - * @return Poll */ public function toggleArchive(int $pollId): Poll { $this->poll = $this->pollMapper->get($pollId) @@ -349,7 +349,6 @@ public function toggleArchive(int $pollId): Poll { /** * Delete poll - * @return Poll */ public function delete(int $pollId): Poll { try { @@ -367,7 +366,6 @@ public function delete(int $pollId): Poll { /** * Close poll - * @return Poll */ public function close(int $pollId): Poll { $this->pollMapper->get($pollId) @@ -377,7 +375,6 @@ public function close(int $pollId): Poll { /** * Reopen poll - * @return Poll */ public function reopen(int $pollId): Poll { $this->pollMapper->get($pollId) @@ -387,7 +384,6 @@ public function reopen(int $pollId): Poll { /** * Close poll - * @return Poll */ private function toggleClose(int $pollId, int $expiry): Poll { $this->poll = $this->pollMapper->get($pollId) @@ -407,30 +403,17 @@ private function toggleClose(int $pollId, int $expiry): Poll { /** * Clone poll - * @return Poll */ public function clone(int $pollId): Poll { $origin = $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_POLL_ACCESS); - $this->appSettings->getPollCreationAllowed(); + if (!$this->appSettings->getPollCreationAllowed()) { + throw new ForbiddenException('Poll creation is disabled'); + } - $this->poll = new Poll(); - $this->poll->setCreated(time()); - $this->poll->setOwner($this->userSession->getCurrentUserId()); - $this->poll->setTitle('Clone of ' . $origin->getTitle()); - $this->poll->setDeleted(0); - $this->poll->setAccess(Poll::ACCESS_PRIVATE); + $this->poll = clone $origin; - $this->poll->setType($origin->getType()); - $this->poll->setVotingVariant($origin->getVotingVariant()); - $this->poll->setDescription($origin->getDescription()); - $this->poll->setExpire($origin->getExpire()); - // deanonymize cloned polls by default, to avoid locked anonymous polls - $this->poll->setAnonymous(0); - $this->poll->setAllowMaybe($origin->getAllowMaybe()); - $this->poll->setVoteLimit($origin->getVoteLimit()); - $this->poll->setShowResults($origin->getShowResults()); - $this->poll->setAdminAccess($origin->getAdminAccess()); + $this->poll->setOwner($this->userSession->getCurrentUserId()); $this->poll = $this->pollMapper->insert($this->poll); $this->eventDispatcher->dispatchTyped(new PollCreatedEvent($this->poll)); @@ -439,7 +422,6 @@ public function clone(int $pollId): Poll { /** * Collect email addresses from particitipants - * */ public function getParticipantsEmailAddresses(int $pollId): array { $this->poll = $this->pollMapper->get($pollId) diff --git a/lib/Service/SystemService.php b/lib/Service/SystemService.php index 600a39ea8d..82f686c209 100644 --- a/lib/Service/SystemService.php +++ b/lib/Service/SystemService.php @@ -226,8 +226,6 @@ private function search(string $query, array $types): array { foreach (ContactGroup::search($query) as $contact) { $items[] = $contact->getRichUserArray(); } - // $items = array_merge($items, Contact::search($query)); - // $items = array_merge($items, ContactGroup::search($query)); } $this->logger->debug('Search took {time}s overall', ['time' => microtime(true) - $startSearchTimer]); diff --git a/lib/UserSession.php b/lib/UserSession.php index 10be5077ec..661cc8c917 100644 --- a/lib/UserSession.php +++ b/lib/UserSession.php @@ -8,6 +8,7 @@ namespace OCA\Polls; +use DateTimeZone; use Exception; use OCA\Polls\Db\Share; use OCA\Polls\Db\ShareMapper; @@ -182,11 +183,12 @@ public function setClientId(string $clientId): void { } /** - * - * @return non-empty-string + * Get client time zone from session or return default time zone + * @return DateTimeZone */ - public function getClientTimeZone(): string { - return $this->session->get(self::CLIENT_TZ) ?? date_default_timezone_get(); + public function getClientTimeZone(): DateTimeZone { + return new DateTimeZone($this->session->get(self::CLIENT_TZ) ?? date_default_timezone_get()); + // TODO: Use \OCP\IDateTimeZone::getDefaultTimezone() when available (NC32+) } public function setClientTimeZone(string $clientTimeZone): void { diff --git a/package-lock.json b/package-lock.json index 6d2550ffb7..5710fbe972 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "polls", - "version": "8.6.3", + "version": "9.0.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "polls", - "version": "8.6.3", + "version": "9.0.0-beta.2", "license": "AGPL-3.0", "dependencies": { "@nextcloud/auth": "^2.5.3", diff --git a/package.json b/package.json index f01fa67dd6..e1090a095a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "polls", - "version": "8.6.3", + "version": "9.0.0-beta.2", "private": true, "description": "Polls app for nextcloud", "homepage": "https://github.com/nextcloud/polls#readme", diff --git a/src/Api/modules/api.types.ts b/src/Api/modules/api.types.ts index f335e195c9..9c80bf4f2a 100644 --- a/src/Api/modules/api.types.ts +++ b/src/Api/modules/api.types.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Option } from '../../stores/options.types' +import type { OptionDto } from '../../stores/options.types' import type { Vote } from '../../stores/votes.types' import type { Poll } from '../../stores/poll.types' import type { Comment } from '../../stores/comments.types' @@ -28,7 +28,7 @@ export type ApiEmailAdressList = { export type FullPollResponse = { poll: Poll - options: Option[] + options: OptionDto[] votes: Vote[] comments: Comment[] shares: Share[] @@ -36,21 +36,21 @@ export type FullPollResponse = { } export type AddOptionResponse = { - option: Option - repetitions: Option[] - options: Option[] + option: OptionDto + repetitions: OptionDto[] + options: OptionDto[] votes: Vote[] } export type setVoteResponse = { vote: Vote poll: Poll - options: Option[] + options: OptionDto[] votes: Vote[] } export type RemoveVotesResponse = { poll: Poll - options: Option[] + options: OptionDto[] votes: Vote[] } diff --git a/src/Api/modules/options.ts b/src/Api/modules/options.ts index 4a0bb7418f..1aeb8c0f99 100644 --- a/src/Api/modules/options.ts +++ b/src/Api/modules/options.ts @@ -4,7 +4,7 @@ */ import { httpInstance, createCancelTokenHandler } from './HttpApi' -import type { DateTimeUnit } from '../../constants/dateUnits' +import type { DateTimeUnits } from '../../Types/dateTime' import type { AxiosResponse } from '@nextcloud/axios' import type { Vote } from '../../stores/votes.types' import type { Option, Sequence, SimpleOption } from '../../stores/options.types' @@ -50,7 +50,6 @@ const options = { return httpInstance.request({ method: 'PUT', url: `option/${option.id}`, - // TODO: replace text with timestamp data: { ...option }, cancelToken: cancelTokenHandlerObject[ @@ -158,7 +157,7 @@ const options = { shiftOptions( pollId: number, step: number, - unit: DateTimeUnit, + unit: DateTimeUnits, ): Promise> { return httpInstance.request({ method: 'POST', diff --git a/src/Api/modules/polls.ts b/src/Api/modules/polls.ts index 6bb8e09275..f2b1f30ce5 100644 --- a/src/Api/modules/polls.ts +++ b/src/Api/modules/polls.ts @@ -7,7 +7,7 @@ import { httpInstance, createCancelTokenHandler } from './HttpApi' import type { AxiosResponse } from '@nextcloud/axios' import type { ApiEmailAdressList, FullPollResponse } from './api.types' import type { PollGroup } from '../../stores/pollGroups.types' -import type { Poll, PollConfiguration, PollType } from '../../stores/poll.types' +import type { Poll, PollConfiguration, PollMandatory } from '../../stores/poll.types' export type Confirmations = { sentMails: { emailAddress: string; displayName: string }[] @@ -87,14 +87,11 @@ const polls = { }) }, - addPoll(type: PollType, title: string): Promise> { + addPoll(payload: PollMandatory): Promise> { return httpInstance.request({ method: 'POST', url: 'poll/add', - data: { - type, - title, - }, + data: payload, cancelToken: cancelTokenHandlerObject[ this.addPoll.name diff --git a/src/Types/dateTime.ts b/src/Types/dateTime.ts new file mode 100644 index 0000000000..9de6f655bd --- /dev/null +++ b/src/Types/dateTime.ts @@ -0,0 +1,30 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export type DateTimeUnits = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year' + +export type DateTimeUnitType = { + id: DateTimeUnits + name: string + timeOption: boolean +} +export type TimeZoneTypes = 'local' | 'poll' + +export type TimeZoneOption = { + label: string + value: TimeZoneTypes +} + +export type TimeUnitsType = { + unit: DateTimeUnitType + value: number +} + +export type DurationType = { + unit: DateTimeUnitType + amount: number +} + +export type DateFormats = 'dateTime' | 'dateShort' diff --git a/src/Types/index.ts b/src/Types/index.ts index bcf67b2201..d21c84f33e 100644 --- a/src/Types/index.ts +++ b/src/Types/index.ts @@ -3,13 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -export { - DateTimeUnit, - DateTimeUnitType, - TimeUnitsType, - DurationType, -} from '../constants/dateUnits' - export enum Event { TransitionsOff = 'polls:transitions:off', TransitionsOn = 'polls:transitions:on', diff --git a/src/components/Actions/modules/ActionAddPoll.vue b/src/components/Actions/modules/ActionAddPoll.vue index f3a9767acb..5c5d3143e2 100644 --- a/src/components/Actions/modules/ActionAddPoll.vue +++ b/src/components/Actions/modules/ActionAddPoll.vue @@ -22,7 +22,7 @@ import type { ButtonVariant } from '@nextcloud/vue/components/NcButton' interface Props { caption?: string - modalSize?: string + modalSize?: 'small' | 'normal' | 'full' | 'large' buttonMode?: ButtonMode } diff --git a/src/components/Base/modules/ButtonModal.vue b/src/components/Base/modules/ButtonModal.vue index 80c43fb73c..02c09af30c 100644 --- a/src/components/Base/modules/ButtonModal.vue +++ b/src/components/Base/modules/ButtonModal.vue @@ -18,7 +18,7 @@ interface Props { buttonVariant?: ButtonVariant buttonMode?: ButtonMode buttonCaption?: string - modalSize?: string + modalSize?: 'small' | 'normal' | 'full' | 'large' noClose?: boolean } diff --git a/src/components/Base/modules/DateBox.vue b/src/components/Base/modules/DateBox.vue index 3161803b39..6814e53bc8 100644 --- a/src/components/Base/modules/DateBox.vue +++ b/src/components/Base/modules/DateBox.vue @@ -7,23 +7,40 @@ import { computed } from 'vue' import { DateTime, Duration } from 'luxon' import { getDates } from '../../../composables/optionDateTime' +import { useSessionStore } from '@/stores/session' interface Props { startDate: DateTime - duration?: Duration + duration: Duration + timezone?: string | undefined } -const { startDate, duration = Duration.fromMillis(0) } = defineProps() +const sessionStore = useSessionStore() -const optionDateTimes = computed(() => getDates(startDate, duration)) +const { + startDate, + duration = Duration.fromMillis(0), + timezone = undefined, +} = defineProps() + +const useTimeZone = computed( + () => + timezone + || sessionStore.currentTimezoneName + || Intl.DateTimeFormat().resolvedOptions().timeZone, +) + +const optionDateTimes = computed(() => + getDates(startDate, duration, useTimeZone.value), +)