/** * 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 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, ): Promise { 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 { const seen = new Set() const found = new Map() 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 /** `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 { 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) }