Migrate backend to Porto architecture and fix minor bugs

- Fully transitioned the backend to the Porto architectural pattern
- Improved code organization and maintainability
- Fixed minor bugs and inconsistencies
This commit is contained in:
F4ilji
2025-05-12 08:47:43 +05:00
parent 76deaf75f3
commit c01016a154
620 changed files with 16218 additions and 6073 deletions
@@ -0,0 +1,11 @@
<?php
namespace App\Containers\Search\Loaders;
class AliasesLoader
{
/**
* @var array
*/
public array $aliases = [];
}
@@ -0,0 +1,26 @@
<?php
namespace App\Containers\Search\Loaders;
class MiddlewareLoader
{
/**
* @var array
*/
public array $middleware = [];
/**
* @var array
*/
public array $middlewareGroups = [];
/**
* @var array
*/
public array $routeMiddleware = [];
/**
* @var array
*/
public array $middlewarePriority = [];
}
@@ -0,0 +1,11 @@
<?php
namespace App\Containers\Search\Loaders;
class ProvidersLoader
{
/**
* @var array
*/
public array $providers = [];
}
@@ -0,0 +1,31 @@
<?php
namespace App\Containers\Search\Services;
use DiDom\Document;
class BreadcrumbFinderService
{
const FILES_DIR = 'sveden';
public function isSameCategory($html)
{
return $this->getBreadcrumb($html);
}
private function getBreadcrumb(string $html): ?string
{
$document = new Document($html);
$breadcrumbs = $document->first('ol.breadcrumb')?->find('li') ?? [];
foreach ($breadcrumbs as $index => $breadcrumb) {
if ($index === 2) { // Индексация с 0 → третий элемент = 2
return $breadcrumb->text();
}
}
return null;
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Containers\Search\Services;
use DiDom\Document;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
class CategoryFinderService
{
const FILES_DIR = 'sveden';
public function getCategories()
{
$file = public_path(self::FILES_DIR . '/' . 'index.html');
$html = file_get_contents($file);
$document = new Document($html);
$dropdownMenu = $document->first('ul.dropdown-menu');
$links = $dropdownMenu->find('a');
$categories = [];
foreach ($links as $link) {
$category = trim($link->text());
$categories[] = $category;
}
return $categories;
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Containers\Search\Services;
use DiDom\Document;
class HtmlContentExtractorService
{
public function getContent(string $html): ?array
{
$document = new Document($html);
$content = $document->first('.vikon-content');
$breadcrumb = $content->first('.row');
$content->firstInDocument('.row')->remove();
return [$content->html(), $breadcrumb->html()] ?? null;
}
}
@@ -0,0 +1,218 @@
<?php
namespace App\Containers\Search\Services;
use App\Ship\Requests\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
class StaticFileSearch
{
const CACHE_KEY = 'static_html_index_v2';
const FILES_DIR = 'sveden';
const CACHE_TTL = 86400; // 24 часа
const PER_PAGE = 10; // Количество результатов на страницу
public function search(Request $request): array
{
$query = $request->input('search');
$category = $request->input('category');
$page = request()->input('page', 1);
if ($query === null) {
return [
'data' => []
];
}
try {
$index = $this->getIndex();
$results = [];
$normalizedQuery = $this->normalizeText(Str::lower($query));
foreach ($index as $filePath => $content) {
if (stripos($content['content'], $normalizedQuery) !== false) {
$relativePath = str_replace(public_path() . '/', '', $filePath);
$results[] = [
'file' => $relativePath,
'content' => $content['title'],
'category' => trim($content['category']),
];
}
}
$categories = array_values(array_unique(array_filter(array_column($results, 'category'))));
if ($category !== null) {
$results = array_filter($results, function($item) use ($category) {
return $item['category'] === $category;
});
// Переиндексировать массив
$results = array_values($results);
}
return $this->paginateResults($results, $page, $categories);
} catch (\Exception $e) {
Log::error('Search error: ' . $e->getMessage());
return [
'data' => [],
'meta' => [
'current_page' => 1,
'total' => 0,
'per_page' => self::PER_PAGE,
'last_page' => 1
]
];
}
}
protected function paginateResults(array $results, int $page, ?array $categories): array
{
$total = count($results);
$lastPage = max(1, ceil($total / self::PER_PAGE));
$page = max(1, min($page, $lastPage));
$offset = ($page - 1) * self::PER_PAGE;
$paginatedResults = array_slice($results, $offset, self::PER_PAGE);
return [
'data' => $paginatedResults,
'meta' => [
'current_page' => $page,
'total' => $total,
'per_page' => self::PER_PAGE,
'last_page' => $lastPage
],
'categories' => $categories
];
}
protected function getIndex(): array
{
return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function() {
try {
$index = [];
$directory = public_path(self::FILES_DIR);
if (!is_dir($directory)) {
Log::error("Directory not found: {$directory}");
return [];
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile() && $this->isHtmlFile($file)) {
$html = file_get_contents($file->getPathname());
[$content, $breadcrumb] = app(HtmlContentExtractorService::class)->getContent($html);
if ($content !== false) {
$breadcrumb = app(BreadcrumbFinderService::class)->isSameCategory($breadcrumb);
$text = $this->normalizeText(strip_tags($content));
$index[$file->getPathname()] = [
'title' => $this->getFirstH1Content($content),
'content' => $text,
'category' => $breadcrumb ?? null,
];
}
}
}
return $index;
} catch (\Exception $e) {
Log::error('Index creation error: ' . $e->getMessage());
return [];
}
});
}
protected function isHtmlFile(\SplFileInfo $file): bool
{
$extension = strtolower($file->getExtension());
return in_array($extension, ['html', 'htm']);
}
protected function normalizeText(string $text): string
{
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = preg_replace('/\s+/u', ' ', $text);
$text = trim($text);
return Str::lower($text);
}
protected function findMatches(string $content, string $query): array
{
$matches = [];
$offset = 0;
$query = Str::lower($query);
$content = Str::lower($content);
$queryLength = mb_strlen($query, 'UTF-8');
while (($offset = mb_strpos($content, $query, $offset, 'UTF-8')) !== false) {
$start = max(0, $offset - 50);
$length = min(150, mb_strlen($content) - $start);
$excerpt = mb_substr($content, $start, $length, 'UTF-8');
$matches[] = str_replace(
$query,
'[[HIGHLIGHT]]'.$query.'[[/HIGHLIGHT]]',
$excerpt
);
$offset += $queryLength;
}
return $matches;
}
public function clearCache(): bool
{
try {
Cache::forget(self::CACHE_KEY);
return true;
} catch (\Exception $e) {
Log::error('Cache clear error: ' . $e->getMessage());
return false;
}
}
public function rebuildIndex(): array
{
$this->clearCache();
return $this->getIndex();
}
public function getCacheStatus(): array
{
return [
'exists' => Cache::has(self::CACHE_KEY),
'ttl' => Cache::get(self::CACHE_KEY.'_ttl', null),
'driver' => config('cache.default'),
'path' => public_path(self::FILES_DIR),
'directory_exists' => is_dir(public_path(self::FILES_DIR))
];
}
public function getFirstH1Content(string $content): ?string
{
try {
if (preg_match('/<h1[^>]*>(.*?)<\/h1>/is', $content, $matches)) {
return $this->normalizeText($matches[1]);
}
return null;
} catch (\Exception $e) {
Log::error('Failed to get H1: ' . $e->getMessage());
return null;
}
}
}
@@ -0,0 +1,176 @@
<?php
namespace App\Containers\Search\UI\API\Controllers;
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
use App\Containers\AppStructure\Models\Page;
use App\Containers\Article\Models\Post;
use App\Containers\Education\Models\EducationalProgram;
use App\Containers\Event\Models\Event;
use App\Containers\InstituteStructure\Models\Faculty;
use App\Containers\Schedule\Models\EducationalGroup;
use App\Containers\Search\UI\API\Transformers\AdditionalEducationSearchResource;
use App\Containers\Search\UI\API\Transformers\EducationalProgramSearchResource;
use App\Containers\Search\UI\API\Transformers\EducationGroupSearchResource;
use App\Containers\Search\UI\API\Transformers\EventSearchResource;
use App\Containers\Search\UI\API\Transformers\FacultySearchResource;
use App\Containers\Search\UI\API\Transformers\PageSearchResource;
use App\Containers\Search\UI\API\Transformers\PostSearchResource;
use App\Containers\Search\UI\API\Transformers\UserSearchResource;
use App\Containers\User\Models\User;
use App\Ship\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Str;
use ProtoneMedia\LaravelCrossEloquentSearch\Search;
class SearchController extends Controller
{
private array $resourceMap = [
Post::class => PostSearchResource::class,
Page::class => PageSearchResource::class,
EducationalGroup::class => EducationGroupSearchResource::class,
EducationalProgram::class => EducationalProgramSearchResource::class,
Event::class => EventSearchResource::class,
AdditionalEducation::class => AdditionalEducationSearchResource::class, //??
User::class => UserSearchResource::class, //??
Faculty::class => FacultySearchResource::class, //??
];
public function index(Request $request)
{
$req = Str::lower($request->query('search'));
if (!$req) {
return response()->json([
'searchRes' => null,
]);
}
$results = Search::new()
->add(Post::where('status', '=', 'published'), ['title', 'search_data'], 'publish_at')
->add(Page::with('section')->where('searchable', '=', true), ['title', 'search_data'])
->add(Event::where('event_date_start', '>', Date::now()), 'title', 'created_at')
->add(AdditionalEducation::where('is_active', '=', true), 'title')
->add(EducationalGroup::with('schedules'), 'title')
->add(EducationalProgram::where('status', '=', true)->whereHas('admission_plans'), 'name')
->add(Faculty::where('is_active', '=', true), 'title')
->add(User::whereHas('userDetail'), 'name')
->orderByDesc()
->beginWithWildcard()
// ->orderByRelevance()
->includeModelType()
->ignoreCase(true)
->search("$req");
$resourceMap = $this->resourceMap;
$resources = collect($results)->map(function ($result) use ($resourceMap) {
$resourceClass = $resourceMap[get_class($result)] ?? null;
return $resourceClass ? new $resourceClass($result) : null;
});
$result_type = $this->getCategoriesSearchResult($resources);
if ($request->query('category')) {
$resources = $this->sortResourcesByCategory($resources, $request->query('category'));
}
$paginate_data = $this->createPaginate($resources, $request, 7);
$sortedData = $this->sortByType($paginate_data['paginator'], $req);
return response()->json([
'searchRes' => $sortedData,
'result_type' => $result_type,
'selectedCategory' => ($request->query('category') !== null) ? $request->query('category') : null,
'paginate' => [
'current_page' => $paginate_data['paginator']->currentPage(),
'last_page' => $paginate_data['paginator']->lastPage(),
'total' => $paginate_data['paginator']->total(),
'next_page' => $paginate_data['next_page'],
'prev_page' => $paginate_data['prev_page'],
]
]);
}
private function sortByType(object $data, string $searchRequest): array
{
$sortedData = [];
foreach ($data as $item) {
$searchData = $item['search_data'] ?? '';
$matches = $this->getMatches($searchData, $searchRequest);
$sortedData[$item['type']][] = [
'data' => $item,
'matches' => $matches,
'tag' => $item['type']
];
}
return $sortedData;
}
private function getMatches(string $haystack, string $needle): array
{
$matches = [];
// while (($offset = mb_strpos($haystack, $needle, $offset, 'UTF-8')) !== false) {
// $left = max(0, $offset - 50);
// $right = min(mb_strlen($haystack, 'UTF-8'), $offset + 100);
// $excerpt = mb_substr($haystack, $left, $right - $left, 'UTF-8');
// $matches[] = $excerpt;
// $offset += mb_strlen($needle, 'UTF-8');
// }
$offset = 0;
if (($offset = mb_strpos($haystack, $needle, $offset, 'UTF-8')) !== false) {
for ($i = 0; 1 > count($matches); $i++) {
$left = max(0, $offset - 50);
$right = min(mb_strlen($haystack, 'UTF-8'), $offset + 100);
$excerpt = mb_substr($haystack, $left, $right - $left, 'UTF-8');
$matches[] = $excerpt;
}
}
return $matches;
}
private function sortResourcesByCategory(Collection $resources, string $category) : Collection
{
if ($category === "All") {
$data = $resources;
} else {
$data = $resources->where('type', $category);
}
return $data;
}
private function getCategoriesSearchResult(Collection $resources)
{
return $resources->pluck('type')->unique()->values()->all();
}
private function createPaginate($resources, $request, $perPage = 10) : array
{
$currentPage = LengthAwarePaginator::resolveCurrentPage();
// Отрезаем нужные элементы для текущей страницы
$currentItems = $resources->slice(($currentPage - 1) * $perPage, $perPage)->all();
// Создаем экземпляр LengthAwarePaginator
$paginator = new LengthAwarePaginator($currentItems, count($resources), $perPage, $currentPage, [
'path' => $request->url(),
'query' => $request->query,
]);
$nextPage = $paginator->hasMorePages() ? $paginator->currentPage() + 1 : null;
$prevPage = $paginator->onFirstPage() ? null : $paginator->currentPage() - 1;
return [
'paginator' => $paginator,
'next_page' => $nextPage,
'prev_page' => $prevPage
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Containers\Search\UI\API\Controllers;
use App\Containers\Search\Services\CategoryFinderService;
use App\Containers\Search\Services\StaticFileSearch;
use App\Ship\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class StaticSearchController extends Controller
{
public function search(Request $request)
{
return app(StaticFileSearch::class)
->search($request);
}
public function getCategories()
{
return Cache::remember('page_static_categories', now()->addWeek(), function () {
return app(CategoryFinderService::class)->getCategories();
});
}
}
@@ -0,0 +1,11 @@
<?php
use App\Containers\Search\UI\API\Controllers\SearchController;
use App\Containers\Search\UI\API\Controllers\StaticSearchController;
use Illuminate\Support\Facades\Route;
Route::get('/search', [SearchController::class, 'index'])->name('client.search.index');
Route::get('/static/search', [StaticSearchController::class, 'search'])->name('client.search.static');
Route::get('/static/categories', [StaticSearchController::class, 'getCategories'])->name('client.categories.static');
@@ -0,0 +1,23 @@
<?php
namespace App\Containers\Search\UI\API\Transformers;
use App\Ship\Resources\JsonResource;
class AdditionalEducationSearchResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Containers\Search\UI\API\Transformers;
use App\Containers\Schedule\UI\WEB\Transformers\ScheduleGroupResource;
use App\Ship\Resources\JsonResource;
class EducationGroupSearchResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'schedules' => ScheduleGroupResource::collection($this->whenLoaded('schedules')),
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Containers\Search\UI\API\Transformers;
use App\Ship\Resources\JsonResource;
class EducationalProgramSearchResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Containers\Search\UI\API\Transformers;
use App\Ship\Resources\JsonResource;
class EventSearchResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'event_date_start' => $this->event_date_start,
'event_time_start' => $this->event_time_start
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Containers\Search\UI\API\Transformers;
use App\Ship\Resources\JsonResource;
class FacultySearchResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Containers\Search\UI\API\Transformers;
use App\Ship\Resources\JsonResource;
class PageSearchResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'path' => $this->path,
'is_url' => $this->is_url,
'section' => $this->section ? $this->section->title : null,
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Containers\Search\UI\API\Transformers;
use Carbon\Carbon;
use App\Ship\Resources\JsonResource;
class PostSearchResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'created_post' => Carbon::parse($this->publish_at)->diffforhumans(),
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Containers\Search\UI\API\Transformers;
use App\Ship\Resources\JsonResource;
class UserSearchResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
];
}
}