Build package / php-lint (8.2) (push) Successful in 45s
Build package / php-lint (8.3) (push) Successful in 39s
Build package / php-lint (8.4) (push) Successful in 42s
Build package / php-lint (8.5) (push) Successful in 39s
Build package / xml-lint (push) Successful in 12s
Build package / unit-tests (push) Successful in 44s
Build package / package (push) Successful in 1m1s
getActiveCardsInStack() reassigned $cards to CardService::enrichCards()'s return value. That return value is an array of CardDetails wrappers whose own entity fields (duedate, stackId, ...) are never populated from the wrapped card - only its overridden jsonSerialize() reads through to them. So every card's getDuedate()/getDaysUntilDue() resolved to null, and WorkflowRunner::isOverdue() was always false: no workflow could ever match a card, regardless of its actual due date. enrichCards() already mutates the original $cards entries in place (labels, assigned users, comment counts, ...), so the fix is to keep using that array instead of capturing the CardDetails return value. Bump to 0.2.3.
642 lines
21 KiB
PHP
642 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\WorkflowDeckAutomation\Service;
|
|
|
|
use OCA\Deck\Db\BoardMapper;
|
|
use OCA\Deck\Db\Card;
|
|
use OCA\Deck\Db\CardMapper;
|
|
use OCA\Deck\Db\StackMapper;
|
|
use OCA\Deck\Service\BoardService;
|
|
use OCA\Deck\Service\CardService;
|
|
use OCA\Deck\Service\PermissionService;
|
|
use OCA\Deck\Service\StackService;
|
|
use OCP\App\IAppManager;
|
|
use OCP\AppFramework\Db\DoesNotExistException;
|
|
use OCP\IGroupManager;
|
|
use OCP\IUser;
|
|
use OCP\IUserManager;
|
|
use OCP\Server;
|
|
use Psr\Log\LoggerInterface;
|
|
use ReflectionProperty;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Single choke point for everything this app does with Deck.
|
|
*
|
|
* Deck's OCA\Deck\* classes are not a documented/stable public API (no
|
|
* @since markers, no <public> declaration in its info.xml). We use them
|
|
* directly anyway, per explicit product requirement (no HTTP/OCS calls
|
|
* against Deck are allowed), but every resolution and call is guarded so a
|
|
* Deck-side change degrades a single workflow instead of crashing the
|
|
* whole background run.
|
|
*/
|
|
class DeckIntegrationService {
|
|
private const DECK_APP_ID = 'deck';
|
|
|
|
/**
|
|
* Deck's Acl::PERMISSION_TYPE_* values, inlined so this class keeps
|
|
* working (degrading to "treat it as a user") if Deck ever moves or
|
|
* renames the constants.
|
|
*/
|
|
private const ACL_TYPE_USER = 0;
|
|
private const ACL_TYPE_GROUP = 1;
|
|
|
|
/**
|
|
* Deck's Acl::PERMISSION_READ, inlined for the same reason. It is the key
|
|
* under which PermissionService::getPermissions() reports read access.
|
|
*/
|
|
private const ACL_PERMISSION_READ = 0;
|
|
|
|
/**
|
|
* The property through which Deck carries "the current user" — a plain
|
|
* string frozen at construction rather than a session lookup. See
|
|
* beginUserContext().
|
|
*/
|
|
private const IDENTITY_PROPERTY = 'userId';
|
|
|
|
/**
|
|
* Further Deck classes holding that same frozen uid. Unlike
|
|
* PermissionService these do not decide access, only whose name ends up
|
|
* on an activity entry or whose unread-comment counts are computed, so
|
|
* failing to pin them is logged and shrugged off.
|
|
*/
|
|
private const OPTIONAL_IDENTITY_CLASSES = [
|
|
CardService::class,
|
|
BoardService::class,
|
|
'OCA\Deck\Activity\ActivityManager',
|
|
];
|
|
|
|
/**
|
|
* @var list<array{ReflectionProperty, object, ?string}>
|
|
*/
|
|
private array $identityRestore = [];
|
|
|
|
public function __construct(
|
|
private IAppManager $appManager,
|
|
private IUserManager $userManager,
|
|
private IGroupManager $groupManager,
|
|
private LoggerInterface $logger,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Pins Deck to $user until endUserContext() — mandatory before any Deck
|
|
* call made on behalf of someone who is not the logged-in web user.
|
|
*
|
|
* IUserSession::setUser() alone is not enough, and that is not obvious:
|
|
* Deck's PermissionService — the class behind *every* permission check,
|
|
* including the ones inside CardService::reorder() — receives the current
|
|
* user as `private ?string $userId`. The app container fills that from
|
|
* `ISession::get('user_id')` through a **shared** service, so Pimple
|
|
* computes it once and caches it for the rest of the process, and
|
|
* ServerContainer keeps the app container just as long. Whoever was
|
|
* active when Deck's container first built PermissionService is therefore
|
|
* the user every later check is evaluated against.
|
|
*
|
|
* In the background job that is never the user we are impersonating: it
|
|
* is `null` whenever a Deck-owned job ran earlier in the same cron pass
|
|
* (cron.php works through many jobs in one process), and otherwise the
|
|
* first workflow owner we happened to touch. `null` fails every check,
|
|
* which is how intact boards started reporting NoPermissionException and
|
|
* got their workflows switched off.
|
|
*
|
|
* Deck exposes no API for this, so the frozen value is swapped directly
|
|
* and put back in endUserContext(). PermissionService is required: if it
|
|
* cannot be pinned this throws, and the caller has to skip the user
|
|
* rather than run them under someone else's permissions.
|
|
*
|
|
* @throws DeckUnavailableException
|
|
*/
|
|
public function beginUserContext(IUser $user): void {
|
|
if ($this->identityRestore !== []) {
|
|
throw new DeckUnavailableException('A Deck user context is already active');
|
|
}
|
|
|
|
$uid = $user->getUID();
|
|
|
|
try {
|
|
$this->pinDeckIdentity($this->resolve(PermissionService::class), $uid);
|
|
} catch (Throwable $e) {
|
|
$this->endUserContext();
|
|
throw new DeckUnavailableException(
|
|
'Could not run Deck as ' . $uid . ': ' . $e->getMessage(),
|
|
0,
|
|
$e,
|
|
);
|
|
}
|
|
|
|
foreach (self::OPTIONAL_IDENTITY_CLASSES as $class) {
|
|
try {
|
|
$this->pinDeckIdentity($this->resolve($class), $uid);
|
|
} catch (Throwable $e) {
|
|
$this->logger->debug('Could not pin ' . $class . ' to ' . $uid . ': ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restores what beginUserContext() replaced. Idempotent, so it can be
|
|
* called unconditionally from a finally block.
|
|
*/
|
|
public function endUserContext(): void {
|
|
foreach (array_reverse($this->identityRestore) as [$property, $service, $previous]) {
|
|
try {
|
|
$property->setValue($service, $previous);
|
|
} catch (Throwable $e) {
|
|
$this->logger->warning('Could not restore Deck identity on ' . $service::class . ': ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
]);
|
|
}
|
|
}
|
|
$this->identityRestore = [];
|
|
}
|
|
|
|
private function pinDeckIdentity(object $service, string $uid): void {
|
|
$property = new ReflectionProperty($service, self::IDENTITY_PROPERTY);
|
|
$previous = $property->isInitialized($service) ? $property->getValue($service) : null;
|
|
$property->setValue($service, $uid);
|
|
$this->identityRestore[] = [$property, $service, is_string($previous) ? $previous : null];
|
|
}
|
|
|
|
public function assertDeckAvailable(IUser $user): void {
|
|
if (!$this->appManager->isEnabledForUser(self::DECK_APP_ID, $user)) {
|
|
throw new DeckUnavailableException('Deck is not enabled for user ' . $user->getUID());
|
|
}
|
|
if (!class_exists(CardService::class) || !class_exists(BoardService::class) || !class_exists(StackService::class)) {
|
|
throw new DeckUnavailableException('Deck internal classes are not available');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id: int, title: string}>
|
|
*
|
|
* Only ever call this from a *request*: getUserBoards() reads the uid
|
|
* BoardService froze at construction time, and beginUserContext() cannot
|
|
* help here — it repoints that field, but Deck may already have cached
|
|
* board lists behind it, and the job has no use for a board *list*
|
|
* anyway. It asks findStackIds() about one known board id instead.
|
|
*/
|
|
public function listBoardsForCurrentUser(): array {
|
|
$boards = $this->call(function () {
|
|
$boardService = $this->resolve(BoardService::class);
|
|
try {
|
|
// getUserBoards(?int $since, bool $includeArchived, …): with
|
|
// the default $includeArchived = true, Deck skips the
|
|
// `archived = false AND deleted_at = 0` conditions entirely,
|
|
// so archived *and* trashed boards come back. Ask for the
|
|
// filtered query instead of sorting them out afterwards.
|
|
return $boardService->getUserBoards(null, false);
|
|
} catch (Throwable $e) {
|
|
// Older/newer Deck with a different signature: fall back to
|
|
// the unfiltered call, isBoardHidden() below still filters.
|
|
return $boardService->getUserBoards();
|
|
}
|
|
}, []);
|
|
|
|
// Deck merges own/group/circle boards, so the same board can come
|
|
// back more than once; and boards in the trash or archived ones are
|
|
// no useful automation target. Keyed by id => deduplicated.
|
|
$result = [];
|
|
foreach ($boards as $board) {
|
|
$id = (int)$board->getId();
|
|
if (isset($result[$id]) || $this->isBoardHidden($board)) {
|
|
continue;
|
|
}
|
|
$title = $board->getTitle();
|
|
$result[$id] = [
|
|
'id' => $id,
|
|
'title' => (is_string($title) && $title !== '') ? $title : ('#' . $id),
|
|
];
|
|
}
|
|
|
|
return array_values($result);
|
|
}
|
|
|
|
private function isBoardHidden(mixed $board): bool {
|
|
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
|
|
return true;
|
|
}
|
|
return method_exists($board, 'getArchived') && $board->getArchived() === true;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id: int, title: string}>
|
|
*/
|
|
public function listStacks(int $boardId): array {
|
|
$stacks = $this->call(function () use ($boardId) {
|
|
return $this->resolve(StackService::class)->findAll($boardId);
|
|
}, []);
|
|
|
|
return array_map(
|
|
static fn ($stack) => ['id' => (int)$stack->getId(), 'title' => $stack->getTitle()],
|
|
$stacks,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Ids of the board's stacks, for deciding whether a stored workflow still
|
|
* points at anything real.
|
|
*
|
|
* Returns `null` only on positive knowledge that the board is gone, in the
|
|
* trash, or no longer readable for `$userId`. Every other failure throws,
|
|
* because "Deck is broken right now" must never be mistaken for "the user
|
|
* deleted this board" — the caller reacts by switching the workflow off
|
|
* and mailing its owner.
|
|
*
|
|
* Deliberately assembled from pieces that cannot answer for the wrong
|
|
* user. BoardMapper and StackMapper are plain QBMappers with no user
|
|
* state (StackMapper::findAll() filters `deleted_at = 0` itself), and
|
|
* getPermissions() is handed the uid explicitly instead of falling back
|
|
* to PermissionService's construction-time `$userId`. Leaning on that
|
|
* frozen value — which is exactly what `StackService::findAll()` does
|
|
* internally — is what disabled workflows whose board was perfectly
|
|
* intact, so this must not be "simplified" back to one service call even
|
|
* once beginUserContext() has pinned the identity.
|
|
*
|
|
* @return int[]|null
|
|
* @throws DeckUnavailableException
|
|
*/
|
|
public function findStackIds(int $boardId, string $userId): ?array {
|
|
try {
|
|
$board = $this->resolve(BoardMapper::class)->find($boardId);
|
|
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
|
|
return null;
|
|
}
|
|
|
|
$permissions = $this->resolve(PermissionService::class)->getPermissions($boardId, $userId);
|
|
if (!array_key_exists(self::ACL_PERMISSION_READ, $permissions)) {
|
|
// Unknown shape: say nothing rather than something wrong.
|
|
throw new DeckUnavailableException('Deck reported no read permission for board ' . $boardId);
|
|
}
|
|
if ($permissions[self::ACL_PERMISSION_READ] !== true) {
|
|
return null;
|
|
}
|
|
|
|
$stacks = $this->resolve(StackMapper::class)->findAll($boardId);
|
|
} catch (DeckUnavailableException $e) {
|
|
throw $e;
|
|
} catch (Throwable $e) {
|
|
if ($this->isMissing($e)) {
|
|
return null;
|
|
}
|
|
$this->logger->error('Could not list stacks of board ' . $boardId . ': ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'exception' => $e,
|
|
]);
|
|
throw new DeckUnavailableException('Deck call failed: ' . $e->getMessage(), 0, $e);
|
|
}
|
|
|
|
return array_map(static fn ($stack) => (int)$stack->getId(), $stacks);
|
|
}
|
|
|
|
/**
|
|
* Deck's own exception classes are referenced by name: `is_a()` with a
|
|
* string simply returns false when the class does not exist, so a missing
|
|
* or renamed Deck degrades to "unknown error" instead of fataling.
|
|
*
|
|
* `OCA\Deck\NoPermissionException` is deliberately *not* treated as "the
|
|
* board is gone" here. It used to be, and that is what switched intact
|
|
* workflows off: Deck raises it for any check that fails, including every
|
|
* check evaluated against the wrong frozen uid — which says nothing at
|
|
* all about whether the board still exists.
|
|
*/
|
|
private function isMissing(Throwable $e): bool {
|
|
if ($e instanceof DoesNotExistException) {
|
|
return true;
|
|
}
|
|
return is_a($e, 'OCA\Deck\NotFoundException');
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id: int, title: string, color: string|null}>
|
|
*/
|
|
public function listLabels(int $boardId): array {
|
|
$labels = $this->call(fn () => $this->readLabels($boardId), []);
|
|
|
|
return array_map(
|
|
static fn ($label) => ['id' => (int)$label->getId(), 'title' => $label->getTitle(), 'color' => $label->getColor()],
|
|
$labels,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* What a workflow's filters *could* legitimately refer to on this board.
|
|
*
|
|
* Unlike listLabels()/listParticipants() this throws instead of degrading
|
|
* to an empty list, because its only caller draws a conclusion from
|
|
* emptiness ("this filter cannot match anything any more"). Same rule as
|
|
* findStackIds(): a Deck hiccup must never be read as positive knowledge.
|
|
*
|
|
* @return array{labelIds: int[], participantUids: string[]}
|
|
* @throws DeckUnavailableException
|
|
*/
|
|
public function findFilterOptions(int $boardId): array {
|
|
try {
|
|
$board = $this->readBoard($boardId);
|
|
|
|
return [
|
|
'labelIds' => array_map(
|
|
static fn ($label) => (int)$label->getId(),
|
|
$board->getLabels() ?? [],
|
|
),
|
|
'participantUids' => array_values(array_unique($this->collectParticipantUids($board))),
|
|
];
|
|
} catch (DeckUnavailableException $e) {
|
|
throw $e;
|
|
} catch (Throwable $e) {
|
|
$this->logger->error('Could not read filter options of board ' . $boardId . ': ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'exception' => $e,
|
|
]);
|
|
throw new DeckUnavailableException('Deck call failed: ' . $e->getMessage(), 0, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Titles of a workflow's board and stacks, for the notification mail.
|
|
*
|
|
* Best effort by design: this only decorates a mail that is being sent
|
|
* because a card *was* already moved, so anything unreadable degrades to
|
|
* `#<id>` rather than costing the user their notification.
|
|
*
|
|
* @return array{board: string, sourceStack: string, targetStack: string}
|
|
*/
|
|
public function describeTargets(int $boardId, int $sourceStackId, int $targetStackId): array {
|
|
$titles = $this->call(function () use ($boardId) {
|
|
$stackTitles = [];
|
|
foreach ($this->resolve(StackMapper::class)->findAll($boardId) as $stack) {
|
|
$stackTitles[(int)$stack->getId()] = (string)$stack->getTitle();
|
|
}
|
|
|
|
return [
|
|
'board' => (string)$this->resolve(BoardMapper::class)->find($boardId)->getTitle(),
|
|
'stacks' => $stackTitles,
|
|
];
|
|
}, ['board' => '', 'stacks' => []]);
|
|
|
|
return [
|
|
'board' => self::titleOr($titles['board'], $boardId),
|
|
'sourceStack' => self::titleOr($titles['stacks'][$sourceStackId] ?? '', $sourceStackId),
|
|
'targetStack' => self::titleOr($titles['stacks'][$targetStackId] ?? '', $targetStackId),
|
|
];
|
|
}
|
|
|
|
private static function titleOr(string $title, int $id): string {
|
|
return $title !== '' ? $title : ('#' . $id);
|
|
}
|
|
|
|
/**
|
|
* Everyone who can hold a card on this board: the board owner (who is
|
|
* *not* part of the ACL — a private board has an empty ACL) plus every
|
|
* ACL entry, with group shares expanded to their members.
|
|
*
|
|
* @return array<int, array{uid: string, displayName: string}>
|
|
*/
|
|
public function listParticipants(int $boardId): array {
|
|
$uids = $this->call(fn () => $this->collectParticipantUids($this->readBoard($boardId)), []);
|
|
|
|
$participants = [];
|
|
foreach ($uids as $uid) {
|
|
if (isset($participants[$uid])) {
|
|
continue;
|
|
}
|
|
$participants[$uid] = [
|
|
'uid' => $uid,
|
|
'displayName' => $this->userManager->get($uid)?->getDisplayName() ?? $uid,
|
|
];
|
|
}
|
|
|
|
return array_values($participants);
|
|
}
|
|
|
|
/**
|
|
* The enriched board — `$fullDetails = true` is what populates labels and
|
|
* the ACL. Shared by every caller that needs one so a single Deck read
|
|
* serves them all.
|
|
*/
|
|
private function readBoard(int $boardId): mixed {
|
|
return $this->resolve(BoardService::class)->find($boardId, true);
|
|
}
|
|
|
|
private function readLabels(int $boardId): array {
|
|
return $this->readBoard($boardId)->getLabels() ?? [];
|
|
}
|
|
|
|
/**
|
|
* @return string[] may contain duplicates
|
|
*/
|
|
private function collectParticipantUids(mixed $board): array {
|
|
$uids = [];
|
|
$owner = $this->unwrapUid($board->getOwner());
|
|
if ($owner !== null) {
|
|
$uids[] = $owner;
|
|
}
|
|
|
|
foreach ($board->getAcl() ?? [] as $entry) {
|
|
$principal = $this->extractParticipantUid($entry);
|
|
if ($principal === null) {
|
|
continue;
|
|
}
|
|
$type = method_exists($entry, 'getType') ? (int)$entry->getType() : self::ACL_TYPE_USER;
|
|
if ($type === self::ACL_TYPE_GROUP) {
|
|
array_push($uids, ...$this->groupMemberUids($principal));
|
|
} elseif ($type === self::ACL_TYPE_USER) {
|
|
$uids[] = $principal;
|
|
}
|
|
// Circles and federated shares are skipped: their members
|
|
// cannot be resolved to plain uids here.
|
|
}
|
|
|
|
return $uids;
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function groupMemberUids(string $groupId): array {
|
|
$group = $this->groupManager->get($groupId);
|
|
if ($group === null) {
|
|
return [];
|
|
}
|
|
return array_map(static fn (IUser $user) => $user->getUID(), $group->getUsers());
|
|
}
|
|
|
|
/**
|
|
* Cards in the given stack that are neither archived, deleted nor marked
|
|
* done, enriched so getAssignedUsers()/getLabels() are populated.
|
|
*
|
|
* The `deletedAt` check is not redundant: Deck's
|
|
* `CardMapper::findAllByStack()` filters on `stack_id` and
|
|
* `archived = false` only, so cards sitting in the trash come back too —
|
|
* they are kept until Deck's own DeleteCron purges them. Without this we
|
|
* would move cards the user already deleted.
|
|
*
|
|
* @return Card[]
|
|
*/
|
|
public function getActiveCardsInStack(int $stackId): array {
|
|
return $this->call(function () use ($stackId) {
|
|
$cardMapper = $this->resolve(CardMapper::class);
|
|
$cardService = $this->resolve(CardService::class);
|
|
|
|
$cards = $cardMapper->findAllByStack($stackId);
|
|
// enrichCards() mutates each $cards entry in place (labels, assigned users, ...) but
|
|
// *returns* CardDetails wrappers whose own fields (duedate, stackId, ...) were never
|
|
// copied from the wrapped card - only its overridden jsonSerialize() reads through to
|
|
// them. Capturing that return value here made every card's getDuedate()/getDaysUntilDue()
|
|
// resolve to null, so isOverdue() was always false and no workflow ever matched a card.
|
|
// Keep using the original, now-enriched $cards instead.
|
|
$cardService->enrichCards($cards);
|
|
|
|
return array_values(array_filter($cards, static function (Card $card) {
|
|
if (method_exists($card, 'getDeletedAt') && (int)$card->getDeletedAt() > 0) {
|
|
return false;
|
|
}
|
|
return !$card->getArchived() && $card->getDone() === null;
|
|
}));
|
|
}, []);
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
public function getCardAssignedUserIds(Card $card): array {
|
|
$assigned = $card->getAssignedUsers() ?? [];
|
|
$uids = [];
|
|
foreach ($assigned as $entry) {
|
|
$uid = $this->extractParticipantUid($entry);
|
|
if ($uid !== null) {
|
|
$uids[] = $uid;
|
|
}
|
|
}
|
|
return $uids;
|
|
}
|
|
|
|
/**
|
|
* @return int[]
|
|
*/
|
|
public function getCardLabelIds(Card $card): array {
|
|
$labels = $card->getLabels() ?? [];
|
|
$ids = [];
|
|
foreach ($labels as $label) {
|
|
if (method_exists($label, 'getId')) {
|
|
$ids[] = (int)$label->getId();
|
|
}
|
|
}
|
|
return $ids;
|
|
}
|
|
|
|
/**
|
|
* Moves a card to another stack using Deck's own reorder logic
|
|
* (the exact same code path behind Deck's "move card" action).
|
|
*/
|
|
public function moveCard(int $cardId, int $targetStackId): void {
|
|
$this->call(function () use ($cardId, $targetStackId) {
|
|
$this->resolve(CardService::class)->reorder($cardId, $targetStackId, 0);
|
|
return null;
|
|
}, null, true);
|
|
}
|
|
|
|
/**
|
|
* @template T
|
|
* @param callable(): T $callback
|
|
* @param T $fallback
|
|
* @return T
|
|
*/
|
|
private function call(callable $callback, mixed $fallback, bool $rethrow = false) {
|
|
try {
|
|
return $callback();
|
|
} catch (DeckUnavailableException $e) {
|
|
throw $e;
|
|
} catch (Throwable $e) {
|
|
$this->logger->error('Deck integration call failed: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'exception' => $e,
|
|
]);
|
|
if ($rethrow) {
|
|
throw new DeckUnavailableException('Deck call failed: ' . $e->getMessage(), 0, $e);
|
|
}
|
|
return $fallback;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @template T of object
|
|
* @param class-string<T> $class
|
|
* @return T
|
|
*/
|
|
private function resolve(string $class) {
|
|
if (!class_exists($class)) {
|
|
throw new DeckUnavailableException("Deck class {$class} does not exist");
|
|
}
|
|
return Server::get($class);
|
|
}
|
|
|
|
/**
|
|
* Deck's assignment/ACL entries are internal, undocumented value
|
|
* objects whose exact accessor shape has changed across versions, so
|
|
* we probe the common accessor names defensively instead of relying
|
|
* on one fixed method signature.
|
|
*/
|
|
private function extractParticipantUid(mixed $entry): ?string {
|
|
$uid = $this->unwrapUid($entry);
|
|
if ($uid !== null) {
|
|
return $uid;
|
|
}
|
|
if (!is_object($entry)) {
|
|
return null;
|
|
}
|
|
foreach (['getParticipant', 'getUid', 'getParticipantUid', 'getUserId'] as $method) {
|
|
if (method_exists($entry, $method)) {
|
|
$uid = $this->unwrapUid($entry->$method());
|
|
if ($uid !== null) {
|
|
return $uid;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Turns whatever Deck hands out for a "participant"/"owner" field into
|
|
* a plain uid.
|
|
*
|
|
* Enriched Deck entities do *not* return the raw uid string: their
|
|
* RelationalEntity base swaps resolved relations for a
|
|
* OCA\Deck\Db\RelationalObject wrapping a User/Group object, and its
|
|
* primary key is the uid we want. Unenriched entities still return the
|
|
* bare string, so both shapes are handled.
|
|
*/
|
|
private function unwrapUid(mixed $value): ?string {
|
|
if (is_string($value)) {
|
|
return $value !== '' ? $value : null;
|
|
}
|
|
if (!is_object($value)) {
|
|
return null;
|
|
}
|
|
foreach (['getPrimaryKey', 'getUID', 'getUid', 'getId'] as $method) {
|
|
if (method_exists($value, $method)) {
|
|
$inner = $value->$method();
|
|
if (is_string($inner) && $inner !== '') {
|
|
return $inner;
|
|
}
|
|
}
|
|
}
|
|
if (method_exists($value, 'getObject')) {
|
|
$inner = $value->getObject();
|
|
foreach (['getUID', 'getUid', 'getId'] as $method) {
|
|
if (is_object($inner) && method_exists($inner, $method)) {
|
|
$uid = $inner->$method();
|
|
if (is_string($uid) && $uid !== '') {
|
|
return $uid;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|