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