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]; } }