Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8858a08a32 | |||
| faa36d0e5e | |||
| 25766959f1 | |||
| 5f949121bf |
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.dctp_backups/
|
||||||
|
.dctp_settings.json
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
# DCTP - Delta Code Transfer Protocol
|
||||||
|
|
||||||
|
Du generierst Code im DCTP-Format fuer effiziente Uebertragung.
|
||||||
|
|
||||||
|
## Regeln
|
||||||
|
|
||||||
|
1. **Zeilennummern am Ende jeder Zeile** im passenden Kommentar-Format
|
||||||
|
2. **Immer mit ###FILE: beginnen** bei jedem Codeblock
|
||||||
|
3. **Bei Korrekturen NUR die geaenderten Zeilen senden**, nie den ganzen File
|
||||||
|
|
||||||
|
## Zeilennummern-Format
|
||||||
|
|
||||||
|
- Python/Shell: `code #Z1`
|
||||||
|
- JavaScript/Java/C/C++: `code //Z1`
|
||||||
|
- HTML: `code <!--Z1-->`
|
||||||
|
- CSS: `code /*Z1*/`
|
||||||
|
- SQL: `code --Z1`
|
||||||
|
|
||||||
|
## Befehle
|
||||||
|
|
||||||
|
| Befehl | Syntax | Beschreibung |
|
||||||
|
|--------|--------|--------------|
|
||||||
|
| `###FILE:` | `###FILE:pfad/datei.ext` | Datei angeben |
|
||||||
|
| `###NEW` | | Neue Datei, kompletter Inhalt folgt |
|
||||||
|
| `###DELETE:` | `###DELETE:Z5-Z12` | Zeilen 5-12 loeschen |
|
||||||
|
| `###INSERT_AFTER:` | `###INSERT_AFTER:Z5` | Nach Zeile 5 einfuegen |
|
||||||
|
| `###REPLACE:` | `###REPLACE:Z5-Z8` | Zeilen 5-8 ersetzen |
|
||||||
|
| `###END` | | Ende des Blocks |
|
||||||
|
| `###RENUMBER` | | Zeilennummern neu berechnen |
|
||||||
|
| `###CHECKSUM:` | `###CHECKSUM:a3f2b8c1` | Optional: Hash zur Validierung |
|
||||||
|
|
||||||
|
## Beispiel: Neue Datei
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###NEW
|
||||||
|
def add(a, b): #Z1
|
||||||
|
return a + b #Z2
|
||||||
|
#Z3
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
return a * b #Z5
|
||||||
|
###END
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beispiel: Korrektur (REPLACE)
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###REPLACE:Z4-Z5
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
"""Multipliziert zwei Zahlen.""" #Z5
|
||||||
|
return a * b #Z6
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beispiel: Zeilen einfuegen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###INSERT_AFTER:Z2
|
||||||
|
#Z3
|
||||||
|
def subtract(a, b): #Z4
|
||||||
|
return a - b #Z5
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beispiel: Zeilen loeschen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###DELETE:Z10-Z15
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beispiel: Mehrere Dateien
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/models/user.py
|
||||||
|
###NEW
|
||||||
|
class User: #Z1
|
||||||
|
def __init__(self, name: str): #Z2
|
||||||
|
self.name = name #Z3
|
||||||
|
###END
|
||||||
|
|
||||||
|
###FILE:src/models/order.py
|
||||||
|
###NEW
|
||||||
|
from .user import User #Z1
|
||||||
|
#Z2
|
||||||
|
class Order: #Z3
|
||||||
|
def __init__(self, user: User): #Z4
|
||||||
|
self.user = user #Z5
|
||||||
|
###END
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wichtig
|
||||||
|
|
||||||
|
- Bei Korrekturen: NUR Delta senden, nie kompletten File
|
||||||
|
- Nach INSERT/DELETE/REPLACE immer ###RENUMBER
|
||||||
|
- Leerzeilen auch nummerieren
|
||||||
|
- Zeilennummern werden beim Schreiben automatisch entfernt
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
# DCTP - Delta Code Transfer Protocol
|
||||||
|
|
||||||
|
Ein Tool um KI-generierten Code effizient in lokale Dateien zu uebertragen. Statt bei jeder Korrektur den kompletten Code neu zu senden, werden nur Aenderungen (Deltas) uebertragen.
|
||||||
|
|
||||||
|
## Das Problem
|
||||||
|
|
||||||
|
Claude generiert 500 Zeilen Code. Eine kleine Korrektur = nochmal 500 Zeilen. Verschwendung.
|
||||||
|
|
||||||
|
## Die Loesung
|
||||||
|
|
||||||
|
Zeilennummerierter Code + Steueranweisungen fuer gezielte Aenderungen.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requirements installieren
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# GUI starten
|
||||||
|
python dctp_gui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
### 1. Projektverzeichnis waehlen
|
||||||
|
|
||||||
|
Klicke auf "Waehlen" und waehle dein Projektverzeichnis.
|
||||||
|
|
||||||
|
### 2. KI-Output einfuegen
|
||||||
|
|
||||||
|
Kopiere den DCTP-formatierten Output aus deinem Claude-Chat in das Input-Feld.
|
||||||
|
|
||||||
|
### 3. Analysieren
|
||||||
|
|
||||||
|
Klicke "Analysieren" um eine Vorschau der Operationen zu sehen.
|
||||||
|
|
||||||
|
### 4. Ausfuehren
|
||||||
|
|
||||||
|
Klicke "Ausfuehren" um die Aenderungen auf deine Dateien anzuwenden.
|
||||||
|
|
||||||
|
## DCTP-Format
|
||||||
|
|
||||||
|
### Neue Datei erstellen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###NEW
|
||||||
|
def add(a, b): #Z1
|
||||||
|
return a + b #Z2
|
||||||
|
###END
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zeilen ersetzen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###REPLACE:Z1-Z2
|
||||||
|
def add(a: int, b: int) -> int: #Z1
|
||||||
|
"""Addiert zwei Zahlen.""" #Z2
|
||||||
|
return a + b #Z3
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zeilen einfuegen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###INSERT_AFTER:Z2
|
||||||
|
#Z3
|
||||||
|
def subtract(a, b): #Z4
|
||||||
|
return a - b #Z5
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zeilen loeschen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###DELETE:Z10-Z15
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zeilennummern-Format
|
||||||
|
|
||||||
|
Die Zeilennummern werden automatisch entsprechend der Programmiersprache formatiert:
|
||||||
|
|
||||||
|
| Sprache | Format | Beispiel |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| Python | `#Z1` | `code #Z1` |
|
||||||
|
| JavaScript | `//Z1` | `code //Z1` |
|
||||||
|
| HTML | `<!--Z1-->` | `code <!--Z1-->` |
|
||||||
|
| CSS | `/*Z1*/` | `code /*Z1*/` |
|
||||||
|
| SQL | `--Z1` | `code --Z1` |
|
||||||
|
|
||||||
|
## Befehle
|
||||||
|
|
||||||
|
| Befehl | Beschreibung |
|
||||||
|
|--------|--------------|
|
||||||
|
| `###FILE:pfad` | Zieldatei angeben |
|
||||||
|
| `###NEW` | Neue Datei erstellen |
|
||||||
|
| `###DELETE:Z5-Z12` | Zeilen loeschen |
|
||||||
|
| `###INSERT_AFTER:Z5` | Nach Zeile einfuegen |
|
||||||
|
| `###REPLACE:Z5-Z8` | Zeilen ersetzen |
|
||||||
|
| `###END` | Block beenden |
|
||||||
|
| `###RENUMBER` | Zeilennummern aktualisieren |
|
||||||
|
| `###CHECKSUM:hash` | Datei-Hash validieren |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Vorschau**: Zeigt was passieren wird, bevor es ausgefuehrt wird
|
||||||
|
- **Diff-Ansicht**: Zeigt Aenderungen farbig markiert (alt vs neu)
|
||||||
|
- **Undo**: Stellt den letzten Zustand wieder her
|
||||||
|
- **Backup**: Automatische Backups vor jeder Aenderung
|
||||||
|
- **Multi-File**: Mehrere Dateien in einem Durchgang bearbeiten
|
||||||
|
- **Checksum**: Optionale Validierung gegen externe Aenderungen
|
||||||
|
|
||||||
|
## Einstellungen
|
||||||
|
|
||||||
|
- **Projektpfad**: Standard-Projektverzeichnis
|
||||||
|
- **Backup-Verzeichnis**: Wo Backups gespeichert werden
|
||||||
|
- **Auto-Renumber**: Zeilennummern automatisch aktualisieren
|
||||||
|
- **Checksum-Validierung**: Externe Aenderungen erkennen
|
||||||
|
- **Theme**: Hell oder dunkel
|
||||||
|
|
||||||
|
## Claude-Integration
|
||||||
|
|
||||||
|
Kopiere den Inhalt von `CLAUDE.md` in deine Claude-Chats (als Custom Instructions oder am Anfang des Gespraechs), damit Claude im DCTP-Format antwortet.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
dctp/
|
||||||
|
├── dctp_gui.py # Hauptfenster (CustomTkinter)
|
||||||
|
├── dctp_parser.py # Core-Logik: parse Steueranweisungen
|
||||||
|
├── dctp_executor.py # Fuehrt Operationen aus
|
||||||
|
├── dctp_backup.py # Undo/Backup-Verwaltung
|
||||||
|
├── dctp_diff.py # Diff-Berechnung fuer Vorschau
|
||||||
|
├── requirements.txt # Dependencies
|
||||||
|
├── CLAUDE.md # Anweisung fuer KI
|
||||||
|
└── README.md # Diese Datei
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT License
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
DCTP Backup Manager - Handles backup and undo functionality.
|
||||||
|
|
||||||
|
Creates timestamped backups before operations and supports
|
||||||
|
restoring files to their previous state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileBackup:
|
||||||
|
"""Represents a single file backup."""
|
||||||
|
original: str
|
||||||
|
backup: str
|
||||||
|
existed: bool # Whether the file existed before (for new file handling)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackupSession:
|
||||||
|
"""Represents a backup session (one execution run)."""
|
||||||
|
timestamp: str
|
||||||
|
files: list[FileBackup]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackupInfo:
|
||||||
|
"""Info about a backup for display purposes."""
|
||||||
|
timestamp: str
|
||||||
|
file_count: int
|
||||||
|
files: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class BackupManager:
|
||||||
|
"""Manages file backups for undo functionality."""
|
||||||
|
|
||||||
|
BACKUP_DIR_NAME = ".dctp_backups"
|
||||||
|
MANIFEST_FILE = "manifest.json"
|
||||||
|
MAX_SESSIONS = 50 # Keep last 50 sessions
|
||||||
|
|
||||||
|
def __init__(self, project_path: str):
|
||||||
|
"""
|
||||||
|
Initialize backup manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_path: Base project directory
|
||||||
|
"""
|
||||||
|
self.project_path = Path(project_path)
|
||||||
|
self.backup_dir = self.project_path / self.BACKUP_DIR_NAME
|
||||||
|
self.manifest_path = self.backup_dir / self.MANIFEST_FILE
|
||||||
|
self._current_session: Optional[BackupSession] = None
|
||||||
|
self._ensure_backup_dir()
|
||||||
|
|
||||||
|
def _ensure_backup_dir(self) -> None:
|
||||||
|
"""Create backup directory if it doesn't exist."""
|
||||||
|
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create .gitignore in backup dir
|
||||||
|
gitignore_path = self.backup_dir / ".gitignore"
|
||||||
|
if not gitignore_path.exists():
|
||||||
|
gitignore_path.write_text("*\n")
|
||||||
|
|
||||||
|
def _load_manifest(self) -> dict:
|
||||||
|
"""Load the manifest file."""
|
||||||
|
if self.manifest_path.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(self.manifest_path.read_text())
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return {"sessions": []}
|
||||||
|
return {"sessions": []}
|
||||||
|
|
||||||
|
def _save_manifest(self, manifest: dict) -> None:
|
||||||
|
"""Save the manifest file."""
|
||||||
|
self.manifest_path.write_text(json.dumps(manifest, indent=2))
|
||||||
|
|
||||||
|
def start_session(self) -> None:
|
||||||
|
"""Start a new backup session."""
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
self._current_session = BackupSession(timestamp=timestamp, files=[])
|
||||||
|
|
||||||
|
def backup(self, file_path: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Create a backup of a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the file (relative to project or absolute)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Backup filename if successful, None if file doesn't exist
|
||||||
|
"""
|
||||||
|
if self._current_session is None:
|
||||||
|
self.start_session()
|
||||||
|
|
||||||
|
# Normalize path
|
||||||
|
if os.path.isabs(file_path):
|
||||||
|
full_path = Path(file_path)
|
||||||
|
rel_path = full_path.relative_to(self.project_path)
|
||||||
|
else:
|
||||||
|
rel_path = Path(file_path)
|
||||||
|
full_path = self.project_path / rel_path
|
||||||
|
|
||||||
|
# Check if file exists
|
||||||
|
existed = full_path.exists()
|
||||||
|
|
||||||
|
if existed:
|
||||||
|
# Generate backup filename
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||||
|
safe_name = str(rel_path).replace(os.sep, "_").replace("/", "_")
|
||||||
|
backup_name = f"{timestamp}_{safe_name}"
|
||||||
|
|
||||||
|
# Copy file to backup
|
||||||
|
backup_path = self.backup_dir / backup_name
|
||||||
|
shutil.copy2(full_path, backup_path)
|
||||||
|
else:
|
||||||
|
backup_name = ""
|
||||||
|
|
||||||
|
# Add to current session
|
||||||
|
self._current_session.files.append(FileBackup(
|
||||||
|
original=str(rel_path),
|
||||||
|
backup=backup_name,
|
||||||
|
existed=existed
|
||||||
|
))
|
||||||
|
|
||||||
|
return backup_name if existed else None
|
||||||
|
|
||||||
|
def end_session(self) -> None:
|
||||||
|
"""End the current backup session and save to manifest."""
|
||||||
|
if self._current_session is None or len(self._current_session.files) == 0:
|
||||||
|
self._current_session = None
|
||||||
|
return
|
||||||
|
|
||||||
|
manifest = self._load_manifest()
|
||||||
|
|
||||||
|
# Convert to dict for JSON storage
|
||||||
|
session_dict = {
|
||||||
|
"timestamp": self._current_session.timestamp,
|
||||||
|
"files": [asdict(f) for f in self._current_session.files]
|
||||||
|
}
|
||||||
|
manifest["sessions"].append(session_dict)
|
||||||
|
|
||||||
|
# Limit number of sessions
|
||||||
|
if len(manifest["sessions"]) > self.MAX_SESSIONS:
|
||||||
|
# Remove old sessions and their backup files
|
||||||
|
old_sessions = manifest["sessions"][:-self.MAX_SESSIONS]
|
||||||
|
for session in old_sessions:
|
||||||
|
for file_info in session["files"]:
|
||||||
|
backup_file = self.backup_dir / file_info["backup"]
|
||||||
|
if backup_file.exists():
|
||||||
|
backup_file.unlink()
|
||||||
|
manifest["sessions"] = manifest["sessions"][-self.MAX_SESSIONS:]
|
||||||
|
|
||||||
|
self._save_manifest(manifest)
|
||||||
|
self._current_session = None
|
||||||
|
|
||||||
|
def restore_last(self) -> tuple[bool, list[str]]:
|
||||||
|
"""
|
||||||
|
Restore files from the last backup session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, list of restored files)
|
||||||
|
"""
|
||||||
|
manifest = self._load_manifest()
|
||||||
|
|
||||||
|
if not manifest["sessions"]:
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
# Get last session
|
||||||
|
last_session = manifest["sessions"].pop()
|
||||||
|
restored_files = []
|
||||||
|
|
||||||
|
for file_info in last_session["files"]:
|
||||||
|
original_path = self.project_path / file_info["original"]
|
||||||
|
|
||||||
|
if file_info["existed"]:
|
||||||
|
# Restore from backup
|
||||||
|
backup_path = self.backup_dir / file_info["backup"]
|
||||||
|
if backup_path.exists():
|
||||||
|
# Ensure parent directory exists
|
||||||
|
original_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(backup_path, original_path)
|
||||||
|
backup_path.unlink() # Remove backup file
|
||||||
|
restored_files.append(file_info["original"])
|
||||||
|
else:
|
||||||
|
# File was newly created, delete it
|
||||||
|
if original_path.exists():
|
||||||
|
original_path.unlink()
|
||||||
|
restored_files.append(f"{file_info['original']} (deleted)")
|
||||||
|
|
||||||
|
self._save_manifest(manifest)
|
||||||
|
return True, restored_files
|
||||||
|
|
||||||
|
def list_backups(self) -> list[BackupInfo]:
|
||||||
|
"""
|
||||||
|
List all backup sessions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of BackupInfo objects, newest first
|
||||||
|
"""
|
||||||
|
manifest = self._load_manifest()
|
||||||
|
backups = []
|
||||||
|
|
||||||
|
for session in reversed(manifest["sessions"]):
|
||||||
|
files = [f["original"] for f in session["files"]]
|
||||||
|
backups.append(BackupInfo(
|
||||||
|
timestamp=session["timestamp"],
|
||||||
|
file_count=len(files),
|
||||||
|
files=files
|
||||||
|
))
|
||||||
|
|
||||||
|
return backups
|
||||||
|
|
||||||
|
def get_file_backup_path(self, file_path: str) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Get the backup path for a file from the most recent session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Original file path (relative to project)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to backup file if found, None otherwise
|
||||||
|
"""
|
||||||
|
manifest = self._load_manifest()
|
||||||
|
|
||||||
|
if not manifest["sessions"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Search from newest to oldest
|
||||||
|
for session in reversed(manifest["sessions"]):
|
||||||
|
for file_info in session["files"]:
|
||||||
|
if file_info["original"] == file_path and file_info["existed"]:
|
||||||
|
backup_path = self.backup_dir / file_info["backup"]
|
||||||
|
if backup_path.exists():
|
||||||
|
return backup_path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def clear_all_backups(self) -> int:
|
||||||
|
"""
|
||||||
|
Clear all backups.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of backup files deleted
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
if self.backup_dir.exists():
|
||||||
|
for item in self.backup_dir.iterdir():
|
||||||
|
if item.name != ".gitignore":
|
||||||
|
if item.is_file():
|
||||||
|
item.unlink()
|
||||||
|
count += 1
|
||||||
|
elif item.is_dir():
|
||||||
|
shutil.rmtree(item)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Reset manifest
|
||||||
|
self._save_manifest({"sessions": []})
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test the backup manager."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Create a temporary project directory
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create some test files
|
||||||
|
test_file = Path(tmpdir) / "test.py"
|
||||||
|
test_file.write_text("print('hello')\n")
|
||||||
|
|
||||||
|
# Initialize backup manager
|
||||||
|
manager = BackupManager(tmpdir)
|
||||||
|
|
||||||
|
# Start a session and backup the file
|
||||||
|
manager.start_session()
|
||||||
|
backup_name = manager.backup("test.py")
|
||||||
|
print(f"Created backup: {backup_name}")
|
||||||
|
|
||||||
|
# Modify the file
|
||||||
|
test_file.write_text("print('modified')\n")
|
||||||
|
print(f"File content after modification: {test_file.read_text()}")
|
||||||
|
|
||||||
|
# End session
|
||||||
|
manager.end_session()
|
||||||
|
|
||||||
|
# List backups
|
||||||
|
backups = manager.list_backups()
|
||||||
|
print(f"Backup sessions: {len(backups)}")
|
||||||
|
for b in backups:
|
||||||
|
print(f" {b.timestamp}: {b.file_count} files")
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
success, restored = manager.restore_last()
|
||||||
|
print(f"Restore successful: {success}")
|
||||||
|
print(f"Restored files: {restored}")
|
||||||
|
print(f"File content after restore: {test_file.read_text()}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""
|
||||||
|
DCTP Diff Generator - Generates diffs for preview display.
|
||||||
|
|
||||||
|
Compares old and new content and produces colored diff output
|
||||||
|
for the GUI preview.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class DiffType(Enum):
|
||||||
|
UNCHANGED = "unchanged"
|
||||||
|
ADDED = "added"
|
||||||
|
REMOVED = "removed"
|
||||||
|
CONTEXT = "context"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiffLine:
|
||||||
|
"""Represents a single line in a diff."""
|
||||||
|
type: DiffType
|
||||||
|
line_number_old: Optional[int] # Line number in old file
|
||||||
|
line_number_new: Optional[int] # Line number in new file
|
||||||
|
content: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prefix(self) -> str:
|
||||||
|
"""Get the diff prefix character."""
|
||||||
|
if self.type == DiffType.ADDED:
|
||||||
|
return "+"
|
||||||
|
elif self.type == DiffType.REMOVED:
|
||||||
|
return "-"
|
||||||
|
else:
|
||||||
|
return " "
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
old_num = str(self.line_number_old) if self.line_number_old else ""
|
||||||
|
new_num = str(self.line_number_new) if self.line_number_new else ""
|
||||||
|
return f"{old_num:>4} {new_num:>4} {self.prefix} {self.content}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiffBlock:
|
||||||
|
"""A block of related diff lines."""
|
||||||
|
start_old: int
|
||||||
|
end_old: int
|
||||||
|
start_new: int
|
||||||
|
end_new: int
|
||||||
|
lines: list[DiffLine]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def header(self) -> str:
|
||||||
|
"""Generate a unified diff style header."""
|
||||||
|
return f"@@ -{self.start_old},{self.end_old - self.start_old + 1} +{self.start_new},{self.end_new - self.start_new + 1} @@"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileDiff:
|
||||||
|
"""Complete diff for a file."""
|
||||||
|
filename: str
|
||||||
|
old_content: list[str]
|
||||||
|
new_content: list[str]
|
||||||
|
blocks: list[DiffBlock]
|
||||||
|
lines: list[DiffLine]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_changes(self) -> bool:
|
||||||
|
return any(line.type in (DiffType.ADDED, DiffType.REMOVED) for line in self.lines)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def additions(self) -> int:
|
||||||
|
return sum(1 for line in self.lines if line.type == DiffType.ADDED)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deletions(self) -> int:
|
||||||
|
return sum(1 for line in self.lines if line.type == DiffType.REMOVED)
|
||||||
|
|
||||||
|
|
||||||
|
class DiffGenerator:
|
||||||
|
"""Generates diffs between old and new content."""
|
||||||
|
|
||||||
|
def __init__(self, context_lines: int = 3):
|
||||||
|
"""
|
||||||
|
Initialize diff generator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context_lines: Number of context lines around changes
|
||||||
|
"""
|
||||||
|
self.context_lines = context_lines
|
||||||
|
|
||||||
|
def generate(
|
||||||
|
self,
|
||||||
|
old_lines: list[str],
|
||||||
|
new_lines: list[str],
|
||||||
|
filename: str = ""
|
||||||
|
) -> FileDiff:
|
||||||
|
"""
|
||||||
|
Generate a diff between old and new content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_lines: Original content lines
|
||||||
|
new_lines: New content lines
|
||||||
|
filename: Optional filename for display
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FileDiff object with all diff information
|
||||||
|
"""
|
||||||
|
diff_lines: list[DiffLine] = []
|
||||||
|
|
||||||
|
# Use difflib to compute differences
|
||||||
|
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
|
||||||
|
|
||||||
|
old_line_num = 1
|
||||||
|
new_line_num = 1
|
||||||
|
|
||||||
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
|
if tag == 'equal':
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.UNCHANGED,
|
||||||
|
line_number_old=old_line_num,
|
||||||
|
line_number_new=new_line_num,
|
||||||
|
content=old_lines[i1 + idx]
|
||||||
|
))
|
||||||
|
old_line_num += 1
|
||||||
|
new_line_num += 1
|
||||||
|
|
||||||
|
elif tag == 'replace':
|
||||||
|
# Show removed lines first, then added
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.REMOVED,
|
||||||
|
line_number_old=old_line_num,
|
||||||
|
line_number_new=None,
|
||||||
|
content=old_lines[i1 + idx]
|
||||||
|
))
|
||||||
|
old_line_num += 1
|
||||||
|
|
||||||
|
for idx in range(j2 - j1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.ADDED,
|
||||||
|
line_number_old=None,
|
||||||
|
line_number_new=new_line_num,
|
||||||
|
content=new_lines[j1 + idx]
|
||||||
|
))
|
||||||
|
new_line_num += 1
|
||||||
|
|
||||||
|
elif tag == 'delete':
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.REMOVED,
|
||||||
|
line_number_old=old_line_num,
|
||||||
|
line_number_new=None,
|
||||||
|
content=old_lines[i1 + idx]
|
||||||
|
))
|
||||||
|
old_line_num += 1
|
||||||
|
|
||||||
|
elif tag == 'insert':
|
||||||
|
for idx in range(j2 - j1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.ADDED,
|
||||||
|
line_number_old=None,
|
||||||
|
line_number_new=new_line_num,
|
||||||
|
content=new_lines[j1 + idx]
|
||||||
|
))
|
||||||
|
new_line_num += 1
|
||||||
|
|
||||||
|
# Generate blocks with context
|
||||||
|
blocks = self._generate_blocks(diff_lines)
|
||||||
|
|
||||||
|
return FileDiff(
|
||||||
|
filename=filename,
|
||||||
|
old_content=old_lines,
|
||||||
|
new_content=new_lines,
|
||||||
|
blocks=blocks,
|
||||||
|
lines=diff_lines
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_blocks(self, diff_lines: list[DiffLine]) -> list[DiffBlock]:
|
||||||
|
"""Generate diff blocks with context."""
|
||||||
|
if not diff_lines:
|
||||||
|
return []
|
||||||
|
|
||||||
|
blocks: list[DiffBlock] = []
|
||||||
|
current_block_lines: list[DiffLine] = []
|
||||||
|
in_change = False
|
||||||
|
unchanged_count = 0
|
||||||
|
|
||||||
|
for line in diff_lines:
|
||||||
|
is_change = line.type in (DiffType.ADDED, DiffType.REMOVED)
|
||||||
|
|
||||||
|
if is_change:
|
||||||
|
if not in_change:
|
||||||
|
# Starting a new change block, include context
|
||||||
|
in_change = True
|
||||||
|
unchanged_count = 0
|
||||||
|
current_block_lines.append(line)
|
||||||
|
|
||||||
|
else: # Unchanged line
|
||||||
|
if in_change:
|
||||||
|
unchanged_count += 1
|
||||||
|
if unchanged_count <= self.context_lines:
|
||||||
|
current_block_lines.append(line)
|
||||||
|
else:
|
||||||
|
# End current block and start fresh
|
||||||
|
if current_block_lines:
|
||||||
|
blocks.append(self._create_block(current_block_lines))
|
||||||
|
current_block_lines = []
|
||||||
|
in_change = False
|
||||||
|
unchanged_count = 0
|
||||||
|
else:
|
||||||
|
# Keep track of potential context lines
|
||||||
|
current_block_lines.append(line)
|
||||||
|
if len(current_block_lines) > self.context_lines:
|
||||||
|
current_block_lines.pop(0)
|
||||||
|
|
||||||
|
# Don't forget the last block
|
||||||
|
if current_block_lines and any(
|
||||||
|
l.type in (DiffType.ADDED, DiffType.REMOVED) for l in current_block_lines
|
||||||
|
):
|
||||||
|
blocks.append(self._create_block(current_block_lines))
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
def _create_block(self, lines: list[DiffLine]) -> DiffBlock:
|
||||||
|
"""Create a DiffBlock from a list of lines."""
|
||||||
|
old_nums = [l.line_number_old for l in lines if l.line_number_old is not None]
|
||||||
|
new_nums = [l.line_number_new for l in lines if l.line_number_new is not None]
|
||||||
|
|
||||||
|
return DiffBlock(
|
||||||
|
start_old=min(old_nums) if old_nums else 0,
|
||||||
|
end_old=max(old_nums) if old_nums else 0,
|
||||||
|
start_new=min(new_nums) if new_nums else 0,
|
||||||
|
end_new=max(new_nums) if new_nums else 0,
|
||||||
|
lines=lines
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_unified_diff(
|
||||||
|
self,
|
||||||
|
old_lines: list[str],
|
||||||
|
new_lines: list[str],
|
||||||
|
old_filename: str = "a/file",
|
||||||
|
new_filename: str = "b/file"
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate a unified diff string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_lines: Original content lines
|
||||||
|
new_lines: New content lines
|
||||||
|
old_filename: Label for old file
|
||||||
|
new_filename: Label for new file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Unified diff as string
|
||||||
|
"""
|
||||||
|
diff = difflib.unified_diff(
|
||||||
|
old_lines,
|
||||||
|
new_lines,
|
||||||
|
fromfile=old_filename,
|
||||||
|
tofile=new_filename,
|
||||||
|
lineterm=""
|
||||||
|
)
|
||||||
|
return "\n".join(diff)
|
||||||
|
|
||||||
|
def generate_side_by_side(
|
||||||
|
self,
|
||||||
|
old_lines: list[str],
|
||||||
|
new_lines: list[str],
|
||||||
|
width: int = 80
|
||||||
|
) -> list[tuple[str, str, str]]:
|
||||||
|
"""
|
||||||
|
Generate a side-by-side diff representation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_lines: Original content lines
|
||||||
|
new_lines: New content lines
|
||||||
|
width: Width for each column
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples (left_line, marker, right_line)
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
half_width = (width - 3) // 2
|
||||||
|
|
||||||
|
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
|
||||||
|
|
||||||
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
|
if tag == 'equal':
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
line = old_lines[i1 + idx][:half_width]
|
||||||
|
result.append((line, " ", line))
|
||||||
|
|
||||||
|
elif tag == 'replace':
|
||||||
|
max_len = max(i2 - i1, j2 - j1)
|
||||||
|
for idx in range(max_len):
|
||||||
|
old_line = old_lines[i1 + idx][:half_width] if idx < i2 - i1 else ""
|
||||||
|
new_line = new_lines[j1 + idx][:half_width] if idx < j2 - j1 else ""
|
||||||
|
result.append((old_line, "|", new_line))
|
||||||
|
|
||||||
|
elif tag == 'delete':
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
old_line = old_lines[i1 + idx][:half_width]
|
||||||
|
result.append((old_line, "<", ""))
|
||||||
|
|
||||||
|
elif tag == 'insert':
|
||||||
|
for idx in range(j2 - j1):
|
||||||
|
new_line = new_lines[j1 + idx][:half_width]
|
||||||
|
result.append(("", ">", new_line))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def format_diff_for_display(diff: FileDiff, use_colors: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
Format a FileDiff for terminal/GUI display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
diff: The FileDiff to format
|
||||||
|
use_colors: Whether to use ANSI colors
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
if diff.filename:
|
||||||
|
lines.append(f"--- {diff.filename}")
|
||||||
|
lines.append(f"+++ {diff.filename}")
|
||||||
|
|
||||||
|
for line in diff.lines:
|
||||||
|
if use_colors:
|
||||||
|
if line.type == DiffType.ADDED:
|
||||||
|
prefix = "\033[32m+" # Green
|
||||||
|
suffix = "\033[0m"
|
||||||
|
elif line.type == DiffType.REMOVED:
|
||||||
|
prefix = "\033[31m-" # Red
|
||||||
|
suffix = "\033[0m"
|
||||||
|
else:
|
||||||
|
prefix = " "
|
||||||
|
suffix = ""
|
||||||
|
else:
|
||||||
|
prefix = line.prefix
|
||||||
|
suffix = ""
|
||||||
|
|
||||||
|
lines.append(f"{prefix} {line.content}{suffix}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test the diff generator."""
|
||||||
|
old_content = [
|
||||||
|
"def calculate_tax(amount):",
|
||||||
|
" rate = 0.19",
|
||||||
|
" if amount > 1000:",
|
||||||
|
" rate = 0.25",
|
||||||
|
" return amount * rate",
|
||||||
|
]
|
||||||
|
|
||||||
|
new_content = [
|
||||||
|
"def calculate_tax(amount):",
|
||||||
|
" rate = 0.19",
|
||||||
|
" if amount > 10000:",
|
||||||
|
" rate = 0.22",
|
||||||
|
" elif amount > 1000:",
|
||||||
|
" rate = 0.19",
|
||||||
|
" return amount * rate",
|
||||||
|
]
|
||||||
|
|
||||||
|
generator = DiffGenerator()
|
||||||
|
diff = generator.generate(old_content, new_content, "calculator.py")
|
||||||
|
|
||||||
|
print(f"File: {diff.filename}")
|
||||||
|
print(f"Additions: {diff.additions}, Deletions: {diff.deletions}")
|
||||||
|
print()
|
||||||
|
print("Diff output:")
|
||||||
|
print(format_diff_for_display(diff))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
"""
|
||||||
|
DCTP Executor - Executes DCTP operations on files.
|
||||||
|
|
||||||
|
Handles CREATE, DELETE, INSERT_AFTER, REPLACE, and RENUMBER operations
|
||||||
|
with backup support and checksum validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from dctp_parser import DCTPParser, Operation, OperationType
|
||||||
|
from dctp_backup import BackupManager
|
||||||
|
from dctp_diff import DiffGenerator, FileDiff
|
||||||
|
|
||||||
|
|
||||||
|
class ResultStatus(Enum):
|
||||||
|
SUCCESS = "success"
|
||||||
|
WARNING = "warning"
|
||||||
|
ERROR = "error"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExecutionResult:
|
||||||
|
"""Result of executing a single operation."""
|
||||||
|
status: ResultStatus
|
||||||
|
operation: Operation
|
||||||
|
message: str
|
||||||
|
diff: Optional[FileDiff] = None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
status_symbols = {
|
||||||
|
ResultStatus.SUCCESS: "✅",
|
||||||
|
ResultStatus.WARNING: "⚠️",
|
||||||
|
ResultStatus.ERROR: "❌",
|
||||||
|
ResultStatus.SKIPPED: "⏭️",
|
||||||
|
}
|
||||||
|
return f"{status_symbols[self.status]} {self.message}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PreviewResult:
|
||||||
|
"""Result of previewing operations before execution."""
|
||||||
|
operation: Operation
|
||||||
|
description: str
|
||||||
|
diff: Optional[FileDiff] = None
|
||||||
|
warnings: list[str] = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.warnings is None:
|
||||||
|
self.warnings = []
|
||||||
|
|
||||||
|
|
||||||
|
class DCTPExecutor:
|
||||||
|
"""Executes DCTP operations on files."""
|
||||||
|
|
||||||
|
# Line number patterns (same as parser)
|
||||||
|
LINE_NUMBER_PATTERNS = [
|
||||||
|
re.compile(r'\s*#Z(\d+)\s*$'),
|
||||||
|
re.compile(r'\s*//Z(\d+)\s*$'),
|
||||||
|
re.compile(r'\s*<!--Z(\d+)-->\s*$'),
|
||||||
|
re.compile(r'\s*/\*Z(\d+)\*/\s*$'),
|
||||||
|
re.compile(r'\s*--Z(\d+)\s*$'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
project_path: str,
|
||||||
|
backup_manager: Optional[BackupManager] = None,
|
||||||
|
auto_renumber: bool = True,
|
||||||
|
validate_checksums: bool = True
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the executor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_path: Base project directory
|
||||||
|
backup_manager: Optional backup manager for undo support
|
||||||
|
auto_renumber: Automatically renumber after operations
|
||||||
|
validate_checksums: Validate checksums before operations
|
||||||
|
"""
|
||||||
|
self.project_path = Path(project_path)
|
||||||
|
self.backup_manager = backup_manager or BackupManager(project_path)
|
||||||
|
self.auto_renumber = auto_renumber
|
||||||
|
self.validate_checksums = validate_checksums
|
||||||
|
self.diff_generator = DiffGenerator()
|
||||||
|
self.parser = DCTPParser()
|
||||||
|
|
||||||
|
def preview(self, operations: list[Operation]) -> list[PreviewResult]:
|
||||||
|
"""
|
||||||
|
Preview operations without executing them.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operations: List of operations to preview
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of preview results
|
||||||
|
"""
|
||||||
|
previews = []
|
||||||
|
|
||||||
|
for op in operations:
|
||||||
|
preview = self._preview_operation(op)
|
||||||
|
previews.append(preview)
|
||||||
|
|
||||||
|
return previews
|
||||||
|
|
||||||
|
def _preview_operation(self, op: Operation) -> PreviewResult:
|
||||||
|
"""Generate preview for a single operation."""
|
||||||
|
file_path = self.project_path / op.file
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
if op.type == OperationType.NEW:
|
||||||
|
if file_path.exists():
|
||||||
|
warnings.append(f"File already exists and will be overwritten")
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"CREATE {op.file} ({len(op.content)} lines)",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
elif op.type == OperationType.DELETE:
|
||||||
|
if not file_path.exists():
|
||||||
|
warnings.append(f"File does not exist")
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"DELETE {op.file} Z{op.start_line}-Z{op.end_line} (file not found)",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
old_lines = self._read_file_lines(file_path)
|
||||||
|
if op.end_line > len(old_lines):
|
||||||
|
warnings.append(f"Line range exceeds file length ({len(old_lines)} lines)")
|
||||||
|
|
||||||
|
new_lines = old_lines.copy()
|
||||||
|
start_idx = op.start_line - 1
|
||||||
|
end_idx = min(op.end_line, len(old_lines))
|
||||||
|
del new_lines[start_idx:end_idx]
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, new_lines, op.file)
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"DELETE {op.file} Z{op.start_line}-Z{op.end_line}",
|
||||||
|
diff=diff,
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
elif op.type == OperationType.INSERT_AFTER:
|
||||||
|
if not file_path.exists():
|
||||||
|
warnings.append(f"File does not exist")
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"INSERT_AFTER {op.file} Z{op.start_line} (file not found)",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
old_lines = self._read_file_lines(file_path)
|
||||||
|
if op.start_line > len(old_lines):
|
||||||
|
warnings.append(f"Line {op.start_line} exceeds file length ({len(old_lines)} lines)")
|
||||||
|
|
||||||
|
new_lines = old_lines.copy()
|
||||||
|
insert_idx = min(op.start_line, len(old_lines))
|
||||||
|
for i, line in enumerate(op.content):
|
||||||
|
new_lines.insert(insert_idx + i, line)
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, new_lines, op.file)
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"INSERT_AFTER {op.file} Z{op.start_line} ({len(op.content)} lines)",
|
||||||
|
diff=diff,
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
elif op.type == OperationType.REPLACE:
|
||||||
|
if not file_path.exists():
|
||||||
|
warnings.append(f"File does not exist")
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"REPLACE {op.file} Z{op.start_line}-Z{op.end_line} (file not found)",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
old_lines = self._read_file_lines(file_path)
|
||||||
|
if op.end_line > len(old_lines):
|
||||||
|
warnings.append(f"Line range exceeds file length ({len(old_lines)} lines)")
|
||||||
|
|
||||||
|
new_lines = old_lines.copy()
|
||||||
|
start_idx = op.start_line - 1
|
||||||
|
end_idx = min(op.end_line, len(old_lines))
|
||||||
|
new_lines[start_idx:end_idx] = op.content
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, new_lines, op.file)
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"REPLACE {op.file} Z{op.start_line}-Z{op.end_line} ({len(op.content)} lines)",
|
||||||
|
diff=diff,
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
elif op.type == OperationType.RENUMBER:
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"RENUMBER {op.file}",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"UNKNOWN {op.type}",
|
||||||
|
warnings=["Unknown operation type"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
operations: list[Operation],
|
||||||
|
skip_checksum_mismatch: bool = False
|
||||||
|
) -> list[ExecutionResult]:
|
||||||
|
"""
|
||||||
|
Execute a list of operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operations: List of operations to execute
|
||||||
|
skip_checksum_mismatch: Continue even if checksums don't match
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of execution results
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Start backup session
|
||||||
|
self.backup_manager.start_session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for op in operations:
|
||||||
|
result = self._execute_operation(op, skip_checksum_mismatch)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
# Stop on error
|
||||||
|
if result.status == ResultStatus.ERROR:
|
||||||
|
break
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# End backup session
|
||||||
|
self.backup_manager.end_session()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _execute_operation(
|
||||||
|
self,
|
||||||
|
op: Operation,
|
||||||
|
skip_checksum_mismatch: bool = False
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""Execute a single operation."""
|
||||||
|
file_path = self.project_path / op.file
|
||||||
|
|
||||||
|
try:
|
||||||
|
if op.type == OperationType.NEW:
|
||||||
|
return self._execute_new(op, file_path)
|
||||||
|
elif op.type == OperationType.DELETE:
|
||||||
|
return self._execute_delete(op, file_path, skip_checksum_mismatch)
|
||||||
|
elif op.type == OperationType.INSERT_AFTER:
|
||||||
|
return self._execute_insert_after(op, file_path, skip_checksum_mismatch)
|
||||||
|
elif op.type == OperationType.REPLACE:
|
||||||
|
return self._execute_replace(op, file_path, skip_checksum_mismatch)
|
||||||
|
elif op.type == OperationType.RENUMBER:
|
||||||
|
return self._execute_renumber(op, file_path)
|
||||||
|
else:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.ERROR,
|
||||||
|
operation=op,
|
||||||
|
message=f"Unknown operation type: {op.type}"
|
||||||
|
)
|
||||||
|
except PermissionError:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.ERROR,
|
||||||
|
operation=op,
|
||||||
|
message=f"Permission denied: {file_path}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.ERROR,
|
||||||
|
operation=op,
|
||||||
|
message=f"Error: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_new(self, op: Operation, file_path: Path) -> ExecutionResult:
|
||||||
|
"""Execute a NEW operation (create file)."""
|
||||||
|
# Backup if file exists
|
||||||
|
if file_path.exists():
|
||||||
|
self.backup_manager.backup(str(op.file))
|
||||||
|
|
||||||
|
# Create parent directories
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write content
|
||||||
|
content = "\n".join(op.content)
|
||||||
|
if op.content and not content.endswith("\n"):
|
||||||
|
content += "\n"
|
||||||
|
file_path.write_text(content)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"CREATE {op.file} ({len(op.content)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_delete(
|
||||||
|
self,
|
||||||
|
op: Operation,
|
||||||
|
file_path: Path,
|
||||||
|
skip_checksum_mismatch: bool
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""Execute a DELETE operation."""
|
||||||
|
if not file_path.exists():
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"File not found: {op.file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate checksum if provided
|
||||||
|
if op.checksum and self.validate_checksums:
|
||||||
|
if not self._validate_checksum(file_path, op.checksum):
|
||||||
|
if not skip_checksum_mismatch:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Checksum mismatch for {op.file} - file was modified externally"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backup file
|
||||||
|
self.backup_manager.backup(str(op.file))
|
||||||
|
|
||||||
|
# Read file and delete lines
|
||||||
|
lines = self._read_file_lines(file_path)
|
||||||
|
old_lines = lines.copy()
|
||||||
|
|
||||||
|
if op.end_line > len(lines):
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Line range Z{op.start_line}-Z{op.end_line} exceeds file length ({len(lines)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
start_idx = op.start_line - 1
|
||||||
|
end_idx = op.end_line
|
||||||
|
del lines[start_idx:end_idx]
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
self._write_file_lines(file_path, lines)
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, lines, op.file)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"DELETE {op.file} Z{op.start_line}-Z{op.end_line}",
|
||||||
|
diff=diff
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_insert_after(
|
||||||
|
self,
|
||||||
|
op: Operation,
|
||||||
|
file_path: Path,
|
||||||
|
skip_checksum_mismatch: bool
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""Execute an INSERT_AFTER operation."""
|
||||||
|
if not file_path.exists():
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"File not found: {op.file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate checksum if provided
|
||||||
|
if op.checksum and self.validate_checksums:
|
||||||
|
if not self._validate_checksum(file_path, op.checksum):
|
||||||
|
if not skip_checksum_mismatch:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Checksum mismatch for {op.file} - file was modified externally"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backup file
|
||||||
|
self.backup_manager.backup(str(op.file))
|
||||||
|
|
||||||
|
# Read file and insert lines
|
||||||
|
lines = self._read_file_lines(file_path)
|
||||||
|
old_lines = lines.copy()
|
||||||
|
|
||||||
|
if op.start_line > len(lines):
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Line Z{op.start_line} exceeds file length ({len(lines)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
insert_idx = op.start_line
|
||||||
|
for i, line in enumerate(op.content):
|
||||||
|
lines.insert(insert_idx + i, line)
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
self._write_file_lines(file_path, lines)
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, lines, op.file)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"INSERT_AFTER {op.file} Z{op.start_line} ({len(op.content)} lines)",
|
||||||
|
diff=diff
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_replace(
|
||||||
|
self,
|
||||||
|
op: Operation,
|
||||||
|
file_path: Path,
|
||||||
|
skip_checksum_mismatch: bool
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""Execute a REPLACE operation."""
|
||||||
|
if not file_path.exists():
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"File not found: {op.file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate checksum if provided
|
||||||
|
if op.checksum and self.validate_checksums:
|
||||||
|
if not self._validate_checksum(file_path, op.checksum):
|
||||||
|
if not skip_checksum_mismatch:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Checksum mismatch for {op.file} - file was modified externally"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backup file
|
||||||
|
self.backup_manager.backup(str(op.file))
|
||||||
|
|
||||||
|
# Read file and replace lines
|
||||||
|
lines = self._read_file_lines(file_path)
|
||||||
|
old_lines = lines.copy()
|
||||||
|
|
||||||
|
if op.end_line > len(lines):
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Line range Z{op.start_line}-Z{op.end_line} exceeds file length ({len(lines)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
start_idx = op.start_line - 1
|
||||||
|
end_idx = op.end_line
|
||||||
|
lines[start_idx:end_idx] = op.content
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
self._write_file_lines(file_path, lines)
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, lines, op.file)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"REPLACE {op.file} Z{op.start_line}-Z{op.end_line} ({len(op.content)} lines)",
|
||||||
|
diff=diff
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_renumber(self, op: Operation, file_path: Path) -> ExecutionResult:
|
||||||
|
"""Execute a RENUMBER operation."""
|
||||||
|
if not file_path.exists():
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"File not found: {op.file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No backup needed for renumber (just updates line numbers)
|
||||||
|
lines = self._read_file_lines(file_path)
|
||||||
|
renumbered_lines = []
|
||||||
|
|
||||||
|
for i, line in enumerate(lines, 1):
|
||||||
|
# Remove existing line number
|
||||||
|
clean_line = self._remove_line_number(line)
|
||||||
|
# Add new line number
|
||||||
|
suffix = self.parser.get_line_number_suffix(op.file, i)
|
||||||
|
renumbered_lines.append(clean_line + suffix)
|
||||||
|
|
||||||
|
self._write_file_lines(file_path, renumbered_lines)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"RENUMBER {op.file} ({len(lines)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_file_lines(self, file_path: Path) -> list[str]:
|
||||||
|
"""Read file and return lines without trailing newlines."""
|
||||||
|
content = file_path.read_text()
|
||||||
|
lines = content.split('\n')
|
||||||
|
# Remove trailing empty line if file ends with newline
|
||||||
|
if lines and lines[-1] == '':
|
||||||
|
lines = lines[:-1]
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _write_file_lines(self, file_path: Path, lines: list[str]) -> None:
|
||||||
|
"""Write lines to file with trailing newline."""
|
||||||
|
content = '\n'.join(lines)
|
||||||
|
if lines and not content.endswith('\n'):
|
||||||
|
content += '\n'
|
||||||
|
file_path.write_text(content)
|
||||||
|
|
||||||
|
def _remove_line_number(self, line: str) -> str:
|
||||||
|
"""Remove line number marker from end of line."""
|
||||||
|
for pattern in self.LINE_NUMBER_PATTERNS:
|
||||||
|
match = pattern.search(line)
|
||||||
|
if match:
|
||||||
|
return line[:match.start()]
|
||||||
|
return line
|
||||||
|
|
||||||
|
def _validate_checksum(self, file_path: Path, expected: str) -> bool:
|
||||||
|
"""Validate file checksum."""
|
||||||
|
content = file_path.read_bytes()
|
||||||
|
actual = hashlib.md5(content).hexdigest()[:8]
|
||||||
|
return actual.lower() == expected.lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_checksum(file_path: Path) -> str:
|
||||||
|
"""Calculate checksum for a file."""
|
||||||
|
content = file_path.read_bytes()
|
||||||
|
return hashlib.md5(content).hexdigest()[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test the executor."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
test_input = """###FILE:calculator.py
|
||||||
|
###NEW
|
||||||
|
def add(a, b): #Z1
|
||||||
|
return a + b #Z2
|
||||||
|
#Z3
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
return a * b #Z5
|
||||||
|
###END
|
||||||
|
"""
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
parser = DCTPParser()
|
||||||
|
result = parser.parse(test_input)
|
||||||
|
|
||||||
|
print("Parsed operations:")
|
||||||
|
for op in result.operations:
|
||||||
|
print(f" {op}")
|
||||||
|
|
||||||
|
executor = DCTPExecutor(tmpdir)
|
||||||
|
|
||||||
|
# Preview
|
||||||
|
print("\nPreviews:")
|
||||||
|
previews = executor.preview(result.operations)
|
||||||
|
for preview in previews:
|
||||||
|
print(f" {preview.description}")
|
||||||
|
if preview.warnings:
|
||||||
|
for w in preview.warnings:
|
||||||
|
print(f" ⚠️ {w}")
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
print("\nExecution:")
|
||||||
|
exec_results = executor.execute(result.operations)
|
||||||
|
for r in exec_results:
|
||||||
|
print(f" {r}")
|
||||||
|
|
||||||
|
# Verify file was created
|
||||||
|
file_path = Path(tmpdir) / "calculator.py"
|
||||||
|
if file_path.exists():
|
||||||
|
print(f"\nFile content:\n{file_path.read_text()}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
DCTP GUI - Delta Code Transfer Protocol graphical user interface.
|
||||||
|
|
||||||
|
A CustomTkinter-based GUI for managing AI-generated code transfers
|
||||||
|
using delta operations for efficient updates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import filedialog, messagebox
|
||||||
|
from typing import Optional
|
||||||
|
import json
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from dctp_parser import DCTPParser, ParseResult, Operation, OperationType
|
||||||
|
from dctp_executor import DCTPExecutor, ExecutionResult, PreviewResult, ResultStatus
|
||||||
|
from dctp_backup import BackupManager
|
||||||
|
from dctp_diff import DiffType
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsDialog(ctk.CTkToplevel):
|
||||||
|
"""Settings dialog window."""
|
||||||
|
|
||||||
|
def __init__(self, parent, settings: dict):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.title("Einstellungen")
|
||||||
|
self.geometry("500x400")
|
||||||
|
self.resizable(False, False)
|
||||||
|
|
||||||
|
self.settings = settings.copy()
|
||||||
|
self.result = None
|
||||||
|
|
||||||
|
# Make modal
|
||||||
|
self.transient(parent)
|
||||||
|
self.grab_set()
|
||||||
|
|
||||||
|
self._create_widgets()
|
||||||
|
|
||||||
|
# Center on parent
|
||||||
|
self.update_idletasks()
|
||||||
|
x = parent.winfo_x() + (parent.winfo_width() - self.winfo_width()) // 2
|
||||||
|
y = parent.winfo_y() + (parent.winfo_height() - self.winfo_height()) // 2
|
||||||
|
self.geometry(f"+{x}+{y}")
|
||||||
|
|
||||||
|
def _create_widgets(self):
|
||||||
|
# Main frame
|
||||||
|
main_frame = ctk.CTkFrame(self)
|
||||||
|
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
|
||||||
|
|
||||||
|
# Project path
|
||||||
|
ctk.CTkLabel(main_frame, text="Standard-Projektpfad:").pack(anchor="w", pady=(0, 5))
|
||||||
|
path_frame = ctk.CTkFrame(main_frame)
|
||||||
|
path_frame.pack(fill="x", pady=(0, 15))
|
||||||
|
|
||||||
|
self.path_entry = ctk.CTkEntry(path_frame, width=350)
|
||||||
|
self.path_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
|
self.path_entry.insert(0, self.settings.get("project_path", ""))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
path_frame,
|
||||||
|
text="...",
|
||||||
|
width=40,
|
||||||
|
command=self._browse_path
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
# Backup directory
|
||||||
|
ctk.CTkLabel(main_frame, text="Backup-Verzeichnis:").pack(anchor="w", pady=(0, 5))
|
||||||
|
backup_frame = ctk.CTkFrame(main_frame)
|
||||||
|
backup_frame.pack(fill="x", pady=(0, 15))
|
||||||
|
|
||||||
|
self.backup_entry = ctk.CTkEntry(backup_frame, width=350)
|
||||||
|
self.backup_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
|
self.backup_entry.insert(0, self.settings.get("backup_dir", ".dctp_backups"))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
backup_frame,
|
||||||
|
text="...",
|
||||||
|
width=40,
|
||||||
|
command=self._browse_backup
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
# Options
|
||||||
|
options_frame = ctk.CTkFrame(main_frame)
|
||||||
|
options_frame.pack(fill="x", pady=15)
|
||||||
|
|
||||||
|
self.auto_renumber_var = ctk.BooleanVar(
|
||||||
|
value=self.settings.get("auto_renumber", True)
|
||||||
|
)
|
||||||
|
ctk.CTkCheckBox(
|
||||||
|
options_frame,
|
||||||
|
text="Auto-Renumber nach Operationen",
|
||||||
|
variable=self.auto_renumber_var
|
||||||
|
).pack(anchor="w", pady=5)
|
||||||
|
|
||||||
|
self.validate_checksum_var = ctk.BooleanVar(
|
||||||
|
value=self.settings.get("validate_checksum", True)
|
||||||
|
)
|
||||||
|
ctk.CTkCheckBox(
|
||||||
|
options_frame,
|
||||||
|
text="Checksum-Validierung aktiviert",
|
||||||
|
variable=self.validate_checksum_var
|
||||||
|
).pack(anchor="w", pady=5)
|
||||||
|
|
||||||
|
# Theme
|
||||||
|
ctk.CTkLabel(main_frame, text="Theme:").pack(anchor="w", pady=(15, 5))
|
||||||
|
self.theme_var = ctk.StringVar(value=self.settings.get("theme", "dark"))
|
||||||
|
theme_frame = ctk.CTkFrame(main_frame)
|
||||||
|
theme_frame.pack(fill="x", pady=(0, 15))
|
||||||
|
|
||||||
|
ctk.CTkRadioButton(
|
||||||
|
theme_frame,
|
||||||
|
text="Dunkel",
|
||||||
|
variable=self.theme_var,
|
||||||
|
value="dark"
|
||||||
|
).pack(side="left", padx=(0, 20))
|
||||||
|
|
||||||
|
ctk.CTkRadioButton(
|
||||||
|
theme_frame,
|
||||||
|
text="Hell",
|
||||||
|
variable=self.theme_var,
|
||||||
|
value="light"
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_frame = ctk.CTkFrame(main_frame)
|
||||||
|
button_frame.pack(fill="x", pady=(20, 0))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Abbrechen",
|
||||||
|
command=self._cancel
|
||||||
|
).pack(side="right", padx=(10, 0))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Speichern",
|
||||||
|
command=self._save
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
def _browse_path(self):
|
||||||
|
path = filedialog.askdirectory(
|
||||||
|
initialdir=self.path_entry.get() or os.path.expanduser("~")
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self.path_entry.delete(0, "end")
|
||||||
|
self.path_entry.insert(0, path)
|
||||||
|
|
||||||
|
def _browse_backup(self):
|
||||||
|
path = filedialog.askdirectory(
|
||||||
|
initialdir=os.path.expanduser("~")
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self.backup_entry.delete(0, "end")
|
||||||
|
self.backup_entry.insert(0, path)
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
self.result = {
|
||||||
|
"project_path": self.path_entry.get(),
|
||||||
|
"backup_dir": self.backup_entry.get(),
|
||||||
|
"auto_renumber": self.auto_renumber_var.get(),
|
||||||
|
"validate_checksum": self.validate_checksum_var.get(),
|
||||||
|
"theme": self.theme_var.get()
|
||||||
|
}
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
def _cancel(self):
|
||||||
|
self.result = None
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class DCTPApp(ctk.CTk):
|
||||||
|
"""Main DCTP application window."""
|
||||||
|
|
||||||
|
SETTINGS_FILE = ".dctp_settings.json"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.title("DCTP - Delta Code Transfer")
|
||||||
|
self.geometry("1200x900")
|
||||||
|
self.minsize(800, 600)
|
||||||
|
|
||||||
|
# Initialize components
|
||||||
|
self.parser = DCTPParser()
|
||||||
|
self.executor: Optional[DCTPExecutor] = None
|
||||||
|
self.backup_manager: Optional[BackupManager] = None
|
||||||
|
self.current_operations: list[Operation] = []
|
||||||
|
self.current_previews: list[PreviewResult] = []
|
||||||
|
|
||||||
|
# Load settings
|
||||||
|
self.settings = self._load_settings()
|
||||||
|
ctk.set_appearance_mode(self.settings.get("theme", "dark"))
|
||||||
|
|
||||||
|
# Create UI
|
||||||
|
self._create_widgets()
|
||||||
|
|
||||||
|
# Initialize project if path is set
|
||||||
|
if self.settings.get("project_path"):
|
||||||
|
self._init_project(self.settings["project_path"])
|
||||||
|
|
||||||
|
self._log("Bereit")
|
||||||
|
|
||||||
|
def _load_settings(self) -> dict:
|
||||||
|
"""Load settings from file."""
|
||||||
|
settings_path = Path.home() / self.SETTINGS_FILE
|
||||||
|
if settings_path.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(settings_path.read_text())
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"project_path": "",
|
||||||
|
"backup_dir": ".dctp_backups",
|
||||||
|
"auto_renumber": True,
|
||||||
|
"validate_checksum": True,
|
||||||
|
"theme": "dark"
|
||||||
|
}
|
||||||
|
|
||||||
|
def _save_settings(self):
|
||||||
|
"""Save settings to file."""
|
||||||
|
settings_path = Path.home() / self.SETTINGS_FILE
|
||||||
|
settings_path.write_text(json.dumps(self.settings, indent=2))
|
||||||
|
|
||||||
|
def _create_widgets(self):
|
||||||
|
"""Create all UI widgets."""
|
||||||
|
# Top bar - project selection
|
||||||
|
top_frame = ctk.CTkFrame(self)
|
||||||
|
top_frame.pack(fill="x", padx=10, pady=10)
|
||||||
|
|
||||||
|
ctk.CTkLabel(top_frame, text="Projekt:").pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
self.project_entry = ctk.CTkEntry(top_frame, width=400)
|
||||||
|
self.project_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
|
self.project_entry.insert(0, self.settings.get("project_path", ""))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
top_frame,
|
||||||
|
text="Waehlen",
|
||||||
|
width=100,
|
||||||
|
command=self._browse_project
|
||||||
|
).pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
top_frame,
|
||||||
|
text="Einstellungen",
|
||||||
|
width=100,
|
||||||
|
command=self._open_settings
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# Main content area with paned layout
|
||||||
|
content_frame = ctk.CTkFrame(self)
|
||||||
|
content_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10))
|
||||||
|
|
||||||
|
# Left side - Input and preview
|
||||||
|
left_frame = ctk.CTkFrame(content_frame)
|
||||||
|
left_frame.pack(side="left", fill="both", expand=True, padx=(0, 5))
|
||||||
|
|
||||||
|
# Input area
|
||||||
|
input_label_frame = ctk.CTkFrame(left_frame)
|
||||||
|
input_label_frame.pack(fill="x", pady=(5, 5), padx=5)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
input_label_frame,
|
||||||
|
text="Input (KI-Output hier einfuegen)",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
self.input_text = ctk.CTkTextbox(
|
||||||
|
left_frame,
|
||||||
|
height=250,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=12)
|
||||||
|
)
|
||||||
|
self.input_text.pack(fill="both", expand=True, padx=5, pady=(0, 10))
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_frame = ctk.CTkFrame(left_frame)
|
||||||
|
button_frame.pack(fill="x", padx=5, pady=(0, 10))
|
||||||
|
|
||||||
|
self.analyze_btn = ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Analysieren",
|
||||||
|
command=self._analyze,
|
||||||
|
width=120
|
||||||
|
)
|
||||||
|
self.analyze_btn.pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
self.execute_btn = ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Ausfuehren",
|
||||||
|
command=self._execute,
|
||||||
|
width=120,
|
||||||
|
state="disabled"
|
||||||
|
)
|
||||||
|
self.execute_btn.pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
self.undo_btn = ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Undo",
|
||||||
|
command=self._undo,
|
||||||
|
width=80
|
||||||
|
)
|
||||||
|
self.undo_btn.pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Clear",
|
||||||
|
command=self._clear,
|
||||||
|
width=80
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# Preview operations
|
||||||
|
preview_label_frame = ctk.CTkFrame(left_frame)
|
||||||
|
preview_label_frame.pack(fill="x", pady=(5, 5), padx=5)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
preview_label_frame,
|
||||||
|
text="Vorschau Operationen",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
self.preview_text = ctk.CTkTextbox(
|
||||||
|
left_frame,
|
||||||
|
height=150,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=11)
|
||||||
|
)
|
||||||
|
self.preview_text.pack(fill="both", expand=True, padx=5, pady=(0, 10))
|
||||||
|
self.preview_text.configure(state="disabled")
|
||||||
|
|
||||||
|
# Right side - Diff and file tree
|
||||||
|
right_frame = ctk.CTkFrame(content_frame)
|
||||||
|
right_frame.pack(side="right", fill="both", expand=True, padx=(5, 0))
|
||||||
|
|
||||||
|
# Diff view
|
||||||
|
diff_label_frame = ctk.CTkFrame(right_frame)
|
||||||
|
diff_label_frame.pack(fill="x", pady=(5, 5), padx=5)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
diff_label_frame,
|
||||||
|
text="Diff-Ansicht",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
self.diff_text = ctk.CTkTextbox(
|
||||||
|
right_frame,
|
||||||
|
height=300,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=11)
|
||||||
|
)
|
||||||
|
self.diff_text.pack(fill="both", expand=True, padx=5, pady=(0, 10))
|
||||||
|
self.diff_text.configure(state="disabled")
|
||||||
|
|
||||||
|
# File tree
|
||||||
|
tree_label_frame = ctk.CTkFrame(right_frame)
|
||||||
|
tree_label_frame.pack(fill="x", pady=(5, 5), padx=5)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
tree_label_frame,
|
||||||
|
text="Projektdateien",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
tree_label_frame,
|
||||||
|
text="Aktualisieren",
|
||||||
|
width=80,
|
||||||
|
command=self._refresh_file_tree
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
self.tree_text = ctk.CTkTextbox(
|
||||||
|
right_frame,
|
||||||
|
height=150,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=11)
|
||||||
|
)
|
||||||
|
self.tree_text.pack(fill="both", expand=True, padx=5, pady=(0, 10))
|
||||||
|
self.tree_text.configure(state="disabled")
|
||||||
|
|
||||||
|
# Bottom - Log
|
||||||
|
log_label_frame = ctk.CTkFrame(self)
|
||||||
|
log_label_frame.pack(fill="x", padx=10, pady=(0, 5))
|
||||||
|
ctk.CTkLabel(
|
||||||
|
log_label_frame,
|
||||||
|
text="Log",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
self.log_text = ctk.CTkTextbox(
|
||||||
|
self,
|
||||||
|
height=120,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=10)
|
||||||
|
)
|
||||||
|
self.log_text.pack(fill="x", padx=10, pady=(0, 10))
|
||||||
|
self.log_text.configure(state="disabled")
|
||||||
|
|
||||||
|
def _log(self, message: str, level: str = "info"):
|
||||||
|
"""Add a message to the log."""
|
||||||
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
prefix = ""
|
||||||
|
if level == "error":
|
||||||
|
prefix = "ERROR "
|
||||||
|
elif level == "warning":
|
||||||
|
prefix = "WARN "
|
||||||
|
|
||||||
|
self.log_text.configure(state="normal")
|
||||||
|
self.log_text.insert("end", f"{timestamp} {prefix}{message}\n")
|
||||||
|
self.log_text.see("end")
|
||||||
|
self.log_text.configure(state="disabled")
|
||||||
|
|
||||||
|
def _init_project(self, path: str):
|
||||||
|
"""Initialize project with given path."""
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
self._log(f"Verzeichnis existiert nicht: {path}", "error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.backup_manager = BackupManager(path)
|
||||||
|
self.executor = DCTPExecutor(
|
||||||
|
path,
|
||||||
|
self.backup_manager,
|
||||||
|
auto_renumber=self.settings.get("auto_renumber", True),
|
||||||
|
validate_checksums=self.settings.get("validate_checksum", True)
|
||||||
|
)
|
||||||
|
self.settings["project_path"] = path
|
||||||
|
self._save_settings()
|
||||||
|
self._log(f"Projekt geladen: {path}")
|
||||||
|
self._refresh_file_tree()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _browse_project(self):
|
||||||
|
"""Open directory browser for project selection."""
|
||||||
|
initial_dir = self.project_entry.get() or os.path.expanduser("~")
|
||||||
|
path = filedialog.askdirectory(initialdir=initial_dir)
|
||||||
|
if path:
|
||||||
|
self.project_entry.delete(0, "end")
|
||||||
|
self.project_entry.insert(0, path)
|
||||||
|
self._init_project(path)
|
||||||
|
|
||||||
|
def _open_settings(self):
|
||||||
|
"""Open settings dialog."""
|
||||||
|
dialog = SettingsDialog(self, self.settings)
|
||||||
|
self.wait_window(dialog)
|
||||||
|
|
||||||
|
if dialog.result:
|
||||||
|
old_theme = self.settings.get("theme")
|
||||||
|
self.settings.update(dialog.result)
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
|
# Apply theme change
|
||||||
|
if dialog.result.get("theme") != old_theme:
|
||||||
|
ctk.set_appearance_mode(dialog.result["theme"])
|
||||||
|
|
||||||
|
# Reinitialize project with new settings
|
||||||
|
if self.settings.get("project_path"):
|
||||||
|
self._init_project(self.settings["project_path"])
|
||||||
|
|
||||||
|
self._log("Einstellungen gespeichert")
|
||||||
|
|
||||||
|
def _analyze(self):
|
||||||
|
"""Analyze input and show preview."""
|
||||||
|
# Ensure project is initialized
|
||||||
|
project_path = self.project_entry.get()
|
||||||
|
if not project_path:
|
||||||
|
messagebox.showerror("Fehler", "Bitte waehle ein Projektverzeichnis")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.executor or self.settings.get("project_path") != project_path:
|
||||||
|
if not self._init_project(project_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get input
|
||||||
|
input_text = self.input_text.get("1.0", "end-1c")
|
||||||
|
if not input_text.strip():
|
||||||
|
self._log("Kein Input vorhanden", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse
|
||||||
|
self._log("Analysiere...")
|
||||||
|
result = self.parser.parse(input_text)
|
||||||
|
|
||||||
|
# Handle errors
|
||||||
|
if result.has_errors:
|
||||||
|
self._log(f"{len(result.errors)} Parse-Fehler gefunden", "error")
|
||||||
|
for error in result.errors:
|
||||||
|
self._log(f" Zeile {error.line_number}: {error.message}", "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not result.operations:
|
||||||
|
self._log("Keine Operationen gefunden", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.current_operations = result.operations
|
||||||
|
self._log(f"{len(result.operations)} Operationen gefunden")
|
||||||
|
|
||||||
|
# Generate previews
|
||||||
|
self.current_previews = self.executor.preview(result.operations)
|
||||||
|
|
||||||
|
# Display previews
|
||||||
|
self._display_previews()
|
||||||
|
|
||||||
|
# Enable execute button
|
||||||
|
self.execute_btn.configure(state="normal")
|
||||||
|
|
||||||
|
def _display_previews(self):
|
||||||
|
"""Display operation previews."""
|
||||||
|
self.preview_text.configure(state="normal")
|
||||||
|
self.preview_text.delete("1.0", "end")
|
||||||
|
|
||||||
|
self.diff_text.configure(state="normal")
|
||||||
|
self.diff_text.delete("1.0", "end")
|
||||||
|
|
||||||
|
for preview in self.current_previews:
|
||||||
|
# Add to preview list
|
||||||
|
self.preview_text.insert("end", f"{preview.description}\n")
|
||||||
|
for warning in preview.warnings:
|
||||||
|
self.preview_text.insert("end", f" WARNING {warning}\n")
|
||||||
|
|
||||||
|
# Add diff if available
|
||||||
|
if preview.diff and preview.diff.has_changes:
|
||||||
|
self.diff_text.insert("end", f"--- {preview.diff.filename} ---\n")
|
||||||
|
for line in preview.diff.lines:
|
||||||
|
if line.type == DiffType.ADDED:
|
||||||
|
self.diff_text.insert("end", f"+ {line.content}\n")
|
||||||
|
elif line.type == DiffType.REMOVED:
|
||||||
|
self.diff_text.insert("end", f"- {line.content}\n")
|
||||||
|
elif line.type == DiffType.UNCHANGED:
|
||||||
|
self.diff_text.insert("end", f" {line.content}\n")
|
||||||
|
self.diff_text.insert("end", "\n")
|
||||||
|
|
||||||
|
self.preview_text.configure(state="disabled")
|
||||||
|
self.diff_text.configure(state="disabled")
|
||||||
|
|
||||||
|
def _execute(self):
|
||||||
|
"""Execute the analyzed operations."""
|
||||||
|
if not self.current_operations:
|
||||||
|
self._log("Keine Operationen zum Ausfuehren", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.executor:
|
||||||
|
self._log("Kein Projekt initialisiert", "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Confirm
|
||||||
|
count = len(self.current_operations)
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Bestaetigen",
|
||||||
|
f"{count} Operationen ausfuehren?"
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._log(f"Fuehre {count} Operationen aus...")
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
results = self.executor.execute(self.current_operations)
|
||||||
|
|
||||||
|
# Log results
|
||||||
|
success_count = 0
|
||||||
|
for result in results:
|
||||||
|
if result.status == ResultStatus.SUCCESS:
|
||||||
|
self._log(f"OK {result.message}")
|
||||||
|
success_count += 1
|
||||||
|
elif result.status == ResultStatus.WARNING:
|
||||||
|
self._log(f"WARN {result.message}", "warning")
|
||||||
|
elif result.status == ResultStatus.ERROR:
|
||||||
|
self._log(f"ERROR {result.message}", "error")
|
||||||
|
|
||||||
|
self._log(f"Abgeschlossen: {success_count}/{count} erfolgreich")
|
||||||
|
|
||||||
|
# Clear current operations
|
||||||
|
self.current_operations = []
|
||||||
|
self.current_previews = []
|
||||||
|
self.execute_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
# Refresh file tree
|
||||||
|
self._refresh_file_tree()
|
||||||
|
|
||||||
|
def _undo(self):
|
||||||
|
"""Undo last operation."""
|
||||||
|
if not self.backup_manager:
|
||||||
|
self._log("Kein Projekt initialisiert", "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
success, restored = self.backup_manager.restore_last()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self._log(f"Undo erfolgreich: {len(restored)} Dateien wiederhergestellt")
|
||||||
|
for f in restored:
|
||||||
|
self._log(f" -> {f}")
|
||||||
|
self._refresh_file_tree()
|
||||||
|
else:
|
||||||
|
self._log("Kein Backup zum Wiederherstellen", "warning")
|
||||||
|
|
||||||
|
def _clear(self):
|
||||||
|
"""Clear input and preview areas."""
|
||||||
|
self.input_text.delete("1.0", "end")
|
||||||
|
|
||||||
|
self.preview_text.configure(state="normal")
|
||||||
|
self.preview_text.delete("1.0", "end")
|
||||||
|
self.preview_text.configure(state="disabled")
|
||||||
|
|
||||||
|
self.diff_text.configure(state="normal")
|
||||||
|
self.diff_text.delete("1.0", "end")
|
||||||
|
self.diff_text.configure(state="disabled")
|
||||||
|
|
||||||
|
self.current_operations = []
|
||||||
|
self.current_previews = []
|
||||||
|
self.execute_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
self._log("Eingabe geloescht")
|
||||||
|
|
||||||
|
def _refresh_file_tree(self):
|
||||||
|
"""Refresh the file tree display."""
|
||||||
|
self.tree_text.configure(state="normal")
|
||||||
|
self.tree_text.delete("1.0", "end")
|
||||||
|
|
||||||
|
project_path = self.project_entry.get()
|
||||||
|
if not project_path or not os.path.isdir(project_path):
|
||||||
|
self.tree_text.insert("end", "(Kein Projekt geladen)")
|
||||||
|
self.tree_text.configure(state="disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build simple tree
|
||||||
|
try:
|
||||||
|
self._add_tree_items(Path(project_path), 0)
|
||||||
|
except Exception as e:
|
||||||
|
self.tree_text.insert("end", f"Fehler: {e}")
|
||||||
|
|
||||||
|
self.tree_text.configure(state="disabled")
|
||||||
|
|
||||||
|
def _add_tree_items(self, path: Path, level: int, max_items: int = 100):
|
||||||
|
"""Recursively add items to tree display."""
|
||||||
|
if level > 5: # Limit depth
|
||||||
|
return
|
||||||
|
|
||||||
|
indent = " " * level
|
||||||
|
|
||||||
|
try:
|
||||||
|
items = sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if count >= max_items:
|
||||||
|
self.tree_text.insert("end", f"{indent} ... (mehr Dateien)\n")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Skip hidden files and backup directory
|
||||||
|
if item.name.startswith('.'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if item.is_dir():
|
||||||
|
self.tree_text.insert("end", f"{indent}DIR {item.name}/\n")
|
||||||
|
self._add_tree_items(item, level + 1, max_items=20)
|
||||||
|
else:
|
||||||
|
self.tree_text.insert("end", f"{indent}FILE {item.name}\n")
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
self.tree_text.insert("end", f"{indent} (Zugriff verweigert)\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
app = DCTPApp()
|
||||||
|
app.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
DCTP Parser - Parses DCTP control commands and code blocks.
|
||||||
|
|
||||||
|
Handles line-numbered code with language-specific comment formats:
|
||||||
|
- Python/Shell: #Z1
|
||||||
|
- JavaScript/Java/C/C++: //Z1
|
||||||
|
- HTML: <!--Z1-->
|
||||||
|
- CSS: /*Z1*/
|
||||||
|
- SQL: --Z1
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class OperationType(Enum):
|
||||||
|
NEW = "NEW"
|
||||||
|
DELETE = "DELETE"
|
||||||
|
INSERT_AFTER = "INSERT_AFTER"
|
||||||
|
REPLACE = "REPLACE"
|
||||||
|
RENUMBER = "RENUMBER"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Operation:
|
||||||
|
"""Represents a single DCTP operation."""
|
||||||
|
type: OperationType
|
||||||
|
file: str
|
||||||
|
start_line: Optional[int] = None
|
||||||
|
end_line: Optional[int] = None
|
||||||
|
content: list[str] = field(default_factory=list)
|
||||||
|
checksum: Optional[str] = None
|
||||||
|
raw_content: list[str] = field(default_factory=list) # Content with line numbers
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if self.type == OperationType.NEW:
|
||||||
|
return f"CREATE {self.file} ({len(self.content)} lines)"
|
||||||
|
elif self.type == OperationType.DELETE:
|
||||||
|
return f"DELETE {self.file} Z{self.start_line}-Z{self.end_line}"
|
||||||
|
elif self.type == OperationType.INSERT_AFTER:
|
||||||
|
return f"INSERT_AFTER {self.file} Z{self.start_line} ({len(self.content)} lines)"
|
||||||
|
elif self.type == OperationType.REPLACE:
|
||||||
|
return f"REPLACE {self.file} Z{self.start_line}-Z{self.end_line} ({len(self.content)} lines)"
|
||||||
|
elif self.type == OperationType.RENUMBER:
|
||||||
|
return f"RENUMBER {self.file}"
|
||||||
|
return f"{self.type.value} {self.file}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParseError:
|
||||||
|
"""Represents a parsing error."""
|
||||||
|
line_number: int
|
||||||
|
line_content: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParseResult:
|
||||||
|
"""Result of parsing DCTP input."""
|
||||||
|
operations: list[Operation]
|
||||||
|
errors: list[ParseError]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_errors(self) -> bool:
|
||||||
|
return len(self.errors) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class DCTPParser:
|
||||||
|
"""Parser for DCTP (Delta Code Transfer Protocol) format."""
|
||||||
|
|
||||||
|
# Regex patterns for line number markers in different languages
|
||||||
|
LINE_NUMBER_PATTERNS = [
|
||||||
|
re.compile(r'\s*#Z(\d+)\s*$'), # Python, Shell
|
||||||
|
re.compile(r'\s*//Z(\d+)\s*$'), # JavaScript, Java, C, C++
|
||||||
|
re.compile(r'\s*<!--Z(\d+)-->\s*$'), # HTML
|
||||||
|
re.compile(r'\s*/\*Z(\d+)\*/\s*$'), # CSS
|
||||||
|
re.compile(r'\s*--Z(\d+)\s*$'), # SQL
|
||||||
|
]
|
||||||
|
|
||||||
|
# Control command patterns
|
||||||
|
FILE_PATTERN = re.compile(r'^###FILE:(.+)$')
|
||||||
|
NEW_PATTERN = re.compile(r'^###NEW\s*$')
|
||||||
|
DELETE_PATTERN = re.compile(r'^###DELETE:Z(\d+)(?:-Z(\d+))?\s*$')
|
||||||
|
INSERT_AFTER_PATTERN = re.compile(r'^###INSERT_AFTER:Z(\d+)\s*$')
|
||||||
|
REPLACE_PATTERN = re.compile(r'^###REPLACE:Z(\d+)(?:-Z(\d+))?\s*$')
|
||||||
|
END_PATTERN = re.compile(r'^###END\s*$')
|
||||||
|
RENUMBER_PATTERN = re.compile(r'^###RENUMBER\s*$')
|
||||||
|
CHECKSUM_PATTERN = re.compile(r'^###CHECKSUM:([a-fA-F0-9]+)\s*$')
|
||||||
|
|
||||||
|
def parse(self, text: str) -> ParseResult:
|
||||||
|
"""
|
||||||
|
Parse DCTP formatted text into a list of operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The DCTP formatted input text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ParseResult containing operations and any errors
|
||||||
|
"""
|
||||||
|
operations: list[Operation] = []
|
||||||
|
errors: list[ParseError] = []
|
||||||
|
|
||||||
|
current_file: Optional[str] = None
|
||||||
|
current_op: Optional[Operation] = None
|
||||||
|
buffer: list[str] = []
|
||||||
|
raw_buffer: list[str] = []
|
||||||
|
|
||||||
|
lines = text.split('\n')
|
||||||
|
|
||||||
|
for line_num, line in enumerate(lines, 1):
|
||||||
|
# Skip empty lines outside of content blocks
|
||||||
|
if not line.strip() and current_op is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for FILE command
|
||||||
|
file_match = self.FILE_PATTERN.match(line)
|
||||||
|
if file_match:
|
||||||
|
current_file = file_match.group(1).strip()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for NEW command
|
||||||
|
if self.NEW_PATTERN.match(line):
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###NEW without ###FILE"))
|
||||||
|
continue
|
||||||
|
current_op = Operation(type=OperationType.NEW, file=current_file)
|
||||||
|
buffer = []
|
||||||
|
raw_buffer = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for DELETE command
|
||||||
|
delete_match = self.DELETE_PATTERN.match(line)
|
||||||
|
if delete_match:
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###DELETE without ###FILE"))
|
||||||
|
continue
|
||||||
|
start = int(delete_match.group(1))
|
||||||
|
end = int(delete_match.group(2)) if delete_match.group(2) else start
|
||||||
|
operations.append(Operation(
|
||||||
|
type=OperationType.DELETE,
|
||||||
|
file=current_file,
|
||||||
|
start_line=start,
|
||||||
|
end_line=end
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for INSERT_AFTER command
|
||||||
|
insert_match = self.INSERT_AFTER_PATTERN.match(line)
|
||||||
|
if insert_match:
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###INSERT_AFTER without ###FILE"))
|
||||||
|
continue
|
||||||
|
current_op = Operation(
|
||||||
|
type=OperationType.INSERT_AFTER,
|
||||||
|
file=current_file,
|
||||||
|
start_line=int(insert_match.group(1))
|
||||||
|
)
|
||||||
|
buffer = []
|
||||||
|
raw_buffer = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for REPLACE command
|
||||||
|
replace_match = self.REPLACE_PATTERN.match(line)
|
||||||
|
if replace_match:
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###REPLACE without ###FILE"))
|
||||||
|
continue
|
||||||
|
start = int(replace_match.group(1))
|
||||||
|
end = int(replace_match.group(2)) if replace_match.group(2) else start
|
||||||
|
current_op = Operation(
|
||||||
|
type=OperationType.REPLACE,
|
||||||
|
file=current_file,
|
||||||
|
start_line=start,
|
||||||
|
end_line=end
|
||||||
|
)
|
||||||
|
buffer = []
|
||||||
|
raw_buffer = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for END command
|
||||||
|
if self.END_PATTERN.match(line):
|
||||||
|
if current_op:
|
||||||
|
current_op.content = buffer.copy()
|
||||||
|
current_op.raw_content = raw_buffer.copy()
|
||||||
|
operations.append(current_op)
|
||||||
|
current_op = None
|
||||||
|
buffer = []
|
||||||
|
raw_buffer = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for RENUMBER command
|
||||||
|
if self.RENUMBER_PATTERN.match(line):
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###RENUMBER without ###FILE"))
|
||||||
|
continue
|
||||||
|
operations.append(Operation(type=OperationType.RENUMBER, file=current_file))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for CHECKSUM command
|
||||||
|
checksum_match = self.CHECKSUM_PATTERN.match(line)
|
||||||
|
if checksum_match:
|
||||||
|
if current_op:
|
||||||
|
current_op.checksum = checksum_match.group(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Regular code line - add to buffer if we're in an operation
|
||||||
|
if current_op is not None:
|
||||||
|
raw_buffer.append(line)
|
||||||
|
clean_line = self._remove_line_number(line)
|
||||||
|
buffer.append(clean_line)
|
||||||
|
|
||||||
|
# Handle unclosed operation
|
||||||
|
if current_op is not None:
|
||||||
|
errors.append(ParseError(
|
||||||
|
len(lines),
|
||||||
|
"",
|
||||||
|
f"Unclosed operation: {current_op.type.value} for {current_op.file}"
|
||||||
|
))
|
||||||
|
|
||||||
|
return ParseResult(operations=operations, errors=errors)
|
||||||
|
|
||||||
|
def _remove_line_number(self, line: str) -> str:
|
||||||
|
"""Remove line number marker from end of line."""
|
||||||
|
for pattern in self.LINE_NUMBER_PATTERNS:
|
||||||
|
match = pattern.search(line)
|
||||||
|
if match:
|
||||||
|
return line[:match.start()]
|
||||||
|
return line
|
||||||
|
|
||||||
|
def extract_line_number(self, line: str) -> Optional[int]:
|
||||||
|
"""Extract line number from a code line."""
|
||||||
|
for pattern in self.LINE_NUMBER_PATTERNS:
|
||||||
|
match = pattern.search(line)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_line_number_suffix(filename: str, line_num: int) -> str:
|
||||||
|
"""Get the appropriate line number suffix for a file type."""
|
||||||
|
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
||||||
|
|
||||||
|
if ext in ('py', 'sh', 'bash', 'zsh', 'yaml', 'yml', 'toml', 'ini', 'conf', 'rb', 'pl'):
|
||||||
|
return f" #Z{line_num}"
|
||||||
|
elif ext in ('js', 'ts', 'jsx', 'tsx', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'go', 'rs', 'swift', 'kt', 'scala'):
|
||||||
|
return f" //Z{line_num}"
|
||||||
|
elif ext in ('html', 'htm', 'xml', 'svg'):
|
||||||
|
return f" <!--Z{line_num}-->"
|
||||||
|
elif ext in ('css', 'scss', 'sass', 'less'):
|
||||||
|
return f" /*Z{line_num}*/"
|
||||||
|
elif ext in ('sql',):
|
||||||
|
return f" --Z{line_num}"
|
||||||
|
else:
|
||||||
|
# Default to Python style
|
||||||
|
return f" #Z{line_num}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test the parser with example input."""
|
||||||
|
test_input = """###FILE:src/calculator.py
|
||||||
|
###NEW
|
||||||
|
def add(a, b): #Z1
|
||||||
|
return a + b #Z2
|
||||||
|
#Z3
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
return a * b #Z5
|
||||||
|
###END
|
||||||
|
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###REPLACE:Z4-Z5
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
\"\"\"Multipliziert zwei Zahlen.\"\"\" #Z5
|
||||||
|
return a * b #Z6
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###INSERT_AFTER:Z2
|
||||||
|
#Z3
|
||||||
|
def subtract(a, b): #Z4
|
||||||
|
return a - b #Z5
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###DELETE:Z10-Z15
|
||||||
|
###RENUMBER
|
||||||
|
"""
|
||||||
|
|
||||||
|
parser = DCTPParser()
|
||||||
|
result = parser.parse(test_input)
|
||||||
|
|
||||||
|
print("Operations found:")
|
||||||
|
for op in result.operations:
|
||||||
|
print(f" {op}")
|
||||||
|
|
||||||
|
if result.has_errors:
|
||||||
|
print("\nErrors:")
|
||||||
|
for error in result.errors:
|
||||||
|
print(f" Line {error.line_number}: {error.message}")
|
||||||
|
print(f" {error.line_content}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
customtkinter>=5.2.0
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# FamilyAlbums - Apache Configuration
|
||||||
|
|
||||||
|
# Security Headers
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header set X-Content-Type-Options "nosniff"
|
||||||
|
Header set X-Frame-Options "SAMEORIGIN"
|
||||||
|
Header set X-XSS-Protection "1; mode=block"
|
||||||
|
Header set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Deny access to config file
|
||||||
|
<Files "config.php">
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order deny,allow
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
</Files>
|
||||||
|
|
||||||
|
# Deny access to hidden files
|
||||||
|
<FilesMatch "^\.">
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order deny,allow
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Enable compression
|
||||||
|
<IfModule mod_deflate.c>
|
||||||
|
AddOutputFilterByType DEFLATE text/html text/plain text/css application/json application/javascript
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
<IfModule mod_expires.c>
|
||||||
|
ExpiresActive On
|
||||||
|
ExpiresByType image/jpeg "access plus 1 month"
|
||||||
|
ExpiresByType image/png "access plus 1 month"
|
||||||
|
ExpiresByType image/gif "access plus 1 month"
|
||||||
|
ExpiresByType image/webp "access plus 1 month"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Default charset
|
||||||
|
AddDefaultCharset UTF-8
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# FamilyAlbums - Familien-Fotoalbum-Portal
|
||||||
|
|
||||||
|
Ein einfaches, PHP-basiertes Portal zur Verwaltung und Anzeige von Familien-Fotoalben mit Links zu Nextcloud.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Öffentliche Galerie-Ansicht mit Jahr/Monat-Filter
|
||||||
|
- Stichwortsuche über Titel, Tags und Beschreibung
|
||||||
|
- Kommentarfunktion für Familienmitglieder
|
||||||
|
- Admin-Interface zur Albumverwaltung
|
||||||
|
- Responsive Design (Tailwind CSS)
|
||||||
|
- Flat-File Datenbank (JSON) - kein MySQL erforderlich
|
||||||
|
- Spam-Schutz (Honeypot + Rate-Limiting)
|
||||||
|
- CSRF-Schutz für Admin-Aktionen
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Dateien kopieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auf den Webserver kopieren
|
||||||
|
sudo cp -r familyalbums /var/www/
|
||||||
|
|
||||||
|
# Berechtigungen setzen
|
||||||
|
sudo chown -R www-data:www-data /var/www/familyalbums
|
||||||
|
sudo chmod -R 755 /var/www/familyalbums
|
||||||
|
sudo chmod 770 /var/www/familyalbums/data
|
||||||
|
sudo chmod 770 /var/www/familyalbums/thumbnails
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Admin-Passwort ändern
|
||||||
|
|
||||||
|
**WICHTIG:** Das Standard-Passwort muss vor dem produktiven Einsatz geändert werden!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Neuen Passwort-Hash generieren
|
||||||
|
php -r "echo password_hash('DeinSicheresPasswort', PASSWORD_DEFAULT);"
|
||||||
|
```
|
||||||
|
|
||||||
|
Den generierten Hash in `config.php` eintragen:
|
||||||
|
|
||||||
|
```php
|
||||||
|
define('ADMIN_PASSWORD_HASH', '$2y$10$DEIN_GENERIERTER_HASH_HIER');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Apache Virtual Host (optional)
|
||||||
|
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName familyalbums.example.com
|
||||||
|
DocumentRoot /var/www/familyalbums
|
||||||
|
|
||||||
|
<Directory /var/www/familyalbums>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
### Öffentliche Galerie
|
||||||
|
|
||||||
|
- URL: `https://deine-domain.ch/`
|
||||||
|
- Filter nach Jahr und Monat
|
||||||
|
- Stichwortsuche
|
||||||
|
- Kommentare zu Alben hinterlassen
|
||||||
|
|
||||||
|
### Admin-Bereich
|
||||||
|
|
||||||
|
- URL: `https://deine-domain.ch/admin.php`
|
||||||
|
- Login mit dem konfigurierten Passwort
|
||||||
|
- Alben hinzufügen, bearbeiten, löschen
|
||||||
|
- Optional: Vorschaubilder hochladen
|
||||||
|
- Kommentare moderieren
|
||||||
|
|
||||||
|
## Datenstruktur
|
||||||
|
|
||||||
|
### albums.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"albums": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"title": "Albumtitel",
|
||||||
|
"url": "https://nextcloud.../apps/photos/public/...",
|
||||||
|
"date": "2024-12-25",
|
||||||
|
"tags": ["tag1", "tag2"],
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"thumbnail": "thumbnails/bild.jpg",
|
||||||
|
"created_at": "2024-12-26T10:00:00+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### comments.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"album_id": "album-uuid",
|
||||||
|
"author": "Name",
|
||||||
|
"text": "Kommentar",
|
||||||
|
"created_at": "2024-12-27T14:30:00+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Admin-Passwort mit bcrypt gehasht
|
||||||
|
- CSRF-Token für alle Admin-Aktionen
|
||||||
|
- XSS-Schutz durch `htmlspecialchars()`
|
||||||
|
- Rate-Limiting für Kommentare (5/Minute pro IP)
|
||||||
|
- Honeypot-Feld gegen Spam-Bots
|
||||||
|
- `.htaccess` schützt config.php und data/
|
||||||
|
|
||||||
|
## Anforderungen
|
||||||
|
|
||||||
|
- PHP 8.0+
|
||||||
|
- Apache mit mod_rewrite (optional)
|
||||||
|
- Schreibrechte für data/ und thumbnails/
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Privates Projekt für Familien-Nutzung.
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FamilyAlbums - Admin Interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$pageTitle = SITE_TITLE . ' - Administration';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= e($pageTitle) ?></title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
.tag-input { display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.5rem; }
|
||||||
|
.tag-item { background: #dbeafe; color: #1d4ed8; padding: 0.25rem 0.5rem; border-radius: 9999px; display: flex; align-items: center; gap: 0.25rem; }
|
||||||
|
.tag-item button { color: #1d4ed8; cursor: pointer; }
|
||||||
|
.tag-input input { flex: 1; min-width: 100px; border: none; outline: none; }
|
||||||
|
.suggestions { position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #d1d5db; border-radius: 0.5rem; max-height: 200px; overflow-y: auto; z-index: 10; }
|
||||||
|
.suggestions div { padding: 0.5rem 1rem; cursor: pointer; }
|
||||||
|
.suggestions div:hover { background: #f3f4f6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen">
|
||||||
|
<!-- Login-Bereich (wird per JS gesteuert) -->
|
||||||
|
<div id="login-section" class="hidden min-h-screen flex items-center justify-center">
|
||||||
|
<div class="bg-white p-8 rounded-xl shadow-lg w-full max-w-md">
|
||||||
|
<h1 class="text-2xl font-bold text-center mb-6">
|
||||||
|
<i class="fas fa-lock mr-2 text-blue-600"></i>Admin Login
|
||||||
|
</h1>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-gray-700 mb-2">Passwort</label>
|
||||||
|
<input type="password" id="login-password" required
|
||||||
|
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Admin-Passwort eingeben">
|
||||||
|
</div>
|
||||||
|
<div id="login-error" class="hidden text-red-500 text-sm mb-4"></div>
|
||||||
|
<button type="submit" class="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition">
|
||||||
|
<i class="fas fa-sign-in-alt mr-2"></i>Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-4 text-center">
|
||||||
|
<a href="index.php" class="text-blue-600 hover:underline">
|
||||||
|
<i class="fas fa-arrow-left mr-1"></i>Zurück zur Galerie
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin-Bereich -->
|
||||||
|
<div id="admin-section" class="hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-gradient-to-r from-gray-800 to-gray-900 text-white shadow-lg">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-bold">
|
||||||
|
<i class="fas fa-cog mr-2"></i><?= e($pageTitle) ?>
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="index.php" class="text-white/80 hover:text-white">
|
||||||
|
<i class="fas fa-eye mr-1"></i>Galerie
|
||||||
|
</a>
|
||||||
|
<button onclick="logout()" class="text-white/80 hover:text-white">
|
||||||
|
<i class="fas fa-sign-out-alt mr-1"></i>Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="bg-white shadow">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<button onclick="showTab('albums')" id="tab-albums"
|
||||||
|
class="tab-btn py-4 px-2 border-b-2 border-blue-600 text-blue-600 font-medium">
|
||||||
|
<i class="fas fa-images mr-1"></i>Alben
|
||||||
|
</button>
|
||||||
|
<button onclick="showTab('comments')" id="tab-comments"
|
||||||
|
class="tab-btn py-4 px-2 border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-comments mr-1"></i>Kommentare
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<!-- Alben-Tab -->
|
||||||
|
<div id="content-albums">
|
||||||
|
<!-- Album hinzufügen -->
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
|
<i class="fas fa-plus-circle mr-2 text-green-600"></i>
|
||||||
|
<span id="form-title">Neues Album hinzufügen</span>
|
||||||
|
</h2>
|
||||||
|
<form id="album-form" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<input type="hidden" id="album-id">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-gray-700 mb-1">Titel *</label>
|
||||||
|
<input type="text" id="album-title" required
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="z.B. Weihnachten bei Oma">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-gray-700 mb-1">Datum *</label>
|
||||||
|
<input type="date" id="album-date" required
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-gray-700 mb-1">Nextcloud-Link *</label>
|
||||||
|
<input type="url" id="album-url" required
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="https://nextcloud.example.com/apps/photos/public/...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-gray-700 mb-1">Beschreibung</label>
|
||||||
|
<textarea id="album-description" rows="2"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Kurze Beschreibung des Albums"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 relative">
|
||||||
|
<label class="block text-gray-700 mb-1">Tags</label>
|
||||||
|
<div class="tag-input" id="tags-container">
|
||||||
|
<input type="text" id="tag-input" placeholder="Tag eingeben und Enter drücken">
|
||||||
|
</div>
|
||||||
|
<div id="tag-suggestions" class="suggestions hidden"></div>
|
||||||
|
<input type="hidden" id="album-tags">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-gray-700 mb-1">Vorschaubild (optional)</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="file" id="thumbnail-file" accept="image/*"
|
||||||
|
class="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
<button type="button" onclick="uploadThumbnail()" class="px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300">
|
||||||
|
<i class="fas fa-upload"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="album-thumbnail">
|
||||||
|
<div id="thumbnail-preview" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 flex gap-2">
|
||||||
|
<button type="submit" class="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 transition">
|
||||||
|
<i class="fas fa-save mr-2"></i><span id="submit-text">Speichern</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="resetForm()" class="bg-gray-200 px-6 py-2 rounded-lg hover:bg-gray-300 transition">
|
||||||
|
<i class="fas fa-times mr-2"></i>Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Album-Liste -->
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
|
<i class="fas fa-list mr-2 text-blue-600"></i>Alle Alben
|
||||||
|
</h2>
|
||||||
|
<div id="albums-list" class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-gray-600">Titel</th>
|
||||||
|
<th class="px-4 py-3 text-left text-gray-600">Datum</th>
|
||||||
|
<th class="px-4 py-3 text-left text-gray-600">Tags</th>
|
||||||
|
<th class="px-4 py-3 text-right text-gray-600">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="albums-table-body">
|
||||||
|
<!-- Wird per JS befüllt -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kommentare-Tab -->
|
||||||
|
<div id="content-comments" class="hidden">
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
|
<i class="fas fa-comments mr-2 text-blue-600"></i>Alle Kommentare
|
||||||
|
</h2>
|
||||||
|
<div id="comments-list" class="space-y-4">
|
||||||
|
<!-- Wird per JS befüllt -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bestätigungs-Modal -->
|
||||||
|
<div id="confirm-modal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4" id="confirm-title">Bestätigung</h3>
|
||||||
|
<p id="confirm-message" class="text-gray-600 mb-6"></p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="closeConfirm()" class="px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button id="confirm-btn" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// === State ===
|
||||||
|
let csrfToken = '';
|
||||||
|
let allTags = [];
|
||||||
|
let currentTags = [];
|
||||||
|
let editingAlbumId = null;
|
||||||
|
let confirmCallback = null;
|
||||||
|
|
||||||
|
// === Auth ===
|
||||||
|
async function checkAuth() {
|
||||||
|
const response = await fetch('api.php?action=check_auth');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.authenticated) {
|
||||||
|
csrfToken = data.csrf;
|
||||||
|
document.getElementById('login-section').classList.add('hidden');
|
||||||
|
document.getElementById('admin-section').classList.remove('hidden');
|
||||||
|
loadAlbums();
|
||||||
|
loadAllTags();
|
||||||
|
} else {
|
||||||
|
document.getElementById('login-section').classList.remove('hidden');
|
||||||
|
document.getElementById('admin-section').classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const password = document.getElementById('login-password').value;
|
||||||
|
const errorDiv = document.getElementById('login-error');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('api.php?action=login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
csrfToken = data.csrf;
|
||||||
|
document.getElementById('login-section').classList.add('hidden');
|
||||||
|
document.getElementById('admin-section').classList.remove('hidden');
|
||||||
|
loadAlbums();
|
||||||
|
loadAllTags();
|
||||||
|
} else {
|
||||||
|
errorDiv.textContent = data.error || 'Login fehlgeschlagen';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorDiv.textContent = 'Verbindungsfehler';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch('api.php?action=logout', { method: 'POST' });
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tabs ===
|
||||||
|
function showTab(tab) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('border-blue-600', 'text-blue-600');
|
||||||
|
btn.classList.add('border-transparent', 'text-gray-500');
|
||||||
|
});
|
||||||
|
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-600');
|
||||||
|
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-gray-500');
|
||||||
|
|
||||||
|
document.getElementById('content-albums').classList.add('hidden');
|
||||||
|
document.getElementById('content-comments').classList.add('hidden');
|
||||||
|
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||||
|
|
||||||
|
if (tab === 'comments') {
|
||||||
|
loadAllComments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Albums ===
|
||||||
|
async function loadAlbums() {
|
||||||
|
const response = await fetch('api.php?action=albums');
|
||||||
|
const data = await response.json();
|
||||||
|
renderAlbumsTable(data.albums || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlbumsTable(albums) {
|
||||||
|
const tbody = document.getElementById('albums-table-body');
|
||||||
|
|
||||||
|
if (albums.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-8 text-gray-500">Noch keine Alben vorhanden</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = albums.map(album => `
|
||||||
|
<tr class="border-t hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="font-medium">${escapeHtml(album.title)}</div>
|
||||||
|
<div class="text-sm text-gray-500 truncate max-w-xs">${escapeHtml(album.url)}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-600">${album.date}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
${album.tags.slice(0, 3).map(tag =>
|
||||||
|
`<span class="bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded-full">${escapeHtml(tag)}</span>`
|
||||||
|
).join('')}
|
||||||
|
${album.tags.length > 3 ? `<span class="text-gray-400 text-xs">+${album.tags.length - 3}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<button onclick='editAlbum(${JSON.stringify(album).replace(/'/g, "'")})' class="text-blue-600 hover:text-blue-800 mr-2">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button onclick="confirmDelete('album', '${album.id}', '${escapeHtml(album.title)}')" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllTags() {
|
||||||
|
const response = await fetch('api.php?action=tags');
|
||||||
|
const data = await response.json();
|
||||||
|
allTags = data.tags || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Album Form ===
|
||||||
|
document.getElementById('album-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const album = {
|
||||||
|
csrf: csrfToken,
|
||||||
|
title: document.getElementById('album-title').value,
|
||||||
|
url: document.getElementById('album-url').value,
|
||||||
|
date: document.getElementById('album-date').value,
|
||||||
|
description: document.getElementById('album-description').value,
|
||||||
|
tags: currentTags,
|
||||||
|
thumbnail: document.getElementById('album-thumbnail').value
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = 'api.php?action=album';
|
||||||
|
let method = 'POST';
|
||||||
|
|
||||||
|
if (editingAlbumId) {
|
||||||
|
album.id = editingAlbumId;
|
||||||
|
method = 'PUT';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(album)
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
resetForm();
|
||||||
|
loadAlbums();
|
||||||
|
loadAllTags();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Fehler beim Speichern');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Verbindungsfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function editAlbum(album) {
|
||||||
|
editingAlbumId = album.id;
|
||||||
|
document.getElementById('album-id').value = album.id;
|
||||||
|
document.getElementById('album-title').value = album.title;
|
||||||
|
document.getElementById('album-url').value = album.url;
|
||||||
|
document.getElementById('album-date').value = album.date;
|
||||||
|
document.getElementById('album-description').value = album.description || '';
|
||||||
|
document.getElementById('album-thumbnail').value = album.thumbnail || '';
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
currentTags = [...album.tags];
|
||||||
|
renderTags();
|
||||||
|
|
||||||
|
// Thumbnail preview
|
||||||
|
if (album.thumbnail) {
|
||||||
|
document.getElementById('thumbnail-preview').innerHTML =
|
||||||
|
`<img src="${escapeHtml(album.thumbnail)}" class="h-20 rounded">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('form-title').textContent = 'Album bearbeiten';
|
||||||
|
document.getElementById('submit-text').textContent = 'Aktualisieren';
|
||||||
|
|
||||||
|
// Scroll to form
|
||||||
|
document.getElementById('album-form').scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingAlbumId = null;
|
||||||
|
document.getElementById('album-form').reset();
|
||||||
|
document.getElementById('album-thumbnail').value = '';
|
||||||
|
document.getElementById('thumbnail-preview').innerHTML = '';
|
||||||
|
currentTags = [];
|
||||||
|
renderTags();
|
||||||
|
document.getElementById('form-title').textContent = 'Neues Album hinzufügen';
|
||||||
|
document.getElementById('submit-text').textContent = 'Speichern';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAlbum(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('api.php?action=album', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, csrf: csrfToken })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
loadAlbums();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Fehler beim Löschen');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Verbindungsfehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tags ===
|
||||||
|
function renderTags() {
|
||||||
|
const container = document.getElementById('tags-container');
|
||||||
|
const input = document.getElementById('tag-input');
|
||||||
|
|
||||||
|
// Remove existing tag items
|
||||||
|
container.querySelectorAll('.tag-item').forEach(el => el.remove());
|
||||||
|
|
||||||
|
// Add tag items before input
|
||||||
|
currentTags.forEach((tag, index) => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'tag-item';
|
||||||
|
span.innerHTML = `${escapeHtml(tag)}<button type="button" onclick="removeTag(${index})">×</button>`;
|
||||||
|
container.insertBefore(span, input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(index) {
|
||||||
|
currentTags.splice(index, 1);
|
||||||
|
renderTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tag-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
const value = e.target.value.trim();
|
||||||
|
if (value && !currentTags.includes(value)) {
|
||||||
|
currentTags.push(value);
|
||||||
|
renderTags();
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
document.getElementById('tag-suggestions').classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('tag-input').addEventListener('input', (e) => {
|
||||||
|
const value = e.target.value.toLowerCase();
|
||||||
|
const suggestions = document.getElementById('tag-suggestions');
|
||||||
|
|
||||||
|
if (value.length < 1) {
|
||||||
|
suggestions.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = allTags.filter(tag =>
|
||||||
|
tag.toLowerCase().includes(value) && !currentTags.includes(tag)
|
||||||
|
).slice(0, 5);
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
suggestions.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions.innerHTML = matches.map(tag =>
|
||||||
|
`<div onclick="selectTag('${escapeHtml(tag)}')">${escapeHtml(tag)}</div>`
|
||||||
|
).join('');
|
||||||
|
suggestions.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectTag(tag) {
|
||||||
|
if (!currentTags.includes(tag)) {
|
||||||
|
currentTags.push(tag);
|
||||||
|
renderTags();
|
||||||
|
}
|
||||||
|
document.getElementById('tag-input').value = '';
|
||||||
|
document.getElementById('tag-suggestions').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Thumbnail Upload ===
|
||||||
|
async function uploadThumbnail() {
|
||||||
|
const fileInput = document.getElementById('thumbnail-file');
|
||||||
|
if (!fileInput.files[0]) {
|
||||||
|
alert('Bitte wähle zuerst ein Bild aus');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('thumbnail', fileInput.files[0]);
|
||||||
|
formData.append('csrf', csrfToken);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('api.php?action=upload_thumbnail', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('album-thumbnail').value = data.path;
|
||||||
|
document.getElementById('thumbnail-preview').innerHTML =
|
||||||
|
`<img src="${escapeHtml(data.path)}" class="h-20 rounded">`;
|
||||||
|
fileInput.value = '';
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Upload fehlgeschlagen');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Verbindungsfehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Comments ===
|
||||||
|
async function loadAllComments() {
|
||||||
|
const albumsResponse = await fetch('api.php?action=albums');
|
||||||
|
const albumsData = await albumsResponse.json();
|
||||||
|
const albums = albumsData.albums || [];
|
||||||
|
|
||||||
|
const commentsContainer = document.getElementById('comments-list');
|
||||||
|
commentsContainer.innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i> Lade Kommentare...</p>';
|
||||||
|
|
||||||
|
// Kommentare für alle Alben laden
|
||||||
|
const allComments = [];
|
||||||
|
for (const album of albums) {
|
||||||
|
const response = await fetch(`api.php?action=comments&album_id=${album.id}`);
|
||||||
|
const data = await response.json();
|
||||||
|
(data.comments || []).forEach(comment => {
|
||||||
|
comment.albumTitle = album.title;
|
||||||
|
allComments.push(comment);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Datum sortieren
|
||||||
|
allComments.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||||
|
|
||||||
|
if (allComments.length === 0) {
|
||||||
|
commentsContainer.innerHTML = '<p class="text-center text-gray-500 py-8">Noch keine Kommentare vorhanden</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commentsContainer.innerHTML = allComments.map(comment => `
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">${escapeHtml(comment.author)}</span>
|
||||||
|
<span class="text-gray-400 text-sm ml-2">${formatDateTime(comment.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<button onclick="confirmDelete('comment', '${comment.id}', 'diesen Kommentar')" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-700 mb-2">${escapeHtml(comment.text)}</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
<i class="fas fa-images mr-1"></i>${escapeHtml(comment.albumTitle)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComment(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('api.php?action=comment', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, csrf: csrfToken })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
loadAllComments();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Fehler beim Löschen');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Verbindungsfehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Confirm Modal ===
|
||||||
|
function confirmDelete(type, id, name) {
|
||||||
|
document.getElementById('confirm-message').textContent =
|
||||||
|
`Möchtest du "${name}" wirklich löschen?`;
|
||||||
|
|
||||||
|
confirmCallback = () => {
|
||||||
|
if (type === 'album') {
|
||||||
|
deleteAlbum(id);
|
||||||
|
} else if (type === 'comment') {
|
||||||
|
deleteComment(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('confirm-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() {
|
||||||
|
document.getElementById('confirm-modal').classList.add('hidden');
|
||||||
|
confirmCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('confirm-btn').addEventListener('click', () => {
|
||||||
|
if (confirmCallback) {
|
||||||
|
confirmCallback();
|
||||||
|
}
|
||||||
|
closeConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(isoStr) {
|
||||||
|
const date = new Date(isoStr);
|
||||||
|
return date.toLocaleDateString('de-CH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Init ===
|
||||||
|
checkAuth();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FamilyAlbums - API Endpunkte
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
// Hilfsfunktion: JSON Response
|
||||||
|
function json_response(array $data, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hilfsfunktion: Admin-Check
|
||||||
|
function require_admin(): void {
|
||||||
|
if (empty($_SESSION['admin_logged_in'])) {
|
||||||
|
json_response(['error' => 'Nicht autorisiert'], 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ALBEN ===
|
||||||
|
|
||||||
|
if ($action === 'albums' && $method === 'GET') {
|
||||||
|
// Alle Alben abrufen (öffentlich)
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$albums = $data['albums'] ?? [];
|
||||||
|
|
||||||
|
// Filter: Jahr
|
||||||
|
if (!empty($_GET['year'])) {
|
||||||
|
$year = $_GET['year'];
|
||||||
|
$albums = array_filter($albums, fn($a) => substr($a['date'], 0, 4) === $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: Monat
|
||||||
|
if (!empty($_GET['month'])) {
|
||||||
|
$month = $_GET['month'];
|
||||||
|
$albums = array_filter($albums, fn($a) => substr($a['date'], 5, 2) === $month);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: Suche
|
||||||
|
if (!empty($_GET['search'])) {
|
||||||
|
$search = mb_strtolower($_GET['search']);
|
||||||
|
$albums = array_filter($albums, function($a) use ($search) {
|
||||||
|
$haystack = mb_strtolower($a['title'] . ' ' . $a['description'] . ' ' . implode(' ', $a['tags']));
|
||||||
|
return str_contains($haystack, $search);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortierung
|
||||||
|
$sort = $_GET['sort'] ?? 'newest';
|
||||||
|
usort($albums, function($a, $b) use ($sort) {
|
||||||
|
if ($sort === 'oldest') {
|
||||||
|
return strcmp($a['date'], $b['date']);
|
||||||
|
}
|
||||||
|
return strcmp($b['date'], $a['date']); // newest first
|
||||||
|
});
|
||||||
|
|
||||||
|
json_response(['albums' => array_values($albums)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'album' && $method === 'POST') {
|
||||||
|
// Album erstellen (Admin)
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!csrf_validate($input['csrf'] ?? '')) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($input['title']) || empty($input['url']) || empty($input['date'])) {
|
||||||
|
json_response(['error' => 'Titel, URL und Datum sind Pflichtfelder'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$album = [
|
||||||
|
'id' => generate_uuid(),
|
||||||
|
'title' => trim($input['title']),
|
||||||
|
'url' => trim($input['url']),
|
||||||
|
'date' => $input['date'],
|
||||||
|
'tags' => array_map('trim', $input['tags'] ?? []),
|
||||||
|
'description' => trim($input['description'] ?? ''),
|
||||||
|
'thumbnail' => $input['thumbnail'] ?? '',
|
||||||
|
'created_at' => date('c')
|
||||||
|
];
|
||||||
|
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$data['albums'][] = $album;
|
||||||
|
write_json(ALBUMS_FILE, $data);
|
||||||
|
|
||||||
|
json_response(['success' => true, 'album' => $album]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'album' && $method === 'PUT') {
|
||||||
|
// Album bearbeiten (Admin)
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!csrf_validate($input['csrf'] ?? '')) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $input['id'] ?? '';
|
||||||
|
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$found = false;
|
||||||
|
|
||||||
|
foreach ($data['albums'] as &$album) {
|
||||||
|
if ($album['id'] === $id) {
|
||||||
|
$album['title'] = trim($input['title'] ?? $album['title']);
|
||||||
|
$album['url'] = trim($input['url'] ?? $album['url']);
|
||||||
|
$album['date'] = $input['date'] ?? $album['date'];
|
||||||
|
$album['tags'] = array_map('trim', $input['tags'] ?? $album['tags']);
|
||||||
|
$album['description'] = trim($input['description'] ?? $album['description']);
|
||||||
|
$album['thumbnail'] = $input['thumbnail'] ?? $album['thumbnail'];
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$found) {
|
||||||
|
json_response(['error' => 'Album nicht gefunden'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json(ALBUMS_FILE, $data);
|
||||||
|
json_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'album' && $method === 'DELETE') {
|
||||||
|
// Album löschen (Admin)
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!csrf_validate($input['csrf'] ?? '')) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $input['id'] ?? '';
|
||||||
|
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$data['albums'] = array_filter($data['albums'], fn($a) => $a['id'] !== $id);
|
||||||
|
$data['albums'] = array_values($data['albums']);
|
||||||
|
write_json(ALBUMS_FILE, $data);
|
||||||
|
|
||||||
|
// Zugehörige Kommentare löschen
|
||||||
|
$comments = read_json(COMMENTS_FILE);
|
||||||
|
$comments['comments'] = array_filter($comments['comments'], fn($c) => $c['album_id'] !== $id);
|
||||||
|
$comments['comments'] = array_values($comments['comments']);
|
||||||
|
write_json(COMMENTS_FILE, $comments);
|
||||||
|
|
||||||
|
json_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === KOMMENTARE ===
|
||||||
|
|
||||||
|
if ($action === 'comments' && $method === 'GET') {
|
||||||
|
// Kommentare für Album abrufen (öffentlich)
|
||||||
|
$album_id = $_GET['album_id'] ?? '';
|
||||||
|
|
||||||
|
$data = read_json(COMMENTS_FILE);
|
||||||
|
$comments = array_filter($data['comments'] ?? [], fn($c) => $c['album_id'] === $album_id);
|
||||||
|
|
||||||
|
// Nach Datum sortieren (neueste zuerst)
|
||||||
|
usort($comments, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
||||||
|
|
||||||
|
json_response(['comments' => array_values($comments)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'comment' && $method === 'POST') {
|
||||||
|
// Kommentar erstellen (öffentlich)
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (empty($input['album_id']) || empty($input['author']) || empty($input['text'])) {
|
||||||
|
json_response(['error' => 'Album-ID, Name und Text sind Pflichtfelder'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Honeypot-Check (Spam-Schutz)
|
||||||
|
if (!empty($input['website'])) {
|
||||||
|
json_response(['success' => true]); // Fake-Erfolg für Bots
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-Limiting: Max 5 Kommentare pro Minute pro IP
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'];
|
||||||
|
$rate_file = DATA_PATH . 'rate_' . md5($ip) . '.json';
|
||||||
|
$rate_data = read_json($rate_file);
|
||||||
|
$now = time();
|
||||||
|
$rate_data['times'] = array_filter($rate_data['times'] ?? [], fn($t) => $t > $now - 60);
|
||||||
|
|
||||||
|
if (count($rate_data['times']) >= 5) {
|
||||||
|
json_response(['error' => 'Zu viele Kommentare. Bitte warte eine Minute.'], 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rate_data['times'][] = $now;
|
||||||
|
write_json($rate_file, $rate_data);
|
||||||
|
|
||||||
|
$comment = [
|
||||||
|
'id' => generate_uuid(),
|
||||||
|
'album_id' => $input['album_id'],
|
||||||
|
'author' => trim($input['author']),
|
||||||
|
'text' => trim($input['text']),
|
||||||
|
'created_at' => date('c')
|
||||||
|
];
|
||||||
|
|
||||||
|
$data = read_json(COMMENTS_FILE);
|
||||||
|
$data['comments'][] = $comment;
|
||||||
|
write_json(COMMENTS_FILE, $data);
|
||||||
|
|
||||||
|
json_response(['success' => true, 'comment' => $comment]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'comment' && $method === 'DELETE') {
|
||||||
|
// Kommentar löschen (Admin)
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!csrf_validate($input['csrf'] ?? '')) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $input['id'] ?? '';
|
||||||
|
|
||||||
|
$data = read_json(COMMENTS_FILE);
|
||||||
|
$data['comments'] = array_filter($data['comments'], fn($c) => $c['id'] !== $id);
|
||||||
|
$data['comments'] = array_values($data['comments']);
|
||||||
|
write_json(COMMENTS_FILE, $data);
|
||||||
|
|
||||||
|
json_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === TAGS ===
|
||||||
|
|
||||||
|
if ($action === 'tags' && $method === 'GET') {
|
||||||
|
// Alle verwendeten Tags abrufen (für Vorschläge)
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$tags = [];
|
||||||
|
|
||||||
|
foreach ($data['albums'] ?? [] as $album) {
|
||||||
|
foreach ($album['tags'] ?? [] as $tag) {
|
||||||
|
$tags[$tag] = ($tags[$tag] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arsort($tags);
|
||||||
|
json_response(['tags' => array_keys($tags)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === JAHRE/MONATE ===
|
||||||
|
|
||||||
|
if ($action === 'dates' && $method === 'GET') {
|
||||||
|
// Verfügbare Jahre und Monate
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$years = [];
|
||||||
|
|
||||||
|
foreach ($data['albums'] ?? [] as $album) {
|
||||||
|
$year = substr($album['date'], 0, 4);
|
||||||
|
$month = substr($album['date'], 5, 2);
|
||||||
|
|
||||||
|
if (!isset($years[$year])) {
|
||||||
|
$years[$year] = [];
|
||||||
|
}
|
||||||
|
if (!in_array($month, $years[$year])) {
|
||||||
|
$years[$year][] = $month;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortieren
|
||||||
|
krsort($years);
|
||||||
|
foreach ($years as &$months) {
|
||||||
|
sort($months);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(['dates' => $years]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === AUTH ===
|
||||||
|
|
||||||
|
if ($action === 'login' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$password = $input['password'] ?? '';
|
||||||
|
|
||||||
|
if (password_verify($password, ADMIN_PASSWORD_HASH)) {
|
||||||
|
$_SESSION['admin_logged_in'] = true;
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
json_response(['success' => true, 'csrf' => $_SESSION['csrf_token']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verzögerung gegen Brute-Force
|
||||||
|
sleep(1);
|
||||||
|
json_response(['error' => 'Falsches Passwort'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'logout' && $method === 'POST') {
|
||||||
|
session_destroy();
|
||||||
|
json_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'check_auth' && $method === 'GET') {
|
||||||
|
json_response([
|
||||||
|
'authenticated' => !empty($_SESSION['admin_logged_in']),
|
||||||
|
'csrf' => $_SESSION['csrf_token'] ?? ''
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === THUMBNAIL UPLOAD ===
|
||||||
|
|
||||||
|
if ($action === 'upload_thumbnail' && $method === 'POST') {
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
if (empty($_POST['csrf']) || !csrf_validate($_POST['csrf'])) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($_FILES['thumbnail']) || $_FILES['thumbnail']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
json_response(['error' => 'Kein Bild hochgeladen'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['thumbnail'];
|
||||||
|
$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
|
if (!in_array($file['type'], $allowed)) {
|
||||||
|
json_response(['error' => 'Nur JPG, PNG, GIF und WebP erlaubt'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file['size'] > 5 * 1024 * 1024) {
|
||||||
|
json_response(['error' => 'Maximale Dateigrösse: 5MB'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
|
||||||
|
$filename = generate_uuid() . '.' . $ext;
|
||||||
|
$path = THUMBNAIL_PATH . $filename;
|
||||||
|
|
||||||
|
if (!move_uploaded_file($file['tmp_name'], $path)) {
|
||||||
|
json_response(['error' => 'Upload fehlgeschlagen'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(['success' => true, 'path' => THUMBNAIL_URL . $filename]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbekannte Aktion
|
||||||
|
json_response(['error' => 'Unbekannte Aktion'], 404);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FamilyAlbums - Konfiguration
|
||||||
|
*
|
||||||
|
* WICHTIG: Nach erster Installation Passwort ändern!
|
||||||
|
* Neuen Hash generieren: php -r "echo password_hash('deinPasswort', PASSWORD_DEFAULT);"
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Standard-Passwort: "familie2024" - BITTE ÄNDERN!
|
||||||
|
define('ADMIN_PASSWORD_HASH', '$2y$10$YxQx8B7GkDqNmPrC4VzKH.qN4tQ8WvX5kF7mZ3hJ9aE1bC2dR6uYO');
|
||||||
|
|
||||||
|
define('SITE_TITLE', 'Familien-Fotoalben');
|
||||||
|
define('DATA_PATH', __DIR__ . '/data/');
|
||||||
|
define('THUMBNAIL_PATH', __DIR__ . '/thumbnails/');
|
||||||
|
define('THUMBNAIL_URL', 'thumbnails/');
|
||||||
|
|
||||||
|
define('ALBUMS_FILE', DATA_PATH . 'albums.json');
|
||||||
|
define('COMMENTS_FILE', DATA_PATH . 'comments.json');
|
||||||
|
|
||||||
|
// Session-Einstellungen
|
||||||
|
define('SESSION_LIFETIME', 3600); // 1 Stunde
|
||||||
|
|
||||||
|
// Zeitzone
|
||||||
|
date_default_timezone_set('Europe/Zurich');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-Datei lesen
|
||||||
|
*/
|
||||||
|
function read_json(string $file): array {
|
||||||
|
if (!file_exists($file)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$content = file_get_contents($file);
|
||||||
|
return json_decode($content, true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-Datei schreiben
|
||||||
|
*/
|
||||||
|
function write_json(string $file, array $data): bool {
|
||||||
|
$dir = dirname($file);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0770, true);
|
||||||
|
}
|
||||||
|
return file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID generieren
|
||||||
|
*/
|
||||||
|
function generate_uuid(): string {
|
||||||
|
return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0x0fff) | 0x4000,
|
||||||
|
mt_rand(0, 0x3fff) | 0x8000,
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XSS-sichere Ausgabe
|
||||||
|
*/
|
||||||
|
function e(string $str): string {
|
||||||
|
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF-Token generieren
|
||||||
|
*/
|
||||||
|
function csrf_token(): string {
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
return $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF-Token validieren
|
||||||
|
*/
|
||||||
|
function csrf_validate(string $token): bool {
|
||||||
|
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialisiere Daten-Dateien falls nicht vorhanden
|
||||||
|
if (!file_exists(ALBUMS_FILE)) {
|
||||||
|
write_json(ALBUMS_FILE, ['albums' => []]);
|
||||||
|
}
|
||||||
|
if (!file_exists(COMMENTS_FILE)) {
|
||||||
|
write_json(COMMENTS_FILE, ['comments' => []]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Deny access to all files in this directory
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order deny,allow
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"albums": [
|
||||||
|
{
|
||||||
|
"id": "demo-001",
|
||||||
|
"title": "Weihnachten 2024",
|
||||||
|
"url": "https://nextcloud.example.com/apps/photos/public/demo",
|
||||||
|
"date": "2024-12-25",
|
||||||
|
"tags": ["weihnachten", "familie", "2024"],
|
||||||
|
"description": "Bescherung und Festessen bei der Familie",
|
||||||
|
"thumbnail": "",
|
||||||
|
"created_at": "2024-12-26T10:00:00+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"comments": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FamilyAlbums - Öffentliche Ansicht
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
$pageTitle = SITE_TITLE;
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= e($pageTitle) ?></title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
.album-card:hover { transform: translateY(-4px); }
|
||||||
|
.tag { transition: all 0.2s; }
|
||||||
|
.tag:hover { transform: scale(1.05); }
|
||||||
|
.modal { transition: opacity 0.3s; }
|
||||||
|
.modal.hidden { opacity: 0; pointer-events: none; }
|
||||||
|
.gradient-placeholder {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg">
|
||||||
|
<div class="container mx-auto px-4 py-6">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold">
|
||||||
|
<i class="fas fa-images mr-2"></i><?= e($pageTitle) ?>
|
||||||
|
</h1>
|
||||||
|
<a href="admin.php" class="text-white/80 hover:text-white text-sm">
|
||||||
|
<i class="fas fa-lock mr-1"></i>Admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Filter-Bereich -->
|
||||||
|
<div class="bg-white shadow-md sticky top-0 z-10">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex flex-col md:flex-row gap-4">
|
||||||
|
<!-- Suche -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" id="search" placeholder="Album suchen..."
|
||||||
|
class="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jahr -->
|
||||||
|
<select id="filter-year" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="">Alle Jahre</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Monat -->
|
||||||
|
<select id="filter-month" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" disabled>
|
||||||
|
<option value="">Alle Monate</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Sortierung -->
|
||||||
|
<select id="sort" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="newest">Neueste zuerst</option>
|
||||||
|
<option value="oldest">Älteste zuerst</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Album-Grid -->
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div id="albums-container" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
<!-- Alben werden per JS geladen -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="no-results" class="hidden text-center py-12 text-gray-500">
|
||||||
|
<i class="fas fa-search text-4xl mb-4"></i>
|
||||||
|
<p class="text-xl">Keine Alben gefunden</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loading" class="text-center py-12">
|
||||||
|
<i class="fas fa-spinner fa-spin text-4xl text-blue-500"></i>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Album-Detail Modal -->
|
||||||
|
<div id="album-modal" class="modal hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h2 id="modal-title" class="text-2xl font-bold text-gray-800"></h2>
|
||||||
|
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-thumbnail" class="mb-4 rounded-lg overflow-hidden"></div>
|
||||||
|
|
||||||
|
<p id="modal-date" class="text-gray-500 mb-2"></p>
|
||||||
|
<p id="modal-description" class="text-gray-700 mb-4"></p>
|
||||||
|
|
||||||
|
<div id="modal-tags" class="flex flex-wrap gap-2 mb-6"></div>
|
||||||
|
|
||||||
|
<a id="modal-link" href="#" target="_blank"
|
||||||
|
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition mb-6">
|
||||||
|
<i class="fas fa-external-link-alt mr-2"></i>Album öffnen
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Kommentare -->
|
||||||
|
<div class="border-t pt-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">
|
||||||
|
<i class="fas fa-comments mr-2"></i>Kommentare
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div id="comments-list" class="space-y-4 mb-6"></div>
|
||||||
|
|
||||||
|
<!-- Kommentar-Formular -->
|
||||||
|
<form id="comment-form" class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<input type="hidden" id="comment-album-id">
|
||||||
|
<!-- Honeypot -->
|
||||||
|
<input type="text" name="website" id="comment-website" class="hidden" tabindex="-1" autocomplete="off">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" id="comment-author" placeholder="Dein Name" required
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<textarea id="comment-text" placeholder="Dein Kommentar..." required rows="3"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition">
|
||||||
|
<i class="fas fa-paper-plane mr-2"></i>Absenden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-gray-800 text-white py-6 mt-12">
|
||||||
|
<div class="container mx-auto px-4 text-center">
|
||||||
|
<p>© <?= date('Y') ?> <?= e($pageTitle) ?></p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// === State ===
|
||||||
|
let allDates = {};
|
||||||
|
let currentAlbumId = null;
|
||||||
|
let debounceTimer = null;
|
||||||
|
|
||||||
|
// === Monatsnamen ===
|
||||||
|
const monthNames = {
|
||||||
|
'01': 'Januar', '02': 'Februar', '03': 'März', '04': 'April',
|
||||||
|
'05': 'Mai', '06': 'Juni', '07': 'Juli', '08': 'August',
|
||||||
|
'09': 'September', '10': 'Oktober', '11': 'November', '12': 'Dezember'
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const [year, month, day] = dateStr.split('-');
|
||||||
|
return `${parseInt(day)}. ${monthNames[month]} ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(isoStr) {
|
||||||
|
const date = new Date(isoStr);
|
||||||
|
return date.toLocaleDateString('de-CH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === API Calls ===
|
||||||
|
async function fetchAlbums() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
const year = document.getElementById('filter-year').value;
|
||||||
|
const month = document.getElementById('filter-month').value;
|
||||||
|
const search = document.getElementById('search').value;
|
||||||
|
const sort = document.getElementById('sort').value;
|
||||||
|
|
||||||
|
if (year) params.append('year', year);
|
||||||
|
if (month) params.append('month', month);
|
||||||
|
if (search) params.append('search', search);
|
||||||
|
params.append('sort', sort);
|
||||||
|
|
||||||
|
const response = await fetch(`api.php?action=albums&${params}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDates() {
|
||||||
|
const response = await fetch('api.php?action=dates');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchComments(albumId) {
|
||||||
|
const response = await fetch(`api.php?action=comments&album_id=${encodeURIComponent(albumId)}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postComment(albumId, author, text, website) {
|
||||||
|
const response = await fetch('api.php?action=comment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ album_id: albumId, author, text, website })
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Rendering ===
|
||||||
|
function renderAlbums(albums) {
|
||||||
|
const container = document.getElementById('albums-container');
|
||||||
|
const noResults = document.getElementById('no-results');
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
|
||||||
|
if (albums.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
noResults.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
noResults.classList.add('hidden');
|
||||||
|
|
||||||
|
container.innerHTML = albums.map(album => `
|
||||||
|
<div class="album-card bg-white rounded-xl shadow-md overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-xl"
|
||||||
|
data-album='${JSON.stringify(album).replace(/'/g, "'")}'
|
||||||
|
onclick="openModalFromCard(this)">
|
||||||
|
<div class="aspect-video gradient-placeholder flex items-center justify-center">
|
||||||
|
${album.thumbnail
|
||||||
|
? `<img src="${escapeHtml(album.thumbnail)}" alt="${escapeHtml(album.title)}" class="w-full h-full object-cover" onerror="this.parentElement.innerHTML='<i class=\\'fas fa-images text-4xl text-white/50\\'></i>'">`
|
||||||
|
: `<i class="fas fa-images text-4xl text-white/50"></i>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-semibold text-lg text-gray-800 mb-1 line-clamp-2">${escapeHtml(album.title)}</h3>
|
||||||
|
<p class="text-gray-500 text-sm mb-3">
|
||||||
|
<i class="fas fa-calendar mr-1"></i>${formatDate(album.date)}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
${album.tags.slice(0, 3).map(tag => `
|
||||||
|
<span class="tag bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full">${escapeHtml(tag)}</span>
|
||||||
|
`).join('')}
|
||||||
|
${album.tags.length > 3 ? `<span class="text-gray-400 text-xs">+${album.tags.length - 3}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDateFilters(dates) {
|
||||||
|
allDates = dates;
|
||||||
|
const yearSelect = document.getElementById('filter-year');
|
||||||
|
|
||||||
|
yearSelect.innerHTML = '<option value="">Alle Jahre</option>' +
|
||||||
|
Object.keys(dates).map(year => `<option value="${year}">${year}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMonthFilter() {
|
||||||
|
const year = document.getElementById('filter-year').value;
|
||||||
|
const monthSelect = document.getElementById('filter-month');
|
||||||
|
|
||||||
|
if (!year || !allDates[year]) {
|
||||||
|
monthSelect.innerHTML = '<option value="">Alle Monate</option>';
|
||||||
|
monthSelect.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
monthSelect.disabled = false;
|
||||||
|
monthSelect.innerHTML = '<option value="">Alle Monate</option>' +
|
||||||
|
allDates[year].map(month => `<option value="${month}">${monthNames[month]}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComments(comments) {
|
||||||
|
const container = document.getElementById('comments-list');
|
||||||
|
|
||||||
|
if (comments.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-gray-500 text-center italic">Noch keine Kommentare. Sei der Erste!</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = comments.map(comment => `
|
||||||
|
<div class="bg-white p-3 rounded-lg border">
|
||||||
|
<div class="flex justify-between items-start mb-1">
|
||||||
|
<span class="font-semibold text-gray-800">${escapeHtml(comment.author)}</span>
|
||||||
|
<span class="text-gray-400 text-xs">${formatDateTime(comment.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-700">${escapeHtml(comment.text)}</p>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Modal ===
|
||||||
|
function openModalFromCard(element) {
|
||||||
|
const album = JSON.parse(element.dataset.album);
|
||||||
|
openModal(album.id, album);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(id, album) {
|
||||||
|
currentAlbumId = id;
|
||||||
|
|
||||||
|
document.getElementById('modal-title').textContent = album.title;
|
||||||
|
document.getElementById('modal-date').innerHTML = `<i class="fas fa-calendar mr-1"></i>${formatDate(album.date)}`;
|
||||||
|
document.getElementById('modal-description').textContent = album.description || 'Keine Beschreibung';
|
||||||
|
document.getElementById('modal-link').href = album.url;
|
||||||
|
document.getElementById('comment-album-id').value = id;
|
||||||
|
|
||||||
|
// Thumbnail
|
||||||
|
const thumbnailContainer = document.getElementById('modal-thumbnail');
|
||||||
|
if (album.thumbnail) {
|
||||||
|
thumbnailContainer.innerHTML = `<img src="${escapeHtml(album.thumbnail)}" alt="${escapeHtml(album.title)}" class="w-full max-h-64 object-cover">`;
|
||||||
|
} else {
|
||||||
|
thumbnailContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
document.getElementById('modal-tags').innerHTML = album.tags.map(tag =>
|
||||||
|
`<span class="bg-blue-100 text-blue-700 text-sm px-3 py-1 rounded-full">${escapeHtml(tag)}</span>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Modal anzeigen
|
||||||
|
document.getElementById('album-modal').classList.remove('hidden');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Kommentare laden
|
||||||
|
loadComments(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('album-modal').classList.add('hidden');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
currentAlbumId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadComments(albumId) {
|
||||||
|
document.getElementById('comments-list').innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i></p>';
|
||||||
|
const data = await fetchComments(albumId);
|
||||||
|
renderComments(data.comments || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Event Listeners ===
|
||||||
|
document.getElementById('search').addEventListener('input', () => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
const data = await fetchAlbums();
|
||||||
|
renderAlbums(data.albums || []);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('filter-year').addEventListener('change', async () => {
|
||||||
|
updateMonthFilter();
|
||||||
|
document.getElementById('filter-month').value = '';
|
||||||
|
const data = await fetchAlbums();
|
||||||
|
renderAlbums(data.albums || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('filter-month').addEventListener('change', async () => {
|
||||||
|
const data = await fetchAlbums();
|
||||||
|
renderAlbums(data.albums || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sort').addEventListener('change', async () => {
|
||||||
|
const data = await fetchAlbums();
|
||||||
|
renderAlbums(data.albums || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('comment-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const albumId = document.getElementById('comment-album-id').value;
|
||||||
|
const author = document.getElementById('comment-author').value.trim();
|
||||||
|
const text = document.getElementById('comment-text').value.trim();
|
||||||
|
const website = document.getElementById('comment-website').value;
|
||||||
|
|
||||||
|
if (!author || !text) return;
|
||||||
|
|
||||||
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Senden...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await postComment(albumId, author, text, website);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
alert(result.error);
|
||||||
|
} else {
|
||||||
|
document.getElementById('comment-text').value = '';
|
||||||
|
await loadComments(albumId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Fehler beim Senden des Kommentars');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i>Absenden';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal schliessen bei Klick ausserhalb
|
||||||
|
document.getElementById('album-modal').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'album-modal') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal schliessen mit Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Init ===
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const [albumsData, datesData] = await Promise.all([
|
||||||
|
fetchAlbums(),
|
||||||
|
fetchDates()
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderAlbums(albumsData.albums || []);
|
||||||
|
renderDateFilters(datesData.dates || {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden:', err);
|
||||||
|
document.getElementById('loading').innerHTML =
|
||||||
|
'<p class="text-red-500"><i class="fas fa-exclamation-triangle mr-2"></i>Fehler beim Laden der Alben</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user