diff --git a/CLAUDE.md b/CLAUDE.md
index 9d3f159..4f9b4e9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -90,6 +90,22 @@ None of these were discoverable locally — there's no npm here (see "Local-only
- **`createAppConfig` prefixes every entry name with the app id**, so the entry `personal-settings` is emitted as `js/workflow_deck_automation-personal-settings.mjs` and `css/workflow_deck_automation-personal-settings.css`. `Util::addScript()`/`addStyle()` take that *full on-disk basename* (minus extension), not the bare entry name — `lib/Settings/Personal.php` therefore passes `Application::APP_ID . '-personal-settings'`. Getting this wrong produces a **silently blank settings page**: the template's empty mount `
` renders fine, and the only symptom is `Could not find resource workflow_deck_automation/js/….js to load` (`jsresourceloader`) plus a matching `Could not find resource file "/apps/workflow_deck_automation/css/….css"` in `nextcloud.log`. If you rename the entry in `vite.config.js`, rename it in `Personal.php` too.
- **The bulk of the CSS lives in a hashed `*.chunk.css`**, loaded at runtime by the `.mjs` bundle (`cssCodeSplit: true`). The `css/workflow_deck_automation-personal-settings.css` that `addStyle()` points at is the ~100-byte stub produced by `createEmptyCSSEntryPoints: true`. A near-empty entry CSS file is expected — don't "fix" it by turning off code splitting.
+## `@nextcloud/vue` 9 component API (Vue 3) — the v8 idioms silently do nothing
+
+This app is on `@nextcloud/vue` ^9 / Vue 3. The v8 (Vue 2) prop spellings that most Nextcloud app code online still uses either fail silently or, worse, land as stray DOM attributes instead of erroring:
+
+- **No `.sync`.** Vue 3 removed it. `NcTextField` and `NcCheckboxRadioSwitch` both bind through `modelValue`/`update:modelValue`, so it's `v-model="x"` — `:value.sync="x"` / `:checked.sync="x"` leave the bound state permanently at its initial value. That's how `form.title` stayed `''` and every save died on "Bitte einen Titel angeben."
+- **`NcSelect`'s `label` prop is the *option display key*, not a caption** (it's vue-select's). Passing `label="Board"` makes it read `option.Board` on every option and render `undefined` for all of them. The visible caption prop is `input-label`.
+- **`NcButton`: `variant` is the style, `type` is the native button type**, and `native-type` no longer exists (v8's `type="primary"` + `native-type="submit"` → v9's `variant="primary"` + `type="submit"`). A leftover `type="primary"` renders `
`, which HTML treats as the invalid-value default `submit` — so a *cancel* button silently submits the form.
+- `NcSelect`'s `reduce` is not declared on `NcSelect` itself; it reaches vue-select through attribute fallthrough (NcSelect's root element *is* the `VueSelect` component and it doesn't set `inheritAttrs: false`). It works, but it's fallthrough, not a documented prop.
+- `NcNoteCard`'s prop is still `type` (`success|info|warning|error`) — not everything was renamed to `variant`.
+
+## Deck entities are "relational": getters don't return the uid string
+
+Deck's `RelationalEntity` swaps resolved relations for an `OCA\Deck\Db\RelationalObject` once an entity is enriched. So on an enriched board/card, `$acl->getParticipant()`, `$board->getOwner()` and a card assignment's participant return a `RelationalObject` (wrapping `OCA\Deck\Db\User`/`Group`/`Circle`), **not** a uid — the uid is its `getPrimaryKey()`. Probing for `getUID()` on the returned object finds nothing and yields `null`, which is how the settings UI's user dropdown came up empty *and* why the runner's assigned-user filter could never match. `DeckIntegrationService::unwrapUid()` handles both shapes (bare string and `RelationalObject`); route any new participant/owner field through it.
+
+Two more Deck facts that bit us in the same pass: a board's ACL does **not** contain its owner (a private board has an empty ACL, so participants must be seeded with `$board->getOwner()`), and ACL entries can be groups (`type === 1`) whose members have to be expanded via `IGroupManager`. `BoardService::getUserBoards()` merges own/group/circle boards and can return the same board twice, and it includes archived and trashed boards — `listBoardsForCurrentUser()` dedupes by id and drops those.
+
## Local-only notes
`CLAUDE.local.md` (gitignored) carries session-specific environment notes (e.g. "no PHP/npm available locally"). Check it at the start of work in this repo — it's not duplicated here since it can change independently of the committed guidance.
diff --git a/lib/Service/DeckIntegrationService.php b/lib/Service/DeckIntegrationService.php
index 2b82cde..9391814 100644
--- a/lib/Service/DeckIntegrationService.php
+++ b/lib/Service/DeckIntegrationService.php
@@ -10,7 +10,9 @@ use OCA\Deck\Service\BoardService;
use OCA\Deck\Service\CardService;
use OCA\Deck\Service\StackService;
use OCP\App\IAppManager;
+use OCP\IGroupManager;
use OCP\IUser;
+use OCP\IUserManager;
use OCP\Server;
use Psr\Log\LoggerInterface;
use Throwable;
@@ -28,8 +30,18 @@ use Throwable;
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;
+
public function __construct(
private IAppManager $appManager,
+ private IUserManager $userManager,
+ private IGroupManager $groupManager,
private LoggerInterface $logger,
) {
}
@@ -51,10 +63,30 @@ class DeckIntegrationService {
return $this->resolve(BoardService::class)->getUserBoards();
}, []);
- return array_map(
- static fn ($board) => ['id' => $board->getId(), 'title' => $board->getTitle()],
- $boards,
- );
+ // 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;
}
/**
@@ -87,25 +119,65 @@ class DeckIntegrationService {
}
/**
+ * 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
*/
public function listParticipants(int $boardId): array {
- $acl = $this->call(function () use ($boardId) {
+ $uids = $this->call(function () use ($boardId) {
$board = $this->resolve(BoardService::class)->find($boardId, true);
- return $board->getAcl() ?? [];
+
+ $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;
}, []);
$participants = [];
- foreach ($acl as $entry) {
- $uid = $this->extractParticipantUid($entry);
- if ($uid !== null && !isset($participants[$uid])) {
- $participants[$uid] = ['uid' => $uid, 'displayName' => $uid];
+ foreach ($uids as $uid) {
+ if (isset($participants[$uid])) {
+ continue;
}
+ $participants[$uid] = [
+ 'uid' => $uid,
+ 'displayName' => $this->userManager->get($uid)?->getDisplayName() ?? $uid,
+ ];
}
return array_values($participants);
}
+ /**
+ * @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 nor marked done,
* enriched so getAssignedUsers()/getLabels() are populated.
@@ -208,20 +280,57 @@ class DeckIntegrationService {
* on one fixed method signature.
*/
private function extractParticipantUid(mixed $entry): ?string {
- if (is_string($entry)) {
- return $entry;
+ $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)) {
- $value = $entry->$method();
- if (is_string($value) && $value !== '') {
- return $value;
+ $uid = $this->unwrapUid($entry->$method());
+ if ($uid !== null) {
+ return $uid;
}
- if (is_object($value) && method_exists($value, 'getUID')) {
- return $value->getUID();
+ }
+ }
+ 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;
+ }
}
}
}
diff --git a/src/PersonalSettings.vue b/src/PersonalSettings.vue
index ca0f413..e9a7c94 100644
--- a/src/PersonalSettings.vue
+++ b/src/PersonalSettings.vue
@@ -39,10 +39,10 @@
{{ workflow.notifyEmail ? 'Ja' : 'Nein' }}
{{ workflow.enabled ? 'Ja' : 'Nein' }}
-
+
Bearbeiten
-
+
Löschen
@@ -53,19 +53,19 @@
Noch keine Workflows angelegt.
-
+
Workflow hinzufügen