diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 11958bd..a997a9b 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.00.00 +# VERSION: 01.06.13 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b61150..d517b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,42 @@ # Changelog -## [Unreleased] - -## [01.06.00] --- 2026-06-28 - - -## [01.06.00] --- 2026-06-28 - -## [01.05.00] --- 2026-06-28 - - - - All notable changes to MokoSuiteOpenGraph will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + + +## [Unreleased] + +### Added +- OG coverage **dashboard** as the default admin view — SVG donut gauge, coverage by content type, and a list of articles missing OG tags with a batch-generate shortcut (#94) +- Single OG tag **create/edit screen** in the admin (the tag manager was previously read-only) (#98) +- **CSV import** button and upload form in the tag manager (#103) +- Component **Options** screen with a Permissions tab, plus `access.xml` ACL actions `mokoog.batch` and `mokoog.import` (#95) +- `og_video`, `event_data`, `recipe_data`, and `custom_schema` are now included in CSV import/export and the REST API (#101) +- Unit tests for `JsonLdBuilder::buildLocalBusiness()` and `toScriptTag()` (#33) + +### Changed +- **Require Joomla 6.0+ and PHP 8.2+** (enforced at install) +- Renamed the product from *MokoJoomOpenGraph* to **MokoSuiteOpenGraph** +- Forward-compatibility for Joomla 7: replaced deprecated `Factory::getDbo/getUser/getSession/getLanguage`, `Joomla\CMS\Filesystem\File/Folder`, and `jexit()` (#102) +- Aligned OG/SEO form `maxlength` values with the database column limits (#77) +- Moved coverage metrics out of the tag list into a dedicated model (no longer runs uncached `COUNT` queries on every list load) + +### Fixed +- Fatal frontend error (HTTP 500) when a non-object value was saved into the custom JSON-LD field — values are now validated as objects/arrays on save and guarded on render (#97) +- Stored XSS via the canonical URL field — now restricted to `http`/`https` (#79) +- Use the `mysqli` driver in the component manifest so install/upgrade SQL actually runs on Joomla 4/5/6 +- `loadArticle()` now caches negative lookups; zero dates are no longer emitted as `article:published_time`/`article:modified_time` (#106) + +### Security +- AI meta-generation endpoint now requires article-edit permission and enforces an HTTP timeout and status check — previously any authenticated back-end user could trigger paid API calls (#99) +- XML sitemap now excludes content above the public view level (no longer leaks registered/special-access articles) and writes atomically (#100) + +### Removed +- Unused `ImageGenerator` class and `JsonLdBuilder::buildOrganization()`; generated OG images are now pruned after 30 days to bound disk usage (#104) +- Empty `src/Field` and `src/Service` stub directories; packaged the `en-US` language folder (#107) + ## [01.05.00] --- 2026-06-28 ### Security diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 656759f..1e32ef6 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,7 +14,7 @@ DEFGROUP: Template-Joomla INGROUP: Template-Joomla.Documentation REPO: https://github.com/mokoconsulting-tech/Template-Joomla/ - VERSION: 01.06.00 + VERSION: 01.06.13 PATH: ./CODE_OF_CONDUCT.md BRIEF: Community expectations and enforcement guidelines NOTE: Adapted with attribution from the Contributor Covenant v2.1 diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 2565af7..e19d088 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -19,7 +19,7 @@ DEFGROUP: mokoconsulting-tech.Template-Joomla INGROUP: MokoStandards.Governance REPO: https://github.com/mokoconsulting-tech/Template-Joomla - VERSION: 01.06.00 + VERSION: 01.06.13 PATH: /GOVERNANCE.md BRIEF: Project governance rules, roles, and decision process for Template-Joomla --> diff --git a/README.md b/README.md index 845172e..81fcc39 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MokoSuiteOpenGraph - + Open Graph, Twitter Card, and social sharing meta tag management for Joomla 6 and higher. @@ -45,21 +45,24 @@ MokoSuiteOpenGraph gives you full control over how your Joomla content appears w - **Debug links** — Quick links to Facebook Debugger, LinkedIn Inspector, Google Rich Results - **Live preview** — Real-time Facebook, Twitter/X, LinkedIn, Discord, Mastodon, and Slack card previews in the editor - **Character count indicators** — Green/yellow/red warnings on OG and SEO text fields -- **OG coverage dashboard** — Coverage percentage and missing field counts -- **AI meta generation** — Generate OG titles and descriptions with Claude or OpenAI +- **Coverage dashboard** — Default admin view: coverage donut, breakdown by content type, and a list of articles missing OG tags with quick batch-generate +- **Manual tag editor** — Create and edit individual OG tag records directly in the admin +- **Component permissions** — ACL actions (`mokoog.batch`, `mokoog.import`) configurable from the component Options → Permissions +- **AI meta generation** — Generate OG titles and descriptions with Claude or OpenAI (article-edit permission required) ### Developer Features - **REST API** — Full CRUD via Joomla Web Services (`/api/v1/mokoog/tags`) - **MokoSuiteShop integration** — Auto-generated OG/JSON-LD for product pages with pricing meta - **Plugin event** — `onMokoOGAfterRender` for third-party plugins to add custom social tags -- **OG image generator** — Text overlay on template backgrounds with auto-resize to 1200x630 -- **Per-platform image resizing** — Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400 -- **XML sitemap** — Auto-generates sitemap.xml on article save, respects noindex +- **Per-platform image resizing** — Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400, with auto-resize to 1200x630 +- **XML sitemap** — Auto-generates sitemap.xml on article save; respects noindex and public access levels, written atomically - **OpenAPI spec** — Full REST API documentation at `openapi.yaml` -- **PHPUnit tests** — 16 unit tests for JsonLdBuilder schema outputs +- **PHPUnit tests** — Unit tests for JsonLdBuilder schema outputs and JSON-LD script-tag escaping ## Installation +**Requirements:** Joomla 6.0 or higher and PHP 8.2 or higher. + 1. Download the latest `pkg_mokoog-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/releases) 2. In Joomla Administrator → Extensions → Install → Upload Package File 3. All plugins are enabled automatically on install diff --git a/SECURITY.md b/SECURITY.md index 007b475..26b9e89 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla INGROUP: Template-Joomla.Documentation REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla PATH: /SECURITY.md -VERSION: 01.06.00 +VERSION: 01.06.13 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/source/packages/com_mokoog/access.xml b/source/packages/com_mokoog/access.xml new file mode 100644 index 0000000..2aa21c2 --- /dev/null +++ b/source/packages/com_mokoog/access.xml @@ -0,0 +1,20 @@ + + + +
+ + + + + + + + +
+
diff --git a/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php b/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php index 4a33148..9ac7aba 100644 --- a/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php +++ b/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php @@ -31,10 +31,14 @@ class JsonapiView extends BaseApiView 'og_description', 'og_image', 'og_type', + 'og_video', 'seo_title', 'meta_description', 'robots', 'canonical_url', + 'event_data', + 'recipe_data', + 'custom_schema', 'language', 'published', 'created', @@ -54,10 +58,14 @@ class JsonapiView extends BaseApiView 'og_description', 'og_image', 'og_type', + 'og_video', 'seo_title', 'meta_description', 'robots', 'canonical_url', + 'event_data', + 'recipe_data', + 'custom_schema', 'language', 'published', 'created', diff --git a/source/packages/com_mokoog/config.xml b/source/packages/com_mokoog/config.xml new file mode 100644 index 0000000..03202c4 --- /dev/null +++ b/source/packages/com_mokoog/config.xml @@ -0,0 +1,33 @@ + + + +
+ +
+
+ +
+
diff --git a/source/packages/com_mokoog/forms/tag.xml b/source/packages/com_mokoog/forms/tag.xml index 7b4a35f..7799093 100644 --- a/source/packages/com_mokoog/forms/tag.xml +++ b/source/packages/com_mokoog/forms/tag.xml @@ -16,13 +16,15 @@ name="content_type" type="text" label="COM_MOKOOG_FIELD_CONTENT_TYPE" - readonly="true" + description="COM_MOKOOG_FIELD_CONTENT_TYPE_DESC" + required="true" /> JPUBLISHED + + + -
+
diff --git a/source/packages/com_mokoog/language/en-GB/com_mokoog.ini b/source/packages/com_mokoog/language/en-GB/com_mokoog.ini index 4f0d4d3..7410242 100644 --- a/source/packages/com_mokoog/language/en-GB/com_mokoog.ini +++ b/source/packages/com_mokoog/language/en-GB/com_mokoog.ini @@ -5,6 +5,13 @@ COM_MOKOOG="MokoSuiteOpenGraph" COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager" COM_MOKOOG_SUBMENU_TAGS="Tags" +COM_MOKOOG_SUBMENU_DASHBOARD="Dashboard" +COM_MOKOOG_DASHBOARD_TITLE="MokoSuiteOpenGraph - Dashboard" +COM_MOKOOG_DASHBOARD_FIELD_GAPS="Field Coverage Gaps" +COM_MOKOOG_DASHBOARD_BY_TYPE="Coverage by Content Type" +COM_MOKOOG_DASHBOARD_MISSING="Articles Missing OG Tags" +COM_MOKOOG_DASHBOARD_ALL_COVERED="All published articles have OG tags." +COM_MOKOOG_DASHBOARD_MISSING_NOTE="Showing up to 20 most recent. Use Batch Generate to create OG tags for all articles at once." COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items." COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags" COM_MOKOOG_AUTO_GENERATED="auto-generated" @@ -66,3 +73,27 @@ COM_MOKOOG_COVERAGE_ARTICLES="%d of %d articles have OG tags" COM_MOKOOG_COVERAGE_MISSING_TITLE="%d tags missing custom title" COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description" COM_MOKOOG_COVERAGE_MISSING_IMAGE="%d tags missing custom image" + +; Single-tag edit form +COM_MOKOOG_TAG_NEW="MokoSuiteOpenGraph - New OG Tag" +COM_MOKOOG_TAG_EDIT="MokoSuiteOpenGraph - Edit OG Tag" +COM_MOKOOG_TAB_DETAILS="Details" +COM_MOKOOG_FIELDSET_SEO="SEO Meta Tags" +COM_MOKOOG_FIELD_CONTENT_TYPE_DESC="The content type this OG tag applies to (e.g. com_content, menu, com_content.category)." +COM_MOKOOG_FIELD_CONTENT_ID_DESC="The ID of the content item this OG tag applies to." +COM_MOKOOG_FIELD_SEO_TITLE="SEO Title" +COM_MOKOOG_FIELD_SEO_TITLE_DESC="Overrides the page <title> tag (max 70 characters)." +COM_MOKOOG_FIELD_META_DESCRIPTION="Meta Description" +COM_MOKOOG_FIELD_META_DESCRIPTION_DESC="Overrides the page meta description (max 200 characters)." +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." diff --git a/source/packages/com_mokoog/language/en-US/com_mokoog.ini b/source/packages/com_mokoog/language/en-US/com_mokoog.ini index 4f0d4d3..7410242 100644 --- a/source/packages/com_mokoog/language/en-US/com_mokoog.ini +++ b/source/packages/com_mokoog/language/en-US/com_mokoog.ini @@ -5,6 +5,13 @@ COM_MOKOOG="MokoSuiteOpenGraph" COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager" COM_MOKOOG_SUBMENU_TAGS="Tags" +COM_MOKOOG_SUBMENU_DASHBOARD="Dashboard" +COM_MOKOOG_DASHBOARD_TITLE="MokoSuiteOpenGraph - Dashboard" +COM_MOKOOG_DASHBOARD_FIELD_GAPS="Field Coverage Gaps" +COM_MOKOOG_DASHBOARD_BY_TYPE="Coverage by Content Type" +COM_MOKOOG_DASHBOARD_MISSING="Articles Missing OG Tags" +COM_MOKOOG_DASHBOARD_ALL_COVERED="All published articles have OG tags." +COM_MOKOOG_DASHBOARD_MISSING_NOTE="Showing up to 20 most recent. Use Batch Generate to create OG tags for all articles at once." COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items." COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags" COM_MOKOOG_AUTO_GENERATED="auto-generated" @@ -66,3 +73,27 @@ COM_MOKOOG_COVERAGE_ARTICLES="%d of %d articles have OG tags" COM_MOKOOG_COVERAGE_MISSING_TITLE="%d tags missing custom title" COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description" COM_MOKOOG_COVERAGE_MISSING_IMAGE="%d tags missing custom image" + +; Single-tag edit form +COM_MOKOOG_TAG_NEW="MokoSuiteOpenGraph - New OG Tag" +COM_MOKOOG_TAG_EDIT="MokoSuiteOpenGraph - Edit OG Tag" +COM_MOKOOG_TAB_DETAILS="Details" +COM_MOKOOG_FIELDSET_SEO="SEO Meta Tags" +COM_MOKOOG_FIELD_CONTENT_TYPE_DESC="The content type this OG tag applies to (e.g. com_content, menu, com_content.category)." +COM_MOKOOG_FIELD_CONTENT_ID_DESC="The ID of the content item this OG tag applies to." +COM_MOKOOG_FIELD_SEO_TITLE="SEO Title" +COM_MOKOOG_FIELD_SEO_TITLE_DESC="Overrides the page <title> tag (max 70 characters)." +COM_MOKOOG_FIELD_META_DESCRIPTION="Meta Description" +COM_MOKOOG_FIELD_META_DESCRIPTION_DESC="Overrides the page meta description (max 200 characters)." +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." diff --git a/source/packages/com_mokoog/mokoog.xml b/source/packages/com_mokoog/mokoog.xml index 0a16646..09d2e63 100644 --- a/source/packages/com_mokoog/mokoog.xml +++ b/source/packages/com_mokoog/mokoog.xml @@ -8,7 +8,7 @@ --> com_mokoog - 01.06.00 + 01.06.13 2026-05-23 Moko Consulting hello@mokoconsulting.tech @@ -50,6 +50,8 @@ View + dashboard + tag tags @@ -63,9 +65,15 @@ en-GB + en-US + + + access.xml + config.xml COM_MOKOOG + COM_MOKOOG_SUBMENU_DASHBOARD COM_MOKOOG_SUBMENU_TAGS diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.05.02.sql b/source/packages/com_mokoog/sql/updates/mysql/01.05.02.sql new file mode 100644 index 0000000..b47f3f7 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.05.02.sql @@ -0,0 +1 @@ +/* 01.05.02 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.06.02.sql b/source/packages/com_mokoog/sql/updates/mysql/01.06.02.sql new file mode 100644 index 0000000..b9fcc0f --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.06.02.sql @@ -0,0 +1 @@ +/* 01.06.02 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.06.03.sql b/source/packages/com_mokoog/sql/updates/mysql/01.06.03.sql new file mode 100644 index 0000000..b25e218 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.06.03.sql @@ -0,0 +1 @@ +/* 01.06.03 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.06.04.sql b/source/packages/com_mokoog/sql/updates/mysql/01.06.04.sql new file mode 100644 index 0000000..2297d2a --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.06.04.sql @@ -0,0 +1 @@ +/* 01.06.04 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.06.05.sql b/source/packages/com_mokoog/sql/updates/mysql/01.06.05.sql new file mode 100644 index 0000000..272fc02 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.06.05.sql @@ -0,0 +1 @@ +/* 01.06.05 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.06.06.sql b/source/packages/com_mokoog/sql/updates/mysql/01.06.06.sql new file mode 100644 index 0000000..5c073d4 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.06.06.sql @@ -0,0 +1 @@ +/* 01.06.06 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.06.07.sql b/source/packages/com_mokoog/sql/updates/mysql/01.06.07.sql new file mode 100644 index 0000000..b49f855 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.06.07.sql @@ -0,0 +1 @@ +/* 01.06.07 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.06.08.sql b/source/packages/com_mokoog/sql/updates/mysql/01.06.08.sql new file mode 100644 index 0000000..ec97fe3 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.06.08.sql @@ -0,0 +1 @@ +/* 01.06.08 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.06.09.sql b/source/packages/com_mokoog/sql/updates/mysql/01.06.09.sql new file mode 100644 index 0000000..66fcb9c --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.06.09.sql @@ -0,0 +1 @@ +/* 01.06.09 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.06.10.sql b/source/packages/com_mokoog/sql/updates/mysql/01.06.10.sql new file mode 100644 index 0000000..2e3ab43 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.06.10.sql @@ -0,0 +1 @@ +/* 01.06.10 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.06.11.sql b/source/packages/com_mokoog/sql/updates/mysql/01.06.11.sql new file mode 100644 index 0000000..e932e52 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.06.11.sql @@ -0,0 +1 @@ +/* 01.06.11 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.06.12.sql b/source/packages/com_mokoog/sql/updates/mysql/01.06.12.sql new file mode 100644 index 0000000..982f504 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.06.12.sql @@ -0,0 +1 @@ +/* 01.06.12 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.06.13.sql b/source/packages/com_mokoog/sql/updates/mysql/01.06.13.sql new file mode 100644 index 0000000..057c11b --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.06.13.sql @@ -0,0 +1 @@ +/* 01.06.13 — no schema changes */ diff --git a/source/packages/com_mokoog/src/Controller/BatchController.php b/source/packages/com_mokoog/src/Controller/BatchController.php index c07cba1..ee2aff8 100644 --- a/source/packages/com_mokoog/src/Controller/BatchController.php +++ b/source/packages/com_mokoog/src/Controller/BatchController.php @@ -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); } diff --git a/source/packages/com_mokoog/src/Controller/DisplayController.php b/source/packages/com_mokoog/src/Controller/DisplayController.php index 93330a7..9461520 100644 --- a/source/packages/com_mokoog/src/Controller/DisplayController.php +++ b/source/packages/com_mokoog/src/Controller/DisplayController.php @@ -21,5 +21,5 @@ class DisplayController extends BaseController * * @var string */ - protected $default_view = 'tags'; + protected $default_view = 'dashboard'; } diff --git a/source/packages/com_mokoog/src/Controller/ImportExportController.php b/source/packages/com_mokoog/src/Controller/ImportExportController.php index dd0a446..336d744 100644 --- a/source/packages/com_mokoog/src/Controller/ImportExportController.php +++ b/source/packages/com_mokoog/src/Controller/ImportExportController.php @@ -60,6 +60,10 @@ class ImportExportController extends BaseController $db->quoteName('t.robots'), $db->quoteName('t.canonical_url'), $db->quoteName('t.language'), + $db->quoteName('t.og_video'), + $db->quoteName('t.event_data'), + $db->quoteName('t.recipe_data'), + $db->quoteName('t.custom_schema'), ]) ->from($db->quoteName('#__mokoog_tags', 't')) ->leftJoin( @@ -84,7 +88,7 @@ class ImportExportController extends BaseController 'content_type', 'content_id', 'article_title', 'og_title', 'og_description', 'og_image', 'og_type', 'seo_title', 'meta_description', 'robots', 'canonical_url', - 'language', + 'language', 'og_video', 'event_data', 'recipe_data', 'custom_schema', ]); foreach ($rows as $row) { @@ -106,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); } @@ -187,6 +192,10 @@ class ImportExportController extends BaseController $robots = trim($row[9] ?? ''); $canonicalUrl = trim($row[10] ?? ''); $language = trim($row[11] ?? '*'); + $ogVideo = $this->sanitizeUrl($row[12] ?? ''); + $eventData = $this->validateJsonField($row[13] ?? ''); + $recipeData = $this->validateJsonField($row[14] ?? ''); + $customSchema = $this->validateJsonField($row[15] ?? ''); // Validate language tag format (e.g., 'en-GB', '*') if ($language !== '*' && !preg_match('/^[a-z]{2,3}-[A-Z]{2}$/', $language)) { @@ -229,6 +238,10 @@ class ImportExportController extends BaseController 'robots' => $robots, 'canonical_url' => $canonicalUrl, 'language' => $language, + 'og_video' => $ogVideo, + 'event_data' => $eventData, + 'recipe_data' => $recipeData, + 'custom_schema' => $customSchema, 'published' => 1, 'modified' => $now, ]; @@ -252,4 +265,45 @@ class ImportExportController extends BaseController ); $app->redirect('index.php?option=com_mokoog&view=tags'); } + + /** + * Validate a JSON field — returns trimmed JSON only if it is an object/array. + * + * Scalars and invalid JSON are dropped to '' so an import can never inject a + * payload that crashes the frontend JSON-LD renderer. + * + * @param string $value Raw CSV cell value + * + * @return string + */ + private function validateJsonField(string $value): string + { + $value = trim($value); + + if ($value === '' || !\is_array(json_decode($value, true))) { + return ''; + } + + return $value; + } + + /** + * Sanitize a URL to only allow http/https schemes. + * + * @param string $url Raw CSV cell value + * + * @return string Sanitized URL or empty string + */ + private function sanitizeUrl(string $url): string + { + $url = trim($url); + + if ($url === '') { + return ''; + } + + $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); + + return \in_array($scheme, ['http', 'https'], true) ? $url : ''; + } } diff --git a/source/packages/com_mokoog/src/Controller/TagController.php b/source/packages/com_mokoog/src/Controller/TagController.php new file mode 100644 index 0000000..c1ce226 --- /dev/null +++ b/source/packages/com_mokoog/src/Controller/TagController.php @@ -0,0 +1,31 @@ + + * @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\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\FormController; + +/** + * Controller for a single OG tag record. + * + * Provides the standard add/edit/save/apply/cancel tasks via FormController, + * backed by the existing TagModel (AdminModel) and TagTable. + */ +class TagController extends FormController +{ + /** + * The list view to redirect to after save/cancel. + * + * @var string + */ + protected $view_list = 'tags'; +} diff --git a/source/packages/com_mokoog/src/Field/index.html b/source/packages/com_mokoog/src/Field/index.html deleted file mode 100644 index 94906bc..0000000 --- a/source/packages/com_mokoog/src/Field/index.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/source/packages/com_mokoog/src/Model/DashboardModel.php b/source/packages/com_mokoog/src/Model/DashboardModel.php new file mode 100644 index 0000000..0d4b12c --- /dev/null +++ b/source/packages/com_mokoog/src/Model/DashboardModel.php @@ -0,0 +1,159 @@ + + * @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(); + } +} diff --git a/source/packages/com_mokoog/src/Service/index.html b/source/packages/com_mokoog/src/Service/index.html deleted file mode 100644 index 94906bc..0000000 --- a/source/packages/com_mokoog/src/Service/index.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/source/packages/com_mokoog/src/View/Dashboard/HtmlView.php b/source/packages/com_mokoog/src/View/Dashboard/HtmlView.php new file mode 100644 index 0000000..1f2c8aa --- /dev/null +++ b/source/packages/com_mokoog/src/View/Dashboard/HtmlView.php @@ -0,0 +1,76 @@ + + * @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\View\Dashboard; + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +/** + * Dashboard view — OG tag coverage metrics. + */ +class HtmlView extends BaseHtmlView +{ + /** + * Overall coverage stats. + * + * @var array + */ + protected $stats = []; + + /** + * Coverage broken down by content_type. + * + * @var array + */ + protected $byType = []; + + /** + * Published articles missing an OG tag. + * + * @var array + */ + protected $missing = []; + + /** + * Display the view. + * + * @param string $tpl Template name + * + * @return void + */ + public function display($tpl = null): void + { + /** @var \Joomla\Component\MokoOG\Administrator\Model\DashboardModel $model */ + $model = $this->getModel(); + + $this->stats = $model->getStats(); + $this->byType = $model->getCoverageByType(); + $this->missing = $model->getMissingArticles(20); + + $this->addToolbar(); + + parent::display($tpl); + } + + /** + * Add the toolbar. + * + * @return void + */ + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOOG_DASHBOARD_TITLE'), 'bookmark'); + ToolbarHelper::preferences('com_mokoog'); + } +} diff --git a/source/packages/com_mokoog/src/View/Tag/HtmlView.php b/source/packages/com_mokoog/src/View/Tag/HtmlView.php new file mode 100644 index 0000000..8b5d31a --- /dev/null +++ b/source/packages/com_mokoog/src/View/Tag/HtmlView.php @@ -0,0 +1,76 @@ + + * @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\View\Tag; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +/** + * Edit view for a single OG tag record. + */ +class HtmlView extends BaseHtmlView +{ + /** + * The edit form. + * + * @var \Joomla\CMS\Form\Form + */ + protected $form; + + /** + * The item being edited. + * + * @var object + */ + protected $item; + + /** + * Display the view. + * + * @param string $tpl Template name + * + * @return void + */ + public function display($tpl = null): void + { + $this->form = $this->get('Form'); + $this->item = $this->get('Item'); + + $this->addToolbar(); + + parent::display($tpl); + } + + /** + * Add the edit toolbar. + * + * @return void + */ + protected function addToolbar(): void + { + Factory::getApplication()->getInput()->set('hidemainmenu', true); + + $isNew = empty($this->item->id); + + ToolbarHelper::title( + Text::_($isNew ? 'COM_MOKOOG_TAG_NEW' : 'COM_MOKOOG_TAG_EDIT'), + 'bookmark' + ); + + ToolbarHelper::apply('tag.apply'); + ToolbarHelper::save('tag.save'); + ToolbarHelper::cancel('tag.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE'); + } +} diff --git a/source/packages/com_mokoog/src/View/Tags/HtmlView.php b/source/packages/com_mokoog/src/View/Tags/HtmlView.php index 9a07a00..f0ae838 100644 --- a/source/packages/com_mokoog/src/View/Tags/HtmlView.php +++ b/source/packages/com_mokoog/src/View/Tags/HtmlView.php @@ -81,8 +81,11 @@ class HtmlView extends BaseHtmlView protected function addToolbar(): void { ToolbarHelper::title(Text::_('COM_MOKOOG_TAGS_TITLE'), 'bookmark'); + ToolbarHelper::addNew('tag.add'); + 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'); } diff --git a/source/packages/com_mokoog/tmpl/dashboard/default.php b/source/packages/com_mokoog/tmpl/dashboard/default.php new file mode 100644 index 0000000..d238d3a --- /dev/null +++ b/source/packages/com_mokoog/tmpl/dashboard/default.php @@ -0,0 +1,142 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Uri\Uri; + +/** @var \Joomla\Component\MokoOG\Administrator\View\Dashboard\HtmlView $this */ + +$s = $this->stats; +$coverage = (int) ($s['coverage'] ?? 0); +$total = (int) ($s['total'] ?? 0); +$withOg = (int) ($s['with_og'] ?? 0); + +$colorClass = $coverage >= 80 ? 'text-success' : ($coverage >= 50 ? 'text-warning' : 'text-danger'); +$stroke = $coverage >= 80 ? '#198754' : ($coverage >= 50 ? '#ffc107' : '#dc3545'); + +$r = 54.0; +$circ = 2 * M_PI * $r; +$dash = round($circ * $coverage / 100, 2); +$gap = round($circ - $dash, 2); +?> +
+
+ +
+
+
+

