diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 0000000..66c19bf --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,43 @@ +name: "Continuous Integration" + +on: + - pull_request + - push + +jobs: + continuous-integration-php-82: + name: "Coding Standards" + runs-on: ubuntu-latest + steps: + - name: "Checkout" + uses: actions/checkout@master + - name: "Install Php 8.2" + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + tools: composer:v2 + extensions: openssl, json, pdo, pdo_mysql, fileinfo, curl + - name: "Validate composer.json" + run: php $(which composer) validate --strict + - name: "Install dependencies with composer" + run: php $(which composer) install --no-interaction --no-progress --no-suggest + - name: "Run PHP CodeSniffer" + run: php vendor/bin/phpcs --standard=phpcs.xml + continuous-integration-php-83: + name: "Coding Standards" + runs-on: ubuntu-latest + steps: + - name: "Checkout" + uses: actions/checkout@master + - name: "Install Php 8.3" + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer:v2 + extensions: openssl, json, pdo, pdo_mysql, fileinfo, curl + - name: "Validate composer.json" + run: php $(which composer) validate --strict + - name: "Install dependencies with composer" + run: php $(which composer) install --no-interaction --no-progress --no-suggest + - name: "Run PHP CodeSniffer" + run: php vendor/bin/phpcs --standard=phpcs.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9668283 --- /dev/null +++ b/.gitignore @@ -0,0 +1,103 @@ +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.DS_Store + + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp +*.exe + +# Windows shortcuts +*.lnk + +# +# ================================= +# Gitignore For Linux OS +# ================================= + +# KDE directory preferences +.directory + + +# +# ================================= +# Gitignore For Jetbrain +# ================================= + +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +*.iml + +# Directory-based project format: +#.idea/*.xml +.idea/ + +# File-based project format: +*.ipr +*.iws + +# JIRA plugin +atlassian-ide-plugin.xml + +# +# ================================= +# Gitignore For Text Editor +# ================================= + +# cache files for sublime text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# workspace files are user-specific +*.sublime-workspace + +# project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using SublimeText +# *.sublime-project + +# sftp configuration file +sftp-config.json + +# Ignore Sublime .phpintel +.phpintel + +# Ignore Visual Studio code +.vscode + +# +# ================================= +# Gitignore For Composer & Projects +# ================================= + +# ignore all log file +*.log + +# ignore composer lock +composer.lock + +# ignore composer phar file +composer.phar + +# add exclude vendor +vendor/ + +# ignore npm lock +package-lock.json + +# ignore node modules +node_modules/ + +# IGNORE ENVIRONMENT +.env +config.php +.config.php +# ignore php ini user +.user.ini diff --git a/Entities/Post.php b/Entities/Post.php new file mode 100644 index 0000000..e64e52d --- /dev/null +++ b/Entities/Post.php @@ -0,0 +1,751 @@ + 'utf8mb4', // remove this or change to utf8 if not use mysql + 'collation' => 'utf8mb4_unicode_ci', // remove this if not use mysql + 'comment' => 'Table posts', + 'priority' => 100, + ] +)] +#[UniqueConstraint( + name: 'unique_slug_site_id', + columns: ['slug', 'site_id'] +)] +#[Index( + columns: [ + 'type', + 'status', + 'site_id', + 'id', + ], + name: 'index_type_status_id_site_id' +)] +#[Index( + columns: [ + 'title', + 'site_id', + 'type', + 'status', + 'id', + 'parent_id', + 'user_id', + 'published_at', + 'created_at', + 'deleted_at', + 'password_protected', + ], + name: 'index_like_search_sorting' +)] +#[Index( + columns: ['published_at', 'created_at'], + name: 'index_published_at_created_at' +)] +#[Index( + columns: ['site_id'], + name: 'relation_posts_site_id_sites_id' +)] +#[Index( + columns: ['category_id'], + name: 'index_category_id' +)] +#[Index( + columns: ['category_id', 'site_id'], + name: 'relation_posts_category_id_post_categories_id_site_id' +)] +#[Index( + columns: ['parent_id'], + name: 'relation_posts_parent_id_posts_id' +)] +#[Index( + columns: ['user_id'], + name: 'relation_posts_user_id_admins_id' +)] +#[HasLifecycleCallbacks] +class Post extends AbstractEntity implements AvailabilityStatusEntityInterface +{ + public const TABLE_NAME = 'posts'; + + use AvailabilityStatusTrait, + PasswordTrait, + ParentIdEventStateTrait; + + public const TYPE_POST = 'post'; + + public const TYPE_PAGE = 'page'; + + public const TYPE_REVISION = 'revision'; + + #[Id] + #[GeneratedValue('AUTO')] + #[Column( + name: 'id', + type: Types::BIGINT, + length: 20, + updatable: false, + options: [ + 'unsigned' => true, + 'comment' => 'Primary key post id' + ] + )] + protected int $id; + + #[Column( + name: 'slug', + type: Types::STRING, + length: 255, + nullable: false, + options: [ + 'comment' => 'Post slug' + ] + )] + protected string $slug; + + #[Column( + name: 'site_id', + type: Types::BIGINT, + length: 20, + nullable: true, + options: [ + 'default' => null, + 'unsigned' => true, + 'comment' => 'Site id' + ] + )] + protected ?int $site_id = null; + + #[Column( + name: 'title', + type: Types::STRING, + length: 255, + nullable: false, + options: [ + 'comment' => 'Post Title' + ] + )] + protected string $title; + + #[Column( + name: 'content', + type: Types::TEXT, + length: 4294967295, + nullable: false, + options: [ + 'default' => '', + 'comment' => 'Post content' + ] + )] + protected string $content = ''; + + #[Column( + name: 'type', + type: Types::STRING, + length: 64, + nullable: false, + options: [ + 'default' => 'post', + 'comment' => 'Post type' + ] + )] + protected string $type = self::TYPE_POST; + + #[Column( + name: 'category_id', + type: Types::BIGINT, + length: 20, + nullable: true, + options: [ + 'unsigned' => true, + 'default' => null, + 'comment' => 'Category id' + ] + )] + protected ?int $category_id = null; + + #[Column( + name: 'status', + type: Types::STRING, + length: 64, + options: [ + 'default' => self::DRAFT, + 'comment' => 'Post status' + ] + )] + protected string $status = self::DRAFT; + + #[Column( + name: 'parent_id', + type: Types::BIGINT, + length: 20, + nullable: true, + options: [ + 'unsigned' => true, + 'comment' => 'Post parent id' + ] + )] + protected ?int $parent_id = null; + + #[Column( + name: 'user_id', + type: Types::BIGINT, + length: 20, + nullable: true, + options: [ + 'default' => null, + 'unsigned' => true, + 'comment' => 'Admin id' + ] + )] + protected ?int $user_id = null; + + #[Column( + name: 'password', + type: Types::STRING, + length: 255, + nullable: true, + updatable: true, + options: [ + 'default' => null, + 'comment' => 'Post password' + ] + )] + protected ?string $password = null; + + #[Column( + name: 'password_protected', + type: Types::BOOLEAN, + options: [ + 'default' => false, + 'comment' => 'Protect post with password' + ] + )] + protected bool $password_protected = false; + + #[Column( + name: 'published_at', + type: Types::DATETIME_IMMUTABLE, + nullable: true, + options: [ + 'default' => null, + 'comment' => 'Date published' + ] + )] + protected ?DateTimeInterface $published_at = null; + + #[Column( + name: 'created_at', + type: Types::DATETIME_MUTABLE, + updatable: false, + options: [ + 'default' => 'CURRENT_TIMESTAMP', + 'comment' => 'Post created time' + ] + )] + protected DateTimeInterface $created_at; + + #[Column( + name: 'updated_at', + type: Types::DATETIME_IMMUTABLE, + unique: false, + updatable: false, + options: [ + 'attribute' => 'ON UPDATE CURRENT_TIMESTAMP', + 'default' => '0000-00-00 00:00:00', + 'comment' => 'Post update time' + ], + // columnDefinition: "DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP" + )] + protected DateTimeInterface $updated_at; + + #[Column( + name: 'deleted_at', + type: Types::DATETIME_IMMUTABLE, + nullable: true, + options: [ + 'default' => null, + 'comment' => 'Post delete time' + ] + )] + protected ?DateTimeInterface $deleted_at = null; + + #[ + JoinColumn( + name: 'parent_id', + referencedColumnName: 'id', + nullable: true, + onDelete: 'SET NULL', + options: [ + 'relation_name' => 'relation_posts_parent_id_posts_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'SET NULL' + ], + ), + ManyToOne( + targetEntity: self::class, + cascade: [ + 'persist' + ], + fetch: 'LAZY' + ) + ] + protected ?Post $parent = null; + + #[JoinTable(name: PostCategory::TABLE_NAME)] + #[JoinColumn( + name: 'category_id', + referencedColumnName: 'id', + nullable: true, + onDelete: 'SET NULL', + options: [ + 'relation_name' => 'relation_posts_category_id_post_categories_id_site_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'SET NULL' + ], + )] + #[JoinColumn( + name: 'site_id', + referencedColumnName: 'site_id', + nullable: true, + onDelete: 'SET NULL', + options: [ + 'relation_name' => 'relation_posts_category_id_post_categories_id_site_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'SET NULL' + ], + )] + #[ + ManyToOne( + targetEntity: PostCategory::class, + cascade: [ + 'persist' + ], + fetch: 'EAGER' + ) + ] + protected ?PostCategory $category = null; + + #[ + JoinColumn( + name: 'site_id', + referencedColumnName: 'id', + nullable: true, + onDelete: 'RESTRICT', + options: [ + 'relation_name' => 'relation_posts_site_id_sites_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'RESTRICT' + ] + ), + ManyToOne( + targetEntity: Site::class, + cascade: [ + "persist" + ], + fetch: 'EAGER' + ) + ] + protected ?Site $site = null; + + #[ + JoinColumn( + name: 'user_id', + referencedColumnName: 'id', + nullable: true, + onDelete: 'SET NULL', + options: [ + 'relation_name' => 'relation_posts_user_id_admins_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'SET NULL' + ], + ), + ManyToOne( + targetEntity: Admin::class, + cascade: [ + 'persist' + ], + fetch: 'LAZY' + ) + ] + protected ?Admin $user = null; + + public function __construct() + { + $this->created_at = new DateTimeImmutable(); + $this->updated_at = new DateTimeImmutable('0000-00-00 00:00:00'); + } + + #[ + PrePersist, + PreUpdate + ] + public function preCheckSlug(PrePersistEventArgs|PreUpdateEventArgs $event): void + { + /** @noinspection DuplicatedCode */ + $oldSlug = null; + $slug = $this->getSlug(); + $isUpdate = $event instanceof PreUpdateEventArgs; + if ($isUpdate) { + if (!$event->hasChangedField('slug')) { + return; + } + $oldSlug = $event->getOldValue('slug'); + $slug = $event->getNewValue('slug')?:$slug; + } + + if ($oldSlug === $slug) { + return; + } + + if (trim($slug) === '') { + $slug = UUID::v4(); + } + do { + $this->slug = $slug; + $query = $event + ->getObjectManager() + ->getRepository($this::class) + ->matching( + Expression::criteria()->where( + Expression::andX( + Expression::eq('slug', $slug), + Expression::eq('site_id', $this->getSite()), + ) + )->setMaxResults(1) + ) + ->count(); + } while ($query > 0 && ($slug = UUID::v4())); + if ($isUpdate) { + $event->setNewValue('slug', $slug); + } + } + + public function getId() : int + { + return $this->id; + } + + public function getSlug(): string + { + return $this->slug; + } + + public function setSlug(string $slug): void + { + $this->slug = $slug; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): void + { + $this->content = $content; + } + + public function getType(): string + { + return $this->type; + } + + /** + * @param string $type + * @return string + */ + public static function normalizeType(string $type): string + { + $lower = strtolower(trim($type)); + return match ($lower) { + self::TYPE_POST, + self::TYPE_PAGE, + self::TYPE_REVISION => $lower, + default => trim($type) + }; + } + + public function getNormalizeType(): string + { + return static::normalizeType($this->getType()); + } + + public function isRevision() : bool + { + return $this->getNormalizeType() === self::TYPE_REVISION + && $this->getParent() + && $this->getParent()->getNormalizeType() !== self::TYPE_REVISION; + } + + public function setType(string $type): void + { + $this->type = $type; + } + + public function getCategoryId(): ?int + { + return $this->category_id; + } + + public function setCategoryId(?int $category_id): void + { + $this->category_id = $category_id; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): void + { + $this->status = $status; + } + + public function getParentId(): ?int + { + return $this->parent_id; + } + + public function setParentId(?int $parent_id): void + { + $this->parent_id = $parent_id; + } + + public function getUserId(): ?int + { + return $this->user_id; + } + + public function setUserId(?int $user_id): void + { + $this->user_id = $user_id; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(?string $password): void + { + $this->password = $password; + } + + public function isPasswordProtected(): bool + { + return $this->password_protected; + } + + public function setPasswordProtected(bool $password_protected): void + { + $this->password_protected = $password_protected; + } + + public function getPublishedAt(): ?DateTimeInterface + { + return $this->published_at; + } + + public function setPublishedAt(?DateTimeInterface $published_at): void + { + $this->published_at = $published_at; + } + + public function getCreatedAt(): DateTimeInterface + { + return $this->created_at; + } + + public function getUpdatedAt(): DateTimeInterface + { + return $this->updated_at; + } + + public function getDeletedAt(): ?DateTimeInterface + { + return $this->deleted_at; + } + + public function setDeletedAt(?DateTimeInterface $deletedAt) : void + { + $this->deleted_at = $deletedAt; + } + + public function getParent(): ?Post + { + return $this->parent; + } + + public function setParent(?Post $parent): void + { + $this->parent = $parent; + $this->setParentId($parent?->getId()); + } + + public function getCategory(): ?PostCategory + { + return $this->category; + } + + public function setCategory(?PostCategory $category): void + { + $this->category = $category; + $this->setCategoryId($category?->getId()); + } + + public function getUser(): ?Admin + { + return $this->user; + } + + public function setUser(?Admin $user): void + { + $this->user = $user; + $this->setUserId($user->getId()); + } + + public function getSiteId(): ?int + { + return $this->site_id; + } + + public function setSiteId(?int $site_id): void + { + $this->site_id = $site_id; + } + + public function getSite(): ?Site + { + return $this->site; + } + + public function setSite(?Site $site): void + { + $this->site = $site; + $this->setSiteId($site?->getId()); + } + + #[ + PreUpdate, + PostLoad, + PrePersist + ] + public function checkDataEvent( + PrePersistEventArgs|PostLoadEventArgs|PreUpdateEventArgs $event + ) : void { + $this->passwordBasedIdUpdatedAt($event); + $this->parentIdCheck($event); + $normalizeStatus = $this->getNormalizedStatus(); + $normalizeType = $this->getNormalizeType(); + $isStatusMatch = $this->getStatus() === $normalizeStatus; + $isTypeMatch = $this->getType() === $normalizeType; + $isMatch = $isTypeMatch && $isStatusMatch; + $isCatSiteIdMissMatch = ($cat = $this->getCategory()) && $cat->getSiteId() !== $this->getSiteId(); + if ($isMatch && !$isCatSiteIdMissMatch) { + return; + } + $this->setType($normalizeType); + $this->setStatus($normalizeStatus); + if ($isCatSiteIdMissMatch) { + $this->setCategory(null); + } + if ($event instanceof PostLoadEventArgs) { + $date = $this->getUpdatedAt(); + $date = str_starts_with($date->format('Y'), '-') + ? '0000-00-00 00:00:00' + : $date->format('Y-m-d H:i:s'); + $args = [ + 'type' => $normalizeType, + 'status' => $normalizeStatus, + 'updated_at' => $date, + 'id' => $this->getId() + ]; + $qb = $event + ->getObjectManager() + ->createQueryBuilder() + ->update($this::class, 'x') + ->set('x.type', ':type') + ->set('x.status', ':status') + ->set('x.updated_at', ':updated_at'); + if ($isCatSiteIdMissMatch) { + $qb->set('x.category_id', ':cat_id'); + $args['cat_id'] = null; + } + // use query builder to make sure updated_at still same + $qb + ->where('x.id = :id') + ->setParameters($args) + ->getQuery() + ->execute(); + } + } +} diff --git a/Entities/PostCategory.php b/Entities/PostCategory.php new file mode 100644 index 0000000..ed522f4 --- /dev/null +++ b/Entities/PostCategory.php @@ -0,0 +1,434 @@ + 'utf8mb4', // remove this or change to utf8 if not use mysql + 'collation' => 'utf8mb4_unicode_ci', // remove this if not use mysql + 'comment' => 'Post category metadata', + 'priority' => 99, + ] +)] +#[UniqueConstraint( + name: 'unique_slug_site_id', + columns: ['slug', 'site_id'] +)] +#[Index( + columns: ['id', 'site_id'], + name: 'index_id_site_id' +)] +#[Index( + columns: ['name', 'site_id'], + name: 'index_name_site_id' +)] +#[Index( + columns: ['site_id'], + name: 'relation_post_categories_site_id_sites_id' +)] +#[Index( + columns: ['parent_id'], + name: 'relation_post_categories_parent_id_post_categories_id' +)] +#[Index( + columns: ['user_id'], + name: 'relation_post_categories_user_id_admins_id' +)] +#[HasLifecycleCallbacks] +class PostCategory extends AbstractEntity +{ + public const TABLE_NAME = 'post_categories'; + + use ParentIdEventStateTrait; + + #[Id] + #[GeneratedValue('AUTO')] + #[Column( + name: 'id', + type: Types::BIGINT, + length: 20, + updatable: false, + options: [ + 'unsigned' => true, + 'comment' => 'Primary key book id' + ] + )] + protected int $id; + + #[Column( + name: 'site_id', + type: Types::BIGINT, + length: 20, + nullable: true, + options: [ + 'default' => null, + 'unsigned' => true, + 'comment' => 'Site id' + ] + )] + protected ?int $site_id = null; + + #[Column( + name: 'parent_id', + type: Types::BIGINT, + length: 20, + nullable: true, + options: [ + 'default' => null, + 'unsigned' => true, + 'comment' => 'Category parent id' + ] + )] + protected ?int $parent_id = null; + + #[Column( + name: 'name', + type: Types::STRING, + length: 255, + nullable: false, + options: [ + 'comment' => 'Category name' + ] + )] + protected string $name; + + #[Column( + name: 'description', + type: Types::TEXT, + length: AbstractMySQLPlatform::LENGTH_LIMIT_TEXT, + nullable: true, + options: [ + 'default' => null, + 'comment' => 'Category description' + ] + )] + protected ?string $description = null; + + #[Column( + name: 'slug', + type: Types::STRING, + length: 255, + nullable: false, + options: [ + 'comment' => 'Unique slug for category' + ] + )] + protected string $slug; + + #[Column( + name: 'user_id', + type: Types::BIGINT, + length: 20, + nullable: true, + options: [ + 'default' => null, + 'unsigned' => true, + 'comment' => 'Admin id' + ] + )] + protected ?int $user_id = null; + + #[Column( + name: 'created_at', + type: Types::DATETIME_MUTABLE, + updatable: false, + options: [ + 'default' => 'CURRENT_TIMESTAMP', + 'comment' => 'Category created time' + ] + )] + protected DateTimeInterface $created_at; + + #[Column( + name: 'updated_at', + type: Types::DATETIME_IMMUTABLE, + unique: false, + updatable: false, + options: [ + 'attribute' => 'ON UPDATE CURRENT_TIMESTAMP', // this column attribute + 'default' => '0000-00-00 00:00:00', + 'comment' => 'Category update time' + ], + // columnDefinition: "DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP" + )] + protected DateTimeInterface $updated_at; + + #[ + JoinColumn( + name: 'parent_id', + referencedColumnName: 'id', + nullable: true, + onDelete: 'SET NULL', + options: [ + 'relation_name' => 'relation_post_categories_parent_id_post_categories_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'SET NULL' + ], + ), + ManyToOne( + targetEntity: self::class, + cascade: [ + 'persist' + ], + fetch: 'LAZY' + ) + ] + protected ?PostCategory $parent = null; + + #[ + JoinColumn( + name: 'site_id', + referencedColumnName: 'id', + nullable: true, + onDelete: 'RESTRICT', + options: [ + 'relation_name' => 'relation_post_categories_site_id_sites_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'RESTRICT' + ] + ), + ManyToOne( + targetEntity: Site::class, + cascade: [ + "persist" + ], + fetch: 'EAGER' + ) + ] + protected ?Site $site; + + #[ + JoinColumn( + name: 'user_id', + referencedColumnName: 'id', + nullable: true, + onDelete: 'SET NULL', + options: [ + 'relation_name' => 'relation_post_categories_user_id_admins_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'SET NULL' + ], + ), + ManyToOne( + targetEntity: Admin::class, + cascade: [ + 'persist' + ], + fetch: 'LAZY' + ) + ] + protected ?Admin $user = null; + + public function __construct() + { + $this->created_at = new DateTimeImmutable(); + $this->updated_at = new DateTimeImmutable('0000-00-00 00:00:00'); + } + + #[ + PrePersist, + PreUpdate + ] + public function preCheckSlug(PrePersistEventArgs|PreUpdateEventArgs $event): void + { + /** @noinspection DuplicatedCode */ + $oldSlug = null; + $slug = $this->getSlug(); + $isUpdate = $event instanceof PreUpdateEventArgs; + if ($isUpdate) { + if (!$event->hasChangedField('slug')) { + return; + } + $oldSlug = $event->getOldValue('slug'); + $slug = $event->getNewValue('slug')?:$slug; + } + + if ($oldSlug === $slug) { + return; + } + + if (trim($slug) === '') { + $slug = UUID::v4(); + } + do { + $this->slug = $slug; + $query = $event + ->getObjectManager() + ->getRepository($this::class) + ->matching( + Expression::criteria()->where( + Expression::andX( + Expression::eq('slug', $slug), + Expression::eq('site_id', $this->getSite()), + ) + )->setMaxResults(1) + ) + ->count(); + } while ($query > 0 && ($slug = UUID::v4())); + if ($isUpdate) { + $event->setNewValue('slug', $slug); + } + } + + public function getId() : int + { + return $this->id; + } + + public function getCreatedAt(): DateTimeInterface + { + return $this->created_at; + } + + public function getUpdatedAt(): DateTimeInterface + { + return $this->updated_at; + } + + public function getParentId(): ?int + { + return $this->parent_id; + } + + public function setParentId(?int $parent_id): void + { + $this->parent_id = $parent_id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } + + public function getSlug(): string + { + return $this->slug; + } + + public function setSlug(string $slug): void + { + $this->slug = $slug; + } + + public function getUserId(): ?int + { + return $this->user_id; + } + + public function setUserId(?int $user_id): void + { + $this->user_id = $user_id; + } + + public function getParent(): ?PostCategory + { + return $this->parent; + } + + public function setParent(?PostCategory $parent): void + { + $this->parent = $parent; + $this->setParentId($parent?->getId()); + } + + public function getUser(): ?Admin + { + return $this->user; + } + + public function setUser(?Admin $user): void + { + $this->user = $user; + $this->setUserId($user?->getId()); + } + + public function getSiteId(): ?int + { + return $this->site_id; + } + + public function setSiteId(?int $site_id): void + { + $this->site_id = $site_id; + } + + public function getSite(): ?Site + { + return $this->site; + } + + public function setSite(?Site $site): void + { + $this->site = $site; + $this->setSiteId($site?->getId()); + } + + #[ + PreUpdate, + PostLoad, + PrePersist + ] + public function checkDataEvent( + PrePersistEventArgs|PostLoadEventArgs|PreUpdateEventArgs $event + ) : void { + $this->parentIdCheck($event); + } +} diff --git a/Entities/PostMeta.php b/Entities/PostMeta.php new file mode 100644 index 0000000..6c1a7df --- /dev/null +++ b/Entities/PostMeta.php @@ -0,0 +1,113 @@ + 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => 'Post metadata', + 'priority' => 101, + 'primaryKey' => [ + 'id', + 'name' + ] + ] +)] +#[Index( + columns: ['name'], + name: 'index_name' +)] +#[Index( + columns: ['post_id'], + name: 'relation_post_meta_post_id_posts_id' +)] +#[HasLifecycleCallbacks] +/** + * @property-read int $post_id + * @property-read Post $post + */ +class PostMeta extends AbstractBasedMeta +{ + public const TABLE_NAME = 'post_meta'; + #[Id] + #[Column( + name: 'id', + type: Types::BIGINT, + length: 20, + updatable: false, + options: [ + 'unsigned' => true, + 'comment' => 'Primary key composite identifier' + ] + )] + protected int $id; + + #[ + JoinColumn( + name: 'post_id', + referencedColumnName: 'id', + nullable: false, + onDelete: 'CASCADE', + options: [ + 'relation_name' => 'relation_post_meta_post_id_posts_id', + 'onUpdate' => 'CASCADE', + 'onDelete' => 'CASCADE' + ] + ), + ManyToOne( + targetEntity: Post::class, + cascade: [ + "persist", + "remove", + "merge", + "detach" + ], + fetch: 'EAGER' + ) + ] + protected Post $post; + + /** + * Allow associations mapping + * @see jsonSerialize() + * + * @var bool + */ + protected bool $entityAllowAssociations = true; + + public function getPostId(): int + { + return $this->post_id; + } + + public function setPostId(int $post_id): void + { + $this->post_id = $post_id; + } + + public function setPost(Post $post): void + { + $this->post = $post; + $this->setPostId($post->getId()); + } + + public function getPost(): Post + { + return $this->post; + } +} diff --git a/Finder/CategoryFinder.php b/Finder/CategoryFinder.php new file mode 100644 index 0000000..a247200 --- /dev/null +++ b/Finder/CategoryFinder.php @@ -0,0 +1,53 @@ + + */ + public function getRepository() : ObjectRepository&Selectable + { + return $this->connection->getRepository( + PostCategory::class + ); + } + + public function find($id) : ?PostCategory + { + if (is_int($id)) { + return $this->findById($id); + } + if (is_string($id)) { + return $this->findBySlug($id); + } + return null; + } + + public function findById(int $id) : ?PostCategory + { + return $this->getRepository()->find($id); + } + + public function findBySlug(string $slug, int|Site|null $site = null) : ?PostCategory + { + return $this + ->getRepository() + ->findOneBy([ + 'slug' => $slug + ]); + } +} diff --git a/Finder/PostFinder.php b/Finder/PostFinder.php new file mode 100644 index 0000000..9e867b9 --- /dev/null +++ b/Finder/PostFinder.php @@ -0,0 +1,52 @@ + + */ + public function getRepository() : ObjectRepository&Selectable + { + return $this->connection->getRepository( + Post::class + ); + } + + public function find($id) : ?Post + { + if (is_int($id)) { + return $this->findById($id); + } + if (is_string($id)) { + return $this->findBySlug($id); + } + return null; + } + + public function findById(int $id) : ?Post + { + return $this->getRepository()->find($id); + } + + public function findBySlug(string $slug, int|Site|null $site = null) : ?Post + { + return $this + ->getRepository() + ->findOneBy([ + 'slug' => $slug + ]); + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e1fd273 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PostTypes/Abstracts/AbstractPostType.php b/PostTypes/Abstracts/AbstractPostType.php new file mode 100644 index 0000000..ac6d384 --- /dev/null +++ b/PostTypes/Abstracts/AbstractPostType.php @@ -0,0 +1,304 @@ +posts; + } + + public function canViewProtectedPost( + PermissionInterface $permission, + ?RoleInterface $role = null + ): bool { + $result = $role && $permission->permitted($role, 'can_view_protected_posts'); + $newResult = $this->module->getManager()?->dispatch( + 'postType.canViewProtectedPost', + $result, + $permission, + $role, + $this + ); + return is_bool($newResult) ? $newResult : $result; + } + + public function permitted(): bool + { + $post = $this->getPost(); + if (!$post) { + return false; + } + + /** + * @var Users $users + */ + $users = $this->module->getModules()->get(Users::class); + $isPublished = $post->isPublished(); + $permission = $users->getPermission(); + $admin = $users->getAdminAccount(); + $user = $users->getUserAccount(); + $canEditPost = $admin && ( + $permission->permitted($admin, 'can_edit_posts') + || $permission->permitted($admin, 'can_preview_posts') + ); + + if ($canEditPost) { + return true; + } + + if (!$isPublished || $this->isRevision()) { + return false; + } + + if ($this->publicArea && $this->isPasswordProtected()) { + return $this->canViewProtectedPost( + $permission, + $user + ); + } + + return true; + } + + public function get(string $name): mixed + { + return match ($name) { + 'posts' => $this->getPosts(), + 'post' => $this->getPost(), + 'postCategory', + 'category' => $this->getPostCategory(), + 'permitted' => $this->permitted(), + 'revisions' => $this->getRevisions(), + 'is_archive', + 'isArchive' => $this->isArchive(), + 'is_search', + 'isSearch' => $this->isSearch(), + 'is_category', + 'isCategory' => $this->isCategory(), + 'is_year', + 'isYear' => $this->isYear(), + 'is_month', + 'isMonth' => $this->isMonth(), + 'is_day', + 'isDay' => $this->isDay(), + 'is_singular', + 'isSingular' => $this->isSingular(), + 'is_revision', + 'isRevision' => $this->isRevision(), + 'post_type', + 'postType' => $this->getPostType(), + 'is_password_protected', + 'isPasswordProtected' => $this->isPasswordProtected(), + 'is_found', + 'isFound' => $this->isFound(), + default => null + }; + } + + public function getRevisions( + int $offset = 0, + int $limit = 10, + bool $desc = true + ): ?LazyResultCriteria { + $post = $this->getPost(); + if ($post->isRevision()) { + $post = $post->getParent(); + } + if ($post->isRevision()) { + return null; + } + return $post ? $this->module->getPostFinder() + ->findByCriteria( + Expression::criteria() + ->where( + Expression::eq('parent_id', $post->getId()) + )->andWhere( + Expression::eq('type', Post::TYPE_REVISION) + ) + ->setFirstResult($offset) + ->setMaxResults($limit) + ->orderBy(['id' => $desc ? Criteria::DESC : Criteria::ASC]) + ) : null; + } + + public function isRevision(): bool + { + return $this->getPost()?->isRevision(); + } + + public function isArchive(): bool + { + return $this->typeIs(self::TYPE_ARCHIVE) + && $this->getPosts() !== null + || $this->isSearch() + || $this->isYear() + || $this->isMonth() + || $this->isDay(); + } + + public function getPosts(): ?LazyResultCriteria + { + return $this->posts; + } + + public function isSearch(): bool + { + return $this->typeIs(self::TYPE_SEARCH) + && $this->getPosts() !== null; + } + + public function isYear(): bool + { + return $this->typeIs(self::TYPE_YEAR) + && $this->getPosts() !== null; + } + + public function isMonth(): bool + { + return $this->typeIs(self::TYPE_MONTH) + && $this->getPosts() !== null; + } + + public function isDay(): bool + { + return $this->typeIs(self::TYPE_DAY) + && $this->getPosts() !== null; + } + + public function isCategory(): bool + { + return $this->typeIs(self::TYPE_CATEGORY) + && $this->getPostCategory() !== null; + } + + public function getPostCategory(): ?PostCategory + { + return $this->postCategory; + } + + public function isSingular(): bool + { + return $this->typeIs(self::TYPE_SINGULAR) + && $this->getPost() !== null; + } + + public function typeIs(string $type): bool + { + return strtolower(trim($this->getType())) === strtolower(trim($type)); + } + + public function getPostType(): ?string + { + return $this->getPost()?->getType(); + } + + abstract public function getType(): string; + + public function getPost(): ?Post + { + return $this->post; + } + + public function isPasswordProtected(): bool + { + return $this->getPost()?->isPasswordProtected() + && $this->getPost()->getPassword(); + } + + abstract public function isFound(): bool; + + public function getParentPost(): ?Post + { + return $this->getPost()?->getParent(); + } + + public function __get(string $name) + { + return $this->get($name); + } + + public function __isset(string $name): bool + { + return match ($name) { + 'posts', + 'post', + 'postCategory', + 'category', + 'permitted', + 'revisions', + 'is_archive', + 'isArchive', + 'is_search', + 'isSearch', + 'is_category', + 'isCategory', + 'is_year', + 'isYear', + 'is_month', + 'isMonth', + 'is_day', + 'isDay', + 'is_singular', + 'isSingular', + 'is_revision', + 'isRevision', + 'post_type', + 'postType', + 'is_password_protected', + 'isPasswordProtected', + 'is_found', + 'isFound' => true, + default => false + }; + } + + public function __debugInfo(): ?array + { + return Consolidation::debugInfo( + $this, + excludeKeys: ['post', 'posts', 'postCategory'] + ); + } +} diff --git a/PostTypes/Abstracts/ArchiveBasedPostAbstract.php b/PostTypes/Abstracts/ArchiveBasedPostAbstract.php new file mode 100644 index 0000000..e0a782b --- /dev/null +++ b/PostTypes/Abstracts/ArchiveBasedPostAbstract.php @@ -0,0 +1,184 @@ +posts !== null; + } + + public function getTotalPosts( + string $postType = Post::TYPE_POST, + ?string $status = null + ): int { + $postType = Post::normalizeType($postType); + $key = $postType; + if ($status) { + $key .= "|$status"; + } + if (isset($this->cachedCount[$key])) { + return $this->cachedCount[$key]; + } + $criteria = ['a.type = :type']; + $params = ['type' => $postType]; + if ($status) { + $params['status'] = $status; + $criteria[] = 'a.status = :status'; + } + + $res = $this + ->module + ->getPostFinder() + ->getConnection() + ->getEntityManager() + ->createQueryBuilder() + ->select('count(a.id) as count') + ->from( + $this + ->module + ->getPostFinder() + ->getRepository() + ->getClassName(), + 'a' + ) + ->where(...$criteria) + ->setMaxResults(1) + ->getQuery() + ->execute($params)[0] ?? []; + return $this->cachedCount[$key] = (int)($res['count'] ?? 0); + } + + /** + * Get posts by criteria + * + * @param int $offset + * @param int $limit + * @param string $postType + * @param array $orderBy + * @param string|null $status + * @return ?LazyResultCriteria + */ + public function getPosts( + int $offset = 0, + int $limit = 10, + string $postType = Post::TYPE_POST, + array $orderBy = [ + 'id' => Criteria::DESC + ], + ?string $status = null + ): ?LazyResultCriteria { + $criteria = $this->filterCriteria( + $offset, + $limit, + $postType, + $orderBy, + $status + ); + if ($this->cachedCriteria === $criteria) { + return $this->posts; + } + + $this->cachedCriteria = $criteria; + $criteria = $this->createCriteria( + $criteria['offset'], + $criteria['limit'], + $criteria['postType'], + $criteria['orderBy'], + $criteria['status'] + ); + $this->posts = $criteria ? $this + ->module + ->getPostFinder() + ->findByCriteria( + $criteria + ) : null; + return $this->posts; + } + + /** + * @param int $offset + * @param int $limit + * @param string $postType + * @param array $orderBy + * @param string|null $status + * @return array{offset: int, limit:int, postType: string, orderBy:array, status: ?string} + */ + protected function filterCriteria( + int $offset = 0, + int $limit = 10, + string $postType = Post::TYPE_POST, + array $orderBy = [ + 'id' => Criteria::DESC + ], + ?string $status = null + ): array { + $allowedOrder = [ + 'created_at', + 'deleted_at', + 'published_at', + 'id', + 'title', + ]; + $orderings = []; + foreach ($orderBy as $key => $order) { + if (is_string($key) && is_string($order)) { + $key = $order; + $order = 'ASC'; + } + if (!is_string($key) || !is_string($order)) { + continue; + } + $key = strtolower($key); + if (!in_array($key, $allowedOrder)) { + continue; + } + $order = strtoupper(trim($order)); + $orderings[$key] = $order === 'ASC' ? $order : 'DESC'; + } + $orderings = empty($orderings) ? ['id' => Criteria::DESC] : $orderings; + $offset = max($offset, 0); + $limit = max($limit, 1); + return [ + 'offset' => $offset, + 'limit' => $limit, + 'postType' => Post::normalizeType($postType), + 'orderBy' => $orderings, + 'status' => $status + ]; + } + + /** + * @param int $offset + * @param int $limit + * @param string $postType + * @param array $orderings + * @param string|null $status + * @return ?Criteria + */ + abstract public function createCriteria( + int $offset, + int $limit, + string $postType, + array $orderings, + ?string $status + ): ?Criteria; +} diff --git a/PostTypes/Abstracts/CategoryPostAbstract.php b/PostTypes/Abstracts/CategoryPostAbstract.php new file mode 100644 index 0000000..2bedabb --- /dev/null +++ b/PostTypes/Abstracts/CategoryPostAbstract.php @@ -0,0 +1,116 @@ + $posts + */ +abstract class CategoryPostAbstract extends ArchiveBasedPostAbstract +{ + private bool $categoryInit = false; + + public function __construct( + Posts $module, + bool $publicArea, + public readonly int|string $identity + ) { + parent::__construct($module, $publicArea); + } + + public function getType(): string + { + return self::TYPE_CATEGORY; + } + + /** + * @param int $offset + * @param int $limit + * @param string $postType + * @param array $orderings + * @param string|null $status + * @return ?Criteria + */ + public function createCriteria( + int $offset, + int $limit, + string $postType, + array $orderings, + ?string $status + ): ?Criteria { + $category = $this->getPostCategory(); + if (!$category) { + return null; + } + $result = Expression::criteria() + ->where( + Expression::eq('category_id', $category->getId()) + )->andWhere( + Expression::eq('type', $postType) + ) + ->setFirstResult($offset) + ->setMaxResults($limit) + ->orderBy( + $orderings + ); + if ($status) { + $result->andWhere(Expression::eq('status', $status)); + } + return $result; + } + + public function getPostCategory(): ?PostCategory + { + if ($this->categoryInit) { + return $this->postCategory; + } + $this->categoryInit = true; + return $this->postCategory = is_int($this->identity) + ? $this->module->findCategoryById($this->identity) + : $this->module->findCategoryBySlug($this->identity); + } + + /** + * {@inheritDoc} + */ + public function getPosts( + int $offset = 0, + int $limit = 10, + string $postType = Post::TYPE_POST, + array $orderBy = [ + 'id' => Criteria::DESC + ], + ?string $status = null + ): ?LazyResultCriteria { + if (!$this->getPostCategory()) { + return null; + } + return parent::getPosts($offset, $limit, $postType, $orderBy, $status); + } + + public function __get(string $name) + { + if (parent::__isset($name)) { + return $this->get($name); + } + return $this->getPostCategory()?->$name; + } + + public function __isset(string $name): bool + { + return parent::__isset($name) || isset($this->getPostCategory()?->$name); + } + + public function __call(string $name, array $arguments) + { + return $this->getPostCategory()?->$name(...$arguments); + } +} diff --git a/PostTypes/Abstracts/SinglePostAbstract.php b/PostTypes/Abstracts/SinglePostAbstract.php new file mode 100644 index 0000000..7965b57 --- /dev/null +++ b/PostTypes/Abstracts/SinglePostAbstract.php @@ -0,0 +1,67 @@ +assertPost($identity); + $this->post = $identity; + $identity = $this->post->getId(); + } + $this->identity = $identity; + parent::__construct($module, $publicArea); + } + + protected function assertPost(Post $post): void + { + } + + public function isFound(): bool + { + return $this->getPost() !== null; + } + + public function getType(): string + { + return self::TYPE_SINGULAR; + } + + public function getPostCategory(): ?PostCategory + { + return $this->getPost()?->getCategory(); + } + + public function __get(string $name) + { + if (parent::__isset($name)) { + return $this->get($name); + } + return $this->getPost()?->$name; + } + + public function __isset(string $name): bool + { + return parent::__isset($name) || isset($this->getPost()?->$name); + } + + public function __call(string $name, array $arguments) + { + return $this->getPost()?->$name(...$arguments); + } +} diff --git a/PostTypes/Archive/TypeArchive.php b/PostTypes/Archive/TypeArchive.php new file mode 100644 index 0000000..9d0c2e0 --- /dev/null +++ b/PostTypes/Archive/TypeArchive.php @@ -0,0 +1,71 @@ +postCategory = $postCategory; + $this->date = $dateTime ?? new DateTime(); + } + + public function getType(): string + { + return self::TYPE_ARCHIVE; + } + + public function isCategory(): bool + { + return $this->postCategory !== null; + } + + public function isFound(): bool + { + return $this->posts !== null; + } + + public function createCriteria( + int $offset, + int $limit, + string $postType, + array $orderings, + ?string $status + ): ?Criteria { + $result = Expression::criteria() + ->where(Expression::eq('type', $postType)) + ->setFirstResult($offset) + ->setMaxResults($limit) + ->orderBy($orderings); + if ($status) { + $result->andWhere( + Expression::eq('status', $status) + ); + } + if ($this->postCategory) { + $result->andWhere( + Expression::eq( + 'category_id', + $this->postCategory->getId() + ) + ); + } + return $result; + } +} diff --git a/PostTypes/Archive/TypeCategory.php b/PostTypes/Archive/TypeCategory.php new file mode 100644 index 0000000..6a86ec2 --- /dev/null +++ b/PostTypes/Archive/TypeCategory.php @@ -0,0 +1,10 @@ +andWhere( + Expression::eq( + 'DATE(published_at)', + $this->date->format('Y-m-d') + ) + ); + } +} diff --git a/PostTypes/Archive/TypeMonthlyArchive.php b/PostTypes/Archive/TypeMonthlyArchive.php new file mode 100644 index 0000000..133faed --- /dev/null +++ b/PostTypes/Archive/TypeMonthlyArchive.php @@ -0,0 +1,41 @@ +andWhere( + Expression::eq( + 'YEAR(published_at)', + $this->date->format('Y') + ) + )->where( + Expression::eq( + 'MONTH(published_at)', + $this->date->format('m') + ) + ); + } +} diff --git a/PostTypes/Archive/TypeSearch.php b/PostTypes/Archive/TypeSearch.php new file mode 100644 index 0000000..791cfe1 --- /dev/null +++ b/PostTypes/Archive/TypeSearch.php @@ -0,0 +1,119 @@ +postCategory !== null; + } + + public function createCriteria( + int $offset, + int $limit, + string $postType, + array $orderings, + ?string $status + ): ?Criteria { + $result = parent::createCriteria( + $offset, + $limit, + $postType, + $orderings, + $status + )->andWhere( + Expression::orX( + Expression::eq('title', $this->searchQuery), + Expression::startsWith('title', $this->searchQuery), + Expression::endsWith('title', $this->searchQuery) + ) + ); + $year = $this->getYear(); + $month = $this->getMonth(); + $day = $this->getDay(); + if ($year !== null) { + $result->andWhere( + Expression::eq('YEAR(published_at)', $year) + ); + } + if ($month !== null) { + $result->andWhere( + Expression::eq('MONTH(published_at)', $month) + ); + } + if ($day !== null) { + $result->andWhere( + Expression::eq('DAY(published_at)', $day) + ); + } + + return $result; + } + + public function getYear(): ?string + { + if (!$this->year || $this->year < 0) { + return null; + } + + $year = (string)$this->year; + while (strlen($year) < 4) { + $year = "0$year"; + } + return substr($year, 0, 4); + } + + public function getMonth(): ?string + { + if (!$this->month || $this->month < 1) { + return null; + } + $month = $this->month % 12; + $month = $month === 0 ? 12 : $month; + $month = (string)$month; + while (strlen($month) < 2) { + $month = "0$month"; + } + return $month; + } + + public function getDay(): ?string + { + if (!$this->day || $this->day < 1) { + return null; + } + $day = $this->day % 31; + $day = $day === 0 ? 31 : $day; + $day = (string)$day; + while (strlen($day) < 2) { + $day = "0$day"; + } + return $day; + } +} diff --git a/PostTypes/Archive/TypeYearlyArchive.php b/PostTypes/Archive/TypeYearlyArchive.php new file mode 100644 index 0000000..8652e1c --- /dev/null +++ b/PostTypes/Archive/TypeYearlyArchive.php @@ -0,0 +1,36 @@ +andWhere( + Expression::eq( + 'YEAR(published_at)', + $this->date->format('Y') + ) + ); + } +} diff --git a/PostTypes/Singular/SingularFinder.php b/PostTypes/Singular/SingularFinder.php new file mode 100644 index 0000000..bcc3274 --- /dev/null +++ b/PostTypes/Singular/SingularFinder.php @@ -0,0 +1,31 @@ +findPostById($identity) + : $posts->findPostBySlug($identity); + if (!$post) { + return null; + } + return match ($post->getNormalizeType()) { + Post::TYPE_POST => new TypePost($posts, $publicArea, $post), + Post::TYPE_PAGE => new TypePage($posts, $publicArea, $post), + Post::TYPE_REVISION => new TypeRevision($posts, $publicArea, $post), + default => new TypeCustom($posts, $publicArea, $post) + }; + } +} diff --git a/PostTypes/Singular/TypeCustom.php b/PostTypes/Singular/TypeCustom.php new file mode 100644 index 0000000..e8beabc --- /dev/null +++ b/PostTypes/Singular/TypeCustom.php @@ -0,0 +1,68 @@ +postInit) { + return $this->post; + } + $this->postInit = true; + if ($this->post) { + return $this->post; + } + $this->postInit = true; + $this->post = null; + $post = is_int($this->identity) + ? $this->module->findPostById($this->identity) + : $this->module->findPostBySlug($this->identity); + if (!$post) { + return $this->post; + } + if ($post->isRevision()) { + if ($post->getParent()->getNormalizeType() === $this->postType) { + $this->postType = Post::TYPE_REVISION; + $this->post = $post; + } + } else { + $this->postType = $post->getNormalizeType(); + $this->post = $post; + } + + return $this->post; + } + + protected function assertPost(Post $post): void + { + $type = $post->getNormalizeType(); + $className = match ($type) { + Post::TYPE_REVISION => TypeRevision::class, + Post::TYPE_PAGE => TypePage::class, + Post::TYPE_POST => TypePost::class, + default => TypeCustom::class, + }; + if ($className === TypeCustom::class) { + return; + } + throw new InvalidArgumentException( + sprintf( + 'Invalid post type using %s. The object should used %s', + $this::class, + $className + ) + ); + } +} diff --git a/PostTypes/Singular/TypePage.php b/PostTypes/Singular/TypePage.php new file mode 100644 index 0000000..72b6fc6 --- /dev/null +++ b/PostTypes/Singular/TypePage.php @@ -0,0 +1,11 @@ +postInit) { + return $this->post; + } + /** @noinspection DuplicatedCode */ + if ($this->post) { + return $this->post; + } + $this->postInit = true; + $this->post = null; + $post = is_int($this->identity) + ? $this->module->findPostById($this->identity) + : $this->module->findPostBySlug($this->identity); + if (!$post) { + return $this->post; + } + if ($post->getNormalizeType() === $this->postType) { + $this->post = $post; + } elseif ($post->isRevision() + && $post->getParent()->getNormalizeType() === $this->postType + ) { + $this->post = $post; + } + + return $this->post; + } + + protected function assertPost(Post $post): void + { + $type = $post->getNormalizeType(); + if ($type === $this->postType) { + return; + } + $className = match ($type) { + Post::TYPE_REVISION => TypeRevision::class, + Post::TYPE_PAGE => TypePage::class, + Post::TYPE_POST => TypePost::class, + default => TypeCustom::class, + }; + throw new InvalidArgumentException( + sprintf( + 'Invalid post type using %s. The object should used %s', + $this::class, + $className + ) + ); + } +} diff --git a/PostTypes/Singular/TypeRevision.php b/PostTypes/Singular/TypeRevision.php new file mode 100644 index 0000000..5ae07dc --- /dev/null +++ b/PostTypes/Singular/TypeRevision.php @@ -0,0 +1,34 @@ +postInit) { + return $this->post; + } + $this->postInit = true; + if ($this->post) { + return $this->post; + } + $this->postInit = true; + $this->post = null; + $post = is_int($this->identity) + ? $this->module->findPostById($this->identity) + : $this->module->findPostBySlug($this->identity); + if ($post?->isRevision()) { + $this->post = $post; + } + return $this->post; + } +} diff --git a/Posts.php b/Posts.php new file mode 100644 index 0000000..f46656c --- /dev/null +++ b/Posts.php @@ -0,0 +1,146 @@ +translateContext( + 'Post & Articles', + 'post-module', + 'module' + ); + } + + public function getDescription(): string + { + return $this->translateContext( + 'Module to make application support posts publishing', + 'post-module', + 'module' + ); + } + + protected function doInit(): void + { + /** @noinspection DuplicatedCode */ + if ($this->didInit) { + return; + } + + Consolidation::registerAutoloader(__NAMESPACE__, __DIR__); + $this->didInit = true; + $kernel = $this->getKernel(); + $kernel->registerControllerDirectory(__DIR__ .'/Controllers'); + $this->getTranslator()?->registerDirectory('module', __DIR__ . '/Languages'); + $this->getConnection()->registerEntityDirectory(__DIR__.'/Entities'); + } + + public function getPostFinder(): ?PostFinder + { + return $this->postFinder ??= new PostFinder( + $this->getConnection() + ); + } + + public function getCategoryFinder(): ?CategoryFinder + { + return $this->categoryFinder ??= new CategoryFinder( + $this->getConnection() + ); + } + + public function findPostById(int $id): ?Post + { + return $this->getPostFinder()->find($id); + } + + public function findCategoryById(int $id): ?PostCategory + { + return $this->getCategoryFinder()->find($id); + } + + public function findPostBySlug(string $slug, int|Site|null $site = null): ?Post + { + return $this->getPostFinder()->findBySlug($slug, $site); + } + + public function findCategoryBySlug(string $slug, int|Site|null $site = null): ?PostCategory + { + return $this->getCategoryFinder()->findBySlug($slug, $site); + } + + /** + * @throws SchemaException + * @throws Exception + * @noinspection PhpUnused + */ + public function searchPost( + string $searchQuery, + int|Site|null $site = null, + int $limit = 10, + int $offset = 0, + array $orderBy = [], + CompositeExpression|Comparison ...$expressions + ): LazyResultCriteria { + return $this->getPostFinder()->search( + $searchQuery, + $site, + $limit, + $offset, + $orderBy, + ...$expressions + ); + } + + /** + * @throws SchemaException + * @throws Exception + * @noinspection PhpUnused + */ + public function searchCategory( + string $searchQuery, + int|Site|null $site = null, + int $limit = 10, + int $offset = 0, + array $orderBy = [], + CompositeExpression|Comparison ...$expressions + ): LazyResultCriteria { + return $this->getCategoryFinder()->search( + $searchQuery, + $site, + $limit, + $offset, + $orderBy, + ...$expressions + ); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6d21a7c --- /dev/null +++ b/composer.json @@ -0,0 +1,32 @@ +{ + "name": "arrayaccess/traydigita-posts-module", + "description": "Posts module for tray digita", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "ArrayIterator", + "email": "arrayiterator@gmail.com", + "role": "developer" + } + ], + "require": { + "php": "^8.2|^8.3", + "ext-curl": "*", + "ext-json": "*", + "ext-pdo": "*", + "ext-openssl": "*", + "ext-pdo_mysql": "*", + "ext-fileinfo": "*" + }, + "require-dev": { + "squizlabs/php_codesniffer": "3.7.2", + "slevomat/coding-standard": "^8.13" + }, + "config": { + "optimize-autoloader": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + } +} diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..6adaeae --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,66 @@ + + + + TrayDigita Educational Module Coding Standard + + + + + + + + + + + + + + + + + + + + + + + + . + vendor/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file