Compare commits

2 Commits
Author SHA1 Message Date
Patrick Niebeling 860e8b7bbb Leave the card description out of the moved-cards mail
Build package / xml-lint (push) Successful in 30s
Build package / unit-tests (push) Successful in 1m14s
Build package / php-lint (8.4) (push) Successful in 55s
Build package / php-lint (8.5) (push) Successful in 56s
Build package / package (push) Successful in 1m17s
2026-09-30 10:41:20 +02:00
Patrick Niebeling 1ffc0765ea Switch the app to German as its only language
The app is used internally only and its settings page was German
already. Mails, controller error messages (shown in the form), the
settings section name and the mail sender are now written in German
directly instead of going through IL10N without any translation files.
Nextcloud's own mail footer is requested in German as well.
2026-09-30 10:39:12 +02:00
6 changed files with 71 additions and 96 deletions

No files matched your search

+5 -1
View File
@@ -56,7 +56,11 @@ Wird ein Board in Deck übertragen, wandern seine Workflows mit.
- `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:<id>: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 `$pendingMails` und verschickt **nach** der Nutzer-Schleife (also ohne Impersonation) eine Sammelmail pro Empfänger und Lauf. Die Sprache kommt pro Empfänger aus `IFactory::getUserLanguage()`, weil ein injiziertes `IL10N` im Cron nur die Instanz-Standardsprache kennt.
- Der Runner sammelt verschobene Karten in `$pendingMails` und verschickt **nach** der Nutzer-Schleife (also ohne Impersonation) eine Sammelmail pro Empfänger und Lauf.
## Nur Deutsch, keine Übersetzungen
Die App wird nur intern genutzt und spricht ausschließlich Deutsch. Oberfläche, Controller-Fehlermeldungen (erscheinen im Formular), Sektionsname und Mails 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älligkeit wird als UTC-Zeitpunkt verglichen
+3 -1
View File
@@ -15,7 +15,9 @@ Pro Workflow lässt sich konfigurieren:
- **Nur an mich** – an den Board-Eigentümer, unabhängig von seinen Deck-Einstellungen
- **An Board-Mitglieder laut Deck-Einstellung** – an alle, die das Board sehen dürfen (Eigentümer, freigegebene Nutzer, Mitglieder freigegebener Gruppen und Teams), gefiltert nach deren eigener Deck-Einstellung *Benachrichtigungen* für dieses Board: *Alle Karten* → immer, *Zugewiesene Karten* (Deck-Standard) → nur wenn man der Karte direkt oder über eine Gruppe/ein Team zugewiesen ist, *Keine* → nie. Der Eigentümer eines nicht geteilten Boards bekommt wie in Deck jede Karte, außer er hat *Keine* gewählt.
Pro Lauf des Hintergrundjobs bekommt jeder Empfänger höchstens **eine** Sammelmail mit allen für ihn verschobenen Karten, in seiner eingestellten Sprache.
Pro Lauf des Hintergrundjobs bekommt jeder Empfänger höchstens **eine** Sammelmail mit allen für ihn verschobenen Karten.
Die App ist für den internen Einsatz gedacht und durchgehend **deutschsprachig** (Oberfläche, Fehlermeldungen, E-Mails). Es gibt keine Übersetzungsdateien.
Workflows lassen sich nur für Boards anlegen, deren Eigentümer man ist. Ein Workflow gehört dem jeweiligen Board-Eigentümer, nicht dem, der ihn angelegt hat: Wird ein Board in Deck übertragen, sieht und verwaltet ab sofort der neue Eigentümer seine Workflows, und der Hintergrundjob läuft in dessen Namen. Ein Nutzer kann beliebig viele Workflows anlegen, bearbeiten, deaktivieren oder löschen.
+2 -2
View File
@@ -11,7 +11,7 @@ Diese App erlaubt es jedem Nutzer, in den persönlichen Einstellungen Automatisi
* Quell-Board und Quell-Stapel wählen
* Ziel-Stapel wählen, in den überfällige Karten verschoben werden
* optional eine E-Mail-Benachrichtigung an sich selbst aktivieren
* optional per E-Mail benachrichtigen: nur sich selbst oder alle Board-Mitglieder gemäß ihrer Deck-Benachrichtigungseinstellung
Ein Hintergrundjob prüft regelmäßig alle aktiven Workflows und verschiebt Karten, deren Fälligkeitsdatum überschritten wurde, ausschließlich über die internen PHP-Klassen der Deck-App (keine HTTP/OCS-Aufrufe).
]]></description>
@@ -20,7 +20,7 @@ This app lets every user configure Deck automation workflows for the boards they
* pick a source board and source stack
* pick a destination stack overdue cards get moved to
* optionally enable an email notification to themselves
* optionally notify by email: themselves only, or every board member according to their Deck notification setting
A background job periodically evaluates all enabled workflows and moves cards whose due date has passed, using Deck's own internal PHP classes only (no HTTP/OCS calls).
]]></description>
+11 -11
View File
@@ -34,7 +34,7 @@ class WorkflowController extends OCSController {
private function currentUserId(): string {
$user = $this->userSession->getUser();
if ($user === null) {
throw new OCSPreconditionFailedException('Not logged in');
throw new OCSPreconditionFailedException('Nicht angemeldet');
}
return $user->getUID();
}
@@ -114,12 +114,12 @@ class WorkflowController extends OCSController {
try {
$workflow = $this->workflowMapper->findById($id);
} catch (DoesNotExistException $e) {
throw new OCSNotFoundException('Workflow not found');
throw new OCSNotFoundException('Workflow nicht gefunden');
}
if (!in_array($workflow->getBoardId(), $this->ownedBoardIds(), true)) {
// Same answer as for a missing id: don't reveal other users' workflows.
throw new OCSNotFoundException('Workflow not found');
throw new OCSNotFoundException('Workflow nicht gefunden');
}
return $workflow;
}
@@ -133,7 +133,7 @@ class WorkflowController extends OCSController {
try {
return $this->deckService->listOwnedBoards($userId);
} catch (DeckUnavailableException $e) {
throw new OCSPreconditionFailedException('Could not load your Deck boards: ' . $e->getMessage());
throw new OCSPreconditionFailedException('Deine Deck-Boards konnten nicht geladen werden: ' . $e->getMessage());
}
}
@@ -147,24 +147,24 @@ class WorkflowController extends OCSController {
private function assertDeck(): void {
$user = $this->userSession->getUser();
if ($user === null) {
throw new OCSPreconditionFailedException('Not logged in');
throw new OCSPreconditionFailedException('Nicht angemeldet');
}
try {
$this->deckService->assertDeckAvailable($user);
} catch (DeckUnavailableException $e) {
throw new OCSPreconditionFailedException('Deck is not available: ' . $e->getMessage());
throw new OCSPreconditionFailedException('Deck ist nicht verfügbar: ' . $e->getMessage());
}
}
private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId, string $notifyMode): void {
if (trim($title) === '') {
throw new OCSBadRequestException('Title must not be empty');
throw new OCSBadRequestException('Bitte einen Titel angeben.');
}
if ($sourceStackId === $targetStackId) {
throw new OCSBadRequestException('Source and target stack must differ');
throw new OCSBadRequestException('Quell- und Ziel-Stapel müssen sich unterscheiden.');
}
if (!NotificationPolicy::isValidMode($notifyMode)) {
throw new OCSBadRequestException('Notification mode must be one of: ' . implode(', ', NotificationPolicy::MODES));
throw new OCSBadRequestException('Ungültige E-Mail-Einstellung, erlaubt sind: ' . implode(', ', NotificationPolicy::MODES));
}
// Reject references to boards or stacks that are gone (or were never
@@ -184,7 +184,7 @@ class WorkflowController extends OCSController {
}
}
if ($board === null || $board['archived']) {
throw new OCSBadRequestException('The selected board does not exist, is archived or is not owned by you');
throw new OCSBadRequestException('Das gewählte Board existiert nicht, ist archiviert oder gehört dir nicht.');
}
$stackIds = array_map(
@@ -192,7 +192,7 @@ class WorkflowController extends OCSController {
$this->deckService->listStacks($boardId),
);
if (!in_array($sourceStackId, $stackIds, true) || !in_array($targetStackId, $stackIds, true)) {
throw new OCSBadRequestException('The selected stack does not exist on this board');
throw new OCSBadRequestException('Der gewählte Stapel existiert auf diesem Board nicht.');
}
}
+48 -78
View File
@@ -6,29 +6,34 @@ namespace OCA\WorkflowDeckAutomation\Service;
use OCA\Deck\Db\Card;
use OCA\WorkflowDeckAutomation\Db\Workflow;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\L10N\IFactory;
use OCP\Mail\IEMailTemplate;
use OCP\Mail\IMailer;
use OCP\Util;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* The app is used internally only and speaks German, like its settings
* page: texts are written in German directly instead of going through
* IL10N with English source strings and a translation file.
*/
class NotificationMailer {
private const APP_ID = 'workflow_deck_automation';
private const SENDER_NAME = 'Deck Workflow-Automatisierung';
/**
* Descriptions are unbounded in Deck; keep the mail readable and well
* clear of any MTA size limits.
* Language of Nextcloud's own footer text ("automatically sent email,
* please do not reply"), which would otherwise follow the instance
* default — in cron there is no request to take a language from.
*/
private const MAX_DESCRIPTION_LENGTH = 2000;
private const FOOTER_LANGUAGE = 'de';
public function __construct(
private IMailer $mailer,
private IURLGenerator $urlGenerator,
private IFactory $l10nFactory,
private LoggerInterface $logger,
) {
}
@@ -50,7 +55,6 @@ class NotificationMailer {
}
$count = count($entries);
$l10n = $this->l10nFor($recipient);
try {
$template = $this->mailer->createEMailTemplate(self::APP_ID . '.CardsMoved', [
@@ -58,20 +62,22 @@ class NotificationMailer {
'cardTitles' => array_map(static fn (array $entry) => $entry['card']->getTitle(), $entries),
]);
$template->setSubject($count === 1
? $l10n->t('Deck card moved: %s', [$entries[0]['card']->getTitle()])
: $l10n->n('%n Deck card moved', '%n Deck cards moved', $count));
? sprintf('Deck-Karte verschoben: %s', $entries[0]['card']->getTitle())
: sprintf('%d Deck-Karten verschoben', $count));
$template->addHeader();
$template->addHeading($l10n->n('A card was moved automatically', '%n cards were moved automatically', $count), false);
$template->addHeading($count === 1
? 'Eine Karte wurde automatisch verschoben'
: sprintf('%d Karten wurden automatisch verschoben', $count), false);
foreach ($entries as $entry) {
$this->addCard($template, $l10n, $entry['workflow'], $entry['card'], $entry['targets']);
$this->addCard($template, $entry['workflow'], $entry['card'], $entry['targets']);
}
$template->addFooter();
$template->addFooter('', self::FOOTER_LANGUAGE);
$message = $this->mailer->createMessage();
$message->setTo([$email => $recipient->getDisplayName()]);
$message->setFrom([Util::getDefaultEmailAddress('noreply') => $l10n->t('Deck Workflow Automation')]);
$message->setFrom([Util::getDefaultEmailAddress('noreply') => self::SENDER_NAME]);
$message->useTemplate($template);
$failedRecipients = $this->mailer->send($message);
@@ -91,21 +97,23 @@ class NotificationMailer {
/**
* @param array{board: string, sourceStack: string, targetStack: string} $targets
*/
private function addCard(IEMailTemplate $template, IL10N $l10n, Workflow $workflow, Card $card, array $targets): void {
$template->addBodyText($l10n->t(
'The card "%1$s" was moved because it is overdue (workflow "%2$s").',
[$card->getTitle(), $workflow->getTitle()],
));
private function addCard(IEMailTemplate $template, Workflow $workflow, Card $card, array $targets): void {
// Single-argument addBodyText() runs htmlspecialchars() itself, so
// these board- and stack-titles (user input) are safe as-is —
// unlike the description below, which passes both parts.
$template->addBodyText($l10n->t(
'Board "%1$s": moved from "%2$s" to "%3$s".',
[$targets['board'], $targets['sourceStack'], $targets['targetStack']],
// these card-, workflow-, board- and stack-titles (user input) are
// safe as-is. The card description is deliberately left out.
$template->addBodyText(sprintf(
'Die Karte „%1$s“ wurde verschoben, weil sie überfällig ist (Workflow „%2$s“).',
$card->getTitle(),
$workflow->getTitle(),
));
$template->addBodyText(sprintf(
'Board „%1$s“: von „%2$s“ nach „%3$s“ verschoben.',
$targets['board'],
$targets['sourceStack'],
$targets['targetStack'],
));
$this->addCardDescription($template, $l10n, $card);
$template->addBodyButton(
$l10n->t('Open card'),
'Karte öffnen',
$this->urlGenerator->getAbsoluteURL('/apps/deck/board/' . $workflow->getBoardId() . '/card/' . $card->getId()),
);
}
@@ -125,11 +133,10 @@ class NotificationMailer {
return;
}
$l10n = $this->l10nFor($user);
$reason = match ($brokenTarget) {
WorkflowRunner::TARGET_SOURCE_STACK => $l10n->t('its source stack no longer exists'),
WorkflowRunner::TARGET_TARGET_STACK => $l10n->t('its target stack no longer exists'),
default => $l10n->t('its board no longer exists or is no longer available to you'),
WorkflowRunner::TARGET_SOURCE_STACK => 'sein Quell-Stapel nicht mehr existiert',
WorkflowRunner::TARGET_TARGET_STACK => 'sein Ziel-Stapel nicht mehr existiert',
default => 'sein Board nicht mehr existiert oder für dich nicht mehr verfügbar ist',
};
try {
@@ -138,22 +145,23 @@ class NotificationMailer {
$template = $this->mailer->createEMailTemplate(self::APP_ID . '.WorkflowDisabled', [
'workflowTitle' => $workflow->getTitle(),
]);
$template->setSubject($l10n->t('Deck workflow deactivated: %s', [$workflow->getTitle()]));
$template->setSubject(sprintf('Deck-Workflow deaktiviert: %s', $workflow->getTitle()));
$template->addHeader();
$template->addHeading($l10n->t('A workflow was deactivated'), false);
$template->addBodyText($l10n->t(
'The workflow "%1$s" was switched off automatically because %2$s. No cards are being moved by it any more.',
[$workflow->getTitle(), $reason],
$template->addHeading('Ein Workflow wurde deaktiviert', false);
$template->addBodyText(sprintf(
'Der Workflow „%1$s“ wurde automatisch ausgeschaltet, weil %2$s. Er verschiebt keine Karten mehr.',
$workflow->getTitle(),
$reason,
));
$template->addBodyText($l10n->t(
'Delete the workflow or point it at an existing board and stack to reactivate it. You will not be reminded about this workflow again.',
));
$template->addBodyButton($l10n->t('Open settings'), $settingsLink);
$template->addFooter();
$template->addBodyText(
'Lösche den Workflow oder stelle ein vorhandenes Board und vorhandene Stapel ein, um ihn wieder zu aktivieren. An diesen Workflow wirst du nicht noch einmal erinnert.',
);
$template->addBodyButton('Einstellungen öffnen', $settingsLink);
$template->addFooter('', self::FOOTER_LANGUAGE);
$message = $this->mailer->createMessage();
$message->setTo([$email => $user->getDisplayName()]);
$message->setFrom([Util::getDefaultEmailAddress('noreply') => $l10n->t('Deck Workflow Automation')]);
$message->setFrom([Util::getDefaultEmailAddress('noreply') => self::SENDER_NAME]);
$message->useTemplate($template);
$failedRecipients = $this->mailer->send($message);
@@ -169,42 +177,4 @@ class NotificationMailer {
]);
}
}
/**
* The recipient's own language. Cron has no request to take one from, so
* an injected IL10N would always speak the instance default — wrong as
* soon as a mail goes to someone other than whoever set that default.
*/
private function l10nFor(IUser $user): IL10N {
return $this->l10nFactory->get(self::APP_ID, $this->l10nFactory->getUserLanguage($user));
}
/**
* Appends the card's description, if it has one.
*
* Deck stores the description as Markdown. It is sent as-is rather than
* rendered: pulling a Markdown parser in just for the mail would be a
* lot of machinery, and unrendered Markdown still reads fine.
*/
private function addCardDescription(IEMailTemplate $template, IL10N $l10n, Card $card): void {
$description = trim((string)$card->getDescription());
if ($description === '') {
return;
}
if (mb_strlen($description) > self::MAX_DESCRIPTION_LENGTH) {
$description = mb_substr($description, 0, self::MAX_DESCRIPTION_LENGTH) . ' […]';
}
$template->addBodyText($l10n->t('Card content:'));
// addBodyText() only runs htmlspecialchars() when it has to build the
// plain-text part itself. Passing both parts means escaping is on us
// -- and it has to be, because a card description is user input that
// would otherwise land unescaped in the HTML mail.
$template->addBodyText(
nl2br(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), false),
$description,
);
}
}
+2 -3
View File
@@ -5,13 +5,11 @@ declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Settings;
use OCA\WorkflowDeckAutomation\AppInfo\Application;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Settings\IIconSection;
class PersonalSection implements IIconSection {
public function __construct(
private IL10N $l10n,
private IURLGenerator $urlGenerator,
) {
}
@@ -21,7 +19,8 @@ class PersonalSection implements IIconSection {
}
public function getName(): string {
return $this->l10n->t('Deck workflow automation');
// German only, like the rest of this internal app.
return 'Deck Workflow-Automatisierung';
}
public function getPriority(): int {