<?php
namespace App\Entity\Artikel;
use App\Entity\Artikel\Exceptions\ArtikelNotValidForAGalleryException;
use App\Entity\Artikel\Exceptions\ArtikelSelfReferenzException;
use App\Entity\Dossier\Dossier;
use App\Entity\EntityId;
use App\Entity\ObjectFileHandlingInterface;
use App\Entity\Redaktor\Redaktor;
use App\Util\UrlSlug;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints;
use Webmozart\Assert\Assert;
#[ORM\Entity]
#[ORM\Table(name: 'artikel')]
class Artikel implements ObjectFileHandlingInterface
{
public $redaktorAlternative;
public const int MAX_LENGTH_TITEL = 80;
public const int MAX_LENGTH_LEAD = 180;
public const int MAX_LENGTH_HINWEIS = 80;
public const int MAX_LENGTH_REDAKTOR_ALTERNATIVE = 64;
public const int MAX_LENGTH_SPITZMARKE = 64;
public const int MAX_LENGTH_URLSLUG = 255;
public const int MAX_WIDTH_IMAGE = 1024;
public const int MAX_WIDTH_PR_LOGO = 350;
public const float MIN_IMAGE_RATIO = 1.65;
public const float MAX_IMAGE_RATIO = 1.85;
public const string REGEX_VIDEO_URL_PATTERN = '#^(|https:\/\/www\.youtube\.com\/watch\?v=[a-zA-Z0-9\-\_]{1,})$#';
public const int MAX_CONCURRENT_LEAD_GALLERY_ARTIKEL = 3;
#[ORM\Column(name: 'titel', type: 'string', length: 128, nullable: true)]
private ?string $titel = null;
#[ORM\Column(name: 'spitzmarke', type: 'string', length: 64, nullable: true)]
private ?string $spitzmarke = null;
#[ORM\Column(name: '`lead`', type: 'text', length: 300, nullable: true)]
private ?string $lead = null;
#[ORM\Column(name: 'contentHtml', type: 'text', nullable: true)]
private ?string $contentHtml = null;
#[ORM\Column(name: 'video_url', type: 'text', length: 255, nullable: true)]
#[Constraints\Regex(pattern: '#^https:\/\/www\.youtube\.com\/watch\?v=[a-zA-Z0-9\-\_]{1,}$#', message: 'Die URL entspricht nicht der Formatvorgaben für eine YouTube URL.', match: true)]
private ?string $videoUrl = null;
/**
* ArtikelRubrik[]
*/
#[ORM\Column(name: 'rubrik_ids', type: 'json', nullable: true)]
private ?array $rubrikIds = [];
/**
* ArtikelKanal[]
*/
#[ORM\Column(name: 'kanal_ids', type: 'json', nullable: true)]
private ?array $kanalIds = [];
#[ORM\ManyToOne(targetEntity: Dossier::class)]
#[ORM\JoinColumn(name: 'dossier_id', referencedColumnName: 'id', nullable: true)]
private ?Dossier $dossier = null;
/**
* ArtikelStatus
*/
#[ORM\Column(name: 'status_id', type: 'string', length: 16)]
private ?string $statusId = null;
/**
* ArtikelTyp
*/
#[ORM\Column(name: 'typ_id', type: 'string', length: 16)]
private ?string $typId = null;
/**
* ArtikelAssettyp
*/
#[ORM\Column(name: 'assettyp_id', type: 'string', length: 16)]
private ?string $assettypId = null;
#[ORM\ManyToOne(targetEntity: Redaktor::class)]
#[ORM\JoinColumn(name: 'redaktor_id', referencedColumnName: 'id', nullable: true)]
private ?Redaktor $redaktor = null;
#[ORM\ManyToMany(targetEntity: Artikel::class)]
#[ORM\JoinTable(name: 'lnk_artikel_artikel', joinColumns: [new ORM\JoinColumn(name: 'artikel_id', referencedColumnName: 'id')], inverseJoinColumns: [new ORM\JoinColumn(name: 'verwandter_artikel_id', referencedColumnName: 'id')])]
private Collection $verwandteArtikel;
/**
* Artikel Datum, falls leer, erhält es das Publikationsdatum.
*/
#[ORM\Column(name: 'authored_at', type: 'datetime', nullable: true)]
private ?\DateTimeInterface $authoredAt = null;
/**
* Definitives Publikationsdatum.
*/
#[ORM\Column(name: 'publish_at', type: 'datetime', nullable: true)]
private ?\DateTimeInterface $publishAt = null;
#[ORM\Column(name: 'edit_at', type: 'datetime', nullable: false)]
private ?\DateTimeInterface $editAt = null;
#[ORM\Column(name: 'storage_contents', type: 'json', nullable: true)]
private array $storageContents = [];
#[ORM\Column(name: 'url_slug', type: 'string', length: 255, unique: true, nullable: true)]
private ?string $urlSlug = null;
#[ORM\Column(name: 'is_lead_artikel', type: 'boolean', nullable: true)]
private bool $isLeadArtikel = false;
#[ORM\Column(name: 'is_lead_gallery_artikel', type: 'boolean', nullable: true)]
private bool $isLeadGalleryArtikel = false;
#[ORM\Column(name: 'pushed_to_twitter_at', type: 'datetime', nullable: true)]
private ?\DateTimeInterface $pushedToTwitterAt = null;
/**
* Publireportage Anzeige in Feed angepinnt auf Position 3 bis zu diesem Datum.
*/
#[ORM\Column(name: 'pr_pinned_until', type: 'date', nullable: true)]
private ?\DateTimeInterface $prPinnedUntil = null;
public function __construct(
#[ORM\Id]
#[ORM\Column(name: "id", type: "entityId")]
#[ORM\GeneratedValue(strategy: "NONE")]
private EntityId $id,
string $titel,
?array $artikelKanale = null
)
{
$this->setTitel($titel);
// Default Values
$this->setStatus(ArtikelStatus::fromString(ArtikelStatus::ID_AS_2)); // Draft Status
$this->setTyp(ArtikelTyp::fromString(ArtikelTyp::ID_AT_1)); // Free
$this->setAssettyp(); // Asset Typ None
$this->verwandteArtikel = new ArrayCollection();
$this->setArtikelKanale($artikelKanale);
$this->setEditAtNow();
$this->setHinweis('Mehr dazu in der gedruckten Ausgabe oder im E-Paper. Kein Abo? Hier bestellen!'); // ACHTUNG: Text "Hier bestellen!" (nicht ändern oder JS-Logik erweitern) wird automatisch im Frontend mit dem ABO Link verlinkt
}
public function change(array $options): void
{
$resolver = new OptionsResolver();
$resolver->setRequired([
'titel',
'rubriken',
'status',
'typ',
'lead',
'hinweis',
'dossier',
'content_html',
'content_html_media_code',
'video_url',
'redaktor',
'redaktor_alternative',
'spitzmarke',
'verwandte_artikel',
'artikel_kanale',
'publish_at',
'authored_at',
'images',
'pr_content_html',
'pr_logo',
'pr_pinned_until',
]);
$options = $resolver->resolve($options);
$this->setTitel($options['titel']);
$this->setHinweis($options['hinweis']);
$this->setRubriken($options['rubriken']);
$this->setStatus($options['status']);
$this->setTyp($options['typ']);
$this->setLead($options['lead']);
$this->setDossier($options['dossier']);
$this->setContentHtml($options['content_html']);
$this->setContentHtmlMediaCode($options['content_html_media_code']);
$this->setVideoUrl($options['video_url']);
$this->setRedaktor($options['redaktor'], $options['redaktor_alternative']);
$this->setSpitzmarke($options['spitzmarke']);
$this->setVerwandteArtikel($options['verwandte_artikel']);
$this->setArtikelKanale($options['artikel_kanale']);
$this->setAuthoredAt($options['authored_at']);
$this->setImages($options['images']); // setze auch den Assettyp
$this->setPrContentHtml($options['pr_content_html']);
$this->setPrLogo($options['pr_logo']);
// --- Domain Logiken
$this->setAssettyp($this->images());
$this->setPublishAtAndPrPinnedUntil($options['status'], $options['publish_at'], $options['pr_pinned_until']);
// Falls LeadGalleryArtikel aktiv, diesen überprüfen ob noch valid()
if ($this->isLeadGalleryArtikel()) {
$this->autoConfigureLeadArtikelGallery();
}
// Ende
$this->setEditAtNow();
}
/**
* Spez function, welche die Flags der Frontendanzeige regelt!
*/
public function changeFrontendConfig(array $options): void
{
// Validierung input
$resolver = new OptionsResolver();
$resolver->setDefaults(
[
'is_lead_artikel' => null,
'is_lead_gallery_artikel' => null,
]
);
$options = $resolver->resolve($options);
// End
if (null !== $options['is_lead_artikel']) {
$this->setIsLeadArtikel($options['is_lead_artikel']);
}
if (null !== $options['is_lead_gallery_artikel']) {
$this->setIsLeadArtikelGallery($options['is_lead_gallery_artikel']);
}
}
/**
* Setzt die Zeit, wann die Mitteilung auf Twitter gepushed wurde.
*/
public function setPushedToTwitterNow(): void
{
$this->setPushedToTwitterAt(new \DateTime());
}
public function setPublishAtAndPrPinnedUntil(ArtikelStatus $status, ?\DateTime $publishAt = null, ?\DateTime $prPinnedUntil = null): void
{
// Berechne und setze das Publikationsdatum
if ($status->isPublish() && !$publishAt) {
$this->publishAt = new \DateTime('now'); // umgehend publizieren
} else {
$this->publishAt = $publishAt;
}
// Berechne und setze das Publireportage pinned Datum
if (!$this->typ()->isPublireportage()) {
$this->prPinnedUntil = null;
} elseif ($status->isPublish() && !$prPinnedUntil) {
$dateTmp = clone $this->publishAt;
$this->prPinnedUntil = $dateTmp->add(new \DateInterval('P10D'));
// Standard
} else {
$this->prPinnedUntil = $prPinnedUntil;
}
// end
}
// ----- Ende Domain Functions --------------------------
public function id(): EntityId
{
return $this->id;
}
public function titel(): ?string
{
return $this->titel;
}
public function spitzmarke(): ?string
{
return $this->spitzmarke;
}
/**
* @return ArtikelRubrik[]
*/
public function rubriken(): array
{
$artikelRubriken = [];
if ($this->rubrikIds) {
foreach ($this->rubrikIds as $rubrikId) {
$artikelRubriken[] = ArtikelRubrik::fromString($rubrikId);
}
}
return $artikelRubriken;
}
/**
* @return ArtikelKanal[]
*/
public function artikelKanale(): array
{
$artikelKanale = [];
if ($this->kanalIds) {
foreach ($this->kanalIds as $kanalId) {
$artikelKanale[] = ArtikelKanal::fromString($kanalId);
}
}
return $artikelKanale;
}
/**
* @return ArtikelStatus
*/
public function status(): ArtikelStatus
{
return ArtikelStatus::fromString($this->statusId);
}
/**
* @return ArtikelTyp
*/
public function typ(): ArtikelTyp
{
return ArtikelTyp::fromString($this->typId);
}
/**
* @return ArtikelAssettyp
*/
public function assettyp(): ArtikelAssettyp
{
return ArtikelAssettyp::fromString($this->assettypId);
}
public function lead(): ?string
{
return $this->lead;
}
private function setLead(?string $lead): void
{
Assert::maxLength($lead, self::MAX_LENGTH_LEAD);
$this->lead = $lead;
}
public function dossier(): ?Dossier
{
return $this->dossier;
}
public function videoUrl(bool $extractYouTubeIdOnly = false): ?string
{
if (true === $extractYouTubeIdOnly && $this->videoUrl) {
return explode('v=', $this->videoUrl)[1];
}
return $this->videoUrl;
}
public function redaktor(): ?Redaktor
{
return $this->redaktor;
}
public function authoredAt(bool $rawData = false): ?\DateTimeInterface
{
if ($rawData || $this->authoredAt) {
return $this->authoredAt;
}
return $this->publishAt();
}
public function publishAt(): ?\DateTimeInterface
{
return $this->publishAt;
}
public function pushedToTwitterAt(): ?\DateTimeInterface
{
return $this->pushedToTwitterAt;
}
public function isPublished(): bool
{
return $this->status()->isPublish() && $this->publishAt() < new \DateTime('now');
}
public function verwandteArtikel(): ArrayCollection|Collection
{
return $this->verwandteArtikel;
}
protected function setStatus(ArtikelStatus $status): static
{
$this->statusId = $status->id();
return $this;
}
protected function setTyp(ArtikelTyp $typ): static
{
$this->typId = $typ->id();
return $this;
}
protected function setAssettyp(?ArtikelImageCollection $artikelImageCollection = null): static
{
$this->assettypId = ArtikelAssettyp::ID_AAT_1;
if ($artikelImageCollection instanceof ArtikelImageCollection) {
if ($artikelImageCollection->count() > 1) {
$this->assettypId = ArtikelAssettyp::ID_AAT_3;
} elseif ($artikelImageCollection->count() === 1) {
$this->assettypId = ArtikelAssettyp::ID_AAT_2;
}
}
return $this;
}
protected function setRubriken(array $rubriken): static
{
$this->rubrikIds = [];
if ($rubriken) {
Assert::allIsInstanceOf($rubriken, ArtikelRubrik::class);
foreach ($rubriken as $item) {
/*
* @var ArtikelKanal $item
*/
$this->rubrikIds[] = $item->id();
}
}
return $this;
}
protected function setDossier(?Dossier $dossier = null): static
{
$this->dossier = $dossier;
return $this;
}
protected function setVideoUrl(?string $url): static
{
Assert::regex($url, self::REGEX_VIDEO_URL_PATTERN);
$this->videoUrl = $url;
return $this;
}
private function setTitel(?string $titel): static
{
Assert::minLength($titel, 1);
Assert::maxLength($titel, self::MAX_LENGTH_TITEL);
// Urlslug generieren und setzen
$this->setUrlSlug($titel);
$this->titel = $titel;
return $this;
}
private function setSpitzmarke(?string $spitzmarke): static
{
Assert::maxLength($spitzmarke, self::MAX_LENGTH_SPITZMARKE);
$this->spitzmarke = $spitzmarke;
return $this;
}
private function setRedaktor(?Redaktor $redaktor = null, $redaktorAlternative = null): static
{
$redaktorAlternative = trim((string) $redaktorAlternative);
if ($redaktor && strlen($redaktorAlternative) > 0) {
throw new \InvalidArgumentException('Gleichzeitige Setzung eines Redaktors und einer Redaktor-Alternative ist nicht erlaubt!');
}
$this->redaktor = null;
$this->redaktorAlternative = null;
if ($redaktor instanceof Redaktor) {
$this->redaktor = $redaktor;
} elseif (strlen($redaktorAlternative) > 0) {
$this->storageContents['redaktor_alternative'] = $redaktorAlternative;
}
return $this;
}
public function isLeadArtikel(): bool
{
return $this->isLeadArtikel;
}
private function setIsLeadArtikel(bool $isLeadArtikel): static
{
Assert::boolean($isLeadArtikel);
$this->isLeadArtikel = $isLeadArtikel;
return $this;
}
public function isLeadGalleryArtikel(): bool
{
return $this->isLeadGalleryArtikel;
}
private function setIsLeadArtikelGallery(bool $boolean): static
{
Assert::boolean($boolean);
// Validierung
if ($boolean) {
if ($this->hasGalleryAssets()) {
$this->isLeadGalleryArtikel = true;
} else {
throw new ArtikelNotValidForAGalleryException();
}
} else {
$this->isLeadGalleryArtikel = false;
}
return $this;
}
/**
* Wert wird automatisch gemäss Artikel Daten gesetzt.
*/
private function autoConfigureLeadArtikelGallery(): void
{
$this->isLeadGalleryArtikel = $this->hasGalleryAssets();
}
/**
* Kann dieser Artikel in einer Gallery angezeigt werden?
*/
public function hasGalleryAssets(): bool
{
return $this->assettyp()->id() === ArtikelAssettyp::ID_AAT_3 || $this->videoUrl();
}
/* --- Publireportage Felder --- */
private function setPrContentHtml(?string $prContentHtml = null): void
{
$prContentHtml = (string) $prContentHtml;
$this->storageContents['pr_content_html'] = $prContentHtml;
}
public function prContentHtml(): ?string
{
return $this->storageContents('pr_content_html');
}
private function setPrLogo(?ArtikelImage $image = null): void
{
if (!$image instanceof ArtikelImage) {
$image = ArtikelImage::createNonExistImage();
}
$this->storageContents['pr_logo'] = [
'filename' => $image->getFilename(),
'alt' => $image->getAlt(),
'caption' => $image->getCaption(),
];
}
public function prLogo(): ArtikelImage
{
$imageData = $this->storageContents('pr_logo');
if (is_array($imageData)) {
return ArtikelImage::create($imageData['filename'], $imageData['alt'], $imageData['caption'], '', $this->baseObjectServerDirPath(), $this->baseObjectWebDirPath());
}
return ArtikelImage::createNonExistImage();
}
public function prPinnedUntil(): ?\DateTimeInterface
{
return $this->prPinnedUntil;
}
/* --- Ende Publireportage Felder --- */
public function contentHtml(): ?string
{
return $this->contentHtml;
}
private function setContentHtml(?string $contentHtml): void
{
$this->contentHtml = $contentHtml;
}
private function setAuthoredAt(?\DateTime $authoredAt = null): void
{
$this->authoredAt = $authoredAt;
}
private function setPushedToTwitterAt(?\DateTime $pushedToTwitterAt = null): void
{
$this->pushedToTwitterAt = $pushedToTwitterAt;
}
/**
* Zusätzlicher Media HTML-Code welcher zum ContentHtml Inhalt dazu generiert wird
* z.B. ein fertiger IFrame Code oder ein HTML Image Code usw. oder auch ein Javascript.
*
* ACHTUNG: Ausgabe wird nicht escaped!
*/
public function contentHtmlMediaCode()
{
return $this->storageContents('content_html_media_code');
}
private function setContentHtmlMediaCode($string): void
{
$this->storageContents['content_html_media_code'] = $string;
}
/**
* Hinweis Text.
*/
public function hinweis()
{
return $this->storageContents('hinweis');
}
private function setHinweis($string): void
{
$this->storageContents['hinweis'] = $string;
}
private function setImages(ArtikelImageCollection|array $images = null): void
{
$this->storageContents['images'] = []; // resetten
if ($images) {
Assert::isInstanceOf($images, ArtikelImageCollection::class);
foreach ($images as $image) {
$this->storageContents['images'][] = $image->toArray();
}
}
}
public function images(): ?ArtikelImageCollection
{
$imageDataRaw = $this->storageContents('images');
if ($imageDataRaw) {
// Filepath wieder gemäss $path Parameter wieder dazu bilden hinzufügen
$imageCollection = new ArtikelImageCollection();
foreach ($imageDataRaw as $data) {
$fileName = $data['filename'];
$alt = $data['alt'];
$caption = $data['caption'];
$rightholder = $data['rightholder'];
$imageCollection->add(ArtikelImage::create($fileName, $alt, $caption, $rightholder, $this->baseObjectServerDirPath(), $this->baseObjectWebDirPath()));
}
// Ende
return $imageCollection;
}
return null;
}
private function setEditAtNow(): void
{
$this->editAt = new \DateTime();
}
public function editAt(): ?\DateTimeInterface
{
return $this->editAt;
}
public function redaktorAlternativ()
{
return $this->storageContents('redaktor_alternative');
}
// Hier muss array der Typ sein, weil es als Array aus dem Form kommt und nicht als Collection, evtl, weil der ChoiceType verwendet wird anstatt EntityType.
// Habe ehrlich gesagt langsam die Schnauze voll von dieser Typisierung. SB/7.3.2024
private function setVerwandteArtikel(?array $verwandteArtikel = null): void
{
// Verwandte Artikel nullen
$this->verwandteArtikel = new ArrayCollection();
if ($verwandteArtikel) {
Assert::allIsInstanceOf($verwandteArtikel, Artikel::class);
// Entferne verwandter Artikel, welcher er selbst ist
foreach ($verwandteArtikel as $artikel) {
if ($artikel->id != $this->id) {
$this->verwandteArtikel[] = $artikel;
} else {
throw new ArtikelSelfReferenzException();
}
}
}
}
private function setArtikelKanale($artikelKanale = null): void
{
$this->kanalIds = [];
if ($artikelKanale) {
Assert::allIsInstanceOf($artikelKanale, ArtikelKanal::class);
foreach ($artikelKanale as $item) {
/*
* @var ArtikelKanal $item
*/
$this->kanalIds[] = $item->id();
}
foreach ($this->kanalIds as $kanalId) {
// Falls nicht als Lead Artikel verwendet werden darf isLeadArtikel Flag zurücksetzen
if (in_array($kanalId, [ArtikelKanal::ID_AK_2, ArtikelKanal::ID_AK_3], true)) {
$this->setIsLeadArtikel(false);
}
}
}
}
private function storageContents($key)
{
$storage = $this->storageContents ?: [];
if (array_key_exists($key, $storage)) {
return $storage[$key];
}
return null;
}
#[\Override]
public function baseObjectServerDirPath(): string
{
return __DIR__.'/../../../var/data/public/artikel/'.substr($this->id, 0, 2).'/'.substr($this->id, 2, 2).'/'.$this->id;
}
#[\Override]
public function baseObjectWebDirPath(): string
{
return '/data/artikel/'.substr($this->id, 0, 2).'/'.substr($this->id, 2, 2).'/'.$this->id;
}
#[\Override]
public function removeUnusedFiles(): void
{
$serverPath = $this->baseObjectServerDirPath();
// Auflistung aller effektiv benötigter Filenamen dieses Objects, damit diese erhalten bleiben
$usedFiles_ = [];
if ($this->images()) {
foreach ($this->images() as $image) {
$usedFiles_[] = $image->getFilename();
}
}
if ($this->prLogo()->doesExist()) {
$usedFiles_[] = $this->prLogo()->getFilename();
}
// Ende
// Lösche alle Files aus dem Objektverzeichnis, welche nicht im Objekt vorhanden sind
$fs = new Filesystem();
if ($fs->exists($serverPath)) {
$finder = new Finder();
$finder->files()->in($serverPath);
foreach ($finder as $file) {
if (!in_array($file->getFilename(), $usedFiles_)) {
$fs->remove($file->getRealPath());
}
}
}
// Ende
}
public function urlSlug(): ?string
{
return $this->urlSlug;
}
/**
* Generiert einen Spez. Slug mit Suffix -p{Nummer}.
*/
public function generateNextUrlSlug(): void
{
// Filtere eine bestehende UrlSlug Nummer raus und erhöhe diese!
$result = [];
preg_match('/.*-p(\\d+)$/', $this->urlSlug, $result);
if ($result) {
$pageNrSuffix = ($result[1] + 1); // inkrementiere die bestehende p Nummer um 1
} else {
$pageNrSuffix = 1;
}
// end
$this->setUrlSlug($this->titel.'-p'.$pageNrSuffix);
}
private function setUrlSlug($string): void
{
// all chars lowercase
$urlSlug = self::extractUrlSlug($string);
Assert::notEmpty($urlSlug);
Assert::maxLength($urlSlug, self::MAX_LENGTH_URLSLUG);
$this->urlSlug = $urlSlug;
}
public static function extractUrlSlug($string): array|string|null
{
return UrlSlug::fromString($string);
}
}