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:
@@ -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. */
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user