From 4cb7cef862cfd907eb54a4abd80f739e33eda9b8 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Tue, 15 Sep 2026 11:43:20 +0200 Subject: [PATCH 1/7] enh: validate datetime fields Signed-off-by: Luka Trovic --- lib/Service/CardService.php | 4 +- lib/Validators/BaseValidator.php | 45 +++++++++++++++++++++-- lib/Validators/CardServiceValidator.php | 3 ++ src/components/card/DueDateSelector.vue | 7 +++- src/components/card/StartDateSelector.vue | 7 +++- 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index 5e770bc53c..afbeb0f808 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -192,7 +192,7 @@ public function findCalendarEntries(int $boardId): array { * @throws BadrequestException */ public function create(string $title, int $stackId, string $type, int $order, string $owner, string $description = '', $duedate = null, $startdate = null, ?string $color = null, bool $insertAtPosition = false): Card { - $this->cardServiceValidator->check(compact('title', 'stackId', 'type', 'order', 'owner')); + $this->cardServiceValidator->check(compact('title', 'stackId', 'type', 'order', 'owner', 'startdate', 'duedate')); $this->permissionService->checkPermission($this->stackMapper, $stackId, Acl::PERMISSION_EDIT); if ($this->boardService->isArchived($this->stackMapper, $stackId)) { @@ -273,7 +273,7 @@ public function delete(int $id): Card { * @throws BadRequestException */ public function update(int $id, string $title, int $stackId, string $type, string $owner, string $description = '', int $order = 0, ?string $duedate = null, ?int $deletedAt = null, ?bool $archived = null, ?OptionalNullableValue $done = null, ?string $startdate = null, ?OptionalNullableValue $color = null): Card { - $this->cardServiceValidator->check(compact('id', 'title', 'stackId', 'type', 'owner', 'order')); + $this->cardServiceValidator->check(compact('id', 'title', 'stackId', 'type', 'owner', 'order', 'startdate', 'duedate', 'done')); $this->permissionService->checkPermission($this->cardMapper, $id, Acl::PERMISSION_EDIT, allowDeletedCard: true); $this->permissionService->checkPermission($this->stackMapper, $stackId, Acl::PERMISSION_EDIT); diff --git a/lib/Validators/BaseValidator.php b/lib/Validators/BaseValidator.php index 8b1350442e..23fe87b458 100644 --- a/lib/Validators/BaseValidator.php +++ b/lib/Validators/BaseValidator.php @@ -11,6 +11,7 @@ use Exception; use OCA\Deck\BadRequestException; +use OCA\Deck\Model\OptionalNullableValue; abstract class BaseValidator { @@ -31,9 +32,22 @@ private function validate($data) { foreach ($data as $field => $value) { $field_rule = $rules[$field]; + $isOptional = is_array($field_rule) && in_array('optional', $field_rule, true); + + if ($isOptional) { + if ($value instanceof OptionalNullableValue) { + $value = $value->getValue(); + } + if ($value === null) { + continue; + } + } if (is_array($field_rule)) { foreach ($field_rule as $rule) { + if ($rule === 'optional') { + continue; + } // The format for specifying validation rules and parameters follows an // easy {rule}:{parameters} formatting convention. For instance the // rule "Max:3" states that the value may only be three letters. @@ -41,12 +55,12 @@ private function validate($data) { [$rule, $parameter] = explode(':', $rule, 2); if (!$this->{$rule}($value, $parameter)) { throw new BadRequestException( - $this->getErrorMessage($rule, $field, $parameter)); + $this->getErrorMessage($rule, $field, $parameter, $isOptional)); } } else { if (!$this->{$rule}($value)) { throw new BadRequestException( - $field . ' must be provided and must be ' . str_replace('_', ' ', $rule)); + $this->getErrorMessage($rule, $field, null, $isOptional)); } } } @@ -135,6 +149,27 @@ private function min($value, $limit): bool { return $this->getSize($value) >= $limit; } + /** + * @throws Exception + */ + private function datetime($value): bool { + if (!is_string($value)) { + return false; + } + + try { + $datetime = new \DateTime($value); + } catch (\Exception $e) { + return false; + } + + if ($datetime->format('Y') > 9999) { + return false; + } + + return true; + } + /** * Get the size of an attribute. * @@ -159,13 +194,17 @@ protected function getSize($value): int { * @param $parameter * @return string */ - protected function getErrorMessage(string $rule, $field, $parameter = null): string { + protected function getErrorMessage(string $rule, $field, $parameter = null, $isOptional = false): string { if (in_array($rule, ['max', 'min'], true)) { return $rule === 'max' ? $field . ' cannot be longer than ' . $parameter . ' characters ' : $field . ' must be at least ' . $parameter . ' characters long '; } + if ($isOptional) { + return $field . ' must be ' . str_replace('_', ' ', $rule); + } + return $field . ' must be provided and must be ' . str_replace('_', ' ', $rule); } } diff --git a/lib/Validators/CardServiceValidator.php b/lib/Validators/CardServiceValidator.php index 17f0174c51..adaa9f9574 100644 --- a/lib/Validators/CardServiceValidator.php +++ b/lib/Validators/CardServiceValidator.php @@ -22,6 +22,9 @@ public function rules() { 'type' => ['not_empty', 'not_null', 'not_false', 'max:64'], 'order' => ['numeric'], 'owner' => ['not_empty', 'not_null', 'not_false', 'max:64'], + 'startdate' => ['optional', 'datetime'], + 'duedate' => ['optional', 'datetime'], + 'done' => ['optional', 'datetime'], ]; } } diff --git a/src/components/card/DueDateSelector.vue b/src/components/card/DueDateSelector.vue index a531c9de03..0711559ff5 100644 --- a/src/components/card/DueDateSelector.vue +++ b/src/components/card/DueDateSelector.vue @@ -180,7 +180,12 @@ export default defineComponent({ }, set(val) { - this.$emit('input', val ? new Date(val) : null) + // Prevent setting a date with a year greater than 9999, as this can cause issues with some date libraries and databases. + const date = val ? new Date(val) : null + if (date && date.getFullYear() > 9999) { + return + } + this.$emit('input', date) }, }, diff --git a/src/components/card/StartDateSelector.vue b/src/components/card/StartDateSelector.vue index 3cacc97057..42c709f432 100644 --- a/src/components/card/StartDateSelector.vue +++ b/src/components/card/StartDateSelector.vue @@ -95,7 +95,12 @@ export default defineComponent({ }, set(val) { - this.$emit('input', val ? new Date(val) : null) + // Prevent setting a date with a year greater than 9999, as this can cause issues with some date libraries and databases. + const date = val ? new Date(val) : null + if (date && date.getFullYear() > 9999) { + return + } + this.$emit('input', date) }, }, }, From 6b870cd8bdf2d61984c3942118f9773ce6708d17 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Fri, 18 Sep 2026 18:44:50 +0200 Subject: [PATCH 2/7] enh: improve datetime validation Signed-off-by: Luka Trovic --- appinfo/info.xml | 1 + lib/Command/FixCardDate.php | 130 ++++++++++++++++++ lib/Service/CardService.php | 4 +- lib/Validators/BaseValidator.php | 30 ++-- src/components/card/DueDateSelector.vue | 6 +- src/components/card/StartDateSelector.vue | 6 +- src/mixins/readableDate.js | 9 ++ .../Validators/CardServiceValidatorTest.php | 61 ++++++++ 8 files changed, 226 insertions(+), 21 deletions(-) create mode 100644 lib/Command/FixCardDate.php create mode 100644 tests/unit/Validators/CardServiceValidatorTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 8f696b737b..dd1b8aac91 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -63,6 +63,7 @@ OCA\Deck\Command\BoardImport OCA\Deck\Command\TransferOwnership OCA\Deck\Command\CalendarToggle + OCA\Deck\Command\FixCardDate diff --git a/lib/Command/FixCardDate.php b/lib/Command/FixCardDate.php new file mode 100644 index 0000000000..d69fd99775 --- /dev/null +++ b/lib/Command/FixCardDate.php @@ -0,0 +1,130 @@ +setName('deck:fix-card-date') + ->setDescription('Fixes the date fields of cards. In some DBs, the date fields may have been stored incorrectly, causing issues with date handling. This command will attempt to fix those dates.') + ->addOption( + 'default-year', + null, + InputOption::VALUE_OPTIONAL, + 'Default year to use for cards with invalid dates. If not provided, the large year number will be trimmed to first 4 digits.', + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_OPTIONAL, + 'Dry run, do not make any changes.', + ) + ; + } + + /** + * @throws Exception + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $isDryRun = (bool)$input->getOption('dry-run'); + $defaultYear = $input->getOption('default-year'); + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('deck_cards') + ->where($qb->expr()->orX( + $qb->expr()->gt('duedate', $qb->createNamedParameter('9999-01-01')), + $qb->expr()->gt('startdate', $qb->createNamedParameter('9999-01-01')), + $qb->expr()->gt('done', $qb->createNamedParameter('9999-01-01')), + )); + $cards = $qb->executeQuery()->fetchAllAssociative(); + + if (empty($cards)) { + $output->writeln('No cards with invalid dates found.'); + return 0; + } + + foreach ($cards as $card) { + $cardId = $card['id']; + $duedate = $card['duedate']; + $startdate = $card['startdate']; + $done = $card['done']; + + $output->writeln("Card ID: $cardId"); + + if ($duedate && $this->isInvalidDate($duedate)) { + $output->writeln(" Original Due Date: $duedate"); + $duedate = $this->fixDate($duedate, $defaultYear); + + if (!$isDryRun) { + $this->updateCardDate($cardId, 'duedate', $duedate); + } + $output->writeln(" Fixed Due Date: $duedate"); + } + + if ($startdate && $this->isInvalidDate($startdate)) { + $output->writeln(" Original Start Date: $startdate"); + $startdate = $this->fixDate($startdate, $defaultYear); + + if (!$isDryRun) { + $this->updateCardDate($cardId, 'startdate', $startdate); + } + $output->writeln(" Fixed Start Date: $startdate"); + } + + if ($done && $this->isInvalidDate($done)) { + $output->writeln(" Original Done Date: $done"); + $done = $this->fixDate($done, $defaultYear); + + if (!$isDryRun) { + $this->updateCardDate($cardId, 'done', $done); + } + $output->writeln(" Fixed Done Date: $done"); + } + } + + return 0; + } + + protected function isInvalidDate(string $date): bool { + $parts = explode('-', $date); + $year = $parts[0]; + return $year > 9999; + } + + protected function fixDate(string $date, ?string $defaultYear): string { + $parts = explode('-', $date); + $year = $parts[0]; + $fixedYear = $defaultYear; + if (!$defaultYear) { + $fixedYear = substr($year, 0, 4); + } + return str_replace($year . '-', $fixedYear . '-', $date); + } + + protected function updateCardDate(int $cardId, string $field, string $newDate): void { + $qb = $this->db->getQueryBuilder(); + $qb->update('deck_cards') + ->set($field, $qb->createNamedParameter($newDate)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($cardId))); + $qb->executeStatement(); + } +} diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index afbeb0f808..64eed73b0f 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -205,8 +205,8 @@ public function create(string $title, int $stackId, string $type, int $order, st $card->setOrder($order); $card->setOwner($owner); $card->setDescription($description); - $card->setDuedate($duedate); - $card->setStartdate($startdate); + $card->setDuedate($duedate ? new \DateTime($duedate) : null); + $card->setStartdate($startdate ? new \DateTime($startdate) : null); $card->setColor($color); if (!$insertAtPosition) { diff --git a/lib/Validators/BaseValidator.php b/lib/Validators/BaseValidator.php index 23fe87b458..1411fdc15b 100644 --- a/lib/Validators/BaseValidator.php +++ b/lib/Validators/BaseValidator.php @@ -152,22 +152,30 @@ private function min($value, $limit): bool { /** * @throws Exception */ - private function datetime($value): bool { - if (!is_string($value)) { + private function datetime(mixed $value): bool { + if (!is_string($value) || $value === '') { return false; } - try { - $datetime = new \DateTime($value); - } catch (\Exception $e) { - return false; - } - - if ($datetime->format('Y') > 9999) { - return false; + $allowedFormats = [ + 'Y-m-d', + 'Y-m-d H:i:s', + 'Y-m-d\TH:i:sP', + 'Y-m-d\TH:i:s.v\Z', + ]; + + foreach ($allowedFormats as $format) { + $datetime = \DateTime::createFromFormat($format, $value); + if ($datetime && $datetime->format($format) === $value) { + // Check if the year is within the valid range + if ((int)$datetime->format('Y') > 9999) { + return false; + } + return true; + } } - return true; + return false; } /** diff --git a/src/components/card/DueDateSelector.vue b/src/components/card/DueDateSelector.vue index 0711559ff5..4f7dcfa599 100644 --- a/src/components/card/DueDateSelector.vue +++ b/src/components/card/DueDateSelector.vue @@ -180,12 +180,10 @@ export default defineComponent({ }, set(val) { - // Prevent setting a date with a year greater than 9999, as this can cause issues with some date libraries and databases. - const date = val ? new Date(val) : null - if (date && date.getFullYear() > 9999) { + if (!this.isValidDate(val)) { return } - this.$emit('input', date) + this.$emit('input', val ? new Date(val) : null) }, }, diff --git a/src/components/card/StartDateSelector.vue b/src/components/card/StartDateSelector.vue index 42c709f432..77e6146aae 100644 --- a/src/components/card/StartDateSelector.vue +++ b/src/components/card/StartDateSelector.vue @@ -95,12 +95,10 @@ export default defineComponent({ }, set(val) { - // Prevent setting a date with a year greater than 9999, as this can cause issues with some date libraries and databases. - const date = val ? new Date(val) : null - if (date && date.getFullYear() > 9999) { + if (!this.isValidDate(val)) { return } - this.$emit('input', date) + this.$emit('input', val ? new Date(val) : null) }, }, }, diff --git a/src/mixins/readableDate.js b/src/mixins/readableDate.js index df30fa8c4b..1f7eb7940f 100644 --- a/src/mixins/readableDate.js +++ b/src/mixins/readableDate.js @@ -5,6 +5,8 @@ import moment from '@nextcloud/moment' +const maxYear = 9999 + export default { computed: { formatReadableDate() { @@ -13,4 +15,11 @@ export default { } }, }, + methods: { + isValidDate(val) { + // Prevent setting a date with a year greater than 9999, as this can cause issues with some date libraries and databases. + const date = val ? new Date(val) : null + return !(date && date.getFullYear() > maxYear) + }, + }, } diff --git a/tests/unit/Validators/CardServiceValidatorTest.php b/tests/unit/Validators/CardServiceValidatorTest.php new file mode 100644 index 0000000000..c8266d25f8 --- /dev/null +++ b/tests/unit/Validators/CardServiceValidatorTest.php @@ -0,0 +1,61 @@ +. + */ + +namespace OCA\Deck\Validators; + +use OCA\Deck\Tests\unit\Validators\ValidatorTestBase; + +class CardServiceValidatorTest extends ValidatorTestBase { + public function setUp(): void { + parent::setUpValidatorTest(CardServiceValidator::class); + } + + public function testInvalidDueDateIsRejected(): void { + $this->assertFail([ + 'duedate' => '', + ]); + $this->assertFail([ + 'duedate' => 'not a date', + ]); + $this->assertFail([ + 'duedate' => '99999-01-01 00:00:00', + ]); + $this->assertFail([ + 'duedate' => '+1 day', + ]); + } + + public function testValidDueDateIsAccepted(): void { + $this->assertPass([ + 'duedate' => '2026-01-01T01:00:00.000Z', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01 00:00:00', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T00:00:00+00:00', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01', + ]); + } +} From 13f4f3b18a78827bf77f997acf0daa7f9b9fb0ed Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Tue, 29 Sep 2026 09:09:40 +0200 Subject: [PATCH 3/7] enh: allow empty string in datetime validation Signed-off-by: Luka Trovic --- lib/Validators/BaseValidator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Validators/BaseValidator.php b/lib/Validators/BaseValidator.php index 1411fdc15b..2925bcdb00 100644 --- a/lib/Validators/BaseValidator.php +++ b/lib/Validators/BaseValidator.php @@ -38,7 +38,7 @@ private function validate($data) { if ($value instanceof OptionalNullableValue) { $value = $value->getValue(); } - if ($value === null) { + if ($value === null || $value === '') { continue; } } From e3263307dc25d440efeb0a46194d269382193823 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Thu, 1 Oct 2026 11:57:56 +0200 Subject: [PATCH 4/7] fix: unit test issue Signed-off-by: Luka Trovic --- tests/unit/Validators/CardServiceValidatorTest.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/unit/Validators/CardServiceValidatorTest.php b/tests/unit/Validators/CardServiceValidatorTest.php index c8266d25f8..451167cdb6 100644 --- a/tests/unit/Validators/CardServiceValidatorTest.php +++ b/tests/unit/Validators/CardServiceValidatorTest.php @@ -30,9 +30,6 @@ public function setUp(): void { } public function testInvalidDueDateIsRejected(): void { - $this->assertFail([ - 'duedate' => '', - ]); $this->assertFail([ 'duedate' => 'not a date', ]); From 5a93ceb3a437eecff6c0bf5a97ceadae1dc564e5 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Thu, 8 Oct 2026 14:34:39 +0200 Subject: [PATCH 5/7] fix: resolve comments Signed-off-by: Luka Trovic --- lib/Command/FixCardDate.php | 37 ++++++++++++++++++++++++++------ lib/Validators/BaseValidator.php | 4 ---- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/lib/Command/FixCardDate.php b/lib/Command/FixCardDate.php index d69fd99775..031413598f 100644 --- a/lib/Command/FixCardDate.php +++ b/lib/Command/FixCardDate.php @@ -34,7 +34,7 @@ protected function configure() { ->addOption( 'dry-run', null, - InputOption::VALUE_OPTIONAL, + InputOption::VALUE_NONE, 'Dry run, do not make any changes.', ) ; @@ -47,13 +47,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int $isDryRun = (bool)$input->getOption('dry-run'); $defaultYear = $input->getOption('default-year'); + // Validate default year if provided + if ($defaultYear !== null && !preg_match('/^\d{4}$/', $defaultYear)) { + $output->writeln('Invalid default year provided. It must be a 4-digit year.'); + return 1; + } + + // Fetch cards with date values and validate invalid years in PHP for DB portability. $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from('deck_cards') ->where($qb->expr()->orX( - $qb->expr()->gt('duedate', $qb->createNamedParameter('9999-01-01')), - $qb->expr()->gt('startdate', $qb->createNamedParameter('9999-01-01')), - $qb->expr()->gt('done', $qb->createNamedParameter('9999-01-01')), + $qb->expr()->isNotNull('duedate'), + $qb->expr()->isNotNull('startdate'), + $qb->expr()->isNotNull('done'), )); $cards = $qb->executeQuery()->fetchAllAssociative(); @@ -62,15 +69,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } + $hasInvalidCards = false; + foreach ($cards as $card) { $cardId = $card['id']; $duedate = $card['duedate']; $startdate = $card['startdate']; $done = $card['done']; + $hasInvalidDueDate = $duedate && $this->isInvalidDate($duedate); + $hasInvalidStartDate = $startdate && $this->isInvalidDate($startdate); + $hasInvalidDoneDate = $done && $this->isInvalidDate($done); + + if (!$hasInvalidDueDate && !$hasInvalidStartDate && !$hasInvalidDoneDate) { + continue; + } + + $hasInvalidCards = true; $output->writeln("Card ID: $cardId"); - if ($duedate && $this->isInvalidDate($duedate)) { + if ($hasInvalidDueDate) { $output->writeln(" Original Due Date: $duedate"); $duedate = $this->fixDate($duedate, $defaultYear); @@ -80,7 +98,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(" Fixed Due Date: $duedate"); } - if ($startdate && $this->isInvalidDate($startdate)) { + if ($hasInvalidStartDate) { $output->writeln(" Original Start Date: $startdate"); $startdate = $this->fixDate($startdate, $defaultYear); @@ -90,7 +108,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(" Fixed Start Date: $startdate"); } - if ($done && $this->isInvalidDate($done)) { + if ($hasInvalidDoneDate) { $output->writeln(" Original Done Date: $done"); $done = $this->fixDate($done, $defaultYear); @@ -101,6 +119,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } + if (!$hasInvalidCards) { + $output->writeln('No cards with invalid dates found.'); + } + return 0; } @@ -124,6 +146,7 @@ protected function updateCardDate(int $cardId, string $field, string $newDate): $qb = $this->db->getQueryBuilder(); $qb->update('deck_cards') ->set($field, $qb->createNamedParameter($newDate)) + ->set('last_modified', $qb->createNamedParameter(time())) ->where($qb->expr()->eq('id', $qb->createNamedParameter($cardId))); $qb->executeStatement(); } diff --git a/lib/Validators/BaseValidator.php b/lib/Validators/BaseValidator.php index 2925bcdb00..7bb26981fb 100644 --- a/lib/Validators/BaseValidator.php +++ b/lib/Validators/BaseValidator.php @@ -167,10 +167,6 @@ private function datetime(mixed $value): bool { foreach ($allowedFormats as $format) { $datetime = \DateTime::createFromFormat($format, $value); if ($datetime && $datetime->format($format) === $value) { - // Check if the year is within the valid range - if ((int)$datetime->format('Y') > 9999) { - return false; - } return true; } } From 9ae3cf8f2dfe75342a1958fdac5f1ef53a34ac9f Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Wed, 21 Oct 2026 14:28:28 +0200 Subject: [PATCH 6/7] enh: optimize card date fetching in command FixCardDate & enhance date validation to support Java/Android ISO formats Signed-off-by: Luka Trovic --- lib/Command/FixCardDate.php | 11 ++---- lib/Validators/BaseValidator.php | 30 ++++++++++++++++ .../Validators/CardServiceValidatorTest.php | 35 +++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/lib/Command/FixCardDate.php b/lib/Command/FixCardDate.php index 031413598f..bbca1409ac 100644 --- a/lib/Command/FixCardDate.php +++ b/lib/Command/FixCardDate.php @@ -55,23 +55,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Fetch cards with date values and validate invalid years in PHP for DB portability. $qb = $this->db->getQueryBuilder(); - $qb->select('*') + $qb->select('id', 'duedate', 'startdate', 'done') ->from('deck_cards') ->where($qb->expr()->orX( $qb->expr()->isNotNull('duedate'), $qb->expr()->isNotNull('startdate'), $qb->expr()->isNotNull('done'), )); - $cards = $qb->executeQuery()->fetchAllAssociative(); - - if (empty($cards)) { - $output->writeln('No cards with invalid dates found.'); - return 0; - } + $result = $qb->executeQuery(); $hasInvalidCards = false; - foreach ($cards as $card) { + while (($card = $result->fetchAssociative()) !== false) { $cardId = $card['id']; $duedate = $card['duedate']; $startdate = $card['startdate']; diff --git a/lib/Validators/BaseValidator.php b/lib/Validators/BaseValidator.php index 7bb26981fb..74fdc510f7 100644 --- a/lib/Validators/BaseValidator.php +++ b/lib/Validators/BaseValidator.php @@ -157,10 +157,40 @@ private function datetime(mixed $value): bool { return false; } + // Java/Android commonly send ISO_OFFSET_DATE_TIME and ISO_INSTANT. + if (preg_match('/^(?\d{4}-\d{2}-\d{2})T(?\d{2}):(?\d{2})(?::(?\d{2})(?:\.(?\d{1,9}))?)?(?Z|[+\-]\d{2}:\d{2})$/', $value, $matches)) { + $second = ($matches['second'] ?? '') !== '' ? $matches['second'] : '00'; + $fraction = $matches['fraction'] ?? ''; + + if ($fraction !== '') { + // PHP supports microseconds, so normalize Java nanoseconds to 6 digits. + $fraction = str_pad(substr($fraction, 0, 6), 6, '0'); + } else { + $fraction = '000000'; + } + + $timezone = $matches['timezone'] === 'Z' ? '+00:00' : $matches['timezone']; + $normalizedValue = sprintf( + '%sT%s:%s:%s.%s%s', + $matches['date'], + $matches['hour'], + $matches['minute'], + $second, + $fraction, + $timezone + ); + + $datetime = \DateTime::createFromFormat('Y-m-d\TH:i:s.uP', $normalizedValue); + if ($datetime && $datetime->format('Y-m-d\TH:i:s.uP') === $normalizedValue) { + return true; + } + } + $allowedFormats = [ 'Y-m-d', 'Y-m-d H:i:s', 'Y-m-d\TH:i:sP', + 'Y-m-d\TH:i:s\Z', 'Y-m-d\TH:i:s.v\Z', ]; diff --git a/tests/unit/Validators/CardServiceValidatorTest.php b/tests/unit/Validators/CardServiceValidatorTest.php index 451167cdb6..a43394efd2 100644 --- a/tests/unit/Validators/CardServiceValidatorTest.php +++ b/tests/unit/Validators/CardServiceValidatorTest.php @@ -39,6 +39,15 @@ public function testInvalidDueDateIsRejected(): void { $this->assertFail([ 'duedate' => '+1 day', ]); + $this->assertFail([ + 'duedate' => '99999-01-01T00:00:00Z', + ]); + $this->assertFail([ + 'duedate' => '99999-01-01T00:00Z', + ]); + $this->assertFail([ + 'duedate' => '99999-01-01', + ]); } public function testValidDueDateIsAccepted(): void { @@ -51,6 +60,32 @@ public function testValidDueDateIsAccepted(): void { $this->assertPass([ 'duedate' => '2026-01-01T00:00:00+00:00', ]); + // Java/Android ISO_INSTANT samples + $this->assertPass([ + 'duedate' => '2026-01-01T00:00:00Z', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T00:00:00.123Z', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T00:00:00.123456789Z', + ]); + // Java/Android ISO_OFFSET_DATE_TIME samples + $this->assertPass([ + 'duedate' => '2026-01-01T07:00:00+07:00', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T07:00+07:00', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T00:00Z', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T07:00:00.123+07:00', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T07:00:00.123456789+07:00', + ]); $this->assertPass([ 'duedate' => '2026-01-01', ]); From a46669e22d5480f07e7414377075c7d3dbc44f83 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Fri, 6 Nov 2026 14:42:00 +0100 Subject: [PATCH 7/7] enh: update date handling in FixCardDate command to clamp non-leap year dates Signed-off-by: Luka Trovic --- lib/Command/FixCardDate.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/Command/FixCardDate.php b/lib/Command/FixCardDate.php index bbca1409ac..e773a9ded4 100644 --- a/lib/Command/FixCardDate.php +++ b/lib/Command/FixCardDate.php @@ -24,7 +24,7 @@ public function __construct( protected function configure() { $this ->setName('deck:fix-card-date') - ->setDescription('Fixes the date fields of cards. In some DBs, the date fields may have been stored incorrectly, causing issues with date handling. This command will attempt to fix those dates.') + ->setDescription('Fixes the date fields of cards. In some DBs, the date fields may have been stored incorrectly, causing issues with date handling. This command will attempt to fix those dates. If a date falls on Feb 29 and the corrected year is not a leap year, it is clamped to Feb 28.') ->addOption( 'default-year', null, @@ -128,13 +128,17 @@ protected function isInvalidDate(string $date): bool { } protected function fixDate(string $date, ?string $defaultYear): string { - $parts = explode('-', $date); + $parts = explode('-', $date, 2); $year = $parts[0]; - $fixedYear = $defaultYear; - if (!$defaultYear) { - $fixedYear = substr($year, 0, 4); + $rest = $parts[1] ?? ''; + $fixedYear = $defaultYear ?: substr($year, 0, 4); + + // Feb 29 does not exist in non-leap years, clamp to Feb 28. + if (str_starts_with($rest, '02-29') && !checkdate(2, 29, (int)$fixedYear)) { + $rest = '02-28' . substr($rest, 5); } - return str_replace($year . '-', $fixedYear . '-', $date); + + return $fixedYear . '-' . $rest; } protected function updateCardDate(int $cardId, string $field, string $newDate): void {