Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
CLAUDE.md

.specstory
.idea
deploy.sh
Expand Down
75 changes: 75 additions & 0 deletions content_intel.install
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

/**
* @file
* Install, update, and uninstall functions for the Content Intel module.
*/

declare(strict_types=1);

/**
* Implements hook_schema().
*/
function content_intel_schema(): array {
$schema['content_intel_search_log'] = [
'description' => 'Stores search query logs for content intelligence.',
'fields' => [
'id' => [
'description' => 'Primary key.',
'type' => 'serial',
'unsigned' => TRUE,
'not null' => TRUE,
],
'keywords' => [
'description' => 'The search keywords.',
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
],
'results_count' => [
'description' => 'Number of results returned.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
],
'index_id' => [
'description' => 'Optional search index identifier.',
'type' => 'varchar',
'length' => 128,
'not null' => FALSE,
],
'timestamp' => [
'description' => 'Unix timestamp of the search.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
],
],
'primary key' => ['id'],
'indexes' => [
'keywords' => ['keywords'],
'results_count' => ['results_count'],
'timestamp' => ['timestamp'],
'keywords_results' => ['keywords', 'results_count'],
],
];

return $schema;
}

/**
* Install the search log table.
*/
function content_intel_update_10001(): void {
$schema = content_intel_schema();
$database = \Drupal::database();

if (!$database->schema()->tableExists('content_intel_search_log')) {
$database->schema()->createTable(
'content_intel_search_log',
$schema['content_intel_search_log']
);
}
}
8 changes: 8 additions & 0 deletions content_intel.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,11 @@ services:
- '@renderer'
- '@entity_type.bundle.info'
- '@file_url_generator'

content_intel.search_query_collector:
class: Drupal\content_intel\Service\SearchQueryCollector
arguments:
- '@database'
- '@module_handler'
- '@date.formatter'
- '@datetime.time'
13 changes: 13 additions & 0 deletions scripts/run-drupal-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ parameters:
- web/modules/contrib/content_intel
# Set the analysis level (0-9)
level: 5
treatPhpDocTypesAsCertain: false
ignoreErrors:
# Ignore method_exists checks (Drupal pattern for optional features)
- '#Call to function method_exists\(\) .* will always evaluate to true#'
# Ignore new static() in plugin base class (Drupal pattern)
- '#Unsafe usage of new static\(\)#'
# Ignore boolean narrowing warnings
- '#Left side of && is always true#'
# Ignore nullsafe on non-nullable (defensive coding)
- '#Using nullsafe method call on non-nullable type#'
EOF

mkdir -p web/modules/contrib/
Expand All @@ -34,6 +44,9 @@ if [ ! -L "web/modules/contrib/content_intel" ]; then
ln -s /src web/modules/contrib/content_intel
fi

# Install the statistics module (removed from core in D11).
composer require drupal/statistics --no-interaction

# Install PHPStan extensions for Drupal 11 and Drush for command analysis
composer require --dev phpstan/phpstan mglaman/phpstan-drupal phpstan/phpstan-deprecation-rules drush/drush --with-all-dependencies --no-interaction

