593 lines
22 KiB
PHP
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));
|
|
}
|
|
}
|