Files
dewemoji/app/app/Services/EmojiCatalog/EmojiCatalogService.php
2026-06-14 15:47:37 +07:00

593 lines
22 KiB
PHP

<?php
namespace App\Services\EmojiCatalog;
use App\Services\System\SettingsService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class EmojiCatalogService
{
public function __construct(
private readonly SettingsService $settings
) {}
/**
* @return array<string,mixed>|null
*/
public function findItem(int $emojiId): ?array
{
$base = DB::table('emojis')->where('emoji_id', $emojiId)->first();
if (! $base) {
return null;
}
return $this->hydrateEditorItem((array) $base);
}
public function nextEmojiId(): int
{
return (int) DB::table('emojis')->max('emoji_id') + 1;
}
/**
* @param array<string,mixed> $input
*/
public function saveItem(array $input): int
{
$payload = $this->normalizeItemPayload($input);
if ($payload['slug'] === '' || $payload['name'] === '') {
throw new RuntimeException('Slug and name are required.');
}
$incomingId = (int) ($input['emoji_id'] ?? 0);
$existingBySlug = DB::table('emojis')->where('slug', $payload['slug'])->first();
if ($incomingId > 0) {
if ($existingBySlug && (int) $existingBySlug->emoji_id !== $incomingId) {
throw new RuntimeException('Slug is already used by another emoji.');
}
$emojiId = $incomingId;
} else {
$emojiId = (int) ($existingBySlug->emoji_id ?? 0);
if ($emojiId <= 0) {
$emojiId = $this->nextEmojiId();
}
}
DB::transaction(function () use ($emojiId, $payload): void {
$exists = DB::table('emojis')->where('emoji_id', $emojiId)->exists();
$base = [
'emoji_id' => $emojiId,
'slug' => $payload['slug'],
'emoji_char' => $payload['emoji'],
'name' => $payload['name'],
'category' => $payload['category'],
'subcategory' => $payload['subcategory'],
'unified' => $payload['unified'],
'default_presentation' => $payload['default_presentation'] ?: 'emoji',
'version' => $payload['version'] ?: 'custom',
'supports_skin_tone' => (bool) $payload['supports_skin_tone'],
'permalink' => $payload['permalink'] ?: '/emoji/'.$payload['slug'],
'description' => $payload['description'],
'meta_title' => $payload['meta_title'],
'meta_description' => $payload['meta_description'],
'title' => $payload['title'],
'updated_at' => now(),
];
if ($exists) {
DB::table('emojis')->where('emoji_id', $emojiId)->update($base);
} else {
$base['created_at'] = now();
DB::table('emojis')->insert($base);
}
DB::table('emoji_aliases')->where('emoji_id', $emojiId)->delete();
foreach ($payload['aliases'] as $alias) {
DB::table('emoji_aliases')->insert([
'emoji_id' => $emojiId,
'alias' => $alias,
]);
}
DB::table('emoji_shortcodes')->where('emoji_id', $emojiId)->delete();
foreach ($payload['shortcodes'] as $shortcode) {
DB::table('emoji_shortcodes')->insert([
'emoji_id' => $emojiId,
'shortcode' => $shortcode,
'kind' => 'primary',
]);
}
foreach ($payload['alt_shortcodes'] as $shortcode) {
DB::table('emoji_shortcodes')->insert([
'emoji_id' => $emojiId,
'shortcode' => $shortcode,
'kind' => 'alt',
]);
}
DB::table('emoji_keywords')
->where('emoji_slug', $payload['slug'])
->where(function ($query) {
$query->whereNull('owner_user_id')
->orWhere('owner_user_id', '');
})
->delete();
foreach ($payload['keywords_en'] as $keyword) {
DB::table('emoji_keywords')->insert([
'emoji_slug' => $payload['slug'],
'lang' => 'en',
'region' => '',
'keyword' => $keyword,
'status' => 'public',
'owner_user_id' => null,
'visibility' => 'public',
'public_key' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
foreach ($payload['keywords_id'] as $keyword) {
DB::table('emoji_keywords')->insert([
'emoji_slug' => $payload['slug'],
'lang' => 'id',
'region' => '',
'keyword' => $keyword,
'status' => 'public',
'owner_user_id' => null,
'visibility' => 'public',
'public_key' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
});
return $emojiId;
}
public function deleteItem(int $emojiId): void
{
$emoji = DB::table('emojis')->where('emoji_id', $emojiId)->first();
if (! $emoji) {
return;
}
DB::transaction(function () use ($emojiId, $emoji): void {
DB::table('emoji_aliases')->where('emoji_id', $emojiId)->delete();
DB::table('emoji_shortcodes')->where('emoji_id', $emojiId)->delete();
DB::table('emoji_keywords')
->where('emoji_slug', (string) $emoji->slug)
->where(function ($query) {
$query->whereNull('owner_user_id')->orWhere('owner_user_id', '');
})
->delete();
DB::table('emojis')->where('emoji_id', $emojiId)->delete();
});
}
/**
* Import from JSON in non-destructive mode.
* Existing rows (matched by slug or emoji_id) are kept as-is.
*
* @return array{total:int,imported:int,skipped:int}
*/
public function importFromDataFile(string $path): array
{
if (! is_file($path)) {
throw new RuntimeException('Dataset file not found: '.$path);
}
$raw = file_get_contents($path);
if ($raw === false) {
throw new RuntimeException('Could not read dataset file.');
}
$decoded = json_decode($raw, true);
if (! is_array($decoded) || ! is_array($decoded['emojis'] ?? null)) {
throw new RuntimeException('Invalid emoji dataset format.');
}
$rows = $decoded['emojis'];
$total = 0;
$imported = 0;
$skipped = 0;
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$total++;
$slug = $this->slugify((string) ($row['slug'] ?? ''));
$emojiId = (int) ($row['emoji_id'] ?? 0);
if ($slug !== '' && DB::table('emojis')->where('slug', $slug)->exists()) {
$skipped++;
continue;
}
if ($emojiId > 0 && DB::table('emojis')->where('emoji_id', $emojiId)->exists()) {
$skipped++;
continue;
}
$this->saveItem($row);
$imported++;
}
return [
'total' => $total,
'imported' => $imported,
'skipped' => $skipped,
];
}
/**
* @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function normalizeItemPayload(array $input): array
{
$slug = $this->slugify((string) ($input['slug'] ?? ''));
$name = trim((string) ($input['name'] ?? ''));
$emoji = trim((string) ($input['emoji'] ?? ''));
$category = trim((string) ($input['category'] ?? ''));
$subcategory = trim((string) ($input['subcategory'] ?? ''));
$description = trim((string) ($input['description'] ?? ''));
$permalink = trim((string) ($input['permalink'] ?? ''));
if ($permalink === '' && $slug !== '') {
$permalink = '/emoji/'.$slug;
}
$title = trim((string) ($input['title'] ?? ''));
if ($title === '' && $name !== '') {
$title = ($emoji !== '' ? $emoji.' ' : '').$name.' — meaning & copy';
}
$metaTitle = trim((string) ($input['meta_title'] ?? ''));
if ($metaTitle === '' && $name !== '') {
$metaTitle = ($emoji !== '' ? $emoji.' ' : '').$name.' | Meaning & Copy';
}
$metaDescription = trim((string) ($input['meta_description'] ?? ''));
if ($metaDescription === '' && $description !== '') {
$metaDescription = Str::limit($description, 160, '…');
}
$keywordsEn = $this->normalizeArray($input['keywords_en'] ?? []);
$keywordsId = $this->normalizeArray($input['keywords_id'] ?? []);
$aliases = $this->normalizeArray($input['aliases'] ?? []);
$shortcodes = $this->normalizeArray($input['shortcodes'] ?? []);
$altShortcodes = $this->normalizeArray($input['alt_shortcodes'] ?? []);
return [
'emoji_id' => isset($input['emoji_id']) ? (int) $input['emoji_id'] : null,
'slug' => $slug,
'emoji' => $emoji,
'name' => $name,
'category' => $category,
'subcategory' => $subcategory,
'aliases' => $aliases,
'shortcodes' => $shortcodes,
'alt_shortcodes' => $altShortcodes,
'keywords_en' => $keywordsEn,
'keywords_id' => $keywordsId,
'usage_examples' => [],
'description' => $description,
'codepoints' => [],
'unified' => trim((string) ($input['unified'] ?? '')),
'default_presentation' => trim((string) ($input['default_presentation'] ?? '')),
'version' => trim((string) ($input['version'] ?? '')),
'supports_skin_tone' => filter_var($input['supports_skin_tone'] ?? false, FILTER_VALIDATE_BOOL),
'related' => [],
'intent_tags' => [],
'search_tokens' => [],
'permalink' => $permalink,
'title' => $title,
'meta_title' => $metaTitle,
'meta_description' => $metaDescription,
];
}
/**
* @return array<string,mixed>
*/
public function publishSnapshot(?string $createdBy = null): array
{
$emojiRows = DB::table('emojis')->orderBy('emoji_id')->get();
$slugs = $emojiRows->pluck('slug')->filter()->values()->all();
$keywords = DB::table('emoji_keywords')
->whereIn('emoji_slug', $slugs)
->where(function ($query) {
$query->whereNull('owner_user_id')->orWhere('owner_user_id', '');
})
->select(['emoji_slug', 'lang', 'keyword'])
->get()
->groupBy('emoji_slug');
$aliases = DB::table('emoji_aliases')
->whereIn('emoji_id', $emojiRows->pluck('emoji_id')->all())
->select(['emoji_id', 'alias'])
->get()
->groupBy('emoji_id');
$shortcodes = DB::table('emoji_shortcodes')
->whereIn('emoji_id', $emojiRows->pluck('emoji_id')->all())
->select(['emoji_id', 'shortcode', 'kind'])
->get()
->groupBy('emoji_id');
$payload = [
'@version' => (string) ($emojiRows->first()->version ?? 'custom'),
'@source' => 'db:emojis(+aliases,+keywords,+shortcodes)',
'count' => $emojiRows->count(),
'emojis' => $emojiRows->map(function ($item) use ($aliases, $shortcodes, $keywords): array {
$itemAliases = $aliases->get($item->emoji_id, collect())->pluck('alias')->filter()->values()->all();
$itemShortcodes = $shortcodes->get($item->emoji_id, collect());
$primaryShortcodes = $itemShortcodes->where('kind', 'primary')->pluck('shortcode')->filter()->values()->all();
$altShortcodes = $itemShortcodes->where('kind', 'alt')->pluck('shortcode')->filter()->values()->all();
$itemKeywords = $keywords->get($item->slug, collect());
$keywordsEn = $itemKeywords->where('lang', 'en')->pluck('keyword')->filter()->values()->all();
$keywordsId = $itemKeywords->where('lang', 'id')->pluck('keyword')->filter()->values()->all();
return [
'emoji' => (string) $item->emoji_char,
'slug' => (string) $item->slug,
'permalink' => (string) ($item->permalink ?: '/emoji/'.$item->slug),
'title' => (string) ($item->title ?: (((string) $item->emoji_char !== '' ? $item->emoji_char.' ' : '').$item->name.' — meaning & copy')),
'meta_title' => (string) ($item->meta_title ?: (((string) $item->emoji_char !== '' ? $item->emoji_char.' ' : '').$item->name.' | Meaning & Copy')),
'meta_description' => (string) ($item->meta_description ?: Str::limit((string) ($item->description ?? ''), 160, '…')),
'name' => (string) $item->name,
'aliases' => $itemAliases,
'shortcodes' => $primaryShortcodes,
'alt_shortcodes' => $altShortcodes,
'keywords_en' => $keywordsEn,
'keywords_id' => $keywordsId,
'usage_examples' => [],
'description' => (string) ($item->description ?? ''),
'category' => (string) $item->category,
'subcategory' => (string) $item->subcategory,
'codepoints' => [],
'unified' => (string) $item->unified,
'default_presentation' => (string) ($item->default_presentation ?: 'emoji'),
'version' => (string) ($item->version ?: 'custom'),
'supports_skin_tone' => (bool) $item->supports_skin_tone,
'related' => [],
'intent_tags' => [],
'search_tokens' => $this->buildSearchTokens(
(string) $item->emoji_char,
(string) $item->slug,
(string) $item->name,
(string) $item->category,
(string) $item->subcategory,
$keywordsEn,
$keywordsId,
$itemAliases,
$primaryShortcodes,
$altShortcodes,
[],
[]
),
];
})->values()->all(),
];
$snapshotDir = $this->snapshotDirectory();
if (! is_dir($snapshotDir) && ! mkdir($snapshotDir, 0775, true) && ! is_dir($snapshotDir)) {
throw new RuntimeException('Could not create snapshot directory.');
}
$version = now()->format('YmdHis');
$filename = 'emojis-'.$version.'.json';
$fullPath = $snapshotDir.'/'.$filename;
$written = file_put_contents($fullPath, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
if ($written === false) {
throw new RuntimeException('Could not write snapshot file.');
}
DB::transaction(function () use ($version, $fullPath, $createdBy): void {
$this->settings->setMany([
'emoji_dataset_active_path' => $fullPath,
'emoji_dataset_active_version' => $version,
'emoji_dataset_last_published_at' => now()->toIso8601String(),
], $createdBy);
});
return [
'snapshot_id' => null,
'version' => $version,
'file_path' => $fullPath,
'count' => $emojiRows->count(),
];
}
/**
* @return array<int,array{name:string,version:string,path:string,is_active:bool,modified_at:int}>
*/
public function listSnapshots(): array
{
$dir = $this->snapshotDirectory();
if (! is_dir($dir)) {
return [];
}
$activePath = (string) $this->settings->get('emoji_dataset_active_path', '');
$files = glob($dir.'/emojis-*.json') ?: [];
$items = [];
foreach ($files as $file) {
if (! is_file($file)) {
continue;
}
$name = basename($file);
if (! preg_match('/^emojis-(\d{14})\.json$/', $name, $m)) {
continue;
}
$items[] = [
'name' => $name,
'version' => $m[1],
'path' => $file,
'is_active' => $activePath !== '' && realpath($activePath) === realpath($file),
'modified_at' => (int) @filemtime($file),
];
}
usort($items, static function (array $a, array $b): int {
return $b['version'] <=> $a['version'];
});
return $items;
}
public function activateSnapshot(string $filename, ?string $updatedBy = null): array
{
if (! preg_match('/^emojis-(\d{14})\.json$/', $filename, $m)) {
throw new RuntimeException('Invalid snapshot filename.');
}
$fullPath = $this->snapshotDirectory().'/'.$filename;
if (! is_file($fullPath)) {
throw new RuntimeException('Snapshot file not found.');
}
$version = $m[1];
$this->settings->setMany([
'emoji_dataset_active_path' => $fullPath,
'emoji_dataset_active_version' => $version,
'emoji_dataset_last_published_at' => now()->toIso8601String(),
], $updatedBy);
return [
'version' => $version,
'file_path' => $fullPath,
];
}
/**
* @param array<string,mixed> $base
* @return array<string,mixed>
*/
private function hydrateEditorItem(array $base): array
{
$emojiId = (int) ($base['emoji_id'] ?? 0);
$slug = (string) ($base['slug'] ?? '');
$aliases = DB::table('emoji_aliases')->where('emoji_id', $emojiId)->pluck('alias')->filter()->values()->all();
$sc = DB::table('emoji_shortcodes')->where('emoji_id', $emojiId)->get(['shortcode', 'kind']);
$keywords = DB::table('emoji_keywords')
->where('emoji_slug', $slug)
->where(function ($query) {
$query->whereNull('owner_user_id')->orWhere('owner_user_id', '');
})
->get(['lang', 'keyword']);
return [
'emoji_id' => $emojiId,
'slug' => $slug,
'emoji' => (string) ($base['emoji_char'] ?? ''),
'name' => (string) ($base['name'] ?? ''),
'category' => (string) ($base['category'] ?? ''),
'subcategory' => (string) ($base['subcategory'] ?? ''),
'aliases' => $aliases,
'shortcodes' => $sc->where('kind', 'primary')->pluck('shortcode')->filter()->values()->all(),
'alt_shortcodes' => $sc->where('kind', 'alt')->pluck('shortcode')->filter()->values()->all(),
'keywords_en' => $keywords->where('lang', 'en')->pluck('keyword')->filter()->values()->all(),
'keywords_id' => $keywords->where('lang', 'id')->pluck('keyword')->filter()->values()->all(),
'usage_examples' => [],
'description' => (string) ($base['description'] ?? ''),
'codepoints' => [],
'unified' => (string) ($base['unified'] ?? ''),
'default_presentation' => (string) ($base['default_presentation'] ?? ''),
'version' => (string) ($base['version'] ?? ''),
'supports_skin_tone' => (bool) ($base['supports_skin_tone'] ?? false),
'related' => [],
'intent_tags' => [],
'search_tokens' => [],
'permalink' => (string) ($base['permalink'] ?? ''),
'title' => (string) ($base['title'] ?? ''),
'meta_title' => (string) ($base['meta_title'] ?? ''),
'meta_description' => (string) ($base['meta_description'] ?? ''),
'is_active' => true,
'sort_order' => 0,
];
}
/**
* @return array<int,string>
*/
private function normalizeArray(mixed $input): array
{
if (is_string($input)) {
$input = preg_split('/\r\n|\r|\n|,/', $input) ?: [];
}
if (! is_array($input)) {
return [];
}
$out = [];
foreach ($input as $value) {
$v = trim((string) $value);
if ($v !== '') {
$out[] = $v;
}
}
return array_values(array_unique($out));
}
private function slugify(string $value): string
{
$value = strtolower(trim($value));
$value = str_replace('&', 'and', $value);
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
return trim($value, '-');
}
private function snapshotDirectory(): string
{
return storage_path('app/private/snapshots');
}
/**
* @param array<int,string> $keywordsEn
* @param array<int,string> $keywordsId
* @param array<int,string> $aliases
* @param array<int,string> $shortcodes
* @param array<int,string> $altShortcodes
* @param array<int,string> $intentTags
* @param array<int,string> $codepoints
* @return array<int,string>
*/
private function buildSearchTokens(
string $emoji,
string $slug,
string $name,
string $category,
string $subcategory,
array $keywordsEn,
array $keywordsId,
array $aliases,
array $shortcodes,
array $altShortcodes,
array $intentTags,
array $codepoints
): array {
$tokens = [
$emoji,
$slug,
$name,
strtolower($name),
strtolower($category),
strtolower($subcategory),
];
$tokens = array_merge($tokens, $keywordsEn, $keywordsId, $aliases, $shortcodes, $altShortcodes, $intentTags, $codepoints);
$cleaned = [];
foreach ($tokens as $token) {
$t = trim((string) $token);
if ($t !== '') {
$cleaned[] = $t;
}
}
return array_values(array_unique($cleaned));
}
}