docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -56,7 +56,7 @@ final readonly class GdImageProcessor implements ImageProcessorInterface
*/
private function createVariant(Image $image, array $config): ImageVariant
{
$sourcePath = $image->path . $image->filename;
$sourcePath = $image->path->join($image->filename)->toString();
/** @var ImageVariantType $type */
/** @var ImageSize $size */
@@ -73,14 +73,14 @@ final readonly class GdImageProcessor implements ImageProcessorInterface
$format->value
);
$destination = $image->path . $filename;
$destination = $image->path->join($filename)->toString();
$actualDimensions = $this->processImage($sourcePath, $destination, $width, $format);
$fileSize = filesize($destination);
return new ImageVariant(
imageId: $image->ulid,
imageId: (string) $image->ulid,
variantType: $type->value,
size: $size->value,
format: $format->value,
@@ -89,7 +89,7 @@ final readonly class GdImageProcessor implements ImageProcessorInterface
width: $actualDimensions['width'],
height: $actualDimensions['height'],
filename: $filename,
path: $image->path,
path: $image->path->toString(),
);
}
@@ -137,9 +137,9 @@ final readonly class GdImageProcessor implements ImageProcessorInterface
// Kopie für konsistente Verarbeitung
$dst = imagecreatetruecolor($newWidth, $newHeight);
imagecopy($dst, $source, 0, 0, 0, 0, $newWidth, $newHeight);
imagedestroy($source);
// imagedestroy is deprecated in PHP 8.0+ and has no effect
$this->saveImageInFormat($dst, $destination, $format);
imagedestroy($dst);
// imagedestroy is deprecated in PHP 8.0+ and has no effect
return ['width' => $newWidth, 'height' => $newHeight];
}
@@ -164,8 +164,8 @@ final readonly class GdImageProcessor implements ImageProcessorInterface
$this->saveImageInFormat($dst, $destination, $format);
imagedestroy($source);
imagedestroy($dst);
// imagedestroy is deprecated in PHP 8.0+ and has no effect
// imagedestroy is deprecated in PHP 8.0+ and has no effect
return ['width' => $newWidth, 'height' => $newHeight];
}

View File

@@ -4,9 +4,15 @@ declare(strict_types=1);
namespace App\Domain\Media;
use App\Framework\Core\ValueObjects\Dimensions;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Entity;
use App\Framework\Database\Attributes\Type;
use App\Framework\Filesystem\FilePath;
use App\Framework\Http\MimeType;
use App\Framework\Ulid\Ulid;
#[Entity(tableName: 'images', idColumn: 'ulid')]
final readonly class Image
@@ -16,31 +22,112 @@ final readonly class Image
public array $variants;
public function __construct(
/*#[Column(name: 'id', primary: true)]
public int $id,*/
#[Column(name: 'ulid', primary: true)]
public string $ulid,
public Ulid $ulid,
#[Column(name: 'filename')]
public string $filename,
#[Column(name: 'original_filename')]
public string $originalFilename,
#[Column(name: 'mime_type')]
public string $mimeType,
public MimeType $mimeType,
#[Column(name: 'file_size')]
public int $fileSize,
public FileSize $fileSize,
#[Column(name: 'width')]
public int $width,
public int $width,
#[Column(name: 'height')]
public int $height,
#[Column(name: 'hash'/*, unique: true*/)]
public string $hash,
public Hash $hash,
#[Column(name: 'path')]
public string $path,
public FilePath $path,
#[Column(name: 'alt_text')]
public string $altText,
) {
}
/**
* Get dimensions as Value Object
*/
public function getDimensions(): Dimensions
{
return new Dimensions($this->width, $this->height);
}
/**
* Get aspect ratio
*/
public function getAspectRatio(): float
{
return $this->getDimensions()->getAspectRatio();
}
/**
* Check if image is portrait orientation
*/
public function isPortrait(): bool
{
return $this->getDimensions()->isPortrait();
}
/**
* Check if image is landscape orientation
*/
public function isLandscape(): bool
{
return $this->getDimensions()->isLandscape();
}
/**
* Check if image is square
*/
public function isSquare(): bool
{
return $this->getDimensions()->isSquare();
}
/**
* Get human readable file size
*/
public function getHumanReadableFileSize(): string
{
return $this->fileSize->toHumanReadable();
}
/**
* Get file extension
*/
public function getFileExtension(): string
{
return $this->path->getExtension();
}
/**
* Check if hash matches
*/
public function verifyHash(Hash $hash): bool
{
return $this->hash->equals($hash);
}
/**
* Check if file is an image based on MIME type
*/
public function isImageFile(): bool
{
return $this->mimeType->isImage();
}
/**
* Get ULID as string
*/
public function getUlidString(): string
{
return $this->ulid->__toString();
}
/**
* Create new instance with updated filename
*/
public function withFilename(string $filename): self
{
return new self(
@@ -56,4 +143,42 @@ final readonly class Image
altText: $this->altText
);
}
/**
* Create new instance with updated alt text
*/
public function withAltText(string $altText): self
{
return new self(
ulid: $this->ulid,
filename: $this->filename,
originalFilename: $this->originalFilename,
mimeType: $this->mimeType,
fileSize: $this->fileSize,
width: $this->width,
height: $this->height,
hash: $this->hash,
path: $this->path,
altText: $altText
);
}
/**
* Create new instance with updated path
*/
public function withPath(FilePath $path): self
{
return new self(
ulid: $this->ulid,
filename: $this->filename,
originalFilename: $this->originalFilename,
mimeType: $this->mimeType,
fileSize: $this->fileSize,
width: $this->width,
height: $this->height,
hash: $this->hash,
path: $path,
altText: $this->altText
);
}
}

