Files
swyxweb/frontend/src/swyx/directory.ts
T
SvenandClaude Opus 5 01594a1f3d 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>
2026-08-24 15:11:55 +02:00

255 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Gesamtbestand der Adressdaten aus vielen Einzelabfragen zusammensetzen.
*
* Seit Protokoll 8 gibt die App ihren **Adress-Cache** am Stück heraus
* (`addresses`) dann genügt eine einzige Nachricht. {@link loadDirectory}
* versucht zuerst das.
*
* Der Rest dieser Datei ist die **Rückfallebene** für ältere App-Versionen und
* für einen leeren Cache. Denn ohne den Cache kennt die App kein „alles
* auflisten": `contacts` verlangt einen Suchbegriff und liefert höchstens
* {@link CONTACT_RESULT_LIMIT} Treffer, ohne die Kürzung zu melden. Der
* Gesamtbestand entsteht dann aus der Vereinigung vieler Abfragen.
*
* **Warum Ziffernpaare?** Gesucht wird als Teilzeichenkette in Name *und*
* Rufnummer. Jeder Eintrag hat eine Rufnummer, und die ist eine reine
* Ziffernfolge. Eine Nummer mit mindestens zwei Ziffern enthält also mindestens
* eines der hundert Paare `"00"`…`"99"` die Vereinigung dieser hundert
* Abfragen ist damit **nachweislich vollständig**, solange keine davon am
* Deckel hängt. Gegen die Anlage gemessen (404 Einträge): kein Paar kam über
* 59 Treffer.
*
* Die zehn einstelligen Abfragen kommen dazu, weil eine einstellige Rufnummer
* (etwa `"0"` für die Zentrale) in keinem Paar vorkäme.
*
* **Grenze der Vollständigkeit:** Hängt eine Abfrage am Deckel, wird sie
* verfeinert vorne *und* hinten je eine Ziffer angehängt. Das erfasst jeden
* Eintrag, dessen Rufnummer **länger** ist als die gekappte Abfrage. Nicht
* erfasst wäre ein Eintrag, dessen Rufnummer **genau** der gekappten Abfrage
* entspricht und der nicht schon unter den ersten hundert Treffern war. In der
* Anlage sind alle Rufnummern vier- bis sechsstellig; das Risiko beschränkt
* sich also auf einstellige Rufnummern bei gekappter einstelliger Abfrage.
*/
import { CONTACT_RESULT_LIMIT, type Contact } from './protocol'
/** Rufnummern sind reine Ziffernfolgen daraus entsteht die Abfrageliste. */
const DIGITS = '0123456789'
/** Bis hierhin werden gekappte Abfragen verfeinert; danach gilt der Bestand als unvollständig. */
const MAX_QUERY_LENGTH = 3
/** Notbremse gegen einen davonlaufenden Durchlauf bei einem sehr großen Bestand. */
const MAX_QUERIES = 2000
/** Mehr bringt nichts: die App beantwortet die Abfragen ohnehin nacheinander. */
const DEFAULT_CONCURRENCY = 4
/** Sucht einen Begriff; wirft, wenn die App nicht antwortet. */
export type ContactSearch = (query: string) => Promise<Contact[]>
export interface DirectoryProgress {
/** Beantwortete Abfragen. */
done: number
/** Bekannte Abfragen; wächst, wenn verfeinert werden muss. */
total: number
/** Bisher gefundene Einträge. */
found: number
}
/** Woher der Bestand stammt die Anzeige nennt es, weil es die Dauer erklärt. */
export type DirectorySource = 'cache' | 'sweep'
export interface Directory {
/** Alle gefundenen Einträge, nach Namen sortiert. */
contacts: Contact[]
source: DirectorySource
/** So viele Abfragen hat der Durchlauf gebraucht. */
queries: number
/**
* Hing keine Abfrage am Deckel, die sich nicht mehr verfeinern ließ, und lief
* der Durchlauf zu Ende? Bei `false` fehlen möglicherweise Einträge die
* Anzeige weist darauf hin.
*/
complete: boolean
/** Vom Benutzer abgebrochen; das Ergebnis ist dann nur der bisherige Stand. */
aborted: boolean
}
export interface SweepOptions {
onProgress?: (progress: DirectoryProgress) => void
/**
* Bricht den Durchlauf ab. Kein Fehler: `sweepDirectory` liefert dann den
* bisherigen Stand mit `aborted: true` abbrechen soll nichts wegwerfen.
*/
signal?: AbortSignal
concurrency?: number
}
/**
* Ein Eintrag ist durch alle drei Felder bestimmt: Namen sind nicht eindeutig
* dieselbe Person kommt mit mehreren Durchwahlen vor.
*/
export function contactKey(contact: Contact): string {
return `${contact.name ?? ''}${contact.number ?? ''}${contact.description ?? ''}`
}
/** Die Abfragen, mit denen jeder Durchlauf beginnt: zehn Ziffern und hundert Paare. */
export function initialQueries(): string[] {
const queries = [...DIGITS]
for (const first of DIGITS) {
for (const second of DIGITS) queries.push(first + second)
}
return queries
}
const collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true })
/** Nach Namen sortieren, bei gleichem Namen nach Rufnummer wie die App selbst. */
export function sortContacts(contacts: Contact[]): Contact[] {
return [...contacts].sort(
(a, b) =>
collator.compare(a.name ?? '', b.name ?? '') || collator.compare(a.number ?? '', b.number ?? ''),
)
}
/**
* Filtert den geladenen Bestand im Browser. Anders als die App wird hier auch
* die Beschreibung durchsucht der Bestand liegt ja vollständig vor.
*/
export function filterContacts(contacts: Contact[], filter: string): Contact[] {
const needle = filter.trim().toLowerCase()
if (!needle) return contacts
return contacts.filter((contact) =>
[contact.name, contact.number, contact.description].some((field) =>
field?.toLowerCase().includes(needle),
),
)
}
/** Arbeitet eine Welle von Abfragen mit begrenzt vielen gleichzeitig ab. */
async function runWave(
queries: string[],
concurrency: number,
task: (query: string) => Promise<void>,
): Promise<void> {
let index = 0
const workers = Array.from({ length: Math.min(concurrency, queries.length) }, async () => {
while (index < queries.length) {
await task(queries[index++])
}
})
await Promise.all(workers)
}
/**
* Holt den gesamten Adressbestand. Der Fortschritt wird nach jeder Antwort
* gemeldet, damit der Wartedialog etwas anzuzeigen hat.
*
* @throws wenn eine Abfrage scheitert ein Abbruch über `signal` dagegen nicht
*/
export async function sweepDirectory(
search: ContactSearch,
{ onProgress, signal, concurrency = DEFAULT_CONCURRENCY }: SweepOptions = {},
): Promise<Directory> {
const seen = new Set<string>()
const found = new Map<string, Contact>()
let wave = initialQueries()
wave.forEach((query) => seen.add(query))
let done = 0
let complete = true
const report = () => onProgress?.({ done, total: seen.size, found: found.size })
report()
while (wave.length > 0 && !signal?.aborted) {
const next: string[] = []
await runWave(wave, Math.max(1, concurrency), async (query) => {
// Nach dem Abbruch läuft die Welle nur noch leer, statt weiter zu fragen.
if (signal?.aborted) return
const contacts = await search(query)
for (const contact of contacts) found.set(contactKey(contact), contact)
// Am Deckel heißt: die App hat gekürzt, hier fehlt also etwas. Ziffer
// vorn *und* hinten eine Teilzeichenkette kann an beiden Enden weitergehen.
if (contacts.length >= CONTACT_RESULT_LIMIT) {
if (query.length >= MAX_QUERY_LENGTH || seen.size >= MAX_QUERIES) {
complete = false
} else {
for (const digit of DIGITS) {
for (const refined of [digit + query, query + digit]) {
if (!seen.has(refined)) {
seen.add(refined)
next.push(refined)
}
}
}
}
}
done += 1
report()
})
wave = next
}
const aborted = signal?.aborted ?? false
return {
contacts: sortContacts([...found.values()]),
source: 'sweep',
queries: done,
complete: complete && !aborted,
aborted,
}
}
export interface DirectoryApi {
/**
* `addresses` der Adress-Cache der App am Stück. Wirft, wenn die App das
* Kommando nicht kennt (ältere Versionen: „Unbekanntes Kommando").
*/
listAddresses: () => Promise<Contact[]>
/** `contacts` Einzelabfrage für die Rückfallebene. */
search: ContactSearch
}
/**
* Holt den Adressbestand: erst über den Cache der App, sonst über die
* Einzelabfragen.
*
* Auf die Einzelabfragen wird auch dann ausgewichen, wenn der Cache **leer**
* ist genau das meldet die Anlage zurzeit. Ein leerer Cache ist für die
* Anzeige nicht von „App kennt das Kommando nicht" zu unterscheiden, und in
* beiden Fällen ist der Sweep die einzige Möglichkeit, überhaupt etwas zu zeigen.
*/
export async function loadDirectory(
api: DirectoryApi,
options: SweepOptions = {},
): Promise<Directory> {
if (!options.signal?.aborted) {
try {
const cached = await api.listAddresses()
if (cached.length > 0) {
// Auch hier vereinheitlichen: der Cache könnte denselben Eintrag
// doppelt führen, und sortiert ist er nicht zugesichert.
const unique = new Map(cached.map((contact) => [contactKey(contact), contact]))
return {
contacts: sortContacts([...unique.values()]),
source: 'cache',
queries: 1,
complete: true,
aborted: false,
}
}
} catch {
// Kommando unbekannt oder Cache nicht abrufbar. Ein echter
// Verbindungsfehler fällt gleich darauf beim Sweep erneut auf.
}
}
return sweepDirectory(api.search, options)
}