feat(notifications): let a plugin register a notification channel

TREK's four channels (in-app, email, webhook, ntfy) were a closed set:
notificationService.send() dispatched with four copy-pasted `if` blocks and no
provider abstraction, so a fifth channel meant editing eight files by hand. A
plugin could produce a notification via ctx.notify.send(), but never deliver
one.

A plugin now registers a channel with `hooks.notificationChannel` +
`hook:notification-channel` on a plain `type: 'integration'` — not a new manifest
type, so the TREK-Plugins registry schema and both its CI gates are untouched.

Core refactor
- New channel registry (services/notifications/): email/webhook/ntfy become
  ExternalChannel providers wrapping the EXISTING send functions — no delivery
  logic is rewritten, only relocated. In-app deliberately stays out: it writes
  typed rows with scope/target/callbacks, not a rendered title+body, the same
  line shared/ already draws with i18n/externalNotifications.
- The event text is now rendered once per recipient instead of once per channel.
- The channel set is open: NotifChannel becomes a string, the matrix is
  registry-derived, and the UI columns are server-driven. The DB column was
  already bare TEXT and the Zod contract already a string record — only the
  TypeScript and the two UIs were ever closed.

The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls
out of the request; a notification is host-initiated for an ARBITRARY recipient,
so ctx.settings.get() would return undefined. The host resolves the recipient's
decrypted scope:'user' settings itself and passes them as an argument. That is
what lets a channel plugin be handed someone's push token WITHOUT being handed
the right to read their trips as them.

Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's
`notification_channels` list. A built-in always exists in code and needs an
explicit switch; a plugin channel only exists because an admin enabled that
plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin
toggle rebuilt it from three booleans, silently dropping anything else — so
requiring a second opt-in meant the channel could never be turned on at all.)

Also fixed, found while building this:
- Plugin settings keys were unvalidated, so a field named `__proto__` or
  `constructor` resolved off Object.prototype: a REQUIRED field with such a name
  reported as configured for every user who had configured nothing — enough, for
  a channel, to be dispatched to everyone with no credentials. Keys are now
  constrained at install and the config blob is parsed null-prototype, so it is
  impossible even for an already-installed plugin.
- A `select` field's options were cast straight through, so the obvious
  `["1","5"]` form rendered every dropdown entry BLANK (the client reads
  value/label). Now coerced, and malformed options are rejected.

Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted
service can't name the operator's host at publish time, so an admin adds it
post-install and the runtime re-spawns the child with the widened allow-list —
only for a plugin that DECLARED operatorEgress, and only an admin, never a user);
settings-page actions (a "Test connection" button, user-initiated so
ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped
notification-channel template in the SDK.

Verified end to end against a real Gotify container, not just in tests.
This commit is contained in:
jubnl
2026-07-11 19:26:53 +02:00
parent 2899465dac
commit c9627af239
95 changed files with 4757 additions and 830 deletions
+26 -1
View File
@@ -508,6 +508,13 @@ export const adminApi = {
// Dev-link (dev-only): register a plugin from a local built dir + hot-reload it.
pluginLink: (path: string) => apiClient.post('/admin/plugins/link', { path }).then(r => r.data),
pluginReload: (id: string) => apiClient.post(`/admin/plugins/${id}/reload`).then(r => r.data),
// Operator-supplied egress hosts: a plugin talking to a SELF-HOSTED service can't name
// the operator's hostname in its manifest, so the admin adds it here. Saving re-spawns
// the plugin with the widened allow-list.
pluginEgressHosts: (id: string): Promise<{ supported: boolean; hosts: string[] }> =>
apiClient.get(`/admin/plugins/${id}/egress-hosts`).then(r => r.data),
pluginSetEgressHosts: (id: string, hosts: string[]): Promise<{ hosts: string[] }> =>
apiClient.put(`/admin/plugins/${id}/egress-hosts`, { hosts }).then(r => r.data),
pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data),
pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data),
// Local LLM (Ollama) management for the AI-parsing addon.
@@ -632,6 +639,11 @@ export interface PluginUserSettingField {
options?: Array<{ value: string; label: string }>
}
/** A button a plugin contributes to its own settings page ("Test connection"). */
export interface PluginAction {
key: string; label: string; hint?: string; danger: boolean
}
export const pluginsApi = {
// Active plugins the client renders (page nav entries, dashboard widgets).
active: () => apiClient.get('/plugins').then(r => r.data),
@@ -675,7 +687,16 @@ export const pluginsApi = {
// A user's OWN scope:'user' settings for a plugin (API key, prefs). Secrets are
// masked; the write only accepts declared user-scope keys.
userSettings: (id: string) =>
apiClient.get(`/plugin-settings/${id}`).then(r => r.data as { fields: PluginUserSettingField[]; config: Record<string, unknown> }),
apiClient.get(`/plugin-settings/${id}`).then(r => r.data as {
fields: PluginUserSettingField[]
config: Record<string, unknown>
actions: PluginAction[]
}),
// Run a settings-page action the plugin declared ("Test connection"). It runs AS the
// caller, so it reads the caller's own settings.
runAction: (id: string, key: string) =>
apiClient.post(`/plugin-settings/${id}/actions/${encodeURIComponent(key)}`)
.then(r => r.data as { ok: boolean; message?: string }),
saveUserSettings: (id: string, config: Record<string, unknown>) =>
apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record<string, unknown> }),
// Host-brokered outbound OAuth (the host owns the tokens; the plugin only triggers).
@@ -986,6 +1007,10 @@ export const notificationsApi = {
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
// Generic channel test — this is how a PLUGIN channel's "Send test" button works.
testChannel: (channelId: string) =>
apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`)
.then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testChannel')),
}
export const inAppNotificationsApi = {
@@ -0,0 +1,121 @@
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it } from 'vitest'
import { server } from '../../../tests/helpers/msw/server'
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
import AdminPluginsPanel from './AdminPluginsPanel'
/**
* The "allowed hosts" chip. A plugin that talks to a SELF-HOSTED service (a Gotify) can't
* name the operator's host in its manifest, so the admin adds it — but they'd never know
* that unless the card says so. Until a host exists the plugin can reach NOTHING and looks
* silently broken, which is why the chip is warning-toned and actionable in that state.
*/
function plugin(over: Record<string, unknown> = {}) {
return {
id: 'trek-gotify', name: 'Gotify', description: 'Push notifications', type: 'integration',
icon: 'Bell', version: '1.0.0', status: 'active', enabled: 1,
last_error: null, reviewed_at: null, source_repo: null,
permissions: JSON.stringify(['hook:notification-channel', 'http:outbound:gotify.net']),
capabilities: '{}',
operatorEgress: true,
egressHostCount: 0,
dependencyStatus: 'ok',
dependencyIssues: { disabledAddons: [], missing: [], versionMismatch: [] },
...over,
}
}
function mockList(p: Record<string, unknown>) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [p] })),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ plugins: [] })),
)
}
beforeEach(() => resetAllStores())
describe('AdminPluginsPanel — allowed-hosts chip', () => {
it('FE-COMP-PLUGINS-EGRESS-001: invites the admin to add a host when none is set', async () => {
mockList(plugin({ egressHostCount: 0 }))
render(<AdminPluginsPanel />)
// The plugin can't reach anything yet — the card must say so, not stay silent.
expect(await screen.findByRole('button', { name: /add allowed host/i })).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-EGRESS-002: shows the count once hosts exist', async () => {
mockList(plugin({ egressHostCount: 2 }))
render(<AdminPluginsPanel />)
expect(await screen.findByRole('button', { name: /2 allowed host/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /add allowed host/i })).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-EGRESS-003: a plugin that never declared operatorEgress gets NO chip', async () => {
mockList(plugin({ operatorEgress: false }))
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
// An admin must never be invited to widen egress for a plugin that didn't ask for it.
expect(screen.queryByRole('button', { name: /allowed host/i })).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-EGRESS-004: clicking the chip opens the allowed-hosts dialog', async () => {
mockList(plugin({ egressHostCount: 1 }))
server.use(
http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
HttpResponse.json({ supported: true, hosts: ['gotify.mydomain.com'] })),
)
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByRole('button', { name: /1 allowed host/i }))
await waitFor(() => expect(screen.getByText('gotify.mydomain.com')).toBeInTheDocument())
})
})
/**
* The Discover (pre-install) modal. Its "Connects to" list is what a reviewer reads to
* judge a plugin's network reach — so for an operatorEgress plugin that list is NOT the
* whole story, and saying nothing would actively mislead them.
*/
function mockDetail(manifest: Record<string, unknown> | null) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
// pluginBrowse returns the ARRAY itself, not { plugins: [...] }.
http.get('*/api/admin/plugins/registry', () =>
HttpResponse.json([{ id: 'trek-gotify', name: 'Gotify', author: 'jubnl', description: 'Push', repo: 'jubnl/trek-gotify', type: 'integration', tags: [] }])),
http.get('*/api/admin/plugins/registry/trek-gotify', () =>
HttpResponse.json({
id: 'trek-gotify', name: 'Gotify', author: 'jubnl', description: 'Push', repo: 'jubnl/trek-gotify',
type: 'integration', tags: [], size: 1024, publishedAt: null, latest: '1.0.0', manifest,
})),
)
}
describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => {
const base = { permissions: ['hook:notification-channel', 'http:outbound:gotify.net'], egress: ['gotify.net'], settings: [], license: 'MIT', icon: null }
/** The panel opens on Installed — switch to Discover, then open the plugin's card. */
async function openDetail() {
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByRole('button', { name: /discover/i }))
fireEvent.click(await screen.findByText('Gotify'))
}
it('FE-COMP-PLUGINS-EGRESS-005: warns that the host list is not the whole story', async () => {
mockDetail({ ...base, operatorEgress: true })
await openDetail()
// The declared host is still listed…
expect(await screen.findByText('gotify.net')).toBeInTheDocument()
// …alongside the pill saying an admin adds more.
expect(screen.getByText(/hosts you add/i)).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-EGRESS-006: an ordinary plugin gets NO such pill', async () => {
mockDetail({ ...base, operatorEgress: false })
await openDetail()
expect(await screen.findByText('gotify.net')).toBeInTheDocument()
// Its egress list IS the whole story — claiming otherwise would be a lie.
expect(screen.queryByText(/hosts you add/i)).not.toBeInTheDocument()
})
})
@@ -42,6 +42,10 @@ interface PluginRow {
source_repo: string | null
permissions: string
capabilities: string
/** The plugin needs OPERATOR-supplied egress hosts (a self-hosted target). */
operatorEgress?: boolean
/** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */
egressHostCount?: number
dependencies?: PluginDependencies
dependencyStatus?: DependencyStatus
dependencyIssues?: DependencyIssues
@@ -68,6 +72,8 @@ interface RegistryDetail extends RegistryItem {
manifest: {
permissions: string[]
egress: string[]
/** The plugin needs OPERATOR-supplied hosts — its egress list is not the whole story. */
operatorEgress?: boolean
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
license: string | null
icon: string | null
@@ -118,7 +124,7 @@ const PERM_KEYS = [
'events:subscribe', 'jobs:run',
'ws:broadcast:trip', 'ws:broadcast:user',
'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider',
'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:user-data', 'http:outbound',
'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'http:outbound',
]
const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page']
@@ -169,6 +175,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') })
if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') })
for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) {
out.push({ icon: ArrowRight, label: h, net: true })
@@ -250,6 +257,10 @@ export default function AdminPluginsPanel() {
const [latest, setLatest] = useState<Record<string, string>>({})
const [detailFor, setDetailFor] = useState<RegistryItem | null>(null)
const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null)
const [egressDraft, setEgressDraft] = useState('')
const [egressSaving, setEgressSaving] = useState(false)
const [egressError, setEgressError] = useState('')
const [confirmUninstall, setConfirmUninstall] = useState<PluginRow | null>(null)
// A QUEUE, not one slot: "Update All" can produce several re-consent prompts —
// each must be shown, not silently overwritten by the last one.
@@ -349,6 +360,31 @@ export default function AdminPluginsPanel() {
const f = e.dataTransfer.files?.[0]
if (f) void uploadPlugin(f)
}
const openEgress = (id: string) => {
setMenu(null)
setEgressDraft(''); setEgressError('')
adminApi.pluginEgressHosts(id)
.then(d => setEgressFor({ id, supported: d.supported, hosts: d.hosts }))
.catch(() => setEgressFor({ id, supported: false, hosts: [] }))
}
// Saving RE-SPAWNS the plugin: the child's egress guard is installed once at init and
// a second init is refused, so a live child's allow-list can never be widened in place.
const saveEgress = async (hosts: string[]) => {
if (!egressFor) return
setEgressSaving(true); setEgressError('')
try {
const d = await adminApi.pluginSetEgressHosts(egressFor.id, hosts)
setEgressFor({ ...egressFor, hosts: d.hosts })
setEgressDraft('')
} catch (e) {
const err = e as { response?: { data?: { error?: string } } }
setEgressError(err.response?.data?.error || t('common.error'))
} finally {
setEgressSaving(false)
}
}
const openErrors = (id: string) => {
setMenu(null)
adminApi.pluginErrors(id)
@@ -657,7 +693,8 @@ export default function AdminPluginsPanel() {
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
onToggle={() => toggle(p)}
onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)}
onErrors={() => openErrors(p.id)} onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} />
onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)}
onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} />
))}
</div>
)}
@@ -693,6 +730,56 @@ export default function AdminPluginsPanel() {
</div>
)}
{/* Operator-supplied egress hosts */}
{egressFor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
<span className="text-sm font-semibold text-content flex items-center gap-2"><Globe size={15} /> {egressFor.id} — {t('admin.plugins.allowedHosts')}</span>
<button onClick={() => setEgressFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
</div>
<div className="p-5 space-y-3">
{!egressFor.supported ? (
<p className="text-sm text-content-faint">{t('admin.plugins.allowedHosts.unsupported')}</p>
) : (
<>
<p className="text-xs text-content-faint">{t('admin.plugins.allowedHosts.hint')}</p>
{egressFor.hosts.length === 0 && (
<p className="text-sm text-content-faint italic">{t('admin.plugins.allowedHosts.none')}</p>
)}
{egressFor.hosts.map(h => (
<div key={h} className="flex items-center justify-between gap-2 rounded-lg border border-edge-secondary px-3 py-2">
<span className="text-sm font-mono text-content break-all">{h}</span>
<button
disabled={egressSaving}
onClick={() => saveEgress(egressFor.hosts.filter(x => x !== h))}
className="text-content-faint hover:text-danger disabled:opacity-50"
aria-label={t('common.delete')}
><Trash2 size={14} /></button>
</div>
))}
<div className="flex gap-2">
<input
value={egressDraft}
onChange={e => setEgressDraft(e.target.value)}
placeholder="gotify.example.com"
className="flex-1 rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content"
/>
<button
disabled={egressSaving || !egressDraft.trim()}
onClick={() => saveEgress([...egressFor.hosts, egressDraft.trim()])}
className="rounded-lg bg-content px-3 py-2 text-sm text-surface disabled:opacity-50"
>{t('common.add')}</button>
</div>
{egressError && <p className="text-xs text-danger">{egressError}</p>}
<p className="text-xs text-content-faint">{t('admin.plugins.allowedHosts.restartNote')}</p>
</>
)}
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={!!confirmUninstall}
onClose={() => setConfirmUninstall(null)}
@@ -799,10 +886,10 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
)
}
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggle, onUpdate, onRestart, onErrors, onUninstall }: {
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggle, onUpdate, onRestart, onErrors, onEgress, onUninstall }: {
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
hasUpdate: boolean; latestVer?: string
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onUninstall: () => void
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onEgress: () => void; onUninstall: () => void
}) {
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
const deps = deriveDeps(p, t)
@@ -830,7 +917,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-danger">
<AlertTriangle size={13} className="shrink-0" /><span className="truncate">{p.last_error}</span>
</div>
) : caps.length > 0 && (
) : (caps.length > 0 || p.operatorEgress) && (
<div className="hidden sm:flex items-center gap-1.5 flex-wrap mt-2">
{caps.map((c, i) => (
<span key={i} className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border ${
@@ -838,6 +925,25 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
<c.icon size={12} className={c.net ? 'text-info' : 'text-content-muted'} />{c.label}
</span>
))}
{/* This plugin talks to a service only the OPERATOR can name (a self-hosted
Gotify/ntfy), so its manifest can't list the host — the admin adds it.
Actionable, and warning-toned until at least one host exists, because
until then the plugin cannot reach anything and looks silently broken. */}
{p.operatorEgress && (
<button
onClick={onEgress}
title={t('admin.plugins.allowedHosts.hint')}
className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border transition-colors ${
p.egressHostCount > 0
? 'text-info border-info/25 bg-info-soft hover:border-info/50'
: 'text-warning border-warning/30 bg-warning-soft hover:border-warning/60'}`}
>
<Globe size={12} className={p.egressHostCount > 0 ? 'text-info' : 'text-warning'} />
{p.egressHostCount > 0
? t('admin.plugins.allowedHosts.count').replace('{n}', String(p.egressHostCount))
: t('admin.plugins.allowedHosts.add')}
</button>
)}
</div>
)}
{deps.length > 0 && (
@@ -874,6 +980,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
)}
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
<>
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
@@ -1070,14 +1177,26 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
</div>
)}
{manifest && manifest.egress.length > 0 && (
{manifest && (manifest.egress.length > 0 || manifest.operatorEgress) && (
<div className="mt-5">
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.connectsTitle')}</h4>
<div className="flex flex-wrap gap-1.5 mt-2">
<div className="flex flex-wrap items-center gap-1.5 mt-2">
{manifest.egress.map(h => (
<code key={h} className="text-[12px] font-mono text-info bg-info-soft rounded-md px-2 py-1">{h}</code>
))}
{/* The hosts above are NOT the whole story for this plugin: it talks to a
service only the operator can name, so its reach is whatever an admin
adds after install. Say so HERE — this is the pre-install review, and a
reviewer who reads only the host list would otherwise be misled. */}
{manifest.operatorEgress && (
<span className="inline-flex items-center gap-1.5 text-[12px] font-medium text-warning bg-warning-soft border border-warning/30 rounded-md px-2 py-1">
<Globe size={12} />{t('admin.plugins.operatorEgressPill')}
</span>
)}
</div>
{manifest.operatorEgress && (
<p className="text-[11.5px] text-content-faint mt-2">{t('admin.plugins.operatorEgressHint')}</p>
)}
</div>
)}
@@ -1,11 +1,10 @@
import React from 'react';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
import NotificationsTab from './NotificationsTab';
@@ -13,7 +12,29 @@ const minimalMatrix = {
preferences: {
trip_invite: { inapp: true, email: false },
},
available_channels: { email: true, webhook: false, inapp: true },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: true,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: false,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'email'] },
};
@@ -25,15 +46,13 @@ beforeEach(() => {
server.use(
http.get('/api/notifications/preferences', () => HttpResponse.json(minimalMatrix)),
http.get('/api/settings', () => HttpResponse.json({ settings: { webhook_url: '' } })),
http.put('/api/notifications/preferences', () => HttpResponse.json({ success: true })),
http.put('/api/notifications/preferences', () => HttpResponse.json({ success: true }))
);
});
describe('NotificationsTab', () => {
it('FE-COMP-NOTIFICATIONS-001: shows loading state initially', () => {
server.use(
http.get('/api/notifications/preferences', () => new Promise(() => {})),
);
server.use(http.get('/api/notifications/preferences', () => new Promise(() => {})));
render(<NotificationsTab />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
@@ -64,11 +83,33 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: {},
available_channels: { email: false, webhook: false, inapp: false },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: false,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: false,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'email'] },
}),
),
})
)
);
render(<NotificationsTab />);
await waitFor(() => {
@@ -86,14 +127,36 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: { trip_invite: { inapp: true }, booking_change: { email: true } },
available_channels: { email: true, webhook: false, inapp: true },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: true,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: false,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
],
event_types: ['trip_invite', 'booking_change'],
implemented_combos: {
trip_invite: ['inapp'], // no email → dash in email column
booking_change: ['email'], // no inapp → dash in inapp column
trip_invite: ['inapp'], // no email → dash in email column
booking_change: ['email'], // no inapp → dash in inapp column
},
}),
),
})
)
);
render(<NotificationsTab />);
await waitFor(() => {
@@ -111,7 +174,7 @@ describe('NotificationsTab', () => {
http.put('/api/notifications/preferences', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({ success: true });
}),
})
);
render(<NotificationsTab />);
@@ -138,9 +201,7 @@ describe('NotificationsTab', () => {
it('FE-COMP-NOTIFICATIONS-007: toggle rolls back on API error', async () => {
const user = userEvent.setup();
server.use(
http.put('/api/notifications/preferences', () => HttpResponse.json({ error: 'fail' }, { status: 500 })),
);
server.use(http.put('/api/notifications/preferences', () => HttpResponse.json({ error: 'fail' }, { status: 500 })));
render(<NotificationsTab />);
await waitFor(() => {
@@ -169,11 +230,13 @@ describe('NotificationsTab', () => {
const user = userEvent.setup();
let resolveRequest!: () => void;
server.use(
http.put('/api/notifications/preferences', () =>
new Promise<Response>(resolve => {
resolveRequest = () => resolve(HttpResponse.json({ success: true }) as unknown as Response);
}),
),
http.put(
'/api/notifications/preferences',
() =>
new Promise<Response>((resolve) => {
resolveRequest = () => resolve(HttpResponse.json({ success: true }) as unknown as Response);
})
)
);
render(<NotificationsTab />);
@@ -200,11 +263,33 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: { trip_invite: { inapp: true, webhook: false } },
available_channels: { email: false, webhook: true, inapp: true },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: true,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
}),
),
})
)
);
render(<NotificationsTab />);
@@ -218,7 +303,7 @@ describe('NotificationsTab', () => {
// Save button should be present
const buttons = screen.getAllByRole('button');
expect(buttons.some(b => /save/i.test(b.textContent || ''))).toBe(true);
expect(buttons.some((b) => /save/i.test(b.textContent || ''))).toBe(true);
});
it('FE-COMP-NOTIFICATIONS-010: webhook URL input shows masked placeholder when webhook is already set', async () => {
@@ -226,14 +311,34 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: { trip_invite: { inapp: true, webhook: false } },
available_channels: { email: false, webhook: true, inapp: true },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: true,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
}),
),
http.get('/api/settings', () =>
HttpResponse.json({ settings: { webhook_url: '••••••••' } }),
})
),
http.get('/api/settings', () => HttpResponse.json({ settings: { webhook_url: '••••••••' } }))
);
render(<NotificationsTab />);
@@ -252,15 +357,37 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: { trip_invite: { inapp: true, webhook: false } },
available_channels: { email: false, webhook: true, inapp: true },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: true,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
}),
})
),
http.put('/api/settings', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({ success: true });
}),
})
);
render(<NotificationsTab />);
@@ -271,7 +398,7 @@ describe('NotificationsTab', () => {
const input = await screen.findByRole('textbox');
await user.type(input, 'https://example.com/hook');
const saveBtn = screen.getAllByRole('button').find(b => /save/i.test(b.textContent || ''));
const saveBtn = screen.getAllByRole('button').find((b) => /save/i.test(b.textContent || ''));
expect(saveBtn).toBeDefined();
await user.click(saveBtn!);
@@ -285,14 +412,34 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: { trip_invite: { inapp: true, webhook: false } },
available_channels: { email: false, webhook: true, inapp: true },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: true,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
}),
),
http.get('/api/settings', () =>
HttpResponse.json({ settings: { webhook_url: '' } }),
})
),
http.get('/api/settings', () => HttpResponse.json({ settings: { webhook_url: '' } }))
);
render(<NotificationsTab />);
@@ -301,7 +448,7 @@ describe('NotificationsTab', () => {
});
await screen.findByRole('textbox');
const testBtn = screen.getAllByRole('button').find(b => /test/i.test(b.textContent || ''));
const testBtn = screen.getAllByRole('button').find((b) => /test/i.test(b.textContent || ''));
expect(testBtn).toBeDefined();
expect(testBtn).toBeDisabled();
});
@@ -312,21 +459,41 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: { trip_invite: { inapp: true, webhook: false } },
available_channels: { email: false, webhook: true, inapp: true },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: true,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
}),
),
http.post('/api/notifications/test-webhook', () =>
HttpResponse.json({ success: true }),
})
),
http.post('/api/notifications/test-webhook', () => HttpResponse.json({ success: true }))
);
render(
<>
<NotificationsTab />
<ToastContainer />
</>,
</>
);
await waitFor(() => {
@@ -336,7 +503,7 @@ describe('NotificationsTab', () => {
const input = await screen.findByRole('textbox');
await user.type(input, 'https://example.com/hook');
const testBtn = screen.getAllByRole('button').find(b => /test/i.test(b.textContent || ''));
const testBtn = screen.getAllByRole('button').find((b) => /test/i.test(b.textContent || ''));
expect(testBtn).toBeDefined();
await user.click(testBtn!);
@@ -352,11 +519,40 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: { trip_invite: { inapp: true, ntfy: false } },
available_channels: { email: false, webhook: false, inapp: true, ntfy: true },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: false,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
{
id: 'ntfy',
source: 'builtin',
labelKey: 'settings.notificationPreferences.ntfy',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'ntfy'] },
}),
),
})
)
);
render(<NotificationsTab />);
@@ -374,12 +570,41 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: { trip_invite: { inapp: true, ntfy: false } },
available_channels: { email: false, webhook: false, inapp: true, ntfy: true },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: false,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
{
id: 'ntfy',
source: 'builtin',
labelKey: 'settings.notificationPreferences.ntfy',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'ntfy'] },
}),
})
),
http.get('/api/settings', () => HttpResponse.json({ settings: { ntfy_topic: '' } })),
http.get('/api/settings', () => HttpResponse.json({ settings: { ntfy_topic: '' } }))
);
render(<NotificationsTab />);
@@ -389,7 +614,7 @@ describe('NotificationsTab', () => {
// Test button should be disabled when topic is empty
const allButtons = await screen.findAllByRole('button');
const testBtn = allButtons.find(b => /test/i.test(b.textContent || ''));
const testBtn = allButtons.find((b) => /test/i.test(b.textContent || ''));
expect(testBtn).toBeDefined();
expect(testBtn).toBeDisabled();
});
@@ -401,22 +626,51 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: { trip_invite: { inapp: true, ntfy: false } },
available_channels: { email: false, webhook: false, inapp: true, ntfy: true },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: false,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
{
id: 'ntfy',
source: 'builtin',
labelKey: 'settings.notificationPreferences.ntfy',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'ntfy'] },
}),
})
),
http.post('/api/notifications/test-ntfy', () => {
ntfyCalled = true;
return HttpResponse.json({ success: true });
}),
})
);
render(
<>
<NotificationsTab />
<ToastContainer />
</>,
</>
);
await waitFor(() => {
@@ -429,7 +683,7 @@ describe('NotificationsTab', () => {
// Test button should now be enabled
const allButtons = screen.getAllByRole('button');
const testBtn = allButtons.find(b => /test/i.test(b.textContent || ''));
const testBtn = allButtons.find((b) => /test/i.test(b.textContent || ''));
expect(testBtn).toBeDefined();
expect(testBtn).not.toBeDisabled();
@@ -446,21 +700,43 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: { trip_invite: { inapp: true, webhook: false } },
available_channels: { email: false, webhook: true, inapp: true },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: true,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
}),
})
),
http.post('/api/notifications/test-webhook', () =>
HttpResponse.json({ success: false, error: 'Connection refused' }),
),
HttpResponse.json({ success: false, error: 'Connection refused' })
)
);
render(
<>
<NotificationsTab />
<ToastContainer />
</>,
</>
);
await waitFor(() => {
@@ -470,7 +746,7 @@ describe('NotificationsTab', () => {
const input = await screen.findByRole('textbox');
await user.type(input, 'https://example.com/hook');
const testBtn = screen.getAllByRole('button').find(b => /test/i.test(b.textContent || ''));
const testBtn = screen.getAllByRole('button').find((b) => /test/i.test(b.textContent || ''));
expect(testBtn).toBeDefined();
await user.click(testBtn!);
@@ -480,3 +756,98 @@ describe('NotificationsTab', () => {
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Plugin notification channels (e.g. a Gotify plugin)
// ─────────────────────────────────────────────────────────────────────────────
//
// A plugin channel must be a first-class citizen here alongside in-app/email/ntfy:
// its own column in the matrix, its state visible, and a way to act on it. The
// credentials themselves live on the plugin's settings page, so an UNconfigured
// channel links there rather than just naming the place.
const pluginMatrix = (over: Record<string, unknown> = {}) => ({
preferences: { trip_invite: { inapp: true, 'plugin:trek-gotify': true } },
channels: [
{ id: 'inapp', source: 'builtin', labelKey: 'settings.notificationPreferences.inapp', active: true, configured: true },
{
id: 'plugin:trek-gotify',
source: 'plugin',
label: 'Gotify',
settingsPath: '/settings?tab=plugins',
active: true,
configured: true,
...over,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'plugin:trek-gotify'] },
});
function mockMatrix(matrix: unknown) {
server.use(
http.get('*/api/notifications/preferences', () => HttpResponse.json(matrix)),
http.get('*/api/settings', () => HttpResponse.json({ settings: {} })),
);
}
describe('NotificationsTab — plugin channels', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
});
it('FE-COMP-NOTIFICATIONS-PLUGIN-001: a configured plugin channel gets its own column, by its own name', async () => {
mockMatrix(pluginMatrix());
render(<NotificationsTab />);
// The server sends the display name outright — plugin channels have no i18n key.
// It appears TWICE by design: once as the channel's own card, once as the matrix
// column header — i.e. it really is a first-class column, not just a side panel.
const shown = await screen.findAllByText('Gotify');
expect(shown.length).toBe(2);
// …and it is togglable per event, exactly like a built-in.
await waitFor(() => expect(screen.getAllByRole('button').length).toBeGreaterThan(0));
});
it('FE-COMP-NOTIFICATIONS-PLUGIN-002: configured → offers a test send, no Configure link', async () => {
mockMatrix(pluginMatrix({ configured: true }));
render(<NotificationsTab />);
const test = await screen.findByRole('button', { name: /send test/i });
expect(test).toBeEnabled();
expect(screen.queryByRole('link', { name: /configure/i })).not.toBeInTheDocument();
});
it('FE-COMP-NOTIFICATIONS-PLUGIN-003: NOT configured → links to the plugin settings tab, test disabled', async () => {
mockMatrix(pluginMatrix({ configured: false }));
render(<NotificationsTab />);
const link = await screen.findByRole('link', { name: /configure/i });
// A real route — settings is one page with a ?tab= param, not a route per plugin.
expect(link).toHaveAttribute('href', '/settings?tab=plugins');
expect(screen.getByRole('button', { name: /send test/i })).toBeDisabled();
});
it('FE-COMP-NOTIFICATIONS-PLUGIN-004: Send test calls the generic channel-test route', async () => {
mockMatrix(pluginMatrix());
let called = '';
server.use(
http.post('*/api/notifications/test/:channelId', ({ params }) => {
called = String(params.channelId);
return HttpResponse.json({ success: true });
}),
);
render(<><NotificationsTab /><ToastContainer /></>);
await userEvent.click(await screen.findByRole('button', { name: /send test/i }));
await waitFor(() => expect(called).toBe('plugin:trek-gotify'));
});
it('FE-COMP-NOTIFICATIONS-PLUGIN-005: an INACTIVE plugin channel is not shown at all', async () => {
mockMatrix(pluginMatrix({ active: false }));
render(<NotificationsTab />);
await screen.findByText(/in-app/i);
// The admin hasn't enabled the channel — it must not appear as an option.
expect(screen.queryByText('Gotify')).not.toBeInTheDocument();
});
});
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Lock } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { notificationsApi, settingsApi } from '../../api/client'
@@ -6,19 +7,30 @@ import { useToast } from '../shared/Toast'
import ToggleSwitch from './ToggleSwitch'
import Section from './Section'
interface ChannelDescriptor {
id: string
source: 'builtin' | 'plugin'
/** Built-ins: an i18n key. */
labelKey?: string
/** Plugin channels: a literal name, already resolved by the server. */
label?: string
settingsPath?: string
active: boolean
configured: boolean
}
interface PreferencesMatrix {
preferences: Record<string, Record<string, boolean>>
available_channels: { email: boolean; webhook: boolean; inapp: boolean; ntfy: boolean }
channels: ChannelDescriptor[]
event_types: string[]
implemented_combos: Record<string, string[]>
defaults?: { ntfyServer: string | null }
}
const CHANNEL_LABEL_KEYS: Record<string, string> = {
email: 'settings.notificationPreferences.email',
webhook: 'settings.notificationPreferences.webhook',
inapp: 'settings.notificationPreferences.inapp',
ntfy: 'settings.notificationPreferences.ntfy',
/** Plugin channels have no i18n — the server sends their display name outright. */
function channelLabel(ch: ChannelDescriptor, t: (k: string) => string): string {
if (ch.labelKey) return t(ch.labelKey) || ch.id
return ch.label || ch.id
}
const EVENT_LABEL_KEYS: Record<string, string> = {
@@ -48,6 +60,7 @@ export default function NotificationsTab(): React.ReactElement {
const [ntfyTokenIsSet, setNtfyTokenIsSet] = useState(false)
const [ntfySaving, setNtfySaving] = useState(false)
const [ntfyTesting, setNtfyTesting] = useState(false)
const [channelTesting, setChannelTesting] = useState<string | null>(null)
useEffect(() => {
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
@@ -71,13 +84,34 @@ export default function NotificationsTab(): React.ReactElement {
}).catch(() => {})
}, [])
// Columns are whatever the server says exists and the admin turned on — so a
// plugin channel gets a column here with no client change.
const visibleChannels = matrix
? (['email', 'webhook', 'ntfy', 'inapp'] as const).filter(ch => {
if (!matrix.available_channels[ch as keyof typeof matrix.available_channels]) return false
return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch))
? matrix.channels.filter(ch => {
if (!ch.active) return false
return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch.id))
})
: []
const hasChannel = (id: string) => matrix?.channels.some(ch => ch.id === id && ch.active) ?? false
// Plugin channels have no bespoke credential form here — the user fills those in on the
// plugin's own settings page. What they DO get is a test send, through the generic route.
const pluginChannels = matrix?.channels.filter(ch => ch.source === 'plugin' && ch.active) ?? []
const testChannel = async (ch: ChannelDescriptor) => {
setChannelTesting(ch.id)
try {
const result = await notificationsApi.testChannel(ch.id)
if (result.success) toast.success(t('settings.notificationPreferences.testSuccess'))
else toast.error(result.error || t('settings.notificationPreferences.testFailed'))
} catch {
toast.error(t('settings.notificationPreferences.testFailed'))
} finally {
setChannelTesting(null)
}
}
const toggle = async (eventType: string, channel: string) => {
if (!matrix) return
const current = matrix.preferences[eventType]?.[channel] ?? true
@@ -184,7 +218,7 @@ export default function NotificationsTab(): React.ReactElement {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{saving && <p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 8 }}>{t('common.saving')}</p>}
{matrix.available_channels.webhook && (
{hasChannel('webhook') && (
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{t('settings.webhookUrl.label')}
@@ -215,7 +249,7 @@ export default function NotificationsTab(): React.ReactElement {
</div>
</div>
)}
{matrix.available_channels.ntfy && (
{hasChannel('ntfy') && (
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{t('settings.ntfyUrl.topicLabel')}
@@ -275,19 +309,50 @@ export default function NotificationsTab(): React.ReactElement {
</div>
</div>
)}
{pluginChannels.map(ch => (
<div key={ch.id} style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{channelLabel(ch, t)}
</label>
<p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 8 }}>
{ch.configured
? t('settings.notificationPreferences.pluginConfigured')
: t('settings.notificationPreferences.notConfigured')}
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{/* Unconfigured is the common case on first use — send them to where the
credentials actually live rather than just naming the place. */}
{!ch.configured && ch.settingsPath && (
<Link
to={ch.settingsPath}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, textDecoration: 'none' }}
>
{t('settings.notificationPreferences.configure')}
</Link>
)}
<button
onClick={() => testChannel(ch)}
disabled={!ch.configured || channelTesting === ch.id}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: (!ch.configured || channelTesting === ch.id) ? 'not-allowed' : 'pointer', opacity: (!ch.configured || channelTesting === ch.id) ? 0.5 : 1 }}
>
{t('settings.notificationPreferences.sendTest')}
</button>
</div>
</div>
))}
{/* Header row */}
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
<span />
{visibleChannels.map(ch => (
<span key={ch} style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{t(CHANNEL_LABEL_KEYS[ch]) || ch}
<span key={ch.id} title={!ch.configured ? t('settings.notificationPreferences.notConfigured') : undefined} style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: ch.configured ? 'var(--text-faint)' : 'var(--color-warning, #d69e2e)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{channelLabel(ch, t)}
</span>
))}
</div>
{/* Event rows */}
{matrix.event_types.map(eventType => {
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch))
const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch.id))
if (relevantChannels.length === 0) return null
return (
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}>
@@ -295,13 +360,13 @@ export default function NotificationsTab(): React.ReactElement {
{t(EVENT_LABEL_KEYS[eventType]) || eventType}
</span>
{visibleChannels.map(ch => {
if (!implementedForEvent.includes(ch)) {
return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>—</span>
if (!implementedForEvent.includes(ch.id)) {
return <span key={ch.id} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>—</span>
}
const isOn = matrix.preferences[eventType]?.[ch] ?? true
const isOn = matrix.preferences[eventType]?.[ch.id] ?? true
return (
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch)} />
<div key={ch.id} style={{ display: 'flex', justifyContent: 'center' }}>
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch.id)} />
</div>
)
})}
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Blocks, Save, Loader2, Link2, Unlink, CheckCircle } from 'lucide-react'
import { pluginsApi, type PluginUserSettingField } from '../../api/client'
import { pluginsApi, type PluginUserSettingField, type PluginAction } from '../../api/client'
import { usePluginStore } from '../../store/pluginStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
@@ -64,6 +64,9 @@ function PluginSettingsForm({ id, name }: { id: string; name: string }) {
const [values, setValues] = useState<Record<string, string | boolean>>({})
const [saving, setSaving] = useState(false)
const [oauth, setOauth] = useState<{ configured: boolean; connected: boolean } | null>(null)
const [actions, setActions] = useState<PluginAction[]>([])
const [running, setRunning] = useState<string | null>(null)
const [actionResult, setActionResult] = useState<Record<string, { ok: boolean; message?: string }>>({})
useEffect(() => {
let alive = true
@@ -71,6 +74,7 @@ function PluginSettingsForm({ id, name }: { id: string; name: string }) {
.then(r => {
if (!alive) return
setFields(r.fields)
setActions(r.actions ?? [])
const init: Record<string, string | boolean> = {}
for (const f of r.fields) {
const v = r.config[f.key]
@@ -84,8 +88,23 @@ function PluginSettingsForm({ id, name }: { id: string; name: string }) {
}, [id])
const hasFields = (fields?.length ?? 0) > 0
// Show the card if the plugin has user fields OR an OAuth connection to offer.
if (fields === null || (!hasFields && !oauth?.configured)) return null
// Show the card if the plugin has user fields, actions, OR an OAuth connection to offer.
if (fields === null || (!hasFields && actions.length === 0 && !oauth?.configured)) return null
// An action runs AS the caller, so it sees the values they just saved — run the save
// first if the form is dirty would be nicer, but keeping it explicit is less surprising.
const runAction = async (a: PluginAction) => {
if (a.danger && !window.confirm(t('settings.plugins.actions.confirm'))) return
setRunning(a.key)
try {
const res = await pluginsApi.runAction(id, a.key)
setActionResult(prev => ({ ...prev, [a.key]: res }))
} catch {
setActionResult(prev => ({ ...prev, [a.key]: { ok: false, message: t('common.error') } }))
} finally {
setRunning(null)
}
}
const save = async () => {
setSaving(true)
@@ -164,6 +183,38 @@ function PluginSettingsForm({ id, name }: { id: string; name: string }) {
{t('common.save')}
</button>
)}
{actions.length > 0 && (
<div className="mt-4 border-t border-border pt-4">
<span className="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">
{t('settings.plugins.actions')}
</span>
<div className="flex flex-col gap-2">
{actions.map(a => {
const res = actionResult[a.key]
return (
<div key={a.key} className="flex flex-wrap items-center gap-2">
<button
onClick={() => runAction(a)}
disabled={running !== null}
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-semibold disabled:opacity-60 ${
a.danger ? 'border-danger text-danger' : 'border-border text-content'
}`}
>
{running === a.key && <Loader2 className="w-4 h-4 animate-spin" />}
{a.label}
</button>
{a.hint && <span className="text-xs text-content-muted">{a.hint}</span>}
{res && (
<span className={`text-xs font-medium ${res.ok ? 'text-success' : 'text-danger'}`}>
{res.message || (res.ok ? t('common.success') : t('common.error'))}
</span>
)}
</div>
)
})}
</div>
</div>
)}
<PluginOAuthSection id={id} state={oauth} setState={setOauth} />
</div>
)
+86 -43
View File
@@ -1,11 +1,10 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, within } from '../../tests/helpers/render';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { buildAdmin } from '../../tests/helpers/factories';
import { server } from '../../tests/helpers/msw/server';
import { fireEvent, render, screen, waitFor, within } from '../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser, buildAdmin } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { useAddonStore } from '../store/addonStore';
import AdminPage from './AdminPage';
// Mock heavy sub-panels to focus on page-level concerns
@@ -145,7 +144,7 @@ describe('AdminPage', () => {
}),
http.get('/api/admin/stats', () => {
return HttpResponse.json({ error: 'Forbidden' }, { status: 403 });
}),
})
);
seedStore(useAuthStore, {
@@ -237,7 +236,7 @@ describe('AdminPage', () => {
await waitFor(() => {
expect(screen.getByText('42')).toBeInTheDocument(); // totalPlaces — unique on page
expect(screen.getByText('8')).toBeInTheDocument(); // totalFiles — unique on page
expect(screen.getByText('8')).toBeInTheDocument(); // totalFiles — unique on page
});
});
});
@@ -302,7 +301,7 @@ describe('AdminPage', () => {
server.use(
http.get('/api/admin/version-check', () => {
return HttpResponse.json({ update_available: true, latest: '9.9.9', current: '1.0.0' });
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -330,7 +329,7 @@ describe('AdminPage', () => {
return HttpResponse.json({
addons: [{ id: 'mcp', name: 'MCP Tokens', type: 'mcp', icon: '', enabled: true }],
});
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -347,9 +346,9 @@ describe('AdminPage', () => {
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({});
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -462,9 +461,9 @@ describe('AdminPage', () => {
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({});
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -543,9 +542,18 @@ describe('AdminPage', () => {
server.use(
http.get('/api/admin/invites', () => {
return HttpResponse.json({
invites: [{ id: 1, token: 'abcdef123456789', max_uses: 5, used_count: 0, expires_at: null, created_by_name: 'admin' }],
invites: [
{
id: 1,
token: 'abcdef123456789',
max_uses: 5,
used_count: 0,
expires_at: null,
created_by_name: 'admin',
},
],
});
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -573,9 +581,18 @@ describe('AdminPage', () => {
server.use(
http.get('/api/admin/invites', () => {
return HttpResponse.json({
invites: [{ id: 1, token: 'abcdef123456789', max_uses: 5, used_count: 0, expires_at: null, created_by_name: 'admin' }],
invites: [
{
id: 1,
token: 'abcdef123456789',
max_uses: 5,
used_count: 0,
expires_at: null,
created_by_name: 'admin',
},
],
});
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -612,11 +629,26 @@ describe('AdminPage', () => {
http.get('/api/admin/notification-preferences', () => {
return HttpResponse.json({
event_types: ['version_available'],
available_channels: { inapp: true, email: true },
channels: [
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: true,
configured: true,
},
],
implemented_combos: { version_available: ['inapp', 'email'] },
preferences: { version_available: { inapp: true, email: true } },
});
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -640,7 +672,7 @@ describe('AdminPage', () => {
return HttpResponse.json({
addons: [{ id: 'mcp', name: 'MCP Tokens', type: 'mcp', icon: '', enabled: true }],
});
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -659,7 +691,7 @@ describe('AdminPage', () => {
server.use(
http.get('/api/admin/version-check', () => {
return HttpResponse.json({ update_available: true, latest: '9.9.9', current: '1.0.0' });
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -723,9 +755,9 @@ describe('AdminPage', () => {
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({});
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -755,7 +787,7 @@ describe('AdminPage', () => {
server.use(
http.put('/api/admin/oidc', async ({ request }) => {
return HttpResponse.json(await request.json());
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -788,9 +820,9 @@ describe('AdminPage', () => {
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({});
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -816,16 +848,16 @@ describe('AdminPage', () => {
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({});
}),
})
);
// Start with email enabled by seeding smtpValues
server.use(
http.get('/api/auth/app-settings', () => {
return HttpResponse.json({ notification_channels: 'email', smtp_host: 'mail.example.com' });
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -882,7 +914,7 @@ describe('AdminPage', () => {
server.use(
http.get('/api/admin/version-check', () => {
return HttpResponse.json({ update_available: true, latest: '9.9.9', current: '1.0.0' });
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -942,8 +974,9 @@ describe('AdminPage', () => {
target: { value: 'alice-new@example.com' },
});
expect((screen.getByDisplayValue('alice-new@example.com') as HTMLInputElement).value)
.toBe('alice-new@example.com');
expect((screen.getByDisplayValue('alice-new@example.com') as HTMLInputElement).value).toBe(
'alice-new@example.com'
);
});
});
@@ -954,7 +987,7 @@ describe('AdminPage', () => {
http.put('/api/auth/me/api-keys', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({ success: true });
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -986,7 +1019,7 @@ describe('AdminPage', () => {
http.put('/api/auth/me/api-keys', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1018,7 +1051,7 @@ describe('AdminPage', () => {
}),
http.get('/api/auth/validate-keys', () => {
return HttpResponse.json({ maps: true, weather: false });
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1079,7 +1112,7 @@ describe('AdminPage', () => {
http.delete('/api/admin/users/:id', ({ params }) => {
deletedId = params.id as string;
return HttpResponse.json({ success: true });
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1109,7 +1142,7 @@ describe('AdminPage', () => {
http.post('/api/admin/rotate-jwt-secret', () => {
rotateCalled = true;
return HttpResponse.json({ success: true });
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1142,7 +1175,7 @@ describe('AdminPage', () => {
smtp_host: 'mail.example.com',
smtp_skip_tls_verify: 'false',
});
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1186,7 +1219,7 @@ describe('AdminPage', () => {
http.post('/api/notifications/test-smtp', () => {
testSmtpCalled = true;
return HttpResponse.json({ success: true });
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1223,7 +1256,7 @@ describe('AdminPage', () => {
http.put('/api/auth/app-settings', async () => {
appSettingsCalled = true;
return HttpResponse.json({});
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1260,7 +1293,7 @@ describe('AdminPage', () => {
http.put('/api/auth/app-settings', async ({ request }) => {
savedPayload = await request.json();
return HttpResponse.json({});
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1292,7 +1325,15 @@ describe('AdminPage', () => {
http.get('/api/admin/notification-preferences', () => {
return HttpResponse.json({
event_types: ['trip.created'],
available_channels: { email: true },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: true,
configured: true,
},
],
implemented_combos: { 'trip.created': ['email'] },
preferences: { 'trip.created': { email: true } },
});
@@ -1300,7 +1341,7 @@ describe('AdminPage', () => {
http.put('/api/admin/notification-preferences', async () => {
prefUpdateCalled = true;
return HttpResponse.json({ success: true });
}),
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
@@ -1346,7 +1387,9 @@ describe('AdminPage', () => {
// Discovery URL field
const discoveryInput = within(oidcCard!).getByPlaceholderText(/openid-configuration/i);
fireEvent.change(discoveryInput, { target: { value: 'https://auth.example.com/.well-known/openid-configuration' } });
fireEvent.change(discoveryInput, {
target: { value: 'https://auth.example.com/.well-known/openid-configuration' },
});
// Client ID field
const clientIdLabel = within(oidcCard!).getByText('Client ID');
@@ -15,8 +15,11 @@ export default function AdminNotificationsPanel({ t, toast }: { t: (k: string) =
if (!matrix) return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading…</p>
// Admin-scoped events only ever go out over the built-in channels (plugin channels
// are user-scoped), so this list stays explicit rather than server-driven.
const isActive = (id: string) => matrix.channels?.some((c: { id: string; active: boolean }) => c.id === id && c.active) ?? false
const visibleChannels = (['inapp', 'email', 'webhook', 'ntfy'] as const).filter(ch => {
if (!matrix.available_channels[ch]) return false
if (!isActive(ch)) return false
return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch))
})
@@ -26,13 +26,16 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none'
// Preserve any id this toggle doesn't know about instead of rebuilding the CSV from
// just these three booleans — that used to silently DROP anything else stored here.
const others = activeChans.filter((c: string) => c !== 'email' && c !== 'webhook' && c !== 'ntfy')
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy', ...others].filter(Boolean).join(',') || 'none'
setSmtpValues(prev => ({ ...prev, notification_channels: chans }))
try {
await authApi.updateAppSettings({ notification_channels: chans })
} catch {
// Revert state on failure
const reverted = [emailActive && 'email', webhookActive && 'webhook', ntfyActive && 'ntfy'].filter(Boolean).join(',') || 'none'
const reverted = [emailActive && 'email', webhookActive && 'webhook', ntfyActive && 'ntfy', ...others].filter(Boolean).join(',') || 'none'
setSmtpValues(prev => ({ ...prev, notification_channels: reverted }))
toast.error(t('common.error'))
}
+8
View File
@@ -35,5 +35,13 @@ export function useSettings() {
}
}, [searchParams])
// Deep link into a tab: /settings?tab=plugins. Lets one tab point at another —
// e.g. an unconfigured plugin notification channel sends you to where its
// credentials actually live, instead of just naming the place.
useEffect(() => {
const tab = searchParams.get('tab')
if (tab) setActiveTab(tab)
}, [searchParams])
return { hasIntegrations, appVersion, activeTab, setActiveTab }
}
+153 -13
View File
@@ -36,24 +36,48 @@ export interface ScaffoldOptions {
requiredAddons?: string[];
/** Other plugins this one depends on, each pinned by a semver range. */
pluginDependencies?: Array<{ id: string; version: string }>;
/**
* Which starter to generate. This is NOT the manifest `type` — a notification
* channel is a plain `integration` that implements the `notificationChannel` hook.
*/
template?: 'blank' | 'notification-channel';
}
export const TEMPLATES = ['blank', 'notification-channel'] as const;
export function scaffold(name: string, type: string, targetDir: string, opts: ScaffoldOptions = {}): void {
if (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) throw new Error(`invalid plugin id "${name}" (lowercase slug, 3–40 chars)`);
if (!['integration', 'page', 'widget', 'trip-page'].includes(type)) throw new Error(`invalid type "${type}"`);
const template = opts.template ?? 'blank';
if (!TEMPLATES.includes(template)) throw new Error(`invalid template "${template}"`);
const isChannel = template === 'notification-channel';
// A notification channel is server-only by construction: it takes a rendered
// message and pushes it somewhere. Nothing else makes sense.
if (isChannel && type !== 'integration') throw new Error('the notification-channel template requires type "integration"');
const root = path.join(targetDir, name);
if (fs.existsSync(root)) throw new Error(`${root} already exists`);
fs.mkdirSync(path.join(root, 'server'), { recursive: true });
const perms = opts.permissions?.length ? opts.permissions : ['db:own'];
const displayName = name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
let perms = opts.permissions?.length ? opts.permissions : ['db:own'];
let egress = opts.egress ?? [];
if (isChannel) {
// The two grants a channel cannot work without, plus a placeholder host the
// author swaps for their own — the manifest rejects http:outbound with no egress.
if (!egress.length) egress = ['gotify.example.com'];
const required = ['hook:notification-channel', ...egress.map((h) => `http:outbound:${h}`)];
perms = [...new Set([...perms.filter((p) => p !== 'db:own'), ...required])];
}
const manifest: Record<string, unknown> = {
id: name,
name: name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
name: displayName,
version: '1.0.0',
apiVersion: 1,
author: opts.author || 'Your Name',
description: opts.description || 'Describe what your plugin does.',
description: opts.description || (isChannel ? `Deliver TREK notifications over ${displayName}.` : 'Describe what your plugin does.'),
type,
trek: '>=3.2.1 <4.0.0',
nativeModules: false,
@@ -64,16 +88,48 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
requiredAddons: opts.requiredAddons ?? [],
pluginDependencies: opts.pluginDependencies ?? [],
};
if (opts.egress?.length) manifest.egress = opts.egress;
if (egress.length) manifest.egress = egress;
// A notification channel usually targets a SELF-HOSTED service, whose hostname the
// author cannot know at publish time. Declaring operatorEgress lets the admin add the
// real host after install; without it the plugin only ever reaches the hosts above.
if (isChannel) manifest.operatorEgress = true;
// A settings-page button so the user can verify their credentials without waiting for
// a real notification. Actions are USER-INITIATED, so ctx.settings.get() works inside.
if (isChannel) manifest.actions = [{ key: 'testConnection', label: 'Test connection' }];
// Routes are declared in server/index.js (definePlugin), NOT the manifest — the
// host ignores a manifest `routes`. A `page` plugin gets its nav entry
// automatically, so there's no `capabilities.nav` either; only `widget` carries a
// capability worth scaffolding.
if (type === 'widget') manifest.capabilities = { widget: { title: manifest.name, defaultSize: 'medium' } };
if (type === 'widget') manifest.capabilities = { widget: { title: displayName, defaultSize: 'medium' } };
if (isChannel) {
manifest.capabilities = { notificationChannel: { title: displayName } };
// The recipient's own credential. `scope: 'user'` is what makes it per-user, and
// the host hands the decrypted value to your hook as `config` at send time.
manifest.settings = [
{
key: 'serverUrl',
label: 'Server URL',
input_type: 'text',
placeholder: 'https://gotify.example.com',
hint: 'Your Gotify server. Must match an entry in the manifest `egress` list.',
required: true,
scope: 'user',
},
{
key: 'appToken',
label: 'App token',
input_type: 'text',
hint: 'Create an application in Gotify and paste its token here.',
required: true,
secret: true,
scope: 'user',
},
];
}
fs.writeFileSync(path.join(root, 'trek-plugin.json'), JSON.stringify(manifest, null, 2) + '\n');
fs.writeFileSync(path.join(root, 'server', 'index.js'), SERVER_JS(perms.includes('db:own')));
fs.writeFileSync(path.join(root, 'README.md'), readme(name, opts.description ?? '> One sentence: what this plugin does.', perms));
fs.writeFileSync(path.join(root, 'server', 'index.js'), isChannel ? CHANNEL_JS : SERVER_JS(perms.includes('db:own')));
fs.writeFileSync(path.join(root, 'README.md'), readme(name, opts.description ?? (isChannel ? `> Deliver your TREK notifications to ${displayName}.` : '> One sentence: what this plugin does.'), perms));
// `type: commonjs` pins how the entry is parsed everywhere (dev, tests, TREK);
// the SDK is a devDependency ONLY (types + mock host) — at runtime both the
// dev server and TREK inject it, so it is never vendored into the artifact.
@@ -91,6 +147,69 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
}
}
const CHANNEL_JS = `// A TREK notification channel — runs in an isolated child process.
//
// TREK renders every notification into the recipient's language and hands it to you
// ready to send. You never touch i18n, and you never pick a recipient: the host does
// that, then calls you once per recipient who has this channel switched on.
//
// This hook is HOST-initiated, so there is NO acting user while it runs:
// - ctx.settings.get() returns undefined here (it resolves against the acting user)
// - trip reads are refused
// The recipient's own settings — the fields you declared with scope: 'user' in
// trek-plugin.json — arrive DECRYPTED as the \`config\` argument. That is the only way
// to reach them, and it is why a channel plugin cannot enumerate anyone's trips.
const { definePlugin } = require('trek-plugin-sdk');
async function push(config, title, message) {
// Every host you call must be listed in the manifest's \`egress\` array AND granted
// via http:outbound:<host>, or the child's network guard blocks the request.
const res = await fetch(String(config.serverUrl).replace(/\\/+$/, '') + '/message', {
method: 'POST',
headers: {
'content-type': 'application/json',
'X-Gotify-Key': String(config.appToken),
},
body: JSON.stringify({ title, message, priority: 5 }),
});
// Throw on failure: the host logs it and isolates it, so one dead channel can never
// stop the others (or the in-app notification) from being delivered.
if (!res.ok) throw new Error('Gotify responded ' + res.status + ' ' + (await res.text().catch(() => '')));
}
module.exports = definePlugin({
// A button on your settings page. USER-INITIATED, so unlike the channel hook there IS
// an acting user (whoever clicked) — ctx.settings.get() returns THEIR values, which is
// what makes "test my credentials" possible.
actions: {
async testConnection(ctx) {
const config = {
serverUrl: await ctx.settings.get('serverUrl'),
appToken: await ctx.settings.get('appToken'),
};
await push(config, 'TREK', 'Test notification. If you can read this, your channel works.');
return { ok: true, message: 'Connected.' };
},
},
hooks: {
notificationChannel: {
async send(msg, config, ctx) {
// msg = { event, title, body, url?, tripName? } — already localized.
const body = msg.url ? msg.body + '\\n\\n' + msg.url : msg.body;
await push(config, msg.title, body);
ctx.log.info('delivered ' + msg.event);
},
// Optional — backs the "Send test" button in the user's notification settings.
async test(config) {
await push(config, 'TREK', 'Test notification. If you can read this, your channel works.');
},
},
},
});
`;
const SERVER_JS = (has_db: boolean) => `// Built plugin entry — runs in an isolated child process.
const { definePlugin } = require('trek-plugin-sdk');
@@ -250,6 +369,20 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin
],
});
// A starter, not a manifest type — a notification channel is a plain `integration`
// that implements the notificationChannel hook, so it only makes sense to offer here.
let template: 'blank' | 'notification-channel' = 'blank';
if (type === 'integration') {
template = await promptSelect<'blank' | 'notification-channel'>({
message: 'Start from a template?',
initialValue: 'blank',
options: [
{ value: 'blank', label: 'blank', hint: 'an empty plugin with one example route' },
{ value: 'notification-channel', label: 'notification channel', hint: 'deliver TREK notifications to Gotify/Pushover/…' },
],
});
}
const author = await promptText({ message: 'Author', placeholder: 'Your Name', defaultValue: 'Your Name' });
const description = await promptText({
message: 'One-line description',
@@ -265,10 +398,14 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin
});
let egress: string[] | undefined;
if (permissions.includes('http:outbound')) {
// A channel has to reach its service, so it always needs an egress host — the
// scaffold turns each one into a matching http:outbound:<host> grant.
if (permissions.includes('http:outbound') || template === 'notification-channel') {
const raw = await promptText({
message: 'External hosts it may call (comma-separated)',
placeholder: 'api.example.com, api.other.com',
message: template === 'notification-channel'
? 'Which host does your notification service live on?'
: 'External hosts it may call (comma-separated)',
placeholder: template === 'notification-channel' ? 'gotify.example.com' : 'api.example.com, api.other.com',
validate: (v) => ((v ?? '').split(',').map((s) => s.trim()).filter(Boolean).length ? undefined : 'list at least one host'),
});
egress = raw.split(',').map((s) => s.trim()).filter(Boolean);
@@ -285,6 +422,7 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin
[
`id ${id}`,
`type ${type}`,
template !== 'blank' ? `template ${template}` : undefined,
`location ${dest}`,
`author ${author}`,
`permissions ${permissions.join(', ') || '(none)'}`,
@@ -300,7 +438,7 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin
process.exit(0);
}
scaffold(id, type, parentDir, { author, description, permissions, egress, requiredAddons });
scaffold(id, type, parentDir, { author, description, permissions, egress, requiredAddons, template });
logSuccess(`Created ${dest}`);
if (!insideGitRepo(parentDir)) {
@@ -339,12 +477,14 @@ if (process.argv[1] && process.argv[1].endsWith('create.js')) {
const name = args.find((a: string) => !a.startsWith('-'));
const typeIdx = args.indexOf('--type');
const type = typeIdx >= 0 ? args[typeIdx + 1] : 'integration';
const tplIdx = args.indexOf('--template');
const template = (tplIdx >= 0 ? args[tplIdx + 1] : 'blank') as ScaffoldOptions['template'];
if (!name) {
console.error('usage: create-trek-plugin <name> [--type integration|page|widget|trip-page]');
console.error('usage: create-trek-plugin <name> [--type integration|page|widget|trip-page] [--template blank|notification-channel]');
process.exit(2);
}
try {
scaffold(name, type, process.cwd());
scaffold(name, type, process.cwd(), { template });
console.log(`Created ${name}/ — fill in the README, build server/index.js, then \`npx trek-plugin-sdk validate ${name}\`.`);
} catch (e) {
console.error(e instanceof Error ? e.message : e);
+5 -1
View File
@@ -19,7 +19,7 @@ import { readJsonFile } from './json.js';
interface Version {
version: string; gitTag: string; commitSha: string; downloadUrl: string;
sha256: string; minTrekVersion: string; size: number; apiVersion: number;
nativeModules: false; publishedAt: string; signature?: string;
nativeModules: false; operatorEgress?: boolean; publishedAt: string; signature?: string;
}
interface Entry {
id: string; name: string; author: string; description: string; repo: string;
@@ -68,6 +68,10 @@ export function buildEntry(opts: {
nativeModules: false,
publishedAt: opts.now,
};
// Mirror the manifest's operatorEgress onto the entry — CI parity-checks it, because the
// flag says the egress[] list is NOT the plugin's full network reach (an admin may add
// hosts after install). Only emitted when true, so ordinary entries stay unchanged.
if (manifest.operatorEgress === true) version.operatorEgress = true;
// Optional author signature over the exact artifact bytes.
let authorPublicKey: string | undefined;
+7 -1
View File
@@ -18,7 +18,7 @@ const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i;
export interface EntryVersion {
version: string; gitTag: string; commitSha: string; downloadUrl: string;
sha256: string; size: number; apiVersion: number; nativeModules?: boolean; signature?: string;
sha256: string; size: number; apiVersion: number; nativeModules?: boolean; operatorEgress?: boolean; signature?: string;
}
export interface Entry {
id: string; name: string; type: string; repo: string; authorPublicKey?: string; versions: EntryVersion[];
@@ -81,6 +81,12 @@ export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Pro
if (m.type !== entry.type) fail(`${tag}: manifest type "${m.type}" != entry "${entry.type}"`);
if (m.apiVersion !== v.apiVersion) fail(`${tag}: manifest apiVersion ${m.apiVersion} != entry ${v.apiVersion}`);
if (m.nativeModules === true) fail(`${tag}: manifest declares nativeModules:true (forbidden)`);
// Mirrors the registry's operatorEgress parity check (validate-entry.mjs): the entry
// must not understate the plugin's network reach.
const mOperatorEgress = (m as { operatorEgress?: unknown }).operatorEgress === true;
if (mOperatorEgress !== (v.operatorEgress === true)) {
fail(`${tag}: manifest operatorEgress ${mOperatorEgress} != entry ${v.operatorEgress === true}`);
}
manifestPerms = Array.isArray(m.permissions) ? (m.permissions as string[]) : [];
if (manifestPerms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) {
const egress = Array.isArray(m.egress) ? (m.egress as string[]) : [];
+1
View File
@@ -93,6 +93,7 @@ export const PERMISSION_CATALOG: { value: string; label: string; hint: string }[
{ value: 'ws:broadcast:user', label: 'ws:broadcast:user', hint: 'Push realtime events to a single user' },
{ value: 'hook:photo-provider', label: 'hook:photo-provider', hint: 'Supply place photos to TREK' },
{ value: 'hook:calendar-source', label: 'hook:calendar-source', hint: 'Supply calendar events to TREK' },
{ value: 'hook:notification-channel', label: 'hook:notification-channel', hint: 'Deliver TREK notifications over your own channel' },
{ value: 'hook:place-detail-provider', label: 'hook:place-detail-provider', hint: 'Contribute extra details (reviews, ratings, links) to a place' },
{ value: 'hook:trip-warning-provider', label: 'hook:trip-warning-provider', hint: 'Raise validation warnings on a trip (shown in the planner)' },
{ value: 'http:outbound', label: 'http:outbound', hint: 'Call external HTTP hosts (needs an egress allow-list)' },
+54
View File
@@ -358,6 +358,49 @@ export interface PhotoProvider {
search(query: string, opts: { page: number; limit: number }, ctx: PluginContext): Promise<{ photos: Photo[]; total: number; hasMore: boolean }>;
getById(id: string, ctx: PluginContext): Promise<Photo | null>;
}
/**
* A notification, already rendered by the host into the recipient's language.
* A channel plugin never touches i18n — it only delivers.
*/
export interface NotificationMessage {
/** The TREK event that produced this, e.g. `trip_invite`. */
event: string;
title: string;
body: string;
/** Absolute deep link back into TREK, when the event has one. */
url?: string;
tripName?: string;
}
/**
* Deliver TREK notifications over a channel TREK doesn't ship (Gotify, Pushover, …).
* Needs `hook:notification-channel`.
*
* Unlike every other hook, this one is HOST-initiated for an arbitrary recipient, so
* it runs WITHOUT an acting user: `ctx.settings.get()` returns undefined here and trip
* reads are refused. The recipient's own `scope:'user'` settings are decrypted by the
* host and handed to you as `config` instead — that is the only way to reach them, and
* it is why a channel plugin cannot enumerate the recipient's trips.
*
* Declare the channel in your manifest:
* "capabilities": { "notificationChannel": { "title": "Gotify" } }
*/
export interface NotificationChannel {
send(msg: NotificationMessage, config: Record<string, unknown>, ctx: PluginContext): Promise<void>;
/** Backs the "Send test" button in the user's notification settings. */
test?(config: Record<string, unknown>, ctx: PluginContext): Promise<void>;
}
/**
* Result of a settings-page action. `message` is shown to the user beside the button
* (bounded + emoji-stripped host-side); throwing is equivalent to `{ ok: false }` with
* the error message.
*/
export interface PluginActionResult {
ok?: boolean;
message?: string;
}
export interface CalendarEvent {
id: string;
title: string;
@@ -537,6 +580,16 @@ export interface PluginDefinition {
* JSON-serialisable value the host aggregates. Userless. Needs `hook:user-data`. */
exportUserData?(input: { userId: number }, ctx: PluginContext): Promise<unknown> | unknown;
events?: PluginEventSubscription[];
/**
* Buttons on the plugin's own settings page ("Test connection", "Sync now"). The key
* must match an entry in the manifest's `actions`.
*
* USER-INITIATED, so unlike the notificationChannel hook there IS an acting user — the
* person who clicked. `ctx.settings.get()` returns THEIR value and trip reads are
* membership-checked against them, which is what makes a "test my credentials" button
* possible at all.
*/
actions?: Record<string, (ctx: PluginContext) => Promise<PluginActionResult | void> | PluginActionResult | void>;
hooks?: {
photoProvider?: PhotoProvider;
calendarSource?: CalendarSource;
@@ -548,6 +601,7 @@ export interface PluginDefinition {
atlasLayerProvider?: AtlasLayerProvider;
journalEntryProvider?: JournalEntryProvider;
tripCardProvider?: TripCardProvider;
notificationChannel?: NotificationChannel;
};
/** Functions exposed to dependents (names must match manifest `capabilities.provides`). */
exports?: Record<string, PluginExport>;
+91 -2
View File
@@ -46,6 +46,27 @@ export const KNOWN_ADDONS = [
// no spaces (mirrors the server manifest validator).
const HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i;
const TYPES = ['integration', 'page', 'widget', 'trip-page'];
/** Mirrors the server's settings-key rules (install/manifest.ts). */
const SETTING_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/;
const RESERVED_SETTING_KEYS = new Set(['constructor', 'prototype', '__proto__']);
/**
* Events a plugin notification channel may carry (mirrors the server's
* PLUGIN_CHANNEL_EVENTS). Admin-scoped and in-app-only events are excluded.
*/
export const CHANNEL_EVENTS = [
'trip_invite',
'booking_change',
'trip_reminder',
'todo_due',
'vacay_invite',
'collection_invite',
'photos_shared',
'collab_message',
'packing_tagged',
'plugin_notification',
];
// Mirror of the server's KNOWN_PERMISSIONS (server envelope.ts) — the host hard-rejects
// anything not in this list at activation, so validate must know the full set.
const KNOWN_PERMISSIONS = [
@@ -63,7 +84,7 @@ const KNOWN_PERMISSIONS = [
'ws:broadcast:trip', 'ws:broadcast:user',
'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider',
'hook:table-contributor', 'hook:map-marker-provider', 'hook:pdf-section-provider', 'hook:atlas-layer-provider',
'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:user-data',
'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data',
'events:subscribe', 'jobs:run', 'http:outbound',
'weather:read', 'rates:read', 'notify:send', 'ai:invoke', 'oauth:client',
];
@@ -103,7 +124,22 @@ export function validateManifest(raw: unknown): ValidationResult {
if (egress.includes('*')) errors.push('egress[] must not contain a bare "*"');
for (const h of egress) if (!HOST_RE.test(h)) errors.push(`invalid egress host "${h}"`);
const capabilities = (m.capabilities ?? undefined) as { widget?: { slot?: unknown }; tripPage?: { replaces?: unknown; position?: unknown }; provides?: unknown; emits?: unknown } | undefined;
// A plugin whose target host only the OPERATOR knows (a self-hosted Gotify/ntfy).
// The admin adds the real hosts post-install; see Plugin-Development → Egress.
if (m.operatorEgress !== undefined && typeof m.operatorEgress !== 'boolean') {
errors.push('operatorEgress must be a boolean');
}
if (m.operatorEgress === true && !permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) {
errors.push('operatorEgress requires an http:outbound permission');
}
const capabilities = (m.capabilities ?? undefined) as {
widget?: { slot?: unknown };
tripPage?: { replaces?: unknown; position?: unknown };
notificationChannel?: { title?: unknown; events?: unknown };
provides?: unknown;
emits?: unknown;
} | undefined;
const widget = capabilities?.widget;
if (widget?.slot !== undefined && widget.slot !== 'sidebar' && widget.slot !== 'hero' && widget.slot !== 'place-detail' && widget.slot !== 'day-detail' && widget.slot !== 'reservation-detail') {
errors.push(`widget slot must be "sidebar", "hero", "place-detail", "day-detail" or "reservation-detail", got "${String(widget.slot)}"`);
@@ -122,6 +158,59 @@ export function validateManifest(raw: unknown): ValidationResult {
errors.push('capabilities.tripPage.position must be an integer between 0 and 50');
}
}
// Mirrors the server's PLUGIN_CHANNEL_EVENTS. Admin-scoped events (version_available)
// and inapp-only ones are absent by design: a plugin channel never carries them.
const notificationChannel = capabilities?.notificationChannel;
if (notificationChannel !== undefined) {
if (!permissions.includes('hook:notification-channel')) {
errors.push('capabilities.notificationChannel requires the "hook:notification-channel" permission');
}
if (notificationChannel.title !== undefined && typeof notificationChannel.title !== 'string') {
errors.push('capabilities.notificationChannel.title must be a string');
}
if (notificationChannel.events !== undefined) {
if (!Array.isArray(notificationChannel.events)) errors.push('capabilities.notificationChannel.events must be an array');
else for (const e of notificationChannel.events) {
if (typeof e !== 'string' || !CHANNEL_EVENTS.includes(e)) {
errors.push(`capabilities.notificationChannel.events: "${String(e)}" is not a plugin-deliverable event (${CHANNEL_EVENTS.join(', ')})`);
}
}
}
}
// Settings keys become JSON object keys in the plugin's stored config, so they are
// constrained (mirrors the server's SETTING_KEY_RE). `__proto__`/`constructor` would
// resolve off Object.prototype on read and make a required field look configured for
// a user who configured nothing.
if (Array.isArray(m.settings)) {
for (const s of m.settings as Array<Record<string, unknown>>) {
if (!s || typeof s !== 'object' || s.key === undefined) continue;
const key = String(s.key);
if (!key) continue;
if (!SETTING_KEY_RE.test(key) || RESERVED_SETTING_KEYS.has(key)) {
errors.push(`invalid settings key "${key}" (letters, digits, . _ - ; must start with a letter; 1–64 chars)`);
}
if (s.scope !== undefined && s.scope !== 'user' && s.scope !== 'instance') {
errors.push(`settings["${key}"].scope must be "user" or "instance"`);
}
}
}
// Settings-page action buttons ("Test connection"). Keys share the settings-key rules.
if (m.actions !== undefined) {
if (!Array.isArray(m.actions)) errors.push('actions must be an array');
else {
if (m.actions.length > 8) errors.push('at most 8 actions');
const seen = new Set<string>();
for (const a of m.actions as Array<Record<string, unknown>>) {
if (!a || typeof a !== 'object') { errors.push('each action must be an object'); continue; }
const key = String(a.key ?? '');
if (!key) { errors.push('action must have a "key"'); continue; }
if (!SETTING_KEY_RE.test(key) || RESERVED_SETTING_KEYS.has(key)) errors.push(`invalid action key "${key}"`);
if (seen.has(key)) errors.push(`duplicate action "${key}"`);
seen.add(key);
if (a.label !== undefined && typeof a.label !== 'string') errors.push(`action "${key}" label must be a string`);
}
}
}
validateCapabilityNames(capabilities?.provides, 'provides', errors);
validateCapabilityNames(capabilities?.emits, 'emits', errors);
+90 -1
View File
@@ -40,7 +40,7 @@ describe('permission catalog', () => {
'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips',
'db:meta',
'ws:broadcast:trip', 'ws:broadcast:user',
'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'http:outbound',
'hook:photo-provider', 'hook:calendar-source', 'hook:notification-channel', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'http:outbound',
]);
for (const p of PERMISSION_CATALOG) expect(p.hint.length).toBeGreaterThan(0); // every option is described
});
@@ -120,3 +120,92 @@ describe('validateManifest dependency rules', () => {
expect(validateManifest({ ...base, pluginDependencies: [{ id: 'koffi', version: '*' }, { id: 'koffi', version: '^1' }] }).ok).toBe(false);
});
});
describe('notification-channel template', () => {
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chan-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
function make(over: Parameters<typeof scaffold>[3] = {}) {
scaffold('my-gotify', 'integration', tmp, { template: 'notification-channel', ...over });
return JSON.parse(fs.readFileSync(path.join(tmp, 'my-gotify', 'trek-plugin.json'), 'utf8'));
}
it('scaffolds a valid manifest with the hook grant, matching egress, and the capability', () => {
const m = make({ egress: ['gotify.example.com'] });
expect(m.type).toBe('integration'); // a channel is NOT a new plugin type
expect(m.permissions).toContain('hook:notification-channel');
expect(m.permissions).toContain('http:outbound:gotify.example.com');
expect(m.egress).toEqual(['gotify.example.com']);
expect(m.capabilities.notificationChannel.title).toBe('My Gotify');
expect(validateManifest(m).ok).toBe(true);
});
it('declares the per-user credential as a secret, required, user-scoped field', () => {
const m = make();
const token = m.settings.find((s: { key: string }) => s.key === 'appToken');
// These three flags are exactly what makes the host hand it to the hook as `config`.
expect(token.scope).toBe('user');
expect(token.secret).toBe(true);
expect(token.required).toBe(true);
});
it('generates a server entry that implements the hook', () => {
make();
const js = fs.readFileSync(path.join(tmp, 'my-gotify', 'server', 'index.js'), 'utf8');
expect(js).toContain('notificationChannel');
expect(js).toContain('async send(msg, config, ctx)');
expect(js).toContain('config.appToken');
// The invariant is about WHERE ctx.settings.get() may be used, not whether:
// - the HOOK is userless, so it must read credentials from its `config` argument;
// - an ACTION is user-initiated, so it SHOULD read them via ctx.settings.get().
const hookBody = js.slice(js.indexOf('notificationChannel:'));
expect(hookBody).not.toContain('ctx.settings.get');
const actionBody = js.slice(js.indexOf('actions:'), js.indexOf('hooks:'));
expect(actionBody).toContain('await ctx.settings.get');
});
it('scaffolds a Test connection action so the user can verify credentials', () => {
const m = make();
expect(m.actions).toEqual([{ key: 'testConnection', label: 'Test connection' }]);
expect(validateManifest(m).ok).toBe(true);
});
it('rejects a malformed action key', () => {
const m = make();
expect(validateManifest({ ...m, actions: [{ key: '__proto__', label: 'x' }] }).ok).toBe(false);
expect(validateManifest({ ...m, actions: [{ key: 'a', label: 'x' }, { key: 'a', label: 'y' }] }).ok).toBe(false);
});
it('is server-only: no client/ dir, and a UI type is refused', () => {
make();
expect(fs.existsSync(path.join(tmp, 'my-gotify', 'client'))).toBe(false);
expect(() => scaffold('my-widget', 'widget', tmp, { template: 'notification-channel' })).toThrow(/requires type "integration"/);
});
});
describe('capabilities.notificationChannel validation', () => {
const base = {
id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration',
nativeModules: false, permissions: ['hook:notification-channel'],
};
it('accepts a narrowed event list', () => {
const r = validateManifest({ ...base, capabilities: { notificationChannel: { events: ['trip_invite', 'booking_change'] } } });
expect(r.ok).toBe(true);
});
it('rejects an event a plugin channel can never carry', () => {
// version_available is admin-scoped — it goes out over the admin's own credentials.
const r = validateManifest({ ...base, capabilities: { notificationChannel: { events: ['version_available'] } } });
expect(r.ok).toBe(false);
expect(r.errors!.join(' ')).toMatch(/not a plugin-deliverable event/);
});
it('rejects the capability without the matching grant', () => {
const r = validateManifest({ ...base, permissions: ['db:own'], capabilities: { notificationChannel: { title: 'X' } } });
expect(r.ok).toBe(false);
expect(r.errors!.join(' ')).toMatch(/requires the "hook:notification-channel" permission/);
});
});
+43
View File
@@ -3583,6 +3583,49 @@ function runMigrations(db: Database.Database): void {
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_hidden_countries_user ON hidden_countries (user_id);');
},
// Operator-supplied egress hosts for a plugin (#plugins).
// A plugin's egress allow-list is fixed in its manifest at publish time, but a plugin
// that talks to a SELF-HOSTED service (Gotify, ntfy, …) cannot know the operator's
// hostname — so a community plugin could serve nobody. These rows let the ADMIN add
// hosts post-install; the runtime unions them into the child's allow-list at spawn.
// Consent stays with the admin (never the end user), exactly as for manifest egress.
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS plugin_egress_hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
host TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (plugin_id, host)
);
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_egress_hosts_plugin ON plugin_egress_hosts (plugin_id);');
// Whether the plugin DECLARED that it needs operator-supplied hosts. Only such a
// plugin may have hosts added — an admin can never widen egress for a plugin that
// never asked for it, so the install-time consent still bounds what's possible.
const columns = db.prepare("PRAGMA table_info('plugins')").all() as Array<{ name: string }>;
if (!columns.some((c) => c.name === 'operator_egress')) {
db.exec('ALTER TABLE plugins ADD COLUMN operator_egress INTEGER NOT NULL DEFAULT 0;');
}
},
// Settings-page action buttons a plugin contributes ("Test connection", "Sync now").
// Descriptors only — the handler lives in the plugin's code and is invoked host-side
// with the CLICKING user bound, so it can read that user's own settings.
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS plugin_actions (
plugin_id TEXT NOT NULL,
action_key TEXT NOT NULL,
label TEXT NOT NULL,
hint TEXT,
danger INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (plugin_id, action_key)
);
`);
},
];
if (currentVersion < migrations.length) {
@@ -103,6 +103,18 @@ export class NotificationsController {
return this.notifications.testNtfy({ topic: resolvedTopic, server: resolvedServer ?? null, token: resolvedToken });
}
/**
* Generic channel test, dispatched through the registry — this is how a plugin
* channel's "Send test" button works. The three routes above stay as they are:
* they carry bespoke masked-secret and admin-fallback resolution whose exact
* error bodies the client depends on, and folding them in here would risk that.
*/
@Post('test/:channelId')
@HttpCode(200)
async testChannel(@CurrentUser() user: User, @Param('channelId') channelId: string): Promise<ChannelTestResult> {
return this.notifications.testChannel(user.id, channelId);
}
@Get('in-app')
listInApp(
@CurrentUser() user: User,
@@ -20,6 +20,7 @@ import {
respondToBoolean,
} from '../../services/inAppNotifications';
import { getPreferencesMatrix, setPreferences } from '../../services/notificationPreferencesService';
import { getChannel } from '../../services/notifications/channelRegistry';
type NtfyConfig = ReturnType<typeof getAdminNtfyConfig>;
type RespondResult = Awaited<ReturnType<typeof respondToBoolean>>;
@@ -39,6 +40,19 @@ export class NotificationsService {
return getPreferencesMatrix(userId, role, 'user');
}
/** Send a test notification over any registered channel (built-in or plugin). */
async testChannel(userId: number, channelId: string): Promise<ChannelTestResult> {
const channel = getChannel(channelId);
if (!channel) return { success: false, error: 'Unknown channel' };
if (!channel.test) return { success: false, error: 'This channel does not support test sends' };
if (!channel.isConfiguredFor(userId)) return { success: false, error: 'Channel is not configured for this user' };
try {
return await channel.test(userId);
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : 'Unknown error' };
}
}
setPreferences(userId: number, body: Parameters<typeof setPreferences>[1]): void {
setPreferences(userId, body);
}
+12 -5
View File
@@ -67,17 +67,24 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
if (existing) {
db.prepare(
`UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?,
min_trek_version = ?, permissions = ?, capabilities = ?, dependencies = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.id);
min_trek_version = ?, permissions = ?, capabilities = ?, dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0, m.id);
} else {
db.prepare(
// granted_permissions '' (empty, not '[]') marks "never consented" so the
// first activation is distinguishable from a plugin consented to zero perms.
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, dependencies, granted_permissions, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies);
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, dependencies, operator_egress, granted_permissions, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0);
}
// Refresh the settings-page action descriptors from the manifest.
db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(m.id);
const insertAction = db.prepare(
'INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
);
m.actions.forEach((a, i) => insertAction.run(m.id, a.key, a.label, a.hint ?? null, a.danger ? 1 : 0, i));
// Refresh the settings-field descriptors from the manifest.
db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(m.id);
const insert = db.prepare(
+158 -2
View File
@@ -1,5 +1,6 @@
import semver from 'semver';
import { isKnownPermission } from '../protocol/envelope';
import type { NotifEventType } from '../../../services/notificationPreferencesService';
/**
* Parse + validate a plugin's trek-plugin.json (#plugins, M4). Kept deliberately
@@ -38,9 +39,32 @@ export interface TripPageCapability {
position?: number;
}
/** Config for a plugin that implements the `notificationChannel` hook. */
export interface NotificationChannelCapability {
/** Column name in the notification preferences matrix. Defaults to the plugin's name. */
title?: string;
/** Narrow which events this channel carries. Defaults to every non-admin event. */
events?: string[];
}
/**
* A button the plugin contributes to its own settings page ("Test connection",
* "Sync now"). Unlike the notification-channel hook, an action is USER-INITIATED — the
* acting user is whoever clicked — so `ctx.settings.get()` and trip reads work normally
* inside it, which is exactly what a "test my credentials" button needs.
*/
export interface ManifestAction {
key: string;
label: string;
hint?: string;
/** Render as destructive and ask for confirmation before running. */
danger?: boolean;
}
export interface PluginCapabilities {
widget?: WidgetCapability;
tripPage?: TripPageCapability;
notificationChannel?: NotificationChannelCapability;
/** Function names this plugin exposes to its dependents via ctx.plugins.call. */
provides?: string[];
/** Event names this plugin publishes to its dependents via ctx.events.emit. */
@@ -68,7 +92,15 @@ export interface PluginManifest {
nativeModules: boolean;
permissions: string[];
egress: string[];
/**
* The plugin talks to a service whose hostname only the OPERATOR knows (a self-hosted
* Gotify, ntfy, Uptime Kuma…). Its manifest `egress` can never cover that, so an admin
* may add hosts post-install and the runtime unions them into the child's allow-list.
* Consent stays with the admin — an end user can never widen egress.
*/
operatorEgress: boolean;
settings: ManifestSettingField[];
actions: ManifestAction[];
capabilities: PluginCapabilities;
/** Addon ids that must be enabled for this plugin to activate (format-only here). */
requiredAddons: string[];
@@ -129,6 +161,12 @@ export function parseManifest(raw: unknown): PluginManifest {
if (badOutbound !== undefined) throw new ManifestError(`invalid http:outbound host "${badOutbound}"`);
const egress = arr(m.egress).map(String);
if (m.operatorEgress !== undefined && typeof m.operatorEgress !== 'boolean') {
throw new ManifestError('operatorEgress must be a boolean');
}
if (m.operatorEgress === true && !permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) {
throw new ManifestError('operatorEgress requires an http:outbound permission');
}
if (permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:')) && egress.length === 0) {
throw new ManifestError('http:outbound declared but egress[] is empty');
}
@@ -152,7 +190,9 @@ export function parseManifest(raw: unknown): PluginManifest {
nativeModules: false,
permissions,
egress,
operatorEgress: m.operatorEgress === true,
settings: parseSettings(m.settings),
actions: parseActions(m.actions),
capabilities: parseCapabilities(m.capabilities),
requiredAddons: parseRequiredAddons(m.requiredAddons),
pluginDependencies: parsePluginDependencies(m.pluginDependencies, id),
@@ -231,6 +271,26 @@ function parseCapabilities(raw: unknown): PluginCapabilities {
}
if (Object.keys(page).length) out.tripPage = page;
}
if (c.notificationChannel && typeof c.notificationChannel === 'object') {
const nc = c.notificationChannel as Record<string, unknown>;
const channel: NotificationChannelCapability = {};
const title = optStr(nc.title);
if (title) channel.title = title;
if (nc.events !== undefined) {
if (!Array.isArray(nc.events)) throw new ManifestError('capabilities.notificationChannel.events must be an array');
const events: string[] = [];
for (const v of nc.events) {
if (typeof v !== 'string' || !(PLUGIN_CHANNEL_EVENTS as readonly string[]).includes(v)) {
throw new ManifestError(
`capabilities.notificationChannel.events: "${String(v)}" is not a plugin-deliverable event (${PLUGIN_CHANNEL_EVENTS.join(', ')})`,
);
}
if (!events.includes(v)) events.push(v);
}
if (events.length) channel.events = events;
}
out.notificationChannel = channel;
}
const provides = parseCapabilityNames(c.provides, 'provides');
if (provides.length) out.provides = provides;
const emits = parseCapabilityNames(c.emits, 'emits');
@@ -238,6 +298,35 @@ function parseCapabilities(raw: unknown): PluginCapabilities {
return out;
}
/**
* Events a plugin notification channel may carry. Two exclusions, both deliberate:
* `version_available` is admin-scoped (it goes out over the admin's own global
* credentials, and a community plugin is never a recipient of one), and
* `synology_session_cleared` is in-app only.
*
* Spelled out rather than derived from the notification service, so the plugin
* installer carries no runtime dependency on it. The guard below is what keeps the
* two in step — same trick as `_eventKeyDriftGuard` in services/notifications.ts.
*/
export const PLUGIN_CHANNEL_EVENTS = [
'trip_invite',
'booking_change',
'trip_reminder',
'todo_due',
'vacay_invite',
'collection_invite',
'photos_shared',
'collab_message',
'packing_tagged',
'plugin_notification',
] as const;
// Compile-time guard: every id above must still be a real notification event.
// If one is renamed or dropped, this stops compiling.
type _ChannelEventsAreReal = (typeof PLUGIN_CHANNEL_EVENTS)[number] extends NotifEventType ? true : never;
const _channelEventDriftGuard: _ChannelEventsAreReal = true;
void _channelEventDriftGuard;
// Core planner tabs a trip-page plugin may replace while it is active. 'plan' is
// deliberately NOT in this list — a trip always keeps its planner view, so a
// plugin can take over bookings/transports/…, never the whole trip.
@@ -255,12 +344,24 @@ function parseCapabilityNames(raw: unknown, field: string): string[] {
return out;
}
/**
* A settings field key. Constrained because the key is used as a JSON object key in
* the plugin's stored config: an unconstrained one (`__proto__`, `constructor`) resolves
* off Object.prototype on read, so a *required* field with such a name would look
* "configured" for every user who had configured nothing — enough, for a notification
* channel, to be dispatched to everyone without anyone entering credentials.
* (safeParse in plugins.service.ts now uses a null-prototype object too; this is the
* front door, that is the backstop.)
*/
const SETTING_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/;
const RESERVED_SETTING_KEYS = new Set(['constructor', 'prototype', '__proto__']);
function parseSettings(raw: unknown): ManifestSettingField[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((s): s is Record<string, unknown> => !!s && typeof s === 'object')
.map((s): ManifestSettingField => ({
key: String(s.key ?? ''),
key: assertSettingKey(String(s.key ?? '')),
label: optStr(s.label),
input_type: optStr(s.input_type) ?? 'text',
placeholder: optStr(s.placeholder),
@@ -268,12 +369,67 @@ function parseSettings(raw: unknown): ManifestSettingField[] {
required: !!s.required,
secret: !!s.secret,
scope: s.scope === 'user' ? 'user' : 'instance',
options: Array.isArray(s.options) ? (s.options as Array<{ value: string; label: string }>) : undefined,
options: parseSettingOptions(s.options),
oauth: s.oauth && typeof s.oauth === 'object' ? (s.oauth as { initPath?: string; callbackPath?: string }) : undefined,
}))
.filter((s) => s.key);
}
/**
* A `select` field's options. The client renders `{value, label}`, so a bare string list —
* the obvious thing to write, and what a manifest was silently allowed to carry — rendered
* every option BLANK (value/label both undefined). Coerce the string form instead of
* failing on it, and reject anything that is neither.
*/
function parseSettingOptions(raw: unknown): Array<{ value: string; label: string }> | undefined {
if (raw === undefined) return undefined;
if (!Array.isArray(raw)) throw new ManifestError('settings option list must be an array');
return raw.map((o) => {
if (typeof o === 'string' || typeof o === 'number') return { value: String(o), label: String(o) };
if (o && typeof o === 'object') {
const { value, label } = o as { value?: unknown; label?: unknown };
if (value === undefined || value === null || String(value) === '') {
throw new ManifestError('settings option must have a non-empty "value"');
}
return { value: String(value), label: String(label ?? value) };
}
throw new ManifestError(`invalid settings option ${JSON.stringify(o)} (expected a string or { value, label })`);
});
}
/** Settings-page action buttons. Bounded: the host renders the label itself. */
function parseActions(raw: unknown): ManifestAction[] {
if (raw === undefined) return [];
if (!Array.isArray(raw)) throw new ManifestError('actions must be an array');
if (raw.length > 8) throw new ManifestError('at most 8 actions');
const out: ManifestAction[] = [];
for (const a of raw) {
if (!a || typeof a !== 'object') throw new ManifestError('each action must be an object');
const { key, label, hint, danger } = a as Record<string, unknown>;
const k = assertSettingKey(String(key ?? ''));
if (!k) throw new ManifestError('action must have a "key"');
if (out.some((x) => x.key === k)) throw new ManifestError(`duplicate action "${k}"`);
out.push({
key: k,
label: String(label ?? k).slice(0, 60),
hint: hint === undefined ? undefined : String(hint).slice(0, 200),
danger: danger === true,
});
}
return out;
}
function assertSettingKey(key: string): string {
// An empty key is dropped by the .filter below (a manifest may legitimately omit a
// field), but a PRESENT key that is malformed is a hard reject — silently ignoring it
// would leave the plugin expecting a setting the host will never store.
if (!key) return key;
if (!SETTING_KEY_RE.test(key) || RESERVED_SETTING_KEYS.has(key)) {
throw new ManifestError(`invalid settings key "${key}" (letters, digits, . _ - ; must start with a letter; 1–64 chars)`);
}
return key;
}
function str(v: unknown, name: string): string {
if (typeof v !== 'string' || !v) throw new ManifestError(`missing/invalid "${name}"`);
return v;
@@ -4,6 +4,10 @@ import { db } from '../../db/database';
import { pluginsEnabled } from './kill-switch';
import { setPluginEventSink } from '../../plugin-event-sink';
import { setUserDeletedSink } from '../../plugin-user-lifecycle';
import { setPluginChannelSource, pluginChannelId, type ChannelMessage, type ExternalChannel } from '../../services/notifications/channelRegistry';
import { readUserSettingsDecrypted, hasRequiredUserSettings } from './plugins.service';
import { PLUGIN_CHANNEL_EVENTS } from './install/manifest';
import { stripEmoji } from './text-sanitize';
import { applyStagedPluginTrees, setStagedRestoreApplier } from './plugin-backup';
import { decrypt_api_key } from '../../services/apiKeyCrypto';
import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor';
@@ -26,6 +30,21 @@ import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enab
const HTTP_OUTBOUND = 'http:outbound:';
// Mirrors HOST_RE in install/manifest.ts: an exact hostname or a `*.`-prefixed wildcard
// with a real multi-label suffix. Rejects a bare `*`, a whole-TLD wildcard, a scheme and
// any embedded space — the string is interpolated into the egress guard and the CSP.
const EGRESS_HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i;
/** Hosts an admin added post-install for a plugin that declared `operatorEgress`. */
function operatorEgressHosts(id: string): string[] {
try {
return (db.prepare('SELECT host FROM plugin_egress_hosts WHERE plugin_id = ? ORDER BY host').all(id) as Array<{ host: string }>)
.map((r) => r.host);
} catch {
return []; // table absent (a slimmed test app) — never block activation
}
}
/**
* Remove `<plugins>/<id>` whether it is a real directory, a POSIX symlink or a
* Windows junction — WITHOUT ever following a dev-link into (and deleting) the
@@ -151,6 +170,10 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
// Forward core trip events to plugins that subscribed (events:subscribe). The
// sink is name-only + fire-and-forget, so it can never block a core broadcast.
setPluginEventSink((tripId, event, meta) => this.supervisor.deliverEvent(tripId, event, meta));
// Expose plugin notification channels to the (plain, non-Nest) notification
// service. Pull-based: it calls this on every dispatch, so enabling or removing
// a plugin takes effect immediately with nothing to invalidate.
setPluginChannelSource(() => this.notificationChannels());
// Fan a deleted account out to plugins so they can erase their own per-user data.
// Enqueued durably (survives restart), so nothing is lost if a plugin is offline.
setUserDeletedSink((userId) => this.enqueueUserErasure(userId));
@@ -461,10 +484,60 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
// Mark it enabled (admin intent) so it reboots after restarts/crashes.
db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id);
const config = decryptConfig(parseObject(row.config));
const egress = declared.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length));
const manifestHosts = declared.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length));
// Union in the hosts the ADMIN added post-install. A plugin that talks to a
// self-hosted service can't name the operator's hostname in its manifest, so without
// this a community plugin (a Gotify channel, an ntfy channel) serves nobody. Only a
// plugin that DECLARED operatorEgress can have hosts, so the install-time consent
// still bounds what is possible — and it is always the admin, never an end user,
// who widens it. The egress list is spawn-time only, which is why changing it
// re-spawns the plugin (see setOperatorEgressHosts).
const egress = [...new Set([...manifestHosts, ...operatorEgressHosts(id)])];
await this.supervisor.activate(id, new Set(declared), config, egress);
}
/** Hosts an admin added for this plugin (empty unless it declared `operatorEgress`). */
operatorEgressHosts(id: string): string[] {
return operatorEgressHosts(id);
}
/** Does this plugin's manifest declare that it needs operator-supplied hosts? */
wantsOperatorEgress(id: string): boolean {
const row = db.prepare('SELECT operator_egress FROM plugins WHERE id = ?').get(id) as { operator_egress: number } | undefined;
return row?.operator_egress === 1;
}
/**
* Replace the admin-supplied egress hosts for a plugin, then RE-SPAWN it so the child's
* guard picks the new list up — `installEgressGuard` runs once at init and a second
* `init` is deliberately refused, so there is no way to widen a live child's allow-list.
*/
async setOperatorEgressHosts(id: string, hosts: string[]): Promise<string[]> {
if (!this.wantsOperatorEgress(id)) {
throw new ForbiddenResource(`plugin ${id} did not declare operatorEgress`);
}
const clean: string[] = [];
for (const raw of hosts) {
const host = String(raw ?? '').trim().toLowerCase().replace(/\.$/, '');
if (!host) continue;
// Same shape the manifest enforces — this string is interpolated into the egress
// guard AND the iframe CSP connect-src, so a bare `*` or a scheme must never land.
if (host === '*' || !EGRESS_HOST_RE.test(host)) throw new ForbiddenResource(`invalid host "${raw}"`);
if (!clean.includes(host)) clean.push(host);
}
db.transaction(() => {
db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id);
const ins = db.prepare('INSERT OR IGNORE INTO plugin_egress_hosts (plugin_id, host) VALUES (?, ?)');
for (const h of clean) ins.run(id, h);
})();
// Re-spawn so a live child actually gets the new allow-list.
if (this.isActive(id)) {
await this.supervisor.disable(id);
await this.activate(id);
}
return clean;
}
async deactivate(id: string): Promise<void> {
await this.supervisor.disable(id);
closePluginDataDb(id);
@@ -653,9 +726,19 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
removePluginCodeEntry(pluginCodeDir(id));
db.prepare('DELETE FROM plugins WHERE id = ?').run(id);
db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(id);
try { db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(id); } catch { /* table absent */ }
// The admin's egress consent dies with the plugin. Unconditional: leaving it would
// silently grant a LATER plugin that reuses this id the hosts the admin approved for
// a different one.
try { db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id); } catch { /* table absent */ }
// Scheduled tasks are operational (not user data), so they go unconditionally —
// a scheduled callback for a plugin that no longer exists must never fire.
db.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ?').run(id);
// If it was a notification channel, retire the channel too. Unconditional, for the
// same reason as the settings fields: these are TREK's config ABOUT the plugin, and
// leaving them means a later plugin that reuses this id silently inherits every
// user's opt-outs and the admin's enablement.
this.retireNotificationChannel(id);
if (deleteData) {
removePluginData(id);
db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
@@ -770,6 +853,121 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
return semver.satisfies(target.version ?? '0.0.0', dep.version, { includePrerelease: true });
}
/**
* Drop every trace of a plugin's notification channel: the per-user event opt-outs
* and the id in the admin's `notification_channels` list.
*/
private retireNotificationChannel(id: string): void {
// Drop the users' per-event opt-outs for this channel. A plugin channel is never
// listed in the admin's `notification_channels` CSV (it is active by virtue of the
// plugin being enabled), so there is nothing to scrub there.
//
// Defensive, like the boot path above: an uninstall must still complete in a context
// without the notification tables (a slimmed test app that imports only the plugin
// module).
try {
db.prepare('DELETE FROM notification_channel_preferences WHERE channel = ?').run(pluginChannelId(id));
} catch { /* no notifications schema here — nothing to retire */ }
}
/**
* Every active, granted `notificationChannel` provider, as an ExternalChannel the
* notification service can dispatch to. Rebuilt on every read (the registry pulls
* through setPluginChannelSource), so a plugin that is disabled or uninstalled
* simply stops being a channel — no cache to invalidate.
*
* The label, the event set and the configured-check are all answered from the
* manifest and the DB, with no IPC: the child is only ever woken to actually send.
*/
notificationChannels(): ExternalChannel[] {
if (!pluginsEnabled()) return [];
return this.supervisor.providersOf('notificationChannel').map((id) => {
const row = db.prepare('SELECT name, capabilities FROM plugins WHERE id = ?').get(id) as
| { name: string; capabilities: string }
| undefined;
let cap: { title?: string; events?: string[] } = {};
try {
cap = ((JSON.parse(row?.capabilities || '{}') as Record<string, unknown>).notificationChannel ?? {}) as typeof cap;
} catch { /* a malformed capabilities blob just means "no overrides" */ }
const allowed = new Set(cap.events?.length ? cap.events : PLUGIN_CHANNEL_EVENTS);
return {
id: pluginChannelId(id),
source: 'plugin' as const,
// A plugin-supplied display string, bounded and emoji-stripped like every other
// one the host renders (cf. the calendar/photo controllers) — it becomes a column
// header in the notification preferences matrix.
label: stripEmoji(String(cap.title || row?.name || id)).slice(0, 40) || id,
// Where the user actually configures this channel. Settings is ONE page with a
// tab, not a route per plugin — a `/settings/plugins/<id>` path would 404.
settingsPath: '/settings?tab=plugins',
// Admin-scoped events never reach a plugin channel — PLUGIN_CHANNEL_EVENTS
// excludes them, and a manifest can only narrow that set, never widen it.
supportsEvent: (event: string) => allowed.has(event),
isConfiguredFor: (userId: number) => hasRequiredUserSettings(id, userId),
sendToUser: (userId: number, msg: ChannelMessage) =>
this.invokeHook(
id,
'notificationChannel',
'send',
[{ event: msg.event, title: msg.title, body: msg.body, url: msg.url, tripName: msg.tripName }, readUserSettingsDecrypted(id, userId)],
// No acting user: a notification is host-initiated for an arbitrary
// recipient, so the hook gets the recipient's config as an argument
// rather than the right to read anything AS them.
undefined,
8000,
),
test: async (userId: number) => {
try {
await this.invokeHook(id, 'notificationChannel', 'test', [readUserSettingsDecrypted(id, userId)], undefined, 8000);
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
},
};
});
}
/** The settings-page action buttons a plugin declared (descriptors, from the DB). */
actionsOf(id: string): Array<{ key: string; label: string; hint?: string; danger: boolean }> {
try {
return (
db.prepare('SELECT action_key, label, hint, danger FROM plugin_actions WHERE plugin_id = ? ORDER BY sort_order').all(id) as Array<{
action_key: string; label: string; hint: string | null; danger: number;
}>
).map((r) => ({ key: r.action_key, label: r.label, hint: r.hint ?? undefined, danger: r.danger === 1 }));
} catch {
return []; // table absent (a slimmed test app)
}
}
/**
* Run a settings-page action for the user who clicked it. The acting user is bound
* host-side (never named by the plugin), so the action reads THAT user's settings and
* any trip read it makes is membership-checked against them.
*/
async invokeAction(id: string, key: string, actingUserId: number): Promise<{ ok: boolean; message?: string }> {
if (!this.actionsOf(id).some((a) => a.key === key)) {
throw new ForbiddenResource(`plugin ${id} did not declare action "${key}"`);
}
const cap = (v: unknown) => stripEmoji(String(v)).slice(0, 200);
try {
const raw = (await this.supervisor.invoke(id, 'invoke.action', { key }, { actingUserId, timeoutMs: 15_000 })) as
| { ok?: unknown; message?: unknown }
| undefined;
// The message is plugin-supplied and rendered to the user — bound it like every
// other plugin string the host displays.
return { ok: raw?.ok !== false, message: raw?.message === undefined ? undefined : cap(raw.message) };
} catch (e) {
// A plugin that throws is reporting a FAILED action, not a server fault — that is
// the documented contract ("throwing == { ok: false }"), and it is the normal path
// for "your credentials don't work". Surface the reason to the user.
return { ok: false, message: cap(e instanceof Error ? e.message : 'Action failed') };
}
}
/** A plugin's declared `capabilities.provides`/`capabilities.emits` (from the DB). */
private capabilityList(id: string, field: 'provides' | 'emits'): string[] {
const row = db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
@@ -1,8 +1,9 @@
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { pluginsEnabled } from './kill-switch';
import { PluginsService } from './plugins.service';
import { PluginRuntimeService } from './plugin-runtime.service';
import { db } from '../../db/database';
/**
@@ -19,7 +20,10 @@ import { db } from '../../db/database';
@Controller('api/plugin-settings')
@UseGuards(JwtAuthGuard)
export class PluginUserSettingsController {
constructor(private readonly plugins: PluginsService) {}
constructor(
private readonly plugins: PluginsService,
private readonly runtime: PluginRuntimeService,
) {}
private activeWithUserFields(id: string): boolean {
const row = db.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id);
@@ -27,10 +31,43 @@ export class PluginUserSettingsController {
}
@Get(':id')
get(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): { fields: unknown[]; config: Record<string, unknown> } {
get(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): {
fields: unknown[];
config: Record<string, unknown>;
actions: Array<{ key: string; label: string; hint?: string; danger: boolean }>;
} {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { fields: [], config: {} };
return { fields: this.plugins.userSettingsFields(id), config: this.plugins.getUserConfig(id, userId) };
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { fields: [], config: {}, actions: [] };
return {
fields: this.plugins.userSettingsFields(id),
config: this.plugins.getUserConfig(id, userId),
actions: this.runtime.actionsOf(id),
};
}
/**
* Run one of the plugin's declared settings-page actions ("Test connection").
* USER-INITIATED: the acting user is the caller, bound host-side — so the action reads
* the CALLER's own settings and any trip read it makes is checked against them. It can
* never act as anyone else, and a key the plugin didn't declare is refused.
*/
@Post(':id/actions/:key')
@HttpCode(200)
async runAction(
@Param('id') id: string,
@Param('key') key: string,
@Req() req: Request & { user?: { id: number } },
): Promise<{ ok: boolean; message?: string }> {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) {
throw new HttpException({ error: 'Plugin is not active' }, 404);
}
try {
return await this.runtime.invokeAction(id, key, userId);
} catch (e) {
// A failing action is a RESULT, not a server error — show the user why.
return { ok: false, message: (e instanceof Error ? e.message : 'Action failed').slice(0, 200) };
}
}
@Post(':id')
@@ -103,6 +103,28 @@ export class PluginsController {
return { config: this.plugins.updateInstanceConfig(id, body || {}) };
}
/**
* Operator-supplied egress hosts. A plugin that talks to a SELF-HOSTED service can't
* name the operator's hostname in its manifest, so an admin adds it here — and the
* runtime re-spawns the plugin with the widened allow-list. Admin-only (this controller
* is admin-guarded): an end user can never widen a plugin's egress.
*/
@Get(':id/egress-hosts')
egressHosts(@Param('id') id: string) {
return { supported: this.runtime.wantsOperatorEgress(id), hosts: this.runtime.operatorEgressHosts(id) };
}
@Put(':id/egress-hosts')
async setEgressHosts(@Param('id') id: string, @Body() body: { hosts?: unknown } = {}) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
const hosts = Array.isArray(body.hosts) ? body.hosts.map(String) : [];
try {
return { hosts: await this.runtime.setOperatorEgressHosts(id, hosts) };
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'Invalid hosts' }, 400);
}
}
@Post(':id/activate')
@HttpCode(200)
async activate(@Param('id') id: string, @Body() body: { consent?: boolean } = {}) {
+77 -4
View File
@@ -37,6 +37,15 @@ interface PluginRawRow {
dependencies: string | null;
}
/** Hosts an admin has added for a plugin (0 unless it declared operatorEgress). */
function egressHostCount(id: string): number {
try {
return (db.prepare('SELECT COUNT(*) AS n FROM plugin_egress_hosts WHERE plugin_id = ?').get(id) as { n: number }).n;
} catch {
return 0; // table absent (a slimmed test app)
}
}
export interface PluginListItem {
id: string;
name: string;
@@ -53,6 +62,10 @@ export interface PluginListItem {
permissions: string;
/** Declared capabilities (JSON string) — e.g. widget slot. */
capabilities: string;
/** The plugin declared it needs OPERATOR-supplied egress hosts (a self-hosted target). */
operatorEgress: boolean;
/** How many hosts an admin has actually added — so the card can nudge when it's 0. */
egressHostCount: number;
/** Declared dependencies (parsed) — required addons + plugin deps. */
dependencies: PluginDependencies;
/** Whether this plugin can currently activate, and why not if it can't. */
@@ -67,7 +80,7 @@ export class PluginsService {
const rows = db
.prepare(
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
permissions, capabilities, dependencies
permissions, capabilities, dependencies, operator_egress
FROM plugins
ORDER BY sort_order, name`,
)
@@ -84,9 +97,11 @@ export class PluginsService {
: state.missing.length || state.versionMismatch.length
? 'missingPlugin'
: 'ok';
const { dependencies: _raw, ...rest } = r;
const { dependencies: _raw, operator_egress: _oe, ...rest } = r as PluginRawRow & { operator_egress?: number };
return {
...rest,
operatorEgress: _oe === 1,
egressHostCount: egressHostCount(r.id),
dependencies: deps,
dependencyStatus,
dependencyIssues: { disabledAddons, missing: state.missing, versionMismatch: state.versionMismatch },
@@ -244,12 +259,28 @@ export class PluginsService {
}
}
/**
* Parse a stored plugin config blob into a NULL-PROTOTYPE object.
*
* The prototype matters: a settings key of `__proto__` or `constructor` would otherwise
* resolve off Object.prototype on read, so `config[key]` comes back truthy for a user who
* has configured nothing — and a *required* field with such a name would report as
* configured for everyone. The manifest now rejects those keys at install
* (SETTING_KEY_RE); this makes it impossible regardless, including for a plugin that was
* installed before that check existed.
*/
function safeParse(json: string): Record<string, unknown> {
const empty = () => Object.create(null) as Record<string, unknown>;
try {
const v = JSON.parse(json || '{}');
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
if (!v || typeof v !== 'object' || Array.isArray(v)) return empty();
const out = empty();
// JSON.parse creates `__proto__` as an OWN property (it never invokes the setter), so
// copy own keys across onto the null-prototype object rather than trusting the parse.
for (const [k, val] of Object.entries(v as Record<string, unknown>)) out[k] = val;
return out;
} catch {
return {};
return empty();
}
}
function safeArray(json: string): unknown[] | undefined {
@@ -275,6 +306,48 @@ export function readUserSettingDecrypted(pluginId: string, userId: number, key:
if (v == null) return undefined;
return isSecret ? decrypt_api_key(v) : v;
}
/** Host-only: ALL of a plugin's per-user settings for one user, decrypted.
* This is how a notification-channel hook reaches the recipient's credentials —
* that dispatch is host-initiated with no acting user, so `ctx.settings.get()`
* (which resolves against the acting user) would return undefined there. Never
* send this to a client. */
export function readUserSettingsDecrypted(pluginId: string, userId: number): Record<string, unknown> {
const fields = db
.prepare("SELECT field_key, secret FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user'")
.all(pluginId) as Array<{ field_key: string; secret: number }>;
const row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as
| { config: string }
| undefined;
const stored = safeParse(row?.config ?? '{}');
// Null-prototype for the same reason as safeParse: never let a field key write through
// to Object.prototype on the way out to the plugin.
const out: Record<string, unknown> = Object.create(null);
for (const f of fields) {
const v = stored[f.field_key];
if (v == null) continue;
out[f.field_key] = f.secret === 1 ? decrypt_api_key(v) : v;
}
return out;
}
/** Has this user filled in every `required`, `scope:'user'` field the plugin declares?
* A plugin with no required user fields is configured for everyone (an instance-wide
* channel, e.g. a shared workspace webhook). */
export function hasRequiredUserSettings(pluginId: string, userId: number): boolean {
const required = db
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' AND required = 1")
.all(pluginId) as Array<{ field_key: string }>;
if (required.length === 0) return true;
const row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as
| { config: string }
| undefined;
const stored = safeParse(row?.config ?? '{}');
return required.every(f => {
const v = stored[f.field_key];
return v != null && String(v) !== '';
});
}
function maskSecrets(config: Record<string, unknown>, secretKeys: Set<string>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(config)) {
@@ -328,6 +328,7 @@ export const KNOWN_PERMISSIONS = [
'hook:atlas-layer-provider',
'hook:journal-entry-provider',
'hook:trip-card-provider',
'hook:notification-channel',
// Data-subject-rights hook: the host calls the plugin's deleteUserData /
// exportUserData when a TREK account is erased or its data is exported. Userless
// (the plugin only receives a userId and acts on its OWN db), so it grants no
@@ -74,6 +74,13 @@ interface Registry {
export interface ManifestPreview {
permissions: string[];
egress: string[];
/**
* The plugin talks to a service only the OPERATOR can name (a self-hosted Gotify/ntfy),
* so its `egress` can't be the whole story — an admin adds the real hosts after install.
* Surfaced pre-install so the reviewer knows the network reach is not fully described by
* the hosts listed above.
*/
operatorEgress: boolean;
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>;
license: string | null;
icon: string | null;
@@ -390,6 +397,7 @@ function hostCompatible(v: RegistryVersion, host: string): boolean {
*/
function previewManifest(raw: unknown): ManifestPreview {
const m = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
const operatorEgress = m.operatorEgress === true;
const strings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
const settings = Array.isArray(m.settings)
@@ -413,6 +421,7 @@ function previewManifest(raw: unknown): ManifestPreview {
return {
permissions: strings(m.permissions),
egress: strings(m.egress),
operatorEgress,
settings,
license: typeof m.license === 'string' ? m.license : null,
icon: typeof m.icon === 'string' ? m.icon : null,
@@ -177,6 +177,16 @@ async function handleInvoke(req: { id: string; method: string; params: Record<st
if (!impl || typeof impl[fnName] !== 'function') throw new Error(`no hook ${hookName}.${fnName}`);
const result = await impl[fnName](...args, invCtx);
respond(true, result);
} else if (req.method === 'invoke.action') {
// A settings-page button the user clicked. USER-INITIATED: invCtx carries the
// clicking user, so ctx.settings.get() returns THEIR value and trip reads are
// membership-checked against them (unlike a job or the notificationChannel hook).
const key = req.params.key as string;
const actions = def.actions as Record<string, ((c: PluginContext) => unknown) | undefined> | undefined;
const fn = actions?.[key];
if (typeof fn !== 'function') throw new Error(`no action ${key}`);
const result = await fn(invCtx);
respond(true, result ?? { ok: true });
} else if (req.method === 'invoke.event') {
// A core event fired for a trip. Run every matching subscription. invCtx carries
// NO user (delivered like a job), so trip reads are refused — the handler reacts
@@ -347,6 +347,46 @@ export interface PhotoProvider {
search(query: string, opts: { page: number; limit: number }, ctx: PluginContext): Promise<{ photos: Photo[]; total: number; hasMore: boolean }>;
getById(id: string, ctx: PluginContext): Promise<Photo | null>;
}
/**
* A notification, already rendered by the host into the recipient's language.
* A channel plugin never touches i18n — it only delivers.
*/
export interface NotificationMessage {
/** The TREK event that produced this, e.g. `trip_invite`. */
event: string;
title: string;
body: string;
/** Absolute deep link back into TREK, when the event has one. */
url?: string;
tripName?: string;
}
/**
* Deliver TREK notifications over a channel TREK doesn't ship (Gotify, Pushover, …).
* Needs `hook:notification-channel`.
*
* Unlike every other hook, this one is HOST-initiated for an arbitrary recipient, so
* it runs WITHOUT an acting user: `ctx.settings.get()` returns undefined here and trip
* reads are refused. The recipient's own `scope:'user'` settings are decrypted by the
* host and handed to you as `config` instead — that is the only way to reach them, and
* it is why a channel plugin cannot enumerate the recipient's trips.
*/
export interface NotificationChannel {
send(msg: NotificationMessage, config: Record<string, unknown>, ctx: PluginContext): Promise<void>;
/** Backs the "Send test" button in the user's notification settings. */
test?(config: Record<string, unknown>, ctx: PluginContext): Promise<void>;
}
/**
* Result of a settings-page action. `message` is shown to the user beside the button
* (bounded + emoji-stripped host-side); throwing is equivalent to `{ ok: false }` with
* the error message.
*/
export interface PluginActionResult {
ok?: boolean;
message?: string;
}
export interface CalendarEvent { id: string; title: string; start: string; end: string; allDay: boolean; }
export interface CalendarSource {
getName(ctx: PluginContext): string;
@@ -504,6 +544,16 @@ export interface PluginDefinition {
/** GDPR portability: return the data you hold about a user (own db only), as a
* JSON-serialisable value the host aggregates. Userless. Needs `hook:user-data`. */
exportUserData?(input: { userId: number }, ctx: PluginContext): Promise<unknown> | unknown;
/**
* Buttons on the plugin's own settings page ("Test connection", "Sync now"). The key
* must match an entry in the manifest's `actions`.
*
* USER-INITIATED, so unlike the notificationChannel hook there IS an acting user — the
* person who clicked. `ctx.settings.get()` returns THEIR value and trip reads are
* membership-checked against them, which is what makes a "test my credentials" button
* possible at all.
*/
actions?: Record<string, (ctx: PluginContext) => Promise<PluginActionResult | void> | PluginActionResult | void>;
events?: PluginEventSubscription[];
hooks?: {
photoProvider?: PhotoProvider;
@@ -516,6 +566,7 @@ export interface PluginDefinition {
atlasLayerProvider?: AtlasLayerProvider;
journalEntryProvider?: JournalEntryProvider;
tripCardProvider?: TripCardProvider;
notificationChannel?: NotificationChannel;
};
/** Functions exposed to dependents (names must match manifest capabilities.provides). */
exports?: Record<string, PluginExport>;
@@ -105,6 +105,7 @@ const HOOK_PERMISSION: Readonly<Record<string, string>> = {
atlasLayerProvider: 'hook:atlas-layer-provider',
journalEntryProvider: 'hook:journal-entry-provider',
tripCardProvider: 'hook:trip-card-provider',
notificationChannel: 'hook:notification-channel',
};
export class PluginSupervisor {
@@ -1,10 +1,23 @@
import type Database from 'better-sqlite3';
import { db } from '../db/database';
import { decrypt_api_key } from './apiKeyCrypto';
import { isSmtpConfigured } from './notifications';
import { listChannels, type ExternalChannel } from './notifications/channelRegistry';
// Side-effect import: populates the registry with email/webhook/ntfy. Safe from
// here — builtins only reaches ../notifications, which imports this module's
// types with `import type`, so there is no runtime cycle.
import './notifications/builtins';
// ── Types ──────────────────────────────────────────────────────────────────
export type NotifChannel = 'email' | 'webhook' | 'inapp' | 'ntfy';
/**
* A channel id. Open by design: the built-ins below plus `plugin:<id>` for any
* plugin implementing the `notificationChannel` hook. The DB column is a bare
* TEXT with no CHECK constraint and the Zod contract is already a string record,
* so the set was only ever closed in TypeScript.
*/
export type NotifChannel = string;
export const INAPP_CHANNEL = 'inapp';
export type NotifEventType =
| 'trip_invite'
@@ -20,29 +33,51 @@ export type NotifEventType =
| 'synology_session_cleared'
| 'plugin_notification';
export interface AvailableChannels {
email: boolean;
webhook: boolean;
inapp: boolean;
ntfy: boolean;
/** Every event, in the order the preferences UI lists them. */
export const ALL_EVENT_TYPES: NotifEventType[] = [
'trip_invite',
'booking_change',
'trip_reminder',
'todo_due',
'vacay_invite',
'collection_invite',
'photos_shared',
'collab_message',
'packing_tagged',
'version_available',
'synology_session_cleared',
'plugin_notification',
];
/** One channel column in the preferences matrix. */
export interface ChannelDescriptor {
id: string;
source: 'builtin' | 'plugin';
/** Built-ins: an i18n key. */
labelKey?: string;
/** Plugin channels: a literal display name. */
label?: string;
/** Where the user sets their credentials for this channel, if anywhere. */
settingsPath?: string;
/** The admin has enabled this channel (in-app is always on). */
active: boolean;
/** This user has credentials for it. */
configured: boolean;
}
// Which channels are implemented for each event type.
// Only implemented combos show toggles in the user preferences UI.
const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
trip_invite: ['inapp', 'email', 'webhook', 'ntfy'],
booking_change: ['inapp', 'email', 'webhook', 'ntfy'],
trip_reminder: ['inapp', 'email', 'webhook', 'ntfy'],
todo_due: ['inapp', 'email', 'webhook', 'ntfy'],
vacay_invite: ['inapp', 'email', 'webhook', 'ntfy'],
collection_invite: ['inapp', 'email', 'webhook', 'ntfy'],
photos_shared: ['inapp', 'email', 'webhook', 'ntfy'],
collab_message: ['inapp', 'email', 'webhook', 'ntfy'],
packing_tagged: ['inapp', 'email', 'webhook', 'ntfy'],
version_available: ['inapp', 'email', 'webhook', 'ntfy'],
synology_session_cleared: ['inapp'],
plugin_notification: ['inapp', 'email', 'webhook', 'ntfy'],
};
/**
* Channels implemented for an event. In-app takes everything; external channels
* decide for themselves (today: everything except `synology_session_cleared`).
*/
export function combosFor(event: NotifEventType): NotifChannel[] {
return [INAPP_CHANNEL, ...listChannels().filter(c => c.supportsEvent(event)).map(c => c.id)];
}
function allCombos(): Record<string, NotifChannel[]> {
const out: Record<string, NotifChannel[]> = {};
for (const event of ALL_EVENT_TYPES) out[event] = combosFor(event);
return out;
}
/** Events that target admins only (shown in admin panel, not in user settings). */
export const ADMIN_SCOPED_EVENTS = new Set<NotifEventType>(['version_available']);
@@ -56,24 +91,26 @@ function getAppSetting(key: string): string | null {
// ── Active channels (admin-configured) ────────────────────────────────────
/**
* Returns which channels the admin has enabled (email and/or webhook).
* Which channels the admin has enabled, as ids.
* Reads `notification_channels` (plural) with fallback to `notification_channel` (singular).
* In-app is always considered active at the service level.
*
* BUILT-INS ONLY. A plugin channel is NOT gated on this list: a built-in always exists in
* the code and so needs an explicit switch, but a plugin channel only exists because an
* admin installed and enabled that plugin — that IS the opt-in. Requiring a second one
* meant a plugin channel could never be turned on at all (nothing writes a `plugin:` id
* into this CSV, and the admin toggle rebuilds it from the three built-in booleans, which
* would silently drop any that were).
*/
export function getActiveChannels(): NotifChannel[] {
const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none';
if (raw === 'none') return [];
return raw.split(',').map(c => c.trim()).filter((c): c is NotifChannel => c === 'email' || c === 'webhook' || c === 'ntfy');
const builtins = new Set(listChannels().filter(c => c.source === 'builtin').map(c => c.id));
return raw.split(',').map(c => c.trim()).filter(c => builtins.has(c));
}
/**
* Returns which channels are configured (have valid credentials/URLs set).
* In-app is always available. Email/webhook depend on configuration.
*/
export function getAvailableChannels(): AvailableChannels {
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
const activeChannels = getActiveChannels();
return { email: hasSmtp, webhook: activeChannels.includes('webhook'), ntfy: activeChannels.includes('ntfy'), inapp: true };
/** Is this channel switched on? Plugin channels are on by virtue of being live. */
export function isChannelActive(channel: ExternalChannel): boolean {
return channel.source === 'plugin' || getActiveChannels().includes(channel.id);
}
// ── Per-user preference checks ─────────────────────────────────────────────
@@ -93,12 +130,71 @@ export function isEnabledForEvent(userId: number, eventType: NotifEventType, cha
export interface PreferencesMatrix {
preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>>;
available_channels: AvailableChannels;
/** The columns to render, in order. Replaces the old fixed-shape available_channels. */
channels: ChannelDescriptor[];
event_types: NotifEventType[];
implemented_combos: Record<NotifEventType, NotifChannel[]>;
implemented_combos: Record<string, NotifChannel[]>;
defaults?: { ntfyServer: string | null };
}
/** The in-app pseudo-channel — always active, never configurable. */
function inAppDescriptor(): ChannelDescriptor {
return {
id: INAPP_CHANNEL,
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
};
}
/**
* The channel columns for a scope.
* scope='user' — a column per channel the admin turned on in `notification_channels`.
* scope='admin' — a column per channel that has admin-global credentials.
*/
function describeChannels(userId: number, scope: 'user' | 'admin'): ChannelDescriptor[] {
const out: ChannelDescriptor[] = [inAppDescriptor()];
if (scope === 'admin') {
// Admin-scoped events go out over the admin's own global credentials, which
// are independent of the per-user `notification_channels` toggle.
const hasSmtp = isSmtpConfigured();
const hasAdminWebhook = !!getAppSetting('admin_webhook_url');
const hasAdminNtfy = !!getAppSetting('admin_ntfy_topic');
const adminActive: Record<string, boolean> = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy };
for (const channel of listChannels()) {
// Plugin channels are user-scoped only — they never carry admin-global events.
if (channel.source !== 'builtin') continue;
const active = adminActive[channel.id] ?? false;
out.push({
id: channel.id,
source: channel.source,
labelKey: channel.labelKey,
label: channel.label,
active,
configured: active,
});
}
return out;
}
for (const channel of listChannels()) {
out.push({
id: channel.id,
source: channel.source,
labelKey: channel.labelKey,
label: channel.label,
settingsPath: channel.settingsPath,
// A live plugin channel is always a column. `configured` tells the user whether
// they still need to enter credentials — it does not hide the channel from them.
active: isChannelActive(channel),
configured: channel.isConfiguredFor(userId),
});
}
return out;
}
/**
* Returns the preferences matrix for a user.
* scope='user' — excludes admin-scoped events (for user settings page)
@@ -116,16 +212,16 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u
stored[row.event_type]![row.channel] = row.enabled === 1;
}
const implemented_combos = allCombos();
// Build the full matrix with defaults (true when no row exists)
const preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
const allEvents = Object.keys(IMPLEMENTED_COMBOS) as NotifEventType[];
for (const eventType of allEvents) {
const channels = IMPLEMENTED_COMBOS[eventType];
for (const eventType of ALL_EVENT_TYPES) {
preferences[eventType] = {};
for (const channel of channels) {
// Admin-scoped events use global settings for email/webhook/ntfy
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook' || channel === 'ntfy')) {
for (const channel of implemented_combos[eventType]) {
// Admin-scoped events use global settings for the built-in external channels
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && isAdminGlobalChannel(channel)) {
preferences[eventType]![channel] = getAdminGlobalPref(eventType, channel);
} else {
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
@@ -135,50 +231,43 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u
// Filter event types by scope
const event_types = scope === 'admin'
? allEvents.filter(e => ADMIN_SCOPED_EVENTS.has(e))
: allEvents.filter(e => !ADMIN_SCOPED_EVENTS.has(e));
// Available channels depend on scope
let available_channels: AvailableChannels;
if (scope === 'admin') {
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
const hasAdminWebhook = !!(getAppSetting('admin_webhook_url'));
const hasAdminNtfy = !!(getAppSetting('admin_ntfy_topic'));
available_channels = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy, inapp: true };
} else {
const activeChannels = getActiveChannels();
available_channels = {
email: activeChannels.includes('email'),
webhook: activeChannels.includes('webhook'),
ntfy: activeChannels.includes('ntfy'),
inapp: true,
};
}
? ALL_EVENT_TYPES.filter(e => ADMIN_SCOPED_EVENTS.has(e))
: ALL_EVENT_TYPES.filter(e => !ADMIN_SCOPED_EVENTS.has(e));
return {
preferences,
available_channels,
channels: describeChannels(userId, scope),
event_types,
implemented_combos: IMPLEMENTED_COMBOS,
implemented_combos,
...(scope === 'user' && { defaults: { ntfyServer: getAppSetting('admin_ntfy_server') || null } }),
};
}
// ── Admin global preferences (stored in app_settings) ─────────────────────
const ADMIN_GLOBAL_CHANNELS: NotifChannel[] = ['email', 'webhook', 'ntfy'];
/**
* Channels whose admin-scoped preference is global (app_settings) rather than
* per-user. Built-ins only: plugin channels are user-scoped and never carry
* admin-scoped events.
*/
const ADMIN_GLOBAL_CHANNELS = ['email', 'webhook', 'ntfy'] as const;
export type AdminGlobalChannel = (typeof ADMIN_GLOBAL_CHANNELS)[number];
export function isAdminGlobalChannel(channel: string): channel is AdminGlobalChannel {
return (ADMIN_GLOBAL_CHANNELS as readonly string[]).includes(channel);
}
/**
* Returns the global admin preference for an event+channel.
* Stored in app_settings as `admin_notif_pref_{event}_{channel}`.
* Defaults to true (enabled) when no row exists.
*/
export function getAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook' | 'ntfy'): boolean {
export function getAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel): boolean {
const val = getAppSetting(`admin_notif_pref_${event}_${channel}`);
return val !== '0';
}
function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook' | 'ntfy', enabled: boolean): void {
function setAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel, enabled: boolean): void {
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
`admin_notif_pref_${event}_${channel}`,
enabled ? '1' : '0'
@@ -248,7 +337,7 @@ export function setAdminPreferences(
for (const [eventType, channels] of Object.entries(prefs)) {
if (!channels) continue;
for (const [channel, enabled] of Object.entries(channels)) {
if (ADMIN_GLOBAL_CHANNELS.includes(channel as NotifChannel)) {
if (isAdminGlobalChannel(channel)) {
if (!globalPrefs[eventType]) globalPrefs[eventType] = {};
globalPrefs[eventType]![channel] = enabled;
} else {
@@ -262,7 +351,8 @@ export function setAdminPreferences(
for (const [eventType, channels] of Object.entries(globalPrefs)) {
if (!channels) continue;
for (const [channel, enabled] of Object.entries(channels)) {
setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook' | 'ntfy', enabled);
if (!isAdminGlobalChannel(channel)) continue;
setAdminGlobalPref(eventType as NotifEventType, channel, enabled);
}
}
@@ -271,10 +361,10 @@ export function setAdminPreferences(
}
// ── SMTP availability helper (for authService) ─────────────────────────────
// Lives in ./notifications now (the module that owns SMTP config); re-exported
// here so existing importers keep working.
export function isSmtpConfigured(): boolean {
return !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
}
export { isSmtpConfigured };
export function isWebhookConfigured(): boolean {
return getActiveChannels().includes('webhook');
+71 -72
View File
@@ -4,25 +4,12 @@ import {
getActiveChannels,
isEnabledForEvent,
getAdminGlobalPref,
isSmtpConfigured,
isAdminGlobalChannel,
ADMIN_SCOPED_EVENTS,
type NotifEventType,
type NotifChannel,
} from './notificationPreferencesService';
import {
getEventText,
sendEmail,
sendWebhook,
sendNtfy,
getUserEmail,
getUserLanguage,
getUserWebhookUrl,
getAdminWebhookUrl,
getUserNtfyConfig,
getAdminNtfyConfig,
resolveNtfyUrl,
getAppUrl,
} from './notifications';
import { getEventText, getUserLanguage, getAppUrl } from './notifications';
import { listChannels, type ChannelMessage, type ExternalChannel } from './notifications/channelRegistry';
import {
resolveRecipients,
createNotificationForRecipient,
@@ -177,6 +164,40 @@ export interface NotificationPayload {
};
}
/**
* Should this channel deliver this event to this recipient?
*
* Encodes, unchanged, the gating the four hand-written dispatch blocks used to do:
* - admin-scoped events (version_available) reach recipients only over a channel
* that bypasses the `notification_channels` toggle (email), gated by the admin
* global pref rather than the per-user opt-out. Their webhook/ntfy copies go out
* once, globally, via sendGlobal() below — not per recipient.
* - everything else: the admin enabled the channel, the user didn't opt out of this
* event on it, and the user has credentials for it.
*/
function shouldSendToUser(
channel: ExternalChannel,
event: NotifEventType,
recipientId: number,
activeChannels: string[],
): boolean {
if (!channel.supportsEvent(event)) return false;
if (channel.isInstanceConfigured && !channel.isInstanceConfigured()) return false;
if (ADMIN_SCOPED_EVENTS.has(event)) {
if (!channel.bypassesActiveToggleForAdminEvents) return false;
if (!isAdminGlobalChannel(channel.id)) return false;
if (!getAdminGlobalPref(event, channel.id)) return false;
} else {
// A built-in needs the admin's explicit switch; a plugin channel is on because the
// admin enabled the plugin — that IS the opt-in (see getActiveChannels).
if (channel.source === 'builtin' && !activeChannels.includes(channel.id)) return false;
if (!isEnabledForEvent(recipientId, event, channel.id)) return false;
}
return channel.isConfiguredFor(recipientId);
}
export async function send(payload: NotificationPayload): Promise<void> {
const { event, actorId, params, scope, targetId, inApp } = payload;
@@ -202,6 +223,7 @@ export async function send(payload: NotificationPayload): Promise<void> {
}
const config = configEntry ?? FALLBACK_EVENT_CONFIG;
const activeChannels = getActiveChannels();
const channels = listChannels();
const appUrl = getAppUrl();
// Build navigate target (used by email/webhook CTA and in-app navigate)
@@ -273,43 +295,23 @@ export async function send(payload: NotificationPayload): Promise<void> {
);
}
// ── Email ────────────────────────────────────────────────────────────
// Admin-scoped events: use global pref + SMTP check (bypass notification_channels toggle)
// Regular events: use active channels + per-user pref
const emailEnabled = ADMIN_SCOPED_EVENTS.has(event)
? isSmtpConfigured() && getAdminGlobalPref(event, 'email')
: activeChannels.includes('email') && isEnabledForEvent(recipientId, event, 'email');
if (emailEnabled) {
const email = getUserEmail(recipientId);
if (email) {
const lang = getUserLanguage(recipientId);
const { title, body } = getEventText(lang, event, params);
promises.push(sendEmail(email, title, body, recipientId, navigateTarget ?? undefined));
}
}
// ── Webhook (per-user) — skip for admin-scoped events (handled globally below) ──
if (!ADMIN_SCOPED_EVENTS.has(event) && activeChannels.includes('webhook') && isEnabledForEvent(recipientId, event, 'webhook')) {
const webhookUrl = getUserWebhookUrl(recipientId);
if (webhookUrl) {
const lang = getUserLanguage(recipientId);
const { title, body } = getEventText(lang, event, params);
promises.push(sendWebhook(webhookUrl, { event, title, body, tripName: params.trip, link: fullLink }));
}
}
// ── Ntfy (per-user) — skip for admin-scoped events (handled globally below) ──
if (!ADMIN_SCOPED_EVENTS.has(event) && activeChannels.includes('ntfy') && isEnabledForEvent(recipientId, event, 'ntfy' as NotifChannel)) {
const userNtfyCfg = getUserNtfyConfig(recipientId);
const adminNtfyCfg = getAdminNtfyConfig();
const ntfyUrl = resolveNtfyUrl(adminNtfyCfg, userNtfyCfg);
if (ntfyUrl) {
const lang = getUserLanguage(recipientId);
const { title, body } = getEventText(lang, event, params);
const token = userNtfyCfg?.token ?? adminNtfyCfg.token;
promises.push(sendNtfy(ntfyUrl, token, { event, title, body, link: fullLink }));
}
// ── External channels (email, webhook, ntfy, plugin:*) ───────────────
// One loop over the registry. The message is rendered once per recipient, in
// their language, and handed to every channel that wants it — so a plugin
// channel never touches i18n.
const deliverable = channels.filter(ch => shouldSendToUser(ch, event, recipientId, activeChannels));
if (deliverable.length > 0) {
const lang = getUserLanguage(recipientId);
const { title, body } = getEventText(lang, event, params);
const msg: ChannelMessage = {
event,
title,
body,
navigateTarget: navigateTarget ?? undefined,
url: fullLink,
tripName: params.trip,
};
for (const ch of deliverable) promises.push(ch.sendToUser(recipientId, msg));
}
const results = await Promise.allSettled(promises);
@@ -320,26 +322,23 @@ export async function send(payload: NotificationPayload): Promise<void> {
}
}));
// ── Admin webhook (scope: admin) — global, respects global pref ──────
if (scope === 'admin' && getAdminGlobalPref(event, 'webhook')) {
const adminWebhookUrl = getAdminWebhookUrl();
if (adminWebhookUrl) {
// ── Admin-global copies (scope: admin) ───────────────────────────────
// One send per channel, over the admin's own credentials, not per-recipient.
// Always rendered in English — there is no single recipient to take a language from.
if (scope === 'admin') {
const globalChannels = channels.filter(
ch => ch.sendGlobal && ch.supportsEvent(event) && isAdminGlobalChannel(ch.id) && getAdminGlobalPref(event, ch.id),
);
if (globalChannels.length > 0) {
const { title, body } = getEventText('en', event, params);
await sendWebhook(adminWebhookUrl, { event, title, body, link: fullLink }).catch((err: unknown) => {
logError(`notificationService.send admin webhook failed event=${event}: ${err instanceof Error ? err.message : err}`);
});
}
}
// ── Admin ntfy (scope: admin) — global, respects global pref ─────────
if (scope === 'admin' && getAdminGlobalPref(event, 'ntfy')) {
const adminNtfyCfg = getAdminNtfyConfig();
const adminNtfyUrl = resolveNtfyUrl(adminNtfyCfg, null);
if (adminNtfyUrl) {
const { title, body } = getEventText('en', event, params);
await sendNtfy(adminNtfyUrl, adminNtfyCfg.token, { event, title, body, link: fullLink }).catch((err: unknown) => {
logError(`notificationService.send admin ntfy failed event=${event}: ${err instanceof Error ? err.message : err}`);
});
const msg: ChannelMessage = { event, title, body, navigateTarget: navigateTarget ?? undefined, url: fullLink };
await Promise.all(
globalChannels.map(ch =>
ch.sendGlobal!(msg).catch((err: unknown) => {
logError(`notificationService.send admin ${ch.id} failed event=${event}: ${err instanceof Error ? err.message : err}`);
}),
),
);
}
}
}
+5
View File
@@ -92,6 +92,11 @@ export function getMcpSafeUrl(): string {
return `http://localhost:${port}`;
}
/** Is SMTP configured at the instance level? (Independent of any one user's address.) */
export function isSmtpConfigured(): boolean {
return !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
}
export function getUserEmail(userId: number): string | null {
// Defense-in-depth (#1362): a guest's synthetic email must never be emailed.
return (db.prepare('SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0').get(userId) as { email: string } | undefined)?.email || null;
@@ -0,0 +1,117 @@
import {
sendEmail,
sendWebhook,
sendNtfy,
testSmtp,
testWebhook,
testNtfy,
isSmtpConfigured,
getUserEmail,
getUserWebhookUrl,
getAdminWebhookUrl,
getUserNtfyConfig,
getAdminNtfyConfig,
resolveNtfyUrl,
} from '../notifications';
import { registerChannel, type ChannelMessage, type ExternalChannel } from './channelRegistry';
// The three built-in external channels, wrapping the send functions and config
// resolvers that already live in ../notifications.ts. No delivery logic is
// rewritten here — it is only relocated behind the ExternalChannel interface so
// notificationService.send() can iterate instead of branching.
/**
* Every event except `synology_session_cleared` is deliverable externally.
* This is the pre-registry IMPLEMENTED_COMBOS table, transposed: it listed
* ['inapp','email','webhook','ntfy'] for every event and ['inapp'] for that one.
*/
function supportsAllButSynology(event: string): boolean {
return event !== 'synology_session_cleared';
}
const emailChannel: ExternalChannel = {
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
// Admin-scoped events (version_available) reach admins by email even when the
// admin has not put `email` in notification_channels — the admin global pref
// gates it instead. Verbatim from the pre-registry dispatch.
bypassesActiveToggleForAdminEvents: true,
supportsEvent: supportsAllButSynology,
isInstanceConfigured: isSmtpConfigured,
isConfiguredFor: (userId) => !!getUserEmail(userId),
async sendToUser(userId, msg) {
const email = getUserEmail(userId);
if (!email) return false;
return sendEmail(email, msg.title, msg.body, userId, msg.navigateTarget);
},
async test(userId) {
const email = getUserEmail(userId);
if (!email) return { success: false, error: 'No email address on file' };
return testSmtp(email);
},
};
const webhookChannel: ExternalChannel = {
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
supportsAdminGlobal: true,
supportsEvent: supportsAllButSynology,
isConfiguredFor: (userId) => !!getUserWebhookUrl(userId),
async sendToUser(userId, msg) {
const url = getUserWebhookUrl(userId);
if (!url) return false;
return sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, tripName: msg.tripName, link: msg.url });
},
async sendGlobal(msg: ChannelMessage) {
const url = getAdminWebhookUrl();
if (!url) return false;
return sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
},
async test(userId, override) {
const url = (typeof override?.url === 'string' && override.url) || getUserWebhookUrl(userId);
if (!url) return { success: false, error: 'No webhook URL configured' };
return testWebhook(url);
},
};
const ntfyChannel: ExternalChannel = {
id: 'ntfy',
source: 'builtin',
labelKey: 'settings.notificationPreferences.ntfy',
supportsAdminGlobal: true,
supportsEvent: supportsAllButSynology,
isConfiguredFor: (userId) => !!resolveNtfyUrl(getAdminNtfyConfig(), getUserNtfyConfig(userId)),
async sendToUser(userId, msg) {
const userCfg = getUserNtfyConfig(userId);
const adminCfg = getAdminNtfyConfig();
const url = resolveNtfyUrl(adminCfg, userCfg);
if (!url) return false;
return sendNtfy(url, userCfg?.token ?? adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
},
async sendGlobal(msg: ChannelMessage) {
const adminCfg = getAdminNtfyConfig();
const url = resolveNtfyUrl(adminCfg, null);
if (!url) return false;
return sendNtfy(url, adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
},
async test(userId, override) {
const topic = typeof override?.topic === 'string' ? override.topic : getUserNtfyConfig(userId)?.topic;
if (!topic) return { success: false, error: 'Could not resolve ntfy URL — missing topic' };
return testNtfy({
topic,
server: typeof override?.server === 'string' ? override.server : null,
token: typeof override?.token === 'string' ? override.token : null,
});
},
};
export const BUILTIN_CHANNELS = [emailChannel, webhookChannel, ntfyChannel];
/** Idempotent — safe to call from every entry point that needs the registry populated. */
export function registerBuiltinChannels(): void {
for (const channel of BUILTIN_CHANNELS) registerChannel(channel);
}
registerBuiltinChannels();
@@ -0,0 +1,164 @@
import type { NotifEventType } from '../notificationPreferencesService';
// ── External notification channels ─────────────────────────────────────────
//
// A channel is anything that delivers a *rendered* notification out of TREK:
// email, webhook, ntfy, and any plugin that implements the `notificationChannel`
// hook. In-app is deliberately NOT a channel — it writes typed rows with
// scope/target/callback payloads rather than a title+body, so it keeps its own
// path in notificationService.send(). The split mirrors the one shared/ already
// draws in i18n/<locale>/externalNotifications.ts.
//
// Channels are declarative: they say what they support and whether a given user
// has credentials, and they send. They do NOT consult preferences — the admin
// toggle (`notification_channels`) and the per-user event opt-outs are applied by
// the dispatch loop. That keeps this module free of any runtime dependency on
// notificationPreferencesService, which in turn imports listChannels() from here.
/** A notification already rendered into the recipient's language. */
export interface ChannelMessage {
event: NotifEventType;
title: string;
body: string;
/** Relative navigate target (e.g. `/trips/12`) — what the email CTA builder takes. */
navigateTarget?: string;
/** Absolute link (appUrl + navigateTarget) — what webhook/ntfy/plugin payloads carry. */
url?: string;
tripName?: string;
}
export interface ExternalChannel {
readonly id: string;
readonly source: 'builtin' | 'plugin';
/** Built-ins: an i18n key the client resolves. */
readonly labelKey?: string;
/** Plugin channels: a literal display name (the host has no i18n for it). */
readonly label?: string;
/** Where the user configures their credentials, if anywhere. */
readonly settingsPath?: string;
/**
* Admin-scoped events (`version_available`) bypass the `notification_channels`
* toggle for this channel and are gated by the admin global pref instead.
* Only email does this today; preserved verbatim from the pre-registry dispatch.
*/
readonly bypassesActiveToggleForAdminEvents?: boolean;
/** Delivers the one admin-scoped global copy (not per-recipient). */
readonly supportsAdminGlobal?: boolean;
supportsEvent(event: NotifEventType): boolean;
/**
* Instance-level readiness, independent of any one recipient (email: is SMTP set
* up at all?). Absent means "always ready".
*/
isInstanceConfigured?(): boolean;
/** Does this recipient have credentials for this channel? */
isConfiguredFor(userId: number): boolean;
sendToUser(userId: number, msg: ChannelMessage): Promise<unknown>;
sendGlobal?(msg: ChannelMessage): Promise<unknown>;
test?(userId: number, override?: Record<string, unknown>): Promise<{ success: boolean; error?: string }>;
}
/** Namespace for plugin channel ids, so a plugin can never claim `email`. */
export const PLUGIN_CHANNEL_PREFIX = 'plugin:';
export function pluginChannelId(pluginId: string): string {
return `${PLUGIN_CHANNEL_PREFIX}${pluginId}`;
}
export function isPluginChannelId(id: string): boolean {
return id.startsWith(PLUGIN_CHANNEL_PREFIX);
}
// ── Registry ───────────────────────────────────────────────────────────────
const builtins = new Map<string, ExternalChannel>();
/** Register a built-in channel. Called at import of ./builtins. */
export function registerChannel(channel: ExternalChannel): void {
// The `plugin:` namespace belongs to plugins; a built-in claiming it would make
// getChannel() resolve inconsistently (it routes namespaced ids past this map).
if (isPluginChannelId(channel.id)) throw new Error(`built-in channel cannot claim the plugin namespace: ${channel.id}`);
builtins.set(channel.id, channel);
}
/**
* Plugin channels cross a trust boundary, so re-establish their invariants HERE
* rather than trusting whoever produced them.
*
* The runtime mints ids with pluginChannelId() and leaves the built-in-only privileges
* off — but nothing structural stopped a bug (or a future caller) from handing us a
* channel that claims `email`. Such a channel would be dispatched to off the user's
* EMAIL opt-in, without them ever enabling a plugin channel, and — because
* isAdminGlobalChannel('email') is true — would also receive admin-scoped events and
* the admin-global copy. Same reasoning as providersOf()/invokeHook re-checking the
* hook grant host-side even though the child reports its own hooks.
*/
function sanitizePluginChannels(raw: readonly ExternalChannel[]): ExternalChannel[] {
const out: ExternalChannel[] = [];
const seen = new Set<string>();
for (const c of raw) {
if (!c || typeof c.id !== 'string') continue;
if (!isPluginChannelId(c.id)) continue; // must be namespaced …
if (builtins.has(c.id)) continue; // … and can never shadow a built-in
if (seen.has(c.id)) continue; // no duplicate columns / double sends
seen.add(c.id);
out.push({
id: c.id,
source: 'plugin',
label: c.label,
settingsPath: c.settingsPath,
// Built-in-only privileges, stripped unconditionally. A plugin channel is
// user-scoped: it never bypasses the admin's channel toggle, and never carries an
// admin-scoped or admin-global notification. (With these off, notificationService
// refuses it an ADMIN_SCOPED_EVENT and the admin-global loop skips it — so this
// holds even if the channel lies in supportsEvent().)
bypassesActiveToggleForAdminEvents: false,
supportsAdminGlobal: false,
// Bound, so a producer that used a class keeps its `this`.
supportsEvent: (e) => c.supportsEvent(e),
isConfiguredFor: (u) => c.isConfiguredFor(u),
sendToUser: (u, m) => c.sendToUser(u, m),
...(c.isInstanceConfigured ? { isInstanceConfigured: () => c.isInstanceConfigured!() } : {}),
...(c.test ? { test: (u: number, o?: Record<string, unknown>) => c.test!(u, o) } : {}),
// NB: sendGlobal is deliberately NOT carried over.
});
}
return out;
}
// Plugin channels are supplied by the plugin runtime, which lives in the Nest
// graph and cannot be imported from a plain service. Same seam as
// setPluginEventSink() in plugin-runtime.service.ts: the runtime pushes a getter
// in at onModuleInit, and we pull from it on every read so a plugin that is
// disabled or uninstalled simply stops appearing.
let pluginChannelSource: (() => ExternalChannel[]) | null = null;
export function setPluginChannelSource(source: (() => ExternalChannel[]) | null): void {
pluginChannelSource = source;
}
/** Every channel that currently exists: built-ins plus live, sanitized plugin channels. */
export function listChannels(): ExternalChannel[] {
const plugins = (() => {
try {
return pluginChannelSource?.() ?? [];
} catch {
// A broken plugin runtime must never take notifications down with it.
return [];
}
})();
// Built-ins first, and they always win: a plugin channel can only ever be an
// addition, never an override.
return [...builtins.values(), ...sanitizePluginChannels(plugins)];
}
export function getChannel(id: string): ExternalChannel | undefined {
if (isPluginChannelId(id)) return listChannels().find((c) => c.id === id);
return builtins.get(id);
}
/** Test seam — drops registered built-ins. */
export function __resetChannelsForTest(): void {
builtins.clear();
pluginChannelSource = null;
}
@@ -175,17 +175,22 @@ describe('In-app notifications', () => {
// ─────────────────────────────────────────────────────────────────────────────
describe('GET /api/notifications/preferences — matrix format', () => {
it('NROUTE-002 — returns preferences, available_channels, event_types, implemented_combos', async () => {
it('NROUTE-002 — returns preferences, channels, event_types, implemented_combos', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/notifications/preferences')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('preferences');
expect(res.body).toHaveProperty('available_channels');
expect(res.body).toHaveProperty('channels');
expect(res.body).toHaveProperty('event_types');
expect(res.body).toHaveProperty('implemented_combos');
expect(res.body.available_channels.inapp).toBe(true);
const inapp = res.body.channels.find((c: { id: string }) => c.id === 'inapp');
expect(inapp.active).toBe(true);
// The built-in external channels are always described, active or not.
expect(res.body.channels.map((c: { id: string }) => c.id)).toEqual(
expect.arrayContaining(['inapp', 'email', 'webhook', 'ntfy']),
);
});
it('NROUTE-003 — regular user does not see version_available in event_types', async () => {
@@ -16,7 +16,7 @@ const { testDb } = vi.hoisted(() => {
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, api_version INTEGER,
min_trek_version TEXT, permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}',
granted_permissions TEXT DEFAULT '', status TEXT, enabled INTEGER DEFAULT 0, config TEXT DEFAULT '{}',
operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '', status TEXT, enabled INTEGER DEFAULT 0, config TEXT DEFAULT '{}',
source_repo TEXT, source_commit TEXT, sha256 TEXT, author_pubkey TEXT, reviewed_at TEXT, last_error TEXT, updated_at TEXT);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
@@ -12,7 +12,7 @@ const { testDb } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, version TEXT, permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '',
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, version TEXT, permissions TEXT DEFAULT '[]', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '',
config TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', capabilities TEXT DEFAULT '{}', last_error TEXT, updated_at TEXT);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
@@ -21,7 +21,7 @@ const { testDb } = vi.hoisted(() => {
const db = new Database(':memory:');
db.exec(`
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', granted_permissions TEXT, status TEXT, enabled INTEGER DEFAULT 0, config TEXT,
api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, enabled INTEGER DEFAULT 0, config TEXT,
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, author_pubkey TEXT, updated_at TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
@@ -165,6 +165,24 @@ describe('PluginRegistryService', () => {
expect(d.manifest).toMatchObject({ permissions: [], egress: ['ok.host'], settings: [] });
});
it('detail surfaces operatorEgress, so the pre-install review can say the host list is not the whole story', async () => {
safeDownload.mockResolvedValue({
bytes: Buffer.from(JSON.stringify({ permissions: ['http:outbound:gotify.net'], egress: ['gotify.net'], operatorEgress: true })),
sha256: 'unused',
});
const d = (await svc.detail('flight-tracker')) as { manifest: { operatorEgress: boolean; egress: string[] } };
expect(d.manifest).toMatchObject({ egress: ['gotify.net'], operatorEgress: true });
});
it('detail defaults operatorEgress to false (an ordinary plugin must not claim it)', async () => {
safeDownload.mockResolvedValue({
bytes: Buffer.from(JSON.stringify({ permissions: [], egress: ['api.example.com'] })),
sha256: 'unused',
});
const d = (await svc.detail('flight-tracker')) as { manifest: { operatorEgress: boolean } };
expect(d.manifest.operatorEgress).toBe(false);
});
it('detail is cached per plugin (one manifest fetch across calls)', async () => {
safeDownload.mockResolvedValue({ bytes: Buffer.from('{}'), sha256: 'unused' });
await svc.detail('flight-tracker');
+3 -1
View File
@@ -27,9 +27,11 @@ beforeEach(() => {
db = new Database(':memory:');
db.exec(`
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT,
required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
CREATE TABLE plugin_actions (plugin_id TEXT, action_key TEXT, label TEXT, hint TEXT, danger INTEGER, sort_order INTEGER,
PRIMARY KEY (plugin_id, action_key));
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
});
afterEach(() => {
@@ -141,3 +141,99 @@ describe('parseManifest dependencies', () => {
expect(() => parseManifest({ ...base, pluginDependencies: [{ id: 'koffi', version: '*' }, { id: 'koffi', version: '^1' }] })).toThrow(/duplicate/);
});
});
describe('capabilities.notificationChannel', () => {
const base = {
id: 'my-gotify',
name: 'My Gotify',
version: '1.0.0',
apiVersion: 1,
type: 'integration',
nativeModules: false,
permissions: ['hook:notification-channel', 'http:outbound:gotify.example.com'],
egress: ['gotify.example.com'],
};
it('accepts what the SDK notification-channel template scaffolds', () => {
const m = parseManifest({
...base,
capabilities: { notificationChannel: { title: 'My Gotify' } },
settings: [{ key: 'appToken', label: 'App token', required: true, secret: true, scope: 'user' }],
});
expect(m.capabilities.notificationChannel?.title).toBe('My Gotify');
// scope:'user' + secret is what lets the host hand the decrypted value to the hook.
expect(m.settings[0].scope).toBe('user');
expect(m.settings[0].secret).toBe(true);
});
it('accepts a narrowed event list', () => {
const m = parseManifest({ ...base, capabilities: { notificationChannel: { events: ['trip_invite', 'booking_change'] } } });
expect(m.capabilities.notificationChannel?.events).toEqual(['trip_invite', 'booking_change']);
});
it('rejects an admin-scoped event — a plugin channel can never carry one', () => {
expect(() => parseManifest({ ...base, capabilities: { notificationChannel: { events: ['version_available'] } } }))
.toThrow(/not a plugin-deliverable event/);
});
it('rejects a non-array events field', () => {
expect(() => parseManifest({ ...base, capabilities: { notificationChannel: { events: 'trip_invite' } } }))
.toThrow(ManifestError);
});
});
describe('select field options', () => {
const base = { id: 'sel', name: 'Sel', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false, permissions: [] };
const opts = (options: unknown) => parseManifest({ ...base, settings: [{ key: 'priority', input_type: 'select', scope: 'user', options }] }).settings[0].options;
it('keeps a proper { value, label } list', () => {
expect(opts([{ value: '5', label: 'Normal' }])).toEqual([{ value: '5', label: 'Normal' }]);
});
it('coerces a bare string/number list instead of rendering blank options', () => {
// The obvious thing to write — and it used to be cast straight through, so the
// client read o.value/o.label as undefined and every dropdown entry was EMPTY.
expect(opts(['1', '5'])).toEqual([{ value: '1', label: '1' }, { value: '5', label: '5' }]);
expect(opts([1, 5])).toEqual([{ value: '1', label: '1' }, { value: '5', label: '5' }]);
});
it('defaults a missing label to the value rather than leaving it blank', () => {
expect(opts([{ value: '5' }])).toEqual([{ value: '5', label: '5' }]);
});
it('rejects an option with no value, and a non-array list', () => {
expect(() => opts([{ label: 'Normal' }])).toThrow(/non-empty "value"/);
expect(() => opts([null])).toThrow(ManifestError);
expect(() => opts('nope')).toThrow(/must be an array/);
});
});
describe('settings-page actions', () => {
const base = { id: 'act', name: 'Act', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false, permissions: [] };
it('parses actions with label/hint/danger', () => {
const m = parseManifest({ ...base, actions: [{ key: 'testConnection', label: 'Test connection', hint: 'Pings the API.' }, { key: 'purge', label: 'Purge', danger: true }] });
expect(m.actions).toEqual([
{ key: 'testConnection', label: 'Test connection', hint: 'Pings the API.', danger: false },
{ key: 'purge', label: 'Purge', hint: undefined, danger: true },
]);
});
it('defaults the label to the key, and bounds label/hint', () => {
const m = parseManifest({ ...base, actions: [{ key: 'sync', label: 'L'.repeat(200), hint: 'H'.repeat(500) }] });
expect(m.actions[0].label.length).toBe(60);
expect(m.actions[0].hint!.length).toBe(200);
expect(parseManifest({ ...base, actions: [{ key: 'sync' }] }).actions[0].label).toBe('sync');
});
it('rejects a prototype-chain key, a duplicate, a non-array and too many', () => {
expect(() => parseManifest({ ...base, actions: [{ key: '__proto__' }] })).toThrow(ManifestError);
expect(() => parseManifest({ ...base, actions: [{ key: 'a' }, { key: 'a' }] })).toThrow(/duplicate action/);
expect(() => parseManifest({ ...base, actions: 'nope' })).toThrow(/must be an array/);
expect(() => parseManifest({ ...base, actions: Array.from({ length: 9 }, (_, i) => ({ key: `a${i}` })) })).toThrow(/at most 8/);
});
it('defaults to no actions', () => {
expect(parseManifest(base).actions).toEqual([]);
});
});
@@ -0,0 +1,149 @@
/**
* Operator-supplied egress hosts.
*
* A plugin's egress allow-list is fixed in its manifest at publish time, but a plugin that
* talks to a SELF-HOSTED service (Gotify, ntfy, …) cannot know the operator's hostname —
* so a community plugin would serve nobody. An ADMIN adds the hosts post-install and the
* runtime unions them into the child's allow-list at spawn.
*
* The invariants that keep this from becoming an egress bypass:
* - only a plugin that DECLARED operatorEgress may have hosts (install-time consent);
* - hosts are validated like manifest egress (no bare `*`, no scheme, no whole-TLD);
* - it is always the ADMIN, never an end user, who widens it;
* - changing the set RE-SPAWNS the plugin, because the child's guard is install-once.
*/
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) {
testDb.prepare(
`INSERT OR REPLACE INTO plugins (id, name, status, enabled, version, permissions, granted_permissions, capabilities, config, operator_egress)
VALUES (?, ?, 'inactive', 0, '1.0.0', ?, ?, '{}', '{}', ?)`,
).run(id, id, JSON.stringify(perms), JSON.stringify(perms), operatorEgress ? 1 : 0);
}
let rt: PluginRuntimeService;
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
beforeEach(() => {
testDb.prepare('DELETE FROM plugins').run();
testDb.prepare('DELETE FROM plugin_egress_hosts').run();
testDb.prepare('DELETE FROM plugin_actions').run();
rt = new PluginRuntimeService();
});
describe('operator-supplied egress hosts', () => {
it('OEG-001 — an admin can add hosts to a plugin that declared operatorEgress', async () => {
install('gotify', true);
expect(rt.wantsOperatorEgress('gotify')).toBe(true);
expect(await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com'])).toEqual(['gotify.mydomain.com']);
expect(rt.operatorEgressHosts('gotify')).toEqual(['gotify.mydomain.com']);
});
it('OEG-002 — a plugin that did NOT declare it can never have hosts added', async () => {
install('sneaky', false);
expect(rt.wantsOperatorEgress('sneaky')).toBe(false);
// This is the load-bearing check: without it an admin could silently widen egress for
// ANY plugin, and the install-time consent would stop bounding what's possible.
await expect(rt.setOperatorEgressHosts('sneaky', ['evil.example.com'])).rejects.toThrow(/did not declare operatorEgress/);
expect(rt.operatorEgressHosts('sneaky')).toEqual([]);
});
it('OEG-003 — hosts are validated exactly like manifest egress', async () => {
install('gotify', true);
for (const bad of ['*', '*.com', 'https://gotify.example.com', 'has space', 'a/b']) {
await expect(rt.setOperatorEgressHosts('gotify', [bad])).rejects.toThrow(/invalid host/);
}
// A legitimate wildcard with a real multi-label suffix is fine.
expect(await rt.setOperatorEgressHosts('gotify', ['*.mydomain.com'])).toEqual(['*.mydomain.com']);
});
it('OEG-004 — hosts are normalized and de-duplicated', async () => {
install('gotify', true);
expect(await rt.setOperatorEgressHosts('gotify', ['Gotify.MyDomain.com', 'gotify.mydomain.com.', ' ', ''])).toEqual([
'gotify.mydomain.com',
]);
});
it('OEG-005 — setting the list replaces it (a removed host is really gone)', async () => {
install('gotify', true);
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
await rt.setOperatorEgressHosts('gotify', ['b.example.com']);
expect(rt.operatorEgressHosts('gotify')).toEqual(['b.example.com']);
});
it('OEG-006 — the manifest rejects operatorEgress without an outbound permission', () => {
const base = { id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false };
expect(() => parseManifest({ ...base, permissions: ['db:own'], operatorEgress: true })).toThrow(/requires an http:outbound/);
expect(() => parseManifest({ ...base, permissions: [], operatorEgress: 'yes' })).toThrow(ManifestError);
// …and accepts the real thing.
const m = parseManifest({ ...base, permissions: ['http:outbound:gotify.net'], egress: ['gotify.net'], operatorEgress: true });
expect(m.operatorEgress).toBe(true);
});
it('OEG-007 — uninstalling drops the admin’s host consent with the plugin', async () => {
install('gotify', true);
await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com']);
await rt.uninstall('gotify', false);
// A LATER plugin reusing this id must not silently inherit hosts approved for another.
expect(rt.operatorEgressHosts('gotify')).toEqual([]);
});
});
describe('settings-page actions (runtime)', () => {
function declareAction(id: string, key: string) {
testDb.prepare('INSERT OR REPLACE INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES (?, ?, ?, NULL, 0, 0)')
.run(id, key, key);
}
it('ACT-001 — actionsOf returns the declared descriptors', () => {
install('p', false);
declareAction('p', 'testConnection');
expect(rt.actionsOf('p')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false }]);
});
it('ACT-002 — invoking an action the plugin never declared is REFUSED', async () => {
install('p', false);
declareAction('p', 'testConnection');
// The key is caller-supplied (it comes off the URL), so the host must check it
// against the manifest rather than forwarding whatever it is handed to the child.
await expect(rt.invokeAction('p', 'somethingElse', 1)).rejects.toThrow(/did not declare action/);
await expect(rt.invokeAction('p', '__proto__', 1)).rejects.toThrow(/did not declare action/);
});
it('ACT-003 — a plugin with no actions can never be invoked', async () => {
install('p', false);
await expect(rt.invokeAction('p', 'testConnection', 1)).rejects.toThrow(/did not declare action/);
});
});
describe('the admin list surfaces operator egress (so the chip can be shown)', () => {
it('OEG-008 — reports operatorEgress + the host count', async () => {
const { PluginsService } = await import('../../../src/nest/plugins/plugins.service');
process.env.TREK_PLUGINS_ENABLED = 'true';
install('gotify', true);
install('plain', false);
const before = new PluginsService().list().plugins;
expect(before.find(p => p.id === 'gotify')).toMatchObject({ operatorEgress: true, egressHostCount: 0 });
// A plugin that never asked for it must never invite the admin to add hosts.
expect(before.find(p => p.id === 'plain')).toMatchObject({ operatorEgress: false, egressHostCount: 0 });
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
const after = new PluginsService().list().plugins;
expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2);
delete process.env.TREK_PLUGINS_ENABLED;
});
});
@@ -13,6 +13,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMock }) } }));
import { PluginUserSettingsController } from '../../../src/nest/plugins/plugin-user-settings.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import type { PluginsService } from '../../../src/nest/plugins/plugins.service';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -23,7 +24,9 @@ function ctrl() {
getUserConfig: vi.fn(() => ({ apiKey: '••••••••' })),
updateUserConfig: vi.fn((_id: string, _uid: number, patch: Record<string, unknown>) => ({ ...patch, apiKey: '••••••••' })),
} as unknown as PluginsService;
return { c: new PluginUserSettingsController(svc), svc };
// The controller now also takes the runtime (for settings-page actions).
const runtime = { actionsOf: () => [], invokeAction: vi.fn() } as unknown as PluginRuntimeService;
return { c: new PluginUserSettingsController(svc, runtime), svc, runtime };
}
describe('PluginUserSettingsController', () => {
@@ -31,13 +34,13 @@ describe('PluginUserSettingsController', () => {
it('GET returns fields + masked config for a bound user; empty when gated', () => {
const { c } = ctrl();
expect(c.get('p', req(5))).toEqual({ fields: [{ key: 'apiKey', secret: true }], config: { apiKey: '••••••••' } });
expect(c.get('p', req(5))).toEqual({ fields: [{ key: 'apiKey', secret: true }], config: { apiKey: '••••••••' }, actions: [] });
pluginsEnabled.mockReturnValue(false);
expect(c.get('p', req(5))).toEqual({ fields: [], config: {} });
expect(c.get('p', req(5))).toEqual({ fields: [], config: {}, actions: [] });
pluginsEnabled.mockReturnValue(true);
expect(c.get('p', req(undefined))).toEqual({ fields: [], config: {} });
expect(c.get('p', req(undefined))).toEqual({ fields: [], config: {}, actions: [] });
getMock.mockReturnValue(undefined as never);
expect(c.get('p', req(5))).toEqual({ fields: [], config: {} });
expect(c.get('p', req(5))).toEqual({ fields: [], config: {}, actions: [] });
});
it('POST delegates the patch for a bound user; empty when gated', () => {
@@ -9,7 +9,7 @@ const { testDb } = vi.hoisted(() => {
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', updated_at TEXT,
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT,
sort_order INTEGER DEFAULT 0);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`);
@@ -49,6 +49,16 @@ describe('providersOf enforces the hook:* grant', () => {
expect(s.providersOf('atlasLayerProvider')).toEqual(['atlas']);
expect(s.providersOf('journalEntryProvider')).toEqual(['journal']);
});
it('maps notificationChannel to hook:notification-channel', () => {
const s = makeSupervisor();
put(s, 'gotify', 'active', ['notificationChannel'], ['hook:notification-channel']);
// Implements the hook but was never granted it — must not become a channel.
put(s, 'sneaky', 'active', ['notificationChannel'], ['notify:send', 'http:outbound']);
// Granted, but disabled — a channel must not survive being turned off.
put(s, 'off', 'stopped', ['notificationChannel'], ['hook:notification-channel']);
expect(s.providersOf('notificationChannel')).toEqual(['gotify']);
});
});
describe('runtime.invokeHook defense-in-depth', () => {
@@ -0,0 +1,135 @@
/**
* Plugin settings isolation.
*
* A plugin's settings live in its OWN storage — instance config in `plugins.config`,
* per-user config in `plugin_user_config` keyed by (plugin_id, user_id). Plugin code
* never writes the core `settings` table, and every read helper is host-bound to one
* plugin id. These tests pin that, plus the settings-key constraint: an unconstrained
* key (`__proto__`, `constructor`) used to resolve off Object.prototype, so a REQUIRED
* field with such a name reported as configured for a user who had configured nothing —
* which for a notification channel meant being dispatched to everyone with no credentials.
*/
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { createUser } from '../../helpers/factories';
import { readUserSettingsDecrypted, hasRequiredUserSettings, readUserSettingDecrypted } from '../../../src/nest/plugins/plugins.service';
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
let uid: number;
function declareField(pluginId: string, key: string, opts: { required?: boolean; secret?: boolean } = {}) {
testDb.prepare(
`INSERT INTO plugin_settings_fields (plugin_id, field_key, label, input_type, required, secret, scope, sort_order)
VALUES (?, ?, ?, 'text', ?, ?, 'user', 0)`,
).run(pluginId, key, key, opts.required ? 1 : 0, opts.secret ? 1 : 0);
}
function setUserConfig(pluginId: string, config: Record<string, unknown>) {
testDb.prepare('INSERT OR REPLACE INTO plugin_user_config (plugin_id, user_id, config) VALUES (?, ?, ?)').run(pluginId, uid, JSON.stringify(config));
}
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
beforeEach(() => {
testDb.prepare('DELETE FROM plugin_settings_fields').run();
testDb.prepare('DELETE FROM plugin_user_config').run();
testDb.prepare('DELETE FROM settings').run();
testDb.prepare('DELETE FROM users').run();
uid = createUser(testDb).user.id;
});
describe('plugin settings are isolated from core and from each other', () => {
it('PSET-001 — a plugin declaring "webhook_url" cannot touch the CORE settings row', () => {
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://core.example.com/real')").run(uid);
declareField('evil', 'webhook_url');
setUserConfig('evil', { webhook_url: 'https://attacker.example.com' });
// The user's REAL notification webhook is untouched — the plugin's value lives in
// its own blob, in its own table. The namespacing is structural, not by key naming.
const core = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(uid) as { value: string };
expect(core.value).toBe('https://core.example.com/real');
expect(readUserSettingsDecrypted('evil', uid)).toEqual({ webhook_url: 'https://attacker.example.com' });
});
it('PSET-002 — plugin A cannot read plugin B’s config, even with the same key name', () => {
declareField('a', 'token', { secret: true });
declareField('b', 'token', { secret: true });
setUserConfig('b', { token: 'B-SECRET' });
expect(readUserSettingsDecrypted('a', uid)).toEqual({});
expect(readUserSettingDecrypted('a', uid, 'token')).toBeUndefined();
expect(readUserSettingDecrypted('b', uid, 'token')).toBe('B-SECRET');
});
it('PSET-003 — a plugin only ever sees its own DECLARED keys', () => {
declareField('p', 'declared');
// An undeclared key that somehow reached the blob is not handed to the plugin.
setUserConfig('p', { declared: 'yes', sneaked: 'no' });
expect(readUserSettingsDecrypted('p', uid)).toEqual({ declared: 'yes' });
});
});
describe('settings keys cannot resolve off the prototype chain', () => {
it.each(['__proto__', 'constructor', 'prototype'])(
'PSET-004 — a REQUIRED field named "%s" is NOT reported as configured',
(key) => {
declareField('evil', key, { required: true });
// The user has configured nothing at all.
expect(hasRequiredUserSettings('evil', uid)).toBe(false);
expect(readUserSettingsDecrypted('evil', uid)).toEqual({});
},
);
it('PSET-005 — a genuinely configured required field still reports configured', () => {
declareField('good', 'appToken', { required: true, secret: true });
expect(hasRequiredUserSettings('good', uid)).toBe(false);
setUserConfig('good', { appToken: 'T' });
expect(hasRequiredUserSettings('good', uid)).toBe(true);
});
it('PSET-006 — the manifest rejects such a key at install', () => {
const base = { id: 'evil', name: 'Evil', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false, permissions: [] };
for (const key of ['__proto__', 'constructor', 'prototype', 'has space', '1leading', 'a'.repeat(65)]) {
expect(() => parseManifest({ ...base, settings: [{ key, scope: 'user' }] })).toThrow(ManifestError);
}
// …and still accepts an ordinary one.
expect(parseManifest({ ...base, settings: [{ key: 'appToken', scope: 'user', secret: true, required: true }] }).settings[0].key).toBe('appToken');
});
});
/**
* The channel label is a plugin-supplied string that becomes a column header in the
* user's notification preferences matrix, so the host bounds it like every other
* plugin string it renders (cf. cap()/stripEmoji in the calendar + photo controllers).
*/
describe('a plugin channel label is bounded by the host', () => {
it('PSET-007 — an oversized, emoji-laden capabilities.title is capped and stripped', async () => {
const { PluginRuntimeService } = await import('../../../src/nest/plugins/plugin-runtime.service');
process.env.TREK_PLUGINS_ENABLED = 'true';
testDb.prepare(
`INSERT OR REPLACE INTO plugins (id, name, status, enabled, version, permissions, granted_permissions, capabilities, config)
VALUES ('loud', 'Loud', 'active', 1, '1.0.0', '[]', '[]', ?, '{}')`,
).run(JSON.stringify({ notificationChannel: { title: '🎉'.repeat(5) + 'A'.repeat(500) } }));
const rt = new PluginRuntimeService();
// Stand the plugin up as a granted, active notificationChannel provider.
(rt as unknown as { supervisor: { running: Map<string, unknown> } }).supervisor.running.set('loud', {
id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']),
});
const [channel] = rt.notificationChannels();
expect(channel.id).toBe('plugin:loud');
expect(channel.label!.length).toBeLessThanOrEqual(40);
expect(channel.label).not.toMatch(/\p{Extended_Pictographic}/u);
delete process.env.TREK_PLUGINS_ENABLED;
});
});
@@ -0,0 +1,193 @@
/**
* A plugin channel may only ever ADD a channel — never override or shadow a built-in.
*
* The runtime mints plugin ids with pluginChannelId() and leaves the built-in-only
* privileges off, so none of this is reachable through a manifest. These tests pin the
* REGISTRY's own enforcement, so the guarantee survives a bug or a future caller that
* hands the registry a channel it shouldn't. Without it, a channel claiming `email`
* rides the user's email opt-in and receives admin-scoped notifications.
*/
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null } };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
vi.mock('../../../src/services/apiKeyCrypto', () => ({ decrypt_api_key: (v: string) => v, maybe_encrypt_api_key: (v: string) => v, encrypt_api_key: (v: string) => v }));
const { sendMailMock } = vi.hoisted(() => ({ sendMailMock: vi.fn().mockResolvedValue({ accepted: ['a@b.c'] }) }));
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: sendMailMock, verify: vi.fn() })) } }));
vi.stubGlobal('fetch', vi.fn());
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
vi.mock('../../../src/utils/ssrfGuard', () => ({ checkSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '1.2.3.4' })), createPinnedDispatcher: vi.fn(() => ({})) }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, setAppSetting, setNotificationChannels } from '../../helpers/factories';
import { send } from '../../../src/services/notificationService';
import { getPreferencesMatrix } from '../../../src/services/notificationPreferencesService';
import {
setPluginChannelSource,
listChannels,
getChannel,
registerChannel,
type ExternalChannel,
} from '../../../src/services/notifications/channelRegistry';
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
beforeEach(() => { resetTestDb(testDb); setPluginChannelSource(null); });
const rogueSend = vi.fn().mockResolvedValue(true);
const rogueGlobal = vi.fn().mockResolvedValue(true);
function setSmtp(): void {
setAppSetting(testDb, 'smtp_host', 'mail.test.com');
setAppSetting(testDb, 'smtp_port', '587');
setAppSetting(testDb, 'smtp_from', 'trek@test.com');
}
/** A channel claiming a BUILT-IN id plus the built-in-only privilege flags. */
function rogue(over: Partial<ExternalChannel> = {}): ExternalChannel {
return {
id: 'email',
source: 'builtin',
bypassesActiveToggleForAdminEvents: true,
supportsAdminGlobal: true,
supportsEvent: () => true,
isConfiguredFor: () => true,
sendToUser: rogueSend,
sendGlobal: rogueGlobal,
...over,
} as ExternalChannel;
}
const TRIP_INVITE = { event: 'trip_invite', actorId: null, scope: 'user', targetId: 0, params: { trip: 'Rome', actor: 'A', invitee: 'B', tripId: '1' } } as const;
describe('a plugin channel can never override a built-in', () => {
beforeEach(() => { rogueSend.mockClear(); rogueGlobal.mockClear(); });
it('CHOVR-001 — a channel claiming a built-in id is dropped from the registry', () => {
setPluginChannelSource(() => [rogue()]);
expect(listChannels().filter(c => c.id === 'email')).toHaveLength(1);
expect(getChannel('email')!.source).toBe('builtin');
});
it('CHOVR-002 — it does NOT ride the user’s email opt-in', async () => {
const { user } = createUser(testDb);
setSmtp();
setNotificationChannels(testDb, 'email'); // the user enabled EMAIL, not a plugin
setPluginChannelSource(() => [rogue()]);
await send({ ...TRIP_INVITE, targetId: user.id });
expect(rogueSend).not.toHaveBeenCalled();
expect(sendMailMock).toHaveBeenCalledTimes(1); // the real email channel still delivers
});
it('CHOVR-003 — an un-namespaced id is dropped even when it collides with nothing', () => {
setPluginChannelSource(() => [rogue({ id: 'carrier-pigeon', source: 'plugin' })]);
expect(listChannels().map(c => c.id)).not.toContain('carrier-pigeon');
});
it('CHOVR-004 — a properly namespaced channel IS admitted', () => {
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin' })]);
expect(listChannels().map(c => c.id)).toContain('plugin:gotify');
});
it('CHOVR-005 — an admitted plugin channel has its built-in-only privileges stripped', () => {
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify' })]);
const ch = getChannel('plugin:gotify')!;
expect(ch.source).toBe('plugin'); // it cannot self-declare as a built-in
expect(ch.bypassesActiveToggleForAdminEvents).toBe(false);
expect(ch.supportsAdminGlobal).toBe(false);
expect(ch.sendGlobal).toBeUndefined();
});
it('CHOVR-006 — a plugin channel gets NO admin-scoped event, even claiming to support one', async () => {
createAdmin(testDb);
setSmtp();
setNotificationChannels(testDb, 'plugin:gotify');
// supportsEvent lies and says yes to everything; the registry strips what makes it matter.
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', supportsEvent: () => true })]);
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '9.9.9' } });
expect(rogueSend).not.toHaveBeenCalled();
expect(rogueGlobal).not.toHaveBeenCalled();
});
it('CHOVR-007 — duplicate plugin ids collapse to one (no double send, no double column)', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'plugin:gotify');
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify' }), rogue({ id: 'plugin:gotify' })]);
expect(listChannels().filter(c => c.id === 'plugin:gotify')).toHaveLength(1);
await send({ ...TRIP_INVITE, targetId: user.id });
expect(rogueSend).toHaveBeenCalledTimes(1);
});
it('CHOVR-008 — the preferences matrix never shows a duplicate column', () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'email');
setPluginChannelSource(() => [rogue()]);
const ids = getPreferencesMatrix(user.id, 'user').channels.map(c => c.id);
expect(ids.filter(i => i === 'email')).toHaveLength(1);
expect(new Set(ids).size).toBe(ids.length);
});
it('CHOVR-009 — a built-in may not claim the plugin namespace either', () => {
expect(() => registerChannel(rogue({ id: 'plugin:sneaky' }))).toThrow(/plugin namespace/);
});
});
describe('a live plugin channel needs no second opt-in', () => {
beforeEach(() => { rogueSend.mockClear(); });
it('CHOVR-011 — it appears in the matrix with NOTHING in notification_channels', () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none'); // the admin has enabled no built-in at all
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin', label: 'Gotify' })]);
const matrix = getPreferencesMatrix(user.id, 'user');
const ch = matrix.channels.find(c => c.id === 'plugin:gotify')!;
// Enabling the PLUGIN is the opt-in. There is no UI that can write a `plugin:` id into
// the notification_channels CSV, so requiring one meant the channel could never show.
expect(ch.active).toBe(true);
expect(ch.label).toBe('Gotify');
expect(matrix.implemented_combos['trip_invite']).toContain('plugin:gotify');
});
it('CHOVR-012 — and it actually DELIVERS with nothing in notification_channels', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none');
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin' })]);
await send({ ...TRIP_INVITE, targetId: user.id });
expect(rogueSend).toHaveBeenCalledTimes(1);
});
it('CHOVR-013 — a BUILT-IN still needs its explicit switch', async () => {
const { user } = createUser(testDb);
setSmtp();
setNotificationChannels(testDb, 'none'); // email NOT enabled
sendMailMock.mockClear();
await send({ ...TRIP_INVITE, targetId: user.id });
expect(sendMailMock).not.toHaveBeenCalled();
});
it('CHOVR-014 — an unconfigured user sees the column but gets no delivery', async () => {
const { user } = createUser(testDb);
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin', isConfiguredFor: () => false })]);
const ch = getPreferencesMatrix(user.id, 'user').channels.find(c => c.id === 'plugin:gotify')!;
expect(ch.active).toBe(true); // shown — so they can see it and go configure it
expect(ch.configured).toBe(false); // …but flagged as needing setup
await send({ ...TRIP_INVITE, targetId: user.id });
expect(rogueSend).not.toHaveBeenCalled(); // no credentials → nothing to send with
});
});
@@ -0,0 +1,115 @@
/**
* Unit tests for the external notification channel registry.
* Covers CHREG-001 to CHREG-008.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
listChannels,
getChannel,
registerChannel,
setPluginChannelSource,
pluginChannelId,
isPluginChannelId,
__resetChannelsForTest,
type ChannelMessage,
type ExternalChannel,
} from '../../../src/services/notifications/channelRegistry';
import { registerBuiltinChannels } from '../../../src/services/notifications/builtins';
function fakeChannel(id: string, over: Partial<ExternalChannel> = {}): ExternalChannel {
return {
id,
source: 'plugin',
label: id,
supportsEvent: () => true,
isConfiguredFor: () => true,
sendToUser: async () => true,
...over,
};
}
const MSG: ChannelMessage = { event: 'trip_invite', title: 't', body: 'b' };
beforeEach(() => {
__resetChannelsForTest();
registerBuiltinChannels();
});
afterEach(() => {
setPluginChannelSource(null);
});
describe('channelRegistry', () => {
it('CHREG-001 — the three built-in external channels are registered; in-app is not', () => {
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
expect(getChannel('inapp')).toBeUndefined();
});
it('CHREG-002 — plugin channels come from the injected source and are namespaced', () => {
setPluginChannelSource(() => [fakeChannel(pluginChannelId('gotify'))]);
expect(listChannels().map(c => c.id)).toContain('plugin:gotify');
expect(getChannel('plugin:gotify')?.source).toBe('plugin');
expect(isPluginChannelId('plugin:gotify')).toBe(true);
expect(isPluginChannelId('email')).toBe(false);
});
it('CHREG-003 — a plugin channel disappears when the runtime stops reporting it', () => {
let live = true;
setPluginChannelSource(() => (live ? [fakeChannel('plugin:gotify')] : []));
expect(getChannel('plugin:gotify')).toBeDefined();
live = false;
expect(getChannel('plugin:gotify')).toBeUndefined();
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
});
it('CHREG-004 — a throwing plugin source cannot take notifications down', () => {
setPluginChannelSource(() => {
throw new Error('runtime exploded');
});
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
});
it('CHREG-005 — a plugin can never claim a built-in id', () => {
// The prefix is the whole defence: an id without it is not a plugin channel id,
// and pluginChannelId() is the only way the runtime mints one.
expect(pluginChannelId('email')).toBe('plugin:email');
expect(getChannel('email')?.source).toBe('builtin');
});
it('CHREG-006 — only email declares the admin-scoped toggle bypass', () => {
expect(getChannel('email')?.bypassesActiveToggleForAdminEvents).toBe(true);
expect(getChannel('webhook')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
expect(getChannel('ntfy')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
});
it('CHREG-007 — webhook and ntfy deliver the admin-global copy; email does not', () => {
expect(getChannel('webhook')?.supportsAdminGlobal).toBe(true);
expect(getChannel('ntfy')?.supportsAdminGlobal).toBe(true);
expect(getChannel('email')?.supportsAdminGlobal).toBeUndefined();
});
it('CHREG-008 — built-ins carry every event except synology_session_cleared', () => {
for (const id of ['email', 'webhook', 'ntfy']) {
expect(getChannel(id)!.supportsEvent('trip_invite')).toBe(true);
expect(getChannel(id)!.supportsEvent('version_available')).toBe(true);
expect(getChannel(id)!.supportsEvent('synology_session_cleared')).toBe(false);
}
});
it('CHREG-009 — registerChannel replaces an existing id rather than duplicating it', () => {
registerChannel(fakeChannel('email', { source: 'builtin' }));
expect(listChannels().filter(c => c.id === 'email')).toHaveLength(1);
});
it('CHREG-010 — a channel that rejects is the caller’s problem, not the registry’s', async () => {
const boom = fakeChannel('plugin:boom', {
sendToUser: async () => {
throw new Error('nope');
},
});
setPluginChannelSource(() => [boom]);
await expect(getChannel('plugin:boom')!.sendToUser(1, MSG)).rejects.toThrow('nope');
// and the registry is unharmed
expect(listChannels()).toHaveLength(4);
});
});
@@ -43,7 +43,7 @@ import {
setAdminPreferences,
getAdminGlobalPref,
getActiveChannels,
getAvailableChannels,
isSmtpConfigured,
isWebhookConfigured,
} from '../../../src/services/notificationPreferencesService';
@@ -134,24 +134,24 @@ describe('getPreferencesMatrix', () => {
expect(preferences['booking_change']!['email']).toBe(true);
});
it('NPREF-008 — available_channels.inapp is always true', () => {
it('NPREF-008 — the inapp channel is always active', () => {
const { user } = createUser(testDb);
const { available_channels } = getPreferencesMatrix(user.id, 'user');
expect(available_channels.inapp).toBe(true);
const { channels } = getPreferencesMatrix(user.id, 'user');
expect(channels.find(c => c.id === 'inapp')?.active).toBe(true);
});
it('NPREF-009 — available_channels.email is true when email is in notification_channels', () => {
it('NPREF-009 — email is active when email is in notification_channels', () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'email');
const { available_channels } = getPreferencesMatrix(user.id, 'user');
expect(available_channels.email).toBe(true);
const { channels } = getPreferencesMatrix(user.id, 'user');
expect(channels.find(c => c.id === 'email')?.active).toBe(true);
});
it('NPREF-010 — available_channels.email is false when email is not in notification_channels', () => {
it('NPREF-010 — email is inactive when email is not in notification_channels', () => {
const { user } = createUser(testDb);
// No notification_channels set → defaults to none
const { available_channels } = getPreferencesMatrix(user.id, 'user');
expect(available_channels.email).toBe(false);
const { channels } = getPreferencesMatrix(user.id, 'user');
expect(channels.find(c => c.id === 'email')?.active).toBe(false);
});
it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook, ntfy]', () => {
@@ -241,34 +241,36 @@ describe('getActiveChannels', () => {
});
// ─────────────────────────────────────────────────────────────────────────────
// getAvailableChannels
// SMTP / active-channel detection
// ─────────────────────────────────────────────────────────────────────────────
describe('getAvailableChannels', () => {
describe('channel availability', () => {
it('NPREF-019 — detects SMTP config from app_settings.smtp_host', () => {
setAppSetting(testDb, 'smtp_host', 'mail.example.com');
const channels = getAvailableChannels();
expect(channels.email).toBe(true);
expect(channels.inapp).toBe(true);
expect(isSmtpConfigured()).toBe(true);
});
it('NPREF-020 — webhook available when admin has enabled the webhook channel', () => {
it('NPREF-020 — webhook is active when admin has enabled the webhook channel', () => {
setNotificationChannels(testDb, 'webhook');
const channels = getAvailableChannels();
expect(channels.webhook).toBe(true);
expect(getActiveChannels()).toContain('webhook');
});
it('NPREF-021 — detects SMTP config from env var SMTP_HOST', () => {
const original = process.env.SMTP_HOST;
process.env.SMTP_HOST = 'env-mail.example.com';
try {
const channels = getAvailableChannels();
expect(channels.email).toBe(true);
expect(isSmtpConfigured()).toBe(true);
} finally {
if (original === undefined) delete process.env.SMTP_HOST;
else process.env.SMTP_HOST = original;
}
});
it('NPREF-022 — an unknown channel id in notification_channels is ignored', () => {
// e.g. a plugin channel left in the CSV after the plugin was uninstalled
setNotificationChannels(testDb, 'webhook,plugin:long-gone');
expect(getActiveChannels()).toEqual(['webhook']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -2,7 +2,7 @@
* Unit tests for the unified notificationService.send().
* Covers NSVC-001 to NSVC-014.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -59,6 +59,7 @@ import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories';
import { send } from '../../../src/services/notificationService';
import { setPluginChannelSource, type ExternalChannel } from '../../../src/services/notifications/channelRegistry';
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -549,3 +550,117 @@ describe('send() — ntfy channel dispatch', () => {
expect(ntfyCalls[0][1].headers['Tags']).toContain('package');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Plugin notification channels (hook:notification-channel)
// ─────────────────────────────────────────────────────────────────────────────
describe('send() — plugin notification channels', () => {
const sendSpy = vi.fn();
function installPluginChannel(over: Partial<ExternalChannel> = {}): void {
sendSpy.mockClear();
sendSpy.mockResolvedValue(undefined);
setPluginChannelSource(() => [
{
id: 'plugin:gotify',
source: 'plugin',
label: 'Gotify',
supportsEvent: (e: string) => e !== 'version_available' && e !== 'synology_session_cleared',
isConfiguredFor: () => true,
sendToUser: sendSpy,
...over,
} as ExternalChannel,
]);
}
afterEach(() => setPluginChannelSource(null));
it('NSVC-PLUG-001 — delivers to a plugin channel the admin enabled', async () => {
const { user } = createUser(testDb);
installPluginChannel();
setNotificationChannels(testDb, 'plugin:gotify');
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
expect(sendSpy).toHaveBeenCalledTimes(1);
const [recipientId, msg] = sendSpy.mock.calls[0];
expect(recipientId).toBe(user.id);
// The host renders the text — a channel plugin never touches i18n.
expect(msg.event).toBe('trip_invite');
expect(msg.title).toBeTruthy();
expect(msg.body).toContain('Rome');
});
it('NSVC-PLUG-002 — delivers with NOTHING in notification_channels: enabling the plugin IS the opt-in', async () => {
const { user } = createUser(testDb);
installPluginChannel();
setNotificationChannels(testDb, 'none');
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
// A built-in always exists in the code, so it needs an explicit switch. A plugin
// channel only exists because an admin installed and enabled that plugin — and
// nothing can write a `plugin:` id into this CSV anyway, so requiring a second
// opt-in meant the channel could never be turned on at all.
expect(sendSpy).toHaveBeenCalledTimes(1);
});
it('NSVC-PLUG-003 — skipped when the user opted out of this event on it', async () => {
const { user } = createUser(testDb);
installPluginChannel();
setNotificationChannels(testDb, 'plugin:gotify');
disableNotificationPref(testDb, user.id, 'trip_invite', 'plugin:gotify');
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
expect(sendSpy).not.toHaveBeenCalled();
});
it('NSVC-PLUG-004 — skipped when the user has not set their credentials', async () => {
const { user } = createUser(testDb);
installPluginChannel({ isConfiguredFor: () => false });
setNotificationChannels(testDb, 'plugin:gotify');
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
expect(sendSpy).not.toHaveBeenCalled();
});
it('NSVC-PLUG-005 — a throwing plugin channel does not stop in-app or email delivery', async () => {
const { user } = createUser(testDb);
installPluginChannel({ sendToUser: vi.fn().mockRejectedValue(new Error('gotify is down')) });
setSmtp();
setNotificationChannels(testDb, 'email,plugin:gotify');
sendMailMock.mockClear();
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
expect(sendMailMock).toHaveBeenCalledTimes(1);
expect(getInAppNotifications(user.id).length).toBe(1);
});
it('NSVC-PLUG-006 — never receives an admin-scoped event', async () => {
createAdmin(testDb);
// Even if a (malicious) channel claims to support it, ADMIN_SCOPED_EVENTS is
// gated host-side: only a channel that bypasses the toggle (email) delivers those.
installPluginChannel({ supportsEvent: () => true });
setNotificationChannels(testDb, 'plugin:gotify');
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '3.0.0' } });
expect(sendSpy).not.toHaveBeenCalled();
});
it('NSVC-PLUG-007 — a channel that narrows its events only gets those', async () => {
const { user } = createUser(testDb);
installPluginChannel({ supportsEvent: (e: string) => e === 'booking_change' });
setNotificationChannels(testDb, 'plugin:gotify');
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
expect(sendSpy).not.toHaveBeenCalled();
await send({ event: 'booking_change', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', tripId: '1' } });
expect(sendSpy).toHaveBeenCalledTimes(1);
});
});
+49 -19
View File
@@ -204,6 +204,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'تم تعطيل الإضافة',
'admin.plugins.viewErrors': 'عرض سجل الأخطاء',
'admin.plugins.errorLog': 'سجل الأخطاء',
'admin.plugins.allowedHosts': 'المضيفون المسموح بهم',
'admin.plugins.allowedHosts.hint': 'تتصل هذه الإضافة بخدمة لا يمكن لأحد سواك تسميتها (خادم مستضاف ذاتيًا). أضف المضيفين الذين يمكنها الوصول إليهم — ولن تصل إلى غيرهم.',
'admin.plugins.allowedHosts.none': 'لم تتم إضافة أي مضيف بعد.',
'admin.plugins.allowedHosts.unsupported': 'لا تستخدم هذه الإضافة مضيفين يحددهم المشغّل. مضيفوها المسموح بهم ثابتون في ملف التعريف.',
'admin.plugins.allowedHosts.restartNote': 'الحفظ يعيد تشغيل الإضافة لتأخذ القائمة الجديدة.',
'admin.plugins.allowedHosts.add': 'إضافة مضيف مسموح به',
'admin.plugins.allowedHosts.count': '{n} مضيف مسموح به',
'admin.plugins.operatorEgressPill': '+ مضيفون تضيفهم',
'admin.plugins.operatorEgressHint': 'تتصل هذه الإضافة بخدمة لا يمكن لأحد سواك تسميتها (خادم مستضاف ذاتيًا). بعد التثبيت أضف المضيفين من ⋯ ← المضيفون المسموح بهم. ولن تصل إلى غيرهم.',
'admin.plugins.noErrors': 'لا توجد أخطاء مسجّلة.',
'admin.plugins.uninstalled': 'تمت إزالة الإضافة',
'admin.plugins.uninstallTitle': 'إزالة الإضافة؟',
@@ -255,33 +264,49 @@ const admin: TranslationStrings = {
'قراءة معلومات الملف الشخصي الأساسية (الاسم والصورة الرمزية — وليس بيانات الاعتماد أبدًا)',
'admin.plugins.perm.db:read:costs': 'قراءة المصروفات (بنود الميزانية) التي يمكن للمستخدم الحالي الوصول إليها',
'admin.plugins.perm.db:read:packing': 'قراءة قوائم الأمتعة للرحلات التي يمكن للمستخدم الحالي الوصول إليها',
'admin.plugins.perm.db:write:packing': 'إنشاء عناصر قائمة الأمتعة وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها',
'admin.plugins.perm.db:write:packing':
'إنشاء عناصر قائمة الأمتعة وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها',
'admin.plugins.perm.weather:read': 'قراءة توقعات الطقس المخزنة مؤقتًا للمضيف (حسب الإحداثيات)',
'admin.plugins.perm.db:read:categories': 'قراءة قائمة فئات الأماكن العامة',
'admin.plugins.perm.db:read:tags': 'قراءة وسوم المستخدم الحالي',
'admin.plugins.perm.db:write:tags': 'إنشاء وسوم المستخدم الحالي وتعديلها وحذفها',
'admin.plugins.perm.db:read:todos': 'قراءة مهام الرحلات التي يمكن للمستخدم الحالي الوصول إليها',
'admin.plugins.perm.db:write:todos': 'إنشاء مهام وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها',
'admin.plugins.perm.db:write:atlas': 'وضع علامة على البلدان والمناطق كمُزارة وإدارة قائمة أمنيات المستخدم الحالي (يتطلب إضافة Atlas)',
'admin.plugins.perm.db:write:vacay': 'تبديل أيام الإجازة وعطلات الشركة في الخطة النشطة للمستخدم الحالي (يتطلب إضافة Vacay)',
'admin.plugins.perm.db:write:journal': 'إنشاء إدخالات اليوميات وتعديلها وحذفها في اليوميات التي يمكن للمستخدم الحالي تعديلها (يتطلب إضافة Journey)',
'admin.plugins.perm.db:write:collections': 'إنشاء المجموعات وتعديلها وحفظ الأماكن فيها، بدور المجموعة الخاص بالمستخدم الحالي (يتطلب إضافة Collections)',
'admin.plugins.perm.db:write:files': 'إرفاق ملفات بالرحلات التي يمكن للمستخدم الحالي تعديلها (حد 10 ميغابايت، الامتدادات المحظورة مرفوضة) وإدارة روابطها',
'admin.plugins.perm.db:write:collab': 'نشر ملاحظات واستطلاعات ورسائل دردشة في الرحلات التي يمكن للمستخدم الحالي تعديلها (يتطلب إضافة Collab)',
'admin.plugins.perm.db:write:members': 'إضافة مستخدمين إلى الرحلات — يمنح الوصول إلى الرحلة؛ محمي بحق إدارة الأعضاء الخاص بالمستخدم الحالي',
'admin.plugins.perm.notify:send': 'إرسال إشعار (صندوق الجرس + بريد إلكتروني/ntfy) إلى المستخدم الحالي أو رحلة ينتمي إليها — لا إلى مستلم عشوائي أبدًا',
'admin.plugins.perm.ai:invoke': 'تشغيل مزود الذكاء الاصطناعي الذي أعده المسؤول (إكمال النص + استخراج المستندات) نيابة عن المستخدم الحالي — لا تحتفظ الإضافة بمفتاح أبدًا',
'admin.plugins.perm.oauth:client': 'الاتصال بخدمة خارجية نيابة عنك عبر OAuth يديره المضيف (يحتفظ المضيف بالرموز، ولا تراها الإضافة أبدًا)',
'admin.plugins.perm.db:write:atlas':
'وضع علامة على البلدان والمناطق كمُزارة وإدارة قائمة أمنيات المستخدم الحالي (يتطلب إضافة Atlas)',
'admin.plugins.perm.db:write:vacay':
'تبديل أيام الإجازة وعطلات الشركة في الخطة النشطة للمستخدم الحالي (يتطلب إضافة Vacay)',
'admin.plugins.perm.db:write:journal':
'إنشاء إدخالات اليوميات وتعديلها وحذفها في اليوميات التي يمكن للمستخدم الحالي تعديلها (يتطلب إضافة Journey)',
'admin.plugins.perm.db:write:collections':
'إنشاء المجموعات وتعديلها وحفظ الأماكن فيها، بدور المجموعة الخاص بالمستخدم الحالي (يتطلب إضافة Collections)',
'admin.plugins.perm.db:write:files':
'إرفاق ملفات بالرحلات التي يمكن للمستخدم الحالي تعديلها (حد 10 ميغابايت، الامتدادات المحظورة مرفوضة) وإدارة روابطها',
'admin.plugins.perm.db:write:collab':
'نشر ملاحظات واستطلاعات ورسائل دردشة في الرحلات التي يمكن للمستخدم الحالي تعديلها (يتطلب إضافة Collab)',
'admin.plugins.perm.db:write:members':
'إضافة مستخدمين إلى الرحلات — يمنح الوصول إلى الرحلة؛ محمي بحق إدارة الأعضاء الخاص بالمستخدم الحالي',
'admin.plugins.perm.notify:send':
'إرسال إشعار (صندوق الجرس + بريد إلكتروني/ntfy) إلى المستخدم الحالي أو رحلة ينتمي إليها — لا إلى مستلم عشوائي أبدًا',
'admin.plugins.perm.ai:invoke':
'تشغيل مزود الذكاء الاصطناعي الذي أعده المسؤول (إكمال النص + استخراج المستندات) نيابة عن المستخدم الحالي — لا تحتفظ الإضافة بمفتاح أبدًا',
'admin.plugins.perm.oauth:client':
'الاتصال بخدمة خارجية نيابة عنك عبر OAuth يديره المضيف (يحتفظ المضيف بالرموز، ولا تراها الإضافة أبدًا)',
'admin.plugins.perm.db:read:files': 'قراءة ملفات الرحلات التي يمكن للمستخدم الحالي الوصول إليها',
'admin.plugins.perm.db:write:reservations': 'إنشاء الحجوزات وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها',
'admin.plugins.perm.db:write:accommodations': 'إنشاء أماكن الإقامة وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها',
'admin.plugins.perm.db:write:reservations':
'إنشاء الحجوزات وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها',
'admin.plugins.perm.db:write:accommodations':
'إنشاء أماكن الإقامة وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها',
'admin.plugins.perm.db:read:journal': 'قراءة يوميات السفر الخاصة بالمستخدم الحالي (يتطلب إضافة Journey)',
'admin.plugins.perm.db:read:atlas': 'قراءة البلدان والمناطق التي زارها المستخدم الحالي (يتطلب إضافة Atlas)',
'admin.plugins.perm.db:read:vacay': 'قراءة خطة الإجازة الخاصة بالمستخدم الحالي (يتطلب إضافة Vacay)',
'admin.plugins.perm.db:read:daynotes': 'قراءة ملاحظات اليوم للرحلات التي يمكن للمستخدم الحالي الوصول إليها',
'admin.plugins.perm.db:read:collections': 'قراءة مجموعات الأماكن المحفوظة الخاصة بالمستخدم الحالي (يتطلب إضافة Collections)',
'admin.plugins.perm.db:write:daynotes': 'إنشاء ملاحظات اليوم وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها',
'admin.plugins.perm.jobs:run': 'تشغيل مهام الخلفية المُعلنة وفق جدول زمني (بدون سياق مستخدم — لا يمكنه قراءة بيانات المستخدم)',
'admin.plugins.perm.db:read:collections':
'قراءة مجموعات الأماكن المحفوظة الخاصة بالمستخدم الحالي (يتطلب إضافة Collections)',
'admin.plugins.perm.db:write:daynotes':
'إنشاء ملاحظات اليوم وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها',
'admin.plugins.perm.jobs:run':
'تشغيل مهام الخلفية المُعلنة وفق جدول زمني (بدون سياق مستخدم — لا يمكنه قراءة بيانات المستخدم)',
'admin.plugins.perm.db:write:costs': 'إنشاء مصروفات (بنود الميزانية) على الرحلات التي يمكن للمستخدم الحالي تعديلها',
'admin.plugins.perm.db:write:places': 'إضافة الأماكن وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها',
'admin.plugins.perm.db:write:days': 'إضافة الأيام وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها',
@@ -301,12 +326,16 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:atlas-layer-provider':
'إبراز دول على خريطة أطلس العالمية (مثل قوائم الأمنيات أو تحذيرات السفر)',
'admin.plugins.perm.hook:journal-entry-provider': 'إضافة صفوف إضافية (روابط، إحصاءات) إلى مدخلات اليوميات',
'admin.plugins.perm.hook:user-data': 'محو أو تصدير البيانات التي يخزّنها عن مستخدم (اللائحة العامة لحماية البيانات: حذف الحساب وطلبات البيانات)',
'admin.plugins.perm.hook:user-data':
'محو أو تصدير البيانات التي يخزّنها عن مستخدم (اللائحة العامة لحماية البيانات: حذف الحساب وطلبات البيانات)',
'admin.plugins.perm.hook:trip-card-provider': 'إضافة شارات صغيرة (الحالة، الأعداد) إلى بطاقات الرحلات في لوحة التحكم',
'admin.plugins.perm.hook:notification-channel': 'تسليم إشعاراتك عبر قناة إضافية',
'admin.plugins.perm.events:subscribe': 'التفاعل مع أحداث النشاط الأساسية (اسم الحدث والرحلة فقط، دون المحتوى مطلقًا)',
'admin.plugins.perm.http:outbound': 'إجراء طلبات صادرة إلى المضيفات المُعلنة الخاصة بها',
'admin.plugins.perm.db:read:collab': 'قراءة الملاحظات والاستطلاعات ورسائل الدردشة في الرحلات التي يمكن للمستخدم الحالي الوصول إليها (يتطلب إضافة Collab)',
'admin.plugins.perm.db:read:files:content': 'قراءة محتوى (بايتات) ملفات الرحلات التي يمكن للمستخدم الحالي الوصول إليها',
'admin.plugins.perm.db:read:collab':
'قراءة الملاحظات والاستطلاعات ورسائل الدردشة في الرحلات التي يمكن للمستخدم الحالي الوصول إليها (يتطلب إضافة Collab)',
'admin.plugins.perm.db:read:files:content':
'قراءة محتوى (بايتات) ملفات الرحلات التي يمكن للمستخدم الحالي الوصول إليها',
'admin.plugins.perm.db:create:trips': 'إنشاء رحلات جديدة يملكها المستخدم الحالي',
'admin.plugins.perm.rates:read': 'قراءة أسعار صرف العملات المخزنة مؤقتًا للمضيف',
'admin.plugins.updateConsentTitle': 'يتطلب هذا التحديث أذونات جديدة',
@@ -376,6 +405,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'تفاصيل الحجز',
'admin.plugins.cap.replacesTabs': 'يستبدل تبويبات المخطط',
'admin.plugins.cap.realtime': 'تحديثات فورية',
'admin.plugins.cap.notificationChannel': 'قناة الإشعارات',
'admin.plugins.cap.photos': 'يوفّر الصور',
'admin.plugins.cap.calendar': 'يوفّر أحداث التقويم',
'admin.plugins.cap.placeDetails': 'يُثري الأماكن',
+8
View File
@@ -270,6 +270,14 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.email': 'Email', // en-fallback
'settings.notificationPreferences.webhook': 'Webhook', // en-fallback
'settings.notificationPreferences.inapp': 'In-App', // en-fallback
'settings.notificationPreferences.notConfigured': 'لم يتم الإعداد بعد — قم بإعداده من إعدادات الإضافة',
'settings.plugins.actions': 'الإجراءات',
'settings.plugins.actions.confirm': 'تنفيذ هذا الإجراء؟',
'settings.notificationPreferences.sendTest': 'إرسال اختبار',
'settings.notificationPreferences.configure': 'تهيئة',
'settings.notificationPreferences.testSuccess': 'تم إرسال إشعار اختباري.',
'settings.notificationPreferences.testFailed': 'فشل الاختبار.',
'settings.notificationPreferences.pluginConfigured': 'تم الإعداد. أدر بيانات الاعتماد من صفحة إعدادات الإضافة.',
'settings.notificationPreferences.ntfy': 'Ntfy', // en-fallback
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', // en-fallback
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', // en-fallback
+59 -24
View File
@@ -166,7 +166,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'Plugin “{name}” enviado — ative para executar',
'admin.plugins.sideloaded': 'Carregado manualmente',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Carregado de um diretório de build local e recarregado a quente com dados reais — apenas para desenvolvimento',
'admin.plugins.devLinkHint':
'Carregado de um diretório de build local e recarregado a quente com dados reais — apenas para desenvolvimento',
'admin.plugins.devLinkTitle': 'Vincular um plugin local',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Vincular',
@@ -183,6 +184,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Plugin desativado',
'admin.plugins.viewErrors': 'Ver registro de erros',
'admin.plugins.errorLog': 'Registro de erros',
'admin.plugins.allowedHosts': 'Hosts permitidos',
'admin.plugins.allowedHosts.hint': 'Este plugin fala com um serviço que só você pode nomear (um servidor auto-hospedado). Adicione os hosts que ele pode alcançar — nenhum outro.',
'admin.plugins.allowedHosts.none': 'Nenhum host adicionado ainda.',
'admin.plugins.allowedHosts.unsupported': 'Este plugin não usa hosts fornecidos pelo operador. Seus hosts permitidos são fixos no manifesto.',
'admin.plugins.allowedHosts.restartNote': 'Salvar reinicia o plugin para que ele use a nova lista.',
'admin.plugins.allowedHosts.add': 'Adicionar host permitido',
'admin.plugins.allowedHosts.count': '{n} host(s) permitido(s)',
'admin.plugins.operatorEgressPill': '+ hosts que você adicionar',
'admin.plugins.operatorEgressHint': 'Este plugin fala com um serviço que só você pode nomear (um servidor auto-hospedado). Após instalar, adicione os hosts que ele pode alcançar em ⋯ → Hosts permitidos. Nenhum outro.',
'admin.plugins.noErrors': 'Nenhum erro registrado.',
'admin.plugins.uninstalled': 'Plugin desinstalado',
'admin.plugins.uninstallTitle': 'Desinstalar plugin?',
@@ -233,33 +243,50 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:users': 'Ler informações básicas de perfil (nome, avatar — nunca credenciais)',
'admin.plugins.perm.db:read:costs': 'Ler despesas (itens de orçamento) às quais o usuário atual tem acesso',
'admin.plugins.perm.db:read:packing': 'Ler listas de bagagem de viagens às quais o usuário atual tem acesso',
'admin.plugins.perm.db:write:packing': 'Criar, editar e remover itens da lista de bagagem em viagens que o usuário atual pode editar',
'admin.plugins.perm.db:write:packing':
'Criar, editar e remover itens da lista de bagagem em viagens que o usuário atual pode editar',
'admin.plugins.perm.weather:read': 'Ler a previsão do tempo em cache do host (por coordenadas)',
'admin.plugins.perm.db:read:categories': 'Ler a lista global de categorias de locais',
'admin.plugins.perm.db:read:tags': 'Ler as etiquetas do usuário atual',
'admin.plugins.perm.db:write:tags': 'Criar, editar e remover as etiquetas do usuário atual',
'admin.plugins.perm.db:read:todos': 'Ler as tarefas de viagens às quais o usuário atual tem acesso',
'admin.plugins.perm.db:write:todos': 'Criar, editar e remover tarefas em viagens que o usuário atual pode editar',
'admin.plugins.perm.db:write:atlas': 'Marcar países e regiões como visitados e gerenciar a lista de desejos do usuário atual (requer o complemento Atlas)',
'admin.plugins.perm.db:write:vacay': 'Alternar dias de férias e feriados da empresa no plano ativo do usuário atual (requer o complemento Vacay)',
'admin.plugins.perm.db:write:journal': 'Criar, editar e remover entradas de diário em diários que o usuário atual pode editar (requer o complemento Journey)',
'admin.plugins.perm.db:write:collections': 'Criar e editar coleções e salvar locais nelas, com o papel de coleção do usuário atual (requer o complemento Collections)',
'admin.plugins.perm.db:write:files': 'Anexar arquivos a viagens que o usuário atual pode editar (limite de 10 MB, extensões bloqueadas recusadas) e gerenciar seus vínculos',
'admin.plugins.perm.db:write:collab': 'Publicar notas, enquetes e mensagens de chat em viagens que o usuário atual pode editar (requer o complemento Collab)',
'admin.plugins.perm.db:write:members': 'Adicionar usuários a viagens — concede acesso à viagem; protegido pelo direito de gerenciar membros do usuário atual',
'admin.plugins.perm.notify:send': 'Enviar uma notificação (caixa do sino + e-mail/ntfy) ao usuário atual ou a uma viagem à qual ele pertence — nunca a um destinatário arbitrário',
'admin.plugins.perm.ai:invoke': 'Usar o provedor de IA configurado pelo administrador (conclusão de texto + extração de documentos) em nome do usuário atual — o complemento nunca guarda uma chave',
'admin.plugins.perm.oauth:client': 'Conectar-se a um serviço de terceiros em seu nome via OAuth intermediado pelo host (o host guarda os tokens, o plugin nunca os vê)',
'admin.plugins.perm.db:write:atlas':
'Marcar países e regiões como visitados e gerenciar a lista de desejos do usuário atual (requer o complemento Atlas)',
'admin.plugins.perm.db:write:vacay':
'Alternar dias de férias e feriados da empresa no plano ativo do usuário atual (requer o complemento Vacay)',
'admin.plugins.perm.db:write:journal':
'Criar, editar e remover entradas de diário em diários que o usuário atual pode editar (requer o complemento Journey)',
'admin.plugins.perm.db:write:collections':
'Criar e editar coleções e salvar locais nelas, com o papel de coleção do usuário atual (requer o complemento Collections)',
'admin.plugins.perm.db:write:files':
'Anexar arquivos a viagens que o usuário atual pode editar (limite de 10 MB, extensões bloqueadas recusadas) e gerenciar seus vínculos',
'admin.plugins.perm.db:write:collab':
'Publicar notas, enquetes e mensagens de chat em viagens que o usuário atual pode editar (requer o complemento Collab)',
'admin.plugins.perm.db:write:members':
'Adicionar usuários a viagens — concede acesso à viagem; protegido pelo direito de gerenciar membros do usuário atual',
'admin.plugins.perm.notify:send':
'Enviar uma notificação (caixa do sino + e-mail/ntfy) ao usuário atual ou a uma viagem à qual ele pertence — nunca a um destinatário arbitrário',
'admin.plugins.perm.ai:invoke':
'Usar o provedor de IA configurado pelo administrador (conclusão de texto + extração de documentos) em nome do usuário atual — o complemento nunca guarda uma chave',
'admin.plugins.perm.oauth:client':
'Conectar-se a um serviço de terceiros em seu nome via OAuth intermediado pelo host (o host guarda os tokens, o plugin nunca os vê)',
'admin.plugins.perm.db:read:files': 'Ler arquivos de viagens às quais o usuário atual tem acesso',
'admin.plugins.perm.db:write:reservations': 'Criar, editar e remover reservas em viagens que o usuário atual pode editar',
'admin.plugins.perm.db:write:accommodations': 'Criar, editar e remover acomodações em viagens que o usuário atual pode editar',
'admin.plugins.perm.db:write:reservations':
'Criar, editar e remover reservas em viagens que o usuário atual pode editar',
'admin.plugins.perm.db:write:accommodations':
'Criar, editar e remover acomodações em viagens que o usuário atual pode editar',
'admin.plugins.perm.db:read:journal': 'Ler os diários de viagem do usuário atual (requer o complemento Journey)',
'admin.plugins.perm.db:read:atlas': 'Ler os países e regiões visitados pelo usuário atual (requer o complemento Atlas)',
'admin.plugins.perm.db:read:atlas':
'Ler os países e regiões visitados pelo usuário atual (requer o complemento Atlas)',
'admin.plugins.perm.db:read:vacay': 'Ler o plano de férias do usuário atual (requer o complemento Vacay)',
'admin.plugins.perm.db:read:daynotes': 'Ler as notas do dia de viagens às quais o usuário atual tem acesso',
'admin.plugins.perm.db:read:collections': 'Ler as coleções de locais salvos do usuário atual (requer o complemento Collections)',
'admin.plugins.perm.db:write:daynotes': 'Criar, editar e remover notas do dia em viagens que o usuário atual pode editar',
'admin.plugins.perm.jobs:run': 'Executar seus jobs em segundo plano de forma agendada (sem contexto de usuário — não pode ler dados do usuário)',
'admin.plugins.perm.db:read:collections':
'Ler as coleções de locais salvos do usuário atual (requer o complemento Collections)',
'admin.plugins.perm.db:write:daynotes':
'Criar, editar e remover notas do dia em viagens que o usuário atual pode editar',
'admin.plugins.perm.jobs:run':
'Executar seus jobs em segundo plano de forma agendada (sem contexto de usuário — não pode ler dados do usuário)',
'admin.plugins.perm.db:write:costs': 'Criar despesas (itens de orçamento) em viagens que o usuário atual pode editar',
'admin.plugins.perm.db:write:places': 'Adicionar, editar e remover locais em viagens que o usuário atual pode editar',
'admin.plugins.perm.db:write:days': 'Adicionar, editar e remover dias em viagens que o usuário atual pode editar',
@@ -276,20 +303,27 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:place-detail-provider':
'Contribuir com detalhes extras (avaliações, notas, links) para um local',
'admin.plugins.perm.hook:trip-warning-provider': 'Emitir avisos de validação em uma viagem (exibidos no planejador)',
'admin.plugins.perm.hook:table-contributor': 'Adicionar colunas e ações às visualizações da viagem (reservas, locais, dias)',
'admin.plugins.perm.hook:map-marker-provider': 'Adicionar marcadores ao mapa da viagem (ex. mostrar reservas ou POIs)',
'admin.plugins.perm.hook:table-contributor':
'Adicionar colunas e ações às visualizações da viagem (reservas, locais, dias)',
'admin.plugins.perm.hook:map-marker-provider':
'Adicionar marcadores ao mapa da viagem (ex. mostrar reservas ou POIs)',
'admin.plugins.perm.hook:pdf-section-provider': 'Anexar seções de texto à exportação em PDF da viagem',
'admin.plugins.perm.hook:atlas-layer-provider':
'Destacar países no mapa-múndi do Atlas (ex. listas de desejos ou avisos de viagem)',
'admin.plugins.perm.hook:journal-entry-provider':
'Adicionar linhas extras (links, estatísticas) às entradas do diário',
'admin.plugins.perm.hook:user-data': 'Apagar ou exportar os dados que armazena sobre um usuário (LGPD/GDPR: exclusão de conta e solicitações de dados)',
'admin.plugins.perm.hook:trip-card-provider': 'Adicionar pequenos selos (status, contagens) aos cartões de viagem do painel',
'admin.plugins.perm.hook:user-data':
'Apagar ou exportar os dados que armazena sobre um usuário (LGPD/GDPR: exclusão de conta e solicitações de dados)',
'admin.plugins.perm.hook:trip-card-provider':
'Adicionar pequenos selos (status, contagens) aos cartões de viagem do painel',
'admin.plugins.perm.hook:notification-channel': 'Entregar suas notificações por um canal adicional',
'admin.plugins.perm.events:subscribe':
'Reagir a eventos de atividade do núcleo (apenas nome do evento + viagem, nunca o conteúdo)',
'admin.plugins.perm.http:outbound': 'Fazer requisições de saída para os hosts declarados',
'admin.plugins.perm.db:read:collab': 'Ler notas, enquetes e mensagens de chat de viagens às quais o usuário atual tem acesso (requer o complemento Collab)',
'admin.plugins.perm.db:read:files:content': 'Ler o conteúdo (bytes) dos arquivos de viagens às quais o usuário atual tem acesso',
'admin.plugins.perm.db:read:collab':
'Ler notas, enquetes e mensagens de chat de viagens às quais o usuário atual tem acesso (requer o complemento Collab)',
'admin.plugins.perm.db:read:files:content':
'Ler o conteúdo (bytes) dos arquivos de viagens às quais o usuário atual tem acesso',
'admin.plugins.perm.db:create:trips': 'Criar novas viagens pertencentes ao usuário atual',
'admin.plugins.perm.rates:read': 'Ler as taxas de câmbio em cache do host',
'admin.plugins.updateConsentTitle': 'Esta atualização precisa de novas permissões',
@@ -359,6 +393,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Detalhe da reserva',
'admin.plugins.cap.replacesTabs': 'Substitui abas do planejador',
'admin.plugins.cap.realtime': 'Atualizações em tempo real',
'admin.plugins.cap.notificationChannel': 'Canal de notificação',
'admin.plugins.cap.photos': 'Fornece fotos',
'admin.plugins.cap.calendar': 'Fornece eventos de calendário',
'admin.plugins.cap.placeDetails': 'Enriquece locais',
+10 -1
View File
@@ -13,7 +13,8 @@ const settings: TranslationStrings = {
'settings.plugins.oauth.disconnect': 'Desconectar',
'settings.tabs.plugins': 'Plugins',
'settings.plugins.title': 'Configurações de plugins',
'settings.plugins.subtitle': 'Suas configurações pessoais para os plugins que você usa (chaves de API, preferências).',
'settings.plugins.subtitle':
'Suas configurações pessoais para os plugins que você usa (chaves de API, preferências).',
'settings.plugins.empty': 'Nenhum plugin ativo.',
'settings.plugins.saved': 'Configurações salvas',
'settings.tabs.account': 'Conta',
@@ -288,6 +289,14 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.testFailed': 'Falha na notificação de teste do Ntfy',
'settings.ntfyUrl.tokenCleared': 'Token de acesso removido',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Ainda não configurado — configure nas definições do plugin',
'settings.plugins.actions': 'Ações',
'settings.plugins.actions.confirm': 'Executar esta ação?',
'settings.notificationPreferences.sendTest': 'Enviar teste',
'settings.notificationPreferences.configure': 'Configurar',
'settings.notificationPreferences.testSuccess': 'Notificação de teste enviada.',
'settings.notificationPreferences.testFailed': 'Falha no teste.',
'settings.notificationPreferences.pluginConfigured': 'Configurado. Gerencie suas credenciais na página de configurações do plugin.',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
+51 -20
View File
@@ -167,7 +167,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'Plugin “{name}” nahrán — aktivujte jej pro spuštění',
'admin.plugins.sideloaded': 'Načteno ručně',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Načteno z místního adresáře sestavení a za běhu znovu načítáno s reálnými daty — jen pro vývoj',
'admin.plugins.devLinkHint':
'Načteno z místního adresáře sestavení a za běhu znovu načítáno s reálnými daty — jen pro vývoj',
'admin.plugins.devLinkTitle': 'Připojit místní plugin',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Připojit',
@@ -184,6 +185,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Plugin deaktivován',
'admin.plugins.viewErrors': 'Zobrazit protokol chyb',
'admin.plugins.errorLog': 'Protokol chyb',
'admin.plugins.allowedHosts': 'Povolení hostitelé',
'admin.plugins.allowedHosts.hint': 'Tento plugin komunikuje se službou, kterou umíš pojmenovat jen ty (self-hosted server). Přidej hostitele, na které smí — na jiné se nedostane.',
'admin.plugins.allowedHosts.none': 'Zatím nebyli přidáni žádní hostitelé.',
'admin.plugins.allowedHosts.unsupported': 'Tento plugin nepoužívá hostitele zadané provozovatelem. Povolení hostitelé jsou pevně dáni v manifestu.',
'admin.plugins.allowedHosts.restartNote': 'Uložení restartuje plugin, aby načetl nový seznam.',
'admin.plugins.allowedHosts.add': 'Přidat povoleného hostitele',
'admin.plugins.allowedHosts.count': '{n} povolených hostitelů',
'admin.plugins.operatorEgressPill': '+ hostitelé, které přidáš',
'admin.plugins.operatorEgressHint': 'Tento plugin komunikuje se službou, kterou umíš pojmenovat jen ty (self-hosted server). Po instalaci přidej hostitele v ⋯ → Povolení hostitelé. Na jiné se nedostane.',
'admin.plugins.noErrors': 'Žádné zaznamenané chyby.',
'admin.plugins.uninstalled': 'Plugin odinstalován',
'admin.plugins.uninstallTitle': 'Odinstalovat plugin?',
@@ -234,33 +244,50 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:users': 'Číst základní údaje profilu (jméno, avatar — nikdy přihlašovací údaje)',
'admin.plugins.perm.db:read:costs': 'Číst výdaje (položky rozpočtu), ke kterým má aktuální uživatel přístup',
'admin.plugins.perm.db:read:packing': 'Číst seznamy věcí k zabalení u cest, ke kterým má aktuální uživatel přístup',
'admin.plugins.perm.db:write:packing': 'Vytvářet, upravovat a mazat položky seznamu balení na cestách, které může aktuální uživatel upravovat',
'admin.plugins.perm.db:write:packing':
'Vytvářet, upravovat a mazat položky seznamu balení na cestách, které může aktuální uživatel upravovat',
'admin.plugins.perm.weather:read': 'Číst hostitelem uloženou předpověď počasí (podle souřadnic)',
'admin.plugins.perm.db:read:categories': 'Číst globální seznam kategorií míst',
'admin.plugins.perm.db:read:tags': 'Číst štítky aktuálního uživatele',
'admin.plugins.perm.db:write:tags': 'Vytvářet, upravovat a mazat štítky aktuálního uživatele',
'admin.plugins.perm.db:read:todos': 'Číst úkoly cest, ke kterým má aktuální uživatel přístup',
'admin.plugins.perm.db:write:todos': 'Vytvářet, upravovat a mazat úkoly na cestách, které může aktuální uživatel upravovat',
'admin.plugins.perm.db:write:atlas': 'Označovat země a regiony jako navštívené a spravovat bucket list aktuálního uživatele (vyžaduje doplněk Atlas)',
'admin.plugins.perm.db:write:vacay': 'Přepínat dny dovolené a firemní svátky v aktivním plánu aktuálního uživatele (vyžaduje doplněk Vacay)',
'admin.plugins.perm.db:write:journal': 'Vytvářet, upravovat a mazat záznamy v denících, které může aktuální uživatel upravovat (vyžaduje doplněk Journey)',
'admin.plugins.perm.db:write:collections': 'Vytvářet a upravovat sbírky a ukládat do nich místa, s rolí aktuálního uživatele ve sbírce (vyžaduje doplněk Collections)',
'admin.plugins.perm.db:write:files': 'Přikládat soubory k cestám, které může aktuální uživatel upravovat (limit 10 MB, blokované přípony odmítnuty), a spravovat jejich propojení',
'admin.plugins.perm.db:write:collab': 'Publikovat poznámky, ankety a zprávy chatu na cestách, které může aktuální uživatel upravovat (vyžaduje doplněk Collab)',
'admin.plugins.perm.db:write:members': 'Přidávat uživatele do cest — uděluje přístup k cestě; chráněno právem správy členů aktuálního uživatele',
'admin.plugins.perm.notify:send': 'Odeslat oznámení (schránka zvonku + e-mail/ntfy) aktuálnímu uživateli nebo jeho cestě — nikdy libovolnému příjemci',
'admin.plugins.perm.ai:invoke': 'Použít poskytovatele AI nakonfigurovaného správcem (dokončení textu + extrakce z dokumentů) jménem aktuálního uživatele — doplněk nikdy nedrží klíč',
'admin.plugins.perm.oauth:client': 'Připojit se k službě třetí strany tvým jménem přes OAuth zprostředkované hostitelem (hostitel drží tokeny, doplněk je nikdy nevidí)',
'admin.plugins.perm.db:write:todos':
'Vytvářet, upravovat a mazat úkoly na cestách, které může aktuální uživatel upravovat',
'admin.plugins.perm.db:write:atlas':
'Označovat země a regiony jako navštívené a spravovat bucket list aktuálního uživatele (vyžaduje doplněk Atlas)',
'admin.plugins.perm.db:write:vacay':
'Přepínat dny dovolené a firemní svátky v aktivním plánu aktuálního uživatele (vyžaduje doplněk Vacay)',
'admin.plugins.perm.db:write:journal':
'Vytvářet, upravovat a mazat záznamy v denících, které může aktuální uživatel upravovat (vyžaduje doplněk Journey)',
'admin.plugins.perm.db:write:collections':
'Vytvářet a upravovat sbírky a ukládat do nich místa, s rolí aktuálního uživatele ve sbírce (vyžaduje doplněk Collections)',
'admin.plugins.perm.db:write:files':
'Přikládat soubory k cestám, které může aktuální uživatel upravovat (limit 10 MB, blokované přípony odmítnuty), a spravovat jejich propojení',
'admin.plugins.perm.db:write:collab':
'Publikovat poznámky, ankety a zprávy chatu na cestách, které může aktuální uživatel upravovat (vyžaduje doplněk Collab)',
'admin.plugins.perm.db:write:members':
'Přidávat uživatele do cest — uděluje přístup k cestě; chráněno právem správy členů aktuálního uživatele',
'admin.plugins.perm.notify:send':
'Odeslat oznámení (schránka zvonku + e-mail/ntfy) aktuálnímu uživateli nebo jeho cestě — nikdy libovolnému příjemci',
'admin.plugins.perm.ai:invoke':
'Použít poskytovatele AI nakonfigurovaného správcem (dokončení textu + extrakce z dokumentů) jménem aktuálního uživatele — doplněk nikdy nedrží klíč',
'admin.plugins.perm.oauth:client':
'Připojit se k službě třetí strany tvým jménem přes OAuth zprostředkované hostitelem (hostitel drží tokeny, doplněk je nikdy nevidí)',
'admin.plugins.perm.db:read:files': 'Číst soubory cest, ke kterým má aktuální uživatel přístup',
'admin.plugins.perm.db:write:reservations': 'Vytvářet, upravovat a mazat rezervace na cestách, které může aktuální uživatel upravovat',
'admin.plugins.perm.db:write:accommodations': 'Vytvářet, upravovat a mazat ubytování na cestách, které může aktuální uživatel upravovat',
'admin.plugins.perm.db:write:reservations':
'Vytvářet, upravovat a mazat rezervace na cestách, které může aktuální uživatel upravovat',
'admin.plugins.perm.db:write:accommodations':
'Vytvářet, upravovat a mazat ubytování na cestách, které může aktuální uživatel upravovat',
'admin.plugins.perm.db:read:journal': 'Číst cestovní deníky aktuálního uživatele (vyžaduje doplněk Journey)',
'admin.plugins.perm.db:read:atlas': 'Číst navštívené země a regiony aktuálního uživatele (vyžaduje doplněk Atlas)',
'admin.plugins.perm.db:read:vacay': 'Číst plán dovolené aktuálního uživatele (vyžaduje doplněk Vacay)',
'admin.plugins.perm.db:read:daynotes': 'Číst denní poznámky cest, ke kterým má aktuální uživatel přístup',
'admin.plugins.perm.db:read:collections': 'Číst sbírky uložených míst aktuálního uživatele (vyžaduje doplněk Collections)',
'admin.plugins.perm.db:write:daynotes': 'Vytvářet, upravovat a mazat denní poznámky na cestách, které může aktuální uživatel upravovat',
'admin.plugins.perm.jobs:run': 'Spouštět deklarované úlohy na pozadí podle plánu (bez kontextu uživatele — nemůže číst uživatelská data)',
'admin.plugins.perm.db:read:collections':
'Číst sbírky uložených míst aktuálního uživatele (vyžaduje doplněk Collections)',
'admin.plugins.perm.db:write:daynotes':
'Vytvářet, upravovat a mazat denní poznámky na cestách, které může aktuální uživatel upravovat',
'admin.plugins.perm.jobs:run':
'Spouštět deklarované úlohy na pozadí podle plánu (bez kontextu uživatele — nemůže číst uživatelská data)',
'admin.plugins.perm.db:write:costs':
'Vytvářet výdaje (položky rozpočtu) na cestách, které může aktuální uživatel upravovat',
'admin.plugins.perm.db:write:places':
@@ -285,12 +312,15 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:atlas-layer-provider':
'Zvýrazňovat země na mapě světa v Atlasu (např. seznamy přání nebo cestovní upozornění)',
'admin.plugins.perm.hook:journal-entry-provider': 'Přidávat další řádky (odkazy, statistiky) k záznamům deníku',
'admin.plugins.perm.hook:user-data': 'Vymazat nebo exportovat data, která o uživateli uchovává (GDPR: smazání účtu a žádosti o data)',
'admin.plugins.perm.hook:user-data':
'Vymazat nebo exportovat data, která o uživateli uchovává (GDPR: smazání účtu a žádosti o data)',
'admin.plugins.perm.hook:trip-card-provider': 'Přidávat malé odznaky (stav, počty) na karty cest na nástěnce',
'admin.plugins.perm.hook:notification-channel': 'Doručovat vaše oznámení dalším kanálem',
'admin.plugins.perm.events:subscribe':
'Reagovat na základní události aktivity (pouze název události a cesta, nikdy obsah)',
'admin.plugins.perm.http:outbound': 'Odesílat odchozí požadavky na deklarované hostitele',
'admin.plugins.perm.db:read:collab': 'Číst poznámky, ankety a zprávy chatu cest, ke kterým má aktuální uživatel přístup (vyžaduje doplněk Collab)',
'admin.plugins.perm.db:read:collab':
'Číst poznámky, ankety a zprávy chatu cest, ke kterým má aktuální uživatel přístup (vyžaduje doplněk Collab)',
'admin.plugins.perm.db:read:files:content': 'Číst obsah (bajty) souborů cest, ke kterým má aktuální uživatel přístup',
'admin.plugins.perm.db:create:trips': 'Vytvářet nové cesty vlastněné aktuálním uživatelem',
'admin.plugins.perm.rates:read': 'Číst hostitelem uložené směnné kurzy měn',
@@ -361,6 +391,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Detail rezervace',
'admin.plugins.cap.replacesTabs': 'Nahrazuje karty plánovače',
'admin.plugins.cap.realtime': 'Aktualizace v reálném čase',
'admin.plugins.cap.notificationChannel': 'Kanál oznámení',
'admin.plugins.cap.photos': 'Poskytuje fotky',
'admin.plugins.cap.calendar': 'Poskytuje události kalendáře',
'admin.plugins.cap.placeDetails': 'Obohacuje místa',
+8
View File
@@ -284,6 +284,14 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.testFailed': 'Testovací notifikace Ntfy selhala',
'settings.ntfyUrl.tokenCleared': 'Přístupový token byl vymazán',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Zatím nenastaveno — nastavte to v nastavení pluginu',
'settings.plugins.actions': 'Akce',
'settings.plugins.actions.confirm': 'Spustit tuto akci?',
'settings.notificationPreferences.sendTest': 'Odeslat test',
'settings.notificationPreferences.configure': 'Nastavit',
'settings.notificationPreferences.testSuccess': 'Testovací oznámení odesláno.',
'settings.notificationPreferences.testFailed': 'Test selhal.',
'settings.notificationPreferences.pluginConfigured': 'Nastaveno. Přihlašovací údaje spravuj v nastavení pluginu.',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
+57 -23
View File
@@ -169,7 +169,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'Plugin „{name}“ hochgeladen — zum Ausführen aktivieren',
'admin.plugins.sideloaded': 'Manuell geladen',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Aus einem lokalen Build-Verzeichnis geladen und gegen echte Daten neu geladen — nur für Entwicklung',
'admin.plugins.devLinkHint':
'Aus einem lokalen Build-Verzeichnis geladen und gegen echte Daten neu geladen — nur für Entwicklung',
'admin.plugins.devLinkTitle': 'Lokales Plugin verknüpfen',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Verknüpfen',
@@ -186,6 +187,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Plugin deaktiviert',
'admin.plugins.viewErrors': 'Fehlerprotokoll ansehen',
'admin.plugins.errorLog': 'Fehlerprotokoll',
'admin.plugins.allowedHosts': 'Erlaubte Hosts',
'admin.plugins.allowedHosts.hint': 'Dieses Plugin spricht mit einem Dienst, den nur du benennen kannst (ein selbst gehosteter Server). Füge die Hosts hinzu, die es erreichen darf — andere erreicht es nicht.',
'admin.plugins.allowedHosts.none': 'Noch keine Hosts hinzugefügt.',
'admin.plugins.allowedHosts.unsupported': 'Dieses Plugin nutzt keine vom Betreiber angegebenen Hosts. Seine erlaubten Hosts stehen fest im Manifest.',
'admin.plugins.allowedHosts.restartNote': 'Beim Speichern wird das Plugin neu gestartet, damit es die neue Liste übernimmt.',
'admin.plugins.allowedHosts.add': 'Erlaubten Host hinzufügen',
'admin.plugins.allowedHosts.count': '{n} erlaubte Hosts',
'admin.plugins.operatorEgressPill': '+ von dir ergänzte Hosts',
'admin.plugins.operatorEgressHint': 'Dieses Plugin spricht mit einem Dienst, den nur du benennen kannst (ein selbst gehosteter Server). Füge nach der Installation unter ⋯ → Erlaubte Hosts die erreichbaren Hosts hinzu. Andere erreicht es nicht.',
'admin.plugins.noErrors': 'Keine Fehler protokolliert.',
'admin.plugins.uninstalled': 'Plugin deinstalliert',
'admin.plugins.uninstallTitle': 'Plugin deinstallieren?',
@@ -236,29 +246,43 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:users': 'Grundlegende Profilinfos lesen (Name, Avatar — nie Zugangsdaten)',
'admin.plugins.perm.db:read:costs': 'Ausgaben (Budgetposten) lesen, auf die der handelnde Nutzer Zugriff hat',
'admin.plugins.perm.db:read:packing': 'Packlisten von Reisen lesen, auf die der handelnde Nutzer Zugriff hat',
'admin.plugins.perm.db:write:packing': 'Packlisten-Einträge auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann',
'admin.plugins.perm.db:write:packing':
'Packlisten-Einträge auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann',
'admin.plugins.perm.weather:read': 'Die zwischengespeicherte Wettervorhersage des Hosts lesen (nach Koordinaten)',
'admin.plugins.perm.db:read:categories': 'Die globale Orts-Kategorienliste lesen',
'admin.plugins.perm.db:read:tags': 'Die eigenen Tags des handelnden Nutzers lesen',
'admin.plugins.perm.db:write:tags': 'Die eigenen Tags des handelnden Nutzers anlegen, bearbeiten und löschen',
'admin.plugins.perm.db:read:todos': 'To-dos von Reisen lesen, auf die der handelnde Nutzer Zugriff hat',
'admin.plugins.perm.db:write:todos': 'To-dos auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann',
'admin.plugins.perm.db:write:atlas': 'Länder und Regionen als besucht markieren und die Bucket-List des handelnden Nutzers verwalten (benötigt das Atlas-Addon)',
'admin.plugins.perm.db:write:vacay': 'Urlaubstage und Betriebsferien im aktiven Plan des handelnden Nutzers umschalten (benötigt das Vacay-Addon)',
'admin.plugins.perm.db:write:journal': 'Tagebucheinträge in Reisetagebüchern anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann (benötigt das Journey-Addon)',
'admin.plugins.perm.db:write:collections': 'Sammlungen anlegen und bearbeiten und Orte darin speichern, mit der Sammlungsrolle des handelnden Nutzers (benötigt das Collections-Addon)',
'admin.plugins.perm.db:write:files': 'Dateien an Reisen anhängen, die der handelnde Nutzer bearbeiten kann (10-MB-Limit, gesperrte Endungen abgelehnt), und deren Verknüpfungen verwalten',
'admin.plugins.perm.db:write:collab': 'Notizen, Umfragen und Chat-Nachrichten auf Reisen erstellen, die der handelnde Nutzer bearbeiten kann (benötigt das Collab-Addon)',
'admin.plugins.perm.db:write:members': 'Nutzer zu Reisen hinzufügen — gewährt Reise-Zugriff; über das Mitglieder-verwalten-Recht des handelnden Nutzers abgesichert',
'admin.plugins.perm.notify:send': 'Eine Benachrichtigung (Glocken-Postfach + E-Mail/ntfy) an den handelnden Nutzer oder eine seiner Reisen senden — nie an beliebige Empfänger',
'admin.plugins.perm.ai:invoke': 'Den vom Admin konfigurierten KI-Anbieter (Textvervollständigung + Dokumentenextraktion) im Namen des handelnden Nutzers nutzen — das Plugin hält nie einen Schlüssel',
'admin.plugins.perm.oauth:client': 'Über host-vermitteltes OAuth in deinem Namen mit einem Drittanbieter verbinden (der Host hält die Tokens, das Plugin sieht sie nie)',
'admin.plugins.perm.db:write:todos':
'To-dos auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann',
'admin.plugins.perm.db:write:atlas':
'Länder und Regionen als besucht markieren und die Bucket-List des handelnden Nutzers verwalten (benötigt das Atlas-Addon)',
'admin.plugins.perm.db:write:vacay':
'Urlaubstage und Betriebsferien im aktiven Plan des handelnden Nutzers umschalten (benötigt das Vacay-Addon)',
'admin.plugins.perm.db:write:journal':
'Tagebucheinträge in Reisetagebüchern anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann (benötigt das Journey-Addon)',
'admin.plugins.perm.db:write:collections':
'Sammlungen anlegen und bearbeiten und Orte darin speichern, mit der Sammlungsrolle des handelnden Nutzers (benötigt das Collections-Addon)',
'admin.plugins.perm.db:write:files':
'Dateien an Reisen anhängen, die der handelnde Nutzer bearbeiten kann (10-MB-Limit, gesperrte Endungen abgelehnt), und deren Verknüpfungen verwalten',
'admin.plugins.perm.db:write:collab':
'Notizen, Umfragen und Chat-Nachrichten auf Reisen erstellen, die der handelnde Nutzer bearbeiten kann (benötigt das Collab-Addon)',
'admin.plugins.perm.db:write:members':
'Nutzer zu Reisen hinzufügen — gewährt Reise-Zugriff; über das Mitglieder-verwalten-Recht des handelnden Nutzers abgesichert',
'admin.plugins.perm.notify:send':
'Eine Benachrichtigung (Glocken-Postfach + E-Mail/ntfy) an den handelnden Nutzer oder eine seiner Reisen senden — nie an beliebige Empfänger',
'admin.plugins.perm.ai:invoke':
'Den vom Admin konfigurierten KI-Anbieter (Textvervollständigung + Dokumentenextraktion) im Namen des handelnden Nutzers nutzen — das Plugin hält nie einen Schlüssel',
'admin.plugins.perm.oauth:client':
'Über host-vermitteltes OAuth in deinem Namen mit einem Drittanbieter verbinden (der Host hält die Tokens, das Plugin sieht sie nie)',
'admin.plugins.perm.db:read:files': 'Dateien von Reisen lesen, auf die der handelnde Nutzer Zugriff hat',
'admin.plugins.perm.db:read:journal': 'Reisetagebücher des handelnden Nutzers lesen (benötigt das Journey-Addon)',
'admin.plugins.perm.db:read:atlas': 'Besuchte Länder und Regionen des handelnden Nutzers lesen (benötigt das Atlas-Addon)',
'admin.plugins.perm.db:read:atlas':
'Besuchte Länder und Regionen des handelnden Nutzers lesen (benötigt das Atlas-Addon)',
'admin.plugins.perm.db:read:vacay': 'Urlaubsplanung des handelnden Nutzers lesen (benötigt das Vacay-Addon)',
'admin.plugins.perm.db:read:daynotes': 'Tagesnotizen von Reisen lesen, auf die der handelnde Nutzer Zugriff hat',
'admin.plugins.perm.db:read:collections': 'Sammlungen gespeicherter Orte des handelnden Nutzers lesen (benötigt das Collections-Addon)',
'admin.plugins.perm.db:read:collections':
'Sammlungen gespeicherter Orte des handelnden Nutzers lesen (benötigt das Collections-Addon)',
'admin.plugins.perm.db:write:costs':
'Ausgaben (Budgetposten) auf Reisen erstellen, die der handelnde Nutzer bearbeiten kann',
'admin.plugins.perm.db:write:places':
@@ -271,7 +295,8 @@ const admin: TranslationStrings = {
'Reisedetails (Titel, Daten, Währung …) auf Reisen bearbeiten, die der handelnde Nutzer bearbeiten kann',
'admin.plugins.perm.db:write:reservations':
'Buchungen (Reservierungen) auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann',
'admin.plugins.perm.db:write:accommodations': 'Unterkünfte (Übernachtungsblöcke) auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten darf',
'admin.plugins.perm.db:write:accommodations':
'Unterkünfte (Übernachtungsblöcke) auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten darf',
'admin.plugins.perm.db:write:daynotes':
'Tagesnotizen auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann',
'admin.plugins.perm.db:meta':
@@ -284,20 +309,28 @@ const admin: TranslationStrings = {
'Zusatzdetails (Bewertungen, Ratings, Links) zu einem Ort beisteuern',
'admin.plugins.perm.hook:trip-warning-provider':
'Validierungswarnungen zu einer Reise ausgeben (im Planer angezeigt)',
'admin.plugins.perm.hook:table-contributor': 'Spalten und Aktionen zu Reise-Ansichten hinzufügen (Reservierungen, Orte, Tage)',
'admin.plugins.perm.hook:map-marker-provider': 'Marker zur Reisekarte hinzufügen (z. B. Buchungen oder POIs anzeigen)',
'admin.plugins.perm.hook:table-contributor':
'Spalten und Aktionen zu Reise-Ansichten hinzufügen (Reservierungen, Orte, Tage)',
'admin.plugins.perm.hook:map-marker-provider':
'Marker zur Reisekarte hinzufügen (z. B. Buchungen oder POIs anzeigen)',
'admin.plugins.perm.hook:pdf-section-provider': 'Textabschnitte an den PDF-Export einer Reise anhängen',
'admin.plugins.perm.hook:atlas-layer-provider':
'Länder auf der Atlas-Weltkarte hervorheben (z. B. Wunschlisten oder Reisehinweise)',
'admin.plugins.perm.hook:journal-entry-provider': 'Zusatzzeilen (Links, Statistiken) zu Journal-Einträgen beisteuern',
'admin.plugins.perm.hook:user-data': 'Von ihm gespeicherte Nutzerdaten löschen oder exportieren (DSGVO: Kontolöschung & Auskunft)',
'admin.plugins.perm.hook:trip-card-provider': 'Kleine Badges (Status, Zähler) zu deinen Dashboard-Trip-Karten hinzufügen',
'admin.plugins.perm.hook:user-data':
'Von ihm gespeicherte Nutzerdaten löschen oder exportieren (DSGVO: Kontolöschung & Auskunft)',
'admin.plugins.perm.hook:trip-card-provider':
'Kleine Badges (Status, Zähler) zu deinen Dashboard-Trip-Karten hinzufügen',
'admin.plugins.perm.hook:notification-channel': 'Deine Benachrichtigungen über einen zusätzlichen Kanal zustellen',
'admin.plugins.perm.events:subscribe':
'Auf Kern-Aktivitäts-Events reagieren (nur Event-Name + Reise, nie der Inhalt)',
'admin.plugins.perm.jobs:run': 'Deklarierte Hintergrund-Jobs zeitgesteuert ausführen (kein Nutzerkontext — kann keine Nutzerdaten lesen)',
'admin.plugins.perm.jobs:run':
'Deklarierte Hintergrund-Jobs zeitgesteuert ausführen (kein Nutzerkontext — kann keine Nutzerdaten lesen)',
'admin.plugins.perm.http:outbound': 'Ausgehende Anfragen an deklarierte Hosts stellen',
'admin.plugins.perm.db:read:collab': 'Notizen, Umfragen und Chat-Nachrichten von Reisen lesen, auf die der handelnde Nutzer Zugriff hat (benötigt das Collab-Addon)',
'admin.plugins.perm.db:read:files:content': 'Dateiinhalte (Bytes) von Reisen lesen, auf die der handelnde Nutzer Zugriff hat',
'admin.plugins.perm.db:read:collab':
'Notizen, Umfragen und Chat-Nachrichten von Reisen lesen, auf die der handelnde Nutzer Zugriff hat (benötigt das Collab-Addon)',
'admin.plugins.perm.db:read:files:content':
'Dateiinhalte (Bytes) von Reisen lesen, auf die der handelnde Nutzer Zugriff hat',
'admin.plugins.perm.db:create:trips': 'Neue Reisen im Besitz des handelnden Nutzers anlegen',
'admin.plugins.perm.rates:read': 'Die zwischengespeicherten Wechselkurse des Hosts lesen',
'admin.plugins.updateConsentTitle': 'Dieses Update braucht neue Berechtigungen',
@@ -367,6 +400,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Reservierungsdetails',
'admin.plugins.cap.replacesTabs': 'Ersetzt Planer-Tabs',
'admin.plugins.cap.realtime': 'Echtzeit-Updates',
'admin.plugins.cap.notificationChannel': 'Benachrichtigungskanal',
'admin.plugins.cap.photos': 'Liefert Fotos',
'admin.plugins.cap.calendar': 'Liefert Kalender-Events',
'admin.plugins.cap.placeDetails': 'Reichert Orte an',
+11 -1
View File
@@ -13,7 +13,8 @@ const settings: TranslationStrings = {
'settings.plugins.oauth.disconnect': 'Trennen',
'settings.tabs.plugins': 'Plugins',
'settings.plugins.title': 'Plugin-Einstellungen',
'settings.plugins.subtitle': 'Deine persönlichen Einstellungen für die von dir genutzten Plugins (API-Schlüssel, Präferenzen).',
'settings.plugins.subtitle':
'Deine persönlichen Einstellungen für die von dir genutzten Plugins (API-Schlüssel, Präferenzen).',
'settings.plugins.empty': 'Es sind keine Plugins aktiv.',
'settings.plugins.saved': 'Einstellungen gespeichert',
'settings.tabs.account': 'Konto',
@@ -289,6 +290,15 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.testFailed': 'Test-Ntfy-Benachrichtigung fehlgeschlagen',
'settings.ntfyUrl.tokenCleared': 'Zugriffstoken gelöscht',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured':
'Noch nicht eingerichtet — in den Einstellungen des Plugins konfigurieren',
'settings.plugins.actions': 'Aktionen',
'settings.plugins.actions.confirm': 'Diese Aktion ausführen?',
'settings.notificationPreferences.sendTest': 'Test senden',
'settings.notificationPreferences.configure': 'Einrichten',
'settings.notificationPreferences.testSuccess': 'Testbenachrichtigung gesendet.',
'settings.notificationPreferences.testFailed': 'Test fehlgeschlagen.',
'settings.notificationPreferences.pluginConfigured': 'Eingerichtet. Zugangsdaten auf der Einstellungsseite des Plugins verwalten.',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
+45 -17
View File
@@ -238,6 +238,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Plugin deactivated',
'admin.plugins.viewErrors': 'View error log',
'admin.plugins.errorLog': 'Error log',
'admin.plugins.allowedHosts': 'Allowed hosts',
'admin.plugins.allowedHosts.hint': 'This plugin talks to a service only you can name (a self-hosted server). Add the hosts it may reach — it can reach no others.',
'admin.plugins.allowedHosts.none': 'No hosts added yet.',
'admin.plugins.allowedHosts.unsupported': 'This plugin does not use operator-supplied hosts. Its allowed hosts are fixed in its manifest.',
'admin.plugins.allowedHosts.restartNote': 'Saving restarts the plugin so it picks up the new list.',
'admin.plugins.allowedHosts.add': 'Add allowed host',
'admin.plugins.allowedHosts.count': '{n} allowed host(s)',
'admin.plugins.operatorEgressPill': '+ hosts you add',
'admin.plugins.operatorEgressHint': 'This plugin talks to a service only you can name (a self-hosted server). After installing, add the hosts it may reach under ⋯ → Allowed hosts. It can reach no others.',
'admin.plugins.noErrors': 'No errors logged.',
'admin.plugins.uninstalled': 'Plugin uninstalled',
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
@@ -295,29 +304,43 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:write:tags': "Create, edit and delete the acting user's own tags",
'admin.plugins.perm.db:read:todos': 'Read the to-dos of trips the acting user can access',
'admin.plugins.perm.db:write:todos': 'Create, edit and delete to-dos on trips the acting user can edit',
'admin.plugins.perm.db:write:atlas': 'Mark countries and regions visited and manage the bucket list of the acting user (needs the Atlas addon)',
'admin.plugins.perm.db:write:vacay': "Toggle vacation days and company holidays on the acting user's active plan (needs the Vacay addon)",
'admin.plugins.perm.db:write:journal': 'Create, edit and delete journal entries on journeys the acting user can edit (needs the Journey addon)',
'admin.plugins.perm.db:write:collections': "Create and edit collections and save places to them, with the acting user's collection role (needs the Collections addon)",
'admin.plugins.perm.db:write:files': 'Attach files to trips the acting user can edit (10MB cap, blocked extensions refused) and manage their links',
'admin.plugins.perm.db:write:collab': 'Post notes, polls and chat messages on trips the acting user can edit (needs the Collab addon)',
'admin.plugins.perm.db:write:members': "Add users to trips — grants trip access; gated by the acting user's member-manage right",
'admin.plugins.perm.notify:send': 'Send a notification (bell inbox + email/ntfy) to the acting user or a trip they belong to — never an arbitrary recipient',
'admin.plugins.perm.ai:invoke': "Run the admin-configured AI provider (text completion + document extraction) on the acting user's behalf — the plugin never holds a key",
'admin.plugins.perm.oauth:client': 'Connect to a third-party service on your behalf via host-brokered OAuth (the host holds the tokens, the plugin never sees them)',
'admin.plugins.perm.db:write:atlas':
'Mark countries and regions visited and manage the bucket list of the acting user (needs the Atlas addon)',
'admin.plugins.perm.db:write:vacay':
"Toggle vacation days and company holidays on the acting user's active plan (needs the Vacay addon)",
'admin.plugins.perm.db:write:journal':
'Create, edit and delete journal entries on journeys the acting user can edit (needs the Journey addon)',
'admin.plugins.perm.db:write:collections':
"Create and edit collections and save places to them, with the acting user's collection role (needs the Collections addon)",
'admin.plugins.perm.db:write:files':
'Attach files to trips the acting user can edit (10MB cap, blocked extensions refused) and manage their links',
'admin.plugins.perm.db:write:collab':
'Post notes, polls and chat messages on trips the acting user can edit (needs the Collab addon)',
'admin.plugins.perm.db:write:members':
"Add users to trips — grants trip access; gated by the acting user's member-manage right",
'admin.plugins.perm.notify:send':
'Send a notification (bell inbox + email/ntfy) to the acting user or a trip they belong to — never an arbitrary recipient',
'admin.plugins.perm.ai:invoke':
"Run the admin-configured AI provider (text completion + document extraction) on the acting user's behalf — the plugin never holds a key",
'admin.plugins.perm.oauth:client':
'Connect to a third-party service on your behalf via host-brokered OAuth (the host holds the tokens, the plugin never sees them)',
'admin.plugins.perm.db:read:files': 'Read files of trips the acting user can access',
'admin.plugins.perm.db:read:journal': 'Read the travel journals of the acting user (needs the Journey addon)',
'admin.plugins.perm.db:read:atlas': 'Read the visited countries and regions of the acting user (needs the Atlas addon)',
'admin.plugins.perm.db:read:atlas':
'Read the visited countries and regions of the acting user (needs the Atlas addon)',
'admin.plugins.perm.db:read:vacay': 'Read the vacation plan of the acting user (needs the Vacay addon)',
'admin.plugins.perm.db:read:daynotes': 'Read the day notes of trips the acting user can access',
'admin.plugins.perm.db:read:collections': 'Read the saved-place collections of the acting user (needs the Collections addon)',
'admin.plugins.perm.db:read:collections':
'Read the saved-place collections of the acting user (needs the Collections addon)',
'admin.plugins.perm.db:write:costs': 'Create costs (budget items) on trips the acting user can edit',
'admin.plugins.perm.db:write:places': 'Add, edit and remove places on trips the acting user can edit',
'admin.plugins.perm.db:write:days': 'Add, edit and remove days on trips the acting user can edit',
'admin.plugins.perm.db:write:itinerary': 'Assign and remove places on days of trips the acting user can edit',
'admin.plugins.perm.db:write:trips': 'Edit trip details (title, dates, currency…) on trips the acting user can edit',
'admin.plugins.perm.db:write:reservations': 'Create, edit and delete bookings (reservations) on trips the acting user can edit',
'admin.plugins.perm.db:write:accommodations': 'Create, edit and delete accommodations (lodging blocks) on trips the acting user can edit',
'admin.plugins.perm.db:write:reservations':
'Create, edit and delete bookings (reservations) on trips the acting user can edit',
'admin.plugins.perm.db:write:accommodations':
'Create, edit and delete accommodations (lodging blocks) on trips the acting user can edit',
'admin.plugins.perm.db:write:daynotes': 'Create, edit and delete day notes on trips the acting user can edit',
'admin.plugins.perm.db:meta': 'Attach its own private data to trips, places and days the acting user can access',
'admin.plugins.perm.ws:broadcast:trip': 'Send real-time updates to trip members',
@@ -333,11 +356,15 @@ const admin: TranslationStrings = {
'Highlight countries on the Atlas world map (e.g. wishlists or travel advisories)',
'admin.plugins.perm.hook:journal-entry-provider': 'Contribute extra rows (links, stats) to journal entries',
'admin.plugins.perm.hook:trip-card-provider': 'Add small badges (status, counts) to your dashboard trip cards',
'admin.plugins.perm.hook:user-data': 'Erase or export the data it stores about a user (GDPR account deletion & data requests)',
'admin.plugins.perm.hook:notification-channel': 'Deliver your notifications over an additional channel',
'admin.plugins.perm.hook:user-data':
'Erase or export the data it stores about a user (GDPR account deletion & data requests)',
'admin.plugins.perm.events:subscribe': 'React to core activity events (event name + trip only, never the content)',
'admin.plugins.perm.jobs:run': 'Run its declared background jobs on a schedule (no user context — cannot read user data)',
'admin.plugins.perm.jobs:run':
'Run its declared background jobs on a schedule (no user context — cannot read user data)',
'admin.plugins.perm.http:outbound': 'Make outbound requests to its declared hosts',
'admin.plugins.perm.db:read:collab': 'Read notes, polls and chat messages of trips the acting user can access (needs the Collab addon)',
'admin.plugins.perm.db:read:collab':
'Read notes, polls and chat messages of trips the acting user can access (needs the Collab addon)',
'admin.plugins.perm.db:read:files:content': 'Read the byte content of files on trips the acting user can access',
'admin.plugins.perm.db:create:trips': 'Create new trips owned by the acting user',
'admin.plugins.perm.rates:read': "Read the host's cached currency exchange rates",
@@ -408,6 +435,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Reservation detail',
'admin.plugins.cap.replacesTabs': 'Replaces planner tabs',
'admin.plugins.cap.realtime': 'Real-time updates',
'admin.plugins.cap.notificationChannel': 'Notification channel',
'admin.plugins.cap.photos': 'Provides photos',
'admin.plugins.cap.calendar': 'Provides calendar events',
'admin.plugins.cap.placeDetails': 'Enriches places',
+8
View File
@@ -101,6 +101,14 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Not configured yet — set it up in the plugin’s settings',
'settings.plugins.actions': 'Actions',
'settings.plugins.actions.confirm': 'Run this action?',
'settings.notificationPreferences.sendTest': 'Send test',
'settings.notificationPreferences.configure': 'Configure',
'settings.notificationPreferences.testSuccess': 'Test notification sent.',
'settings.notificationPreferences.testFailed': 'Test failed.',
'settings.notificationPreferences.pluginConfigured': 'Configured. Manage credentials on the plugin’s settings page.',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.noChannels':
'No notification channels are configured. Ask an admin to set up email or webhook notifications.',
+63 -26
View File
@@ -168,7 +168,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'Plugin “{name}” subido — actívalo para ejecutarlo',
'admin.plugins.sideloaded': 'Cargado manualmente',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Cargado desde un directorio de compilación local y recargado en caliente con datos reales: solo para desarrollo',
'admin.plugins.devLinkHint':
'Cargado desde un directorio de compilación local y recargado en caliente con datos reales: solo para desarrollo',
'admin.plugins.devLinkTitle': 'Vincular un plugin local',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Vincular',
@@ -185,6 +186,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Plugin desactivado',
'admin.plugins.viewErrors': 'Ver registro de errores',
'admin.plugins.errorLog': 'Registro de errores',
'admin.plugins.allowedHosts': 'Hosts permitidos',
'admin.plugins.allowedHosts.hint': 'Este plugin se comunica con un servicio que solo tú puedes nombrar (un servidor autoalojado). Añade los hosts a los que puede acceder — no accederá a ningún otro.',
'admin.plugins.allowedHosts.none': 'Aún no se han añadido hosts.',
'admin.plugins.allowedHosts.unsupported': 'Este plugin no usa hosts proporcionados por el operador. Sus hosts permitidos están fijados en su manifiesto.',
'admin.plugins.allowedHosts.restartNote': 'Al guardar se reinicia el plugin para que tome la nueva lista.',
'admin.plugins.allowedHosts.add': 'Añadir host permitido',
'admin.plugins.allowedHosts.count': '{n} host(s) permitido(s)',
'admin.plugins.operatorEgressPill': '+ hosts que añadas',
'admin.plugins.operatorEgressHint': 'Este plugin se comunica con un servicio que solo tú puedes nombrar (un servidor autoalojado). Tras instalarlo, añade los hosts a los que puede acceder en ⋯ → Hosts permitidos. No accederá a ningún otro.',
'admin.plugins.noErrors': 'No hay errores registrados.',
'admin.plugins.uninstalled': 'Plugin desinstalado',
'admin.plugins.uninstallTitle': '¿Desinstalar el plugin?',
@@ -237,33 +247,53 @@ const admin: TranslationStrings = {
'Leer los gastos (partidas de presupuesto) a los que el usuario activo tiene acceso',
'admin.plugins.perm.db:read:packing':
'Leer las listas de equipaje de los viajes a los que el usuario activo tiene acceso',
'admin.plugins.perm.db:write:packing': 'Crear, editar y eliminar artículos de la lista de equipaje en los viajes que el usuario activo puede editar',
'admin.plugins.perm.db:write:packing':
'Crear, editar y eliminar artículos de la lista de equipaje en los viajes que el usuario activo puede editar',
'admin.plugins.perm.weather:read': 'Leer la previsión meteorológica en caché del host (por coordenadas)',
'admin.plugins.perm.db:read:categories': 'Leer la lista global de categorías de lugares',
'admin.plugins.perm.db:read:tags': 'Leer las etiquetas del usuario activo',
'admin.plugins.perm.db:write:tags': 'Crear, editar y eliminar las etiquetas del usuario activo',
'admin.plugins.perm.db:read:todos': 'Leer las tareas de los viajes a los que el usuario activo tiene acceso',
'admin.plugins.perm.db:write:todos': 'Crear, editar y eliminar tareas en los viajes que el usuario activo puede editar',
'admin.plugins.perm.db:write:atlas': 'Marcar países y regiones como visitados y gestionar la lista de deseos del usuario activo (requiere el complemento Atlas)',
'admin.plugins.perm.db:write:vacay': 'Alternar días de vacaciones y festivos de empresa en el plan activo del usuario activo (requiere el complemento Vacay)',
'admin.plugins.perm.db:write:journal': 'Crear, editar y eliminar entradas de diario en los diarios que el usuario activo puede editar (requiere el complemento Journey)',
'admin.plugins.perm.db:write:collections': 'Crear y editar colecciones y guardar lugares en ellas, con el rol de colección del usuario activo (requiere el complemento Collections)',
'admin.plugins.perm.db:write:files': 'Adjuntar archivos a los viajes que el usuario activo puede editar (límite de 10 MB, extensiones bloqueadas rechazadas) y gestionar sus enlaces',
'admin.plugins.perm.db:write:collab': 'Publicar notas, encuestas y mensajes de chat en los viajes que el usuario activo puede editar (requiere el complemento Collab)',
'admin.plugins.perm.db:write:members': 'Añadir usuarios a los viajes — concede acceso al viaje; protegido por el permiso de gestión de miembros del usuario activo',
'admin.plugins.perm.notify:send': 'Enviar una notificación (bandeja de campana + correo/ntfy) al usuario activo o a un viaje al que pertenece — nunca a un destinatario arbitrario',
'admin.plugins.perm.ai:invoke': 'Usar el proveedor de IA configurado por el administrador (completado de texto + extracción de documentos) en nombre del usuario activo — el complemento nunca guarda una clave',
'admin.plugins.perm.oauth:client': 'Conectarse a un servicio de terceros en tu nombre mediante OAuth gestionado por el host (el host guarda los tokens, el complemento nunca los ve)',
'admin.plugins.perm.db:write:todos':
'Crear, editar y eliminar tareas en los viajes que el usuario activo puede editar',
'admin.plugins.perm.db:write:atlas':
'Marcar países y regiones como visitados y gestionar la lista de deseos del usuario activo (requiere el complemento Atlas)',
'admin.plugins.perm.db:write:vacay':
'Alternar días de vacaciones y festivos de empresa en el plan activo del usuario activo (requiere el complemento Vacay)',
'admin.plugins.perm.db:write:journal':
'Crear, editar y eliminar entradas de diario en los diarios que el usuario activo puede editar (requiere el complemento Journey)',
'admin.plugins.perm.db:write:collections':
'Crear y editar colecciones y guardar lugares en ellas, con el rol de colección del usuario activo (requiere el complemento Collections)',
'admin.plugins.perm.db:write:files':
'Adjuntar archivos a los viajes que el usuario activo puede editar (límite de 10 MB, extensiones bloqueadas rechazadas) y gestionar sus enlaces',
'admin.plugins.perm.db:write:collab':
'Publicar notas, encuestas y mensajes de chat en los viajes que el usuario activo puede editar (requiere el complemento Collab)',
'admin.plugins.perm.db:write:members':
'Añadir usuarios a los viajes — concede acceso al viaje; protegido por el permiso de gestión de miembros del usuario activo',
'admin.plugins.perm.notify:send':
'Enviar una notificación (bandeja de campana + correo/ntfy) al usuario activo o a un viaje al que pertenece — nunca a un destinatario arbitrario',
'admin.plugins.perm.ai:invoke':
'Usar el proveedor de IA configurado por el administrador (completado de texto + extracción de documentos) en nombre del usuario activo — el complemento nunca guarda una clave',
'admin.plugins.perm.oauth:client':
'Conectarse a un servicio de terceros en tu nombre mediante OAuth gestionado por el host (el host guarda los tokens, el complemento nunca los ve)',
'admin.plugins.perm.db:read:files': 'Leer los archivos de los viajes a los que el usuario activo tiene acceso',
'admin.plugins.perm.db:write:reservations': 'Crear, editar y eliminar reservas en los viajes que el usuario activo puede editar',
'admin.plugins.perm.db:write:accommodations': 'Crear, editar y eliminar alojamientos en los viajes que el usuario activo puede editar',
'admin.plugins.perm.db:read:journal': 'Leer los diarios de viaje del usuario activo (requiere el complemento Journey)',
'admin.plugins.perm.db:read:atlas': 'Leer los países y regiones visitados por el usuario activo (requiere el complemento Atlas)',
'admin.plugins.perm.db:write:reservations':
'Crear, editar y eliminar reservas en los viajes que el usuario activo puede editar',
'admin.plugins.perm.db:write:accommodations':
'Crear, editar y eliminar alojamientos en los viajes que el usuario activo puede editar',
'admin.plugins.perm.db:read:journal':
'Leer los diarios de viaje del usuario activo (requiere el complemento Journey)',
'admin.plugins.perm.db:read:atlas':
'Leer los países y regiones visitados por el usuario activo (requiere el complemento Atlas)',
'admin.plugins.perm.db:read:vacay': 'Leer el plan de vacaciones del usuario activo (requiere el complemento Vacay)',
'admin.plugins.perm.db:read:daynotes': 'Leer las notas del día de los viajes a los que el usuario activo tiene acceso',
'admin.plugins.perm.db:read:collections': 'Leer las colecciones de lugares guardados del usuario activo (requiere el complemento Collections)',
'admin.plugins.perm.db:write:daynotes': 'Crear, editar y eliminar notas del día en los viajes que el usuario activo puede editar',
'admin.plugins.perm.jobs:run': 'Ejecutar sus tareas en segundo plano de forma programada (sin contexto de usuario: no puede leer datos del usuario)',
'admin.plugins.perm.db:read:daynotes':
'Leer las notas del día de los viajes a los que el usuario activo tiene acceso',
'admin.plugins.perm.db:read:collections':
'Leer las colecciones de lugares guardados del usuario activo (requiere el complemento Collections)',
'admin.plugins.perm.db:write:daynotes':
'Crear, editar y eliminar notas del día en los viajes que el usuario activo puede editar',
'admin.plugins.perm.jobs:run':
'Ejecutar sus tareas en segundo plano de forma programada (sin contexto de usuario: no puede leer datos del usuario)',
'admin.plugins.perm.db:write:costs':
'Crear gastos (partidas de presupuesto) en los viajes que el usuario activo puede editar',
'admin.plugins.perm.db:write:places':
@@ -283,20 +313,26 @@ const admin: TranslationStrings = {
'Aportar detalles adicionales (reseñas, valoraciones, enlaces) a un lugar',
'admin.plugins.perm.hook:trip-warning-provider':
'Generar advertencias de validación en un viaje (mostradas en el planificador)',
'admin.plugins.perm.hook:table-contributor': 'Añadir columnas y acciones a las vistas del viaje (reservas, lugares, días)',
'admin.plugins.perm.hook:table-contributor':
'Añadir columnas y acciones a las vistas del viaje (reservas, lugares, días)',
'admin.plugins.perm.hook:map-marker-provider': 'Añadir marcadores al mapa del viaje (p. ej. mostrar reservas o POIs)',
'admin.plugins.perm.hook:pdf-section-provider': 'Añadir secciones de texto a la exportación PDF del viaje',
'admin.plugins.perm.hook:atlas-layer-provider':
'Resaltar países en el mapamundi del Atlas (p. ej. listas de deseos o avisos de viaje)',
'admin.plugins.perm.hook:journal-entry-provider':
'Añadir filas adicionales (enlaces, estadísticas) a las entradas del diario',
'admin.plugins.perm.hook:user-data': 'Borrar o exportar los datos que almacena sobre un usuario (RGPD: borrado de cuenta y solicitudes de datos)',
'admin.plugins.perm.hook:trip-card-provider': 'Añadir pequeñas insignias (estado, recuentos) a las tarjetas de viaje del panel',
'admin.plugins.perm.hook:user-data':
'Borrar o exportar los datos que almacena sobre un usuario (RGPD: borrado de cuenta y solicitudes de datos)',
'admin.plugins.perm.hook:trip-card-provider':
'Añadir pequeñas insignias (estado, recuentos) a las tarjetas de viaje del panel',
'admin.plugins.perm.hook:notification-channel': 'Enviar tus notificaciones a través de un canal adicional',
'admin.plugins.perm.events:subscribe':
'Reaccionar a eventos de actividad del núcleo (solo el nombre del evento y el viaje, nunca el contenido)',
'admin.plugins.perm.http:outbound': 'Realizar solicitudes salientes a sus hosts declarados',
'admin.plugins.perm.db:read:collab': 'Leer las notas, encuestas y mensajes de chat de los viajes a los que el usuario activo tiene acceso (requiere el complemento Collab)',
'admin.plugins.perm.db:read:files:content': 'Leer el contenido (bytes) de los archivos de los viajes a los que el usuario activo tiene acceso',
'admin.plugins.perm.db:read:collab':
'Leer las notas, encuestas y mensajes de chat de los viajes a los que el usuario activo tiene acceso (requiere el complemento Collab)',
'admin.plugins.perm.db:read:files:content':
'Leer el contenido (bytes) de los archivos de los viajes a los que el usuario activo tiene acceso',
'admin.plugins.perm.db:create:trips': 'Crear nuevos viajes propiedad del usuario activo',
'admin.plugins.perm.rates:read': 'Leer los tipos de cambio en caché del host',
'admin.plugins.updateConsentTitle': 'Esta actualización necesita nuevos permisos',
@@ -366,6 +402,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Detalle de la reserva',
'admin.plugins.cap.replacesTabs': 'Reemplaza pestañas del planificador',
'admin.plugins.cap.realtime': 'Actualizaciones en tiempo real',
'admin.plugins.cap.notificationChannel': 'Canal de notificaciones',
'admin.plugins.cap.photos': 'Proporciona fotos',
'admin.plugins.cap.calendar': 'Proporciona eventos de calendario',
'admin.plugins.cap.placeDetails': 'Enriquece lugares',
+8
View File
@@ -291,6 +291,14 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.testFailed': 'Error en la notificación de prueba de Ntfy',
'settings.ntfyUrl.tokenCleared': 'Token de acceso eliminado',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Aún no configurado — configúralo en los ajustes del plugin',
'settings.plugins.actions': 'Acciones',
'settings.plugins.actions.confirm': '¿Ejecutar esta acción?',
'settings.notificationPreferences.sendTest': 'Enviar prueba',
'settings.notificationPreferences.configure': 'Configurar',
'settings.notificationPreferences.testSuccess': 'Notificación de prueba enviada.',
'settings.notificationPreferences.testFailed': 'La prueba falló.',
'settings.notificationPreferences.pluginConfigured': 'Configurado. Gestiona tus credenciales en la página de ajustes del plugin.',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
+63 -26
View File
@@ -170,7 +170,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': "Plugin “{name}” téléversé — activez-le pour l'exécuter",
'admin.plugins.sideloaded': 'Chargé manuellement',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Chargé depuis un dossier de build local et rechargé à chaud avec des données réelles — dev uniquement',
'admin.plugins.devLinkHint':
'Chargé depuis un dossier de build local et rechargé à chaud avec des données réelles — dev uniquement',
'admin.plugins.devLinkTitle': 'Lier un plugin local',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Lier',
@@ -187,6 +188,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Plugin désactivé',
'admin.plugins.viewErrors': "Voir le journal d'erreurs",
'admin.plugins.errorLog': "Journal d'erreurs",
'admin.plugins.allowedHosts': 'Hôtes autorisés',
'admin.plugins.allowedHosts.hint': 'Ce plugin communique avec un service que vous seul pouvez nommer (un serveur auto-hébergé). Ajoutez les hôtes qu’il peut joindre — il ne joindra aucun autre.',
'admin.plugins.allowedHosts.none': 'Aucun hôte ajouté pour l’instant.',
'admin.plugins.allowedHosts.unsupported': 'Ce plugin n’utilise pas d’hôtes fournis par l’opérateur. Ses hôtes autorisés sont fixés dans son manifeste.',
'admin.plugins.allowedHosts.restartNote': 'L’enregistrement redémarre le plugin pour qu’il prenne en compte la nouvelle liste.',
'admin.plugins.allowedHosts.add': 'Ajouter un hôte autorisé',
'admin.plugins.allowedHosts.count': '{n} hôte(s) autorisé(s)',
'admin.plugins.operatorEgressPill': '+ hôtes que vous ajoutez',
'admin.plugins.operatorEgressHint': 'Ce plugin communique avec un service que vous seul pouvez nommer (un serveur auto-hébergé). Après installation, ajoutez les hôtes qu’il peut joindre via ⋯ → Hôtes autorisés. Il ne joindra aucun autre.',
'admin.plugins.noErrors': 'Aucune erreur enregistrée.',
'admin.plugins.uninstalled': 'Plugin désinstallé',
'admin.plugins.uninstallTitle': 'Désinstaller le plugin ?',
@@ -237,33 +247,52 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:users': 'Lire les informations de profil de base (nom, avatar — jamais les identifiants)',
'admin.plugins.perm.db:read:costs': "Lire les dépenses (postes de budget) accessibles à l'utilisateur actif",
'admin.plugins.perm.db:read:packing': "Lire les listes de bagages des voyages accessibles à l'utilisateur actif",
'admin.plugins.perm.db:write:packing': "Créer, modifier et supprimer des articles de la liste de bagages sur les voyages que l'utilisateur actif peut modifier",
'admin.plugins.perm.db:write:packing':
"Créer, modifier et supprimer des articles de la liste de bagages sur les voyages que l'utilisateur actif peut modifier",
'admin.plugins.perm.weather:read': "Lire les prévisions météo en cache de l'hôte (par coordonnées)",
'admin.plugins.perm.db:read:categories': 'Lire la liste globale des catégories de lieux',
'admin.plugins.perm.db:read:tags': "Lire les étiquettes de l'utilisateur actif",
'admin.plugins.perm.db:write:tags': "Créer, modifier et supprimer les étiquettes de l'utilisateur actif",
'admin.plugins.perm.db:read:todos': "Lire les tâches des voyages accessibles à l'utilisateur actif",
'admin.plugins.perm.db:write:todos': "Créer, modifier et supprimer des tâches sur les voyages que l'utilisateur actif peut modifier",
'admin.plugins.perm.db:write:atlas': "Marquer des pays et régions comme visités et gérer la liste d'envies de l'utilisateur actif (nécessite le module Atlas)",
'admin.plugins.perm.db:write:vacay': "Basculer les jours de congé et les fermetures d'entreprise sur le plan actif de l'utilisateur actif (nécessite le module Vacay)",
'admin.plugins.perm.db:write:journal': "Créer, modifier et supprimer des entrées de journal dans les journaux que l'utilisateur actif peut modifier (nécessite le module Journey)",
'admin.plugins.perm.db:write:collections': "Créer et modifier des collections et y enregistrer des lieux, avec le rôle de collection de l'utilisateur actif (nécessite le module Collections)",
'admin.plugins.perm.db:write:files': "Joindre des fichiers aux voyages que l'utilisateur actif peut modifier (limite de 10 Mo, extensions bloquées refusées) et gérer leurs liens",
'admin.plugins.perm.db:write:collab': "Publier des notes, des sondages et des messages de chat sur les voyages que l'utilisateur actif peut modifier (nécessite le module Collab)",
'admin.plugins.perm.db:write:members': "Ajouter des utilisateurs aux voyages — accorde l'accès au voyage ; protégé par le droit de gestion des membres de l'utilisateur actif",
'admin.plugins.perm.notify:send': "Envoyer une notification (boîte de réception + e-mail/ntfy) à l'utilisateur actif ou à un voyage auquel il appartient — jamais à un destinataire arbitraire",
'admin.plugins.perm.ai:invoke': "Utiliser le fournisseur d'IA configuré par l'administrateur (complétion de texte + extraction de documents) au nom de l'utilisateur actif — le module ne détient jamais de clé",
'admin.plugins.perm.oauth:client': "Se connecter à un service tiers en votre nom via OAuth géré par l'hôte (l'hôte détient les jetons, le module ne les voit jamais)",
'admin.plugins.perm.db:write:todos':
"Créer, modifier et supprimer des tâches sur les voyages que l'utilisateur actif peut modifier",
'admin.plugins.perm.db:write:atlas':
"Marquer des pays et régions comme visités et gérer la liste d'envies de l'utilisateur actif (nécessite le module Atlas)",
'admin.plugins.perm.db:write:vacay':
"Basculer les jours de congé et les fermetures d'entreprise sur le plan actif de l'utilisateur actif (nécessite le module Vacay)",
'admin.plugins.perm.db:write:journal':
"Créer, modifier et supprimer des entrées de journal dans les journaux que l'utilisateur actif peut modifier (nécessite le module Journey)",
'admin.plugins.perm.db:write:collections':
"Créer et modifier des collections et y enregistrer des lieux, avec le rôle de collection de l'utilisateur actif (nécessite le module Collections)",
'admin.plugins.perm.db:write:files':
"Joindre des fichiers aux voyages que l'utilisateur actif peut modifier (limite de 10 Mo, extensions bloquées refusées) et gérer leurs liens",
'admin.plugins.perm.db:write:collab':
"Publier des notes, des sondages et des messages de chat sur les voyages que l'utilisateur actif peut modifier (nécessite le module Collab)",
'admin.plugins.perm.db:write:members':
"Ajouter des utilisateurs aux voyages — accorde l'accès au voyage ; protégé par le droit de gestion des membres de l'utilisateur actif",
'admin.plugins.perm.notify:send':
"Envoyer une notification (boîte de réception + e-mail/ntfy) à l'utilisateur actif ou à un voyage auquel il appartient — jamais à un destinataire arbitraire",
'admin.plugins.perm.ai:invoke':
"Utiliser le fournisseur d'IA configuré par l'administrateur (complétion de texte + extraction de documents) au nom de l'utilisateur actif — le module ne détient jamais de clé",
'admin.plugins.perm.oauth:client':
"Se connecter à un service tiers en votre nom via OAuth géré par l'hôte (l'hôte détient les jetons, le module ne les voit jamais)",
'admin.plugins.perm.db:read:files': "Lire les fichiers des voyages accessibles à l'utilisateur actif",
'admin.plugins.perm.db:write:reservations': "Créer, modifier et supprimer des réservations sur les voyages que l'utilisateur actif peut modifier",
'admin.plugins.perm.db:write:accommodations': "Créer, modifier et supprimer des hébergements sur les voyages que l'utilisateur actif peut modifier",
'admin.plugins.perm.db:read:journal': "Lire les journaux de voyage de l'utilisateur actif (nécessite le module Journey)",
'admin.plugins.perm.db:read:atlas': "Lire les pays et régions visités par l'utilisateur actif (nécessite le module Atlas)",
'admin.plugins.perm.db:write:reservations':
"Créer, modifier et supprimer des réservations sur les voyages que l'utilisateur actif peut modifier",
'admin.plugins.perm.db:write:accommodations':
"Créer, modifier et supprimer des hébergements sur les voyages que l'utilisateur actif peut modifier",
'admin.plugins.perm.db:read:journal':
"Lire les journaux de voyage de l'utilisateur actif (nécessite le module Journey)",
'admin.plugins.perm.db:read:atlas':
"Lire les pays et régions visités par l'utilisateur actif (nécessite le module Atlas)",
'admin.plugins.perm.db:read:vacay': "Lire le plan de vacances de l'utilisateur actif (nécessite le module Vacay)",
'admin.plugins.perm.db:read:daynotes': "Lire les notes de journée des voyages accessibles à l'utilisateur actif",
'admin.plugins.perm.db:read:collections': "Lire les collections de lieux enregistrés de l'utilisateur actif (nécessite le module Collections)",
'admin.plugins.perm.db:write:daynotes': "Créer, modifier et supprimer des notes de journée sur les voyages que l'utilisateur actif peut modifier",
'admin.plugins.perm.jobs:run': "Exécuter ses tâches d'arrière-plan selon une planification (sans contexte utilisateur — ne peut pas lire les données utilisateur)",
'admin.plugins.perm.db:read:collections':
"Lire les collections de lieux enregistrés de l'utilisateur actif (nécessite le module Collections)",
'admin.plugins.perm.db:write:daynotes':
"Créer, modifier et supprimer des notes de journée sur les voyages que l'utilisateur actif peut modifier",
'admin.plugins.perm.jobs:run':
"Exécuter ses tâches d'arrière-plan selon une planification (sans contexte utilisateur — ne peut pas lire les données utilisateur)",
'admin.plugins.perm.db:write:costs':
"Créer des dépenses (postes de budget) sur les voyages que l'utilisateur actif peut modifier",
'admin.plugins.perm.db:write:places':
@@ -283,20 +312,27 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:place-detail-provider': 'Ajouter des détails supplémentaires (avis, notes, liens) à un lieu',
'admin.plugins.perm.hook:trip-warning-provider':
'Émettre des avertissements de validation sur un voyage (affichés dans le planificateur)',
'admin.plugins.perm.hook:table-contributor': 'Ajouter des colonnes et des actions aux vues du voyage (réservations, lieux, jours)',
'admin.plugins.perm.hook:map-marker-provider': 'Ajouter des marqueurs à la carte du voyage (p. ex. afficher réservations ou POIs)',
'admin.plugins.perm.hook:table-contributor':
'Ajouter des colonnes et des actions aux vues du voyage (réservations, lieux, jours)',
'admin.plugins.perm.hook:map-marker-provider':
'Ajouter des marqueurs à la carte du voyage (p. ex. afficher réservations ou POIs)',
'admin.plugins.perm.hook:pdf-section-provider': "Ajouter des sections de texte à l'export PDF du voyage",
'admin.plugins.perm.hook:atlas-layer-provider':
"Mettre en évidence des pays sur la carte du monde Atlas (p. ex. listes d'envies ou avis aux voyageurs)",
'admin.plugins.perm.hook:journal-entry-provider':
'Ajouter des lignes supplémentaires (liens, statistiques) aux entrées du journal',
'admin.plugins.perm.hook:user-data': "Effacer ou exporter les données qu'il stocke sur un utilisateur (RGPD : suppression de compte et demandes d'accès)",
'admin.plugins.perm.hook:trip-card-provider': 'Ajouter de petits badges (statut, compteurs) aux cartes de voyage du tableau de bord',
'admin.plugins.perm.hook:user-data':
"Effacer ou exporter les données qu'il stocke sur un utilisateur (RGPD : suppression de compte et demandes d'accès)",
'admin.plugins.perm.hook:trip-card-provider':
'Ajouter de petits badges (statut, compteurs) aux cartes de voyage du tableau de bord',
'admin.plugins.perm.hook:notification-channel': 'Envoyer vos notifications via un canal supplémentaire',
'admin.plugins.perm.events:subscribe':
'Réagir aux événements d’activité principaux (nom de l’événement et voyage uniquement, jamais le contenu)',
'admin.plugins.perm.http:outbound': 'Effectuer des requêtes sortantes vers ses hôtes déclarés',
'admin.plugins.perm.db:read:collab': "Lire les notes, sondages et messages de chat des voyages accessibles à l'utilisateur actif (nécessite le module Collab)",
'admin.plugins.perm.db:read:files:content': "Lire le contenu (octets) des fichiers des voyages accessibles à l'utilisateur actif",
'admin.plugins.perm.db:read:collab':
"Lire les notes, sondages et messages de chat des voyages accessibles à l'utilisateur actif (nécessite le module Collab)",
'admin.plugins.perm.db:read:files:content':
"Lire le contenu (octets) des fichiers des voyages accessibles à l'utilisateur actif",
'admin.plugins.perm.db:create:trips': "Créer de nouveaux voyages appartenant à l'utilisateur actif",
'admin.plugins.perm.rates:read': "Lire les taux de change en cache de l'hôte",
'admin.plugins.updateConsentTitle': 'Cette mise à jour nécessite de nouvelles permissions',
@@ -367,6 +403,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Détail de la réservation',
'admin.plugins.cap.replacesTabs': 'Remplace des onglets du planificateur',
'admin.plugins.cap.realtime': 'Mises à jour en temps réel',
'admin.plugins.cap.notificationChannel': 'Canal de notification',
'admin.plugins.cap.photos': 'Fournit des photos',
'admin.plugins.cap.calendar': 'Fournit des événements de calendrier',
'admin.plugins.cap.placeDetails': 'Enrichit les lieux',
+10 -1
View File
@@ -294,6 +294,14 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.testFailed': 'Échec de la notification de test Ntfy',
'settings.ntfyUrl.tokenCleared': "Jeton d'accès effacé",
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Pas encore configuré — à paramétrer dans les réglages du plugin',
'settings.plugins.actions': 'Actions',
'settings.plugins.actions.confirm': 'Exécuter cette action ?',
'settings.notificationPreferences.sendTest': 'Envoyer un test',
'settings.notificationPreferences.configure': 'Configurer',
'settings.notificationPreferences.testSuccess': 'Notification de test envoyée.',
'settings.notificationPreferences.testFailed': 'Échec du test.',
'settings.notificationPreferences.pluginConfigured': 'Configuré. Gérez vos identifiants sur la page de réglages du plugin.',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
@@ -484,7 +492,8 @@ const settings: TranslationStrings = {
'settings.offline.banner.failed': 'Échec de la synchronisation : {count}',
'settings.offline.banner.conflicts': 'Conflits : {count}',
'settings.pluginActivity.title': 'Activité des plugins',
'settings.pluginActivity.description': 'Chaque action qu\'un plugin a effectuée en votre nom, la plus récente en premier.',
'settings.pluginActivity.description':
"Chaque action qu'un plugin a effectuée en votre nom, la plus récente en premier.",
'settings.pluginActivity.empty': 'Aucune activité de plugin pour le moment.',
'settings.pluginActivity.refresh': 'Actualiser',
'settings.pluginActivity.columns.plugin': 'Plugin',
+71 -30
View File
@@ -215,7 +215,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'Το plugin “{name}” μεταφορτώθηκε — ενεργοποιήστε το για εκτέλεση',
'admin.plugins.sideloaded': 'Φορτώθηκε χειροκίνητα',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Φορτώνεται από τοπικό κατάλογο build και επαναφορτώνεται ζωντανά με πραγματικά δεδομένα — μόνο για ανάπτυξη',
'admin.plugins.devLinkHint':
'Φορτώνεται από τοπικό κατάλογο build και επαναφορτώνεται ζωντανά με πραγματικά δεδομένα — μόνο για ανάπτυξη',
'admin.plugins.devLinkTitle': 'Σύνδεση τοπικού πρόσθετου',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Σύνδεση',
@@ -232,6 +233,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Το plugin απενεργοποιήθηκε',
'admin.plugins.viewErrors': 'Προβολή αρχείου σφαλμάτων',
'admin.plugins.errorLog': 'Αρχείο σφαλμάτων',
'admin.plugins.allowedHosts': 'Επιτρεπόμενοι κόμβοι',
'admin.plugins.allowedHosts.hint': 'Αυτό το πρόσθετο επικοινωνεί με μια υπηρεσία που μόνο εσείς μπορείτε να ονομάσετε (αυτο-φιλοξενούμενος διακομιστής). Προσθέστε τους κόμβους που μπορεί να προσεγγίσει — κανέναν άλλον.',
'admin.plugins.allowedHosts.none': 'Δεν έχουν προστεθεί κόμβοι ακόμη.',
'admin.plugins.allowedHosts.unsupported': 'Αυτό το πρόσθετο δεν χρησιμοποιεί κόμβους που ορίζει ο διαχειριστής. Οι επιτρεπόμενοι κόμβοι του είναι σταθεροί στο manifest.',
'admin.plugins.allowedHosts.restartNote': 'Η αποθήκευση επανεκκινεί το πρόσθετο ώστε να λάβει τη νέα λίστα.',
'admin.plugins.allowedHosts.add': 'Προσθήκη επιτρεπόμενου κόμβου',
'admin.plugins.allowedHosts.count': '{n} επιτρεπόμενοι κόμβοι',
'admin.plugins.operatorEgressPill': '+ κόμβοι που προσθέτετε',
'admin.plugins.operatorEgressHint': 'Αυτό το πρόσθετο επικοινωνεί με μια υπηρεσία που μόνο εσείς μπορείτε να ονομάσετε (αυτο-φιλοξενούμενος διακομιστής). Μετά την εγκατάσταση προσθέστε τους κόμβους στο ⋯ → Επιτρεπόμενοι κόμβοι. Κανέναν άλλον.',
'admin.plugins.noErrors': 'Δεν έχουν καταγραφεί σφάλματα.',
'admin.plugins.uninstalled': 'Το plugin απεγκαταστάθηκε',
'admin.plugins.uninstallTitle': 'Απεγκατάσταση plugin;',
@@ -283,33 +293,55 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:costs':
'Ανάγνωση εξόδων (στοιχεία προϋπολογισμού) στα οποία έχει πρόσβαση ο ενεργός χρήστης',
'admin.plugins.perm.db:read:packing': 'Ανάγνωση λιστών αποσκευών ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης',
'admin.plugins.perm.db:write:packing': 'Δημιουργία, επεξεργασία και διαγραφή αντικειμένων λίστας αποσκευών σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης',
'admin.plugins.perm.weather:read': 'Ανάγνωση της προσωρινά αποθηκευμένης πρόγνωσης καιρού του κεντρικού υπολογιστή (ανά συντεταγμένες)',
'admin.plugins.perm.db:write:packing':
'Δημιουργία, επεξεργασία και διαγραφή αντικειμένων λίστας αποσκευών σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης',
'admin.plugins.perm.weather:read':
'Ανάγνωση της προσωρινά αποθηκευμένης πρόγνωσης καιρού του κεντρικού υπολογιστή (ανά συντεταγμένες)',
'admin.plugins.perm.db:read:categories': 'Ανάγνωση της γενικής λίστας κατηγοριών τοποθεσιών',
'admin.plugins.perm.db:read:tags': 'Ανάγνωση των ετικετών του ενεργού χρήστη',
'admin.plugins.perm.db:write:tags': 'Δημιουργία, επεξεργασία και διαγραφή των ετικετών του ενεργού χρήστη',
'admin.plugins.perm.db:read:todos': 'Ανάγνωση των εργασιών ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης',
'admin.plugins.perm.db:write:todos': 'Δημιουργία, επεξεργασία και διαγραφή εργασιών σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης',
'admin.plugins.perm.db:write:atlas': 'Επισήμανση χωρών και περιοχών ως επισκεφθέντων και διαχείριση της λίστας επιθυμιών του ενεργού χρήστη (απαιτεί το πρόσθετο Atlas)',
'admin.plugins.perm.db:write:vacay': 'Εναλλαγή ημερών άδειας και εταιρικών αργιών στο ενεργό πλάνο του ενεργού χρήστη (απαιτεί το πρόσθετο Vacay)',
'admin.plugins.perm.db:write:journal': 'Δημιουργία, επεξεργασία και διαγραφή καταχωρήσεων σε ημερολόγια που μπορεί να επεξεργαστεί ο ενεργός χρήστης (απαιτεί το πρόσθετο Journey)',
'admin.plugins.perm.db:write:collections': 'Δημιουργία και επεξεργασία συλλογών και αποθήκευση τοποθεσιών σε αυτές, με τον ρόλο συλλογής του ενεργού χρήστη (απαιτεί το πρόσθετο Collections)',
'admin.plugins.perm.db:write:files': 'Επισύναψη αρχείων σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης (όριο 10 MB, αποκλεισμένες επεκτάσεις απορρίπτονται) και διαχείριση των συνδέσμων τους',
'admin.plugins.perm.db:write:collab': 'Δημοσίευση σημειώσεων, ψηφοφοριών και μηνυμάτων συνομιλίας σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης (απαιτεί το πρόσθετο Collab)',
'admin.plugins.perm.db:write:members': 'Προσθήκη χρηστών σε ταξίδια — παρέχει πρόσβαση στο ταξίδι· προστατεύεται από το δικαίωμα διαχείρισης μελών του ενεργού χρήστη',
'admin.plugins.perm.notify:send': 'Αποστολή ειδοποίησης (εισερχόμενα κουδουνιού + email/ntfy) στον ενεργό χρήστη ή σε ταξίδι στο οποίο ανήκει — ποτέ σε αυθαίρετο παραλήπτη',
'admin.plugins.perm.ai:invoke': 'Χρήση του παρόχου AI που ρύθμισε ο διαχειριστής (συμπλήρωση κειμένου + εξαγωγή εγγράφων) εκ μέρους του ενεργού χρήστη — το πρόσθετο δεν κρατά ποτέ κλειδί',
'admin.plugins.perm.oauth:client': 'Σύνδεση σε υπηρεσία τρίτου εκ μέρους σας μέσω OAuth με διαμεσολάβηση του κεντρικού υπολογιστή (ο κεντρικός υπολογιστής κρατά τα διακριτικά, το πρόσθετο δεν τα βλέπει ποτέ)',
'admin.plugins.perm.db:write:todos':
'Δημιουργία, επεξεργασία και διαγραφή εργασιών σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης',
'admin.plugins.perm.db:write:atlas':
'Επισήμανση χωρών και περιοχών ως επισκεφθέντων και διαχείριση της λίστας επιθυμιών του ενεργού χρήστη (απαιτεί το πρόσθετο Atlas)',
'admin.plugins.perm.db:write:vacay':
'Εναλλαγή ημερών άδειας και εταιρικών αργιών στο ενεργό πλάνο του ενεργού χρήστη (απαιτεί το πρόσθετο Vacay)',
'admin.plugins.perm.db:write:journal':
'Δημιουργία, επεξεργασία και διαγραφή καταχωρήσεων σε ημερολόγια που μπορεί να επεξεργαστεί ο ενεργός χρήστης (απαιτεί το πρόσθετο Journey)',
'admin.plugins.perm.db:write:collections':
'Δημιουργία και επεξεργασία συλλογών και αποθήκευση τοποθεσιών σε αυτές, με τον ρόλο συλλογής του ενεργού χρήστη (απαιτεί το πρόσθετο Collections)',
'admin.plugins.perm.db:write:files':
'Επισύναψη αρχείων σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης (όριο 10 MB, αποκλεισμένες επεκτάσεις απορρίπτονται) και διαχείριση των συνδέσμων τους',
'admin.plugins.perm.db:write:collab':
'Δημοσίευση σημειώσεων, ψηφοφοριών και μηνυμάτων συνομιλίας σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης (απαιτεί το πρόσθετο Collab)',
'admin.plugins.perm.db:write:members':
'Προσθήκη χρηστών σε ταξίδια — παρέχει πρόσβαση στο ταξίδι· προστατεύεται από το δικαίωμα διαχείρισης μελών του ενεργού χρήστη',
'admin.plugins.perm.notify:send':
'Αποστολή ειδοποίησης (εισερχόμενα κουδουνιού + email/ntfy) στον ενεργό χρήστη ή σε ταξίδι στο οποίο ανήκει — ποτέ σε αυθαίρετο παραλήπτη',
'admin.plugins.perm.ai:invoke':
'Χρήση του παρόχου AI που ρύθμισε ο διαχειριστής (συμπλήρωση κειμένου + εξαγωγή εγγράφων) εκ μέρους του ενεργού χρήστη — το πρόσθετο δεν κρατά ποτέ κλειδί',
'admin.plugins.perm.oauth:client':
'Σύνδεση σε υπηρεσία τρίτου εκ μέρους σας μέσω OAuth με διαμεσολάβηση του κεντρικού υπολογιστή (ο κεντρικός υπολογιστής κρατά τα διακριτικά, το πρόσθετο δεν τα βλέπει ποτέ)',
'admin.plugins.perm.db:read:files': 'Ανάγνωση αρχείων ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης',
'admin.plugins.perm.db:write:reservations': 'Δημιουργία, επεξεργασία και διαγραφή κρατήσεων σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης',
'admin.plugins.perm.db:write:accommodations': 'Δημιουργία, επεξεργασία και διαγραφή καταλυμάτων σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης',
'admin.plugins.perm.db:read:journal': 'Ανάγνωση των ημερολογίων ταξιδιού του ενεργού χρήστη (απαιτεί το πρόσθετο Journey)',
'admin.plugins.perm.db:read:atlas': 'Ανάγνωση των χωρών και περιοχών που έχει επισκεφθεί ο ενεργός χρήστης (απαιτεί το πρόσθετο Atlas)',
'admin.plugins.perm.db:read:vacay': 'Ανάγνωση του προγράμματος διακοπών του ενεργού χρήστη (απαιτεί το πρόσθετο Vacay)',
'admin.plugins.perm.db:read:daynotes': 'Ανάγνωση των σημειώσεων ημέρας ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης',
'admin.plugins.perm.db:read:collections': 'Ανάγνωση των συλλογών αποθηκευμένων τοποθεσιών του ενεργού χρήστη (απαιτεί το πρόσθετο Collections)',
'admin.plugins.perm.db:write:daynotes': 'Δημιουργία, επεξεργασία και διαγραφή σημειώσεων ημέρας σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης',
'admin.plugins.perm.jobs:run': 'Εκτέλεση των δηλωμένων εργασιών παρασκηνίου βάσει προγράμματος (χωρίς περιβάλλον χρήστη — δεν μπορεί να διαβάσει δεδομένα χρήστη)',
'admin.plugins.perm.db:write:reservations':
'Δημιουργία, επεξεργασία και διαγραφή κρατήσεων σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης',
'admin.plugins.perm.db:write:accommodations':
'Δημιουργία, επεξεργασία και διαγραφή καταλυμάτων σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης',
'admin.plugins.perm.db:read:journal':
'Ανάγνωση των ημερολογίων ταξιδιού του ενεργού χρήστη (απαιτεί το πρόσθετο Journey)',
'admin.plugins.perm.db:read:atlas':
'Ανάγνωση των χωρών και περιοχών που έχει επισκεφθεί ο ενεργός χρήστης (απαιτεί το πρόσθετο Atlas)',
'admin.plugins.perm.db:read:vacay':
'Ανάγνωση του προγράμματος διακοπών του ενεργού χρήστη (απαιτεί το πρόσθετο Vacay)',
'admin.plugins.perm.db:read:daynotes':
'Ανάγνωση των σημειώσεων ημέρας ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης',
'admin.plugins.perm.db:read:collections':
'Ανάγνωση των συλλογών αποθηκευμένων τοποθεσιών του ενεργού χρήστη (απαιτεί το πρόσθετο Collections)',
'admin.plugins.perm.db:write:daynotes':
'Δημιουργία, επεξεργασία και διαγραφή σημειώσεων ημέρας σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης',
'admin.plugins.perm.jobs:run':
'Εκτέλεση των δηλωμένων εργασιών παρασκηνίου βάσει προγράμματος (χωρίς περιβάλλον χρήστη — δεν μπορεί να διαβάσει δεδομένα χρήστη)',
'admin.plugins.perm.db:write:costs':
'Δημιουργία εξόδων (στοιχεία προϋπολογισμού) σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης',
'admin.plugins.perm.db:write:places':
@@ -330,22 +362,30 @@ const admin: TranslationStrings = {
'Συνεισφορά επιπλέον λεπτομερειών (κριτικές, βαθμολογίες, σύνδεσμοι) σε ένα μέρος',
'admin.plugins.perm.hook:trip-warning-provider':
'Εμφάνιση προειδοποιήσεων επικύρωσης σε ένα ταξίδι (εμφανίζονται στον σχεδιαστή)',
'admin.plugins.perm.hook:table-contributor': 'Προσθήκη στηλών και ενεργειών στις προβολές του ταξιδιού (κρατήσεις, τοποθεσίες, ημέρες)',
'admin.plugins.perm.hook:map-marker-provider': 'Προσθήκη δεικτών στον χάρτη του ταξιδιού (π.χ. εμφάνιση κρατήσεων ή POI)',
'admin.plugins.perm.hook:table-contributor':
'Προσθήκη στηλών και ενεργειών στις προβολές του ταξιδιού (κρατήσεις, τοποθεσίες, ημέρες)',
'admin.plugins.perm.hook:map-marker-provider':
'Προσθήκη δεικτών στον χάρτη του ταξιδιού (π.χ. εμφάνιση κρατήσεων ή POI)',
'admin.plugins.perm.hook:pdf-section-provider': 'Προσθήκη ενοτήτων κειμένου στην εξαγωγή PDF του ταξιδιού',
'admin.plugins.perm.hook:atlas-layer-provider':
'Επισήμανση χωρών στον παγκόσμιο χάρτη του Άτλαντα (π.χ. λίστες επιθυμιών ή ταξιδιωτικές οδηγίες)',
'admin.plugins.perm.hook:journal-entry-provider':
'Προσθήκη επιπλέον γραμμών (σύνδεσμοι, στατιστικά) στις καταχωρίσεις του ημερολογίου',
'admin.plugins.perm.hook:user-data': 'Διαγραφή ή εξαγωγή των δεδομένων που αποθηκεύει για έναν χρήστη (GDPR: διαγραφή λογαριασμού και αιτήματα δεδομένων)',
'admin.plugins.perm.hook:trip-card-provider': 'Προσθήκη μικρών σημάτων (κατάσταση, μετρήσεις) στις κάρτες ταξιδιού του πίνακα',
'admin.plugins.perm.hook:user-data':
'Διαγραφή ή εξαγωγή των δεδομένων που αποθηκεύει για έναν χρήστη (GDPR: διαγραφή λογαριασμού και αιτήματα δεδομένων)',
'admin.plugins.perm.hook:trip-card-provider':
'Προσθήκη μικρών σημάτων (κατάσταση, μετρήσεις) στις κάρτες ταξιδιού του πίνακα',
'admin.plugins.perm.hook:notification-channel': 'Παράδοση των ειδοποιήσεών σας μέσω πρόσθετου καναλιού',
'admin.plugins.perm.events:subscribe':
'Αντιδρά σε βασικά συμβάντα δραστηριότητας (όνομα συμβάντος + ταξίδι μόνο, ποτέ το περιεχόμενο)',
'admin.plugins.perm.http:outbound': 'Εκτέλεση εξερχόμενων αιτημάτων προς τους δηλωμένους hosts του',
'admin.plugins.perm.db:read:collab': 'Ανάγνωση σημειώσεων, ψηφοφοριών και μηνυμάτων συνομιλίας ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης (απαιτεί το πρόσθετο Collab)',
'admin.plugins.perm.db:read:files:content': 'Ανάγνωση του περιεχομένου (bytes) των αρχείων ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης',
'admin.plugins.perm.db:read:collab':
'Ανάγνωση σημειώσεων, ψηφοφοριών και μηνυμάτων συνομιλίας ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης (απαιτεί το πρόσθετο Collab)',
'admin.plugins.perm.db:read:files:content':
'Ανάγνωση του περιεχομένου (bytes) των αρχείων ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης',
'admin.plugins.perm.db:create:trips': 'Δημιουργία νέων ταξιδιών που ανήκουν στον ενεργό χρήστη',
'admin.plugins.perm.rates:read': 'Ανάγνωση των προσωρινά αποθηκευμένων συναλλαγματικών ισοτιμιών του κεντρικού υπολογιστή',
'admin.plugins.perm.rates:read':
'Ανάγνωση των προσωρινά αποθηκευμένων συναλλαγματικών ισοτιμιών του κεντρικού υπολογιστή',
'admin.plugins.updateConsentTitle': 'Αυτή η ενημέρωση χρειάζεται νέα δικαιώματα',
'admin.plugins.updateConsentBody':
'Το {name} v{version} ζητά δικαιώματα που δεν έχετε ακόμη παραχωρήσει. Η νέα έκδοση έχει εγκατασταθεί αλλά παραμένει ανενεργή μέχρι να την εγκρίνετε.',
@@ -413,6 +453,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Λεπτομέρειες κράτησης',
'admin.plugins.cap.replacesTabs': 'Αντικαθιστά καρτέλες του σχεδιαστή',
'admin.plugins.cap.realtime': 'Ενημερώσεις σε πραγματικό χρόνο',
'admin.plugins.cap.notificationChannel': 'Κανάλι ειδοποιήσεων',
'admin.plugins.cap.photos': 'Παρέχει φωτογραφίες',
'admin.plugins.cap.calendar': 'Παρέχει συμβάντα ημερολογίου',
'admin.plugins.cap.placeDetails': 'Εμπλουτίζει μέρη',
+13 -2
View File
@@ -13,7 +13,8 @@ const settings: TranslationStrings = {
'settings.plugins.oauth.disconnect': 'Αποσύνδεση',
'settings.tabs.plugins': 'Πρόσθετα',
'settings.plugins.title': 'Ρυθμίσεις προσθέτων',
'settings.plugins.subtitle': 'Οι προσωπικές σας ρυθμίσεις για τα πρόσθετα που χρησιμοποιείτε (κλειδιά API, προτιμήσεις).',
'settings.plugins.subtitle':
'Οι προσωπικές σας ρυθμίσεις για τα πρόσθετα που χρησιμοποιείτε (κλειδιά API, προτιμήσεις).',
'settings.plugins.empty': 'Δεν υπάρχουν ενεργά πρόσθετα.',
'settings.plugins.saved': 'Οι ρυθμίσεις αποθηκεύτηκαν',
'settings.tabs.account': 'Λογαριασμός',
@@ -95,6 +96,15 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'Εντός εφαρμογής',
'settings.notificationPreferences.notConfigured':
'Δεν έχει ρυθμιστεί ακόμη — ρυθμίστε το στις ρυθμίσεις του πρόσθετου',
'settings.plugins.actions': 'Ενέργειες',
'settings.plugins.actions.confirm': 'Εκτέλεση αυτής της ενέργειας;',
'settings.notificationPreferences.sendTest': 'Αποστολή δοκιμής',
'settings.notificationPreferences.configure': 'Ρύθμιση',
'settings.notificationPreferences.testSuccess': 'Στάλθηκε δοκιμαστική ειδοποίηση.',
'settings.notificationPreferences.testFailed': 'Η δοκιμή απέτυχε.',
'settings.notificationPreferences.pluginConfigured': 'Ρυθμίστηκε. Διαχειριστείτε τα διαπιστευτήρια στη σελίδα ρυθμίσεων του πρόσθετου.',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.noChannels':
'Δεν έχουν διαμορφωθεί κανάλια ειδοποιήσεων. Ζητήστε από έναν διαχειριστή να ρυθμίσει ειδοποιήσεις email ή webhook.',
@@ -485,7 +495,8 @@ const settings: TranslationStrings = {
'settings.offline.banner.failed': 'Αποτυχία συγχρονισμού: {count}',
'settings.offline.banner.conflicts': 'Διενέξεις: {count}',
'settings.pluginActivity.title': 'Δραστηριότητα προσθέτων',
'settings.pluginActivity.description': 'Κάθε ενέργεια που εκτέλεσε ένα πρόσθετο εκ μέρους σας, με τις πιο πρόσφατες πρώτες.',
'settings.pluginActivity.description':
'Κάθε ενέργεια που εκτέλεσε ένα πρόσθετο εκ μέρους σας, με τις πιο πρόσφατες πρώτες.',
'settings.pluginActivity.empty': 'Δεν υπάρχει ακόμη δραστηριότητα προσθέτων.',
'settings.pluginActivity.refresh': 'Ανανέωση',
'settings.pluginActivity.columns.plugin': 'Πρόσθετο',
+67 -28
View File
@@ -169,7 +169,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'A(z) “{name}” plugin feltöltve — aktiváld a futtatáshoz',
'admin.plugins.sideloaded': 'Manuálisan betöltve',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Helyi build könyvtárból betöltve és valós adatokkal élőben újratöltve — csak fejlesztéshez',
'admin.plugins.devLinkHint':
'Helyi build könyvtárból betöltve és valós adatokkal élőben újratöltve — csak fejlesztéshez',
'admin.plugins.devLinkTitle': 'Helyi bővítmény csatolása',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Csatolás',
@@ -186,6 +187,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Plugin deaktiválva',
'admin.plugins.viewErrors': 'Hibanapló megtekintése',
'admin.plugins.errorLog': 'Hibanapló',
'admin.plugins.allowedHosts': 'Engedélyezett hosztok',
'admin.plugins.allowedHosts.hint': 'Ez a bővítmény olyan szolgáltatással kommunikál, amelyet csak te tudsz megnevezni (saját üzemeltetésű kiszolgáló). Add meg a hosztokat, amelyeket elérhet — másokat nem.',
'admin.plugins.allowedHosts.none': 'Még nincs hozzáadott hoszt.',
'admin.plugins.allowedHosts.unsupported': 'Ez a bővítmény nem használ üzemeltető által megadott hosztokat. Az engedélyezett hosztjai a manifestben rögzítettek.',
'admin.plugins.allowedHosts.restartNote': 'A mentés újraindítja a bővítményt, hogy átvegye az új listát.',
'admin.plugins.allowedHosts.add': 'Engedélyezett hoszt hozzáadása',
'admin.plugins.allowedHosts.count': '{n} engedélyezett hoszt',
'admin.plugins.operatorEgressPill': '+ általad hozzáadott hosztok',
'admin.plugins.operatorEgressHint': 'Ez a bővítmény olyan szolgáltatással kommunikál, amelyet csak te tudsz megnevezni (saját üzemeltetésű kiszolgáló). Telepítés után add meg a hosztokat a ⋯ → Engedélyezett hosztok alatt. Másokat nem ér el.',
'admin.plugins.noErrors': 'Nincsenek naplózott hibák.',
'admin.plugins.uninstalled': 'Plugin eltávolítva',
'admin.plugins.uninstallTitle': 'Plugin eltávolítása?',
@@ -237,33 +247,54 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:costs': 'Az aktuális felhasználó számára elérhető költségek (költségtételek) olvasása',
'admin.plugins.perm.db:read:packing':
'Az aktuális felhasználó számára elérhető utazások csomagolási listáinak olvasása',
'admin.plugins.perm.db:write:packing': 'Csomagolási lista tételek létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon',
'admin.plugins.perm.weather:read': 'A gazdagép gyorsítótárazott időjárás-előrejelzésének olvasása (koordináták szerint)',
'admin.plugins.perm.db:write:packing':
'Csomagolási lista tételek létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon',
'admin.plugins.perm.weather:read':
'A gazdagép gyorsítótárazott időjárás-előrejelzésének olvasása (koordináták szerint)',
'admin.plugins.perm.db:read:categories': 'A globális helykategória-lista olvasása',
'admin.plugins.perm.db:read:tags': 'Az aktuális felhasználó címkéinek olvasása',
'admin.plugins.perm.db:write:tags': 'Az aktuális felhasználó címkéinek létrehozása, szerkesztése és törlése',
'admin.plugins.perm.db:read:todos': 'Az aktuális felhasználó számára elérhető utazások teendőinek olvasása',
'admin.plugins.perm.db:write:todos': 'Teendők létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon',
'admin.plugins.perm.db:write:atlas': 'Országok és régiók meglátogatottként jelölése és az aktuális felhasználó bakancslistájának kezelése (az Atlas bővítmény szükséges)',
'admin.plugins.perm.db:write:vacay': 'Szabadságnapok és céges ünnepek átkapcsolása az aktuális felhasználó aktív tervén (a Vacay bővítmény szükséges)',
'admin.plugins.perm.db:write:journal': 'Naplóbejegyzések létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető naplókban (a Journey bővítmény szükséges)',
'admin.plugins.perm.db:write:collections': 'Gyűjtemények létrehozása és szerkesztése, helyek mentése beléjük, az aktuális felhasználó gyűjteményszerepével (a Collections bővítmény szükséges)',
'admin.plugins.perm.db:write:files': 'Fájlok csatolása az aktuális felhasználó által szerkeszthető utazásokhoz (10 MB-os korlát, tiltott kiterjesztések elutasítva), és hivatkozásaik kezelése',
'admin.plugins.perm.db:write:collab': 'Jegyzetek, szavazások és csevegőüzenetek közzététele az aktuális felhasználó által szerkeszthető utazásokon (a Collab bővítmény szükséges)',
'admin.plugins.perm.db:write:members': 'Felhasználók hozzáadása utazásokhoz — utazás-hozzáférést ad; az aktuális felhasználó tagkezelési jogával védve',
'admin.plugins.perm.notify:send': 'Értesítés küldése (csengő postaláda + e-mail/ntfy) az aktuális felhasználónak vagy egy utazásának — soha tetszőleges címzettnek',
'admin.plugins.perm.ai:invoke': 'Az adminisztrátor által beállított MI-szolgáltató (szövegkiegészítés + dokumentumkinyerés) használata az aktuális felhasználó nevében — a bővítmény soha nem tárol kulcsot',
'admin.plugins.perm.oauth:client': 'Csatlakozás egy harmadik féltől származó szolgáltatáshoz a nevedben, gazdagép által közvetített OAuth-tal (a tokeneket a gazdagép tárolja, a bővítmény sosem látja)',
'admin.plugins.perm.db:write:todos':
'Teendők létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon',
'admin.plugins.perm.db:write:atlas':
'Országok és régiók meglátogatottként jelölése és az aktuális felhasználó bakancslistájának kezelése (az Atlas bővítmény szükséges)',
'admin.plugins.perm.db:write:vacay':
'Szabadságnapok és céges ünnepek átkapcsolása az aktuális felhasználó aktív tervén (a Vacay bővítmény szükséges)',
'admin.plugins.perm.db:write:journal':
'Naplóbejegyzések létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető naplókban (a Journey bővítmény szükséges)',
'admin.plugins.perm.db:write:collections':
'Gyűjtemények létrehozása és szerkesztése, helyek mentése beléjük, az aktuális felhasználó gyűjteményszerepével (a Collections bővítmény szükséges)',
'admin.plugins.perm.db:write:files':
'Fájlok csatolása az aktuális felhasználó által szerkeszthető utazásokhoz (10 MB-os korlát, tiltott kiterjesztések elutasítva), és hivatkozásaik kezelése',
'admin.plugins.perm.db:write:collab':
'Jegyzetek, szavazások és csevegőüzenetek közzététele az aktuális felhasználó által szerkeszthető utazásokon (a Collab bővítmény szükséges)',
'admin.plugins.perm.db:write:members':
'Felhasználók hozzáadása utazásokhoz — utazás-hozzáférést ad; az aktuális felhasználó tagkezelési jogával védve',
'admin.plugins.perm.notify:send':
'Értesítés küldése (csengő postaláda + e-mail/ntfy) az aktuális felhasználónak vagy egy utazásának — soha tetszőleges címzettnek',
'admin.plugins.perm.ai:invoke':
'Az adminisztrátor által beállított MI-szolgáltató (szövegkiegészítés + dokumentumkinyerés) használata az aktuális felhasználó nevében — a bővítmény soha nem tárol kulcsot',
'admin.plugins.perm.oauth:client':
'Csatlakozás egy harmadik féltől származó szolgáltatáshoz a nevedben, gazdagép által közvetített OAuth-tal (a tokeneket a gazdagép tárolja, a bővítmény sosem látja)',
'admin.plugins.perm.db:read:files': 'Az aktuális felhasználó számára elérhető utazások fájljainak olvasása',
'admin.plugins.perm.db:write:reservations': 'Foglalások létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon',
'admin.plugins.perm.db:write:accommodations': 'Szállások létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon',
'admin.plugins.perm.db:read:journal': 'Az aktuális felhasználó úti naplóinak olvasása (a Journey bővítmény szükséges)',
'admin.plugins.perm.db:read:atlas': 'Az aktuális felhasználó által meglátogatott országok és régiók olvasása (az Atlas bővítmény szükséges)',
'admin.plugins.perm.db:read:vacay': 'Az aktuális felhasználó nyaralási tervének olvasása (a Vacay bővítmény szükséges)',
'admin.plugins.perm.db:write:reservations':
'Foglalások létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon',
'admin.plugins.perm.db:write:accommodations':
'Szállások létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon',
'admin.plugins.perm.db:read:journal':
'Az aktuális felhasználó úti naplóinak olvasása (a Journey bővítmény szükséges)',
'admin.plugins.perm.db:read:atlas':
'Az aktuális felhasználó által meglátogatott országok és régiók olvasása (az Atlas bővítmény szükséges)',
'admin.plugins.perm.db:read:vacay':
'Az aktuális felhasználó nyaralási tervének olvasása (a Vacay bővítmény szükséges)',
'admin.plugins.perm.db:read:daynotes': 'Az aktuális felhasználó számára elérhető utazások napi jegyzeteinek olvasása',
'admin.plugins.perm.db:read:collections': 'Az aktuális felhasználó mentett helyeinek gyűjteményeit olvasása (a Collections bővítmény szükséges)',
'admin.plugins.perm.db:write:daynotes': 'Napi jegyzetek létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon',
'admin.plugins.perm.jobs:run': 'A deklarált háttérfeladatok ütemezett futtatása (felhasználói környezet nélkül — nem olvashat felhasználói adatokat)',
'admin.plugins.perm.db:read:collections':
'Az aktuális felhasználó mentett helyeinek gyűjteményeit olvasása (a Collections bővítmény szükséges)',
'admin.plugins.perm.db:write:daynotes':
'Napi jegyzetek létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon',
'admin.plugins.perm.jobs:run':
'A deklarált háttérfeladatok ütemezett futtatása (felhasználói környezet nélkül — nem olvashat felhasználói adatokat)',
'admin.plugins.perm.db:write:costs':
'Költségek (költségtételek) létrehozása az aktuális felhasználó által szerkeszthető utazásokon',
'admin.plugins.perm.db:write:places':
@@ -284,20 +315,27 @@ const admin: TranslationStrings = {
'További részletek (értékelések, minősítések, hivatkozások) hozzáadása egy helyhez',
'admin.plugins.perm.hook:trip-warning-provider':
'Ellenőrzési figyelmeztetések megjelenítése egy utazáson (a tervezőben látható)',
'admin.plugins.perm.hook:table-contributor': 'Oszlopok és műveletek hozzáadása az utazási nézetekhez (foglalások, helyek, napok)',
'admin.plugins.perm.hook:map-marker-provider': 'Jelölők hozzáadása az utazás térképéhez (pl. foglalások vagy POI-k megjelenítése)',
'admin.plugins.perm.hook:table-contributor':
'Oszlopok és műveletek hozzáadása az utazási nézetekhez (foglalások, helyek, napok)',
'admin.plugins.perm.hook:map-marker-provider':
'Jelölők hozzáadása az utazás térképéhez (pl. foglalások vagy POI-k megjelenítése)',
'admin.plugins.perm.hook:pdf-section-provider': 'Szöveges szakaszok hozzáfűzése az utazás PDF-exportjához',
'admin.plugins.perm.hook:atlas-layer-provider':
'Országok kiemelése az Atlas világtérképén (pl. kívánságlisták vagy utazási figyelmeztetések)',
'admin.plugins.perm.hook:journal-entry-provider':
'További sorok (hivatkozások, statisztikák) hozzáadása a naplóbejegyzésekhez',
'admin.plugins.perm.hook:user-data': 'A felhasználóról tárolt adatok törlése vagy exportálása (GDPR: fióktörlés és adatkérések)',
'admin.plugins.perm.hook:trip-card-provider': 'Kis jelvények (állapot, számlálók) hozzáadása az irányítópult utazáskártyáihoz',
'admin.plugins.perm.hook:user-data':
'A felhasználóról tárolt adatok törlése vagy exportálása (GDPR: fióktörlés és adatkérések)',
'admin.plugins.perm.hook:trip-card-provider':
'Kis jelvények (állapot, számlálók) hozzáadása az irányítópult utazáskártyáihoz',
'admin.plugins.perm.hook:notification-channel': 'Értesítéseid kézbesítése egy további csatornán',
'admin.plugins.perm.events:subscribe':
'Reagál az alapvető tevékenységi eseményekre (csak eseménynév + utazás, soha nem a tartalom)',
'admin.plugins.perm.http:outbound': 'Kimenő kérések küldése a bejelentett kiszolgálók felé',
'admin.plugins.perm.db:read:collab': 'Jegyzetek, szavazások és csevegőüzenetek olvasása az aktuális felhasználó számára elérhető utazásokon (a Collab bővítmény szükséges)',
'admin.plugins.perm.db:read:files:content': 'Az aktuális felhasználó számára elérhető utazások fájltartalmának (bájtok) olvasása',
'admin.plugins.perm.db:read:collab':
'Jegyzetek, szavazások és csevegőüzenetek olvasása az aktuális felhasználó számára elérhető utazásokon (a Collab bővítmény szükséges)',
'admin.plugins.perm.db:read:files:content':
'Az aktuális felhasználó számára elérhető utazások fájltartalmának (bájtok) olvasása',
'admin.plugins.perm.db:create:trips': 'Új utazások létrehozása az aktuális felhasználó tulajdonában',
'admin.plugins.perm.rates:read': 'A gazdagép gyorsítótárazott devizaárfolyamainak olvasása',
'admin.plugins.updateConsentTitle': 'Ez a frissítés új engedélyeket igényel',
@@ -367,6 +405,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Foglalás részletei',
'admin.plugins.cap.replacesTabs': 'Tervező lapokat vált ki',
'admin.plugins.cap.realtime': 'Valós idejű frissítések',
'admin.plugins.cap.notificationChannel': 'Értesítési csatorna',
'admin.plugins.cap.photos': 'Fotókat biztosít',
'admin.plugins.cap.calendar': 'Naptáreseményeket biztosít',
'admin.plugins.cap.placeDetails': 'Helyeket gazdagít',
+10 -1
View File
@@ -290,6 +290,14 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.testFailed': 'Teszt Ntfy értesítés sikertelen',
'settings.ntfyUrl.tokenCleared': 'Hozzáférési token törölve',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Még nincs beállítva — állítsd be a bővítmény beállításaiban',
'settings.plugins.actions': 'Műveletek',
'settings.plugins.actions.confirm': 'Végrehajtod ezt a műveletet?',
'settings.notificationPreferences.sendTest': 'Teszt küldése',
'settings.notificationPreferences.configure': 'Beállítás',
'settings.notificationPreferences.testSuccess': 'Teszt értesítés elküldve.',
'settings.notificationPreferences.testFailed': 'A teszt sikertelen.',
'settings.notificationPreferences.pluginConfigured': 'Beállítva. A hitelesítő adatok a bővítmény beállításainál kezelhetők.',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
@@ -477,7 +485,8 @@ const settings: TranslationStrings = {
'settings.offline.banner.failed': 'Sikertelen szinkronizálás: {count}',
'settings.offline.banner.conflicts': 'Ütközések: {count}',
'settings.pluginActivity.title': 'Bővítmény-tevékenység',
'settings.pluginActivity.description': 'Minden művelet, amelyet egy bővítmény a nevedben végzett, a legújabbtól kezdve.',
'settings.pluginActivity.description':
'Minden művelet, amelyet egy bővítmény a nevedben végzett, a legújabbtól kezdve.',
'settings.pluginActivity.empty': 'Még nincs bővítmény-tevékenység.',
'settings.pluginActivity.refresh': 'Frissítés',
'settings.pluginActivity.columns.plugin': 'Bővítmény',
+65 -27
View File
@@ -211,7 +211,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'Plugin “{name}” diunggah — aktifkan untuk menjalankan',
'admin.plugins.sideloaded': 'Dimuat manual',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Dimuat dari direktori build lokal dan dimuat ulang langsung dengan data nyata — hanya untuk pengembangan',
'admin.plugins.devLinkHint':
'Dimuat dari direktori build lokal dan dimuat ulang langsung dengan data nyata — hanya untuk pengembangan',
'admin.plugins.devLinkTitle': 'Tautkan plugin lokal',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Tautkan',
@@ -228,6 +229,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Plugin dinonaktifkan',
'admin.plugins.viewErrors': 'Lihat log error',
'admin.plugins.errorLog': 'Log error',
'admin.plugins.allowedHosts': 'Host yang diizinkan',
'admin.plugins.allowedHosts.hint': 'Plugin ini berbicara dengan layanan yang hanya Anda yang tahu namanya (server swakelola). Tambahkan host yang boleh dijangkau — selain itu tidak bisa.',
'admin.plugins.allowedHosts.none': 'Belum ada host yang ditambahkan.',
'admin.plugins.allowedHosts.unsupported': 'Plugin ini tidak memakai host dari operator. Host yang diizinkan sudah tetap di manifesnya.',
'admin.plugins.allowedHosts.restartNote': 'Menyimpan akan memulai ulang plugin agar memakai daftar baru.',
'admin.plugins.allowedHosts.add': 'Tambah host yang diizinkan',
'admin.plugins.allowedHosts.count': '{n} host diizinkan',
'admin.plugins.operatorEgressPill': '+ host yang Anda tambahkan',
'admin.plugins.operatorEgressHint': 'Plugin ini berbicara dengan layanan yang hanya Anda yang tahu namanya (server swakelola). Setelah dipasang, tambahkan host di ⋯ → Host yang diizinkan. Selain itu tidak bisa.',
'admin.plugins.noErrors': 'Tidak ada error yang tercatat.',
'admin.plugins.uninstalled': 'Plugin dihapus',
'admin.plugins.uninstallTitle': 'Hapus plugin?',
@@ -280,34 +290,55 @@ const admin: TranslationStrings = {
'Membaca pengeluaran (item anggaran) yang dapat diakses oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:read:packing':
'Membaca daftar barang bawaan dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:write:packing': 'Membuat, mengedit, dan menghapus item daftar bawaan pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:write:packing':
'Membuat, mengedit, dan menghapus item daftar bawaan pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan',
'admin.plugins.perm.weather:read': 'Membaca prakiraan cuaca host yang di-cache (berdasarkan koordinat)',
'admin.plugins.perm.db:read:categories': 'Membaca daftar kategori tempat global',
'admin.plugins.perm.db:read:tags': 'Membaca tag pengguna yang bersangkutan',
'admin.plugins.perm.db:write:tags': 'Membuat, mengedit, dan menghapus tag pengguna yang bersangkutan',
'admin.plugins.perm.db:read:todos': 'Membaca tugas dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:write:todos': 'Membuat, mengedit, dan menghapus tugas pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:write:atlas': 'Menandai negara dan wilayah sebagai sudah dikunjungi dan mengelola bucket list pengguna yang bersangkutan (memerlukan add-on Atlas)',
'admin.plugins.perm.db:write:vacay': 'Mengalihkan hari cuti dan libur perusahaan pada rencana aktif pengguna yang bersangkutan (memerlukan add-on Vacay)',
'admin.plugins.perm.db:write:journal': 'Membuat, mengedit, dan menghapus entri jurnal di jurnal yang dapat diedit oleh pengguna yang bersangkutan (memerlukan add-on Journey)',
'admin.plugins.perm.db:write:collections': 'Membuat dan mengedit koleksi serta menyimpan tempat ke dalamnya, dengan peran koleksi pengguna yang bersangkutan (memerlukan add-on Collections)',
'admin.plugins.perm.db:write:files': 'Melampirkan berkas ke perjalanan yang dapat diedit oleh pengguna yang bersangkutan (batas 10 MB, ekstensi terblokir ditolak) dan mengelola tautannya',
'admin.plugins.perm.db:write:collab': 'Memublikasikan catatan, jajak pendapat, dan pesan obrolan pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan (memerlukan add-on Collab)',
'admin.plugins.perm.db:write:members': 'Menambahkan pengguna ke perjalanan — memberikan akses perjalanan; dilindungi oleh hak kelola anggota pengguna yang bersangkutan',
'admin.plugins.perm.notify:send': 'Mengirim notifikasi (kotak lonceng + email/ntfy) kepada pengguna yang bersangkutan atau perjalanan yang diikutinya — tidak pernah ke penerima sembarangan',
'admin.plugins.perm.ai:invoke': 'Menjalankan penyedia AI yang dikonfigurasi admin (penyelesaian teks + ekstraksi dokumen) atas nama pengguna yang bersangkutan — plugin tidak pernah menyimpan kunci',
'admin.plugins.perm.oauth:client': 'Terhubung ke layanan pihak ketiga atas namamu melalui OAuth yang diperantarai host (host menyimpan token, plugin tidak pernah melihatnya)',
'admin.plugins.perm.db:read:todos':
'Membaca tugas dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:write:todos':
'Membuat, mengedit, dan menghapus tugas pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:write:atlas':
'Menandai negara dan wilayah sebagai sudah dikunjungi dan mengelola bucket list pengguna yang bersangkutan (memerlukan add-on Atlas)',
'admin.plugins.perm.db:write:vacay':
'Mengalihkan hari cuti dan libur perusahaan pada rencana aktif pengguna yang bersangkutan (memerlukan add-on Vacay)',
'admin.plugins.perm.db:write:journal':
'Membuat, mengedit, dan menghapus entri jurnal di jurnal yang dapat diedit oleh pengguna yang bersangkutan (memerlukan add-on Journey)',
'admin.plugins.perm.db:write:collections':
'Membuat dan mengedit koleksi serta menyimpan tempat ke dalamnya, dengan peran koleksi pengguna yang bersangkutan (memerlukan add-on Collections)',
'admin.plugins.perm.db:write:files':
'Melampirkan berkas ke perjalanan yang dapat diedit oleh pengguna yang bersangkutan (batas 10 MB, ekstensi terblokir ditolak) dan mengelola tautannya',
'admin.plugins.perm.db:write:collab':
'Memublikasikan catatan, jajak pendapat, dan pesan obrolan pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan (memerlukan add-on Collab)',
'admin.plugins.perm.db:write:members':
'Menambahkan pengguna ke perjalanan — memberikan akses perjalanan; dilindungi oleh hak kelola anggota pengguna yang bersangkutan',
'admin.plugins.perm.notify:send':
'Mengirim notifikasi (kotak lonceng + email/ntfy) kepada pengguna yang bersangkutan atau perjalanan yang diikutinya — tidak pernah ke penerima sembarangan',
'admin.plugins.perm.ai:invoke':
'Menjalankan penyedia AI yang dikonfigurasi admin (penyelesaian teks + ekstraksi dokumen) atas nama pengguna yang bersangkutan — plugin tidak pernah menyimpan kunci',
'admin.plugins.perm.oauth:client':
'Terhubung ke layanan pihak ketiga atas namamu melalui OAuth yang diperantarai host (host menyimpan token, plugin tidak pernah melihatnya)',
'admin.plugins.perm.db:read:files':
'Membaca berkas dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:write:reservations': 'Membuat, mengedit, dan menghapus pemesanan pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:write:accommodations': 'Membuat, mengedit, dan menghapus akomodasi pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:read:journal': 'Membaca jurnal perjalanan pengguna yang bersangkutan (memerlukan add-on Journey)',
'admin.plugins.perm.db:read:atlas': 'Membaca negara dan wilayah yang dikunjungi oleh pengguna yang bersangkutan (memerlukan add-on Atlas)',
'admin.plugins.perm.db:write:reservations':
'Membuat, mengedit, dan menghapus pemesanan pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:write:accommodations':
'Membuat, mengedit, dan menghapus akomodasi pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:read:journal':
'Membaca jurnal perjalanan pengguna yang bersangkutan (memerlukan add-on Journey)',
'admin.plugins.perm.db:read:atlas':
'Membaca negara dan wilayah yang dikunjungi oleh pengguna yang bersangkutan (memerlukan add-on Atlas)',
'admin.plugins.perm.db:read:vacay': 'Membaca rencana liburan pengguna yang bersangkutan (memerlukan add-on Vacay)',
'admin.plugins.perm.db:read:daynotes': 'Membaca catatan harian dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:read:collections': 'Membaca koleksi tempat tersimpan milik pengguna yang bersangkutan (memerlukan add-on Collections)',
'admin.plugins.perm.db:write:daynotes': 'Membuat, mengedit, dan menghapus catatan harian pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan',
'admin.plugins.perm.jobs:run': 'Menjalankan tugas latar belakang yang dideklarasikan sesuai jadwal (tanpa konteks pengguna — tidak dapat membaca data pengguna)',
'admin.plugins.perm.db:read:daynotes':
'Membaca catatan harian dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:read:collections':
'Membaca koleksi tempat tersimpan milik pengguna yang bersangkutan (memerlukan add-on Collections)',
'admin.plugins.perm.db:write:daynotes':
'Membuat, mengedit, dan menghapus catatan harian pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan',
'admin.plugins.perm.jobs:run':
'Menjalankan tugas latar belakang yang dideklarasikan sesuai jadwal (tanpa konteks pengguna — tidak dapat membaca data pengguna)',
'admin.plugins.perm.db:write:costs':
'Membuat pengeluaran (item anggaran) pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:write:places':
@@ -328,19 +359,25 @@ const admin: TranslationStrings = {
'Menyumbangkan detail tambahan (ulasan, peringkat, tautan) untuk suatu tempat',
'admin.plugins.perm.hook:trip-warning-provider':
'Munculkan peringatan validasi pada perjalanan (ditampilkan di perencana)',
'admin.plugins.perm.hook:table-contributor': 'Menambahkan kolom dan tindakan ke tampilan perjalanan (pemesanan, tempat, hari)',
'admin.plugins.perm.hook:map-marker-provider': 'Menambahkan penanda ke peta perjalanan (mis. menampilkan pemesanan atau POI)',
'admin.plugins.perm.hook:table-contributor':
'Menambahkan kolom dan tindakan ke tampilan perjalanan (pemesanan, tempat, hari)',
'admin.plugins.perm.hook:map-marker-provider':
'Menambahkan penanda ke peta perjalanan (mis. menampilkan pemesanan atau POI)',
'admin.plugins.perm.hook:pdf-section-provider': 'Menambahkan bagian teks ke ekspor PDF perjalanan',
'admin.plugins.perm.hook:atlas-layer-provider':
'Menyorot negara di peta dunia Atlas (mis. daftar keinginan atau peringatan perjalanan)',
'admin.plugins.perm.hook:journal-entry-provider': 'Menambahkan baris tambahan (tautan, statistik) ke entri jurnal',
'admin.plugins.perm.hook:user-data': 'Menghapus atau mengekspor data yang disimpannya tentang pengguna (GDPR: penghapusan akun & permintaan data)',
'admin.plugins.perm.hook:user-data':
'Menghapus atau mengekspor data yang disimpannya tentang pengguna (GDPR: penghapusan akun & permintaan data)',
'admin.plugins.perm.hook:trip-card-provider': 'Menambahkan lencana kecil (status, jumlah) ke kartu perjalanan dasbor',
'admin.plugins.perm.hook:notification-channel': 'Mengirim notifikasi Anda melalui saluran tambahan',
'admin.plugins.perm.events:subscribe':
'Bereaksi terhadap peristiwa aktivitas inti (hanya nama peristiwa + perjalanan, tidak pernah kontennya)',
'admin.plugins.perm.http:outbound': 'Membuat permintaan keluar ke host yang telah dideklarasikannya',
'admin.plugins.perm.db:read:collab': 'Membaca catatan, jajak pendapat, dan pesan obrolan dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan (memerlukan add-on Collab)',
'admin.plugins.perm.db:read:files:content': 'Membaca isi (byte) berkas dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:read:collab':
'Membaca catatan, jajak pendapat, dan pesan obrolan dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan (memerlukan add-on Collab)',
'admin.plugins.perm.db:read:files:content':
'Membaca isi (byte) berkas dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan',
'admin.plugins.perm.db:create:trips': 'Membuat perjalanan baru milik pengguna yang bersangkutan',
'admin.plugins.perm.rates:read': 'Membaca kurs mata uang host yang di-cache',
'admin.plugins.updateConsentTitle': 'Pembaruan ini memerlukan izin baru',
@@ -410,6 +447,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Detail reservasi',
'admin.plugins.cap.replacesTabs': 'Menggantikan tab perencana',
'admin.plugins.cap.realtime': 'Pembaruan real-time',
'admin.plugins.cap.notificationChannel': 'Saluran notifikasi',
'admin.plugins.cap.photos': 'Menyediakan foto',
'admin.plugins.cap.calendar': 'Menyediakan acara kalender',
'admin.plugins.cap.placeDetails': 'Memperkaya tempat',
+8
View File
@@ -88,6 +88,14 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Belum dikonfigurasi — atur di pengaturan plugin',
'settings.plugins.actions': 'Tindakan',
'settings.plugins.actions.confirm': 'Jalankan tindakan ini?',
'settings.notificationPreferences.sendTest': 'Kirim tes',
'settings.notificationPreferences.configure': 'Konfigurasi',
'settings.notificationPreferences.testSuccess': 'Notifikasi tes terkirim.',
'settings.notificationPreferences.testFailed': 'Tes gagal.',
'settings.notificationPreferences.pluginConfigured': 'Sudah diatur. Kelola kredensial di halaman pengaturan plugin.',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.noChannels':
'Belum ada saluran notifikasi yang dikonfigurasi. Minta admin untuk mengatur notifikasi email atau webhook.',
+65 -27
View File
@@ -168,7 +168,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'Plugin “{name}” caricato — attivalo per eseguirlo',
'admin.plugins.sideloaded': 'Caricato manualmente',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Caricato da una cartella di build locale e ricaricato a caldo con dati reali — solo per lo sviluppo',
'admin.plugins.devLinkHint':
'Caricato da una cartella di build locale e ricaricato a caldo con dati reali — solo per lo sviluppo',
'admin.plugins.devLinkTitle': 'Collega un plugin locale',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Collega',
@@ -185,6 +186,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Plugin disattivato',
'admin.plugins.viewErrors': 'Visualizza log errori',
'admin.plugins.errorLog': 'Log errori',
'admin.plugins.allowedHosts': 'Host consentiti',
'admin.plugins.allowedHosts.hint': 'Questo plugin comunica con un servizio che solo tu puoi indicare (un server self-hosted). Aggiungi gli host che può raggiungere — non ne raggiungerà altri.',
'admin.plugins.allowedHosts.none': 'Nessun host aggiunto.',
'admin.plugins.allowedHosts.unsupported': 'Questo plugin non usa host forniti dall’operatore. I suoi host consentiti sono fissati nel manifest.',
'admin.plugins.allowedHosts.restartNote': 'Il salvataggio riavvia il plugin perché prenda la nuova lista.',
'admin.plugins.allowedHosts.add': 'Aggiungi host consentito',
'admin.plugins.allowedHosts.count': '{n} host consentiti',
'admin.plugins.operatorEgressPill': '+ host aggiunti da te',
'admin.plugins.operatorEgressHint': 'Questo plugin comunica con un servizio che solo tu puoi indicare (un server self-hosted). Dopo l’installazione, aggiungi gli host che può raggiungere in ⋯ → Host consentiti. Non ne raggiungerà altri.',
'admin.plugins.noErrors': 'Nessun errore registrato.',
'admin.plugins.uninstalled': 'Plugin disinstallato',
'admin.plugins.uninstallTitle': 'Disinstallare il plugin?',
@@ -235,33 +245,53 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:users': 'Leggere le informazioni di base del profilo (nome, avatar — mai le credenziali)',
'admin.plugins.perm.db:read:costs': "Leggere le spese (voci di budget) a cui l'utente attivo ha accesso",
'admin.plugins.perm.db:read:packing': "Leggere le liste dei bagagli dei viaggi a cui l'utente attivo ha accesso",
'admin.plugins.perm.db:write:packing': "Creare, modificare ed eliminare voci della lista bagagli sui viaggi che l'utente attivo può modificare",
'admin.plugins.perm.db:write:packing':
"Creare, modificare ed eliminare voci della lista bagagli sui viaggi che l'utente attivo può modificare",
'admin.plugins.perm.weather:read': "Leggere le previsioni meteo in cache dell'host (per coordinate)",
'admin.plugins.perm.db:read:categories': "Leggere l'elenco globale delle categorie di luoghi",
'admin.plugins.perm.db:read:tags': "Leggere i tag dell'utente attivo",
'admin.plugins.perm.db:write:tags': "Creare, modificare ed eliminare i tag dell'utente attivo",
'admin.plugins.perm.db:read:todos': "Leggere le attività dei viaggi a cui l'utente attivo ha accesso",
'admin.plugins.perm.db:write:todos': "Creare, modificare ed eliminare attività sui viaggi che l'utente attivo può modificare",
'admin.plugins.perm.db:write:atlas': "Contrassegnare paesi e regioni come visitati e gestire la bucket list dell'utente attivo (richiede il componente aggiuntivo Atlas)",
'admin.plugins.perm.db:write:vacay': "Attivare/disattivare giorni di ferie e chiusure aziendali sul piano attivo dell'utente attivo (richiede il componente aggiuntivo Vacay)",
'admin.plugins.perm.db:write:journal': "Creare, modificare ed eliminare voci di diario nei diari che l'utente attivo può modificare (richiede il componente aggiuntivo Journey)",
'admin.plugins.perm.db:write:collections': "Creare e modificare raccolte e salvarvi luoghi, con il ruolo di raccolta dell'utente attivo (richiede il componente aggiuntivo Collections)",
'admin.plugins.perm.db:write:files': "Allegare file ai viaggi che l'utente attivo può modificare (limite 10 MB, estensioni bloccate rifiutate) e gestirne i collegamenti",
'admin.plugins.perm.db:write:collab': "Pubblicare note, sondaggi e messaggi di chat sui viaggi che l'utente attivo può modificare (richiede il componente aggiuntivo Collab)",
'admin.plugins.perm.db:write:members': "Aggiungere utenti ai viaggi — concede l'accesso al viaggio; protetto dal diritto di gestione membri dell'utente attivo",
'admin.plugins.perm.notify:send': "Inviare una notifica (casella campanella + email/ntfy) all'utente attivo o a un viaggio a cui appartiene — mai a un destinatario arbitrario",
'admin.plugins.perm.ai:invoke': "Usare il provider IA configurato dall'amministratore (completamento testo + estrazione documenti) per conto dell'utente attivo — il componente non detiene mai una chiave",
'admin.plugins.perm.oauth:client': "Connettersi a un servizio di terze parti per tuo conto tramite OAuth gestito dall'host (l'host detiene i token, il plugin non li vede mai)",
'admin.plugins.perm.db:write:todos':
"Creare, modificare ed eliminare attività sui viaggi che l'utente attivo può modificare",
'admin.plugins.perm.db:write:atlas':
"Contrassegnare paesi e regioni come visitati e gestire la bucket list dell'utente attivo (richiede il componente aggiuntivo Atlas)",
'admin.plugins.perm.db:write:vacay':
"Attivare/disattivare giorni di ferie e chiusure aziendali sul piano attivo dell'utente attivo (richiede il componente aggiuntivo Vacay)",
'admin.plugins.perm.db:write:journal':
"Creare, modificare ed eliminare voci di diario nei diari che l'utente attivo può modificare (richiede il componente aggiuntivo Journey)",
'admin.plugins.perm.db:write:collections':
"Creare e modificare raccolte e salvarvi luoghi, con il ruolo di raccolta dell'utente attivo (richiede il componente aggiuntivo Collections)",
'admin.plugins.perm.db:write:files':
"Allegare file ai viaggi che l'utente attivo può modificare (limite 10 MB, estensioni bloccate rifiutate) e gestirne i collegamenti",
'admin.plugins.perm.db:write:collab':
"Pubblicare note, sondaggi e messaggi di chat sui viaggi che l'utente attivo può modificare (richiede il componente aggiuntivo Collab)",
'admin.plugins.perm.db:write:members':
"Aggiungere utenti ai viaggi — concede l'accesso al viaggio; protetto dal diritto di gestione membri dell'utente attivo",
'admin.plugins.perm.notify:send':
"Inviare una notifica (casella campanella + email/ntfy) all'utente attivo o a un viaggio a cui appartiene — mai a un destinatario arbitrario",
'admin.plugins.perm.ai:invoke':
"Usare il provider IA configurato dall'amministratore (completamento testo + estrazione documenti) per conto dell'utente attivo — il componente non detiene mai una chiave",
'admin.plugins.perm.oauth:client':
"Connettersi a un servizio di terze parti per tuo conto tramite OAuth gestito dall'host (l'host detiene i token, il plugin non li vede mai)",
'admin.plugins.perm.db:read:files': "Leggere i file dei viaggi a cui l'utente attivo ha accesso",
'admin.plugins.perm.db:write:reservations': "Creare, modificare ed eliminare prenotazioni sui viaggi che l'utente attivo può modificare",
'admin.plugins.perm.db:write:accommodations': "Creare, modificare ed eliminare alloggi sui viaggi che l'utente attivo può modificare",
'admin.plugins.perm.db:read:journal': "Leggere i diari di viaggio dell'utente attivo (richiede il componente aggiuntivo Journey)",
'admin.plugins.perm.db:read:atlas': "Leggere i paesi e le regioni visitati dall'utente attivo (richiede il componente aggiuntivo Atlas)",
'admin.plugins.perm.db:read:vacay': "Leggere il piano vacanze dell'utente attivo (richiede il componente aggiuntivo Vacay)",
'admin.plugins.perm.db:write:reservations':
"Creare, modificare ed eliminare prenotazioni sui viaggi che l'utente attivo può modificare",
'admin.plugins.perm.db:write:accommodations':
"Creare, modificare ed eliminare alloggi sui viaggi che l'utente attivo può modificare",
'admin.plugins.perm.db:read:journal':
"Leggere i diari di viaggio dell'utente attivo (richiede il componente aggiuntivo Journey)",
'admin.plugins.perm.db:read:atlas':
"Leggere i paesi e le regioni visitati dall'utente attivo (richiede il componente aggiuntivo Atlas)",
'admin.plugins.perm.db:read:vacay':
"Leggere il piano vacanze dell'utente attivo (richiede il componente aggiuntivo Vacay)",
'admin.plugins.perm.db:read:daynotes': "Leggere le note del giorno dei viaggi a cui l'utente attivo ha accesso",
'admin.plugins.perm.db:read:collections': "Leggere le raccolte di luoghi salvati dell'utente attivo (richiede il componente aggiuntivo Collections)",
'admin.plugins.perm.db:write:daynotes': "Creare, modificare ed eliminare note del giorno sui viaggi che l'utente attivo può modificare",
'admin.plugins.perm.jobs:run': 'Eseguire i job in background dichiarati secondo una pianificazione (senza contesto utente — non può leggere i dati utente)',
'admin.plugins.perm.db:read:collections':
"Leggere le raccolte di luoghi salvati dell'utente attivo (richiede il componente aggiuntivo Collections)",
'admin.plugins.perm.db:write:daynotes':
"Creare, modificare ed eliminare note del giorno sui viaggi che l'utente attivo può modificare",
'admin.plugins.perm.jobs:run':
'Eseguire i job in background dichiarati secondo una pianificazione (senza contesto utente — non può leggere i dati utente)',
'admin.plugins.perm.db:write:costs': "Creare spese (voci di budget) sui viaggi che l'utente attivo può modificare",
'admin.plugins.perm.db:write:places':
"Aggiungere, modificare e rimuovere luoghi sui viaggi che l'utente attivo può modificare",
@@ -281,19 +311,26 @@ const admin: TranslationStrings = {
'Fornire dettagli aggiuntivi (recensioni, valutazioni, link) a un luogo',
'admin.plugins.perm.hook:trip-warning-provider':
'Segnala avvisi di validazione su un viaggio (mostrati nel pianificatore)',
'admin.plugins.perm.hook:table-contributor': 'Aggiungere colonne e azioni alle viste del viaggio (prenotazioni, luoghi, giorni)',
'admin.plugins.perm.hook:map-marker-provider': 'Aggiungere indicatori alla mappa del viaggio (es. mostrare prenotazioni o POI)',
'admin.plugins.perm.hook:table-contributor':
'Aggiungere colonne e azioni alle viste del viaggio (prenotazioni, luoghi, giorni)',
'admin.plugins.perm.hook:map-marker-provider':
'Aggiungere indicatori alla mappa del viaggio (es. mostrare prenotazioni o POI)',
'admin.plugins.perm.hook:pdf-section-provider': "Aggiungere sezioni di testo all'esportazione PDF del viaggio",
'admin.plugins.perm.hook:atlas-layer-provider':
'Evidenziare paesi sulla mappa del mondo di Atlas (es. liste dei desideri o avvisi di viaggio)',
'admin.plugins.perm.hook:journal-entry-provider': 'Aggiungere righe extra (link, statistiche) alle voci del diario',
'admin.plugins.perm.hook:user-data': 'Cancellare o esportare i dati che memorizza su un utente (GDPR: eliminazione account e richieste dati)',
'admin.plugins.perm.hook:trip-card-provider': 'Aggiungere piccoli badge (stato, conteggi) alle schede viaggio della dashboard',
'admin.plugins.perm.hook:user-data':
'Cancellare o esportare i dati che memorizza su un utente (GDPR: eliminazione account e richieste dati)',
'admin.plugins.perm.hook:trip-card-provider':
'Aggiungere piccoli badge (stato, conteggi) alle schede viaggio della dashboard',
'admin.plugins.perm.hook:notification-channel': 'Inviare le tue notifiche tramite un canale aggiuntivo',
'admin.plugins.perm.events:subscribe':
"Reagisce agli eventi principali dell'attività (solo nome evento e viaggio, mai il contenuto)",
'admin.plugins.perm.http:outbound': 'Effettuare richieste in uscita verso gli host dichiarati',
'admin.plugins.perm.db:read:collab': "Leggere note, sondaggi e messaggi di chat dei viaggi a cui l'utente attivo ha accesso (richiede il componente aggiuntivo Collab)",
'admin.plugins.perm.db:read:files:content': "Leggere il contenuto (byte) dei file dei viaggi a cui l'utente attivo ha accesso",
'admin.plugins.perm.db:read:collab':
"Leggere note, sondaggi e messaggi di chat dei viaggi a cui l'utente attivo ha accesso (richiede il componente aggiuntivo Collab)",
'admin.plugins.perm.db:read:files:content':
"Leggere il contenuto (byte) dei file dei viaggi a cui l'utente attivo ha accesso",
'admin.plugins.perm.db:create:trips': "Creare nuovi viaggi di proprietà dell'utente attivo",
'admin.plugins.perm.rates:read': "Leggere i tassi di cambio in cache dell'host",
'admin.plugins.updateConsentTitle': 'Questo aggiornamento richiede nuovi permessi',
@@ -363,6 +400,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Dettaglio prenotazione',
'admin.plugins.cap.replacesTabs': 'Sostituisce schede del planner',
'admin.plugins.cap.realtime': 'Aggiornamenti in tempo reale',
'admin.plugins.cap.notificationChannel': 'Canale di notifica',
'admin.plugins.cap.photos': 'Fornisce foto',
'admin.plugins.cap.calendar': 'Fornisce eventi calendario',
'admin.plugins.cap.placeDetails': 'Arricchisce luoghi',
+8
View File
@@ -289,6 +289,14 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.testFailed': 'Notifica di test Ntfy fallita',
'settings.ntfyUrl.tokenCleared': 'Token di accesso rimosso',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Non ancora configurato — impostalo nelle impostazioni del plugin',
'settings.plugins.actions': 'Azioni',
'settings.plugins.actions.confirm': 'Eseguire questa azione?',
'settings.notificationPreferences.sendTest': 'Invia test',
'settings.notificationPreferences.configure': 'Configura',
'settings.notificationPreferences.testSuccess': 'Notifica di prova inviata.',
'settings.notificationPreferences.testFailed': 'Test non riuscito.',
'settings.notificationPreferences.pluginConfigured': 'Configurato. Gestisci le credenziali nelle impostazioni del plugin.',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
+41 -15
View File
@@ -200,7 +200,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'プラグイン「{name}」をアップロードしました — 実行するには有効化してください',
'admin.plugins.sideloaded': '手動読み込み',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'ローカルのビルドディレクトリから読み込まれ、実データでホットリロードされます(開発専用)',
'admin.plugins.devLinkHint':
'ローカルのビルドディレクトリから読み込まれ、実データでホットリロードされます(開発専用)',
'admin.plugins.devLinkTitle': 'ローカルプラグインをリンク',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'リンク',
@@ -217,6 +218,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'プラグインを無効化しました',
'admin.plugins.viewErrors': 'エラーログを表示',
'admin.plugins.errorLog': 'エラーログ',
'admin.plugins.allowedHosts': '許可するホスト',
'admin.plugins.allowedHosts.hint': 'このプラグインは、あなたしか指定できないサービス(セルフホストのサーバー)と通信します。到達を許可するホストを追加してください。それ以外には到達できません。',
'admin.plugins.allowedHosts.none': 'ホストはまだ追加されていません。',
'admin.plugins.allowedHosts.unsupported': 'このプラグインは運用者指定のホストを使いません。許可ホストはマニフェストで固定されています。',
'admin.plugins.allowedHosts.restartNote': '保存するとプラグインが再起動し、新しい一覧が反映されます。',
'admin.plugins.allowedHosts.add': '許可ホストを追加',
'admin.plugins.allowedHosts.count': '許可ホスト {n} 件',
'admin.plugins.operatorEgressPill': '+ 追加するホスト',
'admin.plugins.operatorEgressHint': 'このプラグインは、あなたしか指定できないサービス(セルフホストのサーバー)と通信します。インストール後、⋯ → 許可するホスト から追加してください。それ以外には到達できません。',
'admin.plugins.noErrors': '記録されたエラーはありません。',
'admin.plugins.uninstalled': 'プラグインをアンインストールしました',
'admin.plugins.uninstallTitle': 'プラグインをアンインストールしますか?',
@@ -274,16 +284,26 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:write:tags': '操作中のユーザーのタグの作成・編集・削除',
'admin.plugins.perm.db:read:todos': '操作中のユーザーがアクセスできる旅行のToDoを読み取る',
'admin.plugins.perm.db:write:todos': '操作中のユーザーが編集できる旅行のToDoの作成・編集・削除',
'admin.plugins.perm.db:write:atlas': '操作中のユーザーの訪問済みの国と地域をマークし、バケットリストを管理(Atlas アドオンが必要)',
'admin.plugins.perm.db:write:vacay': '操作中のユーザーのアクティブなプランで休暇日と会社休日を切り替え(Vacay アドオンが必要)',
'admin.plugins.perm.db:write:journal': '操作中のユーザーが編集できる日記のエントリの作成・編集・削除(Journey アドオンが必要)',
'admin.plugins.perm.db:write:collections': 'コレクションの作成・編集と場所の保存(操作中のユーザーのコレクションロールで実行、Collections アドオンが必要)',
'admin.plugins.perm.db:write:files': '操作中のユーザーが編集できる旅行にファイルを添付(10MB上限、ブロック対象の拡張子は拒否)し、そのリンクを管理',
'admin.plugins.perm.db:write:collab': '操作中のユーザーが編集できる旅行にメモ・アンケート・チャットメッセージを投稿(Collab アドオンが必要)',
'admin.plugins.perm.db:write:members': '旅行にユーザーを追加 — 旅行へのアクセスを付与。操作中のユーザーのメンバー管理権限で保護',
'admin.plugins.perm.notify:send': '操作中のユーザー、またはそのユーザーが所属する旅行に通知(ベル受信箱+メール/ntfy)を送信 — 任意の受信者には送らない',
'admin.plugins.perm.ai:invoke': '管理者が構成した AI プロバイダー(テキスト補完+ドキュメント抽出)を操作中のユーザーの代わりに実行 — プラグインはキーを保持しない',
'admin.plugins.perm.oauth:client': 'ホスト仲介の OAuth であなたに代わってサードパーティサービスに接続(トークンはホストが保持し、プラグインは決して見ない)',
'admin.plugins.perm.db:write:atlas':
'操作中のユーザーの訪問済みの国と地域をマークし、バケットリストを管理(Atlas アドオンが必要)',
'admin.plugins.perm.db:write:vacay':
'操作中のユーザーのアクティブなプランで休暇日と会社休日を切り替え(Vacay アドオンが必要)',
'admin.plugins.perm.db:write:journal':
'操作中のユーザーが編集できる日記のエントリの作成・編集・削除(Journey アドオンが必要)',
'admin.plugins.perm.db:write:collections':
'コレクションの作成・編集と場所の保存(操作中のユーザーのコレクションロールで実行、Collections アドオンが必要)',
'admin.plugins.perm.db:write:files':
'操作中のユーザーが編集できる旅行にファイルを添付(10MB上限、ブロック対象の拡張子は拒否)し、そのリンクを管理',
'admin.plugins.perm.db:write:collab':
'操作中のユーザーが編集できる旅行にメモ・アンケート・チャットメッセージを投稿(Collab アドオンが必要)',
'admin.plugins.perm.db:write:members':
'旅行にユーザーを追加 — 旅行へのアクセスを付与。操作中のユーザーのメンバー管理権限で保護',
'admin.plugins.perm.notify:send':
'操作中のユーザー、またはそのユーザーが所属する旅行に通知(ベル受信箱+メール/ntfy)を送信 — 任意の受信者には送らない',
'admin.plugins.perm.ai:invoke':
'管理者が構成した AI プロバイダー(テキスト補完+ドキュメント抽出)を操作中のユーザーの代わりに実行 — プラグインはキーを保持しない',
'admin.plugins.perm.oauth:client':
'ホスト仲介の OAuth であなたに代わってサードパーティサービスに接続(トークンはホストが保持し、プラグインは決して見ない)',
'admin.plugins.perm.db:read:files': '操作中のユーザーがアクセスできる旅行のファイルの読み取り',
'admin.plugins.perm.db:write:reservations': '操作中のユーザーが編集できる旅行の予約の作成・編集・削除',
'admin.plugins.perm.db:write:accommodations': '操作中のユーザーが編集できる旅行の宿泊の作成・編集・削除',
@@ -291,9 +311,11 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:atlas': '操作中のユーザーが訪問した国と地域の読み取り(Atlas アドオンが必要)',
'admin.plugins.perm.db:read:vacay': '操作中のユーザーの休暇プランの読み取り(Vacay アドオンが必要)',
'admin.plugins.perm.db:read:daynotes': '操作中のユーザーがアクセスできる旅行の日別メモの読み取り',
'admin.plugins.perm.db:read:collections': '操作中のユーザーの保存した場所のコレクションの読み取り(Collections アドオンが必要)',
'admin.plugins.perm.db:read:collections':
'操作中のユーザーの保存した場所のコレクションの読み取り(Collections アドオンが必要)',
'admin.plugins.perm.db:write:daynotes': '操作中のユーザーが編集できる旅行の日別メモの作成・編集・削除',
'admin.plugins.perm.jobs:run': '宣言されたバックグラウンドジョブをスケジュールに従って実行(ユーザーコンテキストなし — ユーザーデータは読み取れません)',
'admin.plugins.perm.jobs:run':
'宣言されたバックグラウンドジョブをスケジュールに従って実行(ユーザーコンテキストなし — ユーザーデータは読み取れません)',
'admin.plugins.perm.db:write:costs': '操作中のユーザーが編集できる旅行への支出(予算項目)の作成',
'admin.plugins.perm.db:write:places': '操作中のユーザーが編集できる旅行の場所の追加・編集・削除',
'admin.plugins.perm.db:write:days': '操作中のユーザーが編集できる旅行の日程の追加・編集・削除',
@@ -312,12 +334,15 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:pdf-section-provider': '旅行の PDF エクスポートにテキストセクションを追加',
'admin.plugins.perm.hook:atlas-layer-provider': 'Atlas の世界地図で国をハイライト(例: ウィッシュリストや渡航情報)',
'admin.plugins.perm.hook:journal-entry-provider': '日記のエントリーに追加の行(リンク、統計)を提供',
'admin.plugins.perm.hook:user-data': 'ユーザーについて保存したデータの消去またはエクスポート(GDPR:アカウント削除・データ開示請求)',
'admin.plugins.perm.hook:user-data':
'ユーザーについて保存したデータの消去またはエクスポート(GDPR:アカウント削除・データ開示請求)',
'admin.plugins.perm.hook:trip-card-provider': 'ダッシュボードの旅行カードに小さなバッジ(状態・カウント)を追加',
'admin.plugins.perm.hook:notification-channel': '追加のチャンネルで通知を配信する',
'admin.plugins.perm.events:subscribe':
'コアのアクティビティイベントに反応(イベント名と旅程のみで、内容には一切アクセスしません)',
'admin.plugins.perm.http:outbound': '宣言済みホストへの外部リクエストの送信',
'admin.plugins.perm.db:read:collab': '操作中のユーザーがアクセスできる旅行のメモ・アンケート・チャットメッセージの読み取り(Collab アドオンが必要)',
'admin.plugins.perm.db:read:collab':
'操作中のユーザーがアクセスできる旅行のメモ・アンケート・チャットメッセージの読み取り(Collab アドオンが必要)',
'admin.plugins.perm.db:read:files:content': '操作中のユーザーがアクセスできる旅行のファイル内容(バイト)の読み取り',
'admin.plugins.perm.db:create:trips': '操作中のユーザーが所有する新しい旅行の作成',
'admin.plugins.perm.rates:read': 'ホストのキャッシュされた為替レートを読み取る',
@@ -388,6 +413,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': '予約詳細',
'admin.plugins.cap.replacesTabs': 'プランナーのタブを置き換え',
'admin.plugins.cap.realtime': 'リアルタイム更新',
'admin.plugins.cap.notificationChannel': '通知チャンネル',
'admin.plugins.cap.photos': '写真を提供',
'admin.plugins.cap.calendar': 'カレンダー予定を提供',
'admin.plugins.cap.placeDetails': '場所を充実',
+8
View File
@@ -89,6 +89,14 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.email': 'メール',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'アプリ内',
'settings.notificationPreferences.notConfigured': 'まだ設定されていません — プラグインの設定で構成してください',
'settings.plugins.actions': 'アクション',
'settings.plugins.actions.confirm': 'このアクションを実行しますか?',
'settings.notificationPreferences.sendTest': 'テスト送信',
'settings.notificationPreferences.configure': '設定する',
'settings.notificationPreferences.testSuccess': 'テスト通知を送信しました。',
'settings.notificationPreferences.testFailed': 'テストに失敗しました。',
'settings.notificationPreferences.pluginConfigured': '設定済みです。認証情報はプラグインの設定ページで管理します。',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.noChannels': '通知チャネルが未設定です。管理者に設定を依頼してください。',
'settings.webhookUrl.label': 'Webhook URL',
+35 -12
View File
@@ -223,6 +223,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': '플러그인이 비활성화되었습니다',
'admin.plugins.viewErrors': '오류 로그 보기',
'admin.plugins.errorLog': '오류 로그',
'admin.plugins.allowedHosts': '허용된 호스트',
'admin.plugins.allowedHosts.hint': '이 플러그인은 관리자만 알 수 있는 서비스(자체 호스팅 서버)와 통신합니다. 접근을 허용할 호스트를 추가하세요 — 그 외에는 접근할 수 없습니다.',
'admin.plugins.allowedHosts.none': '아직 추가된 호스트가 없습니다.',
'admin.plugins.allowedHosts.unsupported': '이 플러그인은 운영자가 지정하는 호스트를 사용하지 않습니다. 허용 호스트는 매니페스트에 고정되어 있습니다.',
'admin.plugins.allowedHosts.restartNote': '저장하면 플러그인이 재시작되어 새 목록을 적용합니다.',
'admin.plugins.allowedHosts.add': '허용 호스트 추가',
'admin.plugins.allowedHosts.count': '허용 호스트 {n}개',
'admin.plugins.operatorEgressPill': '+ 직접 추가하는 호스트',
'admin.plugins.operatorEgressHint': '이 플러그인은 관리자만 알 수 있는 서비스(자체 호스팅 서버)와 통신합니다. 설치 후 ⋯ → 허용된 호스트에서 추가하세요. 그 외에는 접근할 수 없습니다.',
'admin.plugins.noErrors': '기록된 오류가 없습니다.',
'admin.plugins.uninstalled': '플러그인이 제거되었습니다',
'admin.plugins.uninstallTitle': '플러그인을 제거할까요?',
@@ -280,16 +289,25 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:write:tags': '현재 사용자의 태그 만들기, 편집 및 삭제',
'admin.plugins.perm.db:read:todos': '현재 사용자가 접근할 수 있는 여행의 할 일 읽기',
'admin.plugins.perm.db:write:todos': '현재 사용자가 편집할 수 있는 여행에서 할 일 만들기, 편집 및 삭제',
'admin.plugins.perm.db:write:atlas': '현재 사용자의 방문한 국가와 지역을 표시하고 버킷 리스트 관리(Atlas 애드온 필요)',
'admin.plugins.perm.db:write:atlas':
'현재 사용자의 방문한 국가와 지역을 표시하고 버킷 리스트 관리(Atlas 애드온 필요)',
'admin.plugins.perm.db:write:vacay': '현재 사용자의 활성 계획에서 휴가일과 회사 휴일 전환(Vacay 애드온 필요)',
'admin.plugins.perm.db:write:journal': '현재 사용자가 편집할 수 있는 일지에서 항목 만들기, 편집 및 삭제(Journey 애드온 필요)',
'admin.plugins.perm.db:write:collections': '컬렉션을 만들고 편집하며 장소 저장(현재 사용자의 컬렉션 역할 기준, Collections 애드온 필요)',
'admin.plugins.perm.db:write:files': '현재 사용자가 편집할 수 있는 여행에 파일 첨부(10MB 제한, 차단된 확장자 거부) 및 링크 관리',
'admin.plugins.perm.db:write:collab': '현재 사용자가 편집할 수 있는 여행에 메모, 투표 및 채팅 메시지 게시(Collab 애드온 필요)',
'admin.plugins.perm.db:write:members': '여행에 사용자 추가 — 여행 접근 권한 부여; 현재 사용자의 멤버 관리 권한으로 보호',
'admin.plugins.perm.notify:send': '현재 사용자 또는 그가 속한 여행에 알림(벨 받은함 + 이메일/ntfy) 전송 — 임의의 수신자에게는 보내지 않음',
'admin.plugins.perm.ai:invoke': '관리자가 구성한 AI 공급자(텍스트 완성 + 문서 추출)를 현재 사용자를 대신하여 실행 — 플러그인은 키를 보유하지 않음',
'admin.plugins.perm.oauth:client': '호스트가 중개하는 OAuth로 사용자를 대신해 타사 서비스에 연결(토큰은 호스트가 보유하며 플러그인은 볼 수 없음)',
'admin.plugins.perm.db:write:journal':
'현재 사용자가 편집할 수 있는 일지에서 항목 만들기, 편집 및 삭제(Journey 애드온 필요)',
'admin.plugins.perm.db:write:collections':
'컬렉션을 만들고 편집하며 장소 저장(현재 사용자의 컬렉션 역할 기준, Collections 애드온 필요)',
'admin.plugins.perm.db:write:files':
'현재 사용자가 편집할 수 있는 여행에 파일 첨부(10MB 제한, 차단된 확장자 거부) 및 링크 관리',
'admin.plugins.perm.db:write:collab':
'현재 사용자가 편집할 수 있는 여행에 메모, 투표 및 채팅 메시지 게시(Collab 애드온 필요)',
'admin.plugins.perm.db:write:members':
'여행에 사용자 추가 — 여행 접근 권한 부여; 현재 사용자의 멤버 관리 권한으로 보호',
'admin.plugins.perm.notify:send':
'현재 사용자 또는 그가 속한 여행에 알림(벨 받은함 + 이메일/ntfy) 전송 — 임의의 수신자에게는 보내지 않음',
'admin.plugins.perm.ai:invoke':
'관리자가 구성한 AI 공급자(텍스트 완성 + 문서 추출)를 현재 사용자를 대신하여 실행 — 플러그인은 키를 보유하지 않음',
'admin.plugins.perm.oauth:client':
'호스트가 중개하는 OAuth로 사용자를 대신해 타사 서비스에 연결(토큰은 호스트가 보유하며 플러그인은 볼 수 없음)',
'admin.plugins.perm.db:read:files': '현재 사용자가 접근할 수 있는 여행의 파일 읽기',
'admin.plugins.perm.db:write:reservations': '현재 사용자가 편집할 수 있는 여행에서 예약 만들기, 편집 및 삭제',
'admin.plugins.perm.db:write:accommodations': '현재 사용자가 편집할 수 있는 여행에서 숙소 만들기, 편집 및 삭제',
@@ -299,7 +317,8 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:daynotes': '현재 사용자가 접근할 수 있는 여행의 일자 메모 읽기',
'admin.plugins.perm.db:read:collections': '현재 사용자의 저장한 장소 컬렉션 읽기(Collections 애드온 필요)',
'admin.plugins.perm.db:write:daynotes': '현재 사용자가 편집할 수 있는 여행에서 일자 메모 만들기, 편집 및 삭제',
'admin.plugins.perm.jobs:run': '선언된 백그라운드 작업을 예약에 따라 실행(사용자 컨텍스트 없음 — 사용자 데이터를 읽을 수 없음)',
'admin.plugins.perm.jobs:run':
'선언된 백그라운드 작업을 예약에 따라 실행(사용자 컨텍스트 없음 — 사용자 데이터를 읽을 수 없음)',
'admin.plugins.perm.db:write:costs': '현재 사용자가 편집할 수 있는 여행에 지출(예산 항목) 만들기',
'admin.plugins.perm.db:write:places': '현재 사용자가 편집할 수 있는 여행에서 장소 추가, 편집 및 삭제',
'admin.plugins.perm.db:write:days': '현재 사용자가 편집할 수 있는 여행에서 날짜 추가, 편집 및 삭제',
@@ -317,11 +336,14 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:pdf-section-provider': '여행 PDF 내보내기에 텍스트 섹션 추가',
'admin.plugins.perm.hook:atlas-layer-provider': 'Atlas 세계 지도에서 국가 강조 표시(예: 위시리스트 또는 여행 경보)',
'admin.plugins.perm.hook:journal-entry-provider': '저널 항목에 추가 행(링크, 통계) 제공',
'admin.plugins.perm.hook:user-data': '사용자에 대해 저장한 데이터를 삭제하거나 내보내기 (GDPR: 계정 삭제 및 데이터 요청)',
'admin.plugins.perm.hook:user-data':
'사용자에 대해 저장한 데이터를 삭제하거나 내보내기 (GDPR: 계정 삭제 및 데이터 요청)',
'admin.plugins.perm.hook:trip-card-provider': '대시보드 여행 카드에 작은 배지(상태, 개수) 추가',
'admin.plugins.perm.hook:notification-channel': '추가 채널을 통해 알림을 전달합니다',
'admin.plugins.perm.events:subscribe': '핵심 활동 이벤트에 반응 (이벤트 이름과 여행만, 내용은 절대 표시 안 함)',
'admin.plugins.perm.http:outbound': '선언된 호스트로 아웃바운드 요청 전송',
'admin.plugins.perm.db:read:collab': '현재 사용자가 접근할 수 있는 여행의 메모, 투표 및 채팅 메시지 읽기(Collab 애드온 필요)',
'admin.plugins.perm.db:read:collab':
'현재 사용자가 접근할 수 있는 여행의 메모, 투표 및 채팅 메시지 읽기(Collab 애드온 필요)',
'admin.plugins.perm.db:read:files:content': '현재 사용자가 접근할 수 있는 여행의 파일 내용(바이트) 읽기',
'admin.plugins.perm.db:create:trips': '현재 사용자 소유의 새 여행 만들기',
'admin.plugins.perm.rates:read': '호스트의 캐시된 환율 읽기',
@@ -392,6 +414,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': '예약 상세',
'admin.plugins.cap.replacesTabs': '플래너 탭 대체',
'admin.plugins.cap.realtime': '실시간 업데이트',
'admin.plugins.cap.notificationChannel': '알림 채널',
'admin.plugins.cap.photos': '사진 제공',
'admin.plugins.cap.calendar': '캘린더 일정 제공',
'admin.plugins.cap.placeDetails': '장소 정보 강화',
+8
View File
@@ -89,6 +89,14 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.email': '이메일',
'settings.notificationPreferences.webhook': '웹훅',
'settings.notificationPreferences.inapp': '앱 내',
'settings.notificationPreferences.notConfigured': '아직 설정되지 않았습니다 — 플러그인 설정에서 구성하세요',
'settings.plugins.actions': '작업',
'settings.plugins.actions.confirm': '이 작업을 실행할까요?',
'settings.notificationPreferences.sendTest': '테스트 전송',
'settings.notificationPreferences.configure': '설정하기',
'settings.notificationPreferences.testSuccess': '테스트 알림을 보냈습니다.',
'settings.notificationPreferences.testFailed': '테스트에 실패했습니다.',
'settings.notificationPreferences.pluginConfigured': '설정됨. 자격 증명은 플러그인 설정 페이지에서 관리하세요.',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.noChannels':
'알림 채널이 설정되지 않았습니다. 관리자에게 이메일 또는 웹훅 알림 설정을 요청하세요.',
+61 -25
View File
@@ -169,7 +169,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'Plugin “{name}” geüpload — activeer om uit te voeren',
'admin.plugins.sideloaded': 'Handmatig geladen',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Geladen vanuit een lokale build-map en hot-reload met echte data — alleen voor ontwikkeling',
'admin.plugins.devLinkHint':
'Geladen vanuit een lokale build-map en hot-reload met echte data — alleen voor ontwikkeling',
'admin.plugins.devLinkTitle': 'Een lokale plugin koppelen',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Koppelen',
@@ -186,6 +187,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Plugin gedeactiveerd',
'admin.plugins.viewErrors': 'Foutenlog bekijken',
'admin.plugins.errorLog': 'Foutenlog',
'admin.plugins.allowedHosts': 'Toegestane hosts',
'admin.plugins.allowedHosts.hint': 'Deze plug-in praat met een dienst die alleen jij kunt benoemen (een zelf-gehoste server). Voeg de hosts toe die hij mag bereiken — andere bereikt hij niet.',
'admin.plugins.allowedHosts.none': 'Nog geen hosts toegevoegd.',
'admin.plugins.allowedHosts.unsupported': 'Deze plug-in gebruikt geen door de beheerder opgegeven hosts. De toegestane hosts staan vast in het manifest.',
'admin.plugins.allowedHosts.restartNote': 'Opslaan herstart de plug-in zodat de nieuwe lijst wordt opgepakt.',
'admin.plugins.allowedHosts.add': 'Toegestane host toevoegen',
'admin.plugins.allowedHosts.count': '{n} toegestane host(s)',
'admin.plugins.operatorEgressPill': '+ hosts die jij toevoegt',
'admin.plugins.operatorEgressHint': 'Deze plug-in praat met een dienst die alleen jij kunt benoemen (een zelf-gehoste server). Voeg na installatie de bereikbare hosts toe via ⋯ → Toegestane hosts. Andere bereikt hij niet.',
'admin.plugins.noErrors': 'Geen fouten geregistreerd.',
'admin.plugins.uninstalled': 'Plugin verwijderd',
'admin.plugins.uninstallTitle': 'Plugin verwijderen?',
@@ -236,33 +246,51 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:users': 'Basisprofielgegevens lezen (naam, avatar — nooit inloggegevens)',
'admin.plugins.perm.db:read:costs': 'Uitgaven (budgetposten) lezen waartoe de actieve gebruiker toegang heeft',
'admin.plugins.perm.db:read:packing': 'Paklijsten lezen van reizen waartoe de actieve gebruiker toegang heeft',
'admin.plugins.perm.db:write:packing': 'Paklijst-items aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken',
'admin.plugins.perm.db:write:packing':
'Paklijst-items aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken',
'admin.plugins.perm.weather:read': 'De gecachte weersvoorspelling van de host lezen (op coördinaten)',
'admin.plugins.perm.db:read:categories': 'De globale lijst met plaatscategorieën lezen',
'admin.plugins.perm.db:read:tags': 'De tags van de actieve gebruiker lezen',
'admin.plugins.perm.db:write:tags': 'De tags van de actieve gebruiker aanmaken, bewerken en verwijderen',
'admin.plugins.perm.db:read:todos': 'De taken lezen van reizen waartoe de actieve gebruiker toegang heeft',
'admin.plugins.perm.db:write:todos': 'Taken aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken',
'admin.plugins.perm.db:write:atlas': "Landen en regio's als bezocht markeren en de bucketlist van de actieve gebruiker beheren (vereist de Atlas-add-on)",
'admin.plugins.perm.db:write:vacay': 'Vakantiedagen en bedrijfssluitingen op het actieve plan van de actieve gebruiker omschakelen (vereist de Vacay-add-on)',
'admin.plugins.perm.db:write:journal': 'Dagboekvermeldingen aanmaken, bewerken en verwijderen in dagboeken die de actieve gebruiker mag bewerken (vereist de Journey-add-on)',
'admin.plugins.perm.db:write:collections': 'Collecties aanmaken en bewerken en er plaatsen in opslaan, met de collectierol van de actieve gebruiker (vereist de Collections-add-on)',
'admin.plugins.perm.db:write:files': 'Bestanden toevoegen aan reizen die de actieve gebruiker mag bewerken (limiet 10 MB, geblokkeerde extensies geweigerd) en hun koppelingen beheren',
'admin.plugins.perm.db:write:collab': 'Notities, peilingen en chatberichten plaatsen op reizen die de actieve gebruiker mag bewerken (vereist de Collab-add-on)',
'admin.plugins.perm.db:write:members': 'Gebruikers aan reizen toevoegen — verleent reistoegang; beveiligd door het ledenbeheer-recht van de actieve gebruiker',
'admin.plugins.perm.notify:send': 'Een melding (belinbox + e-mail/ntfy) sturen naar de actieve gebruiker of een reis waartoe hij behoort — nooit naar een willekeurige ontvanger',
'admin.plugins.perm.ai:invoke': 'De door de beheerder geconfigureerde AI-provider (tekstaanvulling + documentextractie) namens de actieve gebruiker gebruiken — de plug-in bezit nooit een sleutel',
'admin.plugins.perm.oauth:client': 'Namens jou verbinden met een externe dienst via host-bemiddelde OAuth (de host bewaart de tokens, de plug-in ziet ze nooit)',
'admin.plugins.perm.db:write:todos':
'Taken aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken',
'admin.plugins.perm.db:write:atlas':
"Landen en regio's als bezocht markeren en de bucketlist van de actieve gebruiker beheren (vereist de Atlas-add-on)",
'admin.plugins.perm.db:write:vacay':
'Vakantiedagen en bedrijfssluitingen op het actieve plan van de actieve gebruiker omschakelen (vereist de Vacay-add-on)',
'admin.plugins.perm.db:write:journal':
'Dagboekvermeldingen aanmaken, bewerken en verwijderen in dagboeken die de actieve gebruiker mag bewerken (vereist de Journey-add-on)',
'admin.plugins.perm.db:write:collections':
'Collecties aanmaken en bewerken en er plaatsen in opslaan, met de collectierol van de actieve gebruiker (vereist de Collections-add-on)',
'admin.plugins.perm.db:write:files':
'Bestanden toevoegen aan reizen die de actieve gebruiker mag bewerken (limiet 10 MB, geblokkeerde extensies geweigerd) en hun koppelingen beheren',
'admin.plugins.perm.db:write:collab':
'Notities, peilingen en chatberichten plaatsen op reizen die de actieve gebruiker mag bewerken (vereist de Collab-add-on)',
'admin.plugins.perm.db:write:members':
'Gebruikers aan reizen toevoegen — verleent reistoegang; beveiligd door het ledenbeheer-recht van de actieve gebruiker',
'admin.plugins.perm.notify:send':
'Een melding (belinbox + e-mail/ntfy) sturen naar de actieve gebruiker of een reis waartoe hij behoort — nooit naar een willekeurige ontvanger',
'admin.plugins.perm.ai:invoke':
'De door de beheerder geconfigureerde AI-provider (tekstaanvulling + documentextractie) namens de actieve gebruiker gebruiken — de plug-in bezit nooit een sleutel',
'admin.plugins.perm.oauth:client':
'Namens jou verbinden met een externe dienst via host-bemiddelde OAuth (de host bewaart de tokens, de plug-in ziet ze nooit)',
'admin.plugins.perm.db:read:files': 'Bestanden lezen van reizen waartoe de actieve gebruiker toegang heeft',
'admin.plugins.perm.db:write:reservations': 'Boekingen (reserveringen) aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken',
'admin.plugins.perm.db:write:accommodations': 'Accommodaties (verblijven) aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken',
'admin.plugins.perm.db:write:reservations':
'Boekingen (reserveringen) aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken',
'admin.plugins.perm.db:write:accommodations':
'Accommodaties (verblijven) aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken',
'admin.plugins.perm.db:read:journal': 'De reisdagboeken van de actieve gebruiker lezen (vereist de Journey-add-on)',
'admin.plugins.perm.db:read:atlas': "De door de actieve gebruiker bezochte landen en regio's lezen (vereist de Atlas-add-on)",
'admin.plugins.perm.db:read:atlas':
"De door de actieve gebruiker bezochte landen en regio's lezen (vereist de Atlas-add-on)",
'admin.plugins.perm.db:read:vacay': 'Het vakantieplan van de actieve gebruiker lezen (vereist de Vacay-add-on)',
'admin.plugins.perm.db:read:daynotes': 'De dagnotities lezen van reizen waartoe de actieve gebruiker toegang heeft',
'admin.plugins.perm.db:read:collections': 'De collecties met opgeslagen plaatsen van de actieve gebruiker lezen (vereist de Collections-add-on)',
'admin.plugins.perm.db:write:daynotes': 'Dagnotities aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken',
'admin.plugins.perm.jobs:run': 'De gedeclareerde achtergrondtaken volgens een schema uitvoeren (zonder gebruikerscontext — kan geen gebruikersgegevens lezen)',
'admin.plugins.perm.db:read:collections':
'De collecties met opgeslagen plaatsen van de actieve gebruiker lezen (vereist de Collections-add-on)',
'admin.plugins.perm.db:write:daynotes':
'Dagnotities aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken',
'admin.plugins.perm.jobs:run':
'De gedeclareerde achtergrondtaken volgens een schema uitvoeren (zonder gebruikerscontext — kan geen gebruikersgegevens lezen)',
'admin.plugins.perm.db:write:costs':
'Uitgaven (budgetposten) aanmaken op reizen die de actieve gebruiker mag bewerken',
'admin.plugins.perm.db:write:places':
@@ -283,19 +311,26 @@ const admin: TranslationStrings = {
'Extra details (reviews, beoordelingen, links) aan een plek toevoegen',
'admin.plugins.perm.hook:trip-warning-provider':
'Validatiewaarschuwingen tonen bij een reis (zichtbaar in de planner)',
'admin.plugins.perm.hook:table-contributor': 'Kolommen en acties toevoegen aan reisweergaven (boekingen, plaatsen, dagen)',
'admin.plugins.perm.hook:map-marker-provider': "Markeringen aan de reiskaart toevoegen (bijv. boekingen of POI's tonen)",
'admin.plugins.perm.hook:table-contributor':
'Kolommen en acties toevoegen aan reisweergaven (boekingen, plaatsen, dagen)',
'admin.plugins.perm.hook:map-marker-provider':
"Markeringen aan de reiskaart toevoegen (bijv. boekingen of POI's tonen)",
'admin.plugins.perm.hook:pdf-section-provider': 'Tekstsecties toevoegen aan de PDF-export van de reis',
'admin.plugins.perm.hook:atlas-layer-provider':
'Landen markeren op de Atlas-wereldkaart (bijv. wensenlijsten of reisadviezen)',
'admin.plugins.perm.hook:journal-entry-provider': 'Extra regels (links, statistieken) toevoegen aan dagboek-items',
'admin.plugins.perm.hook:user-data': 'De gegevens die het over een gebruiker opslaat wissen of exporteren (AVG: accountverwijdering en inzageverzoeken)',
'admin.plugins.perm.hook:trip-card-provider': 'Kleine badges (status, aantallen) aan je dashboard-reiskaarten toevoegen',
'admin.plugins.perm.hook:user-data':
'De gegevens die het over een gebruiker opslaat wissen of exporteren (AVG: accountverwijdering en inzageverzoeken)',
'admin.plugins.perm.hook:trip-card-provider':
'Kleine badges (status, aantallen) aan je dashboard-reiskaarten toevoegen',
'admin.plugins.perm.hook:notification-channel': 'Je meldingen via een extra kanaal bezorgen',
'admin.plugins.perm.events:subscribe':
'Reageert op kernactiviteitsgebeurtenissen (alleen gebeurtenisnaam + reis, nooit de inhoud)',
'admin.plugins.perm.http:outbound': 'Uitgaande verzoeken doen naar de opgegeven hosts',
'admin.plugins.perm.db:read:collab': 'Notities, peilingen en chatberichten lezen van reizen waartoe de actieve gebruiker toegang heeft (vereist de Collab-add-on)',
'admin.plugins.perm.db:read:files:content': 'De inhoud (bytes) van bestanden lezen van reizen waartoe de actieve gebruiker toegang heeft',
'admin.plugins.perm.db:read:collab':
'Notities, peilingen en chatberichten lezen van reizen waartoe de actieve gebruiker toegang heeft (vereist de Collab-add-on)',
'admin.plugins.perm.db:read:files:content':
'De inhoud (bytes) van bestanden lezen van reizen waartoe de actieve gebruiker toegang heeft',
'admin.plugins.perm.db:create:trips': 'Nieuwe reizen aanmaken die eigendom zijn van de actieve gebruiker',
'admin.plugins.perm.rates:read': 'De gecachte wisselkoersen van de host lezen',
'admin.plugins.updateConsentTitle': 'Deze update vereist nieuwe machtigingen',
@@ -365,6 +400,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Reserveringsdetail',
'admin.plugins.cap.replacesTabs': 'Vervangt planner-tabbladen',
'admin.plugins.cap.realtime': 'Realtime updates',
'admin.plugins.cap.notificationChannel': 'Meldingskanaal',
'admin.plugins.cap.photos': "Levert foto's",
'admin.plugins.cap.calendar': 'Levert kalenderevenementen',
'admin.plugins.cap.placeDetails': 'Verrijkt plekken',
+11 -1
View File
@@ -13,7 +13,8 @@ const settings: TranslationStrings = {
'settings.plugins.oauth.disconnect': 'Verbinding verbreken',
'settings.tabs.plugins': 'Plug-ins',
'settings.plugins.title': 'Plug-in-instellingen',
'settings.plugins.subtitle': 'Je persoonlijke instellingen voor de plug-ins die je gebruikt (API-sleutels, voorkeuren).',
'settings.plugins.subtitle':
'Je persoonlijke instellingen voor de plug-ins die je gebruikt (API-sleutels, voorkeuren).',
'settings.plugins.empty': 'Er zijn geen plug-ins actief.',
'settings.plugins.saved': 'Instellingen opgeslagen',
'settings.tabs.account': 'Account',
@@ -288,6 +289,15 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.testFailed': 'Test-Ntfy-melding mislukt',
'settings.ntfyUrl.tokenCleared': 'Toegangstoken gewist',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured':
'Nog niet ingesteld — configureer het in de instellingen van de plug-in',
'settings.plugins.actions': 'Acties',
'settings.plugins.actions.confirm': 'Deze actie uitvoeren?',
'settings.notificationPreferences.sendTest': 'Test versturen',
'settings.notificationPreferences.configure': 'Instellen',
'settings.notificationPreferences.testSuccess': 'Testmelding verstuurd.',
'settings.notificationPreferences.testFailed': 'Test mislukt.',
'settings.notificationPreferences.pluginConfigured': 'Ingesteld. Beheer je gegevens op de instellingenpagina van de plug-in.',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
+61 -25
View File
@@ -162,7 +162,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'Wtyczka “{name}” przesłana — aktywuj ją, aby uruchomić',
'admin.plugins.sideloaded': 'Załadowano ręcznie',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Wczytany z lokalnego katalogu kompilacji i przeładowywany na gorąco na prawdziwych danych — tylko dla programistów',
'admin.plugins.devLinkHint':
'Wczytany z lokalnego katalogu kompilacji i przeładowywany na gorąco na prawdziwych danych — tylko dla programistów',
'admin.plugins.devLinkTitle': 'Podłącz lokalny plugin',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Podłącz',
@@ -179,6 +180,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Wtyczka została dezaktywowana',
'admin.plugins.viewErrors': 'Pokaż dziennik błędów',
'admin.plugins.errorLog': 'Dziennik błędów',
'admin.plugins.allowedHosts': 'Dozwolone hosty',
'admin.plugins.allowedHosts.hint': 'Ta wtyczka łączy się z usługą, którą tylko Ty możesz wskazać (serwer self-hosted). Dodaj hosty, do których może sięgać — do innych nie sięgnie.',
'admin.plugins.allowedHosts.none': 'Nie dodano jeszcze żadnych hostów.',
'admin.plugins.allowedHosts.unsupported': 'Ta wtyczka nie używa hostów podanych przez operatora. Jej dozwolone hosty są zapisane w manifeście.',
'admin.plugins.allowedHosts.restartNote': 'Zapis restartuje wtyczkę, aby pobrała nową listę.',
'admin.plugins.allowedHosts.add': 'Dodaj dozwolony host',
'admin.plugins.allowedHosts.count': '{n} dozwolonych hostów',
'admin.plugins.operatorEgressPill': '+ hosty, które dodasz',
'admin.plugins.operatorEgressHint': 'Ta wtyczka łączy się z usługą, którą tylko Ty możesz wskazać (serwer self-hosted). Po instalacji dodaj hosty w ⋯ → Dozwolone hosty. Do innych nie sięgnie.',
'admin.plugins.noErrors': 'Brak zarejestrowanych błędów.',
'admin.plugins.uninstalled': 'Wtyczka została odinstalowana',
'admin.plugins.uninstallTitle': 'Odinstalować wtyczkę?',
@@ -231,33 +241,52 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:costs': 'Odczyt wydatków (pozycji budżetu), do których działający użytkownik ma dostęp',
'admin.plugins.perm.db:read:packing':
'Odczyt list rzeczy do spakowania w podróżach, do których działający użytkownik ma dostęp',
'admin.plugins.perm.db:write:packing': 'Tworzenie, edytowanie i usuwanie pozycji listy pakowania w podróżach, które działający użytkownik może edytować',
'admin.plugins.perm.db:write:packing':
'Tworzenie, edytowanie i usuwanie pozycji listy pakowania w podróżach, które działający użytkownik może edytować',
'admin.plugins.perm.weather:read': 'Odczyt zapisanej w pamięci prognozy pogody hosta (wg współrzędnych)',
'admin.plugins.perm.db:read:categories': 'Odczyt globalnej listy kategorii miejsc',
'admin.plugins.perm.db:read:tags': 'Odczyt tagów działającego użytkownika',
'admin.plugins.perm.db:write:tags': 'Tworzenie, edytowanie i usuwanie tagów działającego użytkownika',
'admin.plugins.perm.db:read:todos': 'Odczyt zadań w podróżach, do których działający użytkownik ma dostęp',
'admin.plugins.perm.db:write:todos': 'Tworzenie, edytowanie i usuwanie zadań w podróżach, które działający użytkownik może edytować',
'admin.plugins.perm.db:write:atlas': 'Oznaczanie krajów i regionów jako odwiedzonych oraz zarządzanie listą marzeń działającego użytkownika (wymaga dodatku Atlas)',
'admin.plugins.perm.db:write:vacay': 'Przełączanie dni urlopu i świąt firmowych w aktywnym planie działającego użytkownika (wymaga dodatku Vacay)',
'admin.plugins.perm.db:write:journal': 'Tworzenie, edytowanie i usuwanie wpisów w dziennikach, które działający użytkownik może edytować (wymaga dodatku Journey)',
'admin.plugins.perm.db:write:collections': 'Tworzenie i edytowanie kolekcji oraz zapisywanie w nich miejsc, z rolą działającego użytkownika w kolekcji (wymaga dodatku Collections)',
'admin.plugins.perm.db:write:files': 'Dołączanie plików do podróży, które działający użytkownik może edytować (limit 10 MB, zablokowane rozszerzenia odrzucane), i zarządzanie ich powiązaniami',
'admin.plugins.perm.db:write:collab': 'Publikowanie notatek, ankiet i wiadomości czatu w podróżach, które działający użytkownik może edytować (wymaga dodatku Collab)',
'admin.plugins.perm.db:write:members': 'Dodawanie użytkowników do podróży — przyznaje dostęp do podróży; chronione uprawnieniem zarządzania członkami działającego użytkownika',
'admin.plugins.perm.notify:send': 'Wysyłanie powiadomienia (skrzynka dzwonka + e-mail/ntfy) do działającego użytkownika lub jego podróży — nigdy do dowolnego odbiorcy',
'admin.plugins.perm.ai:invoke': 'Korzystanie ze skonfigurowanego przez administratora dostawcy AI (uzupełnianie tekstu + wyodrębnianie z dokumentów) w imieniu działającego użytkownika — wtyczka nigdy nie przechowuje klucza',
'admin.plugins.perm.oauth:client': 'Łączenie się z usługą zewnętrzną w Twoim imieniu przez OAuth pośredniczony przez hosta (host przechowuje tokeny, wtyczka ich nie widzi)',
'admin.plugins.perm.db:write:todos':
'Tworzenie, edytowanie i usuwanie zadań w podróżach, które działający użytkownik może edytować',
'admin.plugins.perm.db:write:atlas':
'Oznaczanie krajów i regionów jako odwiedzonych oraz zarządzanie listą marzeń działającego użytkownika (wymaga dodatku Atlas)',
'admin.plugins.perm.db:write:vacay':
'Przełączanie dni urlopu i świąt firmowych w aktywnym planie działającego użytkownika (wymaga dodatku Vacay)',
'admin.plugins.perm.db:write:journal':
'Tworzenie, edytowanie i usuwanie wpisów w dziennikach, które działający użytkownik może edytować (wymaga dodatku Journey)',
'admin.plugins.perm.db:write:collections':
'Tworzenie i edytowanie kolekcji oraz zapisywanie w nich miejsc, z rolą działającego użytkownika w kolekcji (wymaga dodatku Collections)',
'admin.plugins.perm.db:write:files':
'Dołączanie plików do podróży, które działający użytkownik może edytować (limit 10 MB, zablokowane rozszerzenia odrzucane), i zarządzanie ich powiązaniami',
'admin.plugins.perm.db:write:collab':
'Publikowanie notatek, ankiet i wiadomości czatu w podróżach, które działający użytkownik może edytować (wymaga dodatku Collab)',
'admin.plugins.perm.db:write:members':
'Dodawanie użytkowników do podróży — przyznaje dostęp do podróży; chronione uprawnieniem zarządzania członkami działającego użytkownika',
'admin.plugins.perm.notify:send':
'Wysyłanie powiadomienia (skrzynka dzwonka + e-mail/ntfy) do działającego użytkownika lub jego podróży — nigdy do dowolnego odbiorcy',
'admin.plugins.perm.ai:invoke':
'Korzystanie ze skonfigurowanego przez administratora dostawcy AI (uzupełnianie tekstu + wyodrębnianie z dokumentów) w imieniu działającego użytkownika — wtyczka nigdy nie przechowuje klucza',
'admin.plugins.perm.oauth:client':
'Łączenie się z usługą zewnętrzną w Twoim imieniu przez OAuth pośredniczony przez hosta (host przechowuje tokeny, wtyczka ich nie widzi)',
'admin.plugins.perm.db:read:files': 'Odczyt plików w podróżach, do których działający użytkownik ma dostęp',
'admin.plugins.perm.db:write:reservations': 'Tworzenie, edytowanie i usuwanie rezerwacji w podróżach, które działający użytkownik może edytować',
'admin.plugins.perm.db:write:accommodations': 'Tworzenie, edytowanie i usuwanie zakwaterowań w podróżach, które działający użytkownik może edytować',
'admin.plugins.perm.db:write:reservations':
'Tworzenie, edytowanie i usuwanie rezerwacji w podróżach, które działający użytkownik może edytować',
'admin.plugins.perm.db:write:accommodations':
'Tworzenie, edytowanie i usuwanie zakwaterowań w podróżach, które działający użytkownik może edytować',
'admin.plugins.perm.db:read:journal': 'Odczyt dzienników podróży działającego użytkownika (wymaga dodatku Journey)',
'admin.plugins.perm.db:read:atlas': 'Odczyt krajów i regionów odwiedzonych przez działającego użytkownika (wymaga dodatku Atlas)',
'admin.plugins.perm.db:read:atlas':
'Odczyt krajów i regionów odwiedzonych przez działającego użytkownika (wymaga dodatku Atlas)',
'admin.plugins.perm.db:read:vacay': 'Odczyt planu urlopu działającego użytkownika (wymaga dodatku Vacay)',
'admin.plugins.perm.db:read:daynotes': 'Odczyt notatek dziennych w podróżach, do których działający użytkownik ma dostęp',
'admin.plugins.perm.db:read:collections': 'Odczyt kolekcji zapisanych miejsc działającego użytkownika (wymaga dodatku Collections)',
'admin.plugins.perm.db:write:daynotes': 'Tworzenie, edytowanie i usuwanie notatek dziennych w podróżach, które działający użytkownik może edytować',
'admin.plugins.perm.jobs:run': 'Uruchamianie zadeklarowanych zadań w tle według harmonogramu (bez kontekstu użytkownika — nie może odczytywać danych użytkownika)',
'admin.plugins.perm.db:read:daynotes':
'Odczyt notatek dziennych w podróżach, do których działający użytkownik ma dostęp',
'admin.plugins.perm.db:read:collections':
'Odczyt kolekcji zapisanych miejsc działającego użytkownika (wymaga dodatku Collections)',
'admin.plugins.perm.db:write:daynotes':
'Tworzenie, edytowanie i usuwanie notatek dziennych w podróżach, które działający użytkownik może edytować',
'admin.plugins.perm.jobs:run':
'Uruchamianie zadeklarowanych zadań w tle według harmonogramu (bez kontekstu użytkownika — nie może odczytywać danych użytkownika)',
'admin.plugins.perm.db:write:costs':
'Tworzenie wydatków (pozycji budżetu) w podróżach, które działający użytkownik może edytować',
'admin.plugins.perm.db:write:places':
@@ -279,19 +308,25 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:trip-warning-provider':
'Zgłaszanie ostrzeżeń walidacyjnych dla podróży (widocznych w planerze)',
'admin.plugins.perm.hook:table-contributor': 'Dodawanie kolumn i akcji do widoków podróży (rezerwacje, miejsca, dni)',
'admin.plugins.perm.hook:map-marker-provider': 'Dodawanie znaczników do mapy podróży (np. pokazywanie rezerwacji lub POI)',
'admin.plugins.perm.hook:map-marker-provider':
'Dodawanie znaczników do mapy podróży (np. pokazywanie rezerwacji lub POI)',
'admin.plugins.perm.hook:pdf-section-provider': 'Dołączanie sekcji tekstowych do eksportu PDF podróży',
'admin.plugins.perm.hook:atlas-layer-provider':
'Wyróżnianie krajów na mapie świata Atlasu (np. listy życzeń lub ostrzeżenia dla podróżnych)',
'admin.plugins.perm.hook:journal-entry-provider':
'Dodawanie dodatkowych wierszy (linki, statystyki) do wpisów dziennika',
'admin.plugins.perm.hook:user-data': 'Usuwanie lub eksport przechowywanych danych o użytkowniku (RODO: usunięcie konta i wnioski o dane)',
'admin.plugins.perm.hook:trip-card-provider': 'Dodawanie małych plakietek (status, liczniki) do kart podróży na pulpicie',
'admin.plugins.perm.hook:user-data':
'Usuwanie lub eksport przechowywanych danych o użytkowniku (RODO: usunięcie konta i wnioski o dane)',
'admin.plugins.perm.hook:trip-card-provider':
'Dodawanie małych plakietek (status, liczniki) do kart podróży na pulpicie',
'admin.plugins.perm.hook:notification-channel': 'Dostarczać Twoje powiadomienia dodatkowym kanałem',
'admin.plugins.perm.events:subscribe':
'Reagowanie na podstawowe zdarzenia aktywności (tylko nazwa zdarzenia i podróż, nigdy treść)',
'admin.plugins.perm.http:outbound': 'Wykonywanie wychodzących zapytań do zadeklarowanych hostów',
'admin.plugins.perm.db:read:collab': 'Odczyt notatek, ankiet i wiadomości czatu w podróżach, do których działający użytkownik ma dostęp (wymaga dodatku Collab)',
'admin.plugins.perm.db:read:files:content': 'Odczyt zawartości (bajtów) plików w podróżach, do których działający użytkownik ma dostęp',
'admin.plugins.perm.db:read:collab':
'Odczyt notatek, ankiet i wiadomości czatu w podróżach, do których działający użytkownik ma dostęp (wymaga dodatku Collab)',
'admin.plugins.perm.db:read:files:content':
'Odczyt zawartości (bajtów) plików w podróżach, do których działający użytkownik ma dostęp',
'admin.plugins.perm.db:create:trips': 'Tworzenie nowych podróży należących do działającego użytkownika',
'admin.plugins.perm.rates:read': 'Odczyt zapisanych w pamięci kursów walut hosta',
'admin.plugins.updateConsentTitle': 'Ta aktualizacja wymaga nowych uprawnień',
@@ -362,6 +397,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Szczegóły rezerwacji',
'admin.plugins.cap.replacesTabs': 'Zastępuje karty planera',
'admin.plugins.cap.realtime': 'Aktualizacje na żywo',
'admin.plugins.cap.notificationChannel': 'Kanał powiadomień',
'admin.plugins.cap.photos': 'Dostarcza zdjęcia',
'admin.plugins.cap.calendar': 'Dostarcza wydarzenia kalendarza',
'admin.plugins.cap.placeDetails': 'Wzbogaca miejsca',
+8
View File
@@ -284,6 +284,14 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.testFailed': 'Testowe powiadomienie Ntfy nie powiodło się',
'settings.ntfyUrl.tokenCleared': 'Token dostępu wyczyszczony',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Jeszcze nieskonfigurowane — ustaw to w ustawieniach wtyczki',
'settings.plugins.actions': 'Akcje',
'settings.plugins.actions.confirm': 'Wykonać tę akcję?',
'settings.notificationPreferences.sendTest': 'Wyślij test',
'settings.notificationPreferences.configure': 'Skonfiguruj',
'settings.notificationPreferences.testSuccess': 'Wysłano powiadomienie testowe.',
'settings.notificationPreferences.testFailed': 'Test nie powiódł się.',
'settings.notificationPreferences.pluginConfigured': 'Skonfigurowano. Dane logowania zmienisz na stronie ustawień wtyczki.',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
+63 -26
View File
@@ -169,7 +169,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'Плагин “{name}” загружен — активируйте для запуска',
'admin.plugins.sideloaded': 'Загружен вручную',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Загружено из локального каталога сборки и горячо перезагружается с реальными данными — только для разработки',
'admin.plugins.devLinkHint':
'Загружено из локального каталога сборки и горячо перезагружается с реальными данными — только для разработки',
'admin.plugins.devLinkTitle': 'Подключить локальный плагин',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Подключить',
@@ -186,6 +187,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Плагин деактивирован',
'admin.plugins.viewErrors': 'Показать журнал ошибок',
'admin.plugins.errorLog': 'Журнал ошибок',
'admin.plugins.allowedHosts': 'Разрешённые хосты',
'admin.plugins.allowedHosts.hint': 'Этот плагин обращается к сервису, который можете указать только вы (self-hosted сервер). Добавьте хосты, к которым он может обращаться — к другим он не сможет.',
'admin.plugins.allowedHosts.none': 'Хосты пока не добавлены.',
'admin.plugins.allowedHosts.unsupported': 'Этот плагин не использует хосты, задаваемые оператором. Его разрешённые хосты зафиксированы в манифесте.',
'admin.plugins.allowedHosts.restartNote': 'Сохранение перезапускает плагин, чтобы он подхватил новый список.',
'admin.plugins.allowedHosts.add': 'Добавить разрешённый хост',
'admin.plugins.allowedHosts.count': 'Разрешённых хостов: {n}',
'admin.plugins.operatorEgressPill': '+ добавленные вами хосты',
'admin.plugins.operatorEgressHint': 'Этот плагин обращается к сервису, который можете указать только вы (self-hosted сервер). После установки добавьте хосты в ⋯ → Разрешённые хосты. К другим он не сможет обратиться.',
'admin.plugins.noErrors': 'Ошибок не зафиксировано.',
'admin.plugins.uninstalled': 'Плагин удалён',
'admin.plugins.uninstallTitle': 'Удалить плагин?',
@@ -236,33 +246,52 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:users': 'Читать основную информацию профиля (имя, аватар — но не учётные данные)',
'admin.plugins.perm.db:read:costs': 'Читать расходы (статьи бюджета), доступные текущему пользователю',
'admin.plugins.perm.db:read:packing': 'Читать списки вещей поездок, доступных текущему пользователю',
'admin.plugins.perm.db:write:packing': 'Создавать, редактировать и удалять пункты списка вещей в поездках, которые текущий пользователь может редактировать',
'admin.plugins.perm.db:write:packing':
'Создавать, редактировать и удалять пункты списка вещей в поездках, которые текущий пользователь может редактировать',
'admin.plugins.perm.weather:read': 'Читать кэшированный прогноз погоды хоста (по координатам)',
'admin.plugins.perm.db:read:categories': 'Читать глобальный список категорий мест',
'admin.plugins.perm.db:read:tags': 'Читать теги текущего пользователя',
'admin.plugins.perm.db:write:tags': 'Создавать, редактировать и удалять теги текущего пользователя',
'admin.plugins.perm.db:read:todos': 'Читать задачи поездок, доступных текущему пользователю',
'admin.plugins.perm.db:write:todos': 'Создавать, редактировать и удалять задачи в поездках, которые текущий пользователь может редактировать',
'admin.plugins.perm.db:write:atlas': 'Отмечать страны и регионы как посещённые и управлять списком желаний текущего пользователя (требуется дополнение Atlas)',
'admin.plugins.perm.db:write:vacay': 'Переключать дни отпуска и корпоративные праздники в активном плане текущего пользователя (требуется дополнение Vacay)',
'admin.plugins.perm.db:write:journal': 'Создавать, редактировать и удалять записи в дневниках, которые текущий пользователь может редактировать (требуется дополнение Journey)',
'admin.plugins.perm.db:write:collections': 'Создавать и редактировать коллекции и сохранять в них места с ролью текущего пользователя в коллекции (требуется дополнение Collections)',
'admin.plugins.perm.db:write:files': 'Прикреплять файлы к поездкам, которые текущий пользователь может редактировать (лимит 10 МБ, заблокированные расширения отклоняются), и управлять их связями',
'admin.plugins.perm.db:write:collab': 'Публиковать заметки, опросы и сообщения чата в поездках, которые текущий пользователь может редактировать (требуется дополнение Collab)',
'admin.plugins.perm.db:write:members': 'Добавлять пользователей в поездки — предоставляет доступ к поездке; защищено правом управления участниками текущего пользователя',
'admin.plugins.perm.notify:send': 'Отправлять уведомление (колокольчик + email/ntfy) текущему пользователю или его поездке — никогда произвольному получателю',
'admin.plugins.perm.ai:invoke': 'Использовать настроенного администратором ИИ-провайдера (дополнение текста + извлечение из документов) от имени текущего пользователя — плагин никогда не хранит ключ',
'admin.plugins.perm.oauth:client': 'Подключаться к стороннему сервису от вашего имени через OAuth, посредником которого выступает хост (хост хранит токены, плагин их не видит)',
'admin.plugins.perm.db:write:todos':
'Создавать, редактировать и удалять задачи в поездках, которые текущий пользователь может редактировать',
'admin.plugins.perm.db:write:atlas':
'Отмечать страны и регионы как посещённые и управлять списком желаний текущего пользователя (требуется дополнение Atlas)',
'admin.plugins.perm.db:write:vacay':
'Переключать дни отпуска и корпоративные праздники в активном плане текущего пользователя (требуется дополнение Vacay)',
'admin.plugins.perm.db:write:journal':
'Создавать, редактировать и удалять записи в дневниках, которые текущий пользователь может редактировать (требуется дополнение Journey)',
'admin.plugins.perm.db:write:collections':
'Создавать и редактировать коллекции и сохранять в них места с ролью текущего пользователя в коллекции (требуется дополнение Collections)',
'admin.plugins.perm.db:write:files':
'Прикреплять файлы к поездкам, которые текущий пользователь может редактировать (лимит 10 МБ, заблокированные расширения отклоняются), и управлять их связями',
'admin.plugins.perm.db:write:collab':
'Публиковать заметки, опросы и сообщения чата в поездках, которые текущий пользователь может редактировать (требуется дополнение Collab)',
'admin.plugins.perm.db:write:members':
'Добавлять пользователей в поездки — предоставляет доступ к поездке; защищено правом управления участниками текущего пользователя',
'admin.plugins.perm.notify:send':
'Отправлять уведомление (колокольчик + email/ntfy) текущему пользователю или его поездке — никогда произвольному получателю',
'admin.plugins.perm.ai:invoke':
'Использовать настроенного администратором ИИ-провайдера (дополнение текста + извлечение из документов) от имени текущего пользователя — плагин никогда не хранит ключ',
'admin.plugins.perm.oauth:client':
'Подключаться к стороннему сервису от вашего имени через OAuth, посредником которого выступает хост (хост хранит токены, плагин их не видит)',
'admin.plugins.perm.db:read:files': 'Читать файлы поездок, доступных текущему пользователю',
'admin.plugins.perm.db:write:reservations': 'Создавать, редактировать и удалять бронирования в поездках, которые текущий пользователь может редактировать',
'admin.plugins.perm.db:write:accommodations': 'Создавать, редактировать и удалять размещения в поездках, которые текущий пользователь может редактировать',
'admin.plugins.perm.db:read:journal': 'Читать дневники путешествий текущего пользователя (требуется дополнение Journey)',
'admin.plugins.perm.db:read:atlas': 'Читать посещённые страны и регионы текущего пользователя (требуется дополнение Atlas)',
'admin.plugins.perm.db:write:reservations':
'Создавать, редактировать и удалять бронирования в поездках, которые текущий пользователь может редактировать',
'admin.plugins.perm.db:write:accommodations':
'Создавать, редактировать и удалять размещения в поездках, которые текущий пользователь может редактировать',
'admin.plugins.perm.db:read:journal':
'Читать дневники путешествий текущего пользователя (требуется дополнение Journey)',
'admin.plugins.perm.db:read:atlas':
'Читать посещённые страны и регионы текущего пользователя (требуется дополнение Atlas)',
'admin.plugins.perm.db:read:vacay': 'Читать план отпуска текущего пользователя (требуется дополнение Vacay)',
'admin.plugins.perm.db:read:daynotes': 'Читать заметки дня поездок, доступных текущему пользователю',
'admin.plugins.perm.db:read:collections': 'Читать коллекции сохранённых мест текущего пользователя (требуется дополнение Collections)',
'admin.plugins.perm.db:write:daynotes': 'Создавать, редактировать и удалять заметки дня в поездках, которые текущий пользователь может редактировать',
'admin.plugins.perm.jobs:run': 'Запускать объявленные фоновые задачи по расписанию (без контекста пользователя — не может читать данные пользователя)',
'admin.plugins.perm.db:read:collections':
'Читать коллекции сохранённых мест текущего пользователя (требуется дополнение Collections)',
'admin.plugins.perm.db:write:daynotes':
'Создавать, редактировать и удалять заметки дня в поездках, которые текущий пользователь может редактировать',
'admin.plugins.perm.jobs:run':
'Запускать объявленные фоновые задачи по расписанию (без контекста пользователя — не может читать данные пользователя)',
'admin.plugins.perm.db:write:costs':
'Создавать расходы (статьи бюджета) в поездках, которые текущий пользователь может редактировать',
'admin.plugins.perm.db:write:places':
@@ -282,20 +311,27 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:place-detail-provider': 'Добавлять дополнительные сведения (отзывы, оценки, ссылки) к месту',
'admin.plugins.perm.hook:trip-warning-provider':
'Выдавать предупреждения проверки для поездки (отображаются в планировщике)',
'admin.plugins.perm.hook:table-contributor': 'Добавлять столбцы и действия в представления поездки (бронирования, места, дни)',
'admin.plugins.perm.hook:map-marker-provider': 'Добавлять маркеры на карту поездки (например, показывать бронирования или POI)',
'admin.plugins.perm.hook:table-contributor':
'Добавлять столбцы и действия в представления поездки (бронирования, места, дни)',
'admin.plugins.perm.hook:map-marker-provider':
'Добавлять маркеры на карту поездки (например, показывать бронирования или POI)',
'admin.plugins.perm.hook:pdf-section-provider': 'Добавлять текстовые разделы в PDF-экспорт поездки',
'admin.plugins.perm.hook:atlas-layer-provider':
'Выделять страны на карте мира в Атласе (например, списки желаний или предупреждения для путешественников)',
'admin.plugins.perm.hook:journal-entry-provider':
'Добавлять дополнительные строки (ссылки, статистику) к записям дневника',
'admin.plugins.perm.hook:user-data': 'Удаление или экспорт хранимых данных о пользователе (GDPR: удаление аккаунта и запросы данных)',
'admin.plugins.perm.hook:trip-card-provider': 'Добавлять небольшие значки (статус, счётчики) на карточки поездок на панели',
'admin.plugins.perm.hook:user-data':
'Удаление или экспорт хранимых данных о пользователе (GDPR: удаление аккаунта и запросы данных)',
'admin.plugins.perm.hook:trip-card-provider':
'Добавлять небольшие значки (статус, счётчики) на карточки поездок на панели',
'admin.plugins.perm.hook:notification-channel': 'Доставлять ваши уведомления через дополнительный канал',
'admin.plugins.perm.events:subscribe':
'Реагировать на основные события активности (только название события и поездка, никогда содержимое)',
'admin.plugins.perm.http:outbound': 'Выполнять исходящие запросы к заявленным хостам',
'admin.plugins.perm.db:read:collab': 'Читать заметки, опросы и сообщения чата поездок, доступных текущему пользователю (требуется дополнение Collab)',
'admin.plugins.perm.db:read:files:content': 'Читать содержимое (байты) файлов поездок, доступных текущему пользователю',
'admin.plugins.perm.db:read:collab':
'Читать заметки, опросы и сообщения чата поездок, доступных текущему пользователю (требуется дополнение Collab)',
'admin.plugins.perm.db:read:files:content':
'Читать содержимое (байты) файлов поездок, доступных текущему пользователю',
'admin.plugins.perm.db:create:trips': 'Создавать новые поездки, принадлежащие текущему пользователю',
'admin.plugins.perm.rates:read': 'Читать кэшированные курсы валют хоста',
'admin.plugins.updateConsentTitle': 'Для этого обновления нужны новые разрешения',
@@ -365,6 +401,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Детали брони',
'admin.plugins.cap.replacesTabs': 'Заменяет вкладки планировщика',
'admin.plugins.cap.realtime': 'Обновления в реальном времени',
'admin.plugins.cap.notificationChannel': 'Канал уведомлений',
'admin.plugins.cap.photos': 'Предоставляет фото',
'admin.plugins.cap.calendar': 'Предоставляет события календаря',
'admin.plugins.cap.placeDetails': 'Обогащает места',
+8
View File
@@ -289,6 +289,14 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.testFailed': 'Ошибка отправки тестового уведомления Ntfy',
'settings.ntfyUrl.tokenCleared': 'Токен доступа очищен',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Ещё не настроено — настройте в параметрах плагина',
'settings.plugins.actions': 'Действия',
'settings.plugins.actions.confirm': 'Выполнить это действие?',
'settings.notificationPreferences.sendTest': 'Отправить тест',
'settings.notificationPreferences.configure': 'Настроить',
'settings.notificationPreferences.testSuccess': 'Тестовое уведомление отправлено.',
'settings.notificationPreferences.testFailed': 'Тест не удался.',
'settings.notificationPreferences.pluginConfigured': 'Настроено. Учётные данные — на странице настроек плагина.',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
+59 -24
View File
@@ -225,7 +225,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'Pluginet “{name}” uppladdat — aktivera det för att köra',
'admin.plugins.sideloaded': 'Manuellt laddad',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Laddad från en lokal byggkatalog och hot-reload mot riktig data — endast för utveckling',
'admin.plugins.devLinkHint':
'Laddad från en lokal byggkatalog och hot-reload mot riktig data — endast för utveckling',
'admin.plugins.devLinkTitle': 'Länka ett lokalt plugin',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Länka',
@@ -242,6 +243,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Pluginet har inaktiverats',
'admin.plugins.viewErrors': 'Visa felloggen',
'admin.plugins.errorLog': 'Fellogg',
'admin.plugins.allowedHosts': 'Tillåtna värdar',
'admin.plugins.allowedHosts.hint': 'Detta plugin pratar med en tjänst som bara du kan namnge (en självhostad server). Lägg till de värdar det får nå — inga andra.',
'admin.plugins.allowedHosts.none': 'Inga värdar tillagda ännu.',
'admin.plugins.allowedHosts.unsupported': 'Detta plugin använder inte operatörsangivna värdar. Dess tillåtna värdar är låsta i manifestet.',
'admin.plugins.allowedHosts.restartNote': 'Att spara startar om pluginet så att den nya listan används.',
'admin.plugins.allowedHosts.add': 'Lägg till tillåten värd',
'admin.plugins.allowedHosts.count': '{n} tillåtna värdar',
'admin.plugins.operatorEgressPill': '+ värdar du lägger till',
'admin.plugins.operatorEgressHint': 'Detta plugin pratar med en tjänst som bara du kan namnge (en självhostad server). Lägg efter installation till värdarna under ⋯ → Tillåtna värdar. Inga andra nås.',
'admin.plugins.noErrors': 'Inga fel loggade.',
'admin.plugins.uninstalled': 'Pluginet har avinstallerats',
'admin.plugins.uninstallTitle': 'Avinstallera pluginet?',
@@ -293,33 +303,51 @@ const admin: TranslationStrings = {
'Läsa grundläggande profilinformation (namn, avatar – aldrig inloggningsuppgifter)',
'admin.plugins.perm.db:read:costs': 'Läsa kostnader (budgetposter) som den aktiva användaren har åtkomst till',
'admin.plugins.perm.db:read:packing': 'Läsa packlistor för resor som den aktiva användaren har åtkomst till',
'admin.plugins.perm.db:write:packing': 'Skapa, redigera och ta bort packlisteposter på resor som den aktiva användaren kan redigera',
'admin.plugins.perm.db:write:packing':
'Skapa, redigera och ta bort packlisteposter på resor som den aktiva användaren kan redigera',
'admin.plugins.perm.weather:read': 'Läsa värdens cachade väderprognos (efter koordinater)',
'admin.plugins.perm.db:read:categories': 'Läsa den globala listan över platskategorier',
'admin.plugins.perm.db:read:tags': 'Läsa den aktiva användarens taggar',
'admin.plugins.perm.db:write:tags': 'Skapa, redigera och ta bort den aktiva användarens taggar',
'admin.plugins.perm.db:read:todos': 'Läsa uppgifter för resor som den aktiva användaren har åtkomst till',
'admin.plugins.perm.db:write:todos': 'Skapa, redigera och ta bort uppgifter på resor som den aktiva användaren kan redigera',
'admin.plugins.perm.db:write:atlas': 'Markera länder och regioner som besökta och hantera den aktiva användarens bucketlista (kräver tillägget Atlas)',
'admin.plugins.perm.db:write:vacay': 'Växla semesterdagar och företagshelgdagar i den aktiva användarens aktiva plan (kräver tillägget Vacay)',
'admin.plugins.perm.db:write:journal': 'Skapa, redigera och ta bort dagboksinlägg i dagböcker som den aktiva användaren kan redigera (kräver tillägget Journey)',
'admin.plugins.perm.db:write:collections': 'Skapa och redigera samlingar och spara platser i dem, med den aktiva användarens samlingsroll (kräver tillägget Collections)',
'admin.plugins.perm.db:write:files': 'Bifoga filer till resor som den aktiva användaren kan redigera (10 MB-gräns, blockerade filändelser avvisas) och hantera deras länkar',
'admin.plugins.perm.db:write:collab': 'Publicera anteckningar, omröstningar och chattmeddelanden på resor som den aktiva användaren kan redigera (kräver tillägget Collab)',
'admin.plugins.perm.db:write:members': 'Lägga till användare i resor — ger resåtkomst; skyddas av den aktiva användarens medlemshanteringsrätt',
'admin.plugins.perm.notify:send': 'Skicka en avisering (klockinkorg + e-post/ntfy) till den aktiva användaren eller en resa hen tillhör — aldrig till en godtycklig mottagare',
'admin.plugins.perm.ai:invoke': 'Använda den administratörskonfigurerade AI-leverantören (textkomplettering + dokumentextraktion) å den aktiva användarens vägnar — tillägget håller aldrig en nyckel',
'admin.plugins.perm.oauth:client': 'Ansluta till en tredjepartstjänst för din räkning via värdförmedlad OAuth (värden håller token, tillägget ser dem aldrig)',
'admin.plugins.perm.db:write:todos':
'Skapa, redigera och ta bort uppgifter på resor som den aktiva användaren kan redigera',
'admin.plugins.perm.db:write:atlas':
'Markera länder och regioner som besökta och hantera den aktiva användarens bucketlista (kräver tillägget Atlas)',
'admin.plugins.perm.db:write:vacay':
'Växla semesterdagar och företagshelgdagar i den aktiva användarens aktiva plan (kräver tillägget Vacay)',
'admin.plugins.perm.db:write:journal':
'Skapa, redigera och ta bort dagboksinlägg i dagböcker som den aktiva användaren kan redigera (kräver tillägget Journey)',
'admin.plugins.perm.db:write:collections':
'Skapa och redigera samlingar och spara platser i dem, med den aktiva användarens samlingsroll (kräver tillägget Collections)',
'admin.plugins.perm.db:write:files':
'Bifoga filer till resor som den aktiva användaren kan redigera (10 MB-gräns, blockerade filändelser avvisas) och hantera deras länkar',
'admin.plugins.perm.db:write:collab':
'Publicera anteckningar, omröstningar och chattmeddelanden på resor som den aktiva användaren kan redigera (kräver tillägget Collab)',
'admin.plugins.perm.db:write:members':
'Lägga till användare i resor — ger resåtkomst; skyddas av den aktiva användarens medlemshanteringsrätt',
'admin.plugins.perm.notify:send':
'Skicka en avisering (klockinkorg + e-post/ntfy) till den aktiva användaren eller en resa hen tillhör — aldrig till en godtycklig mottagare',
'admin.plugins.perm.ai:invoke':
'Använda den administratörskonfigurerade AI-leverantören (textkomplettering + dokumentextraktion) å den aktiva användarens vägnar — tillägget håller aldrig en nyckel',
'admin.plugins.perm.oauth:client':
'Ansluta till en tredjepartstjänst för din räkning via värdförmedlad OAuth (värden håller token, tillägget ser dem aldrig)',
'admin.plugins.perm.db:read:files': 'Läsa filer för resor som den aktiva användaren har åtkomst till',
'admin.plugins.perm.db:write:reservations': 'Skapa, redigera och ta bort bokningar på resor som den aktiva användaren kan redigera',
'admin.plugins.perm.db:write:accommodations': 'Skapa, redigera och ta bort boenden på resor som den aktiva användaren kan redigera',
'admin.plugins.perm.db:write:reservations':
'Skapa, redigera och ta bort bokningar på resor som den aktiva användaren kan redigera',
'admin.plugins.perm.db:write:accommodations':
'Skapa, redigera och ta bort boenden på resor som den aktiva användaren kan redigera',
'admin.plugins.perm.db:read:journal': 'Läsa den aktiva användarens resedagböcker (kräver tillägget Journey)',
'admin.plugins.perm.db:read:atlas': 'Läsa den aktiva användarens besökta länder och regioner (kräver tillägget Atlas)',
'admin.plugins.perm.db:read:atlas':
'Läsa den aktiva användarens besökta länder och regioner (kräver tillägget Atlas)',
'admin.plugins.perm.db:read:vacay': 'Läsa den aktiva användarens semesterplan (kräver tillägget Vacay)',
'admin.plugins.perm.db:read:daynotes': 'Läsa daganteckningar för resor som den aktiva användaren har åtkomst till',
'admin.plugins.perm.db:read:collections': 'Läsa den aktiva användarens samlingar med sparade platser (kräver tillägget Collections)',
'admin.plugins.perm.db:write:daynotes': 'Skapa, redigera och ta bort daganteckningar på resor som den aktiva användaren kan redigera',
'admin.plugins.perm.jobs:run': 'Köra sina deklarerade bakgrundsjobb enligt ett schema (utan användarkontext — kan inte läsa användardata)',
'admin.plugins.perm.db:read:collections':
'Läsa den aktiva användarens samlingar med sparade platser (kräver tillägget Collections)',
'admin.plugins.perm.db:write:daynotes':
'Skapa, redigera och ta bort daganteckningar på resor som den aktiva användaren kan redigera',
'admin.plugins.perm.jobs:run':
'Köra sina deklarerade bakgrundsjobb enligt ett schema (utan användarkontext — kan inte läsa användardata)',
'admin.plugins.perm.db:write:costs': 'Skapa kostnader (budgetposter) på resor som den aktiva användaren kan redigera',
'admin.plugins.perm.db:write:places':
'Lägga till, redigera och ta bort platser på resor som den aktiva användaren kan redigera',
@@ -338,19 +366,25 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:place-detail-provider':
'Bidra med extra detaljer (recensioner, betyg, länkar) till en plats',
'admin.plugins.perm.hook:trip-warning-provider': 'Visa valideringsvarningar på en resa (visas i planeraren)',
'admin.plugins.perm.hook:table-contributor': 'Lägga till kolumner och åtgärder i rese­vyer (bokningar, platser, dagar)',
'admin.plugins.perm.hook:table-contributor':
'Lägga till kolumner och åtgärder i rese­vyer (bokningar, platser, dagar)',
'admin.plugins.perm.hook:map-marker-provider': 'Lägga till markörer på reskartan (t.ex. visa bokningar eller POI:er)',
'admin.plugins.perm.hook:pdf-section-provider': 'Lägga till textavsnitt i resans PDF-export',
'admin.plugins.perm.hook:atlas-layer-provider':
'Markera länder på Atlas-världskartan (t.ex. önskelistor eller resevarningar)',
'admin.plugins.perm.hook:journal-entry-provider': 'Lägga till extra rader (länkar, statistik) i dagboksinlägg',
'admin.plugins.perm.hook:user-data': 'Radera eller exportera data den lagrar om en användare (GDPR: kontoborttagning och dataförfrågningar)',
'admin.plugins.perm.hook:trip-card-provider': 'Lägga till små märken (status, antal) på resekorten på instrumentpanelen',
'admin.plugins.perm.hook:user-data':
'Radera eller exportera data den lagrar om en användare (GDPR: kontoborttagning och dataförfrågningar)',
'admin.plugins.perm.hook:trip-card-provider':
'Lägga till små märken (status, antal) på resekorten på instrumentpanelen',
'admin.plugins.perm.hook:notification-channel': 'Leverera dina aviseringar via en ytterligare kanal',
'admin.plugins.perm.events:subscribe':
'Reagera på grundläggande aktivitetshändelser (endast händelsenamn + resa, aldrig innehållet)',
'admin.plugins.perm.http:outbound': 'Göra utgående anrop till sina deklarerade värdar',
'admin.plugins.perm.db:read:collab': 'Läsa anteckningar, omröstningar och chattmeddelanden för resor som den aktiva användaren har åtkomst till (kräver tillägget Collab)',
'admin.plugins.perm.db:read:files:content': 'Läsa innehållet (byte) i filer för resor som den aktiva användaren har åtkomst till',
'admin.plugins.perm.db:read:collab':
'Läsa anteckningar, omröstningar och chattmeddelanden för resor som den aktiva användaren har åtkomst till (kräver tillägget Collab)',
'admin.plugins.perm.db:read:files:content':
'Läsa innehållet (byte) i filer för resor som den aktiva användaren har åtkomst till',
'admin.plugins.perm.db:create:trips': 'Skapa nya resor som ägs av den aktiva användaren',
'admin.plugins.perm.rates:read': 'Läsa värdens cachade växelkurser',
'admin.plugins.updateConsentTitle': 'Den här uppdateringen kräver nya behörigheter',
@@ -421,6 +455,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Bokningsdetalj',
'admin.plugins.cap.replacesTabs': 'Ersätter planeringsflikar',
'admin.plugins.cap.realtime': 'Realtidsuppdateringar',
'admin.plugins.cap.notificationChannel': 'Aviseringskanal',
'admin.plugins.cap.photos': 'Tillhandahåller foton',
'admin.plugins.cap.calendar': 'Tillhandahåller kalenderhändelser',
'admin.plugins.cap.placeDetails': 'Berikar platser',
+8
View File
@@ -95,6 +95,14 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.email': 'E-post',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'I appen',
'settings.notificationPreferences.notConfigured': 'Inte konfigurerat än — ställ in det i pluginets inställningar',
'settings.plugins.actions': 'Åtgärder',
'settings.plugins.actions.confirm': 'Kör den här åtgärden?',
'settings.notificationPreferences.sendTest': 'Skicka test',
'settings.notificationPreferences.configure': 'Konfigurera',
'settings.notificationPreferences.testSuccess': 'Testavisering skickad.',
'settings.notificationPreferences.testFailed': 'Testet misslyckades.',
'settings.notificationPreferences.pluginConfigured': 'Konfigurerat. Hantera dina uppgifter på pluginets inställningssida.',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.noChannels':
'Inga meddelandekanaler har konfigurerats. Be en administratör att ställa in e-post- eller webhook-meddelanden.',
+63 -26
View File
@@ -213,7 +213,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': '“{name}” eklentisi yüklendi — çalıştırmak için etkinleştirin',
'admin.plugins.sideloaded': 'Elle yüklendi',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Yerel bir derleme dizininden yüklenir ve gerçek verilerle anında yeniden yüklenir — yalnızca geliştirme',
'admin.plugins.devLinkHint':
'Yerel bir derleme dizininden yüklenir ve gerçek verilerle anında yeniden yüklenir — yalnızca geliştirme',
'admin.plugins.devLinkTitle': 'Yerel bir eklenti bağla',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Bağla',
@@ -230,6 +231,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Eklenti devre dışı bırakıldı',
'admin.plugins.viewErrors': 'Hata günlüğünü görüntüle',
'admin.plugins.errorLog': 'Hata günlüğü',
'admin.plugins.allowedHosts': 'İzin verilen ana bilgisayarlar',
'admin.plugins.allowedHosts.hint': 'Bu eklenti yalnızca sizin adlandırabileceğiniz bir hizmete bağlanır (kendi barındırdığınız sunucu). Erişebileceği ana bilgisayarları ekleyin — başkasına erişemez.',
'admin.plugins.allowedHosts.none': 'Henüz ana bilgisayar eklenmedi.',
'admin.plugins.allowedHosts.unsupported': 'Bu eklenti operatör tarafından verilen ana bilgisayarları kullanmaz. İzin verilenler manifestinde sabittir.',
'admin.plugins.allowedHosts.restartNote': 'Kaydetmek, yeni listeyi alması için eklentiyi yeniden başlatır.',
'admin.plugins.allowedHosts.add': 'İzin verilen ana bilgisayar ekle',
'admin.plugins.allowedHosts.count': '{n} izin verilen ana bilgisayar',
'admin.plugins.operatorEgressPill': '+ eklediğiniz ana bilgisayarlar',
'admin.plugins.operatorEgressHint': 'Bu eklenti yalnızca sizin adlandırabileceğiniz bir hizmete bağlanır (kendi barındırdığınız sunucu). Kurulumdan sonra ⋯ → İzin verilen ana bilgisayarlar altından ekleyin. Başkasına erişemez.',
'admin.plugins.noErrors': 'Kayıtlı hata yok.',
'admin.plugins.uninstalled': 'Eklenti kaldırıldı',
'admin.plugins.uninstallTitle': 'Eklenti kaldırılsın mı?',
@@ -280,33 +290,52 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:users': 'Temel profil bilgilerini okur (ad, avatar — asla kimlik bilgileri değil)',
'admin.plugins.perm.db:read:costs': 'İşlemi yapan kullanıcının erişebildiği harcamaları (bütçe kalemleri) okur',
'admin.plugins.perm.db:read:packing': 'İşlemi yapan kullanıcının erişebildiği seyahatlerin eşya listelerini okur',
'admin.plugins.perm.db:write:packing': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde bavul listesi öğeleri oluşturur, düzenler ve siler',
'admin.plugins.perm.db:write:packing':
'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde bavul listesi öğeleri oluşturur, düzenler ve siler',
'admin.plugins.perm.weather:read': 'Ana makinenin önbelleğe alınmış hava durumu tahminini okur (koordinatlara göre)',
'admin.plugins.perm.db:read:categories': 'Genel yer kategorisi listesini okur',
'admin.plugins.perm.db:read:tags': 'İşlemi yapan kullanıcının etiketlerini okur',
'admin.plugins.perm.db:write:tags': 'İşlemi yapan kullanıcının etiketlerini oluşturur, düzenler ve siler',
'admin.plugins.perm.db:read:todos': 'İşlemi yapan kullanıcının erişebildiği seyahatlerin yapılacaklarını okur',
'admin.plugins.perm.db:write:todos': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde yapılacaklar oluşturur, düzenler ve siler',
'admin.plugins.perm.db:write:atlas': 'Ülkeleri ve bölgeleri ziyaret edildi olarak işaretler ve işlemi yapan kullanıcının hedef listesini yönetir (Atlas eklentisi gerektirir)',
'admin.plugins.perm.db:write:vacay': 'İşlemi yapan kullanıcının etkin planında izin günlerini ve şirket tatillerini değiştirir (Vacay eklentisi gerektirir)',
'admin.plugins.perm.db:write:journal': 'İşlemi yapan kullanıcının düzenleyebildiği günlüklerde girişler oluşturur, düzenler ve siler (Journey eklentisi gerektirir)',
'admin.plugins.perm.db:write:collections': 'Koleksiyonlar oluşturur ve düzenler, işlemi yapan kullanıcının koleksiyon rolüyle içine yerler kaydeder (Collections eklentisi gerektirir)',
'admin.plugins.perm.db:write:files': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlere dosyalar ekler (10 MB sınırı, engellenen uzantılar reddedilir) ve bağlantılarını yönetir',
'admin.plugins.perm.db:write:collab': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde notlar, anketler ve sohbet mesajları yayınlar (Collab eklentisi gerektirir)',
'admin.plugins.perm.db:write:members': 'Seyahatlere kullanıcı ekler — seyahat erişimi verir; işlemi yapan kullanıcının üye yönetimi hakkıyla korunur',
'admin.plugins.perm.notify:send': 'İşlemi yapan kullanıcıya veya ait olduğu bir seyahate bildirim (zil kutusu + e-posta/ntfy) gönderir — asla rastgele bir alıcıya değil',
'admin.plugins.perm.ai:invoke': 'Yönetici tarafından yapılandırılan yapay zekâ sağlayıcısını (metin tamamlama + belge çıkarımı) işlemi yapan kullanıcı adına çalıştırır — eklenti asla anahtar tutmaz',
'admin.plugins.perm.oauth:client': 'Ana makine aracılı OAuth ile senin adına üçüncü taraf bir hizmete bağlanır (jetonları ana makine tutar, eklenti bunları hiç görmez)',
'admin.plugins.perm.db:write:todos':
'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde yapılacaklar oluşturur, düzenler ve siler',
'admin.plugins.perm.db:write:atlas':
'Ülkeleri ve bölgeleri ziyaret edildi olarak işaretler ve işlemi yapan kullanıcının hedef listesini yönetir (Atlas eklentisi gerektirir)',
'admin.plugins.perm.db:write:vacay':
'İşlemi yapan kullanıcının etkin planında izin günlerini ve şirket tatillerini değiştirir (Vacay eklentisi gerektirir)',
'admin.plugins.perm.db:write:journal':
'İşlemi yapan kullanıcının düzenleyebildiği günlüklerde girişler oluşturur, düzenler ve siler (Journey eklentisi gerektirir)',
'admin.plugins.perm.db:write:collections':
'Koleksiyonlar oluşturur ve düzenler, işlemi yapan kullanıcının koleksiyon rolüyle içine yerler kaydeder (Collections eklentisi gerektirir)',
'admin.plugins.perm.db:write:files':
'İşlemi yapan kullanıcının düzenleyebildiği seyahatlere dosyalar ekler (10 MB sınırı, engellenen uzantılar reddedilir) ve bağlantılarını yönetir',
'admin.plugins.perm.db:write:collab':
'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde notlar, anketler ve sohbet mesajları yayınlar (Collab eklentisi gerektirir)',
'admin.plugins.perm.db:write:members':
'Seyahatlere kullanıcı ekler — seyahat erişimi verir; işlemi yapan kullanıcının üye yönetimi hakkıyla korunur',
'admin.plugins.perm.notify:send':
'İşlemi yapan kullanıcıya veya ait olduğu bir seyahate bildirim (zil kutusu + e-posta/ntfy) gönderir — asla rastgele bir alıcıya değil',
'admin.plugins.perm.ai:invoke':
'Yönetici tarafından yapılandırılan yapay zekâ sağlayıcısını (metin tamamlama + belge çıkarımı) işlemi yapan kullanıcı adına çalıştırır — eklenti asla anahtar tutmaz',
'admin.plugins.perm.oauth:client':
'Ana makine aracılı OAuth ile senin adına üçüncü taraf bir hizmete bağlanır (jetonları ana makine tutar, eklenti bunları hiç görmez)',
'admin.plugins.perm.db:read:files': 'İşlemi yapan kullanıcının erişebildiği seyahatlerin dosyalarını okur',
'admin.plugins.perm.db:write:reservations': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde rezervasyonlar oluşturur, düzenler ve siler',
'admin.plugins.perm.db:write:accommodations': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde konaklamalar oluşturur, düzenler ve siler',
'admin.plugins.perm.db:read:journal': 'İşlemi yapan kullanıcının seyahat günlüklerini okur (Journey eklentisi gerektirir)',
'admin.plugins.perm.db:read:atlas': 'İşlemi yapan kullanıcının ziyaret ettiği ülkeleri ve bölgeleri okur (Atlas eklentisi gerektirir)',
'admin.plugins.perm.db:write:reservations':
'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde rezervasyonlar oluşturur, düzenler ve siler',
'admin.plugins.perm.db:write:accommodations':
'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde konaklamalar oluşturur, düzenler ve siler',
'admin.plugins.perm.db:read:journal':
'İşlemi yapan kullanıcının seyahat günlüklerini okur (Journey eklentisi gerektirir)',
'admin.plugins.perm.db:read:atlas':
'İşlemi yapan kullanıcının ziyaret ettiği ülkeleri ve bölgeleri okur (Atlas eklentisi gerektirir)',
'admin.plugins.perm.db:read:vacay': 'İşlemi yapan kullanıcının tatil planını okur (Vacay eklentisi gerektirir)',
'admin.plugins.perm.db:read:daynotes': 'İşlemi yapan kullanıcının erişebildiği seyahatlerin gün notlarını okur',
'admin.plugins.perm.db:read:collections': 'İşlemi yapan kullanıcının kaydedilen yer koleksiyonlarını okur (Collections eklentisi gerektirir)',
'admin.plugins.perm.db:write:daynotes': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde gün notları oluşturur, düzenler ve siler',
'admin.plugins.perm.jobs:run': 'Bildirdiği arka plan işlerini bir zamanlamaya göre çalıştırır (kullanıcı bağlamı yok — kullanıcı verilerini okuyamaz)',
'admin.plugins.perm.db:read:collections':
'İşlemi yapan kullanıcının kaydedilen yer koleksiyonlarını okur (Collections eklentisi gerektirir)',
'admin.plugins.perm.db:write:daynotes':
'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde gün notları oluşturur, düzenler ve siler',
'admin.plugins.perm.jobs:run':
'Bildirdiği arka plan işlerini bir zamanlamaya göre çalıştırır (kullanıcı bağlamı yok — kullanıcı verilerini okuyamaz)',
'admin.plugins.perm.db:write:costs':
'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde harcamalar (bütçe kalemleri) oluşturur',
'admin.plugins.perm.db:write:places':
@@ -326,19 +355,26 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:place-detail-provider':
'Bir yere ek ayrıntılar (yorumlar, puanlar, bağlantılar) katkıda bulunur',
'admin.plugins.perm.hook:trip-warning-provider': 'Bir gezide doğrulama uyarıları oluşturur (planlayıcıda gösterilir)',
'admin.plugins.perm.hook:table-contributor': 'Seyahat görünümlerine sütunlar ve eylemler ekler (rezervasyonlar, yerler, günler)',
'admin.plugins.perm.hook:map-marker-provider': "Seyahat haritasına işaretçiler ekler (ör. rezervasyonları veya POI'leri gösterir)",
'admin.plugins.perm.hook:table-contributor':
'Seyahat görünümlerine sütunlar ve eylemler ekler (rezervasyonlar, yerler, günler)',
'admin.plugins.perm.hook:map-marker-provider':
"Seyahat haritasına işaretçiler ekler (ör. rezervasyonları veya POI'leri gösterir)",
'admin.plugins.perm.hook:pdf-section-provider': 'Seyahat PDF dışa aktarımına metin bölümleri ekler',
'admin.plugins.perm.hook:atlas-layer-provider':
'Atlas dünya haritasında ülkeleri vurgular (ör. istek listeleri veya seyahat uyarıları)',
'admin.plugins.perm.hook:journal-entry-provider': 'Günlük girişlerine ek satırlar (bağlantılar, istatistikler) ekler',
'admin.plugins.perm.hook:user-data': 'Bir kullanıcı hakkında sakladığı verileri silme veya dışa aktarma (KVKK/GDPR: hesap silme ve veri talepleri)',
'admin.plugins.perm.hook:trip-card-provider': 'Kontrol panelindeki gezi kartlarına küçük rozetler (durum, sayaçlar) ekleme',
'admin.plugins.perm.hook:user-data':
'Bir kullanıcı hakkında sakladığı verileri silme veya dışa aktarma (KVKK/GDPR: hesap silme ve veri talepleri)',
'admin.plugins.perm.hook:trip-card-provider':
'Kontrol panelindeki gezi kartlarına küçük rozetler (durum, sayaçlar) ekleme',
'admin.plugins.perm.hook:notification-channel': 'Bildirimlerinizi ek bir kanal üzerinden iletmek',
'admin.plugins.perm.events:subscribe':
'Temel etkinlik olaylarına tepki verir (yalnızca olay adı + seyahat, asla içerik değil)',
'admin.plugins.perm.http:outbound': 'Bildirdiği ana bilgisayarlara giden istekler yapar',
'admin.plugins.perm.db:read:collab': 'İşlemi yapan kullanıcının erişebildiği seyahatlerin notlarını, anketlerini ve sohbet mesajlarını okur (Collab eklentisi gerektirir)',
'admin.plugins.perm.db:read:files:content': 'İşlemi yapan kullanıcının erişebildiği seyahatlerdeki dosyaların içeriğini (baytlar) okur',
'admin.plugins.perm.db:read:collab':
'İşlemi yapan kullanıcının erişebildiği seyahatlerin notlarını, anketlerini ve sohbet mesajlarını okur (Collab eklentisi gerektirir)',
'admin.plugins.perm.db:read:files:content':
'İşlemi yapan kullanıcının erişebildiği seyahatlerdeki dosyaların içeriğini (baytlar) okur',
'admin.plugins.perm.db:create:trips': 'İşlemi yapan kullanıcıya ait yeni seyahatler oluşturur',
'admin.plugins.perm.rates:read': 'Ana makinenin önbelleğe alınmış döviz kurlarını okur',
'admin.plugins.updateConsentTitle': 'Bu güncelleme yeni izinler gerektiriyor',
@@ -408,6 +444,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Rezervasyon detayı',
'admin.plugins.cap.replacesTabs': 'Planlayıcı sekmelerini değiştirir',
'admin.plugins.cap.realtime': 'Gerçek zamanlı güncellemeler',
'admin.plugins.cap.notificationChannel': 'Bildirim kanalı',
'admin.plugins.cap.photos': 'Fotoğraf sağlar',
'admin.plugins.cap.calendar': 'Takvim etkinlikleri sağlar',
'admin.plugins.cap.placeDetails': 'Yerleri zenginleştirir',
+8
View File
@@ -90,6 +90,14 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.email': 'E-posta',
'settings.notificationPreferences.webhook': 'Web kancası',
'settings.notificationPreferences.inapp': 'Uygulama içi',
'settings.notificationPreferences.notConfigured': 'Henüz yapılandırılmadı — eklentinin ayarlarından yapılandırın',
'settings.plugins.actions': 'Eylemler',
'settings.plugins.actions.confirm': 'Bu eylem çalıştırılsın mı?',
'settings.notificationPreferences.sendTest': 'Test gönder',
'settings.notificationPreferences.configure': 'Yapılandır',
'settings.notificationPreferences.testSuccess': 'Test bildirimi gönderildi.',
'settings.notificationPreferences.testFailed': 'Test başarısız.',
'settings.notificationPreferences.pluginConfigured': 'Yapılandırıldı. Kimlik bilgilerini eklentinin ayarlar sayfasından yönetin.',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.noChannels':
'Bildirim kanalı yapılandırılmadı. Yöneticiden e-posta veya webhook kurmasını isteyin.',
+59 -24
View File
@@ -169,7 +169,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'Плагін “{name}” завантажено — активуйте для запуску',
'admin.plugins.sideloaded': 'Завантажено вручну',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Завантажено з локального каталогу збірки та гаряче перезавантажується з реальними даними — лише для розробки',
'admin.plugins.devLinkHint':
'Завантажено з локального каталогу збірки та гаряче перезавантажується з реальними даними — лише для розробки',
'admin.plugins.devLinkTitle': 'Підключити локальний плагін',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Підключити',
@@ -186,6 +187,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Плагін деактивовано',
'admin.plugins.viewErrors': 'Переглянути журнал помилок',
'admin.plugins.errorLog': 'Журнал помилок',
'admin.plugins.allowedHosts': 'Дозволені хости',
'admin.plugins.allowedHosts.hint': 'Цей плагін звертається до сервісу, який можете вказати лише ви (self-hosted сервер). Додайте хости, до яких він може звертатися — до інших він не зможе.',
'admin.plugins.allowedHosts.none': 'Хости ще не додано.',
'admin.plugins.allowedHosts.unsupported': 'Цей плагін не використовує хости, задані оператором. Його дозволені хости зафіксовані в маніфесті.',
'admin.plugins.allowedHosts.restartNote': 'Збереження перезапускає плагін, щоб він підхопив новий список.',
'admin.plugins.allowedHosts.add': 'Додати дозволений хост',
'admin.plugins.allowedHosts.count': 'Дозволених хостів: {n}',
'admin.plugins.operatorEgressPill': '+ додані вами хости',
'admin.plugins.operatorEgressHint': 'Цей плагін звертається до сервісу, який можете вказати лише ви (self-hosted сервер). Після встановлення додайте хости в ⋯ → Дозволені хости. До інших він не зможе звертатися.',
'admin.plugins.noErrors': 'Помилок не зафіксовано.',
'admin.plugins.uninstalled': 'Плагін видалено',
'admin.plugins.uninstallTitle': 'Видалити плагін?',
@@ -235,33 +245,51 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:read:users': "Читати базову інформацію профілю (ім'я, аватар — ніколи не облікові дані)",
'admin.plugins.perm.db:read:costs': 'Читати витрати (статті бюджету), до яких має доступ поточний користувач',
'admin.plugins.perm.db:read:packing': 'Читати списки речей подорожей, до яких має доступ поточний користувач',
'admin.plugins.perm.db:write:packing': 'Створювати, редагувати та видаляти пункти списку речей у подорожах, які може редагувати поточний користувач',
'admin.plugins.perm.db:write:packing':
'Створювати, редагувати та видаляти пункти списку речей у подорожах, які може редагувати поточний користувач',
'admin.plugins.perm.weather:read': 'Читати кешований прогноз погоди хоста (за координатами)',
'admin.plugins.perm.db:read:categories': 'Читати глобальний список категорій місць',
'admin.plugins.perm.db:read:tags': 'Читати теги поточного користувача',
'admin.plugins.perm.db:write:tags': 'Створювати, редагувати та видаляти теги поточного користувача',
'admin.plugins.perm.db:read:todos': 'Читати завдання подорожей, до яких має доступ поточний користувач',
'admin.plugins.perm.db:write:todos': 'Створювати, редагувати та видаляти завдання в подорожах, які може редагувати поточний користувач',
'admin.plugins.perm.db:write:atlas': 'Позначати країни та регіони як відвідані та керувати списком бажань поточного користувача (потрібен додаток Atlas)',
'admin.plugins.perm.db:write:vacay': 'Перемикати дні відпустки та корпоративні свята в активному плані поточного користувача (потрібен додаток Vacay)',
'admin.plugins.perm.db:write:journal': 'Створювати, редагувати та видаляти записи в щоденниках, які може редагувати поточний користувач (потрібен додаток Journey)',
'admin.plugins.perm.db:write:collections': 'Створювати та редагувати колекції й зберігати в них місця з роллю поточного користувача в колекції (потрібен додаток Collections)',
'admin.plugins.perm.db:write:files': "Прикріплювати файли до подорожей, які може редагувати поточний користувач (ліміт 10 МБ, заблоковані розширення відхиляються), і керувати їхніми зв'язками",
'admin.plugins.perm.db:write:collab': 'Публікувати нотатки, опитування та повідомлення чату в подорожах, які може редагувати поточний користувач (потрібен додаток Collab)',
'admin.plugins.perm.db:write:members': 'Додавати користувачів до подорожей — надає доступ до подорожі; захищено правом керування учасниками поточного користувача',
'admin.plugins.perm.notify:send': 'Надсилати сповіщення (скринька дзвіночка + email/ntfy) поточному користувачеві або його подорожі — ніколи довільному одержувачу',
'admin.plugins.perm.ai:invoke': 'Використовувати налаштованого адміністратором ШІ-провайдера (доповнення тексту + видобування з документів) від імені поточного користувача — плагін ніколи не зберігає ключ',
'admin.plugins.perm.oauth:client': 'Підключення до сторонньої служби від вашого імені через OAuth за посередництва хоста (хост зберігає токени, плагін їх не бачить)',
'admin.plugins.perm.db:write:todos':
'Створювати, редагувати та видаляти завдання в подорожах, які може редагувати поточний користувач',
'admin.plugins.perm.db:write:atlas':
'Позначати країни та регіони як відвідані та керувати списком бажань поточного користувача (потрібен додаток Atlas)',
'admin.plugins.perm.db:write:vacay':
'Перемикати дні відпустки та корпоративні свята в активному плані поточного користувача (потрібен додаток Vacay)',
'admin.plugins.perm.db:write:journal':
'Створювати, редагувати та видаляти записи в щоденниках, які може редагувати поточний користувач (потрібен додаток Journey)',
'admin.plugins.perm.db:write:collections':
'Створювати та редагувати колекції й зберігати в них місця з роллю поточного користувача в колекції (потрібен додаток Collections)',
'admin.plugins.perm.db:write:files':
"Прикріплювати файли до подорожей, які може редагувати поточний користувач (ліміт 10 МБ, заблоковані розширення відхиляються), і керувати їхніми зв'язками",
'admin.plugins.perm.db:write:collab':
'Публікувати нотатки, опитування та повідомлення чату в подорожах, які може редагувати поточний користувач (потрібен додаток Collab)',
'admin.plugins.perm.db:write:members':
'Додавати користувачів до подорожей — надає доступ до подорожі; захищено правом керування учасниками поточного користувача',
'admin.plugins.perm.notify:send':
'Надсилати сповіщення (скринька дзвіночка + email/ntfy) поточному користувачеві або його подорожі — ніколи довільному одержувачу',
'admin.plugins.perm.ai:invoke':
'Використовувати налаштованого адміністратором ШІ-провайдера (доповнення тексту + видобування з документів) від імені поточного користувача — плагін ніколи не зберігає ключ',
'admin.plugins.perm.oauth:client':
'Підключення до сторонньої служби від вашого імені через OAuth за посередництва хоста (хост зберігає токени, плагін їх не бачить)',
'admin.plugins.perm.db:read:files': 'Читати файли подорожей, до яких має доступ поточний користувач',
'admin.plugins.perm.db:write:reservations': 'Створювати, редагувати та видаляти бронювання в подорожах, які може редагувати поточний користувач',
'admin.plugins.perm.db:write:accommodations': 'Створювати, редагувати та видаляти проживання в подорожах, які може редагувати поточний користувач',
'admin.plugins.perm.db:write:reservations':
'Створювати, редагувати та видаляти бронювання в подорожах, які може редагувати поточний користувач',
'admin.plugins.perm.db:write:accommodations':
'Створювати, редагувати та видаляти проживання в подорожах, які може редагувати поточний користувач',
'admin.plugins.perm.db:read:journal': 'Читати щоденники подорожей поточного користувача (потрібен додаток Journey)',
'admin.plugins.perm.db:read:atlas': 'Читати відвідані країни та регіони поточного користувача (потрібен додаток Atlas)',
'admin.plugins.perm.db:read:atlas':
'Читати відвідані країни та регіони поточного користувача (потрібен додаток Atlas)',
'admin.plugins.perm.db:read:vacay': 'Читати план відпустки поточного користувача (потрібен додаток Vacay)',
'admin.plugins.perm.db:read:daynotes': 'Читати нотатки дня подорожей, до яких має доступ поточний користувач',
'admin.plugins.perm.db:read:collections': 'Читати колекції збережених місць поточного користувача (потрібен додаток Collections)',
'admin.plugins.perm.db:write:daynotes': 'Створювати, редагувати та видаляти нотатки дня в подорожах, які може редагувати поточний користувач',
'admin.plugins.perm.jobs:run': 'Запускати оголошені фонові завдання за розкладом (без контексту користувача — не може читати дані користувача)',
'admin.plugins.perm.db:read:collections':
'Читати колекції збережених місць поточного користувача (потрібен додаток Collections)',
'admin.plugins.perm.db:write:daynotes':
'Створювати, редагувати та видаляти нотатки дня в подорожах, які може редагувати поточний користувач',
'admin.plugins.perm.jobs:run':
'Запускати оголошені фонові завдання за розкладом (без контексту користувача — не може читати дані користувача)',
'admin.plugins.perm.db:write:costs':
'Створювати витрати (статті бюджету) у подорожах, які може редагувати поточний користувач',
'admin.plugins.perm.db:write:places':
@@ -282,19 +310,25 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:trip-warning-provider':
'Створювати попередження перевірки для подорожі (показуються в планувальнику)',
'admin.plugins.perm.hook:table-contributor': 'Додавати стовпці та дії до подань подорожі (бронювання, місця, дні)',
'admin.plugins.perm.hook:map-marker-provider': 'Додавати мітки на карту подорожі (наприклад, показувати бронювання або POI)',
'admin.plugins.perm.hook:map-marker-provider':
'Додавати мітки на карту подорожі (наприклад, показувати бронювання або POI)',
'admin.plugins.perm.hook:pdf-section-provider': 'Додавати текстові розділи до PDF-експорту подорожі',
'admin.plugins.perm.hook:atlas-layer-provider':
'Виділяти країни на карті світу в Атласі (наприклад, списки бажань або попередження для мандрівників)',
'admin.plugins.perm.hook:journal-entry-provider':
'Додавати додаткові рядки (посилання, статистику) до записів щоденника',
'admin.plugins.perm.hook:user-data': 'Видалення або експорт збережених даних про користувача (GDPR: видалення акаунта та запити даних)',
'admin.plugins.perm.hook:trip-card-provider': 'Додавати невеликі значки (статус, лічильники) на картки подорожей на панелі',
'admin.plugins.perm.hook:user-data':
'Видалення або експорт збережених даних про користувача (GDPR: видалення акаунта та запити даних)',
'admin.plugins.perm.hook:trip-card-provider':
'Додавати невеликі значки (статус, лічильники) на картки подорожей на панелі',
'admin.plugins.perm.hook:notification-channel': 'Доставляти ваші сповіщення через додатковий канал',
'admin.plugins.perm.events:subscribe':
'Реагувати на основні події активності (лише назва події + подорож, ніколи вміст)',
'admin.plugins.perm.http:outbound': 'Виконувати вихідні запити до заявлених хостів',
'admin.plugins.perm.db:read:collab': 'Читати нотатки, опитування та повідомлення чату подорожей, до яких має доступ поточний користувач (потрібен додаток Collab)',
'admin.plugins.perm.db:read:files:content': 'Читати вміст (байти) файлів подорожей, до яких має доступ поточний користувач',
'admin.plugins.perm.db:read:collab':
'Читати нотатки, опитування та повідомлення чату подорожей, до яких має доступ поточний користувач (потрібен додаток Collab)',
'admin.plugins.perm.db:read:files:content':
'Читати вміст (байти) файлів подорожей, до яких має доступ поточний користувач',
'admin.plugins.perm.db:create:trips': 'Створювати нові подорожі, що належать поточному користувачеві',
'admin.plugins.perm.rates:read': 'Читати кешовані курси валют хоста',
'admin.plugins.updateConsentTitle': 'Це оновлення потребує нових дозволів',
@@ -364,6 +398,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Деталі бронювання',
'admin.plugins.cap.replacesTabs': 'Замінює вкладки планувальника',
'admin.plugins.cap.realtime': 'Оновлення в реальному часі',
'admin.plugins.cap.notificationChannel': 'Канал сповіщень',
'admin.plugins.cap.photos': 'Надає фотографії',
'admin.plugins.cap.calendar': 'Надає події календаря',
'admin.plugins.cap.placeDetails': 'Збагачує місця',
+10 -1
View File
@@ -13,7 +13,8 @@ const settings: TranslationStrings = {
'settings.plugins.oauth.disconnect': 'Відключити',
'settings.tabs.plugins': 'Плагіни',
'settings.plugins.title': 'Налаштування плагінів',
'settings.plugins.subtitle': 'Ваші особисті налаштування для плагінів, які ви використовуєте (ключі API, уподобання).',
'settings.plugins.subtitle':
'Ваші особисті налаштування для плагінів, які ви використовуєте (ключі API, уподобання).',
'settings.plugins.empty': 'Немає активних плагінів.',
'settings.plugins.saved': 'Налаштування збережено',
'settings.tabs.account': 'Обліковий запис',
@@ -282,6 +283,14 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.testFailed': 'Помилка надсилання тестового сповіщення Ntfy',
'settings.ntfyUrl.tokenCleared': 'Токен доступу очищено',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Ще не налаштовано — налаштуйте в параметрах плагіна',
'settings.plugins.actions': 'Дії',
'settings.plugins.actions.confirm': 'Виконати цю дію?',
'settings.notificationPreferences.sendTest': 'Надіслати тест',
'settings.notificationPreferences.configure': 'Налаштувати',
'settings.notificationPreferences.testSuccess': 'Тестове сповіщення надіслано.',
'settings.notificationPreferences.testFailed': 'Тест не вдався.',
'settings.notificationPreferences.pluginConfigured': 'Налаштовано. Облікові дані — на сторінці налаштувань плагіна.',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
+27 -8
View File
@@ -224,7 +224,8 @@ const admin: TranslationStrings = {
'admin.plugins.uploaded': 'Đã tải lên plugin “{name}” — kích hoạt để chạy',
'admin.plugins.sideloaded': 'Tải thủ công',
'admin.plugins.devLinkBadge': 'Dev-Link',
'admin.plugins.devLinkHint': 'Được tải từ thư mục build cục bộ và tải lại nóng với dữ liệu thật — chỉ dành cho phát triển',
'admin.plugins.devLinkHint':
'Được tải từ thư mục build cục bộ và tải lại nóng với dữ liệu thật — chỉ dành cho phát triển',
'admin.plugins.devLinkTitle': 'Liên kết plugin cục bộ',
'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin',
'admin.plugins.devLinkButton': 'Liên kết',
@@ -241,6 +242,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': 'Đã hủy kích hoạt plugin',
'admin.plugins.viewErrors': 'Xem nhật ký lỗi',
'admin.plugins.errorLog': 'Nhật ký lỗi',
'admin.plugins.allowedHosts': 'Máy chủ được phép',
'admin.plugins.allowedHosts.hint': 'Plugin này kết nối tới một dịch vụ mà chỉ bạn mới biết tên (máy chủ tự lưu trữ). Hãy thêm các máy chủ mà nó được phép truy cập — ngoài ra không có gì khác.',
'admin.plugins.allowedHosts.none': 'Chưa thêm máy chủ nào.',
'admin.plugins.allowedHosts.unsupported': 'Plugin này không dùng máy chủ do người vận hành cung cấp. Danh sách được cố định trong manifest.',
'admin.plugins.allowedHosts.restartNote': 'Lưu sẽ khởi động lại plugin để nó dùng danh sách mới.',
'admin.plugins.allowedHosts.add': 'Thêm máy chủ được phép',
'admin.plugins.allowedHosts.count': '{n} máy chủ được phép',
'admin.plugins.operatorEgressPill': '+ máy chủ bạn thêm',
'admin.plugins.operatorEgressHint': 'Plugin này kết nối tới một dịch vụ mà chỉ bạn mới biết tên (máy chủ tự lưu trữ). Sau khi cài, hãy thêm máy chủ tại ⋯ → Máy chủ được phép. Ngoài ra không có gì khác.',
'admin.plugins.noErrors': 'Chưa ghi nhận lỗi nào.',
'admin.plugins.uninstalled': 'Đã gỡ cài đặt plugin',
'admin.plugins.uninstallTitle': 'Gỡ cài đặt plugin?',
@@ -288,7 +298,8 @@ const admin: TranslationStrings = {
'admin.plugins.reviewedOn': 'Đã kiểm duyệt {date}',
'admin.plugins.perm.db:own': 'Lưu trữ dữ liệu riêng trong một cơ sở dữ liệu cách ly',
'admin.plugins.perm.db:read:trips': 'Đọc các chuyến đi mà người dùng hiện tại có quyền truy cập',
'admin.plugins.perm.db:read:users': 'Đọc thông tin hồ sơ cơ bản (tên, ảnh đại diện — không bao giờ là thông tin đăng nhập)',
'admin.plugins.perm.db:read:users':
'Đọc thông tin hồ sơ cơ bản (tên, ảnh đại diện — không bao giờ là thông tin đăng nhập)',
'admin.plugins.perm.ws:broadcast:trip': 'Gửi cập nhật thời gian thực đến các thành viên',
'admin.plugins.perm.ws:broadcast:user': 'Gửi cập nhật thời gian thực đến từng người dùng',
'admin.plugins.perm.hook:photo-provider': 'Cung cấp ảnh cho tính năng Ảnh',
@@ -297,19 +308,26 @@ const admin: TranslationStrings = {
'Đóng góp thêm thông tin chi tiết (đánh giá, xếp hạng, liên kết) cho một địa điểm',
'admin.plugins.perm.hook:trip-warning-provider':
'Đưa ra cảnh báo xác thực trên một chuyến đi (hiển thị trong trình lập kế hoạch)',
'admin.plugins.perm.hook:table-contributor': 'Thêm cột và hành động vào các chế độ xem chuyến đi (đặt chỗ, địa điểm, ngày)',
'admin.plugins.perm.hook:map-marker-provider': 'Thêm điểm đánh dấu vào bản đồ chuyến đi (vd. hiển thị đặt chỗ hoặc POI)',
'admin.plugins.perm.hook:table-contributor':
'Thêm cột và hành động vào các chế độ xem chuyến đi (đặt chỗ, địa điểm, ngày)',
'admin.plugins.perm.hook:map-marker-provider':
'Thêm điểm đánh dấu vào bản đồ chuyến đi (vd. hiển thị đặt chỗ hoặc POI)',
'admin.plugins.perm.hook:pdf-section-provider': 'Thêm các phần văn bản vào bản xuất PDF của chuyến đi',
'admin.plugins.perm.hook:atlas-layer-provider':
'Làm nổi bật các quốc gia trên bản đồ thế giới Atlas (vd. danh sách mong muốn hoặc cảnh báo du lịch)',
'admin.plugins.perm.hook:journal-entry-provider': 'Thêm các dòng bổ sung (liên kết, thống kê) vào các mục nhật ký',
'admin.plugins.perm.hook:user-data': 'Xóa hoặc xuất dữ liệu mà nó lưu về một người dùng (GDPR: xóa tài khoản và yêu cầu dữ liệu)',
'admin.plugins.perm.hook:trip-card-provider': 'Thêm huy hiệu nhỏ (trạng thái, số đếm) vào thẻ chuyến đi trên bảng điều khiển',
'admin.plugins.perm.hook:user-data':
'Xóa hoặc xuất dữ liệu mà nó lưu về một người dùng (GDPR: xóa tài khoản và yêu cầu dữ liệu)',
'admin.plugins.perm.hook:trip-card-provider':
'Thêm huy hiệu nhỏ (trạng thái, số đếm) vào thẻ chuyến đi trên bảng điều khiển',
'admin.plugins.perm.hook:notification-channel': 'Gửi thông báo của bạn qua một kênh bổ sung',
'admin.plugins.perm.events:subscribe':
'Phản hồi các sự kiện hoạt động cốt lõi (chỉ tên sự kiện + chuyến đi, không bao giờ là nội dung)',
'admin.plugins.perm.http:outbound': 'Gửi yêu cầu ra ngoài đến các máy chủ đã khai báo',
'admin.plugins.perm.db:read:collab': 'Đọc ghi chú, bình chọn và tin nhắn trò chuyện của các chuyến đi mà người dùng hiện tại có quyền truy cập (cần add-on Collab)',
'admin.plugins.perm.db:read:files:content': 'Đọc nội dung (byte) tệp của các chuyến đi mà người dùng hiện tại có quyền truy cập',
'admin.plugins.perm.db:read:collab':
'Đọc ghi chú, bình chọn và tin nhắn trò chuyện của các chuyến đi mà người dùng hiện tại có quyền truy cập (cần add-on Collab)',
'admin.plugins.perm.db:read:files:content':
'Đọc nội dung (byte) tệp của các chuyến đi mà người dùng hiện tại có quyền truy cập',
'admin.plugins.perm.db:create:trips': 'Tạo chuyến đi mới thuộc sở hữu của người dùng hiện tại',
'admin.plugins.perm.rates:read': 'Đọc tỷ giá hối đoái đã lưu cache của máy chủ',
'admin.plugins.updateConsentTitle': 'Bản cập nhật này cần các quyền mới',
@@ -379,6 +397,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': 'Chi tiết đặt chỗ',
'admin.plugins.cap.replacesTabs': 'Thay thế các tab trình lập kế hoạch',
'admin.plugins.cap.realtime': 'Cập nhật thời gian thực',
'admin.plugins.cap.notificationChannel': 'Kênh thông báo',
'admin.plugins.cap.photos': 'Cung cấp ảnh',
'admin.plugins.cap.calendar': 'Cung cấp sự kiện lịch',
'admin.plugins.cap.placeDetails': 'Làm phong phú địa điểm',
+8
View File
@@ -96,6 +96,14 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'Trong ứng dụng',
'settings.notificationPreferences.notConfigured': 'Chưa được thiết lập — hãy cấu hình trong cài đặt của plugin',
'settings.plugins.actions': 'Hành động',
'settings.plugins.actions.confirm': 'Chạy hành động này?',
'settings.notificationPreferences.sendTest': 'Gửi thử',
'settings.notificationPreferences.configure': 'Cấu hình',
'settings.notificationPreferences.testSuccess': 'Đã gửi thông báo thử.',
'settings.notificationPreferences.testFailed': 'Thử nghiệm thất bại.',
'settings.notificationPreferences.pluginConfigured': 'Đã cấu hình. Quản lý thông tin đăng nhập ở trang cài đặt của plugin.',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.noChannels':
'Không có kênh thông báo nào được cấu hình. Yêu cầu quản trị viên thiết lập thông báo qua email hoặc webhook.',
+17 -3
View File
@@ -220,6 +220,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': '外掛已停用',
'admin.plugins.viewErrors': '檢視錯誤日誌',
'admin.plugins.errorLog': '錯誤日誌',
'admin.plugins.allowedHosts': '允許的主機',
'admin.plugins.allowedHosts.hint': '此外掛會連線到只有你才知道位址的服務(自架伺服器)。請新增它可以存取的主機 —— 其他主機一律無法存取。',
'admin.plugins.allowedHosts.none': '尚未新增主機。',
'admin.plugins.allowedHosts.unsupported': '此外掛不使用營運者提供的主機。其允許的主機已在資訊清單中固定。',
'admin.plugins.allowedHosts.restartNote': '儲存會重新啟動外掛,以便套用新的清單。',
'admin.plugins.allowedHosts.add': '新增允許的主機',
'admin.plugins.allowedHosts.count': '{n} 個允許的主機',
'admin.plugins.operatorEgressPill': '+ 你新增的主機',
'admin.plugins.operatorEgressHint': '此外掛會連線到只有你才知道位址的服務(自架伺服器)。安裝後請在 ⋯ → 允許的主機 中新增。其他主機一律無法存取。',
'admin.plugins.noErrors': '沒有錯誤記錄。',
'admin.plugins.uninstalled': '外掛已解除安裝',
'admin.plugins.uninstallTitle': '解除安裝外掛?',
@@ -278,11 +287,14 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:write:atlas': '標記操作使用者已造訪的國家與地區並管理願望清單(需要 Atlas 附加元件)',
'admin.plugins.perm.db:write:vacay': '在操作使用者的使用中計畫中切換休假日與公司假日(需要 Vacay 附加元件)',
'admin.plugins.perm.db:write:journal': '在操作使用者可編輯的日誌中建立、編輯及刪除項目(需要 Journey 附加元件)',
'admin.plugins.perm.db:write:collections': '建立及編輯收藏並將地點儲存至其中,依操作使用者的收藏角色執行(需要 Collections 附加元件)',
'admin.plugins.perm.db:write:files': '向操作使用者可編輯的行程附加檔案(10MB 上限,被封鎖的副檔名將被拒絕)並管理其連結',
'admin.plugins.perm.db:write:collections':
'建立及編輯收藏並將地點儲存至其中,依操作使用者的收藏角色執行(需要 Collections 附加元件)',
'admin.plugins.perm.db:write:files':
'向操作使用者可編輯的行程附加檔案(10MB 上限,被封鎖的副檔名將被拒絕)並管理其連結',
'admin.plugins.perm.db:write:collab': '在操作使用者可編輯的行程中發佈筆記、投票及聊天訊息(需要 Collab 附加元件)',
'admin.plugins.perm.db:write:members': '向行程新增使用者 — 授予行程存取權;受操作使用者的成員管理權限保護',
'admin.plugins.perm.notify:send': '向操作使用者或其所屬的行程傳送通知(鈴鐺收件匣 + 郵件/ntfy)— 絕不傳送給任意接收者',
'admin.plugins.perm.notify:send':
'向操作使用者或其所屬的行程傳送通知(鈴鐺收件匣 + 郵件/ntfy)— 絕不傳送給任意接收者',
'admin.plugins.perm.ai:invoke': '代表操作使用者執行管理員設定的 AI 供應商(文字補全 + 文件擷取)— 外掛絕不持有金鑰',
'admin.plugins.perm.oauth:client': '透過主機代理的 OAuth 代表你連線第三方服務(權杖由主機保管,外掛永遠看不到)',
'admin.plugins.perm.db:read:files': '讀取操作使用者可存取之行程的檔案',
@@ -314,6 +326,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:journal-entry-provider': '向日誌條目新增額外列(連結、統計)',
'admin.plugins.perm.hook:user-data': '刪除或匯出其儲存的某使用者資料(GDPR:帳戶刪除與資料請求)',
'admin.plugins.perm.hook:trip-card-provider': '在儀表板的行程卡片上加入小徽章(狀態、計數)',
'admin.plugins.perm.hook:notification-channel': '透過額外的管道傳送你的通知',
'admin.plugins.perm.events:subscribe': '回應核心活動事件(僅事件名稱與行程,絕不包含內容)',
'admin.plugins.perm.http:outbound': '向其宣告的主機發出對外請求',
'admin.plugins.perm.db:read:collab': '讀取操作使用者可存取之行程的筆記、投票及聊天訊息(需要 Collab 附加元件)',
@@ -386,6 +399,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': '預訂詳情',
'admin.plugins.cap.replacesTabs': '取代行程規劃分頁',
'admin.plugins.cap.realtime': '即時更新',
'admin.plugins.cap.notificationChannel': '通知管道',
'admin.plugins.cap.photos': '提供照片',
'admin.plugins.cap.calendar': '提供行事曆事件',
'admin.plugins.cap.placeDetails': '豐富地點',
+8
View File
@@ -86,6 +86,14 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.email': '電子郵件',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': '應用程式內',
'settings.notificationPreferences.notConfigured': '尚未設定 — 請在外掛設定中進行設定',
'settings.plugins.actions': '操作',
'settings.plugins.actions.confirm': '執行此操作?',
'settings.notificationPreferences.sendTest': '傳送測試',
'settings.notificationPreferences.configure': '前往設定',
'settings.notificationPreferences.testSuccess': '已傳送測試通知。',
'settings.notificationPreferences.testFailed': '測試失敗。',
'settings.notificationPreferences.pluginConfigured': '已設定。請在外掛的設定頁面管理憑證。',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationPreferences.noChannels': '未配置通知渠道。請聯絡管理員設定電子郵件或 Webhook 通知。',
'settings.webhookUrl.label': 'Webhook URL',
+16 -4
View File
@@ -81,8 +81,7 @@ const admin: TranslationStrings = {
'admin.oidcLoginHint': '允许用户通过 SSO 登录',
'admin.oidcRegistration': 'SSO 自动创建账户',
'admin.oidcRegistrationHint': '为首次通过 SSO 登录的用户自动创建账户',
'admin.envOverrideHint':
'密码登录设置由 OIDC_ONLY 环境变量控制,无法在此处修改。',
'admin.envOverrideHint': '密码登录设置由 OIDC_ONLY 环境变量控制,无法在此处修改。',
'admin.lockoutWarning': '至少需要保留一种登录方式',
'admin.requireMfa': '要求双因素身份验证(2FA)',
'admin.requireMfaHint': '未启用 2FA 的用户必须先完成设置中的配置才能使用应用。',
@@ -181,6 +180,15 @@ const admin: TranslationStrings = {
'admin.plugins.deactivated': '插件已停用',
'admin.plugins.viewErrors': '查看错误日志',
'admin.plugins.errorLog': '错误日志',
'admin.plugins.allowedHosts': '允许的主机',
'admin.plugins.allowedHosts.hint': '此插件会连接一个只有你才知道地址的服务(自托管服务器)。请添加它可以访问的主机 —— 其他主机一概无法访问。',
'admin.plugins.allowedHosts.none': '尚未添加主机。',
'admin.plugins.allowedHosts.unsupported': '此插件不使用运营者提供的主机。其允许的主机已在清单中固定。',
'admin.plugins.allowedHosts.restartNote': '保存会重启插件,以便应用新的列表。',
'admin.plugins.allowedHosts.add': '添加允许的主机',
'admin.plugins.allowedHosts.count': '{n} 个允许的主机',
'admin.plugins.operatorEgressPill': '+ 你添加的主机',
'admin.plugins.operatorEgressHint': '此插件会连接一个只有你才知道地址的服务(自托管服务器)。安装后请在 ⋯ → 允许的主机 中添加。其他主机一概无法访问。',
'admin.plugins.noErrors': '暂无错误记录。',
'admin.plugins.uninstalled': '插件已卸载',
'admin.plugins.uninstallTitle': '卸载插件?',
@@ -239,8 +247,10 @@ const admin: TranslationStrings = {
'admin.plugins.perm.db:write:atlas': '标记当前用户已访问的国家和地区并管理愿望清单(需要 Atlas 插件)',
'admin.plugins.perm.db:write:vacay': '在当前用户的活动计划中切换休假日和公司假期(需要 Vacay 插件)',
'admin.plugins.perm.db:write:journal': '在当前用户可编辑的日记中创建、编辑和删除条目(需要 Journey 插件)',
'admin.plugins.perm.db:write:collections': '创建和编辑收藏并向其中保存地点,使用当前用户的收藏角色(需要 Collections 插件)',
'admin.plugins.perm.db:write:files': '向当前用户可编辑的旅行附加文件(10MB 上限,被阻止的扩展名将被拒绝)并管理其链接',
'admin.plugins.perm.db:write:collections':
'创建和编辑收藏并向其中保存地点,使用当前用户的收藏角色(需要 Collections 插件)',
'admin.plugins.perm.db:write:files':
'向当前用户可编辑的旅行附加文件(10MB 上限,被阻止的扩展名将被拒绝)并管理其链接',
'admin.plugins.perm.db:write:collab': '在当前用户可编辑的旅行中发布笔记、投票和聊天消息(需要 Collab 插件)',
'admin.plugins.perm.db:write:members': '向旅行添加用户 — 授予旅行访问权限;受当前用户的成员管理权限保护',
'admin.plugins.perm.notify:send': '向当前用户或其所属的旅行发送通知(铃铛收件箱 + 邮件/ntfy)— 绝不发送给任意接收者',
@@ -275,6 +285,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:journal-entry-provider': '向日志条目添加额外行(链接、统计)',
'admin.plugins.perm.hook:user-data': '删除或导出其存储的某用户数据(GDPR:账户删除与数据请求)',
'admin.plugins.perm.hook:trip-card-provider': '在仪表盘的行程卡片上添加小徽章(状态、计数)',
'admin.plugins.perm.hook:notification-channel': '通过额外的渠道发送你的通知',
'admin.plugins.perm.events:subscribe': '响应核心活动事件(仅事件名称和行程,绝不包含内容)',
'admin.plugins.perm.http:outbound': '向其声明的主机发出出站请求',
'admin.plugins.perm.db:read:collab': '读取当前用户可访问的旅行的笔记、投票和聊天消息(需要 Collab 插件)',
@@ -348,6 +359,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.reservationSlot': '预订详情',
'admin.plugins.cap.replacesTabs': '替换行程规划标签页',
'admin.plugins.cap.realtime': '实时更新',
'admin.plugins.cap.notificationChannel': '通知渠道',
'admin.plugins.cap.photos': '提供照片',
'admin.plugins.cap.calendar': '提供日历事件',
'admin.plugins.cap.placeDetails': '丰富地点信息',
+10 -4
View File
@@ -270,6 +270,14 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.testFailed': '测试 Ntfy 通知失败',
'settings.ntfyUrl.tokenCleared': '访问令牌已清除',
'settings.notificationPreferences.inapp': '应用内',
'settings.notificationPreferences.notConfigured': '尚未配置 — 请在插件设置中进行设置',
'settings.plugins.actions': '操作',
'settings.plugins.actions.confirm': '执行此操作?',
'settings.notificationPreferences.sendTest': '发送测试',
'settings.notificationPreferences.configure': '去配置',
'settings.notificationPreferences.testSuccess': '已发送测试通知。',
'settings.notificationPreferences.testFailed': '测试失败。',
'settings.notificationPreferences.pluginConfigured': '已配置。请在插件的设置页面管理凭据。',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': '电子邮件',
'settings.notificationPreferences.ntfy': 'Ntfy',
@@ -348,8 +356,7 @@ const settings: TranslationStrings = {
'settings.appearance.contrastLow': '对比度偏低',
'settings.appearance.readability': '可读性',
'settings.appearance.transparency': '透明效果',
'settings.appearance.transparencyHint':
'使用玻璃感半透明界面,关闭后改用纯色、高对比度背景。',
'settings.appearance.transparencyHint': '使用玻璃感半透明界面,关闭后改用纯色、高对比度背景。',
'settings.appearance.reduceMotion': '减少动态效果',
'settings.appearance.reduceMotionHint': '尽量减少动画和过渡效果。',
'settings.appearance.density': '布局密度',
@@ -379,8 +386,7 @@ const settings: TranslationStrings = {
'settings.appearance.group.belowHero': '顶部摘要下方',
'settings.appearance.group.bottomOfPage': '页面底部',
'settings.appearance.sidebarHint': '控制整个右侧栏,关闭后仪表盘会居中显示。',
'settings.appearance.densityHint':
'紧凑模式会收紧间距和内边距,让屏幕显示更多内容。',
'settings.appearance.densityHint': '紧凑模式会收紧间距和内边距,让屏幕显示更多内容。',
'settings.appearance.textSizeAll': '全部',
'settings.appearance.perSize': '分别调整每类字号',
'settings.appearance.size.large': '大',