View File

@@ -5,90 +5,198 @@ declare(strict_types=1);
namespace App\Domain\Media;
use App\Framework\Database\EntityManager;
use App\Framework\Database\Repository\BatchLoader;
use App\Framework\Database\Repository\PaginatedResult;
/**
* Repository für Image Entities mit optimiertem Batch Loading
* Nutzt Komposition statt Vererbung gemäß Framework-Prinzipien
*/
final readonly class ImageRepository
{
private BatchLoader $batchLoader;
public function __construct(
private EntityManager $entityManager,
private EntityManager $entityManager
) {
$this->batchLoader = new BatchLoader($entityManager);
}
/**
* Speichert ein Bild mit Datei
*/
public function save(Image $image, string $tempPath): void
{
new SaveImageFile()($image, $tempPath);
$this->entityManager->save($image);
}
public function findBySlotName(string $slotName): Image
/**
* Findet Bild durch Slot Name (mit Batch Loading)
*/
public function findBySlotName(string $slotName): ?Image
{
return $this->entityManager->findOneBy(ImageSlot::class, ['slot_name' => $slotName])->image;
$slot = $this->entityManager->findOneBy(ImageSlot::class, ['slot_name' => $slotName]);
if (! $slot) {
return null;
}
// Nutze Batch Loading um N+1 zu vermeiden
$images = $this->batchLoader->findWithRelations(
Image::class,
criteria: ['slot_id' => $slot->id],
relations: ['slot', 'variants']
);
return $images[0] ?? null;
}
public function findById(string $id): ?Image
/**
* Findet Bild durch ID mit optionalen Relations
*/
public function findById(string $id, array $relations = []): ?Image
{
if (! empty($relations)) {
$images = $this->batchLoader->findWithRelations(
Image::class,
criteria: ['id' => $id],
relations: $relations
);
return $images[0] ?? null;
}
return $this->entityManager->find(Image::class, $id);
}
/**
* Findet Bild durch Filename
*/
public function findByFilename(string $filename): ?Image
{
return $this->entityManager->findOneBy(Image::class, ['filename' => $filename]);
}
/**
* Findet Bild durch Hash
*/
public function findByHash(string $hash): ?Image
{
return $this->entityManager->findOneBy(Image::class, ['hash' => $hash]);
}
/**
* @return Image[]
* Findet Bild durch ULID
*/
public function findAll(int $limit = 50, int $offset = 0, ?string $search = null): array
{
// Simplified version - use EntityManager::findAll for now
$allImages = $this->entityManager->findAll(Image::class);
// Apply search filter if provided
if ($search) {
$allImages = array_filter($allImages, function ($image) use ($search) {
return stripos($image->originalFilename, $search) !== false ||
stripos($image->altText, $search) !== false;
});
}
// Apply offset and limit
return array_slice($allImages, $offset, $limit);
}
public function count(?string $search = null): int
{
$allImages = $this->entityManager->findAll(Image::class);
if ($search) {
$allImages = array_filter($allImages, function ($image) use ($search) {
return stripos($image->originalFilename, $search) !== false ||
stripos($image->altText, $search) !== false;
});
}
return count($allImages);
}
public function findByUlid(string $ulid): ?Image
{
return $this->entityManager->find(Image::class, $ulid);
}
/**
* Findet alle Bilder paginiert mit optionaler Suche
*
* @return PaginatedResult
*/
public function findAllPaginated(
int $page = 1,
int $limit = 50,
?string $search = null,
array $relations = []
): PaginatedResult {
$criteria = [];
// TODO: Implementiere Volltext-Suche auf DB-Ebene
// Aktuell müssen wir noch manuell filtern für Suche
if ($search !== null) {
// Temporäre Lösung: Lade alle und filtere
// Dies sollte durch eine richtige DB-Query ersetzt werden
$allImages = $this->batchLoader->findWithRelations(
Image::class,
[],
$relations
);
$filtered = array_filter($allImages, function (Image $image) use ($search) {
return stripos($image->originalFilename, $search) !== false ||
stripos($image->altText ?? '', $search) !== false;
});
$totalItems = count($filtered);
$offset = ($page - 1) * $limit;
$items = array_slice($filtered, $offset, $limit);
return PaginatedResult::fromQuery($items, $totalItems, $page, $limit);
}
// Ohne Suche: Nutze optimierte Pagination
return $this->batchLoader->findPaginated(
Image::class,
$page,
$limit,
$criteria,
$relations
);
}
/**
* Findet alle Bilder (Legacy-Methode für Kompatibilität)
*
* @deprecated Nutze findAllPaginated stattdessen
* @return Image[]
*/
public function findAll(int $limit = 50, int $offset = 0, ?string $search = null): array
{
$page = (int) floor($offset / $limit) + 1;
$result = $this->findAllPaginated($page, $limit, $search);
return $result->items;
}
/**
* Zählt Bilder mit optionaler Suche
*/
public function count(?string $search = null): int
{
if ($search !== null) {
// Temporäre Lösung für Suche
$allImages = $this->entityManager->findAll(Image::class);
$filtered = array_filter($allImages, function (Image $image) use ($search) {
return stripos($image->originalFilename, $search) !== false ||
stripos($image->altText ?? '', $search) !== false;
});
return count($filtered);
}
return $this->batchLoader->countBy(Image::class, []);
}
/**
* Aktualisiert Alt-Text eines Bildes
*/
public function updateAltText(string $ulid, string $altText): void
{
$image = $this->findByUlid($ulid);
if ($image) {
$image->altText = $altText;
$this->entityManager->save($image);
// Verwende withAltText wenn verfügbar, sonst direkte Zuweisung
if (method_exists($image, 'withAltText')) {
$updatedImage = $image->withAltText($altText);
} else {
$image->altText = $altText;
$updatedImage = $image;
}
$this->entityManager->save($updatedImage);
}
}
/**
* Aktualisiert Filename eines Bildes
*/
public function updateFilename(string $ulid, string $filename): void
{
$image = $this->findByUlid($ulid);
@@ -100,15 +208,32 @@ final readonly class ImageRepository
}
/**
* Erweiterte Bildsuche mit Filtern
*
* @return Image[]
*/
public function search(string $query, ?string $type = null, int $minWidth = 0, int $minHeight = 0): array
{
$allImages = $this->entityManager->findAll(Image::class);
public function search(
string $query = '',
?string $type = null,
int $minWidth = 0,
int $minHeight = 0,
int $limit = 100
): array {
// TODO: Implementiere erweiterte Suche auf DB-Ebene mit QueryBuilder
// Aktuell: Temporäre Lösung mit Array-Filterung
$filteredImages = array_filter($allImages, function ($image) use ($query, $type, $minWidth, $minHeight) {
$allImages = $this->batchLoader->findWithRelations(
Image::class,
[],
['variants']
);
$filteredImages = array_filter($allImages, function (Image $image) use ($query, $type, $minWidth, $minHeight) {
// Text search
if ($query && ! (stripos($image->originalFilename, $query) !== false || stripos($image->altText, $query) !== false)) {
if ($query && ! (
stripos($image->originalFilename, $query) !== false ||
stripos($image->altText ?? '', $query) !== false
)) {
return false;
}
@@ -129,7 +254,56 @@ final readonly class ImageRepository
return true;
});
// Limit to 100 results
return array_slice($filteredImages, 0, 100);
return array_slice($filteredImages, 0, $limit);
}
/**
* Findet Bilder mit ihren Slots (optimiert für Gallery Views)
*
* @return Image[]
*/
public function findAllWithSlots(int $limit = 50): array
{
return $this->batchLoader->findWithRelations(
Image::class,
criteria: [],
relations: ['slot', 'variants'],
limit: $limit
);
}
/**
* Batch-Update für mehrere Bilder
*
* @param Image[] $images
* @return Image[]
*/
public function updateBatch(array $images): array
{
return $this->batchLoader->saveBatch($images);
}
/**
* Lädt Bilder mit allen Relations für Export
*
* @return Image[]
*/
public function findForExport(array $imageIds): array
{
return $this->batchLoader->findWithRelations(
Image::class,
criteria: ['id' => $imageIds],
relations: ['slot', 'variants', 'metadata']
);
}
/**
* Löscht mehrere Bilder im Batch
*
* @param Image[] $images
*/
public function deleteBatch(array $images): void
{
$this->batchLoader->deleteBatch($images);
}
}

View File

@@ -19,11 +19,11 @@ final readonly class ImageResizer
int $maxHeight,
int $quality = 9
): ImageVariant {
$sourcePath = $image->path . $image->filename;
$sourcePath = $image->path->join($image->filename)->toString();
$filename = str_replace('original', 'thumbnail', $image->filename);
$destination = $image->path . $filename;
$destination = $image->path->join($filename)->toString();
$imageInfo = getimagesize($sourcePath);
@@ -68,7 +68,7 @@ final readonly class ImageResizer
width : $newWidth,
height : $newHeight,
filename : $filename,
path: $image->path,
path: $image->path->toString(),
);
}

View File

@@ -155,7 +155,7 @@ final readonly class ImageSourceSetGenerator
// Wenn keine Varianten gefunden wurden, das Originalbild verwenden
return new ImageVariant(
imageId: $image->ulid,
imageId: (string) $image->ulid,
variantType: 'original',
size: 'original',
format: pathinfo($image->filename, PATHINFO_EXTENSION),
@@ -164,7 +164,7 @@ final readonly class ImageSourceSetGenerator
width: $image->width,
height: $image->height,
filename: $image->filename,
path: $image->path,
path: $image->path->toString(),
);
}
}

