feat: add Planka service provider with full notification and command support
Webhook-based provider for Planka (self-hosted Kanban board) with: - 15 event types (cards, boards, lists, comments, tasks, attachments, labels) - Bearer token webhook authentication - Async API client for boards/cards/lists - 30 notification templates (en/ru) + 26 command templates (en/ru) - Bot commands: /status, /boards, /cards, /lists - Default tracking config, template config, command config seeded on startup - DB migration for 15 new tracking_config columns - Frontend: provider config UI with auto-name, Planka-specific hints - Frontend: tracking config event toggles for all 15 Planka events
This commit is contained in:
@@ -101,6 +101,7 @@ export const providerTypeFilterItems = (): GridItem[] => [
|
||||
{ value: '', icon: 'mdiFilterOff', label: t('common.allTypes'), desc: t('gridDesc.allEvents') },
|
||||
{ value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich'), desc: t('gridDesc.providerImmich') },
|
||||
{ value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') },
|
||||
{ value: 'planka', icon: 'mdiViewDashboard', label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') },
|
||||
{ value: 'scheduler', icon: 'mdiClockOutline', label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') },
|
||||
];
|
||||
|
||||
@@ -109,5 +110,6 @@ export const providerTypeFilterItems = (): GridItem[] => [
|
||||
export const providerTypeItems = (): GridItem[] => [
|
||||
{ value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich'), desc: t('gridDesc.providerImmich') },
|
||||
{ value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') },
|
||||
{ value: 'planka', icon: 'mdiViewDashboard', label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') },
|
||||
{ value: 'scheduler', icon: 'mdiClockOutline', label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') },
|
||||
];
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"checking": "Checking...",
|
||||
"typeImmich": "Immich",
|
||||
"typeGitea": "Gitea",
|
||||
"typePlanka": "Planka",
|
||||
"typeScheduler": "Scheduler",
|
||||
"loadError": "Failed to load providers.",
|
||||
"externalDomain": "External Domain",
|
||||
@@ -116,6 +117,9 @@
|
||||
"webhookSecret": "Webhook Secret",
|
||||
"webhookSecretKeep": "Webhook Secret (leave empty to keep current)",
|
||||
"webhookSecretHint": "Shared secret for HMAC-SHA256 signature verification. Set the same secret in Gitea webhook settings.",
|
||||
"plankaWebhookSecretHint": "Bearer token for webhook authentication. Set the same token as WEBHOOK_ACCESS_TOKEN in Planka.",
|
||||
"plankaApiKeyHint": "Optional. Needed for connection testing and board listing.",
|
||||
"plankaWebhookUrlHint": "Set this as the Webhook URL in Planka environment config (relative to your bridge host).",
|
||||
"webhookSecretRequired": "Webhook secret is required",
|
||||
"apiToken": "API Token",
|
||||
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
|
||||
@@ -378,6 +382,21 @@
|
||||
"prMerged": "PR merged",
|
||||
"prCommented": "PR commented",
|
||||
"releasePublished": "Release published",
|
||||
"cardCreated": "Card created",
|
||||
"cardUpdated": "Card updated",
|
||||
"cardMoved": "Card moved",
|
||||
"cardDeleted": "Card deleted",
|
||||
"cardCommented": "Card commented",
|
||||
"commentUpdated": "Comment updated",
|
||||
"boardCreated": "Board created",
|
||||
"boardUpdated": "Board updated",
|
||||
"boardDeleted": "Board deleted",
|
||||
"listCreated": "List created",
|
||||
"listUpdated": "List updated",
|
||||
"listDeleted": "List deleted",
|
||||
"attachmentCreated": "Attachment added",
|
||||
"cardLabelAdded": "Label added",
|
||||
"taskCompleted": "Task completed",
|
||||
"scheduledMessage": "Scheduled message",
|
||||
"trackImages": "Track images",
|
||||
"trackVideos": "Track videos",
|
||||
@@ -795,6 +814,7 @@
|
||||
"previewWebhook": "Preview as plain text",
|
||||
"providerImmich": "Self-hosted photo server",
|
||||
"providerGitea": "Self-hosted Git service",
|
||||
"providerPlanka": "Self-hosted Kanban board",
|
||||
"providerScheduler": "Time-based scheduled messages"
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"checking": "Проверка...",
|
||||
"typeImmich": "Immich",
|
||||
"typeGitea": "Gitea",
|
||||
"typePlanka": "Planka",
|
||||
"typeScheduler": "Планировщик",
|
||||
"loadError": "Не удалось загрузить провайдеры.",
|
||||
"externalDomain": "Внешний домен",
|
||||
@@ -116,6 +117,9 @@
|
||||
"webhookSecret": "Секрет вебхука",
|
||||
"webhookSecretKeep": "Секрет вебхука (оставьте пустым для сохранения текущего)",
|
||||
"webhookSecretHint": "Общий секрет для проверки HMAC-SHA256 подписи. Укажите тот же секрет в настройках вебхука Gitea.",
|
||||
"plankaWebhookSecretHint": "Bearer-токен для аутентификации вебхуков. Укажите тот же токен как WEBHOOK_ACCESS_TOKEN в Planka.",
|
||||
"plankaApiKeyHint": "Необязательно. Нужен для проверки подключения и получения списка досок.",
|
||||
"plankaWebhookUrlHint": "Укажите этот URL в конфигурации Planka (относительно хоста bridge).",
|
||||
"webhookSecretRequired": "Секрет вебхука обязателен",
|
||||
"apiToken": "API токен",
|
||||
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
|
||||
@@ -378,6 +382,21 @@
|
||||
"prMerged": "PR влит",
|
||||
"prCommented": "Комментарий к PR",
|
||||
"releasePublished": "Релиз опубликован",
|
||||
"cardCreated": "Карточка создана",
|
||||
"cardUpdated": "Карточка обновлена",
|
||||
"cardMoved": "Карточка перемещена",
|
||||
"cardDeleted": "Карточка удалена",
|
||||
"cardCommented": "Комментарий к карточке",
|
||||
"commentUpdated": "Комментарий обновлён",
|
||||
"boardCreated": "Доска создана",
|
||||
"boardUpdated": "Доска обновлена",
|
||||
"boardDeleted": "Доска удалена",
|
||||
"listCreated": "Список создан",
|
||||
"listUpdated": "Список обновлён",
|
||||
"listDeleted": "Список удалён",
|
||||
"attachmentCreated": "Вложение добавлено",
|
||||
"cardLabelAdded": "Метка добавлена",
|
||||
"taskCompleted": "Задача завершена",
|
||||
"scheduledMessage": "Запланированное сообщение",
|
||||
"trackImages": "Фото",
|
||||
"trackVideos": "Видео",
|
||||
@@ -795,6 +814,7 @@
|
||||
"previewWebhook": "Предпросмотр как текст",
|
||||
"providerImmich": "Фотосервер для самостоятельного размещения",
|
||||
"providerGitea": "Git-сервер для самостоятельного размещения",
|
||||
"providerPlanka": "Канбан-доска для самостоятельного размещения",
|
||||
"providerScheduler": "Запланированные сообщения по расписанию"
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -26,12 +26,25 @@
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' });
|
||||
let nameManuallyEdited = $state(false);
|
||||
let error = $state('');
|
||||
let loadError = $state('');
|
||||
let submitting = $state(false);
|
||||
let loaded = $state(false);
|
||||
let confirmDelete = $state<ServiceProvider | null>(null);
|
||||
|
||||
const providerDefaultNames: Record<string, string> = {
|
||||
immich: 'Immich', gitea: 'Gitea', planka: 'Planka', scheduler: 'Scheduler',
|
||||
};
|
||||
|
||||
// Auto-update name when provider type changes (unless user manually edited)
|
||||
$effect(() => {
|
||||
const type = form.type;
|
||||
if (!nameManuallyEdited && !editing) {
|
||||
form.name = providerDefaultNames[type] || type;
|
||||
}
|
||||
});
|
||||
|
||||
let health = $state<Record<number, boolean | null>>({});
|
||||
|
||||
onMount(load);
|
||||
@@ -53,6 +66,7 @@
|
||||
|
||||
function openNew() {
|
||||
form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' };
|
||||
nameManuallyEdited = false;
|
||||
editing = null; showForm = true;
|
||||
}
|
||||
function edit(p: any) {
|
||||
@@ -62,6 +76,7 @@
|
||||
api_key: '', api_token: '', webhook_secret: '',
|
||||
external_domain: cfg.external_domain || '', icon: p.icon || '',
|
||||
};
|
||||
nameManuallyEdited = true;
|
||||
editing = p.id; showForm = true;
|
||||
}
|
||||
|
||||
@@ -80,6 +95,13 @@
|
||||
error = t('providers.webhookSecretRequired');
|
||||
snackError(error); submitting = false; return;
|
||||
}
|
||||
} else if (form.type === 'planka') {
|
||||
if (form.api_key) config.api_key = form.api_key;
|
||||
if (form.webhook_secret) config.webhook_secret = form.webhook_secret;
|
||||
if (!editing && !form.webhook_secret) {
|
||||
error = t('providers.webhookSecretRequired');
|
||||
snackError(error); submitting = false; return;
|
||||
}
|
||||
}
|
||||
if (editing) {
|
||||
await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
|
||||
@@ -141,13 +163,13 @@
|
||||
<label for="prv-name" class="block text-sm font-medium mb-1">{t('providers.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="prv-name" bind:value={form.name} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<input id="prv-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
{#if form.type !== 'scheduler'}
|
||||
<div>
|
||||
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
|
||||
<input id="prv-url" bind:value={form.url} required placeholder={form.type === 'gitea' ? 'https://gitea.example.com' : t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<input id="prv-url" bind:value={form.url} required placeholder={form.type === 'gitea' ? 'https://gitea.example.com' : form.type === 'planka' ? 'https://planka.example.com' : t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if form.type === 'immich'}
|
||||
@@ -177,6 +199,24 @@
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if form.type === 'planka'}
|
||||
<div>
|
||||
<label for="prv-secret" class="block text-sm font-medium mb-1">{editing ? t('providers.webhookSecretKeep') : t('providers.webhookSecret')}</label>
|
||||
<input id="prv-secret" bind:value={form.webhook_secret} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.plankaWebhookSecretHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="prv-key" class="block text-sm font-medium mb-1">{t('providers.apiKey')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||
<input id="prv-key" bind:value={form.api_key} type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.plankaApiKeyHint')}</p>
|
||||
</div>
|
||||
{#if editing}
|
||||
<div class="bg-[var(--color-muted)] rounded-md p-3">
|
||||
<label class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</label>
|
||||
<code class="text-xs select-all break-all">/api/webhooks/planka/{editing}</code>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.plankaWebhookUrlHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<button type="submit" disabled={submitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
@@ -220,6 +260,8 @@
|
||||
{/if}
|
||||
{#if provider.type === 'gitea'}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">/api/webhooks/gitea/{provider.id}</span></p>
|
||||
{:else if provider.type === 'planka'}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">/api/webhooks/planka/{provider.id}</span></p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,12 @@
|
||||
track_push: true, track_issue_opened: true, track_issue_closed: true, track_issue_commented: false,
|
||||
track_pr_opened: true, track_pr_closed: true, track_pr_merged: true, track_pr_commented: false,
|
||||
track_release_published: true,
|
||||
// Planka event tracking
|
||||
track_card_created: true, track_card_updated: false, track_card_moved: true, track_card_deleted: false,
|
||||
track_card_commented: true, track_comment_updated: false,
|
||||
track_board_created: true, track_board_updated: false, track_board_deleted: true,
|
||||
track_list_created: false, track_list_updated: false, track_list_deleted: false,
|
||||
track_attachment_created: true, track_card_label_added: false, track_task_completed: true,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
@@ -141,6 +147,24 @@
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_commented} /> {t('trackingConfig.prCommented')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_release_published} /> {t('trackingConfig.releasePublished')}</label>
|
||||
</div>
|
||||
{:else if form.provider_type === 'planka'}
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_card_created} /> {t('trackingConfig.cardCreated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_card_updated} /> {t('trackingConfig.cardUpdated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_card_moved} /> {t('trackingConfig.cardMoved')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_card_deleted} /> {t('trackingConfig.cardDeleted')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_card_commented} /> {t('trackingConfig.cardCommented')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_comment_updated} /> {t('trackingConfig.commentUpdated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_board_created} /> {t('trackingConfig.boardCreated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_board_updated} /> {t('trackingConfig.boardUpdated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_board_deleted} /> {t('trackingConfig.boardDeleted')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_list_created} /> {t('trackingConfig.listCreated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_list_updated} /> {t('trackingConfig.listUpdated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_list_deleted} /> {t('trackingConfig.listDeleted')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_attachment_created} /> {t('trackingConfig.attachmentCreated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_card_label_added} /> {t('trackingConfig.cardLabelAdded')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_task_completed} /> {t('trackingConfig.taskCompleted')}</label>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_added} /> {t('trackingConfig.assetsAdded')}</label>
|
||||
|
||||
Reference in New Issue
Block a user