Files
SkinbaseNova/app/Services/NovaCards/NovaCardProjectNormalizer.php
2026-03-28 19:15:39 +01:00

290 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use App\Models\NovaCardTemplate;
use Illuminate\Support\Arr;
class NovaCardProjectNormalizer
{
public function normalize(?array $project, ?NovaCardTemplate $template = null, array $attributes = [], ?NovaCard $card = null): array
{
return $this->upgradeToV3($project, $template, $attributes, $card);
}
public function isLegacyProject(?array $project): bool
{
return (int) Arr::get($project, 'schema_version', 1) < 3;
}
public function upgradeToV3(?array $project, ?NovaCardTemplate $template = null, array $attributes = [], ?NovaCard $card = null): array
{
// First normalize to v2 as the base, then layer on v3 additions.
$v2 = $this->upgradeToV2($project, $template, $attributes, $card);
// v3: enrich background with v3-specific fields.
$v3Background = array_merge($v2['background'], [
'brightness' => (int) Arr::get($project, 'background.brightness', Arr::get($attributes, 'brightness', 0)),
'contrast' => (int) Arr::get($project, 'background.contrast', Arr::get($attributes, 'contrast', 0)),
'texture_overlay' => (string) Arr::get($project, 'background.texture_overlay', Arr::get($attributes, 'texture_overlay', '')),
'gradient_direction' => (string) Arr::get($project, 'background.gradient_direction', Arr::get($attributes, 'gradient_direction', 'to-bottom')),
]);
// v3: enrich typography with v3-specific fields.
$v3Typography = array_merge($v2['typography'], [
'quote_mark_preset' => (string) Arr::get($project, 'typography.quote_mark_preset', Arr::get($attributes, 'quote_mark_preset', 'none')),
'text_panel_style' => (string) Arr::get($project, 'typography.text_panel_style', Arr::get($attributes, 'text_panel_style', 'none')),
'text_glow' => (bool) Arr::get($project, 'typography.text_glow', Arr::get($attributes, 'text_glow', false)),
'text_stroke' => (bool) Arr::get($project, 'typography.text_stroke', Arr::get($attributes, 'text_stroke', false)),
]);
// v3: canvas safe zones and layout anchors.
$v3Canvas = [
'snap_guides' => (bool) Arr::get($project, 'canvas.snap_guides', true),
'safe_zones' => (bool) Arr::get($project, 'canvas.safe_zones', true),
];
// v3: frame pack reference.
$v3Frame = [
'frame_preset' => (string) Arr::get($project, 'frame.frame_preset', Arr::get($attributes, 'frame_preset', 'none')),
'frame_color' => (string) Arr::get($project, 'frame.frame_color', Arr::get($attributes, 'frame_color', '')),
];
// v3: effects layer (glow overlays, vignette, etc.)
$v3Effects = [
'vignette' => (bool) Arr::get($project, 'effects.vignette', Arr::get($attributes, 'vignette', false)),
'vignette_strength' => (string) Arr::get($project, 'effects.vignette_strength', 'soft'),
'color_grade' => (string) Arr::get($project, 'effects.color_grade', Arr::get($attributes, 'color_grade', 'none')),
'glow_overlay' => (bool) Arr::get($project, 'effects.glow_overlay', Arr::get($attributes, 'glow_overlay', false)),
];
// v3: export preferences (what the creator last chose).
$v3ExportPrefs = [
'preferred_format' => (string) Arr::get($project, 'export_preferences.preferred_format', Arr::get($attributes, 'export_preferred_format', 'preview')),
'include_watermark' => (bool) Arr::get($project, 'export_preferences.include_watermark', true),
];
// v3: source/attribution context.
$v3SourceContext = [
'original_card_id' => Arr::get($v2, 'meta.remix.original_card_id'),
'root_card_id' => Arr::get($v2, 'meta.remix.root_card_id'),
'original_creator_id' => Arr::get($attributes, 'original_creator_id', $card?->original_creator_id),
'preset_id' => Arr::get($attributes, 'preset_id', Arr::get($project, 'source_context.preset_id')),
];
return array_merge($v2, [
'schema_version' => 3,
'meta' => array_merge($v2['meta'], [
'editor' => 'nova-cards-v3',
]),
'canvas' => $v3Canvas,
'background' => $v3Background,
'typography' => $v3Typography,
'frame' => $v3Frame,
'effects' => $v3Effects,
'export_preferences' => $v3ExportPrefs,
'source_context' => $v3SourceContext,
]);
}
public function upgradeToV2(?array $project, ?NovaCardTemplate $template = null, array $attributes = [], ?NovaCard $card = null): array
{
$project = is_array($project) ? $project : [];
$templateConfig = is_array($template?->config_json) ? $template->config_json : [];
$existingContent = is_array(Arr::get($project, 'content')) ? Arr::get($project, 'content') : [];
$title = trim((string) Arr::get($attributes, 'title', Arr::get($existingContent, 'title', $card?->title ?? 'Untitled card')));
$quoteText = trim((string) Arr::get($attributes, 'quote_text', Arr::get($existingContent, 'quote_text', $card?->quote_text ?? 'Your next quote starts here.')));
$quoteAuthor = trim((string) Arr::get($attributes, 'quote_author', Arr::get($existingContent, 'quote_author', $card?->quote_author ?? '')));
$quoteSource = trim((string) Arr::get($attributes, 'quote_source', Arr::get($existingContent, 'quote_source', $card?->quote_source ?? '')));
$textBlocks = $this->normalizeTextBlocks(Arr::wrap(Arr::get($project, 'text_blocks', [])), [
'title' => $title,
'quote_text' => $quoteText,
'quote_author' => $quoteAuthor,
'quote_source' => $quoteSource,
]);
[$syncedTitle, $syncedQuote, $syncedAuthor, $syncedSource] = $this->syncLegacyContent($textBlocks, [
'title' => $title,
'quote_text' => $quoteText,
'quote_author' => $quoteAuthor,
'quote_source' => $quoteSource,
]);
if (array_key_exists('title', $attributes)) {
$syncedTitle = $title;
}
if (array_key_exists('quote_text', $attributes)) {
$syncedQuote = $quoteText;
}
if (array_key_exists('quote_author', $attributes)) {
$syncedAuthor = $quoteAuthor;
}
if (array_key_exists('quote_source', $attributes)) {
$syncedSource = $quoteSource;
}
$gradientKey = (string) Arr::get($project, 'background.gradient_preset', Arr::get($attributes, 'gradient_preset', Arr::get($templateConfig, 'gradient_preset', 'midnight-nova')));
$fontKey = (string) Arr::get($project, 'typography.font_preset', Arr::get($attributes, 'font_preset', Arr::get($templateConfig, 'font_preset', 'modern-sans')));
$sourceLineEnabled = collect($textBlocks)->contains(fn (array $block): bool => ($block['type'] ?? null) === 'source' && (bool) ($block['enabled'] ?? true) && trim((string) ($block['text'] ?? '')) !== '');
$authorLineEnabled = collect($textBlocks)->contains(fn (array $block): bool => ($block['type'] ?? null) === 'author' && (bool) ($block['enabled'] ?? true) && trim((string) ($block['text'] ?? '')) !== '');
return [
'schema_version' => 2,
'template' => [
'id' => $template?->id ?? Arr::get($project, 'template.id'),
'slug' => $template?->slug ?? Arr::get($project, 'template.slug'),
],
'meta' => [
'editor' => 'nova-cards-v2',
'remix' => [
'original_card_id' => Arr::get($attributes, 'original_card_id', $card?->original_card_id),
'root_card_id' => Arr::get($attributes, 'root_card_id', $card?->root_card_id),
],
],
'content' => [
'title' => $syncedTitle,
'quote_text' => $syncedQuote,
'quote_author' => $syncedAuthor,
'quote_source' => $syncedSource,
],
'text_blocks' => $textBlocks,
'layout' => [
'layout' => (string) Arr::get($project, 'layout.layout', Arr::get($attributes, 'layout', Arr::get($templateConfig, 'layout', 'quote_heavy'))),
'position' => (string) Arr::get($project, 'layout.position', Arr::get($attributes, 'position', 'center')),
'alignment' => (string) Arr::get($project, 'layout.alignment', Arr::get($attributes, 'alignment', Arr::get($templateConfig, 'text_align', 'center'))),
'padding' => (string) Arr::get($project, 'layout.padding', Arr::get($attributes, 'padding', 'comfortable')),
'max_width' => (string) Arr::get($project, 'layout.max_width', Arr::get($attributes, 'max_width', 'balanced')),
],
'typography' => [
'font_preset' => $fontKey,
'text_color' => (string) Arr::get($project, 'typography.text_color', Arr::get($attributes, 'text_color', Arr::get($templateConfig, 'text_color', '#ffffff'))),
'accent_color' => (string) Arr::get($project, 'typography.accent_color', Arr::get($attributes, 'accent_color', '#e0f2fe')),
'quote_size' => (int) Arr::get($project, 'typography.quote_size', Arr::get($attributes, 'quote_size', 72)),
'author_size' => (int) Arr::get($project, 'typography.author_size', Arr::get($attributes, 'author_size', 28)),
'letter_spacing' => (int) Arr::get($project, 'typography.letter_spacing', Arr::get($attributes, 'letter_spacing', 0)),
'line_height' => (float) Arr::get($project, 'typography.line_height', Arr::get($attributes, 'line_height', 1.2)),
'shadow_preset' => (string) Arr::get($project, 'typography.shadow_preset', Arr::get($attributes, 'shadow_preset', 'soft')),
'author_line_enabled' => $authorLineEnabled,
'source_line_enabled' => $sourceLineEnabled,
],
'background' => [
'type' => (string) Arr::get($project, 'background.type', Arr::get($attributes, 'background_type', $card?->background_type ?? 'gradient')),
'gradient_preset' => $gradientKey,
'gradient_colors' => array_values(Arr::wrap(Arr::get($project, 'background.gradient_colors', Arr::get($attributes, 'gradient_colors', Arr::get(config('nova_cards.gradient_presets'), $gradientKey . '.colors', ['#0f172a', '#1d4ed8']))))),
'solid_color' => (string) Arr::get($project, 'background.solid_color', Arr::get($attributes, 'solid_color', '#111827')),
'background_image_id' => Arr::get($attributes, 'background_image_id', Arr::get($project, 'background.background_image_id', $card?->background_image_id)),
'overlay_style' => (string) Arr::get($project, 'background.overlay_style', Arr::get($attributes, 'overlay_style', Arr::get($templateConfig, 'overlay_style', 'dark-soft'))),
'focal_position' => (string) Arr::get($project, 'background.focal_position', Arr::get($attributes, 'focal_position', 'center')),
'blur_level' => (int) Arr::get($project, 'background.blur_level', Arr::get($attributes, 'blur_level', 0)),
'opacity' => (int) Arr::get($project, 'background.opacity', Arr::get($attributes, 'opacity', 50)),
],
'decorations' => array_values(Arr::wrap(Arr::get($project, 'decorations', Arr::get($attributes, 'decorations', [])))),
'assets' => [
'pack_ids' => array_values(array_filter(
array_map('intval', Arr::wrap(Arr::get($project, 'assets.pack_ids', Arr::get($attributes, 'asset_pack_ids', []))))
)),
'template_pack_ids' => array_values(array_filter(
array_map('intval', Arr::wrap(Arr::get($project, 'assets.template_pack_ids', Arr::get($attributes, 'template_pack_ids', []))))
)),
'items' => array_values(Arr::wrap(Arr::get($project, 'assets.items', []))),
],
];
}
public function normalizeForCard(NovaCard $card): array
{
return $this->normalize($card->project_json, $card->template, [
'title' => $card->title,
'quote_text' => $card->quote_text,
'quote_author' => $card->quote_author,
'quote_source' => $card->quote_source,
'background_type' => $card->background_type,
'background_image_id' => $card->background_image_id,
'original_card_id' => $card->original_card_id,
'root_card_id' => $card->root_card_id,
], $card);
}
public function syncTopLevelAttributes(array $project): array
{
[$title, $quoteText, $quoteAuthor, $quoteSource] = $this->syncLegacyContent(Arr::wrap(Arr::get($project, 'text_blocks', [])), Arr::get($project, 'content', []));
return [
'schema_version' => (int) Arr::get($project, 'schema_version', 3),
'title' => $title,
'quote_text' => $quoteText,
'quote_author' => $quoteAuthor !== '' ? $quoteAuthor : null,
'quote_source' => $quoteSource !== '' ? $quoteSource : null,
'background_type' => (string) Arr::get($project, 'background.type', 'gradient'),
'background_image_id' => Arr::get($project, 'background.background_image_id'),
];
}
private function normalizeTextBlocks(array $blocks, array $fallback): array
{
$normalized = collect($blocks)
->map(function ($block, int $index): array {
$block = is_array($block) ? $block : [];
return [
'key' => (string) Arr::get($block, 'key', 'block-' . ($index + 1)),
'type' => (string) Arr::get($block, 'type', 'body'),
'text' => (string) Arr::get($block, 'text', ''),
'enabled' => ! array_key_exists('enabled', $block) || (bool) Arr::get($block, 'enabled', true),
'style' => is_array(Arr::get($block, 'style')) ? Arr::get($block, 'style') : [],
];
})
->filter(fn (array $block): bool => $block['type'] !== '' && $block['key'] !== '')
->values();
if ($normalized->isEmpty()) {
$normalized = collect([
['key' => 'title', 'type' => 'title', 'text' => (string) ($fallback['title'] ?? ''), 'enabled' => true, 'style' => ['role' => 'eyebrow']],
['key' => 'quote', 'type' => 'quote', 'text' => (string) ($fallback['quote_text'] ?? ''), 'enabled' => true, 'style' => ['role' => 'headline']],
['key' => 'author', 'type' => 'author', 'text' => (string) ($fallback['quote_author'] ?? ''), 'enabled' => (string) ($fallback['quote_author'] ?? '') !== '', 'style' => ['role' => 'byline']],
['key' => 'source', 'type' => 'source', 'text' => (string) ($fallback['quote_source'] ?? ''), 'enabled' => (string) ($fallback['quote_source'] ?? '') !== '', 'style' => ['role' => 'caption']],
]);
}
return $normalized->take((int) config('nova_cards.validation.max_text_blocks', 8))->values()->all();
}
private function syncLegacyContent(array $blocks, array $fallback): array
{
$title = trim((string) ($fallback['title'] ?? 'Untitled card'));
$quoteText = trim((string) ($fallback['quote_text'] ?? 'Your next quote starts here.'));
$quoteAuthor = trim((string) ($fallback['quote_author'] ?? ''));
$quoteSource = trim((string) ($fallback['quote_source'] ?? ''));
foreach ($blocks as $block) {
if (! is_array($block) || ! ($block['enabled'] ?? true)) {
continue;
}
$text = trim((string) ($block['text'] ?? ''));
if ($text === '') {
continue;
}
$type = (string) ($block['type'] ?? '');
if ($type === 'title') {
$title = $text;
} elseif ($type === 'quote') {
$quoteText = $text;
} elseif ($type === 'author') {
$quoteAuthor = $text;
} elseif ($type === 'source') {
$quoteSource = $text;
}
}
return [$title !== '' ? $title : 'Untitled card', $quoteText !== '' ? $quoteText : 'Your next quote starts here.', $quoteAuthor, $quoteSource];
}
}