fix: features & quality batch (#95, #103, #104, #107)
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 16s
Generic: Project CI / Lint & Validate (pull_request) Successful in 48s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 52s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 5s
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
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 16s
Generic: Project CI / Lint & Validate (pull_request) Successful in 48s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 52s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 5s
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
#95 — ACL + Options: - Add access.xml (core actions + mokoog.batch / mokoog.import custom actions) - Add config.xml (Permissions tab + note pointing settings to the system plugin) - Declare both in the manifest; Batch/ImportExport controllers now check the custom actions with a fallback to the prior core checks (no lockout) #103 — CSV import is now reachable: - Add an Import toolbar button that toggles a multipart file-upload form (jform[csv_file]) posting to importexport.import with a CSRF token #104 — Dead code + disk leak: - Delete unused ImageGenerator class and JsonLdBuilder::buildOrganization() - Add ImageHelper::pruneOldFiles() (deletes generated images older than 30d) and call it on content save so the generated-image cache is bounded #107 — Packaging: - Declare language/en-US in the component manifest (was never installed) - Remove undeclared empty stub dirs src/Field, src/Service
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @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
|
||||
-->
|
||||
<access component="com_mokoog">
|
||||
<section name="component">
|
||||
<action name="core.admin" title="JACTION_ADMIN" />
|
||||
<action name="core.manage" title="JACTION_MANAGE" />
|
||||
<action name="core.create" title="JACTION_CREATE" />
|
||||
<action name="core.delete" title="JACTION_DELETE" />
|
||||
<action name="core.edit" title="JACTION_EDIT" />
|
||||
<action name="core.edit.state" title="JACTION_EDITSTATE" />
|
||||
<action name="mokoog.batch" title="COM_MOKOOG_ACTION_BATCH" description="COM_MOKOOG_ACTION_BATCH_DESC" />
|
||||
<action name="mokoog.import" title="COM_MOKOOG_ACTION_IMPORT" description="COM_MOKOOG_ACTION_IMPORT_DESC" />
|
||||
</section>
|
||||
</access>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @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
|
||||
-->
|
||||
<config>
|
||||
<fieldset name="general">
|
||||
<field
|
||||
type="note"
|
||||
label="COM_MOKOOG_CONFIG_NOTE_LABEL"
|
||||
description="COM_MOKOOG_CONFIG_NOTE_DESC"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
name="permissions"
|
||||
label="JCONFIG_PERMISSIONS_LABEL"
|
||||
description="JCONFIG_PERMISSIONS_DESC"
|
||||
>
|
||||
<field
|
||||
name="rules"
|
||||
type="rules"
|
||||
label="JCONFIG_PERMISSIONS_LABEL"
|
||||
class="inputbox"
|
||||
validate="rules"
|
||||
filter="rules"
|
||||
component="com_mokoog"
|
||||
section="component"
|
||||
/>
|
||||
</fieldset>
|
||||
</config>
|
||||
@@ -82,3 +82,11 @@ COM_MOKOOG_FIELD_ROBOTS="Robots"
|
||||
COM_MOKOOG_FIELD_ROBOTS_DESC="Per-page robots directive, e.g. noindex, nofollow."
|
||||
COM_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
|
||||
COM_MOKOOG_FIELD_CANONICAL_URL_DESC="Overrides the canonical URL for this content item (http/https only)."
|
||||
|
||||
; ACL actions (access.xml) and component options (config.xml)
|
||||
COM_MOKOOG_ACTION_BATCH="Batch Generate OG Tags"
|
||||
COM_MOKOOG_ACTION_BATCH_DESC="Allows users in this group to run batch OG tag generation."
|
||||
COM_MOKOOG_ACTION_IMPORT="Import / Export OG Tags"
|
||||
COM_MOKOOG_ACTION_IMPORT_DESC="Allows users in this group to import and export OG tags via CSV."
|
||||
COM_MOKOOG_CONFIG_NOTE_LABEL="Where are the settings?"
|
||||
COM_MOKOOG_CONFIG_NOTE_DESC="Open Graph and SEO settings are configured in the System - MokoSuiteOpenGraph plugin (Extensions → Plugins). This screen manages component permissions only."
|
||||
|
||||
@@ -82,3 +82,11 @@ COM_MOKOOG_FIELD_ROBOTS="Robots"
|
||||
COM_MOKOOG_FIELD_ROBOTS_DESC="Per-page robots directive, e.g. noindex, nofollow."
|
||||
COM_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
|
||||
COM_MOKOOG_FIELD_CANONICAL_URL_DESC="Overrides the canonical URL for this content item (http/https only)."
|
||||
|
||||
; ACL actions (access.xml) and component options (config.xml)
|
||||
COM_MOKOOG_ACTION_BATCH="Batch Generate OG Tags"
|
||||
COM_MOKOOG_ACTION_BATCH_DESC="Allows users in this group to run batch OG tag generation."
|
||||
COM_MOKOOG_ACTION_IMPORT="Import / Export OG Tags"
|
||||
COM_MOKOOG_ACTION_IMPORT_DESC="Allows users in this group to import and export OG tags via CSV."
|
||||
COM_MOKOOG_CONFIG_NOTE_LABEL="Where are the settings?"
|
||||
COM_MOKOOG_CONFIG_NOTE_DESC="Open Graph and SEO settings are configured in the System - MokoSuiteOpenGraph plugin (Extensions → Plugins). This screen manages component permissions only."
|
||||
|
||||
@@ -64,6 +64,11 @@
|
||||
</files>
|
||||
<files folder="language">
|
||||
<folder>en-GB</folder>
|
||||
<folder>en-US</folder>
|
||||
</files>
|
||||
<files>
|
||||
<filename>access.xml</filename>
|
||||
<filename>config.xml</filename>
|
||||
</files>
|
||||
<menu img="class:bookmark">COM_MOKOOG</menu>
|
||||
<submenu>
|
||||
|
||||
@@ -29,7 +29,10 @@ class BatchController extends BaseController
|
||||
{
|
||||
Session::checkToken('get') || throw new \RuntimeException(Text::_('JINVALID_TOKEN'), 403);
|
||||
|
||||
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) {
|
||||
$identity = Factory::getApplication()->getIdentity();
|
||||
|
||||
if (!$identity->authorise('mokoog.batch', 'com_mokoog')
|
||||
&& !$identity->authorise('core.create', 'com_mokoog')) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
@@ -62,7 +65,10 @@ class BatchController extends BaseController
|
||||
{
|
||||
Session::checkToken('get') || throw new \RuntimeException(Text::_('JINVALID_TOKEN'), 403);
|
||||
|
||||
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) {
|
||||
$identity = Factory::getApplication()->getIdentity();
|
||||
|
||||
if (!$identity->authorise('mokoog.batch', 'com_mokoog')
|
||||
&& !$identity->authorise('core.create', 'com_mokoog')) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,8 @@ class ImportExportController extends BaseController
|
||||
|
||||
$identity = Factory::getApplication()->getIdentity();
|
||||
|
||||
if (!$identity->authorise('core.create', 'com_mokoog') || !$identity->authorise('core.edit', 'com_mokoog')) {
|
||||
if (!$identity->authorise('mokoog.import', 'com_mokoog')
|
||||
&& !($identity->authorise('core.create', 'com_mokoog') && $identity->authorise('core.edit', 'com_mokoog'))) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -85,6 +85,7 @@ class HtmlView extends BaseHtmlView
|
||||
ToolbarHelper::editList('tag.edit');
|
||||
ToolbarHelper::custom('batch.generate', 'refresh', '', 'COM_MOKOOG_TOOLBAR_BATCH_GENERATE', false);
|
||||
ToolbarHelper::custom('importexport.export', 'download', '', 'COM_MOKOOG_TOOLBAR_EXPORT', false);
|
||||
ToolbarHelper::custom('mokoog.showimport', 'upload', '', 'COM_MOKOOG_TOOLBAR_IMPORT', false);
|
||||
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'tags.delete');
|
||||
ToolbarHelper::preferences('com_mokoog');
|
||||
}
|
||||
|
||||
@@ -173,6 +173,23 @@ $token = Session::getFormToken();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSV Import -->
|
||||
<div id="mokoog-import-panel" style="display:none;" class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h4><?php echo Text::_('COM_MOKOOG_TOOLBAR_IMPORT'); ?></h4>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokoog&task=importexport.import'); ?>" method="post" enctype="multipart/form-data" class="mt-2">
|
||||
<div class="mb-2">
|
||||
<input type="file" name="jform[csv_file]" accept=".csv" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOOG_TOOLBAR_IMPORT'); ?>
|
||||
</button>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Intercept the batch.generate toolbar button
|
||||
@@ -182,6 +199,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
mokoogBatchGenerate();
|
||||
return;
|
||||
}
|
||||
if (task === 'mokoog.showimport') {
|
||||
var ip = document.getElementById('mokoog-import-panel');
|
||||
if (ip) {
|
||||
ip.style.display = (ip.style.display === 'none' ? 'block' : 'none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (origSubmitbutton) {
|
||||
origSubmitbutton(task);
|
||||
}
|
||||
|
||||
@@ -831,6 +831,10 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
*/
|
||||
public function onContentAfterSaveRebuildSitemap(Event $event): void
|
||||
{
|
||||
// Opportunistic maintenance on content save: prune stale generated images
|
||||
// so the generated-image cache cannot grow without bound.
|
||||
ImageHelper::pruneOldFiles();
|
||||
|
||||
if (!$this->params->get('sitemap_enabled', 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteOpenGraph
|
||||
* @subpackage plg_system_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\Plugin\System\MokoOG\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\Filesystem\Folder;
|
||||
use Joomla\CMS\Log\Log;
|
||||
|
||||
class ImageGenerator
|
||||
{
|
||||
private const WIDTH = 1200;
|
||||
private const HEIGHT = 630;
|
||||
private const OUTPUT_DIR = 'images/mokoog/generated';
|
||||
|
||||
/**
|
||||
* Generate an OG image with title text overlaid on a template background.
|
||||
*
|
||||
* @param string $title Article title to overlay
|
||||
* @param string $templateImage Path to template/background image relative to JPATH_ROOT
|
||||
* @param string $fontFile Absolute path to TTF font file
|
||||
* @param int $fontSize Font size in points (default 42)
|
||||
* @param array $fontColor RGB array [r, g, b] (default white)
|
||||
* @param int $quality JPEG quality (default 90)
|
||||
*
|
||||
* @return string Path to generated image relative to JPATH_ROOT, or empty on failure
|
||||
*/
|
||||
public static function generate(
|
||||
string $title,
|
||||
string $templateImage,
|
||||
string $fontFile = '',
|
||||
int $fontSize = 42,
|
||||
array $fontColor = [255, 255, 255],
|
||||
int $quality = 90
|
||||
): string {
|
||||
if (!\extension_loaded('gd')) {
|
||||
Log::add('MokoOG ImageGenerator: GD extension is not loaded. Image generation disabled.', Log::WARNING, 'mokoog');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
$templateAbs = JPATH_ROOT . '/' . ltrim($templateImage, '/');
|
||||
|
||||
if (!is_file($templateAbs)) {
|
||||
Log::add('MokoOG ImageGenerator: Template image not found: ' . $templateImage, Log::WARNING, 'mokoog');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$fontFile || !is_file($fontFile)) {
|
||||
Log::add('MokoOG ImageGenerator: TTF font file not found: ' . ($fontFile ?: '(not configured)'), Log::WARNING, 'mokoog');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
$outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
|
||||
|
||||
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
|
||||
Log::add('MokoOG ImageGenerator: Cannot create output directory: ' . self::OUTPUT_DIR, Log::WARNING, 'mokoog');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
$hash = md5($title . $templateImage . $fontSize);
|
||||
$outputName = 'overlay_' . $hash . '.jpg';
|
||||
$outputPath = $outputDir . '/' . $outputName;
|
||||
$outputRel = self::OUTPUT_DIR . '/' . $outputName;
|
||||
|
||||
// Skip if already generated
|
||||
if (is_file($outputPath)) {
|
||||
return $outputRel;
|
||||
}
|
||||
|
||||
// Load template image
|
||||
$imageInfo = getimagesize($templateAbs);
|
||||
|
||||
if (!$imageInfo) {
|
||||
Log::add('MokoOG ImageGenerator: Cannot read image dimensions: ' . $templateImage, Log::WARNING, 'mokoog');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
$source = match ($imageInfo[2]) {
|
||||
IMAGETYPE_JPEG => imagecreatefromjpeg($templateAbs),
|
||||
IMAGETYPE_PNG => imagecreatefrompng($templateAbs),
|
||||
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($templateAbs) : false,
|
||||
default => false,
|
||||
};
|
||||
|
||||
if (!$source) {
|
||||
Log::add('MokoOG ImageGenerator: Failed to load image (unsupported type or corrupt): ' . $templateImage, Log::WARNING, 'mokoog');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Create output canvas at target dimensions
|
||||
$canvas = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
|
||||
|
||||
imagecopyresampled(
|
||||
$canvas,
|
||||
$source,
|
||||
0, 0, 0, 0,
|
||||
self::WIDTH, self::HEIGHT,
|
||||
$imageInfo[0], $imageInfo[1]
|
||||
);
|
||||
|
||||
imagedestroy($source);
|
||||
|
||||
// Semi-transparent overlay for text readability
|
||||
$overlay = imagecolorallocatealpha($canvas, 0, 0, 0, 64);
|
||||
imagefilledrectangle($canvas, 0, (int) (self::HEIGHT * 0.55), self::WIDTH, self::HEIGHT, $overlay);
|
||||
|
||||
// Render title text with word wrapping
|
||||
$textColor = imagecolorallocate($canvas, $fontColor[0], $fontColor[1], $fontColor[2]);
|
||||
$wrappedTitle = self::wrapText($title, $fontFile, $fontSize, (int) (self::WIDTH * 0.85));
|
||||
$textX = (int) (self::WIDTH * 0.075);
|
||||
$textY = (int) (self::HEIGHT * 0.72);
|
||||
|
||||
imagettftext($canvas, $fontSize, 0, $textX, $textY, $textColor, $fontFile, $wrappedTitle);
|
||||
|
||||
// Save
|
||||
imagejpeg($canvas, $outputPath, $quality);
|
||||
imagedestroy($canvas);
|
||||
|
||||
return $outputRel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text to fit within a maximum pixel width.
|
||||
*
|
||||
* @param string $text Text to wrap
|
||||
* @param string $fontFile Path to TTF font
|
||||
* @param int $fontSize Font size in points
|
||||
* @param int $maxWidth Maximum width in pixels
|
||||
*
|
||||
* @return string Wrapped text with newlines
|
||||
*/
|
||||
private static function wrapText(string $text, string $fontFile, int $fontSize, int $maxWidth): string
|
||||
{
|
||||
$words = explode(' ', $text);
|
||||
$lines = [];
|
||||
$line = '';
|
||||
|
||||
foreach ($words as $word) {
|
||||
$testLine = $line ? $line . ' ' . $word : $word;
|
||||
$bbox = imagettfbbox($fontSize, 0, $fontFile, $testLine);
|
||||
$lineWidth = abs($bbox[4] - $bbox[0]);
|
||||
|
||||
if ($lineWidth > $maxWidth && $line !== '') {
|
||||
$lines[] = $line;
|
||||
$line = $word;
|
||||
} else {
|
||||
$line = $testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
|
||||
// Limit to 3 lines, truncate last line if needed
|
||||
if (\count($lines) > 3) {
|
||||
$lines = \array_slice($lines, 0, 3);
|
||||
|
||||
if (mb_strlen($lines[2]) > 3) {
|
||||
$lines[2] = mb_substr($lines[2], 0, -3) . '...';
|
||||
} else {
|
||||
$lines[2] .= '...';
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
@@ -300,6 +300,39 @@ class ImageHelper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune generated images older than the given age, to bound disk usage.
|
||||
*
|
||||
* The generated-image cache is never otherwise cleaned, so without this it
|
||||
* grows unbounded over time.
|
||||
*
|
||||
* @param int $maxAgeDays Delete generated files older than this (default 30)
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function pruneOldFiles(int $maxAgeDays = 30): void
|
||||
{
|
||||
$dir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cutoff = time() - ($maxAgeDays * 86400);
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile()
|
||||
&& $file->getFilename() !== 'index.html'
|
||||
&& $file->getMTime() < $cutoff) {
|
||||
File::delete($file->getPathname());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an image meets minimum OG size requirements.
|
||||
*
|
||||
|
||||
@@ -142,23 +142,6 @@ class JsonLdBuilder
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Organization schema from site configuration.
|
||||
*
|
||||
* @param string $siteName Site name
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function buildOrganization(string $siteName): array
|
||||
{
|
||||
return [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Organization',
|
||||
'name' => $siteName,
|
||||
'url' => Uri::root(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Product schema for a MokoSuiteShop product.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user