Files
MokoSuiteOpenGraph/source/packages/com_mokoog/src/Model/DashboardModel.php
T
jmiller 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
feat: OG coverage dashboard as default admin view (#94)
- 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)
2026-06-29 10:48:33 -05:00

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();
}
}