feat: create PostCategoryFactory for generating post category data feat: create PostFactory for generating post data feat: add retry_requested_at column to services table feat: add vcenter provisioning fields to products table feat: add module_host_id to os table feat: create email_notification_templates table for email templates feat: add scenario and format fields to template table feat: add admin fields to transactions table fix: update ticket table columns and types feat: add uuid to ticket table and backfill existing records fix: make ticket_reply user_id nullable feat: create notifications table for user notifications fix: make ticket uuid nullable feat: create post_categories table for blog categories feat: create posts table for blog posts feat: add invoice email template for PDF generation
155 lines
5.0 KiB
PHP
155 lines
5.0 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Services;
|
|
|
|
use App\Models\Post;
|
|
use App\Models\PostCategory;
|
|
use App\Support\GeneratesUniqueSlug;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
|
|
class DocsWordpressSyncService
|
|
{
|
|
/**
|
|
* @return array{created:int,updated:int,skipped:int,total:int}
|
|
*/
|
|
public function sync(?string $sourceUrl = null, int $perPage = 20, ?int $maxPages = null): array
|
|
{
|
|
$url = $sourceUrl ?: config('services.docs.wordpress_posts_url');
|
|
|
|
if (!$url) {
|
|
throw new \RuntimeException('Chưa cấu hình nguồn đồng bộ WordPress.');
|
|
}
|
|
|
|
$created = 0;
|
|
$updated = 0;
|
|
$skipped = 0;
|
|
$total = 0;
|
|
|
|
$page = 1;
|
|
|
|
while (true) {
|
|
if ($maxPages !== null && $page > max(1, $maxPages)) {
|
|
break;
|
|
}
|
|
|
|
$response = Http::timeout(20)
|
|
->retry(2, 300)
|
|
->acceptJson()
|
|
->get($url, [
|
|
'per_page' => min(max($perPage, 1), 100),
|
|
'page' => $page,
|
|
'_embed' => 1,
|
|
'status' => 'publish',
|
|
]);
|
|
|
|
$response->throw();
|
|
|
|
/** @var array<int, array<string, mixed>> $items */
|
|
$items = $response->json() ?? [];
|
|
|
|
if (empty($items)) {
|
|
break;
|
|
}
|
|
|
|
foreach ($items as $item) {
|
|
$total++;
|
|
$result = $this->syncItem($item);
|
|
|
|
match ($result) {
|
|
'created' => $created++,
|
|
'updated' => $updated++,
|
|
default => $skipped++,
|
|
};
|
|
}
|
|
|
|
if (count($items) < $perPage) {
|
|
break;
|
|
}
|
|
|
|
$page++;
|
|
}
|
|
|
|
return compact('created', 'updated', 'skipped', 'total');
|
|
}
|
|
|
|
private function syncItem(array $item): string
|
|
{
|
|
$title = trim((string) data_get($item, 'title.rendered', ''));
|
|
$content = (string) data_get($item, 'content.rendered', '');
|
|
$slug = trim((string) data_get($item, 'slug', ''));
|
|
|
|
if ($title === '' || $content === '') {
|
|
return 'skipped';
|
|
}
|
|
|
|
$category = $this->resolveCategory($item);
|
|
$existing = Post::withTrashed()->where('slug', $slug)->first();
|
|
$thumbnail = $this->resolveFeaturedImage($item);
|
|
$excerpt = trim(strip_tags((string) data_get($item, 'excerpt.rendered', '')));
|
|
$publishedAt = data_get($item, 'date_gmt') ?: data_get($item, 'date');
|
|
|
|
$payload = [
|
|
'post_category_id' => $category->id,
|
|
'title' => html_entity_decode(strip_tags($title), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
|
'excerpt' => $excerpt !== '' ? html_entity_decode($excerpt, ENT_QUOTES | ENT_HTML5, 'UTF-8') : null,
|
|
'content' => $content,
|
|
'thumbnail' => $thumbnail,
|
|
'status' => 'published',
|
|
'is_featured' => false,
|
|
'reading_time' => $this->estimateReadingTime($content),
|
|
'meta_title' => html_entity_decode(strip_tags($title), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
|
'meta_description' => $excerpt !== '' ? html_entity_decode($excerpt, ENT_QUOTES | ENT_HTML5, 'UTF-8') : null,
|
|
'published_at' => $publishedAt ? Carbon::parse($publishedAt) : now(),
|
|
];
|
|
|
|
if ($existing) {
|
|
$existing->restore();
|
|
$existing->update($payload);
|
|
return 'updated';
|
|
}
|
|
|
|
Post::create($payload + [
|
|
'slug' => GeneratesUniqueSlug::forModel(Post::class, $slug !== '' ? $slug : $title),
|
|
'view_count' => 0,
|
|
]);
|
|
|
|
return 'created';
|
|
}
|
|
|
|
private function resolveCategory(array $item): PostCategory
|
|
{
|
|
$termGroups = (array) data_get($item, '_embedded.wp:term', []);
|
|
$terms = is_array($termGroups[0] ?? null) ? $termGroups[0] : [];
|
|
$firstTerm = Arr::first($terms);
|
|
$categoryName = trim((string) data_get($firstTerm, 'name', 'Imported Docs'));
|
|
$categorySlug = trim((string) data_get($firstTerm, 'slug', 'imported-docs'));
|
|
|
|
return PostCategory::firstOrCreate(
|
|
['slug' => $categorySlug !== '' ? $categorySlug : 'imported-docs'],
|
|
[
|
|
'name' => $categoryName !== '' ? $categoryName : 'Imported Docs',
|
|
'description' => 'Danh mục đồng bộ từ WordPress Docs.',
|
|
'status' => 'active',
|
|
'sort_order' => 999,
|
|
]
|
|
);
|
|
}
|
|
|
|
private function resolveFeaturedImage(array $item): ?string
|
|
{
|
|
return data_get($item, '_embedded.wp:featuredmedia.0.source_url')
|
|
?: data_get($item, '_embedded.wp:featuredmedia.0.media_details.sizes.full.source_url')
|
|
?: null;
|
|
}
|
|
|
|
private function estimateReadingTime(string $html): int
|
|
{
|
|
$wordCount = str_word_count(Str::of(strip_tags($html))->squish()->value());
|
|
|
|
return max(1, (int) ceil($wordCount / 200));
|
|
}
|
|
}
|