From a2a9d7ac5ad604fd384eafd3fc92e1e53d56b29d Mon Sep 17 00:00:00 2001 From: Sebastian Zell Date: Fri, 16 Jan 2026 11:23:43 +0000 Subject: [PATCH] Initial commit: CSV Processor --- .abacus.donotdelete | 1 + .gitignore | 68 ++ LICENSE | 21 + README.md | 217 +++++ build_debian.sh | 94 ++ build_linux.sh | 49 ++ build_windows.bat | 48 + csv_processor.py | 1546 +++++++++++++++++++++++++++++++++ csv_processor_gui.py | 829 ++++++++++++++++++ dist/csv-processor-debian.deb | Bin 0 -> 23418 bytes requirements.txt | 7 + 11 files changed, 2880 insertions(+) create mode 100644 .abacus.donotdelete create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100755 build_debian.sh create mode 100755 build_linux.sh create mode 100644 build_windows.bat create mode 100644 csv_processor.py create mode 100644 csv_processor_gui.py create mode 100644 dist/csv-processor-debian.deb create mode 100644 requirements.txt diff --git a/.abacus.donotdelete b/.abacus.donotdelete new file mode 100644 index 0000000..feaecc3 --- /dev/null +++ b/.abacus.donotdelete @@ -0,0 +1 @@ +gAAAAABpah9sgktIo6WojeILJBTHfR6Y65YiGuG0qoAK_wkpSOJpY133w3Qhh_4zqQPY8sHOUAOL9HFXUWkBA7kBFFZAYMDIxETI-Af7l5LIyNiPhdZXzBI= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71570f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Application specific +csv_processor_config/ + +# NICHT ignorieren: dist/ mit .deb-Datei +# !dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bc32fb2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sebastian Zell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..469b5ad --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +# CSV-Processor v2.0 + +Professionelles Werkzeug zur Verarbeitung und Konvertierung von CSV- und Excel-Dateien. + +## Features + +✅ **Import** von CSV, Excel (XLSX/XLS) +✅ **Export** als CSV, Excel (XLSX), OpenDocument (ODS) +✅ **Spaltennamen-Mapping** (z.B. "btr" → "Betrag") +✅ **Datenbereinigung** (leere Zeilen/Spalten entfernen) +✅ **Spaltenauswahl** (nur gewünschte Spalten exportieren) +✅ **Sortierung** nach beliebigen Spalten +✅ **Voreinstellungen** für wiederkehrende Aufgaben + +## Programmversionen + +### CLI-Version (Terminal) +- **Für wen:** Fortgeschrittene Benutzer, Automatisierung +- **Vorteile:** Schnell, scriptfähig, volle Kontrolle +- **Datei:** `csv_processor.py` + +### GUI-Version (Grafisch) +- **Für wen:** Alle Benutzer, besonders Einsteiger +- **Vorteile:** Intuitiv, visuelle Vorschau, einfache Bedienung +- **Datei:** `csv_processor_gui.py` + +## Systemanforderungen + +- Python 3.7 oder höher +- Tkinter (für GUI-Version, normalerweise in Python enthalten) +- Optional: openpyxl (für Excel-Support) +- Optional: odfpy (für ODS-Support) + +## Installation + +### Option 1: Debian-Paket (.deb) + +Für Debian/Ubuntu-basierte Systeme steht ein fertiges Paket bereit: + +```bash +sudo dpkg -i dist/csv-processor-debian.deb +``` + +Nach der Installation: +- CLI-Version: `csv-processor-cli` +- GUI-Version: `csv-processor-gui` oder über Anwendungsmenü + +### Option 2: Direkt mit Python ausführen + +```bash +# Abhängigkeiten installieren +pip install -r requirements.txt + +# CLI-Version starten +python csv_processor.py + +# GUI-Version starten +python csv_processor_gui.py +``` + +## Nutzung + +### CLI-Version + +```bash +python csv_processor.py +``` + +Sie sehen das Hauptmenü: +``` +╔════════════════════════════════════════════════════════════════╗ +║ CSV-PROCESSOR v2.0 - Dateiverarbeitung ║ +╚════════════════════════════════════════════════════════════════╝ + + 1. Neue Datei verarbeiten + 2. Voreinstellungen verwalten + 0. Beenden +``` + +**Schritt-für-Schritt:** +1. Quelldatei auswählen (CSV oder Excel) +2. CSV-Einstellungen konfigurieren (Encoding, Delimiter) +3. Optional: Mapping-Datei für Spaltennamen laden +4. Leere Zeilen/Spalten entfernen +5. Spalten auswählen +6. Optional: Sortierung, Kopf-/Fußzeile +7. Ausgabeformat wählen und speichern +8. Optional: Als Voreinstellung speichern + +### GUI-Version + +```bash +python csv_processor_gui.py +``` + +1. **Datei laden:** Klick auf [Durchsuchen] und Datei auswählen +2. **Einstellungen:** Checkboxen für Kopfzeile, leere Zeilen/Spalten +3. **Spalten:** Im Bereich "Spaltenauswahl" gewünschte Spalten markieren +4. **Vorschau:** Optional [Vorschau] klicken +5. **Speichern:** [Verarbeiten & Speichern] klicken + +### Spaltenauswahl (CLI) + +| Eingabe | Bedeutung | +|---------|-------------------------------| +| `q` | Fertig | +| `alle` | Alle auswählen | +| `keine` | Alle abwählen | +| `3` | Spalte 3 umschalten | +| `1,2,3` | Spalten 1,2,3 umschalten | +| `+5` | Spalte 5 ANwählen | +| `-5` | Spalte 5 ABwählen | +| `1-10` | Spalten 1-10 umschalten | +| `+1-10` | Spalten 1-10 ANwählen | +| `-1-10` | Spalten 1-10 ABwählen | + +## Mapping-Dateien + +Mapping-Dateien übersetzen technische Spaltennamen in lesbare Namen: + +```json +{ + "btr": "Betrag", + "dat": "Datum", + "kto": "Kontonummer", + "empf": "Empfänger" +} +``` + +Speichern Sie diese als `.json`-Datei und laden Sie sie im Programm. + +## Voreinstellungen + +Voreinstellungen speichern Ihre Konfiguration für wiederkehrende Aufgaben: +- Kopfzeile ja/nein +- Leere Zeilen/Spalten entfernen +- Mapping-Datei +- Spaltenauswahl +- Ausgabeformat +- CSV-Export-Einstellungen +- Sortierung + +Voreinstellungen werden im Verzeichnis `csv_processor_config/` gespeichert. + +## Build + +### Linux-Executable erstellen + +```bash +./build_linux.sh +``` + +Erstellt eine standalone-Executable in `dist/`. + +### Debian-Paket erstellen + +```bash +./build_debian.sh +``` + +Erstellt ein `.deb`-Paket für Debian/Ubuntu. + +### Windows-Executable erstellen + +```bash +./build_windows.bat +``` + +Oder unter Linux mit Wine: +```bash +./build_windows_wine.sh +``` + +## Projektstruktur + +``` +csv-processor/ +├── csv_processor.py # CLI-Version +├── csv_processor_gui.py # GUI-Version +├── requirements.txt # Python-Abhängigkeiten +├── README.md # Diese Datei +├── LICENSE # MIT-Lizenz +├── build_linux.sh # Build-Script Linux +├── build_debian.sh # Build-Script Debian-Paket +├── build_windows.bat # Build-Script Windows +└── dist/ # Fertige Pakete + └── csv-processor-debian.deb +``` + +## Tipps & Tricks + +### Große Dateien +- Bei >10.000 Zeilen: 5-30 Sekunden Verarbeitungszeit +- Bei >100.000 Zeilen: CLI-Version ist schneller +- Leere Spalten werden vor der Spaltenauswahl entfernt + +### CSV-Export für Excel +- Delimiter: Semikolon (;) für deutsche Excel-Versionen +- Quoting: Minimal + +### CSV-Export für Datenbanken +- Delimiter: Tab +- Quoting: Minimal +- Keine Kopf-/Fußzeile + +## Lizenz + +MIT License - siehe [LICENSE](LICENSE) + +## Autor + +Sebastian Zell + +--- + +**Version:** 2.0 +**Stand:** Januar 2026 diff --git a/build_debian.sh b/build_debian.sh new file mode 100755 index 0000000..7963ac0 --- /dev/null +++ b/build_debian.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# Build-Script für Debian-Paket (.deb) +# Erstellt ein installierbares Debian-Paket + +set -e + +PKG_NAME="csv-processor" +PKG_VERSION="2.0.0" +PKG_ARCH="amd64" +MAINTAINER="Sebastian Zell " +DESCRIPTION="Professionelles Werkzeug zur Verarbeitung von CSV- und Excel-Dateien" + +echo "========================================" +echo "CSV-Processor - Debian Package Build" +echo "========================================" + +# Prüfe ob PyInstaller installiert ist +if ! command -v pyinstaller &> /dev/null; then + echo "PyInstaller nicht gefunden. Installiere..." + pip install pyinstaller +fi + +# Build-Verzeichnis vorbereiten +echo "Bereite Build-Verzeichnis vor..." +BUILD_DIR="deb_build" +rm -rf "$BUILD_DIR" 2>/dev/null || true +mkdir -p "$BUILD_DIR/DEBIAN" +mkdir -p "$BUILD_DIR/usr/bin" +mkdir -p "$BUILD_DIR/usr/share/applications" +mkdir -p "$BUILD_DIR/usr/share/$PKG_NAME" + +# Executables erstellen +echo "" +echo "Erstelle Executables mit PyInstaller..." + +# CLI-Version +pyinstaller --onefile --name csv-processor-cli --clean csv_processor.py +cp dist/csv-processor-cli "$BUILD_DIR/usr/bin/" + +# GUI-Version +pyinstaller --onefile --name csv-processor-gui --clean csv_processor_gui.py +cp dist/csv-processor-gui "$BUILD_DIR/usr/bin/" + +# Rechte setzen +chmod 755 "$BUILD_DIR/usr/bin/csv-processor-cli" +chmod 755 "$BUILD_DIR/usr/bin/csv-processor-gui" + +# Desktop-Eintrag erstellen +cat > "$BUILD_DIR/usr/share/applications/csv-processor.desktop" << EOF +[Desktop Entry] +Name=CSV-Processor +Comment=Professionelle CSV/Excel-Verarbeitung +Exec=csv-processor-gui +Icon=accessories-text-editor +Terminal=false +Type=Application +Categories=Office;Utility; +EOF + +# DEBIAN/control erstellen +cat > "$BUILD_DIR/DEBIAN/control" << EOF +Package: $PKG_NAME +Version: $PKG_VERSION +Section: utils +Priority: optional +Architecture: $PKG_ARCH +Maintainer: $MAINTAINER +Description: $DESCRIPTION + CSV-Processor ist ein professionelles Werkzeug zur Verarbeitung + und Konvertierung von CSV- und Excel-Dateien. Es bietet + Spaltennamen-Mapping, Datenbereinigung, Spaltenauswahl und + Voreinstellungen für wiederkehrende Aufgaben. +EOF + +# Debian-Paket bauen +echo "" +echo "Erstelle Debian-Paket..." +dpkg-deb --build "$BUILD_DIR" "dist/${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.deb" + +# Aufräumen +rm -rf "$BUILD_DIR" +rm -rf build/ +rm -f *.spec + +echo "" +echo "========================================" +echo "Build abgeschlossen!" +echo "========================================" +echo "" +echo "Erstelltes Paket:" +ls -la dist/*.deb +echo "" +echo "Installation mit:" +echo " sudo dpkg -i dist/${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.deb" diff --git a/build_linux.sh b/build_linux.sh new file mode 100755 index 0000000..a16604d --- /dev/null +++ b/build_linux.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Build-Script für Linux-Executable +# Erstellt standalone-Executables mit PyInstaller + +set -e + +echo "========================================" +echo "CSV-Processor - Linux Build" +echo "========================================" + +# Prüfe ob PyInstaller installiert ist +if ! command -v pyinstaller &> /dev/null; then + echo "PyInstaller nicht gefunden. Installiere..." + pip install pyinstaller +fi + +# Verzeichnisse vorbereiten +echo "Bereite Build-Verzeichnis vor..." +rm -rf build/ dist/*.spec 2>/dev/null || true +mkdir -p dist + +# CLI-Version bauen +echo "" +echo "Baue CLI-Version..." +pyinstaller --onefile \ + --name csv-processor-cli \ + --clean \ + csv_processor.py + +# GUI-Version bauen +echo "" +echo "Baue GUI-Version..." +pyinstaller --onefile \ + --name csv-processor-gui \ + --windowed \ + --clean \ + csv_processor_gui.py + +echo "" +echo "========================================" +echo "Build abgeschlossen!" +echo "========================================" +echo "" +echo "Erstellte Dateien:" +ls -la dist/csv-processor-* +echo "" +echo "Ausführen mit:" +echo " ./dist/csv-processor-cli" +echo " ./dist/csv-processor-gui" diff --git a/build_windows.bat b/build_windows.bat new file mode 100644 index 0000000..161dcd6 --- /dev/null +++ b/build_windows.bat @@ -0,0 +1,48 @@ +@echo off +REM Build-Script für Windows-Executable +REM Erstellt standalone-Executables mit PyInstaller + +echo ======================================== +echo CSV-Processor - Windows Build +echo ======================================== + +REM Prüfe ob Python installiert ist +python --version >nul 2>&1 +if errorlevel 1 ( + echo Python nicht gefunden. Bitte installieren Sie Python 3.7+ + pause + exit /b 1 +) + +REM Prüfe/Installiere PyInstaller +pip show pyinstaller >nul 2>&1 +if errorlevel 1 ( + echo PyInstaller nicht gefunden. Installiere... + pip install pyinstaller +) + +REM Verzeichnisse vorbereiten +echo. +echo Bereite Build-Verzeichnis vor... +if exist build rmdir /s /q build +if not exist dist mkdir dist + +REM CLI-Version bauen +echo. +echo Baue CLI-Version... +pyinstaller --onefile --name csv-processor-cli --clean csv_processor.py + +REM GUI-Version bauen +echo. +echo Baue GUI-Version... +pyinstaller --onefile --name csv-processor-gui --windowed --clean csv_processor_gui.py + +echo. +echo ======================================== +echo Build abgeschlossen! +echo ======================================== +echo. +echo Erstellte Dateien befinden sich in: dist\ +dir dist\csv-processor-*.exe +echo. +pause diff --git a/csv_processor.py b/csv_processor.py new file mode 100644 index 0000000..2ca27d8 --- /dev/null +++ b/csv_processor.py @@ -0,0 +1,1546 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +CSV-Processor v2.0: Erweiterte Verarbeitung und Formatierung von CSV/Excel-Dateien +mit Voreinstellungsverwaltung und Multi-Format-Export +""" + +import csv +import json +import os +import sys +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Any, Tuple +import re + +# Optional: Externe Bibliotheken für Excel/ODT Support +try: + import openpyxl + from openpyxl.styles import Font, PatternFill + EXCEL_SUPPORT = True +except ImportError: + EXCEL_SUPPORT = False + +try: + from odf.opendocument import OpenDocumentSpreadsheet + from odf.table import Table, TableRow, TableCell + from odf.text import P + ODT_SUPPORT = True +except ImportError: + ODT_SUPPORT = False + + +class CSVProcessor: + def __init__(self): + self.config_dir = Path("csv_processor_config") + self.config_dir.mkdir(exist_ok=True) + self.current_config = {} + + def clear_screen(self): + """Bildschirm löschen für bessere Übersicht""" + os.system('cls' if os.name == 'nt' else 'clear') + + def print_header(self, text: str): + """Formatierte Überschrift ausgeben""" + print("\n" + "="*60) + print(f" {text}") + print("="*60 + "\n") + + def get_available_presets(self) -> List[str]: + """Liste aller verfügbaren Voreinstellungen""" + presets = [] + for file in self.config_dir.glob("*.json"): + presets.append(file.stem) + return presets + + def load_preset(self, preset_name: str) -> Dict: + """Voreinstellung laden""" + preset_file = self.config_dir / f"{preset_name}.json" + if preset_file.exists(): + with open(preset_file, 'r', encoding='utf-8') as f: + return json.load(f) + return {} + + def save_preset(self, preset_name: str, config: Dict): + """Voreinstellung speichern""" + preset_file = self.config_dir / f"{preset_name}.json" + with open(preset_file, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=2, ensure_ascii=False) + print(f"\n✓ Voreinstellung '{preset_name}' gespeichert.") + + def delete_preset(self, preset_name: str): + """Voreinstellung löschen""" + preset_file = self.config_dir / f"{preset_name}.json" + if preset_file.exists(): + preset_file.unlink() + print(f"\n✓ Voreinstellung '{preset_name}' gelöscht.") + else: + print(f"\n✗ Voreinstellung '{preset_name}' nicht gefunden.") + + def ask_yes_no(self, question: str, default: Optional[bool] = None) -> bool: + """Ja/Nein Frage stellen""" + if default is not None: + question += f" (Standard: {'Ja' if default else 'Nein'})" + + while True: + answer = input(f"{question} [j/n]: ").lower().strip() + if answer in ['j', 'ja', 'y', 'yes']: + return True + elif answer in ['n', 'nein', 'no']: + return False + elif answer == '' and default is not None: + return default + else: + print("Bitte antworten Sie mit 'j' für Ja oder 'n' für Nein.") + + def select_file(self, prompt: str, extension: str = "*") -> Optional[Path]: + """Datei auswählen""" + while True: + filename = input(f"{prompt}: ").strip() + if not filename: + return None + + path = Path(filename) + if path.exists(): + return path + else: + print(f"Datei '{filename}' nicht gefunden. Bitte erneut versuchen.") + if not self.ask_yes_no("Möchten Sie es erneut versuchen?", True): + return None + + def load_column_mappings(self, mapping_file: Optional[Path]) -> Dict[str, str]: + """Spaltennamen-Zuordnungen aus JSON laden""" + if not mapping_file or not mapping_file.exists(): + return {} + + try: + with open(mapping_file, 'r', encoding='utf-8') as f: + return json.load(f) + except json.JSONDecodeError: + print(f"Fehler beim Lesen der Mapping-Datei: {mapping_file}") + return {} + + def detect_csv_format(self, filepath: Path) -> Tuple[str, str, str]: + """CSV-Format automatisch erkennen""" + encodings = ['utf-8', 'latin-1', 'cp1252'] + + for encoding in encodings: + try: + with open(filepath, 'r', encoding=encoding) as f: + sample = f.read(2048) + + sniffer = csv.Sniffer() + dialect = sniffer.sniff(sample) + delimiter = dialect.delimiter + + if dialect.quoting == csv.QUOTE_ALL: + quoting = 'ALLE' + elif dialect.quoting == csv.QUOTE_MINIMAL: + quoting = 'MINIMAL' + elif dialect.quoting == csv.QUOTE_NONNUMERIC: + quoting = 'NICHT_NUMERISCH' + else: + quoting = 'KEINE' + + return encoding, delimiter, quoting + except: + continue + + return 'utf-8', ';', 'MINIMAL' + + def parse_csv_line(self, line: str, delimiter: str, quoting: str) -> List[str]: + """Eine CSV-Zeile mit gegebenen Einstellungen parsen""" + quoting_map = { + 'ALLE': csv.QUOTE_ALL, + 'MINIMAL': csv.QUOTE_MINIMAL, + 'NICHT_NUMERISCH': csv.QUOTE_NONNUMERIC, + 'KEINE': csv.QUOTE_NONE + } + + try: + reader = csv.reader([line], delimiter=delimiter, quoting=quoting_map.get(quoting, csv.QUOTE_MINIMAL)) + return next(reader) + except: + return line.split(delimiter) + + def configure_csv_import(self, filepath: Path) -> Tuple[str, str, str]: + """Interaktive CSV-Import-Konfiguration""" + detected_encoding, detected_delimiter, detected_quoting = self.detect_csv_format(filepath) + + delimiter_names = { + ';': 'Semikolon (;)', + ',': 'Komma (,)', + '\t': 'Tab', + '|': 'Pipe (|)', + ' ': 'Leerzeichen' + } + + with open(filepath, 'r', encoding=detected_encoding) as f: + test_lines = [f.readline().strip() for _ in range(3)] + + print("\n" + "="*70) + print(" CSV-IMPORT KONFIGURATION") + print("="*70) + print(f"\nDatei: {filepath.name}") + print(f"Erkanntes Encoding: {detected_encoding}") + print(f"Erkannter Delimiter: {delimiter_names.get(detected_delimiter, detected_delimiter)}") + print(f"Erkanntes Quoting: {detected_quoting}") + + current_encoding = detected_encoding + current_delimiter = detected_delimiter + current_quoting = detected_quoting + + while True: + print("\n" + "-"*70) + print(" VORSCHAU DER ERSTEN ZEILEN:") + print("-"*70) + + for i, line in enumerate(test_lines, 1): + if line: + parsed = self.parse_csv_line(line, current_delimiter, current_quoting) + print(f"\nZeile {i}:") + print(f" Rohtext: {line[:80]}...") + print(f" Geparst: {len(parsed)} Felder") + for j, field in enumerate(parsed[:5], 1): + print(f" Feld {j}: '{field}'") + if len(parsed) > 5: + print(f" ... und {len(parsed)-5} weitere Felder") + + print("\n" + "-"*70) + print(" AKTUELLE EINSTELLUNGEN:") + print("-"*70) + print(f" 1. Encoding: {current_encoding}") + print(f" 2. Delimiter: {delimiter_names.get(current_delimiter, current_delimiter)}") + print(f" 3. Quoting: {current_quoting}") + print("\n 0. ✓ Diese Einstellungen verwenden") + print("-"*70) + + choice = input("\nWas möchten Sie ändern? [0-3]: ").strip() + + if choice == '0': + break + elif choice == '1': + print("\nVerfügbare Encodings:") + encodings = ['utf-8', 'latin-1', 'cp1252', 'iso-8859-1'] + for i, enc in enumerate(encodings, 1): + print(f" {i}. {enc}") + enc_choice = input("Ihre Wahl: ").strip() + try: + idx = int(enc_choice) - 1 + if 0 <= idx < len(encodings): + current_encoding = encodings[idx] + with open(filepath, 'r', encoding=current_encoding) as f: + test_lines = [f.readline().strip() for _ in range(3)] + except: + pass + + elif choice == '2': + print("\nVerfügbare Delimiters:") + print(" 1. Semikolon (;)") + print(" 2. Komma (,)") + print(" 3. Tab") + print(" 4. Pipe (|)") + print(" 5. Leerzeichen") + delim_choice = input("Ihre Wahl: ").strip() + delim_map = {'1': ';', '2': ',', '3': '\t', '4': '|', '5': ' '} + if delim_choice in delim_map: + current_delimiter = delim_map[delim_choice] + + elif choice == '3': + print("\nQuoting-Optionen:") + print(" 1. MINIMAL - Nur Felder mit Sonderzeichen werden quoted") + print(" 2. ALLE - Alle Felder werden in Anführungszeichen gesetzt") + print(" 3. NICHT_NUMERISCH - Alle nicht-numerischen Felder werden quoted") + print(" 4. KEINE - Keine Anführungszeichen") + quot_choice = input("Ihre Wahl: ").strip() + quot_map = {'1': 'MINIMAL', '2': 'ALLE', '3': 'NICHT_NUMERISCH', '4': 'KEINE'} + if quot_choice in quot_map: + current_quoting = quot_map[quot_choice] + + print("\n✓ Import-Einstellungen bestätigt") + return current_encoding, current_delimiter, current_quoting + + def read_csv(self, filepath: Path, has_header: Optional[bool] = None, + encoding: str = None, delimiter: str = None, quoting: str = None) -> Tuple[List[str], List[Dict], bool, Dict]: + """CSV-Datei lesen""" + if encoding is None or delimiter is None: + encoding, delimiter, quoting = self.configure_csv_import(filepath) + + quoting_map = { + 'ALLE': csv.QUOTE_ALL, + 'MINIMAL': csv.QUOTE_MINIMAL, + 'NICHT_NUMERISCH': csv.QUOTE_NONNUMERIC, + 'KEINE': csv.QUOTE_NONE + } + + csv_quoting = quoting_map.get(quoting, csv.QUOTE_MINIMAL) + + try: + with open(filepath, 'r', encoding=encoding) as f: + if has_header is None: + has_header = self.ask_yes_no("\nHat die CSV-Datei eine Kopfzeile mit Spaltennamen?", True) + + if has_header: + reader = csv.DictReader(f, delimiter=delimiter, quoting=csv_quoting) + headers = list(reader.fieldnames) + data = list(reader) + else: + reader = csv.reader(f, delimiter=delimiter, quoting=csv_quoting) + rows = list(reader) + if rows: + headers = [f"Spalte_{i+1}" for i in range(len(rows[0]))] + data = [] + for row in rows: + row_dict = {headers[i]: row[i] if i < len(row) else '' + for i in range(len(headers))} + data.append(row_dict) + else: + headers = [] + data = [] + + import_settings = { + 'encoding': encoding, + 'delimiter': delimiter, + 'quoting': quoting + } + + return headers, data, has_header, import_settings + except Exception as e: + print(f"Fehler beim Lesen der CSV: {e}") + raise + + def read_excel(self, filepath: Path) -> Tuple[Optional[List[str]], List[Dict], bool, Dict]: + """Excel-Datei lesen""" + if not EXCEL_SUPPORT: + print("Fehler: openpyxl ist nicht installiert. Installieren Sie es mit: pip install openpyxl") + return None, [], False, {} + + try: + wb = openpyxl.load_workbook(filepath, data_only=True) + + sheet_names = wb.sheetnames + if len(sheet_names) > 1: + print("\nVerfügbare Tabellenblätter:") + for i, name in enumerate(sheet_names, 1): + print(f" {i}. {name}") + + while True: + choice = input("Nummer des Tabellenblatts: ").strip() + try: + idx = int(choice) - 1 + if 0 <= idx < len(sheet_names): + ws = wb[sheet_names[idx]] + break + except ValueError: + pass + print("Ungültige Eingabe.") + else: + ws = wb.active + + has_header = self.ask_yes_no("Hat die Datei eine Kopfzeile mit Spaltennamen?", True) + + data = [] + headers = None + + for i, row in enumerate(ws.iter_rows(values_only=True)): + if i == 0 and has_header: + headers = [str(cell) if cell is not None else f"Spalte_{j+1}" + for j, cell in enumerate(row)] + else: + if headers is None: + headers = [f"Spalte_{j+1}" for j in range(len(row))] + + row_dict = {} + for j, cell in enumerate(row): + if j < len(headers): + row_dict[headers[j]] = str(cell) if cell is not None else '' + data.append(row_dict) + + import_settings = {} + + return headers, data, has_header, import_settings + + except Exception as e: + print(f"Fehler beim Lesen der Excel-Datei: {e}") + return None, [], False, {} + + def apply_column_mappings(self, headers: List[str], data: List[Dict], mappings: Dict[str, str]) -> Tuple[List[str], List[Dict], Dict[str, str]]: + """Spaltennamen umbenennen und Original-Namen behalten""" + new_headers = [mappings.get(h, h) for h in headers] + original_names = {new_h: old_h for old_h, new_h in zip(headers, new_headers)} + + new_data = [] + for row in data: + new_row = {} + for old_h, new_h in zip(headers, new_headers): + new_row[new_h] = row.get(old_h, '') + new_data.append(new_row) + + return new_headers, new_data, original_names + + def remove_empty_columns(self, headers: List[str], data: List[Dict]) -> Tuple[List[str], List[Dict]]: + """Leere Spalten entfernen""" + non_empty_headers = [] + + for header in headers: + has_values = any(row.get(header, '').strip() for row in data) + if has_values: + non_empty_headers.append(header) + + filtered_data = [] + for row in data: + filtered_row = {h: row.get(h, '') for h in non_empty_headers} + filtered_data.append(filtered_row) + + return non_empty_headers, filtered_data + + def remove_empty_rows(self, data: List[Dict]) -> List[Dict]: + """Komplett leere Zeilen entfernen""" + filtered_data = [] + for row in data: + has_values = any(str(v).strip() for v in row.values()) + if has_values: + filtered_data.append(row) + return filtered_data + + def analyze_row_completeness(self, headers: List[str], data: List[Dict]) -> Dict[int, int]: + """Analysiere Zeilen nach Anzahl ausgefüllter Felder""" + analysis = {} + for idx, row in enumerate(data): + filled_count = sum(1 for v in row.values() if str(v).strip()) + analysis[idx] = filled_count + return analysis + + def filter_rows_by_filled_fields(self, headers: List[str], data: List[Dict]) -> List[Dict]: + """Zeilen mit zu wenig Informationen filtern""" + total_columns = len(headers) + analysis = self.analyze_row_completeness(headers, data) + + print(f"\nAnalyse der Datenvollständigkeit:") + print(f"Gesamtanzahl Spalten: {total_columns}") + print(f"Gesamtanzahl Zeilen: {len(data)}") + + from collections import Counter + counts = Counter(analysis.values()) + + print("\nVerteilung ausgefüllter Felder pro Zeile:") + for filled_count in sorted(counts.keys()): + print(f" {filled_count} Felder ausgefüllt: {counts[filled_count]} Zeilen") + + print(f"\nBei wie vielen oder weniger ausgefüllten Feldern sollen Zeilen entfernt werden?") + while True: + try: + threshold = int(input(f"Schwellenwert (0-{total_columns}): ").strip()) + if 0 <= threshold <= total_columns: + break + print(f"Bitte eine Zahl zwischen 0 und {total_columns} eingeben.") + except ValueError: + print("Ungültige Eingabe.") + + rows_to_remove = [idx for idx, count in analysis.items() if count <= threshold] + + if rows_to_remove: + print(f"\n{len(rows_to_remove)} Zeilen haben {threshold} oder weniger ausgefüllte Felder:") + for i, idx in enumerate(rows_to_remove[:10]): + print(f" Zeile {idx + 1}: {analysis[idx]} Felder ausgefüllt") + sample = {k: v for k, v in data[idx].items() if str(v).strip()} + print(f" Daten: {sample}") + + if len(rows_to_remove) > 10: + print(f" ... und {len(rows_to_remove) - 10} weitere Zeilen") + + if self.ask_yes_no(f"\nMöchten Sie diese {len(rows_to_remove)} Zeilen löschen?"): + filtered_data = [row for idx, row in enumerate(data) if idx not in rows_to_remove] + print(f"✓ {len(rows_to_remove)} Zeilen entfernt") + return filtered_data + else: + print(f"Keine Zeilen mit {threshold} oder weniger ausgefüllten Feldern gefunden.") + + return data + + def select_columns(self, headers: List[str], original_names: Dict[str, str] = None, + preselected: Optional[List[str]] = None) -> List[str]: + """Spalten für Export auswählen mit interaktiver An-/Abwahl""" + GREEN = '\033[92m' + RED = '\033[91m' + RESET = '\033[0m' + BOLD = '\033[1m' + + if preselected and all(h in headers for h in preselected): + selected = set(preselected) + else: + selected = set(headers) + + def show_columns(): + print("\n" + "="*70) + print(" SPALTENAUSWAHL") + print("="*70) + print(f"{GREEN}● = Angewählt{RESET} | {RED}○ = Abgewählt{RESET}") + print("-"*70) + + for i, header in enumerate(headers, 1): + symbol = f"{GREEN}●{RESET}" if header in selected else f"{RED}○{RESET}" + + if original_names and header in original_names: + original = original_names[header] + if original != header: + display = f"{original} {BOLD}→ {header}{RESET}" + else: + display = header + else: + display = header + + print(f" {symbol} {i:3d}. {display}") + + print("-"*70) + print(f"Aktuell ausgewählt: {len(selected)} von {len(headers)} Spalten") + print("="*70) + + def parse_selection(input_str: str) -> set: + indices = set() + parts = input_str.split(',') + + for part in parts: + part = part.strip() + if '-' in part: + try: + start, end = part.split('-') + start_idx = int(start.strip()) - 1 + end_idx = int(end.strip()) - 1 + if 0 <= start_idx < len(headers) and 0 <= end_idx < len(headers): + for i in range(start_idx, end_idx + 1): + if 0 <= i < len(headers): + indices.add(i) + except ValueError: + pass + else: + try: + idx = int(part) - 1 + if 0 <= idx < len(headers): + indices.add(idx) + except ValueError: + pass + + return indices + + while True: + show_columns() + + print("\nOptionen:") + print(" [Nummern] - Spalten an/abwählen (z.B. '1,2,3' oder '1-5,10-15')") + print(" + [Nummern] - Spalten ANwählen (z.B. '+1,2,3' oder '+10-20')") + print(" - [Nummern] - Spalten ABwählen (z.B. '-1,2,3' oder '-5-10')") + print(" alle - Alle Spalten anwählen") + print(" keine - Alle Spalten abwählen") + print(" q - Auswahl beenden und fortfahren") + + choice = input("\nIhre Wahl: ").strip() + + if choice.lower() == 'q': + if selected: + break + else: + print(f"\n{RED}Fehler: Mindestens eine Spalte muss ausgewählt sein!{RESET}") + + elif choice.lower() == 'alle': + selected = set(headers) + print(f"{GREEN}✓ Alle Spalten angewählt{RESET}") + + elif choice.lower() == 'keine': + selected = set() + print(f"{RED}✓ Alle Spalten abgewählt{RESET}") + + elif choice.startswith('+'): + indices = parse_selection(choice[1:]) + for idx in indices: + selected.add(headers[idx]) + print(f"{GREEN}✓ {len(indices)} Spalte(n) angewählt{RESET}") + + elif choice.startswith('-'): + indices = parse_selection(choice[1:]) + for idx in indices: + selected.discard(headers[idx]) + print(f"{RED}✓ {len(indices)} Spalte(n) abgewählt{RESET}") + + else: + indices = parse_selection(choice) + for idx in indices: + if headers[idx] in selected: + selected.discard(headers[idx]) + else: + selected.add(headers[idx]) + if indices: + print(f"✓ {len(indices)} Spalte(n) umgeschaltet") + + return [h for h in headers if h in selected] + + def sort_data(self, data: List[Dict], sort_column: str, data_type: str) -> List[Dict]: + """Daten nach Spalte sortieren""" + def get_sort_key(row): + value = row.get(sort_column, '') + + if data_type == 'datum': + formats = ['%d.%m.%Y', '%Y-%m-%d', '%d/%m/%Y', '%m/%d/%Y'] + for fmt in formats: + try: + return datetime.strptime(value, fmt) + except ValueError: + continue + return datetime.min + + elif data_type == 'zeit': + formats = ['%H:%M:%S', '%H:%M'] + for fmt in formats: + try: + return datetime.strptime(value, fmt).time() + except ValueError: + continue + return datetime.min.time() + + elif data_type == 'dezimalzahl': + try: + value = value.replace(',', '.') + return float(value) + except ValueError: + return 0.0 + + else: + return value.lower() + + return sorted(data, key=get_sort_key) + + def configure_csv_export(self) -> Tuple[str, str]: + """CSV-Export-Einstellungen konfigurieren""" + print("\n" + "="*70) + print(" CSV-EXPORT KONFIGURATION") + print("="*70) + + print("\nDelimiter wählen:") + print(" 1. Semikolon (;) - Standard für deutsche Excel-Versionen") + print(" 2. Komma (,) - Internationaler Standard") + print(" 3. Tab - Gut für Import in andere Programme") + + while True: + delim_choice = input("Ihre Wahl [1-3]: ").strip() + delim_map = {'1': ';', '2': ',', '3': '\t'} + if delim_choice in delim_map: + delimiter = delim_map[delim_choice] + break + print("Ungültige Eingabe.") + + print("\nQuoting (Anführungszeichen) wählen:") + print(" 1. MINIMAL - Nur Felder mit Sonderzeichen (empfohlen)") + print(" 2. ALLE - Alle Felder in Anführungszeichen") + print(" 3. NICHT_NUMERISCH - Nur Text-Felder") + print(" 4. KEINE - Keine Anführungszeichen (kann Probleme verursachen)") + + while True: + quot_choice = input("Ihre Wahl [1-4]: ").strip() + quot_map = {'1': 'MINIMAL', '2': 'ALLE', '3': 'NICHT_NUMERISCH', '4': 'KEINE'} + if quot_choice in quot_map: + quoting = quot_map[quot_choice] + break + print("Ungültige Eingabe.") + + delimiter_names = {';': 'Semikolon', ',': 'Komma', '\t': 'Tab'} + print("\n" + "-"*70) + print(f"Gewählte Einstellungen:") + print(f" Delimiter: {delimiter_names.get(delimiter, delimiter)}") + print(f" Quoting: {quoting}") + print("-"*70) + + return delimiter, quoting + + def write_csv(self, filepath: Path, headers: List[str], data: List[Dict], + header_text: str = "", footer_text: str = "", + delimiter: str = ';', quoting: str = 'MINIMAL'): + """CSV-Datei schreiben""" + quoting_map = { + 'ALLE': csv.QUOTE_ALL, + 'MINIMAL': csv.QUOTE_MINIMAL, + 'NICHT_NUMERISCH': csv.QUOTE_NONNUMERIC, + 'KEINE': csv.QUOTE_NONE + } + + csv_quoting = quoting_map.get(quoting, csv.QUOTE_MINIMAL) + + with open(filepath, 'w', encoding='utf-8', newline='') as f: + if header_text: + f.write(f"# {header_text}\n") + + writer = csv.DictWriter(f, fieldnames=headers, delimiter=delimiter, + quoting=csv_quoting, quotechar='"') + writer.writeheader() + writer.writerows(data) + + if footer_text: + f.write(f"# {footer_text}\n") + + print(f"✓ CSV-Datei erfolgreich gespeichert: {filepath}") + + def write_excel(self, filepath: Path, headers: List[str], data: List[Dict], + header_text: str = "", footer_text: str = ""): + """Excel-Datei schreiben""" + if not EXCEL_SUPPORT: + print("Fehler: openpyxl ist nicht installiert. Installieren Sie es mit: pip install openpyxl") + return + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Daten" + + row_num = 1 + + if header_text: + ws.cell(row=row_num, column=1, value=f"# {header_text}") + ws.cell(row=row_num, column=1).font = Font(italic=True) + row_num += 1 + + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=row_num, column=col_num, value=header) + cell.font = Font(bold=True) + cell.fill = PatternFill(start_color="CCCCCC", end_color="CCCCCC", fill_type="solid") + row_num += 1 + + for row_data in data: + for col_num, header in enumerate(headers, 1): + ws.cell(row=row_num, column=col_num, value=row_data.get(header, '')) + row_num += 1 + + if footer_text: + ws.cell(row=row_num, column=1, value=f"# {footer_text}") + ws.cell(row=row_num, column=1).font = Font(italic=True) + + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(cell.value) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + wb.save(filepath) + print(f"✓ Excel-Datei erfolgreich gespeichert: {filepath}") + + def write_ods(self, filepath: Path, headers: List[str], data: List[Dict], + header_text: str = "", footer_text: str = ""): + """OpenDocument Spreadsheet (ODS) schreiben""" + if not ODT_SUPPORT: + print("Fehler: odfpy ist nicht installiert. Installieren Sie es mit: pip install odfpy") + return + + doc = OpenDocumentSpreadsheet() + table = Table(name="Daten") + + if header_text: + row = TableRow() + cell = TableCell() + cell.addElement(P(text=f"# {header_text}")) + row.addElement(cell) + table.addElement(row) + + row = TableRow() + for header in headers: + cell = TableCell() + cell.addElement(P(text=header)) + row.addElement(cell) + table.addElement(row) + + for row_data in data: + row = TableRow() + for header in headers: + cell = TableCell() + cell.addElement(P(text=str(row_data.get(header, '')))) + row.addElement(cell) + table.addElement(row) + + if footer_text: + row = TableRow() + cell = TableCell() + cell.addElement(P(text=f"# {footer_text}")) + row.addElement(cell) + table.addElement(row) + + doc.spreadsheet.addElement(table) + doc.save(filepath) + print(f"✓ ODS-Datei erfolgreich gespeichert: {filepath}") + + def select_output_format(self) -> str: + """Ausgabeformat auswählen""" + formats = ["csv"] + print("\nVerfügbare Ausgabeformate:") + print(" 1. CSV") + + format_num = 2 + if EXCEL_SUPPORT: + formats.append("xlsx") + print(f" {format_num}. Excel (XLSX)") + format_num += 1 + + if ODT_SUPPORT: + formats.append("ods") + print(f" {format_num}. OpenDocument (ODS)") + + while True: + choice = input("Ihre Wahl: ").strip() + try: + idx = int(choice) - 1 + if 0 <= idx < len(formats): + return formats[idx] + except ValueError: + if choice.lower() in formats: + return choice.lower() + print("Ungültige Eingabe.") + + def manage_presets(self): + """Voreinstellungen verwalten""" + while True: + self.clear_screen() + print("╔═══════════════════════════════════════════════════════╗") + print("║ VOREINSTELLUNGEN VERWALTEN ║") + print("╚═══════════════════════════════════════════════════════╝") + + presets = self.get_available_presets() + + if presets: + print("\nVerfügbare Voreinstellungen:") + for i, preset in enumerate(presets, 1): + print(f" {i}. {preset}") + else: + print("\n⚠ Keine Voreinstellungen vorhanden.") + + print("\nOptionen:") + print(" 1. Voreinstellung bearbeiten") + print(" 2. Voreinstellung löschen") + print(" 0. Zurück zum Hauptmenü") + + choice = input("\nIhre Wahl: ").strip() + + if choice == '0': + return + + elif choice == '1' and presets: + while True: + preset_choice = input("\nNummer oder Name der Voreinstellung: ").strip() + + try: + idx = int(preset_choice) - 1 + if 0 <= idx < len(presets): + preset_name = presets[idx] + else: + print("Ungültige Nummer.") + continue + except ValueError: + preset_name = preset_choice + + if preset_name in presets: + self.edit_preset(preset_name) + break + else: + print(f"Voreinstellung '{preset_name}' nicht gefunden.") + + elif choice == '2' and presets: + while True: + preset_choice = input("\nNummer oder Name der zu löschenden Voreinstellung: ").strip() + + try: + idx = int(preset_choice) - 1 + if 0 <= idx < len(presets): + preset_name = presets[idx] + else: + print("Ungültige Nummer.") + continue + except ValueError: + preset_name = preset_choice + + if preset_name in presets: + if self.ask_yes_no(f"Voreinstellung '{preset_name}' wirklich löschen?"): + self.delete_preset(preset_name) + input("\nDrücken Sie Enter um fortzufahren...") + break + else: + print(f"Voreinstellung '{preset_name}' nicht gefunden.") + + def edit_preset(self, preset_name: str): + """Voreinstellung bearbeiten""" + config = self.load_preset(preset_name) + + print(f"\n✓ Voreinstellung '{preset_name}' geladen.") + print("\nSie können nun die Einstellungen Schritt für Schritt durchgehen und ändern.") + print("Die aktuellen Werte werden als Vorschläge angezeigt.") + input("\nDrücken Sie Enter um zu beginnen...") + + config = self.configure_settings(config, edit_mode=True) + + self.clear_screen() + print("╔═══════════════════════════════════════════════════════╗") + print("║ VOREINSTELLUNG SPEICHERN ║") + print("╚═══════════════════════════════════════════════════════╝") + + print("\nMöchten Sie die Änderungen speichern?") + print(f" 1. Unter gleichem Namen überschreiben ('{preset_name}')") + print(f" 2. Als neue Voreinstellung speichern") + print(f" 3. Änderungen verwerfen") + + while True: + save_choice = input("\nIhre Wahl [1-3]: ").strip() + + if save_choice == '1': + self.save_preset(preset_name, config) + input("\nDrücken Sie Enter um fortzufahren...") + return + + elif save_choice == '2': + new_name = input("\nName für die neue Voreinstellung: ").strip() + if new_name: + self.save_preset(new_name, config) + input("\nDrücken Sie Enter um fortzufahren...") + return + + elif save_choice == '3': + print("\nÄnderungen verworfen.") + input("\nDrücken Sie Enter um fortzufahren...") + return + + def configure_settings(self, config: Dict = None, edit_mode: bool = False) -> Dict: + """Einstellungen konfigurieren (für Bearbeitung oder neue Voreinstellung)""" + if config is None: + config = {} + + self.clear_screen() + print("╔═══════════════════════════════════════════════════════╗") + print("║ EINSTELLUNGEN KONFIGURIEREN ║") + print("╚═══════════════════════════════════════════════════════╝") + + # 1. Kopfzeile vorhanden? + print("\n" + "="*60) + print(" 1. Kopfzeile") + print("="*60) + current = config.get('has_header') + if current is not None: + print(f"Aktuell: {'Ja' if current else 'Nein'}") + config['has_header'] = self.ask_yes_no("Hat die Datei normalerweise eine Kopfzeile?", current) + + # 2. Mapping-Datei + print("\n" + "="*60) + print(" 2. Spaltennamen-Mapping") + print("="*60) + current_mapping = config.get('mapping_file') + if current_mapping: + print(f"Aktuell: {current_mapping}") + + if self.ask_yes_no("Möchten Sie eine Mapping-Datei verwenden?", current_mapping is not None): + mapping_file = self.select_file("Pfad zur Mapping-JSON-Datei") + if mapping_file: + config['mapping_file'] = str(mapping_file) + else: + config['mapping_file'] = None + + # 3. Leere Zeilen entfernen + print("\n" + "="*60) + print(" 3. Leere Zeilen") + print("="*60) + current = config.get('remove_empty_rows') + if current is not None: + print(f"Aktuell: {'Ja' if current else 'Nein'}") + config['remove_empty_rows'] = self.ask_yes_no("Leere Zeilen entfernen?", current) + + # 4. Unvollständige Zeilen filtern + print("\n" + "="*60) + print(" 4. Unvollständige Zeilen") + print("="*60) + current = config.get('filter_incomplete_rows') + if current is not None: + print(f"Aktuell: {'Ja' if current else 'Nein'}") + config['filter_incomplete_rows'] = self.ask_yes_no("Zeilen mit zu wenig Daten analysieren?", current) + + # 5. Leere Spalten entfernen + print("\n" + "="*60) + print(" 5. Leere Spalten") + print("="*60) + current = config.get('remove_empty') + if current is not None: + print(f"Aktuell: {'Ja' if current else 'Nein'}") + config['remove_empty'] = self.ask_yes_no("Leere Spalten entfernen?", current) + + # 6. Spaltenauswahl + print("\n" + "="*60) + print(" 6. Spaltenauswahl") + print("="*60) + + if 'selected_columns' in config and config['selected_columns']: + print(f"Aktuell gespeicherte Spalten: {len(config['selected_columns'])}") + print("\nGespeicherte Spalten:") + for i, col in enumerate(config['selected_columns'], 1): + print(f" {i}. {col}") + + if self.ask_yes_no("\nMöchten Sie die Spaltenauswahl ändern?"): + print("\nBitte laden Sie eine Beispieldatei, um Spalten auszuwählen:") + example_file = self.select_file("Pfad zur Beispieldatei (CSV/Excel)") + + if example_file: + try: + file_ext = example_file.suffix.lower() + if file_ext in ['.xlsx', '.xls']: + ex_headers, ex_data, ex_has_header, _ = self.read_excel(example_file) + else: + ex_headers, ex_data, ex_has_header, _ = self.read_csv(example_file) + + if config.get('mapping_file'): + mapping_file = Path(config['mapping_file']) + if mapping_file.exists(): + mappings = self.load_column_mappings(mapping_file) + ex_headers, ex_data, ex_original_names = self.apply_column_mappings(ex_headers, ex_data, mappings) + else: + ex_original_names = {h: h for h in ex_headers} + else: + ex_original_names = {h: h for h in ex_headers} + + config['selected_columns'] = self.select_columns(ex_headers, ex_original_names, config['selected_columns']) + + except Exception as e: + print(f"Fehler beim Laden der Beispieldatei: {e}") + else: + print("Keine Spalten gespeichert.") + if self.ask_yes_no("Möchten Sie Spalten auswählen?"): + print("\nBitte laden Sie eine Beispieldatei, um Spalten auszuwählen:") + example_file = self.select_file("Pfad zur Beispieldatei (CSV/Excel)") + + if example_file: + try: + file_ext = example_file.suffix.lower() + if file_ext in ['.xlsx', '.xls']: + ex_headers, ex_data, ex_has_header, _ = self.read_excel(example_file) + else: + ex_headers, ex_data, ex_has_header, _ = self.read_csv(example_file) + + if config.get('mapping_file'): + mapping_file = Path(config['mapping_file']) + if mapping_file.exists(): + mappings = self.load_column_mappings(mapping_file) + ex_headers, ex_data, ex_original_names = self.apply_column_mappings(ex_headers, ex_data, mappings) + else: + ex_original_names = {h: h for h in ex_headers} + else: + ex_original_names = {h: h for h in ex_headers} + + config['selected_columns'] = self.select_columns(ex_headers, ex_original_names, None) + + except Exception as e: + print(f"Fehler beim Laden der Beispieldatei: {e}") + + # 7. Kopf- und Fußzeilen + print("\n" + "="*60) + print(" 7. Kopf- und Fußzeilen") + print("="*60) + current = config.get('add_header_footer') + if current is not None: + print(f"Aktuell: {'Ja' if current else 'Nein'}") + if current: + print(f" Kopfzeile: {config.get('header_text', '')}") + print(f" Fußzeile: {config.get('footer_text', '')}") + + if self.ask_yes_no("Kopf-/Fußzeile hinzufügen?", current): + header_text = input("Kopfzeile (Enter für keine): ").strip() + footer_text = input("Fußzeile (Enter für keine): ").strip() + config['add_header_footer'] = True + config['header_text'] = header_text + config['footer_text'] = footer_text + else: + config['add_header_footer'] = False + + # 8. Sortierung + print("\n" + "="*60) + print(" 8. Sortierung") + print("="*60) + current_sort = config.get('sort_column') + if current_sort: + print(f"Aktuell: Nach '{current_sort}' ({config.get('sort_type', 'string')})") + + if self.ask_yes_no("Daten sortieren?", current_sort is not None): + sort_column = input("Spaltenname für Sortierung: ").strip() + if sort_column: + print("\nDatentyp für Sortierung:") + print(" 1. Text (string)") + print(" 2. Datum") + print(" 3. Zeit") + print(" 4. Dezimalzahl") + + type_map = {'1': 'string', '2': 'datum', '3': 'zeit', '4': 'dezimalzahl'} + data_type = type_map.get(input("Ihre Wahl [1-4]: ").strip(), 'string') + + config['sort_column'] = sort_column + config['sort_type'] = data_type + else: + config['sort_column'] = None + + # 9. Ausgabeformat + print("\n" + "="*60) + print(" 9. Ausgabeformat") + print("="*60) + current_format = config.get('output_format', 'csv') + print(f"Aktuell: {current_format.upper()}") + + formats = ["csv"] + print("\nVerfügbare Ausgabeformate:") + print(" 1. CSV") + + format_num = 2 + if EXCEL_SUPPORT: + formats.append("xlsx") + print(f" {format_num}. Excel (XLSX)") + format_num += 1 + + if ODT_SUPPORT: + formats.append("ods") + print(f" {format_num}. OpenDocument (ODS)") + + while True: + choice = input("Ihre Wahl: ").strip() + try: + idx = int(choice) - 1 + if 0 <= idx < len(formats): + config['output_format'] = formats[idx] + break + except ValueError: + if choice.lower() in formats: + config['output_format'] = choice.lower() + break + print("Ungültige Eingabe.") + + # 10. CSV-Export-Einstellungen (nur wenn CSV gewählt) + if config['output_format'] == 'csv': + print("\n" + "="*60) + print(" 10. CSV-Export-Einstellungen") + print("="*60) + + current_delim = config.get('export_delimiter') + current_quot = config.get('export_quoting') + + if current_delim and current_quot: + delimiter_names = {';': 'Semikolon', ',': 'Komma', '\t': 'Tab'} + print(f"Aktuell: Delimiter={delimiter_names.get(current_delim, current_delim)}, Quoting={current_quot}") + + if self.ask_yes_no("CSV-Export-Einstellungen konfigurieren?", True): + export_delimiter, export_quoting = self.configure_csv_export() + config['export_delimiter'] = export_delimiter + config['export_quoting'] = export_quoting + + return config + + def main_menu(self): + """Hauptmenü anzeigen""" + while True: + self.clear_screen() + print("╔═══════════════════════════════════════════════════════╗") + print("║ CSV-PROCESSOR v2.0 - Dateiverarbeitung ║") + print("╚═══════════════════════════════════════════════════════╝") + + print("\nVerfügbare Features:") + print(f" Import: CSV{', Excel (XLSX/XLS)' if EXCEL_SUPPORT else ''}") + print(f" Export: CSV{', Excel (XLSX)' if EXCEL_SUPPORT else ''}{', OpenDocument (ODS)' if ODT_SUPPORT else ''}") + + if not EXCEL_SUPPORT: + print(f" ⚠ Excel-Support: pip install openpyxl") + if not ODT_SUPPORT: + print(f" ⚠ ODS-Support: pip install odfpy") + + print("\n" + "="*60) + print(" HAUPTMENÜ") + print("="*60) + print(" 1. Neue Datei verarbeiten") + print(" 2. Voreinstellungen verwalten") + print(" 0. Beenden") + + choice = input("\nIhre Wahl: ").strip() + + if choice == '1': + self.run() + elif choice == '2': + self.manage_presets() + elif choice == '0': + print("\nAuf Wiedersehen!") + sys.exit(0) + else: + print("\nUngültige Eingabe.") + input("Drücken Sie Enter um fortzufahren...") + + def run(self): + """Hauptprogramm ausführen""" + self.clear_screen() + print("╔═══════════════════════════════════════════════════════╗") + print("║ DATEI VERARBEITEN ║") + print("╚═══════════════════════════════════════════════════════╝") + + config = {} + use_preset = False + preset_loaded_file = False + + # 1. Voreinstellungen abfragen + presets = self.get_available_presets() + if presets: + self.print_header("Schritt 1: Voreinstellungen") + print("Verfügbare Voreinstellungen:") + for i, preset in enumerate(presets, 1): + print(f" {i}. {preset}") + + if self.ask_yes_no("\nMöchten Sie eine Voreinstellung laden?"): + while True: + choice = input("Nummer oder Name der Voreinstellung: ").strip() + + try: + idx = int(choice) - 1 + if 0 <= idx < len(presets): + preset_name = presets[idx] + else: + print("Ungültige Nummer.") + continue + except ValueError: + preset_name = choice + + if preset_name in presets: + config = self.load_preset(preset_name) + use_preset = True + print(f"✓ Voreinstellung '{preset_name}' geladen.") + + # Zeige was geladen wurde + if 'source_file' in config and config['source_file']: + print(f" → Quelldatei vorbelegt: {config['source_file']}") + if 'mapping_file' in config and config['mapping_file']: + print(f" → Mapping vorbelegt: {config['mapping_file']}") + if 'selected_columns' in config and config['selected_columns']: + print(f" → Spaltenauswahl: {len(config['selected_columns'])} Spalten") + + print("\nℹ Leere Spalten werden automatisch übersprungen (vor Spaltenauswahl)") + + break + else: + print(f"Voreinstellung '{preset_name}' nicht gefunden.") + + # 2. Quell-Datei + self.print_header("Schritt 2: Quelldatei auswählen") + + # Wenn Voreinstellung Quelldatei enthält, diese verwenden + if use_preset and 'source_file' in config and config['source_file']: + source_path = Path(config['source_file']) + if source_path.exists(): + print(f"Verwende Quelldatei aus Voreinstellung: {source_path}") + if self.ask_yes_no("Diese Datei verwenden?", True): + source_file = source_path + preset_loaded_file = True + else: + source_file = None + else: + print(f"⚠ Gespeicherte Quelldatei nicht gefunden: {config['source_file']}") + source_file = None + else: + source_file = None + + # Wenn keine Datei aus Voreinstellung oder Benutzer will andere Datei + if not source_file: + while not source_file: + source_file = self.select_file("Pfad zur Quelldatei (CSV/XLSX/XLS/ODS)") + if not source_file: + print("Eine Quelldatei ist erforderlich!") + + # Datei lesen + file_ext = source_file.suffix.lower() + has_header = config.get('has_header', None) if use_preset else None + + try: + if file_ext in ['.xlsx', '.xls']: + headers, data, has_header, import_settings = self.read_excel(source_file) + else: + import_encoding = config.get('import_encoding') if use_preset else None + import_delimiter = config.get('import_delimiter') if use_preset else None + import_quoting = config.get('import_quoting') if use_preset else None + + headers, data, has_header, import_settings = self.read_csv( + source_file, has_header, import_encoding, import_delimiter, import_quoting + ) + + config['import_encoding'] = import_settings['encoding'] + config['import_delimiter'] = import_settings['delimiter'] + config['import_quoting'] = import_settings['quoting'] + + if headers is None: + print("Fehler beim Lesen der Datei.") + return + + config['has_header'] = has_header + print(f"\n✓ Datei geladen: {len(headers)} Spalten, {len(data)} Zeilen") + except Exception as e: + print(f"Fehler beim Lesen der Datei: {e}") + import traceback + traceback.print_exc() + return + + # 3. Spaltennamen-Mapping + self.print_header("Schritt 3: Spaltennamen umbenennen") + original_names = {} + + if use_preset and 'mapping_file' in config: + mapping_file = Path(config['mapping_file']) if config['mapping_file'] else None + if mapping_file and mapping_file.exists(): + print(f"Verwende Mapping-Datei aus Voreinstellung: {mapping_file}") + mappings = self.load_column_mappings(mapping_file) + else: + if mapping_file: + print(f"⚠ Mapping-Datei {mapping_file} nicht gefunden.") + mappings = {} + else: + if self.ask_yes_no("Möchten Sie Spaltennamen umbenennen?"): + mapping_file = self.select_file("Pfad zur Mapping-JSON-Datei") + mappings = self.load_column_mappings(mapping_file) + if mapping_file: + config['mapping_file'] = str(mapping_file) + else: + mappings = {} + + if mappings: + headers, data, original_names = self.apply_column_mappings(headers, data, mappings) + print(f"✓ {len(mappings)} Spaltennamen umbenannt") + else: + original_names = {h: h for h in headers} + + # 4. Leere Zeilen entfernen + self.print_header("Schritt 4: Leere Zeilen") + if use_preset and 'remove_empty_rows' in config: + remove_empty_rows = config['remove_empty_rows'] + print(f"Verwende Voreinstellung: {'Ja' if remove_empty_rows else 'Nein'}") + else: + remove_empty_rows = self.ask_yes_no("Sollen komplett leere Zeilen entfernt werden?") + config['remove_empty_rows'] = remove_empty_rows + + if remove_empty_rows: + original_count = len(data) + data = self.remove_empty_rows(data) + removed_count = original_count - len(data) + print(f"✓ {removed_count} leere Zeilen entfernt") + + # 5. Zeilen mit zu wenig Daten filtern + self.print_header("Schritt 5: Unvollständige Zeilen") + if use_preset and 'filter_incomplete_rows' in config: + filter_incomplete = config['filter_incomplete_rows'] + print(f"Verwende Voreinstellung: {'Ja' if filter_incomplete else 'Nein'}") + else: + filter_incomplete = self.ask_yes_no("Möchten Sie Zeilen mit zu wenig Informationen analysieren/entfernen?") + config['filter_incomplete_rows'] = filter_incomplete + + if filter_incomplete: + data = self.filter_rows_by_filled_fields(headers, data) + + # 6. Leere Spalten entfernen (VOR Spaltenauswahl!) + self.print_header("Schritt 6: Leere Spalten") + if use_preset and 'remove_empty' in config: + remove_empty = config['remove_empty'] + print(f"Verwende Voreinstellung: {'Ja' if remove_empty else 'Nein'}") + else: + remove_empty = self.ask_yes_no("Sollen leere Spalten entfernt werden?") + config['remove_empty'] = remove_empty + + if remove_empty: + original_count = len(headers) + headers, data = self.remove_empty_columns(headers, data) + removed_count = original_count - len(headers) + print(f"✓ {removed_count} leere Spalten entfernt") + + # WICHTIG: original_names aktualisieren + original_names = {h: original_names[h] for h in headers if h in original_names} + + # 7. Spaltenauswahl + self.print_header("Schritt 7: Spaltenauswahl") + + # Bei Voreinstellung: Nur vorhandene (nicht-leere) Spalten verwenden + if use_preset and 'selected_columns' in config: + preselected = [col for col in config['selected_columns'] if col in headers] + + if preselected: + selected_columns = preselected + print(f"Verwende gespeicherte Spaltenauswahl:") + print(f"(Nur nicht-leere Spalten werden berücksichtigt)") + for i, col in enumerate(selected_columns, 1): + orig = original_names.get(col, col) + if orig != col: + print(f" {i}. {orig} → {col}") + else: + print(f" {i}. {col}") + print(f"\n✓ {len(selected_columns)} Spalten ausgewählt") + + # Zeige übersprungene Spalten + skipped = [col for col in config['selected_columns'] if col not in headers] + if skipped: + print(f"\nℹ Übersprungene Spalten (leer oder nicht vorhanden): {len(skipped)}") + for col in skipped[:5]: + print(f" - {col}") + if len(skipped) > 5: + print(f" ... und {len(skipped)-5} weitere") + else: + print("⚠ Alle gespeicherten Spalten sind leer oder nicht vorhanden.") + print("Bitte neue Auswahl treffen:") + selected_columns = self.select_columns(headers, original_names, None) + config['selected_columns'] = selected_columns + else: + selected_columns = self.select_columns(headers, original_names, None) + config['selected_columns'] = selected_columns + + # Daten filtern + filtered_data = [] + for row in data: + filtered_row = {col: row.get(col, '') for col in selected_columns} + filtered_data.append(filtered_row) + data = filtered_data + + # 8. Kopf- und Fußzeilen + self.print_header("Schritt 8: Kopf- und Fußzeilen") + header_text = "" + footer_text = "" + + if use_preset and 'add_header_footer' in config: + add_header_footer = config['add_header_footer'] + if add_header_footer: + header_text = config.get('header_text', '') + footer_text = config.get('footer_text', '') + print(f"Verwende Kopfzeile: {header_text}") + print(f"Verwende Fußzeile: {footer_text}") + else: + if self.ask_yes_no("Möchten Sie eine Kopf- oder Fußzeile hinzufügen?"): + header_text = input("Kopfzeile (Enter für keine): ").strip() + footer_text = input("Fußzeile (Enter für keine): ").strip() + config['add_header_footer'] = True + config['header_text'] = header_text + config['footer_text'] = footer_text + else: + config['add_header_footer'] = False + + # 9. Sortierung + self.print_header("Schritt 9: Sortierung") + if use_preset and 'sort_column' in config: + sort_column = config['sort_column'] + if sort_column and sort_column in selected_columns: + data_type = config.get('sort_type', 'string') + print(f"Sortiere nach '{sort_column}' ({data_type})") + data = self.sort_data(data, sort_column, data_type) + else: + if self.ask_yes_no("Möchten Sie die Daten sortieren?"): + print("\nVerfügbare Spalten zum Sortieren:") + for i, col in enumerate(selected_columns, 1): + print(f" {i}. {col}") + + while True: + choice = input("Nummer der Spalte: ").strip() + try: + idx = int(choice) - 1 + if 0 <= idx < len(selected_columns): + sort_column = selected_columns[idx] + break + except ValueError: + pass + print("Ungültige Eingabe.") + + print("\nDatentyp für Sortierung:") + print(" 1. Text (string)") + print(" 2. Datum") + print(" 3. Zeit") + print(" 4. Dezimalzahl") + + type_map = {'1': 'string', '2': 'datum', '3': 'zeit', '4': 'dezimalzahl'} + data_type = type_map.get(input("Ihre Wahl [1-4]: ").strip(), 'string') + + data = self.sort_data(data, sort_column, data_type) + config['sort_column'] = sort_column + config['sort_type'] = data_type + print(f"✓ Daten nach '{sort_column}' sortiert") + else: + config['sort_column'] = None + + # 10. Ausgabeformat und Zieldatei + self.print_header("Schritt 10: Zieldatei speichern") + + if use_preset and 'output_format' in config: + output_format = config['output_format'] + print(f"Ausgabeformat aus Voreinstellung: {output_format.upper()}") + else: + output_format = self.select_output_format() + config['output_format'] = output_format + + while True: + target_path = input(f"Name/Pfad der Zieldatei (ohne Endung): ").strip() + if target_path: + target_file = Path(target_path).with_suffix(f'.{output_format}') + + if target_file.exists(): + if self.ask_yes_no(f"Datei {target_file} existiert bereits. Überschreiben?"): + break + else: + break + + # Datei schreiben + if output_format == 'csv': + if use_preset and 'export_delimiter' in config and 'export_quoting' in config: + export_delimiter = config['export_delimiter'] + export_quoting = config['export_quoting'] + delimiter_names = {';': 'Semikolon', ',': 'Komma', '\t': 'Tab'} + print(f"\nVerwende Export-Einstellungen aus Voreinstellung:") + print(f" Delimiter: {delimiter_names.get(export_delimiter, export_delimiter)}") + print(f" Quoting: {export_quoting}") + else: + export_delimiter, export_quoting = self.configure_csv_export() + config['export_delimiter'] = export_delimiter + config['export_quoting'] = export_quoting + + self.write_csv(target_file, selected_columns, data, header_text, footer_text, + export_delimiter, export_quoting) + elif output_format == 'xlsx': + self.write_excel(target_file, selected_columns, data, header_text, footer_text) + elif output_format == 'ods': + self.write_ods(target_file, selected_columns, data, header_text, footer_text) + + print("\n" + "="*60) + print(" Verarbeitung abgeschlossen!") + print("="*60) + + # Voreinstellung speichern + if not use_preset: + if self.ask_yes_no("\nMöchten Sie diese Einstellungen als Voreinstellung speichern?"): + # Fragen ob Quelldatei mitgespeichert werden soll + save_source = False + if self.ask_yes_no("Pfad zur Quelldatei mit speichern? (Datei wird dann automatisch geladen)", False): + config['source_file'] = str(source_file) + save_source = True + + preset_name = input("Name für die Voreinstellung: ").strip() + if preset_name: + self.save_preset(preset_name, config) + print(f"\nGespeichert:") + print(f" - Einstellungen: Ja") + print(f" - Mapping: {'Ja' if config.get('mapping_file') else 'Nein'}") + print(f" - Quelldatei: {'Ja' if save_source else 'Nein'}") + print(f" - Spaltenauswahl: {len(config['selected_columns'])} Spalten") + + input("\nDrücken Sie Enter um zum Hauptmenü zurückzukehren...") + + +def main(): + """Hauptfunktion""" + processor = CSVProcessor() + + try: + processor.main_menu() + except KeyboardInterrupt: + print("\n\nProgramm wurde durch Benutzer beendet.") + sys.exit(0) + except Exception as e: + print(f"\nFehler: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/csv_processor_gui.py b/csv_processor_gui.py new file mode 100644 index 0000000..ffd4d8c --- /dev/null +++ b/csv_processor_gui.py @@ -0,0 +1,829 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +CSV-Processor GUI v2.0: Grafische Oberfläche für CSV/Excel-Verarbeitung +""" + +import tkinter as tk +from tkinter import ttk, filedialog, messagebox, scrolledtext, simpledialog +import csv +import json +import os +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +# Optional: Excel Support +try: + import openpyxl + from openpyxl.styles import Font, PatternFill + EXCEL_SUPPORT = True +except ImportError: + EXCEL_SUPPORT = False + +try: + from odf.opendocument import OpenDocumentSpreadsheet + from odf.table import Table, TableRow, TableCell + from odf.text import P + ODT_SUPPORT = True +except ImportError: + ODT_SUPPORT = False + + +class CSVProcessorGUI: + def __init__(self, root): + self.root = root + self.root.title("CSV-Processor v2.0") + self.root.geometry("900x700") + + self.config_dir = Path("csv_processor_config") + self.config_dir.mkdir(exist_ok=True) + + self.current_config = {} # Für temporäre Voreinstellungs-Daten + self.headers = [] + self.data = [] + self.original_names = {} + self.selected_columns = set() + + self.setup_ui() + + def setup_ui(self): + """Hauptoberfläche erstellen""" + # Menüleiste + menubar = tk.Menu(self.root) + self.root.config(menu=menubar) + + # Datei-Menü + file_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="Datei", menu=file_menu) + file_menu.add_command(label="Datei öffnen", command=self.load_file) + file_menu.add_command(label="Verarbeiten & Speichern", command=self.process_and_save) + file_menu.add_separator() + file_menu.add_command(label="Beenden", command=self.root.quit) + + # Voreinstellungen-Menü + preset_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="Voreinstellungen", menu=preset_menu) + preset_menu.add_command(label="Laden", command=self.load_preset) + preset_menu.add_command(label="Speichern", command=self.save_preset) + preset_menu.add_command(label="Verwalten", command=self.manage_presets) + + # Hilfe-Menü + help_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="Hilfe", menu=help_menu) + help_menu.add_command(label="Über", command=self.show_about) + + # Hauptcontainer + main_frame = ttk.Frame(self.root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(0, weight=1) + main_frame.columnconfigure(0, weight=1) + main_frame.rowconfigure(4, weight=1) + + # Datei-Auswahl + file_frame = ttk.LabelFrame(main_frame, text="Quelldatei", padding="10") + file_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=5) + file_frame.columnconfigure(1, weight=1) + + ttk.Label(file_frame, text="Datei:").grid(row=0, column=0, sticky=tk.W) + self.file_entry = ttk.Entry(file_frame, width=50) + self.file_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5) + ttk.Button(file_frame, text="Durchsuchen", command=self.browse_file).grid(row=0, column=2) + ttk.Button(file_frame, text="Laden", command=self.load_file).grid(row=0, column=3, padx=5) + + # Einstellungen + settings_frame = ttk.LabelFrame(main_frame, text="Verarbeitungseinstellungen", padding="10") + settings_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=5) + settings_frame.columnconfigure(1, weight=1) + + row = 0 + self.has_header_var = tk.BooleanVar(value=True) + ttk.Checkbutton(settings_frame, text="Datei hat Kopfzeile", + variable=self.has_header_var).grid(row=row, column=0, sticky=tk.W, columnspan=2) + + row += 1 + self.remove_empty_rows_var = tk.BooleanVar(value=True) + ttk.Checkbutton(settings_frame, text="Leere Zeilen entfernen", + variable=self.remove_empty_rows_var).grid(row=row, column=0, sticky=tk.W, columnspan=2) + + row += 1 + self.remove_empty_cols_var = tk.BooleanVar(value=True) + ttk.Checkbutton(settings_frame, text="Leere Spalten entfernen (automatisch vor Spaltenauswahl)", + variable=self.remove_empty_cols_var).grid(row=row, column=0, sticky=tk.W, columnspan=3) + + # Info-Label + info_label = ttk.Label(settings_frame, + text="ℹ Leere Spalten werden vor der Spaltenauswahl entfernt", + foreground="blue", font=('TkDefaultFont', 8)) + info_label.grid(row=row+1, column=0, columnspan=3, sticky=tk.W, pady=(0,5)) + + row += 2 + ttk.Label(settings_frame, text="Mapping-Datei:").grid(row=row, column=0, sticky=tk.W) + self.mapping_entry = ttk.Entry(settings_frame) + self.mapping_entry.grid(row=row, column=1, sticky=(tk.W, tk.E), padx=5) + ttk.Button(settings_frame, text="...", command=self.browse_mapping, width=3).grid(row=row, column=2) + + row += 1 + ttk.Label(settings_frame, text="Ausgabeformat:").grid(row=row, column=0, sticky=tk.W) + self.format_var = tk.StringVar(value="csv") + format_frame = ttk.Frame(settings_frame) + format_frame.grid(row=row, column=1, sticky=tk.W, padx=5) + ttk.Radiobutton(format_frame, text="CSV", variable=self.format_var, value="csv").pack(side=tk.LEFT) + if EXCEL_SUPPORT: + ttk.Radiobutton(format_frame, text="Excel", variable=self.format_var, value="xlsx").pack(side=tk.LEFT, padx=10) + if ODT_SUPPORT: + ttk.Radiobutton(format_frame, text="ODS", variable=self.format_var, value="ods").pack(side=tk.LEFT) + + # Spaltenauswahl + columns_frame = ttk.LabelFrame(main_frame, text="Spaltenauswahl", padding="10") + columns_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=5) + + button_frame = ttk.Frame(columns_frame) + button_frame.pack(fill=tk.X) + ttk.Button(button_frame, text="Alle auswählen", command=self.select_all_columns).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Alle abwählen", command=self.deselect_all_columns).pack(side=tk.LEFT) + ttk.Button(button_frame, text="Auswahl umkehren", command=self.invert_selection).pack(side=tk.LEFT, padx=5) + + # Spalten-Liste mit Checkboxen + list_frame = ttk.Frame(columns_frame) + list_frame.pack(fill=tk.BOTH, expand=True, pady=5) + + scrollbar = ttk.Scrollbar(list_frame) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + self.columns_listbox = tk.Listbox(list_frame, selectmode=tk.MULTIPLE, + yscrollcommand=scrollbar.set, height=10) + self.columns_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.config(command=self.columns_listbox.yview) + + # Vorschau + preview_frame = ttk.LabelFrame(main_frame, text="Datenvorschau", padding="10") + preview_frame.grid(row=3, column=0, sticky=(tk.W, tk.E), pady=5) + + self.info_label = ttk.Label(preview_frame, text="Keine Datei geladen") + self.info_label.pack() + + # Status und Log + log_frame = ttk.LabelFrame(main_frame, text="Status", padding="10") + log_frame.grid(row=4, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5) + + self.log_text = scrolledtext.ScrolledText(log_frame, height=10, state='disabled') + self.log_text.pack(fill=tk.BOTH, expand=True) + + # Aktionsbuttons + action_frame = ttk.Frame(main_frame) + action_frame.grid(row=5, column=0, sticky=(tk.W, tk.E), pady=10) + + ttk.Button(action_frame, text="Verarbeiten & Speichern", + command=self.process_and_save).pack(side=tk.RIGHT, padx=5) + ttk.Button(action_frame, text="Vorschau", command=self.show_preview).pack(side=tk.RIGHT) + + self.log("CSV-Processor GUI v2.0 gestartet") + self.log(f"Excel-Support: {'Ja' if EXCEL_SUPPORT else 'Nein'}") + self.log(f"ODS-Support: {'Ja' if ODT_SUPPORT else 'Nein'}") + + def log(self, message): + """Nachricht im Log anzeigen""" + self.log_text.config(state='normal') + self.log_text.insert(tk.END, f"{datetime.now().strftime('%H:%M:%S')} - {message}\n") + self.log_text.see(tk.END) + self.log_text.config(state='disabled') + + def browse_file(self): + """Datei zum Öffnen auswählen""" + filetypes = [ + ("Alle unterstützten Dateien", "*.csv *.xlsx *.xls"), + ("CSV Dateien", "*.csv"), + ] + if EXCEL_SUPPORT: + filetypes.append(("Excel Dateien", "*.xlsx *.xls")) + filetypes.append(("Alle Dateien", "*.*")) + + filename = filedialog.askopenfilename( + title="Quelldatei auswählen", + filetypes=filetypes + ) + if filename: + self.file_entry.delete(0, tk.END) + self.file_entry.insert(0, filename) + + def browse_mapping(self): + """Mapping-Datei auswählen""" + filename = filedialog.askopenfilename( + title="Mapping-Datei auswählen", + filetypes=[("JSON Dateien", "*.json"), ("Alle Dateien", "*.*")] + ) + if filename: + self.mapping_entry.delete(0, tk.END) + self.mapping_entry.insert(0, filename) + + def load_file(self): + """Datei laden""" + filepath = self.file_entry.get() + if not filepath: + messagebox.showwarning("Warnung", "Bitte wählen Sie zuerst eine Datei aus.") + return + + path = Path(filepath) + if not path.exists(): + messagebox.showerror("Fehler", f"Datei nicht gefunden: {filepath}") + return + + try: + self.log(f"Lade Datei: {path.name}") + + # Datei laden + file_ext = path.suffix.lower() + if file_ext in ['.xlsx', '.xls']: + self.headers, self.data = self.read_excel(path) + else: + self.headers, self.data = self.read_csv(path) + + # Mapping anwenden + mapping_file = self.mapping_entry.get() + if mapping_file and Path(mapping_file).exists(): + mappings = self.load_column_mappings(Path(mapping_file)) + self.headers, self.data, self.original_names = self.apply_column_mappings( + self.headers, self.data, mappings + ) + self.log(f"Mapping angewendet: {len(mappings)} Spalten umbenannt") + else: + self.original_names = {h: h for h in self.headers} + + # Spalten in Liste anzeigen + self.update_columns_list() + + # Wenn Voreinstellung mit Spaltenauswahl geladen wurde, anwenden + if hasattr(self, 'current_config') and 'selected_columns' in self.current_config: + self.apply_column_selection_from_preset(self.current_config['selected_columns']) + # Config zurücksetzen nach Anwendung + self.current_config = {} + + # Info aktualisieren + self.info_label.config(text=f"Geladen: {len(self.headers)} Spalten, {len(self.data)} Zeilen") + self.log(f"Erfolgreich geladen: {len(self.headers)} Spalten, {len(self.data)} Zeilen") + + # Warnung anzeigen wenn "Leere Spalten entfernen" aktiv ist + if self.remove_empty_cols_var.get(): + self.log("ℹ Leere Spalten werden beim Verarbeiten automatisch entfernt") + + except Exception as e: + messagebox.showerror("Fehler", f"Fehler beim Laden: {str(e)}") + self.log(f"FEHLER: {str(e)}") + + def read_csv(self, filepath: Path) -> Tuple[List[str], List[Dict]]: + """CSV-Datei lesen""" + encodings = ['utf-8', 'latin-1', 'cp1252'] + + for encoding in encodings: + try: + with open(filepath, 'r', encoding=encoding) as f: + first_line = f.readline() + f.seek(0) + + sniffer = csv.Sniffer() + delimiter = sniffer.sniff(first_line).delimiter + + if self.has_header_var.get(): + reader = csv.DictReader(f, delimiter=delimiter) + headers = list(reader.fieldnames) + data = list(reader) + else: + reader = csv.reader(f, delimiter=delimiter) + rows = list(reader) + if rows: + headers = [f"Spalte_{i+1}" for i in range(len(rows[0]))] + data = [] + for row in rows: + row_dict = {headers[i]: row[i] if i < len(row) else '' + for i in range(len(headers))} + data.append(row_dict) + else: + headers = [] + data = [] + + return headers, data + except UnicodeDecodeError: + continue + except Exception: + continue + + raise Exception(f"Konnte Datei {filepath} nicht lesen") + + def read_excel(self, filepath: Path) -> Tuple[List[str], List[Dict]]: + """Excel-Datei lesen""" + if not EXCEL_SUPPORT: + raise Exception("Excel-Support nicht verfügbar. Installieren Sie: pip install openpyxl") + + wb = openpyxl.load_workbook(filepath, data_only=True) + ws = wb.active + + data = [] + headers = None + + for i, row in enumerate(ws.iter_rows(values_only=True)): + if i == 0 and self.has_header_var.get(): + headers = [str(cell) if cell is not None else f"Spalte_{j+1}" + for j, cell in enumerate(row)] + else: + if headers is None: + headers = [f"Spalte_{j+1}" for j in range(len(row))] + + row_dict = {} + for j, cell in enumerate(row): + if j < len(headers): + row_dict[headers[j]] = str(cell) if cell is not None else '' + data.append(row_dict) + + return headers, data + + def load_column_mappings(self, mapping_file: Path) -> Dict[str, str]: + """Spaltennamen-Zuordnungen aus JSON laden""" + try: + with open(mapping_file, 'r', encoding='utf-8') as f: + return json.load(f) + except: + return {} + + def apply_column_mappings(self, headers: List[str], data: List[Dict], + mappings: Dict[str, str]) -> Tuple[List[str], List[Dict], Dict[str, str]]: + """Spaltennamen umbenennen""" + new_headers = [mappings.get(h, h) for h in headers] + original_names = {new_h: old_h for old_h, new_h in zip(headers, new_headers)} + + new_data = [] + for row in data: + new_row = {} + for old_h, new_h in zip(headers, new_headers): + new_row[new_h] = row.get(old_h, '') + new_data.append(new_row) + + return new_headers, new_data, original_names + + def update_columns_list(self): + """Spaltenliste aktualisieren""" + self.columns_listbox.delete(0, tk.END) + self.selected_columns = set(self.headers) # Alle initial ausgewählt + + for i, header in enumerate(self.headers): + orig = self.original_names.get(header, header) + if orig != header: + display = f"☑ {orig} → {header}" + else: + display = f"☑ {header}" + + self.columns_listbox.insert(tk.END, display) + self.columns_listbox.selection_set(i) + + def select_all_columns(self): + """Alle Spalten auswählen""" + self.columns_listbox.selection_set(0, tk.END) + self.selected_columns = set(self.headers) + + def deselect_all_columns(self): + """Alle Spalten abwählen""" + self.columns_listbox.selection_clear(0, tk.END) + self.selected_columns = set() + + def invert_selection(self): + """Auswahl umkehren""" + for i in range(len(self.headers)): + if self.columns_listbox.selection_includes(i): + self.columns_listbox.selection_clear(i) + else: + self.columns_listbox.selection_set(i) + + def get_selected_columns(self) -> List[str]: + """Aktuell ausgewählte Spalten ermitteln""" + selected_indices = self.columns_listbox.curselection() + return [self.headers[i] for i in selected_indices] + + def process_data(self) -> Tuple[List[str], List[Dict]]: + """Daten verarbeiten""" + if not self.headers or not self.data: + raise Exception("Keine Daten geladen") + + headers = self.headers[:] + data = self.data[:] + + # 1. Leere Zeilen entfernen + if self.remove_empty_rows_var.get(): + original_count = len(data) + data = [row for row in data if any(str(v).strip() for v in row.values())] + removed = original_count - len(data) + if removed > 0: + self.log(f"Leere Zeilen entfernt: {removed}") + + # 2. Leere Spalten entfernen (VOR der Spaltenauswahl!) + if self.remove_empty_cols_var.get(): + non_empty_headers = [] + for header in headers: + if any(row.get(header, '').strip() for row in data): + non_empty_headers.append(header) + + removed = len(headers) - len(non_empty_headers) + if removed > 0: + self.log(f"Leere Spalten entfernt: {removed}") + # Zeige welche Spalten entfernt wurden + removed_cols = [h for h in headers if h not in non_empty_headers] + for col in removed_cols[:5]: + orig = self.original_names.get(col, col) + if orig != col: + self.log(f" - {orig} → {col} (leer)") + else: + self.log(f" - {col} (leer)") + if len(removed_cols) > 5: + self.log(f" ... und {len(removed_cols)-5} weitere") + + headers = non_empty_headers + data = [{h: row.get(h, '') for h in headers} for row in data] + + # 3. Spaltenauswahl anwenden (arbeitet mit nicht-leeren Spalten) + selected = self.get_selected_columns() + + # Nur Spalten verwenden, die noch existieren (nicht leer) UND ausgewählt sind + final_columns = [h for h in headers if h in selected] + + if not final_columns: + raise Exception("Keine Spalten ausgewählt oder alle ausgewählten Spalten sind leer") + + # Zeige Info über übersprungene Spalten + skipped_empty = [h for h in selected if h not in headers] + skipped_deselected = [h for h in headers if h not in selected] + + if skipped_empty: + self.log(f"Übersprungene Spalten (leer): {len(skipped_empty)}") + if skipped_deselected: + self.log(f"Abgewählte Spalten (rot): {len(skipped_deselected)}") + + headers = final_columns + data = [{h: row.get(h, '') for h in headers} for row in data] + + self.log(f"Finale Verarbeitung: {len(headers)} Spalten, {len(data)} Zeilen") + return headers, data + + def show_preview(self): + """Vorschau der verarbeiteten Daten""" + try: + headers, data = self.process_data() + + preview_window = tk.Toplevel(self.root) + preview_window.title("Datenvorschau") + preview_window.geometry("800x400") + + frame = ttk.Frame(preview_window, padding="10") + frame.pack(fill=tk.BOTH, expand=True) + + # Treeview für Tabelle + tree = ttk.Treeview(frame, columns=headers, show='headings') + + for col in headers: + tree.heading(col, text=col) + tree.column(col, width=100) + + for row in data[:100]: # Max 100 Zeilen + values = [row.get(col, '') for col in headers] + tree.insert('', tk.END, values=values) + + scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=tree.yview) + tree.configure(yscrollcommand=scrollbar.set) + + tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + info_label = ttk.Label(preview_window, + text=f"Zeige erste {min(len(data), 100)} von {len(data)} Zeilen") + info_label.pack(pady=5) + + except Exception as e: + messagebox.showerror("Fehler", f"Fehler bei Vorschau: {str(e)}") + + def process_and_save(self): + """Daten verarbeiten und speichern""" + try: + headers, data = self.process_data() + + # Zieldatei wählen + output_format = self.format_var.get() + + filetypes = [] + if output_format == 'csv': + filetypes = [("CSV Dateien", "*.csv")] + elif output_format == 'xlsx': + filetypes = [("Excel Dateien", "*.xlsx")] + elif output_format == 'ods': + filetypes = [("ODS Dateien", "*.ods")] + filetypes.append(("Alle Dateien", "*.*")) + + filename = filedialog.asksaveasfilename( + title="Datei speichern", + defaultextension=f".{output_format}", + filetypes=filetypes + ) + + if not filename: + return + + # Speichern + target_file = Path(filename) + + if output_format == 'csv': + self.write_csv(target_file, headers, data) + elif output_format == 'xlsx': + self.write_excel(target_file, headers, data) + elif output_format == 'ods': + self.write_ods(target_file, headers, data) + + self.log(f"Datei gespeichert: {target_file.name}") + messagebox.showinfo("Erfolg", f"Datei erfolgreich gespeichert:\n{target_file}") + + except Exception as e: + messagebox.showerror("Fehler", f"Fehler beim Speichern: {str(e)}") + self.log(f"FEHLER: {str(e)}") + + def write_csv(self, filepath: Path, headers: List[str], data: List[Dict]): + """CSV-Datei schreiben""" + with open(filepath, 'w', encoding='utf-8', newline='') as f: + writer = csv.DictWriter(f, fieldnames=headers, delimiter=';') + writer.writeheader() + writer.writerows(data) + + def write_excel(self, filepath: Path, headers: List[str], data: List[Dict]): + """Excel-Datei schreiben""" + if not EXCEL_SUPPORT: + raise Exception("Excel-Support nicht verfügbar") + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Daten" + + # Kopfzeile + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_num, value=header) + cell.font = Font(bold=True) + cell.fill = PatternFill(start_color="CCCCCC", end_color="CCCCCC", fill_type="solid") + + # Daten + for row_num, row_data in enumerate(data, 2): + for col_num, header in enumerate(headers, 1): + ws.cell(row=row_num, column=col_num, value=row_data.get(header, '')) + + wb.save(filepath) + + def write_ods(self, filepath: Path, headers: List[str], data: List[Dict]): + """ODS-Datei schreiben""" + if not ODT_SUPPORT: + raise Exception("ODS-Support nicht verfügbar") + + doc = OpenDocumentSpreadsheet() + table = Table(name="Daten") + + # Kopfzeile + row = TableRow() + for header in headers: + cell = TableCell() + cell.addElement(P(text=header)) + row.addElement(cell) + table.addElement(row) + + # Daten + for row_data in data: + row = TableRow() + for header in headers: + cell = TableCell() + cell.addElement(P(text=str(row_data.get(header, '')))) + row.addElement(cell) + table.addElement(row) + + doc.spreadsheet.addElement(table) + doc.save(filepath) + + def load_preset(self): + """Voreinstellung laden""" + presets = [f.stem for f in self.config_dir.glob("*.json")] + + if not presets: + messagebox.showinfo("Info", "Keine Voreinstellungen vorhanden.") + return + + dialog = PresetDialog(self.root, "Voreinstellung laden", presets) + if dialog.result: + preset_file = self.config_dir / f"{dialog.result}.json" + try: + with open(preset_file, 'r', encoding='utf-8') as f: + config = json.load(f) + + # Einstellungen anwenden + if 'has_header' in config: + self.has_header_var.set(config['has_header']) + if 'remove_empty_rows' in config: + self.remove_empty_rows_var.set(config['remove_empty_rows']) + if 'remove_empty' in config: + self.remove_empty_cols_var.set(config['remove_empty']) + + # Mapping-Datei vorbelegen + if 'mapping_file' in config and config['mapping_file']: + self.mapping_entry.delete(0, tk.END) + self.mapping_entry.insert(0, config['mapping_file']) + self.log(f"Mapping-Datei vorbelegt: {config['mapping_file']}") + + if 'output_format' in config: + self.format_var.set(config['output_format']) + + # Quelldatei vorbelegen (falls gespeichert) + if 'source_file' in config and config['source_file']: + source_path = Path(config['source_file']) + if source_path.exists(): + self.file_entry.delete(0, tk.END) + self.file_entry.insert(0, config['source_file']) + self.log(f"Quelldatei vorbelegt: {source_path.name}") + + # Datei automatisch laden + self.load_file() + + # Spaltenauswahl anwenden (nach dem Laden!) + if 'selected_columns' in config and self.headers: + self.apply_column_selection_from_preset(config['selected_columns']) + else: + self.log(f"⚠ Gespeicherte Quelldatei nicht gefunden: {config['source_file']}") + elif 'selected_columns' in config: + # Keine Quelldatei, aber Spaltenauswahl gespeichert + # Wird angewendet sobald Datei geladen wird + self.current_config = config + self.log(f"Spaltenauswahl wird angewendet sobald Datei geladen wird") + + self.log(f"Voreinstellung geladen: {dialog.result}") + messagebox.showinfo("Erfolg", + f"Voreinstellung '{dialog.result}' geladen.\n\n" + "Hinweis: Leere Spalten werden beim Verarbeiten\n" + "automatisch übersprungen (vor Spaltenauswahl).") + + except Exception as e: + messagebox.showerror("Fehler", f"Fehler beim Laden: {str(e)}") + + def apply_column_selection_from_preset(self, selected_columns: List[str]): + """Spaltenauswahl aus Voreinstellung anwenden""" + saved_selection = set(selected_columns) + + # Alle abwählen + self.columns_listbox.selection_clear(0, tk.END) + + # Nur gespeicherte Spalten auswählen (die existieren) + selected_count = 0 + for i, header in enumerate(self.headers): + if header in saved_selection: + self.columns_listbox.selection_set(i) + selected_count += 1 + + skipped_count = len(saved_selection) - selected_count + + self.log(f"Spaltenauswahl angewendet: {selected_count} Spalten ausgewählt") + if skipped_count > 0: + self.log(f" ⚠ {skipped_count} Spalten nicht verfügbar (leer oder nicht vorhanden)") + + # Info-Text anzeigen + self.info_label.config( + text=f"Geladen: {len(self.headers)} Spalten, {len(self.data)} Zeilen | " + f"Ausgewählt: {selected_count} Spalten" + ) + + def save_preset(self): + """Voreinstellung speichern""" + name = simpledialog.askstring("Voreinstellung speichern", "Name der Voreinstellung:") + if not name: + return + + # Fragen ob Quelldatei mitgespeichert werden soll + save_source = False + if self.file_entry.get(): + save_source = messagebox.askyesno( + "Quelldatei speichern?", + "Möchten Sie den Pfad zur Quelldatei in der Voreinstellung speichern?\n\n" + "Ja = Datei wird beim Laden der Voreinstellung automatisch geladen\n" + "Nein = Nur Einstellungen werden gespeichert" + ) + + config = { + 'has_header': self.has_header_var.get(), + 'remove_empty_rows': self.remove_empty_rows_var.get(), + 'remove_empty': self.remove_empty_cols_var.get(), + 'mapping_file': self.mapping_entry.get(), + 'output_format': self.format_var.get(), + 'selected_columns': self.get_selected_columns() + } + + # Quelldatei optional speichern + if save_source: + config['source_file'] = self.file_entry.get() + + preset_file = self.config_dir / f"{name}.json" + try: + with open(preset_file, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=2, ensure_ascii=False) + + info_text = f"Voreinstellung '{name}' gespeichert.\n\n" + info_text += f"Enthält:\n" + info_text += f"- Einstellungen: Ja\n" + info_text += f"- Mapping: {'Ja' if config['mapping_file'] else 'Nein'}\n" + info_text += f"- Quelldatei: {'Ja' if save_source else 'Nein'}\n" + info_text += f"- Spaltenauswahl: {len(config['selected_columns'])} Spalten" + + self.log(f"Voreinstellung gespeichert: {name}") + messagebox.showinfo("Erfolg", info_text) + + except Exception as e: + messagebox.showerror("Fehler", f"Fehler beim Speichern: {str(e)}") + + def manage_presets(self): + """Voreinstellungen verwalten""" + PresetManagerWindow(self.root, self.config_dir) + + def show_about(self): + """Über-Dialog""" + messagebox.showinfo( + "Über CSV-Processor", + "CSV-Processor v2.0\n\n" + "Professionelle CSV/Excel-Verarbeitung\n\n" + f"Excel-Support: {'Ja' if EXCEL_SUPPORT else 'Nein'}\n" + f"ODS-Support: {'Ja' if ODT_SUPPORT else 'Nein'}" + ) + + +class PresetDialog(simpledialog.Dialog): + """Dialog zur Auswahl einer Voreinstellung""" + def __init__(self, parent, title, presets): + self.presets = presets + self.result = None + super().__init__(parent, title) + + def body(self, master): + tk.Label(master, text="Voreinstellung auswählen:").pack(pady=5) + + self.listbox = tk.Listbox(master) + for preset in self.presets: + self.listbox.insert(tk.END, preset) + self.listbox.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + return self.listbox + + def apply(self): + selection = self.listbox.curselection() + if selection: + self.result = self.presets[selection[0]] + + +class PresetManagerWindow: + """Fenster zur Verwaltung von Voreinstellungen""" + def __init__(self, parent, config_dir): + self.config_dir = config_dir + self.window = tk.Toplevel(parent) + self.window.title("Voreinstellungen verwalten") + self.window.geometry("400x300") + + frame = ttk.Frame(self.window, padding="10") + frame.pack(fill=tk.BOTH, expand=True) + + tk.Label(frame, text="Voreinstellungen:").pack(anchor=tk.W) + + self.listbox = tk.Listbox(frame) + self.listbox.pack(fill=tk.BOTH, expand=True, pady=5) + + button_frame = ttk.Frame(frame) + button_frame.pack(fill=tk.X) + + ttk.Button(button_frame, text="Löschen", command=self.delete_preset).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Schließen", command=self.window.destroy).pack(side=tk.RIGHT) + + self.refresh_list() + + def refresh_list(self): + """Liste aktualisieren""" + self.listbox.delete(0, tk.END) + for preset in self.config_dir.glob("*.json"): + self.listbox.insert(tk.END, preset.stem) + + def delete_preset(self): + """Voreinstellung löschen""" + selection = self.listbox.curselection() + if not selection: + messagebox.showwarning("Warnung", "Bitte wählen Sie eine Voreinstellung aus.") + return + + preset_name = self.listbox.get(selection[0]) + if messagebox.askyesno("Bestätigung", f"Voreinstellung '{preset_name}' wirklich löschen?"): + preset_file = self.config_dir / f"{preset_name}.json" + preset_file.unlink() + self.refresh_list() + messagebox.showinfo("Erfolg", f"Voreinstellung '{preset_name}' gelöscht.") + + +def main(): + root = tk.Tk() + app = CSVProcessorGUI(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/dist/csv-processor-debian.deb b/dist/csv-processor-debian.deb new file mode 100644 index 0000000000000000000000000000000000000000..07b20894948cbc59bed0867973e545b2fc5b55c7 GIT binary patch literal 23418 zcmaf)Q*bW~?51nmwr$&XdusDn+dZ{y+qP}nw(a&5`_1nD_jWUHGLwts?#V?aA?7o3 zGO-kZF*P%`GP0vLwz4yF@+2Z6V&>#v;^JcCp8jqr-LC3RSV;{DG;R@77#|cC%uxbYxp>vvAMa% z0>q~DY%18di@hH$vI1TZ{yi!v>AixHTpEdd8&;Yo{aKC90R|WUjjFtOf7W=~64?osw4^0XHNA~vnn7@A@Stb(P zwiymyoyCwaPQnQDzh8{l z4pcRWstz5#gzPcGj{aRVtewTMf9|TB;U~-ljY`G^wQUBbLG5qTr+h|e#5`n74wa3U z4Nf2=BLolHX|=EI*J_Z=ZRbuAWil4MG8{VzF{gpTIq%K{_dY&UUVweP0+L#!4-P>a z&vyrPtYQLMm^!ruY})AB#lxavKNu)^SD(Luv*p7(XLiEu0<2~2L|>LOg*aL0vZg6Cp4cH$ie}w-(rn6q7G<_ zq`No`#vHvi2pDYt5IDz21REEop1eKy1csGqC=JHl8hn@%0RrQi=*ZN_#pwSb^&fBl zKPp*R*jQQqH<$m1Dlkh^YmiGWbsjk)>Ch}tAUGhq@bPw#(Hd*+%Ad-=h77>l>j3+N zWR-P{4rBJTYc!QxwpPbjBQ)Rn-+e;eqXMS|_(K7>nwo@gIMfTEUCMZ=;A-R`&~VUv zPyz0FNYyqDi>%6nf5ZZ|{ZWz4mXn*k%iVXDCk&NYew6Y>YlER19#ELOgc3!x@-tN15G#Q)x2CB6ms|dme?4PP;^hE zYU%oU_&#;mi_jHenq!AdCY?pbLj|^>N^8rUKL?-A|03J~_+f1AFW9HCwLEHtRoGU( zcy`-!=P=F&%trq{4zOB-;&7{5t=n_Ft%LUbZQ(`fQeQmGW3ahbh0nWc&~OCTg}(8} zV61sCjU6qug{vj&6$-v{Oz308YoVnS>!}Q>a0;ma9)_-5oTm$EDb`iMRk&2me(k6n zP_7VgLPA1v6o{zMLe#X$RGKh)b@ve1a`Z;47Vh$a}`Ky-D^DD>XzP4hclPyVvaN& zi$YebxHhpqZ6gtxBzen@x}?>zEP^llgP_7ELCp>aHmGaTy;*}9Qtl%W0hc&#Oufsb z4tE53mQ~~i0Z(y*g$h?okj}^V`@hxh+_P~_9fw!LYX_cbL`PzKAIRR|X~e<68nTXZ z{6!9}+uFu@~+m>>#evM;5eFI(bXXx4zb# ztK7J?#<+T?um$o*MR9wayJu2@aKz9AJxG)3mcQ-)^qTcuVcS^rKUd;LiDUO{!K{H< zV;2V)7iakErce+h0IWUSGM3WL-ryn&#&RLi(Cc1od$_!pL|FEl`d-(@jp9 zF4G^+QKXjEWe0eNx}-IzJX~Cwf??xl?wOK;aHJmGD*$}Fz*=r8@Ewg@dONyChN8cl zF$T*!_Yu#>0Vu)+Vrn!=kds58U^Q!lC#-?ESNX2jh$=)B3DTwkLnD4Wp~z5BkUDp+ zwVU%G9ob|gg8@Ug4)pD-b&^YQhBcZ@W!?-pgnR8Ddh@oVy`I7)m_kw%Xwu>=0r6^N zuw*Du5n#qB!WC*HB(Qk3e-7(Z&)D8v%!?B;g@nvabOVl;3y_OeDqav&?aQd7C*=;j zD;1NP{BXuDqMv)(a1HI?uH=-0$gJp0=?pQ-xpT5e{tA~~IkkIytlskHVvFeKCWxFG zpd3));4GTMBfMaRhZ5%yFvLH}D3LL*m>vl0mENYf5o6k|b_ zI@}l3?!KLi9a;)OFZwn+q9VTf(a+O!Rd+r4exZL-*kiucSr7W|v=F2@aF!`!sA>KX z*(tPSc@|drL*>zrqEsnukkU)QFnPQy2T24gBuR!(a%Ry7d$W`VngWSf?e(h?Z&SD> zAL&w8pqU^iC^T@|>z8ft8`(>;gL#svixL$j30X={fRBZ;n;)re1>6iaD1;$UQ(=e= zyL$2r1z$*UY3HbiDTV6kZzf=Hat`rbmWV}u0dT^r3Hl(tHlAp&S38d?0Ua{3LXZ{r zmU6IBsuuvwr`gp0@X*v1Yqv496hlTcbBdFwI=IsGdeeN1E;R%o z{sjTo3*KlEDlvH_vu@&NHi+q%9VkXcEP7F7T9MawxuAc+^3Mag|g;ce$Ho zP_Sy%!!)tEzMXo5!+u|^NkFN)cGMfbrw9cs@Q^9#>QXUq(cuuJO^u3+9346pFzYo+ z>}lRb zGFLAw*IBT=3kb&T>l;}ca4;b?H>aqKv4*C@xwX)E?SGCLL0O6YIDQ;%6IOt_E(^Jc z;)NNuFjz}MZ4#<-uQUHdq+art2WdRj=U9?@-Wf>(``meQLYFQy7F_%rAr{$h*5|RFsdk+*^f>k4S5&HS>nXCe zo5m06Z!QqD8WqsfU++mRHtz?c8UN|PWpDdXVENLMCr&3H>kUgA>~xCv5YDsf?Cfoz zmYytkC)cW|x)_@4=!Enwti@-MAKk!d!S3oQWzvTiThb_#?6cP4l`pN>YWG|<=^8!E zLiS(T{DMMZzt#u)bXitL4u>eD13=;{Ll;JTp6<>d>-gYa!7Q~EkhSbx&>PXXrnkfcVuRUiX2!*&?E=ZatfNj@$OHSP%=r5h{|$aIILx&Ce;e# z&RbgL=UUoC&x4tREkIPYm-zm7fER*WH}Hz~$BlP7;G(aMMZldkQfN z6g?bVBvwY2pzz^^`x5M&LP@Y8cg?e$^UDxAH(oeT2TG(0lle4Ucg=GA4*+BKF$f5g zg7y9JW#JgVmkAmqb+bFICAqR*lB5@=>9rFfaEPa6Vb5pQ7O+n#OdZD3I!ILu#P2$M zGWki(0HszE(`un)w-BGT6L0b5zReDr)SpMc@_Yihv4kdmS|C7--*f?bp5E z+T9Ok&(}XCZGI!?$o)3hR3nL-BUrurZ7R}n|7BkfF!r>-5nmD^I492=zCMReAvJc0 z7$iWy)D%sDpa3Zgfe_+hz|{Cdfj~P5fy4jj`o+k_B>kphOfTRt9js)T?=#CirO-)= zuqpCdtGX#U^+{-@#wM4&jrwU6&I4%)GJlfXm=+*UoY^kk&0$&e(P-_Q~RC z%H~&7TAYyEbK!q0cf`$7$gXIT{4)T^Kl#<1))v&%i7dE+vrUN5LV4!+dVPc zzE%s;LDDRVpjMr@L0fO;^%vd-m+zg$b<6?Z#$n{HdldP1xDo0N6xHLy1LA|gI{)PN#c$fSG>`N&wvC=9t?vhLX|rq|QM zT{M{zVPM0+6!6BCs1m9fF<`?+zDO*R9O}!(w(R9dYovmNIye2zy(Suv( z!fAUg?<$;myqjlBr74(}_>Qs-o|Phg+NA`uH?>d@)A#|H)hiOJ3O+kbXGtt}DpOk! z&6%gGpn-Pmh!#(rUQbWw5!LfvuTn?b(0mGVLu`!RY;{>0jyI>zGcWC~?Ut-#^3ZV_ z84o6JYeDWQ8ryr|(u626J!?BduOc)zCA33nH;PqoX}sE%hzC^OrLk$E);qIBq>V6@`IwSSgS3>|p3`q1ML3OVlRCzVk`31^D#~et|VqwRHfC`HgGFPb50hWaD^O$8hwP{6=5YLOQU{cMicRdz{;_%rUwN;wB*tlN19ef5Jv0#l^`E08%xU)v87d( zlcw-;nURq==MW@_xdcc~eMEput`rUQG-#c^{FqM5N~Jd$rA3T8S?Vl;?o3D4*z`p1 zAEKN(+UquxiFAjBgo6TQVGybM_Wk(z24-f_%8;6%dB~=z8Wt0<<>N^iaWXYYFxk_` zyXFI95A_$b<9?+=Xmv*+WyH=z8V>x$5mY8m+L%Lb{`XMAP@DU2fEn4$aCq>MNV}F;0>_uQakm z=4%>|R{ZbuD~t+<&$AsLXf&$J_a`u-2LMhf@cc6CW%CtNu=b+OC&t+^ixcOhhne0V zTbJ#W6nUIAh_M@AfM#7@3zOn{C#Fa&DKc?FIOxK$p(|siLr;_TtsA-u=i6Zqg4@Xo zeD4EidsDBirmlx3ADN8_y*xCl(sCtDK7oFG6CFWK{`rf7_HKbKZNJd zKZ)Z#?`rn|nOR=m{BFihqdWrz1O&9C1O$hX31=o2kzd~UuXsQr6f#6ia!_;jmF4<`X4L1Bq_ z)fp}4Q?YF0{ScCYge5JMC3$+>AJAlMO%318?v1*jPy5AUPb=iG{Ol`N)<#i zgr!7xA0J(UWFPFbK60iKomn8Yr=)I&cjJ$H0GD>GnlWbNi$v<4e`qQ<-x!b%iXr!= zUQo|9Zp=N@3Id{`C~9At+N^m=V$gJeJ9Xche&Lkl^jw|`)!ICdq@o{1Ja49X1!qvi z^g9STJnXPDB33dlJYuBKh6R4Usha#*7d@5+1Io5Vhr%%zOytrd;V5&rG{5V86oAq{ z1ws%L)tZY6ynqycNA1=^_=7pHwC8ER9!N& zabX)k?$MLGo2=)dtYZUUF5Q>w$HuE;NaQ9FI z_@jZAVGA(%kO9U`)z3{m!bZ7`EqhQGwuJENF}PaqU>2k*fx&fYxTb~ei0tbKaF}-3 zq1H7)mNJZw8JOk^mlW9Rc*>zacXf|tLTs}t%+(q3`DJC zOdNXjQ8bhpY9@=La^aT!BYjMp$ZM%Oo)Wq?t`jBPi4u~M0zG4&?Wv`~yk4*E@@a*x z(e%ylLtq|oK&yLCq4cV!{1wQtS%94EV}TpIj8TH7TLxk#)n2{XL;; zY!aGh&HAN4ar!e+!r@U1xwymtDX(u&nj2#M5NpZPGsN}WSfmf#x7U2p%#PDIQ-n&= zRQ`x`bKqiGqq^d%shVe|uRE7rY-DA@t(VcYP~>Buycb+$oO$#jvV{qd6ZJb4CGus? zze$MQIiEJ5$NlZos9|k>padote}s>YsI$-QXJ-~q5G?;1jn5ETNhFvYg(~9s)sLAf zP>d=VE>trdQ#-|6=nq7-7b0B9FNdmv?jdp}dkI07?Hp4IHv48f)4snO4O0>Bu<%vXIOO zBTl=}FWI@wy#6yXeeVtD7xW2kUR4`i{ls;gmE+c_x8ZExW9>->?XQyii7Ymr{Ci4H z4cpVEIA7`T+V;wNs)sQt1wo2X``mcl;_V>sST3FeX-~-d)bi7p)DRIHoYF<3U4iV$ z4grCDravbwH;`&iO;Xd}Vk(>24d&kJ7QZ(av0zK&9nf56E@8fq=|$66jh(Q-FJI!* zyA-K%)dYNhrWr;-bH*ZD5x|P+~-@pnTLM^4-FYot$lwn-iQ%nh(|P~2K+%3x$q~&0}*%H)@5DdwAsa1 zs7~3HH+$NO@M$e*x1R>e1Tr-}rZ@;Dt0rT@K(*ZUdCe%mOHMEn#S@#}0+S5`2{@w0 zErkh2C8aQ|Xr93S(v||Q94c40%%K5AG!3XdIYa)QHa-^eQ*;jern5v-NVNsrl}P@* zTVQV(?_*ZNdengM-;{R3_@pmkCq*+7^j(OeytP~I>BJajCSAd0YRtX%OxOi%>X?Tw*{g~WQG2o3a-PG zD7Lw^zj4m?)zSFXkxy=SF%)xpoc`=>S@I&f*+qg~WcF^tz#_Q5bV%e^%1o7Zb`w|A zK_%z4W{M$y>s+Nj+!<1eQcCH-na5TC0HIf) zP7BPTu^^3w4-l|B24;4;vOVM(&=p(d(U$v*!dq&DIjJMC^Ze4;`>b1JZc$v%%s+RXEi-D)J!yPxEIN^bv^58F=sOg5jq?g_(7 zMj#P6C>qz1d*At^AG?m`b%*Qh1!*vzMTNhL*Lz!*9_WWrr#h z{R2ahQt{{B<6V7cu;N6rQB978ijpOqqR*}hz|zGXyX#P!@=zfI=^~t=vOMECkmn2% zxq#Igx}*TBZ|1qV&_Iq=yw(+fh@F`*N(n_YM1F6BiFN2a z3XN-sv;xvTu_Er3@2O8Djp%!<@Rk~#jEmsMI64;Vb=}&sWzu=JFc2zGC;ST29Nn+- zw-ZW7+eDZ^O(Yn7`C&>=iP|d~f(F3yv>Xw=wt~BoAp~2c{!>faZbc-M1z90zWj)I^ zbt>P6QOTRr@K7EpoaI9Vy_Y4#Q8?2R9-F+C#13KsH0e5zh6swlT0o?48$)p`P|z1< zi^6^YywAwC-}2}t+$S3^4mk_zI=7h(4kT+)cbBr2)bQSE8MFeFg7|`e2jX`G^2LQF z3+B=$moO->bW&ARqQ$s9GGZ}#G6?=lhTK4ty@_UftbZNMM!PUqnEJ*AW8yF+^J+|t z2lCdA*2%l8VL$g?@h9SapWE(EXEZepcB@@};`Eh}A63csqxGz~=pRkmA$=sT3`vZ| z1u9dZ;6ckiHghcO^rG(NcN|ug*j+Ad@8_7?xgfN(%x3w;RyTnK;S%P7kGt_Vm*D_%b=?35c|pkiTlB|7`XJ7 z{A8{9w?heU5g36%W{$T^6mwDjONS3t%s3W2V^9s zC1Q%j51}h`6AXK=^T#BDPcY@@c|9uFJg;v-^N-^pLOGvdHD z&+vX8VFLbZt4J*sxwL;G+`=ioNaPY1hz34HlQM1M#DFmq8^VZgO@ax=WOlgW%=c+V z`zE9v`Ukq<8U0@-mgRsRi=>6dyy3B7k`9Qq{9R%g zL9D_zS;Kll(Ja@Zd2y}>a}hEOk}N>!D2p~hv_&vLc=Q(Ap}U}-E>{W!CB+`uh1rU!?yy0S1bgMc+AvP_VdVCDl$KVwU^{ibdn`- zA7tY~dw3t`n;OgC7rw6u>*~o1PG0pW1rpjSZo>}S^Y^Nj3~Y@7BgNhGUgm}gHA;gh z^hob!ugbIr8&oI<- ztZP$$;;FyLO5I&Fd?9R+Eb3URjd~vg)$GG+qp$9>FZ=x}G$#vkt|nlzNi~gNBaV%C z=V-Ofy@29p(x-^UQ=JD7<>dlZcnYZ5o4SJW!Y%XwEClbfde_+`S>KS<0AmLPv~%f^>ZgYn$gt5l;c{s6U* z_7P|~96^s~A~rpX&A)^@P?%>3ns>m_z$?>4!&K#{H?yD_)t+?8W3QVoIES>aCbhrK zR}q8w3~uUMT@6S##RiMwyn!cX2gEgF&gNqTk~917o3~LU3uJ~0fBPsz?KCj4ghozW zW5CMlE9nFhmJN-z5LkxEf`P~@=6s>6mFd*U zb6bC(LKdU{JV%db-zoEP%uj`eBNCl^s5rLEEM$=emQ(ijjQ8<#r}g@RhC^yXVF9fs z)M$h;3gX(Sc)VD}7%|quJzB}`VR)~P1(WP!pq6vg zQ@tds5gG&eaI}?oDVT{xCsRQTz*v`&uNS>6J)@MEx?w!SLvwE*ziNL$h2Qp(5P&naMAj0xx(O+2!`!$G{EN{98lQW4@i1-A>mbtROywq$1TRh83U1G*tw= z6u^YRZe>+pFB)$Q_BTbcUJ~2U7LbkZ&oMidoypKhNdG8^+AF5W9E`H^6pea*6f^{V zA^^l&P!XmrNrm`@ovUtKN;pN__nTZ3J&y~bDCmW?9MIMxi|+0_nQ9Dl-FCoF_iq$l z)z~GD6}DN%zMfuz`08mPPC0UETwa@+M;a(Cpb%3$OwuJ$=#`5h@+8Y)plw1mfKVHo z@|>2K?S6z=u!&G-=uBNKWr#cb`O8y$B^7xP6CqMi0eoh1j~0QU+Z4@ISQwrLThWf6 zNJ3rV+LtUbn+BK(Cl|A#kvQ5;)aF&aRp6gG(VpQ9UNs0{o?l(yBmHRO0W)l3UKX-| zBDGpj)VS=<^B{0HeY~RO4rDLke7s1y_(s2wJRgs&qy{&rCJu~uwKXuvBn+lPT*>sA zXg6ZZEaBjdv_L9~w$=Cc&bAwYu22fau%jZsPohdeC1^CtP4K;Q4;Y-AfiYS&a*n2)5IF3WKSXi((EXrgcL{z#P z%itfFJtRr-Af?#95To}N%4*_xOtkU~xfcG~g8i>Fs&@MAYR>?(j>Vetc6X?J@YX@Q zk#+KOeJ>*2`?6u^1`F!47wGpl;U~CMB7CAt68rW~yhc{hNd#{w1FRDrN$T8QPPFhN);6%Y5T6>M^)#tQa*CYLhA z*3BrY*5aaldWO@g^0x337omVnL%3evwG$uqsPf@pGvWoqW6qQ`$_x8NcS0{?$pYg1 z{T3A0W73$^{b99I9QsB%2K53H*25g_4a|MXgz5XT*pgpW5$5W^PluqM)>Ar;-At$o z+Y7Lobup9ub7_Q(tjp8nCDh40XJ@FF0Mq?>o4-;I1Baep`S;H~Z5na!Xg8bu4bvEc zR|uGoWn6lcUQBWbl1%+Lja1hL^&EJNbRRPk82t>1?LJre*S7WwiyRT8G_TGK~4(O+NBJm2RY$$dM+Af|3F%n zdAzKml_-mg+V0Tv;49A`C!3Edxs7d2VNr0dQFwslbbA z(A!>rE9de=ZTiDg_p=2+FoC|^n`9i&^$2>+$ol`nw%urLR->TlUqG5MxtQnuSeRM? z{hetbjo-kd%LWVDyAc7m$oQS9iHZmfR)zinlk?@9m#oL{P~Rv&*w))?2nto01~x2E z0SrvhBb<$Ewt zSjAYUbgO<<8e1622e3&=_rY65yKszW2_LM`SDw=b3bU7*C?6Iy5S~!ZWs00s>XFPT zHK^ZKg*R^WvhC6|ps<*^u+R_BDNH`nvRcHRel2Rm!fLYxKU-ma@v+a(M@gNce$}NM zE2!%i54b)w8*qQ5vzr!Z#FQ+$thrTJ=HZtwU`bLR8yxcbK##r5L%%)B%vhT&1uR&%4;#++SInKM8k5YFJ4BDE-SJ1)klk|RGCl>ayAGAK&RcSz^i zR6WmvO+S;}%DY)q76y3m)Ew(*)G%rqpVbHBQv$9wo}i|Q4J;0B8+D0X4glA} zI-lcRN<=AL-6D`KqLmcBrWrcElR$QO`jWfzeFREo5ZwRB{?;XaDVtTiVRm zUazc>pb`Yj96kmu+%$JI)u+Ee1i41?o9797KnnpSv4;ok*6SxslQT&z#9vx#bUAo(L@|oQ^hvVSs5x z=3w#xyAa;6xbf=w3?bXM7QS*i&q%x}yy)?ClM$1Rh*`5=7|DC~E{Jtovc2%_pea;A zIEeVlm$Qf8?;hnUmOR{@K3Z53e+MF2{pk4Ce1G^I(aGo~z7tsHJ=0h6&nt43PzK|p zpJn6kDZ0K7 z_kp<&9>pF3LE^2SKAVO9%-)rk6BNtwFiq2thl03Hn~OarMiEg9EMN1JfhIsjNd~I1 z;(`5wiwn6S)9X|py|PPeGt1ryw<|!#+Nyh!_otjd3Nt2T_8v&A{S9SN8EW#Rl?MP< z18fN7{aOpPc__0}UC~^dXt8xU##dFnz0e&=IC2x?w@I+4{;7S&#abZHCM21iJ|Lu@ z#=$KelFb2yZNN95@_qs}*XGvumWI7*!wK&y2aN@OD+%mp(BQclvU>Fl;uJ+fjgJeg zK4uU9HzGA?EBpb%COy2hX+5@#37Ed z^nI}?{%GKzR})E7E;rA%*Du5}K-y8(=SV8tS>dE#fiDOaO~lL=3#xrSytR{N;E%=9 z4E;eZhEpU;1T{pDAt^oVXB7n&Xm@Zfn#C#;j4ba$_+&1r7ihiBcO@yy`|&~1xTH4q zt`+(=iRzR=t|5-edVj*xhQqj{UNd9Qb#iUh&Q@OqnRo&JG8BWTj^*~0PIzqLQ-8e; z8iy^2?D~N_qadM~qLC?uvgh8!Zm=;O&uPR6CShG;F2X7k=^en8Ha;e75CP?PQDNK;jkR3YD5OeYE&nzk8PYye+NM!+Qlqt7Tn6M^w!ri%nDO2v+q2vZ@ zdZ*zG%BSa1`%8jA%xhqQw1j0nFhU}E6`*IOT@uz-ub~Zk-Wa5`z6F&pS3y5_a3Q}RPy4G57{#{F?Y{u! z5Xox^Svhf!w|Hr$V7lsgE$87r0H9w^9&N|L_H4YDdS5@0}5_1|4BvP@M>Trch16*Et&dQzc znDYQQ8I+>%mg7&f6z+{Kmwm!6!5tZ9i?hBnIwLJ!;=)Rw_?5!nJ*Im#>P~1{ldC5L zihmxXQsh6K+R=ZUv`Sh>wsYq^Ga@Qw9Cr97)bpja;hV450b#IH29T!X>40x2L+H7G zJG4W59Mpl3jFC^SD7!mTgdp%V!%24Eno~+&)qId}f8by+i8Pr8FPYKW)>ym5b&7cJ zaYW1_Na`bA<3k+qgEC;ppRt&T4lH?V|3%rFj-HcjxI;{2Ik1x!Yz56*re@PhHH8nN zR;Kr1cod~pWG!V=>GWF=i+q)6Rv*6ZCII?klr!#%n1;4#8Od7?x5 zI3Kw#pnQPo90s@<5pce@lKW5BlVCVSI2jPX%L`-23yUa-V|}Tb_)0>u1TES#l!XBY z%nK2^P_T0Fz(@j(&h^9ZR9v24wQ9-ksI8|REzZ1#IDn|84zaplw}G}Gq(}S-bdmE! z#B%oz%=mj=3J|<)?Bbh?{^_#)5j0F(==kZqCNSqXBnHh;y%Imk;f{rw>EcOxlC;dw3TIz4;K*fZDEWqQ$6(#DUOj$*`I0`V^O7eb>Arc_nB8n;CaN zlo6R;^tLR=PeQ^YY4(k`RZI;9PO2JPA1g%xRv_h3@wWK4nL#G#f zeym;WG5gQv?g2VlD9ktc1f2C29dL813r%gqE-JO!*)1wZMa|!+sVhR_<`)*fwWd5d zeEc?s>6kcvM-DelH*q#*(_$_Y`Td-nG|Htw#y zuRKz{{nhtd$eY2Sgo*?I;xA(;Y;7iF;8g5KM%k(_ZukXQU~{F={%&0SG`AHBpN|w5 zER5;=dq^|HI;=NQeLFeasQj9)96dxu(xV{aNXezY(CQhLm<)gEGZr4v9a`t=%Rk`2 zl=q3o%qf)@^_`*S)u9aGbi)oYwBhvxSmX~q>R`J}hXGqP_*yP|N1&}*^4qu$8<7#( zfrIP;74uEEzhA29dv%c8zGYzUCQow0yY#kvBP;+k*EzjDc~%k3a-V^G2T1fw+m+7N`i5>w(SfS z%bgM@;a{%!Po{Kj2$6+Ia2yLJ6p(klm0~+N%NbDR!cf>KRFpeUU6yxaaQR?J08{hw zrMhO0*Y=_jR8Cj(LQP!cft52s0#mqD@|-^jS=W+7!;_k_715oSeiMC?AtW~CXQYqD zbZ(_0=HqS0f493OZno+ly-HUF7DaY1G7mT?TQj3?iJFZ!3!?m4;drV*xE z?@JUpZrLVHt!GyMwGttzOyGJzRIpky>kn$unn!9_EbA5k5*rm##x&toRH-P@PdtKz zD)=!u-UqEKxT2xJ)EWiH%0h=v?9B!4ZLrF67zbBly4WO=bCkt-8P5T_!g^xDM#1)f%LYI6nFPU`}-!CGqd^-_s}OW@#n&U;OR^ImpfeL z3^L?s6?lC1YDa}gPv4Ps(idjQT)-2dmHAt<*NB_4D8xT73Q=_WqpLjE<16s(;1>lI zY~J-+2PmM1&kOR%x?^o@5|c$hQ9T1T+NltV93ezo=oXvHo;?J?(IiqQ#>sh7T{l1} zLbn^l-31dY^AQ;tWK|xV`NO%a^_wJ~7jn z7HoAoSO*dZOM4E8t-K-n6-WU1M7$5pjVz0bOc;gi^HBY)9y-vOc&dH3!tL@*b%+$D ze!YdmkB5JK>a6Rn$~8`#2&l!YbS9@Ng)=|3qaGff&E;salx2ruZ)K0=yfx z3nmD|fx9(mxLEEY1v5`qP@zY}HMJ2Gv3>&Zhv`TXZSb3C zu-2-3@`jFktkfOv@2yd-i?_x zK$}eikEigdoZHE?)(@A<Mi)$WRRxL(i4WCQO**_aH1#6MQB#6pI@K@2$#c16Te;kYZ5Qv16RP$5HX4%iW%ZjSy8IWzZT%z^LW9bmk=<~mHEWr)zG9Ra&5@PJ1TL} zTf-e-GuCyVGoEO{U|^2vT0`e<3tjb7K{~4gr6J&mY38Pzgwr(5!S0GFHIeImmW*^k zy(PEXR?xrp6v%&&4B9(P25#GxDJ6o>CmTdmA^BAYU4}1Yp1KDlm&!_Isq|sbEozu; z6lS5nf`*7-jadj(BnKl-5H<}PRl0N`cRkB}fshNq>(o@i#Nb7H(foG-77XJc`1Q*; zu)zIDzLL<(cHbTg*m|;RR=JK4F;ey~F zlT3Qk4bjWVWW0Dt*A)tV%yp7kbaY^{8KA)AH{ljVj;HT%?XQ^AFASnWL2BYmghTlv z)Nah@Yt2JIKY6Kj{FC4HAaR-_gPTN94UJa0@Q65c?@?;S7!+CAvKRAmLzm}m9w4F% zD@ae^`F6CU;}OSiG;GNV#++6K`V%;p{!<9l0?46U4v$&vEbN;o?HIc~OROk#FW;1J zQ2RZ4z~zsv(xfPlWWYFUSOgx;K)Qtw{mvd>YOe64KOeTxA_{?_r31FkWm1@v96j`8 zOfcy${bVE7k`F$FKXUDeDlq*RU|%#apo>!R^XOFej+N=`H!Wi?lFCqqqX|;g8O7gs z6vAkks9NBg%86KDB2dVib8aWFLhX2{AL;h~`)#JdHmMA@(fbT??R$6gA8`|;~yFq{HX-~bpQHYjKIPtpqEn4ny|5pG58~o&>WG?xJvd4beFm+nU z6qRglF0#LW1na~t82H{r8uHB|GFc*Y%M?dw zh2wfKxf`N)Ni6B@O{`xZHk~*?YRx@ny-bs`?!u!ujOu=3tpejQaC%)GHO`e+j?uy* zMSR6EnnE2}R;g*w^MJ#ys7JZ-uS06F&xp6>^>$Nl7%|WTn7{$Rda*@$E|9eW2a8nEpRFa2K^4PaO zPX!Pxw4Ct0jx+xcd?D;?>_UXmXj8GiNn3^uZs2`)%>uuQ!dEF71Z85MM{Jk0{x44B zgggQPQ&BM+XI!K5S6_5Tlwhbzu?7zNK%h$w`zYaEB0v`z&MKm4%u9*uEW|eUX3=ZR z9*$%7a{ARA8hA3Dvrp?o5sTx0S@o^jouoE`b;ZUreXZDtP?MlTp=+wv`Vg~MBD*W} zcKUNVj@_7ufX_pK9!}a~*+Y`>S#yZ(eiVgj7Nry94)+_^4uRjA9pDwy;Nz3GDw~Do zs(pi>y4#nITz8XDJr%n4S=(y7@0srY|hdL5zuT2$`mXEA)I@-hozvL5hT$%TY`pk+ZjQZzsvK- z8AtXK|51d;S=?M`f4)9yBC2dSKDu3>QEZ=85eg=#VJ+W-shwKO!d1WIqii07)>*}J zN6zha{rU*+g4^%jte#IAC863H05ErK zu|$5aLhuVU4M&sVsWAig4$YBj7*Cf?q?KIr@=V{YWHrFJRBkD0`5vV5d)f#rszq_Z zf#j3u3rZ1EYn*)iB=dpi*KF#qN%l`pF3Ti@+$)3^4tp>O{T(&%?D*Nc zB|F2pY1s4jMV)bP+jQACSE%h$=GvKRJiG%+ZDTlkJD7p^1JQJ7mU;URKe2iOjx~x2 zvC1&`_Cy%4oTlvzX)~pNr#mOGK4_zJ} z_}Q09CJYeLO(R#k`3dzwXS^2UBOWr?MV3d0U3t*3 zalcR;FNOO{7^Luosdg*QbVt!s&il9LPnD7Ps9mg@-L-u%;NCC=C>jE#aW%c}G#9mwhdPjQK-Eu08+A{ic@=4yR5WHk8I8v z;TNoEIj1+XxJtiTjF_~9G>r=s!ffFMO6!x^d0xu$Lzv`9FEenin!_%}rl z28{B~2??eph$+8<$kj)qnKfyvCW+F1L8zj`5%R^E_A=Q))zXFMksX%yeRFKp0};hx zAM0ew+Xe2D82B;3raH#qCa0y(@dXQ|{%oMOt3^uMw!xWyCTNM)J^*}ZZ#{XkltEVb zEM)-F%?vpVK73fTR=8O^2Iw2MOK1F`&cI-mVJ>2*o*1QdIL?u2IeUAnsLH;5Suyl>k4lHDk>ySpAPqAp(3` z0gA)!0KcjN?gE35R6f`hjp4vBpd5ZiXtyUXQ3SYU!ksNSHuW0ytt&mksK`>||suk+qI5PLMf^E@izqo3ZHP}1GKE|3hcy5CjE{#blQMyM^+@$3b|jI6K7bK(ArIVv#ap`) znGh*)RtaiGqVI;=D9KKsq&RyR3GG*_b&J;d$EDPjdSVx{!4 zzG4DEva3wTBr!n`lkrIGeb4f`^A0Tl6J)W0#sFo+^U;ifMF{ zf$Lp)2<7h$gp?bqvWG@#K;-3A96h9-+a*RRkKol-lNu>HH8#k(>~>gmwff|N{4vT{rr0H`ib ziJ!aJ*G+*LUJ+&d#!RWo6PRrSu6S^0Cp4N}{NX2&J!UIlBo(ilN{UWgO-4Mzprg-Xt z2}(zd`XnpXN6vM!r6T1zUMZ;qOk%ZC%U258h`{+kbFg8G_CUzd*kbg8FT#9dOgB&CxW(PuI zpf>2`4Z9(=(d#Ml10bqVmR{d6ASB+LC%%;a{Z^;dGd6*1vsI^8ulk?3+uXF#`N1MX z%vwlLgAl1-xbrusIFFbJFeshE2vWf2jKLfs#$~}nGd^)$1RO-kO3X4fu86(Uwwmc} zwP?`s7S%(Ss_VJ>(D*_!G_y8`5Fiw3aqXDow%qw}q^^7f8) zH}4GAT~b1OM4rYUIon|dc=paeXz%P%7;~=^h>;1rZGm%qA)G}`HU`bx7rb~|XUSZp zv9V@k9)7%hTGDfioSm_}g%d!?&9VZ-e3Dtc-|6NOrtmDw+|N~`^eKNQugO?am1-G! z<{)z`4|l#f^U5bV8Amj!{F@FlB2g1QSbiP6WVgos~o@seCc(F^Tp4K7pB`l%1n zN`)*8`op2*z(_pk6pY@)_?=mjnM=J?mAG1hZU@U_6TWABvb~J2U%83w28RQKgxteWH8#;34oPp&JkCU{WACQsvaA zzmRJk9x%^4;Q){2a>^M~9Ibu!2DgE<=3EANddu675>&c*MeRCH6)u79C{E$-n+&R= z*uPTQXyMy)Lf6R;UkV7q@ZV#F?LMnBv?v2@YZ06_02zG?IX~`HmUI0-b!r+e+8I$` z*T+CN1Prjw(O`-?AdwhZYzFAofeUFfLkG-Z9Klk^#sz6u0bCfe2E1thmnQ@1_@Nmc z-S7?62*9l=kX-=*uXB``sxCkz2Nsj zf{2F4a+4sL4<*YH6@ihOfIA)sf+t|mb%YR8>H>(|$l_x__6HJV$kZM5ti)h@d;%1z znsyx_z=S%$A|J5m1R(r@8FDH|rSJg=d6f`Jp{x`KO!7Tr0aOAI7?tqop?3L~>A{0i zZwePm^L7V$+ARX^Bx}%d;7#k4a z*iD%dAa>7G;=ZcN947qE^XTY*>Hrgsr~ggnkuZ?iF_{*i*pQ)X_%iv3wQ^R*kZX%% z8<#<3wWH-jA+_AiztecOZOxYNO#wsIgSl{hRf+kwEd$>g)En%+Y zUEFP4QnXjq$|Lx@q5E{RTDGhR>_+U-Y`;h9-W`e!?>Z&X-gC>GE$Wr1t;=6gRW2I5 z)rW~n9tD91qF<~926R4WTW6_F)~O(v@bbgPEvVe@HbbXI{r8Y~nPj@}GV=|{-QxiY z^5AOqjc<|EF%V?wmvUQRn*CL+Ns$7ha#ea_#VOxr{?cuXA#@^Jk=j^8VbH-|*^;S~ zL`#Nm1EEt{2i1UCUJ2EjEd}lRr%ijov=$Zw$X%^yTT{pNc#!`?+Wd2e@!cq1^{)RE z%coth>lk3^xgG!duTA%0qdxi-BhA#4#zmdizQ2^r$Ba$0NFyzBZr6Q4l z8Y~-U#xE@u_jVaVHsol>j&0;v$DD^DmXFelse>OEF_x&fu#PbSEEx6r^=ip#;c9)6 zV|B4w9IJyLQ?Iyij=mMKTaREJ#K;@X`x{}A_t-1da^z8u9{JVgM{tXxR%@-b=GjFD z9C+~HK}19Z1O!B-Qh9iIpin52$pmwA4265BR~-KB!eRxa%llEgQCSjh*s|^-Q*vr}@uW-BLC2#|^*1XYP9n zMm;^*KFdG7JqAzaDgR%zfF1q1w8WzU+Sl`y=+_Hb(M7B6Iho;lMw_3%?OQm)<%Zv_ zhTuZm432L%`=(s7Q%RtxQ$I&)H~&o`9)-sb80+v-HI)acarZr?yFbT7hf5efyr2{Z zV1MnTxm`W*o_e~JD9Z@CqsxfK%rpuyVS3#!34rXkvEq!k9uN+R4Pz8Mq*Iv(TR{gT z5CqL(ys%UFI#94F0%q6p&`Sv&uZ<5qzBfL1Lju#WvrXBjb!@>w2_evtL@)-=C7kTR zTF4?RlmQBo00Pny4n@onLJrra_ZPOt2}J^f_<5Q40i_p;9WH*ri4wuDB_%UsHG_s! zO@?J(EFl~js~nmK5=q1AKl#7d(ErwhK!Qqe^(TTCTO9IXmubFr3~7y0j|4&WhopjRc1K0CXO6QwqAZg9+~vm+SqD1a>V#{ zH^xOmn~<;7;dOBK8Em#+^Jr13lmf52L;l-1$ir7d0L9662gnLn45ZhI`Mk`f++9ZP(#@lLX3nK76?ZWTmp2b2q6XKWwO`Co zi`EB$FwbJ0m3=XeEV-1}OK4c;e_v0GXn84wK8-Rg**UsJd`4kE&LO+kzgl?uED>FcZ%JlH$=|qqDv5g_vKu12z3w`hq4NRHecR(Qm z^<5+6b*aO|{UhHJ!K}qWrzO&m07t>GfcAe~YTgwXP53rYbX6|s;qid3OZS5s0uf~E zv&q{tR7vhEX;()gBhDw!n+*v#L1F&NSs4+mjLGYZAIuL#8(AcnpeutEQ!v<~d@TU$ zS}a+IWjs5oh^IC|X3pcs2ZG@(AoziKKz+kEoAAT87o>{?2K=cBV&phrY!k?F!pvQ8 z6s=+4LV<(y6I2&;^ECimCnHE8KBTV;8|hdmG#3!IdgHgCL_=N4{wY?EVq6F=h#;NK z0-Er);BF4;D>+(#!;AzYUklj3aLM|7rI!mNwn7WK(rCe@Vd{O(g4Rj9#W(1n?=)PYfqv|yjAk+(UIQW4rwJ2Cz0TH zDlMXAPl^l*=1A^ggG6?5E}4XH$?Rb2WD*u9`iGci1HsZ$GZnD56vcV(yt27eEY(_e zt)3Q4!95Rpwn~dqW-uf2@RR9KGM?FtnacjnJj?{l?8jQ@EP#4xR13JPY|P&)H&4xs zI?uD)F(2LRGdbU%LCqXPld~Su!1eSEpBIic3;y?y2}#SLi$9%5u(9T!a;ge2+h5-Q zlZCuCa(%KxvM0P8eqgg<2aszYzdLmqoA**)?w1}liu)L2+YcAO4Y>CvPCpB*gY#x^ z{;w39@2x|AK2c+!@4<$fm9L4y`^HkhTt_wtavhAc2D6Xy2F>2&-})SYwyo`W{MGo* zVoGiFO>%KMVBb4mvaKb4=>z6_lPqNlV?=(RaaT~gHIIwQ#*ix1@*;?Nw0!AhGypKu zDg_o7AHbUcBgSpBpU$XT`dfJaB||M50w^#Qs+YIn7Z1*iWxyCLd?8F30Vl}EfvHe^c^iItaAvFqjKT5?9g1+x^Kt;!HBRB(#RqsJ zz=(0%?58tyOaB($f5}jb1^|Ujh3e&P_~n5!V;L|8OJArd$2HGO19;asg?ASpz?%Rg z#%;5o&Y)ZRTX_E^LoFHtC@>YOm$%`U2hNOTz!)rRLUUcETc_EwtTDuk2sJ^Wz|sW( zAl_sdG^{>F-u4|NwHluWC?iaiO*YL()H&0ifvpi8?_?xFjqgs-KMw87#qcRo p?KEpPq0P5fpm4*`w*loE?OdSXJu=1SJBc&P!&1^9VeE5YV^kJBJum=3.0.0 + +# OpenDocument-Support (ODS) +odfpy>=1.4.0