diff --git a/appinfo/info.xml b/appinfo/info.xml
index 8f696b737b..490974539d 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -20,7 +20,7 @@
- 🚀 Get your project organized
- 2.0.0-dev.0
+ 2.0.0-dev.1
agpl
Julius Härtl
Deck
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 6b6db49c84..3397f0ccb4 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -29,6 +29,8 @@
use OCA\Deck\Event\SessionClosedEvent;
use OCA\Deck\Event\SessionCreatedEvent;
use OCA\Deck\Federation\DeckFederationProvider;
+use OCA\Deck\Import\BoardImporter;
+use OCA\Deck\Import\IBoardImporter;
use OCA\Deck\Listeners\AclCreatedRemovedListener;
use OCA\Deck\Listeners\BeforeTemplateRenderedListener;
use OCA\Deck\Listeners\CommentEventListener;
@@ -138,6 +140,8 @@ public function register(IRegistrationContext $context): void {
$context->registerMiddleWare(ExceptionMiddleware::class);
$context->registerMiddleWare(DefaultBoardMiddleware::class);
+ $context->registerServiceAlias(IBoardImporter::class, BoardImporter::class);
+
$context->registerService('databaseType', static function (ContainerInterface $c) {
return $c->get(IConfig::class)->getSystemValue('dbtype', 'sqlite');
});
diff --git a/lib/Db/ImportRef.php b/lib/Db/ImportRef.php
new file mode 100644
index 0000000000..f8cee2622a
--- /dev/null
+++ b/lib/Db/ImportRef.php
@@ -0,0 +1,76 @@
+addType('id', 'integer');
+ $this->addType('boardId', 'integer');
+ $this->addType('objectId', 'integer');
+ $this->addType('state', 'integer');
+ $this->addType('createdAt', 'integer');
+ $this->addType('updatedAt', 'integer');
+ }
+
+ public static function hashRef(string $ref): string {
+ return hash('sha256', $ref);
+ }
+}
diff --git a/lib/Db/ImportRefMapper.php b/lib/Db/ImportRefMapper.php
new file mode 100644
index 0000000000..f956198330
--- /dev/null
+++ b/lib/Db/ImportRefMapper.php
@@ -0,0 +1,96 @@
+ */
+class ImportRefMapper extends QBMapper {
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'deck_import_refs', ImportRef::class);
+ }
+
+ public function findByRef(string $app, string $kind, int $boardId, string $ref): ?ImportRef {
+ return $this->findByRefs($app, $kind, $boardId, [$ref])[$ref] ?? null;
+ }
+
+ /**
+ * @param list $refs
+ * @return array by ref
+ */
+ public function findByRefs(string $app, string $kind, int $boardId, array $refs): array {
+ $found = [];
+ foreach (array_chunk(array_values(array_unique($refs)), 1000) as $chunk) {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('app', $qb->createNamedParameter($app)))
+ ->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)))
+ ->andWhere($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT)))
+ ->andWhere($qb->expr()->in('ref_hash', $qb->createNamedParameter(array_map(ImportRef::hashRef(...), $chunk), IQueryBuilder::PARAM_STR_ARRAY)));
+ foreach ($this->findEntities($qb) as $importRef) {
+ // The hash selects the row, the stored reference confirms it
+ if (in_array($importRef->getRef(), $chunk, true)) {
+ $found[$importRef->getRef()] = $importRef;
+ }
+ }
+ }
+ return $found;
+ }
+
+ /**
+ * @return list
+ */
+ public function findAllOfKind(string $app, string $kind, int $boardId): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('app', $qb->createNamedParameter($app)))
+ ->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)))
+ ->andWhere($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT)))
+ ->orderBy('id');
+ return $this->findEntities($qb);
+ }
+
+ /**
+ * The reference of a board an importing app created, whichever app it was
+ */
+ public function findBoard(int $boardId): ?ImportRef {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('kind', $qb->createNamedParameter(ImportRef::KIND_BOARD)))
+ ->andWhere($qb->expr()->eq('object_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT)))
+ ->setMaxResults(1);
+ $entities = $this->findEntities($qb);
+ return $entities[0] ?? null;
+ }
+
+ /**
+ * Active card references of a board in the order they were first written
+ *
+ * @return list
+ */
+ public function findCardPage(string $app, int $boardId, int $afterId, int $limit): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('app', $qb->createNamedParameter($app)))
+ ->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter(ImportRef::KIND_CARD)))
+ ->andWhere($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT)))
+ ->andWhere($qb->expr()->eq('state', $qb->createNamedParameter(ImportRef::STATE_ACTIVE, IQueryBuilder::PARAM_INT)))
+ ->andWhere($qb->expr()->gt('id', $qb->createNamedParameter($afterId, IQueryBuilder::PARAM_INT)))
+ ->orderBy('id')
+ ->setMaxResults($limit);
+ return $this->findEntities($qb);
+ }
+}
diff --git a/lib/Import/BoardContext.php b/lib/Import/BoardContext.php
new file mode 100644
index 0000000000..946de61247
--- /dev/null
+++ b/lib/Import/BoardContext.php
@@ -0,0 +1,47 @@
+ $stackIds by ref
+ * @param array $labelIds by ref
+ */
+ public function __construct(
+ public readonly Board $board,
+ public readonly array $stackIds,
+ public readonly array $labelIds,
+ ) {
+ }
+
+ /**
+ * The reference of a stack, or a stand-in for one the importer didn't write
+ */
+ public function stackRef(int $stackId): string {
+ $ref = array_search($stackId, $this->stackIds, true);
+ return $ref === false ? 'deck:' . $stackId : (string)$ref;
+ }
+
+ /**
+ * The reference of a label, or a stand-in for one the importer didn't write
+ */
+ public function labelRef(int $labelId): string {
+ $ref = array_search($labelId, $this->labelIds, true);
+ return $ref === false ? 'deck:' . $labelId : (string)$ref;
+ }
+}
diff --git a/lib/Import/BoardImporter.php b/lib/Import/BoardImporter.php
new file mode 100644
index 0000000000..657438f843
--- /dev/null
+++ b/lib/Import/BoardImporter.php
@@ -0,0 +1,1195 @@
+checkApp($app);
+ $this->checkRef('ref', $ref);
+ $this->checkTitle('title', $title, self::MAX_BOARD_TITLE);
+ $this->checkColor('color', $color);
+ $this->checkLabels($labels);
+ $this->checkStacks($stacks);
+ if (!$this->userManager->userExists($ownerUid)) {
+ throw EntryException::error(ImportResult::ERROR_UNKNOWN_USER, 'The owner is not a user');
+ }
+ } catch (EntryException $e) {
+ return [
+ 'status' => ImportResult::STATUS_ERROR,
+ 'board_id' => 0,
+ 'created' => false,
+ 'labels' => [],
+ 'stacks' => [],
+ 'changed' => [],
+ 'error' => ['code' => (string)$e->result->errorCode, 'message' => $e->getMessage()],
+ ];
+ }
+
+ $result = $this->retryOnUniqueViolation(fn () => $this->atomic(
+ fn () => $this->writeBoard($app, $ref, $ownerUid, $title, $color, $labels, $stacks),
+ $this->db,
+ ));
+ if ($result['created']) {
+ $this->eventDispatcher->dispatchTyped(new CriticalActionPerformedEvent(
+ 'Board %s of "%s" was imported by %s',
+ [
+ 'boardId' => $result['board_id'],
+ 'owner' => $ownerUid,
+ 'importSystem' => $app,
+ ],
+ false,
+ Operation::BoardImported->value,
+ ));
+ }
+ return $result;
+ }
+
+ /**
+ * @param list $labels
+ * @param list $stacks
+ * @return array{status: string, board_id: int, created: bool, labels: array, stacks: array, changed: list, error?: array{code: string, message: string}}
+ */
+ private function writeBoard(string $app, string $ref, string $ownerUid, string $title, string $color, array $labels, array $stacks): array {
+ $now = $this->timeFactory->getTime();
+ $changed = [];
+ $created = false;
+ $wrote = false;
+ $boardRef = $this->refMapper->findByRef($app, ImportRef::KIND_BOARD, 0, $ref);
+ if ($boardRef === null) {
+ $board = new Board();
+ $board->setTitle($title);
+ $board->setOwner($ownerUid);
+ $board->setColor($color);
+ $board->setArchived(false);
+ $board->setDeletedAt(0);
+ $board->setLastModified($now);
+ $board = $this->boardMapper->insert($board);
+ $this->insertRef($app, ImportRef::KIND_BOARD, 0, $ref, $board->getId(), self::valuesHash($title, $color));
+ $created = true;
+ $wrote = true;
+ } else {
+ try {
+ $board = $this->boardMapper->find($boardRef->getObjectId(), false, false, true);
+ } catch (DoesNotExistException) {
+ $board = null;
+ }
+ if ($board === null || $board->getDeletedAt() > 0) {
+ return [
+ 'status' => ImportResult::STATUS_CONFLICT,
+ 'board_id' => $boardRef->getObjectId(),
+ 'created' => false,
+ 'labels' => [],
+ 'stacks' => [],
+ 'changed' => [$ref],
+ 'error' => ['code' => ImportResult::ERROR_CHANGED, 'message' => 'The board was deleted in Deck'],
+ ];
+ }
+ $current = self::valuesHash($board->getTitle(), $board->getColor());
+ if ($current !== $boardRef->getStoredHash()) {
+ $changed[] = $ref;
+ } elseif ($current !== self::valuesHash($title, $color)) {
+ $board->setTitle($title);
+ $board->setColor($color);
+ $board->setLastModified($now);
+ $this->boardMapper->update($board);
+ $this->updateRef($boardRef, self::valuesHash($title, $color));
+ $wrote = true;
+ }
+ }
+ $boardId = $board->getId();
+
+ [$labelIds, $labelsChanged, $labelsWritten] = $this->ensureLabels($app, $boardId, $labels);
+ [$stackIds, $stacksChanged, $stacksWritten] = $this->ensureStacks($app, $boardId, $stacks);
+
+ if ($wrote || $labelsWritten || $stacksWritten) {
+ $this->boardMapper->flushCache($boardId);
+ $this->changeHelper->boardChanged($boardId);
+ }
+
+ return [
+ 'status' => ImportResult::STATUS_OK,
+ 'board_id' => $boardId,
+ 'created' => $created,
+ 'labels' => $labelIds,
+ 'stacks' => $stackIds,
+ 'changed' => array_merge($changed, $labelsChanged, $stacksChanged),
+ ];
+ }
+
+ /**
+ * @param list $labels
+ * @return array{0: array, 1: list, 2: bool} the label IDs by ref, the refs whose Deck version was kept and whether a label was written
+ */
+ private function ensureLabels(string $app, int $boardId, array $labels): array {
+ $refs = $this->refMapper->findByRefs($app, ImportRef::KIND_LABEL, $boardId, array_map(fn (ImportLabel $label) => $label->ref, $labels));
+ $existing = [];
+ foreach ($this->labelMapper->findAll($boardId) as $label) {
+ $existing[$label->getId()] = $label;
+ }
+
+ $ids = [];
+ $changed = [];
+ $wrote = false;
+ foreach ($labels as $wanted) {
+ $labelRef = $refs[$wanted->ref] ?? null;
+ if ($labelRef === null) {
+ $adopted = null;
+ foreach ($existing as $label) {
+ if ($label->getTitle() === $wanted->title) {
+ $adopted = $label;
+ break;
+ }
+ }
+ if ($adopted !== null) {
+ // A label somebody made with the same title is used as it is
+ $this->insertRef($app, ImportRef::KIND_LABEL, $boardId, $wanted->ref, $adopted->getId(), '');
+ $ids[$wanted->ref] = $adopted->getId();
+ $changed[] = $wanted->ref;
+ $wrote = true;
+ continue;
+ }
+ $label = new Label();
+ $label->setTitle($wanted->title);
+ $label->setColor($wanted->color);
+ $label->setBoardId($boardId);
+ $label = $this->labelMapper->insert($label);
+ $existing[$label->getId()] = $label;
+ $this->insertRef($app, ImportRef::KIND_LABEL, $boardId, $wanted->ref, $label->getId(), self::valuesHash($wanted->title, $wanted->color));
+ $ids[$wanted->ref] = $label->getId();
+ $wrote = true;
+ continue;
+ }
+
+ $label = $existing[$labelRef->getObjectId()] ?? null;
+ if ($label === null) {
+ $changed[] = $wanted->ref;
+ continue;
+ }
+ $ids[$wanted->ref] = $label->getId();
+ $current = self::valuesHash($label->getTitle(), $label->getColor());
+ $next = self::valuesHash($wanted->title, $wanted->color);
+ if ($current !== $labelRef->getStoredHash()
+ || ($label->getTitle() !== $wanted->title && $this->labelMapper->existsByBoardIdAndTitle($boardId, $wanted->title, $label->getId()))) {
+ $changed[] = $wanted->ref;
+ } elseif ($current !== $next) {
+ $label->setTitle($wanted->title);
+ $label->setColor($wanted->color);
+ $this->labelMapper->update($label);
+ $this->updateRef($labelRef, $next);
+ $wrote = true;
+ }
+ }
+ return [$ids, $changed, $wrote];
+ }
+
+ /**
+ * @param list $stacks
+ * @return array{0: array, 1: list, 2: bool} the stack IDs by ref, the refs whose Deck version was kept and whether a stack was written
+ */
+ private function ensureStacks(string $app, int $boardId, array $stacks): array {
+ $refs = $this->refMapper->findByRefs($app, ImportRef::KIND_STACK, $boardId, array_map(fn (ImportStack $stack) => $stack->ref, $stacks));
+ $existing = [];
+ foreach ($this->stackMapper->findAll($boardId) as $stack) {
+ $existing[$stack->getId()] = $stack;
+ }
+
+ $ids = [];
+ $changed = [];
+ $wrote = false;
+ foreach ($stacks as $wanted) {
+ $stackRef = $refs[$wanted->ref] ?? null;
+ if ($stackRef === null) {
+ $stack = new Stack();
+ $stack->setTitle($wanted->title);
+ $stack->setBoardId($boardId);
+ $stack->setOrder($wanted->order);
+ $stack->setLastModified($this->timeFactory->getTime());
+ $stack = $this->stackMapper->insert($stack);
+ $this->insertRef($app, ImportRef::KIND_STACK, $boardId, $wanted->ref, $stack->getId(), self::valuesHash($wanted->title, $wanted->order));
+ $ids[$wanted->ref] = $stack->getId();
+ $wrote = true;
+ continue;
+ }
+
+ $stack = $existing[$stackRef->getObjectId()] ?? null;
+ if ($stack === null) {
+ $changed[] = $wanted->ref;
+ continue;
+ }
+ $ids[$wanted->ref] = $stack->getId();
+ $current = self::valuesHash($stack->getTitle(), $stack->getOrder());
+ $next = self::valuesHash($wanted->title, $wanted->order);
+ if ($current !== $stackRef->getStoredHash()) {
+ $changed[] = $wanted->ref;
+ } elseif ($current !== $next) {
+ $stack->setTitle($wanted->title);
+ $stack->setOrder($wanted->order);
+ $stack->setLastModified($this->timeFactory->getTime());
+ $this->stackMapper->update($stack);
+ $this->updateRef($stackRef, $next);
+ $wrote = true;
+ }
+ }
+ return [$ids, $changed, $wrote];
+ }
+
+ #[\Override]
+ public function importCards(string $app, int $boardId, array $cards): array {
+ $context = $this->context($app, $boardId);
+ $seen = [];
+ $results = [];
+ foreach ($cards as $card) {
+ try {
+ if (!$card instanceof ImportCard) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'Not a card');
+ }
+ $this->checkCard($card, $context, $seen);
+ $results[] = $this->importCard($app, $context, $card);
+ } catch (EntryException $e) {
+ $results[] = $e->result;
+ }
+ }
+ return $results;
+ }
+
+ /**
+ * Writes the card with its labels and assignments in one transaction,
+ * then its attachments: sharing a file runs the server's share and
+ * mount listeners, which must not run inside a transaction (a failed
+ * statement they recover from aborts it on PostgreSQL). Until the
+ * attachments are written the reference has no source hash, so a
+ * repeat completes the card.
+ *
+ * @throws EntryException
+ */
+ private function importCard(string $app, BoardContext $context, ImportCard $card): ImportResult {
+ $cardRef = $this->refMapper->findByRef($app, ImportRef::KIND_CARD, $context->board->getId(), $card->ref);
+ $attached = $cardRef === null ? [] : $this->cardFiles($cardRef->getObjectId());
+ $files = $this->resolveFiles($context->board, array_values(array_diff($card->files, array_keys($attached))));
+
+ $write = $this->retryOnUniqueViolation(fn () => $this->atomic(
+ fn () => $this->writeCard($app, $context, $card),
+ $this->db,
+ ));
+ if ($write instanceof ImportResult) {
+ return $write;
+ }
+ [$entity, $cardRef, $created] = $write;
+ $cardId = $entity->getId();
+
+ try {
+ $this->writeFiles($context->board, $cardId, $card->files, $files);
+ } catch (EntryException $e) {
+ $stored = $this->storedHash($context, $card->ref, $this->cardMapper->find($cardId, false));
+ $this->updateRef($cardRef, $stored);
+ throw new EntryException(new ImportResult(ImportResult::STATUS_ERROR, $cardId, $stored, $e->result->errorCode, $e->result->errorMessage));
+ }
+
+ $stored = $this->storedHash($context, $card->ref, $this->cardMapper->find($cardId, false));
+ $this->updateRef($cardRef, $stored, $card->sha256);
+ $this->changeHelper->cardChanged($cardId, false);
+ $this->fullTextSearch->handle($created ? new CardCreatedEvent($entity) : new CardUpdatedEvent($entity));
+ return ImportResult::ok($cardId, $stored);
+ }
+
+ /**
+ * @param array $seen the refs of the call so far
+ * @throws EntryException
+ */
+ private function checkCard(ImportCard $card, BoardContext $context, array &$seen): void {
+ $this->checkRef('ref', $card->ref);
+ if (isset($seen[$card->ref])) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'The ref appears twice');
+ }
+ $seen[$card->ref] = true;
+ if (!isset($context->stackIds[$card->stackRef])) {
+ throw EntryException::error(ImportResult::ERROR_UNKNOWN_STACK, 'The stack is not on the board');
+ }
+ $this->checkTitle('title', $card->title, Card::TITLE_MAX_LENGTH);
+ if ($card->mode !== ImportCard::MODE_CREATE && $card->mode !== ImportCard::MODE_NEW_VERSION) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'The mode is invalid');
+ }
+ if (preg_match('/^[0-9a-f]{64}$/', $card->sha256) !== 1) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'The sha256 is not a lowercase hex SHA-256');
+ }
+ if ($card->createdBy !== '' && !$this->userManager->userExists($card->createdBy)) {
+ throw EntryException::error(ImportResult::ERROR_UNKNOWN_USER, 'The creator is not a user');
+ }
+ $this->checkList('assignees', $card->assignees, 'is_string');
+ foreach ($card->assignees as $uid) {
+ if (!$this->userManager->userExists($uid)) {
+ throw EntryException::error(ImportResult::ERROR_UNKNOWN_USER, 'An assignee is not a user');
+ }
+ }
+ $this->checkList('labels', $card->labels, 'is_string');
+ foreach ($card->labels as $labelRef) {
+ if (!isset($context->labelIds[$labelRef])) {
+ throw EntryException::error(ImportResult::ERROR_UNKNOWN_LABEL, 'A label is not on the board');
+ }
+ }
+ $this->checkList('files', $card->files, fn ($id) => is_int($id) && $id > 0);
+ }
+
+ /**
+ * Writes the card, its labels and assignments, or answers without a write
+ *
+ * @return ImportResult|array{0: Card, 1: ImportRef, 2: bool} the result, or the written card, its reference and whether it is new
+ * @throws EntryException
+ */
+ private function writeCard(string $app, BoardContext $context, ImportCard $card): ImportResult|array {
+ $boardId = $context->board->getId();
+ $cardRef = $this->refMapper->findByRef($app, ImportRef::KIND_CARD, $boardId, $card->ref);
+ if ($cardRef === null) {
+ if ($card->mode === ImportCard::MODE_NEW_VERSION) {
+ throw EntryException::error(ImportResult::ERROR_NOT_FOUND, 'The card was not imported');
+ }
+ $entity = $this->insertCard($context, $card);
+ $cardRef = $this->insertRef($app, ImportRef::KIND_CARD, $boardId, $card->ref, $entity->getId(), null);
+ $this->writeLabelsAndAssignees($context, $entity->getId(), $card);
+ return [$entity, $cardRef, true];
+ }
+
+ $entity = $this->findCard($cardRef->getObjectId());
+ if ($entity === null) {
+ throw EntryException::conflict(ImportResult::ERROR_CHANGED, 'The card was deleted in Deck', $cardRef->getObjectId());
+ }
+ $current = $this->storedHash($context, $card->ref, $entity);
+ if ($cardRef->getState() === ImportRef::STATE_REMOVED) {
+ if ($entity->getDeletedAt() === 0 || $current !== $cardRef->getStoredHash()) {
+ throw EntryException::conflict(ImportResult::ERROR_CHANGED, 'The card was restored or changed in Deck', $entity->getId(), $current);
+ }
+ $entity->setDeletedAt(0);
+ } else {
+ if ($this->changedInDeck($entity, $current, $cardRef)) {
+ throw EntryException::conflict(ImportResult::ERROR_CHANGED, 'The card was changed in Deck', $entity->getId(), $current);
+ }
+ if ($card->sha256 === $cardRef->getSourceHash()) {
+ return ImportResult::ok($entity->getId(), $current);
+ }
+ if ($card->mode === ImportCard::MODE_CREATE && $cardRef->getSourceHash() !== null) {
+ throw EntryException::conflict(ImportResult::ERROR_EXISTS, 'The card exists with other content', $entity->getId(), $current);
+ }
+ }
+
+ $this->updateCard($context, $entity, $card);
+ $this->writeLabelsAndAssignees($context, $entity->getId(), $card);
+ $cardRef->setState(ImportRef::STATE_ACTIVE);
+ $cardRef->setSourceHash(null);
+ $cardRef->setUpdatedAt($this->timeFactory->getTime());
+ $this->refMapper->update($cardRef);
+ return [$entity, $cardRef, false];
+ }
+
+ private function insertCard(BoardContext $context, ImportCard $card): Card {
+ $entity = new Card();
+ $entity->setTitle($card->title);
+ $entity->setDescription($card->description);
+ $entity->setStackId($context->stackIds[$card->stackRef]);
+ $entity->setType('plain');
+ $entity->setOrder($card->order);
+ $entity->setOwner($card->createdBy !== '' ? $card->createdBy : $context->board->getOwner());
+ $entity->setStartdate(self::mutable($card->start));
+ $entity->setDuedate(self::mutable($card->due));
+ $entity->setDone(self::mutable($card->done));
+ $entity->setArchived(false);
+ $entity->setDeletedAt(0);
+ $entity->setNotified($this->isPast($card->due));
+ $entity = $this->cardMapper->insert($entity);
+
+ // The mapper stamps the insert time, the import keeps the source's
+ $entity->resetUpdatedFields();
+ $entity->setCreatedAt($card->created->getTimestamp());
+ $this->cardMapper->update($entity, false);
+ return $entity;
+ }
+
+ private function updateCard(BoardContext $context, Card $entity, ImportCard $card): void {
+ $entity->setTitle($card->title);
+ $entity->setDescription($card->description);
+ $entity->setStackId($context->stackIds[$card->stackRef]);
+ $entity->setOrder($card->order);
+ $entity->setOwner($card->createdBy !== '' ? $card->createdBy : $context->board->getOwner());
+ $entity->setCreatedAt($card->created->getTimestamp());
+ // Only differing times are set: the mapper resets the due notification when the due date is set
+ if (self::time($entity->getStartdate()) !== self::time($card->start)) {
+ $entity->setStartdate(self::mutable($card->start));
+ }
+ if (self::time($entity->getDuedate()) !== self::time($card->due)) {
+ $entity->setDuedate(self::mutable($card->due));
+ }
+ if (self::time($entity->getDone()) !== self::time($card->done)) {
+ $entity->setDone(self::mutable($card->done));
+ }
+ $this->cardMapper->update($entity);
+ if ($this->isPast($card->due)) {
+ $this->cardMapper->markNotified($entity);
+ }
+ }
+
+ private function writeLabelsAndAssignees(BoardContext $context, int $cardId, ImportCard $card): void {
+ $wantedLabels = array_map(fn (string $ref) => $context->labelIds[$ref], $card->labels);
+ $currentLabels = array_map(fn (Label $label) => $label->getId(), $this->labelMapper->findAssignedLabelsForCard($cardId));
+ foreach (array_diff($wantedLabels, $currentLabels) as $labelId) {
+ $this->cardMapper->assignLabel($cardId, $labelId);
+ }
+ foreach (array_diff($currentLabels, $wantedLabels) as $labelId) {
+ $this->cardMapper->removeLabel($cardId, $labelId);
+ }
+
+ $currentAssignments = [];
+ foreach ($this->assignmentMapper->findAll($cardId) as $assignment) {
+ $currentAssignments[self::assigneeKey($assignment)] = $assignment;
+ }
+ foreach ($card->assignees as $uid) {
+ if (isset($currentAssignments[$uid])) {
+ unset($currentAssignments[$uid]);
+ continue;
+ }
+ $assignment = new Assignment();
+ $assignment->setCardId($cardId);
+ $assignment->setParticipant($uid);
+ $assignment->setType(Assignment::TYPE_USER);
+ $this->assignmentMapper->insert($assignment);
+ }
+ foreach ($currentAssignments as $assignment) {
+ $this->assignmentMapper->delete($assignment);
+ }
+ }
+
+ /**
+ * Finds each file and the user who shares it with a card: its owner,
+ * or the board owner for a file without one when they can see it
+ *
+ * @param list $fileIds
+ * @return array the file in the sharer's view and the sharer, by file ID
+ * @throws EntryException
+ */
+ private function resolveFiles(Board $board, array $fileIds): array {
+ $files = [];
+ foreach ($fileIds as $fileId) {
+ $sharer = null;
+ $node = null;
+ try {
+ $sharer = $this->rootFolder->getFirstNodeById($fileId)?->getOwner()?->getUID();
+ $node = $sharer === null ? null : $this->rootFolder->getUserFolder($sharer)->getFirstNodeById($fileId);
+ if ($node === null) {
+ $sharer = $board->getOwner();
+ $node = $this->rootFolder->getUserFolder($sharer)->getFirstNodeById($fileId);
+ }
+ } catch (\Exception) {
+ $node = null;
+ }
+ if ($node === null || $sharer === null) {
+ throw EntryException::error(ImportResult::ERROR_FILE_UNAVAILABLE, 'File ' . $fileId . ' was not found');
+ }
+ if (!$node->isShareable()) {
+ throw EntryException::error(ImportResult::ERROR_FILE_UNAVAILABLE, 'File ' . $fileId . ' may not be shared');
+ }
+ $files[$fileId] = [$node, $sharer];
+ }
+ return $files;
+ }
+
+ /**
+ * Makes $fileIds the card's file attachments
+ *
+ * @param list $fileIds
+ * @param array $resolved the files that weren't attached yet
+ * @throws EntryException
+ */
+ private function writeFiles(Board $board, int $cardId, array $fileIds, array $resolved): void {
+ $attached = $this->cardFiles($cardId);
+ foreach ($fileIds as $fileId) {
+ if (!isset($attached[$fileId])) {
+ [$node, $sharer] = $resolved[$fileId] ?? $this->resolveFiles($board, [$fileId])[$fileId];
+ $this->attachFile($board, $cardId, $fileId, $node, $sharer);
+ }
+ }
+ foreach (array_diff_key($attached, array_flip($fileIds)) as $fileId => $shareId) {
+ try {
+ $this->shareManager->deleteShare($this->shareProvider->getShareById((string)$shareId));
+ } catch (\Exception $e) {
+ $this->logger->info('Imported file attachment could not be removed', ['exception' => $e, 'fileId' => $fileId, 'cardId' => $cardId]);
+ throw EntryException::error(ImportResult::ERROR_FILE_UNAVAILABLE, 'File ' . $fileId . ' could not be detached');
+ }
+ }
+ }
+
+ /**
+ * Shares an existing file with the card, which makes it a Deck
+ * attachment of type `file`
+ *
+ * @throws EntryException
+ */
+ private function attachFile(Board $board, int $cardId, int $fileId, Node $node, string $sharer): void {
+ $share = $this->shareManager->newShare();
+ $share->setNode($node);
+ $share->setShareType(IShare::TYPE_DECK);
+ $share->setSharedWith((string)$cardId);
+ $share->setPermissions(Constants::PERMISSION_READ);
+ $share->setSharedBy($sharer);
+ $share->setMailSend(false);
+
+ // The Deck share provider checks that its user may edit the card's board
+ $previousUser = $this->permissionService->getUserId();
+ $this->permissionService->setUserId($board->getOwner());
+ try {
+ $this->shareManager->createShare($share);
+ } catch (\Exception $e) {
+ $this->logger->info('Imported file attachment could not be shared', ['exception' => $e, 'fileId' => $fileId, 'cardId' => $cardId]);
+ throw EntryException::error(ImportResult::ERROR_FILE_UNAVAILABLE, 'File ' . $fileId . ' could not be shared');
+ } finally {
+ $this->permissionService->setUserId($previousUser);
+ }
+ }
+
+ /**
+ * The files attached to a card as Deck shares
+ *
+ * @return array share IDs by file ID
+ */
+ private function cardFiles(int $cardId): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('s.id', 's.file_source', 'f.fileid', 'f.path')
+ ->selectAlias('st.id', 'storage_string_id')
+ ->from('share', 's')
+ ->leftJoin('s', 'filecache', 'f', $qb->expr()->eq('s.file_source', 'f.fileid'))
+ ->leftJoin('f', 'storages', 'st', $qb->expr()->eq('f.storage', 'st.numeric_id'))
+ ->where($qb->expr()->eq('s.share_type', $qb->createNamedParameter(IShare::TYPE_DECK, IQueryBuilder::PARAM_INT)))
+ ->andWhere($qb->expr()->eq('s.share_with', $qb->createNamedParameter((string)$cardId)))
+ ->andWhere($qb->expr()->isNull('s.parent'))
+ ->andWhere($qb->expr()->in('s.item_type', $qb->createNamedParameter(['file', 'folder'], IQueryBuilder::PARAM_STR_ARRAY)));
+ $files = [];
+ $result = $qb->executeQuery();
+ while ($row = $result->fetch()) {
+ if ($this->shareProvider->isAccessibleResult($row)) {
+ $files[(int)$row['file_source']] = (int)$row['id'];
+ }
+ }
+ $result->closeCursor();
+ return $files;
+ }
+
+ /**
+ * The SHA-256 of the card's canonical form as Deck holds it
+ */
+ private function storedHash(BoardContext $context, string $ref, Card $card): string {
+ $labelRefs = array_map(fn (Label $label) => $context->labelRef($label->getId()), $this->labelMapper->findAssignedLabelsForCard($card->getId()));
+ $assignees = array_map(self::assigneeKey(...), $this->assignmentMapper->findAll($card->getId()));
+ return CardCanonicalForm::hash(
+ $ref,
+ $context->stackRef($card->getStackId()),
+ $card->getTitle(),
+ (string)$card->getDescription(),
+ (int)$card->getOrder(),
+ $card->getStartdate(),
+ $card->getDuedate(),
+ $card->getDone(),
+ array_values($assignees),
+ array_values($labelRefs),
+ array_keys($this->cardFiles($card->getId())),
+ );
+ }
+
+ /**
+ * Whether somebody changed, archived or deleted a card since the importer wrote it
+ */
+ private function changedInDeck(Card $card, string $currentHash, ImportRef $cardRef): bool {
+ if ($card->getArchived() || $card->getDeletedAt() > 0) {
+ return true;
+ }
+ // A write the importer didn't finish differs from its last hash by design
+ return $cardRef->getSourceHash() !== null && $currentHash !== $cardRef->getStoredHash();
+ }
+
+ #[\Override]
+ public function statCards(string $app, int $boardId, array $refs, bool $hash): array {
+ $context = $this->context($app, $boardId);
+ $found = $this->refMapper->findByRefs($app, ImportRef::KIND_CARD, $boardId, $refs);
+ $stats = [];
+ foreach ($refs as $ref) {
+ $cardRef = $found[$ref] ?? null;
+ if ($cardRef === null) {
+ $stats[] = new ImportCardStat($ref, false);
+ continue;
+ }
+ $card = $this->findCard($cardRef->getObjectId());
+ $removed = $cardRef->getState() === ImportRef::STATE_REMOVED;
+ if ($card === null) {
+ $stats[] = new ImportCardStat($ref, false, null, null, !$removed);
+ continue;
+ }
+ $current = $this->storedHash($context, $ref, $card);
+ $changed = $removed
+ ? $card->getDeletedAt() === 0 || $current !== $cardRef->getStoredHash()
+ : $this->changedInDeck($card, $current, $cardRef);
+ $stats[] = new ImportCardStat($ref, !$removed, $card->getId(), $hash ? $current : null, $changed);
+ }
+ return $stats;
+ }
+
+ #[\Override]
+ public function listCards(string $app, int $boardId, string $cursor, int $limit): array {
+ $this->context($app, $boardId);
+ if ($cursor !== '' && preg_match('/^[1-9][0-9]{0,18}$/', $cursor) !== 1) {
+ throw new ImportException(ImportException::INVALID, 'The cursor is invalid');
+ }
+ if ($limit < 1 || $limit > self::MAX_LIST_LIMIT) {
+ throw new ImportException(ImportException::INVALID, 'The limit must be between 1 and ' . self::MAX_LIST_LIMIT);
+ }
+ $page = $this->refMapper->findCardPage($app, $boardId, $cursor === '' ? 0 : (int)$cursor, $limit + 1);
+ $next = '';
+ if (count($page) > $limit) {
+ array_pop($page);
+ $next = (string)$page[$limit - 1]->getId();
+ }
+ return [
+ 'entries' => array_map(fn (ImportRef $ref) => $ref->getRef(), $page),
+ 'next_cursor' => $next,
+ ];
+ }
+
+ #[\Override]
+ public function removeCards(string $app, int $boardId, array $refs): array {
+ $context = $this->context($app, $boardId);
+ $results = [];
+ foreach ($refs as $ref) {
+ try {
+ [$result, $removed] = $this->atomic(fn () => $this->removeCard($app, $context, $ref), $this->db);
+ if ($removed !== null) {
+ $this->fullTextSearch->handle(new CardDeletedEvent($removed));
+ }
+ $results[] = $result;
+ } catch (EntryException $e) {
+ $results[] = $e->result;
+ }
+ }
+ return $results;
+ }
+
+ /**
+ * @return array{0: ImportResult, 1: ?Card} the result and the card this call removed
+ * @throws EntryException
+ */
+ private function removeCard(string $app, BoardContext $context, string $ref): array {
+ $cardRef = $this->refMapper->findByRef($app, ImportRef::KIND_CARD, $context->board->getId(), $ref);
+ if ($cardRef === null) {
+ throw EntryException::error(ImportResult::ERROR_NOT_FOUND, 'The card was not imported');
+ }
+ if ($cardRef->getState() === ImportRef::STATE_REMOVED) {
+ return [ImportResult::ok($cardRef->getObjectId()), null];
+ }
+ $card = $this->findCard($cardRef->getObjectId());
+ if ($card === null) {
+ throw EntryException::conflict(ImportResult::ERROR_CHANGED, 'The card was deleted in Deck', $cardRef->getObjectId());
+ }
+ $current = $this->storedHash($context, $ref, $card);
+ if ($this->changedInDeck($card, $current, $cardRef)) {
+ throw EntryException::conflict(ImportResult::ERROR_CHANGED, 'The card was changed in Deck', $card->getId(), $current);
+ }
+
+ $card->setDeletedAt($this->timeFactory->getTime());
+ $this->cardMapper->update($card);
+ $cardRef->setState(ImportRef::STATE_REMOVED);
+ $this->updateRef($cardRef, $current);
+ $this->changeHelper->cardChanged($card->getId(), false);
+ return [ImportResult::ok($card->getId()), $card];
+ }
+
+ #[\Override]
+ public function setAccess(string $app, int $boardId, array $acl): array {
+ $context = $this->context($app, $boardId);
+ $board = $context->board;
+ $this->markPastDueNotified($app, $boardId);
+ $managed = [];
+ foreach ($this->refMapper->findAllOfKind($app, ImportRef::KIND_ACL, $boardId) as $aclRef) {
+ $managed[$aclRef->getRef()] = $aclRef;
+ }
+
+ $results = [];
+ $wanted = [];
+ foreach ($acl as $access) {
+ try {
+ if (!$access instanceof ImportAccess) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'Not a share');
+ }
+ if (isset($wanted[$access->ref()])) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'The share appears twice');
+ }
+ $wanted[$access->ref()] = true;
+ [$result, $change] = $this->atomic(fn () => $this->writeAccess($app, $board, $access, $managed[$access->ref()] ?? null), $this->db);
+ if ($change !== null) {
+ $this->accessChanged($change[0], $change[1]);
+ }
+ $results[] = $result;
+ } catch (EntryException $e) {
+ $results[] = $e->result;
+ }
+ }
+
+ foreach ($managed as $ref => $aclRef) {
+ if (!isset($wanted[$ref])) {
+ $change = $this->atomic(fn () => $this->removeAccess($aclRef), $this->db);
+ if ($change !== null) {
+ $this->accessChanged($change[0], $change[1]);
+ }
+ }
+ }
+
+ $this->boardMapper->flushCache($boardId);
+ $this->changeHelper->boardChanged($boardId);
+ return $results;
+ }
+
+ /**
+ * @return array{0: ImportResult, 1: ?array{0: AclCreatedEvent|AclUpdatedEvent, 1: Operation}} the result and the change to announce
+ * @throws EntryException
+ */
+ private function writeAccess(string $app, Board $board, ImportAccess $access, ?ImportRef $aclRef): array {
+ if ($access->type === ImportAccess::TYPE_USER) {
+ $type = Acl::PERMISSION_TYPE_USER;
+ if (!$this->userManager->userExists($access->id)) {
+ throw EntryException::error(ImportResult::ERROR_UNKNOWN_USER, 'The user does not exist');
+ }
+ if ($access->id === $board->getOwner()) {
+ return [ImportResult::ok(), null];
+ }
+ } elseif ($access->type === ImportAccess::TYPE_GROUP) {
+ $type = Acl::PERMISSION_TYPE_GROUP;
+ if (!$this->groupManager->groupExists($access->id)) {
+ throw EntryException::error(ImportResult::ERROR_UNKNOWN_GROUP, 'The group does not exist');
+ }
+ } else {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'The type must be user or group');
+ }
+
+ $next = self::valuesHash($access->edit, $access->share, $access->manage);
+ $existing = null;
+ foreach ($this->aclMapper->findAll($board->getId()) as $acl) {
+ if ($acl->getType() === $type && $acl->getParticipant() === $access->id) {
+ $existing = $acl;
+ }
+ }
+ if ($existing === null && $aclRef !== null) {
+ throw EntryException::conflict(ImportResult::ERROR_CHANGED, 'The share was removed in Deck');
+ }
+ if ($existing === null) {
+ $entity = new Acl();
+ $entity->setBoardId($board->getId());
+ $entity->setType($type);
+ $entity->setParticipant($access->id);
+ $entity->setPermissionEdit($access->edit);
+ $entity->setPermissionShare($access->share);
+ $entity->setPermissionManage($access->manage);
+ $entity = $this->aclMapper->insert($entity);
+ $this->insertRef($app, ImportRef::KIND_ACL, $board->getId(), $access->ref(), $entity->getId(), $next);
+ return [ImportResult::ok(), [new AclCreatedEvent($entity), Operation::BoardShareCreated]];
+ }
+
+ if ($aclRef === null || $aclRef->getObjectId() !== $existing->getId()) {
+ throw EntryException::conflict(ImportResult::ERROR_EXISTS, 'The board is already shared with them in Deck');
+ }
+ $current = self::valuesHash($existing->getPermissionEdit(), $existing->getPermissionShare(), $existing->getPermissionManage());
+ if ($current !== $aclRef->getStoredHash()) {
+ throw EntryException::conflict(ImportResult::ERROR_CHANGED, 'The share was changed in Deck');
+ }
+ if ($current !== $next) {
+ $existing->setPermissionEdit($access->edit);
+ $existing->setPermissionShare($access->share);
+ $existing->setPermissionManage($access->manage);
+ $this->aclMapper->update($existing);
+ $this->updateRef($aclRef, $next);
+ return [ImportResult::ok(), [new AclUpdatedEvent($existing), Operation::BoardShareUpdated]];
+ }
+ return [ImportResult::ok(), null];
+ }
+
+ /**
+ * Removes a share the importer made unless somebody changed it in Deck
+ *
+ * @return ?array{0: AclDeletedEvent, 1: Operation} the change to announce
+ */
+ private function removeAccess(ImportRef $aclRef): ?array {
+ try {
+ /** @var Acl $existing */
+ $existing = $this->aclMapper->find($aclRef->getObjectId());
+ } catch (DoesNotExistException) {
+ $this->refMapper->delete($aclRef);
+ return null;
+ }
+ $current = self::valuesHash($existing->getPermissionEdit(), $existing->getPermissionShare(), $existing->getPermissionManage());
+ if ($current !== $aclRef->getStoredHash()) {
+ return null;
+ }
+ $this->aclMapper->delete($existing);
+ $this->refMapper->delete($aclRef);
+ return [new AclDeletedEvent($existing), Operation::BoardShareDeleted];
+ }
+
+ /**
+ * Updates share access, collaboration resources, full-text search and
+ * the audit log through Deck's own listeners, without dispatching the
+ * event: its live update listener would push to open sessions
+ */
+ private function accessChanged(AclCreatedEvent|AclUpdatedEvent|AclDeletedEvent $event, Operation $operation): void {
+ $this->shareAccess->handle($event);
+ $this->resources->handle($event);
+ $this->fullTextSearch->handle($event);
+
+ $acl = $event->getAcl();
+ $permissions = ['read'];
+ if ($acl->getPermissionEdit()) {
+ $permissions[] = 'edit';
+ }
+ if ($acl->getPermissionShare()) {
+ $permissions[] = 'share';
+ }
+ if ($acl->getPermissionManage()) {
+ $permissions[] = 'manage';
+ }
+ $this->eventDispatcher->dispatchTyped(new CriticalActionPerformedEvent(
+ 'Share %s of board %s with %s "%s" was set by an import (permissions: %s)',
+ [
+ 'aclId' => $acl->getId(),
+ 'boardId' => $acl->getBoardId(),
+ 'shareType' => $acl->getType() === Acl::PERMISSION_TYPE_USER ? 'user' : 'group',
+ 'sharedWith' => $acl->getParticipant(),
+ 'permissions' => implode(', ', $permissions),
+ ],
+ false,
+ $operation->value,
+ ));
+ }
+
+ /**
+ * Imported cards that were already due when they were shared don't
+ * announce themselves as overdue to the people who get the board
+ */
+ private function markPastDueNotified(string $app, int $boardId): void {
+ $cardIds = array_map(fn (ImportRef $ref) => $ref->getObjectId(), $this->refMapper->findAllOfKind($app, ImportRef::KIND_CARD, $boardId));
+ foreach (array_chunk($cardIds, 1000) as $chunk) {
+ $qb = $this->db->getQueryBuilder();
+ $qb->update('deck_cards')
+ ->set('notified', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))
+ ->where($qb->expr()->in('id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)))
+ ->andWhere($qb->expr()->lte('duedate', $qb->createNamedParameter($this->timeFactory->now(), IQueryBuilder::PARAM_DATETIME_IMMUTABLE)))
+ ->andWhere($qb->expr()->eq('notified', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)));
+ $qb->executeStatement();
+ }
+ }
+
+ /**
+ * The board with the imported stacks and labels that still exist
+ *
+ * @throws ImportException
+ */
+ private function context(string $app, int $boardId): BoardContext {
+ try {
+ $this->checkApp($app);
+ } catch (EntryException $e) {
+ throw new ImportException(ImportException::INVALID, $e->getMessage());
+ }
+ if ($boardId < 1 || $boardId > self::MAX_DECK_ID) {
+ throw new ImportException(ImportException::NOT_FOUND, 'The board does not exist');
+ }
+ $boardRef = $this->refMapper->findBoard($boardId);
+ try {
+ $board = $this->boardMapper->find($boardId);
+ } catch (DoesNotExistException) {
+ throw new ImportException(ImportException::NOT_FOUND, 'The board does not exist');
+ }
+ if ($boardRef === null || $boardRef->getApp() !== $app) {
+ throw new ImportException(ImportException::NOT_IMPORTER, 'The board was not imported by ' . $app);
+ }
+
+ $stackIds = [];
+ $stacks = [];
+ foreach ($this->stackMapper->findAll($boardId) as $stack) {
+ $stacks[$stack->getId()] = true;
+ }
+ foreach ($this->refMapper->findAllOfKind($app, ImportRef::KIND_STACK, $boardId) as $stackRef) {
+ if (isset($stacks[$stackRef->getObjectId()])) {
+ $stackIds[$stackRef->getRef()] = $stackRef->getObjectId();
+ }
+ }
+ $labelIds = [];
+ $labels = [];
+ foreach ($this->labelMapper->findAll($boardId) as $label) {
+ $labels[$label->getId()] = true;
+ }
+ foreach ($this->refMapper->findAllOfKind($app, ImportRef::KIND_LABEL, $boardId) as $labelRef) {
+ if (isset($labels[$labelRef->getObjectId()])) {
+ $labelIds[$labelRef->getRef()] = $labelRef->getObjectId();
+ }
+ }
+ return new BoardContext($board, $stackIds, $labelIds);
+ }
+
+ private function findCard(int $cardId): ?Card {
+ try {
+ return $this->cardMapper->find($cardId, false);
+ } catch (DoesNotExistException) {
+ return null;
+ }
+ }
+
+ private function insertRef(string $app, string $kind, int $boardId, string $ref, int $objectId, ?string $storedHash): ImportRef {
+ $now = $this->timeFactory->getTime();
+ $importRef = new ImportRef();
+ $importRef->setApp($app);
+ $importRef->setKind($kind);
+ $importRef->setBoardId($boardId);
+ $importRef->setRef($ref);
+ $importRef->setRefHash(ImportRef::hashRef($ref));
+ $importRef->setObjectId($objectId);
+ $importRef->setStoredHash($storedHash);
+ $importRef->setState(ImportRef::STATE_ACTIVE);
+ $importRef->setCreatedAt($now);
+ $importRef->setUpdatedAt($now);
+ return $this->refMapper->insert($importRef);
+ }
+
+ private function updateRef(ImportRef $importRef, string $storedHash, ?string $sourceHash = null): void {
+ $importRef->setStoredHash($storedHash);
+ if ($sourceHash !== null) {
+ $importRef->setSourceHash($sourceHash);
+ }
+ $importRef->setUpdatedAt($this->timeFactory->getTime());
+ $this->refMapper->update($importRef);
+ }
+
+ /**
+ * Runs a write once more when a concurrent import of the same reference
+ * won the race; the second run finds its rows
+ *
+ * @template T
+ * @param callable(): T $write
+ * @return T
+ */
+ private function retryOnUniqueViolation(callable $write): mixed {
+ try {
+ return $write();
+ } catch (DbException $e) {
+ if ($e->getReason() !== DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
+ throw $e;
+ }
+ return $write();
+ }
+ }
+
+ /**
+ * @throws EntryException
+ */
+ private function checkApp(string $app): void {
+ if (preg_match('/^[a-z0-9_]{1,32}$/', $app) !== 1) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'The app must match [a-z0-9_]{1,32}');
+ }
+ }
+
+ /**
+ * @throws EntryException
+ */
+ private function checkRef(string $field, string $ref): void {
+ if ($ref === '' || strlen($ref) > self::MAX_REF_BYTES) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'The ' . $field . ' must have 1 to ' . self::MAX_REF_BYTES . ' bytes');
+ }
+ }
+
+ /**
+ * @throws EntryException
+ */
+ private function checkTitle(string $field, string $title, int $max): void {
+ $length = mb_strlen($title, 'UTF-8');
+ if ($length === 0 || $length > $max) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'The ' . $field . ' must have 1 to ' . $max . ' characters');
+ }
+ }
+
+ /**
+ * @throws EntryException
+ */
+ private function checkColor(string $field, string $color): void {
+ if (preg_match('/^[0-9a-fA-F]{6}$/', $color) !== 1) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'The ' . $field . ' must be rrggbb');
+ }
+ }
+
+ /**
+ * Lists of distinct values that each pass $valid
+ *
+ * @throws EntryException
+ */
+ private function checkList(string $field, array $values, callable $valid): void {
+ if (!array_is_list($values) || count($values) !== count(array_unique($values, SORT_REGULAR))) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'The ' . $field . ' must be a list of distinct values');
+ }
+ foreach ($values as $value) {
+ if (!$valid($value)) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'The ' . $field . ' hold an invalid value');
+ }
+ }
+ }
+
+ /**
+ * @throws EntryException
+ */
+ private function checkLabels(array $labels): void {
+ $refs = [];
+ $titles = [];
+ foreach ($labels as $label) {
+ if (!$label instanceof ImportLabel) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'The labels must be ImportLabel objects');
+ }
+ $this->checkRef('label ref', $label->ref);
+ $this->checkTitle('label title', $label->title, self::MAX_LABEL_TITLE);
+ $this->checkColor('label color', $label->color);
+ if (isset($refs[$label->ref]) || isset($titles[$label->title])) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'Label refs and titles must be distinct');
+ }
+ $refs[$label->ref] = true;
+ $titles[$label->title] = true;
+ }
+ }
+
+ /**
+ * @throws EntryException
+ */
+ private function checkStacks(array $stacks): void {
+ $refs = [];
+ foreach ($stacks as $stack) {
+ if (!$stack instanceof ImportStack) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'The stacks must be ImportStack objects');
+ }
+ $this->checkRef('stack ref', $stack->ref);
+ $this->checkTitle('stack title', $stack->title, self::MAX_STACK_TITLE);
+ if (isset($refs[$stack->ref])) {
+ throw EntryException::error(ImportResult::ERROR_INVALID, 'Stack refs must be distinct');
+ }
+ $refs[$stack->ref] = true;
+ }
+ }
+
+ private function isPast(?\DateTimeImmutable $time): bool {
+ return $time !== null && $time <= $this->timeFactory->now();
+ }
+
+ private static function assigneeKey(Assignment $assignment): string {
+ return $assignment->getType() === Assignment::TYPE_USER
+ ? $assignment->getParticipant()
+ : $assignment->getType() . ':' . $assignment->getParticipant();
+ }
+
+ /**
+ * Fingerprint of the values the importer last wrote to a board, label,
+ * stack or share, to tell whether somebody changed them since
+ */
+ private static function valuesHash(string|int|bool ...$values): string {
+ return hash('sha256', json_encode($values, JSON_THROW_ON_ERROR));
+ }
+
+ private static function time(?\DateTimeInterface $time): ?string {
+ return CardCanonicalForm::time($time);
+ }
+
+ private static function mutable(?\DateTimeImmutable $time): ?\DateTime {
+ return $time === null ? null : \DateTime::createFromImmutable($time->setTimezone(new \DateTimeZone('UTC')));
+ }
+}
diff --git a/lib/Import/CardCanonicalForm.php b/lib/Import/CardCanonicalForm.php
new file mode 100644
index 0000000000..165fadbac6
--- /dev/null
+++ b/lib/Import/CardCanonicalForm.php
@@ -0,0 +1,70 @@
+` and `&` as
+ * `\u003c`, `\u003e` and `\u0026`, U+2028 and U+2029 as `\u2028` and
+ * `\u2029`, other control characters as `\b`, `\f`, `\n`, `\r`, `\t` or
+ * `\u00XX` (lowercase hex).
+ *
+ * @since 2.0.0
+ */
+final class CardCanonicalForm {
+ /**
+ * @param list $assignees
+ * @param list $labelRefs
+ * @param list $fileIds
+ */
+ public static function hash(string $ref, string $stackRef, string $title, string $description, int $order,
+ ?\DateTimeInterface $start, ?\DateTimeInterface $due, ?\DateTimeInterface $done,
+ array $assignees, array $labelRefs, array $fileIds): string {
+ sort($assignees, SORT_STRING);
+ sort($labelRefs, SORT_STRING);
+ sort($fileIds, SORT_NUMERIC);
+ $values = [
+ $ref,
+ $stackRef,
+ $title,
+ $description,
+ $order,
+ self::time($start),
+ self::time($due),
+ self::time($done),
+ $assignees,
+ $labelRefs,
+ $fileIds,
+ ];
+ return hash('sha256', implode("\n", array_map(self::encode(...), $values)));
+ }
+
+ public static function time(?\DateTimeInterface $time): ?string {
+ if ($time === null) {
+ return null;
+ }
+ return \DateTimeImmutable::createFromInterface($time)
+ ->setTimezone(new \DateTimeZone('UTC'))
+ ->format('Y-m-d\TH:i:s\Z');
+ }
+
+ private static function encode(mixed $value): string {
+ $json = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ // These characters can only occur inside strings
+ return strtr($json, ['<' => '\u003c', '>' => '\u003e', '&' => '\u0026']);
+ }
+}
diff --git a/lib/Import/EntryException.php b/lib/Import/EntryException.php
new file mode 100644
index 0000000000..0758dd14db
--- /dev/null
+++ b/lib/Import/EntryException.php
@@ -0,0 +1,32 @@
+errorMessage);
+ }
+
+ public static function error(string $code, string $message): self {
+ return new self(ImportResult::error($code, $message));
+ }
+
+ public static function conflict(string $code, string $message, ?int $cardId = null, ?string $sha256 = null): self {
+ return new self(ImportResult::conflict($code, $message, $cardId, $sha256));
+ }
+}
diff --git a/lib/Import/IBoardImporter.php b/lib/Import/IBoardImporter.php
new file mode 100644
index 0000000000..20a1c092ff
--- /dev/null
+++ b/lib/Import/IBoardImporter.php
@@ -0,0 +1,106 @@
+ $labels
+ * @param list $stacks
+ * @return array{status: string, board_id: int, created: bool, labels: array, stacks: array, changed: list, error?: array{code: string, message: string}}
+ */
+ public function ensureBoard(string $app, string $ref, string $ownerUid, string $title, string $color,
+ \DateTimeImmutable $created, array $labels, array $stacks): array;
+
+ /**
+ * Writes cards with their original creation, start, due and done times,
+ * assignments, labels and file attachments.
+ *
+ * @param list $cards
+ * @return list one per card, in the same order
+ * @throws ImportException when the board is missing or another app created it
+ */
+ public function importCards(string $app, int $boardId, array $cards): array;
+
+ /**
+ * What Deck holds for each reference, with the SHA-256 of the canonical
+ * form (CardCanonicalForm) when $hash is set.
+ *
+ * @param list $refs
+ * @return list one per reference, in the same order
+ * @throws ImportException
+ */
+ public function statCards(string $app, int $boardId, array $refs, bool $hash): array;
+
+ /**
+ * Pages through the cards the app wrote to a board and didn't remove.
+ *
+ * @param string $cursor '' for the first page, then the previous `next_cursor`
+ * @param int $limit 1 to 1000
+ * @return array{entries: list, next_cursor: string} the card refs; `next_cursor` is '' on the last page
+ * @throws ImportException
+ */
+ public function listCards(string $app, int $boardId, string $cursor, int $limit): array;
+
+ /**
+ * Soft-deletes cards the importer wrote (Deck's deleted cards).
+ *
+ * @param list $refs
+ * @return list one per reference, in the same order
+ * @throws ImportException
+ */
+ public function removeCards(string $app, int $boardId, array $refs): array;
+
+ /**
+ * The complete list of shares the importer manages; no notification.
+ *
+ * Shares the importer made that are missing from $acl are removed;
+ * shares others made stay as they are.
+ *
+ * @param list $acl
+ * @return list one per share, in the same order
+ * @throws ImportException
+ */
+ public function setAccess(string $app, int $boardId, array $acl): array;
+}
diff --git a/lib/Import/ImportAccess.php b/lib/Import/ImportAccess.php
new file mode 100644
index 0000000000..c4f7d47bba
--- /dev/null
+++ b/lib/Import/ImportAccess.php
@@ -0,0 +1,40 @@
+type . ':' . $this->id;
+ }
+}
diff --git a/lib/Import/ImportCard.php b/lib/Import/ImportCard.php
new file mode 100644
index 0000000000..bdf0ea97d4
--- /dev/null
+++ b/lib/Import/ImportCard.php
@@ -0,0 +1,53 @@
+ $assignees user IDs
+ * @param list $labels label refs of the board
+ * @param list $files IDs of existing files, attached as Deck file attachments
+ * @param string $sha256 hex SHA-256 of the card's canonical form (CardCanonicalForm) as the importing app computed it
+ * @param self::MODE_* $mode
+ */
+ public function __construct(
+ public readonly string $ref,
+ public readonly string $stackRef,
+ public readonly string $title,
+ public readonly string $description,
+ public readonly int $order,
+ public readonly ?\DateTimeImmutable $start,
+ public readonly ?\DateTimeImmutable $due,
+ public readonly ?\DateTimeImmutable $done,
+ public readonly \DateTimeImmutable $created,
+ public readonly string $createdBy,
+ public readonly array $assignees,
+ public readonly array $labels,
+ public readonly array $files,
+ public readonly string $sha256,
+ public readonly string $mode = self::MODE_CREATE,
+ ) {
+ }
+}
diff --git a/lib/Import/ImportCardStat.php b/lib/Import/ImportCardStat.php
new file mode 100644
index 0000000000..433d1ea479
--- /dev/null
+++ b/lib/Import/ImportCardStat.php
@@ -0,0 +1,46 @@
+ $this->ref, 'exists' => $this->exists];
+ if ($this->cardId !== null) {
+ $json['card_id'] = $this->cardId;
+ }
+ if ($this->sha256 !== null) {
+ $json['sha256'] = $this->sha256;
+ }
+ $json['changed'] = $this->changed;
+ return $json;
+ }
+}
diff --git a/lib/Import/ImportException.php b/lib/Import/ImportException.php
new file mode 100644
index 0000000000..7044308815
--- /dev/null
+++ b/lib/Import/ImportException.php
@@ -0,0 +1,41 @@
+reason;
+ }
+}
diff --git a/lib/Import/ImportLabel.php b/lib/Import/ImportLabel.php
new file mode 100644
index 0000000000..e303a5260d
--- /dev/null
+++ b/lib/Import/ImportLabel.php
@@ -0,0 +1,29 @@
+ $this->status];
+ if ($this->cardId !== null) {
+ $json['card_id'] = $this->cardId;
+ }
+ if ($this->sha256 !== null) {
+ $json['sha256'] = $this->sha256;
+ }
+ if ($this->errorCode !== null) {
+ $json['error'] = ['code' => $this->errorCode, 'message' => (string)$this->errorMessage];
+ }
+ return $json;
+ }
+}
diff --git a/lib/Import/ImportStack.php b/lib/Import/ImportStack.php
new file mode 100644
index 0000000000..17ffb16dc8
--- /dev/null
+++ b/lib/Import/ImportStack.php
@@ -0,0 +1,28 @@
+hasTable('deck_import_refs')) {
+ return null;
+ }
+
+ $table = $schema->createTable('deck_import_refs');
+ $table->addColumn('id', Types::BIGINT, [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'length' => 20,
+ ]);
+ $table->addColumn('app', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 32,
+ ]);
+ $table->addColumn('kind', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 8,
+ ]);
+ // The board an object belongs to; 0 for boards themselves
+ $table->addColumn('board_id', Types::BIGINT, [
+ 'notnull' => true,
+ 'length' => 20,
+ 'default' => 0,
+ ]);
+ $table->addColumn('ref', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 255,
+ ]);
+ $table->addColumn('ref_hash', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 64,
+ ]);
+ $table->addColumn('object_id', Types::BIGINT, [
+ 'notnull' => true,
+ 'length' => 20,
+ ]);
+ $table->addColumn('source_hash', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 64,
+ ]);
+ $table->addColumn('stored_hash', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 64,
+ ]);
+ $table->addColumn('state', Types::SMALLINT, [
+ 'notnull' => true,
+ 'default' => 0,
+ ]);
+ $table->addColumn('created_at', Types::BIGINT, [
+ 'notnull' => true,
+ 'length' => 20,
+ 'default' => 0,
+ ]);
+ $table->addColumn('updated_at', Types::BIGINT, [
+ 'notnull' => true,
+ 'length' => 20,
+ 'default' => 0,
+ ]);
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(['app', 'kind', 'board_id', 'ref_hash'], 'deck_import_refs_uq');
+ $table->addIndex(['kind', 'object_id'], 'deck_import_refs_obj');
+
+ return $schema;
+ }
+}
diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php
index fdc15e511d..9c7b8704e7 100644
--- a/lib/Service/PermissionService.php
+++ b/lib/Service/PermissionService.php
@@ -395,9 +395,9 @@ private function getGroupLimitList() {
/**
* Set a different user than the current one, e.g. when no user is available in occ
*
- * @param string $userId
+ * @param ?string $userId null for no user
*/
- public function setUserId(string $userId): void {
+ public function setUserId(?string $userId): void {
$this->userId = $userId;
$this->permissionCache->clear();
}
diff --git a/tests/integration/import/BoardImporterTest.php b/tests/integration/import/BoardImporterTest.php
new file mode 100644
index 0000000000..8f5ced1656
--- /dev/null
+++ b/tests/integration/import/BoardImporterTest.php
@@ -0,0 +1,678 @@
+ */
+ private array $deckEvents = [];
+ private int $activityEvents = 0;
+ private int $mails = 0;
+ private int $fileId;
+
+ public static function setUpBeforeClass(): void {
+ parent::setUpBeforeClass();
+ // The test bootstrap loads the server's own apps only; these deliver what the quiet test counts
+ foreach (['deck', 'activity', 'notifications'] as $app) {
+ Server::get(IAppManager::class)->loadApp($app);
+ }
+
+ $backend = new \Test\Util\User\Dummy();
+ Server::get(IUserManager::class)->registerBackend($backend);
+ foreach ([self::OWNER, self::ALICE, self::BOB, self::CAROL] as $uid) {
+ $backend->createUser($uid, $uid);
+ }
+ $groupBackend = new \Test\Util\Group\Dummy();
+ $groupBackend->createGroup(self::GROUP);
+ $groupBackend->addToGroup(self::ALICE, self::GROUP);
+ $groupBackend->addToGroup(self::BOB, self::GROUP);
+ Server::get(IGroupManager::class)->addBackend($groupBackend);
+
+ // The test cleanup empties the file cache but not the mount cache, which then points to storages of earlier runs
+ $qb = Server::get(IDBConnection::class)->getQueryBuilder();
+ $qb->delete('mounts')
+ ->where($qb->expr()->in('user_id', $qb->createNamedParameter([self::OWNER, self::ALICE, self::BOB, self::CAROL], IQueryBuilder::PARAM_STR_ARRAY)))
+ ->executeStatement();
+ }
+
+ protected function setUp(): void {
+ parent::setUp();
+ $this->importer = Server::get(IBoardImporter::class);
+ $this->db = Server::get(IDBConnection::class);
+ $this->cardMapper = Server::get(CardMapper::class);
+ $this->boardRef = 'plan-' . $this->getUniqueID();
+
+ $folder = Server::get(IRootFolder::class)->getUserFolder(self::ALICE);
+ $this->fileId = $folder->newFile('Budget ' . $this->getUniqueID() . '.txt', 'numbers')->getId();
+ // Imports run without a user, like a bridge request
+ Server::get(IUserSession::class)->setUser(null);
+
+ $dispatcher = Server::get(IEventDispatcher::class);
+ foreach ([CardCreatedEvent::class, CardUpdatedEvent::class, CardDeletedEvent::class, AclCreatedEvent::class,
+ AclUpdatedEvent::class, AclDeletedEvent::class, BoardUpdatedEvent::class] as $eventClass) {
+ $dispatcher->addListener($eventClass, function () use ($eventClass): void {
+ $this->deckEvents[] = $eventClass;
+ });
+ }
+ Server::get(IActivityManager::class)->registerConsumer(function () {
+ return new class($this) implements IConsumer {
+ public function __construct(
+ private BoardImporterTest $test,
+ ) {
+ }
+
+ public function receive(IActivityEvent $event): void {
+ $this->test->countActivity();
+ }
+ };
+ });
+ $mailer = $this->createMock(IMailer::class);
+ $mailer->method('createMessage')->willReturn($this->createMock(IMessage::class));
+ $mailer->method('send')->willReturnCallback(function () {
+ $this->mails++;
+ return [];
+ });
+ $this->overwriteService(IMailer::class, $mailer);
+ }
+
+ protected function tearDown(): void {
+ $this->restoreService(IMailer::class);
+ parent::tearDown();
+ }
+
+ public function countActivity(): void {
+ $this->activityEvents++;
+ }
+
+ public function testEnsureBoardCreatesBoardLabelsAndStacksOnce(): void {
+ $first = $this->ensureBoard();
+ $this->assertSame(ImportResult::STATUS_OK, $first['status']);
+ $this->assertTrue($first['created']);
+ $this->assertSame(['category1', 'priority'], array_keys($first['labels']));
+ $this->assertSame(['bucket-todo', 'bucket-done'], array_keys($first['stacks']));
+
+ $board = Server::get(BoardMapper::class)->find($first['board_id']);
+ $this->assertSame('Launch plan', $board->getTitle());
+ $this->assertSame(self::OWNER, $board->getOwner());
+ $this->assertSame('0082c9', $board->getColor());
+ $this->assertCount(2, Server::get(LabelMapper::class)->findAll($first['board_id']), 'no default labels');
+ $this->assertCount(2, Server::get(StackMapper::class)->findAll($first['board_id']));
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->update('deck_boards')
+ ->set('last_modified', $qb->createNamedParameter(1000, IQueryBuilder::PARAM_INT))
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($first['board_id'], IQueryBuilder::PARAM_INT)))
+ ->executeStatement();
+ Server::get(BoardMapper::class)->flushCache();
+
+ $again = $this->ensureBoard();
+ $this->assertFalse($again['created']);
+ $this->assertSame($first['board_id'], $again['board_id']);
+ $this->assertSame($first['labels'], $again['labels']);
+ $this->assertSame($first['stacks'], $again['stacks']);
+ $this->assertSame([], $again['changed']);
+ $this->assertCount(2, Server::get(LabelMapper::class)->findAll($first['board_id']));
+ $this->assertSame(1000, Server::get(BoardMapper::class)->find($first['board_id'])->getLastModified(), 'a repeat writes nothing');
+ }
+
+ public function testEnsureBoardUpdatesWhatNobodyChangedAndKeepsDeckChanges(): void {
+ $board = $this->ensureBoard();
+ $stackMapper = Server::get(StackMapper::class);
+ $stack = $stackMapper->find($board['stacks']['bucket-todo']);
+ $stack->setTitle('Renamed in Deck');
+ $stackMapper->update($stack);
+
+ $result = $this->ensureBoard('Launch plan 2026', [
+ new ImportLabel('category1', 'Marketing', 'ff0000'),
+ new ImportLabel('priority', 'Urgent', 'e9322d'),
+ new ImportLabel('category2', 'Sales', '00ff00'),
+ ], [
+ new ImportStack('bucket-todo', 'Backlog', 0),
+ new ImportStack('bucket-done', 'Finished', 1),
+ ]);
+
+ $this->assertSame(ImportResult::STATUS_OK, $result['status']);
+ $this->assertSame(['bucket-todo'], $result['changed']);
+ $this->assertSame('Launch plan 2026', Server::get(BoardMapper::class)->find($board['board_id'])->getTitle());
+ $stackMapper = Server::get(StackMapper::class);
+ $this->assertSame('Renamed in Deck', $stackMapper->find($board['stacks']['bucket-todo'])->getTitle());
+ $this->assertSame('Finished', $stackMapper->find($board['stacks']['bucket-done'])->getTitle());
+ $this->assertSame('ff0000', Server::get(LabelMapper::class)->find($board['labels']['category1'])->getColor());
+ $this->assertArrayHasKey('category2', $result['labels']);
+ }
+
+ public function testEnsureBoardRefusesInvalidInput(): void {
+ $result = $this->importer->ensureBoard(self::APP, $this->boardRef, 'nobody-' . $this->getUniqueID(), 'Plan', '0082c9',
+ new \DateTimeImmutable(), [], []);
+ $this->assertSame(ImportResult::STATUS_ERROR, $result['status']);
+ $this->assertSame(ImportResult::ERROR_UNKNOWN_USER, $result['error']['code']);
+
+ $result = $this->importer->ensureBoard(self::APP, $this->boardRef, self::OWNER, 'Plan', 'blue',
+ new \DateTimeImmutable(), [], []);
+ $this->assertSame(ImportResult::ERROR_INVALID, $result['error']['code']);
+
+ $result = $this->importer->ensureBoard(self::APP, $this->boardRef, self::OWNER, 'Plan', '0082c9',
+ new \DateTimeImmutable(), [new ImportLabel('a', 'Same', '000000'), new ImportLabel('b', 'Same', '111111')], []);
+ $this->assertSame(ImportResult::ERROR_INVALID, $result['error']['code']);
+ }
+
+ public function testImportCardKeepsOriginalTimesAndRelations(): void {
+ $board = $this->ensureBoard();
+ $created = new \DateTimeImmutable('2025-11-03T09:15:00Z');
+ $card = $this->card('task-1', [
+ 'description' => "Prepare the launch.\n\n- [ ] Draft\n- [x] Review\n\n[Spec](https://example.com/spec)",
+ 'start' => new \DateTimeImmutable('2025-11-04T08:00:00Z'),
+ 'due' => new \DateTimeImmutable('2025-11-20T17:00:00+01:00'),
+ 'done' => new \DateTimeImmutable('2025-11-19T12:30:45Z'),
+ 'created' => $created,
+ 'createdBy' => self::CAROL,
+ 'assignees' => [self::BOB, self::ALICE],
+ 'labels' => ['priority', 'category1'],
+ 'files' => [$this->fileId],
+ ]);
+
+ [$result] = $this->importer->importCards(self::APP, $board['board_id'], [$card]);
+
+ $this->assertSame(ImportResult::STATUS_OK, $result->status, (string)$result->errorMessage);
+ $this->assertSame($card->sha256, $result->sha256, 'Deck hashes what it stored to what the importer computed');
+ $stored = $this->cardMapper->find($result->cardId, false);
+ $this->assertSame('Ship it', $stored->getTitle());
+ $this->assertSame($card->description, $stored->getDescription());
+ $this->assertSame($board['stacks']['bucket-todo'], $stored->getStackId());
+ $this->assertSame($created->getTimestamp(), $stored->getCreatedAt());
+ $this->assertSame('2025-11-19T12:30:45Z', CardCanonicalForm::time($stored->getDone()));
+ $this->assertSame('2025-11-20T16:00:00Z', CardCanonicalForm::time($stored->getDuedate()));
+ $this->assertSame('2025-11-04T08:00:00Z', CardCanonicalForm::time($stored->getStartdate()));
+ $this->assertSame(self::CAROL, $stored->getOwner());
+ $this->assertTrue($stored->getNotified(), 'a due date in the past is never announced');
+
+ $assignees = array_map(fn ($a) => $a->getParticipant(), Server::get(AssignmentMapper::class)->findAll($result->cardId));
+ sort($assignees);
+ $this->assertSame([self::ALICE, self::BOB], $assignees);
+ $labels = array_map(fn ($l) => $l->getId(), Server::get(LabelMapper::class)->findAssignedLabelsForCard($result->cardId));
+ sort($labels);
+ $expected = [$board['labels']['category1'], $board['labels']['priority']];
+ sort($expected);
+ $this->assertSame($expected, $labels);
+
+ $shares = $this->deckShares($result->cardId);
+ $this->assertCount(1, $shares);
+ $this->assertSame($this->fileId, (int)$shares[0]['file_source']);
+ $this->assertSame(self::ALICE, $shares[0]['uid_initiator'], 'the file owner shares the file with the card');
+ }
+
+ public function testRepeatedImportWritesNothing(): void {
+ $board = $this->ensureBoard();
+ $card = $this->card('task-1', ['assignees' => [self::ALICE], 'labels' => ['category1'], 'files' => [$this->fileId]]);
+ [$first] = $this->importer->importCards(self::APP, $board['board_id'], [$card]);
+ $qb = $this->db->getQueryBuilder();
+ $qb->update('deck_cards')
+ ->set('last_modified', $qb->createNamedParameter(1000, IQueryBuilder::PARAM_INT))
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($first->cardId, IQueryBuilder::PARAM_INT)))
+ ->executeStatement();
+ $rows = $this->rowCounts(['deck_cards', 'deck_assigned_users', 'deck_assigned_labels', 'deck_import_refs']) + ['attachments' => $this->deckShareCount()];
+
+ [$second] = $this->importer->importCards(self::APP, $board['board_id'], [$card]);
+
+ $this->assertSame(ImportResult::STATUS_OK, $second->status);
+ $this->assertSame($first->cardId, $second->cardId);
+ $this->assertSame($first->sha256, $second->sha256);
+ $this->assertSame($rows, $this->rowCounts(['deck_cards', 'deck_assigned_users', 'deck_assigned_labels', 'deck_import_refs']) + ['attachments' => $this->deckShareCount()]);
+ $this->assertSame(1000, $this->cardMapper->find($first->cardId, false)->getLastModified(), 'the card was not written again');
+ }
+
+ public function testNewVersionUpdatesTheCard(): void {
+ $board = $this->ensureBoard();
+ $this->importer->importCards(self::APP, $board['board_id'], [
+ $this->card('task-1', ['assignees' => [self::ALICE], 'labels' => ['category1'], 'files' => [$this->fileId]]),
+ ]);
+
+ $next = $this->card('task-1', [
+ 'title' => 'Ship it on Monday',
+ 'stackRef' => 'bucket-done',
+ 'assignees' => [self::BOB],
+ 'labels' => ['priority'],
+ 'files' => [],
+ 'mode' => ImportCard::MODE_NEW_VERSION,
+ ]);
+ [$result] = $this->importer->importCards(self::APP, $board['board_id'], [$next]);
+
+ $this->assertSame(ImportResult::STATUS_OK, $result->status, (string)$result->errorMessage);
+ $this->assertSame($next->sha256, $result->sha256);
+ $stored = $this->cardMapper->find($result->cardId, false);
+ $this->assertSame('Ship it on Monday', $stored->getTitle());
+ $this->assertSame($board['stacks']['bucket-done'], $stored->getStackId());
+ $this->assertSame([self::BOB], array_map(fn ($a) => $a->getParticipant(), Server::get(AssignmentMapper::class)->findAll($result->cardId)));
+ $this->assertSame([], $this->deckShares($result->cardId));
+ }
+
+ public function testCreateOfAnExistingRefWithOtherContentConflicts(): void {
+ $board = $this->ensureBoard();
+ [$first] = $this->importer->importCards(self::APP, $board['board_id'], [$this->card('task-1')]);
+
+ [$result] = $this->importer->importCards(self::APP, $board['board_id'], [$this->card('task-1', ['title' => 'Other'])]);
+
+ $this->assertSame(ImportResult::STATUS_CONFLICT, $result->status);
+ $this->assertSame(ImportResult::ERROR_EXISTS, $result->errorCode);
+ $this->assertSame($first->cardId, $result->cardId);
+ $this->assertSame('Ship it', $this->cardMapper->find($first->cardId, false)->getTitle());
+ }
+
+ public function testCardEditedInDeckIsReportedAndKept(): void {
+ $board = $this->ensureBoard();
+ [$first, $second] = $this->importer->importCards(self::APP, $board['board_id'], [$this->card('task-1'), $this->card('task-2')]);
+
+ $card = $this->cardMapper->find($first->cardId, false);
+ $card->setTitle('Edited by Alice');
+ $this->cardMapper->update($card);
+ $archived = $this->cardMapper->find($second->cardId, false);
+ $archived->setArchived(true);
+ $this->cardMapper->update($archived);
+
+ $stats = $this->importer->statCards(self::APP, $board['board_id'], ['task-1', 'task-2', 'task-unknown'], true);
+ $this->assertTrue($stats[0]->exists);
+ $this->assertTrue($stats[0]->changed);
+ $this->assertNotSame($first->sha256, $stats[0]->sha256);
+ $this->assertTrue($stats[1]->changed, 'archiving is a change');
+ $this->assertFalse($stats[2]->exists);
+
+ [$result] = $this->importer->importCards(self::APP, $board['board_id'], [
+ $this->card('task-1', ['title' => 'Newer in the source', 'mode' => ImportCard::MODE_NEW_VERSION]),
+ ]);
+ $this->assertSame(ImportResult::STATUS_CONFLICT, $result->status);
+ $this->assertSame(ImportResult::ERROR_CHANGED, $result->errorCode);
+ $this->assertSame('Edited by Alice', $this->cardMapper->find($first->cardId, false)->getTitle());
+
+ [$removed] = $this->importer->removeCards(self::APP, $board['board_id'], ['task-1']);
+ $this->assertSame(ImportResult::ERROR_CHANGED, $removed->errorCode);
+ $this->assertSame(0, $this->cardMapper->find($first->cardId, false)->getDeletedAt());
+ }
+
+ public function testUnfinishedWriteIsCompletedByARepeat(): void {
+ $board = $this->ensureBoard();
+ $card = $this->card('task-1', ['files' => [$this->fileId]]);
+ [$first] = $this->importer->importCards(self::APP, $board['board_id'], [$card]);
+ // A write that stopped before its attachments: no source hash, no share
+ $qb = $this->db->getQueryBuilder();
+ $qb->update('deck_import_refs')
+ ->set('source_hash', $qb->createNamedParameter(null))
+ ->where($qb->expr()->eq('object_id', $qb->createNamedParameter($first->cardId, IQueryBuilder::PARAM_INT)))
+ ->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter('card')))
+ ->executeStatement();
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete('share')
+ ->where($qb->expr()->eq('share_type', $qb->createNamedParameter(IShare::TYPE_DECK, IQueryBuilder::PARAM_INT)))
+ ->andWhere($qb->expr()->eq('share_with', $qb->createNamedParameter((string)$first->cardId)))
+ ->executeStatement();
+
+ [$stat] = $this->importer->statCards(self::APP, $board['board_id'], ['task-1'], true);
+ $this->assertFalse($stat->changed);
+ [$repeat] = $this->importer->importCards(self::APP, $board['board_id'], [$card]);
+
+ $this->assertSame(ImportResult::STATUS_OK, $repeat->status, (string)$repeat->errorMessage);
+ $this->assertSame($card->sha256, $repeat->sha256);
+ $this->assertCount(1, $this->deckShares($first->cardId));
+ }
+
+ public function testUnchangedCardStatsWithoutHash(): void {
+ $board = $this->ensureBoard();
+ [$first] = $this->importer->importCards(self::APP, $board['board_id'], [$this->card('task-1')]);
+
+ [$stat] = $this->importer->statCards(self::APP, $board['board_id'], ['task-1'], false);
+
+ $this->assertEquals(new ImportCardStat('task-1', true, $first->cardId, null, false), $stat);
+ }
+
+ public function testRemoveCardsSoftDeletesAndCanRestore(): void {
+ $board = $this->ensureBoard();
+ [$first] = $this->importer->importCards(self::APP, $board['board_id'], [$this->card('task-1'), $this->card('task-2')]);
+
+ $results = $this->importer->removeCards(self::APP, $board['board_id'], ['task-1', 'task-unknown']);
+
+ $this->assertSame(ImportResult::STATUS_OK, $results[0]->status);
+ $this->assertSame(ImportResult::ERROR_NOT_FOUND, $results[1]->errorCode);
+ $this->assertGreaterThan(0, $this->cardMapper->find($first->cardId, false)->getDeletedAt());
+ $deleted = array_map(fn ($c) => $c->getId(), $this->cardMapper->findDeleted($board['board_id']));
+ $this->assertContains($first->cardId, $deleted, 'the card is in Deck\'s deleted cards');
+ $this->assertSame(['task-2'], $this->importer->listCards(self::APP, $board['board_id'], '', 10)['entries']);
+ $this->assertFalse($this->importer->statCards(self::APP, $board['board_id'], ['task-1'], false)[0]->exists);
+ $this->assertSame(ImportResult::STATUS_OK, $this->importer->removeCards(self::APP, $board['board_id'], ['task-1'])[0]->status);
+
+ [$restored] = $this->importer->importCards(self::APP, $board['board_id'], [$this->card('task-1')]);
+ $this->assertSame(ImportResult::STATUS_OK, $restored->status);
+ $this->assertSame($first->cardId, $restored->cardId);
+ $this->assertSame(0, $this->cardMapper->find($first->cardId, false)->getDeletedAt());
+ }
+
+ public function testListCardsPages(): void {
+ $board = $this->ensureBoard();
+ $refs = ['task-a', 'task-b', 'task-c', 'task-d', 'task-e'];
+ $this->importer->importCards(self::APP, $board['board_id'], array_map(fn ($ref) => $this->card($ref), $refs));
+
+ $listed = [];
+ $cursor = '';
+ $pages = 0;
+ do {
+ $page = $this->importer->listCards(self::APP, $board['board_id'], $cursor, 2);
+ $listed = array_merge($listed, $page['entries']);
+ $cursor = $page['next_cursor'];
+ $pages++;
+ } while ($cursor !== '');
+
+ $this->assertSame($refs, $listed);
+ $this->assertSame(3, $pages);
+ }
+
+ public function testInvalidCardsAreRefusedAndWriteNothing(): void {
+ $board = $this->ensureBoard();
+ $rows = $this->rowCounts(['deck_cards', 'deck_import_refs']) + ['attachments' => $this->deckShareCount()];
+
+ $results = $this->importer->importCards(self::APP, $board['board_id'], [
+ $this->card('a', ['stackRef' => 'bucket-unknown']),
+ $this->card('b', ['labels' => ['unknown']]),
+ $this->card('c', ['assignees' => ['nobody-' . $this->getUniqueID()]]),
+ $this->card('d', ['createdBy' => 'nobody-' . $this->getUniqueID()]),
+ $this->card('e', ['sha256' => 'ABC']),
+ $this->card('f', ['mode' => ImportCard::MODE_NEW_VERSION]),
+ $this->card('g', ['files' => [PHP_INT_MAX]]),
+ $this->card('h', ['title' => '']),
+ $this->card('i', ['assignees' => [self::ALICE, self::ALICE]]),
+ ]);
+
+ $this->assertSame([
+ ImportResult::ERROR_UNKNOWN_STACK,
+ ImportResult::ERROR_UNKNOWN_LABEL,
+ ImportResult::ERROR_UNKNOWN_USER,
+ ImportResult::ERROR_UNKNOWN_USER,
+ ImportResult::ERROR_INVALID,
+ ImportResult::ERROR_NOT_FOUND,
+ ImportResult::ERROR_FILE_UNAVAILABLE,
+ ImportResult::ERROR_INVALID,
+ ImportResult::ERROR_INVALID,
+ ], array_map(fn (ImportResult $r) => $r->errorCode, $results));
+ $this->assertSame($rows, $this->rowCounts(['deck_cards', 'deck_import_refs']) + ['attachments' => $this->deckShareCount()], 'a failed card leaves nothing behind');
+ }
+
+ public function testBoardsOfOthersAreRefused(): void {
+ $this->actAs(self::ALICE);
+ $own = Server::get(BoardService::class)->create('Alice\'s board', self::ALICE, '000000');
+ $this->actAs(null);
+ $imported = $this->ensureBoard();
+
+ foreach ([
+ [self::APP, $own->getId(), ImportException::NOT_IMPORTER],
+ ['other_app', $imported['board_id'], ImportException::NOT_IMPORTER],
+ [self::APP, PHP_INT_MAX, ImportException::NOT_FOUND],
+ ['Not An App', $imported['board_id'], ImportException::INVALID],
+ ] as [$app, $boardId, $reason]) {
+ try {
+ $this->importer->importCards($app, $boardId, [$this->card('task-1')]);
+ $this->fail('Expected ' . $reason);
+ } catch (ImportException $e) {
+ $this->assertSame($reason, $e->getReason());
+ }
+ }
+ }
+
+ public function testSetAccessManagesItsOwnShares(): void {
+ $board = $this->ensureBoard();
+ $aclMapper = Server::get(AclMapper::class);
+ $acl = function (int $type, string $participant) use ($aclMapper, $board): ?Acl {
+ foreach ($aclMapper->findAll($board['board_id']) as $acl) {
+ if ($acl->getType() === $type && $acl->getParticipant() === $participant) {
+ return $acl;
+ }
+ }
+ return null;
+ };
+ $this->actAs(self::OWNER);
+ Server::get(BoardService::class)->addAcl($board['board_id'], Acl::PERMISSION_TYPE_USER, self::CAROL, false, false, false);
+ $this->actAs(null);
+
+ $results = $this->importer->setAccess(self::APP, $board['board_id'], [
+ new ImportAccess(ImportAccess::TYPE_GROUP, self::GROUP, true, false, false),
+ new ImportAccess(ImportAccess::TYPE_USER, self::ALICE, true, true, true),
+ new ImportAccess(ImportAccess::TYPE_USER, self::CAROL, true, true, true),
+ new ImportAccess(ImportAccess::TYPE_GROUP, 'no-such-group', true, false, false),
+ ]);
+
+ $this->assertSame([ImportResult::STATUS_OK, ImportResult::STATUS_OK, ImportResult::STATUS_CONFLICT, ImportResult::STATUS_ERROR],
+ array_map(fn (ImportResult $r) => $r->status, $results));
+ $this->assertSame(ImportResult::ERROR_EXISTS, $results[2]->errorCode);
+ $this->assertSame(ImportResult::ERROR_UNKNOWN_GROUP, $results[3]->errorCode);
+ $group = $acl(Acl::PERMISSION_TYPE_GROUP, self::GROUP);
+ $this->assertTrue($group->getPermissionEdit());
+ $this->assertFalse($group->getPermissionManage());
+ $this->assertFalse($acl(Acl::PERMISSION_TYPE_USER, self::CAROL)->getPermissionEdit());
+
+ $results = $this->importer->setAccess(self::APP, $board['board_id'], [
+ new ImportAccess(ImportAccess::TYPE_GROUP, self::GROUP, true, true, false),
+ ]);
+
+ $this->assertSame(ImportResult::STATUS_OK, $results[0]->status);
+ $this->assertTrue($acl(Acl::PERMISSION_TYPE_GROUP, self::GROUP)->getPermissionShare());
+ $this->assertNull($acl(Acl::PERMISSION_TYPE_USER, self::ALICE));
+ $this->assertNotNull($acl(Acl::PERMISSION_TYPE_USER, self::CAROL), 'a share somebody else made stays');
+
+ $aclMapper->delete($acl(Acl::PERMISSION_TYPE_GROUP, self::GROUP));
+ $results = $this->importer->setAccess(self::APP, $board['board_id'], [
+ new ImportAccess(ImportAccess::TYPE_GROUP, self::GROUP, true, true, false),
+ ]);
+ $this->assertSame(ImportResult::ERROR_CHANGED, $results[0]->errorCode, 'a share removed in Deck is not made again');
+ $this->assertNull($acl(Acl::PERMISSION_TYPE_GROUP, self::GROUP));
+ }
+
+ /**
+ * A whole plan is imported and shared, then the cron jobs that announce
+ * due cards and description changes run: nobody hears about any of it.
+ */
+ public function testImportIsQuiet(): void {
+ $before = $this->rowCounts(self::QUIET_TABLES);
+
+ $board = $this->ensureBoard();
+ $results = $this->importer->importCards(self::APP, $board['board_id'], [
+ $this->card('overdue', ['due' => new \DateTimeImmutable('-3 days'), 'assignees' => [self::ALICE, self::BOB], 'labels' => ['priority'], 'files' => [$this->fileId]]),
+ $this->card('due-soon', ['due' => new \DateTimeImmutable('+1 hour'), 'assignees' => [self::BOB]]),
+ $this->card('done', ['done' => new \DateTimeImmutable('-1 day'), 'assignees' => [self::ALICE]]),
+ ]);
+ $results = array_merge($results, $this->importer->setAccess(self::APP, $board['board_id'], [
+ new ImportAccess(ImportAccess::TYPE_GROUP, self::GROUP, true, false, false),
+ new ImportAccess(ImportAccess::TYPE_USER, self::CAROL, true, true, true),
+ ]));
+ $results = array_merge($results, $this->importer->importCards(self::APP, $board['board_id'], [
+ $this->card('due-soon', ['title' => 'Moved', 'due' => new \DateTimeImmutable('-1 hour'), 'assignees' => [self::ALICE], 'mode' => ImportCard::MODE_NEW_VERSION]),
+ ]));
+ $results = array_merge($results, $this->importer->removeCards(self::APP, $board['board_id'], ['done']));
+ $results = array_merge($results, $this->importer->setAccess(self::APP, $board['board_id'], [
+ new ImportAccess(ImportAccess::TYPE_GROUP, self::GROUP, true, true, false),
+ ]));
+ $this->runJob(ScheduledNotifications::class);
+ $this->runJob(CardDescriptionActivity::class);
+
+ $this->assertSame(array_fill(0, 8, ImportResult::STATUS_OK), array_map(fn (ImportResult $r) => $r->status, $results));
+ $this->assertCount(1, $this->deckShares($results[0]->cardId), 'the attachment was shared');
+ $this->assertSame($before, $this->rowCounts(self::QUIET_TABLES));
+ $this->assertSame([], $this->deckEvents, 'no Deck event reaches live updates or other apps');
+ $this->assertSame(0, $this->activityEvents);
+ $this->assertSame(0, $this->mails);
+ }
+
+ /**
+ * The same steps through Deck's services do announce themselves, so the
+ * quiet test would see them
+ */
+ public function testProbesSeeDeckServices(): void {
+ $before = $this->rowCounts(self::QUIET_TABLES);
+ $this->actAs(self::OWNER);
+ $board = Server::get(BoardService::class)->create('Visible', self::OWNER, '000000');
+ Server::get(BoardService::class)->addAcl($board->getId(), Acl::PERMISSION_TYPE_USER, self::ALICE, true, false, false);
+ $stack = Server::get(StackService::class)->create('To do', $board->getId(), 0);
+ $card = Server::get(CardService::class)->create('Task', $stack->getId(), 'plain', 0, self::OWNER);
+ Server::get(AssignmentService::class)->assignUser($card->getId(), self::ALICE);
+ $this->actAs(null);
+
+ $after = $this->rowCounts(self::QUIET_TABLES);
+ foreach (['notifications', 'activity'] as $table) {
+ if ($this->db->tableExists($table)) {
+ $this->assertGreaterThan($before[$table], $after[$table], $table);
+ }
+ }
+ $this->assertNotSame([], $this->deckEvents);
+ $this->assertGreaterThan(0, $this->activityEvents);
+ }
+
+ /**
+ * Deck's services act for the user their permission service holds
+ */
+ private function actAs(?string $uid): void {
+ Server::get(IUserSession::class)->setUser($uid === null ? null : Server::get(IUserManager::class)->get($uid));
+ Server::get(PermissionService::class)->setUserId($uid);
+ }
+
+ /**
+ * @param ?list $labels
+ * @param ?list $stacks
+ */
+ private function ensureBoard(string $title = 'Launch plan', ?array $labels = null, ?array $stacks = null): array {
+ return $this->importer->ensureBoard(self::APP, $this->boardRef, self::OWNER, $title, '0082c9',
+ new \DateTimeImmutable('2025-10-01T10:00:00Z'),
+ $labels ?? [new ImportLabel('category1', 'Marketing', 'f1db50'), new ImportLabel('priority', 'Urgent', 'e9322d')],
+ $stacks ?? [new ImportStack('bucket-todo', 'To do', 0), new ImportStack('bucket-done', 'Done', 1)],
+ );
+ }
+
+ /**
+ * A card as an importing app sends it, with the hash it computes
+ */
+ private function card(string $ref, array $fields = []): ImportCard {
+ $fields += [
+ 'stackRef' => 'bucket-todo',
+ 'title' => 'Ship it',
+ 'description' => '',
+ 'order' => 0,
+ 'start' => null,
+ 'due' => null,
+ 'done' => null,
+ 'created' => new \DateTimeImmutable('2025-11-01T08:00:00Z'),
+ 'createdBy' => '',
+ 'assignees' => [],
+ 'labels' => [],
+ 'files' => [],
+ 'mode' => ImportCard::MODE_CREATE,
+ ];
+ $fields['sha256'] ??= CardCanonicalForm::hash($ref, $fields['stackRef'], $fields['title'], $fields['description'],
+ $fields['order'], $fields['start'], $fields['due'], $fields['done'], $fields['assignees'], $fields['labels'], $fields['files']);
+ return new ImportCard($ref, $fields['stackRef'], $fields['title'], $fields['description'], $fields['order'],
+ $fields['start'], $fields['due'], $fields['done'], $fields['created'], $fields['createdBy'],
+ $fields['assignees'], $fields['labels'], $fields['files'], $fields['sha256'], $fields['mode']);
+ }
+
+ /**
+ * @return list>
+ */
+ private function deckShares(int $cardId): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from('share')
+ ->where($qb->expr()->eq('share_type', $qb->createNamedParameter(IShare::TYPE_DECK, IQueryBuilder::PARAM_INT)))
+ ->andWhere($qb->expr()->eq('share_with', $qb->createNamedParameter((string)$cardId)));
+ return $qb->executeQuery()->fetchAll();
+ }
+
+ /**
+ * Card attachments; the per-user shares Deck adds when somebody's files are set up are not counted
+ */
+ private function deckShareCount(): int {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select($qb->func()->count('*'))
+ ->from('share')
+ ->where($qb->expr()->eq('share_type', $qb->createNamedParameter(IShare::TYPE_DECK, IQueryBuilder::PARAM_INT)));
+ return (int)$qb->executeQuery()->fetchOne();
+ }
+
+ /**
+ * @param list $tables
+ * @return array
+ */
+ private function rowCounts(array $tables): array {
+ $counts = [];
+ foreach ($tables as $table) {
+ if (!$this->db->tableExists($table)) {
+ $counts[$table] = 0;
+ continue;
+ }
+ $qb = $this->db->getQueryBuilder();
+ $qb->select($qb->func()->count('*'))->from($table);
+ $counts[$table] = (int)$qb->executeQuery()->fetchOne();
+ }
+ return $counts;
+ }
+
+ /**
+ * @param class-string<\OCP\BackgroundJob\TimedJob> $class
+ */
+ private function runJob(string $class): void {
+ $job = Server::get($class);
+ $this->invokePrivate($job, 'run', [null]);
+ }
+}
diff --git a/tests/unit/Import/CardCanonicalFormTest.php b/tests/unit/Import/CardCanonicalFormTest.php
new file mode 100644
index 0000000000..a1d274c82d
--- /dev/null
+++ b/tests/unit/Import/CardCanonicalFormTest.php
@@ -0,0 +1,54 @@
+ & \"launch\" \u{2028} ünïcødé 🚀",
+ "Notes\r\n\n- [ ] one\n- [x] two\t\x01\x7f\x08\x0c\\ /path",
+ 8,
+ null,
+ new \DateTimeImmutable('2026-03-04T17:30:00+01:00'),
+ new \DateTimeImmutable('2026-03-05T08:00:09.5Z'),
+ ['bob', 'Alice', 'älex'],
+ ['category2', 'category10', 'priority:urgent'],
+ [42, 7, 1000],
+ );
+
+ $this->assertSame('64303043128b3af48f9572ccba442a4bb91519c7d5c963a35ac4913cdda24231', $hash);
+ }
+
+ public function testEmptyListsAreArrays(): void {
+ $hash = CardCanonicalForm::hash('r', 's', 't', '', 0, null, null, null, [], [], []);
+
+ $this->assertSame('3012ac82cf824f37fe7c854d91ccb0a6d0e9b6681606e5940cf9fa19b249b5ce', $hash);
+ }
+
+ public function testOrderOfListsDoesNotMatter(): void {
+ $this->assertSame(
+ CardCanonicalForm::hash('r', 's', 't', '', 0, null, null, null, ['a', 'b'], ['x', 'y'], [2, 1]),
+ CardCanonicalForm::hash('r', 's', 't', '', 0, null, null, null, ['b', 'a'], ['y', 'x'], [1, 2]),
+ );
+ }
+
+ public function testTimesAreUtcSeconds(): void {
+ $this->assertSame('2026-01-02T02:04:05Z', CardCanonicalForm::time(new \DateTime('2026-01-02T03:04:05.999+01:00')));
+ $this->assertNull(CardCanonicalForm::time(null));
+ }
+}