+ `;
+
+ linkArea.innerHTML = linkInputHTML;
+
+ const input = linkArea.querySelector('.idea-link-input');
+ const saveBtn = linkArea.querySelector('.idea-link-save');
+ const cancelBtn = linkArea.querySelector('.idea-link-cancel');
+
+ input.focus();
+
+ saveBtn.addEventListener('click', () => {
+ const link = input.value.trim();
+
+ // Disable buttons during request.
+ saveBtn.disabled = true;
+ cancelBtn.disabled = true;
+ saveBtn.textContent = Drupal.t('Saving...');
+
+ // Ensure we have an ID for Drupal.ajax.
+ if (!saveBtn.id) {
+ saveBtn.id = 'save-link-' + Date.now();
+ }
+
+ // Use Drupal's AJAX framework.
+ const ajaxObject = Drupal.ajax({
+ url: Drupal.url('admin/reports/ai/content-strategy/save-card/' + section + '/' + uuid),
+ base: saveBtn.id,
+ element: saveBtn,
+ submit: {
+ field: 'link',
+ value: link,
+ idea_uuid: ideaUuid
+ },
+ progress: { type: 'none' },
+ error: function(xhr, status, error) {
+ const messages = new Drupal.Message();
+ messages.add(Drupal.t('Error saving link.'), {
+ type: 'error',
+ id: 'content-strategy-error-' + Date.now()
+ });
+ linkArea.innerHTML = originalContent;
+ Drupal.attachBehaviors(linkArea);
+ }
+ });
+
+ // Override the success method to attach behaviors after commands run.
+ // Drupal's AJAX success handler processes commands, then calls this.
+ const originalSuccess = ajaxObject.success;
+ ajaxObject.success = function(response, status) {
+ // Call original success to process AJAX commands (including HtmlCommand).
+ originalSuccess.call(this, response, status);
+ // Now attach behaviors to the updated linkArea.
+ // Use setTimeout to ensure DOM is updated after HtmlCommand.
+ setTimeout(() => {
+ Drupal.attachBehaviors(linkArea);
+ }, 0);
+ };
+
+ ajaxObject.execute();
+ });
+
+ cancelBtn.addEventListener('click', () => {
+ linkArea.innerHTML = originalContent;
+ Drupal.attachBehaviors(linkArea);
+ });
+
+ // Keyboard shortcuts.
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ saveBtn.click();
+ } else if (e.key === 'Escape') {
+ cancelBtn.click();
+ }
+ });
+ }
+
+ /**
+ * Add link button behavior.
+ */
+ Drupal.behaviors.contentStrategyAddLink = {
+ attach: function(context, settings) {
+ once('contentStrategyAddLink', '.idea-add-link', context).forEach((button) => {
+ button.addEventListener('click', function(event) {
+ event.preventDefault();
+ const section = button.dataset.section;
+ const uuid = button.dataset.uuid;
+ const ideaUuid = button.dataset.ideaUuid;
+ const linkArea = button.closest('.idea-link-area');
+
+ showLinkInput(linkArea, section, uuid, ideaUuid, '', settings);
+ });
+ });
+ }
+ };
+
+ /**
+ * Edit link button behavior.
+ */
+ Drupal.behaviors.contentStrategyEditLink = {
+ attach: function(context, settings) {
+ once('contentStrategyEditLink', '.idea-link-edit', context).forEach((button) => {
+ button.addEventListener('click', function(event) {
+ event.preventDefault();
+ const section = button.dataset.section;
+ const uuid = button.dataset.uuid;
+ const ideaUuid = button.dataset.ideaUuid;
+ const linkArea = button.closest('.idea-link-area');
+ const currentLink = linkArea.querySelector('.idea-link')?.href || '';
+
+ showLinkInput(linkArea, section, uuid, ideaUuid, currentLink, settings);
+ });
+ });
+ }
+ };
+
+})(Drupal, once);
diff --git a/js/content-strategy-utils.js b/js/content-strategy-utils.js
new file mode 100644
index 0000000..d39bcec
--- /dev/null
+++ b/js/content-strategy-utils.js
@@ -0,0 +1,204 @@
+/**
+ * @file
+ * Shared utilities for AI Content Strategy module.
+ */
+
+((Drupal) => {
+ 'use strict';
+
+ // Create namespace for content strategy utilities.
+ Drupal.aiContentStrategy = Drupal.aiContentStrategy || {};
+
+ /**
+ * DOM utility functions.
+ */
+ Drupal.aiContentStrategy.DOMUtils = {
+ /**
+ * Ensures an element has an ID, generating one if needed.
+ *
+ * @param {HTMLElement} element - The element to check.
+ * @param {string} prefix - Prefix for generated ID.
+ * @param {string|number} index - Optional index for uniqueness.
+ * @returns {string} The element's ID.
+ */
+ ensureElementId(element, prefix, index = '') {
+ if (!element.id) {
+ element.id = `${prefix}-${index || Date.now()}`;
+ }
+ return element.id;
+ },
+
+ /**
+ * Gets section and uuid data from a recommendation item.
+ *
+ * @param {HTMLElement} element - Element within a recommendation item.
+ * @returns {Object} Object with section and uuid properties.
+ */
+ getItemData(element) {
+ const item = element.closest('.recommendation-item');
+ if (!item) {
+ return {};
+ }
+ return {
+ section: item.dataset.section,
+ uuid: item.dataset.uuid
+ };
+ },
+
+ /**
+ * Gets translated button text from drupalSettings.
+ *
+ * @param {Object} settings - drupalSettings object.
+ * @param {string} type - Button text type.
+ * @param {string} section - Section identifier.
+ * @returns {string} Translated button text.
+ */
+ getButtonText(settings, type, section) {
+ if (!settings?.aiContentStrategy?.buttonText) {
+ throw new Error('Button text settings are not available.');
+ }
+
+ const buttonTexts = settings.aiContentStrategy.buttonText;
+ if (!buttonTexts[type]) {
+ throw new Error(`Button text type "${type}" is not defined.`);
+ }
+ if (!buttonTexts[type][section] && type !== 'main') {
+ throw new Error(`Button text for section "${section}" not defined.`);
+ }
+
+ return buttonTexts[type][section];
+ },
+
+ /**
+ * Clears progress messages containing specific text.
+ */
+ clearProgressMessages() {
+ document.querySelectorAll('[data-drupal-message-type="status"]').forEach((msg) => {
+ if (msg.textContent.includes('Analyzing') || msg.textContent.includes('generating')) {
+ msg.remove();
+ }
+ });
+ }
+ };
+
+ /**
+ * AJAX handler factory for content strategy requests.
+ *
+ * Creates a Drupal.ajax settings object that uses the Drupal AJAX framework
+ * for command processing while providing customized button states.
+ *
+ * @param {Object} options - Handler options.
+ * @param {HTMLElement} options.element - Triggering element.
+ * @param {string} options.url - Request URL (relative to base path).
+ * @param {string} options.loadingText - Text displayed during loading.
+ * @param {string} options.successText - Text displayed on success.
+ * @param {string} options.errorText - Text displayed on error.
+ * @param {Function} options.onSuccess - Success callback (receives context).
+ * @param {Object} drupalSettings - drupalSettings object.
+ * @returns {Object} AJAX settings object for Drupal.Ajax constructor.
+ */
+ Drupal.aiContentStrategy.createAjaxHandler = function({
+ element,
+ url,
+ loadingText,
+ successText,
+ errorText,
+ onSuccess
+ }, drupalSettings) {
+ const DOMUtils = Drupal.aiContentStrategy.DOMUtils;
+
+ if (!drupalSettings?.aiContentStrategy?.buttonText) {
+ throw new Error('Button text settings are not available.');
+ }
+
+ if (!loadingText || !successText || !errorText) {
+ throw new Error('Required button texts are missing.');
+ }
+
+ DOMUtils.ensureElementId(element, 'content-strategy');
+
+ // Store original text for restoration.
+ const originalText = element.textContent.trim();
+
+ return {
+ base: element.id,
+ element: element,
+ url: Drupal.url(url),
+ submit: { js: true },
+ progress: { type: 'throbber' },
+
+ beforeSend: function(xhr, settings) {
+ element.textContent = loadingText;
+ element.disabled = true;
+ Drupal.announce(loadingText, 'polite');
+ return true;
+ },
+
+ // Let Drupal handle success - commands are processed automatically.
+ // We use complete to reset button state and run callbacks.
+ complete: function(response, status) {
+ DOMUtils.clearProgressMessages();
+ element.disabled = false;
+
+ // Check if there was an error in the response.
+ let hasError = false;
+ if (response && response.responseJSON) {
+ const data = response.responseJSON;
+ if (Array.isArray(data)) {
+ data.forEach((cmd) => {
+ if (cmd.command === 'message' && cmd.messageOptions?.type === 'error') {
+ hasError = true;
+ }
+ });
+ }
+ }
+
+ if (hasError || status === 'error') {
+ element.textContent = errorText || originalText;
+ }
+ else {
+ element.textContent = successText || originalText;
+ Drupal.announce(Drupal.t('Content loaded successfully'), 'polite');
+
+ // Run success callback if provided.
+ if (onSuccess) {
+ const mainContainer = document.querySelector('.content-strategy-recommendations');
+ onSuccess(mainContainer);
+ }
+ }
+ },
+
+ error: function(xhr, status, error) {
+ DOMUtils.clearProgressMessages();
+ element.disabled = false;
+ element.textContent = errorText || originalText;
+
+ // Show error message.
+ const messages = new Drupal.Message();
+ try {
+ const response = JSON.parse(xhr.responseText);
+ if (response[0]?.message) {
+ messages.add(response[0].message, {
+ type: 'error',
+ id: `content-strategy-error-${Date.now()}`,
+ announce: response[0].message
+ });
+ }
+ else {
+ messages.add(Drupal.t('An error occurred while processing your request.'), {
+ type: 'error',
+ id: `content-strategy-error-${Date.now()}`
+ });
+ }
+ }
+ catch (e) {
+ messages.add(Drupal.t('An error occurred while processing your request.'), {
+ type: 'error',
+ id: `content-strategy-error-${Date.now()}`
+ });
+ }
+ }
+ };
+ };
+
+})(Drupal);
diff --git a/src/Controller/ContentStrategyController.php b/src/Controller/ContentStrategyController.php
index a4ef232..5b01329 100644
--- a/src/Controller/ContentStrategyController.php
+++ b/src/Controller/ContentStrategyController.php
@@ -2,11 +2,15 @@
namespace Drupal\ai_content_strategy\Controller;
+use Drupal\Core\Ajax\DataCommand;
use Drupal\ai_content_strategy\Entity\RecommendationCategory;
use Drupal\Core\Controller\ControllerBase;
use Drupal\ai_content_strategy\Service\StrategyGenerator;
use Drupal\ai_content_strategy\Service\ContentAnalyzer;
use Drupal\ai_content_strategy\Service\CategoryPromptBuilder;
+use Drupal\ai_content_strategy\Service\RecommendationStorageService;
+use Drupal\ai_content_strategy\Service\IdeaRowBuilder;
+use Drupal\ai_content_strategy\Service\AjaxResponseBuilder;
use Drupal\ai\AiProviderPluginManager;
use Drupal\ai\Service\PromptJsonDecoder\PromptJsonDecoderInterface;
use Drupal\Component\Serialization\Json;
@@ -28,6 +32,7 @@
use Drupal\Core\Ajax\InvokeCommand;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Component\Datetime\TimeInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
/**
* Controller for content strategy functionality.
@@ -114,6 +119,34 @@ class ContentStrategyController extends ControllerBase {
*/
protected $categoryPromptBuilder;
+ /**
+ * The request stack.
+ *
+ * @var \Symfony\Component\HttpFoundation\RequestStack
+ */
+ protected $requestStack;
+
+ /**
+ * The recommendation storage service.
+ *
+ * @var \Drupal\ai_content_strategy\Service\RecommendationStorageService
+ */
+ protected $recommendationStorage;
+
+ /**
+ * The idea row builder service.
+ *
+ * @var \Drupal\ai_content_strategy\Service\IdeaRowBuilder
+ */
+ protected $ideaRowBuilder;
+
+ /**
+ * The AJAX response builder service.
+ *
+ * @var \Drupal\ai_content_strategy\Service\AjaxResponseBuilder
+ */
+ protected $ajaxResponseBuilder;
+
/**
* Constructs a ContentStrategyController object.
*
@@ -137,6 +170,14 @@ class ContentStrategyController extends ControllerBase {
* The time service.
* @param \Drupal\ai_content_strategy\Service\CategoryPromptBuilder $category_prompt_builder
* The category prompt builder service.
+ * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+ * The request stack.
+ * @param \Drupal\ai_content_strategy\Service\RecommendationStorageService $recommendation_storage
+ * The recommendation storage service.
+ * @param \Drupal\ai_content_strategy\Service\IdeaRowBuilder $idea_row_builder
+ * The idea row builder service.
+ * @param \Drupal\ai_content_strategy\Service\AjaxResponseBuilder $ajax_response_builder
+ * The AJAX response builder service.
*/
public function __construct(
StrategyGenerator $strategy_generator,
@@ -149,6 +190,10 @@ public function __construct(
KeyValueFactoryInterface $key_value_factory,
TimeInterface $time,
CategoryPromptBuilder $category_prompt_builder,
+ RequestStack $request_stack,
+ RecommendationStorageService $recommendation_storage,
+ IdeaRowBuilder $idea_row_builder,
+ AjaxResponseBuilder $ajax_response_builder,
) {
$this->strategyGenerator = $strategy_generator;
$this->contentAnalyzer = $content_analyzer;
@@ -160,6 +205,10 @@ public function __construct(
$this->keyValue = $key_value_factory->get(self::KV_COLLECTION);
$this->time = $time;
$this->categoryPromptBuilder = $category_prompt_builder;
+ $this->requestStack = $request_stack;
+ $this->recommendationStorage = $recommendation_storage;
+ $this->ideaRowBuilder = $idea_row_builder;
+ $this->ajaxResponseBuilder = $ajax_response_builder;
}
/**
@@ -176,7 +225,11 @@ public static function create(ContainerInterface $container) {
$container->get('date.formatter'),
$container->get('keyvalue'),
$container->get('datetime.time'),
- $container->get('ai_content_strategy.category_prompt_builder')
+ $container->get('ai_content_strategy.category_prompt_builder'),
+ $container->get('request_stack'),
+ $container->get('ai_content_strategy.recommendation_storage'),
+ $container->get('ai_content_strategy.idea_row_builder'),
+ $container->get('ai_content_strategy.ajax_response_builder')
);
}
@@ -201,18 +254,33 @@ public function recommendations() {
$stored_data = $this->keyValue->get(self::KV_KEY);
$recommendations = [];
$last_run = NULL;
+ $pages_analyzed = NULL;
if ($stored_data) {
if (is_array($stored_data) && isset($stored_data['data'],
$stored_data['timestamp'])) {
$recommendations = $stored_data['data'];
- $last_run = (int) $stored_data['timestamp'];
+ $last_run = $stored_data['timestamp'];
+ $pages_analyzed = $stored_data['pages_analyzed'] ?? NULL;
}
else {
// Handle legacy format.
$recommendations = $stored_data;
$last_run = $this->time->getRequestTime();
}
+
+ // Migrate existing data by adding UUIDs if missing.
+ $original_recommendations = $recommendations;
+ $recommendations = $this->recommendationStorage->ensureUuids($recommendations);
+ $recommendations = $this->recommendationStorage->ensureIdeaUuids($recommendations);
+ if ($recommendations !== $original_recommendations) {
+ // UUIDs were added, save the updated data.
+ $this->keyValue->set(self::KV_KEY, [
+ 'data' => $recommendations,
+ 'timestamp' => $last_run,
+ 'pages_analyzed' => $pages_analyzed,
+ ]);
+ }
}
// Load enabled categories and build category metadata.
@@ -245,6 +313,8 @@ public function recommendations() {
'#categories' => $categories,
'#last_run' => $last_run ?
$this->dateFormatter->formatTimeDiffSince($last_run) : NULL,
+ '#pages_analyzed' => $pages_analyzed,
+ '#categories_count' => count($category_ids),
'#attached' => [
'library' => ['ai_content_strategy/content_strategy'],
],
@@ -259,32 +329,115 @@ public function recommendations() {
*/
public function generateRecommendationsAjax() {
try {
+ // Get site data for metadata.
+ $sitemap_urls = $this->contentAnalyzer->getSitemapUrls();
+ $pages_count = count($sitemap_urls['urls'] ?? []);
+
// Get recommendations.
$recommendations = $this->strategyGenerator->generateRecommendations();
- // Store the results with timestamp in key-value store.
- $timestamp = (int) $this->time->getCurrentTime();
+ // Ensure all items and ideas have UUIDs for consistent referencing.
+ $recommendations = $this->recommendationStorage->ensureUuids($recommendations);
+ $recommendations = $this->recommendationStorage->ensureIdeaUuids($recommendations);
+
+ // Store the results with timestamp and metadata in key-value store.
+ $timestamp = $this->time->getCurrentTime();
$this->keyValue->set(self::KV_KEY, [
'data' => $recommendations,
'timestamp' => $timestamp,
+ 'pages_analyzed' => $pages_count,
]);
// Create AJAX response.
$response = new AjaxResponse();
- // Re-enable the generate button.
+ // Update button text to reflect that recommendations now exist.
$response->addCommand(new HtmlCommand(
'.generate-recommendations',
- $this->t('Regenerate report')
+ $this->t('Regenerate recommendations')
+ ));
+
+ // Update data attribute to reflect existing recommendations.
+ $response->addCommand(new InvokeCommand(
+ '.generate-recommendations',
+ 'attr',
+ ['data-has-existing', 'true']
));
- // Update the last run time.
+ // Get category count for status display.
+ $category_storage = $this->entityTypeManager()->getStorage('recommendation_category');
+ $category_count = $category_storage->getQuery()
+ ->condition('status', TRUE)
+ ->accessCheck(FALSE)
+ ->count()
+ ->execute();
+
+ // Build the status area HTML.
+ $status_build = [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['content-strategy-status']],
+ 'timestamp' => [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['status-item', 'status-item--timestamp']],
+ 'label' => [
+ '#type' => 'html_tag',
+ '#tag' => 'strong',
+ '#value' => $this->t('Last generated:'),
+ ],
+ 'value' => [
+ '#markup' => ' ' . $this->dateFormatter->formatTimeDiffSince($timestamp),
+ ],
+ ],
+ 'pages' => [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['status-item', 'status-item--pages']],
+ 'label' => [
+ '#type' => 'html_tag',
+ '#tag' => 'strong',
+ '#value' => $this->t('Pages analyzed:'),
+ ],
+ 'value' => [
+ '#markup' => ' ' . $pages_count,
+ ],
+ ],
+ 'categories' => [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['status-item', 'status-item--categories']],
+ 'label' => [
+ '#type' => 'html_tag',
+ '#tag' => 'strong',
+ '#value' => $this->t('Active categories:'),
+ ],
+ 'value' => [
+ '#markup' => ' ' . $category_count,
+ ],
+ ],
+ ];
+
+ // Remove old status area if it exists and add the new one.
+ $response->addCommand(new RemoveCommand('.content-strategy-status'));
$response->addCommand(
- new HtmlCommand(
- '.last-run-time',
- $this->t('Last generated: @time ago', [
- '@time' => $this->dateFormatter->formatTimeDiffSince($timestamp),
- ])
+ new BeforeCommand(
+ '.content-strategy-actions',
+ $this->renderer->renderRoot($status_build)
+ )
+ );
+
+ // Add CSV export button if it doesn't exist (first generation).
+ // Remove any existing one first to prevent duplicates on regenerate.
+ $response->addCommand(new RemoveCommand('.export-csv-button'));
+ $export_button = [
+ '#type' => 'html_tag',
+ '#tag' => 'button',
+ '#attributes' => [
+ 'class' => ['export-csv-button', 'button', 'button--secondary'],
+ ],
+ '#value' => $this->t('Export as CSV'),
+ ];
+ $response->addCommand(
+ new AppendCommand(
+ '.content-strategy-actions',
+ $this->renderer->renderRoot($export_button)
)
);
@@ -349,7 +502,7 @@ public function generateRecommendationsAjax() {
$has_items = !empty($recommendations[$category_id]);
$button_class = $has_items ? 'button--secondary' : 'button--primary';
$button_text = $has_items
- ? ($button_texts['add_more'][$category_id] ?? $this->t('Add AI recommendations'))
+ ? ($button_texts['add_more'][$category_id] ?? $this->t('Generate AI recommendations'))
: ($button_texts['generate'][$category_id] ?? $this->t('Generate AI recommendations'));
// Add empty state message for categories with no items.
@@ -479,13 +632,13 @@ protected function getFrontPageContent(): string {
*
* @param string $section
* The section to generate ideas for (content_gaps, authority_topics, etc.).
- * @param string $title
- * The title of the specific item to generate more ideas for.
+ * @param string $uuid
+ * The UUID of the specific item to generate more ideas for.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* AJAX response containing the new content ideas.
*/
- public function generateMore(string $section, string $title) {
+ public function generateMore(string $section, string $uuid) {
try {
// Get current stored data first.
$stored_data = $this->keyValue->get(self::KV_KEY);
@@ -494,6 +647,13 @@ public function generateMore(string $section, string $title) {
}
$recommendations = $stored_data['data'];
+ // Find the card by UUID to get its title for the prompt.
+ $card = $this->recommendationStorage->getCardByUuid($section, $uuid);
+ if (!$card) {
+ throw new \RuntimeException('Card not found');
+ }
+ $title = $card['title'] ?? $card['topic'] ?? $card['content_type'] ?? $card['signal'] ?? '';
+
// Get site data for context.
$site_structure = $this->contentAnalyzer->getSiteStructure();
$sitemap_urls = $this->contentAnalyzer->getSitemapUrls();
@@ -568,81 +728,61 @@ public function generateMore(string $section, string $title) {
);
}
+ // Normalize ideas to include UUIDs.
+ $normalized_ideas = $this->recommendationStorage->normalizeIdeasWithUuids($ideas);
+
// After successfully generating and parsing new ideas, update the stored
// data.
- $updated = FALSE;
+ $card_index = NULL;
- // Find the correct section and item to update.
+ // Find the correct section and item to update by UUID.
if (isset($recommendations[$section])) {
foreach ($recommendations[$section] as $key => $item) {
- // Try common title fields to match the item.
- $item_title = $item['title'] ?? $item['topic'] ?? $item['content_type'] ?? $item['signal'] ?? NULL;
-
- if ($item_title === $title) {
+ if (isset($item['uuid']) && $item['uuid'] === $uuid) {
// Append new ideas to existing ones (or initialize if empty).
$existing_ideas = $recommendations[$section][$key]['content_ideas'] ?? [];
$recommendations[$section][$key]['content_ideas'] = array_merge(
$existing_ideas,
- $ideas
+ $normalized_ideas
);
- $updated = TRUE;
+ $card_index = $key;
break;
}
}
}
- if ($updated) {
- // Update the stored data with timestamp.
- $timestamp = (int) $this->time->getCurrentTime();
- $this->keyValue->set(self::KV_KEY, [
- 'data' => $recommendations,
- 'timestamp' => $timestamp,
- ]);
- }
- else {
+ if ($card_index === NULL) {
throw new \RuntimeException('Failed to find matching item to update');
}
- // Build HTML for new ideas.
- $rows = [];
- foreach ($ideas as $idea) {
- $rows[] = [
- '#type' => 'html_tag',
- '#tag' => 'tr',
- 'cell' => [
- '#type' => 'html_tag',
- '#tag' => 'td',
- '#value' => $idea,
- ],
- ];
- }
-
- // Create AJAX response.
- $response = new AjaxResponse();
-
- // Build the HTML for the new rows.
- $html = $this->renderer->renderRoot($rows);
+ // Update the stored data with timestamp.
+ $timestamp = $this->time->getCurrentTime();
+ $this->keyValue->set(self::KV_KEY, [
+ 'data' => $recommendations,
+ 'timestamp' => $timestamp,
+ ]);
- // Update the last run time.
- $response->addCommand(
- new HtmlCommand(
- '.last-run-time',
- $this->t('Last generated: @time ago', [
- '@time' => $this->dateFormatter->formatTimeDiffSince($timestamp),
- ])
- )
+ // Build HTML for new ideas using the idea row builder service.
+ $rows_html = $this->ideaRowBuilder->renderRows(
+ $section,
+ $uuid,
+ $normalized_ideas
);
+ // Create AJAX response and update timestamp.
+ $response = $this->ajaxResponseBuilder->create();
+ $this->ajaxResponseBuilder->addTimestampCommand($response, $timestamp);
+
// Add command to append the new rows to the table.
$response->addCommand(
new AppendCommand(
sprintf(
- '.recommendation-item[data-section="%s"][data-title="%s"]' .
+ '.recommendation-item[data-section="%s"][data-uuid="%s"]' .
' .content-ideas-table tbody',
$section,
- str_replace('"', '\"', $title)
+ $uuid
),
- $html
+ $rows_html
)
);
@@ -876,7 +1016,7 @@ public function addMoreRecommendations($section) {
$response->addCommand(
new HtmlCommand(
".recommendation-section[data-section='$section'] .add-more-recommendations-link",
- $button_texts['add_more'][$section] ?? $this->t('Add more AI recommendations')
+ $button_texts['add_more'][$section] ?? $this->t('Generate more AI recommendations')
)
);
@@ -906,14 +1046,12 @@ public function addMoreRecommendations($section) {
);
}
- // Update the last run time.
+ // Update the timestamp in the status area.
$response->addCommand(
new HtmlCommand(
- '.last-run-time',
- $this->t('Last generated: @time ago', [
- '@time' => $this->dateFormatter
- ->formatTimeDiffSince($stored_data['timestamp']),
- ])
+ '.status-item--timestamp',
+ '' . $this->t('Last generated:') . ' ' .
+ $this->dateFormatter->formatTimeDiffSince($stored_data['timestamp'])
)
);
@@ -1060,4 +1198,293 @@ protected function getHttpStatusFromException(\Exception $exception): int {
return 500;
}
+ /**
+ * Deletes a recommendation card via AJAX.
+ *
+ * @param string $section
+ * The category section (e.g., 'content_gaps').
+ * @param string $uuid
+ * The UUID of the card to delete.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * AJAX response with removal commands.
+ */
+ public function deleteCard(string $section, string $uuid) {
+ $response = new AjaxResponse();
+
+ try {
+ // Load stored data.
+ $stored_data = $this->keyValue->get(self::KV_KEY);
+
+ if (!$stored_data || !isset($stored_data['data'][$section])) {
+ throw new \RuntimeException('No data found for this section');
+ }
+
+ $recommendations = $stored_data['data'];
+
+ // Find and remove the card by UUID.
+ $found = FALSE;
+ foreach ($recommendations[$section] as $key => $card) {
+ if (isset($card['uuid']) && $card['uuid'] === $uuid) {
+ unset($recommendations[$section][$key]);
+ $found = TRUE;
+ break;
+ }
+ }
+
+ if (!$found) {
+ throw new \RuntimeException('Card not found');
+ }
+
+ // Re-index array.
+ $recommendations[$section] = array_values($recommendations[$section]);
+
+ // Save updated data.
+ $stored_data['data'] = $recommendations;
+ $this->keyValue->set(self::KV_KEY, $stored_data);
+
+ // Remove card from DOM.
+ $response->addCommand(
+ new RemoveCommand(".recommendation-item[data-section='$section'][data-uuid='$uuid']")
+ );
+
+ // If category is now empty, show empty state.
+ if (empty($recommendations[$section])) {
+ $category_storage = $this->entityTypeManager()->getStorage('recommendation_category');
+ $category = $category_storage->load($section);
+
+ if ($category) {
+ $button_texts = ai_content_strategy_get_button_texts();
+
+ $empty_state = [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['empty-category-state']],
+ 'message' => [
+ '#type' => 'html_tag',
+ '#tag' => 'p',
+ '#value' => $this->t('No recommendations yet. Click below to generate AI-powered content suggestions.'),
+ ],
+ 'button_wrapper' => [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['add-more-recommendations-wrapper']],
+ 'button' => [
+ '#type' => 'html_tag',
+ '#tag' => 'a',
+ '#attributes' => [
+ 'href' => '#',
+ 'class' => ['add-more-recommendations-link', 'button', 'button--primary'],
+ 'data-section' => $section,
+ ],
+ '#value' => $button_texts['generate'][$section] ?? $this->t('Generate AI recommendations'),
+ ],
+ ],
+ ];
+
+ $html = $this->renderer->renderRoot($empty_state);
+
+ // Remove the recommendation-items container.
+ $response->addCommand(
+ new RemoveCommand(".recommendation-section[data-section='$section'] .recommendation-items")
+ );
+
+ // Add empty state before the existing add-more button wrapper.
+ $response->addCommand(
+ new BeforeCommand(
+ ".recommendation-section[data-section='$section'] .add-more-recommendations-wrapper",
+ $html
+ )
+ );
+
+ // Remove the old add-more wrapper.
+ $response->addCommand(
+ new RemoveCommand(".recommendation-section[data-section='$section'] .add-more-recommendations-wrapper:last-child")
+ );
+ }
+ }
+
+ // Show success message.
+ $response->addCommand(
+ new MessageCommand(
+ $this->t('Recommendation deleted successfully.'),
+ NULL,
+ ['type' => 'status']
+ )
+ );
+
+ }
+ catch (\Exception $e) {
+ Error::logException($this->getLogger('ai_content_strategy'), $e);
+
+ $response->addCommand(
+ new MessageCommand(
+ $this->t('Error deleting recommendation: @error', ['@error' => $e->getMessage()]),
+ NULL,
+ ['type' => 'error']
+ )
+ );
+
+ $status_code = $this->getHttpStatusFromException($e);
+ $response->setStatusCode($status_code);
+ }
+
+ return $response;
+ }
+
+ /**
+ * Deletes an individual content idea from a recommendation card via AJAX.
+ *
+ * @param string $section
+ * The category section.
+ * @param string $uuid
+ * The UUID of the recommendation card.
+ * @param string $idea_uuid
+ * The UUID of the content idea to delete.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * The AJAX response.
+ */
+ public function deleteIdea(string $section, string $uuid, string $idea_uuid) {
+ $response = new AjaxResponse();
+
+ try {
+ // Use the storage service to delete the idea by UUID.
+ $this->recommendationStorage->deleteIdeaByUuid($section, $uuid, $idea_uuid);
+
+ // Remove the row from DOM using idea UUID selector.
+ $response->addCommand(
+ new RemoveCommand(".recommendation-item[data-section='$section'][data-uuid='$uuid'] tr[data-idea-uuid='$idea_uuid']")
+ );
+
+ // Show success message.
+ $response->addCommand(
+ new MessageCommand(
+ $this->t('Content idea deleted successfully.'),
+ NULL,
+ ['type' => 'status']
+ )
+ );
+
+ }
+ catch (\Exception $e) {
+ Error::logException($this->getLogger('ai_content_strategy'), $e);
+
+ $response->addCommand(
+ new MessageCommand(
+ $this->t('Error deleting content idea: @error', ['@error' => $e->getMessage()]),
+ NULL,
+ ['type' => 'error']
+ )
+ );
+
+ $status_code = $this->getHttpStatusFromException($e);
+ $response->setStatusCode($status_code);
+ }
+
+ return $response;
+ }
+
+ /**
+ * Saves edits to a recommendation card via AJAX.
+ *
+ * @param string $section
+ * The category section.
+ * @param string $uuid
+ * The UUID of the card.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * AJAX response confirming save.
+ */
+ public function saveCard(string $section, string $uuid) {
+ $response = new AjaxResponse();
+
+ try {
+ // Get POST data.
+ $request = $this->requestStack->getCurrentRequest();
+ $field = $request->request->get('field');
+ $value = $request->request->get('value');
+ $idea_uuid = $request->request->get('idea_uuid');
+
+ if (empty($field) || $value === NULL) {
+ throw new \InvalidArgumentException('Missing field or value');
+ }
+
+ // Update the field based on type.
+ switch ($field) {
+ case 'title':
+ $this->recommendationStorage->updateCardFieldByUuid($section, $uuid, 'title', strip_tags($value));
+ break;
+
+ case 'description':
+ $this->recommendationStorage->updateCardFieldByUuid($section, $uuid, 'description', strip_tags($value));
+ break;
+
+ case 'content_ideas':
+ if ($idea_uuid !== NULL) {
+ $this->recommendationStorage->updateIdeaFieldByUuid($section, $uuid, $idea_uuid, 'text', strip_tags($value));
+ }
+ break;
+
+ case 'implemented':
+ if ($idea_uuid !== NULL) {
+ $is_implemented = $value === '1' || $value === 'true';
+ $this->recommendationStorage->updateIdeaFieldByUuid($section, $uuid, $idea_uuid, 'implemented', $is_implemented);
+ }
+ break;
+
+ case 'link':
+ if ($idea_uuid !== NULL) {
+ $link_value = strip_tags(trim($value));
+ $this->recommendationStorage->updateIdeaFieldByUuid($section, $uuid, $idea_uuid, 'link', $link_value);
+
+ // Render the updated link area using the IdeaRowBuilder service.
+ $link_html = $this->ideaRowBuilder->renderLinkArea(
+ $section,
+ $uuid,
+ $idea_uuid,
+ $link_value
+ );
+
+ // Build selector for the link area within this specific idea row.
+ $card_selector = sprintf(
+ '.recommendation-item[data-section="%s"][data-uuid="%s"] tr[data-idea-uuid="%s"] .idea-link-area',
+ $section,
+ $uuid,
+ $idea_uuid
+ );
+
+ // Return HTML replacement command.
+ $response->addCommand(new HtmlCommand($card_selector, $link_html));
+
+ return $response;
+ }
+ break;
+
+ default:
+ throw new \InvalidArgumentException('Invalid field');
+ }
+
+ // Return success (no visual command needed, JS will handle feedback).
+ $response->addCommand(
+ new DataCommand('.save-indicator', 'saved', 'true')
+ );
+
+ }
+ catch (\Exception $e) {
+ Error::logException($this->getLogger('ai_content_strategy'), $e);
+
+ $response->addCommand(
+ new MessageCommand(
+ $this->t('Error saving: @error', ['@error' => $e->getMessage()]),
+ NULL,
+ ['type' => 'error']
+ )
+ );
+
+ $status_code = $this->getHttpStatusFromException($e);
+ $response->setStatusCode($status_code);
+ }
+
+ return $response;
+ }
+
}
diff --git a/src/Service/AjaxResponseBuilder.php b/src/Service/AjaxResponseBuilder.php
new file mode 100644
index 0000000..bc9d3ee
--- /dev/null
+++ b/src/Service/AjaxResponseBuilder.php
@@ -0,0 +1,245 @@
+dateFormatter = $date_formatter;
+ }
+
+ /**
+ * Creates a new AJAX response.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * A new AJAX response object.
+ */
+ public function create(): AjaxResponse {
+ return new AjaxResponse();
+ }
+
+ /**
+ * Creates a success response with a message.
+ *
+ * @param string $message
+ * The success message.
+ * @param \Drupal\Core\Ajax\AjaxResponse|null $response
+ * Optional existing response to add to.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * The AJAX response with message command.
+ */
+ public function createSuccessResponse(string $message, ?AjaxResponse $response = NULL): AjaxResponse {
+ $response = $response ?? new AjaxResponse();
+ $response->addCommand(new MessageCommand($message, NULL, ['type' => 'status']));
+ return $response;
+ }
+
+ /**
+ * Creates an error response with a message.
+ *
+ * @param string $message
+ * The error message.
+ * @param int $status_code
+ * The HTTP status code.
+ * @param \Drupal\Core\Ajax\AjaxResponse|null $response
+ * Optional existing response to add to.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * The AJAX response with error message.
+ */
+ public function createErrorResponse(string $message, int $status_code = 500, ?AjaxResponse $response = NULL): AjaxResponse {
+ $response = $response ?? new AjaxResponse();
+ $response->addCommand(new MessageCommand($message, NULL, ['type' => 'error']));
+ $response->setStatusCode($status_code);
+ return $response;
+ }
+
+ /**
+ * Adds an append command to insert HTML.
+ *
+ * @param \Drupal\Core\Ajax\AjaxResponse $response
+ * The response to add to.
+ * @param string $selector
+ * The CSS selector.
+ * @param string $html
+ * The HTML to append.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * The response with append command.
+ */
+ public function addAppendCommand(AjaxResponse $response, string $selector, string $html): AjaxResponse {
+ $response->addCommand(new AppendCommand($selector, $html));
+ return $response;
+ }
+
+ /**
+ * Adds an HTML replace command.
+ *
+ * @param \Drupal\Core\Ajax\AjaxResponse $response
+ * The response to add to.
+ * @param string $selector
+ * The CSS selector.
+ * @param string $html
+ * The HTML to replace with.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * The response with HTML command.
+ */
+ public function addHtmlCommand(AjaxResponse $response, string $selector, string $html): AjaxResponse {
+ $response->addCommand(new HtmlCommand($selector, $html));
+ return $response;
+ }
+
+ /**
+ * Adds a remove command.
+ *
+ * @param \Drupal\Core\Ajax\AjaxResponse $response
+ * The response to add to.
+ * @param string $selector
+ * The CSS selector of element to remove.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * The response with remove command.
+ */
+ public function addRemoveCommand(AjaxResponse $response, string $selector): AjaxResponse {
+ $response->addCommand(new RemoveCommand($selector));
+ return $response;
+ }
+
+ /**
+ * Adds an invoke command to call a jQuery method.
+ *
+ * @param \Drupal\Core\Ajax\AjaxResponse $response
+ * The response to add to.
+ * @param string $selector
+ * The CSS selector.
+ * @param string $method
+ * The jQuery method to call.
+ * @param array $arguments
+ * Arguments to pass to the method.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * The response with invoke command.
+ */
+ public function addInvokeCommand(AjaxResponse $response, string $selector, string $method, array $arguments = []): AjaxResponse {
+ $response->addCommand(new InvokeCommand($selector, $method, $arguments));
+ return $response;
+ }
+
+ /**
+ * Adds a command to update the timestamp display.
+ *
+ * @param \Drupal\Core\Ajax\AjaxResponse $response
+ * The response to add to.
+ * @param int $timestamp
+ * The timestamp to display.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * The response with timestamp update command.
+ */
+ public function addTimestampCommand(AjaxResponse $response, int $timestamp): AjaxResponse {
+ $html = '' . $this->t('Last generated:') . ' ' .
+ $this->dateFormatter->formatTimeDiffSince($timestamp);
+
+ $response->addCommand(new HtmlCommand('.status-item--timestamp', $html));
+ return $response;
+ }
+
+ /**
+ * Adds a message command.
+ *
+ * @param \Drupal\Core\Ajax\AjaxResponse $response
+ * The response to add to.
+ * @param string $message
+ * The message text.
+ * @param string $type
+ * The message type (status, warning, error).
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * The response with message command.
+ */
+ public function addMessageCommand(AjaxResponse $response, string $message, string $type = 'status'): AjaxResponse {
+ $response->addCommand(new MessageCommand($message, NULL, ['type' => $type]));
+ return $response;
+ }
+
+ /**
+ * Builds the selector for a recommendation item.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $title
+ * The card title.
+ *
+ * @return string
+ * The CSS selector.
+ */
+ public function buildCardSelector(string $section, string $title): string {
+ $escaped_title = str_replace(["'", '"'], ["\\'", '\\"'], $title);
+ return ".recommendation-item[data-section='{$section}'][data-title='{$escaped_title}']";
+ }
+
+ /**
+ * Builds the selector for an idea row.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $title
+ * The card title.
+ * @param int $idea_index
+ * The idea index.
+ *
+ * @return string
+ * The CSS selector.
+ */
+ public function buildIdeaRowSelector(string $section, string $title, int $idea_index): string {
+ return $this->buildCardSelector($section, $title) . " tr[data-idea-index='{$idea_index}']";
+ }
+
+ /**
+ * Builds the selector for a card's table body.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $title
+ * The card title.
+ *
+ * @return string
+ * The CSS selector.
+ */
+ public function buildTableBodySelector(string $section, string $title): string {
+ return $this->buildCardSelector($section, $title) . ' .content-ideas-table tbody';
+ }
+
+}
diff --git a/src/Service/IdeaRowBuilder.php b/src/Service/IdeaRowBuilder.php
new file mode 100644
index 0000000..82c51f6
--- /dev/null
+++ b/src/Service/IdeaRowBuilder.php
@@ -0,0 +1,170 @@
+renderer = $renderer;
+ }
+
+ /**
+ * Builds a render array for a single idea row.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $card_uuid
+ * The card UUID.
+ * @param array|string $idea
+ * The idea data (string or array with text/implemented/link/uuid).
+ *
+ * @return array
+ * A render array for the idea row.
+ */
+ public function buildRow(string $section, string $card_uuid, $idea): array {
+ // Normalize idea to array format.
+ if (is_string($idea)) {
+ $idea = [
+ 'text' => $idea,
+ 'implemented' => FALSE,
+ 'link' => '',
+ 'uuid' => '',
+ ];
+ }
+
+ $idea_text = $idea['text'] ?? $idea;
+ $idea_implemented = $idea['implemented'] ?? FALSE;
+ $idea_link = $idea['link'] ?? '';
+ $idea_uuid = $idea['uuid'] ?? '';
+
+ return [
+ '#theme' => 'ai_content_strategy_idea_row',
+ '#section' => $section,
+ '#uuid' => $card_uuid,
+ '#idea_uuid' => $idea_uuid,
+ '#idea_text' => $idea_text,
+ '#idea_implemented' => $idea_implemented,
+ '#idea_link' => $idea_link,
+ ];
+ }
+
+ /**
+ * Builds render arrays for multiple idea rows.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $card_uuid
+ * The card UUID.
+ * @param array $ideas
+ * Array of ideas (strings or idea objects with uuid).
+ *
+ * @return array
+ * Array of render arrays.
+ */
+ public function buildRows(string $section, string $card_uuid, array $ideas): array {
+ $rows = [];
+ foreach ($ideas as $idea) {
+ $rows[] = $this->buildRow($section, $card_uuid, $idea);
+ }
+ return $rows;
+ }
+
+ /**
+ * Renders idea rows to HTML string.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $card_uuid
+ * The card UUID.
+ * @param array $ideas
+ * Array of ideas (must have uuid property).
+ *
+ * @return string
+ * The rendered HTML.
+ */
+ public function renderRows(string $section, string $card_uuid, array $ideas): string {
+ $rows = $this->buildRows($section, $card_uuid, $ideas);
+ return (string) $this->renderer->renderRoot($rows);
+ }
+
+ /**
+ * Builds a render array for the link area.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $card_uuid
+ * The card UUID.
+ * @param string $idea_uuid
+ * The idea UUID.
+ * @param string $link
+ * The link URL (empty if no link).
+ * @param bool $implemented
+ * Whether the idea is implemented.
+ *
+ * @return array
+ * A render array for the link area.
+ */
+ public function buildLinkArea(string $section, string $card_uuid, string $idea_uuid, string $link, bool $implemented = TRUE): array {
+ if ($link) {
+ return [
+ '#theme' => 'ai_content_strategy_link_display',
+ '#link' => $link,
+ '#section' => $section,
+ '#uuid' => $card_uuid,
+ '#idea_uuid' => $idea_uuid,
+ ];
+ }
+ else {
+ return [
+ '#theme' => 'ai_content_strategy_link_add_button',
+ '#section' => $section,
+ '#uuid' => $card_uuid,
+ '#idea_uuid' => $idea_uuid,
+ ];
+ }
+ }
+
+ /**
+ * Renders link area to HTML string.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $card_uuid
+ * The card UUID.
+ * @param string $idea_uuid
+ * The idea UUID.
+ * @param string $link
+ * The link URL.
+ * @param bool $implemented
+ * Whether the idea is implemented.
+ *
+ * @return string
+ * The rendered HTML.
+ */
+ public function renderLinkArea(string $section, string $card_uuid, string $idea_uuid, string $link, bool $implemented = TRUE): string {
+ $build = $this->buildLinkArea($section, $card_uuid, $idea_uuid, $link, $implemented);
+ return (string) $this->renderer->renderRoot($build);
+ }
+
+}
diff --git a/src/Service/RecommendationStorageService.php b/src/Service/RecommendationStorageService.php
new file mode 100644
index 0000000..e38c777
--- /dev/null
+++ b/src/Service/RecommendationStorageService.php
@@ -0,0 +1,793 @@
+keyValue = $key_value_factory->get(self::KV_COLLECTION);
+ $this->time = $time;
+ $this->uuid = $uuid;
+ }
+
+ /**
+ * Gets all stored recommendation data.
+ *
+ * @return array|null
+ * The stored data with 'data', 'timestamp', and optional 'pages_analyzed',
+ * or NULL if no data exists.
+ */
+ public function getStoredData(): ?array {
+ $stored_data = $this->keyValue->get(self::KV_KEY);
+
+ if (!$stored_data) {
+ return NULL;
+ }
+
+ // Handle legacy format (direct array without metadata).
+ if (is_array($stored_data) && !isset($stored_data['data'])) {
+ return [
+ 'data' => $stored_data,
+ 'timestamp' => NULL,
+ 'pages_analyzed' => NULL,
+ ];
+ }
+
+ return $stored_data;
+ }
+
+ /**
+ * Gets recommendations data only.
+ *
+ * @return array
+ * The recommendations array, or empty array if none exist.
+ */
+ public function getRecommendations(): array {
+ $stored = $this->getStoredData();
+ return $stored['data'] ?? [];
+ }
+
+ /**
+ * Gets the last generation timestamp.
+ *
+ * @return int|null
+ * The timestamp, or NULL if never generated.
+ */
+ public function getLastRunTimestamp(): ?int {
+ $stored = $this->getStoredData();
+ return $stored['timestamp'] ?? NULL;
+ }
+
+ /**
+ * Gets the pages analyzed count.
+ *
+ * @return int|null
+ * The count, or NULL if not tracked.
+ */
+ public function getPagesAnalyzed(): ?int {
+ $stored = $this->getStoredData();
+ return $stored['pages_analyzed'] ?? NULL;
+ }
+
+ /**
+ * Gets recommendations for a specific section.
+ *
+ * @param string $section
+ * The section identifier.
+ *
+ * @return array
+ * The section's recommendations, or empty array if none.
+ */
+ public function getSection(string $section): array {
+ $recommendations = $this->getRecommendations();
+ return $recommendations[$section] ?? [];
+ }
+
+ /**
+ * Saves recommendations with metadata.
+ *
+ * @param array $recommendations
+ * The recommendations data.
+ * @param int|null $pages_analyzed
+ * Optional pages analyzed count.
+ *
+ * @return int
+ * The timestamp of the save operation.
+ */
+ public function saveRecommendations(array $recommendations, ?int $pages_analyzed = NULL): int {
+ $timestamp = $this->time->getCurrentTime();
+
+ $data = [
+ 'data' => $recommendations,
+ 'timestamp' => $timestamp,
+ ];
+
+ if ($pages_analyzed !== NULL) {
+ $data['pages_analyzed'] = $pages_analyzed;
+ }
+
+ $this->keyValue->set(self::KV_KEY, $data);
+
+ return $timestamp;
+ }
+
+ /**
+ * Finds a card by title within a section.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $title
+ * The card title to find.
+ *
+ * @return int|null
+ * The card index, or NULL if not found.
+ *
+ * @internal This method is kept for backwards compatibility.
+ * New code should use findCardIndexByUuid() instead.
+ */
+ public function findCardIndex(string $section, string $title): ?int {
+ $recommendations = $this->getRecommendations();
+
+ if (!isset($recommendations[$section])) {
+ return NULL;
+ }
+
+ foreach ($recommendations[$section] as $index => $card) {
+ $card_title = $card['title'] ?? $card['topic'] ?? $card['content_type'] ?? $card['signal'] ?? '';
+ if ($card_title === $title) {
+ return $index;
+ }
+ }
+
+ return NULL;
+ }
+
+ /**
+ * Finds a card by UUID within a section.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $uuid
+ * The card UUID to find.
+ *
+ * @return int|null
+ * The card index, or NULL if not found.
+ */
+ public function findCardIndexByUuid(string $section, string $uuid): ?int {
+ $recommendations = $this->getRecommendations();
+
+ if (!isset($recommendations[$section])) {
+ return NULL;
+ }
+
+ foreach ($recommendations[$section] as $index => $card) {
+ if (isset($card['uuid']) && $card['uuid'] === $uuid) {
+ return $index;
+ }
+ }
+
+ return NULL;
+ }
+
+ /**
+ * Gets a card by section and UUID.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $uuid
+ * The card UUID.
+ *
+ * @return array|null
+ * The card data, or NULL if not found.
+ */
+ public function getCardByUuid(string $section, string $uuid): ?array {
+ $index = $this->findCardIndexByUuid($section, $uuid);
+
+ if ($index === NULL) {
+ return NULL;
+ }
+
+ $recommendations = $this->getRecommendations();
+ return $recommendations[$section][$index] ?? NULL;
+ }
+
+ /**
+ * Gets a card by section and title.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $title
+ * The card title.
+ *
+ * @return array|null
+ * The card data, or NULL if not found.
+ */
+ public function getCard(string $section, string $title): ?array {
+ $index = $this->findCardIndex($section, $title);
+
+ if ($index === NULL) {
+ return NULL;
+ }
+
+ $recommendations = $this->getRecommendations();
+ return $recommendations[$section][$index] ?? NULL;
+ }
+
+ /**
+ * Appends ideas to a card.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $title
+ * The card title.
+ * @param array $ideas
+ * The ideas to append (strings or idea objects).
+ *
+ * @return int
+ * The starting index of the new ideas.
+ *
+ * @throws \RuntimeException
+ * If the card is not found.
+ */
+ public function appendIdeas(string $section, string $title, array $ideas): int {
+ $stored = $this->getStoredData();
+ $recommendations = $stored['data'] ?? [];
+
+ $card_index = $this->findCardIndex($section, $title);
+ if ($card_index === NULL) {
+ throw new \RuntimeException('Card not found');
+ }
+
+ $existing_ideas = $recommendations[$section][$card_index]['content_ideas'] ?? [];
+ $starting_index = count($existing_ideas);
+
+ $recommendations[$section][$card_index]['content_ideas'] = array_merge(
+ $existing_ideas,
+ $ideas
+ );
+
+ $this->saveRecommendationsPreservingMetadata($recommendations, $stored);
+
+ return $starting_index;
+ }
+
+ /**
+ * Updates a field on a card.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $title
+ * The card title.
+ * @param string $field
+ * The field name.
+ * @param mixed $value
+ * The new value.
+ *
+ * @return string|null
+ * The new title if title was changed, NULL otherwise.
+ *
+ * @throws \RuntimeException
+ * If the card is not found.
+ */
+ public function updateCardField(string $section, string $title, string $field, $value): ?string {
+ $stored = $this->getStoredData();
+ $recommendations = $stored['data'] ?? [];
+
+ $card_index = $this->findCardIndex($section, $title);
+ if ($card_index === NULL) {
+ throw new \RuntimeException('Card not found');
+ }
+
+ $recommendations[$section][$card_index][$field] = $value;
+
+ $this->saveRecommendationsPreservingMetadata($recommendations, $stored);
+
+ // Return new title if title field was updated.
+ return $field === 'title' ? $value : NULL;
+ }
+
+ /**
+ * Updates a field on an idea within a card.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $title
+ * The card title.
+ * @param int $idea_index
+ * The idea index.
+ * @param string $field
+ * The field name ('text', 'implemented', 'link').
+ * @param mixed $value
+ * The new value.
+ *
+ * @throws \RuntimeException
+ * If the card or idea is not found.
+ */
+ public function updateIdeaField(string $section, string $title, int $idea_index, string $field, $value): void {
+ $stored = $this->getStoredData();
+ $recommendations = $stored['data'] ?? [];
+
+ $card_index = $this->findCardIndex($section, $title);
+ if ($card_index === NULL) {
+ throw new \RuntimeException('Card not found');
+ }
+
+ if (!isset($recommendations[$section][$card_index]['content_ideas'][$idea_index])) {
+ throw new \RuntimeException('Idea not found');
+ }
+
+ $idea = &$recommendations[$section][$card_index]['content_ideas'][$idea_index];
+
+ // Convert string idea to object format if needed.
+ if (is_string($idea)) {
+ $idea = [
+ 'text' => $idea,
+ 'implemented' => FALSE,
+ 'link' => '',
+ ];
+ }
+
+ $idea[$field] = $value;
+
+ $this->saveRecommendationsPreservingMetadata($recommendations, $stored);
+ }
+
+ /**
+ * Deletes a card.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $title
+ * The card title.
+ *
+ * @throws \RuntimeException
+ * If the card is not found.
+ */
+ public function deleteCard(string $section, string $title): void {
+ $stored = $this->getStoredData();
+ $recommendations = $stored['data'] ?? [];
+
+ $card_index = $this->findCardIndex($section, $title);
+ if ($card_index === NULL) {
+ throw new \RuntimeException('Card not found');
+ }
+
+ unset($recommendations[$section][$card_index]);
+ $recommendations[$section] = array_values($recommendations[$section]);
+
+ $this->saveRecommendationsPreservingMetadata($recommendations, $stored);
+ }
+
+ /**
+ * Deletes an idea from a card.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $title
+ * The card title.
+ * @param int $idea_index
+ * The idea index.
+ *
+ * @throws \RuntimeException
+ * If the card or idea is not found.
+ */
+ public function deleteIdea(string $section, string $title, int $idea_index): void {
+ $stored = $this->getStoredData();
+ $recommendations = $stored['data'] ?? [];
+
+ $card_index = $this->findCardIndex($section, $title);
+ if ($card_index === NULL) {
+ throw new \RuntimeException('Card not found');
+ }
+
+ if (!isset($recommendations[$section][$card_index]['content_ideas'][$idea_index])) {
+ throw new \RuntimeException('Idea not found');
+ }
+
+ unset($recommendations[$section][$card_index]['content_ideas'][$idea_index]);
+ $recommendations[$section][$card_index]['content_ideas'] = array_values(
+ $recommendations[$section][$card_index]['content_ideas']
+ );
+
+ $this->saveRecommendationsPreservingMetadata($recommendations, $stored);
+ }
+
+ /**
+ * Adds recommendations to a section.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param array $new_recommendations
+ * The recommendations to add.
+ */
+ public function addToSection(string $section, array $new_recommendations): void {
+ $stored = $this->getStoredData();
+ $recommendations = $stored['data'] ?? [];
+
+ $existing = $recommendations[$section] ?? [];
+ $recommendations[$section] = array_merge($existing, $new_recommendations);
+
+ $this->saveRecommendationsPreservingMetadata($recommendations, $stored);
+ }
+
+ /**
+ * Saves recommendations while preserving existing metadata.
+ *
+ * @param array $recommendations
+ * The recommendations data.
+ * @param array|null $stored
+ * The existing stored data with metadata.
+ */
+ protected function saveRecommendationsPreservingMetadata(array $recommendations, ?array $stored): void {
+ $timestamp = $this->time->getCurrentTime();
+
+ $data = [
+ 'data' => $recommendations,
+ 'timestamp' => $timestamp,
+ ];
+
+ if (isset($stored['pages_analyzed'])) {
+ $data['pages_analyzed'] = $stored['pages_analyzed'];
+ }
+
+ $this->keyValue->set(self::KV_KEY, $data);
+ }
+
+ /**
+ * Ensures all items in the recommendations have UUIDs.
+ *
+ * This migrates existing data that doesn't have UUIDs.
+ *
+ * @param array $recommendations
+ * The recommendations array to process.
+ *
+ * @return array
+ * The recommendations with UUIDs added to any items missing them.
+ */
+ public function ensureUuids(array $recommendations): array {
+ foreach ($recommendations as &$items) {
+ if (!is_array($items)) {
+ continue;
+ }
+ foreach ($items as &$item) {
+ if (is_array($item) && !isset($item['uuid'])) {
+ $item['uuid'] = $this->uuid->generate();
+ }
+ }
+ }
+
+ return $recommendations;
+ }
+
+ /**
+ * Ensures all ideas within cards have UUIDs.
+ *
+ * This migrates existing ideas that don't have UUIDs.
+ *
+ * @param array $recommendations
+ * The recommendations array to process.
+ *
+ * @return array
+ * The recommendations with UUIDs added to any ideas missing them.
+ */
+ public function ensureIdeaUuids(array $recommendations): array {
+ foreach ($recommendations as &$items) {
+ if (!is_array($items)) {
+ continue;
+ }
+ foreach ($items as &$item) {
+ if (!is_array($item) || !isset($item['content_ideas'])) {
+ continue;
+ }
+ foreach ($item['content_ideas'] as &$idea) {
+ // Convert string ideas to object format.
+ if (is_string($idea)) {
+ $idea = [
+ 'text' => $idea,
+ 'implemented' => FALSE,
+ 'link' => '',
+ 'uuid' => $this->uuid->generate(),
+ ];
+ }
+ elseif (is_array($idea) && !isset($idea['uuid'])) {
+ $idea['uuid'] = $this->uuid->generate();
+ }
+ }
+ }
+ }
+
+ return $recommendations;
+ }
+
+ /**
+ * Converts idea strings to objects with UUIDs.
+ *
+ * Used when adding new ideas from AI to ensure they have UUIDs.
+ *
+ * @param array $ideas
+ * Array of ideas (strings or objects).
+ *
+ * @return array
+ * Array of idea objects with UUIDs.
+ */
+ public function normalizeIdeasWithUuids(array $ideas): array {
+ $normalized = [];
+ foreach ($ideas as $idea) {
+ if (is_string($idea)) {
+ $normalized[] = [
+ 'text' => $idea,
+ 'implemented' => FALSE,
+ 'link' => '',
+ 'uuid' => $this->uuid->generate(),
+ ];
+ }
+ elseif (is_array($idea)) {
+ if (!isset($idea['uuid'])) {
+ $idea['uuid'] = $this->uuid->generate();
+ }
+ $normalized[] = $idea;
+ }
+ }
+ return $normalized;
+ }
+
+ /**
+ * Finds an idea by UUID within a card.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $card_uuid
+ * The card UUID.
+ * @param string $idea_uuid
+ * The idea UUID to find.
+ *
+ * @return int|null
+ * The idea index, or NULL if not found.
+ */
+ public function findIdeaIndexByUuid(string $section, string $card_uuid, string $idea_uuid): ?int {
+ $card = $this->getCardByUuid($section, $card_uuid);
+ if (!$card || !isset($card['content_ideas'])) {
+ return NULL;
+ }
+
+ foreach ($card['content_ideas'] as $index => $idea) {
+ if (is_array($idea) && isset($idea['uuid']) && $idea['uuid'] === $idea_uuid) {
+ return $index;
+ }
+ }
+
+ return NULL;
+ }
+
+ /**
+ * Deletes a card by UUID.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $uuid
+ * The card UUID.
+ *
+ * @throws \RuntimeException
+ * If the card is not found.
+ */
+ public function deleteCardByUuid(string $section, string $uuid): void {
+ $stored = $this->getStoredData();
+ $recommendations = $stored['data'] ?? [];
+
+ $card_index = $this->findCardIndexByUuid($section, $uuid);
+ if ($card_index === NULL) {
+ throw new \RuntimeException('Card not found');
+ }
+
+ unset($recommendations[$section][$card_index]);
+ $recommendations[$section] = array_values($recommendations[$section]);
+
+ $this->saveRecommendationsPreservingMetadata($recommendations, $stored);
+ }
+
+ /**
+ * Deletes an idea from a card by UUID.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $card_uuid
+ * The card UUID.
+ * @param string $idea_uuid
+ * The idea UUID.
+ *
+ * @throws \RuntimeException
+ * If the card or idea is not found.
+ */
+ public function deleteIdeaByUuid(string $section, string $card_uuid, string $idea_uuid): void {
+ $stored = $this->getStoredData();
+ $recommendations = $stored['data'] ?? [];
+
+ $card_index = $this->findCardIndexByUuid($section, $card_uuid);
+ if ($card_index === NULL) {
+ throw new \RuntimeException('Card not found');
+ }
+
+ $idea_index = $this->findIdeaIndexByUuid($section, $card_uuid, $idea_uuid);
+ if ($idea_index === NULL) {
+ throw new \RuntimeException('Idea not found');
+ }
+
+ unset($recommendations[$section][$card_index]['content_ideas'][$idea_index]);
+ $recommendations[$section][$card_index]['content_ideas'] = array_values(
+ $recommendations[$section][$card_index]['content_ideas']
+ );
+
+ $this->saveRecommendationsPreservingMetadata($recommendations, $stored);
+ }
+
+ /**
+ * Updates a field on a card by UUID.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $uuid
+ * The card UUID.
+ * @param string $field
+ * The field name.
+ * @param mixed $value
+ * The new value.
+ *
+ * @throws \RuntimeException
+ * If the card is not found.
+ */
+ public function updateCardFieldByUuid(string $section, string $uuid, string $field, $value): void {
+ $stored = $this->getStoredData();
+ $recommendations = $stored['data'] ?? [];
+
+ $card_index = $this->findCardIndexByUuid($section, $uuid);
+ if ($card_index === NULL) {
+ throw new \RuntimeException('Card not found');
+ }
+
+ $recommendations[$section][$card_index][$field] = $value;
+
+ $this->saveRecommendationsPreservingMetadata($recommendations, $stored);
+ }
+
+ /**
+ * Updates a field on an idea within a card by UUID.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $card_uuid
+ * The card UUID.
+ * @param string $idea_uuid
+ * The idea UUID.
+ * @param string $field
+ * The field name ('text', 'implemented', 'link').
+ * @param mixed $value
+ * The new value.
+ *
+ * @throws \RuntimeException
+ * If the card or idea is not found.
+ */
+ public function updateIdeaFieldByUuid(string $section, string $card_uuid, string $idea_uuid, string $field, $value): void {
+ $stored = $this->getStoredData();
+ $recommendations = $stored['data'] ?? [];
+
+ $card_index = $this->findCardIndexByUuid($section, $card_uuid);
+ if ($card_index === NULL) {
+ throw new \RuntimeException('Card not found');
+ }
+
+ $idea_index = $this->findIdeaIndexByUuid($section, $card_uuid, $idea_uuid);
+ if ($idea_index === NULL) {
+ throw new \RuntimeException('Idea not found');
+ }
+
+ $idea = &$recommendations[$section][$card_index]['content_ideas'][$idea_index];
+
+ // Convert string idea to object format if needed.
+ if (is_string($idea)) {
+ $idea = [
+ 'text' => $idea,
+ 'implemented' => FALSE,
+ 'link' => '',
+ 'uuid' => $idea_uuid,
+ ];
+ }
+
+ $idea[$field] = $value;
+
+ $this->saveRecommendationsPreservingMetadata($recommendations, $stored);
+ }
+
+ /**
+ * Appends ideas to a card by UUID.
+ *
+ * @param string $section
+ * The section identifier.
+ * @param string $uuid
+ * The card UUID.
+ * @param array $ideas
+ * The ideas to append (strings or idea objects).
+ *
+ * @return int
+ * The starting index of the new ideas.
+ *
+ * @throws \RuntimeException
+ * If the card is not found.
+ */
+ public function appendIdeasByUuid(string $section, string $uuid, array $ideas): int {
+ $stored = $this->getStoredData();
+ $recommendations = $stored['data'] ?? [];
+
+ $card_index = $this->findCardIndexByUuid($section, $uuid);
+ if ($card_index === NULL) {
+ throw new \RuntimeException('Card not found');
+ }
+
+ $existing_ideas = $recommendations[$section][$card_index]['content_ideas'] ?? [];
+ $starting_index = count($existing_ideas);
+
+ $recommendations[$section][$card_index]['content_ideas'] = array_merge(
+ $existing_ideas,
+ $ideas
+ );
+
+ $this->saveRecommendationsPreservingMetadata($recommendations, $stored);
+
+ return $starting_index;
+ }
+
+}
diff --git a/templates/ai-content-strategy-recommendations-items.html.twig b/templates/ai-content-strategy-recommendations-items.html.twig
index 99f6f87..b3e1df0 100644
--- a/templates/ai-content-strategy-recommendations-items.html.twig
+++ b/templates/ai-content-strategy-recommendations-items.html.twig
@@ -21,16 +21,22 @@
{% elseif item_title is iterable %}
{% set item_title = item_title|first %}
{% endif %}
+ {% set item_uuid = item.uuid|default('') %}
-
+
-
{{ item_title }}
+
+
{{ item_title }}
+
+
{# Try to find description - try common fields in order #}
{% set description = item.description|default(item.rationale|default(item.implementation|default(null))) %}
{% if description %}
-
{{ description is iterable and description['#markup'] is defined ? description['#markup'] : description }}
+
{{ description is iterable and description['#markup'] is defined ? description['#markup'] : description }}
{{ idea is iterable and idea['#markup'] is defined ? idea['#markup'] : idea }}
-
+ {# Support both string ideas (legacy) and object ideas with implemented status #}
+ {% set idea_text = idea.text is defined ? idea.text : idea %}
+ {% set idea_implemented = idea.implemented is defined ? idea.implemented : false %}
+ {% set idea_link = idea.link is defined ? idea.link : '' %}
+ {% set idea_uuid = idea.uuid is defined ? idea.uuid : '' %}
+ {% include '@ai_content_strategy/components/idea-row.html.twig' with {
+ 'section': section,
+ 'uuid': item_uuid,
+ 'idea_uuid': idea_uuid,
+ 'idea_text': idea_text is iterable and idea_text['#markup'] is defined ? idea_text['#markup'] : idea_text,
+ 'idea_implemented': idea_implemented,
+ 'idea_link': idea_link,
+ } only %}
{% endfor %}
@@ -54,7 +71,7 @@
-{% endfor %}
\ No newline at end of file
+{% endfor %}
diff --git a/templates/ai-content-strategy-recommendations.html.twig b/templates/ai-content-strategy-recommendations.html.twig
index f64eaee..6bdde5c 100644
--- a/templates/ai-content-strategy-recommendations.html.twig
+++ b/templates/ai-content-strategy-recommendations.html.twig
@@ -4,12 +4,12 @@
* Default theme implementation for content strategy recommendations.
*
* Available variables:
- * - content_gaps: Array of content gap recommendations.
- * - authority_topics: Array of authority topic recommendations.
- * - expertise_demonstrations: Array of expertise demonstration recommendations.
- * - trust_signals: Array of trust signal recommendations.
+ * - categories: Array of recommendation categories with their items.
* - button_text: Button text configuration.
* - last_run: Last generation time as a formatted string.
+ * - pages_analyzed: Number of pages analyzed from the sitemap.
+ * - categories_count: Number of active recommendation categories.
+ * - configure_link: Link to configure categories (optional).
*
* @ingroup themeable
*/
@@ -25,18 +25,36 @@
{{ 'AI-powered content strategy recommendations based on your site structure.'|t }}