Adressdaten-Bereich, Mock hinter einem Profil, Webhook
Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css, README, application.properties) und deshalb nicht getrennt committet werden können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen: Adressdaten: Der Bereich zeigt den gesamten Bestand des globalen Telefon- buchs - zuerst aus dem Adress-Cache der App (Protokoll 8), sonst über das Kommando "addresses" und ersatzweise aus rund 110 Einzelabfragen hinter einem Wartedialog. Der Mock gibt denselben Bestand heraus und lässt sich über /api/mock/address-cache leeren, um die Rückfallebene zu prüfen. Mock hinter dem Profil "mock": Seine Bohnen (/ws und /api/mock/**) hängen jetzt an @Profile, sind ohne das Profil also nicht vorhanden. Damit kann der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das Container-Image setzt das Profil nicht. Je ein Test hält beide Richtungen fest. Webhook: POST /api/webhook nimmt beliebiges JSON eines fremden Systems an und reicht es über Server-Sent Events an die offenen Browser weiter, wo es der neue Bereich "Webhook" unverändert anzeigt. Der WebSocket kam dafür nicht in Frage - er gehört der SwyxTray-App. Das Backend hält die letzten 50 Nachrichten vor und liefert sie beim Wiederverbinden anhand der Last-Event-ID nach; ein Heartbeat und X-Accel-Buffering: no halten die Verbindung durch Reverse Proxys hindurch offen. Ein Token (app.webhook.token) ist vorgesehen, aber nicht voreingestellt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { describeContact, type Contact } from '../swyx/protocol'
|
||||
import {
|
||||
filterContacts,
|
||||
sortContacts,
|
||||
type Directory,
|
||||
type DirectoryProgress,
|
||||
type SweepOptions,
|
||||
} from '../swyx/directory'
|
||||
import LoadingDialog from './LoadingDialog'
|
||||
|
||||
interface Props {
|
||||
/** Panel ist sichtbar – erst dann wird geladen. */
|
||||
active: boolean
|
||||
disabled: boolean
|
||||
/**
|
||||
* Adress-Cache, den die App unaufgefordert schickt. `null` heißt „noch keine
|
||||
* Cache-Nachricht"; eine leere Liste heißt „Cache ist leer".
|
||||
*/
|
||||
cache: Contact[] | null
|
||||
onLoad: (options: SweepOptions) => Promise<Directory | null>
|
||||
onDial: (number: string) => void
|
||||
}
|
||||
|
||||
const STORAGE_FILTER = 'swyxweb.contacts.filter'
|
||||
|
||||
function stored(key: string): string {
|
||||
try {
|
||||
return localStorage.getItem(key) ?? ''
|
||||
} catch {
|
||||
// Privater Modus o. Ä. – dann eben ohne Gedächtnis.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function remember(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// absichtlich still
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adressdaten des Swyx-Clients – der **gesamte** Bestand des globalen
|
||||
* Telefonbuchs.
|
||||
*
|
||||
* Drei Wege, in dieser Reihenfolge:
|
||||
*
|
||||
* 1. Der **Cache**, den die App seit Protokoll 8 beim Verbinden von selbst
|
||||
* schickt. Er liegt dann schon vor, bevor der Bereich geöffnet wird – kein
|
||||
* Laden, kein Wartedialog.
|
||||
* 2. Sonst beim Öffnen das Kommando `addresses`: derselbe Cache auf Anfrage.
|
||||
* 3. Kennt die App das Kommando nicht oder ist der Cache leer, werden rund 110
|
||||
* Einzelabfragen zusammengesetzt. Das dauert einige Sekunden und läuft hinter
|
||||
* einem Wartedialog.
|
||||
*
|
||||
* Danach liegt der Bestand im Browser: gefiltert wird ohne weitere Abfrage.
|
||||
*/
|
||||
export default function ContactsPanel({ active, disabled, cache, onLoad, onDial }: Props) {
|
||||
const [directory, setDirectory] = useState<Directory | null>(null)
|
||||
const [progress, setProgress] = useState<DirectoryProgress | null>(null)
|
||||
const [filter, setFilter] = useState(() => stored(STORAGE_FILTER))
|
||||
|
||||
// Nach einem Verbindungsabbruch soll beim nächsten Öffnen neu geladen werden.
|
||||
const loadedRef = useRef(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
// Zählt die Ladevorgänge. Trifft der Cache ein, während die Einzelabfragen
|
||||
// noch laufen, darf deren abgebrochenes Ergebnis den Cache nicht überschreiben.
|
||||
const loadIdRef = useRef(0)
|
||||
|
||||
/** Macht einen laufenden Ladevorgang ungültig und beendet ihn. */
|
||||
const supersede = useCallback(() => {
|
||||
loadIdRef.current += 1
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
setProgress(null)
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// Ein zweiter Durchlauf würde nur dieselben Abfragen doppelt stellen.
|
||||
supersede()
|
||||
const ticket = loadIdRef.current
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
setProgress({ done: 0, total: 0, found: 0 })
|
||||
try {
|
||||
const result = await onLoad({
|
||||
signal: controller.signal,
|
||||
onProgress: (next) => {
|
||||
if (ticket === loadIdRef.current) setProgress(next)
|
||||
},
|
||||
})
|
||||
if (ticket !== loadIdRef.current) return
|
||||
// null heißt: Fehler – der steht bereits in der Fehleranzeige der Seite.
|
||||
if (result) setDirectory(result)
|
||||
} finally {
|
||||
if (ticket === loadIdRef.current) {
|
||||
abortRef.current = null
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
}, [onLoad, supersede])
|
||||
|
||||
// Der Cache der App hat Vorrang: Er kommt unaufgefordert und ist damit oft
|
||||
// schon da, bevor der Bereich überhaupt geöffnet wird. Ein leerer Cache ist
|
||||
// dagegen keine Antwort – dann bleibt es beim Laden weiter unten.
|
||||
useEffect(() => {
|
||||
if (!cache || cache.length === 0) return
|
||||
loadedRef.current = true
|
||||
supersede()
|
||||
setDirectory({
|
||||
contacts: sortContacts(cache),
|
||||
source: 'cache',
|
||||
queries: 0,
|
||||
complete: true,
|
||||
aborted: false,
|
||||
})
|
||||
}, [cache, supersede])
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
// Ohne Verbindung ist der Bestand veraltet; er wird beim nächsten Mal neu geholt.
|
||||
supersede()
|
||||
loadedRef.current = false
|
||||
setDirectory(null)
|
||||
return
|
||||
}
|
||||
if (!active || loadedRef.current) return
|
||||
loadedRef.current = true
|
||||
void load()
|
||||
}, [active, disabled, load, supersede])
|
||||
|
||||
// Ein laufender Durchlauf soll nicht weiterfragen, wenn die Seite verschwindet.
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
const visible = useMemo(
|
||||
() => (directory ? filterContacts(directory.contacts, filter) : []),
|
||||
[directory, filter],
|
||||
)
|
||||
|
||||
const loading = progress !== null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card__header">
|
||||
<h2>Adressdaten</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="button button--ghost"
|
||||
onClick={() => void load()}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? 'Lädt …' : 'Neu laden'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{disabled ? (
|
||||
<p className="note">Erst mit der SwyxTray-App verbinden.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`)
|
||||
und würde als direktes Kind der Karte – einer Spalte – in die *Höhe*
|
||||
wachsen und den freien Platz aufsaugen. In der Zeile wächst es in
|
||||
die Breite, wie in allen anderen Bereichen auch. */}
|
||||
<div className="row">
|
||||
<label className="field">
|
||||
<span className="field__label">Filtern</span>
|
||||
<input
|
||||
className="field__input"
|
||||
type="search"
|
||||
value={filter}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder="Name, Rufnummer oder Kürzel"
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value)
|
||||
remember(STORAGE_FILTER, e.target.value.trim())
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{directory === null ? (
|
||||
<p className="note">{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="note">
|
||||
{filter.trim()
|
||||
? `${visible.length} von ${directory.contacts.length} Einträgen`
|
||||
: `${directory.contacts.length} Einträge`}
|
||||
{directory.source === 'cache'
|
||||
? ' · aus dem Adress-Cache der App'
|
||||
: ` · aus ${directory.queries} Einzelabfragen zusammengesetzt`}
|
||||
</p>
|
||||
|
||||
{!directory.complete && (
|
||||
<p className="note note--error">
|
||||
{directory.aborted
|
||||
? 'Abgebrochen – der Bestand ist unvollständig.'
|
||||
: 'Die App hat mindestens eine Abfrage gekürzt; es können Einträge fehlen.'}{' '}
|
||||
„Neu laden" versucht es erneut.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="note">Kein Eintrag zu „{filter.trim()}".</p>
|
||||
) : (
|
||||
<ul className="contacts">
|
||||
{visible.map((contact, index) => (
|
||||
// Namen sind nicht eindeutig – dieselbe Person kommt mit
|
||||
// mehreren Durchwahlen vor; deshalb die Position mit hinein.
|
||||
<li
|
||||
key={`${contact.number ?? ''}-${contact.name ?? ''}-${index}`}
|
||||
className="contacts__item"
|
||||
>
|
||||
<span className="contacts__info">
|
||||
<span className="contacts__name">{describeContact(contact)}</span>
|
||||
{contact.description && (
|
||||
<span className="contacts__description">{contact.description}</span>
|
||||
)}
|
||||
</span>
|
||||
{contact.number && <span className="contacts__number">{contact.number}</span>}
|
||||
{contact.number && (
|
||||
<button
|
||||
type="button"
|
||||
className="button button--small"
|
||||
onClick={() => onDial(contact.number!)}
|
||||
>
|
||||
Anrufen
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="note">
|
||||
Der Bestand kommt aus dem Telefonbuch des Swyx-Clients auf dem Rechner der SwyxTray-App –
|
||||
aus deren Adress-Cache, den sie beim Verbinden von selbst schickt. Kennt die App den Cache
|
||||
noch nicht oder ist er leer, wird der Bestand ersatzweise aus rund 110 Suchabfragen
|
||||
zusammengesetzt. Danach liegt er vollständig im Browser; das Filtern läuft ohne weitere
|
||||
Abfrage.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<LoadingDialog
|
||||
title="Adressdaten werden geladen"
|
||||
done={progress.done}
|
||||
total={progress.total}
|
||||
detail={progress.found > 0 ? `${progress.found} Einträge bisher` : undefined}
|
||||
onCancel={() => abortRef.current?.abort()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
/** Zusatzzeile unter dem Balken, z. B. „287 Einträge bisher". */
|
||||
detail?: ReactNode
|
||||
done: number
|
||||
/** 0 heißt: Umfang noch unbekannt – dann läuft der Balken unbestimmt. */
|
||||
total: number
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartedialog mit Fortschritt. Bewusst ein eigenes Overlay statt `<dialog>`:
|
||||
* die Panels der Startseite liegen in `hidden`-Bereichen, und `showModal()`
|
||||
* verhielte sich darin je nach Browser unterschiedlich.
|
||||
*/
|
||||
export default function LoadingDialog({ title, detail, done, total, onCancel }: Props) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Der Abbrechen-Knopf ist das einzige Bedienelement – er bekommt den Fokus,
|
||||
// damit Escape und Leertaste sofort greifen.
|
||||
useEffect(() => {
|
||||
cancelRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onCancel) return
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onCancel!()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [onCancel])
|
||||
|
||||
const percent = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0
|
||||
|
||||
return (
|
||||
<div className="overlay">
|
||||
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby="loading-dialog-title">
|
||||
<h2 className="dialog__title" id="loading-dialog-title">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className="progress"
|
||||
role="progressbar"
|
||||
aria-valuenow={total > 0 ? done : undefined}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={total > 0 ? total : undefined}
|
||||
aria-valuetext={total > 0 ? `${done} von ${total}` : 'läuft'}
|
||||
>
|
||||
<div
|
||||
className={`progress__bar${total > 0 ? '' : ' progress__bar--unknown'}`}
|
||||
style={total > 0 ? { width: `${percent}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="dialog__status" aria-live="polite">
|
||||
{total > 0 ? `${done} von ${total} Abfragen` : 'Abfragen laufen …'}
|
||||
{detail && <span className="dialog__detail">{detail}</span>}
|
||||
</p>
|
||||
|
||||
{onCancel && (
|
||||
<div className="dialog__actions">
|
||||
<button ref={cancelRef} type="button" className="button" onClick={onCancel}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface Tab<Id extends string> {
|
||||
id: Id
|
||||
label: string
|
||||
/** Anzahl noch nicht gesehener Nachrichten; 0 oder fehlend blendet ihn aus. */
|
||||
badge?: number
|
||||
}
|
||||
|
||||
interface Props<Id extends string> {
|
||||
@@ -35,6 +37,11 @@ export default function Tabs<Id extends string>({ tabs, active, onChange }: Prop
|
||||
onClick={() => onChange(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge ? (
|
||||
<span className="tabs__badge" aria-label={`${tab.badge} neu`}>
|
||||
{tab.badge > 99 ? '99+' : tab.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { formatPayload, summarize, type WebhookEvent } from '../webhook'
|
||||
import type { WebhookStatus } from '../hooks/useWebhook'
|
||||
|
||||
interface Props {
|
||||
events: WebhookEvent[]
|
||||
status: WebhookStatus
|
||||
error: string | null
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
const timeFormat = new Intl.DateTimeFormat('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
/** Eingangszeit; das Datum nur, wenn die Nachricht nicht von heute ist. */
|
||||
function describeTime(receivedAt: string): string {
|
||||
const at = new Date(receivedAt)
|
||||
if (Number.isNaN(at.getTime())) return receivedAt
|
||||
const today = new Date()
|
||||
const sameDay =
|
||||
at.getFullYear() === today.getFullYear() &&
|
||||
at.getMonth() === today.getMonth() &&
|
||||
at.getDate() === today.getDate()
|
||||
return sameDay ? timeFormat.format(at) : `${dateFormat.format(at)} ${timeFormat.format(at)}`
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<WebhookStatus, string> = {
|
||||
connecting: 'Verbindung wird aufgebaut …',
|
||||
open: 'Verbunden – neue Nachrichten erscheinen sofort.',
|
||||
closed: 'Nicht verbunden. Seite neu laden, um den Strom wieder zu öffnen.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt die über den Webhook eingegangenen Nachrichten – jüngste zuerst, jede
|
||||
* mit ihrem JSON im Original.
|
||||
*
|
||||
* Bewusst ohne Deutung der Nutzlast: Das aufrufende System muss sich an kein
|
||||
* Schema halten, deshalb wird angezeigt, was ankam. Nur die Kopfzeile fasst
|
||||
* `name` und `number` zusammen, wenn es sie gibt.
|
||||
*/
|
||||
export default function WebhookPanel({ events, status, error, onClear }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="card__header">
|
||||
<h2>Webhook</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="button button--ghost"
|
||||
onClick={onClear}
|
||||
disabled={events.length === 0}
|
||||
>
|
||||
Leeren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className={`note${status === 'closed' ? ' note--error' : ''}`}>{STATUS_TEXT[status]}</p>
|
||||
{error && <p className="note note--error">{error}</p>}
|
||||
|
||||
{events.length === 0 ? (
|
||||
<p className="note">
|
||||
Noch keine Nachricht eingegangen. Ein fremdes System schickt Adressdaten an{' '}
|
||||
<code>POST /api/webhook</code>; sie erscheinen hier ohne Zutun.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="webhook">
|
||||
{events.map((event) => (
|
||||
<li key={event.id} className="webhook__item">
|
||||
<div className="webhook__meta">
|
||||
<span className="webhook__time">{describeTime(event.receivedAt)}</span>
|
||||
<span className="webhook__id">#{event.id}</span>
|
||||
<span className="webhook__summary">{summarize(event.payload)}</span>
|
||||
</div>
|
||||
<pre className="webhook__json">{formatPayload(event.payload)}</pre>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="note">
|
||||
Der Webhook nimmt beliebiges JSON an – Objekt wie Liste – und zeigt es hier unverändert.
|
||||
Der Verlauf liegt im Speicher des Backends und ist nach dessen Neustart leer; „Leeren"
|
||||
verwirft ihn auch dort.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { SwyxTrayClient, type ConnectionStatus, type RawMessage } from '../swyx/SwyxTrayClient'
|
||||
import { mergeSnapshot, type CallEvent, type SnapshotMessage } from '../swyx/protocol'
|
||||
import { mergeSnapshot, type CallEvent, type Contact, type SnapshotMessage } from '../swyx/protocol'
|
||||
import { loadDirectory as loadDirectoryFrom, type SweepOptions } from '../swyx/directory'
|
||||
|
||||
export interface LogEntry extends RawMessage {
|
||||
id: number
|
||||
@@ -31,6 +32,8 @@ export function useSwyxTray() {
|
||||
// Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu.
|
||||
const [lines, setLines] = useState<Record<number, CallEvent>>({})
|
||||
const [tray, setTray] = useState<TrayState>({})
|
||||
// Adress-Cache der App; null heißt „noch keine Cache-Nachricht erhalten".
|
||||
const [addressCache, setAddressCache] = useState<Contact[] | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const clientRef = useRef<SwyxTrayClient | null>(null)
|
||||
@@ -54,9 +57,14 @@ export function useSwyxTray() {
|
||||
linesRef.current = {}
|
||||
setLines({})
|
||||
setTray({})
|
||||
// Auch der Cache gehörte zur alten Verbindung; die App schickt ihn
|
||||
// beim Verbinden erneut.
|
||||
setAddressCache(null)
|
||||
}
|
||||
}),
|
||||
client.on('error', (message) => setError(message)),
|
||||
// Unaufgefordert: beim Verbinden und wenn die App ihren Cache erneuert.
|
||||
client.on('addresses', (contacts) => setAddressCache(contacts)),
|
||||
client.on('raw', (message) => {
|
||||
setLog((entries) => {
|
||||
const next = [...entries, { ...message, id: logIdRef.current++ }]
|
||||
@@ -123,6 +131,26 @@ export function useSwyxTray() {
|
||||
const openTab = useCallback((url: string) => run(() => client.openTab(url)), [client, run])
|
||||
const closeTab = useCallback((tabId: number) => run(() => client.closeTab(tabId)), [client, run])
|
||||
|
||||
/**
|
||||
* Holt den **gesamten** Adressbestand – über den Adress-Cache der App
|
||||
* (`addresses`), sonst über rund 110 Einzelabfragen. Der zweite Weg braucht
|
||||
* einige Sekunden; daher Fortschritt und Abbruchmöglichkeit. Der Busy-Zustand
|
||||
* gilt für den ganzen Vorgang, nicht für jede Einzelabfrage.
|
||||
*/
|
||||
const loadDirectory = useCallback(
|
||||
(options: SweepOptions = {}) =>
|
||||
run(() =>
|
||||
loadDirectoryFrom(
|
||||
{
|
||||
listAddresses: async () => (await client.listAddresses()).addresses ?? [],
|
||||
search: async (query) => (await client.searchContacts(query)).contacts ?? [],
|
||||
},
|
||||
options,
|
||||
),
|
||||
),
|
||||
[client, run],
|
||||
)
|
||||
|
||||
const sendRaw = useCallback(
|
||||
(text: string) => {
|
||||
try {
|
||||
@@ -157,6 +185,7 @@ export function useSwyxTray() {
|
||||
calls,
|
||||
ringingCall,
|
||||
tray,
|
||||
addressCache,
|
||||
busy,
|
||||
connect,
|
||||
disconnect,
|
||||
@@ -168,6 +197,7 @@ export function useSwyxTray() {
|
||||
listTabs,
|
||||
openTab,
|
||||
closeTab,
|
||||
loadDirectory,
|
||||
sendRaw,
|
||||
clearLog,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
clearHistory,
|
||||
fetchHistory,
|
||||
isWebhookEvent,
|
||||
mergeEvent,
|
||||
mergeEvents,
|
||||
type WebhookEvent,
|
||||
} from '../webhook'
|
||||
|
||||
export type WebhookStatus = 'connecting' | 'open' | 'closed'
|
||||
|
||||
export interface WebhookState {
|
||||
events: WebhookEvent[]
|
||||
status: WebhookStatus
|
||||
error: string | null
|
||||
clear: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hört auf die Nachrichten des Webhooks.
|
||||
*
|
||||
* Der Strom läuft über eine `EventSource` auf `/api/webhook/events` und wird
|
||||
* **unabhängig vom geöffneten Bereich** gehalten: So ist schon alles da, wenn
|
||||
* der Bereich „Webhook" aufgeschlagen wird. Bricht die Verbindung ab, verbindet
|
||||
* der Browser von selbst neu und schickt dabei die `Last-Event-ID` mit – das
|
||||
* Backend liefert dann nach, was in der Lücke ankam.
|
||||
*
|
||||
* Zusätzlich wird beim Start der Verlauf geholt, damit auch Nachrichten von vor
|
||||
* dem Laden der Seite erscheinen.
|
||||
*/
|
||||
export function useWebhook(): WebhookState {
|
||||
const [events, setEvents] = useState<WebhookEvent[]>([])
|
||||
const [status, setStatus] = useState<WebhookStatus>('connecting')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const sourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
fetchHistory(controller.signal)
|
||||
.then((history) => {
|
||||
if (controller.signal.aborted) return
|
||||
setEvents((current) => mergeEvents(current, history))
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
})
|
||||
|
||||
const source = new EventSource('/api/webhook/events')
|
||||
sourceRef.current = source
|
||||
|
||||
source.onopen = () => {
|
||||
setStatus('open')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
source.addEventListener('webhook', (event) => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse((event as MessageEvent<string>).data)
|
||||
if (isWebhookEvent(parsed)) setEvents((current) => mergeEvent(current, parsed))
|
||||
} catch {
|
||||
// Unlesbare Nachricht: lieber übergehen als die Anzeige abstürzen lassen.
|
||||
}
|
||||
})
|
||||
|
||||
source.onerror = () => {
|
||||
// Der Browser verbindet von selbst neu; nur der endgültige Abbruch ist final.
|
||||
setStatus(source.readyState === EventSource.CLOSED ? 'closed' : 'connecting')
|
||||
}
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
source.close()
|
||||
sourceRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
try {
|
||||
await clearHistory()
|
||||
setEvents([])
|
||||
setError(null)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { events, status, error, clear }
|
||||
}
|
||||
@@ -547,6 +547,127 @@ code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(420px, 100%);
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 24px 60px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.dialog__title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.dialog__status {
|
||||
margin: 12px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dialog__detail {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress__bar {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
transition: width 120ms linear;
|
||||
}
|
||||
|
||||
/* Umfang noch unbekannt: ein wanderndes Stück statt eines Füllstands. */
|
||||
.progress__bar--unknown {
|
||||
width: 35%;
|
||||
animation: progress-slide 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-slide {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(300%);
|
||||
}
|
||||
}
|
||||
|
||||
.contacts {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
/* Mehrere hundert Einträge – die Liste darf die Seite nicht sprengen. */
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.contacts__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.contacts__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contacts__name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contacts__description {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contacts__number {
|
||||
flex: 0 0 auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log {
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
@@ -595,3 +716,82 @@ code {
|
||||
.log__entry--system {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Webhook: eingegangene Nachrichten, jüngste zuerst. */
|
||||
.webhook {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.webhook__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.webhook__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
/* Bei schmalem Fenster darf die Kopfzeile umbrechen, statt zu überlaufen. */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.webhook__time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.webhook__id {
|
||||
padding: 1px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--border);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.webhook__summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.webhook__json {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
/* Lange Zeilen scrollen im Block, statt die Seite breit zu ziehen. */
|
||||
overflow-x: auto;
|
||||
/* Ein einzelner Riesenwert soll die Karte nicht sprengen. */
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tabs__badge {
|
||||
margin-left: 8px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--surface);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,24 @@ import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'rea
|
||||
import { DEFAULT_WS_URL, fetchClientConfig } from '../config'
|
||||
import { useSwyxTray } from '../hooks/useSwyxTray'
|
||||
import { useCallNotifications } from '../hooks/useCallNotifications'
|
||||
import { useWebhook } from '../hooks/useWebhook'
|
||||
import { countUnseen } from '../webhook'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import MessageLog from '../components/MessageLog'
|
||||
import IncomingCallCard from '../components/IncomingCallCard'
|
||||
import DialPanel from '../components/DialPanel'
|
||||
import BrowserTabsPanel from '../components/BrowserTabsPanel'
|
||||
import ContactsPanel from '../components/ContactsPanel'
|
||||
import WebhookPanel from '../components/WebhookPanel'
|
||||
import Tabs, { panelId, tabId, type Tab } from '../components/Tabs'
|
||||
import { ActiveCallList, CallHistory } from '../components/CallList'
|
||||
|
||||
type TabName = 'calls' | 'browsertabs' | 'connection'
|
||||
type TabName = 'calls' | 'contacts' | 'webhook' | 'browsertabs' | 'connection'
|
||||
|
||||
const TABS: Tab<TabName>[] = [
|
||||
{ id: 'calls', label: 'Anrufe' },
|
||||
{ id: 'contacts', label: 'Adressdaten' },
|
||||
{ id: 'webhook', label: 'Webhook' },
|
||||
{ id: 'browsertabs', label: 'Tabs' },
|
||||
{ id: 'connection', label: 'Verbindung' },
|
||||
]
|
||||
@@ -40,6 +46,25 @@ export default function HomePage() {
|
||||
|
||||
const { permission, requestPermission } = useCallNotifications(ringingCall)
|
||||
|
||||
// Läuft unabhängig vom geöffneten Bereich: Der Webhook hängt am Backend, nicht
|
||||
// an der SwyxTray-Verbindung, und soll auch dann mitschreiben, wenn gerade ein
|
||||
// anderer Bereich offen ist.
|
||||
const webhook = useWebhook()
|
||||
|
||||
// Eine Webhook-Nachricht kommt unabhängig davon an, welcher Bereich offen ist.
|
||||
// Damit sie nicht unbemerkt bleibt, trägt der Tab die Zahl der noch nicht
|
||||
// gesehenen Nachrichten; gesehen ist alles, was bei offenem Bereich ankam.
|
||||
const [seenWebhookId, setSeenWebhookId] = useState(0)
|
||||
const newestWebhookId = webhook.events[0]?.id ?? 0
|
||||
useEffect(() => {
|
||||
if (tab === 'webhook') setSeenWebhookId(newestWebhookId)
|
||||
}, [tab, newestWebhookId])
|
||||
const unseenWebhook = countUnseen(webhook.events, seenWebhookId)
|
||||
|
||||
const tabsWithBadges = TABS.map((entry) =>
|
||||
entry.id === 'webhook' ? { ...entry, badge: unseenWebhook } : entry,
|
||||
)
|
||||
|
||||
// Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren.
|
||||
const urlTouched = useRef(false)
|
||||
|
||||
@@ -93,7 +118,7 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||
<Tabs tabs={tabsWithBadges} active={tab} onChange={setTab} />
|
||||
|
||||
<TabPanel id="calls" active={tab === 'calls'}>
|
||||
<section className="card">
|
||||
@@ -130,6 +155,32 @@ export default function HomePage() {
|
||||
</section>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="contacts" active={tab === 'contacts'}>
|
||||
<section className="card">
|
||||
<ContactsPanel
|
||||
active={tab === 'contacts'}
|
||||
disabled={!isConnected}
|
||||
cache={tray.addressCache}
|
||||
// Unverpackt weitergereicht: der Rückruf ist stabil, damit das
|
||||
// Panel nicht bei jedem Rendern neu lädt.
|
||||
onLoad={tray.loadDirectory}
|
||||
onDial={(number) => void tray.dial(number)}
|
||||
/>
|
||||
{error && <p className="note note--error">{error}</p>}
|
||||
</section>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="webhook" active={tab === 'webhook'}>
|
||||
<section className="card">
|
||||
<WebhookPanel
|
||||
events={webhook.events}
|
||||
status={webhook.status}
|
||||
error={webhook.error}
|
||||
onClear={() => void webhook.clear()}
|
||||
/>
|
||||
</section>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="browsertabs" active={tab === 'browsertabs'}>
|
||||
<section className="card">
|
||||
<BrowserTabsPanel
|
||||
@@ -240,7 +291,8 @@ export default function HomePage() {
|
||||
|
||||
<footer className="page__footer">
|
||||
SwyxTray · Kommandos <code>call</code> / <code>answer</code> / <code>hangup</code> /{' '}
|
||||
<code>tabs</code> / <code>opentab</code> / <code>closetab</code> · Zustand über{' '}
|
||||
<code>tabs</code> / <code>opentab</code> / <code>closetab</code> / <code>contacts</code> ·
|
||||
Zustand über{' '}
|
||||
<code>snapshot</code> · Standardziel <code>{DEFAULT_WS_URL}</code>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
COMMANDS,
|
||||
CONTACT_QUERY_MAX_LENGTH,
|
||||
parseAddresses,
|
||||
parseHello,
|
||||
parseResult,
|
||||
parseSnapshot,
|
||||
type Contact,
|
||||
type HelloMessage,
|
||||
type ResultMessage,
|
||||
type SnapshotMessage,
|
||||
@@ -20,6 +23,8 @@ interface Listeners {
|
||||
status: (status: ConnectionStatus) => void
|
||||
hello: (hello: HelloMessage) => void
|
||||
snapshot: (snapshot: SnapshotMessage) => void
|
||||
/** Unaufgeforderter Adress-Cache der App; auch eine leere Liste zählt. */
|
||||
addresses: (contacts: Contact[]) => void
|
||||
raw: (message: RawMessage) => void
|
||||
error: (message: string) => void
|
||||
}
|
||||
@@ -50,6 +55,7 @@ export class SwyxTrayClient {
|
||||
status: new Set(),
|
||||
hello: new Set(),
|
||||
snapshot: new Set(),
|
||||
addresses: new Set(),
|
||||
raw: new Set(),
|
||||
error: new Set(),
|
||||
}
|
||||
@@ -179,6 +185,35 @@ export class SwyxTrayClient {
|
||||
return this.send(COMMANDS.closeTab, { tabId }, TAB_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Durchsucht die Adressdaten des Swyx-Clients. Gesucht wird als
|
||||
* Teilzeichenkette in Name und Rufnummer; die App liefert höchstens 100
|
||||
* Treffer und kürzt ohne Hinweis. Ein leerer
|
||||
* Suchbegriff wäre ein Fehler der App – er wird hier gar nicht erst gesendet.
|
||||
*/
|
||||
searchContacts(query: string): Promise<ResultMessage> {
|
||||
const wanted = query.trim()
|
||||
if (!wanted) {
|
||||
return Promise.reject(new Error('Bitte einen Suchbegriff angeben.'))
|
||||
}
|
||||
if (wanted.length > CONTACT_QUERY_MAX_LENGTH) {
|
||||
return Promise.reject(
|
||||
new Error(`Der Suchbegriff darf höchstens ${CONTACT_QUERY_MAX_LENGTH} Zeichen lang sein.`),
|
||||
)
|
||||
}
|
||||
return this.send(COMMANDS.contacts, { query: wanted })
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt den Adress-Cache der App am Stück – ohne Suchbegriff und ohne Deckel.
|
||||
* Ältere App-Versionen kennen das Kommando nicht und antworten mit
|
||||
* `"Unbekanntes Kommando 'addresses'."`; die Startseite weicht dann auf die
|
||||
* Einzelabfragen aus.
|
||||
*/
|
||||
listAddresses(): Promise<ResultMessage> {
|
||||
return this.send(COMMANDS.addresses)
|
||||
}
|
||||
|
||||
ping(): Promise<ResultMessage> {
|
||||
return this.send(COMMANDS.ping)
|
||||
}
|
||||
@@ -270,14 +305,22 @@ export class SwyxTrayClient {
|
||||
return
|
||||
}
|
||||
|
||||
// Die App schickt keine Ereignisse: Jede Änderung – auch ein eingehender
|
||||
// Anruf – kommt als vollständiger Snapshot.
|
||||
// Die App schickt keine Anruf-Ereignisse: Jede Änderung – auch ein
|
||||
// eingehender Anruf – kommt als vollständiger Snapshot.
|
||||
const snapshot = parseSnapshot(parsed)
|
||||
if (snapshot) {
|
||||
this.emit('snapshot', snapshot)
|
||||
return
|
||||
}
|
||||
|
||||
// Der Adress-Cache dagegen kommt unaufgefordert – beim Verbinden und wenn
|
||||
// die App ihn erneuert.
|
||||
const addresses = parseAddresses(parsed)
|
||||
if (addresses) {
|
||||
this.emit('addresses', addresses)
|
||||
return
|
||||
}
|
||||
|
||||
const hello = parseHello(parsed)
|
||||
if (hello) {
|
||||
this.emit('hello', hello)
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { CONTACT_RESULT_LIMIT, type Contact } from './protocol'
|
||||
import {
|
||||
contactKey,
|
||||
filterContacts,
|
||||
initialQueries,
|
||||
loadDirectory,
|
||||
sortContacts,
|
||||
sweepDirectory,
|
||||
type DirectoryProgress,
|
||||
} from './directory'
|
||||
|
||||
/**
|
||||
* Spielt die SwyxTray-App nach – mit genau den Regeln, die gegen die laufende
|
||||
* App sondiert wurden: Teilzeichenkette in **Name und Rufnummer** (nicht in der
|
||||
* Beschreibung), Schreibweise egal, nach Namen sortiert und stillschweigend bei
|
||||
* {@link CONTACT_RESULT_LIMIT} Treffern gekürzt.
|
||||
*/
|
||||
function fakeApp(contacts: Contact[]) {
|
||||
const calls: string[] = []
|
||||
const search = async (query: string): Promise<Contact[]> => {
|
||||
calls.push(query)
|
||||
const needle = query.toLowerCase()
|
||||
return contacts
|
||||
.filter(
|
||||
(c) =>
|
||||
(c.name ?? '').toLowerCase().includes(needle) ||
|
||||
(c.number ?? '').toLowerCase().includes(needle),
|
||||
)
|
||||
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
|
||||
.slice(0, CONTACT_RESULT_LIMIT)
|
||||
}
|
||||
return { search, calls }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestand mit vierstelligen Rufnummern – wie in der Anlage. Die Namen bestehen
|
||||
* bewusst nur aus Buchstaben: eine Ziffer im Namen ließe die Ziffernabfragen
|
||||
* auch über den Namen treffen, was es in der Anlage nicht gibt.
|
||||
*/
|
||||
function directoryOf(count: number, step = 1, from = 4000): Contact[] {
|
||||
const letter = (i: number) => String.fromCharCode(97 + (i % 26))
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
name: `Muster${letter(Math.floor(i / 676))}${letter(Math.floor(i / 26))}${letter(i)}, Max`,
|
||||
number: String(from + i * step),
|
||||
description: 'HH-MT',
|
||||
}))
|
||||
}
|
||||
|
||||
describe('initialQueries', () => {
|
||||
it('fragt zehn Ziffern und hundert Ziffernpaare ab', () => {
|
||||
const queries = initialQueries()
|
||||
expect(queries).toHaveLength(110)
|
||||
expect(new Set(queries).size).toBe(110)
|
||||
expect(queries).toContain('0')
|
||||
expect(queries).toContain('00')
|
||||
expect(queries).toContain('99')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sweepDirectory', () => {
|
||||
it('findet den gesamten Bestand', async () => {
|
||||
// 404 Einträge – der Umfang der echten Anlage, hier aber auf einem
|
||||
// lückenlosen Nummernblock: dann hängt "40" am Deckel und der Durchlauf
|
||||
// muss verfeinern. Vollständig wird er trotzdem.
|
||||
const contacts = directoryOf(404)
|
||||
const { search } = fakeApp(contacts)
|
||||
|
||||
const result = await sweepDirectory(search)
|
||||
|
||||
expect(result.contacts).toHaveLength(404)
|
||||
expect(result.complete).toBe(true)
|
||||
expect(result.aborted).toBe(false)
|
||||
expect(new Set(result.contacts.map(contactKey))).toEqual(new Set(contacts.map(contactKey)))
|
||||
})
|
||||
|
||||
it('kommt ohne Verfeinerung aus, solange keine Abfrage gekürzt wird', async () => {
|
||||
// Rufnummern mit Lücken, wie in einer gewachsenen Anlage.
|
||||
const { search, calls } = fakeApp(directoryOf(404, 7))
|
||||
const result = await sweepDirectory(search)
|
||||
// Genau die 110 Abfragen der Grundliste – gemessen ist bei der echten
|
||||
// Anlage keine davon am Deckel (Höchstwert 59 Treffer).
|
||||
expect(calls).toHaveLength(110)
|
||||
expect(result.queries).toBe(110)
|
||||
})
|
||||
|
||||
it('meldet jeden Eintrag nur einmal, auch wenn ihn mehrere Abfragen finden', async () => {
|
||||
// "4123" wird von "41", "12", "23", "1", "2", "3", "4" gefunden.
|
||||
const { search } = fakeApp([{ name: 'Muster, Max', number: '4123' }])
|
||||
const result = await sweepDirectory(search)
|
||||
expect(result.contacts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('findet auch eine einstellige Rufnummer – dafür sind die Ziffernabfragen da', async () => {
|
||||
// "0" steckt in keinem Ziffernpaar; ohne die einstelligen Abfragen fehlte
|
||||
// die Zentrale im Bestand.
|
||||
const contacts = [...directoryOf(20), { name: 'Zentrale', number: '0', description: '' }]
|
||||
const { search } = fakeApp(contacts)
|
||||
const result = await sweepDirectory(search)
|
||||
expect(result.contacts.map((c) => c.number)).toContain('0')
|
||||
})
|
||||
|
||||
it('verfeinert eine gekürzte Abfrage und wird trotzdem vollständig', async () => {
|
||||
// Alle 105 Nummern beginnen mit "12"; diese Abfrage hängt also am Deckel.
|
||||
const contacts = Array.from({ length: 105 }, (_, i) => ({
|
||||
name: `Muster${String.fromCharCode(97 + (i % 26))}${i}, Max`.replace(/\d/g, ''),
|
||||
number: `12${i % 10}${String(i).padStart(3, '0')}`,
|
||||
}))
|
||||
const { search, calls } = fakeApp(contacts)
|
||||
|
||||
const result = await sweepDirectory(search)
|
||||
|
||||
expect(result.contacts).toHaveLength(105)
|
||||
expect(result.complete).toBe(true)
|
||||
// Verfeinert wurde vorn und hinten – beides gehört dazu, weil eine
|
||||
// Teilzeichenkette an beiden Enden weitergehen kann.
|
||||
expect(calls.length).toBeGreaterThan(110)
|
||||
expect(calls).toContain('120')
|
||||
expect(calls).toContain('012')
|
||||
})
|
||||
|
||||
it('gibt auf, wenn sich eine gekürzte Abfrage nicht mehr verfeinern lässt', async () => {
|
||||
// Über hundert Einträge mit derselben dreistelligen Nummer: die Abfrage
|
||||
// "123" bleibt gekürzt, egal wie oft verfeinert wird.
|
||||
const contacts = directoryOf(130, 1).map((c) => ({ ...c, number: '123' }))
|
||||
const { search } = fakeApp(contacts)
|
||||
|
||||
const result = await sweepDirectory(search)
|
||||
|
||||
expect(result.complete).toBe(false)
|
||||
expect(result.contacts.length).toBeLessThan(130)
|
||||
})
|
||||
|
||||
it('meldet den Fortschritt und zählt bis zum Ende hoch', async () => {
|
||||
const onProgress = vi.fn<(p: DirectoryProgress) => void>()
|
||||
const { search } = fakeApp(directoryOf(30))
|
||||
|
||||
const result = await sweepDirectory(search, { onProgress })
|
||||
|
||||
const reports = onProgress.mock.calls.map(([p]) => p)
|
||||
expect(reports[0]).toEqual({ done: 0, total: 110, found: 0 })
|
||||
expect(reports.at(-1)).toEqual({ done: 110, total: 110, found: 30 })
|
||||
// Monoton – sonst spränge der Balken im Wartedialog zurück.
|
||||
expect(reports.map((p) => p.done)).toEqual([...reports.map((p) => p.done)].sort((a, b) => a - b))
|
||||
expect(result.queries).toBe(110)
|
||||
})
|
||||
|
||||
it('bricht ab, ohne den bisherigen Stand wegzuwerfen', async () => {
|
||||
const contacts = directoryOf(404)
|
||||
const controller = new AbortController()
|
||||
const { search, calls } = fakeApp(contacts)
|
||||
|
||||
const result = await sweepDirectory(
|
||||
async (query) => {
|
||||
// Nach zwanzig Abfragen abbrechen – wie ein Klick auf „Abbrechen".
|
||||
if (calls.length >= 20) controller.abort()
|
||||
return search(query)
|
||||
},
|
||||
{ signal: controller.signal, concurrency: 1 },
|
||||
)
|
||||
|
||||
expect(result.aborted).toBe(true)
|
||||
expect(result.complete).toBe(false)
|
||||
// Kein Fehler, kein leeres Ergebnis: das bereits Geladene bleibt erhalten.
|
||||
expect(result.contacts.length).toBeGreaterThan(0)
|
||||
expect(result.contacts.length).toBeLessThan(404)
|
||||
expect(calls.length).toBeLessThan(110)
|
||||
})
|
||||
|
||||
it('reicht den Fehler einer Abfrage durch – ein Abbruch ist etwas anderes', async () => {
|
||||
await expect(
|
||||
sweepDirectory(async () => {
|
||||
throw new Error('Keine Verbindung zur SwyxTray-App.')
|
||||
}),
|
||||
).rejects.toThrow('Keine Verbindung zur SwyxTray-App.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadDirectory', () => {
|
||||
const cached: Contact[] = [
|
||||
{ name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' },
|
||||
{ name: 'Abt, Bettina', number: '7587', description: 'S-SB' },
|
||||
]
|
||||
|
||||
it('nimmt den Adress-Cache der App und fragt dann nichts mehr', async () => {
|
||||
const { search, calls } = fakeApp(directoryOf(404, 7))
|
||||
|
||||
const result = await loadDirectory({ listAddresses: async () => cached, search })
|
||||
|
||||
expect(result.source).toBe('cache')
|
||||
expect(result.queries).toBe(1)
|
||||
expect(result.complete).toBe(true)
|
||||
// Eine einzige Nachricht statt 110 – darum geht es beim Cache.
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(result.contacts.map((c) => c.name)).toEqual(['Abt, Bettina', 'Muster GmbH'])
|
||||
})
|
||||
|
||||
it('meldet jeden Eintrag des Caches nur einmal', async () => {
|
||||
const doppelt = [...cached, { ...cached[0] }]
|
||||
const { search } = fakeApp([])
|
||||
const result = await loadDirectory({ listAddresses: async () => doppelt, search })
|
||||
expect(result.contacts).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('weicht auf die Einzelabfragen aus, wenn die App das Kommando nicht kennt', async () => {
|
||||
// Genau der Fall der Anlage am 21.08.2026.
|
||||
const { search, calls } = fakeApp(directoryOf(404, 7))
|
||||
|
||||
const result = await loadDirectory({
|
||||
listAddresses: async () => {
|
||||
throw new Error("Unbekanntes Kommando 'addresses'.")
|
||||
},
|
||||
search,
|
||||
})
|
||||
|
||||
expect(result.source).toBe('sweep')
|
||||
expect(result.contacts).toHaveLength(404)
|
||||
expect(calls).toHaveLength(110)
|
||||
})
|
||||
|
||||
it('weicht auch bei leerem Cache aus – so meldet sich die Anlage zurzeit', async () => {
|
||||
const { search } = fakeApp(directoryOf(40, 7))
|
||||
const result = await loadDirectory({ listAddresses: async () => [], search })
|
||||
expect(result.source).toBe('sweep')
|
||||
expect(result.contacts).toHaveLength(40)
|
||||
})
|
||||
|
||||
it('fragt nach einem Abbruch gar nicht erst', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const listAddresses = vi.fn(async () => cached)
|
||||
const { search, calls } = fakeApp(directoryOf(10))
|
||||
|
||||
const result = await loadDirectory({ listAddresses, search }, { signal: controller.signal })
|
||||
|
||||
expect(listAddresses).not.toHaveBeenCalled()
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(result.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortContacts', () => {
|
||||
it('sortiert nach Namen, bei gleichem Namen nach Rufnummer', () => {
|
||||
const sorted = sortContacts([
|
||||
{ name: 'Bandorf, Volker', number: '53119' },
|
||||
{ name: 'Abt, Bettina', number: '7587' },
|
||||
{ name: 'Bandorf, Volker', number: '41295' },
|
||||
])
|
||||
expect(sorted.map((c) => `${c.name} ${c.number}`)).toEqual([
|
||||
'Abt, Bettina 7587',
|
||||
'Bandorf, Volker 41295',
|
||||
'Bandorf, Volker 53119',
|
||||
])
|
||||
})
|
||||
|
||||
it('lässt die Vorlage unangetastet', () => {
|
||||
const input = [{ name: 'Z' }, { name: 'A' }]
|
||||
sortContacts(input)
|
||||
expect(input.map((c) => c.name)).toEqual(['Z', 'A'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterContacts', () => {
|
||||
const contacts: Contact[] = [
|
||||
{ name: 'Abt, Bettina', number: '7587', description: 'S-SB' },
|
||||
{ name: 'Muster, Max', number: '4711', description: 'HH-MT' },
|
||||
]
|
||||
|
||||
it('gibt ohne Filter alles zurück', () => {
|
||||
expect(filterContacts(contacts, ' ')).toEqual(contacts)
|
||||
})
|
||||
|
||||
it('sucht in Namen und Rufnummer, Schreibweise egal', () => {
|
||||
expect(filterContacts(contacts, 'bettina')).toHaveLength(1)
|
||||
expect(filterContacts(contacts, '471')).toEqual([contacts[1]])
|
||||
})
|
||||
|
||||
it('sucht auch in der Beschreibung – anders als die App', () => {
|
||||
// Der Bestand liegt vollständig im Browser; das Kürzel ist damit filterbar.
|
||||
expect(filterContacts(contacts, 'hh-mt')).toEqual([contacts[1]])
|
||||
})
|
||||
})
|
||||
Binary file not shown.
@@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
callStateOf,
|
||||
describeCall,
|
||||
describeContact,
|
||||
describeTab,
|
||||
parseAddresses,
|
||||
mergeSnapshot,
|
||||
parseHello,
|
||||
parseResult,
|
||||
parseContacts,
|
||||
parseSnapshot,
|
||||
parseTabs,
|
||||
type CallEvent,
|
||||
@@ -79,6 +82,22 @@ describe('parseHello', () => {
|
||||
?.protocol,
|
||||
).toBe(6)
|
||||
})
|
||||
|
||||
it('liest die Protokollversion des Adress-Caches', () => {
|
||||
// Wörtlich mitgeschnitten am 21.08.2026 gegen die laufende Anlage.
|
||||
expect(
|
||||
parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":8,"session":3,"type":"hello"}'))
|
||||
?.protocol,
|
||||
).toBe(8)
|
||||
})
|
||||
|
||||
it('liest die Protokollversion der Adressdaten', () => {
|
||||
// Wörtlich mitgeschnitten, nachdem die App `contacts` gelernt hatte.
|
||||
expect(
|
||||
parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":7,"session":3,"type":"hello"}'))
|
||||
?.protocol,
|
||||
).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('callStateOf', () => {
|
||||
@@ -296,6 +315,56 @@ describe('parseResult', () => {
|
||||
const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))
|
||||
expect(result?.tabs).toBeUndefined()
|
||||
expect(result?.tabId).toBeUndefined()
|
||||
expect(result?.contacts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('liest die Quittung auf contacts', () => {
|
||||
// Wörtlich mitgeschnitten – Suchbegriff "abt".
|
||||
const raw =
|
||||
'{"id":20,"ok":true,"contacts":[' +
|
||||
'{"name":"Abt, Bettina","number":"7587","description":"S-SB"},' +
|
||||
'{"name":"Abdul, Rokhsareh","number":"5215","description":""}],"type":"result"}'
|
||||
expect(parseResult(JSON.parse(raw))?.contacts).toEqual([
|
||||
{ name: 'Abt, Bettina', number: '7587', description: 'S-SB' },
|
||||
// Die leere Beschreibung der App wird zu undefined.
|
||||
{ name: 'Abdul, Rokhsareh', number: '5215', description: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
it('liest die leere Trefferliste als leere Liste, nicht als "nicht gefragt"', () => {
|
||||
expect(parseResult(JSON.parse('{"id":21,"ok":true,"contacts":[],"type":"result"}'))?.contacts).toEqual(
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
it('liest die Fehlerquittung auf einen leeren Suchbegriff', () => {
|
||||
const raw =
|
||||
'{"id":22,"ok":false,' +
|
||||
'"error":"Feld \'query\' fehlt oder ist zu lang (max. 128 Zeichen).","type":"result"}'
|
||||
expect(parseResult(JSON.parse(raw))).toMatchObject({
|
||||
id: 22,
|
||||
ok: false,
|
||||
error: "Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen).",
|
||||
})
|
||||
})
|
||||
|
||||
it('liest die Quittung auf addresses', () => {
|
||||
const raw =
|
||||
'{"type":"result","id":5,"ok":true,"addresses":[' +
|
||||
'{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"}]}'
|
||||
expect(parseResult(JSON.parse(raw))?.addresses).toEqual([
|
||||
{ name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' },
|
||||
])
|
||||
})
|
||||
|
||||
it('liest die Fehlerquittung älterer App-Versionen auf addresses', () => {
|
||||
// Genau das antwortet die Anlage am 21.08.2026 noch.
|
||||
const raw = '{"id":5,"ok":false,"error":"Unbekanntes Kommando \'addresses\'.","type":"result"}'
|
||||
expect(parseResult(JSON.parse(raw))).toMatchObject({
|
||||
id: 5,
|
||||
ok: false,
|
||||
error: "Unbekanntes Kommando 'addresses'.",
|
||||
})
|
||||
})
|
||||
|
||||
it('hält einen Snapshot für keine Quittung', () => {
|
||||
@@ -324,6 +393,59 @@ describe('describeTab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAddresses', () => {
|
||||
it('liest die unaufgeforderte Cache-Nachricht', () => {
|
||||
const raw =
|
||||
'{"type":"addresses","addresses":[' +
|
||||
'{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"}]}'
|
||||
expect(parseAddresses(JSON.parse(raw))).toEqual([
|
||||
{ name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' },
|
||||
])
|
||||
})
|
||||
|
||||
it('hält den leeren Cache für eine Antwort, nicht für ein Versehen', () => {
|
||||
// Wörtlich mitgeschnitten: genau das schickt die Anlage zurzeit.
|
||||
expect(parseAddresses(JSON.parse('{"addresses":[],"type":"addresses"}'))).toEqual([])
|
||||
})
|
||||
|
||||
it('hält andere Nachrichten für keine Cache-Nachricht', () => {
|
||||
expect(parseAddresses(JSON.parse(HELLO))).toBeNull()
|
||||
expect(parseAddresses(snapshot())).toBeNull()
|
||||
})
|
||||
|
||||
it('ist keine Quittung – sie trägt weder id noch ok', () => {
|
||||
expect(parseResult(JSON.parse('{"addresses":[],"type":"addresses"}'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseContacts', () => {
|
||||
it('nimmt einen Eintrag ohne Namen hin – die Rufnummer genügt', () => {
|
||||
expect(parseContacts([{ number: '4711' }])).toEqual([
|
||||
{ name: undefined, number: '4711', description: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
it('verwirft Einträge ohne Namen und ohne Rufnummer', () => {
|
||||
// Anzeigen ließe sich so ein Eintrag nicht, wählen erst recht nicht.
|
||||
expect(parseContacts([{ description: 'HH-MT' }, 'Unfug', null, { name: 'Muster, Max' }])).toEqual([
|
||||
{ name: 'Muster, Max', number: undefined, description: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
it('hält die Reihenfolge der App ein – sie sortiert bereits nach Namen', () => {
|
||||
const entries = [{ name: 'Abt, Bettina', number: '7587' }, { name: 'Zentrale', number: '0' }]
|
||||
expect(parseContacts(entries).map((c) => c.name)).toEqual(['Abt, Bettina', 'Zentrale'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeContact', () => {
|
||||
it('nimmt den Namen, sonst die Rufnummer', () => {
|
||||
expect(describeContact({ name: 'Muster, Max', number: '4711' })).toBe('Muster, Max')
|
||||
expect(describeContact({ number: '4711' })).toBe('4711')
|
||||
expect(describeContact({})).toBe('Ohne Namen')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeCall', () => {
|
||||
it('bevorzugt die fertige Anzeigeform der App', () => {
|
||||
expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Protokoll der SwyxTray-App, aufgezeichnet gegen `ws://192.168.180.135:17654/ws`
|
||||
* (App-Version 1.0.0.0, `protocol: 1`; seit der Tab-Verwaltung meldet die App
|
||||
* `protocol: 6`).
|
||||
* (App-Version 1.0.0.0, `protocol: 1`; mit der Tab-Verwaltung meldete die App
|
||||
* `protocol: 6`, mit der Adresssuche `protocol: 7`, mit dem Adress-Cache `protocol: 8`).
|
||||
*
|
||||
* App → Seite:
|
||||
* { "app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello" }
|
||||
@@ -31,7 +31,30 @@
|
||||
* { "id":14,"cmd":"closetab","tabId":43 } → { …,"ok":true }
|
||||
* Ist kein Plugin verbunden, antwortet die App nach rund fünf Sekunden mit `ok:false`.
|
||||
*
|
||||
* **Die App kennt keine Ereignisnachrichten.** Jede Zustandsänderung – auch ein
|
||||
* Protokoll 7 bringt die Adressdaten des Swyx-Clients – das globale Telefonbuch:
|
||||
* { "id":20,"cmd":"contacts","query":"abt" }
|
||||
* → { "type":"result","id":20,"ok":true,
|
||||
* "contacts":[{"name":"Abt, Bettina","number":"7587","description":"S-SB"}, …] }
|
||||
* Gesucht wird als Teilzeichenkette in **Name und Rufnummer**, Schreibweise egal;
|
||||
* die Beschreibung wird nicht durchsucht. Sortiert nach Namen, höchstens
|
||||
* {@link CONTACT_RESULT_LIMIT} Treffer – gekürzt wird stillschweigend. `query` ist
|
||||
* Pflicht, darf nicht leer sein und höchstens {@link CONTACT_QUERY_MAX_LENGTH}
|
||||
* Zeichen lang; sonst antwortet die App mit
|
||||
* `"Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen)."`.
|
||||
*
|
||||
* Protokoll 8 bringt den **Adress-Cache**: die App hält die Adressdaten selbst vor
|
||||
* und gibt sie am Stück heraus – ohne Suchbegriff und ohne Deckel.
|
||||
* { "id":5,"cmd":"addresses" }
|
||||
* → { "type":"result","id":5,"ok":true,
|
||||
* "addresses":[{"name":"Muster GmbH","number":"+493012345",
|
||||
* "description":"Globales Telefonbuch"}] }
|
||||
* Dieselben Daten schickt die App außerdem **unaufgefordert** als eigene Nachricht,
|
||||
* direkt nach dem ersten Snapshot und wenn sich ihr Cache ändert:
|
||||
* { "type":"addresses","addresses":[…] }
|
||||
* Beide tragen dieselben Einträge wie `contacts` – Name, Rufnummer, Beschreibung.
|
||||
* Der Push ist die einzige Nachricht der App **ohne** `id` und ohne `ok`.
|
||||
*
|
||||
* **Sonst kennt die App keine Ereignisnachrichten.** Jede Zustandsänderung – auch ein
|
||||
* eingehender Anruf – kommt als vollständiger `snapshot` über *alle* Leitungen.
|
||||
* Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots
|
||||
* (siehe {@link mergeSnapshot}).
|
||||
@@ -66,6 +89,10 @@ export interface ResultMessage {
|
||||
tabs?: BrowserTab[]
|
||||
/** Nur bei `opentab`: Kennung des neu geöffneten Tabs. */
|
||||
tabId?: number
|
||||
/** Nur bei `contacts`: die gefundenen Adressdaten. */
|
||||
contacts?: Contact[]
|
||||
/** Nur bei `addresses`: der gesamte Adress-Cache der App. */
|
||||
addresses?: Contact[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -79,6 +106,26 @@ export interface BrowserTab {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Eintrag aus den Adressdaten des Swyx-Clients, wie ihn die Quittung auf
|
||||
* `contacts` meldet. Leere Felder schickt die App als `""`; hier bleiben sie
|
||||
* `undefined`.
|
||||
*/
|
||||
export interface Contact {
|
||||
/** Anzeigename, meist „Nachname, Vorname". */
|
||||
name?: string
|
||||
/** Rufnummer, meist die interne Durchwahl – so, wie sie an `call` geht. */
|
||||
number?: string
|
||||
/** Zusatz der Telefonanlage, z. B. der Standort („HH-MT"). */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Mehr Treffer meldet die App nicht; sie kürzt ohne Hinweis. */
|
||||
export const CONTACT_RESULT_LIMIT = 100
|
||||
|
||||
/** Längere Suchbegriffe weist die App mit einem Fehler zurück. */
|
||||
export const CONTACT_QUERY_MAX_LENGTH = 128
|
||||
|
||||
/** Begrüßung beim Verbindungsaufbau. */
|
||||
export interface HelloMessage {
|
||||
app?: string
|
||||
@@ -115,6 +162,8 @@ export const COMMANDS = {
|
||||
tabs: 'tabs',
|
||||
openTab: 'opentab',
|
||||
closeTab: 'closetab',
|
||||
contacts: 'contacts',
|
||||
addresses: 'addresses',
|
||||
} as const
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -207,6 +256,8 @@ export function parseResult(raw: unknown): ResultMessage | null {
|
||||
focused: asBoolean(raw.focused),
|
||||
tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined,
|
||||
tabId: asLine(raw.tabId),
|
||||
contacts: Array.isArray(raw.contacts) ? parseContacts(raw.contacts) : undefined,
|
||||
addresses: Array.isArray(raw.addresses) ? parseContacts(raw.addresses) : undefined,
|
||||
error: asString(raw.error),
|
||||
}
|
||||
}
|
||||
@@ -236,6 +287,35 @@ export function describeTab(tab: BrowserTab): string {
|
||||
return tab.title ?? tab.url ?? `Tab ${tab.id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Adressdaten aus der Quittung auf `contacts`. Einträge ohne Namen
|
||||
* *und* ohne Rufnummer fallen weg – sie ließen sich weder anzeigen noch wählen.
|
||||
*/
|
||||
export function parseContacts(raw: unknown[]): Contact[] {
|
||||
return raw.flatMap((entry) => {
|
||||
if (!isRecord(entry)) return []
|
||||
const name = asString(entry.name)
|
||||
const number = asString(entry.number)
|
||||
if (name === undefined && number === undefined) return []
|
||||
return [{ name, number, description: asString(entry.description) }]
|
||||
})
|
||||
}
|
||||
|
||||
/** Anzeigename eines Adresseintrags: Name, sonst die Rufnummer. */
|
||||
export function describeContact(contact: Contact): string {
|
||||
return contact.name ?? contact.number ?? 'Ohne Namen'
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die unaufgeforderte Cache-Nachricht `{"type":"addresses","addresses":[…]}`.
|
||||
* Ein leeres Feld ist ein gültiger Zustand – die App meldet damit einen leeren
|
||||
* Cache – und darf deshalb nicht wie „keine Nachricht" behandelt werden.
|
||||
*/
|
||||
export function parseAddresses(raw: unknown): Contact[] | null {
|
||||
if (!isRecord(raw) || raw.type !== 'addresses') return null
|
||||
return Array.isArray(raw.addresses) ? parseContacts(raw.addresses) : []
|
||||
}
|
||||
|
||||
/** Liest die Begrüßung. */
|
||||
export function parseHello(raw: unknown): HelloMessage | null {
|
||||
if (!isRecord(raw) || raw.type !== 'hello') return null
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
countUnseen,
|
||||
formatPayload,
|
||||
isWebhookEvent,
|
||||
mergeEvent,
|
||||
mergeEvents,
|
||||
summarize,
|
||||
type WebhookEvent,
|
||||
} from './webhook'
|
||||
|
||||
function event(id: number, payload: unknown = {}): WebhookEvent {
|
||||
return { id, receivedAt: '2026-08-24T10:00:00Z', payload }
|
||||
}
|
||||
|
||||
describe('isWebhookEvent', () => {
|
||||
it('erkennt die Form des Backends', () => {
|
||||
expect(isWebhookEvent(event(1))).toBe(true)
|
||||
})
|
||||
|
||||
it('lässt jede Nutzlast durchgehen – auch null', () => {
|
||||
expect(isWebhookEvent({ id: 1, receivedAt: '2026-08-24T10:00:00Z', payload: null })).toBe(true)
|
||||
})
|
||||
|
||||
it('weist alles ohne id oder Zeitstempel ab', () => {
|
||||
expect(isWebhookEvent(null)).toBe(false)
|
||||
expect(isWebhookEvent({ id: '1', receivedAt: 'x' })).toBe(false)
|
||||
expect(isWebhookEvent({ id: 1 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeEvent', () => {
|
||||
it('stellt die jüngste Nachricht nach vorn', () => {
|
||||
const merged = mergeEvent([event(1)], event(2))
|
||||
expect(merged.map((e) => e.id)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('nimmt eine bereits bekannte id nicht doppelt auf', () => {
|
||||
// Genau dieser Fall tritt im Betrieb auf: Der Verlauf wird geholt, während
|
||||
// dieselbe Nachricht schon über den Ereignisstrom kam.
|
||||
const merged = mergeEvent([event(2), event(1)], event(2, { neu: true }))
|
||||
expect(merged.map((e) => e.id)).toEqual([2, 1])
|
||||
expect(merged[0].payload).toEqual({ neu: true })
|
||||
})
|
||||
|
||||
it('deckelt die Liste', () => {
|
||||
const merged = mergeEvent([event(3), event(2), event(1)], event(4), 3)
|
||||
expect(merged.map((e) => e.id)).toEqual([4, 3, 2])
|
||||
})
|
||||
|
||||
it('sortiert auch dann nach id, wenn Nachrichten verspätet nachkommen', () => {
|
||||
// Nach einem Wiederverbinden liefert das Backend Verpasstes nach.
|
||||
const merged = mergeEvents([event(5)], [event(3), event(4)])
|
||||
expect(merged.map((e) => e.id)).toEqual([5, 4, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarize', () => {
|
||||
it('fasst Adressdaten aus name und number zusammen', () => {
|
||||
expect(summarize({ name: 'Muster GmbH', number: '+493012345' })).toBe('Muster GmbH · +493012345')
|
||||
})
|
||||
|
||||
it('kommt mit nur einem der beiden Felder aus', () => {
|
||||
expect(summarize({ name: 'Abt, Bettina' })).toBe('Abt, Bettina')
|
||||
expect(summarize({ number: '7587' })).toBe('7587')
|
||||
})
|
||||
|
||||
it('zählt die Einträge einer Liste', () => {
|
||||
expect(summarize([{ name: 'A' }, { name: 'B' }])).toBe('2 Einträge')
|
||||
expect(summarize([{ name: 'A' }])).toBe('1 Eintrag')
|
||||
})
|
||||
|
||||
it('beschreibt fremde Formen, statt zu raten', () => {
|
||||
expect(summarize({ kunde: 4711, quelle: 'CRM' })).toBe('2 Felder: kunde, quelle')
|
||||
expect(summarize({})).toBe('leeres Objekt')
|
||||
expect(summarize(null)).toBe('null')
|
||||
expect(summarize(42)).toBe('42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatPayload', () => {
|
||||
it('rückt ein', () => {
|
||||
expect(formatPayload({ a: 1 })).toBe('{\n "a": 1\n}')
|
||||
})
|
||||
|
||||
it('kommt mit einfachen Werten zurecht', () => {
|
||||
expect(formatPayload('text')).toBe('"text"')
|
||||
expect(formatPayload(null)).toBe('null')
|
||||
expect(formatPayload(undefined)).toBe('undefined')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countUnseen', () => {
|
||||
it('zählt, was seit dem letzten Blick dazukam', () => {
|
||||
expect(countUnseen([event(3), event(2), event(1)], 1)).toBe(2)
|
||||
})
|
||||
|
||||
it('zählt ohne vorherigen Blick alles', () => {
|
||||
expect(countUnseen([event(2), event(1)], 0)).toBe(2)
|
||||
})
|
||||
|
||||
it('zählt nichts, wenn die jüngste Nachricht gesehen ist', () => {
|
||||
expect(countUnseen([event(2), event(1)], 2)).toBe(0)
|
||||
expect(countUnseen([], 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Webhook: Adressdaten, die ein fremdes System per POST an das Backend schickt.
|
||||
*
|
||||
* Das Backend nimmt unter `POST /api/webhook` **beliebiges** JSON an und reicht
|
||||
* es über Server-Sent Events (`/api/webhook/events`) an die offenen Browser
|
||||
* weiter. Dieses Modul enthält nur die reine Logik – das Holen und Anzeigen
|
||||
* stehen in `hooks/useWebhook.ts` und `components/WebhookPanel.tsx`.
|
||||
*/
|
||||
|
||||
/** Eine über den Webhook eingegangene Nachricht, so wie das Backend sie liefert. */
|
||||
export interface WebhookEvent {
|
||||
/** Fortlaufend ab 1. Eindeutig bis zum Neustart des Backends. */
|
||||
id: number
|
||||
/** Eingangszeit im Backend, ISO-8601. */
|
||||
receivedAt: string
|
||||
/** Das empfangene JSON, unverändert. Objekt, Liste oder ein einfacher Wert. */
|
||||
payload: unknown
|
||||
}
|
||||
|
||||
/** So viele Nachrichten hält die Anzeige vor; das Backend deckelt zusätzlich. */
|
||||
export const MAX_EVENTS = 50
|
||||
|
||||
/**
|
||||
* Prüft grob, ob eine Nachricht die Form des Backends hat. Die Nutzlast bleibt
|
||||
* bewusst ungeprüft – sie darf alles sein.
|
||||
*/
|
||||
export function isWebhookEvent(value: unknown): value is WebhookEvent {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const candidate = value as Partial<WebhookEvent>
|
||||
return typeof candidate.id === 'number' && typeof candidate.receivedAt === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt eine Nachricht in die Liste auf: jüngste zuerst, ohne Doppel und
|
||||
* gedeckelt.
|
||||
*
|
||||
* Doppel entstehen im Normalbetrieb: Der Bereich holt beim Öffnen den Verlauf
|
||||
* und hört gleichzeitig auf den Ereignisstrom, und nach einem Abbruch liefert
|
||||
* das Backend anhand der `Last-Event-ID` nach. Entschieden wird über die `id`.
|
||||
*/
|
||||
export function mergeEvent(
|
||||
events: WebhookEvent[],
|
||||
incoming: WebhookEvent,
|
||||
max = MAX_EVENTS,
|
||||
): WebhookEvent[] {
|
||||
const without = events.filter((event) => event.id !== incoming.id)
|
||||
return [incoming, ...without].sort((a, b) => b.id - a.id).slice(0, max)
|
||||
}
|
||||
|
||||
/** Wie {@link mergeEvent}, aber für den Verlauf am Stück. */
|
||||
export function mergeEvents(
|
||||
events: WebhookEvent[],
|
||||
incoming: WebhookEvent[],
|
||||
max = MAX_EVENTS,
|
||||
): WebhookEvent[] {
|
||||
return incoming.reduce((all, event) => mergeEvent(all, event, max), events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Nachrichten jünger sind als die zuletzt gesehene.
|
||||
*
|
||||
* Der Bereich „Webhook" ist nicht immer offen, die Nachrichten kommen aber
|
||||
* trotzdem an. Daraus wird der Zähler am Tab.
|
||||
*/
|
||||
export function countUnseen(events: WebhookEvent[], seenId: number): number {
|
||||
return events.filter((event) => event.id > seenId).length
|
||||
}
|
||||
|
||||
/** Die Nutzlast lesbar eingerückt. */
|
||||
export function formatPayload(payload: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(payload, null, 2) ?? String(payload)
|
||||
} catch {
|
||||
// Zirkuläre Strukturen kann es über JSON nicht geben; bleibt die Notbremse.
|
||||
return String(payload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurzfassung für die Kopfzeile eines Eintrags. Adressdaten tragen üblicherweise
|
||||
* `name` und `number`; alles andere wird der Form nach beschrieben, statt zu
|
||||
* raten.
|
||||
*/
|
||||
export function summarize(payload: unknown): string {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.length === 1 ? '1 Eintrag' : `${payload.length} Einträge`
|
||||
}
|
||||
if (typeof payload === 'object' && payload !== null) {
|
||||
const record = payload as Record<string, unknown>
|
||||
const name = typeof record.name === 'string' ? record.name.trim() : ''
|
||||
const number = typeof record.number === 'string' ? record.number.trim() : ''
|
||||
if (name && number) return `${name} · ${number}`
|
||||
if (name) return name
|
||||
if (number) return number
|
||||
const keys = Object.keys(record)
|
||||
if (keys.length === 0) return 'leeres Objekt'
|
||||
return keys.length === 1 ? `1 Feld: ${keys[0]}` : `${keys.length} Felder: ${keys.join(', ')}`
|
||||
}
|
||||
if (payload === null) return 'null'
|
||||
return String(payload)
|
||||
}
|
||||
|
||||
/** Holt den Verlauf des Backends – die Nachrichten vor dem Öffnen des Bereichs. */
|
||||
export async function fetchHistory(signal?: AbortSignal): Promise<WebhookEvent[]> {
|
||||
const response = await fetch('/api/webhook/history', { signal })
|
||||
if (!response.ok) {
|
||||
throw new Error(`/api/webhook/history antwortete mit HTTP ${response.status}`)
|
||||
}
|
||||
const data: unknown = await response.json()
|
||||
if (!Array.isArray(data)) return []
|
||||
return data.filter(isWebhookEvent)
|
||||
}
|
||||
|
||||
/** Leert den Verlauf im Backend. */
|
||||
export async function clearHistory(): Promise<void> {
|
||||
const response = await fetch('/api/webhook/history', { method: 'DELETE' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`Verlauf konnte nicht geleert werden (HTTP ${response.status})`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user