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>
255 lines
8.9 KiB
TypeScript
255 lines
8.9 KiB
TypeScript
/**
|
||
* 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 ?? ''} |