+ + + + % + +

+
+
+
+ + +
+
+
+

+
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+
+
+
+
+ +
+ +
+
+
+

+ byType)) : ?> +

+ + + + + + + + + + + + byType as $row) : ?> + + + + + + + + +
escape($row->content_type); ?>total; ?>with_title; ?>with_image; ?>
+ +
+
+
+ + +
+
+
+
+

+ + + + +
+ missing)) : ?> +

+ + +

+ + + + +
+
+
+
+
diff --git a/source/packages/com_mokoog/tmpl/tag/edit.php b/source/packages/com_mokoog/tmpl/tag/edit.php new file mode 100644 index 0000000..63bfe41 --- /dev/null +++ b/source/packages/com_mokoog/tmpl/tag/edit.php @@ -0,0 +1,41 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; + +/** @var \Joomla\Component\MokoOG\Administrator\View\Tag\HtmlView $this */ + +HTMLHelper::_('behavior.formvalidator'); +?> +
+
+
+ 'details']); ?> + + + form->renderFieldset('details'); ?> + + + + form->renderFieldset('seo'); ?> + + + +
+
+ + + +
diff --git a/source/packages/com_mokoog/tmpl/tags/coverage.php b/source/packages/com_mokoog/tmpl/tags/coverage.php deleted file mode 100644 index 931039d..0000000 --- a/source/packages/com_mokoog/tmpl/tags/coverage.php +++ /dev/null @@ -1,58 +0,0 @@ - - * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. - * @license GNU General Public License version 3 or later; see LICENSE - */ - -defined('_JEXEC') or die; - -use Joomla\CMS\Factory; -use Joomla\CMS\Language\Text; - -$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); - -// Total published articles -$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__content')->where('state = 1')); -$totalArticles = (int) $db->loadResult(); - -// Articles with OG tags -$db->setQuery($db->getQuery(true)->select('COUNT(DISTINCT content_id)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where('published = 1')); -$articlesWithOg = (int) $db->loadResult(); - -// Articles missing OG data fields -$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_title = ''")->where('published = 1')); -$missingTitle = (int) $db->loadResult(); - -$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_description = ''")->where('published = 1')); -$missingDesc = (int) $db->loadResult(); - -$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_image = ''")->where('published = 1')); -$missingImage = (int) $db->loadResult(); - -$coverage = $totalArticles > 0 ? round(($articlesWithOg / $totalArticles) * 100) : 0; -?> -
-
-

-
-
-
- % -
- -
-
-
    -
  • -
  • -
  • -
  • -
-
-
-
-
diff --git a/source/packages/com_mokoog/tmpl/tags/default.php b/source/packages/com_mokoog/tmpl/tags/default.php index f3a3ad0..77e8c56 100644 --- a/source/packages/com_mokoog/tmpl/tags/default.php +++ b/source/packages/com_mokoog/tmpl/tags/default.php @@ -21,7 +21,6 @@ use Joomla\CMS\Session\Session; $token = Session::getFormToken(); ?> -
@@ -85,7 +84,9 @@ $token = Session::getFormToken(); content_id; ?> - escape($item->og_title ?: '(' . Text::_('COM_MOKOOG_AUTO_GENERATED') . ')'); ?> + + escape($item->og_title ?: '(' . Text::_('COM_MOKOOG_AUTO_GENERATED') . ')'); ?> + og_image) : ?> @@ -171,6 +172,23 @@ $token = Session::getFormToken();
+ +
+