Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
<command>OCA\Deck\Command\BoardImport</command>
<command>OCA\Deck\Command\TransferOwnership</command>
<command>OCA\Deck\Command\CalendarToggle</command>
<command>OCA\Deck\Command\FixCardDate</command>
</commands>
<activity>
<settings>
Expand Down
148 changes: 148 additions & 0 deletions lib/Command/FixCardDate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php

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

namespace OCA\Deck\Command;

use OCP\DB\Exception;
use OCP\IDBConnection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class FixCardDate extends Command {
public function __construct(
private IDBConnection $db,
) {
parent::__construct();
}

protected function configure() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand this is a one time thing to run. What is the motivation to go with a command, not a one-time repair job?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is because admin should input the default year to fix those fields.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, if you were an admin, what would you choose? Like, is there a reason to pick any other number than 9999? What would you pinpoint it at?

$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.')
->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_NONE,
'Dry run, do not make any changes.',
)
;
}

/**
* @throws Exception
*/
protected function execute(InputInterface $input, OutputInterface $output): int {
$isDryRun = (bool)$input->getOption('dry-run');
Comment thread
luka-nextcloud marked this conversation as resolved.
$defaultYear = $input->getOption('default-year');
Comment thread
luka-nextcloud marked this conversation as resolved.

// Validate default year if provided
if ($defaultYear !== null && !preg_match('/^\d{4}$/', $defaultYear)) {
$output->writeln('<error>Invalid default year provided. It must be a 4-digit year.</error>');
return 1;
}

// Fetch cards with date values and validate invalid years in PHP for DB portability.
$qb = $this->db->getQueryBuilder();
$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'),
));
$result = $qb->executeQuery();

$hasInvalidCards = false;

while (($card = $result->fetchAssociative()) !== false) {
$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 ($hasInvalidDueDate) {
$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 ($hasInvalidStartDate) {
$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 ($hasInvalidDoneDate) {
$output->writeln(" Original Done Date: $done");
$done = $this->fixDate($done, $defaultYear);

if (!$isDryRun) {
$this->updateCardDate($cardId, 'done', $done);
}
$output->writeln(" Fixed Done Date: $done");
}
}

if (!$hasInvalidCards) {
$output->writeln('No cards with invalid dates found.');
}

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))
Comment thread
luka-nextcloud marked this conversation as resolved.
->set('last_modified', $qb->createNamedParameter(time()))
->where($qb->expr()->eq('id', $qb->createNamedParameter($cardId)));
$qb->executeStatement();
}
}
8 changes: 4 additions & 4 deletions lib/Service/CardService.php
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,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)) {
Expand All @@ -203,8 +203,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) {
Expand Down Expand Up @@ -263,7 +263,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);
Expand Down
79 changes: 76 additions & 3 deletions lib/Validators/BaseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use Exception;
use OCA\Deck\BadRequestException;
use OCA\Deck\Model\OptionalNullableValue;

abstract class BaseValidator {

Expand All @@ -31,22 +32,35 @@ 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 || $value === '') {
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.
if (str_contains($rule, ':')) {
[$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));
}
}
}
Expand Down Expand Up @@ -135,6 +149,61 @@ private function min($value, $limit): bool {
return $this->getSize($value) >= $limit;
}

/**
* @throws Exception
*/
private function datetime(mixed $value): bool {
if (!is_string($value) || $value === '') {
return false;
}

// Java/Android commonly send ISO_OFFSET_DATE_TIME and ISO_INSTANT.
if (preg_match('/^(?<date>\d{4}-\d{2}-\d{2})T(?<hour>\d{2}):(?<minute>\d{2})(?::(?<second>\d{2})(?:\.(?<fraction>\d{1,9}))?)?(?<timezone>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',
];
Comment on lines +189 to +195

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to we need to be restrictive and selective on formats? Would not passing them to \DateTime(Immutable) and evaluating the year be sufficient?

For, other clients (thinking about the Deck mobile app) might send different formats that have been accepted so far.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is worth to restrict the formats by allowing common formats. I will check the Deck android app to ensure the format sent from mobile is supported.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@blizzz Could you please provide the datetime formats and some sample values sent from the mobile app so I can ensure the validation logic correctly cover those formats? I am not able to run the app because I have no way to get the apk of https://github.com/stefan-niedermann/nextcloud-deck

@blizzz blizzz Oct 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With external clients it can also be someone's curl script that then breaks.

According to Clause, Android Deck does this:

[Certain] Android app source (stefan-niedermann/nextcloud-deck, main branch):

  • deprecated/app/.../GsonUTCInstantAdapter.java serialises with DateTimeFormatter.ISO_INSTANT, giving …T10:15:30Z. That is rejected.
  • data/remote/.../OffsetDateTimeAdapter.java uses ISO_OFFSET_DATE_TIME. That writes Z for UTC and drops :ss when seconds are zero. Both are rejected.

and further

[Likely] So after this PR the app gets a 400 on every card update that has a due date. Third-party clients (n8n, scripts) will hit the same thing. docs/API.md only says "ISO-8601", and it never listed these four formats.

Suggestion:

try {
        $year = (int)(new \DateTimeImmutable($value))->format('Y');
} catch (\Exception) {
        return false;
}
return $year >= 1 && $year <= 9999;

which still validates and is format agnostic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With external clients it can also be someone's curl script that then breaks.
According to Clause, Android Deck does this:

I have updated to add support for formats ISO_INSTANT, ISO_OFFSET_DATE_TIME
I think the suggested code makes no sense since \DateTimeImmutable() returns 2001 for year greater than 9999, causing the result to always be TRUE.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the suggested code makes no sense since \DateTimeImmutable() returns 2001 for year greater than 9999, causing the result to always be TRUE.

Yep, I stand corrected.

OK. Ideally the openapi also announces/documents the allowed formats.

@stefan-niedermann do the formats work for you?


foreach ($allowedFormats as $format) {
$datetime = \DateTime::createFromFormat($format, $value);
if ($datetime && $datetime->format($format) === $value) {
return true;
}
}

return false;
}

/**
* Get the size of an attribute.
*
Expand All @@ -159,13 +228,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);
}
}
3 changes: 3 additions & 0 deletions lib/Validators/CardServiceValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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'],
];
}
}
3 changes: 3 additions & 0 deletions src/components/card/DueDateSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ export default defineComponent({
},

set(val) {
if (!this.isValidDate(val)) {
return
}
this.$emit('input', val ? new Date(val) : null)
},
},
Expand Down
3 changes: 3 additions & 0 deletions src/components/card/StartDateSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ export default defineComponent({
},

set(val) {
if (!this.isValidDate(val)) {
return
}
this.$emit('input', val ? new Date(val) : null)
},
},
Expand Down
9 changes: 9 additions & 0 deletions src/mixins/readableDate.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

import moment from '@nextcloud/moment'

const maxYear = 9999

export default {
computed: {
formatReadableDate() {
Expand All @@ -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)
},
},
}
Loading
Loading