696e369ec1
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Generic: Project CI / Lint & Validate (pull_request) Successful in 17s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 48s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
- New DashboardModel (BaseDatabaseModel) with getStats(), getCoverageByType(), getMissingArticles() — coverage logic moved out of the list template - New View/Dashboard/HtmlView + tmpl/dashboard/default.php: SVG donut coverage gauge, field-gap badges, per-content_type breakdown table, and a list of published articles missing OG tags (linking to the article editor) with a Batch Generate shortcut - DisplayController default_view -> dashboard; Dashboard + Tags submenu entries - Removed the inline coverage.php include from the tags list (it ran 6 uncached COUNT queries on every list load); that logic now lives in DashboardModel - Declared tmpl/dashboard in the manifest; added language strings (en-GB, en-US)
160 lines
5.3 KiB
PHP
160 lines
5.3 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoSuiteOpenGraph
|
|
* @subpackage com_mokoog
|
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoOG\Administrator\Model;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
|
|
|
/**
|
|
* Read-only model providing OG tag coverage metrics for the dashboard.
|
|
*/
|
|
class DashboardModel extends BaseDatabaseModel
|
|
{
|
|
/**
|
|
* Overall coverage statistics for com_content articles.
|
|
*
|
|
* @return array{total:int, with_og:int, coverage:int, missing_title:int, missing_description:int, missing_image:int}
|
|
*/
|
|
public function getStats(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
|
|
$total = $this->countContent();
|
|
$withOg = $this->countDistinct();
|
|
$missingTitle = $this->countEmptyField('og_title');
|
|
$missingDesc = $this->countEmptyField('og_description');
|
|
$missingImage = $this->countEmptyField('og_image');
|
|
|
|
return [
|
|
'total' => $total,
|
|
'with_og' => $withOg,
|
|
'coverage' => $total > 0 ? (int) round(($withOg / $total) * 100) : 0,
|
|
'missing_title' => $missingTitle,
|
|
'missing_description' => $missingDesc,
|
|
'missing_image' => $missingImage,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Coverage broken down by content_type.
|
|
*
|
|
* @return array Rows of {content_type, total, with_title, with_image}
|
|
*/
|
|
public function getCoverageByType(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$empty = $db->quote('');
|
|
|
|
$query = $db->getQuery(true)
|
|
->select([
|
|
$db->quoteName('content_type'),
|
|
'COUNT(*) AS ' . $db->quoteName('total'),
|
|
'SUM(CASE WHEN ' . $db->quoteName('og_title') . ' <> ' . $empty . ' THEN 1 ELSE 0 END) AS ' . $db->quoteName('with_title'),
|
|
'SUM(CASE WHEN ' . $db->quoteName('og_image') . ' <> ' . $empty . ' THEN 1 ELSE 0 END) AS ' . $db->quoteName('with_image'),
|
|
])
|
|
->from($db->quoteName('#__mokoog_tags'))
|
|
->where($db->quoteName('published') . ' = 1')
|
|
->group($db->quoteName('content_type'))
|
|
->order($db->quoteName('content_type') . ' ASC');
|
|
|
|
$db->setQuery($query);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Published articles that have no OG tag yet.
|
|
*
|
|
* @param int $limit Maximum rows to return
|
|
*
|
|
* @return array Rows of {id, title}
|
|
*/
|
|
public function getMissingArticles(int $limit = 20): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
|
|
$query = $db->getQuery(true)
|
|
->select([$db->quoteName('c.id'), $db->quoteName('c.title')])
|
|
->from($db->quoteName('#__content', 'c'))
|
|
->leftJoin(
|
|
$db->quoteName('#__mokoog_tags', 't')
|
|
. ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content')
|
|
. ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id')
|
|
)
|
|
->where($db->quoteName('c.state') . ' = 1')
|
|
->where($db->quoteName('t.id') . ' IS NULL')
|
|
->order($db->quoteName('c.id') . ' DESC');
|
|
|
|
$db->setQuery($query, 0, max(1, $limit));
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Count published com_content articles.
|
|
*/
|
|
private function countContent(): int
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from($db->quoteName('#__content'))
|
|
->where($db->quoteName('state') . ' = 1')
|
|
);
|
|
|
|
return (int) $db->loadResult();
|
|
}
|
|
|
|
/**
|
|
* Count distinct articles that have at least one published OG tag.
|
|
*/
|
|
private function countDistinct(): int
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('COUNT(DISTINCT ' . $db->quoteName('content_id') . ')')
|
|
->from($db->quoteName('#__mokoog_tags'))
|
|
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
|
|
->where($db->quoteName('published') . ' = 1')
|
|
);
|
|
|
|
return (int) $db->loadResult();
|
|
}
|
|
|
|
/**
|
|
* Count published OG tag rows whose given field is empty.
|
|
*
|
|
* @param string $field One of og_title, og_description, og_image
|
|
*/
|
|
private function countEmptyField(string $field): int
|
|
{
|
|
// Whitelist the column name — it is never user input here, but keep it strict.
|
|
if (!\in_array($field, ['og_title', 'og_description', 'og_image'], true)) {
|
|
return 0;
|
|
}
|
|
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from($db->quoteName('#__mokoog_tags'))
|
|
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
|
|
->where($db->quoteName('published') . ' = 1')
|
|
->where($db->quoteName($field) . ' = ' . $db->quote(''))
|
|
);
|
|
|
|
return (int) $db->loadResult();
|
|
}
|
|
}
|