Adressdaten-Suche direkt in der MongoDB; Sammlung auf Nummer, Name, Beschreibung verschlankt

Die Sammlung addresses führt für alle Quellen (Webhooks Kunden/Kuriere,
SwyxTray-Telefonbuch) nur noch Rufnummer, Name und Beschreibung; alte
Felder receivedAt/source werden beim Upsert entfernt. GET /api/addresses
sucht mit ?q= als Teilzeichenkette in allen drei Feldern. Der Bereich
"Adressdaten" hält keinen Bestand mehr im Browser: Jede Eingabe fragt
entprellt die Datenbank ab, das Suchfeld startet leer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 17:07:48 +02:00
co-authored by Claude Fable 5
parent 715720fbb3
commit a9b40c7e6a
9 changed files with 275 additions and 230 deletions
@@ -1,6 +1,5 @@
package de.appcreation.swyxweb.storage;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@@ -32,20 +31,15 @@ import org.springframework.data.mongodb.core.mapping.Document;
*
* @param id Kennung des Dokuments, vergibt die MongoDB
* @param number Rufnummer, so wie sie gewählt wird erkennt die Dublette
* @param receivedAt Eingangszeit im Backend, bei bekannten Einträgen die des
* letzten Eingangs
* @param source woher der Eintrag stammt: {@code kunden}, {@code kuriere},
* {@code webhook} (generischer Webhook) oder {@code swyxtray}
* @param name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)"
* @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"
* oder "Kurier | NL Hamburg | PKW | cr 2024"
* oder "Kurier | NL Hamburg | PKW | cr 2024" die Herkunft
* eines Eintrags steht damit im Eintrag selbst
*/
@Document("addresses")
public record AddressEntry(
@Id String id,
String number,
Instant receivedAt,
String source,
String name,
String description) {
@@ -72,7 +66,7 @@ public record AddressEntry(
if (!node.isObject()) return;
String number = text(node, "number");
if (number == null) return;
entries.add(new AddressEntry(null, number, null, null, text(node, "name"), text(node, "description")));
entries.add(new AddressEntry(null, number, text(node, "name"), text(node, "description")));
}
/** Leere Zeichenketten schickt die SwyxTray-App als {@code ""}; hier werden sie {@code null}. */
@@ -1,8 +1,8 @@
package de.appcreation.swyxweb.storage;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.regex.Pattern;
import java.util.concurrent.Executors;
import jakarta.annotation.PreDestroy;
@@ -50,32 +50,46 @@ public class ArchiveService {
}
/**
* Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab. Die
* Sammlung ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer
* bekannten Rufnummer wird aktualisiert statt verdoppelt die
* SwyxTray-App schickt bei jeder Cache-Änderung das ganze Telefonbuch.
*
* @param receivedAt Eingangszeit im Backend
* @param source {@code kunden}, {@code kuriere}, {@code webhook}
* (generischer Webhook) oder {@code swyxtray}
* Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab alle
* Quellen (Webhooks Kunden/Kuriere, SwyxTray-Telefonbuch) in derselben
* Sammlung, je Eintrag nur Rufnummer, Name und Beschreibung. Die Sammlung
* ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer bekannten
* Rufnummer wird aktualisiert statt verdoppelt die SwyxTray-App schickt
* bei jeder Cache-Änderung das ganze Telefonbuch.
*/
public void saveAddresses(Instant receivedAt, String source, List<AddressEntry> entries) {
public void saveAddresses(List<AddressEntry> entries) {
if (entries.isEmpty()) return;
// Upsert über die Rufnummer: aktualisiert den bekannten Eintrag oder
// legt ihn an; die Dokument-Kennung vergibt dabei die MongoDB.
submit("Adressdaten", () -> entries.forEach(entry -> mongo.upsert(
new Query(Criteria.where("number").is(entry.number())),
new Update()
.set("receivedAt", receivedAt)
.set("source", source)
.set("name", entry.name())
.set("description", entry.description()),
.set("description", entry.description())
// Altbestand angleichen: Diese Felder wurden früher
// mitgeschrieben und sollen aus der Sammlung verschwinden.
.unset("receivedAt")
.unset("source"),
AddressEntry.class)));
}
/** Die gespeicherten Adressdaten, nach Namen sortiert. Liest direkt aus der MongoDB. */
public List<AddressEntry> addresses() {
return mongo.find(new Query().with(Sort.by("name")), AddressEntry.class);
/**
* Die gespeicherten Adressdaten, nach Namen sortiert gelesen direkt aus
* der MongoDB, ohne Zwischenstand im Backend oder Browser. Ein Suchbegriff
* wird als Teilzeichenkette in Name, Rufnummer und Beschreibung gesucht,
* ohne Beachtung der Groß-/Kleinschreibung.
*/
public List<AddressEntry> addresses(String query) {
Query find = new Query().with(Sort.by("name"));
if (query != null && !query.isBlank()) {
// Der Begriff ist Text, kein Muster Sonderzeichen werden zitiert.
String regex = Pattern.quote(query.trim());
find.addCriteria(new Criteria().orOperator(
Criteria.where("name").regex(regex, "i"),
Criteria.where("number").regex(regex, "i"),
Criteria.where("description").regex(regex, "i")));
}
return mongo.find(find, AddressEntry.class);
}
/**
@@ -1,6 +1,5 @@
package de.appcreation.swyxweb.web;
import java.time.Instant;
import java.util.List;
import tools.jackson.databind.JsonNode;
@@ -11,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@@ -53,13 +53,17 @@ public class AddressController {
if (entries.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Keine Adressdaten in der Nutzlast.");
}
archive.saveAddresses(Instant.now(), "swyxtray", entries);
archive.saveAddresses(entries);
return entries.size();
}
/** Die gespeicherten Adressdaten gelesen direkt aus der MongoDB. */
/**
* Die gespeicherten Adressdaten gelesen direkt aus der MongoDB, jede
* Abfrage frisch. Mit {@code ?q=…} wird in der Datenbank gesucht
* (Teilzeichenkette in Name, Rufnummer und Beschreibung).
*/
@GetMapping
public List<AddressEntry> list() {
return archive.addresses();
public List<AddressEntry> list(@RequestParam(name = "q", required = false) String query) {
return archive.addresses(query);
}
}
@@ -81,7 +81,7 @@ public class WebhookController {
return accept(payload, token, null);
}
/** Adressdaten der Kunden; abgelegt mit {@code source: "kunden"}. */
/** Adressdaten der Kunden; abgelegt in der Sammlung {@code addresses}. */
@PostMapping(path = "/kunden", consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receiveKunden(
@RequestBody JsonNode payload,
@@ -89,7 +89,7 @@ public class WebhookController {
return accept(payload, token, "kunden");
}
/** Adressdaten der Kuriere; abgelegt mit {@code source: "kuriere"}. */
/** Adressdaten der Kuriere; abgelegt in der Sammlung {@code addresses}. */
@PostMapping(path = "/kuriere", consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receiveKuriere(
@RequestBody JsonNode payload,
@@ -133,22 +133,21 @@ public class WebhookService {
}
/**
* Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten (die
* Herkunft wird als {@code source} vermerkt), Jobs sind Jobs. Beim
* generischen Webhook ({@code channel == null}) entscheidet die Nutzlast
* selbst: {@code "type":"job"} ist ein Job, alles andere sind Adressdaten.
* Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten, Jobs
* sind Jobs. Beim generischen Webhook ({@code channel == null}) entscheidet
* die Nutzlast selbst: {@code "type":"job"} ist ein Job, alles andere sind
* Adressdaten. Alle Adressdaten landen in derselben Sammlung.
*/
private void archiveByChannel(WebhookEvent event, String channel) {
JsonNode payload = event.payload();
switch (channel == null ? "" : channel) {
case "kunden", "kuriere" ->
archive.saveAddresses(event.receivedAt(), channel, AddressEntry.manyFrom(payload));
case "kunden", "kuriere" -> archive.saveAddresses(AddressEntry.manyFrom(payload));
case "jobs" -> archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
default -> {
if (JobEntry.isJob(payload)) {
archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
} else {
archive.saveAddresses(event.receivedAt(), "webhook", AddressEntry.manyFrom(payload));
archive.saveAddresses(AddressEntry.manyFrom(payload));
}
}
}
@@ -1,13 +1,15 @@
package de.appcreation.swyxweb;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
@@ -16,6 +18,7 @@ import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import de.appcreation.swyxweb.storage.AddressEntry;
import de.appcreation.swyxweb.storage.ArchiveService;
/**
@@ -44,7 +47,21 @@ class AddressControllerTests {
.andExpect(status().isAccepted())
.andExpect(jsonPath("$").value(2));
verify(archive).saveAddresses(any(), eq("swyxtray"), argThat(entries -> entries.size() == 2));
verify(archive).saveAddresses(argThat(entries -> entries.size() == 2));
}
/** Die Suche läuft in der Datenbank der Begriff wird durchgereicht. */
@Test
void listPassesQueryToArchive() throws Exception {
when(archive.addresses("muster"))
.thenReturn(List.of(new AddressEntry("1", "+493012345", "Muster GmbH", null)));
mvc.perform(get("/api/addresses").param("q", "muster"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Muster GmbH"))
.andExpect(jsonPath("$[0].number").value("+493012345"));
verify(archive).addresses("muster");
}
@Test
@@ -1,9 +1,7 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -118,8 +116,10 @@ class WebhookControllerTests {
.containsExactly("kunden", "kuriere", "jobs");
// Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job.
verify(archive).saveAddresses(any(), eq("kunden"), argThat(entries -> entries.size() == 1));
verify(archive).saveAddresses(any(), eq("kuriere"), argThat(entries -> entries.size() == 1));
verify(archive).saveAddresses(argThat(entries ->
entries.size() == 1 && "SYSGEN GmbH".equals(entries.getFirst().name())));
verify(archive).saveAddresses(argThat(entries ->
entries.size() == 1 && "Rainer Peters (HH1003)".equals(entries.getFirst().name())));
verify(archive).saveJob(argThat(job -> job.id() == 4711));
}
@@ -132,7 +132,7 @@ class WebhookControllerTests {
.andExpect(status().isOk());
assertThat(service.history().getFirst().channel()).isNull();
verify(archive).saveAddresses(any(), eq("webhook"), argThat(entries -> entries.size() == 1));
verify(archive).saveAddresses(argThat(entries -> entries.size() == 1));
}
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */
+28
View File
@@ -8,6 +8,34 @@ import type { Contact } from './swyx/protocol'
* diese Adressdaten sonst nicht heran. Fire-and-forget wie bei den
* Anrufdaten: Ist das Backend nicht erreichbar, läuft die Anzeige weiter.
*/
/** Ein Eintrag der Sammlung `addresses`, wie ihn `GET /api/addresses` liefert. */
interface StoredAddress {
number?: string | null
name?: string | null
description?: string | null
}
/**
* Fragt die Adress-Ablage (MongoDB-Sammlung `addresses`) über das Backend ab
* den zusammengeführten Bestand aus den Webhooks (Kunden, Kuriere) und dem
* SwyxTray-Telefonbuch. Gesucht wird **in der Datenbank** (Teilzeichenkette in
* Name, Rufnummer und Beschreibung); im Browser wird nichts vorgehalten.
*/
export async function fetchAddresses(query = ''): Promise<Contact[]> {
const wanted = query.trim()
const url = wanted ? `/api/addresses?q=${encodeURIComponent(wanted)}` : '/api/addresses'
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Adress-Ablage nicht abrufbar (/api/addresses antwortete mit HTTP ${response.status}).`)
}
const entries = (await response.json()) as StoredAddress[]
return entries.map((entry) => ({
name: entry.name ?? undefined,
number: entry.number ?? undefined,
description: entry.description ?? undefined,
}))
}
export function reportAddresses(contacts: Contact[]): void {
if (contacts.length === 0) return
void fetch('/api/addresses', {
+172 -183
View File
@@ -1,145 +1,142 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { describeContact, type Contact } from '../swyx/protocol'
import {
filterContacts,
sortContacts,
type Directory,
type DirectoryProgress,
type SweepOptions,
} from '../swyx/directory'
import type { Directory, DirectoryProgress, SweepOptions } from '../swyx/directory'
import { fetchAddresses } from '../addresses'
import LoadingDialog from './LoadingDialog'
interface Props {
/** Panel ist sichtbar erst dann wird geladen. */
/** Panel ist sichtbar erst dann wird abgefragt. */
active: boolean
/** Keine Verbindung zur SwyxTray-App Anrufen und Telefonbuch-Abgleich entfallen. */
disabled: boolean
/**
* Adress-Cache, den die App unaufgefordert schickt. `null` heißt „noch keine
* Cache-Nachricht"; eine leere Liste heißt „Cache ist leer".
* Adress-Cache, den die App unaufgefordert schickt. Er wird hier nicht
* angezeigt, sondern ist das Signal, die Ablage neu abzufragen das Backend
* hat ihn gerade hineingeschrieben.
*/
cache: Contact[] | null
onLoad: (options: SweepOptions) => Promise<Directory | null>
onDial: (number: string) => void
}
const STORAGE_FILTER = 'swyxweb.contacts.filter'
/** So lange bekommt das Backend Zeit, den Telefonbuch-Push wegzuschreiben. */
const ARCHIVE_WRITE_DELAY_MS = 1500
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
}
}
/** Entprellung der Eingabe erst dann geht die Abfrage an die Datenbank. */
const SEARCH_DEBOUNCE_MS = 300
/**
* Adressdaten des Swyx-Clients der **gesamte** Bestand des globalen
* Telefonbuchs.
* Adressdaten aus der **Adress-Ablage des Backends** (MongoDB-Sammlung
* `addresses`) dem zusammengeführten Bestand aus den Webhooks (Kunden,
* Kuriere) und dem Telefonbuch des Swyx-Clients.
*
* Drei Wege, in dieser Reihenfolge:
* Es gibt keinen Bestand im Browser: Jede Eingabe im Suchfeld fragt
* entprellt die Datenbank ab, gesucht wird dort als Teilzeichenkette in
* Name, Rufnummer und Beschreibung. Angezeigt wird immer das frische
* Abfrageergebnis; der Bereich funktioniert damit auch ohne Verbindung zur
* SwyxTray-App.
*
* 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.
* Das Telefonbuch der App fließt auf zwei Wegen in die Ablage:
*
* Danach liegt der Bestand im Browser: gefiltert wird ohne weitere Abfrage.
* 1. Der **Cache-Push** beim Verbinden (Protokoll 8) die Startseite meldet
* ihn ans Backend; das Panel fragt die Ablage danach neu ab.
* 2. „Neu laden" bei bestehender Verbindung: erst das Telefonbuch der App
* holen (Cache oder rund 110 Einzelabfragen), das Backend legt es ab,
* dann die Ablage abfragen.
*/
export default function ContactsPanel({ active, disabled, cache, onLoad, onDial }: Props) {
const [directory, setDirectory] = useState<Directory | null>(null)
// Das jeweils letzte Abfrageergebnis kein Bestand, nur die Anzeige.
const [results, setResults] = useState<Contact[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
// Warnung aus dem letzten Telefonbuch-Abgleich: dort fehlen evtl. Einträge.
const [incomplete, setIncomplete] = useState(false)
const [progress, setProgress] = useState<DirectoryProgress | null>(null)
const [filter, setFilter] = useState(() => stored(STORAGE_FILTER))
// Bewusst nicht gemerkt: Nach einem Neustart beginnt die Suche leer.
const [filter, setFilter] = useState('')
// 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)
// Zählt die Abfragen: Eine überholte darf das Ergebnis einer neueren nicht
// mehr überschreiben Antworten kommen nicht zwingend in Reihenfolge.
const queryIdRef = useRef(0)
// Der aktuelle Suchbegriff für Abfragen außerhalb des Eingabe-Effekts
// (Cache-Push, „Neu laden") ohne den Effekt neu anzustoßen.
const filterRef = useRef(filter)
filterRef.current = filter
/** Macht einen laufenden Ladevorgang ungültig und beendet ihn. */
const supersede = useCallback(() => {
loadIdRef.current += 1
abortRef.current?.abort()
abortRef.current = null
setProgress(null)
/** Fragt die Ablage mit dem aktuellen Suchbegriff ab. */
const search = useCallback(async () => {
const ticket = ++queryIdRef.current
try {
const entries = await fetchAddresses(filterRef.current)
if (ticket !== queryIdRef.current) return
setResults(entries)
setLoadError(null)
} catch (e) {
if (ticket !== queryIdRef.current) return
setLoadError(e instanceof Error ? e.message : String(e))
}
}, [])
const load = useCallback(async () => {
// Ein zweiter Durchlauf würde nur dieselben Abfragen doppelt stellen.
supersede()
const ticket = loadIdRef.current
// Jede Eingabe fragt die Datenbank ab entprellt; die erste Abfrage nach
// dem Öffnen läuft sofort. Beim erneuten Öffnen des Bereichs ebenfalls
// frisch abfragen, damit nie ein alter Stand stehen bleibt.
useEffect(() => {
if (!active) return
const timer = setTimeout(() => void search(), results === null ? 0 : SEARCH_DEBOUNCE_MS)
return () => clearTimeout(timer)
// `results` absichtlich nicht in den Abhängigkeiten: Es würde nach jeder
// Antwort eine weitere Abfrage anstoßen.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, filter, search])
/**
* „Neu laden": Bei bestehender Verbindung zuerst das Telefonbuch der App
* holen das Backend schreibt es dabei in die Ablage , danach in jedem
* Fall die Ablage frisch abfragen.
*/
const reload = useCallback(async () => {
abortRef.current?.abort()
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)
if (!disabled) {
setProgress({ done: 0, total: 0, found: 0 })
const result = await onLoad({
signal: controller.signal,
onProgress: (next) => {
if (abortRef.current === controller) setProgress(next)
},
})
if (abortRef.current !== controller) return
// null heißt: Fehler der steht bereits in der Fehleranzeige der Seite.
setIncomplete(result !== null && !result.complete)
// Das Backend schreibt nebenbei; kurz warten, damit die Abfrage den
// frischen Stand schon sieht.
await new Promise((resolve) => setTimeout(resolve, ARCHIVE_WRITE_DELAY_MS))
if (abortRef.current !== controller) return
}
await search()
} finally {
if (ticket === loadIdRef.current) {
if (abortRef.current === controller) {
abortRef.current = null
setProgress(null)
}
}
}, [onLoad, supersede])
}, [disabled, onLoad, search])
// 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.
// Telefonbuch-Push der App: Die Startseite hat ihn ans Backend gemeldet;
// nach einer kurzen Schreibfrist die Ablage neu abfragen.
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])
const timer = setTimeout(() => void search(), ARCHIVE_WRITE_DELAY_MS)
return () => clearTimeout(timer)
}, [cache, search])
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.
// Ein laufender Telefonbuch-Abgleich 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 (
@@ -149,108 +146,100 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
<button
type="button"
className="button button--ghost"
onClick={() => void load()}
disabled={disabled || loading}
onClick={() => void reload()}
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">Suchen</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)}
/>
</label>
</div>
{loadError && <p className="note note--error">{loadError}</p>}
{results === null ? (
!loadError && <p className="note">Adressdaten werden abgefragt </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>
<p className="note">
{filter.trim() ? `${results.length} Treffer` : `${results.length} Einträge`}
{' · direkt aus der Adress-Ablage des Backends'}
</p>
{directory === null ? (
<p className="note">{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}</p>
{incomplete && (
<p className="note note--error">
Der letzte Telefonbuch-Abgleich war unvollständig; es können Einträge fehlen.
Neu laden" versucht es erneut.
</p>
)}
{results.length === 0 ? (
<p className="note">
{filter.trim()
? `Kein Eintrag zu „${filter.trim()}".`
: 'Die Ablage ist noch leer sie füllt sich über die Webhooks und über das Telefonbuch der SwyxTray-App.'}
</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"
<ul className="contacts">
{results.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!)}
disabled={disabled}
>
<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>
)}
</>
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.
Jede Suche fragt die Adress-Ablage des Backends (MongoDB) direkt ab die per Webhook
gemeldeten Kunden und Kuriere sowie das Telefonbuch des Swyx-Clients; im Browser wird
kein Bestand vorgehalten. Das Telefonbuch fließt bei bestehender Verbindung zur
SwyxTray-App in die Ablage ein beim Verbinden von selbst, bei „Neu laden" auf
Anforderung.
</p>
{loading && (
<LoadingDialog
title="Adressdaten werden geladen"
title="Telefonbuch wird abgeglichen"
done={progress.done}
total={progress.total}
detail={progress.found > 0 ? `${progress.found} Einträge bisher` : undefined}