Expand Down
109 changes: 97 additions & 12 deletions src/Drush/Commands/ContentIntelCommands.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Consolidation\OutputFormatters\StructuredData\RowsOfFields;
use Drupal\content_intel\Service\ContentIntelCollector;
use Drupal\content_intel\Service\SearchQueryCollectorInterface;
use Drush\Attributes as CLI;
use Drush\Commands\DrushCommands;
use Symfony\Component\DependencyInjection\ContainerInterface;
Expand All @@ -22,9 +23,12 @@ final class ContentIntelCommands extends DrushCommands {
*
* @param \Drupal\content_intel\Service\ContentIntelCollector $collector
* The content intel collector service.
* @param \Drupal\content_intel\Service\SearchQueryCollectorInterface $searchQueryCollector
* The search query collector service.
*/
public function __construct(
protected ContentIntelCollector $collector,
protected SearchQueryCollectorInterface $searchQueryCollector,
) {
parent::__construct();
}
Expand All @@ -34,7 +38,8 @@ public function __construct(
*/
public static function create(ContainerInterface $container): self {
return new static(
$container->get('content_intel.collector')
$container->get('content_intel.collector'),
$container->get('content_intel.search_query_collector')
);
}

Expand Down Expand Up @@ -304,31 +309,111 @@ public function batch(
$results = [];

if ($options['ids']) {
// Use bulk loading for better performance.
$ids = array_map('trim', explode(',', $options['ids']));
foreach ($ids as $id) {
$entity = $this->collector->loadEntity($entity_type, $id);
if ($entity) {
$results[] = $this->collector->collectIntel($entity, [], $plugins);
}
$entities = $this->collector->loadEntities($entity_type, $ids);
foreach ($entities as $entity) {
$results[] = $this->collector->collectIntel($entity, [], $plugins);
}
}
else {
$bundle = $options['bundle'] ?: NULL;
$entities = $this->collector->listEntities(
$entity_summaries = $this->collector->listEntities(
$entity_type,
$bundle,
(int) $options['limit']
);

foreach ($entities as $entity_summary) {
$entity = $this->collector->loadEntity($entity_type, $entity_summary['id']);
if ($entity) {
$results[] = $this->collector->collectIntel($entity, [], $plugins);
}
// Extract IDs and use bulk loading for better performance.
$ids = array_column($entity_summaries, 'id');
$entities = $this->collector->loadEntities($entity_type, $ids);
foreach ($entities as $entity) {
$results[] = $this->collector->collectIntel($entity, [], $plugins);
}
}

return $results;
}

/**
* List top search queries.
*
* @param array $options
* Command options.
*
* @return \Consolidation\OutputFormatters\StructuredData\RowsOfFields|array
* Search query data.
*/
#[CLI\Command(name: 'ci:searches', aliases: ['cisrc'])]
#[CLI\Option(name: 'limit', description: 'Maximum queries to return (default: 50)')]
#[CLI\Option(name: 'gaps', description: 'Show only content gaps (zero/low result searches)')]
#[CLI\Option(name: 'max-results', description: 'Max results threshold for gaps (default: 0)')]
#[CLI\Option(name: 'format', description: 'Output format: table, json, yaml (default: table)')]
#[CLI\FieldLabels(labels: [
'query' => 'Query',
'count' => 'Count',
'results_count' => 'Results',
'last_searched' => 'Last Searched',
])]
#[CLI\DefaultFields(fields: ['query', 'count', 'results_count'])]
#[CLI\Usage(name: 'drush ci:searches', description: 'List top search queries')]
#[CLI\Usage(name: 'drush ci:searches --gaps', description: 'Show searches with no results (content gaps)')]
#[CLI\Usage(name: 'drush ci:searches --limit=20 --format=json', description: 'Get top 20 queries as JSON')]
public function searches(
array $options = [
'limit' => 50,
'gaps' => FALSE,
'max-results' => 0,
'format' => 'table',
],
): RowsOfFields|array {
if (!$this->searchQueryCollector->isAvailable()) {
$this->logger()->warning('No search query data source available. Run database updates to create the logging table, or install Search API with logging.');
return new RowsOfFields([]);
}

$limit = (int) $options['limit'];

if ($options['gaps']) {
$queries = $this->searchQueryCollector->getContentGaps($limit, (int) $options['max-results']);
}
else {
$queries = $this->searchQueryCollector->getTopQueries($limit);
}

// Flatten last_searched for table display.
$rows = array_map(function ($query) {
return [
'query' => $query['query'],
'count' => $query['count'],
'results_count' => $query['results_count'] ?? 'N/A',
'last_searched' => $query['last_searched']['human'] ?? 'N/A',
];
}, $queries);

if ($options['format'] === 'json' || $options['format'] === 'yaml') {
return $queries;
}

return new RowsOfFields($rows);
}

/**
* Show search query data source status.
*
* @return array
* Status information.
*/
#[CLI\Command(name: 'ci:search-status', aliases: ['ciss'])]
#[CLI\Usage(name: 'drush ci:search-status', description: 'Check search query logging status')]
public function searchStatus(): array {
return [
'available' => $this->searchQueryCollector->isAvailable(),
'source' => $this->searchQueryCollector->getSource(),
'message' => $this->searchQueryCollector->isAvailable()
? 'Search query logging is active.'
: 'No search query data source found. Run "drush updb" to create the logging table.',
];
}

}
16 changes: 15 additions & 1 deletion src/Plugin/ContentIntel/ContentTranslationPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
Expand Down Expand Up @@ -37,6 +38,13 @@ class ContentTranslationPlugin extends ContentIntelPluginBase {
*/
protected ?LanguageManagerInterface $languageManager = NULL;

/**
* The logger.
*
* @var \Psr\Log\LoggerInterface
*/
protected LoggerInterface $logger;

/**
* {@inheritdoc}
*/
Expand All @@ -53,6 +61,7 @@ public static function create(
}

$instance->languageManager = $container->get('language_manager');
$instance->logger = $container->get('logger.factory')->get('content_intel');

return $instance;
}
Expand Down Expand Up @@ -143,7 +152,12 @@ public function collect(ContentEntityInterface $entity): array {
}
}
catch (\Exception $e) {
// Metadata not available.
// Log the error but continue gracefully.
$this->logger->warning(
'Failed to get translation metadata for @langcode: @message',
['@langcode' => $langcode, '@message' => $e->getMessage()]
);
$detail['metadata_error'] = $e->getMessage();
}
}

Expand Down
11 changes: 10 additions & 1 deletion src/Plugin/ContentIntel/StatisticsPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Drupal\content_intel\Attribute\ContentIntel;
use Drupal\content_intel\ContentIntelPluginBase;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\node\NodeInterface;
Expand All @@ -31,6 +32,13 @@ class StatisticsPlugin extends ContentIntelPluginBase {
*/
protected ?StatisticsStorageInterface $statisticsStorage = NULL;

/**
* The date formatter.
*
* @var \Drupal\Core\Datetime\DateFormatterInterface
*/
protected DateFormatterInterface $dateFormatter;

/**
* {@inheritdoc}
*/
Expand All @@ -45,6 +53,7 @@ public static function create(
if ($container->has('statistics.storage.node')) {
$instance->statisticsStorage = $container->get('statistics.storage.node');
}
$instance->dateFormatter = $container->get('date.formatter');

return $instance;
}
Expand Down Expand Up @@ -90,7 +99,7 @@ public function collect(ContentEntityInterface $entity): array {
'last_view' => $timestamp ? [
'timestamp' => $timestamp,
'iso8601' => date('c', $timestamp),
'human' => \Drupal::service('date.formatter')->format($timestamp, 'medium'),
'human' => $this->dateFormatter->format($timestamp, 'medium'),
] : NULL,
];
}
Expand Down
Loading