Compare commits

..

4 Commits

Author SHA1 Message Date
Claude 8858a08a32 Add DCTP (Delta Code Transfer Protocol) tool for efficient AI code transfers
DCTP enables efficient transfer of AI-generated code using delta operations
instead of sending complete files for each modification. Features include:

- Parser for DCTP control commands (NEW, DELETE, INSERT_AFTER, REPLACE, RENUMBER)
- Line-numbered code with language-specific comment formats
- Backup/Undo system with session management
- Diff generation for preview functionality
- CustomTkinter GUI with project management, preview, and diff views
2025-12-25 12:59:07 +00:00
admin faa36d0e5e Merge pull request #11 from metacube2/claude/family-albums-portal-jhT0O
Build family photo album portal backend
2025-12-25 11:52:32 +01:00
Claude 25766959f1 Add FamilyAlbums family photo portal with Nextcloud integration
A PHP-based family photo album portal featuring:
- Public gallery with year/month filtering and search
- Mobile-responsive design with Tailwind CSS
- Comment system for family members
- Admin interface for album management
- Flat-file JSON database (no MySQL needed)
- CSRF protection and XSS prevention
- Rate limiting and honeypot spam protection
2025-12-25 09:57:51 +00:00
admin 5f949121bf Merge pull request #10 from metacube2/claude/psytrance-visualizer-swift-bFGOw
Build Psytrance Visualizer with Swift and Metal
2025-12-22 22:38:56 +01:00
18 changed files with 4254 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.pyo
.dctp_backups/
.dctp_settings.json
+102
View File
@@ -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
View File
@@ -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
+305
View File
@@ -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()
+385
View File
@@ -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()
+584
View File
@@ -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()
+666
View File
@@ -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()
+307
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
customtkinter>=5.2.0
+48
View File
@@ -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
+131
View File
@@ -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.
+658
View File
@@ -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, "&#39;")})' 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})">&times;</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>
+350
View File
@@ -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);
+91
View File
@@ -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' => []]);
}
+8
View File
@@ -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>
+14
View File
@@ -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"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"comments": []
}
+449
View File
@@ -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>&copy; <?= 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, "&#39;")}'
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>