View File

@@ -59,7 +59,7 @@ final readonly class ImagickImageProcessor implements ImageProcessorInterface
*/
private function createVariant(Image $image, array $config): ImageVariant
{
$sourcePath = $image->path . $image->filename;
$sourcePath = $image->path->join($image->filename)->toString();
/** @var ImageVariantType $type */
/** @var ImageSize $size */
@@ -76,14 +76,14 @@ final readonly class ImagickImageProcessor implements ImageProcessorInterface
$format->value
);
$destination = $image->path . $filename;
$destination = $image->path->join($filename)->toString();
$actualDimensions = $this->processImage($sourcePath, $destination, $width, $format);
$fileSize = filesize($destination);
return new ImageVariant(
imageId: $image->ulid,
imageId: (string) $image->ulid,
variantType: $type->value,
size: $size->value,
format: $format->value,
@@ -92,7 +92,7 @@ final readonly class ImagickImageProcessor implements ImageProcessorInterface
width: $actualDimensions['width'],
height: $actualDimensions['height'],
filename: $filename,
path: $image->path,
path: $image->path->toString(),
);
}

View File

@@ -7,30 +7,37 @@ namespace App\Domain\Media\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Platform\SchemaBuilderFactory;
use App\Framework\Database\Platform\ValueObjects\TableOptions;
final readonly class CreateImageSlotsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS image_slots (
id INT AUTO_INCREMENT,
image_id VARCHAR(26) NOT NULL,
slot_name VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL,
$schema = SchemaBuilderFactory::create($connection);
PRIMARY KEY (id)
)
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SQL;
$connection->query($sql);
$schema->createTable(
'image_slots',
[
$schema->id(),
$schema->string('image_id', 26)->notNull(),
$schema->string('slot_name', 50)->notNull(),
$schema->timestamp('created_at')->notNull()->withDefault('CURRENT_TIMESTAMP'),
$schema->timestamp('updated_at')->withDefault(null),
],
TableOptions::default()
->withEngine('InnoDB')
->withCharset('utf8mb4')
->withCollation('utf8mb4_unicode_ci')
->withIfNotExists()
->withComment('Image slots table for image slot management')
);
}
public function down(ConnectionInterface $connection): void
{
$connection->execute("DROP TABLE IF EXISTS image_slots");
$schema = SchemaBuilderFactory::create($connection);
$schema->dropTable('image_slots', true);
}
public function getVersion(): MigrationVersion

