Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

View File

@@ -0,0 +1,986 @@
<?php
namespace App\Traits;
use App\Helpers\StringHelper;
use App\Models\Category;
use App\Models\CategoryTranslation;
use App\Models\TaggableLocale;
use Cviebrock\EloquentTaggable\Events\ModelTagged;
use Cviebrock\EloquentTaggable\Events\ModelUntagged;
use Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException;
use Cviebrock\EloquentTaggable\Models\Tag;
use Cviebrock\EloquentTaggable\Services\TagService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
/**
* Class TaggableWithLocale
*
* Is a customized copy of:
* @package Cviebrock\EloquentTaggable 9.0
*
* This trait includes modified Taggable methods to use also the extra table tag_contexts,
* which is not included in the original Taggable package.
*/
trait TaggableWithLocale
{
public function translateTagName($tagName, $fromLocale, $toLocale)
{
$tagName = mb_strtolower($tagName);
$result = Tag::where('name', $tagName)
->whereHas('locale', function ($query) use ($fromLocale) {
$query->where('locale', $fromLocale);
})
->with(
'contexts.tags',
function ($q) use ($toLocale) {
$q->whereHas('locale', function ($q) use ($toLocale) {
$q->where('locale', $toLocale);
})->select('normalized');
}
)
->get();
if ($result->count() != 0) {
$result = $result->first()
->contexts;
if ($result->first()) {
$result = $result
->first()
->tags
->pluck('normalized')
->unique()
->values()
->reject($tagName)
->flatten()
;
} else {
$result = [];
}
} else {
$result = [];
}
return $result;
}
//! TODO conditionally select fallback or source locale, similar method as in translateTagIdsWithContexts()
public function translateTagNameWithContext($name = null, $toLocale = null)
{
if (!$toLocale) {
$toFallbackLocale = app()->getFallBackLocale();
}
if (!$toFallbackLocale) {
$toFallbackLocale = app()->getFallBackLocale();
}
$result = Tag::where('name', $name)
->with([
'contexts.tags' => function ($query) use ($toLocale) {
$query->with(['locale' => function ($query) use ($toLocale) {
$query->where('locale', $toLocale)->select('taggable_tag_id', 'locale');
}])
->whereHas('locale', function ($query) use ($toLocale) {
$query->where('locale', $toLocale);
})
->select('taggable_tags.tag_id', 'normalized');
},
'contexts.category' => function ($query) use ($toLocale) {
$query->with(['translations' => function ($query) use ($toLocale) {
$query->where('locale', $toLocale)->select('category_id', 'name');
}])
->select('id');
},
])
->get();
if ($result->count() != 0) {
$result = $result->first()
->contexts;
if ($result->first()) {
$tag = $result
->first()
->tags
->unique()
->values()
->flatten();
$category = $result
->first()
->category
->translations
->first();
$categoryPath = $result
->first()
->category
->ancestorsAndSelf
->sortBy('id')
->pluck('id');
$result = $tag->map(function ($item) use ($category, $categoryPath, $toLocale) {
$localeItem = $item->locale ? $item->locale : null;
$mapped = [
'tag_id' => $item->tag_id,
'tag' => StringHelper::dutchTitleCase($item->normalized),
'category_id' => $category->category_id,
'category' => StringHelper::dutchTitleCase($item->normalized),
'category_path' => implode(
' > ',
CategoryTranslation::whereIn('category_id', $categoryPath)->where('locale', $toLocale)->pluck('name')->toArray()
) . ' > ' . StringHelper::dutchTitleCase($item->normalized),
'locale' => $localeItem->find($item->tag_id)
];
return $mapped;
});
} else {
$result = [];
}
} else {
$result = [];
}
return $result;
}
//! TODO conditionally select fallback or source locale, similar method as in translateTagIdsWithContexts()
public function translateTagNamesWithContexts($array, $toLocale = null, $toFallbackLocale = null)
{
if (!$toLocale) {
$toFallbackLocale = app()->getFallBackLocale();
}
if (!$toFallbackLocale) {
$toFallbackLocale = app()->getFallBackLocale();
}
$collection = collect($array);
$translated = $collection->map(function ($item, $key) use ($toLocale, $toFallbackLocale) {
$source = $item;
$transLocale = $this->translateTagNameWithContext($source, $toLocale);
$transFallbackLocale = $this->translateTagNameWithContext($source, $toFallbackLocale);
if ($transLocale === $source) {
return $source;
} elseif (count($transLocale) > 0) {
return $transLocale;
} elseif (count($transFallbackLocale) > 0) {
return $transFallbackLocale;
} else {
return $source;
}
})
->flatMap(function ($innerCollection) {
return $innerCollection;
});
return $translated;
}
public function translateTagId($tagId, $toLocale)
{
$result = Tag::where('tag_id', $tagId)
->with(
'contexts.tags',
function ($q) use ($toLocale) {
$q->whereHas('locale', function ($q) use ($toLocale) {
$q->where('locale', $toLocale);
})->select('normalized');
}
)
->get();
if ($result->count() != 0) {
$result = $result->first()
->contexts;
if ($result->first()) {
$result = $result
->first()
->tags
->pluck('pivot')
->pluck('tag_id')
->unique()
->values()
->flatten()
;
} else {
$result = [];
}
} else {
$result = [];
}
return $result;
}
public function translateTagIdWithContext($tagId)
{
if ($tagId) {
$sourceLocale = TaggableLocale::where('taggable_tag_id', $tagId)->value('locale');
$tag = Tag::find($tagId);
$translatedTag = $tag->translation;
$category = Category::find($tag->contexts->pluck('category_id')->first());
$translatedCategory = $category->translation ?? '';
if ($translatedCategory) {
$categoryPathIds = $category->ancestorsAndSelf->sortBy('id')->pluck('id');
$categoryPath = implode(
' > ',
CategoryTranslation::whereIn('category_id', $categoryPathIds)
->where('locale', App::getLocale())
->pluck('name')
->toArray()
);
$categoryColor = $category->rootAncestor ? $category->rootAncestor->color : $category->color;
}
// Map and return the finalized result
return [
'original_tag_id' => $tagId,
'tag_id' => $translatedTag->tag_id,
'tag' => $translatedTag->name,
'comment' => $translatedTag->comment,
'locale' => $translatedTag->locale,
'category_id' => $category->id ?? null,
'category' => $translatedCategory->name ?? '',
'category_path' => $categoryPath ?? '',
'category_color' => $categoryColor ?? 'gray',
];
} else {
return false;
}
}
public function translateTagIds($array, $toLocale = null, $toFallbackLocale = null)
{
if (!$toLocale) {
$toFallbackLocale = app()->getFallBackLocale();
}
if (!$toFallbackLocale) {
$toFallbackLocale = app()->getFallBackLocale();
}
$collection = collect($array);
$translated = $collection->map(function ($item, $key) use ($toLocale, $toFallbackLocale) {
$source = $item;
$transLocale = $this->translateTagId($source, $toLocale);
$transFallbackLocale = $this->translateTagId($source, $toFallbackLocale);
if ($transLocale === $source) {
return $source;
} elseif (count($transLocale) > 0) {
return $transLocale;
} elseif (count($transFallbackLocale) > 0) {
return $transFallbackLocale;
} else {
return $source;
}
})->flatten()->toArray();
return $translated;
}
public function translateTagIdsWithContexts($tagIds)
{
$collectionIds = collect($tagIds);
$translated = $collectionIds->map(function ($item) {
$item = $this->translateTagIdWithContext($item);
return $item;
});
return $translated;
}
/**
* Get an array of normalized tags for a given locale.
*
* @param string $locale The locale to get tags for.
*
* @return array An array of normalized tags for the given locale.
*/
public function localTagArray($locale)
{
$array = Tag::whereHas('locale', function ($query) use ($locale) {
$query->where('locale', $locale);
})->pluck('name')->toArray();
return $array;
}
/**
* Clean up duplicate taggables with different locales
* and clean up any orphaned taggables
* Example Usage:
* $user = User::find(161)->cleantaggables();
*
* @return mixed The result of the untagging operation.
*/
public function cleanTaggables()
{
$this->cleanForeignTaggables();
$this->cleanOrhphanedTaggables();
return true;
}
public function cleanForeignTaggables()
{
// Get the tags with their contexts
$tagsWithContexts = $this->tags()->with('localeContext')->get();
$tagIds = $tagsWithContexts->pluck('tag_id');
$contextIds = $tagsWithContexts->pluck('localeContext.context_id')->flatten();
// Find the context IDs that appear more than once
$duplicateContextIds = $contextIds->duplicates()->flatten();
$duplicateTagIds = DB::table('taggable_locale_context')
->whereIn('context_id', $duplicateContextIds)
->whereIn('tag_id', $tagIds)
->pluck('tag_id')
->flatten();
$duplicateTagsIdsForeign = DB::table('taggable_locales')
->where('locale', '!=', App::getLocale())
->whereIn('taggable_tag_id', $duplicateTagIds)
->pluck('taggable_tag_id');
$orhpanedTagIds = DB::table('taggable_taggables')
->leftJoin('taggable_tags', 'taggable_taggables.tag_id', '=', 'taggable_tags.tag_id')
->whereNull('taggable_tags.tag_id')
->select('taggable_taggables.tag_id')
->distinct()
->pluck('tag_id');
return $this->untagById($duplicateTagsIdsForeign);
}
Public function cleanOrhphanedTaggables()
{
$orhpanedTagIds = DB::table('taggable_taggables')
->leftJoin('taggable_tags', 'taggable_taggables.tag_id', '=', 'taggable_tags.tag_id')
->whereNull('taggable_tags.tag_id')
->select('taggable_taggables.tag_id')
->distinct()
->pluck('tag_id');
return $this->tags()->detach($orhpanedTagIds);
}
/**
* Returns a string of normalized tags for the given locale.
*
* @param string $locale The locale to filter tags by.
* @return string The string of normalized tags.
*/
public function localTagList($locale)
{
$array = Tag::whereHas('locale', function ($query) use ($locale) {
$query->where('locale', $locale);
})->pluck('normalized')->toArray();
return implode(config('taggable.glue'), $array);
}
/**
* Find the tag with the given name.
*
* @param string $value
*
* @return static|null
*/
public static function findByName(string $value)
{
return app(TagService::class)->find($value);
}
/**
* Property to control sequence on alias
*
* @var int
*/
private $taggableAliasSequence = 0;
/**
* Boot the trait.
*
* Listen for the deleting event of a model, then remove the relation between it and tags
*/
protected static function bootTaggable(): void
{
static::deleting(function ($model) {
if (!method_exists($model, 'runSoftDelete') || $model->isForceDeleting()) {
$model->detag();
}
});
}
/**
* Get a collection of all tags the model has.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphToMany
*/
public function tags(): MorphToMany
{
$model = config('taggable.model');
$table = config('taggable.tables.taggable_taggables', 'taggable_taggables');
return $this->morphToMany($model, 'taggable', $table, 'taggable_id', 'tag_id')
->withTimestamps();
}
/**
* Attach one or multiple tags to the model.
*
* @param string|array $tags
*
* @return self
*/
public function tag($tags): self
{
$tags = app(TagService::class)->buildTagArray($tags);
foreach ($tags as $tagName) {
$this->addOneTag($tagName);
$this->load('tags');
}
event(new ModelTagged($this, $tags));
return $this;
}
/**
* Attach one or more existing tags to a model,
* identified by the tag's IDs.
*
* @param int|int[] $ids
*
* @return $this
*/
public function tagById($ids): self
{
$tags = app(TagService::class)->findByIds($ids);
$names = $tags->pluck('name')->all();
return $this->tag($names);
}
/**
* Detach one or multiple tags from the model.
*
* @param string|array $tags
*
* @return self
*/
public function untag($tags): self
{
$tags = app(TagService::class)->buildTagArray($tags);
foreach ($tags as $tagName) {
$this->removeOneTag($tagName);
}
event(new ModelUntagged($this, $tags));
return $this->load('tags');
}
/**
* Detach one or more existing tags to a model,
* identified by the tag's IDs.
*
* @param int|int[] $ids
*
* @return $this
*/
public function untagById($ids): self
{
$tags = app(TagService::class)->findByIds($ids);
$names = $tags->pluck('name')->all();
return $this->untag($names);
}
/**
* Remove all tags from the model and assign the given ones.
*
* @param string|array $tags
*
* @return self
*/
public function retag($tags): self
{
return $this->detag()->tag($tags);
}
/**
* Remove all tags from the model and assign the given ones by ID.
*
* @param int|int[] $ids
*
* @return self
*/
public function retagById($ids): self
{
return $this->detag()->tagById($ids);
}
/**
* Remove all tags from the model.
*
* @return self
*/
public function detag(): self
{
$this->tags()->sync([]);
return $this->load('tags');
}
/**
* Add one tag to the model.
*
* @param string $tagName
*/
protected function addOneTag(string $tagName): void
{
/** @var Tag $tag */
$tag = app(TagService::class)->findOrCreate($tagName);
$tagKey = $tag->getKey();
if (!$this->getAttribute('tags')->contains($tagKey)) {
$this->tags()->attach($tagKey);
}
$locale = ['locale' => App::getLocale()]; // Customization: include App Locale when adding a tag
TaggableLocale::updateOrCreate(['taggable_tag_id' => $tag->getKey()], $locale); // Customization: include App Locale when adding a tag
}
/**
* Remove one tag from the model
*
* @param string $tagName
*/
protected function removeOneTag(string $tagName): void
{
$tag = app(TagService::class)->find($tagName);
if ($tag) {
$this->tags()->detach($tag);
}
}
/**
* Get all the tags of the model as a delimited string.
*
* @return string
*/
public function getTagListAttribute(): string
{
return app(TagService::class)->makeTagList($this);
}
/**
* Get all normalized tags of a model as a delimited string.
*
* @return string
*/
public function getTagListNormalizedAttribute(): string
{
return app(TagService::class)->makeTagList($this, 'normalized');
}
/**
* Get all tags of a model as an array.
*
* @return array
*/
public function getTagArrayAttribute(): array
{
return app(TagService::class)->makeTagArray($this);
}
/**
* Get all normalized tags of a model as an array.
*
* @return array
*/
public function getTagArrayNormalizedAttribute(): array
{
return app(TagService::class)->makeTagArray($this, 'normalized');
}
/**
* Determine if a given tag is attached to the model.
*
* @param Tag|string $tag
*
* @return bool
*/
public function hasTag($tag): bool
{
if ($tag instanceof Tag) {
$normalized = $tag->getAttribute('normalized');
} else {
$normalized = app(TagService::class)->normalize($tag);
}
return in_array($normalized, $this->getTagArrayNormalizedAttribute(), true);
}
/**
* Query scope for models that have all of the given tags.
*
* @param Builder $query
* @param array|string $tags
*
* @return Builder
* @throws \Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException
* @throws \ErrorException
*/
public function scopeWithAllTags(Builder $query, $tags): Builder
{
/** @var TagService $service */
$service = app(TagService::class);
$normalized = $service->buildTagArrayNormalized($tags);
// If there are no tags specified, then there
// can't be any results so short-circuit
if (count($normalized) === 0) {
if (config('taggable.throwEmptyExceptions')) {
throw new NoTagsSpecifiedException('Empty tag data passed to withAllTags scope.');
}
return $query->where(\DB::raw(1), 0);
}
$tagKeys = $service->getTagModelKeys($normalized);
// If some of the tags specified don't exist, then there can't
// be any models with all the tags, so so short-circuit
if (count($tagKeys) !== count($normalized)) {
return $query->where(\DB::raw(1), 0);
}
$alias = $this->taggableCreateNewAlias(__FUNCTION__);
$morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
return $this->prepareTableJoin($query, 'inner', $alias)
->whereIn($morphTagKeyName, $tagKeys)
->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) = ?", [count($tagKeys)]);
}
/**
* Query scope for models that have any of the given tags.
*
* @param Builder $query
* @param array|string $tags
*
* @return Builder
* @throws \Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException
* @throws \ErrorException
*/
public function scopeWithAnyTags(Builder $query, $tags): Builder
{
/** @var TagService $service */
$service = app(TagService::class);
$normalized = $service->buildTagArrayNormalized($tags);
// If there are no tags specified, then there is
// no filtering to be done so short-circuit
if (count($normalized) === 0) {
if (config('taggable.throwEmptyExceptions')) {
throw new NoTagsSpecifiedException('Empty tag data passed to withAnyTags scope.');
}
return $query->where(\DB::raw(1), 0);
}
$tagKeys = $service->getTagModelKeys($normalized);
$alias = $this->taggableCreateNewAlias(__FUNCTION__);
$morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
return $this->prepareTableJoin($query, 'inner', $alias)
->whereIn($morphTagKeyName, $tagKeys);
}
/**
* Query scope for models that have any tag.
*
* @param Builder $query
*
* @return Builder
*/
public function scopeIsTagged(Builder $query): Builder
{
$alias = $this->taggableCreateNewAlias(__FUNCTION__);
return $this->prepareTableJoin($query, 'inner', $alias);
}
/**
* Query scope for models that do not have all of the given tags.
*
* @param Builder $query
* @param string|array $tags
* @param bool $includeUntagged
*
* @return Builder
* @throws \ErrorException
*/
public function scopeWithoutAllTags(Builder $query, $tags, bool $includeUntagged = false): Builder
{
/** @var TagService $service */
$service = app(TagService::class);
$normalized = $service->buildTagArrayNormalized($tags);
$tagKeys = $service->getTagModelKeys($normalized);
$tagKeyList = implode(',', $tagKeys);
$alias = $this->taggableCreateNewAlias(__FUNCTION__);
$morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
$query = $this->prepareTableJoin($query, 'left', $alias)
->havingRaw(
"COUNT(DISTINCT CASE WHEN ({$morphTagKeyName} IN ({$tagKeyList})) THEN {$morphTagKeyName} ELSE NULL END) < ?",
[count($tagKeys)]
);
if (!$includeUntagged) {
$query->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) > 0");
}
return $query;
}
/**
* Query scope for models that do not have any of the given tags.
*
* @param Builder $query
* @param string|array $tags
* @param bool $includeUntagged
*
* @return Builder
* @throws \ErrorException
*/
public function scopeWithoutAnyTags(Builder $query, $tags, bool $includeUntagged = false): Builder
{
/** @var TagService $service */
$service = app(TagService::class);
$normalized = $service->buildTagArrayNormalized($tags);
$tagKeys = $service->getTagModelKeys($normalized);
$tagKeyList = implode(',', $tagKeys);
$alias = $this->taggableCreateNewAlias(__FUNCTION__);
$morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
$query = $this->prepareTableJoin($query, 'left', $alias)
->havingRaw("COUNT(DISTINCT CASE WHEN ({$morphTagKeyName} IN ({$tagKeyList})) THEN {$morphTagKeyName} ELSE NULL END) = 0");
if (!$includeUntagged) {
$query->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) > 0");
}
return $query;
}
/**
* Query scope for models that does not have have any tags.
*
* @param Builder $query
*
* @return Builder
*/
public function scopeIsNotTagged(Builder $query): Builder
{
$alias = $this->taggableCreateNewAlias(__FUNCTION__);
$morphForeignKeyName = $this->getQualifiedForeignPivotKeyNameWithAlias($alias);
return $this->prepareTableJoin($query, 'left', $alias)
->havingRaw("COUNT(DISTINCT {$morphForeignKeyName}) = 0");
}
/**
* @param Builder $query
* @param string $joinType
*
* @return Builder
*/
private function prepareTableJoin(Builder $query, string $joinType, string $alias): Builder
{
$morphTable = $this->tags()->getTable();
$morphTableAlias = $morphTable.'_'.$alias;
$modelKeyName = $this->getQualifiedKeyName();
$morphForeignKeyName = $this->getQualifiedForeignPivotKeyNameWithAlias($alias);
$morphTypeName = $morphTableAlias.'.'. $this->tags()->getMorphType();
$morphClass = $this->tags()->getMorphClass();
$closure = function (JoinClause $join) use ($modelKeyName, $morphForeignKeyName, $morphTypeName, $morphClass) {
$join->on($modelKeyName, $morphForeignKeyName)
->where($morphTypeName, $morphClass);
};
return $query
->select($this->getTable() . '.*')
->join($morphTable.' as '.$morphTableAlias, $closure, null, null, $joinType)
->groupBy($modelKeyName);
}
/**
* Get a collection of all the tag models used for the called class.
*
* @return Collection
*/
public static function allTagModels(): Collection
{
return app(TagService::class)->getAllTags(static::class);
}
/**
* Get an array of all tags used for the called class.
*
* @return array
*/
public static function allTags(): array
{
/** @var \Illuminate\Database\Eloquent\Collection $tags */
$tags = static::allTagModels();
return $tags->pluck('name')->sort()->all();
}
/**
* Get all the tags used for the called class as a delimited string.
*
* @return string
*/
public static function allTagsList(): string
{
return app(TagService::class)->joinList(static::allTags());
}
/**
* Rename one the tags for the called class.
*
* @param string $oldTag
* @param string $newTag
*
* @return int
*/
public static function renameTag(string $oldTag, string $newTag): int
{
return app(TagService::class)->renameTags($oldTag, $newTag, static::class);
}
/**
* Get the most popular tags for the called class.
*
* @param int $limit
* @param int $minCount
*
* @return array
*/
public static function popularTags(int $limit = null, int $minCount = 1): array
{
/** @var Collection $tags */
$tags = app(TagService::class)->getPopularTags($limit, static::class, $minCount);
return $tags->pluck('taggable_count', 'name')->all();
}
/**
* Get the most popular tags for the called class.
*
* @param int $limit
* @param int $minCount
*
* @return array
*/
public static function popularTagsNormalized(int $limit = null, int $minCount = 1): array
{
/** @var Collection $tags */
$tags = app(TagService::class)->getPopularTags($limit, static::class, $minCount);
return $tags->pluck('taggable_count', 'normalized')->all();
}
/**
* Returns the Related Pivot Key Name with the table alias.
*
* @param string $alias
*
* @return string
*/
private function getQualifiedRelatedPivotKeyNameWithAlias(string $alias): string
{
$morph = $this->tags();
return $morph->getTable() . '_' . $alias .
'.' . $morph->getRelatedPivotKeyName();
}
/**
* Returns the Foreign Pivot Key Name with the table alias.
*
* @param string $alias
*
* @return string
*/
private function getQualifiedForeignPivotKeyNameWithAlias(string $alias): string
{
$morph = $this->tags();
return $morph->getTable() . '_' . $alias .
'.' . $morph->getForeignPivotKeyName();
}
/**
* Create a new alias to use on scopes to be able to combine many scopes
*
* @param string $scope
*
* @return string
*/
private function taggableCreateNewAlias(string $scope): string
{
$this->taggableAliasSequence++;
$alias = strtolower($scope) . '_' . $this->taggableAliasSequence;
return $alias;
}
}