Compare commits

...
2 Commits
Author SHA1 Message Date
SvenandClaude Fable 5 a9b40c7e6a 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>
2026-08-27 17:07:48 +02:00
SvenandClaude Fable 5 715720fbb3 Erfolgreiche Verbindung merken und automatisch wieder aufbauen; Hinweistexte gekürzt
Die zuletzt erfolgreich verbundene SwyxTray-Adresse landet im localStorage;
beim Laden der Seite wird damit sofort wieder verbunden, manuelles Trennen
vergisst sie. Untertitel, Backend-Hinweis und Webhook-Erklärtexte entfernt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 10:36:24 +02:00
13 changed files with 339 additions and 257 deletions
@@ -1,6 +1,5 @@
package de.appcreation.swyxweb.storage; package de.appcreation.swyxweb.storage;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; 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 id Kennung des Dokuments, vergibt die MongoDB
* @param number Rufnummer, so wie sie gewählt wird erkennt die Dublette * @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 name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)"
* @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164" * @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") @Document("addresses")
public record AddressEntry( public record AddressEntry(
@Id String id, @Id String id,
String number, String number,
Instant receivedAt,
String source,
String name, String name,
String description) { String description) {
@@ -72,7 +66,7 @@ public record AddressEntry(
if (!node.isObject()) return; if (!node.isObject()) return;
String number = text(node, "number"); String number = text(node, "number");
if (number == null) return; 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}. */ /** Leere Zeichenketten schickt die SwyxTray-App als {@code ""}; hier werden sie {@code null}. */
@@ -1,8 +1,8 @@
package de.appcreation.swyxweb.storage; package de.appcreation.swyxweb.storage;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.regex.Pattern;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
@@ -50,32 +50,46 @@ public class ArchiveService {
} }
/** /**
* Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab. Die * Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab alle
* Sammlung ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer * Quellen (Webhooks Kunden/Kuriere, SwyxTray-Telefonbuch) in derselben
* bekannten Rufnummer wird aktualisiert statt verdoppelt die * Sammlung, je Eintrag nur Rufnummer, Name und Beschreibung. Die Sammlung
* SwyxTray-App schickt bei jeder Cache-Änderung das ganze Telefonbuch. * ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer bekannten
* * Rufnummer wird aktualisiert statt verdoppelt die SwyxTray-App schickt
* @param receivedAt Eingangszeit im Backend * bei jeder Cache-Änderung das ganze Telefonbuch.
* @param source {@code kunden}, {@code kuriere}, {@code webhook}
* (generischer Webhook) oder {@code swyxtray}
*/ */
public void saveAddresses(Instant receivedAt, String source, List<AddressEntry> entries) { public void saveAddresses(List<AddressEntry> entries) {
if (entries.isEmpty()) return; if (entries.isEmpty()) return;
// Upsert über die Rufnummer: aktualisiert den bekannten Eintrag oder // Upsert über die Rufnummer: aktualisiert den bekannten Eintrag oder
// legt ihn an; die Dokument-Kennung vergibt dabei die MongoDB. // legt ihn an; die Dokument-Kennung vergibt dabei die MongoDB.
submit("Adressdaten", () -> entries.forEach(entry -> mongo.upsert( submit("Adressdaten", () -> entries.forEach(entry -> mongo.upsert(
new Query(Criteria.where("number").is(entry.number())), new Query(Criteria.where("number").is(entry.number())),
new Update() new Update()
.set("receivedAt", receivedAt)
.set("source", source)
.set("name", entry.name()) .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))); AddressEntry.class)));
} }
/** Die gespeicherten Adressdaten, nach Namen sortiert. Liest direkt aus der MongoDB. */ /**
public List<AddressEntry> addresses() { * Die gespeicherten Adressdaten, nach Namen sortiert gelesen direkt aus
return mongo.find(new Query().with(Sort.by("name")), AddressEntry.class); * 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; package de.appcreation.swyxweb.web;
import java.time.Instant;
import java.util.List; import java.util.List;
import tools.jackson.databind.JsonNode; 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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; 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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -53,13 +53,17 @@ public class AddressController {
if (entries.isEmpty()) { if (entries.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Keine Adressdaten in der Nutzlast."); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Keine Adressdaten in der Nutzlast.");
} }
archive.saveAddresses(Instant.now(), "swyxtray", entries); archive.saveAddresses(entries);
return entries.size(); 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 @GetMapping
public List<AddressEntry> list() { public List<AddressEntry> list(@RequestParam(name = "q", required = false) String query) {
return archive.addresses(); return archive.addresses(query);
} }
} }
@@ -81,7 +81,7 @@ public class WebhookController {
return accept(payload, token, null); 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) @PostMapping(path = "/kunden", consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receiveKunden( public Receipt receiveKunden(
@RequestBody JsonNode payload, @RequestBody JsonNode payload,
@@ -89,7 +89,7 @@ public class WebhookController {
return accept(payload, token, "kunden"); 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) @PostMapping(path = "/kuriere", consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receiveKuriere( public Receipt receiveKuriere(
@RequestBody JsonNode payload, @RequestBody JsonNode payload,
@@ -133,22 +133,21 @@ public class WebhookService {
} }
/** /**
* Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten (die * Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten, Jobs
* Herkunft wird als {@code source} vermerkt), Jobs sind Jobs. Beim * sind Jobs. Beim generischen Webhook ({@code channel == null}) entscheidet
* generischen Webhook ({@code channel == null}) entscheidet die Nutzlast * die Nutzlast selbst: {@code "type":"job"} ist ein Job, alles andere sind
* selbst: {@code "type":"job"} ist ein Job, alles andere sind Adressdaten. * Adressdaten. Alle Adressdaten landen in derselben Sammlung.
*/ */
private void archiveByChannel(WebhookEvent event, String channel) { private void archiveByChannel(WebhookEvent event, String channel) {
JsonNode payload = event.payload(); JsonNode payload = event.payload();
switch (channel == null ? "" : channel) { switch (channel == null ? "" : channel) {
case "kunden", "kuriere" -> case "kunden", "kuriere" -> archive.saveAddresses(AddressEntry.manyFrom(payload));
archive.saveAddresses(event.receivedAt(), channel, AddressEntry.manyFrom(payload));
case "jobs" -> archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt())); case "jobs" -> archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
default -> { default -> {
if (JobEntry.isJob(payload)) { if (JobEntry.isJob(payload)) {
archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt())); archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
} else { } else {
archive.saveAddresses(event.receivedAt(), "webhook", AddressEntry.manyFrom(payload)); archive.saveAddresses(AddressEntry.manyFrom(payload));
} }
} }
} }
@@ -1,13 +1,15 @@
package de.appcreation.swyxweb; package de.appcreation.swyxweb;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify; 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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.List;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; 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.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import de.appcreation.swyxweb.storage.AddressEntry;
import de.appcreation.swyxweb.storage.ArchiveService; import de.appcreation.swyxweb.storage.ArchiveService;
/** /**
@@ -44,7 +47,21 @@ class AddressControllerTests {
.andExpect(status().isAccepted()) .andExpect(status().isAccepted())
.andExpect(jsonPath("$").value(2)); .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 @Test
@@ -1,9 +1,7 @@
package de.appcreation.swyxweb; package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat; 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.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify; 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.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -118,8 +116,10 @@ class WebhookControllerTests {
.containsExactly("kunden", "kuriere", "jobs"); .containsExactly("kunden", "kuriere", "jobs");
// Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job. // 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(argThat(entries ->
verify(archive).saveAddresses(any(), eq("kuriere"), argThat(entries -> entries.size() == 1)); 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)); verify(archive).saveJob(argThat(job -> job.id() == 4711));
} }
@@ -132,7 +132,7 @@ class WebhookControllerTests {
.andExpect(status().isOk()); .andExpect(status().isOk());
assertThat(service.history().getFirst().channel()).isNull(); 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. */ /** 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 * diese Adressdaten sonst nicht heran. Fire-and-forget wie bei den
* Anrufdaten: Ist das Backend nicht erreichbar, läuft die Anzeige weiter. * 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 { export function reportAddresses(contacts: Contact[]): void {
if (contacts.length === 0) return if (contacts.length === 0) return
void fetch('/api/addresses', { 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 { describeContact, type Contact } from '../swyx/protocol'
import { import type { Directory, DirectoryProgress, SweepOptions } from '../swyx/directory'
filterContacts, import { fetchAddresses } from '../addresses'
sortContacts,
type Directory,
type DirectoryProgress,
type SweepOptions,
} from '../swyx/directory'
import LoadingDialog from './LoadingDialog' import LoadingDialog from './LoadingDialog'
interface Props { interface Props {
/** Panel ist sichtbar erst dann wird geladen. */ /** Panel ist sichtbar erst dann wird abgefragt. */
active: boolean active: boolean
/** Keine Verbindung zur SwyxTray-App Anrufen und Telefonbuch-Abgleich entfallen. */
disabled: boolean disabled: boolean
/** /**
* Adress-Cache, den die App unaufgefordert schickt. `null` heißt „noch keine * Adress-Cache, den die App unaufgefordert schickt. Er wird hier nicht
* Cache-Nachricht"; eine leere Liste heißt „Cache ist leer". * angezeigt, sondern ist das Signal, die Ablage neu abzufragen das Backend
* hat ihn gerade hineingeschrieben.
*/ */
cache: Contact[] | null cache: Contact[] | null
onLoad: (options: SweepOptions) => Promise<Directory | null> onLoad: (options: SweepOptions) => Promise<Directory | null>
onDial: (number: string) => void 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 { /** Entprellung der Eingabe erst dann geht die Abfrage an die Datenbank. */
try { const SEARCH_DEBOUNCE_MS = 300
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
}
}
/** /**
* Adressdaten des Swyx-Clients der **gesamte** Bestand des globalen * Adressdaten aus der **Adress-Ablage des Backends** (MongoDB-Sammlung
* Telefonbuchs. * `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 * Das Telefonbuch der App fließt auf zwei Wegen in die Ablage:
* 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.
* *
* 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) { 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 [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) const abortRef = useRef<AbortController | null>(null)
// Zählt die Ladevorgänge. Trifft der Cache ein, während die Einzelabfragen // Zählt die Abfragen: Eine überholte darf das Ergebnis einer neueren nicht
// noch laufen, darf deren abgebrochenes Ergebnis den Cache nicht überschreiben. // mehr überschreiben Antworten kommen nicht zwingend in Reihenfolge.
const loadIdRef = useRef(0) 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. */ /** Fragt die Ablage mit dem aktuellen Suchbegriff ab. */
const supersede = useCallback(() => { const search = useCallback(async () => {
loadIdRef.current += 1 const ticket = ++queryIdRef.current
abortRef.current?.abort() try {
abortRef.current = null const entries = await fetchAddresses(filterRef.current)
setProgress(null) 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 () => { // Jede Eingabe fragt die Datenbank ab entprellt; die erste Abfrage nach
// Ein zweiter Durchlauf würde nur dieselben Abfragen doppelt stellen. // dem Öffnen läuft sofort. Beim erneuten Öffnen des Bereichs ebenfalls
supersede() // frisch abfragen, damit nie ein alter Stand stehen bleibt.
const ticket = loadIdRef.current 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() const controller = new AbortController()
abortRef.current = controller abortRef.current = controller
setProgress({ done: 0, total: 0, found: 0 })
try { try {
const result = await onLoad({ if (!disabled) {
signal: controller.signal, setProgress({ done: 0, total: 0, found: 0 })
onProgress: (next) => { const result = await onLoad({
if (ticket === loadIdRef.current) setProgress(next) signal: controller.signal,
}, onProgress: (next) => {
}) if (abortRef.current === controller) setProgress(next)
if (ticket !== loadIdRef.current) return },
// null heißt: Fehler der steht bereits in der Fehleranzeige der Seite. })
if (result) setDirectory(result) 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 { } finally {
if (ticket === loadIdRef.current) { if (abortRef.current === controller) {
abortRef.current = null abortRef.current = null
setProgress(null) setProgress(null)
} }
} }
}, [onLoad, supersede]) }, [disabled, onLoad, search])
// Der Cache der App hat Vorrang: Er kommt unaufgefordert und ist damit oft // Telefonbuch-Push der App: Die Startseite hat ihn ans Backend gemeldet;
// schon da, bevor der Bereich überhaupt geöffnet wird. Ein leerer Cache ist // nach einer kurzen Schreibfrist die Ablage neu abfragen.
// dagegen keine Antwort dann bleibt es beim Laden weiter unten.
useEffect(() => { useEffect(() => {
if (!cache || cache.length === 0) return if (!cache || cache.length === 0) return
loadedRef.current = true const timer = setTimeout(() => void search(), ARCHIVE_WRITE_DELAY_MS)
supersede() return () => clearTimeout(timer)
setDirectory({ }, [cache, search])
contacts: sortContacts(cache),
source: 'cache',
queries: 0,
complete: true,
aborted: false,
})
}, [cache, supersede])
useEffect(() => { // Ein laufender Telefonbuch-Abgleich soll nicht weiterfragen, wenn die
if (disabled) { // Seite verschwindet.
// 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.
useEffect(() => () => abortRef.current?.abort(), []) useEffect(() => () => abortRef.current?.abort(), [])
const visible = useMemo(
() => (directory ? filterContacts(directory.contacts, filter) : []),
[directory, filter],
)
const loading = progress !== null const loading = progress !== null
return ( return (
@@ -149,108 +146,100 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
<button <button
type="button" type="button"
className="button button--ghost" className="button button--ghost"
onClick={() => void load()} onClick={() => void reload()}
disabled={disabled || loading} disabled={loading}
> >
{loading ? 'Lädt …' : 'Neu laden'} {loading ? 'Lädt …' : 'Neu laden'}
</button> </button>
</div> </div>
{disabled ? ( {/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`)
<p className="note">Erst mit der SwyxTray-App verbinden.</p> 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`) <p className="note">
und würde als direktes Kind der Karte einer Spalte in die *Höhe* {filter.trim() ? `${results.length} Treffer` : `${results.length} Einträge`}
wachsen und den freien Platz aufsaugen. In der Zeile wächst es in {' · direkt aus der Adress-Ablage des Backends'}
die Breite, wie in allen anderen Bereichen auch. */} </p>
<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>
{directory === null ? ( {incomplete && (
<p className="note">{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}</p> <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>
) : ( ) : (
<> <ul className="contacts">
<p className="note"> {results.map((contact, index) => (
{filter.trim() // Namen sind nicht eindeutig dieselbe Person kommt mit
? `${visible.length} von ${directory.contacts.length} Einträgen` // mehreren Durchwahlen vor; deshalb die Position mit hinein.
: `${directory.contacts.length} Einträge`} <li
{directory.source === 'cache' key={`${contact.number ?? ''}-${contact.name ?? ''}-${index}`}
? ' · aus dem Adress-Cache der App' className="contacts__item"
: ` · aus ${directory.queries} Einzelabfragen zusammengesetzt`} >
</p> <span className="contacts__info">
<span className="contacts__name">{describeContact(contact)}</span>
{!directory.complete && ( {contact.description && (
<p className="note note--error"> <span className="contacts__description">{contact.description}</span>
{directory.aborted )}
? 'Abgebrochen der Bestand ist unvollständig.' </span>
: 'Die App hat mindestens eine Abfrage gekürzt; es können Einträge fehlen.'}{' '} {contact.number && <span className="contacts__number">{contact.number}</span>}
Neu laden" versucht es erneut. {contact.number && (
</p> <button
)} type="button"
className="button button--small"
{visible.length === 0 ? ( onClick={() => onDial(contact.number!)}
<p className="note">Kein Eintrag zu „{filter.trim()}".</p> disabled={disabled}
) : (
<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"
> >
<span className="contacts__info"> Anrufen
<span className="contacts__name">{describeContact(contact)}</span> </button>
{contact.description && ( )}
<span className="contacts__description">{contact.description}</span> </li>
)} ))}
</span> </ul>
{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>
)}
</>
)} )}
</> </>
)} )}
<p className="note"> <p className="note">
Der Bestand kommt aus dem Telefonbuch des Swyx-Clients auf dem Rechner der SwyxTray-App Jede Suche fragt die Adress-Ablage des Backends (MongoDB) direkt ab die per Webhook
aus deren Adress-Cache, den sie beim Verbinden von selbst schickt. Kennt die App den Cache gemeldeten Kunden und Kuriere sowie das Telefonbuch des Swyx-Clients; im Browser wird
noch nicht oder ist er leer, wird der Bestand ersatzweise aus rund 110 Suchabfragen kein Bestand vorgehalten. Das Telefonbuch fließt bei bestehender Verbindung zur
zusammengesetzt. Danach liegt er vollständig im Browser; das Filtern läuft ohne weitere SwyxTray-App in die Ablage ein beim Verbinden von selbst, bei „Neu laden" auf
Abfrage. Anforderung.
</p> </p>
{loading && ( {loading && (
<LoadingDialog <LoadingDialog
title="Adressdaten werden geladen" title="Telefonbuch wird abgeglichen"
done={progress.done} done={progress.done}
total={progress.total} total={progress.total}
detail={progress.found > 0 ? `${progress.found} Einträge bisher` : undefined} detail={progress.found > 0 ? `${progress.found} Einträge bisher` : undefined}
+1 -10
View File
@@ -65,10 +65,7 @@ export default function WebhookPanel({ events, status, error, onClear }: Props)
{error && <p className="note note--error">{error}</p>} {error && <p className="note note--error">{error}</p>}
{events.length === 0 ? ( {events.length === 0 ? (
<p className="note"> <p className="note">Noch keine Nachricht eingegangen.</p>
Noch keine Nachricht eingegangen. Ein fremdes System schickt Adressdaten an{' '}
<code>POST /api/webhook</code>; sie erscheinen hier ohne Zutun.
</p>
) : ( ) : (
<ul className="webhook"> <ul className="webhook">
{events.map((event) => ( {events.map((event) => (
@@ -83,12 +80,6 @@ export default function WebhookPanel({ events, status, error, onClear }: Props)
))} ))}
</ul> </ul>
)} )}
<p className="note">
Der Webhook nimmt beliebiges JSON an Objekt wie Liste und zeigt es hier unverändert.
Der Verlauf liegt im Speicher des Backends und ist nach dessen Neustart leer; Leeren"
verwirft ihn auch dort.
</p>
</> </>
) )
} }
+30
View File
@@ -6,6 +6,36 @@ export const DEFAULT_WS_PATH = '/ws'
export const DEFAULT_WS_URL = export const DEFAULT_WS_URL =
import.meta.env.VITE_WS_URL ?? `ws://${DEFAULT_WS_HOST}:${DEFAULT_WS_PORT}${DEFAULT_WS_PATH}` import.meta.env.VITE_WS_URL ?? `ws://${DEFAULT_WS_HOST}:${DEFAULT_WS_PORT}${DEFAULT_WS_PATH}`
const SAVED_WS_URL_KEY = 'swyxweb.wsUrl'
/**
* Zuletzt erfolgreich verbundene Adresse. localStorage kann fehlen oder gesperrt
* sein (Privatmodus, Browser-Einstellung) dann wird schlicht nichts gemerkt.
*/
export function loadSavedWsUrl(): string | null {
try {
return localStorage.getItem(SAVED_WS_URL_KEY)
} catch {
return null
}
}
export function saveWsUrl(url: string): void {
try {
localStorage.setItem(SAVED_WS_URL_KEY, url)
} catch {
// Ohne Speicher kein Wiederverbinden nach Neustart mehr geht nicht verloren.
}
}
export function clearSavedWsUrl(): void {
try {
localStorage.removeItem(SAVED_WS_URL_KEY)
} catch {
// Siehe saveWsUrl.
}
}
export interface ClientConfig { export interface ClientConfig {
websocketUrl: string websocketUrl: string
host: string host: string
-5
View File
@@ -131,11 +131,6 @@ code {
gap: 16px; gap: 16px;
} }
.page__footer {
color: var(--text-muted);
font-size: 13px;
}
.card { .card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
+33 -12
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react' import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { DEFAULT_WS_URL, fetchClientConfig } from '../config' import { DEFAULT_WS_URL, fetchClientConfig, clearSavedWsUrl, loadSavedWsUrl, saveWsUrl } from '../config'
import { useSwyxTray } from '../hooks/useSwyxTray' import { useSwyxTray } from '../hooks/useSwyxTray'
import { useCallNotifications } from '../hooks/useCallNotifications' import { useCallNotifications } from '../hooks/useCallNotifications'
import { useWebhook } from '../hooks/useWebhook' import { useWebhook } from '../hooks/useWebhook'
@@ -68,13 +68,25 @@ export default function HomePage() {
// Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren. // Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren.
const urlTouched = useRef(false) const urlTouched = useRef(false)
// Adresse des laufenden Verbindungsversuchs; gemerkt wird sie erst, wenn die
// Verbindung tatsächlich zustande kommt.
const pendingUrl = useRef<string | null>(null)
useEffect(() => { useEffect(() => {
// Mit der zuletzt erfolgreich verbundenen Adresse direkt wieder verbinden
// die Backend-Konfiguration muss dann nicht gefragt werden.
const saved = loadSavedWsUrl()
if (saved) {
urlTouched.current = true
setUrl(saved)
pendingUrl.current = saved
tray.connect(saved)
return
}
const controller = new AbortController() const controller = new AbortController()
fetchClientConfig(controller.signal) fetchClientConfig(controller.signal)
.then((backendUrl) => { .then((backendUrl) => {
if (controller.signal.aborted) return if (controller.signal.aborted) return
setConfigNote(`Adresse vom Backend übernommen: ${backendUrl}`)
if (!urlTouched.current) setUrl(backendUrl) if (!urlTouched.current) setUrl(backendUrl)
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
@@ -83,12 +95,28 @@ export default function HomePage() {
setConfigNote(`Backend-Konfiguration nicht abrufbar (${message}) Standardadresse wird verwendet.`) setConfigNote(`Backend-Konfiguration nicht abrufbar (${message}) Standardadresse wird verwendet.`)
}) })
return () => controller.abort() return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps -- nur beim Laden der Seite
}, []) }, [])
// Erst die zustande gekommene Verbindung zählt: dann die Adresse merken, damit
// sie nach Verbindungsverlust oder Neustart automatisch wieder aufgebaut wird.
useEffect(() => {
if (status === 'open' && pendingUrl.current) saveWsUrl(pendingUrl.current)
}, [status])
function handleConnect(event: FormEvent) { function handleConnect(event: FormEvent) {
event.preventDefault() event.preventDefault()
const target = url.trim() const target = url.trim()
if (target) tray.connect(target) if (!target) return
pendingUrl.current = target
tray.connect(target)
}
// Manuelles Trennen heißt auch: nach einem Neustart nicht wieder verbinden.
function handleDisconnect() {
clearSavedWsUrl()
pendingUrl.current = null
tray.disconnect()
} }
function handleSendRaw(event: FormEvent) { function handleSendRaw(event: FormEvent) {
@@ -104,7 +132,7 @@ export default function HomePage() {
<StadtboteSignet className="brand__signet" /> <StadtboteSignet className="brand__signet" />
<div> <div>
<h1 className="brand__name">Stadtbote</h1> <h1 className="brand__name">Stadtbote</h1>
<p className="page__subtitle">SwyxWeb · Telefonie über die SwyxTray-App</p> <p className="page__subtitle">SwyxWeb</p>
</div> </div>
</div> </div>
<StatusBadge status={status} /> <StatusBadge status={status} />
@@ -228,7 +256,7 @@ export default function HomePage() {
<button <button
type="button" type="button"
className="button" className="button"
onClick={tray.disconnect} onClick={handleDisconnect}
disabled={!isBusyConnection} disabled={!isBusyConnection}
> >
Trennen Trennen
@@ -292,13 +320,6 @@ export default function HomePage() {
</section> </section>
</TabPanel> </TabPanel>
</main> </main>
<footer className="page__footer">
SwyxTray · Kommandos <code>call</code> / <code>answer</code> / <code>hangup</code> /{' '}
<code>tabs</code> / <code>opentab</code> / <code>closetab</code> / <code>contacts</code> ·
Zustand über{' '}
<code>snapshot</code> · Standardziel <code>{DEFAULT_WS_URL}</code>
</footer>
</div> </div>
) )
} }