This commit is contained in:
Steffen Probst 2025-03-21 18:47:49 +01:00
commit 832ebf099a
19 changed files with 1249 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.env

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM golang:1.21-alpine
WORKDIR /app
RUN mkdir -p static/css && mkdir -p static/images && go mod init ldap_phonebook_html
COPY *.go .
#COPY static static
RUN go mod tidy && go mod download && go build -o ldap_phonebook_html /app
#ENV LDAP_SERVER="<Server>"
#ENV LDAP_PORT="636"
#ENV LDAP_BIND_DN="CN=<User>,CN=Users,DC=<example>,DC=local"
#ENV LDAP_BIND_PASSWORD="<password>"
#ENV LDAP_BASE_DN="CN=Users,DC=<example>,DC=local"
#ENV LDAP_FILTER=(objectClass=person)
#ENV SERVER_PORT="8080"
CMD ["/app/ldap_phonebook_html"]

26
LICENSE.md Normal file
View File

@ -0,0 +1,26 @@
# LICENSE
## Telefonbuch-Programm
Copyright (C) 2023 Steffen Probst
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
## Full License Text
For the full license text, please visit:
[GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.en.html)
## Development Assistance
This program was developed with the assistance of Claude.ai, an AI assistant created by Anthropic, PBC. All copyrights for the resulting code remain with the author, Steffen Probst.

152
MANUAL.md Normal file
View File

