ckeditor_taxonomy_glossary-1.0.0-alpha1/src/Controller/GlossaryAutocompleteController.php
src/Controller/GlossaryAutocompleteController.php
<?php
declare(strict_types=1);
namespace Drupal\ckeditor_taxonomy_glossary\Controller;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\taxonomy\TermInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Render\RendererInterface;
/**
* Controller for glossary autocomplete and term description endpoints.
*/
final class GlossaryAutocompleteController extends ControllerBase {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* The form builder.
*
* @var \Drupal\Core\Form\FormBuilderInterface
*/
protected $formBuilder;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* Constructs a new GlossaryAutocompleteController object.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
* @param \Drupal\Core\Form\FormBuilderInterface $form_builder
* The form builder.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, LanguageManagerInterface $language_manager, FormBuilderInterface $form_builder, RendererInterface $renderer, AccountInterface $current_user) {
$this->entityTypeManager = $entity_type_manager;
$this->languageManager = $language_manager;
$this->formBuilder = $form_builder;
$this->renderer = $renderer;
$this->currentUser = $current_user;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('language_manager'),
$container->get('form_builder'),
$container->get('renderer'),
$container->get('current_user')
);
}
/**
* Returns autocomplete suggestions for glossary terms.
*
* @param string $text
* The text to search for.
*
* @return \Drupal\Core\Cache\CacheableJsonResponse
* A JSON response with matching terms.
*/
public function autocomplete(string $text): CacheableJsonResponse {
$matches = [];
// Sanitize input text
$text = trim($text);
// Only search if text is long enough and not too long
if (strlen($text) >= 2 && strlen($text) <= 100) {
/** @var \Drupal\taxonomy\TermStorageInterface $term_storage */
$term_storage = $this->entityTypeManager()->getStorage('taxonomy_term');
try {
$query = $term_storage->getQuery()
->accessCheck(TRUE)
->condition('vid', 'glossary')
->condition('name', $text, 'CONTAINS')
->range(0, 20)
->sort('name');
// Include all languages for multilingual support
// Users can see language indicators in the autocomplete dropdown
$tids = $query->execute();
if (!empty($tids)) {
/** @var \Drupal\taxonomy\TermInterface[] $terms */
$terms = $term_storage->loadMultiple($tids);
foreach ($terms as $term) {
if (!$term instanceof TermInterface) {
continue;
}
$description = '';
if ($term->hasField('description') && !$term->get('description')->isEmpty()) {
$description = strip_tags($term->get('description')->value);
$description = mb_substr($description, 0, 100) . (mb_strlen($description) > 100 ? '...' : '');
}
$matches[] = [
'id' => $term->id(),
'label' => $term->getName(),
'description' => $description,
'langcode' => $term->language()->getId(),
'language_name' => $term->language()->getName(),
];
}
}
} catch (\Exception $e) {
// Log the error but don't expose it to the user
\Drupal::logger('ckeditor_taxonomy_glossary')->error('Autocomplete search failed: @message', ['@message' => $e->getMessage()]);
}
}
return $this->createCachedAutocompleteResponse($matches);
}
/**
* Creates a cached autocomplete response with proper cache metadata.
*
* @param array $matches
* The autocomplete matches array.
*
* @return \Drupal\Core\Cache\CacheableJsonResponse
* A cached JSON response.
*/
private function createCachedAutocompleteResponse(array $matches): CacheableJsonResponse {
$response = new CacheableJsonResponse($matches);
$cache_metadata = new CacheableMetadata();
$cache_metadata->addCacheTags(['taxonomy_term_list:glossary']);
$cache_metadata->addCacheContexts(['url.path']);
$response->addCacheableDependency($cache_metadata);
// Apply cache duration setting from configuration
$config = $this->config('ckeditor_taxonomy_glossary.settings');
$cache_duration = $config->get('cache_duration') ?? '3600';
if ($cache_duration === '0') {
// No cache - set headers to prevent caching
$response->setMaxAge(0);
$response->setSharedMaxAge(0);
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', '0');
} else {
// Set cache duration
$cache_seconds = (int) $cache_duration;
$response->setMaxAge($cache_seconds);
$response->setSharedMaxAge($cache_seconds);
}
return $response;
}
/**
* Returns the description for a specific glossary term.
*
* @param int $tid
* The term ID.
*
* @return \Drupal\Core\Cache\CacheableJsonResponse
* A JSON response with the term description.
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
* If the term is not found or not in the glossary vocabulary.
*/
public function termDescription(int $tid): CacheableJsonResponse {
/** @var \Drupal\taxonomy\TermStorageInterface $term_storage */
$term_storage = $this->entityTypeManager()->getStorage('taxonomy_term');
/** @var \Drupal\taxonomy\TermInterface|null $term */
$term = $term_storage->load($tid);
if (!$term instanceof TermInterface || $term->bundle() !== 'glossary') {
throw new NotFoundHttpException();
}
// Use translated version if available
$current_language = $this->languageManager()->getCurrentLanguage()->getId();
if ($term->hasTranslation($current_language)) {
$term = $term->getTranslation($current_language);
}
$description = '';
if ($term->hasField('description') && !$term->get('description')->isEmpty()) {
$description = $term->get('description')->processed;
}
$data = [
'id' => $term->id(),
'name' => $term->getName(),
'description' => $description,
'langcode' => $term->language()->getId(),
'language_name' => $term->language()->getName(),
];
$response = new CacheableJsonResponse($data);
$response->addCacheableDependency($term);
// Apply cache duration setting from configuration
$config = $this->config('ckeditor_taxonomy_glossary.settings');
$cache_duration = $config->get('cache_duration') ?? '3600';
if ($cache_duration === '0') {
// No cache - set headers to prevent caching
$response->setMaxAge(0);
$response->setSharedMaxAge(0);
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', '0');
} else {
// Set cache duration
$cache_seconds = (int) $cache_duration;
$response->setMaxAge($cache_seconds);
$response->setSharedMaxAge($cache_seconds);
}
return $response;
}
/**
* Returns basic term information for a specific glossary term.
*
* @param int $tid
* The term ID.
*
* @return \Drupal\Core\Cache\CacheableJsonResponse
* A JSON response with basic term information.
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
* If the term is not found or not in the glossary vocabulary.
*/
public function termInfo(int $tid): CacheableJsonResponse {
/** @var \Drupal\taxonomy\TermStorageInterface $term_storage */
$term_storage = $this->entityTypeManager()->getStorage('taxonomy_term');
/** @var \Drupal\taxonomy\TermInterface|null $term */
$term = $term_storage->load($tid);
if (!$term instanceof TermInterface || $term->bundle() !== 'glossary') {
throw new NotFoundHttpException();
}
// Use translated version if available
$current_language = $this->languageManager()->getCurrentLanguage()->getId();
if ($term->hasTranslation($current_language)) {
$term = $term->getTranslation($current_language);
}
$data = [
'id' => $term->id(),
'name' => $term->getName(),
'langcode' => $term->language()->getId(),
'language_name' => $term->language()->getName(),
];
$response = new CacheableJsonResponse($data);
$response->addCacheableDependency($term);
// Apply cache duration setting from configuration
$config = $this->config('ckeditor_taxonomy_glossary.settings');
$cache_duration = $config->get('cache_duration') ?? '3600';
if ($cache_duration === '0') {
// No cache - set headers to prevent caching
$response->setMaxAge(0);
$response->setSharedMaxAge(0);
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', '0');
} else {
// Set cache duration
$cache_seconds = (int) $cache_duration;
$response->setMaxAge($cache_seconds);
$response->setSharedMaxAge($cache_seconds);
}
return $response;
}
/**
* Returns autocomplete suggestions for glossary terms in a specific language.
*
* @param string $text
* The text to search for.
* @param string $langcode
* The language code to filter by.
*
* @return \Drupal\Core\Cache\CacheableJsonResponse
* A JSON response with matching terms.
*/
public function autocompleteByLanguage(string $text, string $langcode): CacheableJsonResponse {
$matches = [];
// Only search if text is long enough
if (strlen($text) >= 2) {
/** @var \Drupal\taxonomy\TermStorageInterface $term_storage */
$term_storage = $this->entityTypeManager()->getStorage('taxonomy_term');
$query = $term_storage->getQuery()
->accessCheck(TRUE)
->condition('vid', 'glossary')
->condition('name', $text, 'CONTAINS')
->condition('langcode', $langcode)
->range(0, 10)
->sort('name');
$tids = $query->execute();
if (!empty($tids)) {
/** @var \Drupal\taxonomy\TermInterface[] $terms */
$terms = $term_storage->loadMultiple($tids);
foreach ($terms as $term) {
$description = '';
if ($term->hasField('description') && !$term->get('description')->isEmpty()) {
$description = strip_tags($term->get('description')->value);
$description = mb_substr($description, 0, 100) . (mb_strlen($description) > 100 ? '...' : '');
}
$matches[] = [
'id' => $term->id(),
'label' => $term->getName(),
'description' => $description,
'langcode' => $term->language()->getId(),
'language_name' => $term->language()->getName(),
];
}
}
}
return $this->createCachedAutocompleteResponse($matches);
}
/**
* Returns the description for a specific glossary term in a specific language.
*
* @param int $tid
* The term ID.
* @param string $langcode
* The language code.
*
* @return \Drupal\Core\Cache\CacheableJsonResponse
* A JSON response with the term description.
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
* If the term is not found or not in the glossary vocabulary.
*/
public function termDescriptionByLanguage(int $tid, string $langcode): CacheableJsonResponse {
/** @var \Drupal\taxonomy\TermStorageInterface $term_storage */
$term_storage = $this->entityTypeManager()->getStorage('taxonomy_term');
/** @var \Drupal\taxonomy\TermInterface|null $term */
$term = $term_storage->load($tid);
if (!$term instanceof TermInterface || $term->bundle() !== 'glossary') {
throw new NotFoundHttpException();
}
// Use specific language version if available
if ($term->hasTranslation($langcode)) {
$term = $term->getTranslation($langcode);
} else {
// If translation doesn't exist, throw not found
throw new NotFoundHttpException();
}
$description = '';
if ($term->hasField('description') && !$term->get('description')->isEmpty()) {
$description = $term->get('description')->processed;
}
$data = [
'id' => $term->id(),
'name' => $term->getName(),
'description' => $description,
'langcode' => $term->language()->getId(),
'language_name' => $term->language()->getName(),
];
$response = new CacheableJsonResponse($data);
$response->addCacheableDependency($term);
// Apply cache duration setting from configuration
$config = $this->config('ckeditor_taxonomy_glossary.settings');
$cache_duration = $config->get('cache_duration') ?? '3600';
if ($cache_duration === '0') {
// No cache - set headers to prevent caching
$response->setMaxAge(0);
$response->setSharedMaxAge(0);
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', '0');
} else {
// Set cache duration
$cache_seconds = (int) $cache_duration;
$response->setMaxAge($cache_seconds);
$response->setSharedMaxAge($cache_seconds);
}
return $response;
}
/**
* Creates a new glossary term via AJAX.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* A JSON response with the created term data or error message.
*/
public function createTerm(Request $request): JsonResponse {
// Check permission
if (!$this->currentUser->hasPermission('create glossary terms via editor')) {
return new JsonResponse(['error' => 'Access denied'], 403);
}
$data = json_decode($request->getContent(), TRUE);
// Validate JSON decode was successful
if (json_last_error() !== JSON_ERROR_NONE) {
return new JsonResponse(['error' => 'Invalid JSON data'], 400);
}
if (empty($data['name'])) {
return new JsonResponse(['error' => 'Term name is required'], 400);
}
// Sanitize and validate input data
$name = trim($data['name']);
$description = isset($data['description']) ? trim($data['description']) : '';
$langcode = isset($data['langcode']) ? trim($data['langcode']) : '';
// Validate name length
if (strlen($name) > 255) {
return new JsonResponse(['error' => 'Term name is too long (maximum 255 characters)'], 400);
}
// Validate description length
if (strlen($description) > 500) {
return new JsonResponse(['error' => 'Description is too long (maximum 500 characters)'], 400);
}
// Validate language code format if provided
if (!empty($langcode) && !preg_match('/^[a-z]{2}(-[a-z]{2})?$/', $langcode)) {
return new JsonResponse(['error' => 'Invalid language code format'], 400);
}
try {
/** @var \Drupal\taxonomy\TermStorageInterface $term_storage */
$term_storage = $this->entityTypeManager()->getStorage('taxonomy_term');
// Determine language code first
$langcode = !empty($langcode) ? $langcode : $this->languageManager()->getCurrentLanguage()->getId();
// Check if term already exists in the same language
$existing_terms = $term_storage->loadByProperties([
'vid' => 'glossary',
'name' => $name,
'langcode' => $langcode,
]);
if (!empty($existing_terms)) {
$existing_term = reset($existing_terms);
return new JsonResponse([
'error' => 'Term already exists',
'existing_term' => [
'id' => $existing_term->id(),
'name' => $existing_term->getName(),
]
], 409);
}
// Create new term
$term_data = [
'vid' => 'glossary',
'name' => $name,
'langcode' => $langcode,
];
// Add description if provided
if (!empty($description)) {
$term_data['description'] = [
'value' => $description,
'format' => 'basic_html', // Use a safe format
];
}
/** @var \Drupal\taxonomy\TermInterface $term */
$term = $term_storage->create($term_data);
$term->save();
return new JsonResponse([
'success' => TRUE,
'term' => [
'id' => $term->id(),
'name' => $term->getName(),
'description' => !empty($description) ? $description : '',
'langcode' => $term->language()->getId(),
'language_name' => $term->language()->getName(),
]
]);
} catch (\Exception $e) {
\Drupal::logger('ckeditor_taxonomy_glossary')->error('Failed to create term: @message', ['@message' => $e->getMessage()]);
return new JsonResponse(['error' => 'Failed to create term'], 500);
}
}
/**
* Returns a simple form for creating new terms.
*
* @param int|null $tid
* Optional term ID for editing (not implemented yet).
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* A JSON response with form HTML.
*/
public function getTermForm($tid = NULL): JsonResponse {
// Check permission
if (!$this->currentUser->hasPermission('create glossary terms via editor')) {
return new JsonResponse(['error' => 'Access denied'], 403);
}
// For now, just return a simple form structure
// In the future, this could return a full Drupal form
$form_html = '
<div class="glossary-term-form">
<div class="form-item">
<label for="term-name">Term Name *</label>
<input type="text" id="term-name" name="name" required maxlength="255" />
</div>
<div class="form-item">
<label for="term-description">Description</label>
<textarea id="term-description" name="description" rows="3" maxlength="500"></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" id="create-term-btn">Create Term</button>
<button type="button" class="btn btn-secondary" id="cancel-term-btn">Cancel</button>
</div>
</div>
';
return new JsonResponse([
'form_html' => $form_html,
'title' => 'Create New Glossary Term'
]);
}
}
