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
JUNPUBLISHED
+
+ JALL
+
-
+
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');
+?>
+
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();
?>
-