View File

@@ -7,42 +7,53 @@ namespace App\Domain\Media\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Platform\SchemaBuilderFactory;
use App\Framework\Database\Platform\ValueObjects\TableOptions;
final class CreateImageVariantsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS image_variants (
id INT AUTO_INCREMENT,
image_id VARCHAR(26) NOT NULL,
variant_type VARCHAR(50) NOT NULL,
format VARCHAR(25) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
width INT NOT NULL,
height INT NOT NULL,
file_size BIGINT NOT NULL,
filename VARCHAR(500) NOT NULL,
path VARCHAR(500) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL,
$schema = SchemaBuilderFactory::create($connection);
PRIMARY KEY (id),
UNIQUE KEY uk_image_variants_combination (image_id, variant_type, format),
CONSTRAINT fk_image_variants_image_id
FOREIGN KEY (image_id) REFERENCES images(ulid) ON DELETE CASCADE,
INDEX idx_image_variants_lookup (image_id, variant_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SQL;
$schema->createTable(
'image_variants',
[
$schema->id(),
$schema->string('image_id', 26)->notNull(),
$schema->string('variant_type', 50)->notNull(),
$schema->string('format', 25)->notNull(),
$schema->string('mime_type', 100)->notNull(),
$schema->integer('width')->notNull(),
$schema->integer('height')->notNull(),
$schema->bigInteger('file_size')->notNull(),
$schema->string('filename', 500)->notNull(),
$schema->string('path', 500)->notNull(),
$schema->timestamp('created_at')->notNull()->withDefault('CURRENT_TIMESTAMP'),
$schema->timestamp('updated_at')->withDefault(null),
],
TableOptions::default()
->withEngine('InnoDB')
->withCharset('utf8mb4')
->withCollation('utf8mb4_unicode_ci')
->withIfNotExists()
->withComment('Image variants table for different image sizes and formats')
);
$connection->query($sql);
// TODO: Add unique constraint, foreign key constraint, and index
// These will need to be implemented in a future enhancement of the SchemaBuilder
// For now, we'll add them manually:
$connection->query("ALTER TABLE image_variants ADD UNIQUE KEY uk_image_variants_combination (image_id, variant_type, format)");
$connection->query("ALTER TABLE image_variants ADD CONSTRAINT fk_image_variants_image_id FOREIGN KEY (image_id) REFERENCES images(ulid) ON DELETE CASCADE");
$connection->query("ALTER TABLE image_variants ADD INDEX idx_image_variants_lookup (image_id, variant_type)");
}
public function down(ConnectionInterface $connection): void
{
$this->dropExistingConstraints($connection);
$schema = SchemaBuilderFactory::create($connection);
$connection->execute("DROP TABLE IF EXISTS image_variants");
$this->dropExistingConstraints($connection);
$schema->dropTable('image_variants', true);
}
public function getVersion(): MigrationVersion

View File

@@ -16,7 +16,7 @@ final class CreateImagesTableWithSchema implements Migration
{
$schema = new Schema($connection);
$schema->create('images', function (Blueprint $table) {
$schema->createIfNotExists('images', function (Blueprint $table) {
$table->ulid('ulid')->primary();
$table->string('filename', 255);
$table->string('original_filename', 255);

View File

@@ -10,11 +10,12 @@ final readonly class SaveImageFile
{
public function __invoke(Image $image, string $tempFileName): bool
{
$directory = $image->path;
$directory = $image->path->toString();
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
return move_uploaded_file($tempFileName, $image->path . $image->filename);
$fullPath = $image->path->join($image->filename)->toString();
return move_uploaded_file($tempFileName, $fullPath);
}
}