@ -0,0 +1,152 @@
# Telefonbuch-Programm Benutzerhandbuch
## Inhaltsverzeichnis
- [Einführung](#einführung)
- [Installation](#installation)
- [Konfiguration](#konfiguration)
- [Umgebungsvariablen](#umgebungsvariablen)
- [config.yaml](#configyaml)
- [Container-Rollout mit Podman](#container-rollout-mit-podman)
- [Active Directory Konfiguration](#active-directory-konfiguration)
- [Verwendung der Benutzeroberfläche](#verwendung-der-benutzeroberfläche)
- [Fehlerbehebung](#fehlerbehebung)
## Einführung
Dieses Telefonbuch-Programm ermöglicht es Ihnen, Kontaktinformationen aus Ihrem Active Directory (AD) zu extrahieren und in einer benutzerfreundlichen Weboberfläche darzustellen. Es unterstützt sowohl Benutzer als auch Kontakte aus dem AD und wird als Podman-Container bereitgestellt.
## Installation
1. Stellen Sie sicher, dass Podman und podman-compose auf Ihrem System installiert sind.
2. Klonen Sie das Repository oder laden Sie den Quellcode herunter.
3. Navigieren Sie zum Projektverzeichnis.
## Konfiguration
### Umgebungsvariablen
Erstellen Sie eine `.env`-Datei im Projektverzeichnis mit folgenden Variablen:
```
LDAP_SERVER=<Ihr LDAP-Server>
BIND_DN=<Ihr Bind DN>
BIND_PASSWORD=<Ihr Bind-Passwort>
SEARCH_BASE=<Ihr Suchbasis-DN>
```
### config.yaml
Überprüfen und passen Sie die `config.yaml` Datei im `static`-Verzeichnis an:
```yaml
phone_rules:
invalid_number: "+49 5331 89"
country:
prefix: "49"
area_codes:
- "5331"
internal_prefix: "89"
```
## Container-Rollout mit Podman
1. Bauen und Starten des Containers:
```
podman-compose up -d
```
2. Überprüfen Sie, ob der Container läuft:
```
podman ps
```
3. Zugriff auf die Anwendung:
Öffnen Sie einen Webbrowser und navigieren Sie zu `http://localhost:8082`.
4. Stoppen des Containers:
```
podman-compose down
```
5. Logs anzeigen:
```
podman logs <container-name>
```
## Active Directory Konfiguration
### Erforderliche Attribute
Für Benutzer und Kontakte im AD sollten folgende Attribute korrekt gefüllt sein:
- `givenName`: Vorname
- `sn`: Nachname
- `mail`: E-Mail-Adresse
- `telephoneNumber`: Bürotelefonnummer > Konfigurationsformat `+49 5331 89xxx` oder `+49 5331 89xxxxx`
- `mobile`: Mobiltelefonnummer > Konfigurationsformat `+49 1(5..7) xxxxxx`
- `otherTelephone`: Weitere Telefonnummern > Konfigurationsformat `+49 xxxx xxxxxx`
- `physicalDeliveryOfficeName`: Abteilung
Für Kontakte:
- Erstellen Sie Kontaktobjekte in Ihrem AD
- Füllen Sie die oben genannten Attribute entsprechend aus
### Anzeigeverhalten von Einträgen
Das Telefonbuch-Programm filtert die Einträge aus dem Active Directory, um sicherzustellen, dass nur relevante und vollständige Informationen angezeigt werden. Rufnummer die in keine weiterführend Nummer haben, hinter dem Prefix, werden über InvalidNumber(unvollständige Rufnummer) in der `config.yaml` zusätzlich konfiguriert. -> Es werden nur vollständige Rufnmmer angezeigt, die im Format +49 5331 89xxx(xx) vorliegen. Aus der korrekten Rufnummer wird dann auch die interne Rufnummer generiert. Diese Funktion betrifft auch nur Attribute `telephoneNumber` aus dem AD.
#### Allgemeine Bedingungen für alle Einträge
Ein Eintrag (Benutzer oder Kontakt) wird nur dann angezeigt, wenn:
1. Der Vorname (`givenName`) nicht leer ist.
2. Der Nachname (`sn`) nicht leer ist.
#### Spezifische Bedingungen für Benutzer
Ein Benutzer wird im Telefonbuch angezeigt, wenn:
- Die allgemeinen Bedingungen erfüllt sind UND
- Mindestens eine der folgenden Bedingungen zutrifft:
* Die Bürotelefonnummer (`telephoneNumber`) ist vorhanden und nicht als ungültig markiert (siehe `config.yaml`). -> `InvalidNumber`
* Eine Mobiltelefonnummer (`mobile`) ist vorhanden.
* Die E-Mail-Adresse (`mail`) ist vorhanden.
Ein Benutzer wird NICHT angezeigt, wenn:
1. Keine Telefonnummer (weder Büro noch Mobil) vorhanden ist ODER
2. Die einzige vorhandene Telefonnummer als ungültig markiert ist (siehe `config.yaml`) UND
3. Keine E-Mail-Adresse vorhanden ist.
#### Spezifische Bedingungen für Kontakte
Ein Kontakt wird im Telefonbuch angezeigt, wenn die allgemeinen Bedingungen erfüllt sind. Kontakte werden großzügiger behandelt als Benutzer und werden angezeigt, auch wenn keine Telefonnummer oder E-Mail-Adresse vorhanden ist.
#### Hinweise
- Die Abteilung (`physicalDeliveryOfficeName`) ist optional und beeinflusst nicht die Anzeige im Telefonbuch.
- Zusätzliche Telefonnummern (`otherTelephone`) werden angezeigt, wenn vorhanden, beeinflussen aber nicht die Entscheidung, ob ein Eintrag im Telefonbuch erscheint.
- Die Unterscheidung zwischen Benutzern und Kontakten basiert auf dem `objectClass`-Attribut im Active Directory.
## Verwendung der Benutzeroberfläche
1. Starten Sie das Programm.
2. Öffnen Sie einen Webbrowser und navigieren Sie zu `http://localhost:<SERVER_PORT>`.
### Suchfunktion
- Verwenden Sie das Suchfeld oben auf der Seite, um nach Namen, Telefonnummern oder anderen Informationen zu suchen.
- Die Suche ist Case-Sensitive (unterscheidet zwischen Groß- und Kleinschreibung).
### Sortierung
- Klicken Sie auf die Spaltenüberschriften, um die Tabelle nach dieser Spalte zu sortieren.
- Ein erneuter Klick auf dieselbe Spalte kehrt die Sortierreihenfolge um.
## Fehlerbehebung
- Überprüfen Sie die Container-Logs mit `podman logs <container-name>`.
- Stellen Sie sicher, dass alle Umgebungsvariablen in der `.env`-Datei korrekt gesetzt sind.
- Überprüfen Sie die Netzwerkverbindung zum LDAP-Server vom Host-System aus.
- Verifizieren Sie die LDAP-Anmeldeinformationen und Zugriffsrechte.
- Bei Problemen mit dem Volume-Mount, überprüfen Sie die Berechtigungen des `static`-Verzeichnisses.
Wenn Probleme weiterhin bestehen, erstellen Sie bitte ein Issue im GitHub-Repository oder kontaktieren Sie den Entwickler.

68
README.md Normal file
View File

@ -0,0 +1,68 @@
# README
## Beschreibung
Telefonbuch für MKN, in Go programmiert und als Podman-Container bereitgestellt.
## Features
- Anmeldung an die Domäne AD/LDAP
- Auslesen von Benutzern und Kontakten
- Formatierung der Rufnummern
- Ausgabe über internen Webserver in Go
- Suche und Sortierung in JavaScript
- Unterstützung für Benutzer und Kontakte aus dem Active Directory
- Bereitstellung als Podman-Container
## Programmablauf
1. Start des Podman-Containers
2. Initialisierung der Go-Anwendung:
- Laden der Umgebungsvariablen
- Laden der YAML-Konfiguration
- Laden des HTML-Templates
3. Start des HTTP-Servers
4. Periodische Aktualisierung des Caches im Hintergrund
5. Verarbeitung eingehender HTTP-Anfragen
6. Beenden der Anwendung bei Container-Stopp
## Installation und Konfiguration
1. Vorbereitung:
- Stellen Sie sicher, dass Podman und podman-compose auf Ihrem System installiert sind.
- Klonen Sie das Repository in ein lokales Verzeichnis.
2. Konfiguration:
- Erstellen Sie eine `.env`-Datei im Projektverzeichnis mit folgenden Variablen:
```
LDAP_SERVER=<Ihr LDAP-Server>
BIND_DN=<Ihr Bind DN>
BIND_PASSWORD=<Ihr Bind-Passwort>
SEARCH_BASE=<Ihr Suchbasis-DN>
```
- Passen Sie die `config.yaml` im `static`-Verzeichnis nach Bedarf an.
3. Container bauen und starten:
```
podman-compose up -d
```
4. Zugriff auf die Anwendung:
Öffnen Sie einen Webbrowser und navigieren Sie zu `http://localhost:8082`.
5. Container stoppen:
```
podman-compose down
```
Für detailliertere Anweisungen, siehe MANUAL.md.
## Lizenz
Entwickler: Steffen Probst
E-Mail: [pts@mkn.de](mailto:pts@mkn.de)
Dieses Projekt ist lizenziert unter der [GNU General Public License Version 3 (GPL-3.0)](https://www.gnu.org/licenses/gpl-3.0.html).
### Entwicklungsunterstützung
Dieses Programm wurde mit Unterstützung von Claude.ai, einem KI-Assistenten entwickelt von Anthropic, PBC, erstellt. Claude.ai wurde für Code-Generierung, Problemlösung und Optimierung eingesetzt.
Hinweis: Während Claude.ai bei der Entwicklung half, liegt das Urheberrecht des resultierenden Codes beim Autor, Steffen Probst. Claude.ai ist ein Werkzeug und beansprucht keine Rechte an dem erstellten Code.

20
docker-compose.yml Normal file
View File

@ -0,0 +1,20 @@
version: '3.8'
services:
go-app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- LDAP_SERVER=${LDAP_SERVER}
- BIND_DN=${BIND_DN}
- BIND_PASSWORD=${BIND_PASSWORD}
- SEARCH_BASE=${SEARCH_BASE}
- SERVER_PORT=8080
volumes:
- ./static:/app/static # Map the static directory from host to container
volumes:
go-app-data:

468
main.go Normal file
View File

@ -0,0 +1,468 @@
package main
/*
Copyright (C) 2023 Steffen Probst
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Entwickelt mit Unterstützung von Claude.ai, einem KI-Assistenten von Anthropic, PBC.
*/
package main
import (
"bytes"
"crypto/md5"
"crypto/tls"
"encoding/xml"
"encoding/csv"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/go-ldap/ldap/v3"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
)
type Config struct {
LDAPServer string
BindDN string
BindPassword string
SearchBase string
ServerPort string
}
type AddressBook struct {
XMLName xml.Name `xml:"AddressBook"`
Version string `xml:"version,attr"`
PBGroups []PBGroup `xml:"pbgroup"`
Contacts []Contact `xml:"Contact"`
}
type PBGroup struct {
ID int `xml:"id,attr"`
Name string `xml:"name"`
}
type Contact struct {
FirstName string `xml:"FirstName"`
LastName string `xml:"LastName"`
Frequent int `xml:"Frequent"`
Phones []Phone `xml:"Phone"`
Department string `xml:"Department,omitempty"`
Group int `xml:"Group"`
}
type Phone struct {
Type string `xml:"type,attr"`
PhoneNumber string `xml:"phonenumber"`
AccountIndex int `xml:"accountindex"`
}
type YealinkPhoneBook struct {
XMLName xml.Name `xml:"YealinkIPPhoneDirectory"`
Entries []YealinkEntry `xml:"DirectoryEntry"`
}
type YealinkEntry struct {
Name string `xml:"Name"`
Telephone string `xml:"Telephone"`
Mobile string `xml:"Mobile,omitempty"`
OtherMobile string `xml:"Other,omitempty"`
}
// Neue Struktur für das Contact-Format
type ContactXML struct {
XMLName xml.Name `xml:"contacts"`
Contacts []ContactEntry `xml:"contact"`
}
type ContactEntry struct {
Name string `xml:"name,attr"`
Number string `xml:"number,attr"`
FirstName string `xml:"firstname,attr"`
LastName string `xml:"lastname,attr"`
Phone string `xml:"phone,attr"`
Mobile string `xml:"mobile,attr"`
Email string `xml:"email,attr"`
Address string `xml:"address,attr"`
City string `xml:"city,attr"`
State string `xml:"state,attr"`
Zip string `xml:"zip,attr"`
Comment string `xml:"comment,attr"`
ID string `xml:"id,attr"`
Info string `xml:"info,attr"`
Presence string `xml:"presence,attr"`
Starred string `xml:"starred,attr"`
Directory string `xml:"directory,attr"`
}
var (
err error
config Config
cache struct {
grandstreamFile *os.File
yealinkFile *os.File
contactFile *os.File
hash []byte
lastUpdate time.Time
mutex sync.RWMutex
}
)
func main() {
config = Config{
LDAPServer: os.Getenv("LDAP_SERVER"),
BindDN: os.Getenv("BIND_DN"),
BindPassword: os.Getenv("BIND_PASSWORD"),
SearchBase: os.Getenv("SEARCH_BASE"),
ServerPort: os.Getenv("SERVER_PORT"),
}
if config.ServerPort == "" {
config.ServerPort = "8080"
}
// Initialisiere den Cache vor dem Start des Servers
err = initializeCache()
if err != nil {
log.Fatalf("Fehler beim Initialisieren des Caches: %v", err)
}
http.HandleFunc("/phonebook.xml", grandstreamPhoneBookHandler)
http.HandleFunc("/yphonebook.xml", yealinkPhoneBookHandler)
http.HandleFunc("/Contacts.xml", contactXMLHandler)
http.HandleFunc("/phonebook.csv", csvExportHandler)
log.Printf("Server starting on port %s", config.ServerPort)
log.Fatal(http.ListenAndServe(":"+config.ServerPort, nil))
}
func grandstreamPhoneBookHandler(w http.ResponseWriter, r *http.Request) {
servePhoneBook(w, r, cache.grandstreamFile)
}
func yealinkPhoneBookHandler(w http.ResponseWriter, r *http.Request) {
servePhoneBook(w, r, cache.yealinkFile)
}
func contactXMLHandler(w http.ResponseWriter, r *http.Request) {
servePhoneBook(w, r, cache.contactFile)
}
func csvExportHandler(w http.ResponseWriter, r *http.Request) {
err := updateCachedXMLIfNeeded()
if err != nil {
http.Error(w, "Fehler beim Aktualisieren der Kontakte", http.StatusInternalServerError)
return
}
contacts, err := getADContacts()
if err != nil {
http.Error(w, "Fehler beim Abrufen der Kontakte", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/csv; charset=windows-1252")
w.Header().Set("Content-Disposition", "attachment; filename=phonebook.csv")
if err := exportToCSV(contacts, w); err != nil {
http.Error(w, "Fehler beim Exportieren als CSV", http.StatusInternalServerError)
return
}
}
func servePhoneBook(w http.ResponseWriter, r *http.Request, file *os.File) {
err := updateCachedXMLIfNeeded()
if err != nil {
http.Error(w, "Fehler beim Aktualisieren der Kontakte", http.StatusInternalServerError)
return
}
cache.mutex.RLock()
defer cache.mutex.RUnlock()
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, err = file.Seek(0, 0)
if err != nil {
log.Printf("Fehler beim Zurücksetzen des Datei-Offsets: %v", err)
http.Error(w, "Interner Serverfehler", http.StatusInternalServerError)
return
}
_, err = io.Copy(w, file)
if err != nil {
log.Printf("Fehler beim Kopieren der XML-Datei: %v", err)
}
}
func initializeCache() error {
log.Println("Initialisiere Cache...")
// Führen Sie updateCachedXMLIfNeeded aus, um die Caching-Dateien zu erstellen
err := updateCachedXMLIfNeeded()
if err != nil {
return fmt.Errorf("Fehler beim Initialisieren des Caches: %v", err)
}
// Überprüfen Sie, ob alle erforderlichen Dateien erstellt wurden
if cache.grandstreamFile == nil || cache.yealinkFile == nil || cache.contactFile == nil {
return fmt.Errorf("Nicht alle erforderlichen Cache-Dateien wurden erstellt")
}
log.Println("Cache erfolgreich initialisiert")
return nil
}
func updateCachedXMLIfNeeded() error {
cache.mutex.Lock()
defer cache.mutex.Unlock()
contacts, err := getADContacts()
if err != nil {
return err
}
newHash := calculateHash(contacts)
if cache.grandstreamFile != nil && cache.yealinkFile != nil && cache.contactFile != nil && bytes.Equal(newHash, cache.hash) {
return nil
}
addressBook := AddressBook{
Version: "1.0",
PBGroups: []PBGroup{
{ID: 1, Name: "Blacklist"},
{ID: 2, Name: "Work"},
{ID: 3, Name: "Friend"},
},
Contacts: contacts,
}
yealinkPhoneBook := YealinkPhoneBook{
Entries: make([]YealinkEntry, len(contacts)),
}
for i, c := range contacts {
yealinkPhoneBook.Entries[i] = YealinkEntry{
Name: c.FirstName + " " + c.LastName,
Telephone: getPhoneByType(c.Phones, "Work"),
Mobile: getPhoneByType(c.Phones, "Mobile"),
OtherMobile: getPhoneByType(c.Phones, "Home"),
}
}
// Erstellen der Contacts.xml
contactXML := ContactXML{
Contacts: make([]ContactEntry, len(contacts)),
}
for i, c := range contacts {
contactXML.Contacts[i] = ContactEntry{
Name: c.FirstName + " " + c.LastName,
Number: getPhoneByType(c.Phones, "Work"),
FirstName: c.FirstName,
LastName: c.LastName,
Phone: getPhoneByType(c.Phones, "Work"),
Mobile: getPhoneByType(c.Phones, "Mobile"),
Presence: "0",
Starred: "0",
Directory: "0",
}
}
if err := saveToTempFile(&addressBook, &cache.grandstreamFile); err != nil {
return fmt.Errorf("Fehler beim Speichern der Grandstream-XML: %v", err)
}
if err := saveToTempFile(&yealinkPhoneBook, &cache.yealinkFile); err != nil {
return fmt.Errorf("Fehler beim Speichern der Yealink-XML: %v", err)
}
if err := saveToTempFile(&contactXML, &cache.contactFile); err != nil {
return fmt.Errorf("Fehler beim Speichern der Contact-XML: %v", err)
}
csvFile, err := os.OpenFile("phonebook.csv", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("Fehler beim Erstellen der CSV-Datei: %v", err)
}
defer csvFile.Close()
if err := exportToCSV(contacts, csvFile); err != nil {
return fmt.Errorf("Fehler beim Exportieren als CSV: %v", err)
}
cache.hash = newHash
cache.lastUpdate = time.Now()
return nil
}
func saveToTempFile(data interface{}, file **os.File) error {
tempFile, err := os.CreateTemp("", "phonebook_*.xml")
if err != nil {
return fmt.Errorf("Fehler beim Erstellen der temporären Datei: %v", err)
}
enc := xml.NewEncoder(tempFile)
enc.Indent("", " ")
if err := enc.Encode(data); err != nil {
tempFile.Close()
os.Remove(tempFile.Name())
return fmt.Errorf("Fehler beim Kodieren der XML: %v", err)
}
if *file != nil {
(*file).Close()
os.Remove((*file).Name())
}
*file = tempFile
return nil
}
func calculateHash(contacts []Contact) []byte {
h := md5.New()
for _, contact := range contacts {
io.WriteString(h, contact.FirstName)
io.WriteString(h, contact.LastName)
for _, phone := range contact.Phones {
io.WriteString(h, phone.PhoneNumber)
}
}
return h.Sum(nil)
}
func getADContacts() ([]Contact, error) {
l, err := ldap.DialTLS("tcp", config.LDAPServer+":636", &tls.Config{InsecureSkipVerify: true})
if err != nil {
return nil, fmt.Errorf("LDAP-Verbindung fehlgeschlagen: %v", err)
}
defer l.Close()
err = l.Bind(config.BindDN, config.BindPassword)
if err != nil {
return nil, fmt.Errorf("LDAP-Bindung fehlgeschlagen: %v", err)
}
// Erweiterter LDAP-Filter für Benutzer und Kontakte
searchFilter := "(&(|(objectClass=user)(objectClass=contact))(|(telephoneNumber=*)(otherTelephone=*)(mobile=*)(homePhone=*))(!(userAccountControl:1.2.840.113556.1.4.803:=2)))"
searchRequest := ldap.NewSearchRequest(
config.SearchBase,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
searchFilter,
[]string{"givenName", "sn", "displayName", "telephoneNumber", "otherTelephone", "mobile", "homePhone"},
nil,
)
sr, err := l.Search(searchRequest)
if err != nil {
return nil, fmt.Errorf("LDAP-Suche fehlgeschlagen: %v", err)
}
var contacts []Contact
for _, entry := range sr.Entries {
firstName := entry.GetAttributeValue("givenName")
lastName := entry.GetAttributeValue("sn")
if firstName == "" || lastName == "" {
continue
}
phone := formatPhoneNumber(entry.GetAttributeValue("telephoneNumber"))
otherMobile := formatPhoneNumber(entry.GetAttributeValue("otherMobile"))
mobile := formatPhoneNumber(entry.GetAttributeValue("mobile"))
if isExcludedNumber(phone) || isExcludedNumber(otherMobile) || isExcludedNumber(mobile) {
continue
}
if phone != "" || otherMobile != "" || mobile != "" {
contact := Contact{
FirstName: firstName,
LastName: lastName,
Phones: []Phone{},
Group: 2, // Standardmäßig zur "Work"-Gruppe hinzufügen
}
if phone != "" {
contact.Phones = append(contact.Phones, Phone{Type: "Work", PhoneNumber: phone, AccountIndex: 0})
}
if mobile != "" {
contact.Phones = append(contact.Phones, Phone{Type: "Mobile", PhoneNumber: mobile, AccountIndex: 0})
}
if otherMobile != "" {
contact.Phones = append(contact.Phones, Phone{Type: "Home", PhoneNumber: otherMobile, AccountIndex: 0})
}
contacts = append(contacts, contact)
}
}
return contacts, nil
}
func removeDuplicateNumbers(phones []Phone) []Phone {
seen := make(map[string]bool)
result := []Phone{}
for _, phone := range phones {
if !seen[phone.PhoneNumber] {
seen[phone.PhoneNumber] = true
result = append(result, phone)
} else if phone.Type == "Work" {
// Wenn die Nummer bereits gesehen wurde und dies die "Work" Nummer ist,
// ersetzen wir die vorherige (wahrscheinlich "Mobile") mit dieser.
for i, p := range result {
if p.PhoneNumber == phone.PhoneNumber {
result[i] = phone
break
}
}
}
}
return result
}
func isExcludedNumber(number string) bool {
excludedNumbers := []string{
"+49 5331 89",
"+49533189",
"+ 49 5331 89",
}
// Zuerst prüfen wir auf die ausgeschlossenen Nummern
for _, excluded := range excludedNumbers {
if strings.Contains(number, excluded) {
return true
}
}
// Dann prüfen wir, ob die Nummer mit "089" endet
if len(number) >= 3 && number[len(number)-3:] == "089" {
return true
}
// Wenn keine der obigen Bedingungen zutrifft, ist die Nummer nicht ausgeschlossen
return false
}
func

26
static/config.yaml Normal file
View File

@ -0,0 +1,26 @@
phone_rules:
# Präfixe, die von Telefonnummern entfernt werden sollen
# trim_prefixes:
# - "0049"
# - "+49"
# - "49"
# - "00"
# - "0"
# Regeln zur Formatierung von Telefonnummern
#format_rules:
# "+49": "0" # Ersetzt internationale Vorwahl durch nationale
# "00": "0" # Ersetzt internationale Vorwahl durch nationale
# Standard-Präfix für Nummern ohne Ländervorwahl
#default_prefix: "+49"
# Ungültige Telefonnummer, die ignoriert werden soll
invalid_number: "+49 5331 89"
# Konfiguration für Deutschland
country:
prefix: "49" # Ländervorwahl ohne '+' oder '00'
area_codes:
- "5331" # Vorwahl für Wolfenbüttel
internal_prefix: "89" # Präfix für interne Nummern

169
static/css/main.css Normal file
View File

@ -0,0 +1,169 @@
:root {
--background-body: #f5f5f5;
--background: #e0e0e0;
--text-main: #363636;
--text-bright: #000;
--links: #0076d1;
--focus: rgba(0,150,191,0.67);
--border: #dbdbdb;
--code: #000;
--animation-duration: 0.1s;
--button-hover: #9b9b9b;
--scrollbar-thumb: #aaa;
--form-placeholder: #949494;
--form-text: #1d1d1d;
--table-header: #d0d0d0;
--table-row-odd: #e8e8e8;
--table-row-even: #f0f0f0;
--sort-arrow: #666;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
line-height: 1.4;
max-width: 80%;
margin: 0 auto;
padding: 0 10%;
color: var(--text-main);
background: var(--background-body);
}
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 8px;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
background-color: var(--table-header);
font-weight: bold;
cursor: pointer;
position: relative;
padding-right: 20px; /* Space for sort indicator */
}
tr:nth-child(odd) {
background-color: var(--table-row-odd);
}
tr:nth-child(even) {
background-color: var(--table-row-even);
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.logo {
width: 100px;
height: 100px;
}
h1 {
text-align: center;
flex-grow: 1;
margin: 0 20px;
}
#searchInput {
width: 100%;
font-size: 16px;
padding: 12px 20px;
margin: 8px 0;
box-sizing: border-box;
}
/* Sort indicators */
th::after {
content: '\25B2'; /* Upward triangle */
position: absolute;
right: 5px;
opacity: 0.3;
color: var(--sort-arrow);
}
th.asc::after {
content: '\25B2'; /* Upward triangle */
opacity: 1;
}
th.desc::after {
content: '\25BC'; /* Downward triangle */
opacity: 1;
}
th:hover::after {
opacity: 0.6;
}
footer {
margin-top: 20px;
padding: 10px 0;
background-color: var(--background);
font-size: 8px;
}
.footer-container {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
a[href^="tel:"]:before {
content: "📞 ";
}
a[href^="mailto:"]:before {
content: "📧 ";
}
/* Responsive design */
@media (max-width: 768px) {
body {
max-width: 95%;
padding: 0 2.5%;
}
.header-container {
flex-direction: column;
}
.logo {
margin-bottom: 10px;
}
h1 {
font-size: 24px;
}
th, td {
padding: 6px;
}
}
/* Accessibility improvements */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

107
static/css/print.css Normal file
View File

@ -0,0 +1,107 @@
@media print {
@page {
size: A4 landscape;
margin: 0.8cm;
@bottom-right {
content: "Seite " counter(page) " von " counter(pages);
font-size: 8pt;
margin-right: -0.5cm;
margin-bottom: -0.5cm;
}
}
html, body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
font-size: 9pt;
line-height: 1.3;
background: none;
color: #000;
display: flex;
justify-content: center;
align-items: flex-start;
}
.dynamic-table-container {
width: 100%;
display: flex;
justify-content: center;
margin-bottom: 1.5cm;
}
table {
width: 100%;
max-width: 29.7cm; /* A4 landscape width minus margins */
border-collapse: collapse;
page-break-inside: auto;
}
thead {
display: table-header-group;
}
tbody {
display: table-row-group;
}
tr {
page-break-inside: avoid;
page-break-after: auto;
}
th, td {
padding: 0.1cm;
border: 1px solid #000;
text-align: center;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
th {
background-color: #f0f0f0 !important;
-webkit-print-color-adjust: exact;
color-adjust: exact;
}
a {
text-decoration: none;
color: #000;
}
/* Hide elements not needed for print */
#searchInput, .sort-icon::after, .header-container, footer, .print-date {
display: none;
}
/* Column widths */
th:nth-child(1), td:nth-child(1),
th:nth-child(2), td:nth-child(2) {
width: 15%;
}
th:nth-child(3), td:nth-child(3) {
width: 20%;
}
th:nth-child(4), td:nth-child(4) {
width: 10%;
}
th:nth-child(5), td:nth-child(5) {
width: 25%;
}
th:nth-child(6), td:nth-child(6) {
width: 15%;
}
/* Remove top margin for the first page */
.dynamic-table-container:first-child {
margin-top: 0;
}
/* Ensure content on subsequent pages starts at the top */
@page :not(:first) {
margin-top: 0.8cm;
}
}

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW 2018 (64-Bit Evaluation Version) -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="20.4978in" height="18.0152in" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 46296.26 40689.13"
xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:black}
]]>
</style>
</defs>
<g id="Layer_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_1761831900240">
<path class="fil0" d="M23204.91 7530.98c2944.63,-3188.84 6384.04,-4639.01 10366.38,-4077.21 4110.34,579.88 7609.97,3518.41 8854.17,7479.01 957.39,3047.58 559.96,6460.83 -722.09,9573.35 -1993.98,4840.97 -7886.31,11722.09 -18555.24,16532.85 -10668.92,-4810.76 -16561.25,-11691.88 -18555.24,-16532.85 -1282.05,-3112.52 -1679.47,-6525.77 -722.09,-9573.35 1244.19,-3960.6 4743.83,-6899.13 8854.17,-7479.01 3982.46,-561.82 7421.94,888.46 10366.64,4077.48 5.4,5.84 56.52,61.37 56.53,61.36 0.04,0.04 51.9,-56.36 56.79,-61.63zm-56.79 -4522.44c-6431.69,-5048.01 -16512.25,-3730.83 -21147.65,3855.94 -1539.08,2519.03 -2117.14,5447.75 -1981.3,8355.45 235.64,5043.59 2412.75,9452.27 5610.61,13256.78 4306.02,5122.9 10531.26,9148.59 17382.21,12152.72 9.53,4.18 88.63,38.56 136.13,59.69 41.66,-17.53 114.6,-50.41 137.01,-60.3 6815.65,-3004.07 13075.56,-7030.12 17381.33,-12152.12 3198.08,-3804.33 5374.97,-8213.2 5610.61,-13256.78 135.85,-2907.7 -442.2,-5836.43 -1981.3,-8355.45 -4635.4,-7586.77 -14715.95,-8903.95 -21147.65,-3855.94z"/>
<path class="fil0" d="M22983.64 21630.19l-2928.01 -1451.38c-1017.73,1483.99 -1758.21,2488.33 -3897.08,1902.25 -1678.91,-460.05 -2175.85,-2300.18 -2239.67,-3843.76 -87.17,-2108.39 649.94,-4543.46 3168.15,-4413.24 1609.13,83.19 2294.75,1032.23 2661.15,1885.36l3196.99 -1638.9c-1574.75,-3004.31 -5265.13,-4026.05 -8393.32,-3188.81 -3328.66,890.9 -5014.61,3952.95 -4955.5,7255.23 60.43,3375.58 1680.8,6291.51 5161.55,7052.54 1697.16,371.06 3545.13,284.81 5116.74,-503.18 1216.27,-609.83 2567.56,-1786.86 3109,-3056.12zm13802.46 0l-2928.01 -1451.38c-1017.73,1483.99 -1758.21,2488.33 -3897.08,1902.25 -1678.91,-460.05 -2175.86,-2300.18 -2239.67,-3843.76 -87.18,-2108.39 649.94,-4543.46 3168.15,-4413.24 1609.13,83.19 2294.74,1032.23 2661.15,1885.36l3196.99 -1638.9c-1574.75,-3004.31 -5265.14,-4026.05 -8393.32,-3188.81 -3328.66,890.9 -5014.61,3952.95 -4955.5,7255.23 60.42,3375.58 1680.8,6291.51 5161.55,7052.54 1697.16,371.06 3545.13,284.81 5116.74,-503.18 1216.27,-609.83 2567.56,-1786.86 3109,-3056.12z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
static/images/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

21
static/images/logo.svg Normal file
View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW 2018 (64-Bit Evaluation Version) -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="20.4978in" height="18.0152in" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 46296.26 40689.13"
xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:black}
]]>
</style>
</defs>
<g id="Layer_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_1761831900240">
<path class="fil0" d="M23204.91 7530.98c2944.63,-3188.84 6384.04,-4639.01 10366.38,-4077.21 4110.34,579.88 7609.97,3518.41 8854.17,7479.01 957.39,3047.58 559.96,6460.83 -722.09,9573.35 -1993.98,4840.97 -7886.31,11722.09 -18555.24,16532.85 -10668.92,-4810.76 -16561.25,-11691.88 -18555.24,-16532.85 -1282.05,-3112.52 -1679.47,-6525.77 -722.09,-9573.35 1244.19,-3960.6 4743.83,-6899.13 8854.17,-7479.01 3982.46,-561.82 7421.94,888.46 10366.64,4077.48 5.4,5.84 56.52,61.37 56.53,61.36 0.04,0.04 51.9,-56.36 56.79,-61.63zm-56.79 -4522.44c-6431.69,-5048.01 -16512.25,-3730.83 -21147.65,3855.94 -1539.08,2519.03 -2117.14,5447.75 -1981.3,8355.45 235.64,5043.59 2412.75,9452.27 5610.61,13256.78 4306.02,5122.9 10531.26,9148.59 17382.21,12152.72 9.53,4.18 88.63,38.56 136.13,59.69 41.66,-17.53 114.6,-50.41 137.01,-60.3 6815.65,-3004.07 13075.56,-7030.12 17381.33,-12152.12 3198.08,-3804.33 5374.97,-8213.2 5610.61,-13256.78 135.85,-2907.7 -442.2,-5836.43 -1981.3,-8355.45 -4635.4,-7586.77 -14715.95,-8903.95 -21147.65,-3855.94z"/>
<path class="fil0" d="M22983.64 21630.19l-2928.01 -1451.38c-1017.73,1483.99 -1758.21,2488.33 -3897.08,1902.25 -1678.91,-460.05 -2175.85,-2300.18 -2239.67,-3843.76 -87.17,-2108.39 649.94,-4543.46 3168.15,-4413.24 1609.13,83.19 2294.75,1032.23 2661.15,1885.36l3196.99 -1638.9c-1574.75,-3004.31 -5265.13,-4026.05 -8393.32,-3188.81 -3328.66,890.9 -5014.61,3952.95 -4955.5,7255.23 60.43,3375.58 1680.8,6291.51 5161.55,7052.54 1697.16,371.06 3545.13,284.81 5116.74,-503.18 1216.27,-609.83 2567.56,-1786.86 3109,-3056.12zm13802.46 0l-2928.01 -1451.38c-1017.73,1483.99 -1758.21,2488.33 -3897.08,1902.25 -1678.91,-460.05 -2175.86,-2300.18 -2239.67,-3843.76 -87.18,-2108.39 649.94,-4543.46 3168.15,-4413.24 1609.13,83.19 2294.74,1032.23 2661.15,1885.36l3196.99 -1638.9c-1574.75,-3004.31 -5265.14,-4026.05 -8393.32,-3188.81 -3328.66,890.9 -5014.61,3952.95 -4955.5,7255.23 60.42,3375.58 1680.8,6291.51 5161.55,7052.54 1697.16,371.06 3545.13,284.81 5116.74,-503.18 1216.27,-609.83 2567.56,-1786.86 3109,-3056.12z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

67
static/index.html Normal file
View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="/static/css/print.css">
<title>&#9742; Telefonbuch &#9742;</title>
<link rel="icon" type="image/x-icon" href="/static/images/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png">
<script src="/static/js/phonebook.js" defer></script>
</head>
<body>
<div class="header-container">
<img src="/static/images/logo.svg" alt="MKN Raute" class="logo">
<h1>&#9742; Telefonbuch &#9742;</h1>
<img src="/static/images/logo.svg" alt="MKN Raute" class="logo">
</div>
<input type="text" id="searchInput" onkeyup="searchTable()" placeholder="Suche nach Namen, Telefonnummern, Abteilung oder E-Mail...">
<div class="dynamic-table-container">
<table id="phonebookTable" class="dynamic-table">
<thead>
<tr>
<th class="desc" aria-sort="descending">Nachname</th>
<th class="min-width-cell">Vorname</th>
<th class="min-width-cell dynamic-width">Telefonnummer</th>
<th class="min-width-cell">Interne Rufnummer</th>
<th class="min-width-cell">E-Mail</th>
<th class="min-width-cell dynamic-width">Abteilung</th>
</tr>
</thead>
<tbody>
{{range .}}
<tr>
<td class="min-width-cell">{{.LastName}}</td>
<td class="min-width-cell">{{.FirstName}}</td>
<td class="min-width-cell dynamic-width">
{{range .Phones}}
({{.Type}}) <a href="tel:{{.PhoneNumber}}">{{.PhoneNumber}}</a><br>
{{else}}-{{end}}
</td>
<td class="min-width-cell">{{if .InternalPhone}}<a href="tel:{{.InternalPhone}}">{{.InternalPhone}}</a>{{else}}-{{end}}</td>
<td class="min-width-cell"><a href="mailto:{{.Email}}">{{.Email}}</a></td>
<td class="min-width-cell dynamic-width">{{if .Department}}{{.Department}}{{else}}-{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="print-date">Stand der Liste: <span id="currentDate"></span></div>
<footer>
<div class="footer-container">
<div class="developer">
Entwickelt von Steffen Probst
</div>
<div class="license">
<a href="https://www.gnu.org/licenses/gpl-3.0.html" target="_blank">GPL-3.0 Lizenz</a>
</div>
</div>
</footer>
<script>
document.getElementById('currentDate').textContent = new Date().toLocaleDateString('de-DE');
</script>
</body>
</html>

84
static/js/phonebook.js Normal file
View File

@ -0,0 +1,84 @@
function searchTable() {
const input = document.getElementById("searchInput");
const filter = input.value; // Entfernung von .toUpperCase()
const table = document.getElementById("phonebookTable");
const tr = table.getElementsByTagName("tr");
for (let i = 1; i < tr.length; i++) {
let visible = false;
const td = tr[i].getElementsByTagName("td");
for (let j = 0; j < td.length; j++) {
if (td[j]) {
const txtValue = td[j].textContent || td[j].innerText;
if (txtValue.indexOf(filter) > -1) { // Entfernung von .toUpperCase()
visible = true;
break;
}
}
}
tr[i].style.display = visible ? "" : "none";
}
}
let lastSortedColumn = 0;
let sortDirections = Array(document.querySelectorAll('#phonebookTable th').length).fill('asc');
// Array to keep track of the sort state for each column
let sortStates = [];
function sortTable(n) {
const table = document.getElementById("phonebookTable");
const tbody = table.querySelector("tbody");
const rows = Array.from(tbody.querySelectorAll("tr"));
const th = table.querySelectorAll("th")[n];
// Initialize sort state if not set
if (sortStates[n] === undefined) {
sortStates[n] = 'asc';
} else {
// Toggle sort direction
sortStates[n] = sortStates[n] === 'asc' ? 'desc' : 'asc';
}
const isAsc = sortStates[n] === 'asc';
// Clear all sorting classes
table.querySelectorAll("th").forEach(header => header.classList.remove("asc", "desc"));
// Set the appropriate class for the clicked header
th.classList.add(isAsc ? "asc" : "desc");
rows.sort((a, b) => {
let aValue = a.cells[n].textContent.trim();
let bValue = b.cells[n].textContent.trim();
// Check if the values are numbers
if (!isNaN(aValue) && !isNaN(bValue)) {
return isAsc ? aValue - bValue : bValue - aValue;
}
// For non-numeric values, use localeCompare for proper string comparison
return isAsc ?
aValue.localeCompare(bValue, undefined, {sensitivity: 'base'}) :
bValue.localeCompare(aValue, undefined, {sensitivity: 'base'});
});
// Remove all existing rows
while (tbody.firstChild) {
tbody.removeChild(tbody.firstChild);
}
// Add sorted rows
tbody.append(...rows);
}
// Add event listeners to table headers and initial sort
document.addEventListener('DOMContentLoaded', function() {
const headers = document.querySelectorAll('#phonebookTable th');
headers.forEach((header, index) => {
header.addEventListener('click', () => sortTable(index));
});
// Initial sort on the first column (index 0) in ascending order
sortTable(0);
});