# CLAUDE.md Leitfaden für Claude Code (claude.ai/code) in diesem Repository. ## Worum es geht Nextcloud-App `workflow_deck_automation` (Namespace `WorkflowDeckAutomation`) für **Nextcloud 35** (`main`, `min-version="35" max-version="35"`). Jeder Nutzer legt in seinen persönlichen Einstellungen „Workflows“ an, die [Deck](https://github.com/nextcloud/deck)-Karten von einem Stack in einen anderen verschieben, sobald ihr Fälligkeits- oder Startdatum erreicht ist (`trigger_date`, pro Workflow), optional mit Mail. Es gibt bewusst **keine Kartenfilter**: ein Workflow verschiebt *jede* Karte seines Quell-Stacks, deren Datum erreicht ist. Ein Hintergrund-`Job` wertet alle aktiven Workflows bei jedem Cron-Lauf aus. ## Harte Vorgabe: keine HTTP/OCS-Aufrufe gegen Deck Alle Lesezugriffe (Boards, Stacks, Karten) und das Verschieben laufen über Decks interne PHP-Klassen (`OCA\Deck\Service\*`, `OCA\Deck\Db\*`), in-process per `\OCP\Server::get()`. Das ist eine ausdrückliche, wiederholte Anforderung des Nutzers – nicht auf `IClientService`/`curl` gegen `/ocs/...` „umbauen“. **Nur `lib/Service/DeckIntegrationService.php` referenziert `OCA\Deck\*`.** Neue Deck-Daten → diese Klasse erweitern. Jeder Aufruf dort ist abgesichert (`class_exists()`, `IAppManager::isEnabledForUser('deck', …)`, try/catch → `DeckUnavailableException`), weil `OCA\Deck\*` keine stabile, dokumentierte API ist (keine `@since`-Marker). ## Impersonation im Hintergrundjob – Decks eingefrorene `$userId` `WorkflowRunner` gruppiert Workflows nach Owner und impersoniert jeden nacheinander, in zwei Schritten (beide im `finally` via `clearImpersonation()` zurückgesetzt): 1. `IUserSession::setUser()` + `IRootFolder::getUserFolder()` – für die wenigen Dienste, die die Session lesen. Nebensächlich. 2. `DeckIntegrationService::beginUserContext()` – **der tragende Teil.** **Deck liest für Berechtigungen nie die Session.** `PermissionService` (hinter jeder Prüfung, auch in `CardService::reorder()`), `CardService`, `BoardService` und `ActivityManager` halten `private ?string $userId` aus dem `userId`-Service des Deck-Containers – *shared*, also einmal pro Prozess aufgelöst. Im Cron ist das nie der impersonierte Nutzer, sondern `null` (wenn vorher ein Deck-Job lief) oder der erste Owner. `null` lässt jede Prüfung mit `NoPermissionException` scheitern. `beginUserContext()` überschreibt daher das private Feld per Reflection und `endUserContext()` stellt es wieder her. `PermissionService` ist Pflicht: klappt das Pinnen nicht, wirft die Methode und der Runner **überspringt den Nutzer komplett**. Die anderen drei sind best effort (nur Activity-Zuordnung/Unread-Zähler). **Regel:** Bevor ein weiterer `OCA\Deck\*`-Dienst im Runner genutzt wird, prüfen, ob er eine eingefrorene `$userId` trägt – der Fehler ist still und nutzerübergreifend. Bevorzugt Mapper (QBMapper ohne Nutzerzustand) oder Methoden mit explizitem uid-Parameter verwenden. Deshalb wird `BoardService::getUserBoards()` nicht verwendet. Der Datumsvergleich `WorkflowRunner::hasPassed()`, `TriggerDate`, `LoopDetector` und die Empfängerregel `NotificationPolicy::shouldNotify()` sind bewusst `static` und seiteneffektfrei, also ohne Deck unit-testbar (`tests/Unit/Service/`). Das sind die einzigen getesteten Teile der App. Neue reine Logik in dieser Form halten. ## Workflows werden deaktiviert, wenn Board/Stack verschwinden `WorkflowRunner::findBrokenTarget()` prüft vor jedem Lauf Board und Stacks; fehlt etwas, wird der Workflow abgeschaltet (`enabled = false`) und einmalig gemailt. Invarianten: - **Nur positives Wissen deaktiviert.** `findStackIds()` liefert `null` nur bei „Board-Zeile weg“ (`DoesNotExistException`, Decks `NotFoundException`), „Board im Papierkorb“ (`deleted_at > 0`) oder „uid hat keine Leserechte“ – alles andere *wirft*, und der Runner überspringt den Workflow. Degradiert die Methode auf `[]`/`null`, deaktiviert ein Deck-Ausfall alle Workflows der Instanz und mailt alle Nutzer an. - **`findStackIds()` fragt nie einen Deck-Dienst, *wer* fragt.** Sie bekommt die uid als Argument und nutzt nur `BoardMapper`/`StackMapper` plus `PermissionService::getPermissions($boardId, $userId)` mit expliziter uid. Nicht zu `StackService::findAll($boardId)` „vereinfachen“ – genau das prüfte gegen die eingefrorene `null`-uid, warf `NoPermissionException`, wurde als „Board weg“ gelesen und verschickte Deaktivierungsmails für intakte Boards. - **`NoPermissionException` beweist nichts** und steht deshalb bewusst nicht in `isMissing()`. - **Die Deaktivierungsmail ignoriert `notifyMode` und `notifyChannel`** (die betreffen verschobene Karten) und geht immer nur an den Owner, zusätzlich als Nextcloud-Benachrichtigung, falls die Notifications-App aktiv ist. Sie bleibt einmalig, weil `findAllEnabled()` deaktivierte Workflows nicht mehr lädt. Archivierte Boards lösen das nicht aus (sie bleiben lesbar). Ihre Workflows bleiben aktiv, werden aber übersprungen: `runWorkflow()` fragt nach `findBrokenTarget()` `DeckIntegrationService::isBoardArchived()` (`BoardMapper`, `false` bei fehlendem Board, sonst wirft es). Im Dropdown erscheinen archivierte Boards nie, auch nicht beim Bearbeiten (dann leert das Formular Board und Stapel und zeigt einen Hinweis). In der Übersicht werden sie markiert. ## Ein Workflow gehört dem Board-Owner, nicht seinem Ersteller Wird ein Board in Deck übertragen, wandern seine Workflows mit. - **„Meine Boards“** = `listOwnedBoards($uid)` → `BoardMapper::findAllByOwner($uid)` (keine geteilten Boards, keine eingefrorene uid). Diese Mapper-Methode filtert weder Papierkorb noch Archiv: Papierkorb-Boards werden verworfen, archivierte mit `archived: true` behalten (Workflows sichtbar, aber nicht im Dropdown, von `validate()` abgelehnt, vom Runner übersprungen). Wirft statt `[]` zurückzugeben – `[]` würde alle Workflows des Nutzers verstecken. - **Auflisten/Bearbeiten/Löschen** nur über `board_id IN (listOwnedBoards)` (`WorkflowMapper::findAllForBoards()`, `WorkflowController::findOwnWorkflow()`). `user_id` spielt keine Rolle – keine `user_id`-Abfragen im Controller wieder einführen. - **Anlegen/Speichern** validiert gegen dieselbe Liste und setzt `user_id` auf den aktuellen Nutzer. - **Runner:** `followBoardOwner()` → `findBoardOwner()` (`BoardMapper::find()`) schreibt einen abweichenden Owner in `user_id` zurück. Lauf und Mails gehen also an den aktuellen Owner. Ein verschwundenes Board behält den letzten Owner, damit `findBrokenTarget()` ihn anmailen kann. `user_id` heißt also nur „als wer läuft der Job / wer bekommt Mail“. ## Mails über verschobene Karten folgen Decks Benachrichtigungseinstellung `notify_mode` (`NotificationPolicy::MODES`) ersetzt seit `Version1002…`/`Version1003…` das frühere `notify_email` (`true` → `owner`): - `off`: keine Mail. `owner`: nur der Board-Owner, ohne Rücksicht auf seine Deck-Einstellung (altes Verhalten). - `board`: alle Board-Berechtigten, gefiltert nach deren Deck-Nutzereinstellung `deck` / `board::notify-due` (`off`/`assigned`/`all`, Default `assigned`). `NotificationPolicy::shouldNotify()` bildet Decks Regel aus `NotificationHelper::sendCardDuedate()` nach und ist reine, getestete Logik. Dazu gehört: Der Owner eines ungeteilten Boards wird außer bei `off` immer benachrichtigt. - `DeckIntegrationService::findNotificationRecipients()` nutzt nur uid-explizite Bausteine: `PermissionService::findUsers($boardId, true)` (Owner + ACL, löst Gruppen/Circles auf, `true` umgeht den Prozess-Cache), `AssignmentMapper::isUserAssigned()` (auch Gruppen-/Circle-Zuweisungen) und `IConfig`. `FederatedUser` wird übersprungen. Bei jedem Fehler wirft die Methode, und der Runner verschickt dann **keine** Mail, statt an eine falsch aufgelöste Empfängerliste zu schicken. - Der Runner sammelt verschobene Karten in `$pendingNotices` und verschickt **nach** der Nutzer-Schleife (also ohne Impersonation) je nach `notify_channel` des Workflows eine Sammelmail pro Empfänger und Lauf und/oder eine Nextcloud-Benachrichtigung pro Karte. ## Nextcloud-Benachrichtigungen sind ein zweiter Kanal, keine zweite Empfängerregel `notify_channel` (`NotificationPolicy::CHANNELS`: `mail`/`notification`/`both`, Default und Migrationswert `mail`, `Version1004…`) sagt nur, *wie* die über `notify_mode` ermittelten Empfänger informiert werden. `NotificationPolicy::sendsMail()`/`sendsNotification()` sind reine, getestete Logik. Unbekannte Werte zählen als `mail`. - **`NotificationPusher`** legt über `OCP\Notification\IManager` eine Benachrichtigung **pro Karte** an (Objekt `card`/Karten-ID, Subject `card_moved`) und räumt vorher per `markProcessed()` eine ältere zur selben Karte weg. Gespeichert werden nur Rohwerte (Titel, IDs), keine Texte. - **`Notification\Notifier`** (per `registerNotifierService()` in `Application::register()`) baut daraus deutsche Texte und ignoriert `$languageCode`. Titel sind Nutzereingaben und gehen nur als Rich-Objekte (`deck-card`, `deck-board`, `highlight`) in den Text, nie ins Template, sonst würde ein `{…}` im Titel als Platzhalter gelesen. Der Notifier fragt **Deck nicht**: Er läuft beim Abruf durch irgendeinen Nutzer, und Deck-Dienste hätten dort die eingefrorene uid. - **Ohne Notifications-App passiert nichts:** `NotificationPusher::isAvailableFor()` prüft `IAppManager::isEnabledForUser('notifications', $user)` pro Empfänger und kehrt stumm zurück. Kein Fallback auf Mail, kein Fehler-Log. Die Einstellungsseite bekommt denselben Check als `data-notifications-available` und zeigt dann einen Hinweis. - Push aufs Handy/Desktop ist Sache der Notifications-App (Push-Proxy bzw. `notify_push`), nicht dieser App. ## Nur Deutsch, keine Übersetzungen Die App wird nur intern genutzt und spricht ausschließlich Deutsch. Oberfläche, Controller-Fehlermeldungen (erscheinen im Formular), Sektionsname, Mails und Nextcloud-Benachrichtigungen sind direkt auf Deutsch geschrieben. Es gibt kein `IL10N` und keinen `l10n/`-Ordner. Nextclouds eigene Mail-Fußzeile wird per `addFooter('', 'de')` ebenfalls deutsch, sonst würde sie im Cron der Instanz-Standardsprache folgen. Log-Meldungen bleiben englisch. Neue nutzersichtbare Texte auf Deutsch schreiben, mit „du“ wie im Rest der App. ## Fälligkeits- oder Startdatum löst das Verschieben aus `trigger_date` (`TriggerDate::ALL`: `due`/`start`, Default und Migrationswert `due`, `Version1005…`) wählt pro Workflow, welches Kartendatum der Runner mit „jetzt“ vergleicht. Unbekannte Werte zählen als `due`. Karten ohne das gewählte Datum bleiben liegen. - **`startdate` gibt es erst seit Deck 1.18.** `Card`-Getter sind magisch (`Entity::__call`), `method_exists()` sieht sie nicht und `getStartdate()` würde erst beim Aufruf werfen. Daher `DeckIntegrationService::supportsStartdate()` (`property_exists`). Der Runner liest `getStartdate()` nur bei `start` und überspringt solche Workflows ohne Deaktivierung, wenn Deck das Feld nicht kennt. Der Controller lehnt `start` dann ab. - **Texte:** `TriggerDate::describeReason()` liefert den Satzteil („ist überfällig“ / „hat ihr Startdatum erreicht“) für Mail und Notifier. `NotificationPusher` speichert `triggerDate` in den Subject-Parametern. Ältere Benachrichtigungen ohne den Schlüssel gelten als `due`. - **Empfänger:** Auch Startdatums-Workflows nutzen bei `notify_mode = board` Decks `notify-due`-Einstellung. Deck hat keine eigene für das Startdatum. ## Keine Schleifen, kein Quelle = Ziel Jeder aktive Workflow ist eine Kante „Quell-Stack → Ziel-Stack“. `LoopDetector` (rein, getestet) findet Kreise. „Quelle = Ziel“ ist ein Kreis der Länge 1. Das Auslöse-Datum wird bewusst ignoriert: Bei S1→S2 per Start und S2→S1 per Fälligkeit pendelt jede Karte, bei der beide Daten erreicht sind. Ohne Prüfung würde der Runner sie sogar im selben Lauf hin- und herschieben (jeder Workflow liest seinen Quell-Stack neu), mit einer Benachrichtigung pro Bewegung. Ketten sind erlaubt. - **Controller:** `validate()` → `assertNoLoop()` lehnt einen *aktiven* Workflow ab, dessen Ziel über die anderen aktiven Workflows des Boards zu seiner Quelle zurückführt, und nennt sie. Deaktivierte Workflows zählen nicht. Beim Aktivieren läuft dieselbe Prüfung, weil auch das über `update()` geht. - **Runner und Liste:** Altbestand kann trotzdem Kreise bilden. `run()` überspringt alle Workflows auf einem Kreis (nur Log, keine Deaktivierung, keine Mail), und `index()` liefert `loop: true`, das die Oberfläche rot markiert. - Stack-IDs sind in Deck boardübergreifend eindeutig, deshalb darf der Runner alle Workflows der Instanz in einen Graphen werfen. ## Fälligkeit wird als UTC-Zeitpunkt verglichen Decks `duedate` und `startdate` sind `DATETIME`-Spalten ohne Zeitzone mit UTC-Uhrzeit (Frontend sendet `toISOString()`). `Entity` baut daraus `new \DateTime($string)` in PHPs Standardzone, die Nextcloud auf UTC setzt. `WorkflowRunner::asStoredUtc()` legt die Uhrzeit trotzdem ausdrücklich auf UTC fest, damit das Ergebnis nicht von dieser Standardzone abhängt. Decks eigene Pfade `Card::getDaysUntilDue()` (tagesgenau) und `CardMapper::findOverdue()` (vergleicht mit dem `NOW()` der Datenbank, also Ortszeit) sind dafür ungeeignet und werden nicht verwendet. ## Kartenfilter wurden entfernt Die früheren Filter (`filter_user_ids`/`filter_label_ids`) wurden auf Wunsch entfernt. `Version1001Date20260930120000` löscht die Spalten, und gefilterte Workflows laufen seitdem *ungefiltert* weiter (bewusste Entscheidung). Die Spalten müssen gelöscht bleiben: `Entity::fromRow()` ruft pro Spalte einen Setter auf, den `Workflow` nicht mehr hat. Mit entfernt wurden: Labels-/Participants-Endpunkte, `listLabels()`/`listParticipants()`/`findFilterOptions()` und `CardService::enrichCards()` in `getActiveCardsInStack()` (lief über `BoardService::find()` mit eingefrorener uid). Die Karten dort sind also **nicht angereichert** (keine `assignedUsers`/Labels). ## Der Job hat kein Intervall `RunWorkflowsJob` erweitert `OCP\BackgroundJob\Job`, **nicht** `TimedJob`: ein Lauf pro Cron-Durchgang, kein Intervall, keine Konfiguration (der frühere `config.php`-Schlüssel `workflow_deck_automation.interval` ist seit v0.2.0 weg). Nicht wieder einführen. **Zeitkritikalität entscheidet, ob der Job überhaupt läuft.** `CronService::runCli()` holt außerhalb des Wartungsfensters (`maintenance_window_start` … +4 h UTC) nur zeitkritische Jobs (`getNext($onlyTimeSensitive = true)`). Eine frühere Version war `TIME_INSENSITIVE` und lief auf Instanzen mit Wartungsfenster nie (`last_run = 1970-01-01`). Manuelle Tests (`background-job:worker ''`, Ajax/Webcron) umgehen dieses Gate, deshalb wirkt dort alles normal. Als einfacher `Job` ist das ausgeschlossen: `JobList::add()` schreibt die Spalte `time_sensitive` nicht (Default `TIME_SENSITIVE`), und nur `JobList::setLastRun()` senkt sie – ausschließlich für `TimedJob && !isTimeSensitive()`, ohne Rückweg. Alte, einmal als insensitive gestempelte Zeilen bleiben es (auch nach App-Update). Einmalig reparieren: ```sql UPDATE oc_jobs SET time_sensitive = 0 WHERE class = 'OCA\\WorkflowDeckAutomation\\BackgroundJob\\RunWorkflowsJob'; ``` oder `occ background-job:delete `, dann `occ app:disable workflow_deck_automation && occ app:enable workflow_deck_automation`. Debuggen: `occ background-job:list --class ''` (ID, letzter Lauf), dann `occ background-job:execute --force-execute`. `background-job:worker` erwartet **Job-Klassen**, keine App-ID. ## Registrierung deklarativ statt über Bootstrap Hintergrundjob und persönliche Einstellungen stehen in `appinfo/info.xml` (``, `/`). `IRegistrationContext` hat dafür keine Methoden. `lib/AppInfo/Application.php` ist absichtlich fast leer und registriert nur den `Notifier` (dafür gibt es keinen `info.xml`-Eintrag). ## Architektur - **UI:** `src/PersonalSettings.vue` (Vue 3), gemountet aus `templates/settings/personal.php` (Sektion: `lib/Settings/PersonalSection.php` + `Personal.php`) → `src/api.js` → `lib/Controller/WorkflowController.php` (`OCSController`, Routen in `appinfo/routes.php`, `/ocs/v2.php/apps/workflow_deck_automation/api/v1/...`). - **Speicher:** `lib/Db/Workflow.php` / `WorkflowMapper.php`, Tabelle `wfda_workflows` (Migrationen in `lib/Migration/`). - **Automation:** `RunWorkflowsJob` → `WorkflowRunner` → `DeckIntegrationService` (alle Deck-Aufrufe), `NotificationMailer` (`IMailer`, an die Account-Adressen der Empfänger, nie an eine eingegebene Adresse) und `NotificationPusher` (`IManager`, Texte aus `lib/Notification/Notifier.php`). - Controller (Dropdowns) und Runner (Karten lesen/verschieben) nutzen denselben `DeckIntegrationService`. - **Doku:** `README.md` richtet sich an Anwender und Admins und enthält keine Klassennamen. `DEVELOPMENT.md` ist für Entwickler (Aufbau, Datenmodell, Tests, CI, Release, Fehlersuche). Neue Nutzer-Funktionen in der README beschreiben, technische Details in `DEVELOPMENT.md`. Beide Dateien sind per `Makefile` vom Paket ausgeschlossen. ## Befehle und Verifikation **Lokal gibt es kein PHP und kein npm** (Windows, siehe `CLAUDE.local.md`) – `composer`/`php`/`npm` nicht lokal ausführen. Verifiziert wird über Push/PR und den Gitea-Actions-Lauf (Runner `gitea-runner-server03`). ```bash composer install composer run lint # php -l über lib/ und tests/ composer run cs:check # nextcloud/coding-standard; cs:fix korrigiert composer run test:unit # PHPUnit, nur reine Logik vendor/bin/phpunit --filter tests/Unit/Service/WorkflowRunnerTest.php npm install # nicht `npm ci`: Lockfiles sind absichtlich nicht committet npm run build # bzw. npm run watch ``` `composer.lock` und `package-lock.json` stehen in `.gitignore` → CI muss `composer install`/`npm install` verwenden (`npm ci` hat den Build schon einmal gebrochen). Die Deck-Integration lässt sich nur auf einer echten Nextcloud+Deck-Instanz testen. ## Release und CI **Versionsschema:** `..`. Die erste Zahl entspricht `min-/max-version` in `info.xml` und ändert sich nur mit der unterstützten NC-Major (dann `minor.patch` = `0.0`). `0.x`-Versionen stammen aus der Zeit davor. Die 34er-Linie existiert nur noch als Tags (`v34.*`). Kein Branch. 1. `` in `appinfo/info.xml` und `version` in `package.json` synchron erhöhen. 2. Committen, nach `main` pushen, `build-main.yml` muss grün sein. 3. `git tag vX.Y.Z && git push origin vX.Y.Z` → Checks, Frontend-Build, `make appstore`, Release mit `workflow_deck_automation.tar.gz`. Ist ein Tag-Build fehlgeschlagen und **nie ein Artefakt entstanden**, den Tag auf den Fix verschieben statt die Version zu erhöhen (`git tag -d vX.Y.Z && git tag -a vX.Y.Z -m "..." && git push origin :refs/tags/vX.Y.Z && git push origin vX.Y.Z`). Der Job löscht ein vorhandenes Release vorher. Nach einem veröffentlichten Artefakt den Tag nicht mehr verschieben. **`.gitea/workflows/build-main.yml` ist der einzige Build-/Release-Workflow** (Checks `php-lint` mit PHP 8.4 und 8.5, `xml-lint`, `unit-tests` + Job `package`), damit `needs:` das Release an grüne Checks koppelt. Nicht wieder aufteilen. **`.gitea/workflows/appstore.yml` ist nur manuell (`workflow_dispatch`) und soll so bleiben.** Er baut nicht selbst, sondern signiert den Tarball eines vorhandenen `v*`-Release und meldet dessen Gitea-Download-URL beim App Store an (Secrets `APPSTORE_PRIVATE_KEY`, `APPSTORE_TOKEN`). Signiert wird genau die Datei hinter der URL. Nach dem Store-Upload darf der Tag also nicht mehr verschoben werden, sonst passt die Signatur nicht mehr. `latest-main` wird abgelehnt. Kein `appinfo/signature.json` (Code-Signing): Das braucht der Store nicht, und Nextcloud prüft es bei Nicht-Core-Apps nur, wenn die Datei existiert. - `package` baut *und* veröffentlicht in einem Job und braucht nur Node (`make appstore` ist reines `mkdir`/`tar`/`rm`). - Bei `pull_request` wird `Publish release` übersprungen (`if: gitea.event_name == 'push'`) und der Job ist nur ein Build-Check. - Push auf `main` → Pre-Release `latest-main`, Release *und* Tag werden jedes Mal neu erzeugt (Download-Link zeigt immer den neuesten Build). `v*`-Tag → normales Release. Dabei wird **nie der Tag** gelöscht (Flag `RECREATE_TAG`). - **Kein `actions/upload-artifact`:** Gitea gibt sich als GHES aus, `@actions/artifact` ≥ 2 verweigert dann (`GHESNotSupportedError`). Veröffentlicht wird per `curl` gegen die Gitea-Release-API. `checkout`/`setup-node` sind nicht betroffen. - **Kein PAT nötig:** Auth über das automatische `secrets.GITEA_TOKEN` mit `permissions: contents: write`. Bei 403: prüfen, ob die Token-Berechtigung des Repos/der Org auf *Restricted* steht (deckelt `permissions:`). Das ist eine Einstellung, kein Workflow-Bug. ## Frontend-Build-Fallen (alle erst in CI aufgefallen) - **`vite` muss zum Peer von `@nextcloud/vite-config` passen** (aktuell `vite@^7.3.6`), sonst `ERESOLVE`. Beim Update von `vite-config` dessen `peerDependencies.vite` prüfen. - **`"type": "module"` in `package.json`**, sonst scheitert `vite.config.js` am ESM-only-Paket. - **`tsconfig.json` und `typescript` als devDependency sind Pflicht**, obwohl es kein TypeScript gibt: `@nextcloud/vite-config` importiert über sein Barrel `vite-plugin-dts`, das `typescript` als Peer braucht. Ohne das Paket crasht es beim Laden (`Cannot read properties of undefined (reading 'useCaseSensitiveFileNames')`). `typescript` nahe an der Version von `vite-config` halten (aktuell `^5.9.3`). - **`createAppConfig` stellt die App-ID vor jeden Entry-Namen:** `personal-settings` → `js/workflow_deck_automation-personal-settings.mjs`. `Util::addScript()`/`addStyle()` in `lib/Settings/Personal.php` brauchen diesen vollen Namen (`Application::APP_ID . '-personal-settings'`). Falsch → **leere Einstellungsseite**, nur `Could not find resource …` im Log. Umbenennungen in `vite.config.js` auch dort nachziehen. - Die eigentliche CSS steckt in einem gehashten `*.chunk.css`. Die Entry-CSS ist ein ~100-Byte-Stub (`createEmptyCSSEntryPoints`). Das ist korrekt, also Code-Splitting nicht abschalten. ## `@nextcloud/vue` 9 (Vue 3) – v8-Idiome scheitern still - **Kein `.sync`:** `NcTextField`/`NcCheckboxRadioSwitch` → `v-model`. `:value.sync` lässt den Wert auf dem Startwert stehen. - **`NcSelect`: `label` ist der Anzeige-Schlüssel der Optionen** (vue-select), die Beschriftung ist `input-label`. `label="Board"` rendert überall `undefined`. - **`NcButton`: `variant` = Stil, `type` = nativer Button-Typ**, `native-type` gibt es nicht mehr. Ein übrig gebliebenes `type="primary"` wird zu `submit`, und Abbrechen schickt das Formular ab. - `NcSelect`s `reduce` erreicht vue-select nur per Attribut-Fallthrough (funktioniert, ist aber undokumentiert). - `NcNoteCard` nutzt weiterhin `type` (`success|info|warning|error`). ## Deck-Interna, die man kennen muss - **Relationale Entities:** Auf angereicherten Entities liefern `$acl->getParticipant()`, `$board->getOwner()` oder Assignment-Teilnehmer ein `RelationalObject` statt einer uid (uid = `getPrimaryKey()`). Neue Teilnehmer-/Owner-Felder immer über `DeckIntegrationService::unwrapUid()` laufen lassen. - Die ACL eines Boards enthält **nicht** den Owner (privates Board = leere ACL). ACL-Einträge können Gruppen (`type === 1`) oder Circles sein. - `StackMapper::findAll()` filtert `deleted_at = 0` selbst. Stacks haben keinen Archivstatus. - **Entity-Getter sind magisch** (`Entity::__call`, z. B. `getDeletedAt()`, `getArchived()`, `getStartdate()`). `method_exists()` ist für sie immer `false`. Vorhandensein per `property_exists()` prüfen (`DeckIntegrationService::isTrashed()`/`isArchived()`/`supportsStartdate()`). Mit `method_exists()` wurden früher Karten aus dem Papierkorb verschoben, und Papierkorb- wie Archiv-Boards blieben unerkannt. - Der Deck-Quellcode liegt lokal unter `C:\Users\pani\Documents\git-privat\nextcloud-deck` und dient zum Nachprüfen von Interna.