g2-8.x-1.x-dev/g2.module
g2.module
<?php
/**
* @file
* This defines a node-based glossary module, vs the term-based glossary.
*
* @todo Test wipes, rss
*
* @todo For D8, in decreasing priorities
* - implement SettingsForm::validateForm() using Requirements
* - make g2_requirements() less verbose, at least on success.
* - find a way to add the title to the node.add route for ease of creation.
* @copyright 2005-2024 Frédéric G. Marand, for Ouest Systèmes Informatiques.
*
* @link http://wiki.audean.com/g2/choosing @endlink
*/
declare(strict_types=1);
use Drupal\block\BlockInterface;
use Drupal\Core\Entity\Entity\EntityViewMode;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\GeneratedLink;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Url;
use Drupal\g2\G2;
use Drupal\g2\Top;
use Drupal\node\NodeInterface;
use Drupal\views\ViewExecutable;
/**
* XML-RPC callback : returns alphabar data.
*
* @return string[]
* The alphabar data, to be serialized in XML.
*/
function _g2_alphabar(): array {
/** @var \Drupal\g2\Alphabar $alphabar */
$alphabar = Drupal::service(G2::SVC_ALPHABAR);
$ret = array_map(fn(GeneratedLink $link) => "$link", $alphabar->getLinks());
return $ret;
}
/**
* XML-RPC callback : returns a list of the latest n nodes.
*
* "Latest" nodes are identified by time of latest update.
*
* @param int $count
* The maximum number of entries to return.
*
* @return string[]
* Note that the results are NOT filtered, and must be filtered when used.
*
* @throws \Drupal\Core\Entity\EntityMalformedException
*/
function _g2_latest(int $count = 0): array {
$config = Drupal::config(G2::CONFIG_NAME);
/** @var int $service_max */
$service_max = $config->get(G2::VARLATESTMAXCOUNT);
/** @var float $api_throttle */
$api_throttle = $config->get(G2::VARAPITHROTTLE);
$actual_max = (int) ceil($api_throttle * $service_max);
// Limit extraction.
if (empty($count) || ($count > $actual_max)) {
$count = $actual_max;
}
/** @var \Drupal\g2\Latest $latest */
$latest = Drupal::service(G2::SVC_LATEST);
$links = $latest->getLinks($count);
$result = [];
/** @var \Drupal\Core\Link $link */
foreach ($links as $link) {
$result[] = "{$link->toString()}";
}
return $result;
}
/**
* XML-RPC callback: return a random G2 entry.
*
* @return array<string,mixed[]>
* The node as an array, hiding the "internal use" g2_complement and g2_origin
* fields.
*/
function _g2_random(): array {
/** @var \Drupal\g2\Random $random */
$random = \Drupal::service(G2::SVC_RANDOM);
$node = $random->get();
$res = _g2_xmlrpc($node);
return $res;
}
/**
* Extract statistics from the G2 glossary.
*
* @param int $tid
* Taxonomy term id.
* @param string $initial
* Initial segment.
*
* @return array<string,int>
* - g2 entries having chosen taxonomy term
* - g2 entries starting with chosen initial segment
*/
function _g2_stats(int $tid = 0, string $initial = ''): array {
$db = Drupal::database();
$qUnpublished = $db->select('node', 'n');
$unPubAlias = $qUnpublished->innerJoin('node_field_data', 'nfd', 'nfd.nid = n.nid');
$qUnpublished
->condition('n.type', G2::BUNDLE)
->condition("{$unPubAlias}.status", (string) NodeInterface::NOT_PUBLISHED)
->addTag('node_access');
$qPub = $db->select('node', 'n');
$pubAlias = $qPub->innerJoin('node_field_data', 'nfd', 'nfd.nid = n.nid');
$qPub
->condition('n.type', G2::BUNDLE)
->condition("{$pubAlias}.status", (string) NodeInterface::PUBLISHED)
->addTag('node_access');
if (!empty($tid)) {
$qUnpublished->innerJoin('taxonomy_index', 'ti', 'n.nid = ti.nid');
$qPub->innerJoin('taxonomy_index', 'ti', 'n.nid = ti.nid');
$qUnpublished = $qUnpublished->condition('ti.tid', (string) $tid);
$qPub = $qPub->condition('ti.tid', (string) $tid);
}
if (!empty($initial)) {
$qUnpublished = $qUnpublished->condition("{$unPubAlias}.title", "$initial%", 'LIKE');
$qPub = $qPub->condition("{$pubAlias}.title", "$initial%", 'LIKE');
}
$unpublished = $qUnpublished->countQuery()->execute();
$pub = $qPub->countQuery()->execute();
// Avoid empty returns.
$unpublishedCount = $unpublished?->fetchField();
assert(is_numeric($unpublishedCount));
$pubCount = $pub?->fetchField();
assert(is_numeric($pubCount));
$ret = [
'unpublished' => (int) $unpublishedCount,
'published' => (int) $pubCount,
];
return $ret;
}
/**
* Returns a list of the top n nodes as counted by statistics.module.
*
* - Unpublished nodes are not listed.
* - Stickiness is ignored for ordering, but returned in the results for
* client-side ordering if needed.
*
* @param int|null $count
* Number or entries to return.
* @param bool|null $daily_top
* Order by daily views if TRUE, otherwise by total views (default).
*
* @return string[]
* Statistics will be empty without statistics module.
* Note that the title of the nodes is NOT filtered.
*/
function _g2_top($count = NULL, $daily_top = FALSE) {
$config = Drupal::config(G2::CONFIG_NAME);
$service_max = $config->get(G2::VARLATESTMAXCOUNT);
$api_throttle = $config->get(G2::VARAPITHROTTLE);
$actual_max = $api_throttle * $service_max;
// Limit extraction.
if (empty($count) || ($count > $actual_max)) {
$count = $actual_max;
}
$count = (int) $count;
/** @var \Drupal\g2\Top $top */
$top = Drupal::service(G2::SVC_TOP);
$statistic = $daily_top ? Top::STATISTICS_DAY : Top::STATISTICS_TOTAL;
$links = $top->getLinks($count, $statistic);
$result = [];
/** @var \Drupal\Core\GeneratedLink $link */
foreach ($links as $link) {
$result[] = $link->__toString();
}
return $result;
}
/**
* Returns a structure for the WOTD.
*
* @param int $bodySize
* The maximum length for the body. Entails truncation.
*
* @return array<string,mixed>
* An array serialization of the WOTD, not rendered.
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
function _g2_wotd($bodySize = 0) {
/** @var \Drupal\g2\WOTD $wotd */
$wotd = \Drupal::service(G2::SVC_WOTD);
$node = $wotd->get();
$res = _g2_xmlrpc($node);
return $res;
}
/**
* Represent a node as an array for the XML-RPC API.
*
* This implies stripping its internal use fields.
*
* @param \Drupal\node\NodeInterface|null $node
* The node to display.
*
* @return array<string,mixed[]>
* The simplified node.
*/
function _g2_xmlrpc(?NodeInterface $node): array {
if (empty($node) || !$node->isPublished()) {
return [];
}
/** @var array<string,mixed[]> $res */
$res = $node->toArray();
unset($res['g2_complement'], $res['g2_origin']);
return $res;
}
/**
* Implements hook_cron().
*
* In G2's case, change the WOTD once a day if this feature is enabled,
* which is the default case.
*/
function g2_cron(): void {
/** @var \Drupal\g2\WOTD $wotd */
$wotd = \Drupal::service(G2::SVC_WOTD);
$wotd->cron();
}
/**
* Implements hook_entity_operation().
*
* @phpstan-return array<string,mixed>
*/
function g2_entity_operation(EntityInterface $entity): array {
if ($entity->getEntityTypeId() !== G2::TYPE || $entity->bundle() !== G2::BUNDLE) {
return [];
}
$operations = [];
$operations['g2-referers'] = [
'title' => t('G2 Referers'),
'url' => Url::fromRoute(G2::ROUTE_REFERERS, ['node' => $entity->id()]),
// Delete & Devel: 100.
'weight' => 75,
];
return $operations;
}
/**
* Implements hook_help().
*
* @phpstan-return array<string,string>|string|null|\Drupal\Core\StringTranslation\TranslatableMarkup
*/
function g2_help(string $route_name, RouteMatchInterface $route_match): mixed {
$result = '';
switch ($route_name) {
case 'help.page.g2':
$result = t(
'<p>G2 defines a glossary service for Drupal sites.
To compare it with the Glossary and Lexicon modules:</p>
<ul>
<li>G2 content is node-based, not term-based, allowing node access control</li>
<li>G2 leverages existing code from glossary for input filtering and node marking</li>
<li>G2 RAM use does not significantly increase with larger entry counts, which makes is more suitable for larger glossaries</li>
<li>G2 requests much less from the database than the default glossary</li>
<li>G2 uses a "G2 Context" taxonomy vocabulary by default, but does not require it.</li>
<li>G2 defines optional blocks</li>
<li>G2 provides a client and server XML-RPC API</li>
<li>G2 does not provide term feeds</li>
</ul>'
);
break;
case 'entity.block.edit_form':
// G2 block plugins contain a "help" annotation.
$block = $route_match
->getParameter('block');
assert($block instanceof BlockInterface);
/** @var array<string,mixed> $definition */
$definition = $block
->getPlugin()
->getPluginDefinition();
if (G2::NAME !== ($definition['provider'] ?? '')) {
return NULL;
}
/** @var string|\Drupal\Core\StringTranslation\TranslatableMarkup $result */
$result = $definition['help'] ?? '';
break;
case 'entity.entity_view_display.node.view_mode':
if ($route_match->getParameter('entity_type_id') != G2::TYPE) {
break;
}
if ($route_match->getParameter('bundle') !== G2::BUNDLE) {
break;
}
$name = $route_match->getParameter('view_mode_name');
assert(is_scalar($name));
$name = (string) $name;
$args = ['%vm' => EntityViewMode::load(G2::TYPE . ".{$name}")?->label()];
switch ($name) {
case G2::VM_BLOCK:
$result = t('The %vm display is used by the G2 Random and WOTD blocks.', $args);
break;
case G2::VM_ENTRY_LIST:
$result = t('The %vm display is used by the G2 "terms by initial" page and the homonyms page in "plain node list" mode.', $args);
break;
case G2::VM_HOMONYMS_PAGE:
$result = t('The %vm display is used by the G2 homonyms disambiguation page when it is configured to use a node (which is deprecated) instead of a route.', $args);
break;
case G2::VM_TOOLTIPS:
$result = t('The %vm display is used by the G2 definition filter when tooltips are configured to use teasers');
break;
}
if (!empty($result)) {
$result = ['#markup' => "<p>{$result}</p>\n"];
}
}
return $result;
}
/**
* Implements hook_ENTITY_TYPE_delete() for nodes.
*/
function g2_node_delete(NodeInterface $node): void {
/** @var \Drupal\g2\Matcher $matcher */
$matcher = Drupal::service(G2::SVC_MATCHER);
$matcher->rebuild();
}
/**
* Implements hook_ENTITY_TYPE_insert() for nodes.
*/
function g2_node_insert(NodeInterface $node): void {
/** @var \Drupal\g2\Matcher $matcher */
$matcher = Drupal::service(G2::SVC_MATCHER);
$matcher->rebuild();
}
/**
* Implements hook_ENTITY_TYPE_update() for nodes.
*/
function g2_node_update(NodeInterface $node): void {
/** @var \Drupal\g2\Matcher $matcher */
$matcher = Drupal::service(G2::SVC_MATCHER);
$matcher->rebuild();
}
/**
* Implements hook_ENTITY_TYPE_view().
*
* @phpstan-param array<string,mixed> $build
*/
function g2_node_view(array &$build, NodeInterface $node): void {
if ($node->bundle() != G2::BUNDLE) {
return;
}
if (!Drupal::config(G2::CONFIG_NAME)->get(G2::VARLOGREFERERS)) {
return;
}
/** @var \Drupal\g2\RefererTracker $tracker */
$tracker = Drupal::service(G2::SVC_TRACKER);
$tracker->upsertReferer($node);
}
/**
* Implements hook_preprocess_feed_icon().
*
* Remove after #3371937 is fixed.
*
* @see \Drupal\g2\Plugin\Block\WotdBlock::build()
* @see https://www.drupal.org/project/drupal/issues/3371937
* @see https://www.drupal.org/project/drupal/issues/3374891
*
* @phpstan-param array<string,mixed> $variables
*/
function g2_preprocess_feed_icon(&$variables): void {
/** @var \Drupal\Core\Block\BlockManagerInterface $pm */
$pm = Drupal::service(G2::SVC_PM_BLOCK);
/** @var \Drupal\g2\Plugin\Block\WotdBlock $wotdBlock */
$wotdBlock = $pm->createInstance(G2::DELTA_WOTD, []);
$wotdBlock->preprocessFeedIcon($variables);
}
/**
* Implements hook_preprocess_html().
*
* Append the glossary name to the page title on entry pages and G2 own pages,
* possibly including the default site name.
*
* @phpstan-param array<string,mixed> $variables
*/
function g2_preprocess_html(&$variables): void {
/** @var \Drupal\g2\RouteFilter $rf */
$rf = \Drupal::service(G2::SVC_ROUTE_FILTER);
if (!$rf->isG2Route()) {
return;
}
$override = \Drupal::config(G2::CONFIG_NAME)
->get(G2::VARPAGETITLE);
if (empty($override)) {
return;
}
assert(is_string($override));
$ht = $variables['head_title'] ?? [];
assert(is_array($ht));
$name = strtr($override, ['@title' => $ht['name']]);
$ht['name'] = $name;
$variables['head_title'] = $ht;
}
/**
* Implements hook_preprocess_views_view_rss().
*
* @phpstan-param array<string,mixed> $variables
*/
function g2_preprocess_views_view_rss(&$variables): void {
/** @var \Drupal\g2\WOTD $wotd */
$wotd = Drupal::service(G2::SVC_WOTD);
$wotd->preprocessViewsViewRss($variables);
}
/**
* Implements hook_theme().
*
* @phpstan-return array<string,array{variables:array<string,mixed>}>
*/
function g2_theme(): array {
$config = Drupal::config(G2::CONFIG_NAME);
$ret = [
// Checked for D8/9/10.
'g2_alphabar' => [
'variables' => [
'alphabar' => [],
'row_length' => $config->get(G2::VARALPHABARROWLENGTH),
],
],
// Checked for D8/9/10.
'g2_entries' => [
'variables' => [
'raw_entry' => '',
'entries' => [],
'message' => NULL,
'offer' => NULL,
],
],
// Not checked.
'g2_initial' => [
'variables' => [
'initial' => NULL,
'entries' => [],
],
],
// Not checked.
'g2_main' => [
'variables' => [
'alphabar' => $config->get(G2::VARALPHABARCONTENTS),
'text' => '',
],
],
];
return $ret;
}
/**
* Implements hook_views_pre_render().
*/
function g2_views_pre_render(ViewExecutable $view): void {
if ($view->id() === G2::VIEW_WOTD && $view->current_display === G2::VIEW_WOTD_DISPLAY) {
/** @var \Drupal\g2\WOTD $wotd */
$wotd = Drupal::service(G2::SVC_WOTD);
$wotd->viewsPreRender($view);
}
}
/**
* Implements hook_xmlrpc().
*
* Note that functions returning node portions return them unfiltered.
* It is the caller's responsibility to apply filtering depending on
* its actual use of the data.
*
* @phpstan-return array<string,callable>
*/
function g2_xmlrpc(): array {
$mapping = [
// D10/9 OK.
'g2.alphabar' => '_g2_alphabar',
'g2.api' => [G2::class, 'api'],
'g2.latest' => '_g2_latest',
'g2.random' => '_g2_random',
'g2.stats' => '_g2_stats',
'g2.top' => '_g2_top',
'g2.wotd' => '_g2_wotd',
];
$enabled = Drupal::config(G2::CONFIG_NAME)->get(G2::VARAPIENABLED);
if (!$enabled) {
$mapping = [];
}
return $mapping;
}
